Current section
Files
Jump to
Current section
Files
lib/pattern/no_length_comparison_for_empty.ex
defmodule Credence.Pattern.NoLengthComparisonForEmpty do
@moduledoc """
Detects `length(list)` comparisons with small integers (0–5) that can be
replaced with O(1) pattern matching.
`length/1` is O(n) on linked lists — it traverses every element to count
them. LLMs use it freely because Python's `len()` is O(1). In Elixir,
pattern matching can answer the same questions in O(1).
## Bad
length(list) == 0
length(list) > 0
length(list) < 2
length(list) >= 3
## Good
list == []
list != []
!match?([_, _ | _], list)
match?([_, _, _ | _], list)
## What is flagged
Any comparison of `length(expr)` with a literal integer 0–5 using
`==`, `!=`, `>`, `>=`, `<`, or `<=`. Reversed operands like
`0 < length(list)` are also detected. Comparisons with larger
integers are not flagged since the match pattern becomes unwieldy.
## Auto-fix
Rewrites to `== []`, `!= []`, `match?/2`, or `!match?/2` depending
on the comparison. Only fixes when the argument to `length/1` is a
simple variable name.
"""
use Credence.Pattern.Rule
alias Credence.Issue
@max_n 5
@impl true
def fixable?, do: true
@impl true
def check(ast, _opts) do
{_ast, issues} =
Macro.prewalk(ast, [], fn node, acc ->
case detect_pattern(node) do
{:ok, meta} -> {node, [build_issue(meta) | acc]}
:skip -> {node, acc}
end
end)
Enum.reverse(issues)
end
@impl true
def fix(source, _opts) do
source
|> String.split("\n")
|> Enum.map(&fix_line/1)
|> Enum.join("\n")
end
# ── Detection ───────────────────────────────────────────────────
# length(x) op N — only flag simple variables (matching what fix can handle)
defp detect_pattern({op, meta, [{:length, _, [arg]}, n]})
when is_integer(n) and op in [:==, :!=, :>, :>=, :<, :<=] do
if simple_var?(arg) and valid_comparison?(op, n), do: {:ok, meta}, else: :skip
end
# N op length(x) — reversed operand, same simple-variable restriction
defp detect_pattern({op, meta, [n, {:length, _, [arg]}]})
when is_integer(n) and op in [:==, :!=, :>, :>=, :<, :<=] do
rev = reverse_op(op)
if rev && simple_var?(arg) and valid_comparison?(rev, n), do: {:ok, meta}, else: :skip
end
defp detect_pattern(_), do: :skip
# A simple variable in AST is {name, meta, context} where name is an atom
# and context is nil or an atom. This excludes captures (&1), function calls
# (hd(x)), dot-calls (Map.get(m, k)), etc. that the regex-based fix can't
# rewrite.
defp simple_var?({name, _meta, ctx}) when is_atom(name) and is_atom(ctx), do: true
defp simple_var?(_), do: false
# Check whether {op, n} is in our fixable range.
# Each case generates a pattern with at most @max_n underscores.
defp valid_comparison?(:==, n), do: n in 0..@max_n
defp valid_comparison?(:!=, n), do: n in 0..@max_n
# > N means "at least N+1" → need N+1 underscores → N+1 <= @max_n
defp valid_comparison?(:>, n), do: n >= 0 and n + 1 <= @max_n
# >= N means "at least N" → need N underscores → N in 1..@max_n
defp valid_comparison?(:>=, n), do: n in 1..@max_n
# < N means "fewer than N" → need N underscores → N in 1..@max_n
defp valid_comparison?(:<, n), do: n in 1..@max_n
# <= N means "fewer than N+1" → need N+1 underscores → N+1 <= @max_n
defp valid_comparison?(:<=, n), do: n >= 0 and n + 1 <= @max_n
defp valid_comparison?(_, _), do: false
defp reverse_op(:==), do: :==
defp reverse_op(:!=), do: :!=
defp reverse_op(:>), do: :<
defp reverse_op(:<), do: :>
defp reverse_op(:>=), do: :<=
defp reverse_op(:<=), do: :>=
defp reverse_op(_), do: nil
# ── Fix ─────────────────────────────────────────────────────────
defp fix_line(line) do
line
# length(var) op N
|> replace_forward()
# N op length(var)
|> replace_reversed()
end
defp replace_forward(line) do
Regex.replace(
~r/length\((\w+)\)\s*(==|!=|>=|<=|>|<)\s*(\d+)/,
line,
fn _full, var, op, n_str ->
n = String.to_integer(n_str)
build_replacement(var, String.to_existing_atom(op), n) ||
"length(#{var}) #{op} #{n}"
end
)
end
defp replace_reversed(line) do
Regex.replace(
~r/(\d+)\s*(==|!=|>=|<=|>|<)\s*length\((\w+)\)/,
line,
fn _full, n_str, op, var ->
n = String.to_integer(n_str)
rev = reverse_op(String.to_existing_atom(op))
(rev && build_replacement(var, rev, n)) ||
"#{n} #{op} length(#{var})"
end
)
end
# ── Replacement builders ────────────────────────────────────────
# "exactly N"
defp build_replacement(var, :==, 0), do: "#{var} == []"
defp build_replacement(var, :==, n) when n in 1..@max_n,
do: "match?(#{exact_pattern(n)}, #{var})"
# "not exactly N"
defp build_replacement(var, :!=, 0), do: "#{var} != []"
defp build_replacement(var, :!=, n) when n in 1..@max_n,
do: "!match?(#{exact_pattern(n)}, #{var})"
# "at least N" (>= N)
defp build_replacement(var, :>=, n) when n in 1..@max_n,
do: at_least(var, n)
# "at least N+1" (> N)
defp build_replacement(var, :>, n) when n >= 0 and n + 1 <= @max_n,
do: at_least(var, n + 1)
# "fewer than N" (< N)
defp build_replacement(var, :<, n) when n in 1..@max_n,
do: fewer_than(var, n)
# "fewer than N+1" (<= N)
defp build_replacement(var, :<=, n) when n >= 0 and n + 1 <= @max_n,
do: fewer_than(var, n + 1)
defp build_replacement(_, _, _), do: nil
defp at_least(var, 1), do: "#{var} != []"
defp at_least(var, n), do: "match?(#{at_least_pattern(n)}, #{var})"
defp fewer_than(var, 1), do: "#{var} == []"
defp fewer_than(var, n), do: "!match?(#{at_least_pattern(n)}, #{var})"
# ── Pattern generators ─────────────────────────────────────────
# [_, _, _] — exactly N elements
defp exact_pattern(n) do
innards = List.duplicate("_", n) |> Enum.join(", ")
"[#{innards}]"
end
# [_, _, _ | _] — at least N elements
defp at_least_pattern(n) do
innards = List.duplicate("_", n) |> Enum.join(", ")
"[#{innards} | _]"
end
# ── Issue ───────────────────────────────────────────────────────
defp build_issue(meta) do
%Issue{
rule: :no_length_comparison_for_empty,
message: """
`length/1` is O(n) on linked lists — it traverses every element \
just to compare with a small number.
Use pattern matching instead, which is O(1):
list == [] # empty
list != [] # non-empty
match?([_, _ | _], list) # at least 2
match?([_, _, _], list) # exactly 3
""",
meta: %{line: Keyword.get(meta, :line)}
}
end
end