Current section

Files

Jump to
credence lib pattern no_unnecessary_catch_all_raise.ex
Raw

lib/pattern/no_unnecessary_catch_all_raise.ex

defmodule Credence.Pattern.NoUnnecessaryCatchAllRaise do
@moduledoc """
Detects function clauses where every argument is a wildcard and the
body does nothing but `raise`.
## Why this matters
Elixir's `FunctionClauseError` is a first-class debugging tool. When
no clause matches, the runtime raises an error that names the function
_and_ shows the exact arguments that failed to match. A hand-written
catch-all that raises a generic error actively degrades that signal:
# Bad — hides the actual arguments from the error
def missing_number(_), do: raise(ArgumentError, "expected a list")
# Good — Elixir does this automatically, with better diagnostics
# (just remove the catch-all clause entirely)
LLMs generate these defensive catch-alls frequently because their
training data includes Python / Java patterns where unhandled cases
must be raised explicitly. In Elixir the convention is to let
non-matching calls crash naturally.
## Flagged patterns
Any `def` / `defp` clause where:
1. **Every** argument is a wildcard `_` or `_name`), and
2. The body consists solely of a `raise` call.
Guarded clauses are not flagged — the guard implies intentional
matching logic even if the arguments are wildcards.
## Not flagged
- Catch-all clauses that return a value (e.g. `{:error, :invalid}`)
- Clauses with logic before the raise (logging, cleanup)
- Zero-arity functions
- Clauses with guard expressions
## Fix
Removes the unnecessary catch-all clause, letting Elixir's built-in
`FunctionClauseError` handle unmatched arguments with better diagnostics.
"""
use Credence.Pattern.Rule
alias Credence.Issue
@impl true
def check(ast, _opts) do
{_ast, issues} =
Macro.prewalk(ast, [], fn node, issues ->
case check_node(node) do
{:ok, issue} -> {node, [issue | issues]}
:error -> {node, issues}
end
end)
Enum.reverse(issues)
end
@impl true
def fix_patches(ast, _opts) do
{_ast, patches} =
Macro.prewalk(ast, [], fn
{def_type, _meta, [{fn_name, _, args}, body]} = node, acc
when def_type in [:def, :defp] and is_atom(fn_name) and is_list(args) ->
if all_wildcards?(args) and body_only_raises?(body) do
{node, [removal_patch(node) | acc]}
else
{node, acc}
end
node, acc ->
{node, acc}
end)
Enum.reverse(patches)
end
# Replace the def node — plus the trailing newline that follows it
# on its own line — with the empty string.
defp removal_patch(def_node) do
range = Sourceror.get_range(def_node)
%{
range: %{
start: [line: range.start[:line], column: 1],
end: [line: range.end[:line] + 1, column: 1]
},
change: ""
}
end
# NODE MATCHING
defp check_node({def_type, meta, [{fn_name, _, args}, body]})
when def_type in [:def, :defp] and is_atom(fn_name) and is_list(args) do
if all_wildcards?(args) and body_only_raises?(body) do
{:ok,
%Issue{
rule: :no_unnecessary_catch_all_raise,
message: build_message(def_type, fn_name, length(args)),
meta: %{line: Keyword.get(meta, :line)}
}}
else
:error
end
end
defp check_node(_), do: :error
# A zero-arity function cannot be a "catch-all".
defp all_wildcards?([]), do: false
defp all_wildcards?(args), do: Enum.all?(args, &wildcard?/1)
defp wildcard?({:_, _, ctx}) when is_atom(ctx), do: true
defp wildcard?({name, _, ctx}) when is_atom(name) and is_atom(ctx) do
name |> Atom.to_string() |> String.starts_with?("_")
end
defp wildcard?(_), do: false
defp body_only_raises?(body) when is_list(body) do
case extract_do_body(body) do
{:ok, {:raise, _, _}} -> true
_ -> false
end
end
defp body_only_raises?(_), do: false
defp extract_do_body(body) do
Enum.find_value(body, :error, fn
{{:__block__, _, [:do]}, expr} -> {:ok, expr}
_ -> nil
end)
end
defp build_message(def_type, fn_name, arity) do
"""
Unnecessary catch-all clause in `#{def_type} #{fn_name}/#{arity}`.
This clause matches all remaining arguments only to raise an error.
Elixir already raises a `FunctionClauseError` when no clause matches,
and it includes the actual failing arguments in the error — which is
more useful for debugging than a generic message.
Remove this clause and let Elixir's built-in error handling do the work.
"""
end
end