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