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 {:def, meta, [{:when, _, [_call, guard]} | _rest]} = node, issues -> {node, find_fixable_length(guard, meta, issues)} {:defp, meta, [{:when, _, [_call, guard]} | _rest]} = node, issues -> {node, find_fixable_length(guard, meta, issues)} node, issues -> {node, issues} end) Enum.reverse(issues) end @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 find_fixable_length(guard_ast, def_meta, acc) do {_ast, issues} = Macro.prewalk(guard_ast, acc, fn # length(var) > 0 {:>, meta, [{:length, _, [_var]}, n_node]} = node, issues -> if unwrap_int(n_node) == 0 do line = Keyword.get(meta, :line) || Keyword.get(def_meta, :line) {node, [build_issue(:non_empty, line) | issues]} else {node, issues} end # length(var) == N where N in 1..5 {:==, meta, [{:length, _, [_var]}, n_node]} = node, issues -> case unwrap_int(n_node) do n when is_integer(n) and n >= 1 and n <= 5 -> line = Keyword.get(meta, :line) || Keyword.get(def_meta, :line) {node, [build_issue({:exact, n}, line) | issues]} _ -> {node, issues} end node, issues -> {node, issues} end) issues end defp unwrap_int({:__block__, _, [n]}) when is_integer(n), do: n defp unwrap_int(_), do: nil 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