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.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 }