@@ -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
8631127end
0 commit comments