Current section
Files
Jump to
Current section
Files
lib/pattern/no_is_nil_guard.ex
defmodule Credence.Pattern.NoIsNilGuard do
@moduledoc """
Detects `is_nil(param)` in function guards that can be replaced with
pattern matching `nil` directly in the function head.
LLMs reach for `is_nil/1` in guards because Python uses
`if x is None:` — the explicit nil/null check is the only option.
In Elixir, pattern matching `nil` in the function head is shorter,
clearer, and more idiomatic.
## Bad
def foo(x) when is_nil(x), do: :default
def bar(x, y) when is_nil(x) and is_binary(y), do: y
## Good
def foo(nil), do: :default
def bar(nil, y) when is_binary(y), do: y
## What is flagged
Any `def`/`defp` clause whose guard contains `is_nil(param)` where
`param` is a top-level simple parameter. The `is_nil` may be the sole
guard or a direct conjunct in an `and` chain.
Not flagged:
- `is_nil` inside `or` (`when is_nil(x) or is_atom(x)`)
- negated `is_nil` (`when not is_nil(x)`)
- `is_nil` on non-variable expressions (`when is_nil(hd(x))`)
- `is_nil` on destructured bindings (`def foo(%{k: v}) when is_nil(v)`)
- `is_nil` outside of function guards (e.g. inside `if`)
## Auto-fix
Replaces the parameter with `nil` in the function head and removes
`is_nil(param)` from the guard (or drops the guard entirely if it
was the only condition). When the parameter is used in the function
body, the fix uses `nil = param` to preserve the binding.
"""
use Credence.Pattern.Rule
alias Credence.Issue
@impl true
def check(ast, _opts) do
{_ast, issues} =
Macro.prewalk(ast, [], fn node, acc ->
case detect(node) do
{:ok, meta} -> {node, [build_issue(meta) | acc]}
:skip -> {node, acc}
end
end)
Enum.reverse(issues)
end
# def/defp with a when clause
defp detect({def_kind, meta, [{:when, _wm, [fn_head, guard]} | _]})
when def_kind in [:def, :defp] do
params = top_level_param_names(fn_head)
if params != [] and has_fixable_is_nil?(guard, params) do
{:ok, meta}
else
:skip
end
end
defp detect(_), do: :skip
# Extract simple top-level param names: {name, _, ctx} where both atoms
defp top_level_param_names({_name, _meta, params}) when is_list(params) do
for {n, _, ctx} <- params, is_atom(n), is_atom(ctx), do: n
end
defp top_level_param_names(_), do: []
# Walk the guard looking for bare is_nil(param) — only recurse
# into `and` nodes, stop at `or` / `not` / `!`
defp has_fixable_is_nil?({:is_nil, _, [{name, _, ctx}]}, params)
when is_atom(name) and is_atom(ctx),
do: name in params
defp has_fixable_is_nil?({:and, _, [left, right]}, params),
do: has_fixable_is_nil?(left, params) or has_fixable_is_nil?(right, params)
defp has_fixable_is_nil?({:or, _, _}, _), do: false
defp has_fixable_is_nil?({:not, _, _}, _), do: false
defp has_fixable_is_nil?({:!, _, _}, _), do: false
defp has_fixable_is_nil?(_, _), do: false
@impl true
def fix_patches(ast, _opts) do
{_ast, patches} =
Macro.prewalk(ast, [], fn
{def_kind, _meta, [{:when, _wm, [fn_head, guard]} = when_node | rest]} = node, acc
when def_kind in [:def, :defp] ->
case build_when_patch(when_node, fn_head, guard, rest) do
nil -> {node, acc}
patch -> {node, [patch | acc]}
end
node, acc ->
{node, acc}
end)
Enum.reverse(patches)
end
# Builds a patch replacing the `when` node's source range with the
# rewritten function head: params with `nil`/`nil = param`, plus the
# (possibly empty) remaining guard.
defp build_when_patch(when_node, fn_head, guard, body_rest) do
params = top_level_param_names(fn_head)
if params != [] and has_fixable_is_nil?(guard, params) and not guard_has_disallowed?(guard) do
nil_params = collect_nil_params(guard, params)
if nil_params == [] do
nil
else
{_fn_name, _fm, fn_args} = fn_head
body_used = used_in_body?(nil_params, body_rest)
new_fn_args = Enum.map(fn_args, &maybe_replace_param(&1, nil_params, body_used))
new_fn_head = put_elem(fn_head, 2, new_fn_args)
remaining_guard = remove_is_nil_from_guard(guard, nil_params)
head_source =
case remaining_guard do
nil -> Macro.to_string(new_fn_head)
g -> "#{Macro.to_string(new_fn_head)} when #{Macro.to_string(g)}"
end
%{range: Sourceror.get_range(when_node), change: head_source}
end
end
end
defp guard_has_disallowed?({:or, _, _}), do: true
defp guard_has_disallowed?({:not, _, _}), do: true
defp guard_has_disallowed?({:!, _, _}), do: true
defp guard_has_disallowed?({:and, _, [l, r]}),
do: guard_has_disallowed?(l) or guard_has_disallowed?(r)
defp guard_has_disallowed?(_), do: false
defp collect_nil_params({:is_nil, _, [{name, _, ctx}]}, params)
when is_atom(name) and is_atom(ctx) do
if name in params, do: [name], else: []
end
defp collect_nil_params({:and, _, [l, r]}, params),
do: Enum.uniq(collect_nil_params(l, params) ++ collect_nil_params(r, params))
defp collect_nil_params(_, _), do: []
defp maybe_replace_param({name, _meta, ctx} = node, nil_params, body_used)
when is_atom(name) and is_atom(ctx) do
if name in nil_params do
if MapSet.member?(body_used, name) do
{:=, [], [nil, node]}
else
nil
end
else
node
end
end
defp maybe_replace_param(other, _nil_params, _body_used), do: other
defp used_in_body?(nil_params, body_rest) do
nil_params
|> Enum.filter(fn name ->
{_, found} =
Macro.prewalk(body_rest, false, fn
{^name, _, ctx} = n, _acc when is_atom(ctx) -> {n, true}
n, acc -> {n, acc}
end)
found
end)
|> MapSet.new()
end
defp remove_is_nil_from_guard({:is_nil, _, [{name, _, ctx}]} = node, nil_params)
when is_atom(name) and is_atom(ctx) do
if name in nil_params, do: nil, else: node
end
defp remove_is_nil_from_guard({:and, meta, [l, r]}, nil_params) do
case {remove_is_nil_from_guard(l, nil_params), remove_is_nil_from_guard(r, nil_params)} do
{nil, nil} -> nil
{nil, remaining} -> remaining
{remaining, nil} -> remaining
{l2, r2} -> {:and, meta, [l2, r2]}
end
end
defp remove_is_nil_from_guard(other, _), do: other
defp build_issue(meta) do
%Issue{
rule: :no_is_nil_guard,
message: """
`is_nil(param)` in a guard can be replaced with pattern \
matching `nil` directly in the function head.
def foo(x) when is_nil(x) → def foo(nil)
""",
meta: %{line: Keyword.get(meta, :line)}
}
end
end