Skip to content

Commit

Permalink
Lift module attributes to variables when used by directives (#168)
Browse files Browse the repository at this point in the history
Fixes the longstanding compilation-breaking bug that occurs when module attributes are moved below directives that reference them.

Closes #156, Ref #65 and #43
  • Loading branch information
novaugust authored May 20, 2024
1 parent da99d87 commit 6af5537
Show file tree
Hide file tree
Showing 3 changed files with 124 additions and 35 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ See the moduledoc for `Styler.Style.Configs` for more.
### Breaking Changes

* drop support for elixir `1.14`
* ModuleDirectives: group callback attributes (`before_compile after_compile after_verify`) with nondirectives (previously, were grouped with `use`, their relative order maintained). to keep the desired behaviour, you can make new `use` macros that wrap these callbacks. Apologies if this makes using Styler untenable for your codebase, but it's probably not a good tool for macro-heavy libraries.
* sorting configs for the first time can change your configuration; see `Styler.Style.Configs` moduledoc for more

## v0.11.9
Expand Down
101 changes: 77 additions & 24 deletions lib/style/module_directives.ex
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,6 @@ defmodule Styler.Style.ModuleDirectives do
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

Expand Down Expand Up @@ -191,65 +190,119 @@ defmodule Styler.Style.ModuleDirectives do
defp moduledoc(_), do: nil

@acc %{
"@shortdoc": [],
"@moduledoc": [],
"@behaviour": [],
shortdoc: [],
moduledoc: [],
behaviour: [],
use: [],
import: [],
alias: [],
require: [],
nondirectives: [],
dealiases: %{}
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
acc =
parent
|> Zipper.children()
|> Enum.reduce(@acc, fn
{:@, _, [{attr_directive, _, _}]} = ast, acc when attr_directive in @attr_directives ->
# attr_directives are moved above aliases, so we need to dealias them
{ast, acc} = acc.dealiases |> Dealias.apply(ast) |> lift_module_attrs(acc)
%{acc | attr_directive => [ast | acc[attr_directive]]}

{:@, _, [{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]]}
%{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 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}
%{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)}
{: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)}
{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 =
[
acc."@shortdoc",
acc."@moduledoc",
acc."@behaviour",
acc.shortdoc,
acc.moduledoc,
acc.behaviour,
acc.use,
acc.import,
acc.alias,
Expand Down
57 changes: 46 additions & 11 deletions test/style/module_directives_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -388,17 +388,6 @@ defmodule Styler.Style.ModuleDirectivesTest do
)
end

test "groups module callbacks with uses, keeping ordering" do
assert_style """
defmacro __using__(_) do
quote do
@after_compile Foo
use Bar
end
end
"""
end

test "interwoven directives w/o the context of a module" do
assert_style(
"""
Expand Down Expand Up @@ -584,4 +573,50 @@ defmodule Styler.Style.ModuleDirectivesTest do
"""
)
end

describe "module attribute lifting" do
test "replaces uses in other attributes and `use` correctly" do
assert_style(
"""
defmodule MyGreatLibrary do
@library_options [...]
@moduledoc make_pretty_docs(@library_options)
use OptionsMagic, my_opts: @library_options
end
""",
"""
library_options = [...]
defmodule MyGreatLibrary do
@moduledoc make_pretty_docs(library_options)
use OptionsMagic, my_opts: unquote(library_options)
@library_options library_options
end
"""
)
end

test "works with `quote`" do
assert_style(
"""
quote do
@library_options [...]
@moduledoc make_pretty_docs(@library_options)
use OptionsMagic, my_opts: @library_options
end
""",
"""
library_options = [...]
quote do
@moduledoc make_pretty_docs(library_options)
use OptionsMagic, my_opts: unquote(library_options)
@library_options library_options
end
"""
)
end
end
end

0 comments on commit 6af5537

Please sign in to comment.