Current section

Files

Jump to
credence lib rule no_list_append_in_loop.ex
Raw

lib/rule/no_list_append_in_loop.ex

defmodule Credence.Rule.NoListAppendInLoop do
@moduledoc """
Performance rule: Detects the use of `++` inside looping constructs
(`Enum.reduce`, `for` comprehensions) and inside recursive functions.
Appending to a list with `++` is O(n) because it must copy the entire
left-hand list. Inside a loop or recursion this compounds to O(n²).
Prefer prepending with `[item | acc]` and calling `Enum.reverse/1`
after the loop completes.
## Bad — inside Enum.reduce
Enum.reduce(list, [], fn item, acc ->
acc ++ [item * 2]
end)
## Bad — inside a recursive function
defp slide([next | rest], window, current, max) do
new_window = window ++ [next]
slide(rest, new_window, current, max)
end
## Good
Enum.reduce(list, [], fn item, acc ->
[item * 2 | acc]
end)
|> Enum.reverse()
"""
@behaviour Credence.Rule
alias Credence.Issue
@impl true
def check(ast, _opts) do
# Step 1: Traverse the AST looking for looping constructs
{_ast, issues} =
Macro.prewalk(ast, [], fn
# Match Enum.reduce/3 (the AST representation of a remote call to Enum.reduce)
{{:., _, [{:__aliases__, _, [:Enum]}, :reduce]}, _, [_enumerable, _acc, fun]} = node,
issues ->
{node, find_append(fun, issues)}
# Match 'for' comprehensions
{:for, _, args} = node, issues when is_list(args) ->
# The 'do' block is typically the last keyword argument in the comprehension
do_block = Keyword.get(List.last(args) || [], :do)
{node, find_append(do_block, issues)}
# Match def/defp where the body contains both ++ and a recursive call
{kind, _, [{:when, _, [{name, _, _params}, _guard]}, body_kw]} = node, issues
when kind in [:def, :defp] and is_atom(name) ->
body = extract_body(body_kw)
{node, find_append_in_recursive(body, name, issues)}
{kind, _, [{name, _, _params}, body_kw]} = node, issues
when kind in [:def, :defp] and is_atom(name) ->
body = extract_body(body_kw)
{node, find_append_in_recursive(body, name, issues)}
# If it's not a loop, keep walking
node, issues ->
{node, issues}
end)
# Reverse to keep chronological order (since we prepended to the list)
Enum.reverse(issues)
end
# Step 2: Traverse *only* the body of the loop to find `++`
defp find_append(ast, acc) do
{_ast, issues} =
Macro.prewalk(ast, acc, fn
# Match the `++` operator
{:++, meta, _args} = node, issues ->
{node, [build_issue(meta) | issues]}
node, issues ->
{node, issues}
end)
issues
end
# Step 3: For def/defp bodies, only flag ++ if the function is recursive
defp find_append_in_recursive(nil, _name, acc), do: acc
defp find_append_in_recursive(body, name, acc) do
has_recursive_call = body_calls_self?(body, name)
if has_recursive_call do
find_append(body, acc)
else
acc
end
end
defp body_calls_self?(body, name) do
{_ast, found} =
Macro.prewalk(body, false, fn
{^name, _, args} = node, _acc when is_list(args) ->
{node, true}
node, acc ->
{node, acc}
end)
found
end
defp extract_body(body_kw) when is_list(body_kw), do: Keyword.get(body_kw, :do)
defp extract_body(body), do: body
defp build_issue(meta) do
%Issue{
rule: :no_list_append_in_loop,
severity: :high,
message:
"Avoid using '++' inside loops or recursive functions. Prefer prepending with '[item | acc]' and calling 'Enum.reverse/1' outside the loop.",
meta: %{line: Keyword.get(meta, :line)}
}
end
end