Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Scala 3 support #136

Merged
merged 12 commits into from
Sep 23, 2024
1 change: 1 addition & 0 deletions acyclic/resources/plugin.properties
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
pluginClass=acyclic.plugin.RuntimePlugin
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ class PluginPhase(
force: => Boolean,
fatal: => Boolean
) extends PluginComponent
with GraphAnalysis { t =>
with GraphAnalysis[Global#Tree] { t =>

import global._

Expand Down
100 changes: 100 additions & 0 deletions acyclic/src-3/acyclic/plugin/DependencyExtraction.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
package acyclic.plugin

import acyclic.file
import dotty.tools.dotc.ast.tpd
import dotty.tools.dotc.{CompilationUnit, report}
import dotty.tools.dotc.core.Contexts.Context
import dotty.tools.dotc.core.Flags
import dotty.tools.dotc.core.Names.Name
import dotty.tools.dotc.core.Symbols.Symbol
import dotty.tools.dotc.core.Types.Type

object DependencyExtraction {
def apply(unit: CompilationUnit)(using Context): Seq[(Symbol, tpd.Tree)] = {

class CollectTypeTraverser[T](pf: PartialFunction[Type, T]) extends tpd.TreeAccumulator[List[T]] {
def apply(acc: List[T], tree: tpd.Tree)(using Context) =
foldOver(
if (pf.isDefinedAt(tree.tpe)) pf(tree.tpe) :: acc else acc,
tree
)
}

abstract class ExtractDependenciesTraverser extends tpd.TreeTraverser {
protected val depBuf = collection.mutable.ArrayBuffer.empty[(Symbol, tpd.Tree)]
protected def addDependency(sym: Symbol, tree: tpd.Tree): Unit = depBuf += ((sym, tree))
def dependencies: collection.immutable.Set[(Symbol, tpd.Tree)] = {
// convert to immutable set and remove NoSymbol if we have one
depBuf.toSet
}

}

class ExtractDependenciesByMemberRefTraverser extends ExtractDependenciesTraverser {
override def traverse(tree: tpd.Tree)(using Context): Unit = {
tree match {
case i @ tpd.Import(expr, selectors) =>
selectors.foreach { s =>
def lookupImported(name: Name) = expr.symbol.info.member(name).symbol

if (s.isWildcard) {
addDependency(lookupImported(s.name.toTermName), tree)
addDependency(lookupImported(s.name.toTypeName), tree)
}
}
case select: tpd.Select =>
addDependency(select.symbol, tree)
/*
* Idents are used in number of situations:
* - to refer to local variable
* - to refer to a top-level package (other packages are nested selections)
* - to refer to a term defined in the same package as an enclosing class;
* this looks fishy, see this thread:
* https://groups.google.com/d/topic/scala-internals/Ms9WUAtokLo/discussion
*/
case ident: tpd.Ident =>
addDependency(ident.symbol, tree)
case typeTree: tpd.TypeTree =>
val typeSymbolCollector = new CollectTypeTraverser({
case tpe if tpe != null && tpe.typeSymbol != null && !tpe.typeSymbol.is(Flags.Package) => tpe.typeSymbol
})
val deps = typeSymbolCollector(Nil, typeTree).toSet
deps.foreach(addDependency(_, tree))
case t: tpd.Template =>
traverse(t.body)
case other => ()
}
foldOver((), tree)
}
}

def byMembers(): collection.immutable.Set[(Symbol, tpd.Tree)] = {
val traverser = new ExtractDependenciesByMemberRefTraverser
if (!unit.isJava)
traverser.traverse(unit.tpdTree)
traverser.dependencies
}

class ExtractDependenciesByInheritanceTraverser extends ExtractDependenciesTraverser {
override def traverse(tree: tpd.Tree)(using Context): Unit = tree match {
case t: tpd.Template =>
// we are using typeSymbol and not typeSymbolDirect because we want
// type aliases to be expanded
val parentTypeSymbols = t.parents.map(parent => parent.tpe.typeSymbol).toSet
report.debuglog("Parent type symbols for " + tree.sourcePos.show + ": " + parentTypeSymbols.map(_.fullName))
parentTypeSymbols.foreach(addDependency(_, tree))
traverse(t.body)
case tree => foldOver((), tree)
}
}

def byInheritence(): collection.immutable.Set[(Symbol, tpd.Tree)] = {
val traverser = new ExtractDependenciesByInheritanceTraverser
if (!unit.isJava)
traverser.traverse(unit.tpdTree)
traverser.dependencies
}

(byMembers() | byInheritence()).toSeq
}
}
33 changes: 33 additions & 0 deletions acyclic/src-3/acyclic/plugin/Plugin.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package acyclic.plugin

import acyclic.file
import dotty.tools.dotc.plugins.{PluginPhase, StandardPlugin}
import scala.collection.SortedSet
import dotty.tools.dotc.core.Contexts.Context

class RuntimePlugin extends TestPlugin()
class TestPlugin(cycleReporter: Seq[(Value, SortedSet[Int])] => Unit = _ => ()) extends StandardPlugin {

val name = "acyclic"
val description = "Allows the developer to prohibit inter-file dependencies"

var force = false
var fatal = true

private class Phase() extends PluginPhase {
val phaseName = "acyclic"
override val runsBefore = Set("patternMatcher")

override def run(using Context): Unit = new acyclic.plugin.PluginPhase(cycleReporter, force, fatal).run()
}

override def init(options: List[String]): List[PluginPhase] = {
if (options.contains("force")) {
force = true
}
if (options.contains("warn")) {
fatal = false
}
List(Phase())
}
}
205 changes: 205 additions & 0 deletions acyclic/src-3/acyclic/plugin/PluginPhase.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
package acyclic.plugin

import acyclic.file
import scala.collection.{SortedSet, mutable}
import dotty.tools.dotc.ast.tpd
import dotty.tools.dotc.{CompilationUnit, report}
import dotty.tools.dotc.core.Contexts.Context
import dotty.tools.dotc.core.Symbols.NoSymbol
import dotty.tools.dotc.util.NoSource

/**
* - Break dependency graph into strongly connected components
* - Turn acyclic packages into virtual "files" in the dependency graph, as
* aggregates of all the files within them
* - Any strongly connected component which includes an acyclic.file or
* acyclic.pkg is a failure
* - Pick an arbitrary cycle and report it
* - Don't report more than one cycle per file/pkg, to avoid excessive spam
*/
class PluginPhase(
cycleReporter: Seq[(Value, SortedSet[Int])] => Unit,
force: => Boolean,
fatal: => Boolean
)(using ctx: Context) extends GraphAnalysis[tpd.Tree] { t =>

private val pkgNameAccumulator = new tpd.TreeAccumulator[List[String]] {
@annotation.tailrec
private def definitivePackageDef(pkg: tpd.PackageDef): tpd.PackageDef =
pkg.stats.collectFirst { case p: tpd.PackageDef => p } match {
case Some(p) => definitivePackageDef(p)
case None => pkg
}

def apply(acc: List[String], tree: tpd.Tree)(using Context) = tree match {
case p: tpd.PackageDef => definitivePackageDef(p).pid.show :: acc
case _ => foldOver(acc, tree)
}
}

private def pkgName(unit: CompilationUnit) =
pkgNameAccumulator(Nil, unit.tpdTree).reverse.flatMap(_.split('.'))

private lazy val units = Option(ctx.run) match {
case Some(run) => run.units.toSeq.sortBy(_.source.content.mkString.hashCode())
case None => Seq()
}

private def hasImport(selector: String, tree: tpd.Tree): Boolean = {
val accumulator = new tpd.TreeAccumulator[Boolean] {
def apply(acc: Boolean, tree: tpd.Tree)(using Context): Boolean = tree match {
case tpd.Import(expr, List(sel)) =>
acc || (expr.symbol.toString == "object acyclic" && sel.name.show == selector)
case _ => foldOver(acc, tree)
}
}

accumulator(false, tree)
}

private val pkgObjectAccumulator = new tpd.TreeAccumulator[List[tpd.Tree]] {
def apply(acc: List[tpd.Tree], tree: tpd.Tree)(using Context): List[tpd.Tree] =
foldOver(
if (tree.symbol.isPackageObject) tree :: acc else acc,
tree
)
}

def findAcyclics() = {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder how much of the logic in this file can be shared between the Scala 2 and Scala 3 implementations? Like clearly the logic for working with symbols and types and trees is all bespoke, but it seems there's a lot of stuff working with Values and DepNodes that can be shared if properly separated out from the compiler-specific logic

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call, I was able to share pretty much all of it by abstracting over CompilationUnit, Tree, and Symbol

val acyclicNodePaths = for {
unit <- units if hasImport("file", unit.tpdTree)
} yield {
Value.File(unit.source.path, pkgName(unit))
}
val skipNodePaths = for {
unit <- units if hasImport("skipped", unit.tpdTree)
} yield {
Value.File(unit.source.path, pkgName(unit))
}

val acyclicPkgNames = for {
unit <- units
pkgObject <- pkgObjectAccumulator(Nil, unit.tpdTree).reverse
if hasImport("pkg", pkgObject)
} yield {
Value.Pkg(
pkgObject.symbol
.enclosingPackageClass
.fullName
.toString
.split('.')
.toList
)
}
(skipNodePaths, acyclicNodePaths, acyclicPkgNames)
}

def run(): Unit = {
val unitMap = units.map(u => u.source.path -> u).toMap
val nodes = for (unit <- units) yield {

val deps = DependencyExtraction(unit)

val connections = for {
(sym, tree) <- deps
if sym != NoSymbol
if sym.source != null
if sym.source != NoSource
if sym.source.path != unit.source.path
if unitMap.contains(sym.source.path)
} yield (sym.source.path, tree)

Node[Value.File](
Value.File(unit.source.path, pkgName(unit)),
connections.groupBy(c => Value.File(c._1, pkgName(unitMap(c._1))): Value)
.mapValues(_.map(_._2))
.toMap
)
}

val nodeMap = nodes.map(n => n.value -> n).toMap

val (skipNodePaths, acyclicFiles, acyclicPkgs) = findAcyclics()

val allAcyclics = acyclicFiles ++ acyclicPkgs

// synthetic nodes for packages, which aggregate the dependencies of
// their contents
val pkgNodes = acyclicPkgs.map { value =>
Node(
value,
nodes.filter(_.value.pkg.startsWith(value.pkg))
.flatMap(_.dependencies.toSeq)
.groupBy(_._1)
.mapValues(_.flatMap(_._2))
.toMap
)
}

val linkedNodes: Seq[DepNode] = (nodes ++ pkgNodes).map { d =>
val extraLinks = d.dependencies.flatMap {
case (value: Value.File, pos) =>
for {
acyclicPkg <- acyclicPkgs
if nodeMap(value).value.pkg.startsWith(acyclicPkg.pkg)
if !d.value.pkg.startsWith(acyclicPkg.pkg)
} yield (acyclicPkg, pos)

case (_: Value.Pkg, _) => Nil
}
d.copy(dependencies = d.dependencies ++ extraLinks)
}

// only care about cycles with size > 1 here
val components = DepNode.stronglyConnectedComponents(linkedNodes)
.filter(_.size > 1)

val usedNodes = mutable.Set.empty[DepNode]
for {
c <- components
n <- c
if !usedNodes.contains(n)
if (!force && allAcyclics.contains(n.value)) || (force && !skipNodePaths.contains(n.value))
} {
val cycle = DepNode.smallestCycle(n, c)
val cycleInfo =
(cycle :+ cycle.head).sliding(2)
.map { case Seq(a, b) => (a.value, a.dependencies(b.value)) }
.toSeq
cycleReporter(
cycleInfo.map { case (a, b) => a -> b.map(_.sourcePos.line + 1).to(SortedSet) }
)

val msg = "Unwanted cyclic dependency"
if (fatal) {
report.error(msg)
} else {
report.warning(msg)
}

for (Seq((value, locs), (nextValue, _)) <- (cycleInfo :+ cycleInfo.head).sliding(2)) {
report.inform("")
value match {
case Value.Pkg(pkg) => report.inform(s"package ${pkg.mkString(".")}")
case Value.File(_, _) =>
}

report.echo("", locs.head.srcPos)

val otherLines = locs.tail
.map(_.sourcePos.line + 1)
.filter(_ != locs.head.sourcePos.line + 1)

report.inform("symbol: " + locs.head.symbol.toString)

if (!otherLines.isEmpty) {
report.inform("More dependencies at lines " + otherLines.mkString(" "))
}

}
report.inform("")
usedNodes ++= cycle
}
}

}
2 changes: 1 addition & 1 deletion acyclic/src/acyclic/package.scala
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import scala.reflect.internal.annotations.compileTimeOnly
import scala.annotation.compileTimeOnly
package object acyclic {

/**
Expand Down
7 changes: 2 additions & 5 deletions acyclic/src/acyclic/plugin/GraphAnalysis.scala
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package acyclic.plugin

import acyclic.file
import scala.tools.nsc.Global
import collection.mutable

sealed trait Value {
Expand All @@ -19,10 +19,7 @@ object Value {
}
}

trait GraphAnalysis {
val global: Global
import global._

trait GraphAnalysis[Tree] {
case class Node[+T <: Value](value: T, dependencies: Map[Value, Seq[Tree]]) {
override def toString = s"DepNode(\n $value, \n ${dependencies.keys}\n)"
}
Expand Down
3 changes: 3 additions & 0 deletions acyclic/test/src-2/acyclic/CycleTests.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package acyclic

object CycleTests extends BaseCycleTests(TestUtils)
Loading