Current section

Files

Jump to
credence lib pattern prefer_reduce_while_with_halt_value.ex
Raw

lib/pattern/prefer_reduce_while_with_halt_value.ex

defmodule Credence.Pattern.PreferReduceWhileWithHaltValue do
@moduledoc """
Detects `Enum.reduce_while/3` that carries a boolean flag in the accumulator
solely to signal whether to halt, when the halt value itself could convey the
answer directly.
## Bad
{_prefix_sum, _seen_sums, found?} =
Enum.reduce_while(list, {0, MapSet.new([0]), false}, fn num, {ps, ss, _f} ->
new_sum = ps + num
if MapSet.member?(ss, new_sum) do
{:halt, {new_sum, ss, true}}
else
{:cont, {new_sum, MapSet.put(ss, new_sum), false}}
end
end)
found?
## Good
Enum.reduce_while(list, {0, MapSet.new([0])}, fn num, {ps, ss} ->
new_sum = ps + num
if MapSet.member?(ss, new_sum) do
{:halt, true}
else
{:cont, {new_sum, MapSet.put(ss, new_sum)}}
end
end)
|> case do
true -> true
{_, _} -> false
end
## Auto-fix
Removes the boolean flag from the accumulator, callback parameters,
and halt/continue tuples. Replaces the post-reduce extraction of
the flag with a pipe into `case` that distinguishes the halt value
(`true`) from a normal tuple result.
"""
use Credence.Pattern.Rule
alias Credence.Issue
alias Credence.RuleHelpers
@impl true
def check(ast, _opts) do
# Fire only where the full extraction block is present and safely fixable
# (`{_, _, flag} = reduce_while(...); flag`), so `check` and `fix` agree.
{_ast, issues} =
Macro.prewalk(ast, [], fn
{:__block__, _, stmts} = node, issues when is_list(stmts) ->
case find_pattern(stmts) do
{:ok, _idx, _binding, reduce_call, _bool_name} ->
{node, [build_issue(call_meta(reduce_call)) | issues]}
_ ->
{node, issues}
end
node, issues ->
{node, issues}
end)
Enum.reverse(issues)
end
defp call_meta({_dot, meta, _args}), do: meta
@impl true
def fix_patches(ast, opts) do
source = Keyword.get(opts, :source, "")
RuleHelpers.patches_from_ast_transform(ast, source, fn ast ->
transform_ast(ast)
end)
end
# ── Anti-pattern detection ─────────────────────────────────────────
defp reduce_while_call?({:., _, [{:__aliases__, _, [:Enum]}, :reduce_while]}), do: true
defp reduce_while_call?({:., _, [:Enum, :reduce_while]}), do: true
defp reduce_while_call?(_), do: false
defp anti_pattern?(acc_node, fn_node) do
tuple?(acc_node) and false_flag_acc?(acc_node) and
single_clause_fn?(fn_node) and callback_matches?(fn_node)
end
# Only 3-element accumulators are safe: dropping the flag yields a 2-tuple,
# which the generated `case` matches with `{_, _}`. Larger tuples would leave
# an unmatched arity and crash with CaseClauseError on a non-halting run.
defp tuple?({:{}, _, elems}) when is_list(elems) and length(elems) == 3, do: true
defp tuple?(_), do: false
# The initial accumulator's flag must be `false`: on an empty enumerable
# reduce_while returns the initial accumulator unchanged, so a `true` seed
# would make the original yield `true` while the fix yields `false`.
defp false_flag_acc?({:{}, _, elems}) when is_list(elems) do
last = List.last(elems)
boolean_literal?(last) and unwrap_boolean(last) == false
end
defp false_flag_acc?(_), do: false
defp boolean_literal?({:__block__, _, [bool]}) when is_boolean(bool), do: true
defp boolean_literal?(bool) when is_boolean(bool), do: true
defp boolean_literal?(_), do: false
defp unwrap_boolean({:__block__, _, [bool]}) when is_boolean(bool), do: bool
defp unwrap_boolean(bool) when is_boolean(bool), do: bool
defp single_clause_fn?({:fn, _, [{:->, _, _}]}), do: true
defp single_clause_fn?(_), do: false
defp callback_matches?({:fn, _, [{:->, _, [params, body]}]}) do
case Enum.at(params, 1) do
{:{}, _, param_elems} when is_list(param_elems) and length(param_elems) == 3 ->
check_body_if(body)
_ ->
false
end
end
defp check_body_if(body) do
case extract_if(body) do
{:ok, if_node} -> if_anti_pattern?(if_node)
_ -> false
end
end
defp extract_if({:__block__, _, stmts}) when is_list(stmts) do
Enum.find_value(stmts, fn
{:if, _, _} = if_node -> {:ok, if_node}
_ -> nil
end)
end
defp extract_if({:if, _, _} = if_node), do: {:ok, if_node}
defp extract_if(_), do: :error
defp if_anti_pattern?({:if, _, [_, [do_kw, else_kw]]}) do
with {:ok, do_body} <- extract_kw(do_kw, :do),
{:ok, else_body} <- extract_kw(else_kw, :else),
{:halt, halt_tuple} <- extract_halt_or_cont(do_body),
{:cont, cont_tuple} <- extract_halt_or_cont(else_body),
true <- tuple_arity?(halt_tuple, 3),
true <- tuple_arity?(cont_tuple, 3),
true <- tuple_with_boolean_last?(halt_tuple, true),
true <- tuple_with_boolean_last?(cont_tuple, false) do
true
else
_ -> false
end
end
defp if_anti_pattern?(_), do: false
defp tuple_arity?({:{}, _, elems}, n) when is_list(elems), do: length(elems) == n
defp tuple_arity?(_, _), do: false
defp extract_kw({{:__block__, _, [key]}, body}, key), do: {:ok, body}
defp extract_kw(_, _), do: :error
defp extract_halt_or_cont({:__block__, _, [inner]}), do: extract_halt_or_cont(inner)
defp extract_halt_or_cont({{:__block__, _, [tag]}, value})
when tag in [:halt, :cont],
do: {tag, value}
defp extract_halt_or_cont({tag, value}) when tag in [:halt, :cont], do: {tag, value}
defp extract_halt_or_cont(_), do: :error
defp tuple_with_boolean_last?({:{}, _, elems}, expected_bool) when is_list(elems) do
unwrap_boolean(List.last(elems)) == expected_bool
end
defp tuple_with_boolean_last?(_, _), do: false
# ── AST transformation ─────────────────────────────────────────────
# Transform the entire AST, looking for blocks that contain the anti-pattern
# reduce_while result pattern.
defp transform_ast(ast) do
Macro.postwalk(ast, fn
{:__block__, meta, stmts} = node when is_list(stmts) ->
transform_block_if_needed(stmts, meta, node)
node ->
node
end)
end
# Check if a block has the pattern:
# {a, b, flag} = Enum.reduce_while(..., {x, y, false}, fn ... end)
# flag
# And rewrite it to:
# Enum.reduce_while(..., {x, y}, fn ... end) |> case do ... end
defp transform_block_if_needed(stmts, meta, fallback) do
case find_pattern(stmts) do
{:ok, idx, _binding, reduce_call, _bool_var_name} ->
# 1. Transform the reduce_while call (remove boolean from acc, callback)
transformed_call = transform_reduce_while(reduce_call)
# 2. Build the case expression
case_expr = build_case_expr()
# 3. Build pipe: Enum.reduce_while(...) |> case do ... end
piped = {:|>, [], [transformed_call, case_expr]}
# 4. Reconstruct block: everything before + piped, skip old assignment + flag var
before = Enum.take(stmts, idx)
after_flag = Enum.drop(stmts, idx + 2)
{:__block__, meta, before ++ [piped] ++ after_flag}
_ ->
fallback
end
end
# Find the safely-fixable pattern:
#
# {_a, _b, flag} = Enum.reduce_while(..., {x, y, false}, fn ... end)
# flag # the block's last statement
#
# All conditions are required so the rewrite is behaviour-preserving and so
# `check` (which reuses this) never flags a case the fix cannot transform:
# * exactly a 3-element binding whose first two elements are unused (`_`-vars)
# * the flag variable is the very last statement of the block (nothing after
# it can reference the now-removed binding)
# * the reduce_while call itself matches the narrowed anti-pattern
defp find_pattern(stmts) do
last_idx = length(stmts) - 1
Enum.find_value(Enum.with_index(stmts), fn
{{:=, _, [{:{}, _, bind_elems}, reduce_call]}, idx} when is_list(bind_elems) ->
with true <- length(bind_elems) == 3,
true <- first_binds_unused?(bind_elems),
true <- idx + 1 == last_idx,
true <- reduce_while_call_p?(reduce_call),
{bool_name, _, ctx} when is_atom(bool_name) and is_atom(ctx) <-
Enum.at(stmts, idx + 1),
true <- elem(List.last(bind_elems), 0) == bool_name,
{acc_node, fn_node} <- reduce_acc_fn(reduce_call),
true <- anti_pattern?(acc_node, fn_node) do
{:ok, idx, {:{}, [], bind_elems}, reduce_call, bool_name}
else
_ -> nil
end
_ ->
nil
end)
end
defp first_binds_unused?([a, b, _flag]) do
underscore_var?(a) and underscore_var?(b)
end
defp first_binds_unused?(_), do: false
defp underscore_var?({name, _, ctx}) when is_atom(name) and is_atom(ctx) do
String.starts_with?(Atom.to_string(name), "_")
end
defp underscore_var?(_), do: false
defp reduce_acc_fn({_dot, _meta, args}) when is_list(args) and length(args) >= 2 do
{Enum.at(args, -2), List.last(args)}
end
defp reduce_acc_fn(_), do: :error
defp reduce_while_call_p?({{:., _, _} = dot, _, args})
when is_list(args) and length(args) >= 2,
do: reduce_while_call?(dot)
defp reduce_while_call_p?(_), do: false
# Transform the reduce_while call: remove boolean from acc, params, halt/cont.
defp transform_reduce_while({dot, call_meta, args}) do
acc_node = Enum.at(args, -2)
fn_node = List.last(args)
new_acc = remove_last_tuple_elem(acc_node)
new_fn = transform_callback(fn_node)
new_args = args |> List.replace_at(-2, new_acc) |> List.replace_at(-1, new_fn)
{dot, call_meta, new_args}
end
defp transform_callback({:fn, fn_meta, [{:->, arrow_meta, [params, body]}]}) do
new_params =
Enum.map(params, fn
{:{}, t_meta, elems} when length(elems) >= 3 ->
{:{}, t_meta, Enum.drop(elems, -1)}
other ->
other
end)
new_body = transform_body(body)
{:fn, fn_meta, [{:->, arrow_meta, [new_params, new_body]}]}
end
defp transform_body({:__block__, meta, stmts}) when is_list(stmts) do
new_stmts = Enum.map(stmts, &transform_stmt/1)
{:__block__, meta, new_stmts}
end
defp transform_body(stmt), do: transform_stmt(stmt)
defp transform_stmt({:if, if_meta, [cond_expr, branches]}) do
new_branches =
Enum.map(branches, fn
{{:__block__, kw_meta, [key]}, body} when key in [:do, :else] ->
{{:__block__, kw_meta, [key]}, transform_halt_cont_body(body)}
other ->
other
end)
{:if, if_meta, [cond_expr, new_branches]}
end
defp transform_stmt(other), do: other
defp transform_halt_cont_body({:__block__, meta, [inner]}) do
case extract_halt_or_cont(inner) do
{:halt, {:{}, _t_meta, elems}} when is_list(elems) and length(elems) >= 3 ->
# {:halt, {tuple..., true}} -> {:halt, true}
bool_val = unwrap_boolean(List.last(elems))
{:__block__, meta, [{:halt, bool_val}]}
{:cont, {:{}, t_meta, elems}} when is_list(elems) and length(elems) >= 3 ->
# {:cont, {tuple..., false}} -> {:cont, {tuple...}}
new_tuple = {:{}, t_meta, Enum.drop(elems, -1)}
{:__block__, meta, [{:cont, new_tuple}]}
_ ->
{:__block__, meta, [inner]}
end
end
defp transform_halt_cont_body(other), do: other
defp remove_last_tuple_elem({:{}, meta, elems}) when is_list(elems) and length(elems) >= 3 do
{:{}, meta, Enum.drop(elems, -1)}
end
defp remove_last_tuple_elem(other), do: other
# Build: |> case do true -> true; {_, _} -> false end
defp build_case_expr do
true_clause = {:->, [], [[{:__block__, [], [true]}], {:__block__, [], [true]}]}
false_clause =
{:->, [],
[
[{:__block__, [], [{{:_, [], nil}, {:_, [], nil}}]}],
{:__block__, [], [false]}
]}
{:case, [], [[do: [true_clause, false_clause]]]}
end
defp build_issue(meta) do
%Issue{
rule: :prefer_reduce_while_with_halt_value,
message:
"`Enum.reduce_while/3` carries a boolean flag in the accumulator " <>
"solely to signal halt. Use the halt value itself to convey the " <>
"answer — it is more direct and idiomatic.",
meta: %{line: Keyword.get(meta, :line)}
}
end
end