Skip to content

Commit 0f7e8fc

Browse files
committed
Add in initial support for code coverage.
1 parent 83effc3 commit 0f7e8fc

21 files changed

+3892
-2
lines changed

compiler/src/dotty/tools/dotc/Compiler.scala

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ class Compiler {
4646
List(new sjs.PrepJSInterop) :: // Additional checks and transformations for Scala.js (Scala.js only)
4747
List(new sbt.ExtractAPI) :: // Sends a representation of the API of classes to sbt via callbacks
4848
List(new SetRootTree) :: // Set the `rootTreeOrProvider` on class symbols
49+
List(new CoverageTransformMacro) :: // Perform instrumentation for coverage transform (if -coverage is present)
4950
Nil
5051

5152
/** Phases dealing with TASTY tree pickling and unpickling */

compiler/src/dotty/tools/dotc/config/ScalaSettings.scala

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,9 @@ trait CommonScalaSettings:
106106
val explainTypes: Setting[Boolean] = BooleanSetting("-explain-types", "Explain type errors in more detail (deprecated, use -explain instead).", aliases = List("--explain-types", "-explaintypes"))
107107
val unchecked: Setting[Boolean] = BooleanSetting("-unchecked", "Enable additional warnings where generated code depends on assumptions.", initialValue = true, aliases = List("--unchecked"))
108108
val language: Setting[List[String]] = MultiStringSetting("-language", "feature", "Enable one or more language features.", aliases = List("--language"))
109+
/* Coverage settings */
110+
val coverageOutputDir = PathSetting("-coverage", "Destination for coverage classfiles and instrumentation data.", "")
111+
val coverageSourceroot = PathSetting("-coverage-sourceroot", "An alternative root dir of your sources used to relativize.", ".")
109112

110113
/* Other settings */
111114
val encoding: Setting[String] = StringSetting("-encoding", "encoding", "Specify character encoding used by source files.", Properties.sourceEncoding, aliases = List("--encoding"))

compiler/src/dotty/tools/dotc/core/Definitions.scala

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import typer.ImportInfo.RootRef
1414
import Comments.CommentsContext
1515
import Comments.Comment
1616
import util.Spans.NoSpan
17+
import Symbols.requiredModuleRef
1718

1819
import scala.annotation.tailrec
1920

@@ -461,6 +462,8 @@ class Definitions {
461462
}
462463
def NullType: TypeRef = NullClass.typeRef
463464

465+
@tu lazy val InvokerModuleRef = requiredMethodRef("scala.runtime.Invoker")
466+
464467
@tu lazy val ImplicitScrutineeTypeSym =
465468
newPermanentSymbol(ScalaPackageClass, tpnme.IMPLICITkw, EmptyFlags, TypeBounds.empty).entered
466469
def ImplicitScrutineeTypeRef: TypeRef = ImplicitScrutineeTypeSym.typeRef
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package dotty.tools.dotc
2+
package coverage
3+
4+
import scala.collection.mutable
5+
6+
class Coverage {
7+
private val statementsById = mutable.Map[Int, Statement]()
8+
9+
def statements = statementsById.values
10+
11+
def addStatement(stmt: Statement): Unit = statementsById.put(stmt.id, stmt)
12+
}
13+
14+
case class Statement(
15+
source: String,
16+
location: Location,
17+
id: Int,
18+
start: Int,
19+
end: Int,
20+
line: Int,
21+
desc: String,
22+
symbolName: String,
23+
treeName: String,
24+
branch: Boolean,
25+
var count: Int = 0,
26+
ignored: Boolean = false
27+
) {
28+
def invoked(): Unit = count = count + 1
29+
def isInvoked = count > 0
30+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
package dotty.tools.dotc
2+
package coverage
3+
4+
import ast.tpd._
5+
import dotty.tools.dotc.core.Contexts.Context
6+
7+
/** @param packageName
8+
* the name of the encosing package
9+
* @param className
10+
* the name of the closes enclosing class
11+
* @param fullClassName
12+
* the fully qualified name of the closest enclosing class
13+
*/
14+
final case class Location(
15+
packageName: String,
16+
className: String,
17+
fullClassName: String,
18+
classType: String,
19+
method: String,
20+
sourcePath: String
21+
)
22+
23+
object Location {
24+
def apply(tree: Tree)(using ctx: Context): Location = {
25+
26+
val packageName = ctx.owner.denot.enclosingPackageClass.name.toSimpleName.toString()
27+
val className = ctx.owner.denot.enclosingClass.name.toSimpleName.toString()
28+
29+
Location(
30+
packageName,
31+
className,
32+
s"$packageName.$className",
33+
"Class" /* TODO refine this further */,
34+
ctx.owner.denot.enclosingMethod.name.toSimpleName.toString(),
35+
ctx.source.file.absolute.toString()
36+
)
37+
}
38+
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
package dotty.tools.dotc
2+
package coverage
3+
4+
import java.io._
5+
6+
import scala.io.Source
7+
8+
object Serializer {
9+
10+
val coverageFileName = "scoverage.coverage"
11+
val coverageDataFormatVersion = "3.0"
12+
// Write out coverage data to the given data directory, using the default coverage filename
13+
def serialize(coverage: Coverage, dataDir: String, sourceRoot: String): Unit =
14+
serialize(coverage, coverageFile(dataDir), new File(sourceRoot))
15+
16+
// Write out coverage data to given file.
17+
def serialize(coverage: Coverage, file: File, sourceRoot: File): Unit = {
18+
val writer = new BufferedWriter(new FileWriter(file))
19+
serialize(coverage, writer, sourceRoot)
20+
writer.close()
21+
}
22+
23+
def serialize(coverage: Coverage, writer: Writer, sourceRoot: File): Unit = {
24+
25+
def getRelativePath(filePath: String): String = {
26+
val base = sourceRoot.getCanonicalFile().toPath()
27+
val relPath = base.relativize(new File(filePath).getCanonicalFile().toPath())
28+
relPath.toString
29+
}
30+
31+
def writeHeader(writer: Writer): Unit = {
32+
writer.write(s"""# Coverage data, format version: $coverageDataFormatVersion
33+
|# Statement data:
34+
|# - id
35+
|# - source path
36+
|# - package name
37+
|# - class name
38+
|# - class type (Class, Object or Trait)
39+
|# - full class name
40+
|# - method name
41+
|# - start offset
42+
|# - end offset
43+
|# - line number
44+
|# - symbol name
45+
|# - tree name
46+
|# - is branch
47+
|# - invocations count
48+
|# - is ignored
49+
|# - description (can be multi-line)
50+
|# '\f' sign
51+
|# ------------------------------------------
52+
|""".stripMargin)
53+
}
54+
def writeStatement(stmt: Statement, writer: Writer): Unit = {
55+
writer.write(s"""${stmt.id}
56+
|${getRelativePath(stmt.location.sourcePath)}
57+
|${stmt.location.packageName}
58+
|${stmt.location.className}
59+
|${stmt.location.classType}
60+
|${stmt.location.fullClassName}
61+
|${stmt.location.method}
62+
|${stmt.start}
63+
|${stmt.end}
64+
|${stmt.line}
65+
|${stmt.symbolName}
66+
|${stmt.treeName}
67+
|${stmt.branch}
68+
|${stmt.count}
69+
|${stmt.ignored}
70+
|${stmt.desc}
71+
|\f
72+
|""".stripMargin)
73+
}
74+
75+
writeHeader(writer)
76+
coverage.statements.toVector
77+
.sortBy(_.id)
78+
.foreach(stmt => writeStatement(stmt, writer))
79+
}
80+
81+
def coverageFile(dataDir: File): File = coverageFile(dataDir.getAbsolutePath)
82+
def coverageFile(dataDir: String): File = new File(dataDir, coverageFileName)
83+
}
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
package dotty.tools.dotc
2+
package transform
3+
4+
import java.io.File
5+
import java.util.concurrent.atomic.AtomicInteger
6+
7+
import collection.mutable
8+
import core.Flags.JavaDefined
9+
import dotty.tools.dotc.core.Contexts.Context
10+
import dotty.tools.dotc.core.DenotTransformers.IdentityDenotTransformer
11+
import dotty.tools.dotc.coverage.Coverage
12+
import dotty.tools.dotc.coverage.Statement
13+
import dotty.tools.dotc.coverage.Serializer
14+
import dotty.tools.dotc.coverage.Location
15+
import dotty.tools.dotc.core.Symbols.defn
16+
import dotty.tools.dotc.core.Symbols.Symbol
17+
import dotty.tools.dotc.core.Decorators.toTermName
18+
import dotty.tools.dotc.util.SourcePosition
19+
import dotty.tools.dotc.core.Constants.Constant
20+
import dotty.tools.dotc.typer.LiftCoverage
21+
22+
import scala.quoted
23+
24+
/** Phase that implements code coverage, executed when the "-coverage
25+
* OUTPUT_PATH" is added to the compilation.
26+
*/
27+
class CoverageTransformMacro extends MacroTransform with IdentityDenotTransformer {
28+
import ast.tpd._
29+
30+
override def phaseName = "coverage"
31+
32+
// Atomic counter used for assignation of IDs to difference statements
33+
val statementId = new AtomicInteger(0)
34+
35+
var outputPath = ""
36+
37+
// Main class used to store all instrumented statements
38+
val coverage = new Coverage
39+
40+
override def run(using ctx: Context): Unit = {
41+
42+
if (ctx.settings.coverageOutputDir.value.nonEmpty) {
43+
outputPath = ctx.settings.coverageOutputDir.value
44+
45+
// Ensure the dir exists
46+
val dataDir = new File(outputPath)
47+
val newlyCreated = dataDir.mkdirs()
48+
49+
if (!newlyCreated) {
50+
// If the directory existed before, let's clean it up.
51+
dataDir.listFiles
52+
.filter(_.getName.startsWith("scoverage"))
53+
.foreach(_.delete)
54+
}
55+
56+
super.run
57+
58+
59+
Serializer.serialize(coverage, outputPath, ctx.settings.coverageSourceroot.value)
60+
}
61+
}
62+
63+
protected def newTransformer(using Context): Transformer =
64+
new CoverageTransormer
65+
66+
class CoverageTransormer extends Transformer {
67+
var instrumented = false
68+
69+
override def transform(tree: Tree)(using Context): Tree = {
70+
tree match {
71+
case tree: If =>
72+
cpy.If(tree)(
73+
cond = transform(tree.cond),
74+
thenp = instrument(transform(tree.thenp), branch = true),
75+
elsep = instrument(transform(tree.elsep), branch = true)
76+
)
77+
case tree: Try =>
78+
cpy.Try(tree)(
79+
expr = instrument(transform(tree.expr), branch = true),
80+
cases = instrumentCasees(tree.cases),
81+
finalizer = instrument(transform(tree.finalizer), true)
82+
)
83+
case Apply(fun, _)
84+
if (
85+
fun.symbol.exists &&
86+
fun.symbol.isInstanceOf[Symbol] &&
87+
fun.symbol == defn.Boolean_&& || fun.symbol == defn.Boolean_||
88+
) =>
89+
super.transform(tree)
90+
case tree @ Apply(fun, args) if (fun.isInstanceOf[Apply]) =>
91+
// We have nested apply, we have to lift all arguments
92+
// Example: def T(x:Int)(y:Int)
93+
// T(f())(1) // should not be changed to {val $x = f(); T($x)}(1) but to {val $x = f(); val $y = 1; T($x)($y)}
94+
liftApply(tree)
95+
case tree: Apply =>
96+
if (LiftCoverage.needsLift(tree)) {
97+
liftApply(tree)
98+
} else {
99+
super.transform(tree)
100+
}
101+
case Select(qual, _) if (qual.symbol.exists && qual.symbol.is(JavaDefined)) =>
102+
//Java class can't be used as a value, we can't instrument the
103+
//qualifier ({<Probe>;System}.xyz() is not possible !) instrument it
104+
//as it is
105+
instrument(tree)
106+
case tree: Select =>
107+
if (tree.qualifier.isInstanceOf[New]) {
108+
instrument(tree)
109+
} else {
110+
cpy.Select(tree)(transform(tree.qualifier), tree.name)
111+
}
112+
case tree: CaseDef => instrumentCaseDef(tree)
113+
114+
case tree: Literal => instrument(tree)
115+
case tree: Ident if (isWildcardArg(tree)) =>
116+
// We don't want to instrument wildcard arguments. `var a = _` can't be instrumented
117+
tree
118+
case tree: New => instrument(tree)
119+
case tree: This => instrument(tree)
120+
case tree: Super => instrument(tree)
121+
case tree: PackageDef =>
122+
// We don't instrument the pid of the package, but we do instrument the statements
123+
cpy.PackageDef(tree)(tree.pid, transform(tree.stats))
124+
case tree: Assign => cpy.Assign(tree)(tree.lhs, transform(tree.rhs))
125+
case tree: Template =>
126+
// Don't instrument the parents (extends) of a template since it
127+
// causes problems if the parent constructor takes parameters
128+
cpy.Template(tree)(
129+
constr = super.transformSub(tree.constr),
130+
body = transform(tree.body)
131+
)
132+
case tree: Import => tree
133+
// Catch EmptyTree since we can't match directly on it
134+
case tree: Thicket if tree.isEmpty => tree
135+
// For everything else just recurse and transform
136+
case _ =>
137+
report.warning(
138+
"Unmatched: " + tree.getClass + " " + tree.symbol,
139+
tree.sourcePos
140+
)
141+
super.transform(tree)
142+
}
143+
}
144+
145+
def liftApply(tree: Apply)(using Context) = {
146+
val buffer = mutable.ListBuffer[Tree]()
147+
// NOTE: that if only one arg needs to be lifted, we just lift everything
148+
val lifted = LiftCoverage.liftForCoverage(buffer, tree)
149+
val instrumented = buffer.toList.map(transform)
150+
//We can now instrument the apply as it is with a custom position to point to the function
151+
Block(
152+
instrumented,
153+
instrument(
154+
lifted,
155+
tree.sourcePos,
156+
false
157+
)
158+
)
159+
}
160+
161+
def instrumentCasees(cases: List[CaseDef])(using Context): List[CaseDef] = {
162+
cases.map(instrumentCaseDef)
163+
}
164+
165+
def instrumentCaseDef(tree: CaseDef)(using Context): CaseDef = {
166+
cpy.CaseDef(tree)(tree.pat, transform(tree.guard), transform(tree.body))
167+
}
168+
169+
def instrument(tree: Tree, branch: Boolean = false)(using Context): Tree = {
170+
instrument(tree, tree.sourcePos, branch)
171+
}
172+
173+
def instrument(tree: Tree, pos: SourcePosition, branch: Boolean)(using ctx: Context): Tree = {
174+
if (pos.exists && !pos.span.isZeroExtent && !tree.isType) {
175+
val id = statementId.incrementAndGet()
176+
val statement = new Statement(
177+
source = ctx.source.file.name,
178+
location = Location(tree),
179+
id = id,
180+
start = pos.start,
181+
end = pos.end,
182+
line = ctx.source.offsetToLine(pos.point),
183+
desc = tree.source.content.slice(pos.start, pos.end).mkString,
184+
symbolName = tree.symbol.name.toSimpleName.toString(),
185+
treeName = tree.getClass.getSimpleName,
186+
branch
187+
)
188+
coverage.addStatement(statement)
189+
Block(List(invokeCall(id)), tree)
190+
} else {
191+
tree
192+
}
193+
}
194+
195+
def invokeCall(id: Int)(using Context): Tree = {
196+
ref(defn.InvokerModuleRef)
197+
.select("invoked".toTermName)
198+
.appliedToArgs(
199+
List(Literal(Constant(id)), Literal(Constant(outputPath)))
200+
)
201+
}
202+
}
203+
204+
}

0 commit comments

Comments
 (0)