Current section

Files

Jump to
credence lib pattern no_kernel_op_in_pipeline.ex
Raw

lib/pattern/no_kernel_op_in_pipeline.ex

defmodule Credence.Pattern.NoKernelOpInPipeline do
@moduledoc """
Detects qualified `Kernel.op/2` calls used as steps in a pipeline.
LLMs produce code like `list |> Enum.sort() |> Kernel.==(list)` because
they try to keep everything in one pipeline. In Elixir, comparison and
boolean operators are used in infix position, not as qualified calls.
## Bad
list |> Enum.uniq() |> Enum.sort() |> Kernel.==(list)
score |> calculate() |> Kernel.>=(threshold)
## Good
(list |> Enum.uniq() |> Enum.sort()) == list
calculate(score) >= threshold
## Auto-fix
Extracts the operator from the pipeline and restructures:
- **0 remaining steps**: `x |> Kernel.op(y)``x op y`
- **1 remaining step**: `x |> f() |> Kernel.op(y)``f(x) op y`
- **2+ remaining steps**: `x |> f() |> g() |> Kernel.op(y)``(x |> f() |> g()) op y`
Arithmetic operators (`+`, `-`, `*`, `/`) are not flagged since
`Kernel.+(n)` in a pipe has no clearly better alternative.
"""
use Credence.Pattern.Rule
alias Credence.Issue
alias Credence.RuleHelpers
@flagged_ops ~w(== != === !== < > <= >= and or)a
@impl true
def check(ast, _opts) do
consumed = consumed_by_pipe_positions(ast)
{_ast, issues} =
Macro.prewalk(ast, [], fn
{:|>, pipe_meta, [lhs, {{:., _, [{:__aliases__, _, [:Kernel]}, op]}, meta, [_arg]}]} = node,
acc
when op in @flagged_ops ->
if unsafe_to_flatten?(node, lhs, pipe_meta, consumed) do
{node, acc}
else
{node, [build_issue(meta, op) | acc]}
end
node, acc ->
{node, acc}
end)
Enum.reverse(issues)
end
@impl true
def fix_patches(ast, _opts) do
consumed = consumed_by_pipe_positions(ast)
RuleHelpers.patches_from_postwalk(ast, fn
{:|>, pipe_meta, [lhs, {{:., _, [{:__aliases__, _, [:Kernel]}, op]}, _, [arg]}]} = node
when op in @flagged_ops ->
if unsafe_to_flatten?(node, lhs, pipe_meta, consumed) do
node
else
transform_kernel_pipe(lhs, op, arg)
end
node ->
node
end)
end
# A `Kernel.op` pipe step must NOT be flattened to infix when:
#
# 1. it is itself the LHS of a further `|>`. `|>` binds tighter than every
# flagged operator, so `(a op b) |> rest` re-parses as `a op (b |> rest)` —
# a precedence flip that changes meaning and usually crashes downstream.
#
# 2. it is a quoted macro fragment destined to be spliced INTO a pipe: its LHS
# is a zero-arity call (`Enum.count()`, no upstream value yet) and it
# contains an `unquote`. Flattening yields `upstream |> (Enum.count() == x)`,
# which fails at macro expansion (cannot pipe into a binary operator). The
# `unquote` + upstream-less head together mark this fragment.
defp unsafe_to_flatten?(node, lhs, pipe_meta, unsafe) do
MapSet.member?(unsafe, position(pipe_meta)) or
(zero_arity_call?(lhs) and contains_unquote?(node))
end
# Positions of `Kernel.op` pipe steps that cannot be safely flattened to infix
# because they are consumed by a pipe step that REMAINS a pipe after the fix.
#
# A whole chain of flagged Kernel ops (`a |> Kernel.==(b) |> Kernel.or(c)`)
# flattens together to `a == b or c`, so none is unsafe. But the moment a
# non-flagged step sits above an op in the chain (`a |> Kernel.<=(b) |> case`,
# `a |> Kernel.>=(b) |> f.()`), that step stays a `|>`; since `|>` binds
# tighter than every flagged operator, flattening the op below it re-parses
# `(x op y) |> step` as `x op (y |> step)`. We propagate this "consumed by a
# surviving pipe" context down the chain: it taints every op beneath the first
# non-flagged step.
defp consumed_by_pipe_positions(ast), do: collect_unsafe(ast, true, MapSet.new())
# `ctx_safe?` — is an op-pipe at this position safe to flatten (i.e. NOT
# consumed by a surviving pipe)? Only the lhs of a `|>` continues the chain;
# everything else is an independent expression (fresh safe context).
defp collect_unsafe({:|>, meta, [lhs, rhs]}, ctx_safe?, acc) do
rhs_kernel? = flagged_kernel_op?(rhs)
acc = if rhs_kernel? and not ctx_safe?, do: MapSet.put(acc, position(meta)), else: acc
acc = collect_unsafe(lhs, ctx_safe? and rhs_kernel?, acc)
collect_unsafe(rhs, true, acc)
end
defp collect_unsafe({form, _meta, args}, _ctx, acc) when is_list(args) do
acc = collect_unsafe(form, true, acc)
Enum.reduce(args, acc, &collect_unsafe(&1, true, &2))
end
defp collect_unsafe({a, b}, _ctx, acc) do
collect_unsafe(b, true, collect_unsafe(a, true, acc))
end
defp collect_unsafe(list, _ctx, acc) when is_list(list) do
Enum.reduce(list, acc, &collect_unsafe(&1, true, &2))
end
defp collect_unsafe(_leaf, _ctx, acc), do: acc
defp flagged_kernel_op?({{:., _, [{:__aliases__, _, [:Kernel]}, op]}, _, [_arg]})
when op in @flagged_ops,
do: true
defp flagged_kernel_op?(_), do: false
defp position(meta), do: {Keyword.get(meta, :line), Keyword.get(meta, :column)}
defp zero_arity_call?({fun, _meta, []}) when is_atom(fun), do: true
defp zero_arity_call?({{:., _, _}, _meta, []}), do: true
defp zero_arity_call?(_), do: false
defp contains_unquote?(ast) do
{_ast, found} =
Macro.prewalk(ast, false, fn
_node, true -> {nil, true}
{:unquote, _, _} = n, _ -> {n, true}
node, acc -> {node, acc}
end)
found
end
defp transform_kernel_pipe(lhs, op, arg) do
case count_pipes(lhs) do
0 ->
# x |> Kernel.op(y) → x op y
{op, [], [lhs, arg]}
1 ->
# x |> f() |> Kernel.op(y) → f(x) op y
{:|>, _, [initial, step]} = lhs
inlined = inline_pipe_step(initial, step)
{op, [], [inlined, arg]}
_ ->
# x |> f() |> g() |> Kernel.op(y) → (x |> f() |> g()) op y
# Parens are implicit: |> has higher precedence than all flagged ops
{op, [], [lhs, arg]}
end
end
defp count_pipes({:|>, _, [lhs, _]}), do: 1 + count_pipes(lhs)
defp count_pipes(_), do: 0
# Inline: x |> func(args) → func(x, args)
defp inline_pipe_step(input, {func_name, meta, args}) when is_atom(func_name) do
{func_name, meta, [input | args || []]}
end
defp inline_pipe_step(input, {{:., dot_meta, qualified}, call_meta, args}) do
{{:., dot_meta, qualified}, call_meta, [input | args || []]}
end
# Fallback: shouldn't happen, but keep as pipe to be safe
defp inline_pipe_step(input, step) do
{:|>, [], [input, step]}
end
defp build_issue(meta, op) do
%Issue{
rule: :no_kernel_op_in_pipeline,
message: """
Piping into `Kernel.#{op}/2` is non-idiomatic Elixir. Operators should \
be used in infix position.
Extract the comparison from the pipeline:
result = pipeline
result #{op} value
""",
meta: %{line: Keyword.get(meta, :line)}
}
end
end