Support path mappings in JacocoLCOVFormatter
Addresses #12159 by adding support for an additional path format in the JacocoCoverageRunner's `-paths-for-coverage.txt` file which allows rule authors to provide either individual source paths or a new source to class path mapping: `srcPath///classPath`. This new format provides a means to handle cases where a source file's package hierarchy is different than the directory tree where it resides, which is fairly common in other JVM languages like Scala (see #12159 for examples).
Since `JacocoLCOVFormatter` still supports the old path format, this change should not affect any downstream users of `JacocoCoverageRunner`. The goal is just to provide a mechanism for libraries like `rules_scala` to be able to implement coverage support for source files with varying directory structures.
cc @comius @sjoerdvisscher @liucijus
Closes #12627.
PiperOrigin-RevId: 351757439
diff --git a/src/BUILD b/src/BUILD
index 6c55a33..e99b7ff 100644
--- a/src/BUILD
+++ b/src/BUILD
@@ -433,6 +433,7 @@
"//src/java_tools/buildjar:srcs",
"//src/java_tools/import_deps_checker:srcs",
"//src/java_tools/junitrunner:srcs",
+ "//src/java_tools/junitrunner/javatests/com/google/testing/coverage:srcs",
"//src/java_tools/singlejar:srcs",
"//src/main/cpp:srcs",
"//src/main/res:srcs",
diff --git a/src/java_tools/junitrunner/java/com/google/testing/coverage/BUILD b/src/java_tools/junitrunner/java/com/google/testing/coverage/BUILD
index 15a07c0..efbb13d 100644
--- a/src/java_tools/junitrunner/java/com/google/testing/coverage/BUILD
+++ b/src/java_tools/junitrunner/java/com/google/testing/coverage/BUILD
@@ -18,12 +18,26 @@
java_binary(
name = "JacocoCoverage",
srcs = [
+ "JacocoCoverageRunner.java",
+ ],
+ deps = [
+ ":JacocoCoverageLib",
+ ":bitfield",
+ "//third_party:guava",
+ "//third_party/java/jacoco:blaze-agent-0.8.3",
+ "//third_party/java/jacoco:core-0.8.3",
+ "//third_party/java/jacoco:report-0.8.3",
+ ],
+)
+
+java_library(
+ name = "JacocoCoverageLib",
+ srcs = [
"BranchCoverageDetail.java",
"BranchDetailAnalyzer.java",
"BranchExp.java",
"ClassProbesMapper.java",
"CovExp.java",
- "JacocoCoverageRunner.java",
"JacocoLCOVFormatter.java",
"MethodProbesMapper.java",
"ProbeExp.java",
diff --git a/src/java_tools/junitrunner/java/com/google/testing/coverage/JacocoCoverageRunner.java b/src/java_tools/junitrunner/java/com/google/testing/coverage/JacocoCoverageRunner.java
index 103e81b..c466d49 100644
--- a/src/java_tools/junitrunner/java/com/google/testing/coverage/JacocoCoverageRunner.java
+++ b/src/java_tools/junitrunner/java/com/google/testing/coverage/JacocoCoverageRunner.java
@@ -15,6 +15,9 @@
package com.google.testing.coverage;
import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.nio.file.Files.newBufferedWriter;
+import static java.nio.file.StandardOpenOption.APPEND;
+import static java.nio.file.StandardOpenOption.CREATE;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.collect.ImmutableList;
@@ -29,6 +32,7 @@
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
+import java.io.PrintWriter;
import java.io.Reader;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
@@ -68,9 +72,10 @@
* http://www.eclemma.org/jacoco/trunk/doc/examples/java/ReportGenerator.java
*
* <p>The following environment variables are expected:
+ *
* <ul>
- * <li>JAVA_COVERAGE_FILE - specifies final location of the generated lcov file.</li>
- * <li>JACOCO_METADATA_JAR - specifies jar containing uninstrumented classes to be analyzed.</li>
+ * <li>JAVA_COVERAGE_FILE - specifies final location of the generated lcov file.
+ * <li>JACOCO_METADATA_JAR - specifies jar containing uninstrumented classes to be analyzed.
* </ul>
*/
public class JacocoCoverageRunner {
@@ -81,7 +86,6 @@
private ExecFileLoader execFileLoader;
private HashMap<String, byte[]> uninstrumentedClasses;
private ImmutableSet<String> pathsForCoverage = ImmutableSet.of();
-
/**
* Creates a new coverage runner extracting the classes jars from a wrapper file. Uses
* javaRunfilesRoot to compute the absolute path of the jars inside the wrapper file.
@@ -133,37 +137,40 @@
final IBundleCoverage bundleCoverage, final Map<String, BranchCoverageDetail> branchDetails)
throws IOException {
JacocoLCOVFormatter formatter = new JacocoLCOVFormatter(createPathsSet());
- final IReportVisitor visitor = formatter.createVisitor(reportFile, branchDetails);
+ try (PrintWriter writer =
+ new PrintWriter(newBufferedWriter(reportFile.toPath(), UTF_8, CREATE, APPEND))) {
+ final IReportVisitor visitor = formatter.createVisitor(writer, branchDetails);
- // Initialize the report with all of the execution and session information. At this point the
- // report doesn't know about the structure of the report being created.
- visitor.visitInfo(
- execFileLoader.getSessionInfoStore().getInfos(),
- execFileLoader.getExecutionDataStore().getContents());
+ // Initialize the report with all of the execution and session information. At this point the
+ // report doesn't know about the structure of the report being created.
+ visitor.visitInfo(
+ execFileLoader.getSessionInfoStore().getInfos(),
+ execFileLoader.getExecutionDataStore().getContents());
- // Populate the report structure with the bundle coverage information.
- // Call visitGroup if you need groups in your report.
+ // Populate the report structure with the bundle coverage information.
+ // Call visitGroup if you need groups in your report.
- // Note the API requires a sourceFileLocator because the HTML and XML formatters display a page
- // of code annotated with coverage information. Having the source files is not actually needed
- // for generating the lcov report...
- visitor.visitBundle(
- bundleCoverage,
- new ISourceFileLocator() {
+ // Note the API requires a sourceFileLocator because the HTML and XML formatters display a
+ // page of code annotated with coverage information. Having the source files is not actually
+ // needed for generating the lcov report.
+ visitor.visitBundle(
+ bundleCoverage,
+ new ISourceFileLocator() {
- @Override
- public Reader getSourceFile(String packageName, String fileName) throws IOException {
- return null;
- }
+ @Override
+ public Reader getSourceFile(String packageName, String fileName) throws IOException {
+ return null;
+ }
- @Override
- public int getTabWidth() {
- return 0;
- }
- });
+ @Override
+ public int getTabWidth() {
+ return 0;
+ }
+ });
- // Signal end of structure information to allow report to write all information out
- visitor.visitEnd();
+ // Signal end of structure information to allow report to write all information out
+ visitor.visitEnd();
+ }
}
@VisibleForTesting
@@ -252,11 +259,18 @@
* Adds to the given {@link Set} the paths found in a txt file inside the given jar.
*
* <p>If a jar contains uninstrumented classes it will also contain a txt file with the paths of
- * each of these classes, one on each line.
+ * each of these classes, called "-paths-for-coverage.txt". This file expects one path per line
+ * specified as either:
+ *
+ * <ul>
+ * <li>A single path (e.g. /dir/com/example/Foo.java).
+ * <li>A mapping between source and class paths delimited with by /// (e.g.
+ * /dir/Foo.java////com/example/Foo.java).
+ * </ul>
*/
@VisibleForTesting
- static void addEntriesToExecPathsSet(
- File jar, ImmutableSet.Builder<String> execPathsSetBuilder) throws IOException {
+ static void addEntriesToExecPathsSet(File jar, ImmutableSet.Builder<String> execPathsSetBuilder)
+ throws IOException {
JarFile jarFile = new JarFile(jar);
Enumeration<JarEntry> jarFileEntries = jarFile.entries();
while (jarFileEntries.hasMoreElements()) {
diff --git a/src/java_tools/junitrunner/java/com/google/testing/coverage/JacocoLCOVFormatter.java b/src/java_tools/junitrunner/java/com/google/testing/coverage/JacocoLCOVFormatter.java
index 9631d5c..ae9f058 100644
--- a/src/java_tools/junitrunner/java/com/google/testing/coverage/JacocoLCOVFormatter.java
+++ b/src/java_tools/junitrunner/java/com/google/testing/coverage/JacocoLCOVFormatter.java
@@ -13,16 +13,10 @@
// limitations under the License.
package com.google.testing.coverage;
-import static java.nio.charset.StandardCharsets.UTF_8;
-import static java.nio.file.StandardOpenOption.APPEND;
-import static java.nio.file.StandardOpenOption.CREATE;
import com.google.common.collect.ImmutableSet;
-import java.io.File;
import java.io.IOException;
import java.io.PrintWriter;
-import java.io.Writer;
-import java.nio.file.Files;
import java.util.Collection;
import java.util.List;
import java.util.Map;
@@ -49,8 +43,12 @@
// Exec paths of the uninstrumented files that are being analyzed. This is helpful for files in
// jars passed through java_import or some custom rule where blaze doesn't have enough context to
// compute the right paths, but relies on these pre-computed exec paths.
+ // Exec paths can be provided in two formats, either as a plain string or as a delimited
+ // string mapping source file paths to class paths.
private final ImmutableSet<String> execPathsOfUninstrumentedFiles;
+ private static final String EXEC_PATH_DELIMITER = "///";
+
public JacocoLCOVFormatter(ImmutableSet<String> execPathsOfUninstrumentedFiles) {
this.execPathsOfUninstrumentedFiles = execPathsOfUninstrumentedFiles;
}
@@ -60,7 +58,7 @@
}
public IReportVisitor createVisitor(
- final File output, final Map<String, BranchCoverageDetail> branchCoverageDetail) {
+ PrintWriter output, final Map<String, BranchCoverageDetail> branchCoverageDetail) {
return new IReportVisitor() {
private Map<String, Map<String, IClassCoverage>> sourceToClassCoverage = new TreeMap<>();
@@ -73,7 +71,15 @@
String matchingFileName = fileName.startsWith("/") ? fileName : "/" + fileName;
for (String execPath : execPathsOfUninstrumentedFiles) {
- if (execPath.endsWith(matchingFileName)) {
+ if (execPath.contains(EXEC_PATH_DELIMITER)) {
+ String[] parts = execPath.split(EXEC_PATH_DELIMITER, 2);
+ if (parts.length != 2) {
+ continue;
+ }
+ if (parts[1].equals(matchingFileName)) {
+ return parts[0];
+ }
+ } else if (execPath.endsWith(matchingFileName)) {
return execPath;
}
}
@@ -86,11 +92,8 @@
@Override
public void visitEnd() throws IOException {
- try (Writer fileWriter = Files.newBufferedWriter(output.toPath(), UTF_8, CREATE, APPEND);
- PrintWriter printWriter = new PrintWriter(fileWriter)) {
- for (String sourceFile : sourceToClassCoverage.keySet()) {
- processSourceFile(printWriter, sourceFile);
- }
+ for (String sourceFile : sourceToClassCoverage.keySet()) {
+ processSourceFile(output, sourceFile);
}
}
diff --git a/src/java_tools/junitrunner/javatests/com/google/testing/coverage/BUILD b/src/java_tools/junitrunner/javatests/com/google/testing/coverage/BUILD
new file mode 100644
index 0000000..1f079d0
--- /dev/null
+++ b/src/java_tools/junitrunner/javatests/com/google/testing/coverage/BUILD
@@ -0,0 +1,28 @@
+load("@rules_java//java:defs.bzl", "java_test")
+
+package(default_visibility = ["//src:__subpackages__"])
+
+licenses(["notice"])
+
+filegroup(
+ name = "srcs",
+ srcs = glob(["*"]),
+ visibility = ["//src:__subpackages__"],
+)
+
+java_test(
+ name = "JacocoLCOVFormatterUninstrumentedTest",
+ size = "small",
+ srcs = [
+ "JacocoLCOVFormatterUninstrumentedTest.java",
+ ],
+ deps = [
+ "//src/java_tools/junitrunner/java/com/google/testing/coverage:JacocoCoverageLib",
+ "//third_party:guava",
+ "//third_party:junit4",
+ "//third_party:mockito",
+ "//third_party:truth",
+ "//third_party/java/jacoco:core-0.8.3",
+ "//third_party/java/jacoco:report-0.8.3",
+ ],
+)
diff --git a/src/java_tools/junitrunner/javatests/com/google/testing/coverage/JacocoLCOVFormatterUninstrumentedTest.java b/src/java_tools/junitrunner/javatests/com/google/testing/coverage/JacocoLCOVFormatterUninstrumentedTest.java
new file mode 100644
index 0000000..0e6aab1
--- /dev/null
+++ b/src/java_tools/junitrunner/javatests/com/google/testing/coverage/JacocoLCOVFormatterUninstrumentedTest.java
@@ -0,0 +1,162 @@
+// Copyright 2020 The Bazel Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.testing.coverage;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import com.google.common.collect.ImmutableSet;
+import java.io.IOException;
+import java.io.PrintWriter;
+import java.io.StringWriter;
+import java.util.Arrays;
+import java.util.List;
+import java.util.TreeMap;
+import org.jacoco.core.analysis.IBundleCoverage;
+import org.jacoco.core.analysis.IClassCoverage;
+import org.jacoco.core.analysis.IPackageCoverage;
+import org.jacoco.report.IReportVisitor;
+import org.jacoco.report.ISourceFileLocator;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.Description;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/** Tests the uninstrumented class processing logic in {@link JacocoLCOVFormatter}. */
+@RunWith(JUnit4.class)
+public class JacocoLCOVFormatterUninstrumentedTest {
+
+ private StringWriter writer;
+ private IBundleCoverage mockBundle;
+
+ private static IClassCoverage mockIClassCoverage(
+ String className, String packageName, String sourceFileName) {
+ IClassCoverage mocked = mock(IClassCoverage.class);
+ when(mocked.getName()).thenReturn(className);
+ when(mocked.getPackageName()).thenReturn(packageName);
+ when(mocked.getSourceFileName()).thenReturn(sourceFileName);
+ return mocked;
+ }
+
+ private Description createSuiteDescription(String name) {
+ Description suite = Description.createSuiteDescription(name);
+ suite.addChild(Description.createTestDescription(Object.class, "child"));
+ return suite;
+ }
+
+ @Before
+ public void setupTest() {
+ // Initialize writer for storing coverage report outputs
+ writer = new StringWriter();
+ // Initialize mock Jacoco bundle containing the mock coverage
+ // Classes
+ List<IClassCoverage> mockClassCoverages =
+ Arrays.asList(mockIClassCoverage("Foo", "com/example", "Foo.java"));
+ // Package
+ IPackageCoverage mockPackageCoverage = mock(IPackageCoverage.class);
+ when(mockPackageCoverage.getClasses()).thenReturn(mockClassCoverages);
+ // Bundle
+ mockBundle = mock(IBundleCoverage.class);
+ when(mockBundle.getPackages()).thenReturn(Arrays.asList(mockPackageCoverage));
+ }
+
+ @Test
+ public void testVisitBundleWithSimpleUnixPath() throws IOException {
+ // Paths
+ ImmutableSet<String> execPaths = ImmutableSet.of("/parent/dir/com/example/Foo.java");
+ JacocoLCOVFormatter formatter = new JacocoLCOVFormatter(execPaths);
+ IReportVisitor visitor =
+ formatter.createVisitor(
+ new PrintWriter(writer), new TreeMap<String, BranchCoverageDetail>());
+
+ visitor.visitBundle(mockBundle, mock(ISourceFileLocator.class));
+ visitor.visitEnd();
+
+ String coverageOutput = writer.toString();
+ for (String sourcePath : execPaths) {
+ assertThat(coverageOutput).contains(sourcePath);
+ }
+ }
+
+ @Test
+ public void testVisitBundleWithSimpleWindowsPath() throws IOException {
+ // Paths
+ ImmutableSet<String> execPaths = ImmutableSet.of("C:/parent/dir/com/example/Foo.java");
+ JacocoLCOVFormatter formatter = new JacocoLCOVFormatter(execPaths);
+ IReportVisitor visitor =
+ formatter.createVisitor(
+ new PrintWriter(writer), new TreeMap<String, BranchCoverageDetail>());
+
+ visitor.visitBundle(mockBundle, mock(ISourceFileLocator.class));
+ visitor.visitEnd();
+
+ String coverageOutput = writer.toString();
+ for (String sourcePath : execPaths) {
+ assertThat(coverageOutput).contains(sourcePath);
+ }
+ }
+
+ @Test
+ public void testVisitBundleWithMappedUnixPath() throws IOException {
+ // Paths
+ String srcPath = "/some/other/dir/Foo.java";
+ ImmutableSet<String> execPaths = ImmutableSet.of(srcPath + "////com/example/Foo.java");
+ JacocoLCOVFormatter formatter = new JacocoLCOVFormatter(execPaths);
+ IReportVisitor visitor =
+ formatter.createVisitor(
+ new PrintWriter(writer), new TreeMap<String, BranchCoverageDetail>());
+
+ visitor.visitBundle(mockBundle, mock(ISourceFileLocator.class));
+ visitor.visitEnd();
+
+ String coverageOutput = writer.toString();
+ assertThat(coverageOutput).contains(srcPath);
+ }
+
+ @Test
+ public void testVisitBundleWithMappedWindowsPath() throws IOException {
+ // Paths
+ String srcPath = "C:/some/other/dir/Foo.java";
+ ImmutableSet<String> execPaths = ImmutableSet.of(srcPath + "////com/example/Foo.java");
+ JacocoLCOVFormatter formatter = new JacocoLCOVFormatter(execPaths);
+ IReportVisitor visitor =
+ formatter.createVisitor(
+ new PrintWriter(writer), new TreeMap<String, BranchCoverageDetail>());
+
+ visitor.visitBundle(mockBundle, mock(ISourceFileLocator.class));
+ visitor.visitEnd();
+
+ String coverageOutput = writer.toString();
+ assertThat(coverageOutput).contains(srcPath);
+ }
+
+ @Test
+ public void testVisitBundleWithNoMatchHasEmptyOutput() throws IOException {
+ // Paths
+ ImmutableSet<String> execPaths = ImmutableSet.of("/path/does/not/match/anything.txt");
+ JacocoLCOVFormatter formatter = new JacocoLCOVFormatter(execPaths);
+ IReportVisitor visitor =
+ formatter.createVisitor(
+ new PrintWriter(writer), new TreeMap<String, BranchCoverageDetail>());
+
+ visitor.visitBundle(mockBundle, mock(ISourceFileLocator.class));
+ visitor.visitEnd();
+
+ String coverageOutput = writer.toString();
+ assertThat(coverageOutput).isEmpty();
+ }
+}