defmodule Credence.Pattern.NoRedundantDedupBeforeMapset do @moduledoc """ Detects `Enum.dedup/1` or `Enum.uniq/1` used before `MapSet.new/1`, making the deduplication a redundant intermediate step. `MapSet.new/1` already stores only unique elements, so running `Enum.dedup/1` or `Enum.uniq/1` beforehand has no effect. `Enum.dedup/1`/`Enum.uniq/1` and `MapSet` all use exact (`===`) equality, so removing the pre-pass never changes which elements survive (e.g. `1` and `1.0` stay distinct under both). A `Enum.sort/2`/`Enum.sort_by/2,3` (or any `Enum.sort/1`) intermediate is *not* matched: dropping it is not behaviour-preserving. The comparator or key function can have side effects or raise on some element, so removing the sort can change the answer (turn a raise into a `MapSet`). Those shapes are left alone — see the "no issue" tests. ## Bad Enum.dedup(items) |> MapSet.new() items |> Enum.dedup() |> MapSet.new() MapSet.new(Enum.dedup(items)) Enum.uniq(items) |> MapSet.new() ## Good MapSet.new(items) MapSet.new(items) MapSet.new(items) MapSet.new(items) """ use Credence.Pattern.Rule alias Credence.Issue @dedup_funcs [:dedup, :uniq] @impl true def check(ast, _opts) do {_ast, issues} = Macro.prewalk(ast, [], fn # Pipe form: |> MapSet.new(...) {:|>, meta, [left, {{:., _, [{:__aliases__, _, [:MapSet]}, :new]}, _, _}]} = node, acc -> case extract_dedup_expr(left) do {:ok, _} -> issue = %Issue{ rule: :no_redundant_dedup_before_mapset, message: "`Enum.dedup/1` (or `Enum.uniq/1`) is redundant when piped into `MapSet.new/1` — " <> "it already stores only unique elements.", meta: %{line: Keyword.get(meta, :line)} } {node, [issue | acc]} :error -> {node, acc} end # Non-pipe form: MapSet.new(Enum.dedup(...)) {{:., _, [{:__aliases__, _, [:MapSet]}, :new]}, meta, [first_arg | _rest]} = node, acc -> case extract_dedup_expr(first_arg) do {:ok, _} -> issue = %Issue{ rule: :no_redundant_dedup_before_mapset, message: "`Enum.dedup/1` (or `Enum.uniq/1`) is redundant inside `MapSet.new/1` — " <> "it already stores only unique elements.", meta: %{line: Keyword.get(meta, :line)} } {node, [issue | acc]} :error -> {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 # Pipe form: |> MapSet.new() → MapSet.new(expr) {:|>, _, [left, {{:., _, [{:__aliases__, _, [:MapSet]}, :new]}, _, _}]} = node, acc -> case extract_dedup_expr(left) do {:ok, expr} -> range = Sourceror.get_range(node) change = "MapSet.new(#{Sourceror.to_string(expr)})" {node, [%{range: range, change: change} | acc]} :error -> {node, acc} end # Non-pipe form: MapSet.new(Enum.dedup(expr)) → MapSet.new(expr) {{:., _, [{:__aliases__, _, [:MapSet]}, :new]}, _, [first_arg | _rest]} = node, acc -> case extract_dedup_expr(first_arg) do {:ok, expr} -> range = Sourceror.get_range(first_arg) change = Sourceror.to_string(expr) {node, [%{range: range, change: change} | acc]} :error -> {node, acc} end node, acc -> {node, acc} end) Enum.reverse(patches) end # Enum.dedup(x) or Enum.uniq(x) defp extract_dedup_expr({{:., _, [{:__aliases__, _, [:Enum]}, func]}, _, [expr]}) when func in @dedup_funcs, do: {:ok, expr} # x |> Enum.dedup() or x |> Enum.uniq() defp extract_dedup_expr({:|>, _, [expr, {{:., _, [{:__aliases__, _, [:Enum]}, func]}, _, _}]}) when func in @dedup_funcs, do: {:ok, expr} defp extract_dedup_expr(_), do: :error end