# Copyright 2024 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`
* `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 Styler.Style
alias Styler.AliasEnv
alias Styler.Style
alias Styler.Zipper
@directives ~w(alias import require use)a
@attr_directives ~w(moduledoc shortdoc behaviour)a
@defstruct ~w(schema embedded_schema defstruct)a
@env %{
shortdoc: [],
moduledoc: [],
behaviour: [],
use: [],
import: [],
alias: [],
require: [],
nondirectives: [],
alias_env: %{},
attrs: MapSet.new(),
attr_lifts: []
}
# module directives typically doesn't do anything until it sees a module (typical .ex file) or a directive (like a snippet)
# however, if we're in a snippet with no directives we'll never do any work!
# so, we fast-forward the traversal looking for an interesting node.
# if we find one, we'll mark that this file is interesting and proceed as normal from there.
# if we _dont_ find one, then we're likely in a snippet with no typical directives.
# in that case, all that's left is to apply alias lifting and halt.
def run(zipper, %{__MODULE__ => true} = ctx), do: do_run(zipper, ctx)
def run({node, nil} = zipper, ctx) do
if interesting_zipper = Zipper.find(zipper, &interesting?/1) do
do_run(interesting_zipper, Map.put(ctx, __MODULE__, true))
else
# there's no defmodules or aliasy things - see if we can do some alias lifting?
case lift_aliases(%{@env | nondirectives: [node]}) do
%{alias: []} ->
{:halt, zipper, ctx}
%{alias: aliases, nondirectives: [node]} ->
# This is a line handler unique to this situation.
# Nowhere else do we create nodes that we know will go before all existing code in the document.
# All we need to do is set those aliases to have the appropriate lines,
# then bump the line of everything else in the document by the number of aliases + 1!
aliases = Enum.with_index(aliases, fn node, i -> Style.set_line(node, i + 1) end)
shift = Enum.count(aliases) + 1
comments = Enum.map(ctx.comments, &%{&1 | line: &1.line + shift})
node = Style.shift_line(node, shift)
zipper =
case Zipper.replace(zipper, node) do
{{:__block__, _, _}, _} = block_zipper -> Zipper.insert_children(block_zipper, aliases)
# this snippet was a single element, eg just one big map in a credo config file.
non_block_zipper -> Zipper.prepend_siblings(non_block_zipper, aliases)
end
{:halt, zipper, %{ctx | comments: comments}}
end
end
end
defp interesting?({x, _, _}) when x in [:defmodule, :@ | @directives], do: true
defp interesting?(_), do: false
defp do_run({{:defmodule, _, children}, _} = zipper, ctx) do
[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
do_run(body_zipper, ctx)
end
end
end
end
# Style directives inside of snippets or function defs.
defp do_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
defp do_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
defp do_run(zipper, ctx), do: {:cont, zipper, ctx}
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
meta = [line: m[:line] + 1]
{:@, meta, [{:moduledoc, meta, [{:__block__, meta, [false]}]}]}
end
end
# a dynamic module name, like `defmodule my_variable do ... end`
defp moduledoc(_), do: nil
defp lift_module_attrs({node, _, _} = ast, %{attrs: attrs} = acc) do
if MapSet.size(attrs) == 0 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
acc =
parent
|> Zipper.children()
|> Enum.reduce(@env, fn
{:@, _, [{attr_directive, _, _}]} = ast, acc when attr_directive in @attr_directives ->
# attr_directives are moved above aliases, so we need to expand them
{ast, acc} = acc.alias_env |> AliasEnv.expand_ast(ast) |> lift_module_attrs(acc)
%{acc | attr_directive => [ast | acc[attr_directive]]}
{:@, _, [{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 = expand(ast)
# import and used get hoisted above aliases, so need to expand them
ast = if directive in ~w(import use)a, do: AliasEnv.expand_ast(acc.alias_env, ast), else: ast
alias_env = if directive == :alias, do: AliasEnv.define(acc.alias_env, ast), else: acc.alias_env
# the reverse accounts for `expand` putting things in reading order, whereas we're accumulating in reverse
%{acc | directive => Enum.reverse(ast, acc[directive]), alias_env: alias_env}
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)}
{:alias_env, d} -> {:alias_env, d}
{k, v} -> {k, Enum.reverse(v)}
end)
|> redefine_alias_env()
|> lift_aliases()
|> apply_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 =
[
acc.shortdoc,
acc.moduledoc,
acc.behaviour,
acc.use,
acc.import,
acc.alias,
acc.require
]
|> 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
# alias_env have to be recomputed after we've sorted our `alias` nodes
defp redefine_alias_env(%{alias: aliases} = acc), do: %{acc | alias_env: AliasEnv.define(aliases)}
defp lift_aliases(%{alias: aliases, require: requires, nondirectives: nondirectives, alias_env: alias_env} = acc) do
liftable = find_liftable_aliases(requires ++ nondirectives, alias_env)
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_m = [last: m] ++ m
aliases =
liftable
|> Enum.map(&{:alias, m, [{:__aliases__, aliases_m, AliasEnv.expand(alias_env, &1)}]})
|> Enum.concat(aliases)
|> sort()
# aliases to be lifted have to be applied immediately; yes, these means we're doing multiple apply_alias traversals,
# but we're only doing the duplicate traversals when we're updating the file, so the extra cost of walking
# is negligible vs doing disk I/O
%{acc | alias: aliases}
|> apply_aliases(Map.new(liftable, fn modules -> {modules, List.last(modules)} end))
|> redefine_alias_env()
else
acc
end
end
defp find_liftable_aliases(ast, alias_env) do
excluded = alias_env |> Map.keys() |> Enum.into(Styler.Config.get(:lifting_excludes))
firsts = MapSet.new(alias_env, fn {_last, [first | _]} -> first end)
ast
|> Zipper.zip()
# we're reducing a datastructure that looks like
# %{last => {aliases, seen_before?} | :some_collision_problem}
|> 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, skipping 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 ->
last = List.last(aliases)
lifts =
cond do
# this alias already exists, they just wrote it out fully and are leaving it up to us to shorten it down!
# we'll get it when we do the apply-aliases scan
alias_env[last] == aliases ->
lifts
last in excluded or Enum.any?(aliases, &(not is_atom(&1))) ->
lifts
# aliasing this would change the meaning of an existing alias
last > first and last in firsts ->
lifts
# We've seen this once before, time to mark it for lifting and do some bookkeeping for first-collisions
lifts[last] == {aliases, false} ->
lifts = Map.put(lifts, last, {aliases, true})
# Here's the bookkeeping for collisions with this alias's first module name...
case lifts[first] do
{:collision_with_first, claimants, colliders} ->
# release our claim on this collision
claimants = MapSet.delete(claimants, aliases)
empty? = Enum.empty?(claimants)
cond do
empty? and Enum.any?(colliders) ->
# no more claimants, try to promote a collider to be lifted
colliders = Enum.to_list(colliders)
# There's no longer a collision because the only claimant is being lifted.
# So, promote a claimant with these criteria
# - required: its first comes _after_ last, so we aren't promoting an alias that changes the meaning of the other alias we're doing
# - preferred: take a collider we know we want to lift (we've seen it multiple times)
lift =
Enum.reduce_while(colliders, :collision_with_first, fn
{[first | _], true} = liftable, _ when first > last ->
{:halt, liftable}
{[first | _], _false} = promotable, :collision_with_first when first > last ->
{:cont, promotable}
_, result ->
{:cont, result}
end)
Map.put(lifts, first, lift)
empty? ->
Map.delete(lifts, first)
true ->
Map.put(lifts, first, {:collision_with_first, claimants, colliders})
end
_ ->
lifts
end
true ->
lifts
|> Map.update(last, {aliases, false}, fn
# if something is claiming the atom we want, add ourselves to the list of colliders
{:collision_with_first, claimers, colliders} ->
{:collision_with_first, claimers, Map.update(colliders, aliases, false, fn _ -> true end)}
other ->
other
end)
|> Map.update(first, {:collision_with_first, MapSet.new([aliases]), %{}}, fn
{:collision_with_first, claimers, colliders} ->
{:collision_with_first, MapSet.put(claimers, aliases), colliders}
other ->
other
end)
end
{:skip, zipper, lifts}
{{:__aliases__, _, [first | _]}, _} = zipper, lifts ->
# given:
# C.foo()
# A.B.C.foo()
# A.B.C.foo()
# C.foo()
#
# lifting A.B.C would create a collision with C.
# unlike the collision_with_first tuple book-keeping, there's no recovery here because we won't lift a < 3 length alias
{:skip, zipper, Map.put(lifts, first, :collision_with_first)}
zipper, lifts ->
{:cont, zipper, lifts}
end)
|> Enum.filter(&match?({_last, {_aliases, true}}, &1))
|> MapSet.new(fn {_, {aliases, true}} -> aliases end)
end
defp apply_aliases(acc) do
apply_aliases(acc, AliasEnv.invert(acc.alias_env))
end
defp apply_aliases(acc, inverted_env) when map_size(inverted_env) == 0, do: acc
defp apply_aliases(%{require: requires, nondirectives: nondirectives, alias_env: alias_env} = acc, inverted_env) do
# applying aliases to requires can change their ordering again
requires = requires |> apply_aliases(inverted_env, alias_env) |> sort()
nondirectives = apply_aliases(nondirectives, inverted_env, alias_env)
%{acc | require: requires, nondirectives: nondirectives}
end
# applies the aliases withi `to_as` across the given ast
# alias_env is used to expand partial aliases
defp apply_aliases(ast, to_as, alias_env) do
ast
|> Zipper.zip()
|> Zipper.traverse_while(fn
{{defx, _, [{:__aliases__, _, _} | _]}, _} = zipper when defx in ~w(defmodule defimpl defprotocol)a ->
# move the focus to the body block, skipping over the alias (and the `for` keyword for `defimpl`)
zipper = zipper |> Zipper.down() |> Zipper.rightmost() |> Zipper.down() |> Zipper.down() |> Zipper.right()
{:cont, zipper}
{{:quote, _, _}, _} = zipper ->
{:skip, zipper}
# apply_aliases is only called on nondirectives (+requires), so this alias must exist within a child node like a def
# if it's within `to_as` then it's an alias that's already defined further up and is thus a duplicate
# either because we lifted and so created a duplicate, or it was just plain ol' user user error.
# either way, we can nix it
{{:alias, _, [{:__aliases__, _, [_ | _] = modules}]}, _} = zipper ->
zipper = if to_as[modules], do: Zipper.remove(zipper), else: zipper
{:cont, zipper}
# We check even modules of 1 length to catch silly situations like
# alias A.B.C
# alias A.B.C, as: X
# That'll then rename all C to X and C will become unused
{{:__aliases__, meta, [_ | _] = modules}, _} = zipper ->
zipper =
cond do
# There's an alias for this module - replace it with its `as`
as = to_as[modules] -> Zipper.replace(zipper, {:__aliases__, meta, [as]})
# There's an alias for this modules expansion - replace it with its `as`
#
# This addresses the following:
# given modules=`C.D`, to_as=`A.B.C => C, A.B.C.D => X`,
# should yield -> `X` because `C.D -> A.B.C.D -> X`
as = to_as[AliasEnv.expand(alias_env, modules)] -> Zipper.replace(zipper, {:__aliases__, meta, [as]})
true -> zipper
end
# minor optimization - don't need to walk the module list!
{:skip, zipper}
zipper ->
{:cont, 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
# sorting is done with `downcase` to match Credo
directives
|> Enum.map(&{&1, &1 |> Macro.to_string() |> String.downcase()})
|> Enum.uniq_by(&elem(&1, 1))
|> List.keysort(1)
|> Enum.map(&elem(&1, 0))
|> Style.reset_newlines()
end
# "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