1 /* 2 * Licensed to the Apache Software Foundation (ASF) under one 3 * or more contributor license agreements. See the NOTICE file 4 * distributed with this work for additional information 5 * regarding copyright ownership. The ASF licenses this file 6 * to you under the Apache License, Version 2.0 (the 7 * "License"); you may not use this file except in compliance 8 * with the License. You may obtain a copy of the License at 9 * 10 * http://www.apache.org/licenses/LICENSE-2.0 11 * 12 * Unless required by applicable law or agreed to in writing, 13 * software distributed under the License is distributed on an 14 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 15 * KIND, either express or implied. See the License for the 16 * specific language governing permissions and limitations 17 * under the License. 18 */ 19 package org.apache.maven.plugin.compiler; 20 21 import java.io.BufferedInputStream; 22 import java.io.BufferedOutputStream; 23 import java.io.DataInputStream; 24 import java.io.DataOutputStream; 25 import java.io.IOException; 26 import java.io.UncheckedIOException; 27 import java.nio.file.Files; 28 import java.nio.file.LinkOption; 29 import java.nio.file.NoSuchFileException; 30 import java.nio.file.Path; 31 import java.nio.file.StandardOpenOption; 32 import java.nio.file.attribute.FileTime; 33 import java.util.ArrayList; 34 import java.util.Collection; 35 import java.util.EnumSet; 36 import java.util.HashMap; 37 import java.util.List; 38 import java.util.Locale; 39 import java.util.Map; 40 import java.util.Objects; 41 import java.util.Set; 42 import java.util.stream.Stream; 43 44 import org.apache.maven.api.plugin.MojoException; 45 46 /** 47 * Helper methods to support incremental builds. 48 */ 49 final class IncrementalBuild { 50 /** 51 * Elements to take in consideration when deciding whether to recompile a file. 52 * 53 * @see AbstractCompilerMojo#incrementalCompilation 54 */ 55 enum Aspect { 56 /** 57 * Recompile all source files if the compiler options changed. 58 * Changes are detected on a <i>best-effort</i> basis only. 59 */ 60 OPTIONS(Set.of()), 61 62 /** 63 * Recompile all source files if at least one dependency (JAR file) changed since the last build. 64 * This check is based on the last modification times of JAR files. 65 * 66 * <h4>Implementation note</h4> 67 * The checks use information about the previous build saved in {@code target/…/*.cache} files. 68 * Deleting those files cause a recompilation of all sources. 69 */ 70 DEPENDENCIES(Set.of()), 71 72 /** 73 * Recompile source files modified since the last build. 74 * In addition, if a source file has been deleted, then all source files are recompiled. 75 * This check is based on the last modification times of source files, 76 * not on the existence or modification times of the {@code *.class} files. 77 * 78 * <p>It is usually not needed to specify both {@code SOURCES} and {@link #CLASSES}. 79 * But doing so it not forbidden.</p> 80 * 81 * <h4>Implementation note</h4> 82 * The checks use information about the previous build saved in {@code target/…/*.cache} files. 83 * Deleting those files cause a recompilation of all sources. 84 */ 85 SOURCES(Set.of()), 86 87 /** 88 * Recompile source files ({@code *.java}) associated to no output file ({@code *.class}) 89 * or associated to an output file older than the source. This algorithm does not check 90 * if a source file has been removed, potentially leaving non-recompiled classes with 91 * references to classes that no longer exist. 92 * 93 * <p>It is usually not needed to specify both {@link #SOURCES} and {@code CLASSES}. 94 * But doing so it not forbidden.</p> 95 * 96 * <h4>Implementation note</h4> 97 * This check does not use or generate any {@code *.cache} file. 98 */ 99 CLASSES(Set.of()), 100 101 /** 102 * Recompile all source files when the addition of a new file is detected. 103 * This aspect should be used together with {@link #SOURCES} or {@link #CLASSES}. 104 * When used with {@link #CLASSES}, it provides a way to detect class renaming 105 * (this is not needed with {@link #SOURCES}). 106 */ 107 ADDITIONS(Set.of()), 108 109 /** 110 * Recompile modules and let the compiler decides which individual files to recompile. 111 * The compiler plugin does not enumerate the source files to recompile (actually, it does not scan at all the 112 * source directories). Instead, it only specifies the module to recompile using the {@code --module} option. 113 * The Java compiler will scan the source directories itself and compile only those source files that are newer 114 * than the corresponding files in the output directory. 115 * 116 * <p>This option is available only at the following conditions:</p> 117 * <ul> 118 * <li>All sources of the project to compile are modules in the Java sense.</li> 119 * <li>{@link #SOURCES}, {@link #CLASSES} and {@link #ADDITIONS} aspects are not used.</li> 120 * <li>There is no include/exclude filter.</li> 121 * </ul> 122 */ 123 MODULES(Set.of(SOURCES, CLASSES, ADDITIONS)), 124 125 /** 126 * The compiler plugin unconditionally specifies all sources to the Java compiler. 127 * This aspect is mutually exclusive with all other aspects. 128 */ 129 NONE(Set.of(OPTIONS, DEPENDENCIES, SOURCES, CLASSES, ADDITIONS, MODULES)); 130 131 /** 132 * If this aspect is mutually exclusive with other aspects, the excluded aspects. 133 */ 134 private final Set<Aspect> excludes; 135 136 /** 137 * Creates a new enumeration value. 138 * 139 * @param excludes the aspects that are mutually exclusive with this aspect 140 */ 141 Aspect(Set<Aspect> excludes) { 142 this.excludes = excludes; 143 } 144 145 /** 146 * Returns the name in lower-case, for producing error message. 147 */ 148 @Override 149 public String toString() { 150 return name().toLowerCase(Locale.US); 151 } 152 153 /** 154 * Parses a comma-separated list of aspects. 155 * 156 * @param values the plugin parameter to parse as a comma-separated list 157 * @return the aspect 158 * @throws MojoException if a value is not recognized, or if mutually exclusive values are specified 159 */ 160 static EnumSet<Aspect> parse(final String values) { 161 var aspects = EnumSet.noneOf(Aspect.class); 162 for (String value : values.split(",")) { 163 value = value.trim(); 164 try { 165 aspects.add(valueOf(value.toUpperCase(Locale.US))); 166 } catch (IllegalArgumentException e) { 167 var sb = new StringBuilder(256) 168 .append("Illegal incremental build setting: \"") 169 .append(value); 170 String s = "\". Valid values are "; 171 for (Aspect aspect : values()) { 172 sb.append(s).append(aspect); 173 s = ", "; 174 } 175 throw new CompilationFailureException(sb.append('.').toString(), e); 176 } 177 } 178 for (Aspect aspect : aspects) { 179 for (Aspect exclude : aspect.excludes) { 180 if (aspects.contains(exclude)) { 181 throw new CompilationFailureException("Illegal incremental build setting: \"" + aspect 182 + "\" and \"" + exclude + "\" are mutually exclusive."); 183 } 184 } 185 } 186 if (aspects.isEmpty()) { 187 throw new CompilationFailureException("Incremental build setting cannot be empty."); 188 } 189 return aspects; 190 } 191 } 192 193 /** 194 * The options for following links. An empty array means that links will be followed. 195 */ 196 private static final LinkOption[] LINK_OPTIONS = new LinkOption[0]; 197 198 /** 199 * Magic number, generated randomly, to store in the header of the binary file. 200 * This number shall be changed every times that the binary file format is modified. 201 * The file format is described in {@link #writeCache()}. 202 * 203 * @see #writeCache() 204 */ 205 private static final long MAGIC_NUMBER = -8163803035240576921L; 206 207 /** 208 * Flags in the binary output file telling whether the source and/or target directory changed. 209 * Those flags are stored as a byte before each entry. They can be combined as bit mask. 210 * Those flags are for compressing the binary file, not for detecting if something changed 211 * since the last build. 212 */ 213 private static final byte NEW_SOURCE_DIRECTORY = 1, NEW_TARGET_DIRECTORY = 2; 214 215 /** 216 * Flag in the binary output file telling that the output file of a source is different 217 * than the one inferred by heuristic rules. For performance reason, we store the output 218 * files explicitly only when it cannot be inferred. 219 * 220 * @see SourceInfo#toOutputFile(Path, Path, Path) 221 * @see javax.tools.JavaFileManager#getFileForOutput 222 */ 223 private static final byte EXPLICIT_OUTPUT_FILE = 4; 224 225 /** 226 * Name of the file where to store the list of source files and the list of files created by the compiler. 227 * This is a binary format used for detecting changes. The file is stored in the {@code target} directory. 228 * If the file is absent of corrupted, it will be ignored and recreated. 229 * 230 * @see AbstractCompilerMojo#mojoStatusPath 231 */ 232 private final Path cacheFile; 233 234 /** 235 * Whether the cache file has been loaded. 236 */ 237 private boolean cacheLoaded; 238 239 /** 240 * All source files together with their last modification time. 241 * This list is specified at construction time and is not modified by this class. 242 * 243 * @see #getModifiedSources() 244 */ 245 private final List<SourceFile> sourceFiles; 246 247 /** 248 * The build time in milliseconds since January 1st, 1970. 249 * This is used for detecting if a dependency changed since the previous build. 250 */ 251 private final long buildTime; 252 253 /** 254 * Time of the previous build. This value is initialized by {@link #loadCache()}. 255 * If the cache cannot be loaded, then this field is conservatively set to the same value 256 * as {@link #buildTime}, but it shouldn't matter because a full build will be done anyway. 257 */ 258 private long previousBuildTime; 259 260 /** 261 * Hash code value of the compiler options during the previous build. 262 * This value is initialized by {@link #loadCache()}. 263 */ 264 private int previousOptionsHash; 265 266 /** 267 * Whether to provide more details about why a module is rebuilt. 268 */ 269 private final boolean showCompilationChanges; 270 271 /** 272 * Creates a new helper for an incremental build. 273 * 274 * @param mojo the MOJO which is compiling source code 275 * @param sourceFiles all source files 276 * @throws IOException if the parent directory cannot be created 277 */ 278 IncrementalBuild(AbstractCompilerMojo mojo, List<SourceFile> sourceFiles) throws IOException { 279 this.sourceFiles = sourceFiles; 280 Path file = mojo.mojoStatusPath; 281 cacheFile = Files.createDirectories(file.getParent()).resolve(file.getFileName()); 282 showCompilationChanges = mojo.showCompilationChanges; 283 buildTime = System.currentTimeMillis(); 284 previousBuildTime = buildTime; 285 } 286 287 /** 288 * Saves the list of source files in the cache file. The cache is a binary file 289 * and its format may change in any future version. The current format is as below: 290 * 291 * <ul> 292 * <li>The magic number (while change when the format changes).</li> 293 * <li>The build time in milliseconds since January 1st, 1970.</li> 294 * <li>Hash code value of the {@link Options#options} list.</li> 295 * <li>Number of source files, or 0 if {@code sources} is {@code false}.</li> 296 * <li>If {@code sources} is {@code true}, then for each source file:<ul> 297 * <li>A bit mask of {@link #NEW_SOURCE_DIRECTORY}, {@link #NEW_TARGET_DIRECTORY} and {@link #EXPLICIT_OUTPUT_FILE}.</li> 298 * <li>If {@link #NEW_SOURCE_DIRECTORY} is set, the new root directory of source files.</li> 299 * <li>If {@link #NEW_TARGET_DIRECTORY} is set, the new root directory of output files.</li> 300 * <li>If {@link #EXPLICIT_OUTPUT_FILE} is set, the output file.</li> 301 * <li>The file path relative to the parent of the previous file.</li> 302 * <li>Last modification time of the source file, in milliseconds since January 1st.</li> 303 * </ul></li> 304 * </ul> 305 * 306 * The "is sibling" Boolean is for avoiding to repeat the parent directory. If that flag is {@code true}, 307 * then only the filename is stored and the parent is the same as the previous file. 308 * 309 * @param optionsHash hash code value of the {@link Options#options} list 310 * @param sources whether to save also the list of source files 311 * @throws IOException if an error occurred while writing the cache file 312 */ 313 @SuppressWarnings({"checkstyle:InnerAssignment", "checkstyle:NeedBraces"}) 314 public void writeCache(final int optionsHash, final boolean sources) throws IOException { 315 try (DataOutputStream out = new DataOutputStream(new BufferedOutputStream(Files.newOutputStream( 316 cacheFile, 317 StandardOpenOption.WRITE, 318 StandardOpenOption.CREATE, 319 StandardOpenOption.TRUNCATE_EXISTING)))) { 320 out.writeLong(MAGIC_NUMBER); 321 out.writeLong(buildTime); 322 out.writeInt(optionsHash); 323 out.writeInt(sources ? sourceFiles.size() : 0); 324 if (sources) { 325 Path srcDir = null; 326 Path tgtDir = null; 327 Path previousParent = null; 328 for (SourceFile source : sourceFiles) { 329 final Path sourceFile = source.file; 330 final Path outputFile = source.getOutputFile(false); 331 boolean sameSrcDir = Objects.equals(srcDir, srcDir = source.directory.root); 332 boolean sameTgtDir = Objects.equals(tgtDir, tgtDir = source.directory.outputDirectory); 333 boolean sameOutput = (outputFile == null) 334 || outputFile.equals(SourceInfo.toOutputFile(srcDir, tgtDir, sourceFile)); 335 336 out.writeByte((sameSrcDir ? 0 : NEW_SOURCE_DIRECTORY) 337 | (sameTgtDir ? 0 : NEW_TARGET_DIRECTORY) 338 | (sameOutput ? 0 : EXPLICIT_OUTPUT_FILE)); 339 340 if (!sameSrcDir) out.writeUTF((previousParent = srcDir).toString()); 341 if (!sameTgtDir) out.writeUTF(tgtDir.toString()); 342 if (!sameOutput) out.writeUTF(outputFile.toString()); 343 out.writeUTF(previousParent.relativize(sourceFile).toString()); 344 out.writeLong(source.lastModified); 345 previousParent = sourceFile.getParent(); 346 } 347 } 348 } 349 } 350 351 /** 352 * Loads the list of source files and their modification times from the previous build. 353 * The binary file format reads by this method is described in {@link #writeCache()}. 354 * The keys are the source files. The returned map is modifiable. 355 * 356 * @return the source files of previous build 357 * @throws IOException if an error occurred while reading the cache file 358 */ 359 @SuppressWarnings("checkstyle:NeedBraces") 360 private Map<Path, SourceInfo> loadCache() throws IOException { 361 final Map<Path, SourceInfo> previousBuild; 362 try (DataInputStream in = new DataInputStream( 363 new BufferedInputStream(Files.newInputStream(cacheFile, StandardOpenOption.READ)))) { 364 if (in.readLong() != MAGIC_NUMBER) { 365 throw new IOException("Invalid cache file."); 366 } 367 previousBuildTime = in.readLong(); 368 previousOptionsHash = in.readInt(); 369 int remaining = in.readInt(); 370 previousBuild = new HashMap<>(remaining + remaining / 3); 371 Path srcDir = null; 372 Path tgtDir = null; 373 Path srcFile = null; 374 while (--remaining >= 0) { 375 final byte flags = in.readByte(); 376 if ((flags & ~(NEW_SOURCE_DIRECTORY | NEW_TARGET_DIRECTORY | EXPLICIT_OUTPUT_FILE)) != 0) { 377 throw new IOException("Invalid cache file."); 378 } 379 boolean newSrcDir = (flags & NEW_SOURCE_DIRECTORY) != 0; 380 boolean newTgtDir = (flags & NEW_TARGET_DIRECTORY) != 0; 381 boolean newOutput = (flags & EXPLICIT_OUTPUT_FILE) != 0; 382 Path output = null; 383 if (newSrcDir) srcDir = Path.of(in.readUTF()); 384 if (newTgtDir) tgtDir = Path.of(in.readUTF()); 385 if (newOutput) output = Path.of(in.readUTF()); 386 String path = in.readUTF(); 387 srcFile = newSrcDir ? srcDir.resolve(path) : srcFile.resolveSibling(path); 388 srcFile = srcFile.normalize(); 389 if (previousBuild.put(srcFile, new SourceInfo(srcDir, tgtDir, output, in.readLong())) != null) { 390 throw new IOException("Duplicated source file declared in the cache: " + srcFile); 391 } 392 } 393 } 394 cacheLoaded = true; 395 return previousBuild; 396 } 397 398 /** 399 * Information about a source file from a previous build. 400 * 401 * @param sourceDirectory root directory of the source file 402 * @param outputDirectory output directory of the compiled file 403 * @param outputFile the output file if it was explicitly specified, or {@code null} if it can be inferred 404 * @param lastModified last modification times of the source file during the previous build 405 */ 406 private static record SourceInfo(Path sourceDirectory, Path outputDirectory, Path outputFile, long lastModified) { 407 /** 408 * The default output extension used in heuristic rules. It is okay if the actual output file does not use 409 * this extension, because the heuristic rules should be applied only when we have detected that they apply. 410 */ 411 private static final String OUTPUT_EXTENSION = SourceDirectory.CLASS_FILE_SUFFIX; 412 413 /** 414 * Infers the path to the output file using heuristic rules. This method is used for saving space in the 415 * common space where the heuristic rules work. If the heuristic rules do not work, the full output path 416 * will be stored in the {@link #cacheFile}. 417 * 418 * @param sourceDirectory root directory of the source file 419 * @param outputDirectory output directory of the compiled file 420 * @param sourceFile path to the source file 421 * @return path to the target file 422 */ 423 static Path toOutputFile(Path sourceDirectory, Path outputDirectory, Path sourceFile) { 424 return SourceFile.toOutputFile( 425 sourceDirectory, outputDirectory, sourceFile, SourceDirectory.JAVA_FILE_SUFFIX, OUTPUT_EXTENSION); 426 } 427 428 /** 429 * Delete all output files associated to the given source file. If the output file is a {@code .class} file, 430 * then this method deletes also the output files for all inner classes (e.g. {@code "Foo$0.class"}). 431 * 432 * @param sourceFile the source file for which to delete output files 433 * @throws IOException if an error occurred while scanning the output directory or deleting a file 434 */ 435 void deleteClassFiles(final Path sourceFile) throws IOException { 436 Path output = outputFile; 437 if (output == null) { 438 output = toOutputFile(sourceDirectory, outputDirectory, sourceFile); 439 } 440 String filename = output.getFileName().toString(); 441 if (filename.endsWith(OUTPUT_EXTENSION)) { 442 String prefix = filename.substring(0, filename.length() - OUTPUT_EXTENSION.length()); 443 List<Path> outputs; 444 try (Stream<Path> files = Files.walk(output.getParent(), 1)) { 445 outputs = files.filter((f) -> { 446 String name = f.getFileName().toString(); 447 return name.startsWith(prefix) 448 && name.endsWith(OUTPUT_EXTENSION) 449 && (name.equals(filename) || name.charAt(prefix.length()) == '$'); 450 }) 451 .toList(); 452 } 453 for (Path p : outputs) { 454 Files.delete(p); 455 } 456 } else { 457 Files.deleteIfExists(output); 458 } 459 } 460 } 461 462 /** 463 * Detects whether the list of detected files has changed since the last build. 464 * This method loads the list of files of the previous build from a status file 465 * and compare it with the new list. If the file cannot be read, then this method 466 * conservatively assumes that the file tree changed. 467 * 468 * <p>If this method returns {@code null}, the caller can check the {@link SourceFile#isNewOrModified} flag 469 * for deciding which files to recompile. If this method returns non-null value, then the {@code isModified} 470 * flag should be ignored and all files recompiled unconditionally. The returned non-null value is a message 471 * saying why the project needs to be rebuilt.</p> 472 * 473 * @param staleMillis the granularity in milliseconds to use for comparing modification times 474 * @param rebuildOnAdd whether to recompile all source files if a file addition is detected 475 * @return {@code null} if the project does not need to be rebuilt, otherwise a message saying why to rebuild 476 * @throws IOException if an error occurred while deleting output files of the previous build 477 * 478 * @see Aspect#SOURCES 479 */ 480 String inputFileTreeChanges(final long staleMillis, final boolean rebuildOnAdd) throws IOException { 481 final Map<Path, SourceInfo> previousBuild; 482 try { 483 previousBuild = loadCache(); 484 } catch (NoSuchFileException e) { 485 return "Compiling all files."; 486 } catch (IOException e) { 487 return causeOfRebuild("information about the previous build cannot be read", true) 488 .append(System.lineSeparator()) 489 .append(e) 490 .toString(); 491 } 492 boolean rebuild = false; 493 boolean allChanged = true; 494 List<Path> added = new ArrayList<>(); 495 for (SourceFile source : sourceFiles) { 496 SourceInfo previous = previousBuild.remove(source.file); 497 if (previous != null) { 498 if (source.lastModified - previous.lastModified <= staleMillis) { 499 /* 500 * Source file has not been modified. But we still need to check if the output file exists. 501 * It may be, for example, because the compilation failed during the previous build because 502 * of another class. 503 */ 504 allChanged = false; 505 Path output = source.getOutputFile(true); 506 if (Files.exists(output, LINK_OPTIONS)) { 507 continue; // Source file has not been modified and output file exists. 508 } 509 } 510 } else if (!source.ignoreModification) { 511 if (showCompilationChanges) { 512 added.add(source.file); 513 } 514 rebuild |= rebuildOnAdd; 515 } 516 source.isNewOrModified = true; 517 } 518 /* 519 * The files remaining in `previousBuild` are files that have been removed since the last build. 520 * If no file has been removed, then there is no need to rebuild the whole project (added files 521 * do not require a full build). 522 */ 523 if (previousBuild.isEmpty()) { 524 if (allChanged) { 525 return causeOfRebuild("all source files changed", false).toString(); 526 } 527 if (!rebuild) { 528 return null; 529 } 530 } 531 /* 532 * If some files have been removed, we need to delete the corresponding output files. 533 * If the output file extension is ".class", then many files may be deleted because 534 * the output file may be accompanied by inner classes (e.g. {@code "Foo$0.class"}). 535 */ 536 for (Map.Entry<Path, SourceInfo> removed : previousBuild.entrySet()) { 537 removed.getValue().deleteClassFiles(removed.getKey()); 538 } 539 /* 540 * At this point, it has been decided that all source files will be recompiled. 541 * Format a message saying why. 542 */ 543 StringBuilder causeOfRebuild = causeOfRebuild("of added or removed source files", showCompilationChanges); 544 if (showCompilationChanges) { 545 for (Path fileAdded : added) { 546 causeOfRebuild.append(System.lineSeparator()).append(" + ").append(fileAdded); 547 } 548 for (Path fileRemoved : previousBuild.keySet()) { 549 causeOfRebuild.append(System.lineSeparator()).append(" - ").append(fileRemoved); 550 } 551 } 552 return causeOfRebuild.toString(); 553 } 554 555 /** 556 * Returns whether at least one dependency file is more recent than the given build start time. 557 * This method should be invoked only after {@link #inputFileTreeChanges} returned {@code null}. 558 * Each given root can be either a regular file (typically a JAR file) or a directory. 559 * Directories are scanned recursively. 560 * 561 * @param directories files or directories to scan 562 * @param fileExtensions extensions of the file to check (usually "jar" and "class") 563 * @param changeTime the time at which a file is considered as changed 564 * @return {@code null} if the project does not need to be rebuilt, otherwise a message saying why to rebuild 565 * @throws IOException if an error occurred while scanning the directories 566 * 567 * @see Aspect#DEPENDENCIES 568 */ 569 String dependencyChanges(Iterable<List<Path>> dependencies, Collection<String> fileExtensions) throws IOException { 570 if (!cacheLoaded) { 571 loadCache(); 572 } 573 final FileTime changeTime = FileTime.fromMillis(previousBuildTime); 574 List<Path> updated = new ArrayList<>(); 575 for (List<Path> roots : dependencies) { 576 for (Path root : roots) { 577 try (Stream<Path> files = Files.walk(root)) { 578 files.filter((f) -> { 579 String name = f.getFileName().toString(); 580 int s = name.lastIndexOf('.'); 581 if (s < 0 || !fileExtensions.contains(name.substring(s + 1))) { 582 return false; 583 } 584 try { 585 return Files.isRegularFile(f) 586 && Files.getLastModifiedTime(f).compareTo(changeTime) >= 0; 587 } catch (IOException e) { 588 throw new UncheckedIOException(e); 589 } 590 }) 591 .forEach(updated::add); 592 } catch (UncheckedIOException e) { 593 throw e.getCause(); 594 } 595 } 596 } 597 if (updated.isEmpty()) { 598 return null; 599 } 600 StringBuilder causeOfRebuild = causeOfRebuild("some dependencies changed", showCompilationChanges); 601 if (showCompilationChanges) { 602 for (Path file : updated) { 603 causeOfRebuild.append(System.lineSeparator()).append(" ").append(file); 604 } 605 } 606 return causeOfRebuild.toString(); 607 } 608 609 /** 610 * Returns whether the compilar options have changed. 611 * This method should be invoked only after {@link #inputFileTreeChanges} returned {@code null}. 612 * 613 * @param optionsHash hash code value of the {@link Options#options} list 614 * @return {@code null} if the project does not need to be rebuilt, otherwise a message saying why to rebuild 615 * @throws IOException if an error occurred while loading the cache file 616 * 617 * @see Aspect#OPTIONS 618 */ 619 String optionChanges(int optionsHash) throws IOException { 620 if (!cacheLoaded) { 621 loadCache(); 622 } 623 if (optionsHash == previousOptionsHash) { 624 return null; 625 } 626 return causeOfRebuild("of changes in compiler options", false).toString(); 627 } 628 629 /** 630 * Prepares a message saying why a full rebuild is done. A colon character will be added 631 * if showing compilation changes is enabled, otherwise a period is added. 632 * 633 * @param cause the cause of the rebuild, without trailing colon or period 634 * @param colon whether to append a colon instead of a period after the message 635 * @return a buffer where more details can be appended for reporting the cause 636 */ 637 private static StringBuilder causeOfRebuild(String cause, boolean colon) { 638 return new StringBuilder(128) 639 .append("Recompiling all files because ") 640 .append(cause) 641 .append(colon ? ':' : '.'); 642 } 643 644 /** 645 * Compares the modification time of all source files with the modification time of output files. 646 * The files identified as in need to be recompiled have their {@link SourceFile#isNewOrModified} 647 * flag set to {@code true}. This method does not use the cache file. 648 * 649 * @param staleMillis the granularity in milliseconds to use for comparing modification times 650 * @param rebuildOnAdd whether to recompile all source files if a file addition is detected 651 * @return {@code null} if the project does not need to be rebuilt, otherwise a message saying why to rebuild 652 * @throws IOException if an error occurred while reading the time stamp of an output file 653 * 654 * @see Aspect#CLASSES 655 */ 656 String markNewOrModifiedSources(long staleMillis, boolean rebuildOnAdd) throws IOException { 657 for (SourceFile source : sourceFiles) { 658 if (!source.isNewOrModified) { 659 // Check even if `source.ignoreModification` is true. 660 Path output = source.getOutputFile(true); 661 if (Files.exists(output, LINK_OPTIONS)) { 662 FileTime t = Files.getLastModifiedTime(output, LINK_OPTIONS); 663 if (source.lastModified - t.toMillis() <= staleMillis) { 664 continue; 665 } 666 } else if (rebuildOnAdd) { 667 StringBuilder causeOfRebuild = causeOfRebuild("of added source files", showCompilationChanges); 668 if (showCompilationChanges) { 669 causeOfRebuild 670 .append(System.lineSeparator()) 671 .append(" + ") 672 .append(source.file); 673 } 674 return causeOfRebuild.toString(); 675 } 676 source.isNewOrModified = true; 677 } 678 } 679 return null; 680 } 681 682 /** 683 * Returns the source files that are marked as new or modified. The returned list may contain files 684 * that are new or modified, but should nevertheless be ignored in the decision to recompile or not. 685 * In order to decide if a compilation is needed, invoke {@link #isEmptyOrIgnorable(List)} instead 686 * of {@link List#isEmpty()}. 687 * 688 * @return new or modified source files, or an empty list if none 689 */ 690 List<SourceFile> getModifiedSources() { 691 return sourceFiles.stream().filter((s) -> s.isNewOrModified).toList(); 692 } 693 694 /** 695 * {@return whether the given list of modified files should not cause a recompilation}. 696 * This method returns {@code true} if the given list is empty or contains only files 697 * with the {@link SourceFile#ignoreModification} set to {@code true}. 698 * 699 * @param sourceFiles return value of {@link #getModifiedSources()}. 700 */ 701 static boolean isEmptyOrIgnorable(List<SourceFile> sourceFiles) { 702 return !sourceFiles.stream().anyMatch((s) -> !s.ignoreModification); 703 } 704 }