Current section
Files
Jump to
Current section
Files
lib/pattern/no_dead_map_update.ex
defmodule Credence.Pattern.NoDeadMapUpdate do
@moduledoc """
Detects a *no-op* `Map.update(key, literal, & &1) |> Map.drop([key])` (and the
`Map.delete`/direct-call variants) where the updated value is immediately
thrown away by dropping the same key.
## Safe core only
In general `Map.update(map, key, default, fun)` runs `fun` on the **existing**
value when `key` is present, and eagerly evaluates `default`. Removing the
update would therefore drop any exception or side effect that `fun`/`default`
produces — e.g. `%{prev: "x"} |> Map.update(:prev, 0, &(&1 - 1))` raises
`ArithmeticError`, whereas `Map.drop(map, [:prev])` returns `%{}`. That is a
behaviour change, so those forms are intentionally **not** flagged.
This rule fires only when removing the update is provably output-identical:
- `fun` is the identity capture `& &1` — `fun.(value) == value` for every
value, so it can never raise or side-effect; and
- `default` is a literal — a pure value whose eager evaluation has no
observable effect.
Under those two conditions the update is a genuine no-op before the key is
dropped, so it can be removed for *any* input.
## Bad
map |> Map.update(prev, 0, & &1) |> Map.drop([prev])
Map.delete(Map.update(map, key, 0, & &1), key)
## Good
Map.drop(map, [prev])
Map.delete(map, key)
## Auto-fix
Removes the dead identity `Map.update` call, keeping only the drop/delete.
"""
use Credence.Pattern.Rule
alias Credence.Issue
@impl true
def check(ast, _opts) do
{_ast, issues} =
Macro.prewalk(ast, [], fn node, acc ->
case match_dead_update(node) do
{:ok, meta} -> {node, [build_issue(meta) | acc]}
:no_match -> {node, acc}
end
end)
Enum.reverse(issues)
end
@impl true
def fix_patches(ast, _opts) do
Credence.RuleHelpers.patches_from_postwalk(ast, fn node ->
case match_dead_update(node) do
{:ok, _meta} -> simplify(node)
:no_match -> node
end
end)
end
# Piped form: map |> Map.update(key, default, & &1) |> Map.drop([key_list])
defp match_dead_update(
{:|>, meta,
[
{:|>, _, [_map, update_call]},
{{:., _, [{:__aliases__, _, [:Map]}, :drop]}, _, [drop_keys]}
]}
) do
with {key, default, fun} <- piped_update_parts(update_call),
true <- safe_core?(default, fun),
true <- key_in_drop_list?(key, drop_keys) do
{:ok, meta}
else
_ -> :no_match
end
end
# Piped form: map |> Map.update(key, default, & &1) |> Map.delete(key)
defp match_dead_update(
{:|>, meta,
[
{:|>, _, [_map, update_call]},
{{:., _, [{:__aliases__, _, [:Map]}, :delete]}, _, [del_key]}
]}
) do
with {key, default, fun} <- piped_update_parts(update_call),
true <- safe_core?(default, fun),
true <- keys_match?(key, del_key) do
{:ok, meta}
else
_ -> :no_match
end
end
# Direct form: Map.drop(Map.update(map, key, default, & &1), [key_list])
defp match_dead_update(
{{:., meta, [{:__aliases__, _, [:Map]}, :drop]}, _,
[
{{:., _, [{:__aliases__, _, [:Map]}, :update]}, _, [_map, key, default, fun]},
drop_keys
]}
) do
if safe_core?(default, fun) and key_in_drop_list?(key, drop_keys) do
{:ok, meta}
else
:no_match
end
end
# Direct form: Map.delete(Map.update(map, key, default, & &1), key)
defp match_dead_update(
{{:., meta, [{:__aliases__, _, [:Map]}, :delete]}, _,
[
{{:., _, [{:__aliases__, _, [:Map]}, :update]}, _, [_map, key, default, fun]},
del_key
]}
) do
if safe_core?(default, fun) and keys_match?(key, del_key) do
{:ok, meta}
else
:no_match
end
end
defp match_dead_update(_), do: :no_match
# Extract (key, default, fun) from a piped `Map.update(key, default, fun)`
# call (the map is supplied by the pipe, so it is not in the arg list).
defp piped_update_parts(
{{:., _, [{:__aliases__, _, [:Map]}, :update]}, _, [key, default, fun]}
),
do: {key, default, fun}
defp piped_update_parts(_), do: :no_match
# The two conditions that make removing the update output-identical for every
# input: identity `fun` (never raises/side-effects) and a pure literal default.
defp safe_core?(default, fun), do: identity_fun?(fun) and literal_default?(default)
# `& &1` / `&(&1)` — the identity capture.
defp identity_fun?({:&, _, [{:&, _, [1]}]}), do: true
defp identity_fun?(_), do: false
# Eager-evaluation-safe defaults: scalars (incl. negative numbers) and `[]`.
defp literal_default?({:__block__, _, [[]]}), do: true
defp literal_default?({:__block__, _, [v]}) when is_number(v) or is_atom(v) or is_binary(v),
do: true
defp literal_default?({:-, _, [{:__block__, _, [n]}]}) when is_number(n), do: true
defp literal_default?(v) when is_number(v) or is_atom(v) or is_binary(v), do: true
defp literal_default?(_), do: false
# Replace the dead-update node with just the drop/delete, removing the update.
defp simplify(
{:|>, _,
[
{:|>, _, [map, _update_call]},
{{:., _, [{:__aliases__, _, [:Map]}, :drop]}, _, [drop_keys]}
]}
) do
{{:., [], [{:__aliases__, [], [:Map]}, :drop]}, [], [map, drop_keys]}
end
defp simplify(
{:|>, _,
[
{:|>, _, [map, _update_call]},
{{:., _, [{:__aliases__, _, [:Map]}, :delete]}, _, [del_key]}
]}
) do
{{:., [], [{:__aliases__, [], [:Map]}, :delete]}, [], [map, del_key]}
end
defp simplify(
{{:., _, [{:__aliases__, _, [:Map]}, :drop]}, _,
[
{{:., _, [{:__aliases__, _, [:Map]}, :update]}, _, [map | _]},
drop_keys
]}
) do
{{:., [], [{:__aliases__, [], [:Map]}, :drop]}, [], [map, drop_keys]}
end
defp simplify(
{{:., _, [{:__aliases__, _, [:Map]}, :delete]}, _,
[
{{:., _, [{:__aliases__, _, [:Map]}, :update]}, _, [map | _]},
del_key
]}
) do
{{:., [], [{:__aliases__, [], [:Map]}, :delete]}, [], [map, del_key]}
end
defp simplify(node), do: node
defp key_in_drop_list?(key, {:__block__, _, [list]}) when is_list(list) do
Enum.any?(list, &keys_match?(key, &1))
end
defp key_in_drop_list?(key, list) when is_list(list) do
Enum.any?(list, &keys_match?(key, &1))
end
defp key_in_drop_list?(_, _), do: false
defp keys_match?({name, _, ctx}, {name, _, ctx2})
when is_atom(name) and is_atom(ctx) and is_atom(ctx2),
do: true
defp keys_match?(literal, literal), do: true
defp keys_match?(_, _), do: false
defp build_issue(meta) do
%Issue{
rule: :no_dead_map_update,
message:
"`Map.update(key, literal, & &1) |> Map.drop([key])` discards a no-op update. " <>
"Use `Map.drop(map, [key])` or `Map.delete(map, key)` directly.",
meta: %{line: Keyword.get(meta, :line)}
}
end
end