blob: a1f60a44b7500aa8308dac077737697bad039d62 [file] [log] [blame]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.maven.plugin.compiler;
import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.NoSuchFileException;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.nio.file.attribute.FileTime;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.EnumSet;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Stream;
import org.apache.maven.api.plugin.MojoException;
/**
* Helper methods to support incremental builds.
*/
final class IncrementalBuild {
/**
* Elements to take in consideration when deciding whether to recompile a file.
*
* @see AbstractCompilerMojo#incrementalCompilation
*/
enum Aspect {
/**
* Recompile all source files if the compiler options changed.
* Changes are detected on a <i>best-effort</i> basis only.
*/
OPTIONS(Set.of()),
/**
* Recompile all source files if at least one dependency (JAR file) changed since the last build.
* This check is based on the last modification times of JAR files.
*
* <h4>Implementation note</h4>
* The checks use information about the previous build saved in {@code target/…/*.cache} files.
* Deleting those files cause a recompilation of all sources.
*/
DEPENDENCIES(Set.of()),
/**
* Recompile source files modified since the last build.
* In addition, if a source file has been deleted, then all source files are recompiled.
* This check is based on the last modification times of source files,
* not on the existence or modification times of the {@code *.class} files.
*
* <p>It is usually not needed to specify both {@code SOURCES} and {@link #CLASSES}.
* But doing so it not forbidden.</p>
*
* <h4>Implementation note</h4>
* The checks use information about the previous build saved in {@code target/…/*.cache} files.
* Deleting those files cause a recompilation of all sources.
*/
SOURCES(Set.of()),
/**
* Recompile source files ({@code *.java}) associated to no output file ({@code *.class})
* or associated to an output file older than the source. This algorithm does not check
* if a source file has been removed, potentially leaving non-recompiled classes with
* references to classes that no longer exist.
*
* <p>It is usually not needed to specify both {@link #SOURCES} and {@code CLASSES}.
* But doing so it not forbidden.</p>
*
* <h4>Implementation note</h4>
* This check does not use or generate any {@code *.cache} file.
*/
CLASSES(Set.of()),
/**
* Recompile modules and let the compiler decides which individual files to recompile.
* The compiler plugin does not enumerate the source files to recompile (actually, it does not scan at all the
* source directories). Instead, it only specifies the module to recompile using the {@code --module} option.
* The Java compiler will scan the source directories itself and compile only those source files that are newer
* than the corresponding files in the output directory.
*
* <p>This option is available only at the following conditions:</p>
* <ul>
* <li>All sources of the project to compile are modules in the Java sense.</li>
* <li>{@link #SOURCES}, {@link #CLASSES}, {@link #REBUILD_ON_ADD} and {@link #REBUILD_ON_CHANGE}
* aspects are not used.</li>
* <li>There is no include/exclude filter.</li>
* </ul>
*/
MODULES(Set.of(SOURCES, CLASSES)),
/**
* Modifier for recompiling all source files when the addition of a new file is detected.
* This flag is effective only when used together with {@link #SOURCES} or {@link #CLASSES}.
* When used with {@link #CLASSES}, it provides a way to detect class renaming
* (this is not needed with {@link #SOURCES} for detecting renaming).
*/
REBUILD_ON_ADD(Set.of(MODULES)),
/**
* Modifier for recompiling all source files when a change is detected in at least one source file.
* This flag is effective only when used together with {@link #SOURCES} or {@link #CLASSES}.
* It does not rebuild when a new source file is added without change in other files,
* unless {@link #REBUILD_ON_ADD} is also specified.
*/
REBUILD_ON_CHANGE(REBUILD_ON_ADD.excludes),
/**
* The compiler plugin unconditionally specifies all sources to the Java compiler.
* This aspect is mutually exclusive with all other aspects.
*/
NONE(Set.of(OPTIONS, DEPENDENCIES, SOURCES, CLASSES, REBUILD_ON_ADD, REBUILD_ON_CHANGE, MODULES));
/**
* If this aspect is mutually exclusive with other aspects, the excluded aspects.
*/
private final Set<Aspect> excludes;
/**
* Creates a new enumeration value.
*
* @param excludes the aspects that are mutually exclusive with this aspect
*/
Aspect(Set<Aspect> excludes) {
this.excludes = excludes;
}
/**
* Returns the name in lower-case, for producing error message.
*/
@Override
public String toString() {
return name().toLowerCase(Locale.US);
}
/**
* Parses a comma-separated list of aspects.
*
* @param values the plugin parameter to parse as a comma-separated list
* @return the aspects which, when modified, should cause a partial or full rebuild
* @throws MojoException if a value is not recognized, or if mutually exclusive values are specified
*/
static EnumSet<Aspect> parse(final String values) {
var aspects = EnumSet.noneOf(Aspect.class);
for (String value : values.split(",")) {
value = value.trim();
try {
aspects.add(valueOf(value.toUpperCase(Locale.US).replace('-', '_')));
} catch (IllegalArgumentException e) {
var sb = new StringBuilder(256)
.append("Illegal incremental build setting: \"")
.append(value);
String s = "\". Valid values are ";
for (Aspect aspect : values()) {
sb.append(s).append(aspect);
s = ", ";
}
throw new CompilationFailureException(sb.append('.').toString(), e);
}
}
for (Aspect aspect : aspects) {
for (Aspect exclude : aspect.excludes) {
if (aspects.contains(exclude)) {
throw new CompilationFailureException("Illegal incremental build setting: \"" + aspect
+ "\" and \"" + exclude + "\" are mutually exclusive.");
}
}
}
if (aspects.isEmpty()) {
throw new CompilationFailureException("Incremental build setting cannot be empty.");
}
return aspects;
}
}
/**
* The options for following links. An empty array means that links will be followed.
*/
private static final LinkOption[] LINK_OPTIONS = new LinkOption[0];
/**
* Magic number, generated randomly, to store in the header of the binary file.
* This number shall be changed every times that the binary file format is modified.
* The file format is described in {@link #writeCache()}.
*
* @see #writeCache()
*/
private static final long MAGIC_NUMBER = -8163803035240576921L;
/**
* Flags in the binary output file telling whether the source and/or target directory changed.
* Those flags are stored as a byte before each entry. They can be combined as bit mask.
* Those flags are for compressing the binary file, not for detecting if something changed
* since the last build.
*/
private static final byte NEW_SOURCE_DIRECTORY = 1, NEW_TARGET_DIRECTORY = 2;
/**
* Flag in the binary output file telling that the output file of a source is different
* than the one inferred by heuristic rules. For performance reason, we store the output
* files explicitly only when it cannot be inferred.
*
* @see javax.tools.JavaFileManager#getFileForOutput
*/
private static final byte EXPLICIT_OUTPUT_FILE = 4;
/**
* Flag in the binary output file telling that the output file has been omitted.
* This is the case of {@code package-info.class} files when the result is empty.
*/
private static final byte OMITTED_OUTPUT_FILE = 8;
/**
* Bitmask of all flags that are allowed in a cache file.
*/
private static final byte ALL_FLAGS =
NEW_SOURCE_DIRECTORY | NEW_TARGET_DIRECTORY | EXPLICIT_OUTPUT_FILE | OMITTED_OUTPUT_FILE;
/**
* Name of the file where to store the list of source files and the list of files created by the compiler.
* This is a binary format used for detecting changes. The file is stored in the {@code target} directory.
* If the file is absent of corrupted, it will be ignored and recreated.
*
* @see AbstractCompilerMojo#mojoStatusPath
*/
private final Path cacheFile;
/**
* Whether the cache file has been loaded.
*/
private boolean cacheLoaded;
/**
* All source files together with their last modification time.
* This list is specified at construction time and is not modified by this class.
*
* @see #getModifiedSources()
*/
private final List<SourceFile> sourceFiles;
/**
* The build time in milliseconds since January 1st, 1970.
* This is used for detecting if a dependency changed since the previous build.
*/
private final long buildTime;
/**
* Time of the previous build. This value is initialized by {@link #loadCache()}.
* If the cache cannot be loaded, then this field is conservatively set to the same value
* as {@link #buildTime}, but it shouldn't matter because a full build will be done anyway.
*/
private long previousBuildTime;
/**
* The granularity in milliseconds to use for comparing modification times.
*
* @see AbstractCompilerMojo#staleMillis
*/
private final long staleMillis;
/**
* Hash code value of the compiler options during the previous build.
* This value is initialized by {@link #loadCache()}.
*/
private int previousOptionsHash;
/**
* Hash code value of the current {@link Options#options} list.
*/
private final int optionsHash;
/**
* Whether to save the list of source files.
*/
private final boolean saveSourceList;
/**
* Whether to recompile all source files if a file addition is detected.
*
* @see Aspect#REBUILD_ON_ADD
*/
private final boolean rebuildOnAdd;
/**
* Whether to recompile all source files if at least one source changed.
*
* @see Aspect#REBUILD_ON_CHANGE
*/
private final boolean rebuildOnChange;
/**
* Whether to provide more details about why a module is rebuilt.
*/
private final boolean showCompilationChanges;
/**
* Creates a new helper for an incremental build.
*
* @param mojo the MOJO which is compiling source code
* @param sourceFiles all source files
* @param saveSourceList whether to save the list of source files in the cache
* @param options the compiler options
* @param aspects result of {@link Aspect#parse(String)}
* @throws IOException if the parent directory cannot be created
*/
IncrementalBuild(
AbstractCompilerMojo mojo,
List<SourceFile> sourceFiles,
boolean saveSourceList,
Options configuration,
EnumSet<Aspect> aspects)
throws IOException {
this.sourceFiles = sourceFiles;
this.saveSourceList = saveSourceList;
cacheFile = mojo.mojoStatusPath;
if (cacheFile != null) {
// Should never be null, but it has been observed to happen with some Maven versions.
Files.createDirectories(cacheFile.getParent());
}
showCompilationChanges = mojo.showCompilationChanges;
buildTime = System.currentTimeMillis();
previousBuildTime = buildTime;
staleMillis = mojo.staleMillis;
rebuildOnAdd = aspects.contains(Aspect.REBUILD_ON_ADD);
rebuildOnChange = aspects.contains(Aspect.REBUILD_ON_CHANGE);
optionsHash = configuration.options.hashCode();
}
/**
* Deletes the cache if it exists.
*
* @throws IOException if an error occurred while deleting the file
*/
public void deleteCache() throws IOException {
if (cacheFile != null) {
// Should never be null, but it has been observed to happen with some Maven versions.
Files.deleteIfExists(cacheFile);
}
}
/**
* Saves the list of source files in the cache file. The cache is a binary file
* and its format may change in any future version. The current format is as below:
*
* <ul>
* <li>The magic number (while change when the format changes).</li>
* <li>The build time in milliseconds since January 1st, 1970.</li>
* <li>Hash code value of the {@link Options#options} list.</li>
* <li>Number of source files, or 0 if {@code sources} is {@code false}.</li>
* <li>If {@code sources} is {@code true}, then for each source file:<ul>
* <li>A bit mask of {@link #NEW_SOURCE_DIRECTORY}, {@link #NEW_TARGET_DIRECTORY} and {@link #EXPLICIT_OUTPUT_FILE}.</li>
* <li>If {@link #NEW_SOURCE_DIRECTORY} is set, the new root directory of source files.</li>
* <li>If {@link #NEW_TARGET_DIRECTORY} is set, the new root directory of output files.</li>
* <li>If {@link #EXPLICIT_OUTPUT_FILE} is set, the output file.</li>
* <li>The file path as a sibling of the previous file, unless a new root directory has been specified.</li>
* <li>Last modification time of the source file, in milliseconds since January 1st.</li>
* </ul></li>
* </ul>
*
* The "new source directory" flag is for avoiding to repeat the parent directory.
* If that flag is {@code false}, then only the filename is stored and the parent
* is the same as the previous file.
*
* @param sources whether to save also the list of source files
* @throws IOException if an error occurred while writing the cache file
*/
@SuppressWarnings({"checkstyle:InnerAssignment", "checkstyle:NeedBraces"})
public void writeCache() throws IOException {
if (cacheFile == null) {
// Should never be null, but it has been observed to happen with some Maven versions.
return;
}
try (DataOutputStream out = new DataOutputStream(new BufferedOutputStream(Files.newOutputStream(
cacheFile,
StandardOpenOption.WRITE,
StandardOpenOption.CREATE,
StandardOpenOption.TRUNCATE_EXISTING)))) {
out.writeLong(MAGIC_NUMBER);
out.writeLong(buildTime);
out.writeInt(optionsHash);
out.writeInt(saveSourceList ? sourceFiles.size() : 0);
if (saveSourceList) {
Path srcDir = null;
Path tgtDir = null;
Path previousParent = null;
for (SourceFile source : sourceFiles) {
final Path sourceFile = source.file;
final Path outputFile = source.getOutputFile();
boolean sameSrcDir = Objects.equals(srcDir, srcDir = source.directory.root);
boolean sameTgtDir = Objects.equals(tgtDir, tgtDir = source.directory.getOutputDirectory());
boolean sameOutput = source.isStandardOutputFile();
boolean omitted = Files.notExists(outputFile);
out.writeByte((sameSrcDir ? 0 : NEW_SOURCE_DIRECTORY)
| (sameTgtDir ? 0 : NEW_TARGET_DIRECTORY)
| (sameOutput ? 0 : EXPLICIT_OUTPUT_FILE)
| (omitted ? OMITTED_OUTPUT_FILE : 0));
if (!sameSrcDir) out.writeUTF((previousParent = srcDir).toString());
if (!sameTgtDir) out.writeUTF(tgtDir.toString());
if (!sameOutput) out.writeUTF(outputFile.toString());
out.writeUTF(previousParent.relativize(sourceFile).toString());
out.writeLong(source.lastModified);
previousParent = sourceFile.getParent();
}
}
}
}
/**
* Loads the list of source files and their modification times from the previous build.
* The binary file format reads by this method is described in {@link #writeCache()}.
* The keys are the source files. The returned map is modifiable.
*
* @return the source files of previous build
* @throws IOException if an error occurred while reading the cache file
*/
@SuppressWarnings("checkstyle:NeedBraces")
private Map<Path, SourceInfo> loadCache() throws IOException {
if (cacheFile == null) {
// Should never be null, but it has been observed to happen with some Maven versions.
return Collections.emptyMap(); // Not `Map.of()` because we need to allow `Map.remove(…)`.
}
final Map<Path, SourceInfo> previousBuild;
try (DataInputStream in = new DataInputStream(
new BufferedInputStream(Files.newInputStream(cacheFile, StandardOpenOption.READ)))) {
if (in.readLong() != MAGIC_NUMBER) {
throw new IOException("Invalid cache file.");
}
previousBuildTime = in.readLong();
previousOptionsHash = in.readInt();
int remaining = in.readInt();
previousBuild = new HashMap<>(remaining + remaining / 3);
Path srcDir = null;
Path tgtDir = null;
Path srcFile = null;
while (--remaining >= 0) {
final byte flags = in.readByte();
if ((flags & ~ALL_FLAGS) != 0) {
throw new IOException("Invalid cache file.");
}
boolean newSrcDir = (flags & NEW_SOURCE_DIRECTORY) != 0;
boolean newTgtDir = (flags & NEW_TARGET_DIRECTORY) != 0;
boolean newOutput = (flags & EXPLICIT_OUTPUT_FILE) != 0;
boolean omitted = (flags & OMITTED_OUTPUT_FILE) != 0;
Path output = null;
if (newSrcDir) srcDir = Path.of(in.readUTF());
if (newTgtDir) tgtDir = Path.of(in.readUTF());
if (newOutput) output = Path.of(in.readUTF());
String path = in.readUTF();
srcFile = newSrcDir ? srcDir.resolve(path) : srcFile.resolveSibling(path);
srcFile = srcFile.normalize();
var info = new SourceInfo(srcDir, tgtDir, output, omitted, in.readLong());
if (previousBuild.put(srcFile, info) != null) {
throw new IOException("Duplicated source file declared in the cache: " + srcFile);
}
}
}
cacheLoaded = true;
return previousBuild;
}
/**
* Information about a source file from a previous build.
*
* @param sourceDirectory root directory of the source file
* @param outputDirectory output directory of the compiled file
* @param outputFile the output file if it was explicitly specified, or {@code null} if it can be inferred
* @param omitted whether the output file has not be generated by the compiler (e.g. {@code package-info.class})
* @param lastModified last modification times of the source file during the previous build
*/
private static record SourceInfo(
Path sourceDirectory, Path outputDirectory, Path outputFile, boolean omitted, long lastModified) {
/**
* Deletes all output files associated to the given source file. If the output file is a {@code .class} file,
* then this method deletes also the output files for all inner classes (e.g. {@code "Foo$0.class"}).
*
* @param sourceFile the source file for which to delete output files
* @throws IOException if an error occurred while scanning the output directory or deleting a file
*/
void deleteClassFiles(final Path sourceFile) throws IOException {
Path output = outputFile;
if (output == null) {
output = SourceFile.toOutputFile(
sourceDirectory,
outputDirectory,
sourceFile,
SourceDirectory.JAVA_FILE_SUFFIX,
SourceDirectory.CLASS_FILE_SUFFIX);
}
String filename = output.getFileName().toString();
if (filename.endsWith(SourceDirectory.CLASS_FILE_SUFFIX)) {
String prefix = filename.substring(0, filename.length() - SourceDirectory.CLASS_FILE_SUFFIX.length());
List<Path> outputs;
try (Stream<Path> files = Files.walk(output.getParent(), 1)) {
outputs = files.filter((f) -> {
String name = f.getFileName().toString();
return name.startsWith(prefix)
&& name.endsWith(SourceDirectory.CLASS_FILE_SUFFIX)
&& (name.equals(filename) || name.charAt(prefix.length()) == '$');
})
.toList();
}
for (Path p : outputs) {
Files.delete(p);
}
} else {
Files.deleteIfExists(output);
}
}
}
/**
* Detects whether the list of detected files has changed since the last build.
* This method loads the list of files of the previous build from a status file
* and compares it with the new list file. If the list file cannot be read,
* then this method conservatively assumes that the file tree changed.
*
* <p>If this method returns {@code null}, the caller can check the {@link SourceFile#isNewOrModified} flag
* for deciding which files to recompile. If this method returns non-null value, then the {@code isModified}
* flag should be ignored and all files recompiled unconditionally. The returned non-null value is a message
* saying why the project needs to be rebuilt.</p>
*
* @return {@code null} if the project does not need to be rebuilt, otherwise a message saying why to rebuild
* @throws IOException if an error occurred while deleting output files of the previous build
*
* @see Aspect#SOURCES
*/
String inputFileTreeChanges() throws IOException {
final Map<Path, SourceInfo> previousBuild;
try {
previousBuild = loadCache();
} catch (NoSuchFileException e) {
return "Compiling all files.";
} catch (IOException e) {
return causeOfRebuild("information about the previous build cannot be read", true)
.append(System.lineSeparator())
.append(e)
.toString();
}
boolean rebuild = false;
boolean allChanged = true;
List<Path> added = new ArrayList<>();
for (SourceFile source : sourceFiles) {
SourceInfo previous = previousBuild.remove(source.file);
if (previous != null) {
if (source.lastModified - previous.lastModified <= staleMillis) {
/*
* Source file has not been modified. But we still need to check if the output file exists.
* It may be, for example, because the compilation failed during the previous build because
* of another class.
*/
allChanged = false;
if (previous.omitted) {
continue;
}
Path output = source.getOutputFile();
if (Files.exists(output, LINK_OPTIONS)) {
continue; // Source file has not been modified and output file exists.
}
} else if (rebuildOnChange) {
return causeOfRebuild("at least one source file changed", false)
.toString();
}
} else if (!source.ignoreModification) {
if (showCompilationChanges) {
added.add(source.file);
}
rebuild |= rebuildOnAdd;
}
source.isNewOrModified = true;
}
/*
* The files remaining in `previousBuild` are files that have been removed since the last build.
* If no file has been removed, then there is no need to rebuild the whole project (added files
* do not require a full build).
*/
if (previousBuild.isEmpty()) {
if (allChanged) {
return causeOfRebuild("all source files changed", false).toString();
}
if (!rebuild) {
return null;
}
}
/*
* If some files have been removed, we need to delete the corresponding output files.
* If the output file extension is ".class", then many files may be deleted because
* the output file may be accompanied by inner classes (e.g. {@code "Foo$0.class"}).
*/
for (Map.Entry<Path, SourceInfo> removed : previousBuild.entrySet()) {
removed.getValue().deleteClassFiles(removed.getKey());
}
/*
* At this point, it has been decided that all source files will be recompiled.
* Format a message saying why.
*/
StringBuilder causeOfRebuild = causeOfRebuild("of added or removed source files", showCompilationChanges);
if (showCompilationChanges) {
for (Path fileAdded : added) {
causeOfRebuild.append(System.lineSeparator()).append(" + ").append(fileAdded);
}
for (Path fileRemoved : previousBuild.keySet()) {
causeOfRebuild.append(System.lineSeparator()).append(" - ").append(fileRemoved);
}
}
return causeOfRebuild.toString();
}
/**
* Returns whether at least one dependency file is more recent than the given build start time.
* This method should be invoked only after {@link #inputFileTreeChanges} returned {@code null}.
* Each given root can be either a regular file (typically a JAR file) or a directory.
* Directories are scanned recursively.
*
* @param dependencies files or directories to scan
* @param fileExtensions extensions of the file to check (usually "jar" and "class")
* @return {@code null} if the project does not need to be rebuilt, otherwise a message saying why to rebuild
* @throws IOException if an error occurred while scanning the directories
*
* @see Aspect#DEPENDENCIES
*/
String dependencyChanges(Iterable<List<Path>> dependencies, Collection<String> fileExtensions) throws IOException {
if (!cacheLoaded) {
loadCache();
}
final FileTime changeTime = FileTime.fromMillis(previousBuildTime);
final var updated = new ArrayList<Path>();
for (List<Path> roots : dependencies) {
for (Path root : roots) {
try (Stream<Path> files = Files.walk(root)) {
files.filter((f) -> {
String name = f.getFileName().toString();
int s = name.lastIndexOf('.');
if (s < 0 || !fileExtensions.contains(name.substring(s + 1))) {
return false;
}
try {
return Files.isRegularFile(f)
&& Files.getLastModifiedTime(f).compareTo(changeTime) >= 0;
} catch (IOException e) {
throw new UncheckedIOException(e);
}
})
.forEach(updated::add);
} catch (UncheckedIOException e) {
throw e.getCause();
}
}
}
if (updated.isEmpty()) {
return null;
}
StringBuilder causeOfRebuild = causeOfRebuild("some dependencies changed", showCompilationChanges);
if (showCompilationChanges) {
for (Path file : updated) {
causeOfRebuild.append(System.lineSeparator()).append(" ").append(file);
}
}
return causeOfRebuild.toString();
}
/**
* Returns whether the compiler options have changed.
* This method should be invoked only after {@link #inputFileTreeChanges} returned {@code null}.
*
* @return {@code null} if the project does not need to be rebuilt, otherwise a message saying why to rebuild
* @throws IOException if an error occurred while loading the cache file
*
* @see Aspect#OPTIONS
*/
String optionChanges() throws IOException {
if (!cacheLoaded) {
loadCache();
}
if (optionsHash == previousOptionsHash) {
return null;
}
return causeOfRebuild("of changes in compiler options", false).toString();
}
/**
* Prepares a message saying why a full rebuild is done. A colon character will be added
* if showing compilation changes is enabled, otherwise a period is added.
*
* @param cause the cause of the rebuild, without trailing colon or period
* @param colon whether to append a colon instead of a period after the message
* @return a buffer where more details can be appended for reporting the cause
*/
private static StringBuilder causeOfRebuild(String cause, boolean colon) {
return new StringBuilder(128)
.append("Recompiling all files because ")
.append(cause)
.append(colon ? ':' : '.');
}
/**
* Compares the modification time of all source files with the modification time of output files.
* The files identified as in need to be recompiled have their {@link SourceFile#isNewOrModified}
* flag set to {@code true}. This method does not use the cache file.
*
* @return {@code null} if the project does not need to be rebuilt, otherwise a message saying why to rebuild
* @throws IOException if an error occurred while reading the time stamp of an output file
*
* @see Aspect#CLASSES
*/
String markNewOrModifiedSources() throws IOException {
for (SourceFile source : sourceFiles) {
if (!source.isNewOrModified) {
// Check even if `source.ignoreModification` is true.
Path output = source.getOutputFile();
if (Files.exists(output, LINK_OPTIONS)) {
FileTime t = Files.getLastModifiedTime(output, LINK_OPTIONS);
if (source.lastModified - t.toMillis() <= staleMillis) {
continue;
} else if (rebuildOnChange) {
return causeOfRebuild("at least one source file changed", false)
.toString();
}
} else if (rebuildOnAdd) {
StringBuilder causeOfRebuild = causeOfRebuild("of added source files", showCompilationChanges);
if (showCompilationChanges) {
causeOfRebuild
.append(System.lineSeparator())
.append(" + ")
.append(source.file);
}
return causeOfRebuild.toString();
}
source.isNewOrModified = true;
}
}
return null;
}
/**
* Returns the source files that are marked as new or modified. The returned list may contain files
* that are new or modified, but should nevertheless be ignored in the decision to recompile or not.
* In order to decide if a compilation is needed, invoke {@link #isEmptyOrIgnorable(List)} instead
* of {@link List#isEmpty()}.
*
* @return new or modified source files, or an empty list if none
*/
List<SourceFile> getModifiedSources() {
return sourceFiles.stream().filter((s) -> s.isNewOrModified).toList();
}
/**
* {@return whether the given list of modified files should not cause a recompilation}
* This method returns {@code true} if the given list is empty or contains only files
* with the {@link SourceFile#ignoreModification} set to {@code true}.
*
* @param sourceFiles return value of {@link #getModifiedSources()}.
*/
static boolean isEmptyOrIgnorable(List<SourceFile> sourceFiles) {
return !sourceFiles.stream().anyMatch((s) -> !s.ignoreModification);
}
}