Credence.Pattern.NoManualCountWithPredicate (credence v0.7.1)

Copy Markdown

Detects hand-rolled recursive counting functions that should use Enum.count/2.

Why this matters

When a function counts list elements matching a predicate using manual tail-recursion with an accumulator, it is reimplementing Enum.count/2:

# Flagged — 3-clause guard pattern
defp do_count([], _target, acc), do: acc
defp do_count([h | t], target, acc) when h == target,
  do: do_count(t, target, acc + 1)
defp do_count([_h | t], target, acc),
  do: do_count(t, target, acc)

# Flagged — 2-clause if pattern
defp do_count([], acc), do: acc
defp do_count([h | t], acc) do
  new_acc = if h > 0, do: acc + 1, else: acc
  do_count(t, new_acc)
end

The fix

Each flagged group is collapsed to a single clause that delegates to Enum.count/2, threading the accumulator through unchanged:

defp do_count(list, target, acc) when is_list(list),
  do: acc + Enum.count(list, fn h -> h == target end)

This is a behaviour-preserving rewrite for every input:

  • The collapsed clause keeps the original name/arity, so callers (which seed the accumulator, e.g. do_count(list, target, 0)) are unchanged.
  • The when is_list(list) guard preserves the original domain: the multi-clause original only ever matched lists (and raised FunctionClauseError otherwise), and so does the collapsed clause.
  • acc + Enum.count(list, pred) reproduces initial_acc + matches.

Detection scope (only the safely-fixable cases)

3-clause guard pattern — exactly 3 clauses, all arity 3, same name, where:

  1. ([], _bound, acc) returns acc,
  2. ([h | t], bound, acc) when <guard> recurses fn(t, bound, acc + 1),

  3. ([_h | t], bound, acc) recurses fn(t, bound, acc),

and the <guard> is a non-raising boolean guard (comparisons, and/or/not, unary is_* type checks over variables/literals). A guard that can raise (e.g. rem(h, 2) == 0) is not flagged: a guard silently skips an element whose guard raises, but the same expression used as an Enum.count/2 predicate would raise — a different answer. The bound argument must be passed through the recursion unchanged.

2-clause if pattern — exactly 2 clauses, same name, where:

  1. one clause matches [] (in some position) and returns the trailing accumulator,
  2. the other matches [h | t] in the same position, binds new_acc = if(<cond>, do: acc + 1, else: acc) and recurses with the list tail and new_acc, passing every other argument through unchanged.

The <cond> is an ordinary expression (not a guard), so it carries no error-swallowing semantics — moving it into Enum.count/2 preserves its truthiness, side effects, and any exception it raises. No narrowing of <cond> is needed.