Skip to content

Commit a362b71

Browse files
Merge pull request #5253 from olafurpg/semanticdb
Setup SemanticDB tests
2 parents 3bba9e6 + 4215b15 commit a362b71

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)