Current section
Files
Jump to
Current section
Files
lib/rule/no_map_keys_enum_lookup.ex
defmodule Credence.Rule.NoMapKeysEnumLookup do
@moduledoc """
Detects `Map.keys(var)` piped into an `Enum` function whose callback
also looks up values from the same map variable.
## Why this matters
LLMs frequently port the Python idiom `for key in dict: ... dict[key]`
into Elixir as `Map.keys(map) |> Enum.xxx(fn k -> ... map[k] ... end)`.
This creates an unnecessary intermediate list and performs redundant
lookups. In Elixir, maps are directly enumerable as `{key, value}`
pairs:
# Flagged — extra allocation + redundant lookups
Map.keys(freqs)
|> Enum.all?(fn char -> Map.get(other, char, 0) >= freqs[char] end)
# Idiomatic — single traversal, values already in hand
Enum.all?(freqs, fn {char, count} ->
Map.get(other, char, 0) >= count
end)
## Detection scope (strict)
Only flagged when **all three** conditions hold:
1. `Map.keys(var)` is called on a simple variable,
2. The result is passed to one of: `Enum.all?`, `Enum.any?`,
`Enum.each`, `Enum.map`, `Enum.filter`, `Enum.reject`,
`Enum.flat_map`, and
3. The callback body references `var` via `var[key]`,
`Map.get(var, ...)`, `Map.fetch(var, ...)`, or
`Map.fetch!(var, ...)`.
Patterns where only keys are needed (no value lookup in the callback)
are **not** flagged.
## Severity
`:warning`
"""
@behaviour Credence.Rule
alias Credence.Issue
@flagged_enum_fns [:all?, :any?, :each, :map, :filter, :reject, :flat_map]
@impl true
def check(ast, _opts) do
{_ast, issues} =
Macro.prewalk(ast, [], fn node, issues ->
case check_node(node) do
{:ok, issue} -> {node, [issue | issues]}
:error -> {node, issues}
end
end)
Enum.reverse(issues)
end
# ------------------------------------------------------------
# NODE MATCHING
# ------------------------------------------------------------
# Pipeline form: Map.keys(var) |> Enum.xxx(fn ...)
# or: var |> Map.keys() |> Enum.xxx(fn ...)
defp check_node({:|>, meta, _} = node) do
pipeline = flatten_pipeline(node)
check_pipeline(pipeline, meta)
end
# Direct call form: Enum.xxx(Map.keys(var), fn ...)
defp check_node({{:., _, [mod, fn_name]}, meta, [first_arg, callback | _]})
when fn_name in @flagged_enum_fns do
with true <- enum_module?(mod),
{:ok, var_name} <- extract_map_keys_var(first_arg),
true <- references_map_var?(callback, var_name) do
{:ok, build_issue(fn_name, var_name, meta)}
else
_ -> :error
end
end
defp check_node(_), do: :error
# ------------------------------------------------------------
# PIPELINE ANALYSIS
# ------------------------------------------------------------
defp check_pipeline(steps, meta) do
# Try two-step first: [Map.keys(var), Enum.xxx(callback)]
case check_two_step(steps, meta) do
{:ok, _} = result -> result
:error -> check_three_step(steps, meta)
end
end
# Pattern A: Map.keys(var) |> Enum.xxx(callback)
defp check_two_step([map_keys_call, enum_call | _], meta) do
with {:ok, var_name} <- extract_map_keys_var(map_keys_call),
{:ok, fn_name, callback} <- extract_piped_enum(enum_call),
true <- references_map_var?(callback, var_name) do
{:ok, build_issue(fn_name, var_name, meta)}
else
_ -> :error
end
end
defp check_two_step(_, _), do: :error
# Pattern B: var |> Map.keys() |> Enum.xxx(callback)
defp check_three_step([var_expr, map_keys_call, enum_call | _], meta) do
with {:ok, var_name} <- extract_simple_var(var_expr),
true <- map_keys_no_args?(map_keys_call),
{:ok, fn_name, callback} <- extract_piped_enum(enum_call),
true <- references_map_var?(callback, var_name) do
{:ok, build_issue(fn_name, var_name, meta)}
else
_ -> :error
end
end
defp check_three_step(_, _), do: :error
# ------------------------------------------------------------
# EXTRACTORS
# ------------------------------------------------------------
# Map.keys(var) — full call with one argument
defp extract_map_keys_var({{:., _, [mod, :keys]}, _, [arg]}) do
if map_module?(mod) do
extract_simple_var(arg)
else
:error
end
end
defp extract_map_keys_var(_), do: :error
# Map.keys() — pipeline form with no explicit args
defp map_keys_no_args?({{:., _, [mod, :keys]}, _, []}) do
map_module?(mod)
end
defp map_keys_no_args?(_), do: false
# Enum.xxx(callback) — pipeline form, one explicit arg
defp extract_piped_enum({{:., _, [mod, fn_name]}, _, [callback]})
when fn_name in @flagged_enum_fns do
if enum_module?(mod), do: {:ok, fn_name, callback}, else: :error
end
defp extract_piped_enum(_), do: :error
# Simple variable: {name, _, context}
defp extract_simple_var({name, _, ctx}) when is_atom(name) and is_atom(ctx) do
{:ok, name}
end
defp extract_simple_var(_), do: :error
# ------------------------------------------------------------
# CALLBACK BODY INSPECTION
#
# Walk the callback AST looking for lookups on the source map
# variable via Access syntax or Map.get/fetch/fetch!.
# ------------------------------------------------------------
defp references_map_var?(callback, target_var) do
{_, found} =
Macro.prewalk(callback, false, fn
# Short-circuit once found
node, true ->
{node, true}
# Access syntax: var[key]
{{:., _, [Access, :get]}, _, [{name, _, ctx}, _]} = node, acc
when is_atom(name) and is_atom(ctx) ->
if name == target_var, do: {node, true}, else: {node, acc}
# Map.get / Map.fetch / Map.fetch!
{{:., _, [mod, fn_name]}, _, [{name, _, ctx} | _]} = node, acc
when is_atom(name) and is_atom(ctx) and fn_name in [:get, :fetch, :fetch!] ->
if map_module?(mod) and name == target_var,
do: {node, true},
else: {node, acc}
node, acc ->
{node, acc}
end)
found
end
# ------------------------------------------------------------
# HELPERS
# ------------------------------------------------------------
defp flatten_pipeline({:|>, _, [left, right]}) do
flatten_pipeline(left) ++ [right]
end
defp flatten_pipeline(expr), do: [expr]
defp enum_module?({:__aliases__, _, [:Enum]}), do: true
defp enum_module?(_), do: false
defp map_module?({:__aliases__, _, [:Map]}), do: true
defp map_module?(_), do: false
# ------------------------------------------------------------
# MESSAGE GENERATION
# ------------------------------------------------------------
defp build_issue(fn_name, var_name, meta) do
%Issue{
rule: :no_map_keys_enum_lookup,
severity: :warning,
message: build_message(fn_name, var_name),
meta: %{line: Keyword.get(meta, :line)}
}
end
defp build_message(fn_name, var_name) do
"""
`Map.keys(#{var_name})` piped into `Enum.#{fn_name}/2` while also \
looking up values from `#{var_name}` inside the callback.
Iterate the map directly to get both keys and values in one pass:
Enum.#{fn_name}(#{var_name}, fn {key, value} -> ... end)
"""
end
end