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