Current section

Files

Jump to
credence lib pattern no_case_boolean_result.ex
Raw

lib/pattern/no_case_boolean_result.ex

defmodule Credence.Pattern.NoCaseBooleanResult do
@moduledoc """
Detects a two-clause `case` that converts a value to a boolean by matching
a specific pattern against a trailing wildcard, and rewrites it to `match?/2`.
Note: this is the *inverse* of `no_case_true_false`, which catches
`case bool_expr do true -> …; false -> … end`. This rule catches
`case expr do :ok -> true; _ -> false end`.
## Detected patterns (and their fixes)
case expr do :ok -> true; _ -> false end → match?(:ok, expr)
case expr do :ok -> false; _ -> true end → not match?(:ok, expr)
expr |> case do
:ok -> true
_ -> false
end → match?(:ok, expr)
The fired-on shape is exactly: **two clauses, a specific (non-boolean,
non-variable) pattern returning a boolean, then a wildcard (`_`) returning
the opposite boolean.** For such a `case`, `match?/2` gives the identical
answer on every input: the specific clause matches the same values, and the
wildcard covers all the rest.
## Deliberately *not* matched (no safe same-answer fix)
* **Two specific patterns** (`:ok -> true; :no -> false`) — the `case`
raises `CaseClauseError` on any other value, while a comparison returns
`false`. Different answer, so not flagged.
* **Wildcard *first*** (`_ -> false; :ok -> true`) — the wildcard matches
everything, so the second clause is dead and the `case` is a constant.
`match?/2` is not constant, so not flagged.
* **A bare variable pattern** (`x -> true; _ -> false`) — the variable
matches everything, so the `case` is again constant. Not flagged.
"""
use Credence.Pattern.Rule
alias Credence.Issue
@impl true
def check(ast, _opts) do
{_ast, issues} =
Macro.prewalk(ast, [], fn
# case expr do ... end
{:case, meta, [_subject, kw]} = node, acc when is_list(kw) ->
check_case_clauses(kw, node, acc, meta)
# expr |> case do ... end
{:case, meta, [kw]} = node, acc when is_list(kw) ->
check_case_clauses(kw, node, acc, meta)
node, acc ->
{node, acc}
end)
Enum.reverse(issues)
end
@impl true
def fix_patches(ast, _opts) do
Credence.RuleHelpers.patches_from_postwalk(ast, &maybe_rewrite/1)
end
defp check_case_clauses(kw, node, acc, meta) do
case extract_do_clauses(kw) do
[clause_a, clause_b] ->
if classify(clause_a, clause_b) != :skip do
{node, [build_issue(meta) | acc]}
else
{node, acc}
end
_ ->
{node, acc}
end
end
defp unwrap_boolean(true), do: true
defp unwrap_boolean(false), do: false
defp unwrap_boolean({:__block__, _, [true]}), do: true
defp unwrap_boolean({:__block__, _, [false]}), do: false
defp unwrap_boolean(_), do: :other
defp extract_do_clauses([{{:__block__, _, [:do]}, clauses}]) when is_list(clauses),
do: clauses
defp extract_do_clauses(_), do: nil
# --- auto-fix: rewrite the safe wildcard-last case to match?/2 ---
# case expr do ... end
defp maybe_rewrite({:case, _meta, [subject, kw]} = node) when is_list(kw) do
rewrite_case(kw, subject, node)
end
# expr |> case do ... end
defp maybe_rewrite({:|>, _pipe_meta, [subject, {:case, _case_meta, [kw]}]} = node)
when is_list(kw) do
rewrite_case(kw, subject, node)
end
defp maybe_rewrite(node), do: node
defp rewrite_case(kw, subject, node) do
case extract_do_clauses(kw) do
[clause_a, clause_b] ->
case classify(clause_a, clause_b) do
{:match, pattern} -> {:match?, [], [pattern, subject]}
{:not_match, pattern} -> {:not, [], [{:match?, [], [pattern, subject]}]}
:skip -> node
end
_ ->
node
end
end
defp extract_clause({:->, _, [[pattern], body]}), do: {pattern, body}
defp extract_clause(_), do: :error
# The single source of truth shared by check/2 and fix_patches/2: a two-clause
# `case` is safe to rewrite ONLY as `pattern -> bool; _ -> opposite_bool`, with
# the wildcard LAST and `pattern` a specific, non-variable pattern. Returns
# `{:match, pattern}`, `{:not_match, pattern}`, or `:skip`.
defp classify(clause_a, clause_b) do
with {pat_a, body_a} <- extract_clause(clause_a),
{pat_b, body_b} <- extract_clause(clause_b) do
specific_first? =
normalize_pattern(pat_a) == :other and
normalize_pattern(pat_b) == :wildcard and
not variable_pattern?(pat_a)
cond do
specific_first? and unwrap_boolean(body_a) == true and
unwrap_boolean(body_b) == false ->
{:match, strip_block(pat_a)}
specific_first? and unwrap_boolean(body_a) == false and
unwrap_boolean(body_b) == true ->
{:not_match, strip_block(pat_a)}
true ->
:skip
end
else
_ -> :skip
end
end
# A bare variable binding like `x` — not a useful match? pattern.
defp variable_pattern?({name, _, ctx}) when is_atom(name) and is_atom(ctx), do: true
defp variable_pattern?(_), do: false
# Strip Sourceror's {:__block__, _, [value]} wrapper for use in match?/2.
defp strip_block({:__block__, _, [value]}), do: value
defp strip_block(other), do: other
defp normalize_pattern(true), do: :boolean
defp normalize_pattern(false), do: :boolean
defp normalize_pattern({:__block__, _, [true]}), do: :boolean
defp normalize_pattern({:__block__, _, [false]}), do: :boolean
defp normalize_pattern({:_, _, _}), do: :wildcard
defp normalize_pattern(_), do: :other
defp build_issue(meta) do
%Issue{
rule: :no_case_boolean_result,
message:
"`case` that converts non-boolean values to booleans " <>
"could be replaced with a direct comparison or `match?/2`.",
meta: %{line: Keyword.get(meta, :line)}
}
end
end