Current section

Files

Jump to
credence lib pattern no_case_true_false.ex
Raw

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.
## 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
## 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
@impl true
def fixable?, do: true
# ── Check ─────────────────────────────────────────────────────────
# Uses AST from Code.string_to_quoted (bare boolean literals).
@impl true
def check(ast, _opts) do
{_ast, issues} =
Macro.prewalk(ast, [], fn
{:case, meta, [subject, [do: [clause_a, clause_b]]]} = node, acc ->
if not plain_variable?(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 ->
{node, acc}
end)
Enum.reverse(issues)
end
# ── Fix ───────────────────────────────────────────────────────────
# 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(source, _opts) do
case Sourceror.parse_string(source) do
{:ok, ast} ->
if has_fixable_case?(ast) do
ast
|> Macro.postwalk(&maybe_rewrite_case/1)
|> Sourceror.to_string()
else
source
end
{:error, _} ->
source
end
end
# ── Check helpers ─────────────────────────────────────────────────
# Extract the pattern from a case clause: {:->, _, [[pattern], body]}
defp clause_pattern({:->, _, [[pattern], _body]}), do: pattern
defp clause_pattern(_), do: :no_match
# A plain variable like `some_flag` — legitimate pattern match, not flagged.
defp plain_variable?({name, _meta, context})
when is_atom(name) and is_atom(context),
do: true
defp plain_variable?(_), do: false
# 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({:_, _, _}), do: :wildcard
defp normalize_pattern(_), do: :other
# ── Fix helpers ───────────────────────────────────────────────────
# Extracts the clause list from a case node's keyword block.
# Handles both Code.string_to_quoted format ([do: clauses]) and
# Sourceror format ([{{:__block__, _, [:do]}, clauses}]).
defp extract_do_clauses([{:do, clauses}]) when is_list(clauses), do: clauses
defp extract_do_clauses([{{:__block__, _, [:do]}, clauses}]) when is_list(clauses),
do: clauses
defp extract_do_clauses(_), do: nil
# Quick pre-scan: is there at least one case node we can rewrite?
# Prevents unnecessary Sourceror.to_string() (which reformats the file).
defp has_fixable_case?(ast) do
{_, found} =
Macro.prewalk(ast, false, fn
_node, true ->
{nil, true}
{:case, _, [subject, kw]} = node, false when is_list(kw) ->
fixable =
case extract_do_clauses(kw) do
[clause_a, clause_b] ->
not plain_variable?(subject) and
rewrite_clauses(clause_a, clause_b) != :skip
_ ->
false
end
{node, fixable}
node, acc ->
{node, acc}
end)
found
end
# Postwalk callback: rewrite a matching case node to if/else.
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 not plain_variable?(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
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)
cond do
# true -> A; false -> B
ua == true and ub == false -> {:ok, body_a, body_b}
# false -> B; true -> A
ua == false and ub == true -> {:ok, body_b, body_a}
# true -> A; _ -> B
ua == true and ub == :wildcard -> {:ok, body_a, body_b}
# false -> B; _ -> A (wildcard covers the true case)
ua == false and ub == :wildcard -> {:ok, body_b, body_a}
# Wildcard-first variants (unreachable second clause) — don't fix
true -> :skip
end
else
_ -> :skip
end
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
# ── Issue construction ────────────────────────────────────────────
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