diff --git a/pom.xml b/pom.xml
index 4b016c7c4..1733d8211 100644
--- a/pom.xml
+++ b/pom.xml
@@ -82,7 +82,7 @@ under the License.
17
- 4.0.0-rc-4
+ 4.1.0-SNAPSHOT9.10.17.0.0
diff --git a/src/main/java/org/apache/maven/plugin/compiler/AbstractCompilerMojo.java b/src/main/java/org/apache/maven/plugin/compiler/AbstractCompilerMojo.java
index cac92f902..b094993d1 100644
--- a/src/main/java/org/apache/maven/plugin/compiler/AbstractCompilerMojo.java
+++ b/src/main/java/org/apache/maven/plugin/compiler/AbstractCompilerMojo.java
@@ -64,6 +64,7 @@
import org.apache.maven.api.Type;
import org.apache.maven.api.annotations.Nonnull;
import org.apache.maven.api.annotations.Nullable;
+import org.apache.maven.api.build.context.BuildContext;
import org.apache.maven.api.di.Inject;
import org.apache.maven.api.plugin.Log;
import org.apache.maven.api.plugin.Mojo;
@@ -688,22 +689,16 @@ final Charset charset() {
*
* @throws MojoException if a value is not recognized, or if mutually exclusive values are specified
*/
- final EnumSet incrementalCompilationConfiguration() {
+ final EnumSet incrementalCompilationConfiguration() {
if (isAbsent(incrementalCompilation)) {
if (useIncrementalCompilation != null) {
return useIncrementalCompilation
- ? EnumSet.of(
- IncrementalBuild.Aspect.DEPENDENCIES,
- IncrementalBuild.Aspect.SOURCES,
- IncrementalBuild.Aspect.REBUILD_ON_ADD)
- : EnumSet.of(IncrementalBuild.Aspect.CLASSES);
+ ? EnumSet.of(Aspect.DEPENDENCIES, Aspect.SOURCES, Aspect.REBUILD_ON_ADD)
+ : EnumSet.of(Aspect.CLASSES);
}
- return EnumSet.of(
- IncrementalBuild.Aspect.OPTIONS,
- IncrementalBuild.Aspect.DEPENDENCIES,
- IncrementalBuild.Aspect.SOURCES);
+ return EnumSet.of(Aspect.OPTIONS, Aspect.DEPENDENCIES, Aspect.SOURCES);
}
- return IncrementalBuild.Aspect.parse(incrementalCompilation);
+ return Aspect.parse(incrementalCompilation);
}
/**
@@ -712,10 +707,10 @@ final EnumSet incrementalCompilationConfiguration() {
* @param aspects the configuration to amend if an annotation processor is found
* @param dependencyTypes the type of dependencies, for checking if any of them is a processor path
*/
- final void amendincrementalCompilation(EnumSet aspects, Set dependencyTypes) {
+ final void amendincrementalCompilation(EnumSet aspects, Set dependencyTypes) {
if (isAbsent(incrementalCompilation) && hasAnnotationProcessor(dependencyTypes)) {
- aspects.add(IncrementalBuild.Aspect.REBUILD_ON_ADD);
- aspects.add(IncrementalBuild.Aspect.REBUILD_ON_CHANGE);
+ aspects.add(Aspect.REBUILD_ON_ADD);
+ aspects.add(Aspect.REBUILD_ON_CHANGE);
}
}
@@ -942,6 +937,14 @@ final void amendincrementalCompilation(EnumSet aspects,
@Inject
protected MessageBuilderFactory messageBuilderFactory;
+ /**
+ * The build context for incremental build support.
+ * Used to signal to downstream plugins when compilation is skipped
+ * because all classes are up to date or there are no sources to compile.
+ */
+ @Inject
+ protected BuildContext buildContext;
+
/**
* The logger for reporting information or warnings to the user.
* Currently, this is also used for console output.
@@ -1382,6 +1385,7 @@ public Options parseParameters(final OptionChecker compiler) {
private void compile(final JavaCompiler compiler, final Options configuration) throws IOException {
final ToolExecutor executor = createExecutor(null);
if (!executor.applyIncrementalBuild(this, configuration)) {
+ buildContext.markSkipExecution();
return;
}
Throwable failureCause = null;
diff --git a/src/main/java/org/apache/maven/plugin/compiler/Aspect.java b/src/main/java/org/apache/maven/plugin/compiler/Aspect.java
new file mode 100644
index 000000000..5e586be99
--- /dev/null
+++ b/src/main/java/org/apache/maven/plugin/compiler/Aspect.java
@@ -0,0 +1,185 @@
+/*
+ * 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.util.EnumSet;
+import java.util.Locale;
+import java.util.Set;
+
+/**
+ * Elements to take in consideration when deciding whether to recompile a file.
+ * The actual change detection, state persistence, and stale output cleanup are handled by the
+ * {@link org.apache.maven.api.build.context.BuildContext BuildContext} API. This enumeration
+ * configures which kinds of changes should be tracked and how they affect the rebuild decision
+ * (partial vs. full).
+ *
+ * @see AbstractCompilerMojo#incrementalCompilation
+ */
+enum Aspect {
+ /**
+ * Recompile all source files if the compiler options changed.
+ *
+ *
BuildContext note: Options change detection is handled automatically by
+ * the {@code MojoConfigurationDigester}, which digests all {@code @Parameter} fields.
+ * When any parameter value changes between builds, the build context escalates to a
+ * full rebuild. This aspect is still accepted for backwards compatibility but the
+ * detection itself is delegated to BuildContext.
+ */
+ OPTIONS(Set.of()),
+
+ /**
+ * Recompile all source files if at least one dependency (JAR file) changed since the last build.
+ * Dependency JARs are registered as BuildContext inputs; their content changes are tracked
+ * across builds.
+ *
+ *
Implementation note
+ * The checks use BuildContext state files stored in the {@code target/incremental} directory.
+ */
+ DEPENDENCIES(Set.of()),
+
+ /**
+ * Recompile source files modified since the last build.
+ * Source files are registered as BuildContext inputs. The build context tracks their state
+ * (timestamp, size) across builds and reports which files are NEW, MODIFIED, or UNMODIFIED.
+ * When a source file is removed, the build context automatically deletes its associated
+ * output files (including inner classes) during finalization.
+ *
+ *
It is usually not needed to specify both {@code SOURCES} and {@link #CLASSES}.
+ * But doing so is not forbidden.
+ *
+ *
Implementation note
+ * The checks use BuildContext state files stored in the {@code target/incremental} directory.
+ */
+ 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.
+ *
+ *
It is usually not needed to specify both {@link #SOURCES} and {@code CLASSES}.
+ * But doing so is not forbidden.
+ *
+ *
Implementation note
+ * With BuildContext, this aspect is handled the same way as {@link #SOURCES} — input
+ * state tracking across builds subsumes the timestamp-based comparison.
+ */
+ 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.
+ *
+ *
This option is available only at the following conditions:
+ *
+ *
All sources of the project to compile are modules in the Java sense.
+ *
{@link #SOURCES}, {@link #CLASSES}, {@link #REBUILD_ON_ADD} and {@link #REBUILD_ON_CHANGE}
+ * aspects are not used.
+ *
There is no include/exclude filter.
+ *
+ */
+ 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 excludes;
+
+ /**
+ * Creates a new enumeration value.
+ *
+ * @param excludes the aspects that are mutually exclusive with this aspect
+ */
+ Aspect(Set 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 CompilationFailureException if a value is not recognized, or if mutually exclusive values are specified
+ */
+ static EnumSet 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;
+ }
+}
diff --git a/src/main/java/org/apache/maven/plugin/compiler/CompilerMojo.java b/src/main/java/org/apache/maven/plugin/compiler/CompilerMojo.java
index 306ac5933..5a968cbcf 100644
--- a/src/main/java/org/apache/maven/plugin/compiler/CompilerMojo.java
+++ b/src/main/java/org/apache/maven/plugin/compiler/CompilerMojo.java
@@ -192,6 +192,7 @@ public CompilerMojo() {
public void execute() throws MojoException {
if (skipMain) {
logger.info("Not compiling main sources");
+ buildContext.markSkipExecution();
return;
}
try {
diff --git a/src/main/java/org/apache/maven/plugin/compiler/IncrementalBuild.java b/src/main/java/org/apache/maven/plugin/compiler/IncrementalBuild.java
deleted file mode 100644
index 1dc849d4b..000000000
--- a/src/main/java/org/apache/maven/plugin/compiler/IncrementalBuild.java
+++ /dev/null
@@ -1,784 +0,0 @@
-/*
- * 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 best-effort 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.
- *
- *
Implementation note
- * 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.
- *
- *
It is usually not needed to specify both {@code SOURCES} and {@link #CLASSES}.
- * But doing so is not forbidden.
- *
- *
Implementation note
- * 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.
- *
- *
It is usually not needed to specify both {@link #SOURCES} and {@code CLASSES}.
- * But doing so is not forbidden.
- *
- *
Implementation note
- * 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.
- *
- *
This option is available only at the following conditions:
- *
- *
All sources of the project to compile are modules in the Java sense.
- *
{@link #SOURCES}, {@link #CLASSES}, {@link #REBUILD_ON_ADD} and {@link #REBUILD_ON_CHANGE}
- * aspects are not used.
- *
There is no include/exclude filter.
- *
- */
- 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 excludes;
-
- /**
- * Creates a new enumeration value.
- *
- * @param excludes the aspects that are mutually exclusive with this aspect
- */
- Aspect(Set 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 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 indicating that the output file of a source is different
- * from the one inferred by heuristic rules. For performance reasons, we store the output
- * file 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 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 configuration the compiler options
- * @param aspects result of {@link Aspect#parse(String)}
- * @throws IOException if the parent directory cannot be created
- */
- IncrementalBuild(
- AbstractCompilerMojo mojo,
- List sourceFiles,
- boolean saveSourceList,
- Options configuration,
- EnumSet 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:
- *
- *
- *
The magic number (while change when the format changes).
- *
The build time in milliseconds since January 1st, 1970.
- *
Hash code value of the {@link Options#options} list.
- *
Number of source files, or 0 if {@code sources} is {@code false}.
- *
If {@code sources} is {@code true}, then for each source file:
- *
A bit mask of {@link #NEW_SOURCE_DIRECTORY}, {@link #NEW_TARGET_DIRECTORY} and {@link #EXPLICIT_OUTPUT_FILE}.
- *
If {@link #NEW_SOURCE_DIRECTORY} is set, the new root directory of source files.
- *
If {@link #NEW_TARGET_DIRECTORY} is set, the new root directory of output files.
- *
If {@link #EXPLICIT_OUTPUT_FILE} is set, the output file.
- *
The file path as a sibling of the previous file, unless a new root directory has been specified.
- *
Last modification time of the source file, in milliseconds since January 1st.
- *
- *
- *
- * The "new source directory" flag is to avoid repeating 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.
- *
- * @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 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 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 been generated by the compiler (e.g. {@code package-info.class})
- * @param lastModified last modification time of the source file during the previous build
- */
- private 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 outputs;
- try (Stream 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.
- *
- *
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.
- *
- * @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 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;
- final var 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 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> dependencies, Collection fileExtensions)
- throws IOException {
- if (!cacheLoaded) {
- loadCache();
- }
- final FileTime changeTime = FileTime.fromMillis(previousBuildTime);
- final var updated = new ArrayList();
- for (Collection roots : dependencies) {
- for (Path root : roots) {
- try (Stream 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 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 if
- * {@link SourceFile#isEmptyOrIgnorable()} returns {@code true} for every file.
- *
- * @param sourceFiles return value of {@link #getModifiedSources()}.
- */
- static boolean isEmptyOrIgnorable(List sourceFiles) {
- return sourceFiles.stream().allMatch(SourceFile::isEmptyOrIgnorable);
- }
-}
diff --git a/src/main/java/org/apache/maven/plugin/compiler/SourceFile.java b/src/main/java/org/apache/maven/plugin/compiler/SourceFile.java
index 06fdbb760..1800a660f 100644
--- a/src/main/java/org/apache/maven/plugin/compiler/SourceFile.java
+++ b/src/main/java/org/apache/maven/plugin/compiler/SourceFile.java
@@ -53,8 +53,8 @@ final class SourceFile {
/**
* Whether this source has been flagged as new or modified since the last build.
- *
- * @see IncrementalBuildHelper#inputFileTreeChanges
+ * Set by the BuildContext-based change detection in
+ * {@link ToolExecutor#applyIncrementalBuild}.
*/
boolean isNewOrModified;
@@ -103,7 +103,6 @@ final class SourceFile {
* Then, {@link #getOutputFile} should compare that value with the inferred one and set a flag.
*/
boolean isStandardOutputFile() {
- // The constants below must match the ones in `IncrementalBuild.SourceInfo`.
return SourceDirectory.JAVA_FILE_SUFFIX.equals(directory.fileKind.extension)
&& SourceDirectory.CLASS_FILE_SUFFIX.equals(directory.outputFileKind.extension);
}
diff --git a/src/main/java/org/apache/maven/plugin/compiler/TestCompilerMojo.java b/src/main/java/org/apache/maven/plugin/compiler/TestCompilerMojo.java
index 1cdc33633..f10e282bb 100644
--- a/src/main/java/org/apache/maven/plugin/compiler/TestCompilerMojo.java
+++ b/src/main/java/org/apache/maven/plugin/compiler/TestCompilerMojo.java
@@ -246,6 +246,7 @@ public TestCompilerMojo() {
public void execute() throws MojoException {
if (skip) {
logger.info("Not compiling test sources");
+ buildContext.markSkipExecution();
return;
}
super.execute();
diff --git a/src/main/java/org/apache/maven/plugin/compiler/ToolExecutor.java b/src/main/java/org/apache/maven/plugin/compiler/ToolExecutor.java
index 907d32390..aa8f0b1f4 100644
--- a/src/main/java/org/apache/maven/plugin/compiler/ToolExecutor.java
+++ b/src/main/java/org/apache/maven/plugin/compiler/ToolExecutor.java
@@ -47,9 +47,14 @@
import java.util.Map;
import java.util.Optional;
import java.util.Set;
+import java.util.stream.Stream;
import org.apache.maven.api.JavaPathType;
import org.apache.maven.api.PathType;
+import org.apache.maven.api.build.context.BuildContext;
+import org.apache.maven.api.build.context.Input;
+import org.apache.maven.api.build.context.Metadata;
+import org.apache.maven.api.build.context.Status;
import org.apache.maven.api.plugin.Log;
import org.apache.maven.api.plugin.MojoException;
import org.apache.maven.api.services.DependencyResolverResult;
@@ -167,13 +172,21 @@ public class ToolExecutor {
* @see AbstractCompilerMojo#incrementalCompilation
* @see AbstractCompilerMojo#useIncrementalCompilation
*/
- private final EnumSet incrementalBuildConfig;
+ private final EnumSet incrementalBuildConfig;
/**
- * The incremental build to save if the build succeed.
- * In case of failure, the cached information will be unchanged.
+ * The build context for incremental build support.
+ * Used to register inputs, detect changes, associate outputs, and clean up stale outputs.
*/
- private IncrementalBuild incrementalBuild;
+ private final BuildContext buildContext;
+
+ /**
+ * Map of source file paths to their BuildContext {@link Input} handles.
+ * Populated during {@link #applyIncrementalBuild} for sources that were processed
+ * (compiled), and used after compilation to associate output class files.
+ * A {@code null} value means BuildContext was not used (e.g. MODULES or NONE aspect).
+ */
+ private Map processedInputs;
/**
* Whether only a subset of the files will be compiled. This flag can be {@code true} only when
@@ -229,6 +242,7 @@ protected ToolExecutor(final AbstractCompilerMojo mojo, DiagnosticListener sup
}
this.listener = listener;
encoding = mojo.charset();
+ buildContext = mojo.buildContext;
incrementalBuildConfig = mojo.incrementalCompilationConfiguration();
outputDirectory = Files.createDirectories(mojo.getOutputDirectory());
sourceDirectories = mojo.getSourceDirectories(outputDirectory);
@@ -240,7 +254,7 @@ protected ToolExecutor(final AbstractCompilerMojo mojo, DiagnosticListener sup
* skip the build if there is no source code to compile. We want arguments to be verified first
* in order to warn about possible configuration problems.
*/
- if (incrementalBuildConfig.contains(IncrementalBuild.Aspect.MODULES)) {
+ if (incrementalBuildConfig.contains(Aspect.MODULES)) {
boolean hasNoFileMatchers = mojo.hasNoFileMatchers();
for (SourceDirectory root : sourceDirectories) {
if (root.moduleName == null) {
@@ -349,14 +363,16 @@ final boolean isReleaseSpecifiedForAll() {
/**
* Filters the source files to recompile, or cleans the output directory if everything should be rebuilt.
- * If the directory structure of the source files has changed since the last build,
- * or if a compiler option changed, or if a dependency changed,
- * then this method keeps all source files and cleans the {@linkplain #outputDirectory output directory}.
- * Otherwise, the source files that did not changed since the last build are removed from the list of sources
- * to compile. If all source files have been removed, then this method returns {@code false} for notifying the
- * caller that it can skip the build.
+ * Uses the {@link BuildContext} API to detect changes in source files, compiler options and plugin
+ * classpath since the last build. When the build context detects a configuration change (mojo parameters
+ * or plugin classpath), it escalates — treating all inputs as modified and forcing a full rebuild.
+ *
+ *
Source files are registered as {@link BuildContext} inputs. After compilation,
+ * {@link #associateOutputs()} associates each compiled source with its output class files
+ * (including inner classes) so that the build context can clean up stale outputs when
+ * sources are removed.
*
- *
If this method is invoked many times, all invocations after this first one have no effect.
+ *
If this method is invoked many times, all invocations after the first one have no effect.
*
* @param mojo the MOJO from which to take the incremental build configuration
* @param configuration the options which should match the options used during the last build
@@ -365,69 +381,191 @@ final boolean isReleaseSpecifiedForAll() {
*/
public boolean applyIncrementalBuild(final AbstractCompilerMojo mojo, final Options configuration)
throws IOException {
- final boolean checkSources = incrementalBuildConfig.contains(IncrementalBuild.Aspect.SOURCES);
- final boolean checkClasses = incrementalBuildConfig.contains(IncrementalBuild.Aspect.CLASSES);
- final boolean checkDepends = incrementalBuildConfig.contains(IncrementalBuild.Aspect.DEPENDENCIES);
- final boolean checkOptions = incrementalBuildConfig.contains(IncrementalBuild.Aspect.OPTIONS);
- if (checkSources | checkClasses | checkDepends | checkOptions) {
- incrementalBuild =
- new IncrementalBuild(mojo, sourceFiles, checkSources, configuration, incrementalBuildConfig);
- String causeOfRebuild = null;
- if (checkSources) {
- // Should be first, because this method deletes output files of removed sources.
- causeOfRebuild = incrementalBuild.inputFileTreeChanges();
- }
- if (checkClasses && causeOfRebuild == null) {
- causeOfRebuild = incrementalBuild.markNewOrModifiedSources();
- }
- if (checkDepends && causeOfRebuild == null) {
- List fileExtensions = mojo.fileExtensions;
- causeOfRebuild = incrementalBuild.dependencyChanges(dependencies.values(), fileExtensions);
+ final boolean checkSources = incrementalBuildConfig.contains(Aspect.SOURCES);
+ final boolean checkClasses = incrementalBuildConfig.contains(Aspect.CLASSES);
+ final boolean checkDepends = incrementalBuildConfig.contains(Aspect.DEPENDENCIES);
+ // Note: OPTIONS and plugin classpath changes are handled automatically by BuildContext's
+ // MojoConfigurationDigester and ClasspathDigester — they trigger escalation when changed.
+ if (!(checkSources | checkClasses | checkDepends | incrementalBuildConfig.contains(Aspect.OPTIONS))) {
+ incrementalBuildConfig.clear();
+ return true;
+ }
+ final boolean rebuildOnAdd = incrementalBuildConfig.contains(Aspect.REBUILD_ON_ADD);
+ final boolean rebuildOnChange = incrementalBuildConfig.contains(Aspect.REBUILD_ON_CHANGE);
+
+ /*
+ * Register all source files with BuildContext. Each file is individually registered so
+ * we respect the filtering already applied by PathFilter (includes/excludes, file kind).
+ * The Metadata provides the change status relative to the previous build.
+ */
+ final var allInputs = new ArrayList>(sourceFiles.size());
+ final var pathToSourceFile = new HashMap(sourceFiles.size() + sourceFiles.size() / 3);
+ for (SourceFile sf : sourceFiles) {
+ allInputs.add(buildContext.registerInput(sf.file));
+ pathToSourceFile.put(sf.file, sf);
+ }
+
+ /*
+ * Check dependency changes if the DEPENDENCIES aspect is active.
+ * Register JAR dependencies as BuildContext inputs so their content changes
+ * are tracked across builds. Directory dependencies are treated conservatively.
+ */
+ boolean dependencyChanged = false;
+ if (checkDepends) {
+ for (Collection deps : dependencies.values()) {
+ for (Path dep : deps) {
+ if (Files.isRegularFile(dep)) {
+ Metadata depMeta = buildContext.registerInput(dep);
+ if (depMeta.getStatus() != Status.UNMODIFIED) {
+ dependencyChanged = true;
+ }
+ }
+ }
}
- if (checkOptions && causeOfRebuild == null) {
- causeOfRebuild = incrementalBuild.optionChanges();
+ }
+
+ /*
+ * Determine what needs to be rebuilt. BuildContext escalation (from config or classpath
+ * change) reports all inputs as NEW or MODIFIED, which naturally leads to a full rebuild.
+ */
+ boolean hasNew = false;
+ boolean hasModified = false;
+ for (Metadata meta : allInputs) {
+ Status status = meta.getStatus();
+ if (status == Status.NEW) {
+ hasNew = true;
+ } else if (status == Status.MODIFIED) {
+ hasModified = true;
}
- if (causeOfRebuild != null) {
- if (!sourceFiles.isEmpty()) { // Avoid misleading message such as "all sources changed".
- logger.info(causeOfRebuild);
- }
- } else {
- isPartialBuild = true;
- sourceFiles = incrementalBuild.getModifiedSources();
- if (IncrementalBuild.isEmptyOrIgnorable(sourceFiles)) {
- incrementalBuildConfig.clear(); // Prevent this method to be executed twice.
- logger.info("Nothing to compile - all classes are up to date.");
- sourceFiles = List.of();
- return false;
+ }
+ boolean hasChanged = hasNew || hasModified;
+ boolean fullRebuild = dependencyChanged || (rebuildOnChange && hasModified) || (rebuildOnAdd && hasNew);
+
+ /*
+ * If BuildContext reports processing required but no current inputs are changed,
+ * this indicates either removed source files or some other state change.
+ * In either case, a full rebuild is warranted for correctness (remaining sources
+ * might reference the deleted classes). BuildContext's finalizeContext() will
+ * automatically clean up stale outputs from removed inputs.
+ */
+ if (!hasChanged && buildContext.isProcessingRequired()) {
+ fullRebuild = true;
+ hasChanged = true; // force compilation of all sources
+ }
+
+ if (!hasChanged) {
+ incrementalBuildConfig.clear();
+ logger.info("Nothing to compile - all classes are up to date.");
+ sourceFiles = List.of();
+ return false;
+ }
+
+ if (fullRebuild) {
+ // Full rebuild: compile all source files.
+ if (!sourceFiles.isEmpty()) {
+ if (dependencyChanged) {
+ logger.info("Recompiling all files because some dependencies changed.");
+ } else if (rebuildOnChange && hasModified) {
+ logger.info("Recompiling all files because at least one source file changed.");
+ } else if (rebuildOnAdd && hasNew) {
+ logger.info("Recompiling all files because of added source files.");
} else {
- int n = sourceFiles.size();
- var sb = new StringBuilder("Compiling ").append(n).append(" modified source file");
- if (n > 1) {
- sb.append('s'); // Make plural.
+ logger.info("Recompiling all files.");
+ }
+ }
+ } else {
+ // Partial build: determine which sources need compilation.
+ isPartialBuild = true;
+ for (Metadata meta : allInputs) {
+ if (meta.getStatus() != Status.UNMODIFIED) {
+ SourceFile sf = pathToSourceFile.get(meta.getPath());
+ if (sf != null) {
+ sf.isNewOrModified = true;
}
- logger.info(sb.append('.'));
}
}
- if (!(checkSources | checkDepends | checkOptions)) {
- incrementalBuild.deleteCache();
- incrementalBuild = null;
+ sourceFiles = sourceFiles.stream().filter(sf -> sf.isNewOrModified).toList();
+
+ if (sourceFiles.stream().allMatch(SourceFile::isEmptyOrIgnorable)) {
+ // All modified sources are empty or ignorable — no compilation needed.
+ // Do NOT call meta.process() so that markSkipExecution() can be called.
+ incrementalBuildConfig.clear();
+ logger.info("Nothing to compile - all classes are up to date.");
+ sourceFiles = List.of();
+ return false;
+ }
+ int n = sourceFiles.size();
+ var sb = new StringBuilder("Compiling ").append(n).append(" modified source file");
+ if (n > 1) {
+ sb.append('s');
+ }
+ logger.info(sb.append('.'));
+ }
+
+ /*
+ * Process inputs with BuildContext and build the map for output association.
+ * This step is deferred until after we confirm compilation will actually happen,
+ * because processing inputs prevents markSkipExecution() from being called later.
+ * For a full rebuild, all inputs are processed. For a partial rebuild, only
+ * changed inputs are processed — unchanged inputs' associations from the
+ * previous build are carried over automatically by BuildContext.
+ */
+ processedInputs = new HashMap<>();
+ for (Metadata meta : allInputs) {
+ if (fullRebuild || meta.getStatus() != Status.UNMODIFIED) {
+ Input input = meta.process();
+ processedInputs.put(input.getPath(), input);
}
}
- incrementalBuildConfig.clear(); // Prevent this method to be executed twice.
+ incrementalBuildConfig.clear();
return true;
}
/**
- * Writes the incremental build cache into the {@code target/maven-status/maven-compiler-plugin/} directory.
- * This method should be invoked only once. Next invocations after the first one have no effect.
+ * Associates compiled source files with their output class files in the build context.
+ * This enables the build context to track input-to-output relationships and automatically
+ * clean up stale outputs (including inner class files) when source files are removed.
*
- * @throws IOException if an error occurred while writing the cache
+ *
For each source file that was processed during this build, this method discovers the
+ * output class file and any inner class files ({@code Foo$Bar.class}, {@code Foo$1.class})
+ * by scanning the output directory. Each discovered class file is associated with the source
+ * file's {@link Input} handle via {@link Input#associateOutput(Path)}.
+ *
+ * @throws IOException if an error occurred while scanning the output directory
*/
- private void saveIncrementalBuild() throws IOException {
- if (incrementalBuild != null) {
- incrementalBuild.writeCache();
- incrementalBuild = null;
+ private void associateOutputs() throws IOException {
+ if (processedInputs == null || processedInputs.isEmpty()) {
+ return;
+ }
+ for (SourceFile sf : sourceFiles) {
+ Input input = processedInputs.get(sf.file);
+ if (input == null) {
+ continue;
+ }
+ Path classFile = sf.getOutputFile();
+ if (!Files.exists(classFile)) {
+ continue; // e.g. package-info.java with no output
+ }
+ // Associate the primary class file.
+ input.associateOutput(classFile);
+ // Discover and associate inner class files (Foo$Bar.class, Foo$1.class, etc.)
+ String className = classFile.getFileName().toString();
+ String prefix = className.substring(0, className.length() - SourceDirectory.CLASS_FILE_SUFFIX.length());
+ Path parentDir = classFile.getParent();
+ if (parentDir != null && Files.isDirectory(parentDir)) {
+ try (Stream siblings = Files.list(parentDir)) {
+ siblings.filter(p -> {
+ String name = p.getFileName().toString();
+ return name.startsWith(prefix)
+ && name.endsWith(SourceDirectory.CLASS_FILE_SUFFIX)
+ && !name.equals(className)
+ && name.charAt(prefix.length()) == '$';
+ })
+ .forEach(p -> input.associateOutput(p));
+ }
+ }
}
+ processedInputs = null;
}
/**
@@ -976,12 +1114,12 @@ public boolean compile(JavaCompiler compiler, final Options configuration, final
throw e.getCause();
}
- // Performs post-compilation tasks such as logging and writing incremental build cache.
+ // Performs post-compilation tasks such as logging and associating outputs with BuildContext.
if (listener instanceof DiagnosticLogger diagnostic) {
diagnostic.logSummary();
}
if (success) {
- saveIncrementalBuild();
+ associateOutputs();
}
return success;
}
diff --git a/src/test/java/org/apache/maven/plugin/compiler/CompilerMojoTestCase.java b/src/test/java/org/apache/maven/plugin/compiler/CompilerMojoTestCase.java
index e58e3ca8e..a8d5bab68 100644
--- a/src/test/java/org/apache/maven/plugin/compiler/CompilerMojoTestCase.java
+++ b/src/test/java/org/apache/maven/plugin/compiler/CompilerMojoTestCase.java
@@ -34,26 +34,32 @@
import org.apache.maven.api.PathScope;
import org.apache.maven.api.Project;
import org.apache.maven.api.Session;
+import org.apache.maven.api.build.context.BuildContext;
import org.apache.maven.api.di.Inject;
+import org.apache.maven.api.di.Priority;
import org.apache.maven.api.di.Provides;
import org.apache.maven.api.di.Singleton;
import org.apache.maven.api.model.Build;
import org.apache.maven.api.model.Model;
import org.apache.maven.api.plugin.Log;
-import org.apache.maven.api.plugin.testing.Basedir;
-import org.apache.maven.api.plugin.testing.InjectMojo;
-import org.apache.maven.api.plugin.testing.MojoExtension;
-import org.apache.maven.api.plugin.testing.MojoParameter;
-import org.apache.maven.api.plugin.testing.MojoTest;
-import org.apache.maven.api.plugin.testing.stubs.ProducedArtifactStub;
-import org.apache.maven.api.plugin.testing.stubs.ProjectStub;
-import org.apache.maven.api.plugin.testing.stubs.SessionMock;
import org.apache.maven.api.services.ArtifactManager;
import org.apache.maven.api.services.MessageBuilderFactory;
+import org.apache.maven.api.services.PathMatcherFactory;
import org.apache.maven.api.services.ToolchainManager;
import org.apache.maven.impl.DefaultMessageBuilderFactory;
+import org.apache.maven.impl.DefaultPathMatcherFactory;
import org.apache.maven.impl.InternalSession;
+import org.apache.maven.internal.build.context.impl.DefaultBuildContext;
+import org.apache.maven.internal.build.context.impl.FilesystemWorkspace;
import org.apache.maven.plugin.compiler.stubs.CompilerStub;
+import org.apache.maven.testing.plugin.Basedir;
+import org.apache.maven.testing.plugin.InjectMojo;
+import org.apache.maven.testing.plugin.MojoExtension;
+import org.apache.maven.testing.plugin.MojoParameter;
+import org.apache.maven.testing.plugin.MojoTest;
+import org.apache.maven.testing.plugin.stubs.ProducedArtifactStub;
+import org.apache.maven.testing.plugin.stubs.ProjectStub;
+import org.apache.maven.testing.plugin.stubs.SessionMock;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
@@ -200,6 +206,9 @@ public void testCompilerEmptySourceChangeDetection(
compileMojo.execute();
clearInvocations(log);
+ // In Maven runtime, each mojo execution gets a fresh BuildContext from DI.
+ // Simulate that here since the context is closed after the first execution.
+ compileMojo.buildContext = buildContext();
compileMojo.execute();
verify(log).info("Nothing to compile - all classes are up to date.");
}
@@ -495,4 +504,19 @@ private static Project createProject() {
stub.setBasedir(Path.of(MojoExtension.getBasedir()));
return stub;
}
+
+ @Provides
+ @Priority(10)
+ @SuppressWarnings("unused")
+ private static PathMatcherFactory pathMatcherFactory() {
+ return new DefaultPathMatcherFactory();
+ }
+
+ @Provides
+ @Priority(10)
+ @SuppressWarnings("unused")
+ private static BuildContext buildContext() {
+ return new DefaultBuildContext(
+ new FilesystemWorkspace(), null, new HashMap<>(), null, new DefaultPathMatcherFactory());
+ }
}