Current section

Files

Jump to
credence lib pattern no_identity_enum_map.ex
Raw

lib/pattern/no_identity_enum_map.ex

defmodule Credence.Pattern.NoIdentityEnumMap do
@moduledoc """
Detects `Enum.map/2` called with an identity function callback, which is just
a verbose `Enum.to_list/1`.
`Enum.map(enum, fn x -> x end)` walks the enumerable and rebuilds every
element unchanged — the closure does nothing. The idiomatic spelling of
"materialise this enumerable into a list" is `Enum.to_list/1`.
## Why `Enum.to_list/1` and not just the bare argument
Dropping the call entirely (`Enum.map(enum, & &1)``enum`) is **not**
behaviour-preserving: `Enum.map/2` always returns a list, so on a map it
returns a keyword list, on a range a list, and on a non-enumerable (a bare
string, an integer) it raises `Protocol.UndefinedError`. `Enum.to_list/1` has
the identical contract on every one of those inputs, so the rewrite is safe
with no assumptions. (A "delete it" rule would need to prove the argument is
statically a list — see `no_enum_count_for_length` for that style.)
## Bad
Enum.map(list, fn x -> x end)
list |> Enum.map(& &1)
Enum.map(enum, &Function.identity/1)
## Good
Enum.to_list(list)
list |> Enum.to_list()
Enum.to_list(enum)
## Auto-fix
Rewrites `Enum.map(enum, identity)` to `Enum.to_list(enum)` and the piped form
`|> Enum.map(identity)` to `|> Enum.to_list()`. Handles `fn x -> x end`,
`& &1`, `&(&1)`, and `&Function.identity/1` as identity functions.
"""
use Credence.Pattern.Rule
alias Credence.Issue
# Run LATE (after the default 500 band). This is a cleanup/normalisation:
# collapsing the no-op closure must not pre-empt a more specific structural
# rule that needs the raw `Enum.map` node — e.g. `no_map_then_aggregate` fuses
# `Enum.map(x, & &1) |> Enum.sum()` into a reduce that `no_explicit_sum_reduce`
# (501) then turns into `Enum.sum(x)`. Firing first would strand it as
# `Enum.to_list(x) |> Enum.sum()`. Let those run, then mop up any standalone
# identity map.
@impl true
def priority, do: 520
@impl true
def check(ast, _opts) do
{_ast, issues} =
Macro.prewalk(ast, [], fn
# Direct: Enum.map(enum, identity)
{{:., _, [{:__aliases__, _, [:Enum]}, :map]}, meta, [_enum, callback]} = node, acc ->
if identity_fn?(callback) do
{node, [build_issue(meta) | acc]}
else
{node, acc}
end
# Piped: enum |> Enum.map(identity)
{:|>, _, [_lhs, {{:., _, [{:__aliases__, _, [:Enum]}, :map]}, meta, [callback]}]} = node,
acc ->
if identity_fn?(callback) do
{node, [build_issue(meta) | acc]}
else
{node, acc}
end
node, acc ->
{node, acc}
end)
Enum.reverse(issues)
end
@impl true
def fix_patches(ast, _opts) do
{_ast, patches} =
Macro.prewalk(ast, [], fn
# Direct: Enum.map(enum, identity)
{{:., _, [{:__aliases__, _, [:Enum]}, :map]}, _meta, [enum, callback]} = node, acc ->
if identity_fn?(callback) do
{node, [direct_patch(node, enum) | acc]}
else
{node, acc}
end
# Piped: enum |> Enum.map(identity). Patch only the RHS call, leaving the
# `|>` and the LHS source byte-identical.
{:|>, _, [_lhs, {{:., _, [{:__aliases__, _, [:Enum]}, :map]}, _meta, [callback]} = rhs]} =
node,
acc ->
if identity_fn?(callback) do
{node, [piped_patch(rhs) | acc]}
else
{node, acc}
end
node, acc ->
{node, acc}
end)
Enum.reverse(patches)
end
defp direct_patch(node, enum) do
enum_text = Sourceror.to_string(enum)
%{
range: Sourceror.get_range(node),
change: "Enum.to_list(#{enum_text})"
}
end
defp piped_patch(rhs_node) do
%{
range: Sourceror.get_range(rhs_node),
change: "Enum.to_list()"
}
end
# fn x -> x end (single-clause, same variable in arg and body)
defp identity_fn?({:fn, _, [{:->, _, [[{var, _, ctx}], {var, _, ctx}]}]})
when is_atom(var) and is_atom(ctx),
do: true
# & &1
defp identity_fn?({:&, _, [{:&, _, [1]}]}), do: true
# &(&1)
defp identity_fn?({:&, _, [{:&, _, [{:__block__, _, [1]}]}]}), do: true
# &Function.identity/1
defp identity_fn?(
{:&, _,
[
{:/, _,
[
{{:., _, [{:__aliases__, _, [:Function]}, :identity]}, _, []},
1
]}
]}
),
do: true
# &Function.identity/1 with __block__-wrapped 1
defp identity_fn?(
{:&, _,
[
{:/, _,
[
{{:., _, [{:__aliases__, _, [:Function]}, :identity]}, _, []},
{:__block__, _, [1]}
]}
]}
),
do: true
defp identity_fn?(_), do: false
defp build_issue(meta) do
%Issue{
rule: :no_identity_enum_map,
message: """
`Enum.map/2` with an identity function is equivalent to `Enum.to_list/1`.
Simplify:
Enum.to_list(enum)
""",
meta: %{line: Keyword.get(meta, :line)}
}
end
end