Current section
Files
Jump to
Current section
Files
lib/pattern/prefer_counts_for_length.ex
defmodule Credence.Pattern.PreferCountsForLength do
@moduledoc """
Detects `length(String.codepoints(string))` when a `counts` map from
`Enum.frequencies/1` on the same codepoints already exists.
`String.codepoints/1` traverses the string once for `Enum.frequencies/1`
and a second time for `length/1`. Derive `n` from the existing counts map
via `Enum.sum(Map.values(counts))` to avoid the redundant traversal.
## Bad
counts = string |> String.codepoints() |> Enum.frequencies()
n = length(String.codepoints(string))
## Good
counts = string |> String.codepoints() |> Enum.frequencies()
n = Enum.sum(Map.values(counts))
"""
use Credence.Pattern.Rule
alias Credence.Issue
alias Credence.RuleHelpers
@impl true
def check(ast, _opts) do
for {_counts_var, length_stmt} <- collect_pairs(ast) do
%Issue{
rule: :prefer_counts_for_length,
message:
"Redundant `String.codepoints/1` traversal. " <>
"Use `Enum.sum(Map.values(counts))` instead of " <>
"`length(String.codepoints(...))` when a frequency map already exists.",
meta: %{line: length_line(length_stmt)}
}
end
end
# Find every (counts-assignment, length-statement) pair that the fix would
# rewrite. Scoped to a single `__block__`'s straight-line statement list so a
# `counts`/`string` defined in one scope never matches a `length` call in an
# unrelated one. check and fix share this so they always agree.
defp collect_pairs(ast) do
{_ast, pairs} =
Macro.prewalk(ast, [], fn
{:__block__, _, statements} = node, acc when is_list(statements) ->
{node, acc ++ scan_block(statements)}
node, acc ->
{node, acc}
end)
pairs
end
defp scan_block([]), do: []
defp scan_block([stmt | rest]) do
with {:ok, counts_var, string_var} <- extract_counts_assignment(stmt),
{:ok, length_stmt, remaining} <- find_length_codepoints(counts_var, string_var, rest) do
[{counts_var, length_stmt} | scan_block(remaining)]
else
_ -> scan_block(rest)
end
end
defp length_line({:=, _, [_, {:length, meta, _}]}), do: Keyword.get(meta, :line)
@impl true
def fix_patches(ast, opts) do
RuleHelpers.patches_from_ast_transform(ast, Keyword.get(opts, :source, ""), fn ast ->
transform_ast(ast)
end)
end
defp transform_ast({:__block__, meta, statements}) do
transformed = Enum.map(statements, &transform_ast/1)
{:__block__, meta, transform_block(transformed)}
end
defp transform_ast([{_, _} | _] = kw) do
Enum.map(kw, fn {k, v} -> {k, transform_ast(v)} end)
end
defp transform_ast(node) when is_tuple(node) do
node
|> Tuple.to_list()
|> Enum.map(&transform_ast/1)
|> List.to_tuple()
end
defp transform_ast(node) when is_list(node) do
Enum.map(node, &transform_ast/1)
end
defp transform_ast(node), do: node
defp transform_block([]), do: []
defp transform_block([stmt | rest]) do
with {:ok, counts_var, string_var} <- extract_counts_assignment(stmt),
{:ok, length_line, remaining} <- find_length_codepoints(counts_var, string_var, rest) do
replacement = build_replacement(length_line, counts_var)
[stmt, replacement | transform_block(remaining)]
else
_ -> [stmt | transform_block(rest)]
end
end
defp extract_counts_assignment(
{:=, _,
[
{counts_var, _, nil},
{:|>, _,
[
{:|>, _, [{string_var, _, nil}, codepoints_call]},
frequencies_call
]}
]}
)
when is_atom(counts_var) and is_atom(string_var) do
if frequencies_call?(frequencies_call) and codepoints_call?(codepoints_call) do
{:ok, counts_var, string_var}
else
:error
end
end
defp extract_counts_assignment(_), do: :error
defp find_length_codepoints(_counts_var, _string_var, []), do: :not_found
defp find_length_codepoints(counts_var, string_var, [stmt | rest]) do
cond do
# `counts` reassigned or otherwise touched before the `length` call would
# make `Enum.sum(Map.values(counts))` read a different map. Bail out so the
# rewrite only fires when `counts` is unchanged at the call site.
references_var?(stmt, counts_var) ->
:not_found
references_var?(stmt, string_var) ->
case extract_length_codepoints(stmt, string_var) do
{:ok, _var} -> {:ok, stmt, rest}
:not_found -> :not_found
end
true ->
with {:ok, result, remaining} <- find_length_codepoints(counts_var, string_var, rest) do
{:ok, result, [stmt | remaining]}
end
end
end
defp extract_length_codepoints(
{:=, _,
[
{assign_var, _, nil},
{:length, _,
[
{{:., _, [{:__aliases__, _, [:String]}, :codepoints]}, _, [{string_var, _, nil}]}
]}
]},
string_var
)
when is_atom(assign_var) and is_atom(string_var) do
if assign_var != string_var do
{:ok, assign_var}
else
:not_found
end
end
defp extract_length_codepoints(_, _), do: :not_found
defp build_replacement(original_stmt, counts_var) do
{:=, meta, [{assign_var, assign_meta, nil}, _length_expr]} = original_stmt
new_rhs = enum_sum_map_values_call(counts_var)
{:=, meta, [{assign_var, assign_meta, nil}, new_rhs]}
end
defp enum_sum_map_values_call(counts_var) do
{{:., [], [{:__aliases__, [], [:Enum]}, :sum]}, [],
[
{{:., [], [{:__aliases__, [], [:Map]}, :values]}, [], [{counts_var, [], nil}]}
]}
end
defp references_var?(ast, var_name) do
{_, found} =
Macro.prewalk(ast, false, fn
{^var_name, _, nil} = node, _acc -> {node, true}
node, acc -> {node, acc}
end)
found
end
defp codepoints_call?({{:., _, [{:__aliases__, _, [:String]}, :codepoints]}, _, _}), do: true
defp codepoints_call?(_), do: false
defp frequencies_call?({{:., _, [{:__aliases__, _, [:Enum]}, :frequencies]}, _, _}), do: true
defp frequencies_call?(_), do: false
end