diff --git a/.gitignore b/.gitignore index 19961ed1..a95276af 100644 --- a/.gitignore +++ b/.gitignore @@ -20,5 +20,12 @@ credentials.sbt .idea_modules *.iml +# VSCode with Metals specific +.bsp +.bloop +.metals +.vscode +metals.sbt + # npm specific node_modules/ diff --git a/reporter/src/main/scala/scoverage/reporter/BaseReportWriter.scala b/reporter/src/main/scala/scoverage/reporter/BaseReportWriter.scala index 6c89b2e4..753d93c4 100644 --- a/reporter/src/main/scala/scoverage/reporter/BaseReportWriter.scala +++ b/reporter/src/main/scala/scoverage/reporter/BaseReportWriter.scala @@ -2,39 +2,62 @@ package scoverage.reporter import java.io.File -class BaseReportWriter( - sourceDirectories: Seq[File], +/** Abstract report writer. + * + * @param sourceRoots list of source directories + * @param outputDir directory where to store the reports + * @param outputEncoding encoding to use when writing files + * @param recoverNoSourceRoot specifies how to handle source paths that are outside of the source roots. + */ +abstract class BaseReportWriter( + sourceRoots: Seq[File], outputDir: File, - sourceEncoding: Option[String] + outputEncoding: Option[String], + recoverNoSourceRoot: BaseReportWriter.PathRecoverer ) { // Source paths in canonical form WITH trailing file separator private val formattedSourcePaths: Seq[String] = - sourceDirectories + sourceRoots .filter(_.isDirectory) - .map(_.getCanonicalPath + File.separator) + .map(_.getCanonicalPath + File.separatorChar) - /** Converts absolute path to relative one if any of the source directories is it's parent. - * If there is no parent directory, the path is returned unchanged (absolute). + /** Converts an absolute path to a path relative to the reporter's source directories (aka "source roots"). + * If the path is not in the source roots, returns None. * * @param src absolute file path in canonical form + * @return `Some(relativePath)` if `src` is in the source roots, else `None` */ - def relativeSource(src: String): String = + def relativeSource(src: String): Option[String] = relativeSource(src, formattedSourcePaths) - private def relativeSource(src: String, sourcePaths: Seq[String]): String = { + private def relativeSource( + src: String, + sourceRoots: Seq[String] + ): Option[String] = { // We need the canonical path for the given src because our formattedSourcePaths are canonical val canonicalSrc = new File(src).getCanonicalPath - val sourceRoot: Option[String] = - sourcePaths.find(sourcePath => canonicalSrc.startsWith(sourcePath)) - sourceRoot match { - case Some(path: String) => canonicalSrc.replace(path, "") - case _ => - val fmtSourcePaths: String = sourcePaths.mkString("'", "', '", "'") - throw new RuntimeException( - s"No source root found for '$canonicalSrc' (source roots: $fmtSourcePaths)" - ); - } + sourceRoots + .find(root => canonicalSrc.startsWith(root)) + .map(root => canonicalSrc.substring(root.length)) + .orElse(recoverNoSourceRoot(new File(canonicalSrc), formattedSourcePaths)) } +} +object BaseReportWriter { + + /** Specifies how to handle source path that are outside of the source roots. + * Takes the source path (as a canonical File) and returns: + * - `None` to skip the element + * - `Some(newPath)` to use `newPath` instead + * + * The function may of course take additional actions, such as logging a warning, + * throwing an error, etc. + */ + type PathRecoverer = (File, Seq[String]) => Option[String] + /** Throws an exception */ + def failIfNoSourceRoot(f: File, roots: Seq[String]): Option[String] = + throw new RuntimeException( + s"No source root found for '${f.getPath}' (source roots: $roots)" + ) } diff --git a/reporter/src/main/scala/scoverage/reporter/CoberturaXmlWriter.scala b/reporter/src/main/scala/scoverage/reporter/CoberturaXmlWriter.scala index e71ec786..5faf6834 100644 --- a/reporter/src/main/scala/scoverage/reporter/CoberturaXmlWriter.scala +++ b/reporter/src/main/scala/scoverage/reporter/CoberturaXmlWriter.scala @@ -15,11 +15,22 @@ import scoverage.domain.MeasuredPackage class CoberturaXmlWriter( sourceDirectories: Seq[File], outputDir: File, - sourceEncoding: Option[String] -) extends BaseReportWriter(sourceDirectories, outputDir, sourceEncoding) { + sourceEncoding: Option[String], + recoverNoSourceRoot: BaseReportWriter.PathRecoverer +) extends BaseReportWriter( + sourceDirectories, + outputDir, + sourceEncoding, + recoverNoSourceRoot + ) { - def this(baseDir: File, outputDir: File, sourceEncoding: Option[String]) = { - this(Seq(baseDir), outputDir, sourceEncoding) + def this( + baseDir: File, + outputDir: File, + sourceEncoding: Option[String], + recoverNoSourceRoot: BaseReportWriter.PathRecoverer + ) = { + this(Seq(baseDir), outputDir, sourceEncoding, recoverNoSourceRoot) } def write(coverage: Coverage): Unit = { @@ -49,24 +60,26 @@ class CoberturaXmlWriter( } - def klass(klass: MeasuredClass): Node = { - - - {klass.methods.map(method)} - - - { - klass.statements.map(stmt => ) - } - - + def klass(klass: MeasuredClass): Option[Node] = { + relativeSource(klass.source).map(sourcePath => { + + + {klass.methods.map(method)} + + + { + klass.statements.map(stmt => ) + } + + + }) } def pack(pack: MeasuredPackage): Node = { @@ -75,7 +88,7 @@ class CoberturaXmlWriter( branch-rate={DoubleFormat.twoFractionDigits(pack.branchCoverage)} complexity="0"> - {pack.classes.map(klass)} + {pack.classes.flatMap(klass)} } diff --git a/reporter/src/main/scala/scoverage/reporter/ScoverageHtmlWriter.scala b/reporter/src/main/scala/scoverage/reporter/ScoverageHtmlWriter.scala index e93bf48e..f076bf78 100644 --- a/reporter/src/main/scala/scoverage/reporter/ScoverageHtmlWriter.scala +++ b/reporter/src/main/scala/scoverage/reporter/ScoverageHtmlWriter.scala @@ -15,8 +15,14 @@ import scoverage.domain.MeasuredPackage class ScoverageHtmlWriter( sourceDirectories: Seq[File], outputDir: File, - sourceEncoding: Option[String] -) extends BaseReportWriter(sourceDirectories, outputDir, sourceEncoding) { + sourceEncoding: Option[String], + recoverNoSourceRoot: BaseReportWriter.PathRecoverer +) extends BaseReportWriter( + sourceDirectories, + outputDir, + sourceEncoding, + recoverNoSourceRoot + ) { // to be used by gradle-scoverage plugin def this( @@ -24,17 +30,34 @@ class ScoverageHtmlWriter( outputDir: File, sourceEncoding: Option[String] ) = { - this(sourceDirectories.toSeq, outputDir, sourceEncoding) + this( + sourceDirectories.toSeq, + outputDir, + sourceEncoding, + BaseReportWriter.failIfNoSourceRoot + ) } // for backward compatibility only + @deprecated def this(sourceDirectories: Seq[File], outputDir: File) = { - this(sourceDirectories, outputDir, None); + this( + sourceDirectories, + outputDir, + None, + BaseReportWriter.failIfNoSourceRoot + ); } // for backward compatibility only + @deprecated def this(sourceDirectory: File, outputDir: File) = { - this(Seq(sourceDirectory), outputDir) + this( + Seq(sourceDirectory), + outputDir, + None, + BaseReportWriter.failIfNoSourceRoot + ) } def write(coverage: Coverage): Unit = { @@ -81,16 +104,25 @@ class ScoverageHtmlWriter( private def writeFile(mfile: MeasuredFile): Unit = { // each highlighted file is written out using the same structure as the original file. - val file = new File(outputDir, relativeSource(mfile.source) + ".html") + val sourcePath = relativeSource(mfile.source).getOrElse( + throw new RuntimeException( + s"Expected the file $mfile to be in the source roots" + ) + ) + val htmlPath = sourcePath + ".html" + val file = new File(outputDir, htmlPath) file.getParentFile.mkdirs() - IOUtils.writeToFile(file, filePage(mfile).toString(), sourceEncoding) + IOUtils.writeToFile( + file, + filePage(mfile, htmlPath).toString(), + sourceEncoding + ) } private def packageOverviewRelativePath(pkg: MeasuredPackage) = pkg.name.replace("", "(empty)") + ".html" - private def filePage(mfile: MeasuredFile): Node = { - val filename = relativeSource(mfile.source) + ".html" + private def filePage(mfile: MeasuredFile, filename: String): Node = { val css = "table.codegrid { font-family: monospace; font-size: 12px; width: auto!important; }" + "table.statementlist { width: auto!important; font-size: 13px; } " + @@ -236,18 +268,18 @@ class ScoverageHtmlWriter( - {classes.toSeq.sortBy(_.fullClassName) map classRow} + {classes.toSeq.sortBy(_.fullClassName).flatMap(classRow)} } - def classRow(klass: MeasuredClass): Node = { + def classRow(klass: MeasuredClass): Option[Node] = { + relativeSource(klass.source).map(path => classRow(klass, path)) + } + def classRow(klass: MeasuredClass, relativeSourcePath: String): Node = { val filename: String = { - - val fileRelativeToSource = new File( - relativeSource(klass.source) + ".html" - ) + val fileRelativeToSource = new File(relativeSourcePath + ".html") val path = fileRelativeToSource.getParent val value = fileRelativeToSource.getName diff --git a/reporter/src/main/scala/scoverage/reporter/ScoverageXmlWriter.scala b/reporter/src/main/scala/scoverage/reporter/ScoverageXmlWriter.scala index ad09adae..3c552864 100644 --- a/reporter/src/main/scala/scoverage/reporter/ScoverageXmlWriter.scala +++ b/reporter/src/main/scala/scoverage/reporter/ScoverageXmlWriter.scala @@ -16,16 +16,23 @@ class ScoverageXmlWriter( sourceDirectories: Seq[File], outputDir: File, debug: Boolean, - sourceEncoding: Option[String] -) extends BaseReportWriter(sourceDirectories, outputDir, sourceEncoding) { + sourceEncoding: Option[String], + recoverNoSourceRoot: BaseReportWriter.PathRecoverer +) extends BaseReportWriter( + sourceDirectories, + outputDir, + sourceEncoding, + recoverNoSourceRoot + ) { def this( sourceDir: File, outputDir: File, debug: Boolean, - sourceEncoding: Option[String] + sourceEncoding: Option[String], + recoverNoSourceRoot: BaseReportWriter.PathRecoverer ) = { - this(Seq(sourceDir), outputDir, debug, sourceEncoding) + this(Seq(sourceDir), outputDir, debug, sourceEncoding, recoverNoSourceRoot) } def write(coverage: Coverage): Unit = { @@ -97,17 +104,19 @@ class ScoverageXmlWriter( } - private def klass(klass: MeasuredClass): Node = { - - - {klass.methods.map(method)} - - + private def klass(klass: MeasuredClass): Option[Node] = { + relativeSource(klass.source).map(sourcePath => { + + + {klass.methods.map(method)} + + + }) } private def pack(pack: MeasuredPackage): Node = { @@ -116,7 +125,7 @@ class ScoverageXmlWriter( statements-invoked={pack.invokedStatementCount.toString} statement-rate={pack.statementCoverageFormatted}> - {pack.classes.map(klass)} + {pack.classes.flatMap(klass)} } diff --git a/reporter/src/test/scala/scoverage/reporter/CoberturaXmlWriterTest.scala b/reporter/src/test/scala/scoverage/reporter/CoberturaXmlWriterTest.scala index d9dc1ef0..af17bb23 100644 --- a/reporter/src/test/scala/scoverage/reporter/CoberturaXmlWriterTest.scala +++ b/reporter/src/test/scala/scoverage/reporter/CoberturaXmlWriterTest.scala @@ -1,7 +1,8 @@ package scoverage.reporter import java.io.File -import java.util.UUID +import java.nio.file.Files +import java.nio.file.Path import javax.xml.parsers.DocumentBuilderFactory import javax.xml.parsers.SAXParserFactory @@ -12,79 +13,35 @@ import scala.xml.factory.XMLLoader import munit.FunSuite import org.xml.sax.ErrorHandler import org.xml.sax.SAXParseException -import scoverage.domain.ClassType import scoverage.domain.Coverage -import scoverage.domain.Location -import scoverage.domain.Statement + +import TestUtils._ +import BaseReportWriter.failIfNoSourceRoot /** @author Stephen Samuel */ class CoberturaXmlWriterTest extends FunSuite { - def tempDir(): File = { - val dir = new File(IOUtils.getTempDirectory, UUID.randomUUID.toString) - dir.mkdirs() - dir.deleteOnExit() - dir - } + val xmlOutputPath = FunFixture[Path]( + setup = test => { + val dir = Files.createTempDirectory("test-cobertura") + dir.resolve("cobertura.xml") + }, + teardown = file => { + Files.deleteIfExists(file) + Files.deleteIfExists(file.getParent()) + } + ) - def fileIn(dir: File) = new File(dir, "cobertura.xml") + // Let the current directory be our source root (any dir would do) + val sourceRoot = new File(".") - // Let current directory be our source root - private val sourceRoot = new File(".") - private def canonicalPath(fileName: String) = + def canonicalPath(fileName: String) = new File(sourceRoot, fileName).getCanonicalPath - test("cobertura output has relative file path") { - - val dir = tempDir() - - val coverage = Coverage() - coverage.add( - Statement( - Location( - "com.sksamuel.scoverage", - "A", - "com.sksamuel.scoverage.A", - ClassType.Object, - "create", - canonicalPath("a.scala") - ), - 1, - 2, - 3, - 12, - "", - "", - "", - false, - 3 - ) - ) - coverage.add( - Statement( - Location( - "com.sksamuel.scoverage.A", - "B", - "com.sksamuel.scoverage.A.B", - ClassType.Object, - "create", - canonicalPath("a/b.scala") - ), - 2, - 2, - 3, - 12, - "", - "", - "", - false, - 3 - ) - ) - - val writer = new CoberturaXmlWriter(sourceRoot, dir, None) - writer.write(coverage) + def relativePath(fileName: String) = + new File(sourceRoot, fileName).getPath.replace("./", "") + def parseXML(file: Path): Elem = { // Needed to acount for https://github.com/scala/scala-xml/pull/177 val customXML: XMLLoader[Elem] = XML.withSAXParser { val factory = SAXParserFactory.newInstance() @@ -94,316 +51,109 @@ class CoberturaXmlWriterTest extends FunSuite { ) factory.newSAXParser() } + customXML.loadFile(file.toFile()) + } - val xml = customXML.loadFile(fileIn(dir)) + xmlOutputPath.test("cobertura output has relative file path") { xmlPath => + val coverage = Coverage() + val outputDir = xmlPath.getParent().toFile() + coverage.add( + testStatement( + testLocation(canonicalPath("a.scala")) + ) + ) + coverage.add( + testStatement( + testLocation(canonicalPath("a/b.scala")) + ) + ) + val writer = + new CoberturaXmlWriter(sourceRoot, outputDir, None, failIfNoSourceRoot) + writer.write(coverage) + + val xml = parseXML(xmlPath) assertEquals( ((xml \\ "coverage" \ "packages" \ "package" \ "classes" \ "class")( 0 ) \ "@filename").text, - new File("a.scala").getPath() + relativePath("a.scala") ) assertEquals( ((xml \\ "coverage" \ "packages" \ "package" \ "classes" \ "class")( 1 ) \ "@filename").text, - new File("a", "b.scala").getPath() + relativePath("a/b.scala") ) } - test("cobertura output validates") { - - val dir = tempDir() - + xmlOutputPath.test("cobertura output validates") { xmlPath => val coverage = Coverage() - coverage - .add( - Statement( - Location( - "com.sksamuel.scoverage", - "A", - "com.sksamuel.scoverage.A", - ClassType.Object, - "create", - canonicalPath("a.scala") - ), - 1, - 2, - 3, - 12, - "", - "", - "", - false, - 3 - ) - ) - coverage - .add( - Statement( - Location( - "com.sksamuel.scoverage", - "A", - "com.sksamuel.scoverage.A", - ClassType.Object, - "create2", - canonicalPath("a.scala") - ), - 2, - 2, - 3, - 16, - "", - "", - "", - false, - 3 - ) - ) - coverage - .add( - Statement( - Location( - "com.sksamuel.scoverage2", - "B", - "com.sksamuel.scoverage2.B", - ClassType.Object, - "retrieve", - canonicalPath("b.scala") - ), - 3, - 2, - 3, - 21, - "", - "", - "", - false, - 0 - ) - ) - coverage - .add( - Statement( - Location( - "com.sksamuel.scoverage2", - "B", - "B", - ClassType.Object, - "retrieve2", - canonicalPath("b.scala") - ), - 4, - 2, - 3, - 9, - "", - "", - "", - false, - 3 - ) - ) - coverage - .add( - Statement( - Location( - "com.sksamuel.scoverage3", - "C", - "com.sksamuel.scoverage3.C", - ClassType.Object, - "update", - canonicalPath("c.scala") - ), - 5, - 2, - 3, - 66, - "", - "", - "", - true, - 3 - ) - ) - coverage - .add( - Statement( - Location( - "com.sksamuel.scoverage3", - "C", - "com.sksamuel.scoverage3.C", - ClassType.Object, - "update2", - canonicalPath("c.scala") - ), - 6, - 2, - 3, - 6, - "", - "", - "", - true, - 3 - ) - ) - coverage - .add( - Statement( - Location( - "com.sksamuel.scoverage4", - "D", - "com.sksamuel.scoverage4.D", - ClassType.Object, - "delete", - canonicalPath("d.scala") - ), - 7, - 2, - 3, - 4, - "", - "", - "", - false, - 0 - ) - ) - coverage - .add( - Statement( - Location( - "com.sksamuel.scoverage4", - "D", - "com.sksamuel.scoverage4.D", - ClassType.Object, - "delete2", - canonicalPath("d.scala") - ), - 8, - 2, - 3, - 14, - "", - "", - "", - false, - 0 - ) - ) - - val writer = new CoberturaXmlWriter(sourceRoot, dir, None) + val outputDir = xmlPath.getParent().toFile() + + val fakeSources = Seq("a.scala", "b.scala", "c.scala", "d.scala") + for { + s <- fakeSources + loc = testLocation(canonicalPath(s)) + isBranch <- Seq(true, false) + invokeCount <- Seq(0, 3) + } coverage.add(testStatement(loc, isBranch, invokeCount)) + + val writer = + new CoberturaXmlWriter(sourceRoot, outputDir, None, failIfNoSourceRoot) writer.write(coverage) val domFactory = DocumentBuilderFactory.newInstance() domFactory.setValidating(true) val builder = domFactory.newDocumentBuilder() builder.setErrorHandler(new ErrorHandler() { - @Override - def error(e: SAXParseException): Unit = { + override def error(e: SAXParseException): Unit = { fail(e.getMessage(), e.getCause()) } - @Override - def fatalError(e: SAXParseException): Unit = { + override def fatalError(e: SAXParseException): Unit = { fail(e.getMessage(), e.getCause()) } - - @Override - def warning(e: SAXParseException): Unit = { + override def warning(e: SAXParseException): Unit = { fail(e.getMessage(), e.getCause()) } }) - builder.parse(fileIn(dir)) + builder.parse(xmlPath.toFile()) } - test( + xmlOutputPath.test( "coverage rates are written as 2dp decimal values rather than percentage" - ) { - - val dir = tempDir() - + ) { xmlPath => val coverage = Coverage() - coverage - .add( - Statement( - Location( - "com.sksamuel.scoverage", - "A", - "com.sksamuel.scoverage.A", - ClassType.Object, - "create", - canonicalPath("a.scala") - ), - 1, - 2, - 3, - 12, - "", - "", - "", - false - ) + val outputDir = xmlPath.getParent().toFile() + val fakeSourcePath = canonicalPath("a.scala") + coverage.add( + testStatement( + testLocation(fakeSourcePath), + isBranch = false, + invokeCount = 0 // not covered ) - coverage - .add( - Statement( - Location( - "com.sksamuel.scoverage", - "A", - "com.sksamuel.scoverage.A", - ClassType.Object, - "create2", - canonicalPath("a.scala") - ), - 2, - 2, - 3, - 16, - "", - "", - "", - true - ) + ) + coverage.add( + testStatement( + testLocation(fakeSourcePath), + isBranch = true, + invokeCount = 0 // not covered ) - coverage - .add( - Statement( - Location( - "com.sksamuel.scoverage", - "A", - "com.sksamuel.scoverage.A", - ClassType.Object, - "create3", - canonicalPath("a.scala") - ), - 3, - 3, - 3, - 20, - "", - "", - "", - true, - 1 - ) + ) + coverage.add( + testStatement( + testLocation(fakeSourcePath), + isBranch = true, + invokeCount = 1 // covered ) + ) - val writer = new CoberturaXmlWriter(sourceRoot, dir, None) + val writer = + new CoberturaXmlWriter(sourceRoot, outputDir, None, failIfNoSourceRoot) writer.write(coverage) - // Needed to acount for https://github.com/scala/scala-xml/pull/177 - val customXML: XMLLoader[Elem] = XML.withSAXParser { - val factory = SAXParserFactory.newInstance() - factory.setFeature( - "http://apache.org/xml/features/nonvalidating/load-external-dtd", - false - ) - factory.newSAXParser() - } - - val xml = customXML.loadFile(fileIn(dir)) + val xml = parseXML(xmlPath) assertEquals((xml \\ "coverage" \ "@line-rate").text, "0.33", "line-rate") assertEquals( @@ -413,4 +163,67 @@ class CoberturaXmlWriterTest extends FunSuite { ) } + + def testPathRecovery(name: String, policy: BaseReportWriter.PathRecoverer)(checks: Elem => Unit)(implicit loc: munit.Location) = { + xmlOutputPath.test(name) { xmlPath => + val outputDir = xmlPath.getParent().toFile() + val coverage = Coverage() + + val notInRoot = "/*not*/in/root.scala" + val inRoot = "in-root.sc" + + coverage.add( + testStatement( + testLocation(notInRoot, className = "A") // should be replaced + ) + ) + coverage.add( + testStatement( + testLocation( + canonicalPath(inRoot), + className = "B" + ) // should be unchanged + ) + ) + val writer = new CoberturaXmlWriter( + sourceRoot, + outputDir, + None, + policy + ) + writer.write(coverage) + + val xml = parseXML(xmlPath) + checks(xml) + } + } + + testPathRecovery("path recovery replace", (f, roots) => Some("recovered/path")) { xml => + val classes = + (xml \\ "coverage" \ "packages" \ "package" \ "classes" \ "class") + + assertEquals( + (classes(0) \ "@filename").text, + "recovered/path" + ) + assertEquals( + (classes(1) \ "@filename").text, + relativePath("in-root.sc") + ) + } + + testPathRecovery("path recovery: skip", (f, roots) => None) { xml => + val classes = + (xml \\ "coverage" \ "packages" \ "package" \ "classes" \ "class") + + println(classes) + assertEquals( + (classes(0) \ "@filename").text, + "in-root.sc" + ) + assertEquals( + classes.length, + 1 + ) + } } diff --git a/reporter/src/test/scala/scoverage/reporter/TestUtils.scala b/reporter/src/test/scala/scoverage/reporter/TestUtils.scala new file mode 100644 index 00000000..d78f758a --- /dev/null +++ b/reporter/src/test/scala/scoverage/reporter/TestUtils.scala @@ -0,0 +1,43 @@ +package scoverage.reporter + +import scoverage.domain.Location +import scoverage.domain.ClassType +import scoverage.domain.Statement + +object TestUtils { + private var nextId = 0 + + def testLocation( + sourcePath: String, + className: String = s"T$nextId", + classType: ClassType = ClassType.Class + ): Location = + Location( + "scoverage.test", + className, + s"scoverage.test.$className", + classType, + "method", + sourcePath + ) + + def testStatement( + location: Location, + isBranch: Boolean = false, + invokeCount: Int = 0 + ): Statement = { + nextId += 1 + Statement( + location, + nextId, + 10 + nextId, + 50 + nextId, + nextId * 10, + nextId.toString, + "sym", + "", + isBranch, + invokeCount + ) + } +}