Skip to content

Commit

Permalink
Fix coverage serialization when encountering macro suspension (#22303)
Browse files Browse the repository at this point in the history
Fixes #22247

The fix is simple, as we mainly move the coverage object to a global
ContextBase object, which persists it between runs. Initially I thought
that appending the newly generated coverage indices would be enough, but
if the macro suspends after the InstrumentCoverage phase runs, we end up
with duplicate indices. For that reason, when generating indexes for a
compilation unit, we also remove the previously generated ones for the
same compilation unit.

To support having multiple scala files compiled in the coverage tests I
had to slightly adjust the suite. While doing that, I noticed that some
check files for run tests were ignored, as they were incorrectly named.
I added an assertion that throws when `.check` do not exist and renamed
the files appropriately (having to add some additional ones as well).
  • Loading branch information
jchyb authored Jan 10, 2025
1 parent a50a1e4 commit f06b95f
Show file tree
Hide file tree
Showing 23 changed files with 378 additions and 32 deletions.
6 changes: 6 additions & 0 deletions compiler/src/dotty/tools/dotc/core/Contexts.scala
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import util.Store
import plugins.*
import java.util.concurrent.atomic.AtomicInteger
import java.nio.file.InvalidPathException
import dotty.tools.dotc.coverage.Coverage

object Contexts {

Expand Down Expand Up @@ -982,6 +983,11 @@ object Contexts {
/** Was best effort file used during compilation? */
private[core] var usedBestEffortTasty = false

/** If coverage option is used, it stores all instrumented statements (for InstrumentCoverage).
* We need this information to be persisted across different runs, so it's stored here.
*/
private[dotc] var coverage: Coverage | Null = null

// Types state
/** A table for hash consing unique types */
private[core] val uniques: Uniques = Uniques()
Expand Down
12 changes: 12 additions & 0 deletions compiler/src/dotty/tools/dotc/coverage/Coverage.scala
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,27 @@ package dotty.tools.dotc
package coverage

import scala.collection.mutable
import java.nio.file.Path

/** Holds a list of statements to include in the coverage reports. */
class Coverage:
private val statementsById = new mutable.LongMap[Statement](256)

private var statementId: Int = 0

def nextStatementId(): Int =
statementId += 1
statementId - 1


def statements: Iterable[Statement] = statementsById.values

def addStatement(stmt: Statement): Unit = statementsById(stmt.id) = stmt

def removeStatementsFromFile(sourcePath: Path) =
val removedIds = statements.filter(_.location.sourcePath == sourcePath).map(_.id.toLong)
removedIds.foreach(statementsById.remove)


/**
* A statement that can be invoked, and thus counted as "covered" by code coverage tools.
Expand Down
18 changes: 8 additions & 10 deletions compiler/src/dotty/tools/dotc/transform/InstrumentCoverage.scala
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,6 @@ class InstrumentCoverage extends MacroTransform with IdentityDenotTransformer:
override def isEnabled(using ctx: Context) =
ctx.settings.coverageOutputDir.value.nonEmpty

// counter to assign a unique id to each statement
private var statementId = 0

// stores all instrumented statements
private val coverage = Coverage()

private var coverageExcludeClasslikePatterns: List[Pattern] = Nil
private var coverageExcludeFilePatterns: List[Pattern] = Nil

Expand All @@ -61,12 +55,17 @@ class InstrumentCoverage extends MacroTransform with IdentityDenotTransformer:
.foreach(_.nn.delete())
end if

// Initialise a coverage object if it does not exist yet
if ctx.base.coverage == null then
ctx.base.coverage = Coverage()

coverageExcludeClasslikePatterns = ctx.settings.coverageExcludeClasslikes.value.map(_.r.pattern)
coverageExcludeFilePatterns = ctx.settings.coverageExcludeFiles.value.map(_.r.pattern)

ctx.base.coverage.nn.removeStatementsFromFile(ctx.compilationUnit.source.file.absolute.jpath)
super.run

Serializer.serialize(coverage, outputPath, ctx.settings.sourceroot.value)
Serializer.serialize(ctx.base.coverage.nn, 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 @@ -110,8 +109,7 @@ class InstrumentCoverage extends MacroTransform with IdentityDenotTransformer:
* @return the statement's id
*/
private def recordStatement(tree: Tree, pos: SourcePosition, branch: Boolean)(using ctx: Context): Int =
val id = statementId
statementId += 1
val id = ctx.base.coverage.nn.nextStatementId()

val sourceFile = pos.source
val statement = Statement(
Expand All @@ -127,7 +125,7 @@ class InstrumentCoverage extends MacroTransform with IdentityDenotTransformer:
treeName = tree.getClass.getSimpleName.nn,
branch
)
coverage.addStatement(statement)
ctx.base.coverage.nn.addStatement(statement)
id

/**
Expand Down
41 changes: 28 additions & 13 deletions compiler/test/dotty/tools/dotc/coverage/CoverageTests.scala
Original file line number Diff line number Diff line change
Expand Up @@ -54,15 +54,27 @@ class CoverageTests:
lines
end fixWindowsPaths

def runOnFile(p: Path): Boolean =
scalaFile.matches(p) &&
(Properties.testsFilter.isEmpty || Properties.testsFilter.exists(p.toString.contains))
def runOnFileOrDir(p: Path): Boolean =
(scalaFile.matches(p) || Files.isDirectory(p))
&& (p != dir)
&& (Properties.testsFilter.isEmpty || Properties.testsFilter.exists(p.toString.contains))

Files.walk(dir, 1).filter(runOnFileOrDir).forEach(path => {
// measurement files only exist in the "run" category
// as these are generated at runtime by the scala.runtime.coverage.Invoker
val (targetDir, expectFile, expectMeasurementFile) =
if Files.isDirectory(path) then
val dirName = path.getFileName().toString
assert(!Files.walk(path).filter(scalaFile.matches(_)).toArray.isEmpty, s"No scala files found in test directory: ${path}")
val targetDir = computeCoverageInTmp(path, isDirectory = true, dir, run)
(targetDir, path.resolve(s"test.scoverage.check"), path.resolve(s"test.measurement.check"))
else
val fileName = path.getFileName.toString.stripSuffix(".scala")
val targetDir = computeCoverageInTmp(path, isDirectory = false, dir, run)
(targetDir, path.resolveSibling(s"${fileName}.scoverage.check"), path.resolveSibling(s"${fileName}.measurement.check"))

Files.walk(dir).filter(runOnFile).forEach(path => {
val fileName = path.getFileName.toString.stripSuffix(".scala")
val targetDir = computeCoverageInTmp(path, dir, run)
val targetFile = targetDir.resolve(s"scoverage.coverage")
val expectFile = path.resolveSibling(s"$fileName.scoverage.check")

if updateCheckFiles then
Files.copy(targetFile, expectFile, StandardCopyOption.REPLACE_EXISTING)
else
Expand All @@ -72,9 +84,6 @@ class CoverageTests:
val instructions = FileDiff.diffMessage(expectFile.toString, targetFile.toString)
fail(s"Coverage report differs from expected data.\n$instructions")

// measurement files only exist in the "run" category
// as these are generated at runtime by the scala.runtime.coverage.Invoker
val expectMeasurementFile = path.resolveSibling(s"$fileName.measurement.check")
if run && Files.exists(expectMeasurementFile) then

// Note that this assumes that the test invoked was single threaded,
Expand All @@ -95,14 +104,20 @@ class CoverageTests:
})

/** Generates the coverage report for the given input file, in a temporary directory. */
def computeCoverageInTmp(inputFile: Path, sourceRoot: Path, run: Boolean)(using TestGroup): Path =
def computeCoverageInTmp(inputFile: Path, isDirectory: Boolean, sourceRoot: Path, run: Boolean)(using TestGroup): Path =
val target = Files.createTempDirectory("coverage")
val options = defaultOptions.and("-Ycheck:instrumentCoverage", "-coverage-out", target.toString, "-sourceroot", sourceRoot.toString)
if run then
val test = compileDir(inputFile.getParent.toString, options)
val path = if isDirectory then inputFile.toString else inputFile.getParent.toString
val test = compileDir(path, options)
test.checkFilePaths.foreach { checkFilePath =>
assert(checkFilePath.exists, s"Expected checkfile for $path $checkFilePath does not exist.")
}
test.checkRuns()
else
val test = compileFile(inputFile.toString, options)
val test =
if isDirectory then compileDir(inputFile.toString, options)
else compileFile(inputFile.toString, options)
test.checkCompile()
target

Expand Down
24 changes: 15 additions & 9 deletions compiler/test/dotty/tools/vulpix/ParallelTesting.scala
Original file line number Diff line number Diff line change
Expand Up @@ -258,15 +258,7 @@ trait ParallelTesting extends RunnerOrchestration { self =>
* For a given test source, returns a check file against which the result of the test run
* should be compared. Is used by implementations of this trait.
*/
final def checkFile(testSource: TestSource): Option[JFile] = (testSource match {
case ts: JointCompilationSource =>
ts.files.collectFirst {
case f if !f.isDirectory =>
new JFile(f.getPath.replaceFirst("\\.(scala|java)$", ".check"))
}
case ts: SeparateCompilationSource =>
Option(new JFile(ts.dir.getPath + ".check"))
}).filter(_.exists)
final def checkFile(testSource: TestSource): Option[JFile] = (CompilationLogic.checkFilePath(testSource)).filter(_.exists)

/**
* Checks if the given actual lines are the same as the ones in the check file.
Expand Down Expand Up @@ -343,6 +335,18 @@ trait ParallelTesting extends RunnerOrchestration { self =>
}
}

object CompilationLogic {
private[ParallelTesting] def checkFilePath(testSource: TestSource) = testSource match {
case ts: JointCompilationSource =>
ts.files.collectFirst {
case f if !f.isDirectory =>
new JFile(f.getPath.replaceFirst("\\.(scala|java)$", ".check"))
}
case ts: SeparateCompilationSource =>
Option(new JFile(ts.dir.getPath + ".check"))
}
}

/** Each `Test` takes the `testSources` and performs the compilation and assertions
* according to the implementing class "neg", "run" or "pos".
*/
Expand Down Expand Up @@ -1157,6 +1161,8 @@ trait ParallelTesting extends RunnerOrchestration { self =>
def this(targets: List[TestSource]) =
this(targets, 1, true, None, false, false)

def checkFilePaths: List[JFile] = targets.map(CompilationLogic.checkFilePath).flatten

def copy(targets: List[TestSource],
times: Int = times,
shouldDelete: Boolean = shouldDelete,
Expand Down
13 changes: 13 additions & 0 deletions tests/coverage/pos/macro-late-suspend/Test.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package example

sealed trait Test

object Test {
case object Foo extends Test

val visitorType = mkVisitorType[Test]

trait Visitor[A] {
type V[a] = visitorType.Out[a]
}
}
3 changes: 3 additions & 0 deletions tests/coverage/pos/macro-late-suspend/UsesTest.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package example

val _ = Test.Foo
12 changes: 12 additions & 0 deletions tests/coverage/pos/macro-late-suspend/VisitorMacros.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package example

import scala.quoted.*

private def mkVisitorTypeImpl[T: Type](using q: Quotes): Expr[VisitorType[T]] =
'{new VisitorType[T]{}}

transparent inline def mkVisitorType[T]: VisitorType[T] = ${ mkVisitorTypeImpl[T] }

trait VisitorType[T] {
type Out[A]
}
88 changes: 88 additions & 0 deletions tests/coverage/pos/macro-late-suspend/test.scoverage.check
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# Coverage data, format version: 3.0
# Statement data:
# - id
# - source path
# - package name
# - class name
# - class type (Class, Object or Trait)
# - full class name
# - method name
# - start offset
# - end offset
# - line number
# - symbol name
# - tree name
# - is branch
# - invocations count
# - is ignored
# - description (can be multi-line)
# ' ' sign
# ------------------------------------------
1
macro-late-suspend/VisitorMacros.scala
example
VisitorMacros$package
Object
example.VisitorMacros$package
mkVisitorTypeImpl
124
144
6
unpickleExprV2
Apply
false
0
false
new VisitorType[T]{}

2
macro-late-suspend/VisitorMacros.scala
example
VisitorMacros$package
Object
example.VisitorMacros$package
mkVisitorTypeImpl
40
69
5
mkVisitorTypeImpl
DefDef
false
0
false
private def mkVisitorTypeImpl

3
macro-late-suspend/Test.scala
example
Test
Object
example.Test
<init>
102
121
8
<init>
Apply
false
0
false
mkVisitorType[Test]

4
macro-late-suspend/UsesTest.scala
example
UsesTest$package
Object
example.UsesTest$package
<init>
22
22
3
<none>
Literal
true
0
false


1 change: 1 addition & 0 deletions tests/coverage/run/i16940/i16940.check
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
2
2 changes: 2 additions & 0 deletions tests/coverage/run/i18233-min/i182233-min.check
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
List()
List(abc, def)
1 change: 1 addition & 0 deletions tests/coverage/run/i18233/i18233.check
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Baz
File renamed without changes.
1 change: 1 addition & 0 deletions tests/coverage/run/macro-suspend/Macro.check
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
>>> hello <<<
8 changes: 8 additions & 0 deletions tests/coverage/run/macro-suspend/Macro.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import scala.quoted.{Expr, Quotes}

object Macro:
inline def decorate(inline s: String): String = ${ decorateQuotes('s) }
def decorateQuotes(s: Expr[String])(using Quotes): Expr[String] = '{ ">>> " + $s + " <<<" }

object Greeting:
def greet() = "hello"
4 changes: 4 additions & 0 deletions tests/coverage/run/macro-suspend/Test.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
object Test:
def main(args: Array[String]): Unit =
println(Macro.decorate(Greeting.greet()))

Loading

0 comments on commit f06b95f

Please sign in to comment.