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();
+  }
+}