Credence.Pattern.NoManualFind
(credence v0.7.0)
Copy Markdown
Detects hand-rolled recursive "find first matching element" functions that
should use Enum.find/3.
Why this matters
The classic 3-clause recursive find — empty-list base returning a default,
a guarded clause returning the head, a fallback clause recursing on the
tail — reimplements Enum.find/3 with no benefit:
# Flagged (arity 1)
defp find_odd([]), do: -1
defp find_odd([h | _t]) when rem_safe(h), do: h
defp find_odd([_h | t]), do: find_odd(t)
# Flagged (arity 2)
defp find_first([], default), do: default
defp find_first([h | _t], _default) when h > 0, do: h
defp find_first([_h | t], default), do: find_first(t, default)The fix
Each flagged group collapses to a single clause delegating to Enum.find/3:
defp find_first(list, default) when is_list(list),
do: Enum.find(list, default, fn h -> h > 0 end)This is behaviour-preserving for every input:
- Domain. The collapsed clause keeps the original name/arity and guards
when is_list(list). The multi-clause original only ever matched[]or[_ | _](and raisedFunctionClauseErrorotherwise).is_list/1is true for both proper and improper lists and false for everything else — exactly the original domain. Maps/ranges/scalars raiseFunctionClauseErroron both sides; an improper list whose match occurs before the improper tail returns the same element on both sides (verified), and one with no match raisesFunctionClauseErroron both sides. - First match, same order. Both walk a list head-to-tail and stop at the
first element satisfying the predicate, returning that element (not the
predicate's value — so a
nil/falsematched element is returned as-is, just like the original head clause). - Default. Returned only when nothing matches. See the narrowing below for why the default must be eager-evaluation-safe.
Detection scope (only the safely-fixable cases)
Exactly 3 clauses, same name, all arity 1 or all arity 2, where some assignment of the clauses fills these three roles:
- Base —
([])(arity 1) /([], default)(arity 2), no guard, whose body is a single expression:- arity 1: a literal (number incl. negative, atom/
nil/booleans, binary,[]); - arity 2: the second parameter variable returned unchanged.
- arity 1: a literal (number incl. negative, atom/
Match —
([h | _], …) when <guard>, no other guard vars, body is exactly the head variableh.Recurse —
([_ | t], …), no guard, body is exactly the self-call on the tail (fn(t)/fn(t, default)), every other argument threaded through unchanged.
Why these narrowings (each drops a different-answer case)
- Non-raising boolean guard only. The
<guard>must be a non-raising boolean: comparisons,and/or/not, and unaryis_*checks over the head variable and literals. A guard that can raise (e.g.rem(h, 2) != 0on a non-integer) silently skips the element in the multi-clause form, but the same expression as anEnum.find/3predicate would raise — a different answer (verified). Such groups are left untouched. - Eager-evaluation-safe default only. An
Enum.find/3default argument is evaluated eagerly, whereas the base clause runs only when the list is exhausted. A literal (arity 1) or an already-bound parameter (arity 2) has no side effect and the same value either way; an arbitrary expression default (e.g.(IO.puts(...); -1)) would run eagerly even when an element matches (verified) — not flagged. - Single-expression bodies only. Each role's body must be exactly its one expression (head var / self-call / default), never a multi-statement block. A block could carry a side effect that the predicate-based rewrite would drop or move — a different answer.