Current section

Files

Jump to
styler lib style module_directives.ex
Raw

lib/style/module_directives.ex

# 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`
## Breakages
**This can break your code.**
### Strict Layout
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
defmodule Approximation do
@pi 3.14
use Math, pi: @pi
end
# after
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!
### Strict Layout: interwoven conflicting aliases
Ideally no one writes code like this as it's hard for our human brains to notice the context switching!
Still, it's a possible source of breakages in Styler.
alias Foo.Bar
Bar.Baz.bop()
alias Baz.Bar
Bar.Baz.bop()
# becomes
alias Baz.Bar
alias Baz.Bar.Baz
alias Foo.Bar
Baz.bop() # was Foo.Bar.Baz, is now Baz.Bar.Baz
Baz.bop()
"""
@behaviour Styler.Style
alias Styler.Dealias
alias Styler.Style
alias Styler.Zipper
@directives ~w(alias import require use)a
@callback_attrs ~w(before_compile after_compile after_verify)a
@attr_directives ~w(moduledoc shortdoc behaviour)a
@defstruct ~w(schema embedded_schema defstruct)a
@moduledoc_false {:@, [line: nil], [{:moduledoc, [line: nil], [{:__block__, [line: nil], [false]}]}]}
def 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
run(body_zipper, ctx)
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}
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
unless 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: %{}
}
defp organize_directives(parent, moduledoc \\ nil) do
acc =
parent
|> Zipper.children()
|> Enum.reduce(@acc, fn
{:@, _, [{attr, _, _}]} = ast, acc ->
key =
cond do
# TODO drop for a 1.0 release?
# the order of callbacks relative to use can matter if the use is also doing callbacks
# looking back, this is probably a hack to support one person's weird hackery 🤣
attr in @callback_attrs -> :use
attr in @attr_directives -> :"@#{attr}"
true -> :nondirectives
end
# both callback and attr_directives are moved above aliases, so we need to dealias them
ast = if key == :nondirectives, do: ast, else: Dealias.apply(acc.dealiases, ast)
%{acc | key => [ast | acc[key]]}
{directive, _, _} = ast, acc when directive in @directives ->
ast = expand(ast)
# import and used get hoisted above aliases, so need to dealias
ast = if directive in ~w(import use)a, do: Dealias.apply(acc.dealiases, ast), else: ast
dealiases = if directive == :alias, do: Dealias.put(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()
nondirectives = acc.nondirectives
directives =
[
acc."@shortdoc",
acc."@moduledoc",
acc."@behaviour",
acc.use,
acc.import,
acc.alias,
acc.require
]
|> Stream.concat()
|> Style.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, require: requires, 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 = Dealias.new(aliases)
excluded = dealiases |> Map.keys() |> Enum.into(Styler.Config.get(:lifting_excludes))
liftable = find_liftable_aliases(requires ++ nondirectives, excluded)
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 abritrarily high line annnnd comments behave! i think.
m = [line: 999_999]
aliases =
liftable
|> Enum.map(&Dealias.apply(dealiases, {:alias, m, [{:__aliases__, [{:last, m} | m], &1}]}))
|> Enum.concat(aliases)
|> sort()
# lifting could've given us a new order
requires = requires |> do_lift_aliases(liftable) |> sort()
nondirectives = do_lift_aliases(nondirectives, liftable)
%{acc | alias: aliases, require: requires, nondirectives: nondirectives}
else
acc
end
end
defp find_liftable_aliases(ast, excluded) do
ast
|> Zipper.zip()
|> 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__, _, [_, _, _ | _] = aliases}, _} = zipper, lifts ->
last = List.last(aliases)
lifts =
if last in excluded or not Enum.all?(aliases, &is_atom/1) do
lifts
else
Map.update(lifts, last, {aliases, false}, fn
{^aliases, _} -> {aliases, true}
# if we have `Foo.Bar.Baz` and `Foo.Bar.Bop.Baz` both not aliased, we'll create a collision by lifting both
# grouping by last alias lets us detect these collisions
_ -> :collision_with_last
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.
{: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 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,
do: Zipper.remove(zipper),
else: zipper
{{:__aliases__, meta, [_, _, _ | _] = aliases}, _} = zipper ->
if aliases in to_alias,
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
# 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
end