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