Current section
Files
Jump to
Current section
Files
lib/pattern/no_find_value_default_case.ex
defmodule Credence.Pattern.NoFindValueDefaultCase do
@moduledoc """
Detects `Enum.find_value/2` where the result is immediately checked
against `nil` to provide a default, instead of using the 3-arity
version that accepts a default argument.
## Bad
case Enum.find_value(list, &process/1) do
nil -> :default
val -> val
end
Enum.find_value(list, &process/1) || :default
## Good
Enum.find_value(list, :default, &process/1)
## Auto-fix
Replaces the `case`/`||` wrapper with the 3-arity call.
## Safety
This rule is deliberately limited to `Enum.find_value`, and (for the
`case` form) only when the `nil ->` clause comes **first**. The reason
is that the rewrite must give the exact same answer for every input:
* `Enum.find/2` is **not** covered. `find/2` returns the matching
*element*, which can itself be `nil` (or `false`). A genuinely
found `nil` element would be treated as "not found" by both the
`case nil ->` clause and by `Enum.find/3` differently
(`find/3` returns the found `nil`, the `case` returns the default),
so the answers diverge. `find_value` only ever returns a *truthy*
value or `nil`, so `nil` unambiguously means "nothing found".
* The identity clause must come *after* the `nil ->` clause. If it
comes first (`val -> val; nil -> d`) it matches `nil` too, making
the `nil ->` clause dead code — the expression then returns `nil`
(never the default), so rewriting to `/3` would change the answer.
* `||` is safe only for `find_value` (its result is never `false`).
`Enum.find/2 || default` is not covered: a found `false`/`nil`
element is falsy and would wrongly fall through to the default.
"""
use Credence.Pattern.Rule
alias Credence.Issue
@impl true
def check(ast, _opts) do
{_ast, issues} =
Macro.prewalk(ast, [], fn
# case Enum.find_value(coll, fun) do nil -> d; v -> v end
{:case, meta, [find_call, kw]} = node, acc when is_list(kw) ->
if find2?(find_call) and nil_identity?(kw) do
{node, [build_issue(:case, meta, find_call) | acc]}
else
{node, acc}
end
# coll |> Enum.find_value(fun) |> case do nil -> d; v -> v end
# (must precede the 2-arg pipe clause below, which would otherwise
# match this shape with find_call bound to the inner pipe)
{:|>, _,
[
{:|>, _, [_coll, find1_call]},
{:case, meta, [kw]}
]} = node,
acc
when is_list(kw) ->
if find1?(find1_call) and nil_identity?(kw) do
{node, [build_issue(:case, meta, find1_call) | acc]}
else
{node, acc}
end
# Enum.find_value(coll, fun) |> case do nil -> d; v -> v end
{:|>, _, [find_call, {:case, meta, [kw]}]} = node, acc when is_list(kw) ->
if find2?(find_call) and nil_identity?(kw) do
{node, [build_issue(:case, meta, find_call) | acc]}
else
{node, acc}
end
# Enum.find_value(coll, fun) || default
{:||, meta, [find_call, _default]} = node, acc ->
if find2?(find_call) do
{node, [build_issue(:or, meta, find_call) | acc]}
else
{node, acc}
end
node, acc ->
{node, acc}
end)
Enum.reverse(issues)
end
@impl true
def fix_patches(ast, _opts) do
Credence.RuleHelpers.patches_from_postwalk(ast, fn
# case Enum.find_value(coll, fun) do nil -> d; v -> v end
{:case, _, [find_call, kw]} = node ->
if find2?(find_call) and nil_identity?(kw) do
rewrite_2arg(find_call, extract_default(kw))
else
node
end
# coll |> Enum.find_value(fun) |> case do nil -> d; v -> v end
# (must precede the 2-arg pipe clause below)
{:|>, _, [{:|>, _, [coll, find1_call]}, {:case, _, [kw]}]} = node ->
if find1?(find1_call) and nil_identity?(kw) do
rewrite_1arg_pipe(coll, find1_call, extract_default(kw))
else
node
end
# Enum.find_value(coll, fun) |> case do nil -> d; v -> v end
{:|>, _, [find_call, {:case, _, [kw]}]} = node ->
if find2?(find_call) and nil_identity?(kw) do
rewrite_2arg(find_call, extract_default(kw))
else
node
end
# Enum.find_value(coll, fun) || default
{:||, _, [find_call, default]} = node ->
if find2?(find_call) do
rewrite_2arg(find_call, default)
else
node
end
node ->
node
end)
end
# ── Matchers ───────────────────────────────────────────────────────────
defp find2?({{:., _, [{:__aliases__, _, [:Enum]}, :find_value]}, _, [_, _]}), do: true
defp find2?(_), do: false
defp find1?({{:., _, [{:__aliases__, _, [:Enum]}, :find_value]}, _, [_]}), do: true
defp find1?(_), do: false
# ── Case clause checks ─────────────────────────────────────────────────
# Only the safe ordering: `nil ->` clause first, identity clause second.
defp nil_identity?(kw) do
case do_clauses(kw) do
[c1, c2] -> nil_clause?(c1) and identity?(c2)
_ -> false
end
end
defp do_clauses([{{:__block__, _, [:do]}, clauses}]) when is_list(clauses), do: clauses
defp do_clauses(_), do: nil
defp nil_clause?({:->, _, [[{:__block__, _, [nil]}], _]}), do: true
defp nil_clause?({:->, _, [[nil], _]}), do: true
defp nil_clause?(_), do: false
defp identity?({:->, _, [[{name, _, ctx}], {name2, _, ctx2}]})
when is_atom(name) and is_atom(name2) and name == name2 and
(is_nil(ctx) or is_atom(ctx)) and (is_nil(ctx2) or is_atom(ctx2)),
do: true
defp identity?(_), do: false
# ── Extractors ─────────────────────────────────────────────────────────
defp extract_default(kw) do
do_clauses(kw)
|> Enum.find_value(fn
{:->, _, [[{:__block__, _, [nil]}], body]} -> body
{:->, _, [[nil], body]} -> body
_ -> nil
end)
end
# ── Rewriters ──────────────────────────────────────────────────────────
defp rewrite_2arg(
{{:., dm, [{:__aliases__, am, [:Enum]}, func]}, cm, [coll, fun]},
default
) do
{{:., dm, [{:__aliases__, am, [:Enum]}, func]}, cm, [coll, default, fun]}
end
defp rewrite_1arg_pipe(
coll,
{{:., dm, [{:__aliases__, am, [:Enum]}, func]}, cm, [fun]},
default
) do
{{:., dm, [{:__aliases__, am, [:Enum]}, func]}, cm, [coll, default, fun]}
end
# ── Issue builders ─────────────────────────────────────────────────────
defp build_issue(:case, meta, find_call) do
{{:., _, [{:__aliases__, _, [:Enum]}, func]}, _, _} = find_call
%Issue{
rule: :no_find_value_default_case,
message:
"`case Enum.#{func}/2 do nil -> default; val -> val end` should use " <>
"`Enum.#{func}/3` with the default as the second argument.",
meta: %{line: Keyword.get(meta, :line)}
}
end
defp build_issue(:or, meta, find_call) do
{{:., _, [{:__aliases__, _, [:Enum]}, func]}, _, _} = find_call
%Issue{
rule: :no_find_value_default_case,
message:
"`Enum.#{func}/2 || default` should use " <>
"`Enum.#{func}/3` with the default as the second argument.",
meta: %{line: Keyword.get(meta, :line)}
}
end
end