Current section
Files
Jump to
Current section
Files
lib/pattern/no_redundant_enum_join_separator.ex
defmodule Credence.Pattern.NoRedundantEnumJoinSeparator do
@moduledoc """
Readability rule: Detects `Enum.join("")` and `Enum.map_join("", mapper)`
where the empty-string separator is passed explicitly.
`Enum.join/1` and `Enum.map_join/2` already default to `""`, so the argument
adds visual noise without changing behaviour.
## Bad
graphemes |> Enum.join("")
Enum.join(list, "")
items |> Enum.map_join("", &to_string/1)
Enum.map_join(items, "", &to_string/1)
## Good
graphemes |> Enum.join()
Enum.join(list)
items |> Enum.map_join(&to_string/1)
Enum.map_join(items, &to_string/1)
"""
use Credence.Pattern.Rule
alias Credence.Issue
@impl true
def check(ast, _opts) do
{_ast, issues} =
Macro.prewalk(ast, [], fn
# Enum.join(list, "") | ... |> Enum.join("")
{{:., _, [{:__aliases__, _, [:Enum]}, :join]}, meta, args} = node, issues
when length(args) in [1, 2] ->
if empty_string?(List.last(args)),
do: {node, [build_issue(meta) | issues]},
else: {node, issues}
# Enum.map_join(list, "", mapper) | ... |> Enum.map_join("", mapper)
{{:., _, [{:__aliases__, _, [:Enum]}, :map_join]}, meta, args} = node, issues
when length(args) in [2, 3] ->
sep = args |> Enum.reverse() |> Enum.at(1)
if empty_string?(sep),
do: {node, [build_issue(meta) | issues]},
else: {node, issues}
node, issues ->
{node, issues}
end)
Enum.reverse(issues)
end
@impl true
def fix_patches(ast, _opts) do
Credence.RuleHelpers.patches_from_postwalk(ast, fn
# Direct call: Enum.join(list, "") → Enum.join(list)
{{:., dot_m, [{:__aliases__, al_m, [:Enum]}, :join]}, call_m, [list_arg, sep]} = node ->
if empty_string?(sep),
do: {{:., dot_m, [{:__aliases__, al_m, [:Enum]}, :join]}, call_m, [list_arg]},
else: node
# Piped call: ... |> Enum.join("") → ... |> Enum.join()
{{:., dot_m, [{:__aliases__, al_m, [:Enum]}, :join]}, call_m, [sep]} = node ->
if empty_string?(sep),
do: {{:., dot_m, [{:__aliases__, al_m, [:Enum]}, :join]}, call_m, []},
else: node
# Direct call: Enum.map_join(list, "", mapper) → Enum.map_join(list, mapper)
{{:., dot_m, [{:__aliases__, al_m, [:Enum]}, :map_join]}, call_m, [list_arg, sep, mapper]} =
node ->
if empty_string?(sep),
do:
{{:., dot_m, [{:__aliases__, al_m, [:Enum]}, :map_join]}, call_m, [list_arg, mapper]},
else: node
# Piped call: ... |> Enum.map_join("", mapper) → ... |> Enum.map_join(mapper)
{{:., dot_m, [{:__aliases__, al_m, [:Enum]}, :map_join]}, call_m, [sep, mapper]} = node ->
if empty_string?(sep),
do: {{:., dot_m, [{:__aliases__, al_m, [:Enum]}, :map_join]}, call_m, [mapper]},
else: node
# NOTE: we deliberately do NOT collapse `x |> Enum.join()` to `Enum.join(x)`
# (or the map_join equivalent). The piped result of dropping `""` is already
# correct and idiomatic; collapsing it cannot tell "I just removed the `""`
# here" from a pre-existing `x |> Enum.join()` that the check never flagged,
# so it would rewrite clean code — putting the fix's scope outside the
# check's. Leave the pipe form as-is.
node ->
node
end)
end
# Sourceror wraps string literals with metadata: {:__block__, meta, [""]}
# Match both the wrapped and bare forms.
defp empty_string?(""), do: true
defp empty_string?({:__block__, _, [""]}), do: true
defp empty_string?(_), do: false
defp build_issue(meta) do
%Issue{
rule: :no_redundant_enum_join_separator,
message:
"`Enum.join/1` and `Enum.map_join/2` already default to an empty string separator. " <>
"Remove the redundant `\"\"` argument.",
meta: %{line: Keyword.get(meta, :line)}
}
end
end