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 check(ast, _opts) do {_ast, issues} = Macro.prewalk(ast, [], fn {:|>, meta, [ _left, {{:., _, [{:__aliases__, _, [:Regex]}, :replace]}, _, _args} ]} = node, acc -> {node, [build_issue(meta) | acc]} 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 -> patch = %{ range: Sourceror.get_range(alias_node), change: "String" } {node, [patch | acc]} node, acc -> {node, acc} end) Enum.reverse(patches) end 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