lib/bylaw/credo/check/elixir/prefer_enum_uniq_by.ex

defmodule Bylaw.Credo.Check.Elixir.PreferEnumUniqBy do
  @moduledoc """
  Prefer `Enum.uniq_by/2` before projecting fields with `Enum.map/2`.

  ## Examples

  Avoid:

        items
        |> Enum.map(& &1.step)
        |> Enum.uniq()
  Prefer:

        items
        |> Enum.uniq_by(& &1.step)
        |> Enum.map(& &1.step)

  This keeps the uniqueness rule attached to the original items instead of
  first projecting values and then deduplicating the projected list.

  ## Notes

  This check uses static AST analysis, so it favors clear source-level patterns over runtime behavior.

  ## Options

  This check has no check-specific options. Configure it with an empty option list.

  ## Usage

  Add this check to Credo's `checks:` list in `.credo.exs`:

  ```elixir
  %{
    configs: [
      %{
        name: "default",
        checks: [
          {Bylaw.Credo.Check.Elixir.PreferEnumUniqBy, []}
        ]
      }
    ]
  }
  ```
  """

  use Credo.Check,
    base_priority: :high,
    category: :readability,
    explanations: [
      check: @moduledoc
    ]

  @doc false
  @impl Credo.Check
  def run(source_file, params \\ []) do
    ctx = Context.build(source_file, params, __MODULE__)
    Credo.Code.prewalk(source_file, &walk/2, ctx).issues
  end

  defp walk(
         {:|>, _pipe_meta, [map_expression, {{:., meta, [enum_module, :uniq]}, _call_meta, []}]} =
           ast,
         ctx
       ) do
    if enum_module?(enum_module) do
      case projection_callback(map_expression) do
        {:ok, callback} -> {ast, put_issue(ctx, issue_for(ctx, meta, callback))}
        :error -> {ast, ctx}
      end
    else
      {ast, ctx}
    end
  end

  defp walk(
         {{:., meta, [enum_module, :uniq]}, _call_meta, [map_expression]} = ast,
         ctx
       ) do
    if enum_module?(enum_module) do
      case projection_callback(map_expression) do
        {:ok, callback} -> {ast, put_issue(ctx, issue_for(ctx, meta, callback))}
        :error -> {ast, ctx}
      end
    else
      {ast, ctx}
    end
  end

  defp walk(ast, ctx), do: {ast, ctx}

  defp projection_callback({:|>, _pipe_meta, [_enumerable, map_stage]}),
    do: projection_callback_from_map_stage(map_stage)

  defp projection_callback(
         {{:., _dot_meta, [enum_module, :map]}, _call_meta, [_enumerable, callback]}
       )
       when is_tuple(enum_module),
       do: projection_callback_from_callback(callback)

  defp projection_callback(_other), do: :error

  defp projection_callback_from_map_stage(
         {{:., _dot_meta, [enum_module, :map]}, _call_meta, [callback]}
       ) do
    if enum_module?(enum_module) do
      projection_callback_from_callback(callback)
    else
      :error
    end
  end

  defp projection_callback_from_map_stage(_other), do: :error

  defp projection_callback_from_callback(callback) do
    if field_projection?(callback) do
      {:ok, callback}
    else
      :error
    end
  end

  defp field_projection?({:&, _meta, [body]}), do: capture_field_chain?(body)

  defp field_projection?({:fn, _meta, [{:->, _arrow_meta, [[param], body]}]}) do
    case extract_var_name(param) do
      nil -> false
      var_name -> variable_field_chain?(body, var_name)
    end
  end

  defp field_projection?(_other), do: false

  defp capture_field_chain?({{:., _dot_meta, [base, _field]}, _call_meta, []}),
    do: capture_root?(base)

  defp capture_field_chain?(_other), do: false

  defp capture_root?({:&, _meta, [1]}), do: true
  defp capture_root?(field_access), do: capture_field_chain?(field_access)

  defp variable_field_chain?({{:., _dot_meta, [base, _field]}, _call_meta, []}, var_name),
    do: variable_root?(base, var_name)

  defp variable_field_chain?(_other, _var_name), do: false

  defp variable_root?({var_name, _meta, context}, var_name) when is_atom(context), do: true
  defp variable_root?(field_access, var_name), do: variable_field_chain?(field_access, var_name)

  defp extract_var_name({var_name, _meta, context})
       when is_atom(var_name) and is_atom(context),
       do: var_name

  defp extract_var_name(_other), do: nil

  defp enum_module?({:__aliases__, _meta, [:Enum]}), do: true
  defp enum_module?(_other), do: false

  defp issue_for(ctx, meta, callback) do
    callback_string = Macro.to_string(callback)

    format_issue(
      ctx,
      message:
        "Prefer `Enum.uniq_by/2` before projecting fields. Rewrite as `Enum.uniq_by(#{callback_string}) |> Enum.map(#{callback_string})`.",
      trigger: "Enum.uniq",
      line_no: meta[:line]
    )
  end
end