lib/graft/link/rewriter.ex

defmodule Graft.Link.Rewriter do
  @moduledoc """
  Pure rewrite engine for `mix.exs` dep tuples.

  Locates the dep tuple matching `app` inside the `def deps` / `defp deps`
  function body and replaces its **source portion** (version string and
  source-related keyword opts like `:path`, `:git`, `:branch`, etc.) with
  a new keyword list. Non-source opts (`:only`, `:optional`, `:runtime`,
  `:targets`, …) are preserved.

  ## Guarantees

    * No filesystem access, no shell-outs, no network calls.
    * No code is evaluated. Sourceror parsing only.
    * Rewriting the same input twice with the same replacement yields
      byte-identical output (deterministic).
    * Only the matching dep tuple is touched. Unrelated deps and
      unrelated code (other functions, attributes, comments) are
      preserved.

  ## Result shape

  Returns `{:ok, %Graft.Link.RewriteResult{}}` on every successful
  parse — including the case where the target dep is absent. In that
  case `matched_dep?: false` and `changed?: false`. Errors are returned
  as `{:error, %Graft.Error{}}` for parse failures, invalid
  replacement opts, or unrecognised dep shapes.
  """

  alias Graft.Error
  alias Graft.Link.RewriteResult

  # Source-related keys in a dep's opts list. These are dropped when we
  # rewrite a dep's source; everything else (only, optional, runtime,
  # targets, …) is preserved verbatim.
  @source_keys [:path, :git, :github, :branch, :tag, :ref, :submodules, :sparse]

  # Replacement opts must contain at least one of these to identify the
  # new source.
  @source_identifier_keys [:path, :git, :github]

  @type replacement_opt :: {atom(), term()}

  @doc """
  Rewrite `contents` so the dep `app` uses `replacement_opts` as its
  source. See module doc for semantics.
  """
  @spec rewrite(String.t(), atom(), [replacement_opt()]) ::
          {:ok, RewriteResult.t()} | {:error, Error.t()}
  def rewrite(contents, app, replacement_opts)
      when is_binary(contents) and is_atom(app) and is_list(replacement_opts) do
    with :ok <- validate_replacement(replacement_opts),
         {:ok, ast} <- safe_parse(contents) do
      case rewrite_ast(ast, app, replacement_opts) do
        {:no_match, _ast} ->
          {:ok,
           %RewriteResult{
             original_contents: contents,
             rewritten_contents: contents,
             changed?: false,
             matched_dep?: false
           }}

        {:match, new_ast, original_dep, rewritten_dep} ->
          rewritten_contents = render(new_ast, contents)

          {:ok,
           %RewriteResult{
             original_contents: contents,
             rewritten_contents: rewritten_contents,
             changed?: contents != rewritten_contents,
             matched_dep?: true,
             original_dep_ast: original_dep,
             rewritten_dep_ast: rewritten_dep
           }}

        {:error, %Error{}} = err ->
          err
      end
    end
  end

  ## ─── Validation ─────────────────────────────────────────────────────

  defp validate_replacement([]) do
    {:error, Error.new(:rewriter_invalid_replacement, "Replacement opts must not be empty")}
  end

  defp validate_replacement(opts) do
    unless Keyword.keyword?(opts) do
      {:error,
       Error.new(:rewriter_invalid_replacement, "Replacement opts must be a keyword list")}
    else
      if Enum.any?(opts, fn {k, _} -> k in @source_identifier_keys end) do
        :ok
      else
        {:error,
         Error.new(
           :rewriter_invalid_replacement,
           "Replacement opts must include one of #{inspect(@source_identifier_keys)}",
           %{got: Keyword.keys(opts)}
         )}
      end
    end
  end

  ## ─── Parse ──────────────────────────────────────────────────────────

  defp safe_parse(contents) do
    {:ok, Sourceror.parse_string!(contents)}
  rescue
    e ->
      {:error,
       Error.new(
         :rewriter_malformed_source,
         "Failed to parse mix.exs: #{Exception.message(e)}"
       )}
  end

  ## ─── Locate + rewrite the deps function ─────────────────────────────

  # Walk the entire AST looking for the `deps` function. When found,
  # rewrite its body and substitute back. The walk continues, but the
  # first match's accumulator is preserved (we don't expect more than
  # one `deps` function).
  defp rewrite_ast(ast, app, replacement_opts) do
    {new_ast, acc} =
      Macro.prewalk(ast, :no_match, fn
        {def_kind, m, [{:deps, dm, ctx}, [{do_kw, body}]]} = node, :no_match
        when def_kind in [:def, :defp] and (is_atom(ctx) or ctx == []) ->
          case rewrite_body(body, app, replacement_opts) do
            {:no_match, new_body} ->
              {{def_kind, m, [{:deps, dm, ctx}, [{do_kw, new_body}]]}, :no_match}

            {:match, new_body, orig, rew} ->
              {{def_kind, m, [{:deps, dm, ctx}, [{do_kw, new_body}]]}, {:match, orig, rew}}

            {:error, _} = err ->
              {node, err}
          end

        node, acc ->
          {node, acc}
      end)

    case acc do
      :no_match -> {:no_match, new_ast}
      {:match, orig, rew} -> {:match, new_ast, orig, rew}
      {:error, _} = err -> err
    end
  end

  ## ─── Rewrite within the deps function body ──────────────────────────

  defp rewrite_body({:__block__, m, [list]}, app, opts) when is_list(list) do
    case rewrite_list(list, app, opts) do
      {:no_match, new_list} -> {:no_match, {:__block__, m, [new_list]}}
      {:match, new_list, o, r} -> {:match, {:__block__, m, [new_list]}, o, r}
      {:error, _} = err -> err
    end
  end

  defp rewrite_body(list, app, opts) when is_list(list) do
    rewrite_list(list, app, opts)
  end

  defp rewrite_body(other, _app, _opts) do
    {:no_match, other}
  end

  defp rewrite_list(list, app, opts) do
    Enum.reduce_while(list, {[], :no_match}, fn elem, {acc, status} ->
      case rewrite_list_element(elem, app, opts, status) do
        {:error, _} = err -> {:halt, err}
        {new_elem, new_status} -> {:cont, {[new_elem | acc], new_status}}
      end
    end)
    |> case do
      {:error, _} = err -> err
      {rev_list, :no_match} -> {:no_match, Enum.reverse(rev_list)}
      {rev_list, {:match, o, r}} -> {:match, Enum.reverse(rev_list), o, r}
    end
  end

  defp rewrite_list_element({:__block__, m, [tuple]}, app, opts, status) when is_tuple(tuple) do
    case rewrite_dep_tuple(tuple, app, opts) do
      {:no_match, _} ->
        {{:__block__, m, [tuple]}, status}

      {:match, new_tuple, orig, rew} ->
        new_status = if status == :no_match, do: {:match, orig, rew}, else: status
        {{:__block__, m, [new_tuple]}, new_status}

      {:error, _} = err ->
        err
    end
  end

  defp rewrite_list_element(tuple, app, opts, status) when is_tuple(tuple) do
    case rewrite_dep_tuple(tuple, app, opts) do
      {:no_match, _} ->
        {tuple, status}

      {:match, new_tuple, orig, rew} ->
        new_status = if status == :no_match, do: {:match, orig, rew}, else: status
        {new_tuple, new_status}

      {:error, _} = err ->
        err
    end
  end

  defp rewrite_list_element(other, _app, _opts, status) do
    {other, status}
  end

  ## ─── Per-tuple rewrite ──────────────────────────────────────────────

  defp rewrite_dep_tuple({name_node, value_node} = original, app, replacement_opts) do
    if unwrap(name_node) == app do
      case classify_value(value_node) do
        :version_string ->
          new_value = build_value_ast(replacement_opts, [])
          new_tuple = {name_node, new_value}
          {:match, new_tuple, original, new_tuple}

        :kw_list ->
          kept = collect_non_source(value_node)
          new_value = build_value_ast(replacement_opts, kept)
          new_tuple = {name_node, new_value}
          {:match, new_tuple, original, new_tuple}

        :unknown ->
          {:error,
           Error.new(
             :rewriter_unknown_dep_shape,
             "Cannot rewrite dep #{inspect(app)}: unrecognised value shape",
             %{app: app}
           )}
      end
    else
      {:no_match, original}
    end
  end

  defp rewrite_dep_tuple(
         {:{}, _m, [name_node, version_node, opts_node]} = original,
         app,
         replacement_opts
       ) do
    if unwrap(name_node) == app do
      case classify_value(version_node) do
        :version_string ->
          kept_from_opts = collect_non_source(opts_node)
          new_value = build_value_ast(replacement_opts, kept_from_opts)
          new_tuple = {name_node, new_value}
          {:match, new_tuple, original, new_tuple}

        _ ->
          {:error,
           Error.new(
             :rewriter_unknown_dep_shape,
             "Cannot rewrite dep #{inspect(app)}: 3-tuple middle slot is not a version string",
             %{app: app}
           )}
      end
    else
      {:no_match, original}
    end
  end

  defp rewrite_dep_tuple(other, _app, _opts), do: {:no_match, other}

  ## ─── Value classification ───────────────────────────────────────────

  defp classify_value(node) do
    case unwrap(node) do
      s when is_binary(s) -> :version_string
      kw when is_list(kw) -> if Keyword.keyword?(unwrap_kw_keys(kw)), do: :kw_list, else: :unknown
      _ -> :unknown
    end
  end

  # Sourceror keyword lists have wrapped keys; checking Keyword.keyword?
  # on the raw kw produces false. Build a shadow list with unwrapped keys
  # for the check.
  defp unwrap_kw_keys(kw) do
    Enum.map(kw, fn
      {k, v} -> {unwrap(k), v}
      other -> other
    end)
  end

  ## ─── Non-source opts retention ──────────────────────────────────────

  defp collect_non_source(node) do
    case unwrap(node) do
      kw when is_list(kw) ->
        Enum.reject(kw, fn
          {k, _v} -> unwrap(k) in @source_keys
          _ -> true
        end)

      _ ->
        []
    end
  end

  ## ─── New value AST construction ─────────────────────────────────────

  # The new value is always a keyword list. We splice the replacement
  # opts (with explicit keyword formatting) before any preserved
  # non-source pairs so the new source identifier (path/git/github)
  # appears first — matching how a developer would naturally write it.
  defp build_value_ast(replacement_opts, kept_pairs) do
    new_pairs = Enum.map(replacement_opts, &build_kw_pair/1)
    new_pairs ++ kept_pairs
  end

  defp build_kw_pair({key, value}) when is_atom(key) do
    {{:__block__, [format: :keyword], [key]}, value_ast(value)}
  end

  defp value_ast(s) when is_binary(s),
    do: {:__block__, [delimiter: ~s(")], [s]}

  defp value_ast(b) when is_boolean(b), do: {:__block__, [], [b]}
  defp value_ast(nil), do: {:__block__, [], [nil]}
  defp value_ast(n) when is_integer(n), do: {:__block__, [token: Integer.to_string(n)], [n]}
  defp value_ast(a) when is_atom(a), do: {:__block__, [], [a]}
  defp value_ast(other), do: other

  ## ─── Render ─────────────────────────────────────────────────────────

  # Sourceror.to_string may not append a trailing newline even if the
  # original had one. Match the original's terminal newline state to
  # keep diffs minimal.
  defp render(ast, original) do
    rendered = Sourceror.to_string(ast)

    cond do
      String.ends_with?(original, "\n") and not String.ends_with?(rendered, "\n") ->
        rendered <> "\n"

      not String.ends_with?(original, "\n") and String.ends_with?(rendered, "\n") ->
        String.trim_trailing(rendered, "\n")

      true ->
        rendered
    end
  end

  ## ─── Helpers ────────────────────────────────────────────────────────

  defp unwrap({:__block__, _, [v]}), do: v
  defp unwrap(other), do: other
end