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 }