Current section
Files
Jump to
Current section
Files
lib/pattern/no_reduce_while_without_halt.ex
defmodule Credence.Pattern.NoReduceWhileWithoutHalt do
@moduledoc """
Detects `Enum.reduce_while/3` where every callback clause returns only
`{:cont, value}` — never `:halt` or `{:halt, value}`. This is equivalent
to plain `Enum.reduce/3` and the `reduce_while` adds unnecessary ceremony.
## Bad
Enum.reduce_while(list, 0, fn x, acc ->
{:cont, acc + x}
end)
list
|> Enum.reduce_while({0, []}, fn h, {max, acc} ->
new_max = max(h, max)
{:cont, {new_max, [new_max | acc]}}
end)
## Good
Enum.reduce(list, 0, fn x, acc ->
acc + x
end)
list
|> Enum.reduce({0, []}, fn h, {max, acc} ->
new_max = max(h, max)
{new_max, [new_max | acc]}
end)
## Auto-fix
Replaces `reduce_while` with `reduce` and unwraps each `{:cont, value}`
return to just `value`.
"""
use Credence.Pattern.Rule
alias Credence.Issue
alias Credence.RuleHelpers
@impl true
def check(ast, _opts) do
{_ast, issues} =
Macro.prewalk(ast, [], fn
{{:., _, _} = dot, meta, args} = node, issues when is_list(args) ->
if reduce_while_call?(dot) and length(args) >= 2 do
fn_node = List.last(args)
if all_cont?(fn_node) do
{node, [build_issue(meta) | issues]}
else
{node, issues}
end
else
{node, issues}
end
node, issues ->
{node, issues}
end)
Enum.reverse(issues)
end
@impl true
def fix_patches(ast, opts) do
source = Keyword.get(opts, :source, "")
RuleHelpers.patches_from_postwalk(ast, &maybe_rewrite(&1, source))
end
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 reduce_call(
{:., dot_meta, [{:__aliases__, alias_meta, [:Enum]}, :reduce_while]},
call_meta
) do
{{:., dot_meta, [{:__aliases__, alias_meta, [:Enum]}, :reduce]}, call_meta}
end
defp reduce_call({:., dot_meta, [:Enum, :reduce_while]}, call_meta) do
{{:., dot_meta, [:Enum, :reduce]}, call_meta}
end
# Returns true if every clause of the anonymous function returns only
# {:cont, _} and never :halt or {:halt, _}.
defp all_cont?({:fn, _, clauses}) when is_list(clauses) do
Enum.all?(clauses, &clause_all_cont?/1)
end
defp all_cont?(_), do: false
defp clause_all_cont?({:->, _, [_args, body]}) do
not halts?(body) and returns_cont?(body)
end
defp clause_all_cont?(_), do: false
# Check if the body contains any :halt or {:halt, _} return.
defp halts?(body) do
{_, found} =
Macro.prewalk(body, false, fn
node, true ->
{node, true}
node, false ->
if halt_value?(node) do
{node, true}
else
{node, false}
end
end)
found
end
defp halt_value?(:halt), do: true
defp halt_value?({:__block__, _, [:halt]}), do: true
defp halt_value?({:halt, _}), do: true
# Sourceror wraps 2-tuples: {a, b} → {{:__block__, _, [a]}, b}
defp halt_value?({{:__block__, _, [:halt]}, _}), do: true
defp halt_value?({:__block__, _, [{:halt, _}]}), do: true
defp halt_value?(_), do: false
# Check that the body returns {:cont, _} as its last expression.
defp returns_cont?({:__block__, _meta, stmts}) when is_list(stmts) do
case List.last(stmts) do
nil -> false
last -> cont_value?(last)
end
end
defp returns_cont?(single), do: cont_value?(single)
defp cont_value?({:cont, _}), do: true
# Sourceror wraps 2-tuples: {:cont, v} → {{:__block__, _, [:cont]}, v}
defp cont_value?({{:__block__, _, [:cont]}, _}), do: true
defp cont_value?({:__block__, _, [{:cont, _}]}), do: true
defp cont_value?({:__block__, _, [{{:__block__, _, [:cont]}, _}]}), do: true
defp cont_value?(_), do: false
# Rewrite: replace reduce_while → reduce, unwrap {:cont, value} → value
defp maybe_rewrite(
{{:., _, _} = dot, call_meta, args} = node,
_source
) do
if reduce_while_call?(dot) and length(args) >= 2 do
fn_node = List.last(args)
if all_cont?(fn_node) do
{new_dot, _} = reduce_call(dot, call_meta)
new_args = List.update_at(args, -1, &unwrap_cont_fn/1)
{new_dot, call_meta, new_args}
else
node
end
else
node
end
end
defp maybe_rewrite(node, _source), do: node
defp unwrap_cont_fn({:fn, fn_meta, clauses}) do
new_clauses = Enum.map(clauses, &unwrap_cont_clause/1)
{:fn, fn_meta, new_clauses}
end
defp unwrap_cont_clause({:->, arrow_meta, [args, body]}) do
{:->, arrow_meta, [args, unwrap_cont_body(body)]}
end
defp unwrap_cont_body({:cont, value}), do: value
# Sourceror wraps 2-tuples: {:cont, v} → {{:__block__, _, [:cont]}, v}
defp unwrap_cont_body({{:__block__, _, [:cont]}, value}), do: value
defp unwrap_cont_body({:__block__, meta, [{:cont, value}]}), do: {:__block__, meta, [value]}
defp unwrap_cont_body({:__block__, meta, [{{:__block__, _, [:cont]}, value}]}),
do: {:__block__, meta, [value]}
defp unwrap_cont_body({:__block__, meta, stmts}) when is_list(stmts) do
{init, [last]} = Enum.split(stmts, -1)
{:__block__, meta, init ++ [unwrap_cont_body(last)]}
end
defp unwrap_cont_body(other), do: other
defp build_issue(meta) do
%Issue{
rule: :no_reduce_while_without_halt,
message:
"`Enum.reduce_while/3` used where every clause returns `{:cont, ...}` " <>
"and none returns `:halt`. Use `Enum.reduce/3` instead — it is " <>
"equivalent and avoids unnecessary `{:cont, ...}` wrapping.",
meta: %{line: Keyword.get(meta, :line)}
}
end
end