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

wip adding coverage #415

Closed
wants to merge 4 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
42 changes: 39 additions & 3 deletions build.sc
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,8 @@ object scalalib extends MillModule {
genTask(main)() ++
genTask(scalalib)() ++
genTask(scalajslib)() ++
genTask(scalanativelib)()
genTask(scalanativelib)() ++
genTask(scalacoverage)()

worker.testArgs() ++
main.graphviz.testArgs() ++
Expand Down Expand Up @@ -287,6 +288,39 @@ object scalanativelib extends MillModule {
}
}


object scalacoverage extends MillModule {
def moduleDeps = Seq(scalalib)

def scalacOptions = Seq[String]() // disable -P:acyclic:force

def testArgs = T{
val mapping = Map(
"MILL_SCALACOVERAGE_WORKER_1_4" ->
worker("1.4").runClasspath()
.map(_.path)
.filter(_.toIO.exists)
.mkString(",")
)
scalalib.worker.testArgs() ++
scalalib.backgroundwrapper.testArgs() ++
(for((k, v) <- mapping.toSeq) yield s"-D$k=$v")
}

object worker extends Cross[WorkerModule]("1.4")
class WorkerModule(scalaCoverageBinary: String) extends MillModule {
def scalaCoverageVersion = T{ "1.4.0-M3" }
def moduleDeps = Seq(scalacoverage)
def ivyDeps = scalaCoverageBinary match {
case "1.4" =>
Agg(
ivy"org.scoverage::scalac-scoverage-runtime:${scalaCoverageVersion()}",
ivy"org.scoverage::scalac-scoverage-plugin:${scalaCoverageVersion()}"
)
}
}
}

def testRepos = T{
Seq(
"MILL_ACYCLIC_REPO" ->
Expand All @@ -307,12 +341,13 @@ def testRepos = T{
}

object integration extends MillModule{
def moduleDeps = Seq(main.moduledefs, scalalib, scalajslib, scalanativelib)
def moduleDeps = Seq(main.moduledefs, scalalib, scalajslib, scalanativelib, scalacoverage)
def testArgs = T{
scalajslib.testArgs() ++
scalalib.worker.testArgs() ++
scalalib.backgroundwrapper.testArgs() ++
scalanativelib.testArgs() ++
scalacoverage.testArgs() ++
Seq(
"-DMILL_TESTNG=" + contrib.testng.runClasspath().map(_.path).mkString(","),
"-DMILL_VERSION=" + build.publishVersion()._2,
Expand Down Expand Up @@ -361,13 +396,14 @@ def launcherScript(shellJvmArgs: Seq[String],
}

object dev extends MillModule{
def moduleDeps = Seq(scalalib, scalajslib, scalanativelib, contrib.scalapblib)
def moduleDeps = Seq(scalalib, scalajslib, scalanativelib, scalacoverage, contrib.scalapblib)
def forkArgs =
(
scalalib.testArgs() ++
scalajslib.testArgs() ++
scalalib.worker.testArgs() ++
scalanativelib.testArgs() ++
scalacoverage.testArgs() ++
scalalib.backgroundwrapper.testArgs() ++
// Workaround for Zinc/JNA bug
// https://github.com/sbt/sbt/blame/6718803ee6023ab041b045a6988fafcfae9d15b5/main/src/main/scala/sbt/Main.scala#L130
Expand Down
197 changes: 197 additions & 0 deletions scalacoverage/src/mill/scalacoverage/ScoverageModule.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
package mill.scalacoverage
import ammonite.ops.Path
import coursier.Cache
import coursier.maven.MavenRepository
import mill.define.{Sources, Target, Task}
import mill.eval.Result
import mill.scalalib.{Lib, ScalaModule, _}
import mill.{Agg, PathRef, T}
import mill.util.JsonFormatters.pathReadWrite

object ScoverageOptions {
val OrgScoverage = "org.scoverage"
val ScalacRuntimeArtifact = "scalac-scoverage-runtime"
val ScalacPluginArtifact = "scalac-scoverage-plugin"
val DefaultScoverageVersion = "1.4.0-M3"
}


trait ScoverageOptions {
import ScoverageOptions._

/** The version of scoverage. */
def scoverageVersion: String = DefaultScoverageVersion

/** A sequence of packages to exclude from coverage. */
def excludedPackages: Seq[String] = Seq.empty

/** A sequence of files to exclude from coverage. */
def excludedFiles: Seq[String] = Seq.empty

def highlighting: Boolean = true

/** The minimum coverage. */
def minimum: Double = 0

/** Fail the build if the minimum coverage is not met. */
def failOnMinimum: Boolean = false

/** True to output a Cobertura coverage report. */
def outputCobertura: Boolean = true

/** True to output an XML coverage report. */
def outputXML: Boolean = true

/** True to output an HTML coverage report. */
def outputHTML: Boolean = true

/** True to output debugging information to the HTML report. */
def outputDebug: Boolean = false

//def outputTeamCity: Boolean = false // FIXME

/** True to clean coverage data after writing the reports. */
def cleanSubprojectFiles: Boolean = true
}


trait ScoverageReportModule extends ScoverageModule {
/** The target to generate the coverage report. */
def coverageReport: T[Path] = T {
val reportDir = coverageReportDir()
scalaCoverageWorker().coverageReport(
options = this,
dataDir = coverageDataDir(),
reportDir = reportDir,
compileSourceDirectories = sources().filter(_.path.toNIO.toFile.isDirectory).map(_.path),
encoding = encoding(),
log = T.ctx.log
)
reportDir
}
}


trait ScoverageAggregateModule extends ScoverageModule {

/** The set of coverage modules to aggregate. */
def coverageModules: Seq[ScoverageModule]

/** The sources across modules. */
override def sources: Sources = T.sources{ Task.traverse(coverageModules)(_.sources)().flatten }

/** The set of report directories, one per module. */
private def coverageReportDirs: T[Seq[Path]] = T{ Task.traverse(coverageModules)(_.coverageReportDir)() }

override def moduleDeps = coverageModules

/** The target to generate the coverage report. */
def aggregateCoverage: T[Path] = T {
val aggregateReportDir = coverageReportDir()
scalaCoverageWorker().aggregateCoverage(
options = this,
coverageReportDirs = coverageReportDirs(),
aggregateReportDir = aggregateReportDir,
compileSourceDirectories = sources().filter(_.path.toNIO.toFile.isDirectory).map(_.path),
encoding = encoding(),
log = T.ctx.log
)
aggregateReportDir
}
}


trait ScoverageModule extends ScalaModule with ScoverageOptions {
import ScoverageOptions._

/** The path to where the coverage target should write its data. */
def coverageDataDir: T[Path] = T{ T.ctx().dest }

/** The path to where the coverage data should be written */
def coverageReportDir: T[Path] = T{ coverageDataDir() }

/** The specific test dependencies (ex. "org.scalatest::scalatest:3.0.1"). */
def testIvyDeps: T[Agg[Dep]]

protected def _scoverageVersion = T { scoverageVersion }

/** The encoding parsed from the [[scalacOptions]]. */
protected def encoding: T[Option[String]] = T{
val options = scalacOptions()
val i = options.indexOf("-encoding") + 1
if (i > 0 && i < options.length) Some(options(i)) else None
}

/** The dependencies for scoverage during runtime. */
private def runtimeIvyDeps = T { Seq(ivy"${OrgScoverage}::${ScalacRuntimeArtifact}:${_scoverageVersion()}") }

/** The dependencies for scoverage's plugin. */
private def pluginIvyDeps = T{ Seq(ivy"${OrgScoverage}::${ScalacPluginArtifact}:${_scoverageVersion()}") }

/** The path to the scoverage plugin found via the plugin classpath. */
private def pluginPath: T[Path] = T {
val cp = scalacPluginClasspath()
cp.find { pathRef =>
val fileName = pathRef.path.toNIO.getFileName.toString
fileName.toString.contains(ScalacPluginArtifact) && fileName.toString
.contains(_scoverageVersion())
}.getOrElse {
throw new Exception(
s"Fatal: ${ScalacPluginArtifact} (${_scoverageVersion()}) not found on the classpath:\n\t" + cp
.map(_.path)
.mkString("\n\t"))
}.path
}

// Adds the runtime coverage dependencies
final override def ivyDeps = super.ivyDeps() ++ runtimeIvyDeps() ++ testIvyDeps()

// Adds the runtime coverage dependencies
override def transitiveIvyDeps: T[Agg[Dep]] = T {
ivyDeps() ++ runtimeIvyDeps() ++ Task.traverse(moduleDeps)(_.transitiveIvyDeps)().flatten
}

// Adds the plugin coverage dependencies
override def scalacPluginIvyDeps = super.scalacPluginIvyDeps() ++ pluginIvyDeps()

// Gets the major and minor version of the scoverage
private def scoverageBinaryVersion = T{ _scoverageVersion().split('.').take(2).mkString(".") }

/** Builds a new worker for coverage tasks. */
protected def scalaCoverageWorker: Task[ScoverageWorkerApi] = T.task{
ScoverageWorkerApi.scoverageWorker().impl(bridgeFullClassPath())
}

/** Adds the worker dependencies to the classpath. */
private def scoverageWorkerClasspath = T {
val workerKey = "MILL_SCALACOVERAGE_WORKER_" + scoverageBinaryVersion().replace('.', '_').replace('-', '_')
val workerPath = sys.props(workerKey)
if (workerPath != null)
Result.Success(Agg(workerPath.split(',').map(p => PathRef(Path(p), quick = true)): _*))
else
Lib.resolveDependencies(
Seq(Cache.ivy2Local, MavenRepository("https://repo1.maven.org/maven2")),
Lib.depToDependency(_, "2.12.4", ""),
Seq(ivy"com.lihaoyi::mill-scalacoverage-worker-${scoverageBinaryVersion()}:${sys.props("MILL_VERSION")}")
)
}

// Adds the plugin path and report directory path to the set of scalac options.
override def scalacOptions = T {
super.scalacOptions() ++ Seq(
Some(s"-Xplugin:${pluginPath()}"),
Some(s"-P:scoverage:dataDir:${coverageReportDir().toNIO}"),
Option(excludedPackages).map(v => s"-P:scoverage:excludedPackages:$v"),
Option(excludedFiles).map(v => s"-P:scoverage:excludedFiles:$v"),
if (highlighting) Some("-Yrangepos") else None // rangepos is broken in some releases of scala so option to turn it off
).flatten
}

private def bridgeFullClassPath: T[Seq[Path]] = T {
Lib.resolveDependencies(
Seq(Cache.ivy2Local, MavenRepository("https://repo1.maven.org/maven2")),
Lib.depToDependency(_, scalaVersion(), platformSuffix()),
runtimeIvyDeps()
).map(t => (scoverageWorkerClasspath().toSeq ++ t.toSeq).map(_.path))
}
}
81 changes: 81 additions & 0 deletions scalacoverage/src/mill/scalacoverage/ScoverageWorkerApi.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package mill.scalacoverage

import java.net.URLClassLoader

import ammonite.ops.Path
import mill.define.{Discover, Worker}
import mill.util.Logger
import mill.{Agg, T}


class ScoverageWorker {
private var scalaInstanceCache = Option.empty[(Long, ScoverageWorkerApi)]

def impl(toolsClasspath: Agg[Path]): ScoverageWorkerApi = {
// TODO: this method could be extracted from mill.scalanativelib.ScalaNativeWorker.impl() for re-use, it's duplicated here
val classloaderSig = toolsClasspath.map(p => p.toString().hashCode + p.mtime.toMillis).sum
scalaInstanceCache match {
case Some((sig, bridge)) if sig == classloaderSig => bridge
case _ =>
val cl = new URLClassLoader(
toolsClasspath.map(_.toIO.toURI.toURL).toArray,
getClass.getClassLoader
)
try {
val bridge = cl
.loadClass("mill.scalacoverage.worker.ScoverageWorkerImpl")
.getDeclaredConstructor()
.newInstance()
.asInstanceOf[ScoverageWorkerApi]
scalaInstanceCache = Some((classloaderSig, bridge))
bridge
}
catch {
case e: Exception =>
e.printStackTrace()
throw e
}
}
}
}

trait ScoverageWorkerApi {

/** Produces a coverage report.
*
* @param options the scoverage options
* @param dataDir the directory with coverage data
* @param reportDir the directory to which reports should be written
* @param compileSourceDirectories the source directories
* @param encoding optionally the encoding
* @param log a logger to write any build messages.
*/
def coverageReport(options: ScoverageOptions,
dataDir: Path,
reportDir: Path,
compileSourceDirectories: Seq[Path],
encoding: Option[String],
log: Logger): Unit

/** Aggregates multiple coverage reports.
*
* @param options the scoverage options
* @param coverageReportDirs the directories with coverage data
* @param aggregateReportDir the directory to which the aggregate report should be written
* @param compileSourceDirectories the source directories
* @param encoding optionally the encoding
* @param log a logger to write any build messages.
*/
def aggregateCoverage(options: ScoverageOptions,
coverageReportDirs: Seq[Path],
aggregateReportDir: Path,
compileSourceDirectories: Seq[Path],
encoding: Option[String],
log: Logger): Unit
}

object ScoverageWorkerApi extends mill.define.ExternalModule {
def scoverageWorker: Worker[ScoverageWorker] = T.worker { new ScoverageWorker() }

lazy val millDiscover = Discover[this.type]
}
12 changes: 12 additions & 0 deletions scalacoverage/test/resources/basic-coverage/src/basic/Main.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package basic

object Main {
def runMethod(branch: Int): Int = {
if (branch == 0) { println("The value was 0"); 0 }
else if (branch == 1) { println("The value was 1"); 1 }
else { println(s"The value was $branch"); branch + 1 }
}
def notTestedMethod(): Unit = {
throw new IllegalStateException("Not tested")
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package basic

import org.scalatest._

class MainSpec extends FlatSpec with Matchers {

behavior of "Main"

"runMethod" should "return 0 when 0 is given" in {
Main.runMethod(0) shouldBe 0
}

it should "return 1 when 1 is given" in {
Main.runMethod(1) shouldBe 1
}
}
Loading