lib/eunomo.ex

defmodule Eunomo do
  @moduledoc """
  Sorts `alias`, `import` and `require` definitions alphabetically.

  The sorting does not happen globally. Instead each "block" is sorted separately. An "block" is a
  set of expressions that are _not_ separated by at least one empty newline or other
  non-(alias,import,require) expressions. Note that the file is first formatter by the default
  Elixir code formatter!

  Only the order of lines is modified by this formatter. Neither the overall number of lines nor the
  content of a single line will change.

  Besides the options `Code.format_string!/2` and `Mix.Tasks.Format`, the `.formatter.exs` file
  supports the following Eunomo specific options:

    * `:eunomo_opts` (a keyword list) - toggles expressions to be sorted. Available switches:
        * `sort_alias: :boolean`
        * `sort_import: :boolean`
        * `sort_require: :boolean`

      By default all are `false` & the behavior is identical to the default formatter.

  A complete `my_app`'s `.formatter.exs` would look like this:

      # my_app/.formatter.exs
      [
        plugins: [Eunomo],
        eunomo_opts: [
          sort_alias: true,
          sort_import: true,
          sort_require: true
        ],
        inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
      ]

  ## Examples

  Sorting only alias expressions:
      iex> code_snippet = \"\"\"
      ...> alias Eunomo.Z.{L, I}
      ...> alias Eunomo.Z
      ...> alias __MODULE__.B
      ...> alias __MODULE__.A
      ...> alias Eunomo.C
      ...> alias Eunomo.{
      ...>   L,
      ...>   B,
      ...>   # test
      ...> }
      ...> \\nalias Eunomo.PG.Repo
      ...> alias A
      ...> alias Eunomo.Patient
      ...> \"\"\"
      ...> Eunomo.format(code_snippet, [eunomo_opts: [sort_alias: true]])
      \"\"\"
      alias __MODULE__.A
      alias __MODULE__.B
      alias Eunomo.C
      alias Eunomo.Z
      alias Eunomo.Z.{L, I}
      \\nalias Eunomo.{
        L,
        B
        # test
      }
      \\nalias A
      alias Eunomo.Patient
      alias Eunomo.PG.Repo
      \"\"\"

  Sorting only import expressions:
      iex> code_snippet = \"\"\"
      ...> import Eunomo.Z.{L, I}
      ...> import Eunomo.Z, only: [hello_world: 0]
      ...> import B, expect: [callback: 1]
      ...> import Eunomo.C
      ...> import Eunomo.{
      ...>   L,
      ...>   B,
      ...>   # test
      ...> }
      ...> \\nimport Eunomo.PG.Repo
      ...> import A
      ...> import Eunomo.Patient
      ...> \"\"\"
      ...> Eunomo.format(code_snippet, [eunomo_opts: [sort_import: true]])
      \"\"\"
      import B, expect: [callback: 1]
      import Eunomo.C
      import Eunomo.Z, only: [hello_world: 0]
      import Eunomo.Z.{L, I}
      \\nimport Eunomo.{
        L,
        B
        # test
      }
      \\nimport A
      import Eunomo.Patient
      import Eunomo.PG.Repo
      \"\"\"


  Sorting only require expressions:
      iex> code_snippet = \"\"\"
      ...> require Eunomo.Z.{L, I}
      ...> require Eunomo.Z
      ...> require Eunomo.C
      ...> require Eunomo.{
      ...>   L,
      ...>   B,
      ...>   # test
      ...> }
      ...> \\nrequire Eunomo.PG.Repo
      ...> require A
      ...> require Eunomo.Patient
      ...> \"\"\"
      ...> Eunomo.format(code_snippet, [eunomo_opts: [sort_require: true]])
      \"\"\"
      require Eunomo.C
      require Eunomo.Z
      require Eunomo.Z.{L, I}
      \\nrequire Eunomo.{
        L,
        B
        # test
      }
      \\nrequire A
      require Eunomo.Patient
      require Eunomo.PG.Repo
      \"\"\"

  """

  @behaviour Mix.Tasks.Format

  alias Eunomo.ExpressionSorter
  alias Eunomo.LineMap

  @impl true
  @spec features(Keyword.t()) :: [sigils: [atom()], extensions: [binary()]]
  def features(_opts) do
    [extensions: [".ex", ".exs"]]
  end

  @impl true
  @spec format(String.t(), Keyword.t()) :: String.t()
  def format(content, opts) do
    content
    # Elixir formatter plugin system checks if a file matches a plugin file extension and then
    # dispatches to a matching formatter. In current main (>1.14.1) that is already expanded by
    # allowing multiple formatters formatting the same file extension after each other
    # (https://github.com/elixir-lang/elixir/pull/12032). But .ex and .exs files do not allow this,
    # hence we have to explicitly call the Elixir formatter here.
    |> elixir_format(opts)
    |> eunomo_format(opts)
  end

  defp eunomo_format(content, opts) do
    eunomo_opts = Keyword.get(opts, :eunomo_opts, [])
    sort_alias? = Keyword.get(eunomo_opts, :sort_alias, false)
    sort_import? = Keyword.get(eunomo_opts, :sort_import, false)
    sort_require? = Keyword.get(eunomo_opts, :sort_require, false)

    line_map = LineMap.from_code_string(content)

    line_map =
      if sort_alias? do
        ExpressionSorter.format(line_map, :alias)
      else
        line_map
      end

    line_map =
      if sort_import? do
        ExpressionSorter.format(line_map, :import)
      else
        line_map
      end

    line_map =
      if sort_require? do
        ExpressionSorter.format(line_map, :require)
      else
        line_map
      end

    LineMap.to_code_string(line_map)
  end

  # Copied from Mix.Tasks.Format since it is private
  defp elixir_format(content, formatter_opts) do
    case Code.format_string!(content, formatter_opts) do
      [] -> ""
      formatted_content -> IO.iodata_to_binary([formatted_content, ?\n])
    end
  end
end