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