Current section
Files
Jump to
Current section
Files
lib/pattern/no_piped_regex_replace.ex
defmodule Credence.Pattern.NoPipedRegexReplace do
@moduledoc """
Detects `Regex.replace` used as a pipe target and replaces it with
`String.replace`, which accepts the string as its first argument.
`Regex.replace/3` expects `(regex, string, replacement)` — regex first.
When used in a pipeline, the pipe injects the left-hand value as the
first argument, putting the string where the regex should be:
input |> Regex.replace(~r/[^a-z0-9]/, "")
# becomes: Regex.replace(input, ~r/[^a-z0-9]/, "")
# ^^^^^ string in regex position — crash
`String.replace/3` takes `(string, pattern, replacement)` and accepts
regex patterns, so it is a drop-in replacement that works in pipelines:
input |> String.replace(~r/[^a-z0-9]/, "")
"""
@behaviour Credence.Pattern.Rule
@impl true
def priority, do: 50
@impl true
def assumptions, do: []
@impl true
def check(ast, _opts) do
{_ast, issues} =
Macro.prewalk(ast, [], fn
{:|>, meta,
[
_left,
{{:., _, [{:__aliases__, _, [:Regex]}, :replace]}, _, args}
]} = node,
acc ->
if misused_pipe?(args) do
{node, [build_issue(meta) | acc]}
else
{node, acc}
end
node, acc ->
{node, acc}
end)
Enum.reverse(issues)
end
@impl true
def fix_patches(ast, _opts) do
{_ast, patches} =
Macro.prewalk(ast, [], fn
{:|>, _meta,
[
_left,
{{:., _, [{:__aliases__, _, [:Regex]} = alias_node, :replace]}, _, args}
]} = node,
acc ->
if misused_pipe?(args) do
patch = %{range: Sourceror.get_range(alias_node), change: "String"}
{node, [patch | acc]}
else
{node, acc}
end
node, acc ->
{node, acc}
end)
Enum.reverse(patches)
end
# `regex |> Regex.replace(string, repl)` desugars to the CORRECT
# `Regex.replace(regex, string, repl)` and must not be rewritten — doing so
# would put a %Regex{} into `String.replace/3`'s subject slot and crash. The
# rule only targets the misuse where the pipe injects a string into the regex
# slot, i.e. `string |> Regex.replace(regex, repl)`. The tell is that the
# explicit first argument (the string slot) is itself a regex; then the piped
# value belongs in the string slot. When that arg is anything else, the piped
# value is presumably the regex (correct usage), so we leave it alone.
defp misused_pipe?([first | _]), do: regex_literal?(first)
defp misused_pipe?(_), do: false
defp regex_literal?({sigil, _, _}) when sigil in [:sigil_r, :sigil_R], do: true
defp regex_literal?({{:., _, [{:__aliases__, _, [:Regex]}, fun]}, _, _})
when fun in [:compile, :compile!],
do: true
defp regex_literal?({:|>, _, [_, right]}), do: regex_literal?(right)
defp regex_literal?(_), do: false
defp build_issue(meta) do
%Credence.Issue{
rule: :no_piped_regex_replace,
message:
"`Regex.replace/3` expects `(regex, string, replacement)` but the pipe " <>
"injects the string as the first argument. Use `String.replace/3` instead, " <>
"which takes the string first and accepts regex patterns.",
meta: %{line: Keyword.get(meta, :line)}
}
end
end