Skip to content

Commit 193e064

Browse files
authored
Merge pull request #5058 from dotty-staging/topic/test-ide-multi-project
Support multi-project setups in the IDE tests
2 parents 24331a9 + 82d37e5 commit 193e064

File tree

5 files changed

+239
-48
lines changed

5 files changed

+239
-48
lines changed

language-server/test/dotty/tools/languageserver/DefinitionTest.scala

+30
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,25 @@ class DefinitionTest {
3737
.definition(m13 to m14, List(m3 to m4))
3838
}
3939

40+
@Test def classDefinitionDifferentProject: Unit = {
41+
val p0 = Project.withSources(
42+
code"""class ${m1}Foo${m2}"""
43+
)
44+
45+
val p1 = Project.dependingOn(p0).withSources(
46+
code"""class Bar { new ${m3}Foo${m4} }"""
47+
)
48+
49+
val p2 = Project.dependingOn(p1).withSources(
50+
code"""class Baz extends ${m5}Foo${m6}"""
51+
)
52+
53+
withProjects(p0, p1, p2)
54+
.definition(m1 to m2, List(m1 to m2))
55+
.definition(m3 to m4, List(m1 to m2))
56+
.definition(m5 to m6, List(m1 to m2))
57+
}
58+
4059
@Test def valDefinition0: Unit = {
4160
withSources(
4261
code"class Foo { val ${m1}x$m2 = 0; ${m3}x$m4 }",
@@ -189,4 +208,15 @@ class DefinitionTest {
189208
.definition(m9 to m10, List(m3 to m4))
190209
}
191210

211+
@Test def definitionFromTasty: Unit = {
212+
withSources(
213+
tasty"""package mypackage
214+
class ${m1}A${m2}""",
215+
code"""package mypackage
216+
object O {
217+
new ${m3}A${m4}
218+
}"""
219+
).definition(m3 to m4, List(m1 to m2))
220+
}
221+
192222
}

language-server/test/dotty/tools/languageserver/util/Code.scala

+77-7
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,17 @@ object Code {
6060
WorksheetWithPositions(text, positions)
6161
}
6262

63+
/**
64+
* An interpolator similar to `code`, but used for defining a source that will
65+
* be unpickled from TASTY.
66+
*
67+
* @see code
68+
*/
69+
def tasty(args: Embedded*): TastyWithPositions = {
70+
val (text, positions) = textAndPositions(args: _*)
71+
TastyWithPositions(text, positions)
72+
}
73+
6374
private def textAndPositions(args: Embedded*): (String, List[(CodeMarker, Int, Int)]) = {
6475
val pi = sc.parts.iterator
6576
val ai = args.iterator
@@ -99,19 +110,25 @@ object Code {
99110
}
100111
}
101112

102-
/** A new `CodeTester` working with `sources` in the workspace. */
103-
def withSources(sources: SourceWithPositions*): CodeTester = new CodeTester(sources.toList, Nil)
113+
/** A new `CodeTester` working with a single project containing `sources`. */
114+
def withSources(sources: SourceWithPositions*): CodeTester = withProjects(Project(sources.toList))
115+
116+
/** A new `CodeTester` working with `projects`. */
117+
def withProjects(projects: Project*): CodeTester = new CodeTester(projects.toList)
104118

105119
sealed trait SourceWithPositions {
106120

107-
/** The code contained within the virtual source file. */
121+
/** A name for this source given its index. */
122+
def sourceName(index: Int): String
123+
124+
/** The code contained within the virtual source file. */
108125
def text: String
109126

110127
/** The positions of the markers that have been set. */
111128
def positions: List[(CodeMarker, Int, Int)]
112129

113-
/** A new `CodeTester` with only this source in the workspace. */
114-
def withSource: CodeTester = new CodeTester(this :: Nil, Nil)
130+
/** A new `CodeTester` with only this source in the project. */
131+
def withSource: CodeTester = withSources(this)
115132

116133
}
117134

@@ -121,14 +138,67 @@ object Code {
121138
* @param text The code contained within the virtual source file.
122139
* @param positions The positions of the markers that have been set.
123140
*/
124-
case class ScalaSourceWithPositions(text: String, positions: List[(CodeMarker, Int, Int)]) extends SourceWithPositions
141+
case class ScalaSourceWithPositions(text: String, positions: List[(CodeMarker, Int, Int)]) extends SourceWithPositions {
142+
def sourceName(index: Int): String = s"Source$index.scala"
143+
}
125144

126145
/**
127146
* A virtual worksheet where several markers have been set.
128147
*
129148
* @param text The code contained within the virtual source file.
130149
* @param positions The positions of the markers that have been set.
131150
*/
132-
case class WorksheetWithPositions(text: String, positions: List[(CodeMarker, Int, Int)]) extends SourceWithPositions
151+
case class WorksheetWithPositions(text: String, positions: List[(CodeMarker, Int, Int)]) extends SourceWithPositions {
152+
def sourceName(index: Int): String = s"Worksheet$index.sc"
153+
}
154+
155+
/**
156+
* A virtual source file that will not be opened in the IDE, but instead unpickled from TASTY.
157+
*
158+
* @param text The code contained within the virtual source file.
159+
* @param positions The positions of the markers that have been set.
160+
*/
161+
case class TastyWithPositions(text: String, positions: List[(CodeMarker, Int, Int)]) extends SourceWithPositions {
162+
def sourceName(index: Int): String = s"Source-from-tasty-$index.scala"
163+
}
164+
165+
/**
166+
* A group of sources belonging to the same project.
167+
*
168+
* @param sources The sources that this project holds.
169+
* @param name The name of this project
170+
* @param dependsOn The other projects on which this project depend.
171+
*/
172+
case class Project(sources: List[SourceWithPositions],
173+
name: String = Project.freshName,
174+
dependsOn: List[Project] = Nil) {
175+
176+
/**
177+
* Add `sources` to the sources of this project.
178+
*/
179+
def withSources(sources: SourceWithPositions*): Project = copy(sources = this.sources ::: sources.toList)
180+
181+
}
182+
183+
object Project {
184+
private[this] val count = new java.util.concurrent.atomic.AtomicInteger()
185+
private def freshName: String = s"project${count.incrementAndGet()}"
186+
187+
/**
188+
* Creates a new project that depends on `projects`.
189+
*
190+
* @param projects The dependencies of the new project.
191+
* @return An empty project with a dependency on the specified projects.
192+
*/
193+
def dependingOn(projects: Project*) = new Project(Nil, dependsOn = projects.toList)
194+
195+
/**
196+
* Create a new project with the given sources.
197+
*
198+
* @param sources The sources to add to this project.
199+
* @return a new project containing the specified sources.
200+
*/
201+
def withSources(sources: SourceWithPositions*): Project = new Project(sources.toList)
202+
}
133203

134204
}

language-server/test/dotty/tools/languageserver/util/CodeTester.scala

+22-11
Original file line numberDiff line numberDiff line change
@@ -7,19 +7,24 @@ import dotty.tools.languageserver.util.server.{TestFile, TestServer}
77
import org.eclipse.lsp4j.{CompletionItemKind, DocumentHighlightKind}
88

99
/**
10-
* Simulates an LSP client for test in a workspace defined by `sources`.
10+
* Simulates an LSP client for test in a project defined by `sources`.
1111
*
12-
* @param sources The list of sources in the workspace
13-
* @param actions Unused
12+
* @param sources The list of sources in the project
1413
*/
15-
class CodeTester(sources: List[SourceWithPositions], actions: List[Action]) {
14+
class CodeTester(projects: List[Project]) {
1615

17-
private val testServer = new TestServer(TestFile.testDir)
16+
private val testServer = new TestServer(TestFile.testDir, projects)
17+
18+
private val sources = for { project <- projects
19+
source <- project.sources } yield (project, source)
20+
21+
private val files =
22+
for { project <- projects
23+
(source, id) <- project.sources.zipWithIndex } yield source match {
24+
case src @ TastyWithPositions(text, _) => testServer.openCode(text, project, src.sourceName(id), openInIDE = false)
25+
case other => testServer.openCode(other.text, project, other.sourceName(id), openInIDE = true)
26+
}
1827

19-
private val files = sources.zipWithIndex.map {
20-
case (ScalaSourceWithPositions(text, _), i) => testServer.openCode(text, s"Source$i.scala")
21-
case (WorksheetWithPositions(text, _), i) => testServer.openCode(text, s"Worksheet$i.sc")
22-
}
2328
private val positions: PositionContext = getPositions(files)
2429

2530
/**
@@ -158,7 +163,13 @@ class CodeTester(sources: List[SourceWithPositions], actions: List[Action]) {
158163
action.execute()(testServer, testServer.client, positions)
159164
} catch {
160165
case ex: AssertionError =>
161-
val sourcesStr = sources.zip(files).map{ case (source, file) => "// " + file.file + "\n" + source.text}.mkString("\n")
166+
val sourcesStr =
167+
sources.zip(files).map {
168+
case ((project, source), file) =>
169+
s"""// ${file.file} in project ${project.name}
170+
|${source.text}""".stripMargin
171+
}.mkString(System.lineSeparator)
172+
162173
val msg =
163174
s"""
164175
|
@@ -177,7 +188,7 @@ class CodeTester(sources: List[SourceWithPositions], actions: List[Action]) {
177188
private def getPositions(files: List[TestFile]): PositionContext = {
178189
val posSeq = {
179190
for {
180-
(code, file) <- sources.zip(files)
191+
((_, code), file) <- sources.zip(files)
181192
(position, line, char) <- code.positions
182193
} yield position -> (file, line, char)
183194
}
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,75 @@
11
package dotty.tools.languageserver.util.server
22

33
import java.io.PrintWriter
4+
import java.io.File.{pathSeparator, separator}
45
import java.net.URI
5-
import java.nio.file.Path
6+
import java.nio.file.{Files, Path}
67
import java.util
78

9+
import dotty.tools.dotc.Main
10+
import dotty.tools.dotc.reporting.{Reporter, ThrowingReporter}
11+
import dotty.tools.io.Directory
812
import dotty.tools.languageserver.DottyLanguageServer
13+
import dotty.tools.languageserver.util.Code.{TastyWithPositions, Project}
914
import org.eclipse.lsp4j.{ DidOpenTextDocumentParams, InitializeParams, InitializeResult, TextDocumentItem}
1015

11-
class TestServer(testFolder: Path) {
16+
class TestServer(testFolder: Path, projects: List[Project]) {
1217

1318
val server = new DottyLanguageServer
1419
var client: TestClient = _
1520

1621
init()
1722

1823
private[this] def init(): InitializeResult = {
19-
// Fill the configuration with values populated by sbt
20-
def showSeq[T](lst: Seq[T]): String =
21-
lst
22-
.map(elem => '"' + elem.toString.replace('\\', '/') + '"')
23-
.mkString("[ ", ", ", " ]")
24-
val dottyIdeJson: String =
25-
s"""[ {
26-
| "id" : "dotty-ide-test",
24+
var compiledProjects: Set[Project] = Set.empty
25+
26+
/** Compile the dependencies of the given project, and then the project. */
27+
def compileProjectAndDependencies(project: Project): Unit =
28+
if (!compiledProjects.contains(project)) {
29+
project.dependsOn.foreach(compileProjectAndDependencies)
30+
compileProject(project)
31+
compiledProjects += project
32+
}
33+
34+
/**
35+
* Set up given project, return JSON config.
36+
*
37+
* If the project has dependencies, these dependencies are compiled. The classfiles of the
38+
* dependent projects are put on the classpath of this project.
39+
*
40+
* @param project The project to configure.
41+
* @return A JSON object representing the configuration for this project.
42+
*/
43+
def projectSetup(project: Project): String = {
44+
def showSeq[T](lst: Seq[T]): String =
45+
lst
46+
.map(elem => '"' + elem.toString.replace('\\', '/') + '"')
47+
.mkString("[ ", ", ", " ]")
48+
49+
if (project.sources.exists(_.isInstanceOf[TastyWithPositions])) {
50+
compileProjectAndDependencies(project)
51+
} else {
52+
// Compile all the dependencies of this project
53+
project.dependsOn.foreach(compileProjectAndDependencies)
54+
}
55+
56+
s"""{
57+
| "id" : "${project.name}",
2758
| "compilerVersion" : "${BuildInfo.ideTestsCompilerVersion}",
2859
| "compilerArguments" : ${showSeq(BuildInfo.ideTestsCompilerArguments)},
29-
| "sourceDirectories" : ${showSeq(BuildInfo.ideTestsSourceDirectories)},
30-
| "dependencyClasspath" : ${showSeq(BuildInfo.ideTestsDependencyClasspath)},
31-
| "classDirectory" : "${BuildInfo.ideTestsClassDirectory.toString.replace('\\','/')}"
60+
| "sourceDirectories" : ${showSeq(sourceDirectory(project, wipe = false) :: Nil)},
61+
| "dependencyClasspath" : ${showSeq(dependencyClasspath(project))},
62+
| "classDirectory" : "${classDirectory(project, wipe = false).toString.replace('\\','/')}"
3263
|}
33-
|]""".stripMargin
64+
|""".stripMargin
65+
}
66+
67+
Files.createDirectories(testFolder)
3468
val configFile = testFolder.resolve(DottyLanguageServer.IDE_CONFIG_FILE)
35-
testFolder.toFile.mkdirs()
36-
testFolder.resolve("src").toFile.mkdirs()
37-
testFolder.resolve("out").toFile.mkdirs()
69+
val configuration = projects.map(projectSetup).mkString("[", ",", "]")
3870

3971
new PrintWriter(configFile.toString) {
40-
write(dottyIdeJson)
72+
write(configuration)
4173
close()
4274
}
4375

@@ -52,17 +84,71 @@ class TestServer(testFolder: Path) {
5284
/** Open the code in the given file and returns the file.
5385
* @param code code in file
5486
* @param fileName file path in the source directory
87+
* @param openInIDE If true, send `textDocument/didOpen` to the server.
5588
* @return the file opened
5689
*/
57-
def openCode(code: String, fileName: String): TestFile = {
58-
val testFile = new TestFile(fileName)
59-
val dotdp = new DidOpenTextDocumentParams()
90+
def openCode(code: String, project: Project, fileName: String, openInIDE: Boolean): TestFile = {
91+
val testFile = new TestFile(project.name + separator + fileName)
6092
val tdi = new TextDocumentItem()
6193
tdi.setUri(testFile.uri)
6294
tdi.setText(code)
63-
dotdp.setTextDocument(tdi)
64-
server.didOpen(dotdp)
95+
96+
if (openInIDE) {
97+
val dotdp = new DidOpenTextDocumentParams()
98+
dotdp.setTextDocument(tdi)
99+
server.didOpen(dotdp)
100+
}
101+
65102
testFile
66103
}
67104

105+
private def classDirectory(project: Project, wipe: Boolean): Path = {
106+
val path = testFolder.resolve(project.name).resolve("out")
107+
if (wipe) {
108+
Directory(path).deleteRecursively()
109+
Files.createDirectories(path)
110+
}
111+
path.toAbsolutePath
112+
}
113+
114+
private def dependencyClasspath(project: Project): Seq[String] = {
115+
BuildInfo.ideTestsDependencyClasspath.map(_.getAbsolutePath) ++
116+
project.dependsOn.flatMap { dep =>
117+
classDirectory(dep, wipe = false).toString +: dependencyClasspath(dep)
118+
}
119+
}.distinct
120+
121+
private def sourceDirectory(project: Project, wipe: Boolean): Path = {
122+
val path = TestFile.sourceDir.resolve(project.name).toAbsolutePath
123+
if (wipe) {
124+
Directory(path).deleteRecursively()
125+
Files.createDirectories(path)
126+
}
127+
path
128+
}
129+
130+
/**
131+
* Sets up the sources of the given project, creates the necessary directories
132+
* and compile the sources.
133+
*
134+
* @param project The project to set up.
135+
*/
136+
private def compileProject(project: Project): Unit = {
137+
val sourcesDir = sourceDirectory(project, wipe = true)
138+
val sources = project.sources.zipWithIndex.map { case (src, id) =>
139+
val path = sourcesDir.resolve(src.sourceName(id)).toAbsolutePath
140+
Files.write(path, src.text.getBytes("UTF-8"))
141+
path.toString
142+
}
143+
144+
val compileOptions =
145+
sources.toArray ++
146+
Array(
147+
"-classpath", dependencyClasspath(project).mkString(pathSeparator),
148+
"-d", classDirectory(project, wipe = true).toString
149+
)
150+
val reporter = new ThrowingReporter(Reporter.NoReporter)
151+
Main.process(compileOptions, reporter)
152+
}
153+
68154
}

0 commit comments

Comments
 (0)