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