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 }