Current section

Files

Jump to
credence lib pattern prefer_then_over_capture_invocation.ex
Raw

lib/pattern/prefer_then_over_capture_invocation.ex

defmodule Credence.Pattern.PreferThenOverCaptureInvocation do
@moduledoc """
Detects immediately-invoked captures `(&body).()` in pipelines and
rewrites them to use `Kernel.then/2` (Elixir 1.12+), which is the
idiomatic replacement.
LLMs often produce `|> (&(&1 == String.reverse(&1))).()` — a capture
expression applied with `.()` at the end of a pipe. This is
non-idiomatic; `Kernel.then/2` was added in Elixir 1.12 specifically
for this purpose.
Only captures that use exactly `&1` (arity 1) are rewritten. Captures
that use `&2` or higher (arity > 1) or that don't use `&1` at all
(arity 0) are left alone because `then/2` expects an arity-1 function
and would raise a different error than the original `.()` call.
## Bad
number
|> Integer.to_string()
|> (&(&1 == String.reverse(&1))).()
## Good
number
|> Integer.to_string()
|> then(&(&1 == String.reverse(&1)))
"""
use Credence.Pattern.Rule
alias Credence.Issue
@impl true
def check(ast, _opts) do
{_ast, issues} =
Macro.prewalk(ast, [], fn
{:|>, meta, [_left, {{:., _, [{:&, _, [body]}]}, _, []}]} = node, issues ->
if arity_one_capture?(body) do
issue = %Issue{
rule: :prefer_then_over_capture_invocation,
message:
"Avoid applying captures with `.()` in a pipeline. " <>
"Use `then/2` instead: `|> then(&body)`.",
meta: %{line: Keyword.get(meta, :line)}
}
{node, [issue | issues]}
else
{node, issues}
end
node, issues ->
{node, issues}
end)
Enum.reverse(issues)
end
@impl true
def fix_patches(ast, _opts) do
# `... |> (&body).()` → `... |> then(&body)`.
#
# We patch the `.()`-application node on the pipe's right in
# place. Sourceror's range for the application starts at the `&`,
# EXCLUDING the wrapping `(` that `.()` requires, so we extend
# the range one column to the left to swallow that `(`.
{_ast, patches} =
Macro.prewalk(ast, [], fn
{:|>, _, [_left, {{:., _, [{:&, _, [body]} = capture_node]}, _, []} = dotcall]} = node,
acc ->
if arity_one_capture?(body) do
case Sourceror.get_range(dotcall) do
nil ->
{node, acc}
%Sourceror.Range{start: start, end: stop} ->
start_at_paren = Keyword.update!(start, :column, &(&1 - 1))
range = %Sourceror.Range{start: start_at_paren, end: stop}
change =
Credence.RuleHelpers.render_replacement({:then, [], [capture_node]}, range)
{node, [%{range: range, change: change} | acc]}
end
else
{node, acc}
end
node, acc ->
{node, acc}
end)
Enum.reverse(patches)
end
# Returns true if the capture body uses &1 (positional placeholder for the
# first argument) and does NOT use &2 or higher. This ensures the capture is
# exactly arity 1 — the only safe case for rewriting to then/2, which expects
# an arity-1 function.
defp arity_one_capture?(body) do
{_found, {has_one, has_higher}} =
Macro.prewalk(body, {false, false}, fn
{:&, _, [n]}, {_has_one, has_higher} when is_integer(n) and n >= 1 ->
{nil, {true, has_higher or n > 1}}
node, acc ->
{node, acc}
end)
has_one and not has_higher
end
end