Credence.Pattern.NoManualListReduce (credence v0.7.1)

Copy Markdown

Detects hand-rolled recursive list-folding functions that should use Enum.reduce/3.

Why this matters

The classic two-clause recursive fold — an empty-list base returning the accumulator, and a [h | t] clause that recurses on the tail with an updated accumulator — reimplements Enum.reduce/3 with no benefit:

# Flagged (arity 2)
defp sum([], acc), do: acc
defp sum([h | t], acc), do: sum(t, acc + h)

# Flagged (arity 3, extra argument threaded through unchanged)
defp scale([], _factor, acc), do: acc
defp scale([h | t], factor, acc), do: scale(t, factor, acc + h * factor)

The fix

Each flagged group collapses to a single clause delegating to Enum.reduce/3, moving the accumulator-update expression into the reducer function:

defp sum(list, acc) when is_list(list),
  do: Enum.reduce(list, acc, fn h, acc -> acc + h end)

This is behaviour-preserving for every input:

  • Domain. The collapsed clause keeps the original name/arity and guards when is_list(list). The two-clause original only ever matched [] or [_ | _] (and raised FunctionClauseError otherwise). is_list/1 is true for both proper and improper lists and false for everything else — exactly the original domain. Maps/ranges/scalars raise FunctionClauseError on both sides; an improper list raises FunctionClauseError on both sides (Enum.reduce and the manual recursion both fail the same way on the improper tail — verified).
  • Same walk, same accumulator. Enum.reduce/3 folds the list head-to-tail, calling fun.(element, accumulator). The manual recursion walks the same order, computing the same updated accumulator at each step from the same h/acc. For [] both return the seed accumulator untouched (the reducer runs zero times). The accumulator-update expression is moved verbatim into the reducer body, so its value, side effects, evaluation count (once per element), and any exception it raises are identical.

Detection scope (only the safely-fixable cases)

Exactly 2 clauses, same name, arity ≥ 2, no guards, where some assignment of the clauses fills these two roles. The accumulator is the last parameter; exactly one other parameter is the list (cons) position; every remaining parameter is a plain variable threaded through unchanged.

  1. Base([], …, acc): [] at the cons position, the last parameter a variable, whose body is a single expression returning exactly that variable.
  2. Recurse([h | t], …, acc): [h | t] (both variables) at the cons position, whose body is exactly the self-call fn(…, t, …, <expr>) — the tail t at the cons position, an accumulator-update <expr> at the last position, and every other argument the matching parameter unchanged.

Why these narrowings (each drops a different-answer case)

  • Single-expression bodies only. Each role's body must be its one expression (the accumulator variable / the self-call), never a multi-statement block. A block could carry a side effect that the reducer-based rewrite would drop or move — a different answer.
  • No tail reference in the update. The accumulator-update expression must not mention the tail variable t. Inside Enum.reduce/3 the reducer sees only the element and the accumulator — t is not in scope — so any update that read the remaining tail could not be reproduced. (Every other variable the update can mention — the head, the accumulator, and the threaded parameters — is in scope in the collapsed clause / reducer.) Such groups are left untouched.
  • Accumulator last, single cons position. Only the shape with the accumulator as the final parameter and exactly one cons (list) parameter is flagged; other layouts are dropped rather than risk a mis-collapse.