Current section

Files

Jump to
credence lib pattern no_case_tuple_guard_dispatch.ex
Raw

lib/pattern/no_case_tuple_guard_dispatch.ex

defmodule Credence.Pattern.NoCaseTupleGuardDispatch do
@moduledoc """
Detects `case` on a tuple of variables where every clause's pattern
is the same tuple of the same variables — all dispatch is via guards.
This is a `cond` in disguise.
## Bad
case {e1, e2} do
{e1, e2} when e1 < e2 -> advance_left(rest1, list2)
{e1, e2} when e1 > e2 -> advance_right(list1, rest2)
{e1, e2} -> advance_both(rest1, rest2)
end
## Good
cond do
e1 < e2 -> advance_left(rest1, list2)
e1 > e2 -> advance_right(list1, rest2)
true -> advance_both(rest1, rest2)
end
## Auto-fix
Unwraps the tuple, extracts guards, and rewrites as `cond`.
A trailing `_` wildcard (or a final guardless tuple clause) is
converted to `true`.
## Safety — why this is narrowed
Moving a `when` guard into a `cond` condition is **not** generally
behaviour-preserving:
* Guards swallow errors: `when hd(a) > 0` treats a raised error as a
failed clause, but the same expression as a `cond` condition
propagates the error. So we only fire when every guard is built
from operators that can never raise — the boolean connectives
(`and`/`or`/`not`) over the total comparison operators
(`==`, `!=`, `===`, `!==`, `<`, `>`, `<=`, `>=`) whose operands are
bare variables or literals. Term comparison is total, so these are
bit-identical in a guard and in an expression, and they always
yield a real boolean (so guard-truth and `cond`-truthiness agree).
* A guard-only `case` with no catch-all raises `CaseClauseError`,
while the corresponding `cond` raises `CondClauseError`. So we only
fire when the final clause is a guardless catch-all (`_` or the
full tuple), making both constructs total over the same inputs.
"""
use Credence.Pattern.Rule
alias Credence.Issue
alias Credence.RuleHelpers
@comparison_ops [:==, :!=, :===, :!==, :<, :>, :<=, :>=]
@boolean_ops [:and, :or]
@impl true
def check(ast, _opts) do
{_ast, issues} =
Macro.prewalk(ast, [], fn
{:case, meta, _} = node, acc ->
if guard_only_tuple_case?(node) do
{node, [build_issue(meta) | acc]}
else
{node, acc}
end
node, acc ->
{node, acc}
end)
Enum.reverse(issues)
end
@impl true
def fix_patches(ast, _opts) do
RuleHelpers.patches_from_postwalk(ast, &maybe_rewrite/1)
end
# ── detection ────────────────────────────────────────────────────
defp guard_only_tuple_case?({:case, _meta, [scrutinee, kw]}) when is_list(kw) do
with {:ok, clauses} <- RuleHelpers.extract_do_body(kw),
true <- is_list(clauses) and length(clauses) >= 2,
vars when is_list(vars) <- tuple_vars(scrutinee),
{init, [last]} <- Enum.split(clauses, -1) do
# Every clause but the last must be a full-tuple pattern with a
# never-raising guard; the last must be a guardless catch-all.
init != [] and
Enum.all?(init, &guarded_clause_ok?(&1, vars)) and
guardless_catchall?(last, vars)
else
_ -> false
end
end
defp guard_only_tuple_case?(_), do: false
# Extract variable names from a tuple AST node.
# Sourceror may wrap single expressions in __block__.
defp tuple_vars({:__block__, _, [inner]}), do: tuple_vars(inner)
defp tuple_vars({:{}, _, elems}) when is_list(elems) do
names =
for {name, _, ctx} <- elems, is_atom(name), is_atom(ctx) do
name
end
if length(names) == length(elems), do: names, else: nil
end
# 2-element tuple uses {a, b} syntax (no :{} wrapper).
defp tuple_vars({a, b}), do: tuple_vars({:{}, [], [a, b]})
defp tuple_vars(_), do: nil
# A non-final clause: full-tuple pattern + exactly one never-raising guard.
defp guarded_clause_ok?({:->, _, [[{:when, _, [tuple_pat, guard]}], _body]}, vars) do
tuple_matches?(tuple_pat, vars) and safe_guard?(guard)
end
defp guarded_clause_ok?(_, _), do: false
# The final clause: no guard, matches everything (full tuple or `_`).
defp guardless_catchall?({:->, _, [[pattern], _body]}, vars) do
not match?({:when, _, _}, pattern) and
(tuple_matches?(pattern, vars) or wildcard?(pattern))
end
defp guardless_catchall?(_, _), do: false
# A guard that cannot raise: boolean connectives over total comparisons
# whose operands are bare variables or literals.
defp safe_guard?({:__block__, _, [inner]}), do: safe_guard?(inner)
defp safe_guard?({op, _, [l, r]}) when op in @boolean_ops do
safe_guard?(l) and safe_guard?(r)
end
defp safe_guard?({:not, _, [x]}), do: safe_guard?(x)
defp safe_guard?({op, _, [l, r]}) when op in @comparison_ops do
safe_operand?(l) and safe_operand?(r)
end
defp safe_guard?(_), do: false
# A comparison operand that, once evaluated, cannot itself raise:
# a bare variable or a literal. Anything else (a call, arithmetic,
# a constructed collection) is rejected.
defp safe_operand?({:__block__, _, [v]}), do: literal?(v)
defp safe_operand?({name, _, ctx}) when is_atom(name) and is_atom(ctx), do: true
defp safe_operand?(v), do: literal?(v)
defp literal?(v), do: is_number(v) or is_atom(v) or is_binary(v)
# Sourceror may wrap tuple patterns in __block__.
defp tuple_matches?({:__block__, _, [inner]}, vars), do: tuple_matches?(inner, vars)
defp tuple_matches?({:{}, _, elems}, vars) when is_list(elems) do
names =
for {name, _, ctx} <- elems, is_atom(name), is_atom(ctx) do
name
end
names == vars
end
defp tuple_matches?({a, b}, vars), do: tuple_matches?({:{}, [], [a, b]}, vars)
defp tuple_matches?(_, _), do: false
defp wildcard?(:_), do: true
defp wildcard?({:_, _, ctx}) when is_atom(ctx), do: true
defp wildcard?(_), do: false
# ── rewrite ──────────────────────────────────────────────────────
defp maybe_rewrite({:case, case_meta, [_scrutinee, kw]} = node) when is_list(kw) do
if guard_only_tuple_case?(node) do
{:ok, clauses} = RuleHelpers.extract_do_body(kw)
cond_clauses = Enum.map(clauses, &rewrite_clause/1)
{:cond, case_meta, [RuleHelpers.replace_do_body(kw, cond_clauses)]}
else
node
end
end
defp maybe_rewrite(node), do: node
# Rewrite a single case clause to a cond clause.
defp rewrite_clause({:->, arrow_meta, [[{:when, _, [_tuple, guard]}], body]}) do
{:->, arrow_meta, [[guard], body]}
end
defp rewrite_clause({:->, arrow_meta, [_pattern, body]}) do
{:->, arrow_meta, [[true], body]}
end
# ── issue ────────────────────────────────────────────────────────
defp build_issue(meta) do
%Issue{
rule: :no_case_tuple_guard_dispatch,
message:
"`case` on a tuple of variables where all dispatch is via guards " <>
"is a `cond` in disguise. Use `cond` instead.",
meta: %{line: Keyword.get(meta, :line)}
}
end
end