Skip to content

Add support for coverage exclusion comments #26

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

Merged
merged 3 commits into from
Mar 31, 2014
Merged
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
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,26 @@ project you will need to use one of the build plugins:
If you want to write a tool that uses this code coverage library then it is available on maven central.
Search for scalac-scoverage-plugin.

#### Excluding code from coverage stats

You can exclude whole classes or packages by name. Pass a semicolon separated
list of regexes to the 'excludedPackages' option.

For example:
-P:scoverage:excludedPackages:.*\.utils\..*;.*\.SomeClass;org\.apache\..*

The regular expressions are matched against the fully qualified class name, and must match the entire string to take effect.

Any matched classes will not be instrumented or included in the coverage report.

You can also mark sections of code with comments like:

// $COVERAGE-OFF$
...
// $COVERAGE-ON$

Any code between two such comments will not be instrumented or included in the coverage report.

### Alternatives

There are still only a few code coverage tools for Scala. Here are two that we know of:
Expand Down
2 changes: 1 addition & 1 deletion build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ name := "scalac-scoverage-plugin"

organization := "org.scoverage"

version := "0.97.0"
version := "0.98.0"

scalacOptions := Seq("-unchecked", "-deprecation", "-feature", "-encoding", "utf8")

Expand Down
76 changes: 72 additions & 4 deletions src/main/scala/scoverage/CoverageFilter.scala
Original file line number Diff line number Diff line change
@@ -1,8 +1,76 @@
package scoverage

/** @author Stephen Samuel */
import scala.collection.mutable
import scala.reflect.internal.util.SourceFile
import scala.reflect.internal.util.Position

/**
* Methods related to filtering the instrumentation and coverage.
*
* @author Stephen Samuel
*/
class CoverageFilter(excludedPackages: Seq[String]) {
def isIncluded(className: String): Boolean = {
excludedPackages.isEmpty || !excludedPackages.exists(_.r.pattern.matcher(className).matches)

val excludedClassNamePatterns = excludedPackages.map(_.r.pattern)
/**
* We cache the excluded ranges to avoid scanning the source code files
* repeatedly. For a large project there might be a lot of source code
* data, so we only hold a weak reference.
*/
val linesExcludedByScoverageCommentsCache: mutable.Map[SourceFile, List[Range]] =
mutable.WeakHashMap.empty

final val scoverageExclusionCommentsRegex =
"""(?ms)^\s*//\s*(\$COVERAGE-OFF\$).*?(^\s*//\s*\$COVERAGE-ON\$|\Z)""".r

/**
* True if the given className has not been excluded by the
* `excludedPackages` option.
*/
def isClassIncluded(className: String): Boolean = {
excludedClassNamePatterns.isEmpty ||
!excludedClassNamePatterns.exists(_.matcher(className).matches)
}

/**
* True if the line containing `position` has not been excluded by a magic comment.
*/
def isLineIncluded(position: Position): Boolean = {
if (position.isDefined) {
val excludedLineNumbers = getExcludedLineNumbers(position.source)
val lineNumber = position.line
!excludedLineNumbers.exists(_.contains(lineNumber))
} else {
true
}
}

/**
* Checks the given sourceFile for any magic comments which exclude lines
* from coverage. Returns a list of Ranges of lines that should be excluded.
*
* The line numbers returned are conventional 1-based line numbers (i.e. the
* first line is line number 1)
*/
def getExcludedLineNumbers(sourceFile: SourceFile): List[Range] = {
linesExcludedByScoverageCommentsCache.get(sourceFile) match {
case Some(lineNumbers) => lineNumbers
case None => {
val lineNumbers = scoverageExclusionCommentsRegex.findAllIn(sourceFile.content).matchData.map { m =>
// Asking a SourceFile for the line number of the char after
// the end of the file gives an exception
val endChar = math.min(m.end(2), sourceFile.content.length - 1)
// Most of the compiler API appears to use conventional
// 1-based line numbers (e.g. "Position.line"), but it appears
// that the "offsetToLine" method in SourceFile uses 0-based
// line numbers
Range(
1 + sourceFile.offsetToLine(m.start(1)),
1 + sourceFile.offsetToLine(endChar))
}.toList
linesExcludedByScoverageCommentsCache.put(sourceFile, lineNumbers)
lineNumbers
}
}
}
}
}
123 changes: 81 additions & 42 deletions src/main/scala/scoverage/plugin.scala
Original file line number Diff line number Diff line change
Expand Up @@ -10,26 +10,30 @@ import java.util.concurrent.atomic.AtomicInteger
/** @author Stephen Samuel */
class ScoveragePlugin(val global: Global) extends Plugin {

val name: String = "scoverage"
val description: String = "scoverage code coverage compiler plugin"
val opts = new ScoverageOptions
val components: List[PluginComponent] = List(new ScoverageComponent(global, opts))

override def processOptions(options: List[String], error: String => Unit) {
for ( opt <- options ) {
override val name: String = "scoverage"
override val description: String = "scoverage code coverage compiler plugin"
val component = new ScoverageComponent(global)
override val components: List[PluginComponent] = List(component)

override def processOptions(opts: List[String], error: String => Unit) {
val options = new ScoverageOptions
for ( opt <- opts ) {
if (opt.startsWith("excludedPackages:")) {
opts.excludedPackages = opt.substring("excludedPackages:".length).split(";").map(_.trim).filterNot(_.isEmpty)
options.excludedPackages = opt.substring("excludedPackages:".length).split(";").map(_.trim).filterNot(_.isEmpty)
} else if (opt.startsWith("dataDir:")) {
opts.dataDir = opt.substring("dataDir:".length)
options.dataDir = opt.substring("dataDir:".length)
} else {
error("Unknown option: " + opt)
}
}
component.setOptions(options)
}

override val optionsHelp: Option[String] = Some(Seq(
"-P:scoverage:dataDir:<pathtodatadir> where the coverage files should be written\n",
"-P:scoverage:excludedPackages:<regex>;<regex> semicolon separated list of regexs for packages to exclude\n"
"-P:scoverage:excludedPackages:<regex>;<regex> semicolon separated list of regexs for packages to exclude",
" Any classes whose fully qualified name matches the regex will",
" be excluded from coverage."
).mkString("\n"))
}

Expand All @@ -38,16 +42,38 @@ class ScoverageOptions {
var dataDir: String = _
}

class ScoverageComponent(val global: Global, options: ScoverageOptions)
extends PluginComponent with TypingTransformers with Transform with TreeDSL {
class ScoverageComponent(
val global: Global)
extends PluginComponent
with TypingTransformers
with Transform
with TreeDSL {

import global._

val statementIds = new AtomicInteger(0)
val coverage = new Coverage
val phaseName: String = "scoverage"
val runsAfter: List[String] = List("typer")
override val phaseName: String = "scoverage"
override val runsAfter: List[String] = List("typer")
override val runsBefore = List[String]("patmat")
/**
* Our options are not provided at construction time, but shortly after,
* so they start as None.
* You must call "setOptions" before running any commands that rely on
* the options.
*/
private var _options: Option[ScoverageOptions] = None
private var coverageFilter: Option[CoverageFilter] = None

private def options: ScoverageOptions = {
require(_options.nonEmpty, "You must first call \"setOptions\"")
_options.get
}

def setOptions(options: ScoverageOptions): Unit = {
_options = Some(options)
coverageFilter = Some(new CoverageFilter(options.excludedPackages))
}

override def newPhase(prev: scala.tools.nsc.Phase): Phase = new Phase(prev) {

Expand All @@ -70,8 +96,14 @@ class ScoverageComponent(val global: Global, options: ScoverageOptions)

var location: Location = null

def safeStart(tree: Tree): Int = if (tree.pos.isDefined) tree.pos.start else -1
def safeEnd(tree: Tree): Int = if (tree.pos.isDefined) tree.pos.end else -1
/**
* The 'start' of the position, if it is available, else -1
* We cannot use 'isDefined' to test whether pos.start will work, as some
* classes (e.g. [[scala.reflect.internal.util.OffsetPosition]] have
* isDefined true, but throw on `start`
*/
def safeStart(tree: Tree): Int = scala.util.Try(tree.pos.start).getOrElse(-1)
def safeEnd(tree: Tree): Int = scala.util.Try(tree.pos.end).getOrElse(-1)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this a bug in the Scala compiler API? It seems like pos.start should be OK if pos.isDefined, but the old version crashes on my project codebase. Worth raising upstream?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Prob not worth raising as its changed slightly in 2.11

def safeLine(tree: Tree): Int = if (tree.pos.isDefined) tree.pos.line else -1
def safeSource(tree: Tree): Option[SourceFile] = if (tree.pos.isDefined) Some(tree.pos.source) else None

Expand Down Expand Up @@ -117,25 +149,28 @@ class ScoverageComponent(val global: Global, options: ScoverageOptions)
println(s"[warn] Could not instrument [${tree.getClass.getSimpleName}/${tree.symbol}]. No position.")
tree
case Some(source) =>

val id = statementIds.incrementAndGet
val statement = MeasuredStatement(
source.path,
location,
id,
safeStart(tree),
safeEnd(tree),
safeLine(tree),
tree.toString(),
Option(tree.symbol).map(_.fullNameString).getOrElse("<nosymbol>"),
tree.getClass.getSimpleName,
branch
)
coverage.add(statement)

val apply = invokeCall(id)
val block = Block(List(apply), tree)
localTyper.typed(atPos(tree.pos)(block))
if (tree.pos.isDefined && !isStatementIncluded(tree.pos)) {
tree
} else {
val id = statementIds.incrementAndGet
val statement = MeasuredStatement(
source.path,
location,
id,
safeStart(tree),
safeEnd(tree),
safeLine(tree),
tree.toString(),
Option(tree.symbol).map(_.fullNameString).getOrElse("<nosymbol>"),
tree.getClass.getSimpleName,
branch
)
coverage.add(statement)

val apply = invokeCall(id)
val block = Block(List(apply), tree)
localTyper.typed(atPos(tree.pos)(block))
}
}
}

Expand All @@ -153,8 +188,12 @@ class ScoverageComponent(val global: Global, options: ScoverageOptions)
dir.getPath
}

def isIncluded(t: Tree): Boolean = {
new CoverageFilter(options.excludedPackages).isIncluded(t.symbol.fullNameString)
def isClassIncluded(symbol: Symbol): Boolean = {
coverageFilter.get.isClassIncluded(symbol.fullNameString)
}

def isStatementIncluded(pos: Position): Boolean = {
coverageFilter.get.isLineIncluded(pos)
}

def className(s: Symbol): String = {
Expand Down Expand Up @@ -275,21 +314,21 @@ class ScoverageComponent(val global: Global, options: ScoverageOptions)
// special support to handle partial functions
case c: ClassDef if c.symbol.isAnonymousFunction &&
c.symbol.enclClass.superClass.nameString.contains("AbstractPartialFunction") =>
if (isIncluded(c))
if (isClassIncluded(c.symbol))
transformPartial(c)
else
c

// scalac generated classes, we just instrument the enclosed methods/statments
// the location would stay as the source class
case c: ClassDef if c.symbol.isAnonymousClass || c.symbol.isAnonymousFunction =>
if (isIncluded(c))
if (isClassIncluded(c.symbol))
super.transform(tree)
else
c

case c: ClassDef =>
if (isIncluded(c)) {
if (isClassIncluded(c.symbol)) {
updateLocation(c.symbol)
super.transform(tree)
}
Expand Down Expand Up @@ -386,7 +425,7 @@ class ScoverageComponent(val global: Global, options: ScoverageOptions)

// user defined objects
case m: ModuleDef =>
if (isIncluded(m)) {
if (isClassIncluded(m.symbol)) {
updateLocation(m.symbol)
super.transform(tree)
}
Expand Down Expand Up @@ -422,7 +461,7 @@ class ScoverageComponent(val global: Global, options: ScoverageOptions)
case n: New => super.transform(n)

case p: PackageDef =>
if (isIncluded(p)) treeCopy.PackageDef(p, p.pid, transformStatements(p.stats))
if (isClassIncluded(p.symbol)) treeCopy.PackageDef(p, p.pid, transformStatements(p.stats))
else p

// This AST node corresponds to the following Scala code: `return` expr
Expand Down
Loading