Current section

Files

Jump to
credence lib pattern no_duplicate_function_clauses.ex
Raw

lib/pattern/no_duplicate_function_clauses.ex

defmodule Credence.Pattern.NoDuplicateFunctionClauses do
@moduledoc """
Detects duplicate function clauses with identical argument patterns.
In Elixir, a second clause whose argument patterns are structurally identical
to an earlier clause is unreachable — the first clause always matches first.
The compiler emits "this clause cannot match because a previous clause always
matches" which blocks compilation under `--warnings-as-errors`.
Two clauses have identical patterns when their arguments normalize to the same
structure: bare variables are positionally equivalent regardless of name, and
literal/structural patterns must match exactly.
## Bad
defmodule Example do
def bar(x, y), do: {x, y}
def bar(x, y), do: {x, y}
end
## Good
defmodule Example do
def bar(x, y), do: {x, y}
end
"""
use Credence.Pattern.Rule
alias Credence.Issue
alias Credence.RuleHelpers
@impl true
def check(ast, _opts) do
{_ast, issues} =
Macro.prewalk(ast, [], fn
{:__block__, _, stmts} = node, acc when is_list(stmts) ->
new =
for {dup, name, arity} <- duplicate_clauses(stmts),
do: build_issue(elem(dup, 1), name, arity)
{node, new ++ acc}
node, acc ->
{node, acc}
end)
Enum.reverse(issues)
end
@impl true
def fix_patches(ast, _opts) do
# Delete each duplicate clause surgically (whole-line), so the surrounding
# code — unrelated module attributes, other clauses — keeps its exact source.
# Re-rendering the whole block reformatted distant code (e.g. collapsed a
# multi-line `@attr` keyword list).
{_ast, patches} =
Macro.prewalk(ast, [], fn
{:__block__, _meta, stmts} = node, acc when is_list(stmts) ->
dups = for {dup, _name, _arity} <- duplicate_clauses(stmts), do: dup
{node, acc ++ Enum.map(dups, &RuleHelpers.deletion_patch/1)}
node, acc ->
{node, acc}
end)
Enum.reject(patches, &is_nil/1)
end
# The duplicate clauses in a block: every def/defp clause after the first with
# the same full signature. Returns `[{node, name, arity}]`. Used by both check
# (→ issues) and fix (→ deletion patches), so they agree exactly.
#
# The signature is computed in source order while tracking module-attribute
# *redefinitions*: a clause that references `@ops` is distinct from an earlier
# same-head clause if `@ops` was reassigned between them (its guard then filters
# a different set). The signature also includes the (normalized) BODY — two
# clauses that share a head but have different bodies are NOT collapsed: the
# later one is the author's likely copy-paste bug, not safe-to-delete noise.
defp duplicate_clauses(stmts) do
{_seen, _vers, dups} =
Enum.reduce(stmts, {MapSet.new(), %{}, []}, fn stmt, {seen, vers, dups} ->
case attr_assignment_name(stmt) do
{:ok, attr} ->
{seen, Map.update(vers, attr, 1, &(&1 + 1)), dups}
:error ->
case clause_signature(stmt, vers) do
{:ok, sig, name, arity} ->
if MapSet.member?(seen, sig),
do: {seen, vers, [{stmt, name, arity} | dups]},
else: {MapSet.put(seen, sig), vers, dups}
:error ->
{seen, vers, dups}
end
end
end)
Enum.reverse(dups)
end
# A module-attribute assignment `@name value` (inner has a non-empty arg list),
# as opposed to a usage `@name` (inner is a context atom).
defp attr_assignment_name({:@, _, [{name, _, args}]})
when is_atom(name) and is_list(args) and args != [],
do: {:ok, name}
defp attr_assignment_name(_), do: :error
# Build `{:ok, signature, name, arity}` for a def/defp clause, or `:error`.
#
# Requires a body (`[head, body]`): a bodiless head (`def code(x)` — the
# 1-element `[head]` declaration form for default args / docs) generates no
# runtime clause and must not be compared against the real clauses below it.
defp clause_signature({dt, _meta, [head, body]}, attr_versions) when dt in [:def, :defp] do
# A head containing `unquote(...)` is macro-generated: the surface AST
# collapses every `unquote(var)` to the same placeholder, so distinct clauses
# (e.g. `def f(unquote(lower))` vs `def f(unquote(upper))`) look identical and
# would be wrongly deleted. The real patterns are only known after expansion.
if contains_unquote?(head) do
:error
else
case head_parts(head) do
{name, args, guard_parts} ->
do_body = extract_do_value(body)
{norm_args, norm_guards, norm_body} = normalize_clause(args, guard_parts, do_body)
attr_refs = referenced_attr_versions([args, guard_parts, do_body], attr_versions)
sig = {name, length(args), norm_args, norm_guards, norm_body, attr_refs}
{:ok, sig, name, length(args)}
nil ->
:error
end
end
end
defp clause_signature(_, _), do: :error
defp head_parts({:when, _, [{name, _, args} | guard_parts]})
when is_atom(name) and is_list(args),
do: {name, args, guard_parts}
defp head_parts({name, _, args}) when is_atom(name) and is_list(args), do: {name, args, []}
defp head_parts(_), do: nil
defp extract_do_value(body) when is_list(body) do
case Keyword.fetch(body, :do) do
{:ok, v} ->
v
:error ->
Enum.find_value(body, fn
{{:__block__, _, [:do]}, v} -> v
_ -> nil
end)
end
end
defp extract_do_value(_), do: nil
# Normalize args, guards and body together with ONE shared variable-binding map
# so variable identity is preserved across all three.
defp normalize_clause(args, guard_parts, do_body) do
{norm_args, state} = Enum.map_reduce(args, {%{}, 0}, &normalize_node/2)
{norm_guards, state} = Enum.map_reduce(guard_parts, state, &normalize_node/2)
{norm_body, _state} = normalize_node(do_body, state)
{norm_args, norm_guards, norm_body}
end
# For every module attribute *used* anywhere in `asts`, its current version
# (how many times it has been (re)assigned so far in the block). Sorted so the
# signature is order-independent.
defp referenced_attr_versions(asts, attr_versions) do
{_ast, used} =
Macro.prewalk(asts, MapSet.new(), fn
{:@, _, [{name, _, ctx}]} = node, acc when is_atom(name) and is_atom(ctx) ->
{node, MapSet.put(acc, name)}
node, acc ->
{node, acc}
end)
used
|> Enum.map(fn name -> {name, Map.get(attr_versions, name, 0)} end)
|> Enum.sort()
end
defp contains_unquote?(head) do
{_node, found?} =
Macro.prewalk(head, false, fn
{form, _, _} = node, _acc when form in [:unquote, :unquote_splicing] -> {node, true}
node, acc -> {node, acc}
end)
found?
end
# The Nth distinct variable name becomes `{:v, N}` and a *repeated* name reuses
# its placeholder, threading `{name => placeholder_index, next_index}`. This
# keeps `def f(x, x)` (a non-linear equality constraint) distinct from
# `def f(a, b)`.
# Bare `_` is always a fresh placeholder (each underscore is independent).
defp normalize_node({:_, _, ctx}, {names, n}) when is_atom(ctx) do
{{:v, n}, {names, n + 1}}
end
# `__MODULE__` is the current-module constant, NOT a bindable variable. In
# struct-name position `%__MODULE__{}` matches only this module's struct while
# `%_{}` matches any struct, so they must stay distinct — keep `__MODULE__`
# literal rather than collapsing it to a `_`-style placeholder.
defp normalize_node({:__MODULE__, _, ctx}, state) when is_atom(ctx) do
{{:special, :__MODULE__}, state}
end
# A bare name on the RHS of a pipe (`text |> is_list`) is a FUNCTION reference,
# not a variable — keep its name literal so `|> is_list` and `|> is_binary`
# (and other pipe-form guards) stay distinct instead of collapsing to the same
# placeholder.
defp normalize_node({:|>, _, [left, {fname, _, ctx}]}, state)
when is_atom(fname) and is_atom(ctx) do
{nleft, state} = normalize_node(left, state)
{{:|>, [nleft, {:fn_ref, fname}]}, state}
end
defp normalize_node({var, _, ctx}, {names, n}) when is_atom(var) and is_atom(ctx) do
case names do
%{^var => idx} -> {{:v, idx}, {names, n}}
_ -> {{:v, n}, {Map.put(names, var, n), n + 1}}
end
end
# Module attribute `@name` — the name is a constant identifier, not a bindable
# variable; keep it literal so e.g. `@joins` and `@from_join_opts` (and the
# guards that reference them) do not normalize equal.
defp normalize_node({:@, _, [{name, _, ctx}]}, state) when is_atom(name) and is_atom(ctx) do
{{:@, name}, state}
end
defp normalize_node({form, _meta, args}, state) when is_list(args) do
{normalized, state} = Enum.map_reduce(args, state, &normalize_node/2)
{{form, normalized}, state}
end
defp normalize_node({left, right}, state) do
{nl, state} = normalize_node(left, state)
{nr, state} = normalize_node(right, state)
{{nl, nr}, state}
end
defp normalize_node(list, state) when is_list(list) do
Enum.map_reduce(list, state, &normalize_node/2)
end
defp normalize_node(other, state), do: {other, state}
defp build_issue(meta, name, arity) do
%Issue{
rule: :no_duplicate_function_clauses,
message:
"Duplicate function clause for `#{name}/#{arity}`. " <>
"The second clause has identical argument patterns and is unreachable.",
meta: %{line: Keyword.get(meta, :line)}
}
end
end