Current section

Files

Jump to
credence lib pattern no_redundant_case_nil_clause.ex
Raw

lib/pattern/no_redundant_case_nil_clause.ex

defmodule Credence.Pattern.NoRedundantCaseNilClause do
@moduledoc """
Detects `case` expressions where a `nil` clause and a trailing wildcard
clause have identical bodies, and the intermediate guarded clause can be
extended with `not is_nil/1` to absorb the `nil` case.
In Erlang term ordering, atoms (including `nil`) sort above numbers, so
`nil >= 0` is `true`. LLMs frequently write an explicit `nil ->` guard
before a `var when var >= threshold ->` clause to prevent nil from
matching, then add a catch-all wildcard that duplicates the nil body.
An expert would add `not is_nil(var)` to the guard and drop the
redundant nil clause.
## Detected patterns
case expr do
nil -> body_a
var when guard -> body_b
_ -> body_a # identical to nil body
end
## Good
case expr do
var when not is_nil(var) and guard -> body_b
_ -> body_a
end
## Auto-fix
Removes the `nil` clause and prepends `not is_nil(var) and` to the
existing guard of the middle clause.
"""
use Credence.Pattern.Rule
alias Credence.Issue
@impl true
def check(ast, _opts) do
{_ast, issues} =
Macro.prewalk(ast, [], fn
{:case, meta, [_subject, kw]} = node, acc when is_list(kw) ->
check_case(kw, node, acc, meta)
{:case, meta, [kw]} = node, acc when is_list(kw) ->
check_case(kw, node, acc, meta)
node, acc ->
{node, acc}
end)
Enum.reverse(issues)
end
@impl true
def fix_patches(ast, _opts) do
transformed = maybe_fix(ast)
patches_between(ast, transformed)
end
# Emit one patch per *matching* `case` node, rendering the whole node.
#
# Removing the `nil` clause shortens the `do` clause list (3 → 2). The
# generic AST-diff helper would land its patch on the clause *list* and
# render a bare list of `->` clauses, which Sourceror wraps in stray
# parens (`case x do ( ... ) end`). Patching at the `case`-node level
# instead renders a clean `case ... do ... end`. `maybe_fix` already
# fixed any nested cases (it is a `postwalk`), so the rendered node
# carries those fixes — we short-circuit at the matching case and do
# not recurse into it, keeping patches non-overlapping.
defp patches_between(same, same), do: []
defp patches_between({:case, _, args_o} = orig, {:case, _, args_m} = modified) do
if case_matches?(orig) do
case Sourceror.get_range(orig) do
%Sourceror.Range{} = range ->
[%{range: range, change: Credence.RuleHelpers.render_replacement(modified, range)}]
_ ->
[]
end
else
zip_recurse(args_o, args_m)
end
end
defp patches_between({form, _, args_o}, {form, _, args_m})
when is_list(args_o) and is_list(args_m) and length(args_o) == length(args_m) do
zip_recurse(args_o, args_m)
end
defp patches_between([_ | _] = orig, [_ | _] = modified)
when length(orig) == length(modified) do
zip_recurse(orig, modified)
end
defp patches_between({a_o, b_o}, {a_m, b_m}) do
patches_between(a_o, a_m) ++ patches_between(b_o, b_m)
end
defp patches_between(_, _), do: []
defp zip_recurse(orig, modified) do
orig
|> Enum.zip(modified)
|> Enum.flat_map(fn {o, m} -> patches_between(o, m) end)
end
defp case_matches?({:case, _, [_subject, kw]}) when is_list(kw), do: kw_matches?(kw)
defp case_matches?({:case, _, [kw]}) when is_list(kw), do: kw_matches?(kw)
defp case_matches?(_), do: false
defp kw_matches?(kw) do
case extract_do_clauses(kw) do
[first, middle, last] -> redundant_nil_clause?(first, middle, last)
_ -> false
end
end
# ── detection ──────────────────────────────────────────────────────
defp check_case(kw, node, acc, meta) do
case extract_do_clauses(kw) do
[first, middle, last] ->
if redundant_nil_clause?(first, middle, last) do
{node, [build_issue(meta) | acc]}
else
{node, acc}
end
_ ->
{node, acc}
end
end
defp redundant_nil_clause?(first, middle, last) do
nil_clause?(first) and wildcard_clause?(last) and guarded_clause?(middle) and
bodies_equivalent?(first, last)
end
defp extract_do_clauses([{{:__block__, _, [:do]}, clauses}]) when is_list(clauses),
do: clauses
defp extract_do_clauses(_), do: nil
# ── pattern predicates ─────────────────────────────────────────────
defp nil_clause?({:->, _, [[pattern], _body]}), do: nil_pattern?(pattern)
defp nil_clause?(_), do: false
defp nil_pattern?(nil), do: true
defp nil_pattern?({:__block__, _, [nil]}), do: true
defp nil_pattern?(_), do: false
defp wildcard_clause?({:->, _, [[pattern], _body]}), do: wildcard_pattern?(pattern)
defp wildcard_clause?(_), do: false
defp wildcard_pattern?({:_, _, _}), do: true
defp wildcard_pattern?({name, _, ctx}) when is_atom(name) and is_atom(ctx),
do: name |> to_string() |> String.starts_with?("_")
defp wildcard_pattern?(_), do: false
# Only a *bare variable* middle pattern is a safe target. The fix reuses
# the pattern as an expression inside `not is_nil(...)`; for a bare
# variable that is exactly `is_nil(var)`, the documented LLM anti-pattern
# (`var when var >= threshold`). Any other pattern is rejected:
# - a tuple/map/list/literal makes `not is_nil(...)` always-true dead
# code an expert would never write, and
# - a match pattern (`r = {:ok, v}`) renders `is_nil(r = {:ok, v})`,
# which does not compile (`=` is not allowed in guards).
defp guarded_clause?({:->, _, [[{:when, _, [var, _guard]}], _body]}), do: bare_var?(var)
defp guarded_clause?(_), do: false
defp bare_var?({name, _, ctx}) when is_atom(name) and is_atom(ctx) do
not (name |> to_string() |> String.starts_with?("_"))
end
defp bare_var?(_), do: false
defp bodies_equivalent?({:->, _, [[_], body_a]}, {:->, _, [[_], body_b]}) do
Macro.to_string(body_a) == Macro.to_string(body_b)
end
defp bodies_equivalent?(_, _), do: false
# ── issue ──────────────────────────────────────────────────────────
defp build_issue(meta) do
%Issue{
rule: :no_redundant_case_nil_clause,
message:
"The `nil` clause and the wildcard clause have identical bodies. " <>
"Add `not is_nil(var)` to the guard and remove the `nil` clause.",
meta: %{line: Keyword.get(meta, :line)}
}
end
# ── auto-fix ───────────────────────────────────────────────────────
defp maybe_fix(ast) do
Macro.postwalk(ast, fn
# case expr do ... end
{:case, meta, [subject, kw]} = node when is_list(kw) ->
case extract_do_clauses(kw) do
[first, middle, last] ->
if redundant_nil_clause?(first, middle, last) do
new_middle = add_nil_guard(middle)
{:case, meta, [subject, replace_clauses(kw, [new_middle, last])]}
else
node
end
_ ->
node
end
# expr |> case do ... end
{:case, meta, [kw]} = node when is_list(kw) ->
case extract_do_clauses(kw) do
[first, middle, last] ->
if redundant_nil_clause?(first, middle, last) do
new_middle = add_nil_guard(middle)
{:case, meta, [replace_clauses(kw, [new_middle, last])]}
else
node
end
_ ->
node
end
node ->
node
end)
end
defp replace_clauses([{{:__block__, block_meta, [:do]}, _old_clauses}], new_clauses) do
[{{:__block__, block_meta, [:do]}, new_clauses}]
end
defp add_nil_guard({:->, clause_meta, [[{:when, when_meta, [var, guard]}], body]}) do
nil_guard = {:not, [], [{:is_nil, [], [var]}]}
new_guard = {:and, [], [nil_guard, guard]}
{:->, clause_meta, [[{:when, when_meta, [var, new_guard]}], body]}
end
end