Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Finish support for code coverage #14620

Closed
wants to merge 3 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -59,6 +59,7 @@ class Compiler {

/** Phases dealing with the transformation from pickled trees to backend trees */
protected def transformPhases: List[List[Phase]] =
List(new InstrumentCoverage) :: // Perform instrumentation for code coverage (if -coverage setting is set)
List(new FirstTransform, // Some transformations to put trees into a canonical form
new CheckReentrant, // Internal use only: Check that compiled program has no data races involving global vars
new ElimPackagePrefixes, // Eliminate references to package prefixes in Select nodes
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 @@ -114,6 +114,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 @@ -13,6 +13,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 @@ -460,6 +461,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
31 changes: 31 additions & 0 deletions compiler/src/dotty/tools/dotc/coverage/Coverage.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package dotty.tools.dotc
package coverage

import scala.collection.mutable

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

def statements: Iterable[Statement] = statementsById.values

def addStatement(stmt: Statement): Unit = statementsById(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 += 1

def isInvoked: Boolean =
count > 0
36 changes: 36 additions & 0 deletions compiler/src/dotty/tools/dotc/coverage/Location.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
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()
)
79 changes: 79 additions & 0 deletions compiler/src/dotty/tools/dotc/coverage/Serializer.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package dotty.tools.dotc
package coverage

import java.io._

import scala.io.Source

object Serializer:

private val coverageFileName = "scoverage.coverage"
private 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 = File(dataDir, coverageFileName)
197 changes: 197 additions & 0 deletions compiler/src/dotty/tools/dotc/transform/InstrumentCoverage.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
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

/** Implements code coverage by inserting calls to scala.runtime.Invoker
* ("instruments" the source code).
* The result can then be consumed by the Scoverage tool.
*/
class InstrumentCoverage extends MacroTransform with IdentityDenotTransformer:
import ast.tpd._

override def phaseName = InstrumentCoverage.name

override def description = InstrumentCoverage.description

// Enabled by argument "-coverage OUTPUT_DIR"
override def isEnabled(using ctx: Context) =
ctx.settings.coverageOutputDir.value.nonEmpty

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

private var outputPath = ""

// Main class used to store all instrumented statements
private val coverage = Coverage()

override def run(using ctx: Context): Unit =
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)

override protected def newTransformer(using Context) = 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)))
)

object InstrumentCoverage:
val name: String = "instrumentCoverage"
val description: String = "instrument code for coverage cheking"
Loading