2024-05-28 15:30:41 +12:00
defmodule Igniter do
@moduledoc """
2024-06-13 10:22:08 +12:00
Tools for generating and patching code into an Elixir project .
2024-05-28 15:30:41 +12:00
"""
2024-06-28 15:15:51 +12:00
defstruct [ :rewrite , issues : [ ] , tasks : [ ] , warnings : [ ] , assigns : %{ } , moves : %{ } ]
2024-06-01 14:09:38 +12:00
2024-06-13 11:16:03 +12:00
alias Sourceror.Zipper
2024-06-01 14:09:38 +12:00
@type t :: % __MODULE__ {
rewrite : Rewrite . t ( ) ,
issues : [ String . t ( ) ] ,
2024-06-04 15:14:36 +12:00
tasks : [ { String . t ( ) | list ( String . t ( ) ) } ] ,
warnings : [ String . t ( ) ] ,
2024-06-28 15:15:51 +12:00
assigns : map ( ) ,
moves : %{ optional ( String . t ( ) ) = > String . t ( ) }
2024-06-01 14:09:38 +12:00
}
2024-05-28 15:30:41 +12:00
2024-06-13 10:22:08 +12:00
@type zipper_updater :: ( Zipper . t ( ) -> { :ok , Zipper . t ( ) } | { :error , String . t ( ) | [ String . t ( ) ] } )
@doc " Returns a new igniter "
@spec new ( ) :: t ( )
2024-06-01 14:59:36 +12:00
def new do
2024-05-28 15:30:41 +12:00
% __MODULE__ { rewrite : Rewrite . new ( ) }
2024-06-28 15:15:51 +12:00
|> include_existing_elixir_file ( " .igniter.exs " , required? : false )
|> parse_igniter_config ( )
end
def move_file ( igniter , from , from , opts \\ [ ] )
def move_file ( igniter , from , from , _opts ) , do : igniter
def move_file ( igniter , from , to , opts ) do
case Enum . find ( igniter . moves , fn { _key , value } -> value == from end ) do
{ key , _ } ->
move_file ( igniter . moves , key , to )
_ ->
if File . exists? ( to ) || match? ( { :ok , _ } , Rewrite . source ( igniter . rewrite , to ) ) do
if Keyword . get ( opts , :error_if_exists? , true ) do
add_issue ( igniter , " Cannot move #{ from } to #{ to } , as #{ to } already exists. " )
else
igniter
end
else
igniter = include_existing_file ( igniter , from )
source = Rewrite . source! ( igniter . rewrite , from )
if Rewrite.Source . from? ( source , :string ) do
rewrite =
igniter . rewrite
|> Rewrite . drop ( [ source . path ] )
|> Rewrite . put! ( %{ source | path : to } )
%{ igniter | rewrite : rewrite }
else
%{ igniter | moves : Map . put ( igniter . moves , from , to ) }
end
end
end
2024-05-28 15:30:41 +12:00
end
2024-06-13 10:22:08 +12:00
@doc " Stores the key/value pair in `igniter.assigns` "
@spec assign ( t , atom , term ( ) ) :: t ( )
2024-06-04 15:14:36 +12:00
def assign ( igniter , key , value ) do
%{ igniter | assigns : Map . put ( igniter . assigns , key , value ) }
end
def assign ( igniter , key_vals ) do
Enum . reduce ( key_vals , igniter , fn { key , value } , igniter ->
assign ( igniter , key , value )
end )
end
2024-06-28 15:15:51 +12:00
def update_assign ( igniter , key , default , fun ) do
%{ igniter | assigns : Map . update ( igniter . assigns , key , default , fun ) }
end
2024-06-13 10:22:08 +12:00
@doc " Includes all files matching the given glob, expecting them all (for now) to be elixir files. "
2024-06-13 11:16:03 +12:00
@spec include_glob ( t , Path . t ( ) | GlobEx . t ( ) ) :: t ( )
2024-06-11 01:58:20 +12:00
def include_glob ( igniter , glob ) do
glob
2024-06-13 11:16:03 +12:00
|> case do
% GlobEx { } = glob -> glob
2024-06-28 15:15:51 +12:00
string -> GlobEx . compile! ( Path . expand ( string ) )
2024-06-13 11:16:03 +12:00
end
|> GlobEx . ls ( )
2024-06-28 15:15:51 +12:00
|> Enum . filter ( fn path ->
2024-06-11 01:58:20 +12:00
if Path . extname ( path ) in [ " .ex " , " .exs " ] do
2024-06-28 15:15:51 +12:00
true
2024-06-11 01:58:20 +12:00
else
raise ArgumentError ,
" Cannot include #{ inspect ( path ) } because it is not an Elixir file. This can be supported in the future, but the work hasn't been done yet. "
end
end )
2024-06-28 15:15:51 +12:00
|> Enum . map ( & Path . relative_to_cwd / 1 )
|> then ( fn paths ->
Enum . reduce ( paths , igniter , fn path , igniter ->
Igniter . include_existing_elixir_file ( igniter , path , format? : false )
end )
|> format ( paths )
end )
2024-06-11 01:58:20 +12:00
end
2024-06-13 10:22:08 +12:00
@doc """
Updates all files matching the given glob with the given zipper function .
Adds any new files matching that glob to the igniter first .
"""
@spec update_glob (
t ,
2024-06-20 07:35:06 +12:00
Path . t ( ) | GlobEx . t ( ) ,
2024-06-13 10:22:08 +12:00
zipper_updater
) :: t ( )
2024-06-11 01:58:20 +12:00
def update_glob ( igniter , glob , func ) do
2024-06-20 07:35:06 +12:00
glob =
case glob do
% GlobEx { } = glob -> glob
2024-06-28 16:36:04 +12:00
string -> GlobEx . compile! ( Path . expand ( string ) )
2024-06-20 07:35:06 +12:00
end
2024-06-11 01:58:20 +12:00
igniter = include_glob ( igniter , glob )
2024-06-04 15:14:36 +12:00
Enum . reduce ( igniter . rewrite , igniter , fn source , igniter ->
path = Rewrite.Source . get ( source , :path )
if GlobEx . match? ( glob , path ) do
update_elixir_file ( igniter , path , func )
else
igniter
end
end )
end
2024-06-13 10:22:08 +12:00
@doc " Adds an issue to the issues list. Any issues will prevent writing and be displayed to the user. "
2024-06-15 12:09:20 +12:00
@spec add_issue ( t , term | list ( term ) ) :: t ( )
2024-05-28 15:30:41 +12:00
def add_issue ( igniter , issue ) do
2024-06-15 12:09:20 +12:00
%{ igniter | issues : List . wrap ( issue ) ++ igniter . issues }
2024-05-28 15:30:41 +12:00
end
2024-06-13 10:22:08 +12:00
@doc " Adds a warning to the warnings list. Warnings will not prevent writing, but will be displayed to the user. "
2024-06-15 12:09:20 +12:00
@spec add_warning ( t , term | list ( term ) ) :: t ( )
2024-06-04 15:14:36 +12:00
def add_warning ( igniter , warning ) do
2024-06-15 12:09:20 +12:00
%{ igniter | warnings : List . wrap ( warning ) ++ igniter . warnings }
2024-06-04 15:14:36 +12:00
end
2024-06-13 10:22:08 +12:00
@doc " Adds a task to the tasks list. Tasks will be run after all changes have been commited "
2024-06-01 14:09:38 +12:00
def add_task ( igniter , task , argv \\ [ ] ) when is_binary ( task ) do
%{ igniter | tasks : igniter . tasks ++ [ { task , argv } ] }
end
2024-06-27 03:40:58 +12:00
@doc """
Finds the ` Igniter.Mix.Task ` task by name and composes it ( calls its ` igniter / 2 ` ) into the current igniter .
If the task doesn ' t exist, a fallback implementation may be provided as the last argument.
"""
def compose_task ( igniter , task , argv \\ [ ] , fallback \\ nil )
2024-06-13 11:37:32 +12:00
2024-06-27 03:40:58 +12:00
def compose_task ( igniter , task , argv , fallback ) when is_atom ( task ) do
2024-06-01 14:09:38 +12:00
Code . ensure_compiled! ( task )
if function_exported? ( task , :igniter , 2 ) do
if ! task . supports_umbrella? ( ) && Mix.Project . umbrella? ( ) do
add_issue ( igniter , " Cannot run #{ inspect ( task ) } in an umbrella project. " )
else
task . igniter ( igniter , argv )
end
else
2024-06-27 03:40:58 +12:00
if is_function ( fallback ) do
fallback . ( igniter , argv )
else
add_issue (
igniter ,
" #{ inspect ( task ) } does not implement `Igniter.igniter/2` and no alternative implementation was provided. "
)
end
2024-06-01 14:09:38 +12:00
end
end
2024-06-27 03:40:58 +12:00
def compose_task ( igniter , task_name , argv , fallback ) do
2024-05-28 15:30:41 +12:00
if igniter . issues == [ ] do
task_name
|> Mix.Task . get ( )
|> case do
nil ->
2024-06-27 03:40:58 +12:00
if is_function ( fallback ) do
fallback . ( igniter , argv )
else
igniter
end
2024-05-28 15:30:41 +12:00
task ->
2024-06-27 03:40:58 +12:00
compose_task ( igniter , task , argv , fallback )
2024-05-28 15:30:41 +12:00
end
else
igniter
end
end
2024-06-13 10:22:08 +12:00
@doc """
Updates the source code of the given elixir file
"""
@spec update_elixir_file ( t ( ) , Path . t ( ) , zipper_updater ( ) ) :: Igniter . t ( )
2024-06-04 05:13:49 +12:00
def update_elixir_file ( igniter , path , func ) do
if Rewrite . has_source? ( igniter . rewrite , path ) do
2024-06-15 12:09:20 +12:00
source = Rewrite . source! ( igniter . rewrite , path )
igniter
|> apply_func_with_zipper ( source , func )
2024-06-06 02:12:07 +12:00
|> format ( path )
2024-06-04 05:13:49 +12:00
else
if File . exists? ( path ) do
2024-06-28 15:15:51 +12:00
source = read_ex_source! ( path )
2024-06-04 05:13:49 +12:00
%{ igniter | rewrite : Rewrite . put! ( igniter . rewrite , source ) }
|> format ( path )
2024-06-15 12:09:20 +12:00
|> apply_func_with_zipper ( source , func )
2024-06-04 05:13:49 +12:00
else
add_issue ( igniter , " Required #{ path } but it did not exist " )
end
end
end
2024-06-13 10:22:08 +12:00
@doc """
Updates a given file ' s `Rewrite.Source`
"""
@spec update_file ( t ( ) , Path . t ( ) , ( Rewrite.Source . t ( ) -> Rewrite.Source . t ( ) ) ) :: t ( )
2024-06-20 07:35:06 +12:00
def update_file ( igniter , path , updater ) do
2024-05-28 15:30:41 +12:00
if Rewrite . has_source? ( igniter . rewrite , path ) do
2024-06-20 07:35:06 +12:00
%{ igniter | rewrite : Rewrite . update! ( igniter . rewrite , path , updater ) }
2024-05-28 15:30:41 +12:00
else
2024-06-01 14:09:38 +12:00
if File . exists? ( path ) do
2024-06-28 15:15:51 +12:00
source = read_ex_source! ( path )
2024-06-01 14:09:38 +12:00
%{ igniter | rewrite : Rewrite . put! ( igniter . rewrite , source ) }
|> format ( path )
|> Map . update! ( :rewrite , fn rewrite ->
source = Rewrite . source! ( rewrite , path )
2024-06-20 07:35:06 +12:00
Rewrite . update! ( rewrite , path , updater . ( source ) )
2024-06-01 14:09:38 +12:00
end )
else
add_issue ( igniter , " Required #{ path } but it did not exist " )
end
2024-05-28 15:30:41 +12:00
end
end
2024-06-28 15:15:51 +12:00
@doc " Includes the given elixir file in the project, expecting it to exist. Does nothing if its already been added. "
@spec include_existing_elixir_file ( t ( ) , Path . t ( ) , opts :: Keyword . t ( ) ) :: t ( )
def include_existing_elixir_file ( igniter , path , opts \\ [ ] ) do
required? = Keyword . get ( opts , :required? , false )
if Rewrite . has_source? ( igniter . rewrite , path ) do
igniter
else
if File . exists? ( path ) do
source = read_ex_source! ( path )
%{ igniter | rewrite : Rewrite . put! ( igniter . rewrite , source ) }
|> then ( fn igniter ->
if opts [ :format? ] do
format ( igniter , path )
else
igniter
end
end )
else
if required? do
add_issue ( igniter , " Required #{ path } but it did not exist " )
else
igniter
end
end
end
end
2024-06-13 10:22:08 +12:00
@doc " Includes the given file in the project, expecting it to exist. Does nothing if its already been added. "
2024-06-28 15:15:51 +12:00
@spec include_existing_file ( t ( ) , Path . t ( ) , opts :: Keyword . t ( ) ) :: t ( )
def include_existing_file ( igniter , path , opts \\ [ ] ) do
required? = Keyword . get ( opts , :required? , false )
2024-05-28 15:30:41 +12:00
if Rewrite . has_source? ( igniter . rewrite , path ) do
igniter
else
if File . exists? ( path ) do
2024-06-28 15:15:51 +12:00
source = Rewrite.Source . read! ( path )
2024-06-22 08:07:26 +12:00
%{ igniter | rewrite : Rewrite . put! ( igniter . rewrite , source ) }
2024-06-01 14:09:38 +12:00
|> format ( path )
2024-05-28 15:30:41 +12:00
else
2024-06-28 15:15:51 +12:00
if required? do
add_issue ( igniter , " Required #{ path } but it did not exist " )
else
igniter
end
2024-05-28 15:30:41 +12:00
end
end
end
2024-06-13 10:22:08 +12:00
@doc " Includes or creates the given file in the project with the provided contents. Does nothing if its already been added. "
@spec include_or_create_elixir_file ( t ( ) , Path . t ( ) , contents :: String . t ( ) ) :: t ( )
2024-05-28 15:30:41 +12:00
def include_or_create_elixir_file ( igniter , path , contents \\ " " ) do
if Rewrite . has_source? ( igniter . rewrite , path ) do
igniter
else
2024-06-28 15:15:51 +12:00
source =
2024-05-28 15:30:41 +12:00
try do
2024-06-22 08:07:26 +12:00
read_ex_source! ( path )
2024-05-28 15:30:41 +12:00
rescue
_ ->
2024-06-28 15:15:51 +12:00
" "
|> Rewrite.Source.Ex . from_string ( path )
|> Rewrite.Source . update ( :file_creator , :content , contents )
2024-05-28 15:30:41 +12:00
end
%{ igniter | rewrite : Rewrite . put! ( igniter . rewrite , source ) }
2024-06-01 14:09:38 +12:00
|> format ( path )
2024-05-28 15:30:41 +12:00
end
end
2024-06-14 11:10:20 +12:00
@spec exists? ( t ( ) , Path . t ( ) ) :: boolean ( )
def exists? ( igniter , path ) do
Rewrite . has_source? ( igniter . rewrite , path ) || File . exists? ( path )
end
2024-06-20 07:35:06 +12:00
@doc " Creates the given file in the project with the provided string contents, or updates it with a function of type `zipper_updater()` if it already exists. "
2024-06-13 10:22:08 +12:00
@spec create_or_update_elixir_file ( t ( ) , Path . t ( ) , String . t ( ) , zipper_updater ( ) ) :: Igniter . t ( )
2024-06-20 07:35:06 +12:00
def create_or_update_elixir_file ( igniter , path , contents , updater ) do
2024-06-06 02:12:07 +12:00
if Rewrite . has_source? ( igniter . rewrite , path ) do
igniter
2024-06-20 07:35:06 +12:00
|> update_elixir_file ( path , updater )
2024-06-06 02:12:07 +12:00
else
2024-06-28 15:15:51 +12:00
{ created? , source } =
2024-06-06 02:12:07 +12:00
try do
2024-06-22 08:07:26 +12:00
{ false , read_ex_source! ( path ) }
2024-06-06 02:12:07 +12:00
rescue
_ ->
{ true ,
2024-06-28 15:15:51 +12:00
" "
|> Rewrite.Source.Ex . from_string ( path )
|> Rewrite.Source . update ( :file_creator , :content , contents ) }
2024-06-06 02:12:07 +12:00
end
%{ igniter | rewrite : Rewrite . put! ( igniter . rewrite , source ) }
|> format ( path )
|> then ( fn igniter ->
if created? do
igniter
else
2024-06-20 07:35:06 +12:00
update_elixir_file ( igniter , path , updater )
2024-06-06 02:12:07 +12:00
end
end )
end
end
2024-06-20 07:35:06 +12:00
@doc " Creates a new elixir file in the project with the provided string contents. Adds an error if it already exists. "
2024-06-13 10:22:08 +12:00
@spec create_new_elixir_file ( t ( ) , Path . t ( ) , String . t ( ) ) :: Igniter . t ( )
2024-05-28 15:30:41 +12:00
def create_new_elixir_file ( igniter , path , contents \\ " " ) do
2024-06-28 15:15:51 +12:00
source =
2024-05-28 15:30:41 +12:00
try do
2024-06-28 15:15:51 +12:00
source = read_ex_source! ( path )
Rewrite.Source . add_issue ( source , " File already exists " )
2024-05-28 15:30:41 +12:00
rescue
_ ->
2024-06-28 15:15:51 +12:00
" "
|> Rewrite.Source.Ex . from_string ( path )
|> Rewrite.Source . update ( :file_creator , :content , contents )
2024-05-28 15:30:41 +12:00
end
%{ igniter | rewrite : Rewrite . put! ( igniter . rewrite , source ) }
2024-06-01 14:09:38 +12:00
|> format ( path )
end
2024-07-02 07:29:51 +12:00
@doc """
Applies the current changes to the ` mix . exs ` in the Igniter and fetches dependencies .
Returns the remaining changes in the Igniter if successful .
## Options
* ` :error_on_abort? ` - If ` true ` , raises an error if the user aborts the operation . Returns the original igniter if not .
"""
2024-07-02 10:27:41 +12:00
# sobelow_skip ["RCE.CodeModule"]
2024-07-02 07:29:51 +12:00
def apply_and_fetch_dependencies ( igniter , opts \\ [ ] ) do
2024-07-02 09:56:14 +12:00
if has_changes? ( igniter , [ " mix.exs " ] ) do
2024-07-02 07:29:51 +12:00
case Igniter . do_or_dry_run ( igniter , [ " --dry-run " ] ,
title : " Preview " ,
paths : [ " mix.exs " ]
) do
:issues ->
raise " Exiting due to issues found while previewing changes. "
_ ->
message =
if opts [ :error_on_abort? ] do
"""
Before continuing , we need to first apply the changes and install dependencies . Would you like to do so now?
If not , the task will be aborted .
"""
else
"""
We would first like to first apply the changes and install dependencies . Would you like to do so now?
If not , the task will continue , but some nested installation steps may not be performed .
"""
end
proceed? =
Mix . shell ( ) . yes? ( message )
if proceed? do
:changes_made = Igniter . do_or_dry_run ( igniter , [ " --yes " ] , title : " Applying changes " )
Mix . shell ( ) . info ( " running mix deps.get " )
case Mix . shell ( ) . cmd ( " mix deps.get " ) do
0 ->
Mix.Project . clear_deps_cache ( )
Mix.Project . pop ( )
" mix.exs "
|> File . read! ( )
|> Code . eval_string ( [ ] , file : Path . expand ( " mix.exs " ) )
Mix.Dep . clear_cached ( )
Mix.Project . clear_deps_cache ( )
Mix.Task . run ( " deps.compile " )
Mix.Task . reenable ( " compile " )
Mix.Task . run ( " compile " )
exit_code ->
Mix . shell ( ) . info ( """
mix deps . get returned exited with code : ` #{exit_code}`
""" )
end
Map . update! ( igniter , :rewrite , fn rewrite ->
Rewrite . drop ( rewrite , [ " mix.exs " ] )
end )
else
if opts [ :error_on_abort? ] do
raise " Aborted by the user. "
else
igniter
end
end
end
else
igniter
end
end
@doc """
Returns whether the current Igniter has pending changes .
"""
def has_changes? ( igniter , paths \\ nil ) do
paths =
if paths do
Enum . map ( paths , & Path . relative_to_cwd / 1 )
end
igniter . rewrite
|> Rewrite . sources ( )
|> then ( fn sources ->
if paths do
sources
|> Enum . filter ( & ( &1 . path in paths ) )
else
sources
end
end )
|> Enum . any? ( fn source ->
Rewrite.Source . from? ( source , :string ) || Rewrite.Source . updated? ( source )
end )
end
2024-06-13 10:22:08 +12:00
@doc """
Executes or dry - runs a given Igniter .
This takes ` argv ` to parameterize it as it is generally invoked from a mix task .
"""
2024-06-01 14:59:36 +12:00
def do_or_dry_run ( igniter , argv , opts \\ [ ] ) do
2024-07-02 07:29:51 +12:00
igniter = prepare_for_write ( igniter , opts )
2024-06-01 14:59:36 +12:00
title = opts [ :title ] || " Igniter "
sources =
igniter . rewrite
|> Rewrite . sources ( )
issues =
Enum . flat_map ( sources , fn source ->
changed_issues =
if Rewrite.Source . file_changed? ( source ) do
[ " File has been changed since it was originally read. " ]
else
[ ]
end
issues = Enum . uniq ( changed_issues ++ Rewrite.Source . issues ( source ) )
case issues do
[ ] -> [ ]
issues -> [ { source , issues } ]
end
end )
case issues do
[ _ | _ ] ->
explain_issues ( issues )
2024-06-14 02:57:10 +12:00
2024-06-01 14:59:36 +12:00
:issues
[ ] ->
case igniter do
%{ issues : [ ] } ->
result_of_dry_run =
2024-07-02 07:29:51 +12:00
if has_changes? ( igniter ) do
if " --dry-run " in argv || " --yes " not in argv do
Mix . shell ( ) . info ( " \n #{ title } : Proposed changes: \n " )
Enum . each ( sources , fn source ->
if Rewrite.Source . from? ( source , :string ) do
content_lines =
source
|> Rewrite.Source . get ( :content )
|> String . split ( " \n " )
|> Enum . with_index ( )
space_padding =
content_lines
|> Enum . map ( & elem ( &1 , 1 ) )
|> Enum . max ( )
|> to_string ( )
|> String . length ( )
diffish_looking_text =
Enum . map_join ( content_lines , " \n " , fn { line , line_number_minus_one } ->
line_number = line_number_minus_one + 1
" #{ String . pad_trailing ( to_string ( line_number ) , space_padding ) } #{ IO.ANSI . yellow ( ) } | #{ IO.ANSI . green ( ) } #{ line } #{ IO.ANSI . reset ( ) } "
end )
if String . trim ( diffish_looking_text ) != " " do
Mix . shell ( ) . info ( """
Create : #{Rewrite.Source.get(source, :path)}
#{diffish_looking_text}
""" )
end
else
diff = Rewrite.Source . diff ( source ) |> IO . iodata_to_binary ( )
if String . trim ( diff ) != " " do
Mix . shell ( ) . info ( """
Update : #{Rewrite.Source.get(source, :path)}
2024-06-01 14:59:36 +12:00
2024-07-02 07:29:51 +12:00
#{diff}
""" )
2024-06-04 05:13:49 +12:00
end
2024-07-02 07:29:51 +12:00
end
end )
end
:dry_run_with_changes
else
unless opts [ :quiet_on_no_changes? ] || " --yes " in argv do
2024-07-02 12:43:39 +12:00
Mix . shell ( ) . info ( " \n #{ title } : No proposed content changes! \n " )
2024-07-02 07:29:51 +12:00
end
2024-06-01 14:59:36 +12:00
2024-07-02 07:29:51 +12:00
:dry_run_with_no_changes
2024-06-01 14:59:36 +12:00
end
2024-06-15 12:09:20 +12:00
if igniter . warnings != [ ] do
Mix . shell ( ) . info ( " \n #{ title } - #{ IO.ANSI . yellow ( ) } Notices: #{ IO.ANSI . reset ( ) } \n " )
igniter . warnings
|> Enum . map_join ( " \n --- \n " , fn error ->
if is_binary ( error ) do
" * #{ IO.ANSI . yellow ( ) } #{ error } #{ IO.ANSI . reset ( ) } "
else
" * #{ IO.ANSI . yellow ( ) } #{ Exception . format ( :error , error ) } #{ IO.ANSI . reset ( ) } "
end
end )
|> Mix . shell ( ) . info ( )
end
2024-06-28 15:15:51 +12:00
unless Enum . empty? ( igniter . moves ) do
2024-07-02 12:43:39 +12:00
Mix . shell ( ) . info ( " The following files will be moved: " )
2024-06-28 15:15:51 +12:00
Enum . each ( igniter . moves , fn { from , to } ->
Mix . shell ( ) . info (
" #{ IO.ANSI . red ( ) } #{ from } #{ IO.ANSI . reset ( ) } : #{ IO.ANSI . green ( ) } #{ to } #{ IO.ANSI . reset ( ) } "
)
end )
end
2024-06-22 10:06:00 +12:00
if igniter . tasks != [ ] && " --yes " not in argv do
2024-06-01 14:59:36 +12:00
message =
if result_of_dry_run == :dry_run_with_no_changes do
" The following tasks will be run "
else
" The following tasks will be run after the above changes: "
end
Mix . shell ( ) . info ( """
#{message}
#{Enum.map_join(igniter.tasks, "\n", fn {task, args} -> "* #{IO.ANSI.red()}#{task}#{IO.ANSI.yellow()} #{Enum.join(args, " ")}#{IO.ANSI.reset()}" end)}
""" )
end
2024-07-02 12:43:39 +12:00
if " --dry-run " in argv ||
( result_of_dry_run == :dry_run_with_no_changes && Enum . empty? ( igniter . tasks ) &&
Enum . empty? ( igniter . moves ) ) do
2024-06-01 14:59:36 +12:00
result_of_dry_run
else
if " --yes " in argv ||
Mix . shell ( ) . yes? ( opts [ :confirmation_message ] || " Proceed with changes? " ) do
sources
|> Enum . any? ( fn source ->
2024-07-02 12:43:39 +12:00
Rewrite.Source . from? ( source , :string ) || Rewrite.Source . updated? ( source )
2024-06-01 14:59:36 +12:00
end )
2024-07-02 12:43:39 +12:00
|> Kernel . || ( ! Enum . empty? ( igniter . tasks ) )
|> Kernel . || ( ! Enum . empty? ( igniter . tasks ) )
2024-06-01 14:59:36 +12:00
|> if do
igniter . rewrite
|> Rewrite . write_all ( )
|> case do
{ :ok , _result } ->
2024-06-22 10:06:00 +12:00
unless Enum . empty? ( igniter . tasks ) do
Mix . shell ( ) . cmd ( " mix deps.get " )
end
2024-06-28 15:15:51 +12:00
igniter . moves
|> Enum . each ( fn { from , to } ->
File . rename! ( from , to )
end )
2024-06-13 11:16:03 +12:00
igniter . tasks
|> Enum . each ( fn { task , args } ->
Mix . shell ( ) . cmd ( " mix #{ task } #{ Enum . join ( args , " " ) } " )
end )
2024-06-01 14:59:36 +12:00
:changes_made
{ :error , error , rewrite } ->
igniter
|> Map . put ( :rewrite , rewrite )
|> Igniter . add_issue ( error )
|> igniter_issues ( )
:issues
end
else
:no_changes
end
else
:changes_aborted
end
end
igniter ->
igniter_issues ( igniter )
:issues
end
end
end
defp igniter_issues ( igniter ) do
Mix . shell ( ) . info ( " Issues during code generation " )
igniter . issues
|> Enum . map_join ( " \n " , fn error ->
if is_binary ( error ) do
" * #{ error } "
else
" * #{ Exception . format ( :error , error ) } "
end
end )
|> Mix . shell ( ) . info ( )
end
defp explain_issues ( issues ) do
Mix . shell ( ) . info ( " Igniter: Issues found in proposed changes: \n " )
Enum . each ( issues , fn { source , issues } ->
Mix . shell ( ) . info ( " Issues with #{ Rewrite.Source . get ( source , :path ) } " )
issues
|> Enum . map_join ( " \n " , fn error ->
if is_binary ( error ) do
" * #{ error } "
else
" * #{ Exception . format ( :error , error ) } "
end
end )
2024-06-15 12:09:20 +12:00
|> Mix . shell ( ) . error ( )
2024-06-01 14:59:36 +12:00
end )
end
2024-06-28 15:15:51 +12:00
defp format ( igniter , adding_paths \\ nil ) do
igniter =
igniter
|> include_existing_elixir_file ( " config/config.exs " , require? : false )
|> include_existing_elixir_file ( " config/ #{ Mix . env ( ) } .exs " , require? : false )
2024-06-11 01:58:20 +12:00
2024-06-28 15:15:51 +12:00
if adding_paths &&
Enum . any? ( List . wrap ( adding_paths ) , & ( Path . basename ( &1 ) == " .formatter.exs " ) ) do
2024-06-01 14:09:38 +12:00
format ( igniter )
else
igniter =
" **/.formatter.exs "
|> Path . wildcard ( )
|> Enum . reduce ( igniter , fn path , igniter ->
Igniter . include_existing_elixir_file ( igniter , path )
end )
2024-06-04 15:14:36 +12:00
igniter =
if File . exists? ( " .formatter.exs " ) do
Igniter . include_existing_elixir_file ( igniter , " .formatter.exs " )
else
igniter
end
2024-06-01 14:09:38 +12:00
rewrite = igniter . rewrite
formatter_exs_files =
rewrite
|> Enum . filter ( fn source ->
source
|> Rewrite.Source . get ( :path )
|> Path . basename ( )
|> Kernel . == ( " .formatter.exs " )
end )
|> Map . new ( fn source ->
dir =
source
|> Rewrite.Source . get ( :path )
|> Path . dirname ( )
{ dir , source }
end )
rewrite =
Rewrite . map! ( rewrite , fn source ->
path = source |> Rewrite.Source . get ( :path )
2024-06-28 15:15:51 +12:00
if is_nil ( adding_paths ) || path in List . wrap ( adding_paths ) do
2024-06-01 14:09:38 +12:00
dir = Path . dirname ( path )
2024-06-14 00:17:50 +12:00
opts =
2024-06-14 11:10:20 +12:00
case find_formatter_exs_file_options ( dir , formatter_exs_files , Path . extname ( path ) ) do
2024-06-14 00:17:50 +12:00
:error ->
[ ]
{ :ok , opts } ->
opts
end
formatted =
with_evaled_configs ( rewrite , fn ->
Rewrite.Source.Ex . format ( source , opts )
end )
source
|> Rewrite.Source.Ex . put_formatter_opts ( opts )
|> Rewrite.Source . update ( :content , formatted )
2024-06-01 14:09:38 +12:00
else
source
end
end )
%{ igniter | rewrite : rewrite }
end
end
2024-06-04 15:14:36 +12:00
# for now we only eval `config.exs`
defp with_evaled_configs ( rewrite , fun ) do
2024-06-11 01:58:20 +12:00
[
Rewrite . source ( rewrite , " config/config.exs " ) ,
Rewrite . source ( rewrite , " config/ #{ Mix . env ( ) } .exs " )
]
|> Enum . flat_map ( fn
2024-06-04 15:14:36 +12:00
{ :ok , source } ->
2024-06-11 01:58:20 +12:00
[ Rewrite.Source . get ( source , :content ) ]
2024-06-04 15:14:36 +12:00
_ ->
2024-06-11 01:58:20 +12:00
[ ]
end )
|> case do
[ ] ->
2024-06-04 15:14:36 +12:00
fun . ( )
2024-06-11 01:58:20 +12:00
contents ->
to_set =
contents
|> Enum . join ( " \n " )
|> String . split ( " import_config " , parts : 2 )
|> List . first ( )
|> then ( & Config.Reader . eval! ( " config/config.exs " , &1 , env : Mix . env ( ) ) )
restore =
to_set
|> Keyword . keys ( )
|> Enum . map ( fn key ->
{ key , Application . get_all_env ( key ) }
end )
try do
Application . put_all_env ( to_set )
fun . ( )
after
Application . put_all_env ( restore )
end
2024-06-04 15:14:36 +12:00
end
end
2024-06-01 14:59:36 +12:00
# sobelow_skip ["RCE.CodeModule"]
2024-06-14 11:10:20 +12:00
defp find_formatter_exs_file_options ( path , formatter_exs_files , ext ) do
2024-06-01 14:09:38 +12:00
case Map . fetch ( formatter_exs_files , path ) do
{ :ok , source } ->
{ opts , _ } = Rewrite.Source . get ( source , :quoted ) |> Code . eval_quoted ( )
2024-06-14 11:10:20 +12:00
{ :ok , opts |> eval_deps ( ) |> filter_plugins ( ext ) }
2024-06-01 14:09:38 +12:00
:error ->
if path in [ " / " , " . " ] do
:error
else
new_path =
Path . join ( path , " .. " )
|> Path . expand ( )
|> Path . relative_to_cwd ( )
2024-06-14 11:10:20 +12:00
find_formatter_exs_file_options ( new_path , formatter_exs_files , ext )
2024-06-01 14:09:38 +12:00
end
end
end
# This can be removed if/when this PR is merged: https://github.com/hrzndhrn/rewrite/pull/34
defp eval_deps ( formatter_opts ) do
deps = Keyword . get ( formatter_opts , :import_deps , [ ] )
locals_without_parens = eval_deps_opts ( deps )
formatter_opts =
Keyword . update (
formatter_opts ,
:locals_without_parens ,
locals_without_parens ,
& ( locals_without_parens ++ &1 )
)
formatter_opts
end
defp eval_deps_opts ( [ ] ) do
[ ]
end
defp eval_deps_opts ( deps ) do
deps_paths = Mix.Project . deps_paths ( )
for dep <- deps ,
dep_path = fetch_valid_dep_path ( dep , deps_paths ) ,
! is_nil ( dep_path ) ,
dep_dot_formatter = Path . join ( dep_path , " .formatter.exs " ) ,
File . regular? ( dep_dot_formatter ) ,
dep_opts = eval_file_with_keyword_list ( dep_dot_formatter ) ,
parenless_call <- dep_opts [ :export ] [ :locals_without_parens ] || [ ] ,
uniq : true ,
do : parenless_call
end
defp fetch_valid_dep_path ( dep , deps_paths ) when is_atom ( dep ) do
with %{ ^ dep = > path } <- deps_paths ,
true <- File . dir? ( path ) do
path
else
_ ->
nil
end
end
defp fetch_valid_dep_path ( _dep , _deps_paths ) do
nil
end
2024-06-01 14:59:36 +12:00
# sobelow_skip ["RCE.CodeModule"]
2024-06-01 14:09:38 +12:00
defp eval_file_with_keyword_list ( path ) do
{ opts , _ } = Code . eval_file ( path )
unless Keyword . keyword? ( opts ) do
raise " Expected #{ inspect ( path ) } to return a keyword list, got: #{ inspect ( opts ) } "
end
opts
2024-05-28 15:30:41 +12:00
end
2024-06-04 05:13:49 +12:00
2024-06-15 12:09:20 +12:00
defp apply_func_with_zipper ( igniter , source , func ) do
2024-06-04 05:13:49 +12:00
quoted = Rewrite.Source . get ( source , :quoted )
zipper = Sourceror.Zipper . zip ( quoted )
case func . ( zipper ) do
2024-06-06 02:12:07 +12:00
{ :ok , % Sourceror.Zipper { } = zipper } ->
2024-06-15 12:09:20 +12:00
Rewrite . update! (
igniter . rewrite ,
Rewrite.Source . update (
source ,
:configure ,
:quoted ,
Sourceror.Zipper . root ( zipper )
)
2024-06-06 02:12:07 +12:00
)
2024-06-15 12:09:20 +12:00
|> then ( & Map . put ( igniter , :rewrite , &1 ) )
2024-06-06 02:12:07 +12:00
2024-06-04 05:13:49 +12:00
% Sourceror.Zipper { } = zipper ->
2024-06-15 12:09:20 +12:00
Rewrite . update! (
igniter . rewrite ,
Rewrite.Source . update (
source ,
:configure ,
:quoted ,
Sourceror.Zipper . root ( zipper )
)
2024-06-04 05:13:49 +12:00
)
2024-06-15 12:09:20 +12:00
|> then ( & Map . put ( igniter , :rewrite , &1 ) )
2024-06-04 05:13:49 +12:00
{ :error , error } ->
2024-06-15 12:09:20 +12:00
Rewrite . update! (
igniter . rewrite ,
Rewrite.Source . add_issues ( source , List . wrap ( error ) )
)
|> then ( & Map . put ( igniter , :rewrite , &1 ) )
{ :warning , warning } ->
Igniter . add_warning ( igniter , warning )
2024-06-04 05:13:49 +12:00
end
end
2024-06-14 11:10:20 +12:00
defp filter_plugins ( opts , ext ) do
Keyword . put ( opts , :plugins , plugins_for_ext ( opts , ext ) )
end
defp plugins_for_ext ( formatter_opts , ext ) do
formatter_opts
|> Keyword . get ( :plugins , [ ] )
|> Enum . filter ( fn plugin ->
Code . ensure_loaded? ( plugin ) and function_exported? ( plugin , :features , 1 ) and
ext in List . wrap ( plugin . features ( formatter_opts ) [ :extensions ] )
end )
end
2024-06-22 08:07:26 +12:00
defp read_ex_source! ( path ) do
source = Rewrite.Source.Ex . read! ( path )
2024-06-28 15:15:51 +12:00
content =
2024-06-22 08:07:26 +12:00
source
|> Rewrite.Source . get ( :content )
2024-06-28 15:15:51 +12:00
Rewrite.Source . update ( source , :content , content )
2024-06-22 08:07:26 +12:00
end
@doc false
2024-07-02 07:29:51 +12:00
def prepare_for_write ( igniter , opts \\ [ ] ) do
igniter =
if opts [ :paths ] do
all_paths = Rewrite . paths ( igniter . rewrite )
%{ igniter | rewrite : Rewrite . drop ( igniter . rewrite , all_paths -- opts [ :paths ] ) }
else
igniter
end
2024-06-28 15:15:51 +12:00
%{
2024-06-22 08:07:26 +12:00
igniter
| issues : Enum . uniq ( igniter . issues ) ,
warnings : Enum . uniq ( igniter . warnings ) ,
tasks : Enum . uniq ( igniter . tasks )
}
2024-06-28 15:15:51 +12:00
|> Igniter.Code.Module . move_modules ( )
|> remove_unchanged_files ( )
end
2024-06-22 08:07:26 +12:00
2024-06-28 15:15:51 +12:00
defp remove_unchanged_files ( igniter ) do
igniter . rewrite
|> Enum . flat_map ( fn source ->
if Rewrite.Source . from? ( source , :string ) || changed? ( source ) do
[ ]
else
[ source . path ]
end
end )
|> then ( fn paths ->
%{ igniter | rewrite : Rewrite . drop ( igniter . rewrite , paths ) }
end )
end
2024-06-22 08:07:26 +12:00
2024-06-28 16:36:04 +12:00
# sobelow_skip ["RCE.CodeModule"]
2024-06-28 15:15:51 +12:00
defp parse_igniter_config ( igniter ) do
case Rewrite . source ( igniter . rewrite , " .igniter.exs " ) do
{ :error , _ } ->
assign ( igniter , :igniter_exs , [ ] )
2024-06-22 08:07:26 +12:00
2024-06-28 15:15:51 +12:00
{ :ok , source } ->
{ igniter_exs , _ } = Rewrite.Source . get ( source , :quoted ) |> Code . eval_quoted ( )
assign ( igniter , :igniter_exs , igniter_exs )
end
end
2024-06-22 08:07:26 +12:00
2024-06-28 15:15:51 +12:00
defp changed? ( source ) do
diff = Rewrite.Source . diff ( source ) |> IO . iodata_to_binary ( )
2024-06-22 08:07:26 +12:00
2024-06-28 15:15:51 +12:00
String . trim ( diff ) != " "
2024-06-22 08:07:26 +12:00
end
2024-05-28 15:30:41 +12:00
end