View Javadoc
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 }