write a bunch more code, and add some tests, rework various patterns

This commit is contained in:
Zach Daniel 2024-05-31 22:09:38 -04:00
parent b5354ce0e3
commit 281ec0f656
16 changed files with 841 additions and 276 deletions

View file

@ -9,3 +9,4 @@
# TODO list:
- [ ] properly parse args, not `"--dry-run" in argv`. Do we want to have some kind of "maybe parsed" args structure so we can call tasks with `argv` or one of those? Maybe.
- [ ] the mix deps.get step is slow and weird sometimes, need to figure out why.

View file

@ -1,4 +1,23 @@
defmodule Igniter.Args do
def validate_nth_present_and_underscored(igniter, argv, n, option, message) do
value = Enum.at(argv, n)
cond do
!value ->
{:error, Igniter.add_issue(igniter, message)}
not (Macro.underscore(value) == value) ->
{:error,
Igniter.add_issue(
igniter,
"Must provide the #{option} in snake_case. Did you mean `#{Macro.underscore(value)}`"
)}
true ->
{:ok, value}
end
end
def validate_present_and_underscored(igniter, opts, option, message) do
cond do
!opts[option] ->

View file

@ -1,4 +1,7 @@
defmodule Igniter.Common do
@doc """
Common utilities for working with igniter, primarily with zippers.
"""
alias Sourceror.Zipper
def find(zipper, direction \\ :next, pred) do
@ -16,7 +19,7 @@ defmodule Igniter.Common do
quote do
ast =
unquote(zipper)
|> Igniter.Common.maybe_enter_block()
|> Igniter.Common.maybe_move_to_block()
|> Zipper.subtree()
|> Zipper.root()
@ -24,15 +27,18 @@ defmodule Igniter.Common do
end
end
defmacro find_pattern(zipper, direction \\ :next, pattern) do
defmacro move_to_pattern(zipper, direction \\ :next, pattern) do
quote do
Sourceror.Zipper.find(unquote(zipper), unquote(direction), fn
unquote(pattern) ->
true
case Sourceror.Zipper.find(unquote(zipper), unquote(direction), fn
unquote(pattern) ->
true
_ ->
false
end)
_ ->
false
end) do
nil -> :error
value -> {:ok, value}
end
end
end
@ -56,16 +62,21 @@ defmodule Igniter.Common do
zipper
end
def add_code(zipper, new_code) when is_binary(new_code) do
code = Sourceror.parse_string!(new_code)
add_code(zipper, code)
end
def add_code(zipper, new_code) do
current_code =
zipper
|> Zipper.subtree()
|> Zipper.root()
|> IO.inspect()
case current_code do
{:__block__, block_meta, stuff} ->
Zipper.replace(zipper, {:__block__, block_meta, stuff ++ [new_code]})
{:__block__, _, stuff} ->
Zipper.replace(zipper, {:__block__, [], stuff ++ [new_code]})
code ->
Zipper.replace(zipper, {:__block__, [], [code, new_code]})
@ -84,26 +95,27 @@ defmodule Igniter.Common do
defp do_put_in_keyword(zipper, [key | rest], value, updater) do
if node_matches_pattern?(zipper, value when is_list(value)) do
case find_list_item(zipper, fn item ->
case move_to_list_item(zipper, fn item ->
if is_tuple?(item) do
first_elem = tuple_elem(item, 0)
first_elem && node_matches_pattern?(first_elem, ^key)
end
end) do
nil ->
:error ->
value = keywordify(rest, value)
prepend_to_list(
zipper,
{{:__block__, [format: :keyword], [key]}, {:__block__, [], [value]}}
)
{:ok,
prepend_to_list(
zipper,
{{:__block__, [format: :keyword], [key]}, {:__block__, [], [value]}}
)}
zipper ->
{:ok, zipper} ->
zipper
|> tuple_elem(1)
|> case do
nil ->
nil
:error
zipper ->
do_put_in_keyword(zipper, rest, value, updater)
@ -114,46 +126,171 @@ defmodule Igniter.Common do
def set_keyword_key(zipper, key, value, updater) do
if node_matches_pattern?(zipper, value when is_list(value)) do
case find_list_item(zipper, fn item ->
case move_to_list_item(zipper, fn item ->
if is_tuple?(item) do
first_elem = tuple_elem(item, 0)
first_elem && node_matches_pattern?(first_elem, ^key)
end
end) do
nil ->
prepend_to_list(
zipper,
{{:__block__, [format: :keyword], [key]}, {:__block__, [], [value]}}
)
:error ->
{:ok,
prepend_to_list(
zipper,
{{:__block__, [format: :keyword], [key]}, {:__block__, [], [value]}}
)}
zipper ->
{:ok, zipper} ->
zipper
|> tuple_elem(1)
|> case do
nil ->
nil
:error
zipper ->
updater.(zipper)
{:ok, updater.(zipper)}
end
end
end
end
def find_function_call_in_current_scope(zipper, name, arity, predicate \\ fn _ -> true end) do
case Zipper.down(zipper) do
nil ->
nil
def put_in_map(zipper, path, value, updater \\ nil) do
updater = updater || fn _ -> value end
zipper ->
find_right(zipper, fn zipper ->
is_function_call(zipper, name, arity) && predicate.(zipper)
do_put_in_map(zipper, path, value, updater)
end
defp do_put_in_map(zipper, [key], value, updater) do
set_map_key(zipper, key, value, updater)
end
defp do_put_in_map(zipper, [key | rest], value, updater) do
cond do
node_matches_pattern?(zipper, {:%{}, _, []}) ->
{:ok,
Zipper.append_child(
zipper,
mappify([key | rest], value)
)}
node_matches_pattern?(zipper, {:%{}, _, _}) ->
zipper
|> Zipper.down()
|> move_to_list_item(fn item ->
if is_tuple?(item) do
first_elem = tuple_elem(item, 0)
first_elem && node_matches_pattern?(first_elem, ^key)
end
end)
|> case do
:error ->
format = map_keys_format(zipper)
value = mappify(rest, value)
{:ok,
prepend_to_list(
zipper,
{{:__block__, [format: format], [key]}, {:__block__, [], [value]}}
)}
{:ok, zipper} ->
zipper
|> tuple_elem(1)
|> case do
nil ->
:error
zipper ->
do_put_in_map(zipper, rest, value, updater)
end
end
true ->
:error
end
end
def set_map_key(zipper, key, value, updater) do
cond do
node_matches_pattern?(zipper, {:%{}, _, []}) ->
{:ok,
Zipper.append_child(
zipper,
mappify([key], value)
)}
node_matches_pattern?(zipper, {:%{}, _, _}) ->
zipper
|> Zipper.down()
|> move_to_list_item(fn item ->
if is_tuple?(item) do
first_elem = tuple_elem(item, 0)
first_elem && node_matches_pattern?(first_elem, ^key)
end
end)
|> case do
:error ->
format = map_keys_format(zipper)
{:ok,
prepend_to_list(
zipper,
{{:__block__, [format: format], [key]}, {:__block__, [], [value]}}
)}
{:ok, zipper} ->
zipper
|> tuple_elem(1)
|> case do
nil ->
:error
zipper ->
{:ok, updater.(zipper)}
end
end
true ->
:error
end
end
defp map_keys_format(zipper) do
zipper
|> Zipper.subtree()
|> Zipper.node()
|> case do
value when is_list(value) ->
Enum.all?(value, fn
{:__block__, meta, _} ->
meta[:format] == :keyword
_ ->
false
end)
|> case do
true ->
:keyword
false ->
:map
end
_ ->
:map
end
end
def move_to_function_call_in_current_scope(zipper, name, arity, predicate \\ fn _ -> true end) do
zipper
|> maybe_move_to_block()
|> move_right(fn zipper ->
is_function_call(zipper, name, arity) && predicate.(zipper)
end)
end
def is_function_call(zipper, name, arity) do
zipper
|> maybe_move_to_block()
|> Zipper.subtree()
|> Zipper.root()
|> case do
@ -181,7 +318,7 @@ defmodule Igniter.Common do
|> Zipper.down()
|> case do
nil ->
nil
:error
zipper ->
func.(zipper)
@ -191,7 +328,7 @@ defmodule Igniter.Common do
|> Zipper.down()
|> case do
nil ->
nil
:error
zipper ->
zipper
@ -199,17 +336,17 @@ defmodule Igniter.Common do
|> Zipper.down()
|> case do
nil ->
nil
:error
zipper ->
zipper
|> nth_right(index)
|> case do
nil ->
:error ->
nil
nth ->
func.(nth)
{:ok, nth} ->
{:ok, func.(nth)}
end
end
end
@ -225,11 +362,11 @@ defmodule Igniter.Common do
zipper
|> nth_right(index)
|> case do
nil ->
nil
:error ->
:error
nth ->
func.(nth)
{:ok, nth} ->
{:ok, func.(nth)}
end
end
end
@ -262,10 +399,17 @@ defmodule Igniter.Common do
zipper ->
zipper
|> nth_right(index - 1)
|> maybe_enter_block()
|> Zipper.subtree()
|> Zipper.root()
|> func.()
|> case do
:error ->
false
{:ok, zipper} ->
zipper
|> maybe_move_to_block()
|> Zipper.subtree()
|> Zipper.root()
|> func.()
end
end
end
end
@ -279,10 +423,17 @@ defmodule Igniter.Common do
zipper ->
zipper
|> nth_right(index)
|> maybe_enter_block()
|> Zipper.subtree()
|> Zipper.root()
|> func.()
|> case do
:error ->
false
{:ok, zipper} ->
zipper
|> maybe_move_to_block()
|> Zipper.subtree()
|> Zipper.root()
|> func.()
end
end
end
end
@ -303,7 +454,7 @@ defmodule Igniter.Common do
|> Module.split()
|> Enum.map(&String.to_atom/1)
with zipper when not is_nil(zipper) <- find_pattern(zipper, {:defmodule, _, [_, _]}),
with {:ok, zipper} <- move_to_pattern(zipper, {:defmodule, _, [_, _]}),
subtree <- Zipper.subtree(zipper),
subtree <- subtree |> Zipper.down() |> Zipper.rightmost(),
subtree <- remove_module_definitions(subtree),
@ -347,42 +498,42 @@ defmodule Igniter.Common do
defp do_equal_modules?(_, _), do: false
def move_to_defp(zipper, fun, arity) do
case find_pattern(zipper, {:defp, _, [{^fun, _, args}, _]} when length(args) == arity) do
nil ->
case move_to_pattern(zipper, {:defp, _, [{^fun, _, args}, _]} when length(args) == arity) do
:error ->
if arity == 0 do
case find_pattern(zipper, {:defp, _, [{^fun, _, context}, _]} when is_atom(context)) do
nil ->
case move_to_pattern(zipper, {:defp, _, [{^fun, _, context}, _]} when is_atom(context)) do
:error ->
:error
zipper ->
{:ok, zipper} ->
move_to_do_block(zipper)
end
else
:error
end
zipper ->
{:ok, zipper} ->
move_to_do_block(zipper)
end
end
def move_to_do_block(zipper) do
case find_pattern(zipper, {{:__block__, _, [:do]}, _}) do
nil ->
case move_to_pattern(zipper, {{:__block__, _, [:do]}, _}) do
:error ->
:error
zipper ->
{:ok, zipper} ->
{:ok,
zipper
|> Zipper.down()
|> Zipper.rightmost()
|> maybe_enter_block()}
|> maybe_move_to_block()}
end
end
def maybe_enter_block(nil), do: nil
def maybe_move_to_block(nil), do: nil
def maybe_enter_block(zipper) do
def maybe_move_to_block(zipper) do
zipper
|> Zipper.subtree()
|> Zipper.root()
@ -396,7 +547,7 @@ defmodule Igniter.Common do
end
def remove_module_definitions(zipper) do
Sourceror.Zipper.traverse(zipper, fn
Zipper.traverse(zipper, fn
{:defmodule, _, _} ->
nil
@ -411,9 +562,9 @@ defmodule Igniter.Common do
equality_pred.(value, quoted)
end)
|> case do
nil ->
:error ->
zipper
|> maybe_enter_block()
|> maybe_move_to_block()
|> Zipper.insert_child(quoted)
_ ->
@ -430,13 +581,13 @@ defmodule Igniter.Common do
def prepend_to_list(zipper, quoted) do
zipper
|> maybe_enter_block()
|> maybe_move_to_block()
|> Zipper.insert_child(quoted)
end
def remove_index(zipper, index) do
zipper
|> maybe_enter_block()
|> maybe_move_to_block()
|> Zipper.down()
|> case do
nil ->
@ -466,7 +617,7 @@ defmodule Igniter.Common do
end
defp nth_right(zipper, 0) do
zipper
{:ok, zipper}
end
defp nth_right(zipper, n) do
@ -474,7 +625,7 @@ defmodule Igniter.Common do
|> Zipper.right()
|> case do
nil ->
nil
:error
zipper ->
nth_right(zipper, n - 1)
@ -484,28 +635,28 @@ defmodule Igniter.Common do
def find_list_item_index(zipper, pred) do
# go into first list item
zipper
|> maybe_enter_block()
|> maybe_move_to_block()
|> Zipper.down()
|> case do
nil ->
nil
:error
zipper ->
find_index_right(zipper, pred, 0)
end
end
def find_list_item(zipper, pred) do
def move_to_list_item(zipper, pred) do
# go into first list item
zipper
|> maybe_enter_block()
|> maybe_move_to_block()
|> Zipper.down()
|> case do
nil ->
nil
:error
zipper ->
find_right(zipper, pred)
move_right(zipper, pred)
end
end
@ -522,13 +673,13 @@ defmodule Igniter.Common do
def tuple_elem(item, elem) do
item
|> maybe_enter_block()
|> maybe_move_to_block()
|> Zipper.down()
|> go_right_n_times(elem)
|> maybe_enter_block()
|> maybe_move_to_block()
end
defp go_right_n_times(zipper, 0), do: maybe_enter_block(zipper)
defp go_right_n_times(zipper, 0), do: maybe_move_to_block(zipper)
defp go_right_n_times(zipper, n) do
zipper
@ -540,12 +691,12 @@ defmodule Igniter.Common do
end
defp find_index_right(zipper, pred, index) do
if pred.(maybe_enter_block(zipper)) do
index
if pred.(maybe_move_to_block(zipper)) do
{:ok, index}
else
case Zipper.right(zipper) do
nil ->
nil
:error
zipper ->
zipper
@ -554,27 +705,45 @@ defmodule Igniter.Common do
end
end
defp find_right(zipper, pred) do
if pred.(maybe_enter_block(zipper)) do
zipper
defp move_right(zipper, pred) do
zipper_in_block = maybe_move_to_block(zipper)
if pred.(zipper_in_block) do
{:ok, zipper_in_block}
else
case Zipper.right(zipper) do
nil ->
nil
:error
zipper ->
zipper
|> find_right(pred)
|> move_right(pred)
end
end
end
@doc false
def keywordify([], value) do
value
{:__block__, [], [value]}
end
def keywordify([key | rest], value) do
[{key, keywordify(rest, value)}]
[{{:__block__, [format: :keyword], [key]}, [keywordify(rest, value)]}]
end
@doc false
def mappify([], value) do
{:__block__, [], [value]}
end
def mappify([key | rest], value) do
format =
if is_atom(key) do
:keyword
else
:map
end
[{{:__block__, [format: format], [key]}, [mappify(rest, value)]}]
end
end

View file

@ -26,14 +26,10 @@ defmodule Igniter.Config do
:error ->
# add new code here
[first | rest] = config_path
config =
if Enum.count(config_path) == 1 do
quote do
config unquote(app_name), unquote(Enum.at(config_path, 0)), unquote(value)
end
else
{:config, [], [app_name, Igniter.Common.keywordify(config_path, value)]}
end
{:config, [], [app_name, [{first, Igniter.Common.keywordify(rest, value)}]]}
code =
zipper
@ -55,21 +51,15 @@ defmodule Igniter.Config do
if Enum.count(config_path) == 1 do
config_item = Enum.at(config_path, 0)
case Common.find_function_call_in_current_scope(zipper, :config, 3, fn function_call ->
Common.argument_matches_pattern?(function_call, 0, ^app_name)
Common.argument_matches_pattern?(function_call, 1, ^config_item)
case Common.move_to_function_call_in_current_scope(zipper, :config, 3, fn function_call ->
Common.argument_matches_pattern?(function_call, 0, ^app_name) &&
Common.argument_matches_pattern?(function_call, 1, ^config_item)
end) do
nil ->
:error ->
:error
zipper ->
case Common.update_nth_argument(zipper, 2, updater) do
nil ->
:error
zipper ->
{:ok, zipper}
end
{:ok, zipper} ->
Common.update_nth_argument(zipper, 2, updater)
end
else
:error
@ -77,23 +67,22 @@ defmodule Igniter.Config do
end
defp try_update_two_arg(zipper, config_path, app_name, value, updater) do
case Common.find_function_call_in_current_scope(zipper, :config, 2, fn function_call ->
case Common.move_to_function_call_in_current_scope(zipper, :config, 2, fn function_call ->
Common.argument_matches_pattern?(function_call, 0, ^app_name)
end) do
nil ->
:error ->
:error
zipper ->
{:ok, zipper} ->
Common.update_nth_argument(zipper, 1, fn zipper ->
Igniter.Common.put_in_keyword(zipper, config_path, value, updater)
end)
|> case do
nil ->
nil
case Igniter.Common.put_in_keyword(zipper, config_path, value, updater) do
{:ok, new_zipper} ->
new_zipper
zipper ->
{:ok, zipper}
end
_ ->
zipper
end
end)
end
end
end

View file

@ -15,8 +15,8 @@ defmodule Igniter.Deps do
with {:ok, zipper} <- Common.move_to_module_using(zipper, Mix.Project),
{:ok, zipper} <- Common.move_to_defp(zipper, :deps, 0),
true <- Common.node_matches_pattern?(zipper, value when is_list(value)),
current_declaration when not is_nil(current_declaration) <-
Common.find_list_item(zipper, fn item ->
{:ok, current_declaration} <-
Common.move_to_list_item(zipper, fn item ->
if Common.is_tuple?(item) do
first_elem = Common.tuple_elem(item, 0)
first_elem && Common.node_matches_pattern?(first_elem, ^name)
@ -25,7 +25,7 @@ defmodule Igniter.Deps do
current_declaration
|> Zipper.subtree()
|> Zipper.node()
|> Macro.to_string()
|> Sourceror.to_string()
else
_ ->
nil
@ -98,7 +98,6 @@ defmodule Igniter.Deps do
defp do_add_dependency(igniter, name, version) do
igniter
|> Igniter.Formatter.import_dep(name)
|> Igniter.update_file("mix.exs", fn source ->
quoted = Rewrite.Source.get(source, :quoted)

View file

@ -38,6 +38,13 @@ defmodule Igniter.Formatter do
dep
)
end)
|> case do
{:ok, zipper} ->
zipper
:error ->
zipper
end
end
|> Zipper.root()
@ -75,6 +82,13 @@ defmodule Igniter.Formatter do
&Igniter.Common.equal_modules?/2
)
end)
|> case do
{:ok, zipper} ->
zipper
_ ->
zipper
end
end
|> Zipper.root()

View file

@ -3,7 +3,13 @@ defmodule Igniter do
Igniter is a library for installing packages and generating code.
"""
defstruct [:rewrite, issues: []]
defstruct [:rewrite, issues: [], tasks: []]
@type t :: %__MODULE__{
rewrite: Rewrite.t(),
issues: [String.t()],
tasks: [{String.t() | list(STring.t())}]
}
def new() do
%__MODULE__{rewrite: Rewrite.new()}
@ -13,6 +19,24 @@ defmodule Igniter do
%{igniter | issues: [issue | igniter.issues]}
end
def add_task(igniter, task, argv \\ []) when is_binary(task) do
%{igniter | tasks: igniter.tasks ++ [{task, argv}]}
end
def compose_task(igniter, task, argv) when is_atom(task) do
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
add_issue(igniter, "#{inspect(task)} does not implement `Igniter.igniter/2`")
end
end
def compose_task(igniter, task_name, argv) do
if igniter.issues == [] do
task_name
@ -22,19 +46,7 @@ defmodule Igniter do
igniter
task ->
Code.ensure_compiled!(task)
if function_exported?(task, :igniter, 2) do
if !task.supports_umbrella?() && Mix.Project.umbrella?() do
raise """
Cannot run #{inspect(task)} in an umbrella project.
"""
end
task.igniter(igniter, argv)
else
add_issue(igniter, "#{inspect(task)} does not implement `Igniter.igniter/2`")
end
compose_task(igniter, task, argv)
end
else
igniter
@ -45,9 +57,18 @@ defmodule Igniter do
if Rewrite.has_source?(igniter.rewrite, path) do
%{igniter | rewrite: Rewrite.update!(igniter.rewrite, path, func)}
else
igniter
|> include_existing_elixir_file(path)
|> update_file(path, func)
if File.exists?(path) do
source = Rewrite.Source.Ex.read!(path)
%{igniter | rewrite: Rewrite.put!(igniter.rewrite, source)}
|> format(path)
|> Map.update!(:rewrite, fn rewrite ->
source = Rewrite.source!(rewrite, path)
Rewrite.update!(rewrite, path, func.(source))
end)
else
add_issue(igniter, "Required #{path} but it did not exist")
end
end
end
@ -57,6 +78,7 @@ defmodule Igniter do
else
if File.exists?(path) do
%{igniter | rewrite: Rewrite.put!(igniter.rewrite, Rewrite.Source.Ex.read!(path))}
|> format(path)
else
add_issue(igniter, "Required #{path} but it did not exist")
end
@ -78,6 +100,7 @@ defmodule Igniter do
end
%{igniter | rewrite: Rewrite.put!(igniter.rewrite, source)}
|> format(path)
end
end
@ -95,5 +118,144 @@ defmodule Igniter do
end
%{igniter | rewrite: Rewrite.put!(igniter.rewrite, source)}
|> format(path)
end
defp format(igniter, adding_path \\ nil) do
if adding_path && Path.basename(adding_path) == ".formatter.exs" do
format(igniter)
else
igniter =
"**/.formatter.exs"
|> Path.relative_to(File.cwd!())
|> Path.wildcard()
|> Enum.reduce(igniter, fn path, igniter ->
Igniter.include_existing_elixir_file(igniter, path)
end)
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)
if is_nil(adding_path) || path == adding_path do
dir = Path.dirname(path)
case find_formatter_exs_file_options(dir, formatter_exs_files) do
:error ->
source
{:ok, opts} ->
formatted = Rewrite.Source.Ex.format(source, opts)
source
|> Rewrite.Source.Ex.put_formatter_opts(opts)
|> Rewrite.Source.update(:content, formatted)
end
else
source
end
end)
%{igniter | rewrite: rewrite}
end
end
defp find_formatter_exs_file_options(path, formatter_exs_files) do
case Map.fetch(formatter_exs_files, path) do
{:ok, source} ->
{opts, _} = Rewrite.Source.get(source, :quoted) |> Code.eval_quoted()
{:ok, eval_deps(opts)}
:error ->
if path in ["/", "."] do
:error
else
new_path =
Path.join(path, "..")
|> Path.expand()
|> Path.relative_to_cwd()
find_formatter_exs_file_options(new_path, formatter_exs_files)
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
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
end
end

130
lib/install.ex Normal file
View file

@ -0,0 +1,130 @@
defmodule Igniter.Install do
@option_schema [
switches: [
no_network: :boolean,
example: :boolean,
dry_run: :boolean
],
aliases: [
d: :dry_run,
n: :no_network,
e: :example
]
]
# only supports hex installation at the moment
def install(install, argv) do
install_list =
install
|> String.split(",")
|> Enum.map(&String.to_atom/1)
Application.ensure_all_started(:req)
{options, _} =
OptionParser.parse!(argv, @option_schema)
argv = OptionParser.to_argv(options)
igniter = Igniter.new()
igniter =
Enum.reduce(install_list, igniter, fn install, igniter ->
if Mix.Project.config()[:deps][install][:path] do
Mix.shell().info(
"Not looking up dependency for #{install}, because a local dependency is detected"
)
igniter
else
case Req.get!("https://hex.pm/api/packages/#{install}").body do
%{
"releases" => [
%{"version" => version}
| _
]
} ->
requirement =
version
|> Version.parse!()
|> case do
%Version{major: 0, minor: minor} ->
"~> 0.#{minor}"
%Version{major: major} ->
"~> #{major}.0"
end
Igniter.Deps.add_dependency(igniter, install, requirement)
_ ->
Igniter.add_issue(igniter, "No published versions of #{install} on hex")
end
end
end)
confirmation_message =
unless options[:dry_run] do
"Dependencies changes must go into effect before individual installers can be run. Proceed with changes?"
end
dependency_add_result =
Igniter.Tasks.do_or_dry_run(igniter, argv,
title: "Fetching Dependency",
quiet_on_no_changes?: true,
confirmation_message: confirmation_message
)
if dependency_add_result == :issues do
raise "Exiting due to issues found while fetching dependency"
end
if dependency_add_result == :dry_run_with_changes do
install_dep_now? =
Mix.shell().yes?("""
Cannot run any associated installers for the requested packages without
commiting changes and fetching dependencies.
Would you like to do so now? The remaining steps will be displayed as a dry run.
""")
if install_dep_now? do
Igniter.Tasks.do_or_dry_run(igniter, (argv ++ ["--yes"]) -- ["--dry-run"],
title: "Fetching Dependency",
quiet_on_no_changes?: true
)
end
end
Mix.shell().info("running mix deps.get")
case Mix.shell().cmd("mix deps.get") do
0 ->
Mix.Task.reenable("compile")
Mix.Task.run("compile")
exit_code ->
Mix.shell().info("""
mix deps.get returned exited with code: `#{exit_code}`
""")
end
all_tasks =
Enum.filter(Mix.Task.load_all(), &Spark.implements_behaviour?(&1, Igniter.Mix.Task))
install_list
|> Enum.flat_map(fn install ->
all_tasks
|> Enum.find(fn task ->
Mix.Task.task_name(task) == "#{install}.install"
end)
|> List.wrap()
end)
|> Enum.reduce(Igniter.new(), fn task, igniter ->
Igniter.compose_task(igniter, task, argv)
end)
|> Igniter.Tasks.do_or_dry_run(argv)
:ok
end
end

View file

@ -5,11 +5,7 @@ defmodule Mix.Tasks.Igniter.Install do
def run([install | argv]) do
Application.ensure_all_started([:rewrite])
if String.contains?(install, "/") do
raise "installation from github not supported yet"
else
Mix.Task.run("igniter.install_from_hex", [install | argv])
end
Igniter.Install.install(install, argv)
end
def run([]) do

View file

@ -1,98 +0,0 @@
defmodule Mix.Tasks.Igniter.InstallFromHex do
use Mix.Task
@impl true
def run([install | argv]) do
install = String.to_atom(install)
Application.ensure_all_started(:req)
case Req.get!("https://hex.pm/api/packages/#{install}").body do
%{
"releases" => [
%{"version" => version}
| _
]
} ->
requirement =
version
|> Version.parse!()
|> case do
%Version{major: 0, minor: minor} ->
"~> 0.#{minor}"
%Version{major: major} ->
"~> #{major}.0"
end
dependency_add_result =
Igniter.new()
|> Igniter.Deps.add_dependency(install, requirement)
|> Igniter.Tasks.do_or_dry_run(argv,
title: "Fetching Dependency",
quiet_on_no_changes?: true
)
if dependency_add_result == :issues do
raise "Exiting due to issues found while fetching dependency"
end
if dependency_add_result == :dry_run_with_changes do
install_dep_now? =
Mix.shell().yes?("""
Cannot display any further installation changes without installing the `#{install}` dependency.
Would you like to install the dependency now?
This will be the only change made, and then any remaining steps will be displayed as a dry-run.
""")
if install_dep_now? do
Igniter.new()
|> Igniter.Deps.add_dependency(install, requirement)
|> Igniter.Tasks.do_or_dry_run(argv -- ["--dry-run"],
title: "Fetching Dependency",
quiet_on_no_changes?: true
)
end
end
case System.cmd("mix", ["deps.get"]) do
{_, 0} ->
:ok
{output, exit} ->
Mix.shell().info("""
mix deps.get returned exited with code: `#{exit}`
#{output}
""")
end
Mix.Task.load_all()
|> Enum.find(fn module ->
Mix.Task.task_name(module) == "igniter.install.#{install}"
end)
|> case do
nil ->
if dependency_add_result in [:dry_run_with_no_changes, :no_changes] do
Mix.shell().info("Igniter: #{install} already installed")
else
if dependency_add_result == :changes_aborted do
Mix.shell().info("Igniter: #{install} installation aborted")
else
Mix.shell().info("Igniter: #{install} installation complete")
end
end
_task ->
Mix.shell().info("Igniter: Installing #{install}...")
Mix.Task.run("igniter.install.#{install}", argv)
end
_ ->
raise "No published versions of #{install}"
end
:ok
end
end

View file

@ -1,4 +1,4 @@
defmodule Mix.Tasks.Igniter.Install.Spark do
defmodule Mix.Tasks.Spark.Install do
use Igniter.Mix.Task
def igniter(igniter, _argv) do

View file

@ -4,6 +4,7 @@ defmodule Igniter.Tasks do
end
def do_or_dry_run(igniter, argv, opts \\ []) do
igniter = %{igniter | issues: Enum.uniq(igniter.issues)}
title = opts[:title] || "Igniter"
sources =
@ -19,7 +20,7 @@ defmodule Igniter.Tasks do
[]
end
issues = changed_issues ++ Rewrite.Source.issues(source)
issues = Enum.uniq(changed_issues ++ Rewrite.Source.issues(source))
case issues do
[] -> []
@ -42,29 +43,73 @@ defmodule Igniter.Tasks do
|> case do
[] ->
unless opts[:quiet_on_no_changes?] do
IO.puts("\n#{title}: No proposed changes!\n")
Mix.shell().info("\n#{title}: No proposed changes!\n")
end
:dry_run_with_no_changes
sources ->
IO.puts("\n#{title}: Proposed changes:\n")
Mix.shell().info("\n#{title}: Proposed changes:\n")
Enum.each(sources, fn source ->
IO.puts("""
#{Rewrite.Source.get(source, :path)}
if Rewrite.Source.from?(source, :string) do
content_lines =
source
|> Rewrite.Source.get(:content)
|> String.split("\n")
|> Enum.with_index()
#{Rewrite.Source.diff(source)}
""")
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)
Mix.shell().info("""
Create: #{Rewrite.Source.get(source, :path)}
#{diffish_looking_text}
""")
else
Mix.shell().info("""
Update: #{Rewrite.Source.get(source, :path)}
#{Rewrite.Source.diff(source)}
""")
end
end)
:dry_run_with_changes
end
if igniter.tasks != [] do
message =
if result_of_dry_run in [:dry_run_with_no_changes, :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
if "--dry-run" in argv || result_of_dry_run == :dry_run_with_no_changes do
result_of_dry_run
else
if "--yes" in argv || Mix.shell().yes?("Proceed with changes?") do
if "--yes" in argv ||
Mix.shell().yes?(opts[:confirmation_message] || "Proceed with changes?") do
sources
|> Enum.any?(fn source ->
Rewrite.Source.updated?(source)
@ -72,8 +117,22 @@ defmodule Igniter.Tasks do
|> if do
igniter.rewrite
|> Rewrite.write_all()
|> case do
{:ok, _result} ->
igniter.tasks
|> Enum.each(fn {task, args} ->
Mix.Task.run(task, args)
end)
:changes_made
:changes_made
{:error, error} ->
igniter
|> Igniter.add_issue(error)
|> igniter_issues()
{:error, error}
end
else
:no_changes
end
@ -82,26 +141,30 @@ defmodule Igniter.Tasks do
end
end
else
IO.puts("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)
|> IO.puts()
igniter_issues(igniter)
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
IO.puts("Igniter: Issues found in proposed changes:\n")
Mix.shell().info("Igniter: Issues found in proposed changes:\n")
Enum.each(issues, fn {source, issues} ->
IO.puts("Issues with #{Rewrite.Source.get(source, :path)}")
Mix.shell().info("Issues with #{Rewrite.Source.get(source, :path)}")
issues
|> Enum.map_join("\n", fn error ->
@ -111,7 +174,7 @@ defmodule Igniter.Tasks do
"* #{Exception.format(:error, error)}"
end
end)
|> IO.puts()
|> Mix.shell().info()
end)
end
end

View file

@ -28,7 +28,7 @@ defmodule Igniter.MixProject do
logo: "logos/igniter-logo.png",
extra_section: "GUIDES",
extras: [
{"README.md", title: "Home"},
{"README.md", title: "Home"}
# "CHANGELOG.md"
],
before_closing_head_tag: fn type ->

View file

@ -38,7 +38,7 @@
"nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"},
"reactor": {:hex, :reactor, "0.8.4", "344d02ba4a0010763851f4e4aa0ff190ebe7e392e3c27c6cd143dde077b986e7", [:mix], [{:libgraph, "~> 0.16", [hex: :libgraph, repo: "hexpm", optional: false]}, {:spark, "~> 2.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.2", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "49c1fd3c786603cec8140ce941c41c7ea72cc4411860ccdee9876c4ca2204f81"},
"req": {:hex, :req, "0.4.14", "103de133a076a31044e5458e0f850d5681eef23dfabf3ea34af63212e3b902e2", [:mix], [{:aws_signature, "~> 0.3.2", [hex: :aws_signature, repo: "hexpm", optional: true]}, {:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:nimble_ownership, "~> 0.2.0 or ~> 0.3.0", [hex: :nimble_ownership, repo: "hexpm", optional: false]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "2ddd3d33f9ab714ced8d3c15fd03db40c14dbf129003c4a3eb80fac2cc0b1b08"},
"rewrite": {:hex, :rewrite, "0.10.1", "238073297d122dad6b5501d761cb3bc0ce5bb4ab86e34c826c395f5f44b2f562", [:mix], [{:glob_ex, "~> 0.1", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.0", [hex: :sourceror, repo: "hexpm", optional: false]}], "hexpm", "91f8d6fe363033e8ff60097bb5e0b76867667df0b4d67e79c2850444c02d8b19"},
"rewrite": {:hex, :rewrite, "0.10.3", "1c998cceac960c3025a1701158d846dee94bc426d95abefd2b4a2e981835ea1c", [:mix], [{:glob_ex, "~> 0.1", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.0", [hex: :sourceror, repo: "hexpm", optional: false]}], "hexpm", "d3ea3179de167ebda56bf81b7e5c2697256a0719fdcc2c0df65ea8173efe3563"},
"sobelow": {:hex, :sobelow, "0.13.0", "218afe9075904793f5c64b8837cc356e493d88fddde126a463839351870b8d1e", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "cd6e9026b85fc35d7529da14f95e85a078d9dd1907a9097b3ba6ac7ebbe34a0d"},
"sourceror": {:hex, :sourceror, "1.2.1", "b415255ad8bd05f0e859bb3d7ea617f6c2a4a405f2a534a231f229bd99b89f8b", [:mix], [], "hexpm", "e4d97087e67584a7585b5fe3d5a71bf8e7332f795dd1a44983d750003d5e750c"},
"spark": {:hex, :spark, "2.1.22", "a36400eede64c51af578de5fdb5a5aaa3e0811da44bcbe7545fce059bd2a990b", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.0", [hex: :sourceror, repo: "hexpm", optional: false]}], "hexpm", "f764611d0b15ac132e72b2326539acc11fc4e63baa3e429f541bca292b5f7064"},

125
test/config_test.exs Normal file
View file

@ -0,0 +1,125 @@
defmodule Igniter.ConfigTest do
use ExUnit.Case
alias Rewrite.Source
describe "configure/6" do
test "it creates the config file if it does not exist" do
%{rewrite: rewrite} =
Igniter.Config.configure(Igniter.new(), "fake.exs", :fake, [:foo, :bar], "baz")
config_file = Rewrite.source!(rewrite, "config/fake.exs")
assert Source.from?(config_file, :string)
assert Source.get(config_file, :content) == """
import Config
config :fake, foo: [bar: "baz"]
"""
end
test "it merges with 2 arg version of existing config" do
%{rewrite: rewrite} =
Igniter.new()
|> Igniter.create_new_elixir_file("config/fake.exs", """
config :fake, buz: [:blat]
""")
|> Igniter.Config.configure("fake.exs", :fake, [:foo, :bar], "baz")
config_file = Rewrite.source!(rewrite, "config/fake.exs")
assert Source.get(config_file, :content) == """
config :fake, foo: [bar: "baz"], buz: [:blat]
"""
end
test "it merges with 2 arg version of existing config with a single path item" do
%{rewrite: rewrite} =
Igniter.new()
|> Igniter.create_new_elixir_file("config/fake.exs", """
config :fake, buz: [:blat]
""")
|> Igniter.Config.configure("fake.exs", :fake, [:foo], "baz")
config_file = Rewrite.source!(rewrite, "config/fake.exs")
assert Source.get(config_file, :content) == """
config :fake, foo: "baz", buz: [:blat]
"""
end
test "it merges with 3 arg version of existing config" do
%{rewrite: rewrite} =
Igniter.new()
|> Igniter.create_new_elixir_file("config/fake.exs", """
config :fake, :buz, [:blat]
""")
|> Igniter.Config.configure("fake.exs", :fake, [:foo, :bar], "baz")
config_file = Rewrite.source!(rewrite, "config/fake.exs")
assert Source.get(config_file, :content) == """
config :fake, :buz, [:blat]
config :fake, foo: [bar: "baz"]
"""
end
test "it merges with 3 arg version of existing config with a single path item" do
%{rewrite: rewrite} =
Igniter.new()
|> Igniter.create_new_elixir_file("config/fake.exs", """
config :fake, :buz, [:blat]
""")
|> Igniter.Config.configure("fake.exs", :fake, [:foo], "baz")
config_file = Rewrite.source!(rewrite, "config/fake.exs")
assert Source.get(config_file, :content) == """
config :fake, :buz, [:blat]
config :fake, foo: "baz"
"""
end
test "present values can be updated" do
%{rewrite: rewrite} =
Igniter.new()
|> Igniter.create_new_elixir_file("config/fake.exs", """
config :fake, :buz, [:blat]
""")
|> Igniter.Config.configure("fake.exs", :fake, [:buz], "baz", fn list ->
Igniter.Common.prepend_new_to_list(list, "baz")
end)
config_file = Rewrite.source!(rewrite, "config/fake.exs")
assert Source.get(config_file, :content) == """
config :fake, :buz, ["baz", :blat]
"""
end
test "present values can be updated by updating map keys" do
%{rewrite: rewrite} =
Igniter.new()
|> Igniter.create_new_elixir_file("config/fake.exs", """
config :fake, foo: %{"a" => ["a", "b"]}
""")
|> Igniter.Config.configure("fake.exs", :fake, [:foo], %{"b" => ["c", "d"]}, fn zipper ->
Igniter.Common.set_map_key(zipper, "b", ["c", "d"], fn zipper ->
zipper
|> Igniter.Common.prepend_new_to_list(zipper, "c")
|> Igniter.Common.prepend_new_to_list(zipper, "d")
end)
|> case do
{:ok, zipper} -> zipper
_ -> zipper
end
end)
config_file = Rewrite.source!(rewrite, "config/fake.exs")
assert Source.get(config_file, :content) == """
config :fake, foo: %{"b" => ["c", "d"], "a" => ["a", "b"]}
"""
end
end
end

View file

@ -1,8 +1,4 @@
defmodule IgniterTest do
use ExUnit.Case
doctest Igniter
test "greets the world" do
assert Igniter.hello() == :world
end
end