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 fixable?, do: true
@impl true
def check(ast, _opts) do
{_ast, issues} =
Macro.prewalk(ast, [], fn
# Match: String.length(x) == 1, String.length(x) != 1, etc.
{op, _meta,
[
{{:., _, [{:__aliases__, _, [:String]}, :length]}, _, [_arg]},
1
]} = node,
issues
when op in [:==, :!=, :===, :!==] ->
{node, [build_issue(node) | issues]}
# Match the reversed form: 1 == String.length(x)
{op, _meta,
[
1,
{{:., _, [{:__aliases__, _, [:String]}, :length]}, _, [_arg]}
]} = node,
issues
when op in [:==, :!=, :===, :!==] ->
{node, [build_issue(node) | issues]}
node, issues ->
{node, issues}
end)
Enum.reverse(issues)
end
@impl true
def fix(source, _opts) do
source
|> Code.string_to_quoted!()
|> Macro.postwalk(fn
# Standard form: String.length(x) op 1
{op, _meta,
[
{{:., _, [{:__aliases__, _, [:String]}, :length]}, _, [arg]},
1
]}
when op in [:==, :!=, :===, :!==] ->
build_fix(op, arg)
# Reversed form: 1 op String.length(x)
{op, _meta,
[
1,
{{:., _, [{:__aliases__, _, [:String]}, :length]}, _, [arg]}
]}
when op in [:==, :!=, :===, :!==] ->
build_fix(op, arg)
node ->
node
end)
|> Sourceror.to_string()
end
# ==, === → 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