defmodule Credence.Pattern.PreferGraphemesForCharacterUniqueness do @moduledoc """ Readability & correctness rule: Detects the pattern `String.to_charlist(s) |> Enum.uniq() |> Enum.count() |> (&(&1 == String.length(s))).()`. `String.to_charlist/1` decomposes into **codepoints** (integers), while `String.length/1` counts **graphemes** (whole characters). For decomposed Unicode (e.g. `"é"` = `"e"` + U+0301) these differ — the codepoint count is higher than the grapheme count — so the comparison is wrong. Using `String.graphemes/1` instead keeps both sides grapheme-level. Under the `single_codepoint_graphemes` assumption (every grapheme is exactly one codepoint), the two decompositions agree, so the rewrite is safe. The fix also modernises the capture call idiom from `(&expr).()` to `then(&expr)`, which reads more naturally in a pipeline. ## Bad (only rewritten while `single_codepoint_graphemes` is on) String.to_charlist(s) |> Enum.uniq() |> Enum.count() |> (&(&1 == String.length(s))).() ## Good String.graphemes(s) |> Enum.uniq() |> Enum.count() |> then(&(&1 == String.length(s))) """ use Credence.Pattern.Rule alias Credence.Issue @impl true def assumptions, do: [:single_codepoint_graphemes] @impl true def check(ast, _opts) do {_ast, issues} = Macro.prewalk(ast, [], fn # Full pipeline: ... |> Enum.count() |> (&(&1 == String.length(var))).() {:|>, meta, [left, capture_invocation]} = node, issues -> if immediate_capture_invocation?(capture_invocation) and enum_count_step?(rightmost_pipe_end(left)) do # Walk the pipeline to find String.to_charlist if pipeline_has_to_charlist?(left) do {node, [build_issue(meta) | issues]} else {node, issues} end else {node, issues} end node, issues -> {node, issues} end) Enum.reverse(issues) end @impl true def fix_patches(ast, opts) do _opts = opts {_ast, patches} = Macro.prewalk(ast, [], fn # Full pipeline: Enum.count() |> (&(&1 == String.length(var))).() {:|>, pipe_meta, [left, capture_invocation]} = node, patches -> rewrite_count_pipe(node, pipe_meta, left, capture_invocation, patches) node, patches -> {node, patches} end) Enum.reverse(patches) end # Rewrite `Enum.count(...) |> (&(&1 == String.length(var))).()` to the # grapheme form; returns the (possibly unchanged) `{node, patches}` accumulator. defp rewrite_count_pipe(node, pipe_meta, left, capture_invocation, patches) do case capture_invocation do # Match the capture invocation pattern: (&expr).() {{:., _, [capture_expr = {:&, _, [{:==, _, _}]}]}, _, []} -> if enum_count_step?(rightmost_pipe_end(left)) do case find_and_replace_charlist(left) do {:ok, new_left} -> clean_capture = strip_parens_meta(capture_expr) then_call = {:then, [], [clean_capture]} new_pipe = {:|>, pipe_meta, [new_left, then_call]} rendered = Sourceror.to_string(new_pipe) case Sourceror.get_range(node) do %Sourceror.Range{} = range -> {node, [%{range: range, change: rendered} | patches]} _ -> {node, patches} end :error -> {node, patches} end else {node, patches} end _ -> {node, patches} end end # Strip parens and closing metadata from AST nodes to avoid rendering issues defp strip_parens_meta(ast) do Macro.prewalk(ast, fn {form, meta, args} -> {form, Keyword.drop(meta, [:parens, :closing, :line, :column, :last, :end]), args} other -> other end) end # Check if a node is an immediate invocation of a capture: (&expr).() defp immediate_capture_invocation?({{:., _, [{:&, _, [{:==, _, _}]}]}, _, []}), do: true defp immediate_capture_invocation?(_), do: false # Check if a node is a String.to_charlist call defp to_charlist_call?({{:., _, [{:__aliases__, _, [:String]}, :to_charlist]}, _, _}), do: true defp to_charlist_call?(_), do: false # Check if a node is an Enum.count() call (no arguments) defp enum_count_step?({{:., _, [{:__aliases__, _, [:Enum]}, :count]}, _, []}), do: true defp enum_count_step?(_), do: false # Get the rightmost element of a pipe chain (the last step before current) defp rightmost_pipe_end({:|>, _, [_, right]}), do: right defp rightmost_pipe_end(other), do: other # Check if the pipeline contains a String.to_charlist call defp pipeline_has_to_charlist?({:|>, _, [left, right]}) do to_charlist_call?(left) or to_charlist_call?(right) or pipeline_has_to_charlist?(left) end defp pipeline_has_to_charlist?(node), do: to_charlist_call?(node) # Find and replace String.to_charlist with String.graphemes in the pipe chain defp find_and_replace_charlist({:|>, meta, [left, right]}) do case find_and_replace_charlist(left) do {:ok, new_left} -> {:ok, {:|>, meta, [new_left, right]}} :error -> case find_and_replace_charlist(right) do {:ok, new_right} -> {:ok, {:|>, meta, [left, new_right]}} :error -> :error end end end defp find_and_replace_charlist( {{:., call_meta, [{:__aliases__, alias_meta, [:String]}, :to_charlist]}, fun_meta, args} ) do {:ok, {{:., call_meta, [{:__aliases__, alias_meta, [:String]}, :graphemes]}, fun_meta, args}} end defp find_and_replace_charlist(_), do: :error defp build_issue(meta) do %Issue{ rule: :prefer_graphemes_for_character_uniqueness, message: "Use `String.graphemes/1` instead of `String.to_charlist/1` when comparing with " <> "`String.length/1`. `to_charlist` returns codepoints while `length` counts graphemes — " <> "mixing them is wrong for decomposed Unicode. Under `single_codepoint_graphemes`, " <> "the rewrite is safe.", meta: %{line: Keyword.get(meta, :line)} } end end