Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions compiler/src/dotty/tools/dotc/coverage/Coverage.scala
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,16 @@ import java.nio.file.Path
class Coverage:
private val statementsById = new mutable.LongMap[Statement](256)

private var statementId: Int = 0

def nextStatementId(): Int =
statementId += 1
statementId - 1
private var _nextStatementId: Int = 1

def nextStatementId(): Int = _nextStatementId
def setNextStatementId(id: Int): Unit = _nextStatementId = id

def statements: Iterable[Statement] = statementsById.values

def addStatement(stmt: Statement): Unit = statementsById(stmt.id) = stmt
def addStatement(stmt: Statement): Unit =
if stmt.id >= _nextStatementId then _nextStatementId = stmt.id + 1
statementsById(stmt.id) = stmt

def removeStatementsFromFile(sourcePath: Path | Null) =
val removedIds = statements.filter(_.location.sourcePath == sourcePath).map(_.id.toLong)
Expand Down
67 changes: 65 additions & 2 deletions compiler/src/dotty/tools/dotc/coverage/Serializer.scala
Original file line number Diff line number Diff line change
@@ -1,22 +1,27 @@
package dotty.tools.dotc
package coverage

import java.nio.charset.StandardCharsets.UTF_8
import java.nio.file.{Path, Paths, Files}
import java.io.Writer
import scala.collection.mutable.StringBuilder
import scala.io.Source

/**
* Serializes scoverage data.
* @see https://github.com/scoverage/scalac-scoverage-plugin/blob/main/scalac-scoverage-plugin/src/main/scala/scoverage/Serializer.scala
* @see https://github.com/scoverage/scalac-scoverage-plugin/blob/main/serializer/src/main/scala/scoverage/serialize/Serializer.scala
*/
object Serializer:

private val CoverageFileName = "scoverage.coverage"
private val CoverageDataFormatVersion = "3.0"

def coverageFilePath(dataDir: String): Path =
Paths.get(dataDir, CoverageFileName).toAbsolutePath

/** Write out coverage data to the given data directory, using the default coverage filename */
def serialize(coverage: Coverage, dataDir: String, sourceRoot: String): Unit =
serialize(coverage, Paths.get(dataDir, CoverageFileName).toAbsolutePath, Paths.get(sourceRoot).toAbsolutePath)
serialize(coverage, coverageFilePath(dataDir), Paths.get(sourceRoot).toAbsolutePath)

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

def deserialize(file: Path, sourceRoot: String): Coverage =
val source = Source.fromFile(file.toFile(), UTF_8.name())
try deserialize(source.getLines(), Paths.get(sourceRoot).toAbsolutePath)
finally source.close()

def deserialize(lines: Iterator[String], sourceRoot: Path): Coverage =
def toStatement(lines: Iterator[String]): Statement =
val id: Int = lines.next().toInt
val sourcePath = lines.next()
val packageName = lines.next()
val className = lines.next()
val classType = lines.next()
val fullClassName = lines.next()
val method = lines.next()
val loc = Location(
packageName,
className,
fullClassName,
classType,
method,
sourceRoot.resolve(sourcePath).normalize()
)
val start: Int = lines.next().toInt
val end: Int = lines.next().toInt
val lineNo: Int = lines.next().toInt
val symbolName: String = lines.next()
val treeName: String = lines.next()
val branch: Boolean = lines.next().toBoolean
val count: Int = lines.next().toInt
val ignored: Boolean = lines.next().toBoolean
val desc = lines.toList.mkString("\n")
Statement(
loc,
id,
start,
end,
lineNo,
desc,
symbolName,
treeName,
branch,
ignored
)

val headerFirstLine = lines.next()
require(
headerFirstLine == s"# Coverage data, format version: $CoverageDataFormatVersion",
"Wrong file format"
)

val linesWithoutHeader = lines.dropWhile(_.startsWith("#"))
val coverage = Coverage()
while !linesWithoutHeader.isEmpty do
val oneStatementLines = linesWithoutHeader.takeWhile(_ != "\f")
coverage.addStatement(toStatement(oneStatementLines))
end while
coverage

/** Makes a String suitable for output in the coverage statement data as a single line.
* Escaped characters: '\\' (backslash), '\n', '\r', '\f'
*/
Expand Down
31 changes: 28 additions & 3 deletions compiler/src/dotty/tools/dotc/transform/InstrumentCoverage.scala
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package dotty.tools.dotc
package transform

import java.io.File
import java.nio.file.Files

import ast.tpd.*
import collection.mutable
Expand Down Expand Up @@ -41,6 +42,7 @@ class InstrumentCoverage extends MacroTransform with IdentityDenotTransformer:

private var coverageExcludeClasslikePatterns: List[Pattern] = Nil
private var coverageExcludeFilePatterns: List[Pattern] = Nil
private var lastCompiledFiles: Set[String] = Set.empty

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

if !newlyCreated then
// If the directory existed before, let's clean it up.
// If the directory existed before, clean measurement files.
dataDir.listFiles
.filter(_.getName.startsWith("scoverage"))
.filter(_.getName.startsWith("scoverage.measurements."))
.foreach(_.delete())
end if

val coverageFilePath = Serializer.coverageFilePath(outputPath)
val previousCoverage =
if Files.exists(coverageFilePath) then
Serializer.deserialize(coverageFilePath, ctx.settings.sourceroot.value)
else Coverage()

// Initialise a coverage object if it does not exist yet
if ctx.base.coverage == null then
ctx.base.coverage = Coverage()
Expand All @@ -64,9 +72,23 @@ class InstrumentCoverage extends MacroTransform with IdentityDenotTransformer:
coverageExcludeFilePatterns = ctx.settings.coverageExcludeFiles.value.map(_.r.pattern)

ctx.base.coverage.nn.removeStatementsFromFile(ctx.compilationUnit.source.file.absolute.jpath)
ctx.base.coverage.nn.setNextStatementId(previousCoverage.nextStatementId())

super.run

Serializer.serialize(ctx.base.coverage.nn, outputPath, ctx.settings.sourceroot.value)
val mergedCoverage = Coverage()

previousCoverage.statements
.filterNot(stmt =>
val source = stmt.location.sourcePath
lastCompiledFiles.contains(source.toString) || !Files.exists(source)
)
.foreach { stmt =>
mergedCoverage.addStatement(stmt)
}
ctx.base.coverage.nn.statements.foreach(stmt => mergedCoverage.addStatement(stmt))

Serializer.serialize(mergedCoverage, outputPath, ctx.settings.sourceroot.value)

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

override def transform(tree: Tree)(using Context): Tree =
val path = tree.sourcePos.source.file.absolute.jpath
if path != null then lastCompiledFiles += path.toString

inContext(transformCtx(tree)) { // necessary to position inlined code properly
tree match
// simple cases
Expand Down
66 changes: 66 additions & 0 deletions compiler/test/dotty/tools/dotc/coverage/CoverageTests.scala
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import scala.language.unsafeNulls
import scala.collection.mutable.Buffer
import dotty.tools.dotc.util.DiffUtil

import java.nio.charset.StandardCharsets
import java.util.stream.Collectors

@Category(Array(classOf[BootstrappedOnlyTests]))
Expand Down Expand Up @@ -127,6 +128,71 @@ class CoverageTests:
)
}

@Test
def checkIncrementalCoverage(): Unit =
val target = Files.createTempDirectory("coverage")
val sourceRoot = target.resolve("src")
Files.createDirectory(sourceRoot)
val sourceFile1 = sourceRoot.resolve("file1.scala")
Files.write(sourceFile1, "def file1() = 1".getBytes(StandardCharsets.UTF_8))

val coverageOut = target.resolve("coverage-out")
Files.createDirectory(coverageOut)
val options = defaultOptions.and("-Ycheck:instrumentCoverage", "-coverage-out", coverageOut.toString, "-sourceroot", sourceRoot.toString)
compileFile(sourceFile1.toString, options).checkCompile()

val scoverageFile = coverageOut.resolve("scoverage.coverage")
assert(Files.exists(scoverageFile), s"Expected scoverage file to exist at $scoverageFile")

locally {
val coverage = Serializer.deserialize(scoverageFile, sourceRoot.toString())
val filesWithCoverage = coverage.statements.map(_.location.sourcePath.getFileName.toString).toSet
assertEquals(Set("file1.scala"), filesWithCoverage)
}

val sourceFile2 = sourceRoot.resolve("file2.scala")
Files.write(sourceFile2, "def file2() = 2".getBytes(StandardCharsets.UTF_8))

compileFile(sourceFile2.toString, options).checkCompile()
locally {
val coverage = Serializer.deserialize(scoverageFile, sourceRoot.toString())
val filesWithCoverage = coverage.statements.map(_.location.sourcePath.getFileName.toString).toSet
assertEquals(Set("file1.scala", "file2.scala"), filesWithCoverage)
}

@Test
def `deleted source files should not be kept in incremental coverage`(): Unit =
val target = Files.createTempDirectory("coverage")
val sourceRoot = target.resolve("src")
Files.createDirectory(sourceRoot)
val sourceFile1 = sourceRoot.resolve("file1.scala")
Files.write(sourceFile1, "def file1() = 1".getBytes(StandardCharsets.UTF_8))

val coverageOut = target.resolve("coverage-out")
Files.createDirectory(coverageOut)
val options = defaultOptions.and("-Ycheck:instrumentCoverage", "-coverage-out", coverageOut.toString, "-sourceroot", sourceRoot.toString)
compileFile(sourceFile1.toString, options).checkCompile()

val scoverageFile = coverageOut.resolve("scoverage.coverage")
assert(Files.exists(scoverageFile), s"Expected scoverage file to exist at $scoverageFile")

locally {
val coverage = Serializer.deserialize(scoverageFile, sourceRoot.toString())
val filesWithCoverage = coverage.statements.map(_.location.sourcePath.getFileName.toString).toSet
assertEquals(Set("file1.scala"), filesWithCoverage)
}

val sourceFile2 = sourceRoot.resolve("file2.scala")
Files.write(sourceFile2, "def file2() = 2".getBytes(StandardCharsets.UTF_8))

Files.delete(sourceFile1)

compileFile(sourceFile2.toString, options).checkCompile()
locally {
val coverage = Serializer.deserialize(scoverageFile, sourceRoot.toString())
val filesWithCoverage = coverage.statements.map(_.location.sourcePath.getFileName.toString).toSet
assertEquals(Set("file2.scala"), filesWithCoverage)
}

object CoverageTests extends ParallelTesting:
import scala.concurrent.duration.*
Expand Down
Loading