Current section
Files
Jump to
Current section
Files
lib/pattern/no_case_true_false.ex
defmodule Credence.Pattern.NoCaseTrueFalse do
@moduledoc """
Detects `case expr do true -> …; false -> … end` that should be `if/else`.
LLMs frequently transliterate Python's `if/else` through a `case` on a
boolean expression with explicit `true`/`false` (or `_`) clauses. Idiomatic
Elixir uses `if/else` when the condition is already a boolean.
Only flags cases where the subject is a boolean expression (comparison,
function call, operator) — not a plain variable, which may be a legitimate
pattern match on a tristate value.
Also catches the piped variant: `expr |> case do true -> …; false -> … end`.
## Detected patterns
case expr do true -> A; false -> B end
case expr do false -> B; true -> A end
case expr do true -> A; _ -> B end
case expr do false -> B; _ -> A end
expr |> case do true -> A; false -> B end
## Bad
case rem(n, 2) == 0 do
true -> :even
false -> :odd
end
## Good
if rem(n, 2) == 0 do
:even
else
:odd
end
## Auto-fix
Rewrites the `case` to `if/else`, placing the `true` body (or the wildcard
counterpart) in the `do` block and the `false` body in `else`.
"""
use Credence.Pattern.Rule
alias Credence.Issue
alias Credence.RuleHelpers
@impl true
def check(ast, _opts) do
{_ast, issues} =
Macro.prewalk(ast, [], fn
# case expr do true -> …; false -> … end
{:case, meta, [subject, kw]} = node, acc when is_list(kw) ->
case extract_do_clauses(kw) do
[clause_a, clause_b] ->
if provably_boolean?(subject) and
boolean_clause_pair?(clause_pattern(clause_a), clause_pattern(clause_b)) do
{node, [build_issue(meta) | acc]}
else
{node, acc}
end
_ ->
{node, acc}
end
# expr |> case do true -> …; false -> … end
# Match the pipe node so we can see the piped subject `expr`. The case
# node itself carries only the keyword block (the subject comes from
# the pipe), so the guard below would otherwise be impossible to apply.
# As with the direct form, skip a plain variable on the left: it may be
# a legitimate tristate pattern match that `if` would not preserve.
{:|>, _, [expr, {:case, case_meta, [kw]}]} = node, acc when is_list(kw) ->
case extract_do_clauses(kw) do
[clause_a, clause_b] ->
if provably_boolean?(expr) and
boolean_clause_pair?(clause_pattern(clause_a), clause_pattern(clause_b)) do
{node, [build_issue(case_meta) | acc]}
else
{node, acc}
end
_ ->
{node, acc}
end
node, acc ->
{node, acc}
end)
Enum.reverse(issues)
end
# Uses Sourceror for parsing; rewrites matching case nodes to if/else
# via Macro.postwalk, then emits source with Sourceror.to_string().
@impl true
def fix_patches(ast, _opts) do
Credence.RuleHelpers.patches_from_postwalk(ast, &maybe_rewrite_case/1)
end
# Extract the pattern from a case clause: {:->, _, [[pattern], body]}
defp clause_pattern({:->, _, [[pattern], _body]}), do: pattern
defp clause_pattern(_), do: :no_match
# Only a *provably boolean* subject is safe to rewrite to `if`: a `case` on a
# boolean literal raises `CaseClauseError` on a non-boolean, whereas `if`
# treats any truthy value as `true`. Comparisons, boolean operators, `is_*`
# guards, `?`-suffixed predicate calls (local or remote), and known boolean
# stdlib calls qualify; plain variables, `Access` (`opts[:flag]`), and opaque
# calls (`fun.(x)`, non-`?` functions) do not.
@comparison_ops [:==, :!=, :===, :!==, :<, :>, :<=, :>=, :=~]
@boolean_ops [:and, :or, :not, :!, :in]
@type_guards [
:is_atom,
:is_binary,
:is_bitstring,
:is_boolean,
:is_float,
:is_function,
:is_integer,
:is_list,
:is_map,
:is_map_key,
:is_nil,
:is_number,
:is_pid,
:is_port,
:is_reference,
:is_struct,
:is_tuple
]
defp provably_boolean?({op, _, [_, _]}) when op in @comparison_ops, do: true
defp provably_boolean?({op, _, args}) when op in @boolean_ops and is_list(args), do: true
defp provably_boolean?({op, _, args}) when op in @type_guards and is_list(args), do: true
# A pipe takes the type of its right-most step: `x |> f() |> valid?()`.
defp provably_boolean?({:|>, _, [_left, right]}), do: provably_boolean?(right)
# Remote predicate call `Mod.fun?(...)`
defp provably_boolean?({{:., _, [_mod, fun]}, _, args}) when is_atom(fun) and is_list(args),
do: predicate_name?(fun)
# Local predicate call `fun?(...)` (operator/guard atoms are handled above)
defp provably_boolean?({fun, _, args}) when is_atom(fun) and is_list(args),
do: predicate_name?(fun)
defp provably_boolean?(_), do: false
defp predicate_name?(name), do: name |> Atom.to_string() |> String.ends_with?("?")
# Recognises the boolean pairs we flag: true/false, true/_, false/_
# and their flipped orderings.
defp boolean_clause_pair?(a, b) do
case {normalize_pattern(a), normalize_pattern(b)} do
{true, false} -> true
{false, true} -> true
{true, :wildcard} -> true
{:wildcard, true} -> true
{false, :wildcard} -> true
{:wildcard, false} -> true
_ -> false
end
end
defp normalize_pattern(true), do: true
defp normalize_pattern(false), do: false
defp normalize_pattern({:__block__, _, [true]}), do: true
defp normalize_pattern({:__block__, _, [false]}), do: false
defp normalize_pattern({:_, _, _}), do: :wildcard
defp normalize_pattern(_), do: :other
# Extracts the clause list from a case node's keyword block.
defp extract_do_clauses([{{:__block__, _, [:do]}, clauses}]) when is_list(clauses),
do: clauses
defp extract_do_clauses(_), do: nil
# Postwalk callback: rewrite a matching case node to if/else.
# Handles both `case expr do … end` and `expr |> case do … end`.
# case expr do true -> A; false -> B end
defp maybe_rewrite_case({:case, meta, [subject, kw]} = node) when is_list(kw) do
case extract_do_clauses(kw) do
[clause_a, clause_b] ->
if provably_boolean?(subject) do
case rewrite_clauses(clause_a, clause_b) do
{:ok, do_body, else_body} ->
{:if, meta, [subject, [do: do_body, else: else_body]]}
:skip ->
node
end
else
node
end
_ ->
node
end
end
# expr |> case do true -> A; false -> B end
# The pipe node wraps the case; rewrite the entire pipe to if/else.
defp maybe_rewrite_case({:|>, _pipe_meta, [expr, {:case, case_meta, [kw]}]} = node)
when is_list(kw) do
case extract_do_clauses(kw) do
[clause_a, clause_b] ->
if provably_boolean?(expr) do
case rewrite_clauses(clause_a, clause_b) do
{:ok, do_body, else_body} ->
{:if, case_meta, [expr, [do: do_body, else: else_body]]}
:skip ->
node
end
else
node
end
_ ->
node
end
end
defp maybe_rewrite_case(node), do: node
# Determines if two clauses form a fixable boolean pair and extracts
# the correct body placement for if (do = true branch, else = false branch).
#
# Returns {:ok, do_body, else_body} or :skip.
defp rewrite_clauses(clause_a, clause_b) do
with {pat_a, body_a} <- extract_clause(clause_a),
{pat_b, body_b} <- extract_clause(clause_b) do
ua = unwrap_pattern(pat_a)
ub = unwrap_pattern(pat_b)
# The `->`/pattern of each clause may carry comments (e.g. a note
# before `false ->`). Rewriting to `if` drops the clause wrappers, so
# carry those comments onto the body that moves into `do`/`else`.
da = with_clause_comments(clause_a, body_a)
db = with_clause_comments(clause_b, body_b)
cond do
# true -> A; false -> B
ua == true and ub == false -> {:ok, da, db}
# false -> B; true -> A
ua == false and ub == true -> {:ok, db, da}
# true -> A; _ -> B
ua == true and ub == :wildcard -> {:ok, da, db}
# false -> B; _ -> A (wildcard covers the true case)
ua == false and ub == :wildcard -> {:ok, db, da}
# Wildcard-first variants (unreachable second clause) — don't fix
true -> :skip
end
else
_ -> :skip
end
end
# Comments that sat on the clause's `->`/pattern (not inside its body), carried
# onto the body as leading comments so the `case`→`if` rewrite preserves them.
defp with_clause_comments(clause, body) do
extra = RuleHelpers.collect_comments(clause) -- RuleHelpers.collect_comments(body)
RuleHelpers.carry_comments(body, extra, [])
end
defp extract_clause({:->, _, [[pattern], body]}), do: {pattern, body}
defp extract_clause(_), do: :error
# Normalise a clause pattern, handling Sourceror's __block__ wrapping.
defp unwrap_pattern({:__block__, _, [true]}), do: true
defp unwrap_pattern({:__block__, _, [false]}), do: false
defp unwrap_pattern(true), do: true
defp unwrap_pattern(false), do: false
defp unwrap_pattern({:_, _, _}), do: :wildcard
defp unwrap_pattern(_), do: :other
defp build_issue(meta) do
%Issue{
rule: :no_case_true_false,
message:
"`case` on a boolean expression with `true`/`false` clauses " <>
"should be written as `if`/`else`.",
meta: %{line: Keyword.get(meta, :line)}
}
end
end