Current section
Files
Jump to
Current section
Files
lib/pattern/no_manual_string_reverse.ex
defmodule Credence.Pattern.NoManualStringReverse do
@moduledoc """
Readability & performance rule: Detects the pattern
`String.graphemes(s) |> Enum.reverse() |> Enum.join()` (and the
`IO.iodata_to_binary/1` reassemble variant) which is a manual
reimplementation of `String.reverse/1`.
This rule covers only the **grapheme** decompose. Reversing a list of
graphemes and gluing it back is identical to `String.reverse/1` for *every*
input — graphemes are exactly what `String.reverse/1` reverses — so it needs
no assumption and runs even in `:strict` mode. The `String.codepoints/1`
variants are handled by `Credence.Pattern.NoCodepointStringReverse`, which
needs the `single_codepoint_graphemes` promise (see decision 4: the split key
is the *decompose* function, not the reassemble function).
## Bad
reversed = str |> String.graphemes() |> Enum.reverse() |> Enum.join()
reversed = str |> String.graphemes() |> Enum.reverse() |> IO.iodata_to_binary()
reversed = Enum.join(Enum.reverse(String.graphemes(str)))
## Good
reversed = String.reverse(str)
"""
use Credence.Pattern.Rule
alias Credence.Issue
@impl true
def check(ast, _opts) do
{_ast, issues} =
Macro.prewalk(ast, [], fn
# Pipeline: ... |> String.graphemes() |> Enum.reverse() |> REASSEMBLE()
{:|>, meta, [left, right]} = node, issues ->
if reassemble_call?(right) and remote_call?(rightmost(left), :Enum, :reverse) do
grandparent =
case left do
{:|>, _, [inner_left, _]} -> rightmost(inner_left)
_ -> nil
end
if grandparent != nil and decompose_call?(grandparent) do
{node, [build_issue(meta) | issues]}
else
{node, issues}
end
else
{node, issues}
end
# Nested call: REASSEMBLE(Enum.reverse(String.graphemes(s)))
node, issues ->
case match_nested_manual_reverse(node) do
{:ok, meta, _subject} -> {node, [build_issue(meta) | issues]}
:error -> {node, issues}
end
end)
Enum.reverse(issues)
end
@impl true
def fix_patches(ast, _opts) do
Credence.RuleHelpers.patches_from_postwalk(ast, fn
{:|>, _, [left, reassemble]} = node ->
with true <- reassemble_fixable?(reassemble),
{:|>, _, [middle, reverse]} <- left,
true <- remote_call?(reverse, :Enum, :reverse),
{:ok, subject} <- decompose_in_middle(middle) do
fix_pipe_subject(subject)
else
_ -> node
end
node ->
case match_nested_manual_reverse(node) do
{:ok, _meta, subject} -> string_reverse_call(subject)
:error -> node
end
end)
end
# Extracts the subject from String.graphemes in the middle of a pipe chain.
defp decompose_in_middle({:|>, _, [subject, decompose]}) do
if decompose_call?(decompose), do: {:ok, subject}, else: :error
end
defp decompose_in_middle({{:., _, [{:__aliases__, _, [:String]}, :graphemes]}, _, [subject]}) do
{:ok, subject}
end
defp decompose_in_middle(_), do: :error
defp fix_pipe_subject({:|>, _, _} = pipe) do
{:|>, [], [pipe, string_reverse_call()]}
end
defp fix_pipe_subject(subject) do
string_reverse_call(subject)
end
defp string_reverse_call do
{{:., [], [{:__aliases__, [], [:String]}, :reverse]}, [], []}
end
defp string_reverse_call(subject) do
{{:., [], [{:__aliases__, [], [:String]}, :reverse]}, [], [subject]}
end
defp join_no_separator?({{:., _, [{:__aliases__, _, [:Enum]}, :join]}, _, []}), do: true
defp join_no_separator?({{:., _, [{:__aliases__, _, [:Enum]}, :join]}, _, [sep]}),
do: empty_string_literal?(sep)
defp join_no_separator?(_), do: false
defp empty_string_literal?(""), do: true
defp empty_string_literal?({:__block__, _, [""]}), do: true
defp empty_string_literal?(_), do: false
defp rightmost({:|>, _, [_, right]}), do: right
defp rightmost(other), do: other
defp remote_call?(node, mod, func) do
match?({{:., _, [{:__aliases__, _, [^mod]}, ^func]}, _, _}, node)
end
# Decompose step: String.graphemes only (codepoints is a separate rule).
defp decompose_call?(node), do: remote_call?(node, :String, :graphemes)
# Reassemble step: Enum.join or IO.iodata_to_binary (for check — any args).
defp reassemble_call?(node) do
remote_call?(node, :Enum, :join) or remote_call?(node, :IO, :iodata_to_binary)
end
# Reassemble that is safe to auto-fix (Enum.join without separator, or any
# IO.iodata_to_binary — it has no separator concept).
defp reassemble_fixable?(node) do
(remote_call?(node, :Enum, :join) and join_no_separator?(node)) or
remote_call?(node, :IO, :iodata_to_binary)
end
# Nested form: REASSEMBLE(Enum.reverse(String.graphemes(subject)))
defp match_nested_manual_reverse(
{{:., meta, [{:__aliases__, _, outer_mod}, outer_func]}, _,
[
{{:., _, [{:__aliases__, _, [:Enum]}, :reverse]}, _,
[{{:., _, [{:__aliases__, _, [:String]}, :graphemes]}, _, [subject]}]}
]}
)
when outer_mod in [[:Enum], [:IO]] and outer_func in [:join, :iodata_to_binary] do
{:ok, meta, subject}
end
defp match_nested_manual_reverse(_), do: :error
defp build_issue(meta) do
%Issue{
rule: :no_manual_string_reverse,
message:
"Use `String.reverse/1` instead of manually decomposing a string into graphemes, " <>
"reversing, and reassembling. It is clearer and handles Unicode correctly.",
meta: %{line: Keyword.get(meta, :line)}
}
end
end