From cd5926cbb214065c314da865c6cf1a3ab25264ef Mon Sep 17 00:00:00 2001 From: Lorenzo Gabriele Date: Mon, 15 Dec 2025 11:32:25 +0100 Subject: [PATCH 1/2] Incremental coverage support This makes scoverage work with incremental compilation. Instead of starting from scratch we reuse the data in `scoverage.coverage` for files that are not being compiled in this compilation unit (because zinc skipped them). We read the existing `scoverage.coverage` and merge it with the new one being generated. We make sure that statement ids by starting from the max id in the existing coverage. This PR also refactors the test code a little bit to be able to test for incremental compilation. --- .../scala/scoverage/domain/coverage.scala | 17 ++- .../main/scala/scoverage/CoverageMerge.scala | 27 +++++ .../scala/scoverage/ScoveragePlugin.scala | 34 ++++-- .../scoverage/IncrementalCoverageTest.scala | 111 ++++++++++++++++++ .../scala/scoverage/ScoverageCompiler.scala | 42 ++++--- 5 files changed, 206 insertions(+), 25 deletions(-) create mode 100644 plugin/src/main/scala/scoverage/CoverageMerge.scala create mode 100644 plugin/src/test/scala/scoverage/IncrementalCoverageTest.scala diff --git a/domain/src/main/scala/scoverage/domain/coverage.scala b/domain/src/main/scala/scoverage/domain/coverage.scala index a6b8764c..86e59757 100644 --- a/domain/src/main/scala/scoverage/domain/coverage.scala +++ b/domain/src/main/scala/scoverage/domain/coverage.scala @@ -12,14 +12,23 @@ case class Coverage() with PackageBuilders with FileBuilders { + private var _maxId: Int = 0 private val statementsById = mutable.Map[Int, Statement]() override def statements = statementsById.values - def add(stmt: Statement): Unit = statementsById.put(stmt.id, stmt) - + def add(stmt: Statement): Unit = { + if (stmt.id > _maxId) { + _maxId = stmt.id + } + statementsById.put(stmt.id, stmt) + } private val ignoredStatementsById = mutable.Map[Int, Statement]() override def ignoredStatements = ignoredStatementsById.values - def addIgnoredStatement(stmt: Statement): Unit = + def addIgnoredStatement(stmt: Statement): Unit = { + if (stmt.id > _maxId) { + _maxId = stmt.id + } ignoredStatementsById.put(stmt.id, stmt) + } def avgClassesPerPackage = classCount / packageCount.toDouble def avgClassesPerPackageFormatted: String = DoubleFormat.twoFractionDigits( @@ -46,6 +55,8 @@ case class Coverage() def apply(ids: Iterable[(Int, String)]): Unit = ids foreach invoked def invoked(id: (Int, String)): Unit = statementsById.get(id._1).foreach(_.invoked(id._2)) + + def maxId: Int = _maxId } trait ClassBuilders { diff --git a/plugin/src/main/scala/scoverage/CoverageMerge.scala b/plugin/src/main/scala/scoverage/CoverageMerge.scala new file mode 100644 index 00000000..4a9d6215 --- /dev/null +++ b/plugin/src/main/scala/scoverage/CoverageMerge.scala @@ -0,0 +1,27 @@ +package scoverage + +import java.io.File + +import scoverage.domain.Coverage + +object CoverageMerge { + def mergePreviousAndCurrentCoverage( + lastCompiledFiles: Set[String], + previousCoverage: Coverage, + currentCoverage: Coverage + ): Coverage = { + val mergedCoverage = Coverage() + + previousCoverage.statements + .filterNot(stmt => + lastCompiledFiles.contains(stmt.source) || + !new File(stmt.source).exists() + ) + .foreach { stmt => + mergedCoverage.add(stmt) + } + currentCoverage.statements.foreach(stmt => mergedCoverage.add(stmt)) + + mergedCoverage + } +} diff --git a/plugin/src/main/scala/scoverage/ScoveragePlugin.scala b/plugin/src/main/scala/scoverage/ScoveragePlugin.scala index bedc079d..90da4703 100644 --- a/plugin/src/main/scala/scoverage/ScoveragePlugin.scala +++ b/plugin/src/main/scala/scoverage/ScoveragePlugin.scala @@ -84,6 +84,7 @@ class ScoverageInstrumentationComponent( val statementIds = new AtomicInteger(0) val coverage = new Coverage + val compiledFiles = Set.newBuilder[String] override val phaseName: String = ScoveragePlugin.phaseName override val runsAfter: List[String] = @@ -121,22 +122,39 @@ class ScoverageInstrumentationComponent( override def newPhase(prev: scala.tools.nsc.Phase): Phase = new Phase(prev) { override def run(): Unit = { - reporter.echo(s"Cleaning datadir [${options.dataDir}]") - // we clean the data directory, because if the code has changed, then the number / order of - // statements has changed by definition. So the old data would reference statements incorrectly - // and thus skew the results. + reporter.echo( + s"Cleaning measurements files in datadir [${options.dataDir}]" + ) Serializer.clean(options.dataDir) + val coverageFile = Serializer.coverageFile(options.dataDir) + val sourceRootFile = new File(options.sourceRoot) + val previousCoverage = + if (coverageFile.exists()) + Serializer.deserialize( + coverageFile, + sourceRootFile + ) + else Coverage() + + statementIds.set(previousCoverage.maxId) + reporter.echo("Beginning coverage instrumentation") super.run() reporter.echo( s"Instrumentation completed [${coverage.statements.size} statements]" ) + val mergedCoverage = CoverageMerge.mergePreviousAndCurrentCoverage( + lastCompiledFiles = compiledFiles.result(), + previousCoverage = previousCoverage, + currentCoverage = coverage + ) + Serializer.serialize( - coverage, - Serializer.coverageFile(options.dataDir), - new File(options.sourceRoot) + mergedCoverage, + coverageFile, + sourceRootFile ) reporter.echo( s"Wrote instrumentation file [${Serializer.coverageFile(options.dataDir)}]" @@ -153,6 +171,8 @@ class ScoverageInstrumentationComponent( import global._ + compiledFiles += unit.source.file.absolute.canonicalPath + // contains the location of the last node var location: domain.Location = _ diff --git a/plugin/src/test/scala/scoverage/IncrementalCoverageTest.scala b/plugin/src/test/scala/scoverage/IncrementalCoverageTest.scala new file mode 100644 index 00000000..333c1de6 --- /dev/null +++ b/plugin/src/test/scala/scoverage/IncrementalCoverageTest.scala @@ -0,0 +1,111 @@ +package scoverage + +import munit.FunSuite + +class IncrementalCoverageTest extends FunSuite { + + case class Compilation(basePath: java.io.File, code: String) { + val coverageFile = serialize.Serializer.coverageFile(basePath) + val compiler = ScoverageCompiler(basePath = basePath) + + val file = compiler.writeCodeSnippetToTempFile(code) + compiler.compileSourceFiles(file) + compiler.assertNoErrors() + + val coverage = serialize.Serializer.deserialize(coverageFile, basePath) + } + + test( + "should keep coverage from previous compilation when compiling incrementally" + ) { + val basePath = ScoverageCompiler.tempBasePath() + val coverageFile = serialize.Serializer.coverageFile(basePath) + + val compilation1 = + Compilation(basePath, """object First { def test(): Int = 42 }""") + + locally { + val sourceFiles = compilation1.coverage.files.map(_.source).toSet + assert( + sourceFiles.contains(compilation1.file.getCanonicalPath), + s"First file should be in coverage, but found: ${sourceFiles.mkString(", ")}" + ) + } + + val compilation2 = + Compilation( + basePath, + """object Second { def test(): String = "hello" }""" + ) + + locally { + val sourceFiles = compilation2.coverage.files.map(_.source).toSet + assert( + sourceFiles.contains(compilation2.file.getCanonicalPath), + s"Second file should be in coverage, but found: ${sourceFiles.mkString(", ")}" + ) + } + } + + test( + "should not keep coverage from previous compilation if the source file was deleted" + ) { + val basePath = ScoverageCompiler.tempBasePath() + + val compilation1 = + Compilation(basePath, """object First { def test(): Int = 42 }""") + + locally { + val sourceFiles = compilation1.coverage.files.map(_.source).toSet + + assert( + sourceFiles.contains(compilation1.file.getCanonicalPath), + s"First file should be in coverage, but found: ${sourceFiles.mkString(", ")}" + ) + } + + compilation1.file.delete() + + val compilation2 = Compilation(basePath, "") + + locally { + val sourceFiles = compilation2.coverage.files.map(_.source).toSet + assert( + sourceFiles.isEmpty, + s"Coverage should be empty, but found: ${sourceFiles.mkString(", ")}" + ) + } + } + + test( + "should not keep coverage from previous compilation if the source file was compiled again" + ) { + val basePath = ScoverageCompiler.tempBasePath() + + val compilation1 = + Compilation(basePath, """object First { def test(): Int = 42 }""") + + reporter.IOUtils.writeToFile( + compilation1.file, + """object Second { def test(): String = "hello" }""", + None + ) + + val coverageFile = serialize.Serializer.coverageFile(basePath) + val compiler = ScoverageCompiler(basePath = basePath) + + compiler.compileSourceFiles(compilation1.file) + compiler.assertNoErrors() + + val coverage = serialize.Serializer.deserialize(coverageFile, basePath) + + locally { + val classNames = coverage.statements.map(_.location.className).toSet + assertEquals( + classNames, + Set("Second"), + s"First class should not be in coverage, but found: ${classNames.mkString(", ")}" + ) + } + } +} diff --git a/plugin/src/test/scala/scoverage/ScoverageCompiler.scala b/plugin/src/test/scala/scoverage/ScoverageCompiler.scala index 0f766474..f709f51e 100644 --- a/plugin/src/test/scala/scoverage/ScoverageCompiler.scala +++ b/plugin/src/test/scala/scoverage/ScoverageCompiler.scala @@ -3,6 +3,7 @@ package scoverage import java.io.File import java.io.FileNotFoundException import java.net.URL +import java.util.UUID import scala.collection.mutable.ListBuffer import scala.tools.nsc.Global @@ -55,20 +56,30 @@ private[scoverage] object ScoverageCompiler { s } - def default: ScoverageCompiler = { - val reporter = new scala.tools.nsc.reporters.ConsoleReporter(settings) - new ScoverageCompiler(settings, reporter, validatePositions = true) + def tempBasePath(): File = + new File(IOUtils.getTempPath, UUID.randomUUID.toString) + + def apply( + settings: scala.tools.nsc.Settings = settings, + reporter: scala.tools.nsc.reporters.Reporter = + new scala.tools.nsc.reporters.ConsoleReporter(settings), + validatePositions: Boolean = true, + basePath: File = tempBasePath() + ) = { + new ScoverageCompiler( + settings, + reporter, + validatePositions, + basePath + ) } - def noPositionValidation: ScoverageCompiler = { - val reporter = new scala.tools.nsc.reporters.ConsoleReporter(settings) - new ScoverageCompiler(settings, reporter, validatePositions = false) - } + def default = ScoverageCompiler() - def defaultJS: ScoverageCompiler = { - val reporter = new scala.tools.nsc.reporters.ConsoleReporter(jsSettings) - new ScoverageCompiler(jsSettings, reporter, validatePositions = true) - } + def noPositionValidation: ScoverageCompiler = + ScoverageCompiler(validatePositions = false) + + def defaultJS: ScoverageCompiler = ScoverageCompiler(settings = jsSettings) def locationCompiler: LocationCompiler = { val reporter = new scala.tools.nsc.reporters.ConsoleReporter(settings) @@ -158,7 +169,8 @@ private[scoverage] object ScoverageCompiler { class ScoverageCompiler( settings: scala.tools.nsc.Settings, rep: scala.tools.nsc.reporters.Reporter, - validatePositions: Boolean + validatePositions: Boolean, + basePath: File ) extends scala.tools.nsc.Global(settings, rep) { def addToClassPath(file: File): Unit = { @@ -171,8 +183,8 @@ class ScoverageCompiler( val coverageOptions = ScoverageOptions .default() - .copy(dataDir = IOUtils.getTempPath) - .copy(sourceRoot = IOUtils.getTempPath) + .copy(dataDir = basePath.getAbsolutePath) + .copy(sourceRoot = basePath.getAbsolutePath) instrumentationComponent.setOptions(coverageOptions) val testStore = new ScoverageTestStoreComponent(this) @@ -188,7 +200,7 @@ class ScoverageCompiler( } def writeCodeSnippetToTempFile(code: String): File = { - val file = File.createTempFile("scoverage_snippet", ".scala") + val file = File.createTempFile("scoverage_snippet", ".scala", basePath) IOUtils.writeToFile(file, code, None) file.deleteOnExit() file From fb51cf867c17c415fa8dbf6c2bff0407978e453c Mon Sep 17 00:00:00 2001 From: Lorenzo Gabriele Date: Mon, 15 Dec 2025 13:14:59 +0100 Subject: [PATCH 2/2] Fix tests --- .../scoverage/IncrementalCoverageTest.scala | 22 +++++-------------- 1 file changed, 6 insertions(+), 16 deletions(-) diff --git a/plugin/src/test/scala/scoverage/IncrementalCoverageTest.scala b/plugin/src/test/scala/scoverage/IncrementalCoverageTest.scala index 333c1de6..8e2688e1 100644 --- a/plugin/src/test/scala/scoverage/IncrementalCoverageTest.scala +++ b/plugin/src/test/scala/scoverage/IncrementalCoverageTest.scala @@ -19,17 +19,13 @@ class IncrementalCoverageTest extends FunSuite { "should keep coverage from previous compilation when compiling incrementally" ) { val basePath = ScoverageCompiler.tempBasePath() - val coverageFile = serialize.Serializer.coverageFile(basePath) val compilation1 = Compilation(basePath, """object First { def test(): Int = 42 }""") locally { val sourceFiles = compilation1.coverage.files.map(_.source).toSet - assert( - sourceFiles.contains(compilation1.file.getCanonicalPath), - s"First file should be in coverage, but found: ${sourceFiles.mkString(", ")}" - ) + assertEquals(sourceFiles, Set(compilation1.file.getCanonicalPath)) } val compilation2 = @@ -40,9 +36,9 @@ class IncrementalCoverageTest extends FunSuite { locally { val sourceFiles = compilation2.coverage.files.map(_.source).toSet - assert( - sourceFiles.contains(compilation2.file.getCanonicalPath), - s"Second file should be in coverage, but found: ${sourceFiles.mkString(", ")}" + assertEquals( + sourceFiles, + Set(compilation1.file, compilation2.file).map(_.getCanonicalPath) ) } } @@ -58,10 +54,7 @@ class IncrementalCoverageTest extends FunSuite { locally { val sourceFiles = compilation1.coverage.files.map(_.source).toSet - assert( - sourceFiles.contains(compilation1.file.getCanonicalPath), - s"First file should be in coverage, but found: ${sourceFiles.mkString(", ")}" - ) + assertEquals(sourceFiles, Set(compilation1.file.getCanonicalPath)) } compilation1.file.delete() @@ -70,10 +63,7 @@ class IncrementalCoverageTest extends FunSuite { locally { val sourceFiles = compilation2.coverage.files.map(_.source).toSet - assert( - sourceFiles.isEmpty, - s"Coverage should be empty, but found: ${sourceFiles.mkString(", ")}" - ) + assertEquals(sourceFiles, Set.empty[String]) } }