diff --git a/language-server/test/dotty/tools/languageserver/DefinitionTest.scala b/language-server/test/dotty/tools/languageserver/DefinitionTest.scala index 6c11d6d68786..d83ad9792ca5 100644 --- a/language-server/test/dotty/tools/languageserver/DefinitionTest.scala +++ b/language-server/test/dotty/tools/languageserver/DefinitionTest.scala @@ -37,6 +37,25 @@ class DefinitionTest { .definition(m13 to m14, List(m3 to m4)) } + @Test def classDefinitionDifferentProject: Unit = { + val p0 = Project.withSources( + code"""class ${m1}Foo${m2}""" + ) + + val p1 = Project.dependingOn(p0).withSources( + code"""class Bar { new ${m3}Foo${m4} }""" + ) + + val p2 = Project.dependingOn(p1).withSources( + code"""class Baz extends ${m5}Foo${m6}""" + ) + + withProjects(p0, p1, p2) + .definition(m1 to m2, List(m1 to m2)) + .definition(m3 to m4, List(m1 to m2)) + .definition(m5 to m6, List(m1 to m2)) + } + @Test def valDefinition0: Unit = { withSources( code"class Foo { val ${m1}x$m2 = 0; ${m3}x$m4 }", @@ -189,4 +208,15 @@ class DefinitionTest { .definition(m9 to m10, List(m3 to m4)) } + @Test def definitionFromTasty: Unit = { + withSources( + tasty"""package mypackage + class ${m1}A${m2}""", + code"""package mypackage + object O { + new ${m3}A${m4} + }""" + ).definition(m3 to m4, List(m1 to m2)) + } + } diff --git a/language-server/test/dotty/tools/languageserver/util/Code.scala b/language-server/test/dotty/tools/languageserver/util/Code.scala index 9fe5009d5422..81edf2c322d9 100644 --- a/language-server/test/dotty/tools/languageserver/util/Code.scala +++ b/language-server/test/dotty/tools/languageserver/util/Code.scala @@ -60,6 +60,17 @@ object Code { WorksheetWithPositions(text, positions) } + /** + * An interpolator similar to `code`, but used for defining a source that will + * be unpickled from TASTY. + * + * @see code + */ + def tasty(args: Embedded*): TastyWithPositions = { + val (text, positions) = textAndPositions(args: _*) + TastyWithPositions(text, positions) + } + private def textAndPositions(args: Embedded*): (String, List[(CodeMarker, Int, Int)]) = { val pi = sc.parts.iterator val ai = args.iterator @@ -99,19 +110,25 @@ object Code { } } - /** A new `CodeTester` working with `sources` in the workspace. */ - def withSources(sources: SourceWithPositions*): CodeTester = new CodeTester(sources.toList, Nil) + /** A new `CodeTester` working with a single project containing `sources`. */ + def withSources(sources: SourceWithPositions*): CodeTester = withProjects(Project(sources.toList)) + + /** A new `CodeTester` working with `projects`. */ + def withProjects(projects: Project*): CodeTester = new CodeTester(projects.toList) sealed trait SourceWithPositions { - /** The code contained within the virtual source file. */ + /** A name for this source given its index. */ + def sourceName(index: Int): String + + /** The code contained within the virtual source file. */ def text: String /** The positions of the markers that have been set. */ def positions: List[(CodeMarker, Int, Int)] - /** A new `CodeTester` with only this source in the workspace. */ - def withSource: CodeTester = new CodeTester(this :: Nil, Nil) + /** A new `CodeTester` with only this source in the project. */ + def withSource: CodeTester = withSources(this) } @@ -121,7 +138,9 @@ object Code { * @param text The code contained within the virtual source file. * @param positions The positions of the markers that have been set. */ - case class ScalaSourceWithPositions(text: String, positions: List[(CodeMarker, Int, Int)]) extends SourceWithPositions + case class ScalaSourceWithPositions(text: String, positions: List[(CodeMarker, Int, Int)]) extends SourceWithPositions { + def sourceName(index: Int): String = s"Source$index.scala" + } /** * A virtual worksheet where several markers have been set. @@ -129,6 +148,57 @@ object Code { * @param text The code contained within the virtual source file. * @param positions The positions of the markers that have been set. */ - case class WorksheetWithPositions(text: String, positions: List[(CodeMarker, Int, Int)]) extends SourceWithPositions + case class WorksheetWithPositions(text: String, positions: List[(CodeMarker, Int, Int)]) extends SourceWithPositions { + def sourceName(index: Int): String = s"Worksheet$index.sc" + } + + /** + * A virtual source file that will not be opened in the IDE, but instead unpickled from TASTY. + * + * @param text The code contained within the virtual source file. + * @param positions The positions of the markers that have been set. + */ + case class TastyWithPositions(text: String, positions: List[(CodeMarker, Int, Int)]) extends SourceWithPositions { + def sourceName(index: Int): String = s"Source-from-tasty-$index.scala" + } + + /** + * A group of sources belonging to the same project. + * + * @param sources The sources that this project holds. + * @param name The name of this project + * @param dependsOn The other projects on which this project depend. + */ + case class Project(sources: List[SourceWithPositions], + name: String = Project.freshName, + dependsOn: List[Project] = Nil) { + + /** + * Add `sources` to the sources of this project. + */ + def withSources(sources: SourceWithPositions*): Project = copy(sources = this.sources ::: sources.toList) + + } + + object Project { + private[this] val count = new java.util.concurrent.atomic.AtomicInteger() + private def freshName: String = s"project${count.incrementAndGet()}" + + /** + * Creates a new project that depends on `projects`. + * + * @param projects The dependencies of the new project. + * @return An empty project with a dependency on the specified projects. + */ + def dependingOn(projects: Project*) = new Project(Nil, dependsOn = projects.toList) + + /** + * Create a new project with the given sources. + * + * @param sources The sources to add to this project. + * @return a new project containing the specified sources. + */ + def withSources(sources: SourceWithPositions*): Project = new Project(sources.toList) + } } diff --git a/language-server/test/dotty/tools/languageserver/util/CodeTester.scala b/language-server/test/dotty/tools/languageserver/util/CodeTester.scala index b9c91a0853d0..257208e98c9c 100644 --- a/language-server/test/dotty/tools/languageserver/util/CodeTester.scala +++ b/language-server/test/dotty/tools/languageserver/util/CodeTester.scala @@ -7,19 +7,24 @@ import dotty.tools.languageserver.util.server.{TestFile, TestServer} import org.eclipse.lsp4j.{CompletionItemKind, DocumentHighlightKind} /** - * Simulates an LSP client for test in a workspace defined by `sources`. + * Simulates an LSP client for test in a project defined by `sources`. * - * @param sources The list of sources in the workspace - * @param actions Unused + * @param sources The list of sources in the project */ -class CodeTester(sources: List[SourceWithPositions], actions: List[Action]) { +class CodeTester(projects: List[Project]) { - private val testServer = new TestServer(TestFile.testDir) + private val testServer = new TestServer(TestFile.testDir, projects) + + private val sources = for { project <- projects + source <- project.sources } yield (project, source) + + private val files = + for { project <- projects + (source, id) <- project.sources.zipWithIndex } yield source match { + case src @ TastyWithPositions(text, _) => testServer.openCode(text, project, src.sourceName(id), openInIDE = false) + case other => testServer.openCode(other.text, project, other.sourceName(id), openInIDE = true) + } - private val files = sources.zipWithIndex.map { - case (ScalaSourceWithPositions(text, _), i) => testServer.openCode(text, s"Source$i.scala") - case (WorksheetWithPositions(text, _), i) => testServer.openCode(text, s"Worksheet$i.sc") - } private val positions: PositionContext = getPositions(files) /** @@ -158,7 +163,13 @@ class CodeTester(sources: List[SourceWithPositions], actions: List[Action]) { action.execute()(testServer, testServer.client, positions) } catch { case ex: AssertionError => - val sourcesStr = sources.zip(files).map{ case (source, file) => "// " + file.file + "\n" + source.text}.mkString("\n") + val sourcesStr = + sources.zip(files).map { + case ((project, source), file) => + s"""// ${file.file} in project ${project.name} + |${source.text}""".stripMargin + }.mkString(System.lineSeparator) + val msg = s""" | @@ -177,7 +188,7 @@ class CodeTester(sources: List[SourceWithPositions], actions: List[Action]) { private def getPositions(files: List[TestFile]): PositionContext = { val posSeq = { for { - (code, file) <- sources.zip(files) + ((_, code), file) <- sources.zip(files) (position, line, char) <- code.positions } yield position -> (file, line, char) } 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 6859b9e2d777..b8857a7c9241 100644 --- a/language-server/test/dotty/tools/languageserver/util/server/TestServer.scala +++ b/language-server/test/dotty/tools/languageserver/util/server/TestServer.scala @@ -1,14 +1,19 @@ package dotty.tools.languageserver.util.server import java.io.PrintWriter +import java.io.File.{pathSeparator, separator} import java.net.URI -import java.nio.file.Path +import java.nio.file.{Files, Path} import java.util +import dotty.tools.dotc.Main +import dotty.tools.dotc.reporting.{Reporter, ThrowingReporter} +import dotty.tools.io.Directory import dotty.tools.languageserver.DottyLanguageServer +import dotty.tools.languageserver.util.Code.{TastyWithPositions, Project} import org.eclipse.lsp4j.{ DidOpenTextDocumentParams, InitializeParams, InitializeResult, TextDocumentItem} -class TestServer(testFolder: Path) { +class TestServer(testFolder: Path, projects: List[Project]) { val server = new DottyLanguageServer var client: TestClient = _ @@ -16,28 +21,55 @@ class TestServer(testFolder: Path) { init() private[this] def init(): InitializeResult = { - // Fill the configuration with values populated by sbt - def showSeq[T](lst: Seq[T]): String = - lst - .map(elem => '"' + elem.toString.replace('\\', '/') + '"') - .mkString("[ ", ", ", " ]") - val dottyIdeJson: String = - s"""[ { - | "id" : "dotty-ide-test", + var compiledProjects: Set[Project] = Set.empty + + /** Compile the dependencies of the given project, and then the project. */ + def compileProjectAndDependencies(project: Project): Unit = + if (!compiledProjects.contains(project)) { + project.dependsOn.foreach(compileProjectAndDependencies) + compileProject(project) + compiledProjects += project + } + + /** + * Set up given project, return JSON config. + * + * If the project has dependencies, these dependencies are compiled. The classfiles of the + * dependent projects are put on the classpath of this project. + * + * @param project The project to configure. + * @return A JSON object representing the configuration for this project. + */ + def projectSetup(project: Project): String = { + def showSeq[T](lst: Seq[T]): String = + lst + .map(elem => '"' + elem.toString.replace('\\', '/') + '"') + .mkString("[ ", ", ", " ]") + + if (project.sources.exists(_.isInstanceOf[TastyWithPositions])) { + compileProjectAndDependencies(project) + } else { + // Compile all the dependencies of this project + project.dependsOn.foreach(compileProjectAndDependencies) + } + + s"""{ + | "id" : "${project.name}", | "compilerVersion" : "${BuildInfo.ideTestsCompilerVersion}", | "compilerArguments" : ${showSeq(BuildInfo.ideTestsCompilerArguments)}, - | "sourceDirectories" : ${showSeq(BuildInfo.ideTestsSourceDirectories)}, - | "dependencyClasspath" : ${showSeq(BuildInfo.ideTestsDependencyClasspath)}, - | "classDirectory" : "${BuildInfo.ideTestsClassDirectory.toString.replace('\\','/')}" + | "sourceDirectories" : ${showSeq(sourceDirectory(project, wipe = false) :: Nil)}, + | "dependencyClasspath" : ${showSeq(dependencyClasspath(project))}, + | "classDirectory" : "${classDirectory(project, wipe = false).toString.replace('\\','/')}" |} - |]""".stripMargin + |""".stripMargin + } + + Files.createDirectories(testFolder) val configFile = testFolder.resolve(DottyLanguageServer.IDE_CONFIG_FILE) - testFolder.toFile.mkdirs() - testFolder.resolve("src").toFile.mkdirs() - testFolder.resolve("out").toFile.mkdirs() + val configuration = projects.map(projectSetup).mkString("[", ",", "]") new PrintWriter(configFile.toString) { - write(dottyIdeJson) + write(configuration) close() } @@ -52,17 +84,71 @@ class TestServer(testFolder: Path) { /** Open the code in the given file and returns the file. * @param code code in file * @param fileName file path in the source directory + * @param openInIDE If true, send `textDocument/didOpen` to the server. * @return the file opened */ - def openCode(code: String, fileName: String): TestFile = { - val testFile = new TestFile(fileName) - val dotdp = new DidOpenTextDocumentParams() + def openCode(code: String, project: Project, fileName: String, openInIDE: Boolean): TestFile = { + val testFile = new TestFile(project.name + separator + fileName) val tdi = new TextDocumentItem() tdi.setUri(testFile.uri) tdi.setText(code) - dotdp.setTextDocument(tdi) - server.didOpen(dotdp) + + if (openInIDE) { + val dotdp = new DidOpenTextDocumentParams() + dotdp.setTextDocument(tdi) + server.didOpen(dotdp) + } + testFile } + private def classDirectory(project: Project, wipe: Boolean): Path = { + val path = testFolder.resolve(project.name).resolve("out") + if (wipe) { + Directory(path).deleteRecursively() + Files.createDirectories(path) + } + path.toAbsolutePath + } + + private def dependencyClasspath(project: Project): Seq[String] = { + BuildInfo.ideTestsDependencyClasspath.map(_.getAbsolutePath) ++ + project.dependsOn.flatMap { dep => + classDirectory(dep, wipe = false).toString +: dependencyClasspath(dep) + } + }.distinct + + private def sourceDirectory(project: Project, wipe: Boolean): Path = { + val path = TestFile.sourceDir.resolve(project.name).toAbsolutePath + if (wipe) { + Directory(path).deleteRecursively() + Files.createDirectories(path) + } + path + } + + /** + * Sets up the sources of the given project, creates the necessary directories + * and compile the sources. + * + * @param project The project to set up. + */ + private def compileProject(project: Project): Unit = { + val sourcesDir = sourceDirectory(project, wipe = true) + val sources = project.sources.zipWithIndex.map { case (src, id) => + val path = sourcesDir.resolve(src.sourceName(id)).toAbsolutePath + Files.write(path, src.text.getBytes("UTF-8")) + path.toString + } + + val compileOptions = + sources.toArray ++ + Array( + "-classpath", dependencyClasspath(project).mkString(pathSeparator), + "-d", classDirectory(project, wipe = true).toString + ) + val reporter = new ThrowingReporter(Reporter.NoReporter) + Main.process(compileOptions, reporter) + } + } diff --git a/project/Build.scala b/project/Build.scala index e5ddd91d0de4..807599da1f3f 100644 --- a/project/Build.scala +++ b/project/Build.scala @@ -103,9 +103,7 @@ object Build { // Settings used to configure the test language server lazy val ideTestsCompilerVersion = taskKey[String]("Compiler version to use in IDE tests") lazy val ideTestsCompilerArguments = taskKey[Seq[String]]("Compiler arguments to use in IDE tests") - lazy val ideTestsSourceDirectories = taskKey[Seq[File]]("Source directories to use in IDE tests") lazy val ideTestsDependencyClasspath = taskKey[Seq[File]]("Dependency classpath to use in IDE tests") - lazy val ideTestsClassDirectory = taskKey[File]("Class directory to use in IDE tests") // Settings shared by the build (scoped in ThisBuild). Used in build.sbt lazy val thisBuildSettings = Def.settings( @@ -883,7 +881,6 @@ object Build { settings( ideTestsCompilerVersion := (version in `dotty-compiler`).value, ideTestsCompilerArguments := (scalacOptions in `dotty-compiler`).value, - ideTestsSourceDirectories := Seq((baseDirectory in ThisBuild).value / "out" / "ide-tests" / "src"), ideTestsDependencyClasspath := { val dottyLib = (classDirectory in `dotty-library-bootstrapped` in Compile).value val scalaLib = @@ -894,13 +891,10 @@ object Build { .toList dottyLib :: scalaLib }, - ideTestsClassDirectory := (baseDirectory in ThisBuild).value / "out" / "ide-tests" / "out", buildInfoKeys in Test := Seq[BuildInfoKey]( ideTestsCompilerVersion, ideTestsCompilerArguments, - ideTestsSourceDirectories, - ideTestsDependencyClasspath, - ideTestsClassDirectory + ideTestsDependencyClasspath ), buildInfoPackage in Test := "dotty.tools.languageserver.util.server", BuildInfoPlugin.buildInfoScopedSettings(Test),