diff --git a/acyclic/resources/plugin.properties b/acyclic/resources/plugin.properties new file mode 100644 index 0000000..ff75cfc --- /dev/null +++ b/acyclic/resources/plugin.properties @@ -0,0 +1 @@ +pluginClass=acyclic.plugin.RuntimePlugin diff --git a/acyclic/src/acyclic/plugin/DependencyExtraction.scala b/acyclic/src-2/acyclic/plugin/DependencyExtraction.scala similarity index 100% rename from acyclic/src/acyclic/plugin/DependencyExtraction.scala rename to acyclic/src-2/acyclic/plugin/DependencyExtraction.scala diff --git a/acyclic/src/acyclic/plugin/Plugin.scala b/acyclic/src-2/acyclic/plugin/Plugin.scala similarity index 100% rename from acyclic/src/acyclic/plugin/Plugin.scala rename to acyclic/src-2/acyclic/plugin/Plugin.scala diff --git a/acyclic/src-2/acyclic/plugin/PluginPhase.scala b/acyclic/src-2/acyclic/plugin/PluginPhase.scala new file mode 100644 index 0000000..ac58e9c --- /dev/null +++ b/acyclic/src-2/acyclic/plugin/PluginPhase.scala @@ -0,0 +1,68 @@ +package acyclic.plugin + +import acyclic.file +import acyclic.plugin.Compat._ +import scala.collection.{SortedSet, mutable} +import scala.tools.nsc.{Global, Phase} +import tools.nsc.plugins.PluginComponent + +/** + * - 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( + val global: Global, + cycleReporter: Seq[(Value, SortedSet[Int])] => Unit, + force: => Boolean, + fatal: => Boolean +) extends PluginComponent { t => + + import global._ + + val runsAfter = List("typer") + + override val runsBefore = List("patmat") + + val phaseName = "acyclic" + + private object base extends BasePluginPhase[CompilationUnit, Tree, Symbol] with GraphAnalysis[Tree] { + protected val cycleReporter = t.cycleReporter + protected lazy val force = t.force + protected lazy val fatal = t.fatal + + def treeLine(tree: Tree): Int = tree.pos.line + def treeSymbolString(tree: Tree): String = tree.symbol.toString + + def reportError(msg: String): Unit = global.error(msg) + def reportWarning(msg: String): Unit = global.warning(msg) + def reportInform(msg: String): Unit = global.inform(msg) + def reportEcho(msg: String, tree: Tree): Unit = global.reporter.echo(tree.pos, msg) + + def units: Seq[CompilationUnit] = global.currentRun.units.toSeq.sortBy(_.source.content.mkString.hashCode()) + def unitTree(unit: CompilationUnit): Tree = unit.body + def unitPath(unit: CompilationUnit): String = unit.source.path + def unitPkgName(unit: CompilationUnit): List[String] = + unit.body.collect { case x: PackageDef => x.pid.toString }.flatMap(_.split('.')) + def findPkgObjects(tree: Tree): List[Tree] = tree.collect { case x: ModuleDef if x.name.toString == "package" => x } + def pkgObjectName(pkgObject: Tree): String = pkgObject.symbol.enclosingPackageClass.fullName + def hasAcyclicImport(tree: Tree, selector: String): Boolean = + tree.collect { + case Import(expr, List(sel)) => expr.symbol.toString == "package acyclic" && sel.name.toString == selector + }.exists(identity) + + def extractDependencies(unit: CompilationUnit): Seq[(Symbol, Tree)] = DependencyExtraction(global)(unit) + def symbolPath(sym: Symbol): String = sym.sourceFile.path + def isValidSymbol(sym: Symbol): Boolean = sym != NoSymbol && sym.sourceFile != null + } + + override def newPhase(prev: Phase): Phase = new Phase(prev) { + override def run(): Unit = base.runAllUnits() + + def name: String = "acyclic" + } +} diff --git a/acyclic/src-3/acyclic/plugin/Compat.scala b/acyclic/src-3/acyclic/plugin/Compat.scala new file mode 100644 index 0000000..e195a41 --- /dev/null +++ b/acyclic/src-3/acyclic/plugin/Compat.scala @@ -0,0 +1,5 @@ +package acyclic.plugin + +import acyclic.file + +object Compat diff --git a/acyclic/src-3/acyclic/plugin/DependencyExtraction.scala b/acyclic/src-3/acyclic/plugin/DependencyExtraction.scala new file mode 100644 index 0000000..cab9d67 --- /dev/null +++ b/acyclic/src-3/acyclic/plugin/DependencyExtraction.scala @@ -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 + } +} diff --git a/acyclic/src-3/acyclic/plugin/Plugin.scala b/acyclic/src-3/acyclic/plugin/Plugin.scala new file mode 100644 index 0000000..9833f8b --- /dev/null +++ b/acyclic/src-3/acyclic/plugin/Plugin.scala @@ -0,0 +1,39 @@ +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 + var alreadyRun = false + + private class Phase() extends PluginPhase { + val phaseName = "acyclic" + override val runsBefore = Set("patternMatcher") + + override def run(using Context): Unit = { + if (!alreadyRun) { + alreadyRun = true + 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()) + } +} diff --git a/acyclic/src-3/acyclic/plugin/PluginPhase.scala b/acyclic/src-3/acyclic/plugin/PluginPhase.scala new file mode 100644 index 0000000..8c59d22 --- /dev/null +++ b/acyclic/src-3/acyclic/plugin/PluginPhase.scala @@ -0,0 +1,81 @@ +package acyclic.plugin + +import acyclic.file +import scala.collection.SortedSet +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, Symbol} +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( + protected val cycleReporter: Seq[(Value, SortedSet[Int])] => Unit, + protected val force: Boolean, + protected val fatal: Boolean +)(using ctx: Context) extends BasePluginPhase[CompilationUnit, tpd.Tree, Symbol], GraphAnalysis[tpd.Tree] { + + def treeLine(tree: tpd.Tree): Int = tree.sourcePos.line + 1 + def treeSymbolString(tree: tpd.Tree): String = tree.symbol.toString + + def reportError(msg: String): Unit = report.error(msg) + def reportWarning(msg: String): Unit = report.warning(msg) + def reportInform(msg: String): Unit = report.echo(msg) + def reportEcho(msg: String, tree: tpd.Tree): Unit = report.echo(msg, tree.srcPos) + + 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 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 + ) + } + + private def hasAcyclicImportAccumulator(selector: String) = 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) + } + } + + lazy val units = Option(ctx.run) match { + case Some(run) => run.units.toSeq.sortBy(_.source.content.mkString.hashCode()) + case None => Seq() + } + + def unitTree(unit: CompilationUnit): tpd.Tree = unit.tpdTree + def unitPath(unit: CompilationUnit): String = unit.source.path + def unitPkgName(unit: CompilationUnit): List[String] = pkgNameAccumulator(Nil, unit.tpdTree).reverse.flatMap(_.split('.')) + def findPkgObjects(tree: tpd.Tree): List[tpd.Tree] = pkgObjectAccumulator(Nil, tree).reverse + def pkgObjectName(pkgObject: tpd.Tree): String = pkgObject.symbol.enclosingPackageClass.fullName.toString + def hasAcyclicImport(tree: tpd.Tree, selector: String): Boolean = hasAcyclicImportAccumulator(selector)(false, tree) + + def extractDependencies(unit: CompilationUnit): Seq[(Symbol, tpd.Tree)] = DependencyExtraction(unit) + def symbolPath(sym: Symbol): String = sym.source.path + def isValidSymbol(sym: Symbol): Boolean = sym != NoSymbol && sym.source != null && sym.source != NoSource + + def run(): Unit = runAllUnits() +} diff --git a/acyclic/src/acyclic/package.scala b/acyclic/src/acyclic/package.scala index 43947e6..1cee541 100644 --- a/acyclic/src/acyclic/package.scala +++ b/acyclic/src/acyclic/package.scala @@ -1,4 +1,4 @@ -import scala.reflect.internal.annotations.compileTimeOnly +import scala.annotation.compileTimeOnly package object acyclic { /** diff --git a/acyclic/src/acyclic/plugin/BasePluginPhase.scala b/acyclic/src/acyclic/plugin/BasePluginPhase.scala new file mode 100644 index 0000000..71fdd91 --- /dev/null +++ b/acyclic/src/acyclic/plugin/BasePluginPhase.scala @@ -0,0 +1,154 @@ +package acyclic.plugin + +import acyclic.plugin.Compat._ +import scala.collection.{mutable, SortedSet} + +trait BasePluginPhase[CompilationUnit, Tree, Symbol] { self: GraphAnalysis[Tree] => + protected val cycleReporter: Seq[(Value, SortedSet[Int])] => Unit + protected def force: Boolean + protected def fatal: Boolean + + def treeLine(tree: Tree): Int + def treeSymbolString(tree: Tree): String + + def reportError(msg: String): Unit + def reportWarning(msg: String): Unit + def reportInform(msg: String): Unit + def reportEcho(msg: String, tree: Tree): Unit + + def units: Seq[CompilationUnit] + def unitTree(unit: CompilationUnit): Tree + def unitPath(unit: CompilationUnit): String + def unitPkgName(unit: CompilationUnit): List[String] + def findPkgObjects(tree: Tree): List[Tree] + def pkgObjectName(pkgObject: Tree): String + def hasAcyclicImport(tree: Tree, selector: String): Boolean + + def extractDependencies(unit: CompilationUnit): Seq[(Symbol, Tree)] + def symbolPath(sym: Symbol): String + def isValidSymbol(sym: Symbol): Boolean + + final def findAcyclics(): (Seq[Value.File], Seq[Value.File], Seq[Value.Pkg]) = { + val acyclicNodePaths = for { + unit <- units if hasAcyclicImport(unitTree(unit), "file") + } yield { + Value.File(unitPath(unit), unitPkgName(unit)) + } + val skipNodePaths = for { + unit <- units if hasAcyclicImport(unitTree(unit), "skipped") + } yield { + Value.File(unitPath(unit), unitPkgName(unit)) + } + + val acyclicPkgNames = for { + unit <- units + pkgObject <- findPkgObjects(unitTree(unit)) + if hasAcyclicImport(pkgObject, "pkg") + } yield Value.Pkg(pkgObjectName(pkgObject).split('.').toList) + (skipNodePaths, acyclicNodePaths, acyclicPkgNames) + } + + final def runAllUnits(): Unit = { + val unitMap = units.map(u => unitPath(u) -> u).toMap + val nodes = for (unit <- units) yield { + val deps = extractDependencies(unit) + + val connections = for { + (sym, tree) <- deps + if isValidSymbol(sym) + if symbolPath(sym) != unitPath(unit) + if unitMap.contains(symbolPath(sym)) + } yield (symbolPath(sym), tree) + + Node[Value.File, Tree]( + Value.File(unitPath(unit), unitPkgName(unit)), + connections.groupBy(c => Value.File(c._1, unitPkgName(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(treeLine).to(SortedSet) } + ) + + val msg = "Unwanted cyclic dependency" + if (fatal) { + reportError(msg) + } else { + reportWarning(msg) + } + + for (Seq((value, locs), (nextValue, _)) <- (cycleInfo :+ cycleInfo.head).sliding(2)) { + reportInform("") + value match { + case Value.Pkg(pkg) => reportInform(s"package ${pkg.mkString(".")}") + case Value.File(_, _) => + } + + reportEcho("", locs.head) + + val otherLines = locs.tail + .map(treeLine) + .filter(_ != treeLine(locs.head)) + + reportInform("symbol: " + treeSymbolString(locs.head)) + + if (!otherLines.isEmpty) { + reportInform("More dependencies at lines " + otherLines.mkString(" ")) + } + + } + reportInform("") + usedNodes ++= cycle + } + } +} diff --git a/acyclic/src/acyclic/plugin/GraphAnalysis.scala b/acyclic/src/acyclic/plugin/GraphAnalysis.scala index 98d3434..50b11ad 100644 --- a/acyclic/src/acyclic/plugin/GraphAnalysis.scala +++ b/acyclic/src/acyclic/plugin/GraphAnalysis.scala @@ -1,6 +1,6 @@ package acyclic.plugin + import acyclic.file -import scala.tools.nsc.Global import collection.mutable sealed trait Value { @@ -19,17 +19,14 @@ object Value { } } -trait GraphAnalysis { - val global: Global - import global._ - - case class Node[+T <: Value](value: T, dependencies: Map[Value, Seq[Tree]]) { - override def toString = s"DepNode(\n $value, \n ${dependencies.keys}\n)" - } +case class Node[+T <: Value, Tree](value: T, dependencies: Map[Value, Seq[Tree]]) { + override def toString = s"DepNode(\n $value, \n ${dependencies.keys}\n)" +} - type DepNode = Node[Value] - type FileNode = Node[Value.File] - type PkgNode = Node[Value.Pkg] +trait GraphAnalysis[Tree] { + type DepNode = Node[Value, Tree] + type FileNode = Node[Value.File, Tree] + type PkgNode = Node[Value.Pkg, Tree] object DepNode { diff --git a/acyclic/src/acyclic/plugin/PluginPhase.scala b/acyclic/src/acyclic/plugin/PluginPhase.scala deleted file mode 100644 index 76e089c..0000000 --- a/acyclic/src/acyclic/plugin/PluginPhase.scala +++ /dev/null @@ -1,187 +0,0 @@ -package acyclic.plugin -import acyclic.file -import acyclic.plugin.Compat._ -import scala.collection.{SortedSet, mutable} -import scala.tools.nsc.{Global, Phase} -import tools.nsc.plugins.PluginComponent - -/** - * - 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( - val global: Global, - cycleReporter: Seq[(Value, SortedSet[Int])] => Unit, - force: => Boolean, - fatal: => Boolean -) extends PluginComponent - with GraphAnalysis { t => - - import global._ - - val runsAfter = List("typer") - - override val runsBefore = List("patmat") - - val phaseName = "acyclic" - def pkgName(unit: CompilationUnit) = { - unit.body - .collect { case x: PackageDef => x.pid.toString } - .flatMap(_.split('.')) - } - - def units = global.currentRun - .units - .toSeq - .sortBy(_.source.content.mkString.hashCode()) - - def findAcyclics() = { - val acyclicNodePaths = for { - unit <- units - if unit.body.children.collect { - case Import(expr, List(sel)) => - expr.symbol.toString == "package acyclic" && sel.name.toString == "file" - }.exists(x => x) - } yield { - Value.File(unit.source.path, pkgName(unit)) - } - val skipNodePaths = for { - unit <- units - if unit.body.children.collect { - case Import(expr, List(sel)) => - expr.symbol.toString == "package acyclic" && sel.name.toString == "skipped" - }.exists(x => x) - } yield { - Value.File(unit.source.path, pkgName(unit)) - } - - val acyclicPkgNames = for { - unit <- units - pkgObject <- unit.body.collect { case x: ModuleDef if x.name.toString == "package" => x } - if pkgObject.impl.children.collect { case Import(expr, List(sel)) => - expr.symbol.toString == "package acyclic" && sel.name.toString == "pkg" - }.exists(x => x) - } yield { - Value.Pkg( - pkgObject.symbol - .enclosingPackageClass - .fullName - .split('.') - .toList - ) - } - (skipNodePaths, acyclicNodePaths, acyclicPkgNames) - } - - override def newPhase(prev: Phase): Phase = new Phase(prev) { - override def run() { - val unitMap = units.map(u => u.source.path -> u).toMap - val nodes = for (unit <- units) yield { - - val deps = DependencyExtraction(t.global)(unit) - - val connections = for { - (sym, tree) <- deps - if sym != NoSymbol - if sym.sourceFile != null - if sym.sourceFile.path != unit.source.path - } yield (sym.sourceFile.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 = for { - (value: Value.File, pos) <- d.dependencies - acyclicPkg <- acyclicPkgs - if nodeMap(value).value.pkg.startsWith(acyclicPkg.pkg) - if !d.value.pkg.startsWith(acyclicPkg.pkg) - } yield (acyclicPkg, pos) - 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(_.pos.line).to(SortedSet) } - ) - - val msg = "Unwanted cyclic dependency" - if (fatal) { - global.error(msg) - } else { - global.warning(msg) - } - - for (Seq((value, locs), (nextValue, _)) <- (cycleInfo :+ cycleInfo.head).sliding(2)) { - global.inform("") - value match { - case Value.Pkg(pkg) => global.inform(s"package ${pkg.mkString(".")}") - case Value.File(_, _) => - } - - global.reporter.echo(locs.head.pos, "") - - val otherLines = locs.tail - .map(_.pos.line) - .filter(_ != locs.head.pos.line) - - global.inform("symbol: " + locs.head.symbol.toString) - - if (!otherLines.isEmpty) { - global.inform("More dependencies at lines " + otherLines.mkString(" ")) - } - - } - global.inform("") - usedNodes ++= cycle - } - } - - def name: String = "acyclic" - } - -} diff --git a/acyclic/test/src-2/acyclic/CycleTests.scala b/acyclic/test/src-2/acyclic/CycleTests.scala new file mode 100644 index 0000000..fd85c1f --- /dev/null +++ b/acyclic/test/src-2/acyclic/CycleTests.scala @@ -0,0 +1,3 @@ +package acyclic + +object CycleTests extends BaseCycleTests(TestUtils) diff --git a/acyclic/test/src/acyclic/TestUtils.scala b/acyclic/test/src-2/acyclic/TestUtils.scala similarity index 87% rename from acyclic/test/src/acyclic/TestUtils.scala rename to acyclic/test/src-2/acyclic/TestUtils.scala index d33bc26..6ac2787 100644 --- a/acyclic/test/src/acyclic/TestUtils.scala +++ b/acyclic/test/src-2/acyclic/TestUtils.scala @@ -14,14 +14,9 @@ import acyclic.plugin.Value import java.io.OutputStream import javax.print.attribute.standard.Severity import scala.collection.SortedSet -import scala.reflect.api.Position -object TestUtils { - def getFilePaths(src: String): List[String] = { - val f = new java.io.File(src) - if (f.isDirectory) f.list.toList.flatMap(x => getFilePaths(src + "/" + x)) - else List(src) - } +object TestUtils extends BaseTestUtils { + val srcDirName: String = "src-2" /** * Attempts to compile a resource folder as a compilation run, in order @@ -33,7 +28,7 @@ object TestUtils { force: Boolean = false, warn: Boolean = false, collectInfo: Boolean = true - ): Seq[(Position, String, String)] = { + ): Seq[(String, String)] = { val src = "acyclic/test/resources/" + path val sources = getFilePaths(src) ++ extraIncludes @@ -80,7 +75,7 @@ object TestUtils { if (vd.toList.isEmpty) throw CompilationException(cycles.get) - storeReporter.map(_.infos.toSeq.map(i => (i.pos, i.msg, i.severity.toString))).getOrElse(Seq.empty) + storeReporter.map(_.infos.toSeq.map(i => (i.msg, i.severity.toString))).getOrElse(Seq.empty) } def makeFail(path: String, force: Boolean = false)(expected: Seq[(Value, SortedSet[Int])]*) = { @@ -112,6 +107,4 @@ object TestUtils { assert(fullExpected.forall(cycles.contains)) } - case class CompilationException(cycles: Seq[Seq[(Value, SortedSet[Int])]]) extends Exception - } diff --git a/acyclic/test/src-3/acyclic/CycleTests.scala b/acyclic/test/src-3/acyclic/CycleTests.scala new file mode 100644 index 0000000..fd85c1f --- /dev/null +++ b/acyclic/test/src-3/acyclic/CycleTests.scala @@ -0,0 +1,3 @@ +package acyclic + +object CycleTests extends BaseCycleTests(TestUtils) diff --git a/acyclic/test/src-3/acyclic/TestUtils.scala b/acyclic/test/src-3/acyclic/TestUtils.scala new file mode 100644 index 0000000..6f3769c --- /dev/null +++ b/acyclic/test/src-3/acyclic/TestUtils.scala @@ -0,0 +1,122 @@ +package acyclic + +import acyclic.plugin.Value +import java.io.OutputStream +import javax.print.attribute.standard.Severity +import scala.collection.SortedSet +import dotty.tools.io.{ClassPath, Path, PlainFile, VirtualDirectory} +import dotty.tools.dotc.Compiler +import dotty.tools.dotc.config.ScalaSettings +import dotty.tools.dotc.core.Contexts.{Context, ContextBase, FreshContext, NoContext} +import dotty.tools.dotc.interfaces.Diagnostic.{ERROR, INFO, WARNING} +import dotty.tools.dotc.plugins.Plugin +import dotty.tools.dotc.reporting.{ConsoleReporter, StoreReporter} +import java.net.URLClassLoader +import java.nio.file.Paths +import utest._ +import utest.asserts._ + +object TestUtils extends BaseTestUtils { + val srcDirName: String = "src-3" + + /** + * Attempts to compile a resource folder as a compilation run, in order + * to test whether it succeeds or fails correctly. + */ + def make( + path: String, + extraIncludes: Seq[String] = Seq("acyclic/src/acyclic/package.scala"), + force: Boolean = false, + warn: Boolean = false, + collectInfo: Boolean = true + ): Seq[(String, String)] = { + val src = "acyclic/test/resources/" + path + val sources = (getFilePaths(src) ++ extraIncludes).map(f => PlainFile(Path(Paths.get(f)))) + val vd = new VirtualDirectory("(memory)", None) + val loader = getClass.getClassLoader.asInstanceOf[URLClassLoader] + val entries = loader.getURLs map (_.getPath) + + val scalaSettings = new ScalaSettings {} + val settingsState1 = scalaSettings.outputDir.updateIn(scalaSettings.defaultState, vd) + val settingsState2 = scalaSettings.classpath.updateIn(settingsState1, ClassPath.join(entries*)) + + val opts = List( + if (force) Seq("force") else Seq(), + if (warn) Seq("warn") else Seq() + ).flatten + + val settingsState3 = if (opts.nonEmpty) { + val options = opts.map("acyclic:" + _) + println("options: " + options) + scalaSettings.pluginOptions.updateIn(settingsState2, options) + } else { + settingsState2 + } + + var cycles: Option[Seq[Seq[(Value, SortedSet[Int])]]] = None + val storeReporter = if (collectInfo) Some(new StoreReporter()) else None + + val ctxBase = new ContextBase { + override val initialCtx: Context = FreshContext.initial(NoContext.base, settings) + + override protected def loadRoughPluginsList(using Context): List[Plugin] = + List(new plugin.TestPlugin(foundCycles => + cycles = cycles match { + case None => Some(Seq(foundCycles)) + case Some(oldCycles) => Some(oldCycles :+ foundCycles) + } + )) + } + + given ctx: Context = FreshContext.initial(ctxBase, new ScalaSettings { + override val defaultState = settingsState3 + }) + .asInstanceOf[FreshContext] + .setReporter(storeReporter.getOrElse(ConsoleReporter())) + + ctx.initialize() + + val compiler = new Compiler() + val run = compiler.newRun + + run.compile(sources) + + if (vd.toList.isEmpty) throw CompilationException(cycles.get) + + storeReporter.map(_.pendingMessages.toSeq.map(i => (i.msg.message, i.level match { + case ERROR => "ERROR" + case INFO => "INFO" + case WARNING => "WARNING" + }))).getOrElse(Seq.empty) + } + + def makeFail(path: String, force: Boolean = false)(expected: Seq[(Value, SortedSet[Int])]*) = { + def canonicalize(cycle: Seq[(Value, SortedSet[Int])]): Seq[(Value, SortedSet[Int])] = { + val startIndex = cycle.indexOf(cycle.minBy(_._1.toString)) + cycle.toList.drop(startIndex) ++ cycle.toList.take(startIndex) + } + + val ex = intercept[CompilationException] { make(path, force = force, collectInfo = false) } + val cycles = ex.cycles + .map(canonicalize) + .map( + _.map { + case (Value.File(p, pkg), v) => (Value.File(p, Nil), v) + case x => x + } + ) + .toSet + + def expand(v: Value) = v match { + case Value.File(filePath, pkg) => Value.File("acyclic/test/resources/" + path + "/" + filePath, Nil) + case v => v + } + + val fullExpected = expected.map(_.map(x => x.copy(_1 = expand(x._1)))) + .map(canonicalize) + .toSet + + assert(fullExpected.forall(cycles.contains)) + } + +} diff --git a/acyclic/test/src/acyclic/CycleTests.scala b/acyclic/test/src/acyclic/BaseCycleTests.scala similarity index 85% rename from acyclic/test/src/acyclic/CycleTests.scala rename to acyclic/test/src/acyclic/BaseCycleTests.scala index 134a4dd..1ee2cd3 100644 --- a/acyclic/test/src/acyclic/CycleTests.scala +++ b/acyclic/test/src/acyclic/BaseCycleTests.scala @@ -1,13 +1,12 @@ package acyclic import utest._ -import TestUtils.{make, makeFail} -import scala.tools.nsc.util.ScalaClassLoader.URLClassLoader import acyclic.plugin.Value.{Pkg, File} import scala.collection.SortedSet import acyclic.file -object CycleTests extends TestSuite { +class BaseCycleTests(utils: BaseTestUtils) extends TestSuite { + import utils.{make, makeFail, srcDirName} def tests = Tests { test("fail") - { @@ -53,14 +52,14 @@ object CycleTests extends TestSuite { test("innercycle") - make("success/pkg/innercycle") } } - test("self") - make("../../src", extraIncludes = Nil) + test("self") - make(s"../../$srcDirName", extraIncludes = Nil) test("force") - { test("warn") - { test("fail") - { - make("force/simple", force = true, warn = true).exists { - case (_, "Unwanted cyclic dependency", "warning") => true + assert(make("force/simple", force = true, warn = true).exists { + case ("Unwanted cyclic dependency", "WARNING") => true case _ => false - } + }) } } test("fail") - makeFail("force/simple", force = true)(Seq( diff --git a/acyclic/test/src/acyclic/BaseTestUtils.scala b/acyclic/test/src/acyclic/BaseTestUtils.scala new file mode 100644 index 0000000..cab5378 --- /dev/null +++ b/acyclic/test/src/acyclic/BaseTestUtils.scala @@ -0,0 +1,30 @@ +package acyclic + +import acyclic.plugin.Value +import scala.collection.SortedSet + +abstract class BaseTestUtils { + val srcDirName: String + + /** + * Attempts to compile a resource folder as a compilation run, in order + * to test whether it succeeds or fails correctly. + */ + def make( + path: String, + extraIncludes: Seq[String] = Seq("acyclic/src/acyclic/package.scala"), + force: Boolean = false, + warn: Boolean = false, + collectInfo: Boolean = true + ): Seq[(String, String)] + + def makeFail(path: String, force: Boolean = false)(expected: Seq[(Value, SortedSet[Int])]*): Unit + + case class CompilationException(cycles: Seq[Seq[(Value, SortedSet[Int])]]) extends Exception + + final def getFilePaths(src: String): List[String] = { + val f = new java.io.File(src) + if (f.isDirectory) f.list.toList.flatMap(x => getFilePaths(src + "/" + x)) + else List(src) + } +} diff --git a/build.sc b/build.sc index f602b43..43615d7 100644 --- a/build.sc +++ b/build.sc @@ -4,15 +4,29 @@ import mill._, scalalib._, publish._ import de.tobiasroeser.mill.vcs.version.VcsVersion object Deps { + val scala211 = Seq("2.11.12") + val scala212 = 8.to(20).map("2.12." + _) + val scala213 = 0.to(14).map("2.13." + _) + val scala33 = 0.to(3).map("3.3." + _) + val scala34 = 0.to(3).map("3.4." + _) + val scala35 = 0.to(0).map("3.5." + _) + + val unreleased = scala33 ++ scala34 ++ scala35 + + def scalaCompiler(scalaVersion: String) = + if (scalaVersion.startsWith("3.")) ivy"org.scala-lang::scala3-compiler:$scalaVersion" + else ivy"org.scala-lang:scala-compiler:$scalaVersion" - def scalaCompiler(scalaVersion: String) = ivy"org.scala-lang:scala-compiler:${scalaVersion}" val utest = ivy"com.lihaoyi::utest:0.8.2" } val crosses = - Seq("2.11.12") ++ - 8.to(20).map("2.12." + _) ++ - 0.to(15).map("2.13." + _) + Deps.scala211 ++ + Deps.scala212 ++ + Deps.scala213 ++ + Deps.scala33 ++ + Deps.scala34 ++ + Deps.scala35 object acyclic extends Cross[AcyclicModule](crosses) trait AcyclicModule extends CrossScalaModule with PublishModule { @@ -35,7 +49,7 @@ trait AcyclicModule extends CrossScalaModule with PublishModule { object test extends ScalaTests with TestModule.Utest { - override def sources = T.sources(millSourcePath / "src", millSourcePath / "resources") + override def sources = T.sources(super.sources() :+ PathRef(millSourcePath / "resources")) override def ivyDeps = Agg( Deps.utest, Deps.scalaCompiler(crossScalaVersion)