• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
2  * Copyright 2022 The Android Open Source Project
3  * Licensed under the Apache License, Version 2.0 (the "License");
4  * you may not use this file except in compliance with the License.
5  * You may obtain a copy of the License at
6  *
7  *     https://www.apache.org/licenses/LICENSE-2.0
8  *
9  * Unless required by applicable law or agreed to in writing, software
10  * distributed under the License is distributed on an "AS IS" BASIS,
11  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12  * See the License for the specific language governing permissions and
13  * limitations under the License.
14  */
15 package com.android.jacocolcov;
16 
17 import static org.jacoco.core.analysis.ICounter.EMPTY;
18 import static org.jacoco.core.analysis.ISourceNode.UNKNOWN_LINE;
19 
20 import com.google.common.collect.HashMultimap;
21 import com.google.common.collect.SetMultimap;
22 import com.google.errorprone.annotations.FormatMethod;
23 import com.google.errorprone.annotations.FormatString;
24 
25 import org.apache.commons.cli.CommandLine;
26 import org.apache.commons.cli.CommandLineParser;
27 import org.apache.commons.cli.OptionBuilder;
28 import org.apache.commons.cli.Options;
29 import org.apache.commons.cli.ParseException;
30 import org.apache.commons.cli.PosixParser;
31 import org.jacoco.core.analysis.Analyzer;
32 import org.jacoco.core.analysis.CoverageBuilder;
33 import org.jacoco.core.analysis.IClassCoverage;
34 import org.jacoco.core.analysis.ILine;
35 import org.jacoco.core.analysis.IMethodCoverage;
36 import org.jacoco.core.tools.ExecFileLoader;
37 
38 import java.io.BufferedWriter;
39 import java.io.IOException;
40 import java.nio.charset.StandardCharsets;
41 import java.nio.file.Files;
42 import java.nio.file.Path;
43 import java.nio.file.Paths;
44 import java.util.stream.Stream;
45 
46 /** Converts JaCoCo measurements and class files into a lcov-format coverage report. */
47 final class JacocoToLcovConverter {
48 
49     // Command line flags.
50     private static final String CLASSFILES_OPTION = "classfiles";
51     private static final String SOURCEPATH_OPTION = "sourcepath";
52     private static final String OUTPUT_OPTION = "o";
53     private static final String VERBOSE_OPTION = "v";
54     private static final String STRICT_OPTION = "strict";
55 
main(String[] args)56     public static void main(String[] args) {
57         Options options = new Options();
58 
59         options.addOption(
60                 OptionBuilder.hasArg()
61                         .withArgName("<path>")
62                         .withDescription("location of the Java class files")
63                         .create(CLASSFILES_OPTION));
64 
65         options.addOption(
66                 OptionBuilder.hasArg()
67                         .withArgName("<path>")
68                         .withDescription("location of the source files")
69                         .create(SOURCEPATH_OPTION));
70 
71         options.addOption(
72                 OptionBuilder.isRequired()
73                         .hasArg()
74                         .withArgName("<destfile>")
75                         .withDescription("location to write lcov data")
76                         .create(OUTPUT_OPTION));
77 
78         options.addOption(OptionBuilder.withDescription("verbose logging").create(VERBOSE_OPTION));
79 
80         options.addOption(
81                 OptionBuilder.withDescription("fail if any error is encountered")
82                         .create(STRICT_OPTION));
83 
84         CommandLineParser parser = new PosixParser();
85         CommandLine cmd;
86 
87         try {
88             cmd = parser.parse(options, args);
89         } catch (ParseException e) {
90             logError("error parsing command line options: %s", e.getMessage());
91             System.exit(1);
92             return;
93         }
94 
95         String[] classFiles = cmd.getOptionValues(CLASSFILES_OPTION);
96         String[] sourcePaths = cmd.getOptionValues(SOURCEPATH_OPTION);
97         String outputFile = cmd.getOptionValue(OUTPUT_OPTION);
98         boolean verbose = cmd.hasOption(VERBOSE_OPTION);
99         boolean strict = cmd.hasOption(STRICT_OPTION);
100         String[] execFiles = cmd.getArgs();
101 
102         JacocoToLcovConverter converter = new JacocoToLcovConverter(verbose, strict);
103 
104         try {
105             if (sourcePaths != null) {
106                 for (String sourcePath : sourcePaths) {
107                     converter.indexSourcePath(Paths.get(sourcePath));
108                 }
109             }
110 
111             if (execFiles != null) {
112                 for (String execFile : execFiles) {
113                     converter.loadExecFile(Paths.get(execFile));
114                 }
115             }
116 
117             if (classFiles != null) {
118                 for (String classFile : classFiles) {
119                     converter.loadClassFile(Paths.get(classFile));
120                 }
121             }
122 
123             converter.write(Paths.get(outputFile));
124         } catch (IOException e) {
125             logError("failed to generate a coverage report: %s", e.getMessage());
126             System.exit(2);
127         }
128     }
129 
130     private Analyzer analyzer;
131     private final CoverageBuilder builder;
132     private final ExecFileLoader loader;
133     private final boolean verbose;
134     private final boolean strict;
135 
136     private int execFilesLoaded;
137     private int classFilesLoaded;
138     private SetMultimap<String, Path> sourceFiles;
139 
JacocoToLcovConverter(final boolean verbose, final boolean strict)140     JacocoToLcovConverter(final boolean verbose, final boolean strict) {
141         this.verbose = verbose;
142         this.strict = strict;
143         analyzer = null;
144         builder = new CoverageBuilder();
145         loader = new ExecFileLoader();
146         execFilesLoaded = 0;
147         classFilesLoaded = 0;
148         sourceFiles = HashMultimap.create();
149     }
150 
151     /**
152      * Indexes the path and all subdirectories for Java or Kotlin files.
153      *
154      * @param path the path to search for files
155      */
indexSourcePath(final Path path)156     void indexSourcePath(final Path path) throws IOException {
157         try (Stream<Path> stream = Files.walk(path)) {
158             stream.filter(Files::isRegularFile)
159                     .filter(p -> p.toString().endsWith(".java") || p.toString().endsWith(".kt"))
160                     .forEach(p -> sourceFiles.put(p.getFileName().toString(), p.toAbsolutePath()));
161         }
162     }
163 
164     /**
165      * Loads JaCoCo execution data files.
166      *
167      * <p>If strict is not set, logs any exception thrown and returns. If strict is set, rethrows
168      * any exception encountered while loading the file. Execution data files are occasionally
169      * malformed and will cause the tool to fail if strict is set.
170      *
171      * @param execFile the file to load
172      * @throws IOException on error reading file or incorrect file format
173      */
loadExecFile(final Path execFile)174     void loadExecFile(final Path execFile) throws IOException {
175         try {
176             logVerbose("Loading exec file %s", execFile);
177             loader.load(execFile.toFile());
178             execFilesLoaded++;
179         } catch (IOException e) {
180             logError("Failed to load exec file %s", execFile);
181             if (strict) {
182                 throw e;
183             }
184             logError(e.getMessage());
185         }
186     }
187 
188     /**
189      * Loads uninstrumented Java class files.
190      *
191      * <p>This should be run only after loading all exec files, otherwise coverage data may be
192      * incorrect.
193      *
194      * @param classFile the class file or class file archive to load
195      * @throws IOException on error reading file or incorrect file format
196      */
loadClassFile(final Path classFile)197     void loadClassFile(final Path classFile) throws IOException {
198         if (analyzer == null) {
199             analyzer = new Analyzer(loader.getExecutionDataStore(), builder);
200         }
201 
202         logVerbose("Loading class file %s", classFile);
203         analyzer.analyzeAll(classFile.toFile());
204         classFilesLoaded++;
205     }
206 
207     /**
208      * Writes out the lcov format file based on the exec data and class files loaded.
209      *
210      * @param outputFile the file to write to
211      * @throws IOException on error writing to the output file
212      */
write(final Path outputFile)213     void write(final Path outputFile) throws IOException {
214         logVerbose(
215                 "%d exec files loaded and %d class files loaded.",
216                 execFilesLoaded, classFilesLoaded);
217 
218         try (BufferedWriter writer = Files.newBufferedWriter(outputFile, StandardCharsets.UTF_8)) {
219             // Write lcov header test name: <test name>. Displayed on the front page but otherwise
220             // not used for anything important.
221             writeLine(writer, "TN:%s", outputFile.getFileName());
222 
223             for (IClassCoverage coverage : builder.getClasses()) {
224                 if (coverage.isNoMatch()) {
225                     String message = "Mismatch in coverage data for " + coverage.getName();
226                     logVerbose(message);
227                     if (strict) {
228                         throw new IOException(message);
229                     }
230                 }
231                 // Looping over coverage.getMethods() is done multiple times below due to lcov
232                 // ordering requirements.
233                 // lcov was designed around native code, and uses functions rather than methods as
234                 // its terminology of choice. We use methods here as we are working with Java code.
235                 int methodsFound = 0;
236                 int methodsHit = 0;
237                 int linesFound = 0;
238                 int linesHit = 0;
239 
240                 // Sourcefile information: <absolute path to sourcefile>. If the sourcefile does not
241                 // match any file given on --sourcepath, it will not be included in the coverage
242                 // report.
243                 String sourcefile = findSourceFileMatching(sourcefile(coverage));
244                 if (sourcefile == null) {
245                     continue;
246                 }
247                 writeLine(writer, "SF:%s", sourcefile);
248 
249                 // Function information: <starting line>,<name>.
250                 for (IMethodCoverage method : coverage.getMethods()) {
251                     writeLine(writer, "FN:%d,%s", method.getFirstLine(), name(method));
252                 }
253 
254                 // Function coverage information: <execution count>,<name>.
255                 for (IMethodCoverage method : coverage.getMethods()) {
256                     int count = method.getMethodCounter().getCoveredCount();
257                     writeLine(writer, "FNDA:%d,%s", count, name(method));
258 
259                     methodsFound++;
260                     if (count > 0) {
261                         methodsHit++;
262                     }
263                 }
264 
265                 // Write the count of methods(functions) found and hit.
266                 writeLine(writer, "FNF:%d", methodsFound);
267                 writeLine(writer, "FNH:%d", methodsHit);
268 
269                 // TODO: Write branch coverage information.
270 
271                 // Write line coverage information.
272                 for (IMethodCoverage method : coverage.getMethods()) {
273                     int start = method.getFirstLine();
274                     int end = method.getLastLine();
275 
276                     if (start == UNKNOWN_LINE || end == UNKNOWN_LINE) {
277                         continue;
278                     }
279 
280                     for (int i = start; i <= end; i++) {
281                         ILine line = method.getLine(i);
282                         if (line.getStatus() == EMPTY) {
283                             continue;
284                         }
285                         int count = line.getInstructionCounter().getCoveredCount();
286                         writeLine(writer, "DA:%d,%d", i, count);
287 
288                         linesFound++;
289                         if (count > 0) {
290                             linesHit++;
291                         }
292                     }
293                 }
294 
295                 // Write the count of lines hit and found.
296                 writeLine(writer, "LH:%d", linesHit);
297                 writeLine(writer, "LF:%d", linesFound);
298 
299                 // End of the sourcefile block.
300                 writeLine(writer, "end_of_record");
301             }
302         }
303 
304         log("Coverage data written to %s", outputFile);
305     }
306 
307     /**
308      * Finds the absolute path to the sourcefile that ends with the given file path.
309      *
310      * <p>Searches all the files indexed on -sourcepath and returns the first file that matches the
311      * package and class name. The input is the full Java class name, separated by `/` rather than
312      * `.`
313      *
314      * @param filename the filename to match
315      * @return the absolute path to the file, or null if none was found
316      */
findSourceFileMatching(String filename)317     private String findSourceFileMatching(String filename) {
318         String key = Paths.get(filename).getFileName().toString();
319         for (Path path : sourceFiles.get(key)) {
320             if (path.endsWith(filename)) {
321                 logVerbose("%s matched to %s", filename, path);
322                 return path.toAbsolutePath().toString();
323             }
324         }
325         logVerbose("%s did not match any source path", filename);
326         return null;
327     }
328 
329     /** Writes a line to the file. */
330     @FormatMethod
writeLine( BufferedWriter writer, @FormatString String format, Object... args)331     private static void writeLine(
332             BufferedWriter writer, @FormatString String format, Object... args) throws IOException {
333         writer.write(String.format(format, args));
334         writer.newLine();
335     }
336 
337     /** Prints log message. */
338     @FormatMethod
log(@ormatString String format, Object... args)339     private static void log(@FormatString String format, Object... args) {
340         System.out.println(String.format(format, args));
341     }
342 
343     /** Prints verbose log. */
344     @FormatMethod
logVerbose(@ormatString String format, Object... args)345     private void logVerbose(@FormatString String format, Object... args) {
346         logVerbose(String.format(format, args));
347     }
348 
349     /** Prints verbose log. */
logVerbose(String message)350     private void logVerbose(String message) {
351         if (verbose) {
352             System.out.println(message);
353         }
354     }
355 
356     /** Prints format string error message. */
357     @FormatMethod
logError(@ormatString String format, Object... args)358     private static void logError(@FormatString String format, Object... args) {
359         logError(String.format(format, args));
360     }
361 
362     /** Prints error message. */
logError(String message)363     private static void logError(String message) {
364         System.err.println(message);
365     }
366 
367     /** Converts IClassCoverage to a sourcefile path. */
sourcefile(IClassCoverage coverage)368     private static String sourcefile(IClassCoverage coverage) {
369         return coverage.getPackageName() + "/" + coverage.getSourceFileName();
370     }
371 
372     /** Converts IMethodCoverage to a unique method descriptor. */
name(IMethodCoverage coverage)373     private static String name(IMethodCoverage coverage) {
374         return coverage.getName() + coverage.getDesc();
375     }
376 }
377