Current section

Files

Jump to
credence lib pattern prefer_map_intersect_over_mapset_intersection.ex
Raw

lib/pattern/prefer_map_intersect_over_mapset_intersection.ex

defmodule Credence.Pattern.PreferMapIntersectOverMapsetIntersection do
@moduledoc """
Detects MapSet-based intersection of map keys that can be replaced with
`Map.intersect/3` (Elixir 1.14+).
The verbose pipeline `Map.keys(a) |> MapSet.new() |> MapSet.intersection(MapSet.new(Map.keys(b))) |> MapSet.to_list()`
followed by an `Enum.map` that fetches and merges values from both maps
can be replaced with a single `Map.intersect/3` call.
## Bad
common_keys =
Map.keys(freq1)
|> MapSet.new()
|> MapSet.intersection(MapSet.new(Map.keys(freq2)))
|> MapSet.to_list()
common_keys
|> Enum.map(fn element ->
count1 = Map.fetch!(freq1, element)
count2 = Map.fetch!(freq2, element)
{element, min(count1, count2)}
end)
|> Enum.sort()
## Good
freq1
|> Map.intersect(freq2, fn _key, count1, count2 -> min(count1, count2) end)
|> Enum.sort_by(fn {key, _value} -> key end)
## Scope — what makes the rewrite safe
The whole two-statement shape must be present (the rule flags and fixes the
*same* node — never one without the other), and every gap that could change
the answer is closed:
- **The intersection assignment feeds exactly one `Enum.map |> Enum.sort`.**
The assigned variable is used once; the fix deletes its binding, so a second
use would strand it unbound.
- **`freq1`/`freq2` are bare variables.** They are spliced verbatim into the
`Map.intersect/3` call.
- **The merge expression is a pure arithmetic combination of `count1`/`count2`
and numeric literals** (`min`/`max`/`+`/`-`/`*`/`div`/`rem`/`abs`). This both
rules out a merge that references the `element` key (which becomes `_key` and
would be unbound) and guarantees the result is independent of evaluation
order — `MapSet.to_list` and `Map.intersect` enumerate keys in different
orders, but for a pure merge the final `Enum.sort`/`Enum.sort_by(key)` (keys
are unique, so sorting by key equals sorting by the whole `{key, value}`
tuple) produces an identical list.
"""
use Credence.Pattern.Rule
alias Credence.Issue
alias Credence.RuleHelpers
# Pure arithmetic operators/locals allowed in the merge expression.
@pure_binops [:+, :-, :*, :min, :max, :div, :rem]
@impl true
def check(ast, _opts) do
{_ast, issues} =
Macro.prewalk(ast, [], fn
{:__block__, _, exprs} = node, acc when is_list(exprs) ->
case analyze_block(exprs) do
{:ok, %{meta: meta}} -> {node, [build_issue(meta) | acc]}
:error -> {node, acc}
end
node, acc ->
{node, acc}
end)
Enum.reverse(issues)
end
@impl true
def fix_patches(ast, opts) do
source = Keyword.fetch!(opts, :source)
RuleHelpers.patches_from_ast_transform(ast, source, fn input ->
Macro.postwalk(input, fn
{:__block__, meta, exprs} when is_list(exprs) ->
case transform_block(exprs) do
{:ok, new_exprs} -> {:__block__, meta, new_exprs}
:error -> {:__block__, meta, exprs}
end
node ->
node
end)
end)
end
# ── block analysis (shared by check and fix) ───────────────────────
defp analyze_block(exprs) do
with {:ok, assign_idx, var_name, freq1, freq2, meta} <- find_mapset_assignment(exprs),
true <- bare_var?(freq1) and bare_var?(freq2),
{:ok, map_idx, count1, count2, merge_expr} <-
find_enum_map(exprs, assign_idx + 1, var_name, freq1, freq2),
true <- pure_merge?(merge_expr, count1, count2),
true <- var_used_once?(exprs, var_name) do
{:ok,
%{
assign_idx: assign_idx,
map_idx: map_idx,
freq1: freq1,
freq2: freq2,
count1: count1,
count2: count2,
merge_expr: merge_expr,
meta: meta
}}
else
_ -> :error
end
end
defp transform_block(exprs) do
case analyze_block(exprs) do
{:ok, info} ->
replacement =
build_map_intersect(info.freq1, info.freq2, {info.count1, info.count2, info.merge_expr})
new_exprs =
exprs
|> Enum.with_index()
|> Enum.flat_map(fn
{_, idx} when idx == info.assign_idx -> []
{_, idx} when idx == info.map_idx -> [replacement]
{expr, _} -> [expr]
end)
{:ok, new_exprs}
:error ->
:error
end
end
# ── detection helpers ──────────────────────────────────────────────
# Find assignment: var_name = Map.keys(a) |> MapSet.new() |> ...
defp find_mapset_assignment(exprs) do
exprs
|> Enum.with_index()
|> Enum.find_value(:error, fn
{{:=, meta, [{var_name, _, nil}, pipeline]}, idx} ->
case extract_mapset_pipeline_vars(pipeline) do
{:ok, freq1, freq2} -> {:ok, idx, var_name, freq1, freq2, meta}
:error -> nil
end
_ ->
nil
end)
end
defp extract_mapset_pipeline_vars(node) do
case node do
{:|>, _,
[
{:|>, _,
[
{:|>, _,
[
{{:., _, [{:__aliases__, _, [:Map]}, :keys]}, _, [freq1]},
{{:., _, [{:__aliases__, _, [:MapSet]}, :new]}, _, []}
]},
{{:., _, [{:__aliases__, _, [:MapSet]}, :intersection]}, _,
[
{{:., _, [{:__aliases__, _, [:MapSet]}, :new]}, _,
[
{{:., _, [{:__aliases__, _, [:Map]}, :keys]}, _, [freq2]}
]}
]}
]},
{{:., _, [{:__aliases__, _, [:MapSet]}, :to_list]}, _, []}
]} ->
{:ok, freq1, freq2}
_ ->
:error
end
end
# Find Enum.map(var, fn ...) |> Enum.sort() using the assigned variable
defp find_enum_map(exprs, start_idx, var_name, freq1_ast, freq2_ast) do
freq1_name = elem(freq1_ast, 0)
freq2_name = elem(freq2_ast, 0)
exprs
|> Enum.drop(start_idx)
|> Enum.with_index(start_idx)
|> Enum.find_value(:error, fn {expr, idx} ->
case extract_enum_map_merge(expr, var_name, freq1_name, freq2_name) do
{:ok, result} -> {:ok, idx, elem(result, 0), elem(result, 1), elem(result, 2)}
:error -> nil
end
end)
end
defp extract_enum_map_merge(expr, var_name, freq1_name, freq2_name) do
case expr do
{:|>, _,
[
{:|>, _,
[
{^var_name, _, nil},
{{:., _, [{:__aliases__, _, [:Enum]}, :map]}, _, [fn_expr]}
]},
{{:., _, [{:__aliases__, _, [:Enum]}, :sort]}, _, []}
]} ->
extract_merge_fn(fn_expr, freq1_name, freq2_name)
_ ->
:error
end
end
defp extract_merge_fn(fn_expr, freq1_name, freq2_name) do
case fn_expr do
{:fn, _,
[
{:->, _,
[
[{elem_var, _, nil}],
{:__block__, _, stmts}
]}
]} ->
match_merge_stmts(stmts, elem_var, freq1_name, freq2_name)
_ ->
:error
end
end
defp match_merge_stmts(stmts, elem_var, freq1_name, freq2_name) do
case stmts do
[
{:=, _,
[
{count1, _, nil},
{{:., _, [{:__aliases__, _, [:Map]}, :fetch!]}, _,
[{^freq1_name, _, nil}, {^elem_var, _, nil}]}
]},
{:=, _,
[
{count2, _, nil},
{{:., _, [{:__aliases__, _, [:Map]}, :fetch!]}, _,
[{^freq2_name, _, nil}, {^elem_var, _, nil}]}
]},
{:__block__, _, [{{^elem_var, _, nil}, merge_expr}]}
]
when count1 != count2 ->
{:ok, {count1, count2, merge_expr}}
_ ->
:error
end
end
# ── safety gates ───────────────────────────────────────────────────
defp bare_var?({:__block__, _, [inner]}), do: bare_var?(inner)
defp bare_var?({name, _, nil}) when is_atom(name), do: true
defp bare_var?(_), do: false
# The merge is a pure arithmetic combination of `count1`/`count2` and numeric
# literals only. This excludes side effects (so the differing key-enumeration
# order of MapSet vs Map.intersect is unobservable) and any reference to the
# `element` key (which the rewrite drops to `_key`).
defp pure_merge?({:__block__, _, [inner]}, c1, c2), do: pure_merge?(inner, c1, c2)
defp pure_merge?({name, _, nil}, c1, c2) when name == c1 or name == c2, do: true
defp pure_merge?(n, _c1, _c2) when is_integer(n) or is_float(n), do: true
defp pure_merge?({op, _, [a, b]}, c1, c2) when op in @pure_binops,
do: pure_merge?(a, c1, c2) and pure_merge?(b, c1, c2)
defp pure_merge?({:-, _, [a]}, c1, c2), do: pure_merge?(a, c1, c2)
defp pure_merge?({:abs, _, [a]}, c1, c2), do: pure_merge?(a, c1, c2)
defp pure_merge?(_, _c1, _c2), do: false
# The assigned variable is bound once and used exactly once (in the
# `Enum.map`) — total of two occurrences across the block. The fix deletes the
# binding, so any further use would be left unbound.
defp var_used_once?(exprs, var_name) do
Enum.reduce(exprs, 0, fn expr, acc ->
{_, count} =
Macro.prewalk(expr, 0, fn
{^var_name, _, nil} = node, c -> {node, c + 1}
node, c -> {node, c}
end)
acc + count
end) == 2
end
# ── build replacement AST ─────────────────────────────────────────
defp build_map_intersect(freq1_ast, freq2_ast, {count1, count2, merge_expr}) do
# fn _key, count1, count2 -> merge_expr end
intersect_fn =
{:fn, [],
[
{:->, [],
[
[{:_key, [], nil}, {count1, [], nil}, {count2, [], nil}],
merge_expr
]}
]}
# fn {key, _value} -> key end
sort_by_fn =
{:fn, [],
[
{:->, [],
[
[{:__block__, [], [{{:key, [], nil}, {:_value, [], nil}}]}],
{:key, [], nil}
]}
]}
# freq1 |> Map.intersect(freq2, intersect_fn) |> Enum.sort_by(sort_by_fn)
freq1_ast
|> pipe({{:., [], [{:__aliases__, [], [:Map]}, :intersect]}, [], [freq2_ast, intersect_fn]})
|> pipe({{:., [], [{:__aliases__, [], [:Enum]}, :sort_by]}, [], [sort_by_fn]})
end
defp pipe(left, right), do: {:|>, [], [left, right]}
defp build_issue(meta) do
%Issue{
rule: :prefer_map_intersect_over_mapset_intersection,
message: "Use `Map.intersect/3` instead of MapSet intersection pipeline on map keys.",
meta: %{line: Keyword.get(meta, :line)}
}
end
end