lib/style/module_directives.ex

# Copyright 2023 Adobe. All rights reserved.
# This file is licensed to you under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. You may obtain a copy
# of the License at http://www.apache.org/licenses/LICENSE-2.0

# Unless required by applicable law or agreed to in writing, software distributed under
# the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
# OF ANY KIND, either express or implied. See the License for the specific language
# governing permissions and limitations under the License.

defmodule Styler.Style.ModuleDirectives do
  @moduledoc """
  Styles up module directives!

  This Style will expand multi-aliases/requires/imports/use and sort the directive within its groups (except `use`s, which cannot be sorted)
  It also adds a blank line after each directive group.

  ## Credo rules

  Rewrites for the following Credo rules:

    * `Credo.Check.Consistency.MultiAliasImportRequireUse` (force expansion)
    * `Credo.Check.Readability.AliasOrder` (we sort `__MODULE__`, which credo doesn't)
    * `Credo.Check.Readability.ModuleDoc` (adds `@moduledoc false` if missing. includes `*.exs` files)
    * `Credo.Check.Readability.MultiAlias`
    * `Credo.Check.Readability.StrictModuleLayout` (see section below for details)
    * `Credo.Check.Readability.UnnecessaryAliasExpansion`

  ## Strict Layout

  **This can break your code.**

  Modules directives are sorted into the following order:

    * `@shortdoc`
    * `@moduledoc`
    * `@behaviour`
    * `use`
    * `import`
    * `alias`
    * `require`
    * everything else (unchanged)

  If any of the sorted directives had a dependency on code that is now below it, your code will fail to compile after being styled.

  For instance, the following will be broken because the module attribute definition will
  be moved below the `use` clause, meaning `@pi` is undefined when invoked.

    ```elixir
    # before `mix style`
    defmodule Approximation do
      @pi 3.14
      use Math, pi: @pi
    end

    # after `mix style`
    defmodule Approximation do
      @moduledoc false
      use Math, pi: @pi
      @pi 3.14
    end
    ```

  For now, it's up to you to come up with a fix for this issue. Sorry!
  """
  @behaviour Styler.Style

  alias Styler.Style
  alias Styler.Zipper

  @directives ~w(alias import require use)a
  @attr_directives ~w(moduledoc shortdoc behaviour)a

  # module names ending with these suffixes will not have a default moduledoc appended
  @dont_moduledoc ~w(Test Mixfile MixProject Controller Endpoint Repo Router Socket View HTML JSON)
  @moduledoc_false {:@, [], [{:moduledoc, [], [{:__block__, [], [false]}]}]}

  def run({{:defmodule, _, children}, _} = zipper, ctx) do
    [{:__aliases__, _, aliases}, [{{:__block__, do_meta, [:do]}, _module_body}]] = children

    if do_meta[:format] == :keyword do
      {:skip, zipper, ctx}
    else
      name = aliases |> List.last() |> to_string()
      add_moduledoc? = not String.ends_with?(name, @dont_moduledoc)
      # Move the zipper's focus to the module's body
      body_zipper = zipper |> Zipper.down() |> Zipper.right() |> Zipper.down() |> Zipper.down() |> Zipper.right()

      case Zipper.node(body_zipper) do
        {:__block__, _, _} ->
          {:skip, organize_directives(body_zipper, add_moduledoc?), ctx}

        {:@, _, [{:moduledoc, _, _}]} ->
          # a module whose only child is a moduledoc. nothing to do here!
          # seems weird at first blush but lots of projects/libraries do this with their root namespace module
          {:skip, zipper, ctx}

        only_child ->
          # There's only one child, and it's not a moduledoc. Conditionally add a moduledoc, then style the only_child
          if add_moduledoc? do
            body_zipper
            |> Zipper.replace({:__block__, [], [@moduledoc_false, only_child]})
            |> Zipper.down()
            |> Zipper.right()
            |> run(ctx)
          else
            run(body_zipper, ctx)
          end
      end
    end
  end

  def run({{directive, _, _}, _} = zipper, ctx) when directive in @directives do
    parent = zipper |> Style.ensure_block_parent() |> Zipper.up()
    {:skip, organize_directives(parent), ctx}
  end

  def run(zipper, ctx), do: {:cont, zipper, ctx}

  defp organize_directives(parent, add_moduledoc? \\ false) do
    {directives, nondirectives} =
      parent
      |> Zipper.node()
      |> Zipper.children()
      |> Enum.split_with(fn
        {:@, _, [{attr, _, _}]} -> attr in @attr_directives
        {directive, _, _} -> directive in @directives
        _ -> false
      end)

    directives =
      Enum.group_by(directives, fn
        {:@, _, [{attr_name, _, _}]} -> :"@#{attr_name}"
        {directive, _, _} -> directive
      end)

    shortdocs = directives[:"@shortdoc"] || []
    moduledocs = directives[:"@moduledoc"] || if add_moduledoc?, do: [@moduledoc_false], else: []
    behaviours = expand_and_sort(directives[:"@behaviour"] || [])

    uses = (directives[:use] || []) |> Enum.flat_map(&expand_directive/1) |> reset_newlines()

    imports = expand_and_sort(directives[:import] || [])
    aliases = expand_and_sort(directives[:alias] || [])
    requires = expand_and_sort(directives[:require] || [])

    directives =
      Enum.concat([
        shortdocs,
        moduledocs,
        behaviours,
        uses,
        imports,
        aliases,
        requires
      ])

    cond do
      Enum.empty?(directives) ->
        parent

      Enum.empty?(nondirectives) ->
        Zipper.update(parent, &Zipper.replace_children(&1, directives))

      true ->
        {last_directive, meta} =
          parent
          |> Zipper.update(&Zipper.replace_children(&1, directives))
          |> Zipper.down()
          |> Zipper.rightmost()

        {last_directive, %{meta | r: nondirectives}}
    end
  end

  defp expand_and_sort(directives) do
    # sorting is done with `downcase` to match Credo
    directives
    |> Enum.flat_map(&expand_directive/1)
    |> Enum.map(&{&1, &1 |> Macro.to_string() |> String.downcase()})
    |> Enum.uniq_by(&elem(&1, 1))
    |> List.keysort(1)
    |> Enum.map(&elem(&1, 0))
    |> reset_newlines()
  end

  # alias Foo.{Bar, Baz}
  # =>
  # alias Foo.Bar
  # alias Foo.Baz
  defp expand_directive({directive, _, [{{:., _, [{_, _, module}, :{}]}, _, right}]}),
    do: Enum.map(right, fn {_, meta, segments} -> {directive, meta, [{:__aliases__, [], module ++ segments}]} end)

  defp expand_directive(other), do: [other]

  defp reset_newlines([]), do: []
  defp reset_newlines(directives), do: reset_newlines(directives, [])

  defp reset_newlines([directive], acc), do: Enum.reverse([set_newlines(directive, 2) | acc])
  defp reset_newlines([directive | rest], acc), do: reset_newlines(rest, [set_newlines(directive, 1) | acc])

  defp set_newlines({directive, meta, children}, newline) do
    updated_meta = Keyword.update(meta, :end_of_expression, [newlines: newline], &Keyword.put(&1, :newlines, newline))
    {directive, updated_meta, children}
  end
end