diff --git a/.tool-versions b/.tool-versions index 18625f1..18a6828 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,2 +1,2 @@ erlang 26.0.2 -elixir 1.17.0 +elixir 1.17.1 diff --git a/lib/igniter.ex b/lib/igniter.ex index fea01f7..88c3d06 100644 --- a/lib/igniter.ex +++ b/lib/igniter.ex @@ -116,7 +116,7 @@ defmodule Igniter do glob = case glob do %GlobEx{} = glob -> glob - string -> GlobEx.compile!(string) + string -> GlobEx.compile!(Path.expand(string)) end igniter = include_glob(igniter, glob) @@ -885,6 +885,7 @@ defmodule Igniter do end) end + # sobelow_skip ["RCE.CodeModule"] defp parse_igniter_config(igniter) do case Rewrite.source(igniter.rewrite, ".igniter.exs") do {:error, _} -> diff --git a/lib/igniter/code/common.ex b/lib/igniter/code/common.ex index 8e215b8..4d4f122 100644 --- a/lib/igniter/code/common.ex +++ b/lib/igniter/code/common.ex @@ -7,7 +7,7 @@ defmodule Igniter.Code.Common do @doc """ Moves to the next node that matches the predicate. """ - @spec move_to(Zipper.t(), (Zipper.tree() -> Zipper.t())) :: {:ok, Zipper.t()} | :error + @spec move_to(Zipper.t(), (Zipper.tree() -> boolean())) :: {:ok, Zipper.t()} | :error def move_to(zipper, pred) do Zipper.find(zipper, fn thing -> try do @@ -26,6 +26,22 @@ defmodule Igniter.Code.Common do end end + @doc """ + Moves to the next zipper that matches the predicate. + """ + @spec move_to(Zipper.t(), (Zipper.t() -> boolean())) :: {:ok, Zipper.t()} | :error + def move_to_zipper(zipper, pred) do + if pred.(zipper) do + {:ok, zipper} + else + if next = Zipper.next(zipper) do + move_to_zipper(next, pred) + else + :error + end + end + end + @doc """ Returns `true` if the current node matches the given pattern. @@ -612,11 +628,20 @@ defmodule Igniter.Code.Common do @spec nodes_equal?(Zipper.t() | Macro.t(), Macro.t()) :: boolean def nodes_equal?(%Zipper{} = left, right) do - left - |> expand_aliases() - |> Zipper.subtree() - |> Zipper.node() - |> nodes_equal?(right) + with zipper when not is_nil(zipper) <- Zipper.up(left), + {:defmodule, _, [{:__aliases__, _, parts}, _]} <- + zipper |> Zipper.subtree() |> Zipper.node(), + {:ok, env} <- current_env(zipper), + true <- nodes_equal?({:__aliases__, [], [Module.concat([env.module | parts])]}, right) do + true + else + _ -> + left + |> expand_aliases() + |> Zipper.subtree() + |> Zipper.node() + |> nodes_equal?(right) + end end def nodes_equal?(_left, %Zipper{}) do @@ -631,14 +656,14 @@ defmodule Igniter.Code.Common do @spec expand_aliases(Zipper.t()) :: Zipper.t() def expand_aliases(zipper) do - case current_env(zipper) do - {:ok, env} -> - Zipper.traverse(zipper, fn x -> - x - |> Zipper.subtree() - |> Zipper.node() - |> case do - {:__aliases__, _, parts} -> + Zipper.traverse(zipper, fn x -> + x + |> Zipper.subtree() + |> Zipper.node() + |> case do + {:__aliases__, _, parts} -> + case current_env(zipper) do + {:ok, env} -> case Macro.Env.expand_alias(env, [], parts) do {:alias, value} -> Zipper.replace(x, {:__aliases__, [], Module.split(value)}) @@ -650,11 +675,11 @@ defmodule Igniter.Code.Common do _ -> x end - end) - _ -> - zipper - end + _ -> + x + end + end) rescue _ -> zipper diff --git a/lib/igniter/code/module.ex b/lib/igniter/code/module.ex index e54db65..7be88a5 100644 --- a/lib/igniter/code/module.ex +++ b/lib/igniter/code/module.ex @@ -4,6 +4,75 @@ defmodule Igniter.Code.Module do alias Igniter.Code.Common alias Sourceror.Zipper + @doc "Find or create module" + def find_and_update_or_create_module(igniter, module_name, contents, updater) do + igniter + |> Igniter.include_glob("lib/**/*.ex") + |> Map.get(:rewrite) + |> Enum.find_value(fn source -> + source + |> Rewrite.Source.get(:quoted) + |> Zipper.zip() + |> Igniter.Code.Common.move_to_zipper(fn zipper -> + with true <- Igniter.Code.Function.function_call?(zipper, :defmodule, 2), + {:ok, inner_zipper} <- Igniter.Code.Function.move_to_nth_argument(zipper, 0), + inner_zipper <- Igniter.Code.Common.expand_aliases(inner_zipper), + true <- + Igniter.Code.Common.nodes_equal?( + inner_zipper, + module_name + ) do + {:ok, inner_zipper} + else + _ -> + nil + end + end) + |> case do + {:ok, zipper} -> + {source, zipper} + + _ -> + nil + end + end) + |> case do + {source, zipper} -> + case Common.move_to_do_block(zipper) do + {:ok, zipper} -> + case updater.(zipper) do + {:ok, zipper} -> + new_quoted = + zipper + |> Zipper.topmost() + |> Zipper.node() + + new_source = Rewrite.Source.update(source, :quoted, new_quoted) + %{igniter | rewrite: Rewrite.update!(igniter.rewrite, new_source)} + + {:error, error} -> + Igniter.add_issue(igniter, error) + + {:warning, error} -> + Igniter.add_warning(igniter, error) + end + + _ -> + igniter + end + + nil -> + contents = + """ + defmodule #{inspect(module_name)} do + #{contents} + end + """ + + Igniter.create_new_elixir_file(igniter, proper_location(module_name), contents) + end + end + @doc "Given a suffix, returns a module name with the prefix of the current project." @spec module_name(String.t()) :: module() def module_name(suffix) do @@ -18,9 +87,9 @@ defmodule Igniter.Code.Module do iex> Igniter.Code.Module.proper_location(MyApp.Hello) "lib/my_app/hello.ex" """ - @spec proper_location(igniter :: Igniter.t() | nil, module()) :: Path.t() - def proper_location(igniter \\ nil, module_name) do - do_proper_location(igniter, module_name, :lib) + @spec proper_location(module()) :: Path.t() + def proper_location(module_name) do + do_proper_location(module_name, :lib) end @doc """ @@ -35,9 +104,9 @@ defmodule Igniter.Code.Module do iex> Igniter.Code.Module.proper_test_location(MyApp.HelloTest) "test/my_app/hello_test.exs" """ - @spec proper_test_location(igniter :: Igniter.t() | nil, module()) :: Path.t() - def proper_test_location(igniter \\ nil, module_name) do - do_proper_location(igniter, module_name, :test) + @spec proper_test_location(module()) :: Path.t() + def proper_test_location(module_name) do + do_proper_location(module_name, :test) end @doc """ @@ -49,9 +118,9 @@ defmodule Igniter.Code.Module do iex> Igniter.Code.Module.proper_test_support_location(MyApp.DataCase) "test/support/data_case.ex" """ - @spec proper_test_support_location(igniter :: Igniter.t() | nil, module()) :: Path.t() - def proper_test_support_location(igniter \\ nil, module_name) do - do_proper_location(igniter, module_name, :test_support) + @spec proper_test_support_location(module()) :: Path.t() + def proper_test_support_location(module_name) do + do_proper_location(module_name, :test_support) end @doc false @@ -149,7 +218,7 @@ defmodule Igniter.Code.Module do split_from_path == split end - defp do_proper_location(igniter, module_name, kind) do + defp do_proper_location(module_name, kind) do path = module_name |> Module.split() @@ -174,35 +243,6 @@ defmodule Igniter.Code.Module do [_prefix | leading_rest] = leading Path.join(["test/support" | leading_rest] ++ ["#{last}.ex"]) end - |> apply_leaf_module_configuration(igniter) - end - - defp apply_leaf_module_configuration(path, nil), do: path - - defp apply_leaf_module_configuration(path, igniter) do - case Igniter.Project.IgniterConfig.get(igniter, :leaf_module_location) do - :outside_folder -> - path - - :inside_folder -> - path - |> Path.split() - |> Enum.reverse() - |> Enum.split(2) - |> case do - {[filename, last_folder_name], rest} -> - if Path.rootname(filename) == last_folder_name do - [last_folder_name <> Path.extname(filename) | rest] - |> Enum.reverse() - |> Path.join() - else - path - end - - _ -> - path - end - end end def module?(zipper) do diff --git a/lib/igniter/project/igniter_config.ex b/lib/igniter/project/igniter_config.ex index d1661e8..6a999a4 100644 --- a/lib/igniter/project/igniter_config.ex +++ b/lib/igniter/project/igniter_config.ex @@ -54,7 +54,7 @@ defmodule Igniter.Project.IgniterConfig do unquote(config[:default]) end - # TODO: when we have a way to comment ahead of a keyword item + # when we have a way to comment ahead of a keyword item # we should comment the docs case Igniter.Code.Keyword.set_keyword_key( zipper, diff --git a/test/code/module_test.exs b/test/code/module_test.exs index a6a279d..c9739b6 100644 --- a/test/code/module_test.exs +++ b/test/code/module_test.exs @@ -20,17 +20,101 @@ defmodule Igniter.Code.ModuleTest do assert "lib/foo/bar/bar.ex" in paths assert "lib/foo/bar/baz.ex" in paths + end - # Igniter.Project.Config.configure(Igniter.new(), "fake.exs", :fake, [:foo, :bar], "baz") + test "modules can be found anywhere across the project" do + %{rewrite: rewrite} = + Igniter.new() + |> Igniter.create_new_elixir_file("lib/foo/bar.ex", """ + defmodule Foo.Bar do + defmodule Baz do + 10 + end + end + """) + |> Igniter.Code.Module.find_and_update_or_create_module( + Foo.Bar.Baz, + """ + 20 + """, + fn zipper -> + {:ok, Igniter.Code.Common.replace_code(zipper, 30)} + end + ) - # config_file = Rewrite.source!(rewrite, "config/fake.exs") + contents = + rewrite + |> Rewrite.source!("lib/foo/bar.ex") + |> Rewrite.Source.get(:content) - # assert Source.from?(config_file, :string) + assert contents == """ + defmodule Foo.Bar do + defmodule Baz do + 30 + end + end + """ + end - # assert Source.get(config_file, :content) == """ - # import Config - # config :fake, foo: [bar: "baz"] - # """ - # end + test "modules will be created if they do not exist, in the conventional place" do + %{rewrite: rewrite} = + Igniter.new() + |> Igniter.create_new_elixir_file("lib/foo/bar.ex", """ + defmodule Foo.Bar do + end + """) + |> Igniter.Code.Module.find_and_update_or_create_module( + Foo.Bar.Baz, + """ + 20 + """, + fn zipper -> + {:ok, Igniter.Code.Common.replace_code(zipper, 30)} + end + ) + + contents = + rewrite + |> Rewrite.source!("lib/foo/bar/baz.ex") + |> Rewrite.Source.get(:content) + + assert contents == """ + defmodule Foo.Bar.Baz do + 20 + end + """ + end + + test "modules will be created if they do not exist, in the conventional place, which can be configured" do + %{rewrite: rewrite} = + Igniter.new() + |> Igniter.assign(:igniter_exs, + leaf_module_location: :inside_folder + ) + |> Igniter.create_new_elixir_file("lib/foo/bar/something.ex", """ + defmodule Foo.Bar.Something do + end + """) + |> Igniter.Code.Module.find_and_update_or_create_module( + Foo.Bar, + """ + 20 + """, + fn zipper -> + {:ok, Igniter.Code.Common.replace_code(zipper, 30)} + end + ) + |> Igniter.prepare_for_write() + + contents = + rewrite + |> Rewrite.source!("lib/foo/bar/bar.ex") + |> Rewrite.Source.get(:content) + + assert contents == """ + defmodule Foo.Bar do + 20 + end + """ end end