Current section

Files

Jump to
credence lib pattern no_string_concat_in_loop.ex
Raw

lib/pattern/no_string_concat_in_loop.ex

defmodule Credence.Pattern.NoStringConcatInLoop do
@moduledoc """
Performance rule: Detects string concatenation with `<>` inside
`Enum.reduce` calls with an empty string initial accumulator that can
be automatically fixed.
Each `<>` concatenation copies the entire accumulated binary, making
character-by-character string building O(n²). This is the string equivalent
of `list ++ [element]`.
The following patterns are automatically fixed:
* `Enum.reduce(list, "", fn elem, acc -> acc <> elem end)` → `Enum.join(list)`
* `Enum.reduce(list, "", fn elem, acc -> acc <> expr end)` where `expr`
doesn't reference `acc` → `Enum.map_join(list, fn elem -> expr end)`
## Bad
Enum.reduce(graphemes, "", fn char, acc ->
acc <> char
end)
Enum.reduce(graphemes, "", fn char, acc ->
acc <> String.upcase(char)
end)
## Good
Enum.join(graphemes)
Enum.map_join(graphemes, fn char -> String.upcase(char) end)
"""
use Credence.Pattern.Rule
alias Credence.Issue
alias Credence.RuleHelpers
@impl true
def check(ast, _opts) do
{_ast, issues} =
Macro.prewalk(ast, [], fn
# Direct call: Enum.reduce(list, "", fn elem, acc -> acc <> expr end)
{{:., _, [{:__aliases__, _, [:Enum]}, :reduce]}, meta, [_list, init, lambda]} = node,
issues ->
if empty_string_literal?(init) and match?({:ok, _, _}, extract_simple_concat(lambda)),
do: {node, [build_issue(meta) | issues]},
else: {node, issues}
# Pipeline: ... |> Enum.reduce("", fn elem, acc -> acc <> expr end)
{:|>, _,
[
_,
{{:., _, [{:__aliases__, _, [:Enum]}, :reduce]}, meta, [init, lambda]}
]} = node,
issues ->
if empty_string_literal?(init) and match?({:ok, _, _}, extract_simple_concat(lambda)),
do: {node, [build_issue(meta) | issues]},
else: {node, issues}
node, issues ->
{node, issues}
end)
Enum.reverse(issues)
end
@impl true
def fix_patches(ast, _opts) do
RuleHelpers.patches_from_postwalk(ast, &maybe_rewrite/1)
end
# Direct call: Enum.reduce(list, "", fn elem, acc -> acc <> expr end)
defp maybe_rewrite(
{{:., _, [{:__aliases__, _, [:Enum]}, :reduce]}, _, [list, init, lambda]} = node
) do
with true <- empty_string_literal?(init),
{:ok, elem_var, expr} <- extract_simple_concat(lambda) do
if simple_identity?(elem_var, expr) do
enum_join_call(list)
else
enum_map_join_call(list, elem_var, expr)
end
else
_ -> node
end
end
# Pipe form: lhs |> Enum.reduce("", fn elem, acc -> acc <> expr end)
defp maybe_rewrite(
{:|>, pipe_meta,
[
lhs,
{{:., _, [{:__aliases__, _, [:Enum]}, :reduce]}, _, [init, lambda]}
]} = node
) do
with true <- empty_string_literal?(init),
{:ok, elem_var, expr} <- extract_simple_concat(lambda) do
replacement =
if simple_identity?(elem_var, expr) do
enum_join_pipe_call()
else
enum_map_join_pipe_call(elem_var, expr)
end
{:|>, pipe_meta, [lhs, replacement]}
else
_ -> node
end
end
defp maybe_rewrite(node), do: node
defp empty_string_literal?(""), do: true
defp empty_string_literal?({:__block__, _, [""]}), do: true
defp empty_string_literal?(_), do: false
# `acc <> elem` — RHS of <> is exactly the elem var, so `Enum.join` suffices.
defp simple_identity?({elem_name, _, ctx1}, {right_name, _, ctx2})
when is_atom(elem_name) and is_atom(right_name) and
(is_nil(ctx1) or is_atom(ctx1)) and (is_nil(ctx2) or is_atom(ctx2)) do
elem_name == right_name
end
defp simple_identity?(_, _), do: false
defp enum_join_call(list) do
{{:., [], [{:__aliases__, [], [:Enum]}, :join]}, [], [list]}
end
defp enum_join_pipe_call do
{{:., [], [{:__aliases__, [], [:Enum]}, :join]}, [], []}
end
defp enum_map_join_call(list, elem_var, expr) do
{{:., [], [{:__aliases__, [], [:Enum]}, :map_join]}, [], [list, build_lambda(elem_var, expr)]}
end
defp enum_map_join_pipe_call(elem_var, expr) do
{{:., [], [{:__aliases__, [], [:Enum]}, :map_join]}, [], [build_lambda(elem_var, expr)]}
end
defp build_lambda(elem_var, body) do
{:fn, [], [{:->, [], [[elem_var], body]}]}
end
defp extract_simple_concat(
{:fn, _,
[
{:->, _,
[
[{_elem_ctx, _, _} = elem_var, {acc_name, _, _}],
{:<>, _, [left, right]}
]}
]}
) do
case left do
{^acc_name, _, _} ->
if references_var?(right, acc_name) do
:error
else
{:ok, elem_var, right}
end
_ ->
:error
end
end
# Block body: fn elem, acc -> stmts...; acc <> expr end
defp extract_simple_concat(
{:fn, _,
[
{:->, _,
[
[{_elem_ctx, _, _} = elem_var, {acc_name, _, _}],
{:__block__, _, stmts}
]}
]}
)
when length(stmts) > 1 do
{preceding, [last]} = Enum.split(stmts, -1)
case last do
{:<>, _, [left, right]} ->
if match?({^acc_name, _, _}, left) and
not references_var?(right, acc_name) and
not Enum.any?(preceding, &references_var?(&1, acc_name)) do
new_body = {:__block__, [], preceding ++ [right]}
{:ok, elem_var, new_body}
else
:error
end
_ ->
:error
end
end
defp extract_simple_concat(_), do: :error
defp references_var?(ast, name) do
{_ast, found} =
Macro.prewalk(ast, false, fn
{^name, _, _} = node, _acc -> {node, true}
node, acc -> {node, acc}
end)
found
end
defp build_issue(meta) do
%Issue{
rule: :no_string_concat_in_loop,
message:
"Avoid `<>` string concatenation inside `Enum.reduce` with an empty " <>
"string accumulator — each concatenation copies the entire accumulated " <>
"binary (O(n²)). Use `Enum.join/1` or `Enum.map_join/2` instead.",
meta: %{line: Keyword.get(meta, :line)}
}
end
end