lib/alias_sorter.ex

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

  ## Limitations

  - AliasSorter works on text and not on AST.
  """

  @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
    @alias_regex
    |> Regex.split(contents, include_captures: true)
    |> unsplit_groups()
    |> Enum.chunk_while({[], []}, &group_aliases/2, &dump_remaining/1)
    |> flatten_aliases_tuples()
  end

  defp unsplit_groups(split_by_aliases) do
    do_unsplit_groups(split_by_aliases, [])
  end

  defguardp group_sep(text) when text == "\n" or text == "\n\n"

  defp do_unsplit_groups([], result) do
    Enum.reverse(result)
  end

  defp do_unsplit_groups([w1, text, w2 | rest], result) when group_sep(w1) and group_sep(w2) do
    if grouped_alias?(text) do
      do_unsplit_groups(rest, ["\n", text, "\n" | result])
    else
      do_unsplit_groups(rest, [w1, text, w2 | result])
    end
  end

  defp do_unsplit_groups([text | rest], result) do
    do_unsplit_groups(rest, [text | result])
  end

  defp group_aliases(part, {aliases, with_as_part} = acc) do
    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

  defp dump_remaining({[], []}), do: {:cont, {[], []}}

  defp dump_remaining({aliases, with_as_part}) do
    {:cont, {Enum.reverse(aliases), Enum.reverse(with_as_part)}, {[], []}}
  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(&String.downcase/1)
    |> 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
    ["alias #{Enum.join(prefix, ".")}.{#{Enum.join(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 != [] ->
        grouped
        |> Enum.sort_by(&String.downcase/1)
        |> Enum.map(&(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

  defp grouped_alias?(text) do
    alias?(text) and Regex.match?(~r/\{.+\}/s, text)
  end
end