lib/mix/tasks/recode.rename.ex

defmodule Mix.Tasks.Recode.Rename do
  @shortdoc "Renames the given function"

  @moduledoc """
  A mix task to rename functions.

  ```shell
  mix recode.rename Module.name[/arity] new_name
  ```
  This mix task overwrite the definition and all calls of the given function
  with the `new_name`. If the `arity` is given to `Module.name` only functions
  with the corresponding `arity` will be rewritten.

  ## Examples
  ```shell
  mix recode.rename MyApp.hello say_hello
  ```

  ```shell
  mix recode.rename MyApp.SomeMoudle.do_it/2 make
  ```

  ## Switchtes

    * `--config` - specifies an alternative config file.

    * `--dry`, `--no-dry` - Activates/deactivates the dry mode. No file is
      overwritten in dry mode. Overwrites the corresponding value in the
      configuration.

    * `--verbose`, `--no-verbose` - Activate/deactivates the verbose mode.
      Overwrites the corresponding value in the configuration.
  """

  use Mix.Task

  alias Recode.Config
  alias Recode.Runner
  alias Recode.Task.Rename

  @opts strict: [config: :string, dry: :boolean, verbose: :boolean]

  @impl Mix.Task
  def run(opts) do
    {opts, args} = OptionParser.parse!(opts, @opts)
    args = args!(args)

    config =
      opts
      |> config!()
      |> Keyword.merge(opts)
      |> update(:verbose)

    :ok = prepare(config)

    Runner.run({Rename, args}, config)
  end

  defp config!(opts) do
    case Config.read(opts) do
      {:ok, config} -> config
      {:error, :not_found} -> Mix.raise("Config file not found")
    end
  end

  defp prepare(opts) do
    Mix.Task.run("compile")

    inputs = opts[:inputs]

    exclude_compilation =
      case Keyword.fetch(opts, :exclude_compilation) do
        {:ok, wildcard} ->
          wildcard
          |> List.wrap()
          |> Enum.flat_map(fn wildcard -> Path.wildcard(wildcard) end)

        :error ->
          []
      end

    ExUnit.start(autorun: false)

    Code.put_compiler_option(:ignore_module_conflict, true)

    config = Mix.Project.config()
    elixirc_paths = Keyword.fetch!(config, :elixirc_paths)
    compile_path = Mix.Project.compile_path()

    {:ok, _modules, _warnings} =
      inputs
      |> List.wrap()
      |> Enum.flat_map(&Path.wildcard/1)
      |> Enum.reject(fn input ->
        String.ends_with?(input, ".exs") or
          Enum.any?(elixirc_paths, fn path -> String.starts_with?(input, path) end) or
          input in exclude_compilation
      end)
      |> Kernel.ParallelCompiler.compile_to_path(compile_path)

    :ok
  end

  defp args!([from, to]) do
    case to_mfa(from) do
      {:ok, from_mfa} ->
        to = %{fun: String.to_atom(to)}
        [from: from_mfa, to: to]

      :error ->
        Mix.raise("Can not parse arguments")
    end
  end

  defp args!(_invalid) do
    Mix.raise("""
    Expected module.fun and the new function name.
    Examples:
    mix recode.rename MyApp.Some.make_it do_it\
    """)
  end

  defp to_mfa(string) when is_binary(string) do
    ~r{[.|/]}
    |> Regex.split(string)
    |> Enum.group_by(fn part ->
      cond do
        part =~ ~r/^[A-Z]/ -> :module
        part =~ ~r/^[a-z]/ -> :fun
        part =~ ~r/^[0-9]/ -> :arity
        true -> :error
      end
    end)
    |> to_mfa()
  end

  defp to_mfa(%{error: _error}), do: :error

  defp to_mfa(%{} = parts) do
    with {:ok, fun} <- to_fun(parts),
         {:ok, arity} <- to_arity(parts),
         {:ok, module} <- to_module(parts) do
      {:ok, {module, fun, arity}}
    end
  end

  defp to_module(%{module: module}), do: {:ok, Module.concat(module)}

  defp to_module(_parts), do: :error

  defp to_fun(%{fun: [fun]}), do: {:ok, String.to_atom(fun)}

  defp to_fun(_parts), do: :error

  defp to_arity(%{arity: [arity]}), do: {:ok, String.to_integer(arity)}

  defp to_arity(_parts), do: {:ok, nil}

  defp update(opts, :verbose) do
    case opts[:dry] do
      true -> Keyword.put(opts, :verbose, true)
      false -> opts
    end
  end
end