# Copyright 2024 Adobe. All rights reserved. # Copyright 2025 SmartRent. 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 Quokka.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.MultiAlias` * `Credo.Check.Readability.StrictModuleLayout` (see section below for details) * `Credo.Check.Readability.UnnecessaryAliasExpansion` * `Credo.Check.Design.AliasUsage` ### Strict Layout Modules directives are sorted into the following order: * `@shortdoc` * `@moduledoc` * `@behaviour` * `use` * `import` * `alias` * `require` * everything else (unchanged) """ @behaviour Quokka.Style alias Quokka.AliasEnv alias Quokka.Style alias Quokka.Zipper @directives ~w(alias import require use)a @attr_directives ~w(moduledoc shortdoc behaviour)a @defstruct ~w(schema embedded_schema defstruct)a @module_placeholder "Xk9pLm3Qw7_RAND_PLACEHOLDER" @moduledoc_false {:@, [line: nil], [ {:moduledoc, [line: nil], [{:__block__, [line: nil], [@module_placeholder]}]} ]} def run({{:defmodule, _, children}, _} = zipper, ctx) do if has_skip_comment?(ctx) do {:skip, zipper, ctx} else [name, [{{:__block__, do_meta, [:do]}, _body}]] = children if do_meta[:format] == :keyword do {:skip, zipper, ctx} else moduledoc = moduledoc(name) # 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 # an empty body - replace it with a moduledoc and call it a day ¯\_(ツ)_/¯ {:__block__, _, []} -> zipper = if moduledoc, do: Zipper.replace(body_zipper, moduledoc), else: body_zipper {:skip, zipper, ctx} # we want only-child literal block to be handled in the only-child catch-all. it means someone did a weird # (that would be a literal, so best case someone wrote a string and forgot to put `@moduledoc` before it) {:__block__, _, [_, _ | _]} -> {:skip, organize_directives(body_zipper, moduledoc), ctx} # 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 {:@, _, [{:moduledoc, _, _}]} -> {:skip, zipper, ctx} # There's only one child, and it's not a moduledoc. Conditionally add a moduledoc, then style the only_child only_child -> if moduledoc do zipper = body_zipper |> Zipper.replace({:__block__, [], [moduledoc, only_child]}) |> organize_directives() {:skip, zipper, ctx} else run(body_zipper, ctx) end end end end end # Style directives inside of snippets or function defs. def run({{directive, _, children}, _} = zipper, ctx) when directive in @directives and is_list(children) do # Need to be careful that we aren't getting false positives on variables or fns like `def import(foo)` or `alias = 1` case Style.ensure_block_parent(zipper) do {:ok, zipper} -> {:skip, zipper |> Zipper.up() |> organize_directives(), ctx} # not actually a directive! carry on. :error -> {:cont, zipper, ctx} end end # puts `@derive` before `defstruct` etc, fixing compiler warnings def run({{:@, _, [{:derive, _, _}]}, _} = zipper, ctx) do case Style.ensure_block_parent(zipper) do {:ok, {derive, %{l: left_siblings} = z_meta}} -> previous_defstruct = left_siblings |> Stream.with_index() |> Enum.find_value(fn {{struct_def, meta, _}, index} when struct_def in @defstruct -> {meta[:line], index} _ -> nil end) if previous_defstruct do {defstruct_line, defstruct_index} = previous_defstruct derive = Style.set_line(derive, defstruct_line - 1) left_siblings = List.insert_at(left_siblings, defstruct_index + 1, derive) {:skip, Zipper.remove({derive, %{z_meta | l: left_siblings}}), ctx} else {:cont, zipper, ctx} end :error -> {:cont, zipper, ctx} end end def run(zipper, ctx), do: {:cont, zipper, ctx} def moduledoc_placeholder(), do: @module_placeholder defp moduledoc({:__aliases__, m, aliases}) do name = aliases |> List.last() |> to_string() # module names ending with these suffixes will not have a default moduledoc appended if !String.ends_with?( name, ~w(Test Mixfile MixProject Controller Endpoint Repo Router Socket View HTML JSON) ) do Style.set_line(@moduledoc_false, m[:line] + 1) end end # a dynamic module name, like `defmodule my_variable do ... end` defp moduledoc(_), do: nil @acc %{ shortdoc: [], moduledoc: [], behaviour: [], use: [], import: [], alias: [], require: [], nondirectives: [], dealiases: %{}, attrs: MapSet.new(), attr_lifts: [] } defp lift_module_attrs({node, _, _} = ast, %{attrs: attrs} = acc) do if Enum.empty?(attrs) do {ast, acc} else use? = node == :use Macro.prewalk(ast, acc, fn {:@, m, [{attr, _, _} = var]} = ast, acc -> if attr in attrs do replacement = if use?, do: {:unquote, [closing: [line: m[:line]], line: m[:line]], [var]}, else: var {replacement, %{acc | attr_lifts: [attr | acc.attr_lifts]}} else {ast, acc} end ast, acc -> {ast, acc} end) end end defp organize_directives(parent, moduledoc \\ nil) do {before, _after} = Enum.split_while(Quokka.Config.strict_module_layout_order(), &(&1 != :alias)) acc = parent |> Zipper.children() |> Enum.reduce(@acc, fn {:@, _, [{attr_directive, _, _}]} = ast, acc when attr_directive in @attr_directives -> # attr_directives might get hoisted above aliases, so need to dealias depending on the layout order if Enum.member?(before, attr_directive) do {ast, acc} = acc.dealiases |> AliasEnv.expand(ast) |> lift_module_attrs(acc) %{acc | attr_directive => [ast | acc[attr_directive]]} else %{acc | attr_directive => [ast | acc[attr_directive]]} end {:@, _, [{attr, _, _}]} = ast, acc -> %{acc | nondirectives: [ast | acc.nondirectives], attrs: MapSet.put(acc.attrs, attr)} {directive, _, _} = ast, acc when directive in @directives -> {ast, acc} = lift_module_attrs(ast, acc) ast = if Quokka.Config.rewrite_multi_alias?(), do: expand(ast), else: [ast] # import and use might get hoisted above aliases, so need to dealias depending on the layout order needs_dealiasing = directive in ~w(import use)a and Enum.member?(before, directive) ast = if needs_dealiasing, do: AliasEnv.expand(acc.dealiases, ast), else: ast dealiases = if directive == :alias, do: AliasEnv.define(acc.dealiases, ast), else: acc.dealiases # the reverse accounts for `expand` putting things in reading order, whereas we're accumulating in reverse %{acc | directive => Enum.reverse(ast, acc[directive]), dealiases: dealiases} ast, acc -> %{acc | nondirectives: [ast | acc.nondirectives]} end) # Reversing once we're done accumulating since `reduce`ing into list accs means you're reversed! |> Map.new(fn {:moduledoc, []} -> {:moduledoc, List.wrap(moduledoc)} {:use, uses} -> {:use, uses |> Enum.reverse() |> Style.reset_newlines()} {directive, to_sort} when directive in ~w(behaviour import alias require)a -> {directive, sort(to_sort)} {:dealiases, d} -> {:dealiases, d} {k, v} -> {k, Enum.reverse(v)} end) |> lift_aliases() # Not happy with it, but this does the work to move module attribute assignments above the module or quote or whatever # Given that it'll only be run once and not again, i'm okay with it being inefficient {acc, parent} = if Enum.any?(acc.attr_lifts) do lifts = acc.attr_lifts nondirectives = Enum.map(acc.nondirectives, fn {:@, m, [{attr, am, _}]} = ast -> if attr in lifts, do: {:@, m, [{attr, am, [{attr, am, nil}]}]}, else: ast ast -> ast end) assignments = Enum.flat_map(acc.nondirectives, fn {:@, m, [{attr, am, [val]}]} -> if attr in lifts, do: [{:=, m, [{attr, am, nil}, val]}], else: [] _ -> [] end) {past, _} = parent parent = parent |> Zipper.up() |> Style.find_nearest_block() |> Zipper.prepend_siblings(assignments) |> Zipper.find(&(&1 == past)) {%{acc | nondirectives: nondirectives}, parent} else {acc, parent} end nondirectives = acc.nondirectives directives = Quokka.Config.strict_module_layout_order() |> Enum.map(&Map.get(acc, &1, [])) |> Stream.concat() |> fix_line_numbers(List.first(nondirectives)) # the # of aliases can be decreased during sorting - if there were any, we need to be sure to write the deletion if Enum.empty?(directives) do Zipper.replace_children(parent, nondirectives) else # this ensures we continue the traversal _after_ any directives parent |> Zipper.replace_children(directives) |> Zipper.down() |> Zipper.rightmost() |> Zipper.insert_siblings(nondirectives) end end defp lift_aliases(%{alias: aliases, nondirectives: nondirectives} = acc) do # we can't use the dealias map built into state as that's what things look like before sorting # now that we've sorted, it could be different! dealiases = AliasEnv.define(aliases) {_before, [_alias | after_alias]} = Quokka.Config.strict_module_layout_order() |> Enum.split_while(&(&1 != :alias)) liftable = if Quokka.Config.lift_alias?() do Map.take(acc, after_alias) |> Map.values() |> List.flatten() |> Kernel.++(nondirectives) |> find_liftable_aliases(dealiases) else [] end if Enum.any?(liftable) do # This is a silly hack that helps comments stay put. # The `cap_line` algo was designed to handle high-line stuff moving up into low line territory, so we set our # new node to have an arbitrarily high line annnnd comments behave! i think. m = [line: 999_999] aliases = liftable |> Enum.map(&AliasEnv.expand(dealiases, {:alias, m, [{:__aliases__, [{:last, m} | m], &1}]})) |> Enum.concat(aliases) |> sort() lifted_directives = Map.take(acc, after_alias) |> Map.new(fn {:behaviour, ast_nodes} -> {:behaviour, ast_nodes} {:use, ast_nodes} -> {:use, do_lift_aliases(ast_nodes, liftable)} {directive, ast_nodes} -> {directive, ast_nodes |> do_lift_aliases(liftable) |> sort()} end) nondirectives = do_lift_aliases(nondirectives, liftable) Map.merge(acc, lifted_directives) |> Map.merge(%{nondirectives: nondirectives, alias: aliases}) else acc end end defp find_liftable_aliases(ast, dealiases) do excluded = dealiases |> Map.keys() |> Enum.into(Quokka.Config.lift_alias_excluded_lastnames()) firsts = MapSet.new(dealiases, fn {_last, [first | _]} -> first end) ast |> Zipper.zip() # we're reducing a datastructure that looks like # %{last => {aliases, seen_before?} | :some_collision_probelm} |> Zipper.reduce_while(%{}, fn # we don't want to rewrite alias name `defx Aliases ... do` of these three keywords {{defx, _, args}, _} = zipper, lifts when defx in ~w(defmodule defimpl defprotocol)a -> # don't conflict with submodules, which elixir automatically aliases # we could've done this earlier when building excludes from aliases, but this gets it done without two traversals. lifts = case args do [{:__aliases__, _, aliases} | _] when defx == :defmodule -> Map.put(lifts, List.last(aliases), :collision_with_submodule) _ -> lifts end # move the focus to the body block, zkipping over the alias (and the `for` keyword for `defimpl`) {:skip, zipper |> Zipper.down() |> Zipper.rightmost() |> Zipper.down() |> Zipper.down(), lifts} {{:quote, _, _}, _} = zipper, lifts -> {:skip, zipper, lifts} {{:__aliases__, _, [first, _ | _] = aliases}, _} = zipper, lifts -> if Enum.all?(aliases, &is_atom/1) do alias_string = Enum.join(aliases, ".") included_namespace? = case Quokka.Config.lift_alias_only() do nil -> true only -> only |> List.wrap() |> Enum.any?(&Regex.match?(&1, alias_string)) end excluded_namespace? = Quokka.Config.lift_alias_excluded_namespaces() |> MapSet.filter(fn namespace -> String.starts_with?(alias_string, Atom.to_string(namespace) <> ".") end) |> MapSet.size() > 0 last = List.last(aliases) lifts = cond do # this alias existed before running format, so let's ensure it gets lifted dealiases[last] == aliases -> Map.put(lifts, last, {aliases, Quokka.Config.lift_alias_frequency() + 1}) # this alias would conflict with an existing alias, or the namespace is excluded, or the depth is too shallow last in excluded or excluded_namespace? or length(aliases) <= Quokka.Config.lift_alias_depth() -> lifts # aliasing this would change the meaning of an existing alias last > first and last in firsts -> lifts # if only is set, don't lift aliases that don't match the only regex not included_namespace? -> lifts # Never seen this alias before is_nil(lifts[last]) -> Map.put(lifts, last, {aliases, 1}) # We've seen this before, add and do some bookkeeping for first-collisions match?({^aliases, n} when is_integer(n), lifts[last]) -> Map.put(lifts, last, {aliases, elem(lifts[last], 1) + 1}) # There is some type of collision true -> lifts end {:skip, zipper, Map.put(lifts, first, :collision_with_first)} else {:skip, zipper, lifts} end {{directive, _, [{:__aliases__, _, _} | _]}, _} = zipper, lifts when directive in [:use, :import, :behaviour] -> {:cont, zipper |> Zipper.down() |> Zipper.rightmost(), lifts} zipper, lifts -> {:cont, zipper, lifts} end) |> Enum.filter(fn {_last, value} -> case value do {_aliases, count} -> count > Quokka.Config.lift_alias_frequency() _ -> false end end) |> MapSet.new(fn {_, {aliases, _count}} -> aliases end) end defp do_lift_aliases(ast, to_alias) do ast |> Zipper.zip() |> Zipper.traverse(fn {{defx, _, [{:__aliases__, _, _} | _]}, _} = zipper when defx in ~w(defmodule defimpl defprotocol)a -> # move the focus to the body block, zkipping over the alias (and the `for` keyword for `defimpl`) zipper |> Zipper.down() |> Zipper.rightmost() |> Zipper.down() |> Zipper.down() |> Zipper.right() {{:alias, _, [{:__aliases__, _, [_, _ | _] = aliases}]}, _} = zipper -> # the alias was aliased deeper down. we've lifted that alias to a root, so delete this alias if aliases in to_alias and Enum.all?(aliases, &is_atom/1) and length(aliases) > Quokka.Config.lift_alias_depth(), do: Zipper.remove(zipper), else: zipper {{:__aliases__, meta, [_, _ | _] = aliases}, _} = zipper -> if aliases in to_alias and Enum.all?(aliases, &is_atom/1) and length(aliases) > Quokka.Config.lift_alias_depth(), do: Zipper.replace(zipper, {:__aliases__, meta, [List.last(aliases)]}), else: zipper zipper -> zipper end) |> Zipper.node() end # Deletes root level aliases ala (`alias Foo` -> ``) defp expand({:alias, _, [{:__aliases__, _, [_]}]}), do: [] # import Foo.{Bar, Baz} # => # import Foo.Bar # import Foo.Baz defp expand({directive, _, [{{:., _, [{:__aliases__, _, module}, :{}]}, _, right}]}) do Enum.map(right, fn {_, meta, segments} -> {directive, meta, [{:__aliases__, [line: meta[:line]], module ++ segments}]} end) end # alias __MODULE__.{Bar, Baz} defp expand({directive, _, [{{:., _, [{:__MODULE__, _, _} = module, :{}]}, _, right}]}) do Enum.map(right, fn {_, meta, segments} -> {directive, meta, [{:__aliases__, [line: meta[:line]], [module | segments]}]} end) end defp expand(other), do: [other] defp sort(directives) do directive_strings = if Quokka.Config.sort_order() == :ascii do Enum.map(directives, &{&1, Macro.to_string(&1)}) else Enum.map(directives, &{&1, &1 |> Macro.to_string() |> String.downcase()}) end directive_strings |> Enum.uniq_by(&elem(&1, 1)) |> List.keysort(1) |> Enum.map(&elem(&1, 0)) |> Style.reset_newlines() end defp has_skip_comment?(context) do Enum.any?( context.comments, &String.contains?(&1.text, "quokka:skip-module-reordering") ) end # TODO investigate removing this in favor of the Style.post_sort_cleanup(node, comments) # "Fixes" the line numbers of nodes who have had their orders changed via sorting or other methods. # This "fix" simply ensures that comments don't get wrecked as part of us moving AST nodes willy-nilly. # # The fix is rather naive, and simply enforces the following property on the code: # A given node must have a line number less than the following node. # Et voila! Comments behave much better. # # ## In Detail # # For example, given document # # 1: defmodule ... # 2: alias B # 3: # this is foo # 4: def foo ... # 5: alias A # # Sorting aliases the ast node for would put `alias A` (line 5) before `alias B` (line 2). # # 1: defmodule ... # 5: alias A # 2: alias B # 3: # this is foo # 4: def foo ... # # Elixir's document algebra would then encounter `line: 5` and immediately dump all comments with `line <= 5`, # meaning after running through the formatter we'd end up with # # 1: defmodule # 2: # hi # 3: # this is foo # 4: alias A # 5: alias B # 6: # 7: def foo ... # # This function fixes that by seeing that `alias A` has a higher line number than its following sibling `alias B` and so # updates `alias A`'s line to be preceding `alias B`'s line. # # Running the results of this function through the formatter now no longer dumps the comments prematurely # # 1: defmodule ... # 2: alias A # 3: alias B # 4: # this is foo # 5: def foo ... defp fix_line_numbers(nodes, nil), do: fix_line_numbers(nodes, 999_999) defp fix_line_numbers(nodes, {_, meta, _}), do: fix_line_numbers(nodes, meta[:line]) defp fix_line_numbers(nodes, max), do: nodes |> Enum.reverse() |> do_fix_lines(max, []) defp do_fix_lines([], _, acc), do: acc defp do_fix_lines([{_, meta, _} = node | nodes], max, acc) do line = meta[:line] # the -2 is just an ugly hack to leave room for one-liner comments and not hijack them. if line > max, do: do_fix_lines(nodes, max, [Style.shift_line(node, max - line - 2) | acc]), else: do_fix_lines(nodes, line, [node | acc]) end end