diff --git a/compiler/src/dotty/tools/dotc/interactive/Interactive.scala b/compiler/src/dotty/tools/dotc/interactive/Interactive.scala index 030875578aa0..e30a0b587e37 100644 --- a/compiler/src/dotty/tools/dotc/interactive/Interactive.scala +++ b/compiler/src/dotty/tools/dotc/interactive/Interactive.scala @@ -490,4 +490,33 @@ object Interactive { } } + /** + * Given `sym`, originating from `sourceDriver`, find its representation in + * `targetDriver`. + * + * @param symbol The symbol to expression in the new driver. + * @param sourceDriver The driver from which `symbol` originates. + * @param targetDriver The driver in which we want to get a representation of `symbol`. + * @return A representation of `symbol` in `targetDriver`. + */ + def localize(symbol: Symbol, sourceDriver: InteractiveDriver, targetDriver: InteractiveDriver): Symbol = { + + def in[T](driver: InteractiveDriver)(fn: Context => T): T = + fn(driver.currentCtx) + + if (sourceDriver == targetDriver) symbol + else { + val owners = in(sourceDriver) { implicit ctx => + symbol.ownersIterator.toList.reverse.map(_.name) + } + in(targetDriver) { implicit ctx => + val base: Symbol = ctx.definitions.RootClass + owners.tail.foldLeft(base) { (prefix, symbolName) => + if (prefix.exists) prefix.info.member(symbolName).symbol + else NoSymbol + } + } + } + } + } diff --git a/compiler/src/dotty/tools/dotc/interactive/InteractiveDriver.scala b/compiler/src/dotty/tools/dotc/interactive/InteractiveDriver.scala index 02fdc7acd9ec..0e4c38fddf30 100644 --- a/compiler/src/dotty/tools/dotc/interactive/InteractiveDriver.scala +++ b/compiler/src/dotty/tools/dotc/interactive/InteractiveDriver.scala @@ -36,78 +36,27 @@ class InteractiveDriver(val settings: List[String]) extends Driver { } private[this] var myCtx: Context = myInitCtx - def currentCtx: Context = myCtx + private val compiler: Compiler = new InteractiveCompiler + private val myOpenedFiles = new mutable.LinkedHashMap[URI, SourceFile] { override def default(key: URI) = NoSource } + def openedFiles: Map[URI, SourceFile] = myOpenedFiles private val myOpenedTrees = new mutable.LinkedHashMap[URI, List[SourceTree]] { override def default(key: URI) = Nil } + def openedTrees: Map[URI, List[SourceTree]] = myOpenedTrees private val myCompilationUnits = new mutable.LinkedHashMap[URI, CompilationUnit] - - def openedFiles: Map[URI, SourceFile] = myOpenedFiles - def openedTrees: Map[URI, List[SourceTree]] = myOpenedTrees def compilationUnits: Map[URI, CompilationUnit] = myCompilationUnits - def allTrees(implicit ctx: Context): List[SourceTree] = allTreesContaining("") - - def allTreesContaining(id: String)(implicit ctx: Context): List[SourceTree] = { - val fromSource = openedTrees.values.flatten.toList - val fromClassPath = (dirClassPathClasses ++ zipClassPathClasses).flatMap { cls => - val className = cls.toTypeName - List(tree(className, id), tree(className.moduleClassName, id)).flatten - } - (fromSource ++ fromClassPath).distinct - } - - private def tree(className: TypeName, id: String)(implicit ctx: Context): Option[SourceTree] = { - val clsd = ctx.base.staticRef(className) - clsd match { - case clsd: ClassDenotation => - clsd.ensureCompleted() - SourceTree.fromSymbol(clsd.symbol.asClass, id) - case _ => - None - } - } - // Presence of a file with one of these suffixes indicates that the // corresponding class has been pickled with TASTY. private val tastySuffixes = List(".hasTasty", ".tasty") - private def classNames(cp: ClassPath, packageName: String): List[String] = { - def className(classSegments: List[String]) = - classSegments.mkString(".").stripSuffix(".class") - - val ClassPathEntries(pkgs, classReps) = cp.list(packageName) - - classReps - .filter((classRep: ClassRepresentation) => classRep.binary match { - case None => - true - case Some(binFile) => - val prefix = - if (binFile.name.endsWith(".class")) - binFile.name.stripSuffix(".class") - else - null - prefix != null && { - binFile match { - case pf: PlainFile => - tastySuffixes.map(suffix => pf.givenPath.parent / (prefix + suffix)).exists(_.exists) - case _ => - sys.error(s"Unhandled file type: $binFile [getClass = ${binFile.getClass}]") - } - } - }) - .map(classRep => (packageName ++ (if (packageName != "") "." else "") ++ classRep.name)).toList ++ - pkgs.flatMap(pkg => classNames(cp, pkg.name)) - } - // FIXME: All the code doing classpath handling is very fragile and ugly, // improving this requires changing the dotty classpath APIs to handle our usecases. // We also need something like sbt server-mode to be informed of changes on @@ -128,46 +77,173 @@ class InteractiveDriver(val settings: List[String]) extends Driver { } // Like in `ZipArchiveFileLookup` we assume that zips are immutable - private val zipClassPathClasses: Seq[String] = zipClassPaths.flatMap { zipCp => - val zipFile = new ZipFile(zipCp.zipFile) + private val zipClassPathClasses: Seq[TypeName] = { + val names = new mutable.ListBuffer[TypeName] + zipClassPaths.foreach { zipCp => + val zipFile = new ZipFile(zipCp.zipFile) + classesFromZip(zipFile, names) + } + names + } + + initialize() + + /** + * The trees for all the source files in this project. + * + * This includes the trees for the buffers that are presently open in the IDE, and the trees + * from the target directory. + */ + def sourceTrees(implicit ctx: Context): List[SourceTree] = sourceTreesContaining("") + + /** + * The trees for all the source files in this project that contain `id`. + * + * This includes the trees for the buffers that are presently open in the IDE, and the trees + * from the target directory. + */ + def sourceTreesContaining(id: String)(implicit ctx: Context): List[SourceTree] = { + val fromBuffers = openedTrees.values.flatten.toList + val fromCompilationOutput = { + val classNames = new mutable.ListBuffer[TypeName] + val output = ctx.settings.outputDir.value + if (output.isDirectory) { + classesFromDir(output.jpath, classNames) + } else { + val zipFile = new ZipFile(output.file) + classesFromZip(zipFile, classNames) + } + classNames.flatMap { cls => + treesFromClassName(cls, id) + } + } + (fromBuffers ++ fromCompilationOutput).distinct + } + /** + * All the trees for this project. + * + * This includes the trees of the sources of this project, along with the trees that are found + * on this project's classpath. + */ + def allTrees(implicit ctx: Context): List[SourceTree] = allTreesContaining("") + + /** + * All the trees for this project that contain `id`. + * + * This includes the trees of the sources of this project, along with the trees that are found + * on this project's classpath. + */ + def allTreesContaining(id: String)(implicit ctx: Context): List[SourceTree] = { + val fromSource = openedTrees.values.flatten.toList + val fromClassPath = (dirClassPathClasses ++ zipClassPathClasses).flatMap { cls => + treesFromClassName(cls, id) + } + (fromSource ++ fromClassPath).distinct + } + + def run(uri: URI, sourceCode: String): List[MessageContainer] = run(uri, toSource(uri, sourceCode)) + + def run(uri: URI, source: SourceFile): List[MessageContainer] = { + val previousCtx = myCtx try { - for { - entry <- zipFile.stream.toArray((size: Int) => new Array[ZipEntry](size)) - name = entry.getName - tastySuffix <- tastySuffixes.find(name.endsWith) - } yield name.replace("/", ".").stripSuffix(tastySuffix) + val reporter = + new StoreReporter(null) with UniqueMessagePositions with HideNonSensicalMessages + + val run = compiler.newRun(myInitCtx.fresh.setReporter(reporter)) + myCtx = run.runContext + + implicit val ctx = myCtx + + myOpenedFiles(uri) = source + + run.compileSources(List(source)) + run.printSummary() + val unit = ctx.run.units.head + val t = unit.tpdTree + cleanup(t) + myOpenedTrees(uri) = topLevelClassTrees(t, source) + myCompilationUnits(uri) = unit + + reporter.removeBufferedMessages + } + catch { + case ex: FatalError => + myCtx = previousCtx + close(uri) + Nil + } + } + + def close(uri: URI): Unit = { + myOpenedFiles.remove(uri) + myOpenedTrees.remove(uri) + myCompilationUnits.remove(uri) + } + + /** + * The `SourceTree`s that define the class `className` and/or module `className`. + * + * @see SourceTree.fromSymbol + */ + private def treesFromClassName(className: TypeName, id: String)(implicit ctx: Context): List[SourceTree] = { + def tree(className: TypeName, id: String): Option[SourceTree] = { + val clsd = ctx.base.staticRef(className) + clsd match { + case clsd: ClassDenotation => + clsd.ensureCompleted() + SourceTree.fromSymbol(clsd.symbol.asClass, id) + case _ => + None + } } - finally zipFile.close() + List(tree(className, id), tree(className.moduleClassName, id)).flatten } // FIXME: classfiles in directories may change at any point, so we retraverse // the directories each time, if we knew when classfiles changed (sbt // server-mode might help here), we could do cache invalidation instead. - private def dirClassPathClasses: Seq[String] = { - val names = new mutable.ListBuffer[String] + private def dirClassPathClasses: Seq[TypeName] = { + val names = new mutable.ListBuffer[TypeName] dirClassPaths.foreach { dirCp => val root = dirCp.dir.toPath - try - Files.walkFileTree(root, new SimpleFileVisitor[Path] { - override def visitFile(path: Path, attrs: BasicFileAttributes) = { - if (!attrs.isDirectory) { - val name = path.getFileName.toString - for { - tastySuffix <- tastySuffixes - if name.endsWith(tastySuffix) - } { - names += root.relativize(path).toString.replace("/", ".").stripSuffix(tastySuffix) - } + classesFromDir(root, names) + } + names + } + + /** Adds the names of the classes that are defined in `zipFile` to `buffer`. */ + private def classesFromZip(zipFile: ZipFile, buffer: mutable.ListBuffer[TypeName]): Unit = { + try { + for { + entry <- zipFile.stream.toArray((size: Int) => new Array[ZipEntry](size)) + name = entry.getName + tastySuffix <- tastySuffixes.find(name.endsWith) + } buffer += name.replace("/", ".").stripSuffix(tastySuffix).toTypeName + } + finally zipFile.close() + } + + /** Adds the names of the classes that are defined in `dir` to `buffer`. */ + private def classesFromDir(dir: Path, buffer: mutable.ListBuffer[TypeName]): Unit = { + try + Files.walkFileTree(dir, new SimpleFileVisitor[Path] { + override def visitFile(path: Path, attrs: BasicFileAttributes) = { + if (!attrs.isDirectory) { + val name = path.getFileName.toString + for { + tastySuffix <- tastySuffixes + if name.endsWith(tastySuffix) + } { + buffer += dir.relativize(path).toString.replace("/", ".").stripSuffix(tastySuffix).toTypeName } - FileVisitResult.CONTINUE } - }) - catch { - case _: NoSuchFileException => - } + FileVisitResult.CONTINUE + } + }) + catch { + case _: NoSuchFileException => } - names.toList } private def topLevelClassTrees(topTree: Tree, source: SourceFile): List[SourceTree] = { @@ -185,8 +261,6 @@ class InteractiveDriver(val settings: List[String]) extends Driver { trees.toList } - private val compiler: Compiler = new InteractiveCompiler - /** Remove attachments and error out completers. The goal is to avoid * having a completer hanging in a typed tree which can capture the context * of a previous run. Note that typed trees can have untyped or partially @@ -224,44 +298,20 @@ class InteractiveDriver(val settings: List[String]) extends Driver { new SourceFile(virtualFile, Codec.UTF8) } - def run(uri: URI, sourceCode: String): List[MessageContainer] = run(uri, toSource(uri, sourceCode)) - - def run(uri: URI, source: SourceFile): List[MessageContainer] = { - val previousCtx = myCtx - try { - val reporter = - new StoreReporter(null) with UniqueMessagePositions with HideNonSensicalMessages - - val run = compiler.newRun(myInitCtx.fresh.setReporter(reporter)) - myCtx = run.runContext - - implicit val ctx = myCtx - - myOpenedFiles(uri) = source - - run.compileSources(List(source)) - run.printSummary() - val unit = ctx.run.units.head - val t = unit.tpdTree - cleanup(t) - myOpenedTrees(uri) = topLevelClassTrees(t, source) - myCompilationUnits(uri) = unit - - reporter.removeBufferedMessages - } - catch { - case ex: FatalError => - myCtx = previousCtx - close(uri) - Nil - } + /** + * Initialize this driver and compiler. + * + * This is necessary because an `InteractiveDriver` can be put to work without having + * compiled anything (for instance, resolving a symbol coming from a different compiler in + * this compiler). In those cases, an un-initialized compiler may crash (for instance if + * late-compilation is needed). + */ + private[this] def initialize(): Unit = { + val run = compiler.newRun(myInitCtx.fresh) + myCtx = run.runContext + run.compileUnits(Nil, myCtx) } - def close(uri: URI): Unit = { - myOpenedFiles.remove(uri) - myOpenedTrees.remove(uri) - myCompilationUnits.remove(uri) - } } object InteractiveDriver { diff --git a/language-server/src/dotty/tools/languageserver/DottyLanguageServer.scala b/language-server/src/dotty/tools/languageserver/DottyLanguageServer.scala index 5972dcf5318d..736d1f335d77 100644 --- a/language-server/src/dotty/tools/languageserver/DottyLanguageServer.scala +++ b/language-server/src/dotty/tools/languageserver/DottyLanguageServer.scala @@ -23,7 +23,7 @@ import Comments._, Contexts._, Flags._, Names._, NameOps._, Symbols._, SymDenota import classpath.ClassPathEntries import reporting._, reporting.diagnostic.{Message, MessageContainer, messages} import typer.Typer -import util._ +import util.{Set => _, _} import interactive._, interactive.InteractiveDriver._ import Interactive.Include import config.Printers.interactiv @@ -60,6 +60,8 @@ class DottyLanguageServer extends LanguageServer private[this] var myDrivers: mutable.Map[ProjectConfig, InteractiveDriver] = _ + private[this] var myDependentProjects: mutable.Map[ProjectConfig, mutable.Set[ProjectConfig]] = _ + def drivers: Map[ProjectConfig, InteractiveDriver] = thisServer.synchronized { if (myDrivers == null) { assert(rootUri != null, "`drivers` cannot be called before `initialize`") @@ -80,6 +82,7 @@ class DottyLanguageServer extends LanguageServer val settings = defaultFlags ++ config.compilerArguments.toList + .update("-d", config.classDirectory.getAbsolutePath) .update("-classpath", (config.classDirectory +: config.dependencyClasspath).mkString(File.pathSeparator)) .update("-sourcepath", config.sourceDirectories.mkString(File.pathSeparator)) :+ "-scansource" @@ -106,21 +109,44 @@ class DottyLanguageServer extends LanguageServer private def checkMemory() = if (Memory.isCritical()) CompletableFutures.computeAsync { _ => restart() } - /** The driver instance responsible for compiling `uri` */ - def driverFor(uri: URI): InteractiveDriver = thisServer.synchronized { - val matchingConfig = + /** The configuration of the project that owns `uri`. */ + def configFor(uri: URI): ProjectConfig = thisServer.synchronized { + val config = drivers.keys.find(config => config.sourceDirectories.exists(sourceDir => new File(uri.getPath).getCanonicalPath.startsWith(sourceDir.getCanonicalPath))) - matchingConfig match { - case Some(config) => - drivers(config) - case None => - val config = drivers.keys.head - // println(s"No configuration contains $uri as a source file, arbitrarily choosing ${config.id}") - drivers(config) + + config.getOrElse { + val config = drivers.keys.head + // println(s"No configuration contains $uri as a source file, arbitrarily choosing ${config.id}") + config } } + /** The driver instance responsible for compiling `uri` */ + def driverFor(uri: URI): InteractiveDriver = { + drivers(configFor(uri)) + } + + /** A mapping from project `p` to the set of projects that transitively depend on `p`. */ + def dependentProjects: Map[ProjectConfig, Set[ProjectConfig]] = thisServer.synchronized { + if (myDependentProjects == null) { + val idToConfig = drivers.keys.map(k => k.id -> k).toMap + val allProjects = drivers.keySet + + def transitiveDependencies(config: ProjectConfig): Set[ProjectConfig] = { + val dependencies = config.projectDependencies.map(idToConfig).toSet + dependencies ++ dependencies.flatMap(transitiveDependencies) + } + + myDependentProjects = new mutable.HashMap().withDefaultValue(mutable.Set.empty) + for { project <- allProjects + dependency <- transitiveDependencies(project) } { + myDependentProjects(dependency) += project + } + } + myDependentProjects + } + def connect(client: WorksheetClient): Unit = { myClient = client } @@ -279,24 +305,56 @@ class DottyLanguageServer extends LanguageServer override def references(params: ReferenceParams) = computeAsync { cancelToken => val uri = new URI(params.getTextDocument.getUri) val driver = driverFor(uri) - implicit val ctx = driver.currentCtx + + val includes = { + val includeDeclaration = params.getContext.isIncludeDeclaration + Include.references | Include.overriding | (if (includeDeclaration) Include.definitions else 0) + } val pos = sourcePosition(driver, uri, params.getPosition) - val sym = Interactive.enclosingSourceSymbol(driver.openedTrees(uri), pos) - if (sym == NoSymbol) Nil.asJava - else { - // FIXME: this will search for references in all trees on the classpath, but we really - // only need to look for trees in the target directory if the symbol is defined in the - // current project - val trees = driver.allTreesContaining(sym.name.sourceModuleName.toString) - val includeDeclaration = params.getContext.isIncludeDeclaration - val includes = - Include.references | Include.overriding | (if (includeDeclaration) Include.definitions else 0) - val refs = Interactive.findTreesMatching(trees, includes, sym) + val (definitions, projectsToInspect, originalSymbol, originalSymbolName) = { + implicit val ctx: Context = driver.currentCtx + val path = Interactive.pathTo(driver.openedTrees(uri), pos) + val originalSymbol = Interactive.enclosingSourceSymbol(path) + val originalSymbolName = originalSymbol.name.sourceModuleName.toString + + // Find definitions of the symbol under the cursor, so that we can determine + // what projects are worth exploring + val definitions = Interactive.findDefinitions(path, driver) + val projectsToInspect = + if (definitions.isEmpty) { + drivers.keySet + } else { + for { + definition <- definitions + uri <- toUriOption(definition.pos.source).toSet + config = configFor(uri) + project <- dependentProjects(config) + config + } yield project + } - refs.flatMap(ref => location(ref.namePos, positionMapperFor(ref.source))).asJava + (definitions, projectsToInspect, originalSymbol, originalSymbolName) } + + val references = { + // Collect the information necessary to look into each project separately: representation of + // `originalSymbol` in this project, the context and correct Driver. + val perProjectInfo = projectsToInspect.toList.map { config => + val remoteDriver = drivers(config) + val ctx = remoteDriver.currentCtx + val definition = Interactive.localize(originalSymbol, driver, remoteDriver) + (remoteDriver, ctx, definition) + } + + perProjectInfo.flatMap { (remoteDriver, ctx, definition) => + val trees = remoteDriver.sourceTreesContaining(originalSymbolName)(ctx) + val matches = Interactive.findTreesMatching(trees, includes, definition)(ctx) + matches.map(tree => location(tree.namePos(ctx), positionMapperFor(tree.source))) + } + }.toList + + references.flatten.asJava } override def rename(params: RenameParams) = computeAsync { cancelToken => diff --git a/language-server/src/dotty/tools/languageserver/config/ProjectConfig.java b/language-server/src/dotty/tools/languageserver/config/ProjectConfig.java index 7d8499b2d2ea..e1f55554dc42 100644 --- a/language-server/src/dotty/tools/languageserver/config/ProjectConfig.java +++ b/language-server/src/dotty/tools/languageserver/config/ProjectConfig.java @@ -11,6 +11,7 @@ public class ProjectConfig { public final File[] sourceDirectories; public final File[] dependencyClasspath; public final File classDirectory; + public final String[] projectDependencies; @JsonCreator public ProjectConfig( @@ -19,12 +20,14 @@ public ProjectConfig( @JsonProperty("compilerArguments") String[] compilerArguments, @JsonProperty("sourceDirectories") File[] sourceDirectories, @JsonProperty("dependencyClasspath") File[] dependencyClasspath, - @JsonProperty("classDirectory") File classDirectory) { + @JsonProperty("classDirectory") File classDirectory, + @JsonProperty("projectDependencies") String[] projectDependencies) { this.id = id; this.compilerVersion = compilerVersion; this.compilerArguments = compilerArguments; this.sourceDirectories = sourceDirectories; this.dependencyClasspath = dependencyClasspath; - this.classDirectory =classDirectory; + this.classDirectory = classDirectory; + this.projectDependencies = projectDependencies; } } diff --git a/language-server/test/dotty/tools/languageserver/ReferencesTest.scala b/language-server/test/dotty/tools/languageserver/ReferencesTest.scala index 67adfc232f80..9b2425582157 100644 --- a/language-server/test/dotty/tools/languageserver/ReferencesTest.scala +++ b/language-server/test/dotty/tools/languageserver/ReferencesTest.scala @@ -59,4 +59,162 @@ class ReferencesTest { .references(m3 to m4, List(m3 to m4), withDecl = false) } + @Test def valReferencesInDifferentProject: Unit = { + val p0 = Project.withSources( + code"""object A { val ${m1}x${m2} = 1 }""" + ) + + val p1 = Project.dependingOn(p0).withSources( + code"""object B { A.${m3}x${m4} }""" + ) + + val p2 = Project.dependingOn(p0).withSources( + code"""object C { A.${m5}x${m6} }""" + ) + + withProjects(p0, p1, p2) + .references(m1 to m2, List(m1 to m2, m3 to m4, m5 to m6), withDecl = true) + .references(m1 to m2, List(m3 to m4, m5 to m6), withDecl = false) + .references(m3 to m4, List(m1 to m2, m3 to m4, m5 to m6), withDecl = true) + .references(m3 to m4, List(m3 to m4, m5 to m6), withDecl = false) + .references(m5 to m6, List(m1 to m2, m3 to m4, m5 to m6), withDecl = true) + .references(m5 to m6, List(m3 to m4, m5 to m6), withDecl = false) + } + + @Test def valReferencesInDifferentProjectNoDef: Unit = { + val p0 = Project.withSources( + code"""object A { new java.util.${m1}ArrayList${m2}[Int] }""" + ) + + val p1 = Project.withSources( + code"""object B { new java.util.${m3}ArrayList${m4}[Int] }""" + ) + + val p2 = Project.withSources( + code"""object C { new java.util.${m5}ArrayList${m6}[Int] }""" + ) + + withProjects(p0, p1, p2) + .references(m1 to m2, List(m1 to m2, m3 to m4, m5 to m6), withDecl = true) + .references(m1 to m2, List(m1 to m2, m3 to m4, m5 to m6), withDecl = false) + .references(m3 to m4, List(m1 to m2, m3 to m4, m5 to m6), withDecl = true) + .references(m3 to m4, List(m1 to m2, m3 to m4, m5 to m6), withDecl = false) + .references(m5 to m6, List(m1 to m2, m3 to m4, m5 to m6), withDecl = true) + .references(m5 to m6, List(m1 to m2, m3 to m4, m5 to m6), withDecl = false) + } + + @Test def moduleReferencesInDifferentProject: Unit = { + val p0 = Project.withSources( + code"""object ${m1}A${m2}""" + ) + + val p1 = Project.dependingOn(p0).withSources( + code"""class B { ${m3}A${m4} }""" + ) + + withProjects(p0, p1) + .references(m1 to m2, List(m1 to m2, m3 to m4), withDecl = true) + .references(m1 to m2, List(m3 to m4), withDecl = false) + .references(m3 to m4, List(m1 to m2, m3 to m4), withDecl = true) + .references(m3 to m4, List(m3 to m4), withDecl = false) + } + + @Test def classReferencesInDifferentProject: Unit = { + val p0 = Project.withSources( + code"""class ${m1}A${m2}""" + ) + + val p1 = Project.dependingOn(p0).withSources( + code"""class B extends ${m3}A${m4}""" + ) + + val p2 = Project.dependingOn(p0).withSources( + code"""class C { new ${m5}A${m6} }""" + ) + + withProjects(p0, p1, p2) + .references(m1 to m2, List(m1 to m2, m3 to m4, m5 to m6), withDecl = true) + .references(m1 to m2, List(m3 to m4, m5 to m6), withDecl = false) + .references(m3 to m4, List(m1 to m2, m3 to m4, m5 to m6), withDecl = true) + .references(m3 to m4, List(m3 to m4, m5 to m6), withDecl = false) + .references(m5 to m6, List(m1 to m2, m3 to m4, m5 to m6), withDecl = true) + .references(m5 to m6, List(m3 to m4, m5 to m6), withDecl = false) + } + + @Test def defReferencesInDifferentProject: Unit = { + val p0 = Project.withSources( + code"""object A { def ${m1}x${m2} = 1 }""" + ) + + val p1 = Project.dependingOn(p0).withSources( + code"""object B { A.${m3}x${m4} }""" + ) + + val p2 = Project.dependingOn(p0).withSources( + code"""object C { A.${m5}x${m6} }""" + ) + + withProjects(p0, p1, p2) + .references(m1 to m2, List(m1 to m2, m3 to m4, m5 to m6), withDecl = true) + .references(m1 to m2, List(m3 to m4, m5 to m6), withDecl = false) + .references(m3 to m4, List(m1 to m2, m3 to m4, m5 to m6), withDecl = true) + .references(m3 to m4, List(m3 to m4, m5 to m6), withDecl = false) + .references(m5 to m6, List(m1 to m2, m3 to m4, m5 to m6), withDecl = true) + .references(m5 to m6, List(m3 to m4, m5 to m6), withDecl = false) + } + + @Test def deeplyNestedValReferencesInDifferentProject: Unit = { + val p0 = Project.withSources( + code"""class A { class Z { class Y { class X { val ${m1}x${m2} = 1 } } } }""" + ) + + val p1 = Project.dependingOn(p0).withSources( + code"""class B { + val a = new A() + val z = new a.Z() + val y = new z.Y() + val x = new y.X() + x.${m3}x${m4} + }""" + ) + + withProjects(p0, p1) + .references(m1 to m2, List(m1 to m2, m3 to m4), withDecl = true) + .references(m1 to m2, List(m3 to m4), withDecl = false) + .references(m3 to m4, List(m1 to m2, m3 to m4), withDecl = true) + .references(m3 to m4, List(m3 to m4), withDecl = false) + } + + @Test def deeplyNestedStaticValReferencesInDifferentProject: Unit = { + val p0 = Project.withSources( + code"""object A { object Z { object Y { object X { val ${m1}x${m2} = 1 } } } }""" + ) + + val p1 = Project.dependingOn(p0).withSources( + code"""object B { A.Z.Y.X.${m3}x${m4} }""" + ) + + withProjects(p0, p1) + .references(m1 to m2, List(m1 to m2, m3 to m4), withDecl = true) + .references(m1 to m2, List(m3 to m4), withDecl = false) + .references(m3 to m4, List(m1 to m2, m3 to m4), withDecl = true) + .references(m3 to m4, List(m3 to m4), withDecl = false) + } + + @Test def findReferencesInUntouchedProject: Unit = { + val p0 = Project.withSources( + code"""package hello + object A { def ${m1}foo${m2} = 1 }""" + ) + + val p1 = Project.dependingOn(p0).withSources( + tasty"""package hello + object B { def bar = A.${m3}foo${m4} }""" + ) + + withProjects(p0, p1) + .references(m1 to m2, List(m1 to m2, m3 to m4), withDecl = true) + .references(m1 to m2, List(m3 to m4), withDecl = false) + } + } diff --git a/language-server/test/dotty/tools/languageserver/util/actions/CodeReferences.scala b/language-server/test/dotty/tools/languageserver/util/actions/CodeReferences.scala index 758a2b0e43fc..8bd213e24dd7 100644 --- a/language-server/test/dotty/tools/languageserver/util/actions/CodeReferences.scala +++ b/language-server/test/dotty/tools/languageserver/util/actions/CodeReferences.scala @@ -5,6 +5,8 @@ import dotty.tools.languageserver.util.{CodeRange, PositionContext} import org.junit.Assert.assertEquals +import org.eclipse.lsp4j.Location + import scala.collection.JavaConverters._ /** @@ -19,9 +21,11 @@ class CodeReferences(override val range: CodeRange, expected: List[CodeRange], withDecl: Boolean) extends ActionOnRange { + private implicit val LocationOrdering: Ordering[Location] = Ordering.by(_.toString) + override def onMarker(marker: CodeMarker): Exec[Unit] = { - val expectedLocations = expected.map(_.toLocation) - val results = server.references(marker.toReferenceParams(withDecl)).get().asScala + val expectedLocations = expected.map(_.toLocation).sorted + val results = server.references(marker.toReferenceParams(withDecl)).get().asScala.sorted assertEquals(expectedLocations, results) } diff --git a/language-server/test/dotty/tools/languageserver/util/server/TestServer.scala b/language-server/test/dotty/tools/languageserver/util/server/TestServer.scala index b8857a7c9241..25a8ca417d4e 100644 --- a/language-server/test/dotty/tools/languageserver/util/server/TestServer.scala +++ b/language-server/test/dotty/tools/languageserver/util/server/TestServer.scala @@ -59,7 +59,8 @@ class TestServer(testFolder: Path, projects: List[Project]) { | "compilerArguments" : ${showSeq(BuildInfo.ideTestsCompilerArguments)}, | "sourceDirectories" : ${showSeq(sourceDirectory(project, wipe = false) :: Nil)}, | "dependencyClasspath" : ${showSeq(dependencyClasspath(project))}, - | "classDirectory" : "${classDirectory(project, wipe = false).toString.replace('\\','/')}" + | "classDirectory" : "${classDirectory(project, wipe = false).toString.replace('\\','/')}", + | "projectDependencies": ${showSeq(project.dependsOn.map(_.name))} |} |""".stripMargin } @@ -106,8 +107,8 @@ class TestServer(testFolder: Path, projects: List[Project]) { val path = testFolder.resolve(project.name).resolve("out") if (wipe) { Directory(path).deleteRecursively() - Files.createDirectories(path) } + Files.createDirectories(path) path.toAbsolutePath } diff --git a/sbt-dotty/src/dotty/tools/sbtplugin/DottyIDEPlugin.scala b/sbt-dotty/src/dotty/tools/sbtplugin/DottyIDEPlugin.scala index cfd029fb8ca2..4f2610362a7e 100644 --- a/sbt-dotty/src/dotty/tools/sbtplugin/DottyIDEPlugin.scala +++ b/sbt-dotty/src/dotty/tools/sbtplugin/DottyIDEPlugin.scala @@ -269,14 +269,21 @@ object DottyIDEPlugin extends AutoPlugin { Command.process("runCode", state1) } + private def makeId(name: String, config: String): String = s"$name/$config" + private def projectConfigTask(config: Configuration): Initialize[Task[Option[ProjectConfig]]] = Def.taskDyn { val depClasspath = Attributed.data((dependencyClasspath in config).value) + val projectName = name.value // Try to detect if this is a real Scala project or not. This is pretty // fragile because sbt simply does not keep track of this information. We // could check if at least one source file ends with ".scala" but that // doesn't work for empty projects. - val isScalaProject = depClasspath.exists(_.getAbsolutePath.contains("dotty-library")) && depClasspath.exists(_.getAbsolutePath.contains("scala-library")) + val isScalaProject = ( + // Our `dotty-library` project is a Scala project + (projectName.startsWith("dotty-library") || depClasspath.exists(_.getAbsolutePath.contains("dotty-library"))) + && depClasspath.exists(_.getAbsolutePath.contains("scala-library")) + ) if (!isScalaProject) Def.task { None } else Def.task { @@ -285,11 +292,34 @@ object DottyIDEPlugin extends AutoPlugin { // step. val _ = (compile in config).value - val id = s"${thisProject.value.id}/${config.name}" + val project = thisProject.value + val id = makeId(project.id, config.name) val compilerVersion = (scalaVersion in config).value val compilerArguments = (scalacOptions in config).value val sourceDirectories = (unmanagedSourceDirectories in config).value ++ (managedSourceDirectories in config).value val classDir = (classDirectory in config).value + val extracted = Project.extract(state.value) + val settings = extracted.structure.data + + val dependencies = { + val logger = streams.value.log + // Project dependencies come from classpath deps and also inter-project config deps + // We filter out dependencies that do not compile using Dotty + val classpathProjectDependencies = + project.dependencies.filter { d => + val version = scalaVersion.in(d.project).get(settings).get + isDottyVersion(version) + }.map(d => projectDependencyName(d, config, project, logger)) + val configDependencies = + eligibleDepsFromConfig(config).value.map(c => makeId(project.id, c.name)) + + // The distinct here is important to make sure that there are no repeated project deps + (classpathProjectDependencies ++ configDependencies).distinct.toList + } + + // For projects without sources, we need to create it. Otherwise `InteractiveDriver` + // complains that the target directory doesn't exist. + if (!classDir.exists) IO.createDirectory(classDir) Some(new ProjectConfig( id, @@ -297,7 +327,8 @@ object DottyIDEPlugin extends AutoPlugin { compilerArguments.toArray, sourceDirectories.toArray, depClasspath.toArray, - classDir + classDir, + dependencies.toArray )) } } @@ -338,4 +369,106 @@ object DottyIDEPlugin extends AutoPlugin { } ) ++ addCommandAlias("launchIDE", ";configureIDE;runCode") + + // Ported from Bloop + /** + * Detect the eligible configuration dependencies from a given configuration. + * + * A configuration is eligible if the project defines it and `compile` + * exists for it. Otherwise, the configuration dependency is ignored. + * + * This is required to prevent transitive configurations like `Runtime` from + * generating useless IDE configuration files and possibly incorrect project + * dependencies. For example, if we didn't do this then the dependencies of + * `IntegrationTest` would be `projectName/runtime` and `projectName/compile`, + * whereas the following logic will return only the configuration `Compile` + * so that the use site of this function can create the project dep + * `projectName/compile`. + */ + private def eligibleDepsFromConfig(config: Configuration): Def.Initialize[Task[List[Configuration]]] = { + Def.task { + def depsFromConfig(configuration: Configuration): List[Configuration] = { + configuration.extendsConfigs.toList match { + case config :: Nil if config.extendsConfigs.isEmpty => config :: Nil + case config :: Nil => config :: depsFromConfig(config) + case Nil => Nil + } + } + + val configs = depsFromConfig(config) + val activeProjectConfigs = thisProject.value.configurations.toSet + + val data = settingsData.value + val thisProjectRef = Keys.thisProjectRef.value + + val eligibleConfigs = activeProjectConfigs.filter { c => + val configKey = ConfigKey.configurationToKey(c) + // Consider only configurations where the `compile` key is defined + val eligibleKey = compile in (thisProjectRef, configKey) + eligibleKey.get(data) match { + case Some(t) => + // Sbt seems to return tasks for the extended configurations (looks like a big bug) + t.info.get(taskDefinitionKey) match { + // So we now make sure that the returned config key matches the original one + case Some(taskDef) => taskDef.scope.config.toOption.toList.contains(configKey) + case None => true + } + case None => false + } + } + + configs.filter(c => eligibleConfigs.contains(c)) + } + } + + /** + * Creates a project name from a classpath dependency and its configuration. + * + * This function uses internal sbt utils (`sbt.Classpaths`) to parse configuration + * dependencies like sbt does and extract them. This parsing only supports compile + * and test, any kind of other dependency will be assumed to be test and will be + * reported to the user. + * + * Ref https://www.scala-sbt.org/1.x/docs/Library-Management.html#Configurations. + */ + private def projectDependencyName( + dep: ClasspathDep[ProjectRef], + configuration: Configuration, + project: ResolvedProject, + logger: Logger + ): String = { + val ref = dep.project + dep.configuration match { + case Some(_) => + val mapping = sbt.Classpaths.mapped( + dep.configuration, + List("compile", "test"), + List("compile", "test"), + "compile", + "*->compile" + ) + + mapping(configuration.name) match { + case Nil => + makeId(ref.project, configuration.name) + case List(conf) if Compile.name == conf => + makeId(ref.project, Compile.name) + case List(conf) if Test.name == conf => + makeId(ref.project, Test.name) + case List(conf1, conf2) if Test.name == conf1 && Compile.name == conf2 => + makeId(ref.project, Test.name) + case List(conf1, conf2) if Compile.name == conf1 && Test.name == conf2 => + makeId(ref.project, Test.name) + case unknown => + val msg = + s"Unsupported dependency '${project.id}' -> '${ref.project}:${unknown.mkString(", ")}' is understood as '${ref.project}:test'." + logger.warn(msg) + makeId(ref.project, Test.name) + } + case None => + // If no configuration, default is `Compile` dependency (see scripted tests `cross-compile-test-configuration`) + makeId(ref.project, Compile.name) + } + } + }