From 281ec0f65695d9e90820e929d1c8422e9b2703a4 Mon Sep 17 00:00:00 2001 From: Zach Daniel Date: Fri, 31 May 2024 22:09:38 -0400 Subject: [PATCH] write a bunch more code, and add some tests, rework various patterns --- README.md | 1 + lib/args.ex | 19 + lib/common.ex | 353 +++++++++++++----- lib/config.ex | 49 +-- lib/deps.ex | 7 +- lib/formatter.ex | 14 + lib/igniter.ex | 196 +++++++++- lib/install.ex | 130 +++++++ lib/mix/tasks/igniter.install.ex | 6 +- lib/mix/tasks/igniter.install_from_hex.ex | 98 ----- ...iter.install.spark.ex => spark.install.ex} | 2 +- lib/tasks.ex | 109 ++++-- mix.exs | 2 +- mix.lock | 2 +- test/config_test.exs | 125 +++++++ test/igniter_test.exs | 4 - 16 files changed, 841 insertions(+), 276 deletions(-) create mode 100644 lib/install.ex delete mode 100644 lib/mix/tasks/igniter.install_from_hex.ex rename lib/mix/tasks/{igniter.install.spark.ex => spark.install.ex} (84%) create mode 100644 test/config_test.exs diff --git a/README.md b/README.md index 51570fb..376ccd9 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/lib/args.ex b/lib/args.ex index 2dd3cc8..f032e1d 100644 --- a/lib/args.ex +++ b/lib/args.ex @@ -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] -> diff --git a/lib/common.ex b/lib/common.ex index 018d271..f8a7283 100644 --- a/lib/common.ex +++ b/lib/common.ex @@ -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 diff --git a/lib/config.ex b/lib/config.ex index b84fa9b..e2729af 100644 --- a/lib/config.ex +++ b/lib/config.ex @@ -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 diff --git a/lib/deps.ex b/lib/deps.ex index ccf471f..ee91de3 100644 --- a/lib/deps.ex +++ b/lib/deps.ex @@ -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) diff --git a/lib/formatter.ex b/lib/formatter.ex index 3809f95..21eeead 100644 --- a/lib/formatter.ex +++ b/lib/formatter.ex @@ -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() diff --git a/lib/igniter.ex b/lib/igniter.ex index 7ae4ccc..d547a51 100644 --- a/lib/igniter.ex +++ b/lib/igniter.ex @@ -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 diff --git a/lib/install.ex b/lib/install.ex new file mode 100644 index 0000000..a64629b --- /dev/null +++ b/lib/install.ex @@ -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 diff --git a/lib/mix/tasks/igniter.install.ex b/lib/mix/tasks/igniter.install.ex index e17fdc0..6794f0d 100644 --- a/lib/mix/tasks/igniter.install.ex +++ b/lib/mix/tasks/igniter.install.ex @@ -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 diff --git a/lib/mix/tasks/igniter.install_from_hex.ex b/lib/mix/tasks/igniter.install_from_hex.ex deleted file mode 100644 index d09a5ff..0000000 --- a/lib/mix/tasks/igniter.install_from_hex.ex +++ /dev/null @@ -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 diff --git a/lib/mix/tasks/igniter.install.spark.ex b/lib/mix/tasks/spark.install.ex similarity index 84% rename from lib/mix/tasks/igniter.install.spark.ex rename to lib/mix/tasks/spark.install.ex index 215f245..0fdc40c 100644 --- a/lib/mix/tasks/igniter.install.spark.ex +++ b/lib/mix/tasks/spark.install.ex @@ -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 diff --git a/lib/tasks.ex b/lib/tasks.ex index cd4f5d4..c8ddf41 100644 --- a/lib/tasks.ex +++ b/lib/tasks.ex @@ -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 diff --git a/mix.exs b/mix.exs index cd776c4..e5b5282 100644 --- a/mix.exs +++ b/mix.exs @@ -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 -> diff --git a/mix.lock b/mix.lock index 9a951fb..f3a87e5 100644 --- a/mix.lock +++ b/mix.lock @@ -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"}, diff --git a/test/config_test.exs b/test/config_test.exs new file mode 100644 index 0000000..2b40f70 --- /dev/null +++ b/test/config_test.exs @@ -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 diff --git a/test/igniter_test.exs b/test/igniter_test.exs index 232426d..ddfb1c9 100644 --- a/test/igniter_test.exs +++ b/test/igniter_test.exs @@ -1,8 +1,4 @@ defmodule IgniterTest do use ExUnit.Case doctest Igniter - - test "greets the world" do - assert Igniter.hello() == :world - end end