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