Current section

Files

Jump to
credence lib rule use_map_join.ex
Raw

lib/rule/use_map_join.ex

defmodule Credence.Rule.UseMapJoin do
@moduledoc """
Detects `Enum.map/2` chained into `Enum.join/1` or `Enum.join/2`,
and suggests `Enum.map_join/3` instead.
## Why this matters
`Enum.map/2` followed by `Enum.join` creates a throwaway intermediate
list. `Enum.map_join/3` maps and joins in a single pass with no
intermediate allocation:
# Flagged — intermediate list
list
|> Enum.map(&to_string/1)
|> Enum.join(", ")
# Idiomatic — single pass
Enum.map_join(list, ", ", &to_string/1)
LLMs generate the two-step version frequently because they decompose
"transform then combine" into separate operations.
## Flagged patterns
- `Enum.map(enum, f) |> Enum.join()` (pipeline, any separator)
- `Enum.join(Enum.map(enum, f))` (nested call)
- Longer pipelines where map and join are adjacent steps
Only **adjacent** map→join is flagged. Intervening steps like
`Enum.map(f) |> Enum.filter(g) |> Enum.join()` are left alone.
## Severity
`:warning`
"""
@behaviour Credence.Rule
alias Credence.Issue
@impl true
def check(ast, _opts) do
{_ast, issues} =
Macro.prewalk(ast, [], fn
# Pipeline form: ... |> Enum.map(...) |> Enum.join(...)
{:|>, meta, [left, right]} = node, issues ->
if remote_call?(right, :Enum, :join) and
remote_call?(rightmost(left), :Enum, :map) do
{node, [build_issue(meta) | issues]}
else
{node, issues}
end
# Nested call form: Enum.join(Enum.map(...), ...)
{{:., _, [{:__aliases__, _, [:Enum]}, :join]}, meta,
[{{:., _, [{:__aliases__, _, [:Enum]}, :map]}, _, _} | _rest]} = node,
issues ->
{node, [build_issue(meta) | issues]}
node, issues ->
{node, issues}
end)
Enum.reverse(issues)
end
# In a pipeline `a |> b |> c`, the AST is nested as
# {:|>, _, [{:|>, _, [a, b]}, c]}. `rightmost` extracts `b` —
# the step immediately before the current right-hand side.
defp rightmost({:|>, _, [_, right]}), do: right
defp rightmost(other), do: other
defp remote_call?(node, mod, func) do
match?({{:., _, [{:__aliases__, _, [^mod]}, ^func]}, _, _}, node)
end
defp build_issue(meta) do
%Issue{
rule: :use_map_join,
severity: :warning,
message: """
`Enum.map/2` piped into `Enum.join` creates an intermediate list.
Use `Enum.map_join/3` for a single-pass operation:
Enum.map_join(enumerable, separator, mapper_fn)
""",
meta: %{line: Keyword.get(meta, :line)}
}
end
end