lib/alias_sorter.ex

defmodule AliasSorter do
  @moduledoc """
  Sorts and groups aliases.

  ## Limitations

  - AliasSorter works on text and not on AST.
  - Grouped aliases spanning multiple lines have leading and trailing newline.
    This Elixir formatter behavior causes AliasSorter to consider it as a
    different group.
  """

  @behaviour Mix.Tasks.Format

  @formatter_opts ~w|file line_length locals_without_parens force_do_end_blocks|a

  # We match `alias X.Y`, `alias X.{Y, Z}`, `alias X, as: W` that are
  # placed at start of the line
  @module "(?:\\w+\\.)*\\w+"
  @modules_comma "(?:\\s*#{@module}\\s*(?:,|(?=})))+\\s*"
  @grouping_part "(?:\\.{#{@modules_comma}})"
  @as_part "(?:\\s*,\\s*as:\\s*\\w+)"
  @alias_regex ~r/^\ *alias #{@module}(?:#{@as_part}|#{@grouping_part}?)/m

  @spec features(keyword()) :: [extensions: [String.t()]]
  def features(_opts), do: [extensions: [".ex", ".exs"]]

  @spec format(String.t(), keyword()) :: String.t()
  def format(contents, opts) do
    formatter_config = Keyword.take(opts, @formatter_opts)

    contents
    |> find_alias_groups()
    |> Enum.map_join(&split_grouped/1)
    |> Code.format_string!(formatter_config)
    |> Kernel.++(["\n"])
    |> IO.iodata_to_binary()
  end

  defp find_alias_groups(contents) do
    group_aliases = fn part, {aliases, with_as_part} = acc ->
      cond do
        # we extract modules from aliases, treating aliases aliased by `:as` specially
        alias?(part) ->
          case extract_modules_from_aliases(part) do
            {:as, alias_} -> {:cont, {aliases, [alias_ | with_as_part]}}
            {:modules, modules} -> {:cont, {[modules | aliases], with_as_part}}
          end

        # we treat single newline as part of the group
        part == "\n" ->
          {:cont, acc}

        # we got multiple newlines or some other code so we dump gathered
        # aliases as a new group
        # this generates tuple containing previous aliases group and next code
        true ->
          {:cont, {{Enum.reverse(aliases), Enum.reverse(with_as_part)}, part}, {[], []}}
      end
    end

    dump_remaining = fn
      {[], []} ->
        {:cont, {[], []}}

      {aliases, with_as_part} ->
        {:cont, {Enum.reverse(aliases), Enum.reverse(with_as_part)}, {[], []}}
    end

    @alias_regex
    |> Regex.split(contents, include_captures: true)
    |> Enum.chunk_while({[], []}, group_aliases, dump_remaining)
    |> flatten_aliases_tuples()
  end

  defp split_grouped({aliases, with_as_part}) do
    aliases
    |> expand_aliases()
    |> split_to_module_parts()
    |> group_prefixes()
    |> join_prefixes(with_as_part)
  end

  defp split_grouped(code), do: code

  defp join_prefixes(grouped_aliases, with_as_part) do
    grouped_aliases
    |> Enum.flat_map(&join_alias_parts/1)
    |> Kernel.++(with_as_part)
    |> Enum.sort_by(fn a ->
      a |> String.downcase() |> String.split("{", parts: 2) |> List.first()
    end)
    |> Enum.join("\n")
  end

  defp join_alias_parts({[], modules}) do
    Enum.map(modules, &"alias #{&1}")
  end

  defp join_alias_parts({prefix, [single_module]}) do
    ["alias " <> Enum.join(prefix ++ [single_module], ".")]
  end

  defp join_alias_parts({prefix, suffixes}) do
    sorted_suffixes = Enum.sort_by(suffixes, &String.downcase/1)

    ["alias #{Enum.join(prefix, ".")}.{#{Enum.join(sorted_suffixes, ", ")}}"]
  end

  # We only group by second to last part of alias.
  defp group_prefixes(aliases) do
    Enum.group_by(
      aliases,
      fn modules -> List.delete_at(modules, -1) end,
      fn value -> List.last(value) end
    )
  end

  defp split_to_module_parts(aliases) do
    Enum.map(aliases, &String.split(&1, "."))
  end

  # Transforms `"X.{Y,Z}"` to `"X.Y\nX.Z". Aliases are sorted alphabetically.
  defp expand_aliases(aliases) do
    aliases
    |> Enum.map(&expand_alias/1)
    |> List.flatten()
    |> Enum.uniq()
  end

  defp expand_alias(alias_) do
    alias_
    |> String.split(["{", "}", ","], trim: true)
    |> case do
      [main_part | grouped] when grouped != [] -> Enum.map(grouped, &(main_part <> &1))
      [single_alias] -> single_alias
    end
  end

  # Transforms list of tuples with aliases groups and code to flat list of
  # groups and code
  defp flatten_aliases_tuples(grouped) do
    grouped
    |> Enum.reduce([], fn
      {[], code}, acc -> [code | acc]
      {alias_group, code}, acc -> [code, alias_group | acc]
    end)
    |> Enum.reverse()
  end

  # Takes full alias like `alias A.B.{C, D}`
  # and extracts only modules part and type of alias
  # into tuple: {:modules, "A.B.{C,D}"}.
  #
  # We treat aliases with `as: ` specially because they can't be grouped.
  defp extract_modules_from_aliases(full_alias) do
    has_as_part =
      @as_part
      |> Regex.compile!()
      |> Regex.match?(full_alias)

    trimmed = String.trim(full_alias)
    "alias" <> modules = remove_whitespace(trimmed)

    if has_as_part do
      {:as, trimmed}
    else
      {:modules, modules}
    end
  end

  defp remove_whitespace(string), do: String.replace(string, [" ", "\n"], "")

  defp alias?(text) do
    text
    |> String.trim_leading()
    |> String.starts_with?("alias")
  end
end