Current section

Files

Jump to
credence lib pattern no_string_length_for_char_check.ex
Raw

lib/pattern/no_string_length_for_char_check.ex

defmodule Credence.Pattern.NoStringLengthForCharCheck do
@moduledoc """
Performance rule: Detects `String.length(x) == 1` (or `!= 1`) used to
validate that a string is a single character.
`String.length/1` traverses the entire string to count grapheme clusters,
making it O(n). For a simple single-character check, pattern matching on
the result of `String.graphemes/1` is more expressive and idiomatic.
This rule automatically rewrites the comparison to use `match?/2` with
`String.graphemes/1`, which produces a clean boolean result that works
in any expression context.
## Bad
if String.length(target_char) != 1 do
raise ArgumentError, "expected a single character"
end
String.length(s) == 1
1 === String.length(s)
## Good
if not match?([_], String.graphemes(target_char)) do
raise ArgumentError, "expected a single character"
end
match?([_], String.graphemes(s))
"""
use Credence.Pattern.Rule
alias Credence.Issue
@impl true
def check(ast, _opts) do
{_ast, issues} =
Macro.prewalk(ast, [], fn
{op, _meta, [lhs, rhs]} = node, issues when op in [:==, :!=, :===, :!==] ->
cond do
string_length_call?(lhs) and int_one?(rhs) -> {node, [build_issue(node) | issues]}
int_one?(lhs) and string_length_call?(rhs) -> {node, [build_issue(node) | issues]}
true -> {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
{op, _meta, [lhs, rhs]} = node when op in [:==, :!=, :===, :!==] ->
cond do
string_length_call?(lhs) and int_one?(rhs) -> build_fix(op, length_arg(lhs))
int_one?(lhs) and string_length_call?(rhs) -> build_fix(op, length_arg(rhs))
true -> node
end
node ->
node
end)
end
defp string_length_call?({{:., _, [{:__aliases__, _, [:String]}, :length]}, _, [_]}), do: true
defp string_length_call?(_), do: false
defp length_arg({{:., _, [{:__aliases__, _, [:String]}, :length]}, _, [arg]}), do: arg
defp int_one?(1), do: true
defp int_one?({:__block__, _, [1]}), do: true
defp int_one?(_), do: false
# ==, === → match?([_], String.graphemes(x))
# !=, !== → not match?([_], String.graphemes(x))
defp build_fix(op, arg) when op in [:==, :===] do
match_graphemes_one(arg)
end
defp build_fix(op, arg) when op in [:!=, :!==] do
{:not, [], [match_graphemes_one(arg)]}
end
defp match_graphemes_one(arg) do
graphemes_call = {{:., [], [{:__aliases__, [], [:String]}, :graphemes]}, [], [arg]}
{:match?, [], [[{:_, [], nil}], graphemes_call]}
end
defp build_issue(node) do
meta = elem(node, 1)
%Issue{
rule: :no_string_length_for_char_check,
message:
"`String.length/1` traverses the entire string (O(n)) just to check for a single character. " <>
"Use pattern matching (e.g. `<<_::utf8>>`) or `String.graphemes/1` with a match instead.",
meta: %{line: Keyword.get(meta, :line)}
}
end
end