Current section

Files

Jump to
credence lib pattern avoid_graphemes_enum_count_with_predicate.ex
Raw

lib/pattern/avoid_graphemes_enum_count_with_predicate.ex

defmodule Credence.Pattern.AvoidGraphemesEnumCountWithPredicate do
@moduledoc """
Performance rule: Detects `Enum.count/2` with an equality predicate or
`Enum.sum_by/2` with a counting function on the result of `String.graphemes/1`.
Splitting a string into a grapheme list just to count occurrences of a
specific character is wasteful. `String.count/2` performs the same count
directly on the string without allocating the intermediate list.
## Safety: behind a switch
The rewrite is identical to the original only while every character in the
*running data* is a single codepoint — otherwise `String.count/2` (which scans
raw text) and the grapheme comparison disagree on a decomposed accent. So this
rule needs `single_codepoint_graphemes` (see `Credence.Assumptions`) and runs
only while that promise is on.
Two guards keep the rewrite honest (decision 6a — shrink first, then promise):
1. The compared literal is narrowed to a **single codepoint**
(`length(String.to_charlist(lit)) == 1`), dropping `""`, `"ab"`, and the
two-codepoint form of `"é"`. After that narrowing the only remaining
difference between old and new is the rare decomposed-accent case in the
*data* — which is exactly what the promise covers.
2. A shared `single_codepoint?/1` helper gates both `check` and `fix`, so they
can never disagree on which literals qualify.
## Bad
String.graphemes(str) |> Enum.count(&(&1 == "1"))
Enum.count(String.graphemes(str), &(&1 == "1"))
str |> String.graphemes() |> Enum.count(fn c -> c == "1" end)
String.graphemes(str) |> Enum.sum_by(fn "1" -> 1; _ -> 0 end)
Enum.sum_by(String.graphemes(str), fn "1" -> 1; _ -> 0 end)
## Good
String.count(str, "1")
str |> String.count("1")
"""
use Credence.Pattern.Rule
alias Credence.Issue
@impl true
def assumptions, do: [:single_codepoint_graphemes]
@impl true
def check(ast, _opts) do
{_ast, issues} =
Macro.prewalk(ast, [], fn
# Pipe form: ... |> Enum.count(predicate)
{:|>, meta, [lhs, rhs]} = node, issues ->
with {:ok, _literal} <- extract_enum_count_pred_literal(rhs),
true <- immediate_graphemes?(lhs) do
{node, [build_issue(meta) | issues]}
else
_ ->
# Pipe form: ... |> Enum.sum_by(fn literal -> 1; _ -> 0 end)
with {:ok, _literal} <- extract_sum_by_counting_literal(rhs),
true <- immediate_graphemes?(lhs) do
{node, [build_issue(meta) | issues]}
else
_ -> {node, issues}
end
end
# Direct: Enum.count(String.graphemes(...), predicate)
{{:., meta, [{:__aliases__, _, [:Enum]}, :count]}, _, [arg, pred]} = node, issues ->
with {:ok, _literal} <- equality_literal(pred),
true <- graphemes_call?(arg) do
{node, [build_issue(meta) | issues]}
else
_ -> {node, issues}
end
# Direct: Enum.sum_by(String.graphemes(...), fn literal -> 1; _ -> 0 end)
{{:., meta, [{:__aliases__, _, [:Enum]}, :sum_by]}, _, [arg, fn_ast]} = node, issues ->
with {:ok, _literal} <- sum_by_counting_literal(fn_ast),
true <- graphemes_call?(arg) do
{node, [build_issue(meta) | 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
# Pipe: ... |> String.graphemes() |> Enum.count(pred)
{:|>, _, [lhs, rhs]} = node ->
with {:ok, literal} <- extract_enum_count_pred_literal(rhs),
true <- immediate_graphemes?(lhs) do
fix_pipe(lhs, literal)
else
_ ->
# Pipe: ... |> String.graphemes() |> Enum.sum_by(fn literal -> 1; _ -> 0 end)
with {:ok, literal} <- extract_sum_by_counting_literal(rhs),
true <- immediate_graphemes?(lhs) do
fix_pipe(lhs, literal)
else
_ -> node
end
end
# Direct: Enum.count(String.graphemes(x), pred)
{{:., _, [{:__aliases__, _, [:Enum]}, :count]}, _, [arg, pred]} = node ->
with {:ok, literal} <- equality_literal(pred),
{:ok, subject} <- extract_graphemes_arg(arg) do
string_count_call(subject, literal)
else
_ -> node
end
# Direct: Enum.sum_by(String.graphemes(x), fn literal -> 1; _ -> 0 end)
{{:., _, [{:__aliases__, _, [:Enum]}, :sum_by]}, _, [arg, fn_ast]} = node ->
with {:ok, literal} <- sum_by_counting_literal(fn_ast),
{:ok, subject} <- extract_graphemes_arg(arg) do
string_count_call(subject, literal)
else
_ -> node
end
node ->
node
end)
end
# String.graphemes(x) |> Enum.count(pred) → String.count(x, literal)
defp fix_pipe(
{{:., _, [{:__aliases__, _, [:String]}, :graphemes]}, _, [subject]},
literal
) do
string_count_call(subject, literal)
end
# x |> String.graphemes() |> Enum.count(pred)
# → String.count(x, literal) when x is a simple expression
# → x |> String.count(literal) when x is an upstream pipeline
defp fix_pipe(
{:|>, pipe_meta, [deeper, {{:., _, [{:__aliases__, _, [:String]}, :graphemes]}, _, _}]},
literal
) do
case deeper do
{:|>, _, _} ->
{:|>, pipe_meta,
[deeper, {{:., [], [{:__aliases__, [], [:String]}, :count]}, [], [literal]}]}
_ ->
string_count_call(deeper, literal)
end
end
defp fix_pipe(lhs, _literal), do: lhs
defp string_count_call(subject, literal) do
{{:., [], [{:__aliases__, [], [:String]}, :count]}, [], [subject, literal]}
end
# Extract literal from Enum.count/2 in a pipe (only pred arg present)
defp extract_enum_count_pred_literal({{:., _, [{:__aliases__, _, [:Enum]}, :count]}, _, [pred]}) do
equality_literal(pred)
end
defp extract_enum_count_pred_literal(_), do: :error
# Extract literal from Enum.sum_by/2 in a pipe (only fn arg present)
defp extract_sum_by_counting_literal(
{{:., _, [{:__aliases__, _, [:Enum]}, :sum_by]}, _, [fn_ast]}
) do
sum_by_counting_literal(fn_ast)
end
defp extract_sum_by_counting_literal(_), do: :error
# Match fn literal -> 1; _ -> 0 end (two-clause counting function)
defp sum_by_counting_literal(
{:fn, _,
[
{:->, _, [[{:__block__, _, [literal]}], {:__block__, _, [1]}]},
{:->, _, [[{:_, _, _}], {:__block__, _, [0]}]}
]}
)
when is_binary(literal),
do: ok_if_single_codepoint(literal)
# Same but with variable instead of underscore in catch-all
defp sum_by_counting_literal(
{:fn, _,
[
{:->, _, [[{:__block__, _, [literal]}], {:__block__, _, [1]}]},
{:->, _, [[{_var, _, _}], {:__block__, _, [0]}]}
]}
)
when is_binary(literal),
do: ok_if_single_codepoint(literal)
defp sum_by_counting_literal(_), do: :error
# Match &(&1 == literal) or &(&1 === literal)
defp equality_literal({:&, _, [{op, _, [{:&, _, [1]}, {:__block__, _, [literal]}]}]})
when op in [:==, :===] and is_binary(literal),
do: ok_if_single_codepoint(literal)
# Match fn c -> c == literal end or fn c -> c === literal end
defp equality_literal(
{:fn, _,
[
{:->, _,
[
[{var, _, _}],
{op, _, [{var, _, _}, {:__block__, _, [literal]}]}
]}
]}
)
when op in [:==, :===] and is_binary(literal) and is_atom(var),
do: ok_if_single_codepoint(literal)
defp equality_literal(_), do: :error
# The shrink-first narrowing (decision 6a), shared by check and fix so they
# never disagree: only single-codepoint literals qualify, leaving the promise
# to cover only the rare-data difference. Drops "", "ab", and the two-codepoint
# form of "é"; keeps "a", " ", and the precomposed one-codepoint "é".
defp ok_if_single_codepoint(literal) do
if single_codepoint?(literal), do: {:ok, literal}, else: :error
end
defp single_codepoint?(literal) when is_binary(literal) do
length(String.to_charlist(literal)) == 1
end
defp extract_graphemes_arg({{:., _, [{:__aliases__, _, [:String]}, :graphemes]}, _, [subject]}),
do: {:ok, subject}
defp extract_graphemes_arg(_), do: :error
defp immediate_graphemes?({:|>, _, [_, rhs]}), do: graphemes_call?(rhs)
defp immediate_graphemes?(other), do: graphemes_call?(other)
defp graphemes_call?({{:., _, [{:__aliases__, _, [:String]}, :graphemes]}, _, args})
when is_list(args),
do: true
defp graphemes_call?(_), do: false
defp build_issue(meta) do
%Issue{
rule: :avoid_graphemes_enum_count_with_predicate,
message: """
`String.graphemes/1` allocates an intermediate list of every grapheme \
just to count occurrences of a specific character. `String.count/2` \
performs the same count directly on the string without the allocation.
Replace the pattern with `String.count/2`:
# Before (allocates a list):
String.graphemes(str) |> Enum.count(&(&1 == "1"))
Enum.count(String.graphemes(str), &(&1 == "1"))
# After (no intermediate list):
String.count(str, "1")
""",
meta: %{line: Keyword.get(meta, :line)}
}
end
end