defmodule Credence.Pattern.PreferTupleDestructureAfterWithIndex do @moduledoc """ Detects `Enum.map` calls that follow `Enum.with_index()` in a pipe chain but use a multi-argument anonymous function instead of destructuring the `{element, index}` tuple that `with_index/1` produces. ## Bad matrix |> Enum.with_index() |> Enum.map(fn row, index -> {index, Enum.count(row, &(&1 == 1))} end) `Enum.map/2` expects a 1-arity function, but `fn row, index ->` has arity 2. At runtime this raises `BadArityError` on every input because `Enum.with_index/1` emits `{element, index}` tuples — a single argument. ## Good matrix |> Enum.with_index() |> Enum.map(fn {row, index} -> {index, Enum.count(row, &(&1 == 1))} end) The tuple from `with_index/1` is destructured directly in the function head, which is both idiomatic and arity-correct. """ use Credence.Pattern.Rule alias Credence.Issue @impl true def check(ast, _opts) do {_ast, issues} = Macro.prewalk(ast, [], fn node, issues -> case detect_issue(node) do {:ok, issue} -> {node, [issue | issues]} :error -> {node, issues} end end) Enum.reverse(issues) end @impl true def fix_patches(ast, _opts) do {_ast, patches} = Macro.prewalk(ast, [], fn node, patches -> case emit_patch(node) do {:ok, patch} -> {node, [patch | patches]} :error -> {node, patches} end end) Enum.reverse(patches) end # Detect the anti-pattern: `|> Enum.map(fn arg1, arg2 -> body end)` after # `Enum.with_index()` somewhere earlier in the pipe chain. defp detect_issue({:|>, _, [left, map_call]}) do with {:ok, fn_meta} <- match_enum_map_with_multi_arity_fn(map_call), true <- pipe_contains_with_index?(left) do line = Keyword.get(fn_meta, :line) {:ok, %Issue{ rule: :prefer_tuple_destructure_after_with_index, message: build_message(), meta: %{line: line} }} else _ -> :error end end defp detect_issue(_), do: :error # Emit a patch for the anti-pattern: replace `fn var1, var2 ->` with `fn {var1, var2} ->`. defp emit_patch({:|>, _, [left, map_call]}) do with {:ok, _fn_meta} <- match_enum_map_with_multi_arity_fn(map_call), true <- pipe_contains_with_index?(left) do build_destructure_patch(map_call) else _ -> :error end end defp emit_patch(_), do: :error # Match `Enum.map(fn var1, var2 -> body end)` and return the fn's meta. # # Both parameters must be *simple variables* — that is the only shape the fix # (`{var1, var2}`) handles. A variable node is `{name, meta, context}` with # `name` and `context` atoms; a call is `{name, meta, arg_list}` (context is a # list) and a literal pattern (`0`, `"x"`, `{a, b}`, `[a]`, `%{}`) is not a # 3-element tuple at all, so all of those are excluded. This keeps `check` in # lockstep with `fix`: a 2-arity fn whose head pattern-matches anything other # than two bare variables still crashes at runtime, but it is not this rule's # safe core, so we do not flag it. defp match_enum_map_with_multi_arity_fn( {{:., _, [{:__aliases__, _, [:Enum]}, :map]}, _call_meta, [{:fn, fn_meta, [{:->, _, [[{v1, _, c1}, {v2, _, c2}], _body]}]}]} ) when is_atom(v1) and is_atom(c1) and is_atom(v2) and is_atom(c2) do {:ok, fn_meta} end defp match_enum_map_with_multi_arity_fn(_), do: :error # Walk the left side of a pipe to check if `Enum.with_index()` appears. defp pipe_contains_with_index?({:|>, _, [left, right]}) do with_index?(right) or pipe_contains_with_index?(left) end defp pipe_contains_with_index?(node), do: with_index?(node) defp with_index?({{:., _, [{:__aliases__, _, [:Enum]}, :with_index]}, _, args}) when is_list(args), do: true defp with_index?(_), do: false # Build a patch that replaces `var1, var2` with `{var1, var2}` in the fn head. defp build_destructure_patch( {{:., _, [{:__aliases__, _, [:Enum]}, :map]}, _call_meta, [{:fn, _fn_meta, [{:->, _arrow_meta, [args, _body]}]}]} ) do [{var1, var1_meta, _}, {var2, var2_meta, _}] = args var1_name = Atom.to_string(var1) var2_name = Atom.to_string(var2) var1_line = Keyword.get(var1_meta, :line) var1_col = Keyword.get(var1_meta, :column) var2_line = Keyword.get(var2_meta, :line) var2_col = Keyword.get(var2_meta, :column) # Range: from start of first var to end of last var range = %Sourceror.Range{ start: [line: var1_line, column: var1_col], end: [line: var2_line, column: var2_col + String.length(var2_name)] } change = "{#{var1_name}, #{var2_name}}" {:ok, %{range: range, change: change}} end defp build_message do """ `Enum.map` called with a multi-argument function after `Enum.with_index()`. `with_index/1` emits `{element, index}` tuples, so `Enum.map` receives a single argument. Destructure the tuple in the function head: fn {elem, idx} -> ... end """ end end