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

Scoverage report generator #8098

Merged
merged 11 commits into from Jul 24, 2019
2 changes: 2 additions & 0 deletions 3rdparty/jvm/com/twitter/BUILD
Expand Up @@ -14,3 +14,5 @@ jar_library(name='scrooge-core',
scala_jar(org='com.twitter', name='scrooge-core', rev=SCROOGE_REV),
],
)


14 changes: 14 additions & 0 deletions 3rdparty/jvm/com/twitter/scoverage/BUILD
@@ -0,0 +1,14 @@
# Copyright 2015 Pants project contributors (see CONTRIBUTORS.md).
# Licensed under the Apache License, Version 2.0 (see LICENSE).

# Modified scoverge plugin, which writes to classpath, currently consumed from Twitter forked scoverage here:
# https://github.com/twitter-forks/scalac-scoverage-plugin. PR for the modifications
# on original scoverage repo here: https://github.com/scoverage/scalac-scoverage-plugin/pull/267.
# In future, we should ping OSS Scoverage to get that PR merged and consume scoverage directly
# from there.

jar_library(name='scalac-scoverage-plugin',
jars=[
scala_jar(org='com.twitter.scoverage', name='scalac-scoverage-plugin', rev='1.0.1-twitter'),
],
)
16 changes: 16 additions & 0 deletions 3rdparty/jvm/org/slf4j/BUILD
@@ -0,0 +1,16 @@
# Copyright 2017 Pants project contributors (see CONTRIBUTORS.md).
# Licensed under the Apache License, Version 2.0 (see LICENSE).

jar_library(
name='slf4j-simple',
jars=[
jar(org='org.slf4j', name='slf4j-simple', rev='1.7.26'),
],
)

jar_library(
name='slf4j-api',
jars=[
jar(org='org.slf4j', name='slf4j-api', rev='1.7.26'),
],
)
15 changes: 15 additions & 0 deletions src/scala/org/pantsbuild/scoverage/report/BUILD
@@ -0,0 +1,15 @@
scala_library(
provides=scala_artifact(
org='org.pantsbuild',
name='scoverage-report-generator',
repo=public,
publication_metadata=pants_library('Report Generator for scoverage.')
),
dependencies = [
'3rdparty/jvm/commons-io',
'3rdparty/jvm/com/github/scopt',
'3rdparty/jvm/com/twitter/scoverage:scalac-scoverage-plugin',
'3rdparty/jvm/org/slf4j:slf4j-simple',
'3rdparty/jvm/org/slf4j:slf4j-api'
],
)
208 changes: 208 additions & 0 deletions src/scala/org/pantsbuild/scoverage/report/ScoverageReport.scala
@@ -0,0 +1,208 @@
package org.pantsbuild.scoverage.report

import java.io.File
import org.apache.commons.io.FileUtils
import java.util.concurrent.atomic.AtomicInteger

import org.slf4j.Logger
import org.slf4j.LoggerFactory

import scoverage.{ Coverage, IOUtils, Serializer }
import scoverage.report.{ ScoverageHtmlWriter, ScoverageXmlWriter }

object ScoverageReport {
val Scoverage = "scoverage"

// Setting the logger
val logger: Logger = LoggerFactory.getLogger(Scoverage)

/**
*
* @param dataDirs list of measurement directories for which coverage
* report has to be generated
* @return [Coverage] object for the [dataDirs]
*/
private def aggregatedCoverage(dataDirs: Seq[File]): Coverage = {
var id = new AtomicInteger(0)
val coverage = Coverage()
dataDirs foreach { dataDir =>
val coverageFile: File = Serializer.coverageFile(dataDir)
if (coverageFile.exists) {
val subcoverage: Coverage = Serializer.deserialize(coverageFile)
val measurementFiles: Array[File] = IOUtils.findMeasurementFiles(dataDir)
val measurements = IOUtils.invoked(measurementFiles.toIndexedSeq)
subcoverage.apply(measurements)
subcoverage.statements foreach { stmt =>
// need to ensure all the ids are unique otherwise the coverage object will have stmt collisions
coverage add stmt.copy(id = id.incrementAndGet())
}
}
}
coverage
}

/**
*
* @param dataDirs list of measurement directories for which coverage
* report has to be generated
* @return Coverage object
*/
private def aggregate(dataDirs: Seq[File]): Coverage = {
logger.info(s"Found ${dataDirs.size} subproject scoverage data directories [${dataDirs.mkString(",")}]")
Copy link
Contributor

Choose a reason for hiding this comment

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

If dataDirs is null, this will throw an NullPointerException

Copy link
Contributor

Choose a reason for hiding this comment

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

Feels like this method could just be consolidated with aggregatedCoverage

Copy link
Contributor Author

Choose a reason for hiding this comment

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

aggregate is called only by loadAggregatedCoverage and I am checking there if the dataDirs is null or not. If it doesn't exist, it raises a runtime exception.

if (dataDirs.nonEmpty) {
aggregatedCoverage(dataDirs)
} else {
throw new RuntimeException(s"Datadir ${dataDirs.mkString(",")} empty")
}
}

/**
*
* @param dataDir root directory to search under
* @return all the directories and subdirs containing scoverage files beginning at [dataDir]
*/
def getAllCoverageDirs(dataDir: File, acc: Seq[File]): Seq[File] = {
if (dataDir.listFiles.filter(_.isFile).toSeq.exists(_.getName contains "scoverage.coverage")) {
dataDir.listFiles.filter(_.isDirectory).toSeq
.foldRight(dataDir +: acc) { (e, a) => getAllCoverageDirs(e, a) }
} else {
dataDir.listFiles.filter(_.isDirectory).toSeq
.foldRight(acc) { (e, a) => getAllCoverageDirs(e, a) }
}
}
/**
* Select the appropriate directories for which the scoverage report has
* to be generated. If [targetFiles] is empty, report is generated for all
* measurements directories inside in [dataDir].
*/
def filterFiles(dataDir: File, settings: Settings): Seq[File] = {
val targetFiles = settings.targetFilters

val coverageDirs = getAllCoverageDirs(dataDir, Seq())

if (targetFiles.nonEmpty) {
logger.info(s"Looking for targets: $targetFiles")
coverageDirs.filter {
file => targetFiles.exists(file.toString contains _)
}
} else {
coverageDirs
}
}

/**
* Aggregating coverage from all the coverage measurements.
*/
private def loadAggregatedCoverage(dataPath: String, settings: Settings): Coverage = {
val dataDir: File = new File(dataPath)
logger.info(s"Attempting to open scoverage data dir: [$dataDir]")
if (dataDir.exists) {
logger.info(s"Aggregating coverage.")
val dataDirs: Seq[File] = filterFiles(dataDir, settings)
aggregate(dataDirs)
} else {
logger.error("Coverage directory does not exist.")
throw new RuntimeException("Coverage directory does not exist.")
}
}

/**
* Loads coverage data from the specified data directory.
*/
private def loadCoverage(dataPath: String): Coverage = {
val dataDir: File = new File(dataPath)
logger.info(s"Attempting to open scoverage data dir [$dataDir]")

if (dataDir.exists) {
val coverageFile = Serializer.coverageFile(dataDir)
logger.info(s"Reading scoverage instrumentation [$coverageFile]")

coverageFile.exists match {
case true =>
val coverage = Serializer.deserialize(coverageFile)
logger.info(s"Reading scoverage measurements...")

val measurementFiles = IOUtils.findMeasurementFiles(dataDir)
val measurements = IOUtils.invoked(measurementFiles)
coverage.apply(measurements)
coverage

case false =>
logger.error("Coverage file did not exist")
throw new RuntimeException("Coverage file did not exist")

}
} else {
logger.error("Data dir did not exist!")
throw new RuntimeException("Data dir did not exist!")
}

}

/**
* Writes coverage reports usign the specified source path to the specified report directory.
*/
private def writeReports(coverage: Coverage, settings: Settings): Unit = {
val sourceDir = new File(settings.sourceDirPath)
val reportDir = new File(settings.reportDirPath)
val reportDirHtml = new File(settings.reportDirPath + "/html")
val reportDirXml = new File(settings.reportDirPath + "/xml")

if (sourceDir.exists) {
if (settings.cleanOldReports && reportDir.exists) {
logger.info(s"Nuking old report directory [$reportDir].")
FileUtils.deleteDirectory(reportDir)
}

if (!reportDir.exists) {
logger.info(s"Creating HTML report directory [$reportDirHtml]")
reportDirHtml.mkdirs
logger.info(s"Creating XML report directory [$reportDirXml]")
reportDirXml.mkdirs
}

if (settings.writeHtmlReport) {
logger.info(s"Writing HTML scoverage reports to [$reportDirHtml]")
new ScoverageHtmlWriter(Seq(sourceDir), reportDirHtml, None).write(coverage)
}

if (settings.writeXmlReport) {
logger.info(s"Writing XML scoverage reports to [$reportDirXml]")
new ScoverageXmlWriter(Seq(sourceDir), reportDirXml, false).write(coverage)
if (settings.writeXmlDebug) {
new ScoverageXmlWriter(Seq(sourceDir), reportDirXml, true).write(coverage)
}
}
} else {
logger.error(s"Source dir [$sourceDir] does not exist")
throw new RuntimeException(s"Source dir [$sourceDir] does not exist")
}

logger.info(s"Statement coverage: ${coverage.statementCoverageFormatted}%")
logger.info(s"Branch coverage: ${coverage.branchCoverageFormatted}%")
}

def main(args: Array[String]): Unit = {
Settings.parser1.parse(args, Settings()) match {
case Some(settings) =>
val writeScoverageReports = settings.writeHtmlReport || settings.writeXmlReport

settings.loadDataDir match {
case false =>
val cov = loadAggregatedCoverage(settings.measurementsDirPath, settings)
logger.info("Coverage loaded successfully.")
if (writeScoverageReports) {
writeReports(cov, settings)
}
case true =>
val cov = loadCoverage(settings.dataDirPath)
logger.info("Coverage loaded successfully!")
if (writeScoverageReports) {
writeReports(cov, settings)
}
}

case None => throw new RuntimeException("ScoverageReport: Incorrect options supplied.")
}
}
}
68 changes: 68 additions & 0 deletions src/scala/org/pantsbuild/scoverage/report/Settings.scala
@@ -0,0 +1,68 @@
package org.pantsbuild.scoverage.report

/**
* All parsed command-line options.
*/
case class Settings(
loadDataDir: Boolean = false,
measurementsDirPath: String = "",
reportDirPath: String = "",
sourceDirPath: String = ".",
dataDirPath: String = "",
writeHtmlReport: Boolean = true,
writeXmlReport: Boolean = true,
writeXmlDebug: Boolean = false,
cleanOldReports: Boolean = true,
targetFilters: Seq[String] = Seq())

object Settings {

val parser1 = new scopt.OptionParser[Settings]("scoverage") {
head("scoverageReportGenerator")

help("help")
.text("Print this usage message.")

opt[Unit]("loadDataDir")
.action((_, s: Settings) => s.copy(loadDataDir = true))
.text("Load a single measurements directory instead of aggregating coverage reports. Must pass in `dataDirPath <dir>`")

opt[String]("measurementsDirPath")
.action((dir: String, s: Settings) => s.copy(measurementsDirPath = dir))
.text("Directory where all scoverage measurements data is stored.")

opt[String]("reportDirPath")
Copy link
Sponsor Member

Choose a reason for hiding this comment

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

Rather than taking a reportDirPath and boolean writeHtmlReport and writeXmlReport settings, consider maybe taking explicit paths for each of those? Otherwise the caller has to guess the name of the output inside the destination directory.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Currently, if both options are on, it creates < dest >/html and < dest >/xml inside the destination directory. If only one option is on (say html) it creates just < dest >/html. Would this not work?

Copy link
Sponsor Member

@stuhood stuhood Jul 23, 2019

Choose a reason for hiding this comment

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

That's more confusing than just letting the caller choose where to put each thing.

.action((dir: String, s: Settings) => s.copy(reportDirPath = dir))
.text("Target output directory to place the reports.")

opt[String]("sourceDirPath")
.action((dir: String, s: Settings) => s.copy(sourceDirPath = dir))
.text("Directory containing the project sources.")
Copy link
Sponsor Member

Choose a reason for hiding this comment

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

"Project sources" is pretty ambiguous... is this tool used per-target?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

No, its just there for backwards compatibility. I don't think there is any use for this tool for latest ReportWriters (or in my report generator):
https://github.com/scoverage/scalac-scoverage-plugin/blob/9bb6198cb6c4dc6498d034ba4c1810ea2ac8d62c/scalac-scoverage-plugin/src/main/scala/scoverage/report/ScoverageHtmlWriter.scala#L13-L22

Since its still necessary to pass it in as a parameter, I just pass in the current directory by default.


opt[String]("dataDirPath")
Copy link
Contributor

Choose a reason for hiding this comment

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

Looks like this option and loadDataDir are tightly bounded, does it make sense to just consolidate to one?

.action((dir: String, s: Settings) => s.copy(dataDirPath = dir))
.text("Scoverage data file directory to be used in case report needed for single measurements " +
"directory. Must set `loadDataDir` to use this options.")

opt[Unit]("writeHtmlReport")
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: I think the general usage of output over write is more dominant.

.action((_, s: Settings) => s.copy(writeHtmlReport = true))
.text("Write the HTML version of the coverage report.")

opt[Unit]("writeXmlReport")
.action((_, s: Settings) => s.copy(writeXmlReport = true))
.text("Write the XML version of the coverage report.")

opt[Unit]("writeXmlDebug")
.action((_, s: Settings) => s.copy(writeXmlDebug = true))
.text("Write debug information to the XML version of the coverage report.")

opt[Unit]("cleanOldReports")
.action((_, s: Settings) => s.copy(cleanOldReports = true))
.text("Delete any existing reports directory prior to writing reports.")

opt[Seq[String]]("targetFilters")
.action((f: Seq[String], s: Settings) => s.copy(targetFilters = f))
.text("If not specified, reports are generated for directories with coverage instrument files." +
"Else, report is generated only for the directories specified in the list.")
}
}