Skip to content

Commit

Permalink
MVP for alias lifting
Browse files Browse the repository at this point in the history
  • Loading branch information
novaugust committed Mar 15, 2024
1 parent c0cf104 commit f5f960c
Show file tree
Hide file tree
Showing 3 changed files with 197 additions and 4 deletions.
101 changes: 99 additions & 2 deletions lib/style/module_directives.ex
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,20 @@ defmodule Styler.Style.ModuleDirectives do
@attr_directives ~w(moduledoc shortdoc behaviour)a ++ @callback_attrs
@defstruct ~w(schema embedded_schema defstruct)a

# taken from Credo
@excluded_namespaces MapSet.new(~w(File IO Inspect Kernel Macro Supervisor Task Version)a)
@stdlib MapSet.new(~w(Access Agent Application Atom Base Behaviour
Bitwise Code Date DateTime Dict Ecto Enum Exception
File Float GenEvent GenServer HashDict HashSet
Integer IO Kernel Keyword List Macro Map MapSet
Module NaiveDateTime Node Oban OptionParser Path Port
Process Protocol Range Record Regex Registry Set
Stream String StringIO Supervisor System Task Time
Tuple URI Version)a)

@libraries MapSet.new(~w(Ecto Plug Phoenix Oban)a)
@stdlib MapSet.union(@stdlib, @libraries)

@moduledoc_false {:@, [line: nil], [{:moduledoc, [line: nil], [{:__block__, [line: nil], [false]}]}]}

def run({{:defmodule, _, children}, _} = zipper, ctx) do
Expand Down Expand Up @@ -190,9 +204,16 @@ defmodule Styler.Style.ModuleDirectives do

uses = (directives[:use] || []) |> Enum.flat_map(&expand_directive/1) |> reset_newlines()
imports = expand_and_sort(directives[:import] || [])
requires = expand_and_sort(directives[:require] || [])

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

to_alias = find_liftable_aliases(requires ++ nondirectives, aliases)
# @TODO bug here if the first line of the parent is a comment
new_aliases = Enum.map(to_alias, &{:alias, [line: 0], [{:__aliases__, [last: [line: 0], line: 0], &1}]})
aliases = expand_and_sort(aliases ++ new_aliases)
requires = use_aliases(requires, to_alias)

directives =
[
Expand Down Expand Up @@ -220,7 +241,83 @@ defmodule Styler.Style.ModuleDirectives do
|> Zipper.update(&Zipper.replace_children(&1, directives))
|> Zipper.down()
|> Zipper.rightmost()
|> Zipper.insert_siblings(nondirectives)
|> Zipper.insert_siblings(use_aliases(nondirectives, to_alias))
end
end

defp find_liftable_aliases(ast, aliases) do
aliased =
aliases
|> Enum.flat_map(fn
{:alias, _, [{:__aliases__, _, aliases}]} -> [aliases]
_ -> []
end)
|> MapSet.new(&List.last/1)

excluded_first = MapSet.union(aliased, @excluded_namespaces)
excluded_last = MapSet.union(aliased, @stdlib)

ast
|> Zipper.zip()
|> Zipper.traverse_while(%{}, fn
{{defx, _, [{:__aliases__, _, _} | _]}, _} = zipper, acc 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`)
{:skip, zipper |> Zipper.down() |> Zipper.rightmost() |> Zipper.down() |> Zipper.down(), acc}

{{:quote, _, _}, _} = zipper, acc ->
{:skip, zipper, acc}

# A.B.C.f(...)
{{:__aliases__, _, [first, _, _ | _] = aliases}, _} = zipper, acc ->
last = List.last(aliases)

acc =
if last in excluded_last or first in excluded_first or not Enum.all?(aliases, &is_atom/1),
do: acc,
else: Map.update(acc, aliases, 1, &(&1 + 1))

{:skip, zipper, acc}

zipper, acc ->
{:cont, zipper, acc}
end)
|> elem(1)
# if we have `Foo.Bar.Baz` and `Foo.Bar.Bop.Baz` both not aliased, we'll create a collision by extracting both.
# grouping by last alias lets us detect these collisions
|> Enum.group_by(fn {aliases, _} -> List.last(aliases) end)
|> Enum.filter(fn
{_, [{_, n}]} -> n > 1
_ -> false
end)
|> MapSet.new(fn {_, [{aliases, _}]} -> aliases end)
end

defp use_aliases(ast, new_aliases) do
if Enum.empty?(new_aliases) do
ast
else
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 new_aliases,
do: Zipper.remove(zipper),
else: zipper

{{:__aliases__, meta, [_, _, _ | _] = aliases}, _} = zipper ->
if aliases in new_aliases,
do: Zipper.replace(zipper, {:__aliases__, meta, [List.last(aliases)]}),
else: zipper

zipper ->
zipper
end)
|> Zipper.node()
end
end

Expand Down
4 changes: 2 additions & 2 deletions lib/zipper.ex
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ defmodule Styler.Zipper do
{leftmost, %{meta | l: [], r: r}}
end

def leftmost(zipper), do: zipper
def leftmost({_, _} = zipper), do: zipper

@doc """
Returns the zipper of the right sibling of the node at this zipper, or nil.
Expand All @@ -141,7 +141,7 @@ defmodule Styler.Zipper do
{rightmost, %{meta | l: l, r: []}}
end

def rightmost(zipper), do: zipper
def rightmost({_, _} = zipper), do: zipper

@doc """
Replaces the current node in the zipper with a new node.
Expand Down
96 changes: 96 additions & 0 deletions test/style/module_directives/alias_lifting_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
# 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.AliasLiftingTest do
@moduledoc false
use Styler.StyleCase, async: true

test "lifts aliases repeated >=2 times from 3 deep" do
assert_style(
"""
defmodule A do
@moduledoc false
@spec bar :: A.B.C.t()
def bar do
A.B.C.f()
end
end
""",
"""
defmodule A do
@moduledoc false
alias A.B.C
@spec bar :: C.t()
def bar do
C.f()
end
end
"""
)
end

describe "it doesn't lift" do
test "defprotocol, defmodule, or defimpl" do
assert_style """
defmodule No do
@moduledoc false
defprotocol A.B.C do
:body
end
A.B.C.f()
end
"""

assert_style """
defmodule No do
@moduledoc false
alias A.B.C
defprotocol A.B.C do
:body
end
C.f()
C.f()
end
"""

assert_style """
defmodule No do
@moduledoc false
defmodule A.B.C do
@moduledoc false
:body
end
A.B.C.f()
end
"""

assert_style """
defmodule No do
@moduledoc false
defimpl A.B.C, for: A.B.C do
:body
end
A.B.C.f()
end
"""
end
end
end

0 comments on commit f5f960c

Please sign in to comment.