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-SNAPSHOT 9.10.1 7.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, DiagnosticListenerescalates — 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()); + } }