Current section
Files
Jump to
Current section
Files
lib/pattern/prefer_regex_match.ex
defmodule Credence.Pattern.PreferRegexMatch do
@moduledoc """
Rewrites a `case` over `Regex.run/2` that only tests **whether** the regex
matched — never the captured groups — into the idiomatic boolean form built
on `Regex.match?/2`.
## Bad
case Regex.run(~r/ab{3,}/, string) do
[_ | _] -> "Found a match!"
_ -> "Not matched!"
end
## Good
if Regex.match?(~r/ab{3,}/, string) do
"Found a match!"
else
"Not matched!"
end
## Scope (the safe core)
`Regex.run(re, str)` (default `:capture`) returns `nil` on no match and a
**non-empty** list (the whole match is always element 0) on a match. So the
pattern `[_ | _]` matches *exactly* the "matched" case and `nil`/`_` the
"no match" case — which is precisely what `Regex.match?/2` decides. The fix
fires only when ALL of these hold:
- The scrutinee is `Regex.run(re, str)` with **exactly two arguments**.
- There are **exactly two clauses**, neither guarded.
- One clause pattern is `[_ | _]` (wildcard head *and* tail — binds nothing).
- The other clause pattern is `nil` or `_`.
- When the other clause is the catch-all `_`, the `[_ | _]` clause comes
**first** (otherwise `_` shadows it and the `[_ | _]` body is dead code —
converting to an `if` would change the result). When the other clause is
`nil` the two patterns are disjoint, so either order is safe.
The `[_ | _]` body becomes the `do` branch and the `nil`/`_` body the `else`
branch, regardless of source order.
## Does NOT fire (deliberately dropped — not provably behaviour-preserving)
- **`Regex.run/3` with options.** `capture: :none` (and `:all_but_first`
with no subpatterns) returns `[]` on a match, which `[_ | _]` does *not*
match — the two forms disagree, so any 3-argument call is skipped.
- **A bound head** (`[match | _]`) — the body reads the capture, which
`Regex.match?/2` does not provide.
- **More than two clauses, or specific-capture patterns** (`["x"]`,
`[_, a, b]`, `[]`) — these inspect the captures, not mere existence.
- **A single clause, or a second clause that does not cover `nil`** — the
original raises `CaseClauseError` on a no-match where an `if` would not.
- **A named catch-all** (`other ->`) — its body may read the bound `nil`.
- **Guarded clauses.**
"""
use Credence.Pattern.Rule
alias Credence.Issue
alias Credence.RuleHelpers
@impl true
def check(ast, _opts) do
{_ast, issues} =
Macro.prewalk(ast, [], fn
{:case, meta, [call, [{{:__block__, _, [:do]}, clauses}]]} = node, issues
when is_list(clauses) ->
case convert(call, clauses) do
{:ok, _do_body, _else_body} -> {node, [build_issue(meta) | issues]}
:no -> {node, issues}
end
node, issues ->
{node, issues}
end)
Enum.reverse(issues)
end
@impl true
def fix_patches(ast, opts) do
source = Keyword.get(opts, :source) || Sourceror.to_string(ast)
RuleHelpers.patches_from_ast_transform(ast, source, &rewrite/1)
end
defp rewrite(ast) do
Macro.prewalk(ast, fn
{:case, meta, [call, [{{:__block__, _, [:do]}, clauses}]]} = node when is_list(clauses) ->
case convert(call, clauses) do
{:ok, do_body, else_body} -> build_if(meta, call, do_body, else_body)
:no -> node
end
node ->
node
end)
end
# The single shared decision used by both check and fix: returns the `do`
# and `else` bodies of the equivalent `if`, or `:no`. check and fix can
# never disagree because they call exactly this.
defp convert(call, clauses) do
with true <- regex_run_2arg?(call),
{:ok, do_body, else_body} <- split_clauses(clauses) do
{:ok, do_body, else_body}
else
_ -> :no
end
end
# `Regex.run(re, str)` — exactly two arguments (no options).
defp regex_run_2arg?({{:., _, [{:__aliases__, _, [:Regex]}, :run]}, _, [_re, _str]}), do: true
defp regex_run_2arg?(_), do: false
# Exactly two clauses: a `[_ | _]` clause and a `nil`/`_` clause. The `[_|_]`
# body is the `do` branch, the other body the `else` branch. A leading `_`
# would shadow a trailing `[_|_]`, so that ordering is rejected.
defp split_clauses([c1, c2]) do
case {classify(c1), classify(c2)} do
{{:cons, do_body}, {:nil_pat, else_body}} -> {:ok, do_body, else_body}
{{:nil_pat, else_body}, {:cons, do_body}} -> {:ok, do_body, else_body}
{{:cons, do_body}, {:wild, else_body}} -> {:ok, do_body, else_body}
_ -> :no
end
end
defp split_clauses(_), do: :no
# An unguarded single-pattern clause classified by its pattern. A guarded
# clause carries a `{:when, ...}` node as its pattern and falls through to
# `:other`.
defp classify({:->, _, [[pattern], body]}) do
cond do
cons_wildcard?(pattern) -> {:cons, body}
nil_pattern?(pattern) -> {:nil_pat, body}
wildcard?(pattern) -> {:wild, body}
true -> :other
end
end
defp classify(_), do: :other
# `[_ | _]` — Sourceror wraps the list pattern in `{:__block__, _, [[...]]}`.
defp cons_wildcard?({:__block__, _, [[{:|, _, [{:_, _, _}, {:_, _, _}]}]]}), do: true
defp cons_wildcard?(_), do: false
defp nil_pattern?({:__block__, _, [nil]}), do: true
defp nil_pattern?(nil), do: true
defp nil_pattern?(_), do: false
defp wildcard?({:_, _, ctx}) when is_atom(ctx), do: true
defp wildcard?(_), do: false
defp build_if(meta, {_, _, [re, str]}, do_body, else_body) do
match_call =
{{:., [], [{:__aliases__, [], [:Regex]}, :match?]}, [], [re, str]}
{:if, meta,
[
match_call,
[
{{:__block__, [], [:do]}, do_body},
{{:__block__, [], [:else]}, else_body}
]
]}
end
defp build_issue(meta) do
%Issue{
rule: :prefer_regex_match,
message:
"`Regex.run/2` is used only to test whether the regex matches, but `Regex.match?/2` " <>
"returns a boolean directly and is the idiomatic way to check for a match.",
meta: %{line: Keyword.get(meta, :line)}
}
end
end