Current section

Files

Jump to
credence lib pattern no_case_destructure_in_pipe.ex
Raw

lib/pattern/no_case_destructure_in_pipe.ex

defmodule Credence.Pattern.NoCaseDestructureInPipe do
@moduledoc """
Readability rule: Detects a single-clause `case` used inside a pipeline
whose clause head is an **irrefutable** variable pattern (a bare variable
or `_`, with no guard). That shape is exactly equivalent to `then/1` and
is non-idiomatic.
LLMs often use `|> case do pattern -> body end` as a workaround when
`|> (fn ... end).()` is caught by `no_anon_fn_application_in_pipe`.
Both are non-idiomatic; `then/1` is the idiomatic alternative.
## Bad
result
|> compute()
|> case do
value -> value + 1
end
## Good
result
|> compute()
|> then(fn value -> value + 1 end)
## Why only irrefutable patterns
A refutable pattern (e.g. `{x, acc}`, `{:ok, v}`, a pin, or any clause
with a guard) is **not** safe to rewrite. When the piped value does not
match, a single-clause `case` raises `CaseClauseError` while
`then(fn pattern -> ... end)` raises `FunctionClauseError`. Those are
different exceptions, so a caller that rescues `CaseClauseError` would
behave differently after the rewrite. An irrefutable variable pattern
always matches, so there is no divergent error path and the rewrite is
answer-preserving for every input.
"""
use Credence.Pattern.Rule
alias Credence.Issue
@impl true
def check(ast, _opts) do
{_ast, issues} =
Macro.prewalk(ast, [], fn
# Match: ... |> case do single_clause -> body end
# In the pipe AST, the case node has a single arg (the keyword block)
# because the subject comes from the pipe.
{:|>, meta, [_left, {:case, _case_meta, [kw]}]} = node, issues
when is_list(kw) ->
case safe_single_clause(kw) do
{:ok, _pattern, _body} ->
issue = %Issue{
rule: :no_case_destructure_in_pipe,
message:
"A single-clause `case` in a pipeline should use `then/1` instead: " <>
"`|> then(fn pattern -> body end)`.",
meta: %{line: Keyword.get(meta, :line)}
}
{node, [issue | issues]}
:no_match ->
{node, issues}
end
node, issues ->
{node, issues}
end)
Enum.reverse(issues)
end
@impl true
def fix_patches(ast, _opts) do
Credence.RuleHelpers.patches_from_postwalk(ast, fn
# |> case do pattern -> body end → |> then(fn pattern -> body end)
{:|>, pipe_meta, [left, {:case, _case_meta, [kw]}]} = node
when is_list(kw) ->
case safe_single_clause(kw) do
{:ok, pattern, body} ->
fn_node =
{:fn, [], [{:->, [], [[pattern], body]}]}
{:|>, pipe_meta, [left, {:then, [], [fn_node]}]}
:no_match ->
node
end
node ->
node
end)
end
# Extract the single, *irrefutable* clause from a case keyword block.
# Returns {:ok, pattern, body} only when the case has exactly one clause
# whose head is a bare variable (or `_`) with no guard — the one shape that
# always matches, so rewriting to `then/1` cannot change the error path.
# Returns :no_match otherwise.
defp safe_single_clause([{{:__block__, _, [:do]}, clauses}])
when is_list(clauses) do
single_irrefutable_clause(clauses)
end
defp safe_single_clause([{:do, clauses}]) when is_list(clauses) do
single_irrefutable_clause(clauses)
end
defp safe_single_clause(_), do: :no_match
defp single_irrefutable_clause([{:->, _, [[pattern], body]}]) do
if irrefutable_var?(pattern), do: {:ok, pattern, body}, else: :no_match
end
defp single_irrefutable_clause(_), do: :no_match
# An irrefutable variable pattern: `value`, `_`, `_x`, etc. In the AST a
# variable is `{name, _meta, context}` with both `name` and `context` atoms.
# Calls (`foo()` -> context is a list), pins (`^v`), tuples, literals, and
# guarded heads (`{:when, _, [...]}`) all fail this and are left alone.
defp irrefutable_var?({name, _meta, context})
when is_atom(name) and is_atom(context),
do: true
defp irrefutable_var?(_), do: false
end