Skip to content

Commit ce19337

Browse files
committed
improvement: add rename_module/4 and mix igniter.refactor.rename_module task
1 parent 601d010 commit ce19337

3 files changed

Lines changed: 489 additions & 0 deletions

File tree

lib/igniter/refactors/rename.ex

Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,78 @@ defmodule Igniter.Refactors.Rename do
99

1010
@function_module_attrs [:doc, :spec, :decorate]
1111

12+
@doc """
13+
Renames a module globally across a project.
14+
15+
Renames the module everywhere it appears: `defmodule`, `alias`, `use`,
16+
`import`, `require`, and all call sites. Also handles submodules, the
17+
corresponding test module, string literals mentioning the module name, and
18+
moves the file(s) to match the new module's proper location.
19+
20+
## Alias handling
21+
22+
- `alias Foo.Bar` — the alias declaration and all `Bar.*` call sites are renamed.
23+
- `alias Foo.{Bar, Other}` — the declaration and call sites are renamed.
24+
Spitfire does not macro-expand the brace form, so resolution falls back to
25+
an explicit AST scan.
26+
- `alias Foo.Bar, as: B` — only the declaration is updated (`alias Foo.Baz, as: B`).
27+
`B.*` call sites are left untouched because the `as:` clause still resolves correctly.
28+
29+
## Limitations
30+
31+
- Dynamic references (e.g. `apply/3`, `Module.concat/2` with variables) are not rewritten.
32+
- String-literal replacement is a plain substring replace over the raw file content,
33+
so it also rewrites occurrences in comments and unrelated strings. Grep after.
34+
"""
35+
@spec rename_module(Igniter.t(), module(), module(), Keyword.t()) :: Igniter.t()
36+
def rename_module(igniter, old_module, new_module, _opts \\ [])
37+
when is_atom(old_module) and is_atom(new_module) do
38+
old_parts = Module.split(old_module)
39+
new_parts = Module.split(new_module)
40+
41+
old_aliases = Enum.map(old_parts, &String.to_atom/1)
42+
new_aliases = Enum.map(new_parts, &String.to_atom/1)
43+
44+
old_test_aliases =
45+
List.update_at(old_parts, -1, &(&1 <> "Test")) |> Enum.map(&String.to_atom/1)
46+
47+
new_test_aliases =
48+
List.update_at(new_parts, -1, &(&1 <> "Test")) |> Enum.map(&String.to_atom/1)
49+
50+
old_short = [List.last(old_aliases)]
51+
new_short = [List.last(new_aliases)]
52+
53+
old_module_str = Enum.join(old_parts, ".")
54+
new_module_str = Enum.join(new_parts, ".")
55+
56+
renames = [
57+
{old_aliases, new_aliases},
58+
{old_test_aliases, new_test_aliases}
59+
]
60+
61+
igniter = Igniter.include_all_elixir_files(igniter)
62+
63+
old_path =
64+
find_module_file(igniter, old_module_str) ||
65+
Igniter.Project.Module.proper_location(igniter, old_module)
66+
67+
new_path = Igniter.Project.Module.proper_location(igniter, new_module)
68+
affected_files = find_affected_files(igniter, old_module_str, old_parts)
69+
70+
igniter
71+
|> rewrite_affected_files(
72+
affected_files,
73+
renames,
74+
old_aliases,
75+
old_short,
76+
new_short,
77+
old_module_str,
78+
new_module_str
79+
)
80+
|> move_submodule_files(old_path, new_path)
81+
|> move_module_file(old_path, new_path)
82+
end
83+
1284
@doc """
1385
Renames a function globally across a project.
1486
@@ -860,4 +932,196 @@ defmodule Igniter.Refactors.Rename do
860932
|> Module.split()
861933
|> Enum.map(&String.to_atom/1)
862934
end
935+
936+
## rename_module helpers
937+
938+
defp find_module_file(igniter, module_str) do
939+
igniter.rewrite
940+
|> Rewrite.sources()
941+
|> Enum.find_value(fn source ->
942+
path = Rewrite.Source.get(source, :path)
943+
content = Rewrite.Source.get(source, :content)
944+
945+
if String.ends_with?(path, [".ex", ".exs"]) &&
946+
String.contains?(content, "defmodule #{module_str}"),
947+
do: path
948+
end)
949+
end
950+
951+
defp find_affected_files(igniter, module_str, old_parts) do
952+
igniter.rewrite
953+
|> Rewrite.sources()
954+
|> Enum.filter(fn source ->
955+
path = Rewrite.Source.get(source, :path)
956+
content = Rewrite.Source.get(source, :content)
957+
958+
String.ends_with?(path, [".ex", ".exs"]) &&
959+
(String.contains?(content, module_str) ||
960+
multi_alias_in_content?(content, old_parts))
961+
end)
962+
|> Enum.map(&Rewrite.Source.get(&1, :path))
963+
end
964+
965+
# Detects `alias Foo.{Bar, Baz}` style references where the full module name
966+
# (e.g. "Foo.Bar") is not present as a substring. Checks for the namespace
967+
# prefix followed by ".{" and the short segment name anywhere in the content.
968+
# This is a heuristic and may include false positives, but the subsequent AST
969+
# passes are no-ops when nothing actually matches.
970+
defp multi_alias_in_content?(content, old_parts) do
971+
case Enum.split(old_parts, -1) do
972+
{[], _} ->
973+
false
974+
975+
{namespace_parts, [short]} ->
976+
namespace_str = Enum.join(namespace_parts, ".")
977+
978+
String.contains?(content, "#{namespace_str}.{") &&
979+
String.contains?(content, short)
980+
end
981+
end
982+
983+
defp rewrite_affected_files(
984+
igniter,
985+
files,
986+
renames,
987+
old_aliases,
988+
old_short,
989+
new_short,
990+
old_str,
991+
new_str
992+
) do
993+
Enum.reduce(files, igniter, fn path, igniter ->
994+
igniter
995+
|> Igniter.update_elixir_file(path, fn zipper ->
996+
zipper
997+
# Short-form rename must run first: expand_alias reads the original
998+
# alias declarations, which apply_module_renames would overwrite afterward.
999+
|> rename_aliased_short_forms(old_aliases, old_short, new_short)
1000+
|> apply_module_renames(renames)
1001+
|> then(&{:ok, &1})
1002+
end)
1003+
|> replace_module_strings_in_file(path, old_str, new_str)
1004+
end)
1005+
end
1006+
1007+
# Renames short-form call sites (e.g. Bar.foo()) that resolve to old_aliases
1008+
# via an alias declaration. Uses Igniter's expand_alias, which delegates to
1009+
# Macro.Env.expand_alias/3 — the Elixir compiler itself. This correctly
1010+
# handles plain `alias Foo.Bar` and `alias Foo.Bar, as: B` (the last form is
1011+
# deliberately excluded: B.foo() stays B.foo() since the as: clause is
1012+
# preserved by apply_module_renames).
1013+
#
1014+
# For multi-alias (`alias Foo.{Bar, Baz}`): Spitfire does not macro-expand
1015+
# this form, so expand_alias cannot resolve the short name. We fall back to
1016+
# an explicit AST scan via has_multi_alias_for?. The fallback also renames the
1017+
# short name inside the declaration itself ([:Bar] → [:Baz] inside the braces),
1018+
# which is correct because apply_module_renames only matches full alias lists.
1019+
defp rename_aliased_short_forms(zipper, old_aliases, old_short, new_short) do
1020+
namespace_segs = Enum.drop(old_aliases, -1)
1021+
has_multi = has_multi_alias_for?(Zipper.top(zipper), namespace_segs, old_short)
1022+
1023+
case Common.update_all_matches(
1024+
zipper,
1025+
fn
1026+
%Zipper{node: {:__aliases__, _, ^old_short}} = z ->
1027+
case Common.expand_alias(z) |> Zipper.node() do
1028+
{:__aliases__, _, ^old_aliases} ->
1029+
true
1030+
1031+
_ ->
1032+
# Fallback for multi-alias: if the file declares
1033+
# alias Ns.{..., OldShort, ...} we rename by convention.
1034+
has_multi
1035+
end
1036+
1037+
_ ->
1038+
false
1039+
end,
1040+
fn %Zipper{node: {:__aliases__, meta, _}} = z ->
1041+
{:ok, Zipper.replace(z, {:__aliases__, meta, new_short})}
1042+
end
1043+
) do
1044+
{:ok, updated} -> updated
1045+
_ -> zipper
1046+
end
1047+
end
1048+
1049+
# Returns true when the zipper contains a multi-alias declaration of the form
1050+
# alias <namespace_segs>.{..., <old_short>, ...}
1051+
defp has_multi_alias_for?(zipper, namespace_segs, old_short) do
1052+
Zipper.find(zipper, fn
1053+
{:alias, _, [{{:., _, [{:__aliases__, _, ^namespace_segs}, :{}]}, _, short_nodes}]} ->
1054+
Enum.any?(short_nodes, fn
1055+
{:__aliases__, _, ^old_short} -> true
1056+
_ -> false
1057+
end)
1058+
1059+
_ ->
1060+
false
1061+
end) != nil
1062+
end
1063+
1064+
defp apply_module_renames(zipper, renames) do
1065+
Enum.reduce(renames, zipper, fn {old_aliases, new_aliases}, zipper ->
1066+
case Common.update_all_matches(
1067+
zipper,
1068+
fn %Zipper{node: node} -> module_aliases_node_matches?(node, old_aliases) end,
1069+
fn %Zipper{node: {:__aliases__, meta, aliases}} = z ->
1070+
{:ok,
1071+
Zipper.replace(
1072+
z,
1073+
{:__aliases__, meta, replace_module_prefix(aliases, old_aliases, new_aliases)}
1074+
)}
1075+
end
1076+
) do
1077+
{:ok, updated} -> updated
1078+
_ -> zipper
1079+
end
1080+
end)
1081+
end
1082+
1083+
defp replace_module_strings_in_file(igniter, path, old_str, new_str) do
1084+
Igniter.update_file(igniter, path, fn source ->
1085+
Rewrite.Source.update(source, :content, fn content ->
1086+
String.replace(content, old_str, new_str)
1087+
end)
1088+
end)
1089+
end
1090+
1091+
defp move_submodule_files(igniter, old_path, new_path) do
1092+
old_dir = module_file_dir(old_path)
1093+
new_dir = module_file_dir(new_path)
1094+
1095+
igniter.rewrite
1096+
|> Rewrite.sources()
1097+
|> Enum.map(&Rewrite.Source.get(&1, :path))
1098+
|> Enum.filter(&String.starts_with?(&1, old_dir))
1099+
|> Enum.reduce(igniter, fn sub_path, igniter ->
1100+
new_sub_path = new_dir <> String.trim_leading(sub_path, old_dir)
1101+
move_module_file(igniter, sub_path, new_sub_path)
1102+
end)
1103+
end
1104+
1105+
defp module_file_dir(path) do
1106+
path
1107+
|> String.replace_suffix(".exs", "")
1108+
|> String.replace_suffix(".ex", "")
1109+
|> Kernel.<>("/")
1110+
end
1111+
1112+
defp move_module_file(igniter, same, same), do: igniter
1113+
1114+
defp move_module_file(igniter, old_path, new_path) do
1115+
Igniter.move_file(igniter, old_path, new_path)
1116+
end
1117+
1118+
defp module_aliases_node_matches?({:__aliases__, _meta, aliases}, target_aliases) do
1119+
List.starts_with?(aliases, target_aliases)
1120+
end
1121+
1122+
defp module_aliases_node_matches?(_, _), do: false
1123+
1124+
defp replace_module_prefix(aliases, old_prefix, new_prefix) do
1125+
new_prefix ++ Enum.drop(aliases, length(old_prefix))
1126+
end
8631127
end
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# SPDX-FileCopyrightText: 2024 igniter contributors <https://github.com/ash-project/igniter/graphs/contributors>
2+
#
3+
# SPDX-License-Identifier: MIT
4+
5+
defmodule Mix.Tasks.Igniter.Refactor.RenameModule do
6+
use Igniter.Mix.Task
7+
8+
@example "mix igniter.refactor.rename_module Foo.Bar Foo.Baz"
9+
10+
@shortdoc "Rename a module across a project with automatic reference updates."
11+
@moduledoc """
12+
#{@shortdoc}
13+
14+
Rename a given module across a whole project.
15+
16+
Renames the module everywhere it appears: `defmodule`, `alias`, `use`,
17+
`import`, `require`, and all call sites. Also handles submodules, the
18+
corresponding test module, string literals mentioning the module name, and
19+
moves the file(s) to match the new module's proper location.
20+
21+
Keep in mind that it cannot detect 100% of cases, and will always miss
22+
usage of dynamic module references (e.g. via `apply/3`).
23+
24+
## Example
25+
26+
```bash
27+
#{@example}
28+
```
29+
"""
30+
31+
def info(_argv, _composing_task) do
32+
%Igniter.Mix.Task.Info{
33+
group: :igniter,
34+
example: @example,
35+
positional: [:old_module, :new_module]
36+
}
37+
end
38+
39+
def igniter(igniter) do
40+
arguments = igniter.args.positional
41+
42+
old_module = Igniter.Project.Module.parse(arguments[:old_module])
43+
new_module = Igniter.Project.Module.parse(arguments[:new_module])
44+
45+
Igniter.Refactors.Rename.rename_module(igniter, old_module, new_module)
46+
end
47+
end

0 commit comments

Comments
 (0)