Skip to content

Commit 6c70656

Browse files
committed
Setup SemanticDB tests.
This commit updates the SemanticDB tests to assert that the output from converting TASTy to SemanticDB is the same as the SemanticDB produced by the semanticdb-scalac compiler plugin for Scala 2.12.7. Test failures report a diff like this: ```diff Test dotty.semanticdb.Tests.testExample failed: java.lang.AssertionError: --- tasty +++ scala2 -class Example { - val a: String = "1" +class Example /*example/Example#*/ { + val a /*example/Example#a.*/ : String /*scala/Predef.String#*/ = "1" ``` The "tasty" output is currently identical to the original source file because the TASTy to SemanticDB converter doesn't do anything. Once the converter is updated to emit `SymbolOccurrence` (https://scalameta.org/docs/semanticdb/specification.html#symboloccurrence) then the "tasty" output will include inline comments like `class Example /* pkg/Example# */` that can be read as "the symbol `pkg/Example#` was resolved next to the identifier on the left". The spec for SemanticDB symbols can be found here: - Scala symbols: https://scalameta.org/docs/semanticdb/specification.html#scala-symbol - Java symbols: https://scalameta.org/docs/semanticdb/specification.html#java-symbol To run the tests you must first produce the SemanticDB files for the semanticdb/input project by running the following commands: ``` cd semanticdb/input sbt compile ``` It's possible to include the semanticdb/input project in the main Dotty build but I tried to limit the number of changes to the main build. We use the org.scalameta:semanticdb_2.12 dependency to construct SemanticDB data structures. If there is interest it's possible to replace that dependency with a pure-Java module. I don't estimate it's a lot of work, I have just never used the Java protobuf binding generator.
1 parent 7e5f0a2 commit 6c70656

File tree

9 files changed

+239
-19
lines changed

9 files changed

+239
-19
lines changed

project/Build.scala

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -381,10 +381,15 @@ object Build {
381381
dottyLib + File.pathSeparator + dottyInterfaces + File.pathSeparator + otherDeps
382382
}
383383

384-
lazy val semanticDBSettings = Seq(
384+
lazy val semanticdbSettings = Seq(
385385
baseDirectory in (Compile, run) := baseDirectory.value / "..",
386386
baseDirectory in Test := baseDirectory.value / "..",
387-
libraryDependencies += "com.novocode" % "junit-interface" % "0.11" % Test
387+
unmanagedSourceDirectories in Test += baseDirectory.value / "input" / "src" / "main" / "scala",
388+
libraryDependencies ++= List(
389+
("org.scalameta" %% "semanticdb" % "4.0.0" % Test).withDottyCompat(scalaVersion.value),
390+
"com.novocode" % "junit-interface" % "0.11" % Test,
391+
"com.googlecode.java-diff-utils" % "diffutils" % "1.3.0" % Test
392+
)
388393
)
389394

390395
// Settings shared between dotty-doc and dotty-doc-bootstrapped
@@ -910,7 +915,7 @@ object Build {
910915
lazy val `dotty-bench` = project.in(file("bench")).asDottyBench(NonBootstrapped)
911916
lazy val `dotty-bench-bootstrapped` = project.in(file("bench")).asDottyBench(Bootstrapped)
912917

913-
lazy val `dotty-semanticdb` = project.in(file("semanticdb")).asDottySemanticDB(Bootstrapped)
918+
lazy val `dotty-semanticdb` = project.in(file("semanticdb")).asDottySemanticdb(Bootstrapped)
914919

915920
// Depend on dotty-library so that sbt projects using dotty automatically
916921
// depend on the dotty-library
@@ -1305,9 +1310,9 @@ object Build {
13051310
settings(commonBenchmarkSettings).
13061311
enablePlugins(JmhPlugin)
13071312

1308-
def asDottySemanticDB(implicit mode: Mode): Project = project.withCommonSettings.
1313+
def asDottySemanticdb(implicit mode: Mode): Project = project.withCommonSettings.
13091314
dependsOn(dottyCompiler).
1310-
settings(semanticDBSettings)
1315+
settings(semanticdbSettings)
13111316

13121317
def asDist(implicit mode: Mode): Project = project.
13131318
enablePlugins(PackPlugin).

semanticdb/input/build.sbt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
scalaVersion := "2.12.7"
2+
scalacOptions += "-Yrangepos"
3+
addCompilerPlugin("org.scalameta" % "semanticdb-scalac" % "4.0.0" cross CrossVersion.full)
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
sbt.version=1.2.3
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package example
2+
3+
class Example {
4+
val a: String = "1"
5+
def a(
6+
x: Int
7+
): String =
8+
x.toString
9+
def a(
10+
x: Int,
11+
y: Int
12+
): String =
13+
a(
14+
x +
15+
y
16+
)
17+
}

semanticdb/src/dotty/semanticdb/Main.scala

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ object Main {
1212
println("Dotty Semantic DB: No classes where passed as argument")
1313
} else {
1414
println("Running Dotty Semantic DB on: " + args.mkString(" "))
15-
ConsumeTasty(extraClasspath, classes, new DBConsumer)
15+
ConsumeTasty(extraClasspath, classes, new SemanticdbConsumer)
1616
}
1717
}
1818
}

semanticdb/src/dotty/semanticdb/DBConsumer.scala renamed to semanticdb/src/dotty/semanticdb/SemanticdbConsumer.scala

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import scala.tasty.Tasty
44
import scala.tasty.file.TastyConsumer
55
import scala.tasty.util.TreeTraverser
66

7-
class DBConsumer extends TastyConsumer {
7+
class SemanticdbConsumer extends TastyConsumer {
88

99
final def apply(tasty: Tasty)(root: tasty.Tree): Unit = {
1010
import tasty._
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package dotty.semanticdb
2+
3+
import java.nio.ByteBuffer
4+
import java.nio.charset.StandardCharsets
5+
import java.security.MessageDigest
6+
7+
object MD5 {
8+
/** Returns the MD5 finger print for this string */
9+
def compute(string: String): String = {
10+
compute(ByteBuffer.wrap(string.getBytes(StandardCharsets.UTF_8)))
11+
}
12+
def compute(buffer: ByteBuffer): String = {
13+
val md = MessageDigest.getInstance("MD5")
14+
md.update(buffer)
15+
bytesToHex(md.digest())
16+
}
17+
private val hexArray = "0123456789ABCDEF".toCharArray
18+
private def bytesToHex(bytes: Array[Byte]): String = {
19+
val hexChars = new Array[Char](bytes.length * 2)
20+
var j = 0
21+
while (j < bytes.length) {
22+
val v: Int = bytes(j) & 0xFF
23+
hexChars(j * 2) = hexArray(v >>> 4)
24+
hexChars(j * 2 + 1) = hexArray(v & 0x0F)
25+
j += 1
26+
}
27+
new String(hexChars)
28+
}
29+
}
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
package dotty.semanticdb
2+
3+
import java.nio.file._
4+
import java.nio.charset.StandardCharsets
5+
import scala.meta.internal.{semanticdb => s}
6+
import scala.collection.JavaConverters._
7+
import dotty.tools.dotc.util.SourceFile
8+
9+
object Semanticdbs {
10+
11+
/**
12+
* Utility to load SemanticDB for Scala source files.
13+
*
14+
* @param sourceroot The workspace root directory, by convention matches the directory of build.sbt
15+
* @param classpath The classpath for this project, can be a combination of jars and directories.
16+
* Matches the `fullClasspath` task key from sbt but can be only `classDirectory`
17+
* if you only care about reading SemanticDB files from a single project.
18+
*/
19+
class Loader(sourceroot: Path, classpath: List[Path]) {
20+
private val META_INF = Paths.get("META-INF", "semanticdb")
21+
private val classLoader = new java.net.URLClassLoader(classpath.map(_.toUri.toURL).toArray)
22+
/** Returns a SemanticDB for a single Scala source file, if any. The path must be absolute. */
23+
def resolve(scalaAbsolutePath: Path): Option[s.TextDocument] = {
24+
val scalaRelativePath = sourceroot.relativize(scalaAbsolutePath)
25+
val filename = scalaRelativePath.getFileName.toString
26+
val semanticdbRelativePath = scalaRelativePath.resolveSibling(filename + ".semanticdb")
27+
val metaInfPath = META_INF.resolve(semanticdbRelativePath).toString
28+
Option(classLoader.findResource(metaInfPath)).map { url =>
29+
val semanticdbAbsolutePath = Paths.get(url.toURI)
30+
Semanticdbs.loadTextDocument(scalaAbsolutePath, scalaRelativePath, semanticdbAbsolutePath)
31+
}
32+
}
33+
}
34+
35+
/** Load SemanticDB TextDocument for a single Scala source file
36+
*
37+
* @param scalaAbsolutePath Absolute path to a Scala source file.
38+
* @param scalaRelativePath scalaAbsolutePath relativized by the sourceroot.
39+
* @param semanticdbAbsolutePath Absolute path to the SemanticDB file.
40+
*/
41+
def loadTextDocument(
42+
scalaAbsolutePath: Path,
43+
scalaRelativePath: Path,
44+
semanticdbAbsolutePath: Path
45+
): s.TextDocument = {
46+
val reluri = scalaRelativePath.iterator.asScala.mkString("/")
47+
val sdocs = parseTextDocuments(semanticdbAbsolutePath)
48+
sdocs.documents.find(_.uri == reluri) match {
49+
case None => throw new NoSuchElementException(reluri)
50+
case Some(document) =>
51+
val text = new String(Files.readAllBytes(scalaAbsolutePath), StandardCharsets.UTF_8)
52+
// Assert the SemanticDB payload is in-sync with the contents of the Scala file on disk.
53+
val md5FingerprintOnDisk = MD5.compute(text)
54+
if (document.md5 != md5FingerprintOnDisk) {
55+
throw new IllegalArgumentException("stale semanticdb: " + reluri)
56+
} else {
57+
// Update text document to include full text contents of the file.
58+
document.withText(text)
59+
}
60+
}
61+
}
62+
63+
/** Parses SemanticDB text documents from an absolute path to a `*.semanticdb` file. */
64+
def parseTextDocuments(path: Path): s.TextDocuments = {
65+
// NOTE: a *.semanticdb file is of type s.TextDocuments, not s.TextDocument
66+
val in = Files.newInputStream(path)
67+
try s.TextDocuments.parseFrom(in)
68+
finally in.close()
69+
}
70+
71+
72+
/** Prettyprint a text document with symbol occurrences next to each resolved identifier.
73+
*
74+
* Useful for testing purposes to ensure that SymbolOccurrence values make sense and are correct.
75+
* Example output (NOTE, slightly modified to avoid "unclosed comment" errors):
76+
* {{{
77+
* class Example *example/Example#* {
78+
* val a *example/Example#a.* : String *scala/Predef.String#* = "1"
79+
* }
80+
* }}}
81+
**/
82+
def printTextDocument(doc: s.TextDocument): String = {
83+
val sb = new StringBuilder
84+
val occurrences = doc.occurrences.sorted
85+
val sourceFile = new SourceFile(doc.uri, doc.text)
86+
var offset = 0
87+
occurrences.foreach { occ =>
88+
val range = occ.range.get
89+
val end = sourceFile.lineToOffset(range.endLine) + range.endCharacter
90+
sb.append(doc.text.substring(offset, end))
91+
sb.append(" /* ")
92+
.append(occ.symbol)
93+
.append(" */ ")
94+
offset = end
95+
}
96+
sb.append(doc.text.substring(offset))
97+
sb.toString()
98+
}
99+
100+
/** Sort symbol occurrences by their start position. */
101+
implicit val occurrenceOrdering: Ordering[s.SymbolOccurrence] =
102+
new Ordering[s.SymbolOccurrence] {
103+
override def compare(x: s.SymbolOccurrence, y: s.SymbolOccurrence): Int = {
104+
if (x.range.isEmpty) 0
105+
else if (y.range.isEmpty) 0
106+
else {
107+
val a = x.range.get
108+
val b = y.range.get
109+
val byLine = Integer.compare(
110+
a.startLine,
111+
b.startLine
112+
)
113+
if (byLine != 0) {
114+
byLine
115+
} else {
116+
val byCharacter = Integer.compare(
117+
a.startCharacter,
118+
b.startCharacter
119+
)
120+
byCharacter
121+
}
122+
}
123+
}
124+
}
125+
}

semanticdb/test/dotty/semanticdb/Tests.scala

Lines changed: 52 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,26 +6,66 @@ import scala.tasty.file._
66

77
import org.junit.Test
88
import org.junit.Assert._
9+
import java.nio.file._
10+
import scala.meta.internal.{semanticdb => s}
11+
import scala.collection.JavaConverters._
912

1013
class Tests {
1114

1215
// TODO: update scala-0.10 on version change (or resolve automatically)
13-
final def testClasspath = "out/bootstrap/dotty-semanticdb/scala-0.10/test-classes"
14-
15-
@Test def testMain(): Unit = {
16-
testOutput(
17-
"tests.SimpleClass",
18-
"SimpleClass;<init>;"
19-
)
20-
testOutput(
21-
"tests.SimpleDef",
22-
"SimpleDef;<init>;foo;"
23-
)
16+
final def tastyClassDirectory = "out/bootstrap/dotty-semanticdb/scala-0.11/test-classes"
17+
val sourceroot = Paths.get("semanticdb", "input").toAbsolutePath
18+
val sourceDirectory = sourceroot.resolve("src/main/scala")
19+
20+
val semanticdbClassDirectory = sourceroot.resolve("target/scala-2.12/classes")
21+
val semanticdbLoader = new Semanticdbs.Loader(sourceroot, List(semanticdbClassDirectory))
22+
/** Returns the SemanticDB for this Scala source file. */
23+
def getScalacSemanticdb(scalaFile: Path): s.TextDocument = {
24+
semanticdbLoader.resolve(scalaFile).get
25+
}
26+
27+
/** TODO: Produce semanticdb from TASTy for this Scala source file. */
28+
def getTastySemanticdb(scalaFile: Path): s.TextDocument = {
29+
???
30+
}
31+
32+
/** Fails the test if the s.TextDocument from tasty and semanticdb-scalac are not the same. */
33+
def checkFile(filename: String): Unit = {
34+
val path = sourceDirectory.resolve(filename)
35+
val scalac = getScalacSemanticdb(path)
36+
val tasty = s.TextDocument(text = scalac.text) // TODO: replace with `getTastySemanticdb(path)`
37+
val obtained = Semanticdbs.printTextDocument(tasty)
38+
val expected = Semanticdbs.printTextDocument(scalac)
39+
assertNoDiff(obtained, expected)
2440
}
2541

42+
/** Fails the test with a pretty diff if there obtained is not the same as expected */
43+
def assertNoDiff(obtained: String, expected: String): Unit = {
44+
if (obtained.isEmpty && !expected.isEmpty) fail("obtained empty output")
45+
def splitLines(string: String): java.util.List[String] =
46+
string.trim.replace("\r\n", "\n").split("\n").toSeq.asJava
47+
val obtainedLines = splitLines(obtained)
48+
val b = splitLines(expected)
49+
val patch = difflib.DiffUtils.diff(obtainedLines, b)
50+
val diff =
51+
if (patch.getDeltas.isEmpty) ""
52+
else {
53+
difflib.DiffUtils.generateUnifiedDiff(
54+
"tasty", "scala2", obtainedLines, patch, 1
55+
).asScala.mkString("\n")
56+
}
57+
if (!diff.isEmpty) {
58+
fail("\n" + diff)
59+
}
60+
}
61+
62+
63+
@Test def testExample(): Unit = checkFile("example/Example.scala")
64+
// TODO: add more tests
65+
2666
def testOutput(className: String, expected: String): Unit = {
2767
val out = new StringBuilder
28-
ConsumeTasty(testClasspath, List(className), new DBConsumer {
68+
ConsumeTasty(tastyClassDirectory, List(className), new SemanticdbConsumer {
2969
override def println(x: Any): Unit = out.append(x).append(";")
3070
})
3171
assertEquals(expected, out.result())

0 commit comments

Comments
 (0)