diff --git a/lib/ex_factor.ex b/lib/ex_factor.ex index be5144f..269ca4a 100644 --- a/lib/ex_factor.ex +++ b/lib/ex_factor.ex @@ -9,6 +9,7 @@ defmodule ExFactor do new module's name. """ + alias ExFactor.Changer alias ExFactor.Extractor alias ExFactor.Remover @@ -26,6 +27,8 @@ defmodule ExFactor do emplace = Extractor.emplace(opts) remove = Remover.remove(opts) + _changes = Changer.change(opts) + |> IO.inspect(label: "") {emplace, remove} end diff --git a/lib/ex_factor/changer.ex b/lib/ex_factor/changer.ex new file mode 100644 index 0000000..b4bdf01 --- /dev/null +++ b/lib/ex_factor/changer.ex @@ -0,0 +1,139 @@ +defmodule ExFactor.Changer do + @moduledoc """ + Documentation for `ExFactor.Changer`. + """ + + alias ExFactor.Callers + alias ExFactor.Util + + @doc """ + Given all the Callers to a module, find the instances of the target function and refactor the + function module reference to the new module. Respect any existing aliases. + """ + def change(opts) do + source_module = Keyword.fetch!(opts, :source_module) + Mix.Tasks.Compile.Elixir.run([]) + callers = Callers.callers(source_module) + + Enum.map(callers, fn caller -> + File.read!(caller.filepath) + |> String.split("\n") + |> find_and_replace_target(opts, caller.filepath) + end) + end + + defp find_and_replace_target(list, opts, filepath) do + # opts values + source_module = Keyword.fetch!(opts, :source_module) + source_function = Keyword.fetch!(opts, :source_function) + target_module = Keyword.fetch!(opts, :target_module) + _arity = Keyword.fetch!(opts, :arity) + dry_run = Keyword.get(opts, :dry_run, false) + + # modified values + source_string = Util.module_to_string(source_module) + source_modules = Module.split(source_module) + source_alias = Enum.at(source_modules, -1) + target_alias = preferred_alias(list, target_module) + source_alias_alt = find_alias_as(list, source_module) + + Enum.reduce(list, {:unchanged, []}, fn elem, {state, acc} -> + cond do + String.match?(elem, ~r/#{source_string}\.#{source_function}/) -> + elem = String.replace(elem, source_module, target_module) + {:changed, [elem | acc]} + String.match?(elem, ~r/#{source_alias}\.#{source_function}/) -> + elem = String.replace(elem, source_alias, target_alias) + {:changed, [elem | acc]} + String.match?(elem, ~r/#{source_alias_alt}\.#{source_function}/) -> + elem = String.replace(elem, source_alias_alt, target_alias) + {:changed, [elem | acc]} + true -> {state, [elem | acc]} + end + end) + |> maybe_add_alias(opts) + |> write_file(source_function, filepath, dry_run) + end + + defp find_alias_as(list, module) do + aalias = Enum.find(list, "", fn el -> el =~ "alias #{Util.module_to_string(module)}" end) + if String.match?(aalias, ~r/, as: /) do + aalias + |> String.split("as:", trim: true) + |> Enum.at(-1) + else + "" + end + end + + defp preferred_alias(list, target_module) do + target_modules = Module.split(target_module) + target_alias = Enum.at(target_modules, -1) + target_alias_alt = find_alias_as(list, target_module) + + if target_alias_alt == "" do + target_alias + else + target_alias_alt + end + end + + defp write_file({:unchanged, contents_list}, source_function, target_path, true) do + %{ + path: target_path, + state: [:unchanged], + message: "#{source_function} not found, no changes to make", + file_contents: list_to_string(contents_list) + } + end + + defp write_file({state, contents_list}, _, target_path, true) do + %{ + path: target_path, + state: [:dry_run, state], + message: "--dry_run changes to make", + file_contents: list_to_string(contents_list) + } + end + + defp write_file({state, contents_list}, _, target_path, _dry_run) do + contents = list_to_string(contents_list) + File.write(target_path, contents, [:write]) + %{ + path: target_path, + state: [state], + message: "changes made", + file_contents: contents + } + end + + defp list_to_string(contents_list) do + contents_list + |> Enum.reverse() + |> Enum.join("\n") + end + + defp maybe_add_alias({:unchanged, contents_list}, _), do: {:unchanged, contents_list} + defp maybe_add_alias({state, contents_list}, opts) do + source_module = Keyword.fetch!(opts, :source_module) + source_string = Util.module_to_string(source_module) + target_module = Keyword.fetch!(opts, :target_module) + target_string = Util.module_to_string(target_module) + + if Enum.find(contents_list, fn el -> el =~ "alias #{target_string}" end) do + {state, contents_list} + else + contents_list + |> Enum.reduce([], fn elem, acc -> + if (elem =~ "alias #{source_string}") do + new_alias = String.replace(elem, source_string, target_string) + [new_alias | [elem | acc]] + else + [elem | acc] + end + end) + |> Enum.reverse() + |> then(fn list -> {state, list} end) + end + end +end diff --git a/lib/ex_factor/util.ex b/lib/ex_factor/util.ex new file mode 100644 index 0000000..aca3aff --- /dev/null +++ b/lib/ex_factor/util.ex @@ -0,0 +1,9 @@ +defmodule ExFactor.Util do + @moduledoc false + + def module_to_string(module) do + module + |> Module.split() + |> Enum.join(".") + end +end diff --git a/test/ex_factor/changer_test.exs b/test/ex_factor/changer_test.exs new file mode 100644 index 0000000..4a3da22 --- /dev/null +++ b/test/ex_factor/changer_test.exs @@ -0,0 +1,234 @@ +defmodule ExFactor.ChangerTest do + use ExUnit.Case + alias ExFactor.Changer + + setup_all do + File.mkdir_p("lib/ex_factor/tmp") + + on_exit(fn -> + File.rm_rf("lib/ex_factor/tmp") + end) + end + + describe "change/1" do + test "it finds all the callers of a module, function, and arity, and updates the calls to the new module " do + content = """ + defmodule ExFactor.Tmp.SourceMod do + @moduledoc "This is moduedoc" + @doc "this is some documentation for refactor1/1" + def refactor1([]) do + :empty + end + def refactor1(arg1) do + {:ok, arg1} + end + end + """ + + File.write("lib/ex_factor/tmp/source_module.ex", content) + + content = """ + defmodule ExFactor.Tmp.CallerModule do + alias ExFactor.Tmp.SourceMod + def pub1(arg_a) do + SourceMod.refactor1(arg_a) + end + end + """ + + File.write("lib/ex_factor/tmp/caller_module.ex", content) + + opts = [ + target_module: ExFactor.Tmp.TargetModule, + source_module: ExFactor.Tmp.SourceMod, + source_function: :refactor1, + arity: 1 + ] + + Changer.change(opts) + + caller = File.read!("lib/ex_factor/tmp/caller_module.ex") + assert caller =~ "alias ExFactor.Tmp.TargetModule" + assert caller =~ "TargetModule.refactor1(arg_a)" + end + + test "only add alias entry if it's missing" do + content = """ + defmodule ExFactor.Tmp.SourceMod do + @moduledoc "This is moduedoc" + @doc "this is some documentation for refactor1/1" + def refactor1([]) do + :empty + end + def refactor1(arg1) do + {:ok, arg1} + end + end + """ + + File.write("lib/ex_factor/tmp/source_module.ex", content) + + content = """ + defmodule ExFactor.Tmp.CallerModule do + alias ExFactor.Tmp.SourceMod + alias ExFactor.Tmp.TargetModule + def pub1(arg_a) do + SourceMod.refactor1(arg_a) + end + def alias2, do: TargetModule + end + """ + + File.write("lib/ex_factor/tmp/caller_module.ex", content) + + opts = [ + target_module: ExFactor.Tmp.TargetModule, + source_module: ExFactor.Tmp.SourceMod, + source_function: :refactor1, + arity: 1 + ] + + Changer.change(opts) + + caller = File.read!("lib/ex_factor/tmp/caller_module.ex") + + caller_list = String.split(caller, "\n") + assert caller =~ "alias ExFactor.Tmp.TargetModule" + assert caller =~ "TargetModule.refactor1(arg_a)" + assert 1 == Enum.count(caller_list, fn el -> + el =~ "alias ExFactor.Tmp.TargetModule" + end) + end + + test "handle alias exists with :as" do + content = """ + defmodule ExFactor.Tmp.SourceMod do + @moduledoc "This is moduedoc" + @doc "this is some documentation for refactor1/1" + def refactor1([]) do + :empty + end + def refactor1(arg1) do + {:ok, arg1} + end + end + """ + + File.write("lib/ex_factor/tmp/source_module.ex", content) + + content = """ + defmodule ExFactor.Tmp.CallerModule do + alias ExFactor.Tmp.SourceMod + alias ExFactor.Tmp.TargetModule, as: TM + def pub1(arg_a) do + SourceMod.refactor1(arg_a) + end + def alias2, do: TM + end + """ + + File.write("lib/ex_factor/tmp/caller_module.ex", content) + + opts = [ + target_module: ExFactor.Tmp.TargetModule, + source_module: ExFactor.Tmp.SourceMod, + source_function: :refactor1, + arity: 1 + ] + + Changer.change(opts) + + caller = File.read!("lib/ex_factor/tmp/caller_module.ex") + + caller_list = String.split(caller, "\n") + assert caller =~ "alias ExFactor.Tmp.TargetModule" + assert caller =~ "TM.refactor1(arg_a)" + assert 1 == Enum.count(caller_list, fn el -> + el =~ "alias ExFactor.Tmp.TargetModule" + end) + end + + test "it finds all the callers of a module by an alias, function, and arity, and updates the calls to the new module " do + content = """ + defmodule ExFactor.Tmp.SourceMod do + def refactor1(_arg1, _opt2 \\\\ []) do + :ok + end + end + """ + + File.write("lib/ex_factor/tmp/source_module.ex", content) + + content = """ + defmodule ExFactor.Tmp.CallerModule do + alias ExFactor.Tmp.SourceMod, as: SM + def pub1(arg_a) do + SM.refactor1(arg_a) + end + end + """ + + File.write("lib/ex_factor/tmp/caller_module.ex", content) + + opts = [ + target_module: ExFactor.Tmp.TargetModule, + source_module: ExFactor.Tmp.SourceMod, + source_function: :refactor1, + arity: 1 + ] + + Changer.change(opts) + + caller = File.read!("lib/ex_factor/tmp/caller_module.ex") + assert caller =~ "alias ExFactor.Tmp.TargetModule" + assert caller =~ "TargetModule.refactor1(arg_a)" + end + + test "matches the arity" do + end + + test "changes multiple functions" do + end + + test "takes a dry_run argument and doesn't update the files" do + content = """ + defmodule ExFactor.Tmp.SourceMod do + def refactor1(_arg1, _opt2 \\\\ []) do + :ok + end + end + """ + + File.write("lib/ex_factor/tmp/source_module.ex", content) + + content = """ + defmodule ExFactor.Tmp.CallerModule do + alias ExFactor.Tmp.SourceMod, as: SM + def pub1(arg_a) do + SM.refactor1(arg_a) + end + end + """ + + File.write("lib/ex_factor/tmp/caller_module.ex", content) + + opts = [ + target_module: ExFactor.Tmp.TargetModule, + source_module: ExFactor.Tmp.SourceMod, + source_function: :refactor1, + dry_run: true, + arity: 1 + ] + + [change_map] = Changer.change(opts) + # |> IO.inspect(label: "") + + caller = File.read!("lib/ex_factor/tmp/caller_module.ex") + + refute caller =~ "alias ExFactor.Tmp.TargetModule" + refute caller =~ "TargetModule.refactor1(arg_a)" + assert change_map.state == [:dry_run, :changed] + assert change_map.message == "--dry_run changes to make" + end + end +end diff --git a/test/ex_factor/util_test.exs b/test/ex_factor/util_test.exs new file mode 100644 index 0000000..caebdf9 --- /dev/null +++ b/test/ex_factor/util_test.exs @@ -0,0 +1,19 @@ +defmodule ExFactor.UtilTest do + use ExUnit.Case + alias ExFactor.Util + + setup_all do + File.mkdir_p("test/tmp") + + on_exit(fn -> + File.rm_rf("test/tmp") + end) + end + + describe "module_to_string/1" do + test "given a module name, convert it to a string, ensure the Elixir. prefix is not included" do + refute Util.module_to_string(MyMod.SubMod.SubSubMod) =~ "Elixir." + assert Util.module_to_string(MyMod.SubMod.SubSubMod) == "MyMod.SubMod.SubSubMod" + end + end +end