Skip to content

Commit

Permalink
Add in initial support for code coverage.
Browse files Browse the repository at this point in the history
  • Loading branch information
ckipp01 committed Nov 4, 2021
1 parent 83effc3 commit 0f7e8fc
Show file tree
Hide file tree
Showing 21 changed files with 3,892 additions and 2 deletions.
1 change: 1 addition & 0 deletions compiler/src/dotty/tools/dotc/Compiler.scala
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ class Compiler {
List(new sjs.PrepJSInterop) :: // Additional checks and transformations for Scala.js (Scala.js only)
List(new sbt.ExtractAPI) :: // Sends a representation of the API of classes to sbt via callbacks
List(new SetRootTree) :: // Set the `rootTreeOrProvider` on class symbols
List(new CoverageTransformMacro) :: // Perform instrumentation for coverage transform (if -coverage is present)
Nil

/** Phases dealing with TASTY tree pickling and unpickling */
Expand Down
3 changes: 3 additions & 0 deletions compiler/src/dotty/tools/dotc/config/ScalaSettings.scala
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,9 @@ trait CommonScalaSettings:
val explainTypes: Setting[Boolean] = BooleanSetting("-explain-types", "Explain type errors in more detail (deprecated, use -explain instead).", aliases = List("--explain-types", "-explaintypes"))
val unchecked: Setting[Boolean] = BooleanSetting("-unchecked", "Enable additional warnings where generated code depends on assumptions.", initialValue = true, aliases = List("--unchecked"))
val language: Setting[List[String]] = MultiStringSetting("-language", "feature", "Enable one or more language features.", aliases = List("--language"))
/* Coverage settings */
val coverageOutputDir = PathSetting("-coverage", "Destination for coverage classfiles and instrumentation data.", "")
val coverageSourceroot = PathSetting("-coverage-sourceroot", "An alternative root dir of your sources used to relativize.", ".")

/* Other settings */
val encoding: Setting[String] = StringSetting("-encoding", "encoding", "Specify character encoding used by source files.", Properties.sourceEncoding, aliases = List("--encoding"))
Expand Down
3 changes: 3 additions & 0 deletions compiler/src/dotty/tools/dotc/core/Definitions.scala
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import typer.ImportInfo.RootRef
import Comments.CommentsContext
import Comments.Comment
import util.Spans.NoSpan
import Symbols.requiredModuleRef

import scala.annotation.tailrec

Expand Down Expand Up @@ -461,6 +462,8 @@ class Definitions {
}
def NullType: TypeRef = NullClass.typeRef

@tu lazy val InvokerModuleRef = requiredMethodRef("scala.runtime.Invoker")

@tu lazy val ImplicitScrutineeTypeSym =
newPermanentSymbol(ScalaPackageClass, tpnme.IMPLICITkw, EmptyFlags, TypeBounds.empty).entered
def ImplicitScrutineeTypeRef: TypeRef = ImplicitScrutineeTypeSym.typeRef
Expand Down
30 changes: 30 additions & 0 deletions compiler/src/dotty/tools/dotc/coverage/Coverage.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package dotty.tools.dotc
package coverage

import scala.collection.mutable

class Coverage {
private val statementsById = mutable.Map[Int, Statement]()

def statements = statementsById.values

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

case class Statement(
source: String,
location: Location,
id: Int,
start: Int,
end: Int,
line: Int,
desc: String,
symbolName: String,
treeName: String,
branch: Boolean,
var count: Int = 0,
ignored: Boolean = false
) {
def invoked(): Unit = count = count + 1
def isInvoked = count > 0
}
38 changes: 38 additions & 0 deletions compiler/src/dotty/tools/dotc/coverage/Location.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package dotty.tools.dotc
package coverage

import ast.tpd._
import dotty.tools.dotc.core.Contexts.Context

/** @param packageName
* the name of the encosing package
* @param className
* the name of the closes enclosing class
* @param fullClassName
* the fully qualified name of the closest enclosing class
*/
final case class Location(
packageName: String,
className: String,
fullClassName: String,
classType: String,
method: String,
sourcePath: String
)

object Location {
def apply(tree: Tree)(using ctx: Context): Location = {

val packageName = ctx.owner.denot.enclosingPackageClass.name.toSimpleName.toString()
val className = ctx.owner.denot.enclosingClass.name.toSimpleName.toString()

Location(
packageName,
className,
s"$packageName.$className",
"Class" /* TODO refine this further */,
ctx.owner.denot.enclosingMethod.name.toSimpleName.toString(),
ctx.source.file.absolute.toString()
)
}
}
83 changes: 83 additions & 0 deletions compiler/src/dotty/tools/dotc/coverage/Serializer.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package dotty.tools.dotc
package coverage

import java.io._

import scala.io.Source

object Serializer {

val coverageFileName = "scoverage.coverage"
val coverageDataFormatVersion = "3.0"
// 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, coverageFile(dataDir), new File(sourceRoot))

// Write out coverage data to given file.
def serialize(coverage: Coverage, file: File, sourceRoot: File): Unit = {
val writer = new BufferedWriter(new FileWriter(file))
serialize(coverage, writer, sourceRoot)
writer.close()
}

def serialize(coverage: Coverage, writer: Writer, sourceRoot: File): Unit = {

def getRelativePath(filePath: String): String = {
val base = sourceRoot.getCanonicalFile().toPath()
val relPath = base.relativize(new File(filePath).getCanonicalFile().toPath())
relPath.toString
}

def writeHeader(writer: Writer): Unit = {
writer.write(s"""# Coverage data, format version: $coverageDataFormatVersion
|# 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)
|# '\f' sign
|# ------------------------------------------
|""".stripMargin)
}
def writeStatement(stmt: Statement, writer: Writer): Unit = {
writer.write(s"""${stmt.id}
|${getRelativePath(stmt.location.sourcePath)}
|${stmt.location.packageName}
|${stmt.location.className}
|${stmt.location.classType}
|${stmt.location.fullClassName}
|${stmt.location.method}
|${stmt.start}
|${stmt.end}
|${stmt.line}
|${stmt.symbolName}
|${stmt.treeName}
|${stmt.branch}
|${stmt.count}
|${stmt.ignored}
|${stmt.desc}
|\f
|""".stripMargin)
}

writeHeader(writer)
coverage.statements.toVector
.sortBy(_.id)
.foreach(stmt => writeStatement(stmt, writer))
}

def coverageFile(dataDir: File): File = coverageFile(dataDir.getAbsolutePath)
def coverageFile(dataDir: String): File = new File(dataDir, coverageFileName)
}
204 changes: 204 additions & 0 deletions compiler/src/dotty/tools/dotc/transform/CoverageTransformMacro.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
package dotty.tools.dotc
package transform

import java.io.File
import java.util.concurrent.atomic.AtomicInteger

import collection.mutable
import core.Flags.JavaDefined
import dotty.tools.dotc.core.Contexts.Context
import dotty.tools.dotc.core.DenotTransformers.IdentityDenotTransformer
import dotty.tools.dotc.coverage.Coverage
import dotty.tools.dotc.coverage.Statement
import dotty.tools.dotc.coverage.Serializer
import dotty.tools.dotc.coverage.Location
import dotty.tools.dotc.core.Symbols.defn
import dotty.tools.dotc.core.Symbols.Symbol
import dotty.tools.dotc.core.Decorators.toTermName
import dotty.tools.dotc.util.SourcePosition
import dotty.tools.dotc.core.Constants.Constant
import dotty.tools.dotc.typer.LiftCoverage

import scala.quoted

/** Phase that implements code coverage, executed when the "-coverage
* OUTPUT_PATH" is added to the compilation.
*/
class CoverageTransformMacro extends MacroTransform with IdentityDenotTransformer {
import ast.tpd._

override def phaseName = "coverage"

// Atomic counter used for assignation of IDs to difference statements
val statementId = new AtomicInteger(0)

var outputPath = ""

// Main class used to store all instrumented statements
val coverage = new Coverage

override def run(using ctx: Context): Unit = {

if (ctx.settings.coverageOutputDir.value.nonEmpty) {
outputPath = ctx.settings.coverageOutputDir.value

// Ensure the dir exists
val dataDir = new File(outputPath)
val newlyCreated = dataDir.mkdirs()

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

super.run


Serializer.serialize(coverage, outputPath, ctx.settings.coverageSourceroot.value)
}
}

protected def newTransformer(using Context): Transformer =
new CoverageTransormer

class CoverageTransormer extends Transformer {
var instrumented = false

override def transform(tree: Tree)(using Context): Tree = {
tree match {
case tree: If =>
cpy.If(tree)(
cond = transform(tree.cond),
thenp = instrument(transform(tree.thenp), branch = true),
elsep = instrument(transform(tree.elsep), branch = true)
)
case tree: Try =>
cpy.Try(tree)(
expr = instrument(transform(tree.expr), branch = true),
cases = instrumentCasees(tree.cases),
finalizer = instrument(transform(tree.finalizer), true)
)
case Apply(fun, _)
if (
fun.symbol.exists &&
fun.symbol.isInstanceOf[Symbol] &&
fun.symbol == defn.Boolean_&& || fun.symbol == defn.Boolean_||
) =>
super.transform(tree)
case tree @ Apply(fun, args) if (fun.isInstanceOf[Apply]) =>
// We have nested apply, we have to lift all arguments
// Example: def T(x:Int)(y:Int)
// 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)}
liftApply(tree)
case tree: Apply =>
if (LiftCoverage.needsLift(tree)) {
liftApply(tree)
} else {
super.transform(tree)
}
case Select(qual, _) if (qual.symbol.exists && qual.symbol.is(JavaDefined)) =>
//Java class can't be used as a value, we can't instrument the
//qualifier ({<Probe>;System}.xyz() is not possible !) instrument it
//as it is
instrument(tree)
case tree: Select =>
if (tree.qualifier.isInstanceOf[New]) {
instrument(tree)
} else {
cpy.Select(tree)(transform(tree.qualifier), tree.name)
}
case tree: CaseDef => instrumentCaseDef(tree)

case tree: Literal => instrument(tree)
case tree: Ident if (isWildcardArg(tree)) =>
// We don't want to instrument wildcard arguments. `var a = _` can't be instrumented
tree
case tree: New => instrument(tree)
case tree: This => instrument(tree)
case tree: Super => instrument(tree)
case tree: PackageDef =>
// We don't instrument the pid of the package, but we do instrument the statements
cpy.PackageDef(tree)(tree.pid, transform(tree.stats))
case tree: Assign => cpy.Assign(tree)(tree.lhs, transform(tree.rhs))
case tree: Template =>
// Don't instrument the parents (extends) of a template since it
// causes problems if the parent constructor takes parameters
cpy.Template(tree)(
constr = super.transformSub(tree.constr),
body = transform(tree.body)
)
case tree: Import => tree
// Catch EmptyTree since we can't match directly on it
case tree: Thicket if tree.isEmpty => tree
// For everything else just recurse and transform
case _ =>
report.warning(
"Unmatched: " + tree.getClass + " " + tree.symbol,
tree.sourcePos
)
super.transform(tree)
}
}

def liftApply(tree: Apply)(using Context) = {
val buffer = mutable.ListBuffer[Tree]()
// NOTE: that if only one arg needs to be lifted, we just lift everything
val lifted = LiftCoverage.liftForCoverage(buffer, tree)
val instrumented = buffer.toList.map(transform)
//We can now instrument the apply as it is with a custom position to point to the function
Block(
instrumented,
instrument(
lifted,
tree.sourcePos,
false
)
)
}

def instrumentCasees(cases: List[CaseDef])(using Context): List[CaseDef] = {
cases.map(instrumentCaseDef)
}

def instrumentCaseDef(tree: CaseDef)(using Context): CaseDef = {
cpy.CaseDef(tree)(tree.pat, transform(tree.guard), transform(tree.body))
}

def instrument(tree: Tree, branch: Boolean = false)(using Context): Tree = {
instrument(tree, tree.sourcePos, branch)
}

def instrument(tree: Tree, pos: SourcePosition, branch: Boolean)(using ctx: Context): Tree = {
if (pos.exists && !pos.span.isZeroExtent && !tree.isType) {
val id = statementId.incrementAndGet()
val statement = new Statement(
source = ctx.source.file.name,
location = Location(tree),
id = id,
start = pos.start,
end = pos.end,
line = ctx.source.offsetToLine(pos.point),
desc = tree.source.content.slice(pos.start, pos.end).mkString,
symbolName = tree.symbol.name.toSimpleName.toString(),
treeName = tree.getClass.getSimpleName,
branch
)
coverage.addStatement(statement)
Block(List(invokeCall(id)), tree)
} else {
tree
}
}

def invokeCall(id: Int)(using Context): Tree = {
ref(defn.InvokerModuleRef)
.select("invoked".toTermName)
.appliedToArgs(
List(Literal(Constant(id)), Literal(Constant(outputPath)))
)
}
}

}
Loading

0 comments on commit 0f7e8fc

Please sign in to comment.