Current section

Files

Jump to
credence lib pattern prefer_enum_count.ex
Raw

lib/pattern/prefer_enum_count.ex

defmodule Credence.Pattern.PreferEnumCount do
@moduledoc """
Detects `Enum.reduce/3` calls that count elements matching a predicate
(using 0 as initial accumulator and `if pred, do: acc + 1, else: acc` as body)
and rewrites them to the more concise `Enum.count/2`.
## Bad
values
|> Enum.reduce(0, fn count, odd_count ->
if rem(count, 2) == 1, do: odd_count + 1, else: odd_count
end)
## Good
values
|> Enum.count(&(rem(&1, 2) == 1))
"""
use Credence.Pattern.Rule
alias Credence.Issue
@impl true
def check(ast, _opts) do
{_ast, issues} =
Macro.prewalk(ast, [], fn
{{:., _, _}, meta, args} = node, issues ->
if counting_reduce?(node, args) do
issue = %Issue{
rule: :prefer_enum_count,
message:
"Enum.reduce with counting if-body reimplements Enum.count/2. " <>
"Use Enum.count/2 with a predicate instead.",
meta: %{line: Keyword.get(meta, :line)}
}
{node, [issue | issues]}
else
{node, issues}
end
node, issues ->
{node, issues}
end)
Enum.reverse(issues)
end
@impl true
def fix_patches(ast, _opts) do
Credence.RuleHelpers.patches_from_postwalk(ast, fn
{{:., _, _}, _, args} = node ->
case build_count_call(node, args) do
{:ok, new_node} -> new_node
:error -> node
end
node ->
node
end)
end
# ── Pattern detection ──────────────────────────────────────────────
defp counting_reduce?(node, args) do
reduce_call?(node) and counting_body?(args)
end
defp reduce_call?({{:., _, [{:__aliases__, _, [:Enum]}, :reduce]}, _, _}), do: true
defp reduce_call?(_), do: false
# 3-arg form: Enum.reduce(enum, 0, fn ...)
defp counting_body?([_enum, acc_init, fn_expr]) do
zero?(acc_init) and counting_fn?(fn_expr)
end
# 2-arg form (piped): Enum.reduce(0, fn ...)
defp counting_body?([acc_init, fn_expr]) do
zero?(acc_init) and counting_fn?(fn_expr)
end
defp counting_body?(_), do: false
defp zero?({:__block__, _, [0]}), do: true
defp zero?(_), do: false
defp counting_fn?({:fn, _, [{:->, _, [[elem_var, acc_var], body]}]}) do
var?(elem_var) and var?(acc_var) and counting_if?(body, elem_var, acc_var)
end
defp counting_fn?(_), do: false
defp counting_if?({:if, _, [condition, clauses]}, elem_var, acc_var) when is_list(clauses) do
do_body = extract_keyword_clause(clauses, :do)
else_body = extract_keyword_clause(clauses, :else)
acc_increment?(do_body, acc_var) and same_var?(else_body, acc_var) and
predicate_safe?(condition, elem_var, acc_var)
end
defp counting_if?(_, _, _), do: false
# The condition becomes the body of an `Enum.count/2` capture, with the element
# variable rewritten to `&1`. That is only equivalent when the condition:
# * references the element variable (otherwise `&(...)` has no `&1` and is a
# compile error: "invalid args for &"), and
# * does NOT reference the accumulator variable (otherwise the original
# depends on the running count — e.g. `if acc < 3` caps the result — and
# the rewritten `acc` is an unbound variable; not the same answer), and
# * contains no `&` capture (wrapping it in `&(...)` would make a nested
# capture: "nested captures are not allowed" — a compile error).
defp predicate_safe?(condition, {elem_name, _, _}, {acc_name, _, _}) do
var_used?(condition, elem_name) and not var_used?(condition, acc_name) and
not has_capture?(condition)
end
defp has_capture?(ast) do
{_ast, found?} =
Macro.prewalk(ast, false, fn
{:&, _, _} = node, _acc -> {node, true}
node, acc -> {node, acc}
end)
found?
end
defp var_used?(ast, name) do
{_ast, used?} =
Macro.prewalk(ast, false, fn
{^name, _, ctx} = node, _acc when is_nil(ctx) or is_atom(ctx) -> {node, true}
node, acc -> {node, acc}
end)
used?
end
# acc + 1 or 1 + acc
defp acc_increment?({:+, _, [left, right]}, acc_var) do
(same_var?(left, acc_var) and literal_one?(right)) or
(literal_one?(left) and same_var?(right, acc_var))
end
defp acc_increment?(_, _), do: false
defp same_var?({name, _, ctx1}, {name, _, ctx2})
when is_atom(name) and (is_nil(ctx1) or is_atom(ctx1)) and
(is_nil(ctx2) or is_atom(ctx2)),
do: true
defp same_var?(_, _), do: false
defp var?({name, _, ctx}) when is_atom(name) and (is_nil(ctx) or is_atom(ctx)), do: true
defp var?(_), do: false
defp literal_one?({:__block__, _, [1]}), do: true
defp literal_one?(1), do: true
defp literal_one?(_), do: false
defp extract_keyword_clause(clauses, key) do
Enum.find_value(clauses, fn
{{:__block__, _, [^key]}, body} -> body
_ -> nil
end)
end
# ── Fix transformation ─────────────────────────────────────────────
defp build_count_call(_node, args) do
case args do
# 3-arg form: Enum.reduce(enum, 0, fn ...)
[enum, acc_init, fn_expr] ->
if zero?(acc_init) do
case extract_predicate(fn_expr) do
{:ok, elem_name, _acc_name, condition} ->
capture = build_capture(condition, elem_name)
{:ok, {{:., [], [{:__aliases__, [], [:Enum]}, :count]}, [], [enum, capture]}}
:error ->
:error
end
else
:error
end
# 2-arg form (piped): Enum.reduce(0, fn ...)
[acc_init, fn_expr] ->
if zero?(acc_init) do
case extract_predicate(fn_expr) do
{:ok, elem_name, _acc_name, condition} ->
capture = build_capture(condition, elem_name)
{:ok, {{:., [], [{:__aliases__, [], [:Enum]}, :count]}, [], [capture]}}
:error ->
:error
end
else
:error
end
_ ->
:error
end
end
defp extract_predicate({:fn, _, [{:->, _, [[elem_var, acc_var], body]}]}) do
if var?(elem_var) and var?(acc_var) do
case body do
{:if, _, [condition, clauses]} when is_list(clauses) ->
do_body = extract_keyword_clause(clauses, :do)
else_body = extract_keyword_clause(clauses, :else)
if acc_increment?(do_body, acc_var) and same_var?(else_body, acc_var) and
predicate_safe?(condition, elem_var, acc_var) do
{:ok, elem(elem_var, 0), elem(acc_var, 0), condition}
else
:error
end
_ ->
:error
end
else
:error
end
end
defp extract_predicate(_), do: :error
defp build_capture(predicate, elem_name) do
replaced =
Macro.prewalk(predicate, fn
{^elem_name, _, ctx} when (is_nil(ctx) or is_atom(ctx)) and is_atom(elem_name) ->
{:&, [], [1]}
node ->
node
end)
{:&, [], [replaced]}
end
end