All Downloads are FREE. Search and download functionalities are using the official Maven repository.

scoverage.ScoverageSbtPlugin.scala Maven / Gradle / Ivy

The newest version!
package scoverage

import sbt.Keys._
import sbt.{given, _}
import sbt.internal.util.Util.isWindows
import sbt.plugins.JvmPlugin
import scoverage.reporter.CoberturaXmlWriter
import scoverage.domain.Constants
import scoverage.domain.Coverage
import scoverage.reporter.CoverageAggregator
import scoverage.reporter.IOUtils
import scoverage.reporter.ScoverageHtmlWriter
import scoverage.reporter.ScoverageXmlWriter
import scoverage.serialize.Serializer

import java.time.Instant

object ScoverageSbtPlugin extends AutoPlugin {

  val orgScoverage = "org.scoverage"
  val scalacRuntimeArtifact = "scalac-scoverage-runtime"
  val scalacPluginArtifact = "scalac-scoverage-plugin"
  val scalacDomainArtifact = "scalac-scoverage-domain"
  val scalacReporterArtifact = "scalac-scoverage-reporter"
  val scalacSerializerArtifact = "scalac-scoverage-serializer"
  val defaultScoverageVersion = BuildInfo.scoverageVersion
  val autoImport = ScoverageKeys
  lazy val ScoveragePluginConfig = config("scoveragePlugin").hide

  import autoImport._

  val aggregateFilter = ScopeFilter(
    inAggregates(ThisProject),
    inConfigurations(Compile)
  ) // must be outside of the 'coverageAggregate' task (see: https://github.com/sbt/sbt/issues/1095 or https://github.com/sbt/sbt/issues/780)

  override def requires: JvmPlugin.type = plugins.JvmPlugin
  override def trigger: PluginTrigger = allRequirements

  override def globalSettings: Seq[Def.Setting[_]] =
    super.globalSettings ++ Seq(
      coverageEnabled := false,
      coverageExcludedPackages := "",
      coverageExcludedFiles := "",
      coverageMinimumStmtTotal := 0,
      coverageMinimumBranchTotal := 0,
      coverageMinimumStmtPerPackage := 0,
      coverageMinimumBranchPerPackage := 0,
      coverageMinimumStmtPerFile := 0,
      coverageMinimumBranchPerFile := 0,
      coverageFailOnMinimum := false,
      coverageHighlighting := true,
      coverageOutputXML := true,
      coverageOutputHTML := true,
      coverageOutputCobertura := true,
      coverageOutputDebug := false,
      coverageOutputTeamCity := false,
      coverageScalacPluginVersion := defaultScoverageVersion,
      coverageSourceRoot := (ThisBuild / baseDirectory).value
    )

  override def buildSettings: Seq[Setting[_]] = super.buildSettings ++
    addCommandAlias("coverage", ";set ThisBuild / coverageEnabled := true") ++
    addCommandAlias("coverageOn", ";set ThisBuild / coverageEnabled := true") ++
    addCommandAlias("coverageOff", ";set ThisBuild / coverageEnabled := false")

  override def projectSettings: Seq[Setting[_]] = Seq(
    ivyConfigurations += ScoveragePluginConfig,
    coverageReport := coverageReport0.value,
    coverageAggregate := coverageAggregate0.value,
    coverageAggregate / aggregate := false,
    coverageDataDir := crossTarget.value
  ) ++ coverageSettings ++ scalacSettings

  private def isScala2(scalaVersion: String) =
    CrossVersion
      .partialVersion(scalaVersion)
      .exists {
        case (2, _) => true
        case _      => false
      }

  private def isScala3SupportingScoverage(scalaVersion: String) =
    CrossVersion
      .partialVersion(scalaVersion)
      .exists {
        case (3, minor) if minor >= 2 => true
        case _                        => false
      }

  private def isScala3SupportingFilePackageExclusion(scalaVersion: String) = {
    def patch = scalaVersion.split('.').map(_.toInt).drop(2).headOption
    CrossVersion
      .partialVersion(scalaVersion)
      .exists {
        case (3, minor) if minor > 4                            => true
        case (3, minor) if (minor == 4 && patch.exists(_ >= 2)) => true
        case (3, minor) if (minor == 3 && patch.exists(_ >= 4)) => true
        case _                                                  => false
      }
  }

  private lazy val coverageSettings = Seq(
    libraryDependencies ++= {
      if (coverageEnabled.value && isScala2(scalaVersion.value)) {
        Seq(
          orgScoverage %% scalacDomainArtifact % coverageScalacPluginVersion.value,
          orgScoverage %% scalacReporterArtifact % coverageScalacPluginVersion.value,
          orgScoverage %% scalacSerializerArtifact % coverageScalacPluginVersion.value,
          // We only add for "compile" because of macros. This setting could be optimed to just "test" if the handling
          // of macro coverage was improved.
          orgScoverage %% (scalacRuntime(
            libraryDependencies.value
          )) % coverageScalacPluginVersion.value,
          // We don't want to instrument the test code itself, nor add to a pom when published with coverage enabled.
          (orgScoverage %% scalacPluginArtifact % coverageScalacPluginVersion.value % ScoveragePluginConfig.name)
            .cross(CrossVersion.full)
        )
      } else
        Nil
    }
  )

  private lazy val scalacSettings = Seq(
    Compile / compile / scalacOptions ++= {

      implicit val log: Logger = streams.value.log

      val excludedPackages =
        Option(coverageExcludedPackages.value.trim).filter(_.nonEmpty)
      val excludedFiles = Option(coverageExcludedFiles.value.trim)
        .filter(_.nonEmpty)
        .map(v =>
          // On windows, replace unix file separators with windows file
          // separators. Note that we need to replace / with \\ because
          // the plugin treats this string as a regular expression and
          // backslashes must be escaped in regular expressions.
          if (isWindows) v.replace("/", """\\""") else v
        )

      val updateReport = update.value
      if (coverageEnabled.value && isScala2(scalaVersion.value)) {
        val scoverageDeps: Seq[File] =
          updateReport.matching(configurationFilter(ScoveragePluginConfig.name))

        // Since everything isn't contained in a single plugin jar since we
        // want to share reporter/domain code between the plugin and the
        // reporter which can be used for Scala3 we need to essentially put
        // together the little classpath to pass in to the compiler which
        // includes everything it needs for the compiler plugin phase:
        //  1. the plugin jar
        //  2. the domain jar
        //  3. the serializer jar
        //  NOTE: Even though you'd think that since plugin relies on domain
        //  it'd just auto include it... it doesn't.
        //  https://github.com/sbt/sbt/issues/2255
        val pluginPaths = scoverageDeps.collect {
          case path
              if path.getAbsolutePath().contains(scalacPluginArtifact) || path
                .getAbsolutePath()
                .contains(scalacDomainArtifact) ||
                path.getAbsolutePath().contains(scalacSerializerArtifact) =>
            path.getAbsolutePath()
        }

        // NOTE: A little hacky, but make sure we are matching on the exact
        // number of deps that we expect to collect up above.
        if (pluginPaths.size != 3)
          throw new Exception(
            s"Fatal: Not finding either $scalacDomainArtifact or $scalacPluginArtifact or $scalacSerializerArtifact in libraryDependencies."
          )

        Seq(
          Some(
            s"-Xplugin:${pluginPaths.mkString(java.io.File.pathSeparator)}"
          ),
          Some(
            s"-P:scoverage:dataDir:${new java.io.File(coverageDataDir.value, "scoverage-data").getAbsolutePath}"
          ),
          Some(
            s"-P:scoverage:sourceRoot:${coverageSourceRoot.value.getAbsolutePath}"
          ),
          excludedPackages.map(v => s"-P:scoverage:excludedPackages:$v"),
          excludedFiles.map(v => s"-P:scoverage:excludedFiles:$v"),
          Some("-P:scoverage:reportTestName"),
          // rangepos is broken in some releases of scala so option to turn it off
          if (coverageHighlighting.value) Some("-Yrangepos") else None
        ).flatten
      } else if (
        coverageEnabled.value && isScala3SupportingScoverage(scalaVersion.value)
      ) {
        Seq(
          Some(
            s"-coverage-out:${new java.io.File(coverageDataDir.value, "scoverage-data").getAbsolutePath}"
          ),
          excludedPackages
            .collect {
              case v
                  if isScala3SupportingFilePackageExclusion(
                    scalaVersion.value
                  ) =>
                s"-coverage-exclude-classlikes:${v.replace(';', ',')}"
            },
          excludedFiles
            .collect {
              case v
                  if isScala3SupportingFilePackageExclusion(
                    scalaVersion.value
                  ) =>
                s"-coverage-exclude-files:${v.replace(';', ',')}"
            }
        ).flatten
      } else if (coverageEnabled.value && !isScala2(scalaVersion.value)) {
        log.warn(
          "coverage in Scala 3 needs at least 3.2.x. Please update your Scala version and try again."
        )
        Nil
      } else {
        Nil
      }
    }
  )

  private def scalacRuntime(deps: Seq[ModuleID]): String = {
    scalacRuntimeArtifact + optionalScalaJsSuffix(deps)
  }

  // returns "_sjs$sjsVersion" for Scala.js projects or "" otherwise
  private def optionalScalaJsSuffix(deps: Seq[ModuleID]): String = {
    val sjsClassifier = deps
      .collectFirst {
        case moduleId
            if moduleId.organization == "org.scala-js" && moduleId.name == "scalajs-library" =>
          moduleId.revision
      }
      .map(_.take(1))
      .map(sjsVersion => "_sjs" + sjsVersion)

    sjsClassifier getOrElse ""
  }

  private lazy val coverageReport0 = Def.task {
    val target = coverageDataDir.value
    implicit val log: Logger = streams.value.log

    log.info(s"Waiting for measurement data to sync...")
    if (System.getProperty("os.name").toLowerCase.contains("windows")) {
      Thread.sleep(
        1000
      ) // have noticed some delay in writing on windows, hacky but works
    }

    loadCoverage(
      target,
      log,
      coverageSourceRoot.value.getAbsoluteFile()
    ) match {
      case Some(cov) =>
        writeReports(
          target,
          (Compile / sourceDirectories).value,
          cov,
          coverageOutputCobertura.value,
          coverageOutputXML.value,
          coverageOutputHTML.value,
          coverageOutputDebug.value,
          coverageOutputTeamCity.value,
          sourceEncoding((Compile / scalacOptions).value),
          log
        )

        CoverageMinimum.all.value
          .checkCoverage(cov, coverageFailOnMinimum.value)
      case None => log.warn("No coverage data, skipping reports")
    }
  }

  private lazy val coverageAggregate0 = Def.task {
    implicit val log: Logger = streams.value.log
    log.info(s"Aggregating coverage from subprojects...")

    val dataDirs = coverageDataDir.?.all(aggregateFilter).value
      .collect {
        case Some(file) if (file / Constants.DataDir).isDirectory =>
          file / Constants.DataDir
      }

    CoverageAggregator.aggregate(dataDirs, coverageSourceRoot.value) match {
      case Some(cov) =>
        writeReports(
          coverageDataDir.value,
          sourceDirectories.all(aggregateFilter).value.flatten,
          cov,
          coverageOutputCobertura.value,
          coverageOutputXML.value,
          coverageOutputHTML.value,
          coverageOutputDebug.value,
          coverageOutputTeamCity.value,
          sourceEncoding((Compile / scalacOptions).value),
          log
        )
        val cfmt = cov.statementCoverageFormatted
        log.info(s"Aggregation complete. Coverage was [$cfmt]")

        CoverageMinimum.all.value
          .checkCoverage(cov, coverageFailOnMinimum.value)
      case None =>
        log.info("No subproject data to aggregate, skipping reports")
    }
  }

  private def writeReports(
      crossTarget: File,
      compileSourceDirectories: Seq[File],
      coverage: Coverage,
      coverageOutputCobertura: Boolean,
      coverageOutputXML: Boolean,
      coverageOutputHTML: Boolean,
      coverageDebug: Boolean,
      coverageOutputTeamCity: Boolean,
      coverageSourceEncoding: Option[String],
      log: Logger
  ): Unit = {
    log.info(s"Generating scoverage reports...")

    val coberturaDir = crossTarget / "coverage-report"
    val reportDir = crossTarget / "scoverage-report"
    coberturaDir.mkdirs()
    reportDir.mkdirs()

    if (coverageOutputCobertura) {
      new CoberturaXmlWriter(
        compileSourceDirectories,
        coberturaDir,
        coverageSourceEncoding
      ).write(
        coverage
      )
      log.info(
        s"Written Cobertura report [${coberturaDir.getAbsolutePath}/cobertura.xml]"
      )
    }

    if (coverageOutputXML) {
      new ScoverageXmlWriter(
        compileSourceDirectories,
        reportDir,
        false,
        coverageSourceEncoding
      ).write(
        coverage
      )
      if (coverageDebug) {
        new ScoverageXmlWriter(
          compileSourceDirectories,
          reportDir,
          true,
          coverageSourceEncoding
        ).write(
          coverage
        )
      }
      log.info(
        s"Written XML coverage report [${reportDir.getAbsolutePath}/scoverage.xml]"
      )
    }

    if (coverageOutputHTML) {
      new ScoverageHtmlWriter(
        compileSourceDirectories,
        reportDir,
        coverageSourceEncoding
      ).write(coverage)
      log.info(
        s"Written HTML coverage report [${reportDir.getAbsolutePath}/index.html]"
      )
    }
    if (coverageOutputTeamCity) {
      reportToTeamcity(
        coverage,
        coverageOutputHTML,
        reportDir,
        crossTarget,
        log
      )
      log.info("Written coverage report to TeamCity")
    }

    log.info(s"Statement coverage.: ${coverage.statementCoverageFormatted}%")
    log.info(s"Branch coverage....: ${coverage.branchCoverageFormatted}%")
    log.info("Coverage reports completed")
  }

  private def reportToTeamcity(
      coverage: Coverage,
      createCoverageZip: Boolean,
      reportDir: File,
      crossTarget: File,
      log: Logger
  ): Unit = {

    def statsKeyValue(key: String, value: Int): String =
      s"##teamcity[buildStatisticValue key='$key' value='$value']"

    // Log statement coverage as per: https://devnet.jetbrains.com/message/5467985
    log.info(
      statsKeyValue("CodeCoverageAbsSCovered", coverage.invokedStatementCount)
    )
    log.info(statsKeyValue("CodeCoverageAbsSTotal", coverage.statementCount))
    log.info(
      statsKeyValue("CodeCoverageAbsRCovered", coverage.invokedBranchesCount)
    )
    log.info(statsKeyValue("CodeCoverageAbsRTotal", coverage.branchCount))

    // Log branch coverage as a custom metrics (in percent)
    log.info(
      statsKeyValue(
        "CodeCoverageBranch",
        "%.0f".format(coverage.branchCoveragePercent).toInt
      )
    )

    // Create the coverage report for teamcity (HTML files)
    if (createCoverageZip)
      IO.zip(
        Path.allSubpaths(reportDir),
        crossTarget / "coverage.zip",
        Some(Instant.now().toEpochMilli())
      )
  }

  private def loadCoverage(
      crossTarget: File,
      log: Logger,
      sourceRoot: File
  ): Option[Coverage] = {

    val dataDir = crossTarget / "/scoverage-data"
    val coverageFile = Serializer.coverageFile(dataDir)

    log.info(s"Reading scoverage instrumentation [$coverageFile]")

    if (coverageFile.exists) {

      val coverage = Serializer.deserialize(
        coverageFile,
        sourceRoot
      )

      log.info(s"Reading scoverage measurements...")
      val measurementFiles = IOUtils.findMeasurementFiles(dataDir)
      val measurements = IOUtils.invoked(measurementFiles)
      coverage.apply(measurements)
      Some(coverage)

    } else {
      None
    }
  }

  private def sourceEncoding(scalacOptions: Seq[String]): Option[String] = {
    val i = scalacOptions.indexOf("-encoding") + 1
    if (i > 0 && i < scalacOptions.length) Some(scalacOptions(i)) else None
  }

}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy