Current section

Files

Jump to
credence lib rule avoid_graphemes_length.ex
Raw

lib/rule/avoid_graphemes_length.ex

defmodule Credence.Rule.AvoidGraphemesLength do
@moduledoc """
Performance rule: warns when `String.graphemes/1 |> length()` is used.
`String.length/1` is significantly more efficient. `String.graphemes/1`
allocates a list of all graphemes in memory, which is then traversed by
`length/1` and immediately garbage collected.
`String.length/1` avoids building this intermediate list.
"""
@behaviour Credence.Rule
alias Credence.Issue
@impl true
def check(ast, _opts) do
{_ast, issues} =
Macro.prewalk(ast, [], fn
# Match: ... |> length()
{:|>, meta, [lhs, {:length, _, _}]} = node, issues ->
if immediate_graphemes?(lhs) do
{node, [trigger_issue(meta) | issues]}
else
{node, issues}
end
# Match: length(String.graphemes(...))
{:length, meta, [arg]} = node, issues ->
if graphemes_call?(arg) do
{node, [trigger_issue(meta) | issues]}
else
{node, issues}
end
node, issues ->
{node, issues}
end)
Enum.reverse(issues)
end
# Ensure graphemes is the immediate previous pipeline step
defp immediate_graphemes?({:|>, _, [_, rhs]}),
do: graphemes_call?(rhs)
defp immediate_graphemes?(other),
do: graphemes_call?(other)
# Relaxed match for String.graphemes/1
defp graphemes_call?({{:., _, [{:__aliases__, _, [:String]}, :graphemes]}, _, args})
when is_list(args),
do: true
defp graphemes_call?(_), do: false
defp trigger_issue(meta) do
%Issue{
rule: :avoid_graphemes_length,
severity: :warning,
message: """
Use `String.length/1` instead of counting `String.graphemes/1`.
`String.graphemes/1` builds an intermediate list that is immediately
discarded, while `String.length/1` avoids this allocation.
""",
meta: %{line: Keyword.get(meta, :line)}
}
end
end