Skip to content

Commit 0715804

Browse files
committed
Merge pull request #26 from RichardBradley/add-exclude-comments
Add support for coverage exclusion comments
2 parents c4f34cb + 5396129 commit 0715804

File tree

5 files changed

+279
-61
lines changed

5 files changed

+279
-61
lines changed

README.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,26 @@ project you will need to use one of the build plugins:
9494
If you want to write a tool that uses this code coverage library then it is available on maven central.
9595
Search for scalac-scoverage-plugin.
9696

97+
#### Excluding code from coverage stats
98+
99+
You can exclude whole classes or packages by name. Pass a semicolon separated
100+
list of regexes to the 'excludedPackages' option.
101+
102+
For example:
103+
-P:scoverage:excludedPackages:.*\.utils\..*;.*\.SomeClass;org\.apache\..*
104+
105+
The regular expressions are matched against the fully qualified class name, and must match the entire string to take effect.
106+
107+
Any matched classes will not be instrumented or included in the coverage report.
108+
109+
You can also mark sections of code with comments like:
110+
111+
// $COVERAGE-OFF$
112+
...
113+
// $COVERAGE-ON$
114+
115+
Any code between two such comments will not be instrumented or included in the coverage report.
116+
97117
### Alternatives
98118

99119
There are still only a few code coverage tools for Scala. Here are two that we know of:

build.sbt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ name := "scalac-scoverage-plugin"
22

33
organization := "org.scoverage"
44

5-
version := "0.97.0"
5+
version := "0.98.0"
66

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

Lines changed: 72 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,76 @@
11
package scoverage
22

3-
/** @author Stephen Samuel */
3+
import scala.collection.mutable
4+
import scala.reflect.internal.util.SourceFile
5+
import scala.reflect.internal.util.Position
6+
7+
/**
8+
* Methods related to filtering the instrumentation and coverage.
9+
*
10+
* @author Stephen Samuel
11+
*/
412
class CoverageFilter(excludedPackages: Seq[String]) {
5-
def isIncluded(className: String): Boolean = {
6-
excludedPackages.isEmpty || !excludedPackages.exists(_.r.pattern.matcher(className).matches)
13+
14+
val excludedClassNamePatterns = excludedPackages.map(_.r.pattern)
15+
/**
16+
* We cache the excluded ranges to avoid scanning the source code files
17+
* repeatedly. For a large project there might be a lot of source code
18+
* data, so we only hold a weak reference.
19+
*/
20+
val linesExcludedByScoverageCommentsCache: mutable.Map[SourceFile, List[Range]] =
21+
mutable.WeakHashMap.empty
22+
23+
final val scoverageExclusionCommentsRegex =
24+
"""(?ms)^\s*//\s*(\$COVERAGE-OFF\$).*?(^\s*//\s*\$COVERAGE-ON\$|\Z)""".r
25+
26+
/**
27+
* True if the given className has not been excluded by the
28+
* `excludedPackages` option.
29+
*/
30+
def isClassIncluded(className: String): Boolean = {
31+
excludedClassNamePatterns.isEmpty ||
32+
!excludedClassNamePatterns.exists(_.matcher(className).matches)
33+
}
34+
35+
/**
36+
* True if the line containing `position` has not been excluded by a magic comment.
37+
*/
38+
def isLineIncluded(position: Position): Boolean = {
39+
if (position.isDefined) {
40+
val excludedLineNumbers = getExcludedLineNumbers(position.source)
41+
val lineNumber = position.line
42+
!excludedLineNumbers.exists(_.contains(lineNumber))
43+
} else {
44+
true
45+
}
46+
}
47+
48+
/**
49+
* Checks the given sourceFile for any magic comments which exclude lines
50+
* from coverage. Returns a list of Ranges of lines that should be excluded.
51+
*
52+
* The line numbers returned are conventional 1-based line numbers (i.e. the
53+
* first line is line number 1)
54+
*/
55+
def getExcludedLineNumbers(sourceFile: SourceFile): List[Range] = {
56+
linesExcludedByScoverageCommentsCache.get(sourceFile) match {
57+
case Some(lineNumbers) => lineNumbers
58+
case None => {
59+
val lineNumbers = scoverageExclusionCommentsRegex.findAllIn(sourceFile.content).matchData.map { m =>
60+
// Asking a SourceFile for the line number of the char after
61+
// the end of the file gives an exception
62+
val endChar = math.min(m.end(2), sourceFile.content.length - 1)
63+
// Most of the compiler API appears to use conventional
64+
// 1-based line numbers (e.g. "Position.line"), but it appears
65+
// that the "offsetToLine" method in SourceFile uses 0-based
66+
// line numbers
67+
Range(
68+
1 + sourceFile.offsetToLine(m.start(1)),
69+
1 + sourceFile.offsetToLine(endChar))
70+
}.toList
71+
linesExcludedByScoverageCommentsCache.put(sourceFile, lineNumbers)
72+
lineNumbers
73+
}
74+
}
775
}
8-
}
76+
}

src/main/scala/scoverage/plugin.scala

Lines changed: 81 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -10,26 +10,30 @@ import java.util.concurrent.atomic.AtomicInteger
1010
/** @author Stephen Samuel */
1111
class ScoveragePlugin(val global: Global) extends Plugin {
1212

13-
val name: String = "scoverage"
14-
val description: String = "scoverage code coverage compiler plugin"
15-
val opts = new ScoverageOptions
16-
val components: List[PluginComponent] = List(new ScoverageComponent(global, opts))
17-
18-
override def processOptions(options: List[String], error: String => Unit) {
19-
for ( opt <- options ) {
13+
override val name: String = "scoverage"
14+
override val description: String = "scoverage code coverage compiler plugin"
15+
val component = new ScoverageComponent(global)
16+
override val components: List[PluginComponent] = List(component)
17+
18+
override def processOptions(opts: List[String], error: String => Unit) {
19+
val options = new ScoverageOptions
20+
for ( opt <- opts ) {
2021
if (opt.startsWith("excludedPackages:")) {
21-
opts.excludedPackages = opt.substring("excludedPackages:".length).split(";").map(_.trim).filterNot(_.isEmpty)
22+
options.excludedPackages = opt.substring("excludedPackages:".length).split(";").map(_.trim).filterNot(_.isEmpty)
2223
} else if (opt.startsWith("dataDir:")) {
23-
opts.dataDir = opt.substring("dataDir:".length)
24+
options.dataDir = opt.substring("dataDir:".length)
2425
} else {
2526
error("Unknown option: " + opt)
2627
}
2728
}
29+
component.setOptions(options)
2830
}
2931

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

@@ -38,16 +42,38 @@ class ScoverageOptions {
3842
var dataDir: String = _
3943
}
4044

41-
class ScoverageComponent(val global: Global, options: ScoverageOptions)
42-
extends PluginComponent with TypingTransformers with Transform with TreeDSL {
45+
class ScoverageComponent(
46+
val global: Global)
47+
extends PluginComponent
48+
with TypingTransformers
49+
with Transform
50+
with TreeDSL {
4351

4452
import global._
4553

4654
val statementIds = new AtomicInteger(0)
4755
val coverage = new Coverage
48-
val phaseName: String = "scoverage"
49-
val runsAfter: List[String] = List("typer")
56+
override val phaseName: String = "scoverage"
57+
override val runsAfter: List[String] = List("typer")
5058
override val runsBefore = List[String]("patmat")
59+
/**
60+
* Our options are not provided at construction time, but shortly after,
61+
* so they start as None.
62+
* You must call "setOptions" before running any commands that rely on
63+
* the options.
64+
*/
65+
private var _options: Option[ScoverageOptions] = None
66+
private var coverageFilter: Option[CoverageFilter] = None
67+
68+
private def options: ScoverageOptions = {
69+
require(_options.nonEmpty, "You must first call \"setOptions\"")
70+
_options.get
71+
}
72+
73+
def setOptions(options: ScoverageOptions): Unit = {
74+
_options = Some(options)
75+
coverageFilter = Some(new CoverageFilter(options.excludedPackages))
76+
}
5177

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

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

7197
var location: Location = null
7298

73-
def safeStart(tree: Tree): Int = if (tree.pos.isDefined) tree.pos.start else -1
74-
def safeEnd(tree: Tree): Int = if (tree.pos.isDefined) tree.pos.end else -1
99+
/**
100+
* The 'start' of the position, if it is available, else -1
101+
* We cannot use 'isDefined' to test whether pos.start will work, as some
102+
* classes (e.g. [[scala.reflect.internal.util.OffsetPosition]] have
103+
* isDefined true, but throw on `start`
104+
*/
105+
def safeStart(tree: Tree): Int = scala.util.Try(tree.pos.start).getOrElse(-1)
106+
def safeEnd(tree: Tree): Int = scala.util.Try(tree.pos.end).getOrElse(-1)
75107
def safeLine(tree: Tree): Int = if (tree.pos.isDefined) tree.pos.line else -1
76108
def safeSource(tree: Tree): Option[SourceFile] = if (tree.pos.isDefined) Some(tree.pos.source) else None
77109

@@ -117,25 +149,28 @@ class ScoverageComponent(val global: Global, options: ScoverageOptions)
117149
println(s"[warn] Could not instrument [${tree.getClass.getSimpleName}/${tree.symbol}]. No position.")
118150
tree
119151
case Some(source) =>
120-
121-
val id = statementIds.incrementAndGet
122-
val statement = MeasuredStatement(
123-
source.path,
124-
location,
125-
id,
126-
safeStart(tree),
127-
safeEnd(tree),
128-
safeLine(tree),
129-
tree.toString(),
130-
Option(tree.symbol).map(_.fullNameString).getOrElse("<nosymbol>"),
131-
tree.getClass.getSimpleName,
132-
branch
133-
)
134-
coverage.add(statement)
135-
136-
val apply = invokeCall(id)
137-
val block = Block(List(apply), tree)
138-
localTyper.typed(atPos(tree.pos)(block))
152+
if (tree.pos.isDefined && !isStatementIncluded(tree.pos)) {
153+
tree
154+
} else {
155+
val id = statementIds.incrementAndGet
156+
val statement = MeasuredStatement(
157+
source.path,
158+
location,
159+
id,
160+
safeStart(tree),
161+
safeEnd(tree),
162+
safeLine(tree),
163+
tree.toString(),
164+
Option(tree.symbol).map(_.fullNameString).getOrElse("<nosymbol>"),
165+
tree.getClass.getSimpleName,
166+
branch
167+
)
168+
coverage.add(statement)
169+
170+
val apply = invokeCall(id)
171+
val block = Block(List(apply), tree)
172+
localTyper.typed(atPos(tree.pos)(block))
173+
}
139174
}
140175
}
141176

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

156-
def isIncluded(t: Tree): Boolean = {
157-
new CoverageFilter(options.excludedPackages).isIncluded(t.symbol.fullNameString)
191+
def isClassIncluded(symbol: Symbol): Boolean = {
192+
coverageFilter.get.isClassIncluded(symbol.fullNameString)
193+
}
194+
195+
def isStatementIncluded(pos: Position): Boolean = {
196+
coverageFilter.get.isLineIncluded(pos)
158197
}
159198

160199
def className(s: Symbol): String = {
@@ -275,21 +314,21 @@ class ScoverageComponent(val global: Global, options: ScoverageOptions)
275314
// special support to handle partial functions
276315
case c: ClassDef if c.symbol.isAnonymousFunction &&
277316
c.symbol.enclClass.superClass.nameString.contains("AbstractPartialFunction") =>
278-
if (isIncluded(c))
317+
if (isClassIncluded(c.symbol))
279318
transformPartial(c)
280319
else
281320
c
282321

283322
// scalac generated classes, we just instrument the enclosed methods/statments
284323
// the location would stay as the source class
285324
case c: ClassDef if c.symbol.isAnonymousClass || c.symbol.isAnonymousFunction =>
286-
if (isIncluded(c))
325+
if (isClassIncluded(c.symbol))
287326
super.transform(tree)
288327
else
289328
c
290329

291330
case c: ClassDef =>
292-
if (isIncluded(c)) {
331+
if (isClassIncluded(c.symbol)) {
293332
updateLocation(c.symbol)
294333
super.transform(tree)
295334
}
@@ -386,7 +425,7 @@ class ScoverageComponent(val global: Global, options: ScoverageOptions)
386425

387426
// user defined objects
388427
case m: ModuleDef =>
389-
if (isIncluded(m)) {
428+
if (isClassIncluded(m.symbol)) {
390429
updateLocation(m.symbol)
391430
super.transform(tree)
392431
}
@@ -422,7 +461,7 @@ class ScoverageComponent(val global: Global, options: ScoverageOptions)
422461
case n: New => super.transform(n)
423462

424463
case p: PackageDef =>
425-
if (isIncluded(p)) treeCopy.PackageDef(p, p.pid, transformStatements(p.stats))
464+
if (isClassIncluded(p.symbol)) treeCopy.PackageDef(p, p.pid, transformStatements(p.stats))
426465
else p
427466

428467
// This AST node corresponds to the following Scala code: `return` expr

0 commit comments

Comments
 (0)