diff --git a/src/main/scala/de/johoop/jacoco4sbt/FormattedReport.scala b/src/main/scala/de/johoop/jacoco4sbt/FormattedReport.scala index 9a52ba0a..68ebeb77 100644 --- a/src/main/scala/de/johoop/jacoco4sbt/FormattedReport.scala +++ b/src/main/scala/de/johoop/jacoco4sbt/FormattedReport.scala @@ -41,6 +41,14 @@ case class HTMLReport(encoding: String = "utf-8") extends FormattedReport { } } +case class ScalaHTMLReport(encoding: String = "utf-8") extends FormattedReport { + def visitor(directory: File) = { + val formatter = new ScalaHtmlFormatter + formatter setOutputEncoding encoding + formatter createVisitor new FileMultiReportOutput(new File(directory, "html")) + } +} + case class XMLReport(encoding: String = "utf-8") extends FormattedReport { def visitor(directory: File) = { val formatter = new XMLFormatter diff --git a/src/main/scala/de/johoop/jacoco4sbt/JacocoPlugin.scala b/src/main/scala/de/johoop/jacoco4sbt/JacocoPlugin.scala index 78d22406..8a2708a7 100755 --- a/src/main/scala/de/johoop/jacoco4sbt/JacocoPlugin.scala +++ b/src/main/scala/de/johoop/jacoco4sbt/JacocoPlugin.scala @@ -21,7 +21,7 @@ object JacocoPlugin extends Plugin { private object JacocoDefaults extends Reporting with Keys { val settings = Seq( outputDirectory := crossTarget.value / "jacoco", - reportFormats := Seq(HTMLReport()), + reportFormats := Seq(ScalaHTMLReport()), reportTitle := "Jacoco Coverage Report", sourceTabWidth := 2, sourceEncoding := "utf-8", diff --git a/src/main/scala/de/johoop/jacoco4sbt/Report.scala b/src/main/scala/de/johoop/jacoco4sbt/Report.scala index 07957433..2e814719 100644 --- a/src/main/scala/de/johoop/jacoco4sbt/Report.scala +++ b/src/main/scala/de/johoop/jacoco4sbt/Report.scala @@ -18,6 +18,7 @@ import html.HTMLFormatter import java.io.File import java.io.FileInputStream +import de.johoop.jacoco4sbt.filter.FilteringAnalyzer class Report(executionDataFile: File, classDirectories: Seq[File], sourceDirectories: Seq[File], sourceEncoding: String, tabWidth: Int, @@ -51,7 +52,7 @@ class Report(executionDataFile: File, classDirectories: Seq[File], private def analyzeStructure(executionDataStore: ExecutionDataStore, sessionInfoStore: SessionInfoStore) = { val coverageBuilder = new CoverageBuilder - val analyzer = new Analyzer(executionDataStore, coverageBuilder) + val analyzer = new FilteringAnalyzer(executionDataStore, coverageBuilder) classDirectories foreach { analyzer analyzeAll _ } diff --git a/src/main/scala/de/johoop/jacoco4sbt/ScalaHtmlFormatter.java b/src/main/scala/de/johoop/jacoco4sbt/ScalaHtmlFormatter.java new file mode 100644 index 00000000..fd044fa2 --- /dev/null +++ b/src/main/scala/de/johoop/jacoco4sbt/ScalaHtmlFormatter.java @@ -0,0 +1,60 @@ +package de.johoop.jacoco4sbt; + +import org.jacoco.core.analysis.ICoverageNode; +import org.jacoco.report.html.HTMLFormatter; +import org.jacoco.report.internal.html.resources.Styles; +import org.jacoco.report.internal.html.table.BarColumn; +import org.jacoco.report.internal.html.table.CounterColumn; +import org.jacoco.report.internal.html.table.LabelColumn; +import org.jacoco.report.internal.html.table.Table; + +/** + * Omits displaying instruction and branch coverage in the coverage tables, as Scala generates null checks which make these too noisy + * + * TODO: Find a way to remove them from the annotated source code reports, too. + */ +public class ScalaHtmlFormatter extends HTMLFormatter { + private Table table; + + public ScalaHtmlFormatter() { + setLanguageNames(new ScalaLanguageNames()); + } + + public Table getTable() { + if (table == null) { + table = createTable(); + } + return table; + } + + private Table createTable() { + final Table t = new Table(); + t.add("Element", null, new LabelColumn(), false); + + // Just show line coverage in Scala projects. + // t.add("Missed Instructions", Styles.BAR, new BarColumn(ICoverageNode.CounterEntity.INSTRUCTION, + // locale), true); + // t.add("Cov.", Styles.CTR2, + // new PercentageColumn(ICoverageNode.CounterEntity.INSTRUCTION, locale), false); + // t.add("Missed Branches", Styles.BAR, new BarColumn(ICoverageNode.CounterEntity.BRANCH, locale), + // false); + // t.add("Cov.", Styles.CTR2, new PercentageColumn(ICoverageNode.CounterEntity.BRANCH, locale), + // false); + // addMissedTotalColumns(t, "Cxty", ICoverageNode.CounterEntity.COMPLEXITY); + + t.add("Missed Lines", Styles.BAR, new BarColumn(ICoverageNode.CounterEntity.LINE, getLocale()), true); + t.add("Total Lines", Styles.CTR1, CounterColumn.newTotal(ICoverageNode.CounterEntity.LINE, getLocale()), false); + + addMissedTotalColumns(t, "Methods", ICoverageNode.CounterEntity.METHOD); + addMissedTotalColumns(t, "Classes", ICoverageNode.CounterEntity.CLASS); + return t; + } + + private void addMissedTotalColumns(final Table table, final String label, + final ICoverageNode.CounterEntity entity) { + table.add("Missed", Styles.CTR1, + CounterColumn.newMissed(entity, getLocale()), false); + table.add(label, Styles.CTR2, CounterColumn.newTotal(entity, getLocale()), + false); + } +} diff --git a/src/main/scala/de/johoop/jacoco4sbt/ScalaLanguageNames.scala b/src/main/scala/de/johoop/jacoco4sbt/ScalaLanguageNames.scala new file mode 100644 index 00000000..f857f011 --- /dev/null +++ b/src/main/scala/de/johoop/jacoco4sbt/ScalaLanguageNames.scala @@ -0,0 +1,52 @@ +package de.johoop.jacoco4sbt + +import org.jacoco.report.JavaNames +import scala.reflect.NameTransformer._ +import de.johoop.jacoco4sbt.filter.ScalaForwarderDetector + +class ScalaLanguageNames extends JavaNames { + override def getPackageName(vmname: String): String = + super.getPackageName(decode(vmname)) + + override def getClassName(vmname: String, vmsignature: String, vmsuperclass: String, vminterfaces: Array[String]): String = { + if (vmname.contains("anonfun$")) + vmname.split("""anonfun\$""").toList match { + case List(pre, post) => + getClassName(cleanClassName(pre)) + " anonfun$" + post + case _ => + getClassName(cleanClassName(vmname)) + } + else if (vmname.contains("$anon$")) + vminterfaces.map(getClassName).mkString("new " + getClassName(vmsuperclass), " with ", "{ ... }") + else getClassName(cleanClassName(vmname)) + } + + override def getQualifiedClassName(vmname: String): String = + super.getQualifiedClassName(cleanClassName(vmname)) + + override def getMethodName(vmclassname: String, vmmethodname: String, vmdesc: String, vmsignature: String): String = + super.getMethodName(vmclassname, getMethodName(vmmethodname), vmdesc, vmsignature) + + override def getQualifiedMethodName(vmclassname: String, vmmethodname: String, vmdesc: String, vmsignature: String): String = + super.getQualifiedMethodName(vmclassname, getMethodName(vmmethodname), vmdesc, vmsignature) + + private def cleanClassName(name: String) = { + decode(name.stripSuffix("$class")) + } + + private def getClassName(vmname: String) = { + val pos: Int = vmname.lastIndexOf('/') + val name: String = if (pos == -1) vmname else vmname.substring(pos + 1) + cleanClassName( + if (name.endsWith("$$")) name.dropRight(2).replace('$', '.') // ambiguous, we could be an inner class of the object or the class. + else if (name.endsWith("$")) name.dropRight(1).replace('$', '.') + " (object)" + else name.replace('$', '.') + ) + } + + private def getMethodName(vmname: String) = { + val pos: Int = vmname.lastIndexOf("$$") + val name: String = if (pos == -1) vmname else vmname.substring(pos + 2) + decode(name.stripSuffix(ScalaForwarderDetector.LazyComputeSuffix)) + } +} diff --git a/src/main/scala/de/johoop/jacoco4sbt/filter/AccessorDetector.scala b/src/main/scala/de/johoop/jacoco4sbt/filter/AccessorDetector.scala new file mode 100644 index 00000000..6c151ba6 --- /dev/null +++ b/src/main/scala/de/johoop/jacoco4sbt/filter/AccessorDetector.scala @@ -0,0 +1,23 @@ +package de.johoop.jacoco4sbt.filter + +import org.objectweb.asm.tree._ +import org.objectweb.asm.Opcodes +import scala.collection.JavaConverters._ +import Opcodes._ + +/** Detects accessor methods that do nothing other than load and return a field */ +object AccessorDetector { + def isAccessor(node: MethodNode): Boolean = { + (node.instructions.size() < 10) && { + val insn: List[AbstractInsnNode] = node.instructions.iterator().asInstanceOf[java.util.ListIterator[AbstractInsnNode]].asScala.toList + val filtered = insn.filter { + case _: LabelNode | _: LineNumberNode => false + case _ => true + } + filtered.map(_.getOpcode) match { + case ALOAD :: GETFIELD :: (IRETURN | LRETURN | FRETURN | DRETURN | ARETURN | RETURN) :: Nil => true + case _ => false + } + } + } +} diff --git a/src/main/scala/de/johoop/jacoco4sbt/filter/FilteringClassAnalyzer.scala b/src/main/scala/de/johoop/jacoco4sbt/filter/FilteringClassAnalyzer.scala new file mode 100644 index 00000000..45cf5b4e --- /dev/null +++ b/src/main/scala/de/johoop/jacoco4sbt/filter/FilteringClassAnalyzer.scala @@ -0,0 +1,105 @@ +package de.johoop.jacoco4sbt.filter + +import scala.collection.mutable +import org.jacoco.core.internal.analysis.{MethodAnalyzer, StringPool, ClassAnalyzer} +import org.jacoco.core.internal.flow.{ClassProbesAdapter, MethodProbesVisitor} +import org.jacoco.core.internal.instr.InstrSupport +import org.objectweb.asm._ +import org.jacoco.core.analysis.{Analyzer, ICoverageVisitor, IMethodCoverage} +import org.jacoco.core.internal.data.CRC64 +import org.jacoco.core.data.ExecutionDataStore +import org.objectweb.asm.tree.{JumpInsnNode, MethodInsnNode, MethodNode, ClassNode} +import scala.collection.JavaConverters._ + +/** + * Filters coverage results from Scala synthetic methods: + * - trait forwarders + * - case class toString / equals / apply / unapply + * + * These are identified by the heuristic that they have the same line number as a constructor, or + * the same line as other one-line methods if we are in a module class. + * + * This filtering should really happen in Jacoco core, but the API for this is not available and + * scheduled for Q1 2014. + * + * See [[https://github.com/jacoco/jacoco/wiki/FilteringOptions]] and [[https://github.com/jacoco/jacoco/issues/139]] + * for more discussion of the JaCoCo roadmap. + * + * These filters are based on [[https://github.com/timezra/jacoco/commit/b6146ebed8b8e7507ec634ee565fe03f3e940fdd]], + * but extended to correctly exclude synthetics in module classes. + */ +private final class FilteringClassAnalyzer(classid: Long, classNode: ClassNode, probes: Array[Boolean], + stringPool: StringPool, coverageVisitor: ICoverageVisitor) extends ClassAnalyzer(classid, probes, stringPool) { + + private val className = classNode.name + + private val coverages = mutable.Buffer[IMethodCoverage]() + + override def visitMethod(access: Int, name: String, desc: String, + signature: String, exceptions: Array[String]): MethodProbesVisitor = { + InstrSupport.assertNotInstrumented(name, getCoverage.getName) + if ((access & Opcodes.ACC_SYNTHETIC) != 0) + null + else { + new MethodAnalyzer(stringPool.get(name), stringPool.get(desc), stringPool.get(signature), probes) { + override def visitEnd() { + super.visitEnd() + val hasInstructions = getCoverage.getInstructionCounter.getTotalCount > 0 + if (hasInstructions) + coverages += getCoverage + } + } + } + } + + override def visitEnd() { + try visitFiltered() + finally { + super.visitEnd() + coverageVisitor.visitCoverage(getCoverage) + } + } + + private val isModuleClass = className.endsWith("$") + + private val methods: Seq[MethodNode] = classNode.methods.asInstanceOf[java.util.List[MethodNode]].asScala + + private def visitFiltered() { + for { + mc <- coverages + methodNode = methods.find(m => m.name == mc.getName && m.desc == mc.getDesc).get + if !ignore(mc, methodNode) + } getCoverage.addMethod(mc) + } + + private def ignore(mc: IMethodCoverage, node: MethodNode): Boolean = { + import node.name + import ScalaSyntheticMethod._, ScalaForwarderDetector._, AccessorDetector._ + def isModuleStaticInit = isModuleClass && name == "" + + ( + isSyntheticMethod(className, name, mc.getFirstLine, mc.getLastLine) // equals/hashCode/unapply et al + || isModuleStaticInit // static init, `otherwise `case class Foo` reports uncovered code if `object Foo` is not accessed + || isScalaForwarder(className, node) + || isAccessor(node) + ) + } +} + +final class FilteringAnalyzer(executionData: ExecutionDataStore, + coverageVisitor: ICoverageVisitor) extends Analyzer(executionData, coverageVisitor) { + override def analyzeClass(reader: ClassReader) { + val classNode = new ClassNode() + reader.accept(classNode, 0) + val visitor = createFilteringVisitor(CRC64.checksum(reader.b), classNode) + reader.accept(visitor, 0) + } + + private def createFilteringVisitor(classid: Long, classNode: ClassNode): ClassVisitor = { + val data = executionData.get(classid) + val probes = if (data == null) null else data.getProbes + val stringPool = new StringPool + val analyzer = new FilteringClassAnalyzer(classid, classNode, probes, stringPool, coverageVisitor) + new ClassProbesAdapter(analyzer) + } +} diff --git a/src/main/scala/de/johoop/jacoco4sbt/filter/ScalaForwarderDetector.scala b/src/main/scala/de/johoop/jacoco4sbt/filter/ScalaForwarderDetector.scala new file mode 100644 index 00000000..079d4186 --- /dev/null +++ b/src/main/scala/de/johoop/jacoco4sbt/filter/ScalaForwarderDetector.scala @@ -0,0 +1,53 @@ +package de.johoop.jacoco4sbt.filter + +import org.objectweb.asm.Opcodes +import org.objectweb.asm.tree.{JumpInsnNode, MethodNode, MethodInsnNode} +import scala.collection.JavaConverters._ + +/** Detects forwarder methods added by Scala + * - classes and objects that mix in traits have a forwarder to the method body + * in the trait implementation class + * - classes contains static forwarders to methods in the companion object (for convenient Java interop) + * - methods in (boxed) value classes forward to the method body in the companion object + * - implicit classes creates a factory method beside the class. + * - lazy vals have an accessor that forwards to $lzycompute, which is the method + * with the interesting code. + */ +object ScalaForwarderDetector { + val LazyComputeSuffix: String = "$lzycompute" + def isScalaForwarder(className: String, node: MethodNode): Boolean = { + if (node.instructions.size() > 100) return false + + val insn = node.instructions.iterator().asScala.toList + val hasJump = insn.exists { + case insn: JumpInsnNode => true + case _ => false + } + val hasForwarderCall = insn.exists { + case insn: MethodInsnNode => + isScalaForwarder(className, node.name, insn.getOpcode, insn.owner, insn.name, insn.desc, hasJump) + case _ => false + } + hasForwarderCall + } + + def isScalaForwarder(className: String, methodName: String, opcode: Int, calledMethodOwner: String, + calledMethodName: String, desc: String, hasJump: Boolean): Boolean = { + def callingCompanionModule = calledMethodOwner == (className + "$") + val callingImplClass = calledMethodOwner.endsWith("$class") + val callingImplicitClass = calledMethodOwner.endsWith("$" + methodName) || calledMethodOwner == methodName + def extensionName = methodName + "$extension" + import Opcodes._ + + val staticForwarder = opcode == INVOKEVIRTUAL && callingCompanionModule && calledMethodName == methodName + val traitForwarder = opcode == INVOKESTATIC && callingImplClass && calledMethodName == methodName + val extensionMethodForwarder = opcode == INVOKEVIRTUAL && callingCompanionModule && calledMethodName == extensionName + val implicitClassFactory = opcode == INVOKESPECIAL && callingImplicitClass && calledMethodName == "" + val lazyAccessor = opcode == INVOKESPECIAL && calledMethodName.endsWith(LazyComputeSuffix) + val forwards = ( + (staticForwarder || traitForwarder || extensionMethodForwarder || implicitClassFactory) && !hasJump // second condition a sanity check + || lazyAccessor + ) + forwards + } +} diff --git a/src/main/scala/de/johoop/jacoco4sbt/filter/ScalaSyntheticMethod.scala b/src/main/scala/de/johoop/jacoco4sbt/filter/ScalaSyntheticMethod.scala new file mode 100644 index 00000000..e0057ee2 --- /dev/null +++ b/src/main/scala/de/johoop/jacoco4sbt/filter/ScalaSyntheticMethod.scala @@ -0,0 +1,30 @@ +package de.johoop.jacoco4sbt.filter + +object ScalaSyntheticMethod { + def isSyntheticMethod(owner: String, name: String, firstLine: Int, lastLine: Int) = { + val isModuleClass = owner.endsWith("$") + val isOneLiner = firstLine == lastLine + isOneLiner && ( + (isModuleClass && isSyntheticObjectMethodName(name)) + || isSyntheticInstanceMethodName(name) + ) + } + + private def isSyntheticInstanceMethodName(name: String): Boolean = isCaseInstanceMethod(name) + private def isSyntheticObjectMethodName(name: String): Boolean = isCaseCompanionMethod(name) || isAnyValCompanionMethod(name) + + private def isCaseInstanceMethod(name: String) = name match { + case "canEqual" | "copy" | "equals" | "hashCode" |"productPrefix" | + "productArity" | "productElement" | "productIterator" | "toString" => true + case _ if name.startsWith("copy$default") => true + case _ => false + } + private def isCaseCompanionMethod(name: String) = name match { + case "apply" | "unapply" | "unapplySeq" | "readResolve" => true + case _ => false + } + private def isAnyValCompanionMethod(name: String) = name match { + case "equals$extension" | "hashCode$extension" => true + case _ => false + } +}