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..8e2688e1 --- /dev/null +++ b/plugin/src/test/scala/scoverage/IncrementalCoverageTest.scala @@ -0,0 +1,101 @@ +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 compilation1 = + Compilation(basePath, """object First { def test(): Int = 42 }""") + + locally { + val sourceFiles = compilation1.coverage.files.map(_.source).toSet + assertEquals(sourceFiles, Set(compilation1.file.getCanonicalPath)) + } + + val compilation2 = + Compilation( + basePath, + """object Second { def test(): String = "hello" }""" + ) + + locally { + val sourceFiles = compilation2.coverage.files.map(_.source).toSet + assertEquals( + sourceFiles, + Set(compilation1.file, compilation2.file).map(_.getCanonicalPath) + ) + } + } + + 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 + + assertEquals(sourceFiles, Set(compilation1.file.getCanonicalPath)) + } + + compilation1.file.delete() + + val compilation2 = Compilation(basePath, "") + + locally { + val sourceFiles = compilation2.coverage.files.map(_.source).toSet + assertEquals(sourceFiles, Set.empty[String]) + } + } + + 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