Skip to content

Commit 32a6ea6

Browse files
authored
Make coverage support incremental compilation (#24773)
Port of scoverage/scalac-scoverage-plugin#742 for Scala 3.
1 parent 3c90d5e commit 32a6ea6

File tree

4 files changed

+165
-11
lines changed

4 files changed

+165
-11
lines changed

compiler/src/dotty/tools/dotc/coverage/Coverage.scala

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,16 +8,16 @@ import java.nio.file.Path
88
class Coverage:
99
private val statementsById = new mutable.LongMap[Statement](256)
1010

11-
private var statementId: Int = 0
12-
13-
def nextStatementId(): Int =
14-
statementId += 1
15-
statementId - 1
11+
private var _nextStatementId: Int = 1
1612

13+
def nextStatementId(): Int = _nextStatementId
14+
def setNextStatementId(id: Int): Unit = _nextStatementId = id
1715

1816
def statements: Iterable[Statement] = statementsById.values
1917

20-
def addStatement(stmt: Statement): Unit = statementsById(stmt.id) = stmt
18+
def addStatement(stmt: Statement): Unit =
19+
if stmt.id >= _nextStatementId then _nextStatementId = stmt.id + 1
20+
statementsById(stmt.id) = stmt
2121

2222
def removeStatementsFromFile(sourcePath: Path | Null) =
2323
val removedIds = statements.filter(_.location.sourcePath == sourcePath).map(_.id.toLong)

compiler/src/dotty/tools/dotc/coverage/Serializer.scala

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,27 @@
11
package dotty.tools.dotc
22
package coverage
33

4+
import java.nio.charset.StandardCharsets.UTF_8
45
import java.nio.file.{Path, Paths, Files}
56
import java.io.Writer
67
import scala.collection.mutable.StringBuilder
8+
import scala.io.Source
79

810
/**
911
* Serializes scoverage data.
10-
* @see https://github.com/scoverage/scalac-scoverage-plugin/blob/main/scalac-scoverage-plugin/src/main/scala/scoverage/Serializer.scala
12+
* @see https://github.com/scoverage/scalac-scoverage-plugin/blob/main/serializer/src/main/scala/scoverage/serialize/Serializer.scala
1113
*/
1214
object Serializer:
1315

1416
private val CoverageFileName = "scoverage.coverage"
1517
private val CoverageDataFormatVersion = "3.0"
1618

19+
def coverageFilePath(dataDir: String): Path =
20+
Paths.get(dataDir, CoverageFileName).toAbsolutePath
21+
1722
/** Write out coverage data to the given data directory, using the default coverage filename */
1823
def serialize(coverage: Coverage, dataDir: String, sourceRoot: String): Unit =
19-
serialize(coverage, Paths.get(dataDir, CoverageFileName).toAbsolutePath, Paths.get(sourceRoot).toAbsolutePath)
24+
serialize(coverage, coverageFilePath(dataDir), Paths.get(sourceRoot).toAbsolutePath)
2025

2126
/** Write out coverage data to a file. */
2227
def serialize(coverage: Coverage, file: Path, sourceRoot: Path): Unit =
@@ -85,6 +90,64 @@ object Serializer:
8590
.sortBy(_.id)
8691
.foreach(stmt => writeStatement(stmt, writer))
8792

93+
def deserialize(file: Path, sourceRoot: String): Coverage =
94+
val source = Source.fromFile(file.toFile(), UTF_8.name())
95+
try deserialize(source.getLines(), Paths.get(sourceRoot).toAbsolutePath)
96+
finally source.close()
97+
98+
def deserialize(lines: Iterator[String], sourceRoot: Path): Coverage =
99+
def toStatement(lines: Iterator[String]): Statement =
100+
val id: Int = lines.next().toInt
101+
val sourcePath = lines.next()
102+
val packageName = lines.next()
103+
val className = lines.next()
104+
val classType = lines.next()
105+
val fullClassName = lines.next()
106+
val method = lines.next()
107+
val loc = Location(
108+
packageName,
109+
className,
110+
fullClassName,
111+
classType,
112+
method,
113+
sourceRoot.resolve(sourcePath).normalize()
114+
)
115+
val start: Int = lines.next().toInt
116+
val end: Int = lines.next().toInt
117+
val lineNo: Int = lines.next().toInt
118+
val symbolName: String = lines.next()
119+
val treeName: String = lines.next()
120+
val branch: Boolean = lines.next().toBoolean
121+
val count: Int = lines.next().toInt
122+
val ignored: Boolean = lines.next().toBoolean
123+
val desc = lines.toList.mkString("\n")
124+
Statement(
125+
loc,
126+
id,
127+
start,
128+
end,
129+
lineNo,
130+
desc,
131+
symbolName,
132+
treeName,
133+
branch,
134+
ignored
135+
)
136+
137+
val headerFirstLine = lines.next()
138+
require(
139+
headerFirstLine == s"# Coverage data, format version: $CoverageDataFormatVersion",
140+
"Wrong file format"
141+
)
142+
143+
val linesWithoutHeader = lines.dropWhile(_.startsWith("#"))
144+
val coverage = Coverage()
145+
while !linesWithoutHeader.isEmpty do
146+
val oneStatementLines = linesWithoutHeader.takeWhile(_ != "\f")
147+
coverage.addStatement(toStatement(oneStatementLines))
148+
end while
149+
coverage
150+
88151
/** Makes a String suitable for output in the coverage statement data as a single line.
89152
* Escaped characters: '\\' (backslash), '\n', '\r', '\f'
90153
*/

compiler/src/dotty/tools/dotc/transform/InstrumentCoverage.scala

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package dotty.tools.dotc
22
package transform
33

44
import java.io.File
5+
import java.nio.file.Files
56

67
import ast.tpd.*
78
import collection.mutable
@@ -41,6 +42,7 @@ class InstrumentCoverage extends MacroTransform with IdentityDenotTransformer:
4142

4243
private var coverageExcludeClasslikePatterns: List[Pattern] = Nil
4344
private var coverageExcludeFilePatterns: List[Pattern] = Nil
45+
private var lastCompiledFiles: Set[String] = Set.empty
4446

4547
override def run(using ctx: Context): Unit =
4648
val outputPath = ctx.settings.coverageOutputDir.value
@@ -50,12 +52,18 @@ class InstrumentCoverage extends MacroTransform with IdentityDenotTransformer:
5052
val newlyCreated = dataDir.mkdirs()
5153

5254
if !newlyCreated then
53-
// If the directory existed before, let's clean it up.
55+
// If the directory existed before, clean measurement files.
5456
dataDir.listFiles
55-
.filter(_.getName.startsWith("scoverage"))
57+
.filter(_.getName.startsWith("scoverage.measurements."))
5658
.foreach(_.delete())
5759
end if
5860

61+
val coverageFilePath = Serializer.coverageFilePath(outputPath)
62+
val previousCoverage =
63+
if Files.exists(coverageFilePath) then
64+
Serializer.deserialize(coverageFilePath, ctx.settings.sourceroot.value)
65+
else Coverage()
66+
5967
// Initialise a coverage object if it does not exist yet
6068
if ctx.base.coverage == null then
6169
ctx.base.coverage = Coverage()
@@ -64,9 +72,23 @@ class InstrumentCoverage extends MacroTransform with IdentityDenotTransformer:
6472
coverageExcludeFilePatterns = ctx.settings.coverageExcludeFiles.value.map(_.r.pattern)
6573

6674
ctx.base.coverage.nn.removeStatementsFromFile(ctx.compilationUnit.source.file.absolute.jpath)
75+
ctx.base.coverage.nn.setNextStatementId(previousCoverage.nextStatementId())
76+
6777
super.run
6878

69-
Serializer.serialize(ctx.base.coverage.nn, outputPath, ctx.settings.sourceroot.value)
79+
val mergedCoverage = Coverage()
80+
81+
previousCoverage.statements
82+
.filterNot(stmt =>
83+
val source = stmt.location.sourcePath
84+
lastCompiledFiles.contains(source.toString) || !Files.exists(source)
85+
)
86+
.foreach { stmt =>
87+
mergedCoverage.addStatement(stmt)
88+
}
89+
ctx.base.coverage.nn.statements.foreach(stmt => mergedCoverage.addStatement(stmt))
90+
91+
Serializer.serialize(mergedCoverage, outputPath, ctx.settings.sourceroot.value)
7092

7193
private def isClassIncluded(sym: Symbol)(using Context): Boolean =
7294
val fqn = sym.fullName.toText(ctx.printerFn(ctx)).show
@@ -253,6 +275,9 @@ class InstrumentCoverage extends MacroTransform with IdentityDenotTransformer:
253275
InstrumentedParts.singleExprTree(coverageCall, transformed)
254276

255277
override def transform(tree: Tree)(using Context): Tree =
278+
val path = tree.sourcePos.source.file.absolute.jpath
279+
if path != null then lastCompiledFiles += path.toString
280+
256281
inContext(transformCtx(tree)) { // necessary to position inlined code properly
257282
tree match
258283
// simple cases

compiler/test/dotty/tools/dotc/coverage/CoverageTests.scala

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import scala.language.unsafeNulls
1818
import scala.collection.mutable.Buffer
1919
import dotty.tools.dotc.util.DiffUtil
2020

21+
import java.nio.charset.StandardCharsets
2122
import java.util.stream.Collectors
2223

2324
@Category(Array(classOf[BootstrappedOnlyTests]))
@@ -127,6 +128,71 @@ class CoverageTests:
127128
)
128129
}
129130

131+
@Test
132+
def checkIncrementalCoverage(): Unit =
133+
val target = Files.createTempDirectory("coverage")
134+
val sourceRoot = target.resolve("src")
135+
Files.createDirectory(sourceRoot)
136+
val sourceFile1 = sourceRoot.resolve("file1.scala")
137+
Files.write(sourceFile1, "def file1() = 1".getBytes(StandardCharsets.UTF_8))
138+
139+
val coverageOut = target.resolve("coverage-out")
140+
Files.createDirectory(coverageOut)
141+
val options = defaultOptions.and("-Ycheck:instrumentCoverage", "-coverage-out", coverageOut.toString, "-sourceroot", sourceRoot.toString)
142+
compileFile(sourceFile1.toString, options).checkCompile()
143+
144+
val scoverageFile = coverageOut.resolve("scoverage.coverage")
145+
assert(Files.exists(scoverageFile), s"Expected scoverage file to exist at $scoverageFile")
146+
147+
locally {
148+
val coverage = Serializer.deserialize(scoverageFile, sourceRoot.toString())
149+
val filesWithCoverage = coverage.statements.map(_.location.sourcePath.getFileName.toString).toSet
150+
assertEquals(Set("file1.scala"), filesWithCoverage)
151+
}
152+
153+
val sourceFile2 = sourceRoot.resolve("file2.scala")
154+
Files.write(sourceFile2, "def file2() = 2".getBytes(StandardCharsets.UTF_8))
155+
156+
compileFile(sourceFile2.toString, options).checkCompile()
157+
locally {
158+
val coverage = Serializer.deserialize(scoverageFile, sourceRoot.toString())
159+
val filesWithCoverage = coverage.statements.map(_.location.sourcePath.getFileName.toString).toSet
160+
assertEquals(Set("file1.scala", "file2.scala"), filesWithCoverage)
161+
}
162+
163+
@Test
164+
def `deleted source files should not be kept in incremental coverage`(): Unit =
165+
val target = Files.createTempDirectory("coverage")
166+
val sourceRoot = target.resolve("src")
167+
Files.createDirectory(sourceRoot)
168+
val sourceFile1 = sourceRoot.resolve("file1.scala")
169+
Files.write(sourceFile1, "def file1() = 1".getBytes(StandardCharsets.UTF_8))
170+
171+
val coverageOut = target.resolve("coverage-out")
172+
Files.createDirectory(coverageOut)
173+
val options = defaultOptions.and("-Ycheck:instrumentCoverage", "-coverage-out", coverageOut.toString, "-sourceroot", sourceRoot.toString)
174+
compileFile(sourceFile1.toString, options).checkCompile()
175+
176+
val scoverageFile = coverageOut.resolve("scoverage.coverage")
177+
assert(Files.exists(scoverageFile), s"Expected scoverage file to exist at $scoverageFile")
178+
179+
locally {
180+
val coverage = Serializer.deserialize(scoverageFile, sourceRoot.toString())
181+
val filesWithCoverage = coverage.statements.map(_.location.sourcePath.getFileName.toString).toSet
182+
assertEquals(Set("file1.scala"), filesWithCoverage)
183+
}
184+
185+
val sourceFile2 = sourceRoot.resolve("file2.scala")
186+
Files.write(sourceFile2, "def file2() = 2".getBytes(StandardCharsets.UTF_8))
187+
188+
Files.delete(sourceFile1)
189+
190+
compileFile(sourceFile2.toString, options).checkCompile()
191+
locally {
192+
val coverage = Serializer.deserialize(scoverageFile, sourceRoot.toString())
193+
val filesWithCoverage = coverage.statements.map(_.location.sourcePath.getFileName.toString).toSet
194+
assertEquals(Set("file2.scala"), filesWithCoverage)
195+
}
130196

131197
object CoverageTests extends ParallelTesting:
132198
import scala.concurrent.duration.*

0 commit comments

Comments
 (0)