Current section
Files
Jump to
Current section
Files
lib/pattern/no_uniq_then_count.ex
defmodule Credence.Pattern.NoUniqThenCount do
@moduledoc """
Detects `Enum.uniq/1` piped into `length/1` or `Enum.count/1`, which
creates an unnecessary intermediate list only to count its size.
## Why this matters
LLMs frequently produce `Enum.uniq |> length()` pipelines to count
distinct elements. This allocates an intermediate list that is
immediately consumed by a linear scan:
# Flagged — intermediate list from uniq
items |> Enum.uniq() |> length()
items |> Enum.uniq() |> Enum.count()
# Better — MapSet deduplicates on insertion, no intermediate list
items |> MapSet.new() |> MapSet.size()
`MapSet.new/1` builds the set in a single pass, and `MapSet.size/1`
returns the count in O(1).
## Why the rewrite is exact
`Enum.uniq/1` and `MapSet` both deduplicate using map-key (exact,
`===`) equality — `1` and `1.0` stay distinct under both, `+0.0` and
`-0.0` stay distinct under both. The number of distinct elements is
therefore identical, and `length/1`/`Enum.count/1` and `MapSet.size/1`
all return that cardinality. The source enumerable is evaluated exactly
once on the left of the pipe in both forms.
## Flagged patterns
`Enum.uniq()` piped into `length/1` or `Enum.count/1`, in two forms:
- piped uniq: `enum |> Enum.uniq() |> length()`
- head uniq: `Enum.uniq(enum) |> Enum.count()`
## Not flagged
- `Enum.uniq()` alone (without count/length following)
- `Enum.uniq |> Enum.map(...)` (uniq used for further processing)
- `Enum.uniq |> Enum.count(predicate)` (a filtered count, different op)
"""
use Credence.Pattern.Rule
alias Credence.Issue
@impl true
def check(ast, _opts) do
{_ast, issues} =
Macro.prewalk(ast, [], fn node, issues ->
case match_pipeline(node) do
{:ok, _mode, uniq_node} -> {node, [build_issue(uniq_node) | issues]}
:no -> {node, issues}
end
end)
Enum.reverse(issues)
end
@impl true
def fix_patches(ast, _opts) do
Credence.RuleHelpers.patches_from_postwalk(ast, fn
{:|>, _, [_, _]} = node ->
case match_pipeline(node) do
{:ok, mode, _uniq_node} -> rewrite(node, mode)
:no -> node
end
node ->
node
end)
end
#
# A pipeline node `left |> right` is a match when `right` is the count
# step (`length()` / `Enum.count()`) and the step immediately before it
# is `Enum.uniq`. Examining only the top adjacent pair of each `|>` node
# visits every consecutive step-pair exactly once, so a flagged pipeline
# produces exactly one issue (no double-counting when more steps follow).
#
# `:head` — uniq is the pipeline head: `Enum.uniq(enum) |> count`.
# `:piped` — uniq is a piped step: `... |> Enum.uniq() |> count`.
defp match_pipeline({:|>, _, [left, right]}) do
if count_step?(right) do
uniq_in_left(left)
else
:no
end
end
defp match_pipeline(_), do: :no
# Head form: the uniq call carries its source as its single argument.
defp uniq_in_left({{:., _, [mod, :uniq]}, _, [_arg]} = node) do
if enum_module?(mod), do: {:ok, :head, node}, else: :no
end
# Piped form: the previous step is a zero-arg `|> Enum.uniq()`.
defp uniq_in_left({:|>, _, [_, {{:., _, [mod, :uniq]}, _, []} = node]}) do
if enum_module?(mod), do: {:ok, :piped, node}, else: :no
end
defp uniq_in_left(_), do: :no
# `length()` in pipeline position is called with zero explicit args.
defp count_step?({:length, _, []}), do: true
# `Enum.count/1` only — `Enum.count(pred)` is a filtered count, excluded.
defp count_step?({{:., _, [mod, :count]}, _, []}), do: enum_module?(mod)
defp count_step?(_), do: false
defp enum_module?({:__aliases__, _, [:Enum]}), do: true
defp enum_module?(_), do: false
# `Enum.uniq(enum) |> count` → `MapSet.new(enum) |> MapSet.size()`
defp rewrite({:|>, _, [left, _right]}, :head) do
{{:., _, [_, :uniq]}, _, [arg]} = left
pipe(mapset_call(:new, [arg]), pipe_tail(:size))
end
# `pre |> Enum.uniq() |> count` → `pre |> MapSet.new() |> MapSet.size()`
defp rewrite({:|>, _, [left, _right]}, :piped) do
{:|>, _, [prefix, _uniq]} = left
pipe(pipe(prefix, mapset_call(:new, [])), pipe_tail(:size))
end
defp pipe(left, right), do: {:|>, [], [left, right]}
defp pipe_tail(fun), do: mapset_call(fun, [])
defp mapset_call(fun, args),
do: {{:., [], [{:__aliases__, [], [:MapSet]}, fun]}, [], args}
defp build_issue(uniq_node) do
{{:., meta, [_, :uniq]}, _, _} = uniq_node
%Issue{
rule: :no_uniq_then_count,
message: message(),
meta: %{line: Keyword.get(meta, :line)}
}
end
defp message do
"`Enum.uniq/1` piped into `length/1` or `Enum.count/1` creates an " <>
"unnecessary intermediate list. Use `MapSet.new(enum) |> MapSet.size()` " <>
"instead — it deduplicates in a single pass with O(1) size lookup."
end
end