Current section

Files

Jump to
credence lib pattern no_length_guard_to_pattern.ex
Raw

lib/pattern/no_length_guard_to_pattern.ex

defmodule Credence.Pattern.NoLengthGuardToPattern do
@moduledoc """
Refactoring rule: Detects guards that check list length with a literal
comparison that can be replaced by a pattern match in the function head.
Covers two forms:
* `length(var) > 0` — non-empty check, replaceable with `[_ | _]`
* `length(var) == N` for N in 1..5 — exact-size check, replaceable with
`[_, _, ...]`
Pattern matching is O(1) and idiomatic, while `length/1` traverses the
entire list.
## Bad
def process(list) when length(list) > 0 do
Enum.sum(list)
end
defp triplet(list) when length(list) == 3 do
List.to_tuple(list)
end
## Good
def process([_ | _] = list) do
Enum.sum(list)
end
defp triplet([_, _, _] = list) do
List.to_tuple(list)
end
"""
use Credence.Pattern.Rule
alias Credence.Issue
@impl true
def check(ast, _opts) do
{_ast, issues} =
Macro.prewalk(ast, [], fn
{kind, meta, [{:when, _, [call, guard]} | _rest]} = node, issues
when kind in [:def, :defp] ->
{node, flag_if_fixable(call, guard, meta, issues)}
node, issues ->
{node, issues}
end)
Enum.reverse(issues)
end
# Flag only what the fix can actually rewrite: a `length(var) > 0` / `== N`
# guard whose `var` is a top-level function parameter. Mirroring the fix
# (`extract_fixable_check` + the param check in `replace_param`) keeps check and
# fix in agreement, so the rule never flags a guard it would then no-op on
# (e.g. a `length(x) == 1` on a variable captured deep inside a pattern).
defp flag_if_fixable(call, guard, def_meta, acc) do
with {:ok, var, pattern_kind, _remaining} <- extract_fixable_check(guard),
true <- var_is_param?(call, var) do
[build_issue(pattern_kind, Keyword.get(def_meta, :line)) | acc]
else
_ -> acc
end
end
defp var_is_param?({_name, _meta, params}, var) when is_list(params),
do: Enum.any?(params, &same_var?(&1, var))
defp var_is_param?(_call, _var), do: false
@impl true
def fix_patches(ast, _opts) do
Credence.RuleHelpers.patches_from_postwalk(ast, fn
{:def, meta, [{:when, when_meta, [call, guard]} | rest]} = node ->
try_fix_def(:def, meta, when_meta, call, guard, rest, node)
{:defp, meta, [{:when, when_meta, [call, guard]} | rest]} = node ->
try_fix_def(:defp, meta, when_meta, call, guard, rest, node)
node ->
node
end)
end
# Check helpers
defp build_issue(:non_empty, line) do
%Issue{
rule: :no_length_guard_to_pattern,
message:
"`length(list) > 0` in a guard traverses the entire list. " <>
"Use `[_ | _] = list` pattern matching instead — it is O(1).",
meta: %{line: line}
}
end
defp build_issue({:exact, n}, line) do
underscores = List.duplicate("_", n) |> Enum.join(", ")
%Issue{
rule: :no_length_guard_to_pattern,
message:
"`length(list) == #{n}` in a guard traverses the entire list. " <>
"Use `[#{underscores}] = list` pattern matching instead — it is O(1).",
meta: %{line: line}
}
end
# Fix helpers
defp try_fix_def(kind, meta, when_meta, call, guard, rest, original) do
case extract_fixable_check(guard) do
{:ok, var, pattern_kind, remaining_guard} ->
pattern = build_match_pattern(pattern_kind)
case replace_param(call, var, pattern) do
{:ok, new_call} ->
case remaining_guard do
nil -> {kind, meta, [new_call | rest]}
other -> {kind, meta, [{:when, when_meta, [new_call, other]} | rest]}
end
:error ->
original
end
:error ->
original
end
end
# length(var) > 0
defp extract_fixable_check({:>, _, [{:length, _, [var]}, zero]}) do
with {:ok, 0} <- extract_int(zero),
true <- simple_var?(var) do
{:ok, var, :non_empty, nil}
else
_ -> :error
end
end
# length(var) == N (1..5)
defp extract_fixable_check({:==, _, [{:length, _, [var]}, n_ast]}) do
with {:ok, n} <- extract_int(n_ast),
true <- n >= 1 and n <= 5,
true <- simple_var?(var) do
{:ok, var, {:exact, n}, nil}
else
_ -> :error
end
end
# Compound guard: left and right — extract from either side
defp extract_fixable_check({:and, _, [left, right]}) do
case extract_fixable_check(left) do
{:ok, var, kind, nil} ->
{:ok, var, kind, right}
_ ->
case extract_fixable_check(right) do
{:ok, var, kind, nil} -> {:ok, var, kind, left}
_ -> :error
end
end
end
defp extract_fixable_check(_), do: :error
defp extract_int({:__block__, _, [n]}) when is_integer(n), do: {:ok, n}
defp extract_int(_), do: :error
defp simple_var?({name, _, ctx}) when is_atom(name) and (is_nil(ctx) or is_atom(ctx)),
do: true
defp simple_var?(_), do: false
defp same_var?({name, _, _}, {name, _, _}) when is_atom(name), do: true
defp same_var?(_, _), do: false
defp replace_param({func_name, func_meta, params}, var, pattern) do
if Enum.any?(params, &same_var?(&1, var)) do
new_params =
Enum.map(params, fn param ->
if same_var?(param, var), do: {:=, [], [pattern, param]}, else: param
end)
{:ok, {func_name, func_meta, new_params}}
else
:error
end
end
# [_ | _]
defp build_match_pattern(:non_empty) do
[{:|, [], [{:_, [], nil}, {:_, [], nil}]}]
end
# [_, _, ...] with exactly n underscores
defp build_match_pattern({:exact, n}) do
List.duplicate({:_, [], nil}, n)
end
end