From 1e02718dce3afe8bedb45384cbaaa62474c11f7e Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Tue, 28 Jul 2026 23:27:16 +0200 Subject: [PATCH 01/16] Add incremental build context API and implementation from PR #1118 Port the incremental build context from PR #1118 to Maven 4 conventions: - API in org.apache.maven.api.build.context (avoids collision with build report API) - Core impl in maven-impl using Maven 4 DI (org.apache.maven.api.di) - Maven integration (MavenBuildContext, ProjectWorkspace, digest) stays in maven-core - Replace plexus-utils dependencies (MatchPatterns, CachingOutputStream) with pure Java - Add Maven 4 annotations (@Experimental, @Nonnull, @Nullable, @since 4.0.0) - All 41 tests passing in maven-impl Co-Authored-By: Claude Opus 4.6 --- .../maven/api/build/context/BuildContext.java | 138 +++ .../build/context/BuildContextException.java | 50 ++ .../maven/api/build/context/Incremental.java | 51 ++ .../apache/maven/api/build/context/Input.java | 47 + .../maven/api/build/context/InputSet.java | 105 +++ .../maven/api/build/context/Metadata.java | 59 ++ .../maven/api/build/context/Output.java | 65 ++ .../maven/api/build/context/Resource.java | 62 ++ .../maven/api/build/context/Severity.java | 38 + .../maven/api/build/context/Status.java | 52 ++ .../context/spi/BuildContextEnvironment.java | 65 ++ .../context/spi/BuildContextFinalizer.java | 43 + .../context/spi/CommitableBuildContext.java | 44 + .../api/build/context/spi/FileState.java | 120 +++ .../maven/api/build/context/spi/Message.java | 126 +++ .../maven/api/build/context/spi/Sink.java | 55 ++ .../api/build/context/spi/Workspace.java | 154 ++++ .../context/impl/maven/MavenBuildContext.java | 113 +++ .../maven/MavenBuildContextConfiguration.java | 112 +++ .../maven/MavenBuildContextFinalizer.java | 136 +++ .../context/impl/maven/ProjectWorkspace.java | 118 +++ .../context/impl/maven/digest/BytesHash.java | 52 ++ .../impl/maven/digest/ClasspathDigester.java | 176 ++++ .../context/impl/maven/digest/Digesters.java | 197 +++++ .../digest/MojoConfigurationDigester.java | 176 ++++ .../impl/maven/digest/SHA1Digester.java | 52 ++ impl/maven-impl/pom.xml | 5 + .../context/impl/DefaultBuildContext.java | 813 ++++++++++++++++++ .../impl/DefaultBuildContextState.java | 520 +++++++++++ .../build/context/impl/DefaultInput.java | 34 + .../context/impl/DefaultInputMetadata.java | 34 + .../build/context/impl/DefaultInputSet.java | 83 ++ .../build/context/impl/DefaultMetadata.java | 73 ++ .../build/context/impl/DefaultOutput.java | 35 + .../context/impl/DefaultOutputMetadata.java | 34 + .../build/context/impl/DefaultResource.java | 78 ++ .../build/context/impl/FileMatcher.java | 364 ++++++++ .../build/context/impl/FileState.java | 100 +++ .../context/impl/FilesystemWorkspace.java | 114 +++ .../impl/AbstractBuildContextTest.java | 60 ++ .../DefaultAggregatorBuildContextTest.java | 181 ++++ .../impl/DefaultBasicBuildContextTest.java | 57 ++ .../impl/DefaultBuildContextStateTest.java | 85 ++ .../context/impl/DefaultBuildContextTest.java | 605 +++++++++++++ .../build/context/impl/DefaultOutputTest.java | 52 ++ .../context/impl/DeltaWorkspaceTest.java | 289 +++++++ .../context/impl/ResourceMessagesTest.java | 167 ++++ .../internal/build/context/impl/Snippets.java | 137 +++ .../build/context/impl/TestBuildContext.java | 89 ++ .../src/test/projects/dummy-1.0.jar | Bin 0 -> 1902 bytes .../src/test/projects/dummy/dummy-1.0.jar | Bin 0 -> 1902 bytes .../src/test/projects/dummy/pom.xml | 15 + .../dummy/src/main/java/dummy/Dummy.java | 7 + impl/maven-impl/src/test/projects/pom.xml | 15 + .../projects/src/main/java/dummy/Dummy.java | 7 + .../test/resources/simplelogger.properties | 18 + 56 files changed, 6477 insertions(+) create mode 100644 api/maven-api-core/src/main/java/org/apache/maven/api/build/context/BuildContext.java create mode 100644 api/maven-api-core/src/main/java/org/apache/maven/api/build/context/BuildContextException.java create mode 100644 api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Incremental.java create mode 100644 api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Input.java create mode 100644 api/maven-api-core/src/main/java/org/apache/maven/api/build/context/InputSet.java create mode 100644 api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Metadata.java create mode 100644 api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Output.java create mode 100644 api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Resource.java create mode 100644 api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Severity.java create mode 100644 api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Status.java create mode 100644 api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/BuildContextEnvironment.java create mode 100644 api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/BuildContextFinalizer.java create mode 100644 api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/CommitableBuildContext.java create mode 100644 api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/FileState.java create mode 100644 api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/Message.java create mode 100644 api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/Sink.java create mode 100644 api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/Workspace.java create mode 100644 impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/MavenBuildContext.java create mode 100644 impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/MavenBuildContextConfiguration.java create mode 100644 impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/MavenBuildContextFinalizer.java create mode 100644 impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/ProjectWorkspace.java create mode 100644 impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/digest/BytesHash.java create mode 100644 impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/digest/ClasspathDigester.java create mode 100644 impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/digest/Digesters.java create mode 100644 impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/digest/MojoConfigurationDigester.java create mode 100644 impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/digest/SHA1Digester.java create mode 100644 impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultBuildContext.java create mode 100644 impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultBuildContextState.java create mode 100644 impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultInput.java create mode 100644 impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultInputMetadata.java create mode 100644 impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultInputSet.java create mode 100644 impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultMetadata.java create mode 100644 impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultOutput.java create mode 100644 impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultOutputMetadata.java create mode 100644 impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultResource.java create mode 100644 impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/FileMatcher.java create mode 100644 impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/FileState.java create mode 100644 impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/FilesystemWorkspace.java create mode 100644 impl/maven-impl/src/test/java/org/apache/maven/internal/build/context/impl/AbstractBuildContextTest.java create mode 100644 impl/maven-impl/src/test/java/org/apache/maven/internal/build/context/impl/DefaultAggregatorBuildContextTest.java create mode 100644 impl/maven-impl/src/test/java/org/apache/maven/internal/build/context/impl/DefaultBasicBuildContextTest.java create mode 100644 impl/maven-impl/src/test/java/org/apache/maven/internal/build/context/impl/DefaultBuildContextStateTest.java create mode 100644 impl/maven-impl/src/test/java/org/apache/maven/internal/build/context/impl/DefaultBuildContextTest.java create mode 100644 impl/maven-impl/src/test/java/org/apache/maven/internal/build/context/impl/DefaultOutputTest.java create mode 100644 impl/maven-impl/src/test/java/org/apache/maven/internal/build/context/impl/DeltaWorkspaceTest.java create mode 100644 impl/maven-impl/src/test/java/org/apache/maven/internal/build/context/impl/ResourceMessagesTest.java create mode 100644 impl/maven-impl/src/test/java/org/apache/maven/internal/build/context/impl/Snippets.java create mode 100644 impl/maven-impl/src/test/java/org/apache/maven/internal/build/context/impl/TestBuildContext.java create mode 100644 impl/maven-impl/src/test/projects/dummy-1.0.jar create mode 100644 impl/maven-impl/src/test/projects/dummy/dummy-1.0.jar create mode 100644 impl/maven-impl/src/test/projects/dummy/pom.xml create mode 100644 impl/maven-impl/src/test/projects/dummy/src/main/java/dummy/Dummy.java create mode 100644 impl/maven-impl/src/test/projects/pom.xml create mode 100644 impl/maven-impl/src/test/projects/src/main/java/dummy/Dummy.java create mode 100644 impl/maven-impl/src/test/resources/simplelogger.properties diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/BuildContext.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/BuildContext.java new file mode 100644 index 000000000000..dac07135867d --- /dev/null +++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/BuildContext.java @@ -0,0 +1,138 @@ +/* + * 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.api.build.context; + +import java.nio.file.Path; +import java.util.Collection; + +import org.apache.maven.api.annotations.Experimental; +import org.apache.maven.api.annotations.Nonnull; +import org.apache.maven.api.annotations.NotThreadSafe; +import org.apache.maven.api.annotations.Nullable; +import org.apache.maven.api.annotations.Provider; + +/** + * Provides incremental build support by tracking inputs, outputs and their relationships. + *

+ * A first use case is a basic build where inputs and outputs are identified but without + * any relationship between those. For such cases, the code would look like: + *

+ *     context.registerInput(path1);
+ *     context.registerInput(path2);
+ * 
+ *

+ * A second use case is an aggregated build where multiple inputs contribute to a single + * output. Use {@link #newInputSet()} to create an {@link InputSet} and register inputs that are then + * aggregated into outputs. + * + * @since 4.0.0 + */ +@Experimental +@NotThreadSafe +@Provider +public interface BuildContext { + + /** + * {@return whether the build needs to process any inputs} + */ + boolean isProcessingRequired(); + + /** + * Registers and processes the given output file. + * + * @param outputFile the output file path + * @return the processed output resource + */ + @Nonnull + Output processOutput(@Nonnull Path outputFile); + + /** + * Creates a new {@link InputSet} which can be used to associate inputs to outputs. + * + * @return a new input set, never {@code null} + */ + @Nonnull + InputSet newInputSet(); + + /** + * Registers the specified input {@code Path} with this build context. + * + * @param inputFile the input file to register + * @return the metadata representing the input file, never {@code null} + * @throws IllegalArgumentException if {@code inputFile} is not a file or cannot be read + */ + @Nonnull + Metadata registerInput(@Nonnull Path inputFile); + + /** + * Registers inputs identified by {@code basedir} and {@code includes}/{@code excludes} ant + * patterns. + *

+ * When a file is found under {@code basedir}, it will be registered if it does not match + * {@code excludes} patterns and matches {@code includes} patterns. {@code null} or empty includes + * parameter will match all files. {@code excludes} match takes precedence over {@code includes}: + * if a file matches one of the excludes patterns it will not be registered regardless of includes + * patterns match. + *

+ * The implementation is not expected to handle changes to {@code basedir}, {@code includes} or + * {@code excludes} incrementally. + * + * @param basedir the base directory to look for inputs, must not be {@code null} + * @param includes patterns of the files to register, may be {@code null} + * @param excludes patterns of the files to ignore, may be {@code null} + * @return the metadata for the registered inputs + * @throws BuildContextException if an I/O error occurs + */ + @Nonnull + Collection> registerInputs( + @Nonnull Path basedir, @Nullable Collection includes, @Nullable Collection excludes); + + /** + * Registers inputs identified by {@code basedir} and {@code includes}/{@code excludes} ant + * patterns, then processes inputs that are new or modified since the previous build. + * + * @param basedir the base directory to look for inputs, must not be {@code null} + * @param includes patterns of the files to register, may be {@code null} + * @param excludes patterns of the files to ignore, may be {@code null} + * @return the processed inputs + * @throws BuildContextException if an I/O error occurs + */ + @Nonnull + Collection registerAndProcessInputs( + @Nonnull Path basedir, @Nullable Collection includes, @Nullable Collection excludes); + + /** + * Marks skipped build execution. All inputs, outputs and their associated metadata are carried + * over to the next build as-is. No context modification operations ({@code register*} or + * {@code process}) are permitted after this call. + */ + void markSkipExecution(); + + /** + * Sets whether the build should continue even if there are build errors. + * + * @param failOnError {@code true} to fail on error, {@code false} to continue + */ + void setFailOnError(boolean failOnError); + + /** + * {@return whether the build should fail on error} + */ + boolean getFailOnError(); +} diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/BuildContextException.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/BuildContextException.java new file mode 100644 index 000000000000..e3951420da62 --- /dev/null +++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/BuildContextException.java @@ -0,0 +1,50 @@ +/* + * 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.api.build.context; + +import java.io.Serial; + +import org.apache.maven.api.annotations.Experimental; +import org.apache.maven.api.services.MavenException; + +/** + * Exception thrown when an error occurs during incremental build context operations. + * + * @since 4.0.0 + */ +@Experimental +public class BuildContextException extends MavenException { + + @Serial + private static final long serialVersionUID = 1L; + + public BuildContextException() {} + + public BuildContextException(String message) { + super(message); + } + + public BuildContextException(String message, Throwable cause) { + super(message, cause); + } + + public BuildContextException(Throwable cause) { + super(cause); + } +} diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Incremental.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Incremental.java new file mode 100644 index 000000000000..4251f1034e08 --- /dev/null +++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Incremental.java @@ -0,0 +1,51 @@ +/* + * 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.api.build.context; + +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import org.apache.maven.api.annotations.Experimental; + +import static java.lang.annotation.ElementType.FIELD; +import static java.lang.annotation.ElementType.METHOD; +import static java.lang.annotation.ElementType.PARAMETER; +import static java.lang.annotation.ElementType.TYPE; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +/** + * Optional annotation that customizes how the incremental build implementation handles + * configuration parameters. + *

+ * This annotation is mandatory on {@link org.apache.maven.api.Project} + * and {@link org.apache.maven.api.Session} attributes to indicate whether + * they should be considered for change detection. + * + * @since 4.0.0 + */ +@Experimental +@Retention(RUNTIME) +@Target({FIELD, METHOD, PARAMETER, TYPE}) +public @interface Incremental { + + /** + * {@return whether to consider (the default) or ignore the annotated configuration parameter} + */ + boolean consider() default true; +} diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Input.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Input.java new file mode 100644 index 000000000000..3f00ef71c0d8 --- /dev/null +++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Input.java @@ -0,0 +1,47 @@ +/* + * 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.api.build.context; + +import java.nio.file.Path; + +import org.apache.maven.api.annotations.Experimental; +import org.apache.maven.api.annotations.Nonnull; +import org.apache.maven.api.annotations.NotThreadSafe; +import org.apache.maven.api.annotations.Provider; + +/** + * Represents an input resource in the incremental build context. + * An input can be associated with one or more {@link Output} resources. + * + * @since 4.0.0 + */ +@Experimental +@NotThreadSafe +@Provider +public interface Input extends Resource { + + /** + * Associates this input with the given output file and returns the output resource. + * + * @param outputFile the path of the output file to associate + * @return the associated output resource + */ + @Nonnull + Output associateOutput(@Nonnull Path outputFile); +} diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/InputSet.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/InputSet.java new file mode 100644 index 000000000000..b06a7acdef87 --- /dev/null +++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/InputSet.java @@ -0,0 +1,105 @@ +/* + * 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.api.build.context; + +import java.io.Serializable; +import java.nio.file.Path; +import java.util.Collection; +import java.util.function.BiConsumer; +import java.util.function.BinaryOperator; +import java.util.function.Function; + +import org.apache.maven.api.annotations.Experimental; +import org.apache.maven.api.annotations.Nonnull; +import org.apache.maven.api.annotations.NotThreadSafe; +import org.apache.maven.api.annotations.Nullable; +import org.apache.maven.api.annotations.Provider; + +/** + * Represents a set of inputs being aggregated into one or more outputs. + *

+ * An input set allows registering multiple input files and then aggregating them + * into an output file, with automatic change detection across builds. + * + * @since 4.0.0 + */ +@Experimental +@NotThreadSafe +@Provider +public interface InputSet { + + /** + * Adds a previously registered input to this set. + * + * @param inputMetadata the input metadata to add + */ + void addInput(@Nonnull Metadata inputMetadata); + + /** + * Registers an input file and adds it to this set. + * + * @param inputFile the input file to register + * @return the metadata for the registered input + */ + @Nonnull + Metadata registerInput(@Nonnull Path inputFile); + + /** + * Registers input files matching the given patterns and adds them to this set. + * + * @param basedir the base directory to scan for inputs + * @param includes patterns of files to include, may be {@code null} to match all + * @param excludes patterns of files to exclude, may be {@code null} + * @return the metadata for all registered inputs + */ + @Nonnull + Collection> registerInputs( + @Nonnull Path basedir, @Nullable Collection includes, @Nullable Collection excludes); + + /** + * Aggregates all registered inputs into the given output file. + * + * @param outputFile the output file to write + * @param aggregator a consumer that receives the output and the collection of inputs + * @return {@code true} if the output was written, {@code false} if it was up-to-date + */ + boolean aggregate(@Nonnull Path outputFile, @Nonnull BiConsumer> aggregator); + + /** + * Performs an indirect metadata aggregation. The metadata for each input file is cached + * across builds, avoiding recomputation when an input has not changed. + * + * @param outputFile the output file to write + * @param stepId a unique identifier for this aggregation step + * @param identity the identity value for the accumulator + * @param mapper extracts metadata from each input + * @param accumulator combines metadata values + * @param writer writes the accumulated result to the output + * @param the metadata type, must be {@link Serializable} + * @return {@code true} if the output was rewritten, {@code false} if it was up-to-date + * @throws BuildContextException if an error occurs + */ + boolean aggregate( + @Nonnull Path outputFile, + @Nonnull String stepId, + @Nonnull T identity, + @Nonnull Function mapper, + @Nonnull BinaryOperator accumulator, + @Nonnull BiConsumer writer); +} diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Metadata.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Metadata.java new file mode 100644 index 000000000000..e7b4900e440b --- /dev/null +++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Metadata.java @@ -0,0 +1,59 @@ +/* + * 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.api.build.context; + +import java.nio.file.Path; + +import org.apache.maven.api.annotations.Experimental; +import org.apache.maven.api.annotations.Nonnull; +import org.apache.maven.api.annotations.NotThreadSafe; +import org.apache.maven.api.annotations.Provider; + +/** + * Wraps a registered resource with its file path and change status, + * and provides a {@link #process()} method to obtain the full resource handle. + * + * @param the resource type ({@link Input} or {@link Output}) + * @since 4.0.0 + */ +@Experimental +@NotThreadSafe +@Provider +public interface Metadata { + + /** + * {@return the path of the registered resource} + */ + @Nonnull + Path getPath(); + + /** + * {@return the change status of the resource relative to the previous build} + */ + @Nonnull + Status getStatus(); + + /** + * Marks this resource for processing and returns the full resource handle. + * + * @return the resource to process + */ + @Nonnull + R process(); +} diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Output.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Output.java new file mode 100644 index 000000000000..70d9a6e4a5e5 --- /dev/null +++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Output.java @@ -0,0 +1,65 @@ +/* + * 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.api.build.context; + +import java.io.BufferedWriter; +import java.io.OutputStream; +import java.io.OutputStreamWriter; +import java.nio.charset.Charset; + +import org.apache.maven.api.annotations.Experimental; +import org.apache.maven.api.annotations.Nonnull; +import org.apache.maven.api.annotations.NotThreadSafe; +import org.apache.maven.api.annotations.Provider; + +/** + * Represents an output resource in the incremental build context. + *

+ * The output stream returned by {@link #newOutputStream()} may be a caching stream that only + * overwrites the target file when the content has actually changed, helping IDEs and downstream + * tools avoid unnecessary rebuilds. + * + * @since 4.0.0 + */ +@Experimental +@NotThreadSafe +@Provider +public interface Output extends Resource { + + /** + * Returns a new caching output stream for this output resource. + * + * @return a new output stream, never {@code null} + * @throws BuildContextException if an I/O error occurs + */ + @Nonnull + OutputStream newOutputStream(); + + /** + * Returns a new buffered writer for this output resource using the given charset. + * + * @param charset the charset to use for encoding + * @return a new buffered writer, never {@code null} + * @throws BuildContextException if an I/O error occurs + */ + @Nonnull + default BufferedWriter newBufferedWriter(@Nonnull Charset charset) { + return new BufferedWriter(new OutputStreamWriter(newOutputStream(), charset)); + } +} diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Resource.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Resource.java new file mode 100644 index 000000000000..6002f98562a0 --- /dev/null +++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Resource.java @@ -0,0 +1,62 @@ +/* + * 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.api.build.context; + +import java.nio.file.Path; + +import org.apache.maven.api.annotations.Experimental; +import org.apache.maven.api.annotations.Nonnull; +import org.apache.maven.api.annotations.NotThreadSafe; +import org.apache.maven.api.annotations.Nullable; +import org.apache.maven.api.annotations.Provider; + +/** + * Represents a build resource (input or output file) tracked by the incremental build context. + * + * @since 4.0.0 + */ +@Experimental +@NotThreadSafe +@Provider +public interface Resource { + + /** + * {@return the path of this resource} + */ + @Nonnull + Path getPath(); + + /** + * {@return the change status of this resource relative to the previous build} + */ + @Nonnull + Status getStatus(); + + /** + * Attaches a diagnostic message to this resource at the given source location. + * + * @param line the 1-based line number, or {@code 0} if unknown + * @param column the 1-based column number, or {@code 0} if unknown + * @param message the human-readable message text + * @param severity the severity level + * @param cause the underlying cause, or {@code null} + */ + void addMessage( + int line, int column, @Nonnull String message, @Nonnull Severity severity, @Nullable Throwable cause); +} diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Severity.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Severity.java new file mode 100644 index 000000000000..dd0236afc7ee --- /dev/null +++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Severity.java @@ -0,0 +1,38 @@ +/* + * 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.api.build.context; + +import org.apache.maven.api.annotations.Experimental; +import org.apache.maven.api.annotations.Immutable; + +/** + * Severity level for build messages attached to resources. + * + * @since 4.0.0 + */ +@Experimental +@Immutable +public enum Severity { + /** An error that should fail the build (unless fail-on-error is disabled). */ + ERROR, + /** A warning that does not fail the build. */ + WARNING, + /** An informational message. */ + INFO +} diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Status.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Status.java new file mode 100644 index 000000000000..a83592de3033 --- /dev/null +++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Status.java @@ -0,0 +1,52 @@ +/* + * 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.api.build.context; + +import org.apache.maven.api.annotations.Experimental; +import org.apache.maven.api.annotations.Immutable; + +/** + * Indicates the change status of a resource between the current and previous build. + * + * @since 4.0.0 + */ +@Experimental +@Immutable +public enum Status { + + /** + * Resource is new in this build, i.e. it was not present in the previous build. + */ + NEW, + + /** + * Resource changed since the previous build. + */ + MODIFIED, + + /** + * Resource did not change since the previous build. + */ + UNMODIFIED, + + /** + * Resource was removed since the previous build. + */ + REMOVED +} diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/BuildContextEnvironment.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/BuildContextEnvironment.java new file mode 100644 index 000000000000..e7ae06411905 --- /dev/null +++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/BuildContextEnvironment.java @@ -0,0 +1,65 @@ +/* + * 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.api.build.context.spi; + +import java.io.Serializable; +import java.nio.file.Path; +import java.util.Map; + +import org.apache.maven.api.annotations.Consumer; +import org.apache.maven.api.annotations.Experimental; +import org.apache.maven.api.annotations.Nonnull; +import org.apache.maven.api.annotations.Nullable; +import org.apache.maven.api.annotations.ThreadSafe; + +/** + * Provides the environment configuration needed to initialize a build context, + * including state file location, workspace, parameters, and an optional finalizer. + * + * @since 4.0.0 + */ +@Experimental +@ThreadSafe +@Consumer +public interface BuildContextEnvironment { + + /** + * {@return the path to the file where build context state is persisted} + */ + @Nonnull + Path getStateFile(); + + /** + * {@return the workspace that provides file system abstraction} + */ + @Nonnull + Workspace getWorkspace(); + + /** + * {@return the configuration parameters for the build context} + */ + @Nonnull + Map getParameters(); + + /** + * {@return the optional context finalizer, or {@code null} if none is configured} + */ + @Nullable + BuildContextFinalizer getFinalizer(); +} diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/BuildContextFinalizer.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/BuildContextFinalizer.java new file mode 100644 index 000000000000..8c96811e59b5 --- /dev/null +++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/BuildContextFinalizer.java @@ -0,0 +1,43 @@ +/* + * 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.api.build.context.spi; + +import org.apache.maven.api.annotations.Consumer; +import org.apache.maven.api.annotations.Experimental; +import org.apache.maven.api.annotations.Nonnull; +import org.apache.maven.api.annotations.ThreadSafe; + +/** + * Callback interface for registering build contexts that should be committed + * at the end of a mojo execution. + * + * @since 4.0.0 + */ +@Experimental +@ThreadSafe +@Consumer +public interface BuildContextFinalizer { + + /** + * Registers a build context to be committed when the mojo execution completes. + * + * @param context the build context to register + */ + void registerContext(@Nonnull CommitableBuildContext context); +} diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/CommitableBuildContext.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/CommitableBuildContext.java new file mode 100644 index 000000000000..2869db964b9d --- /dev/null +++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/CommitableBuildContext.java @@ -0,0 +1,44 @@ +/* + * 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.api.build.context.spi; + +import org.apache.maven.api.annotations.Experimental; +import org.apache.maven.api.annotations.Nonnull; +import org.apache.maven.api.annotations.NotThreadSafe; +import org.apache.maven.api.annotations.Provider; +import org.apache.maven.api.build.context.BuildContext; + +/** + * Extended {@link BuildContext} that supports committing state changes and + * reporting messages through a {@link Sink}. + * + * @since 4.0.0 + */ +@Experimental +@NotThreadSafe +@Provider +public interface CommitableBuildContext extends BuildContext { + + /** + * Commits all changes in this build context and reports messages to the given sink. + * + * @param sink the sink that receives build messages + */ + void commit(@Nonnull Sink sink); +} diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/FileState.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/FileState.java new file mode 100644 index 000000000000..07bfd2e09499 --- /dev/null +++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/FileState.java @@ -0,0 +1,120 @@ +/* + * 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.api.build.context.spi; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.BasicFileAttributes; +import java.nio.file.attribute.FileTime; + +import org.apache.maven.api.annotations.Experimental; +import org.apache.maven.api.annotations.Immutable; +import org.apache.maven.api.annotations.Nonnull; +import org.apache.maven.api.annotations.Nullable; +import org.apache.maven.api.build.context.BuildContextException; +import org.apache.maven.api.build.context.Status; + +/** + * Immutable snapshot of a file's state (path, last-modified time, size) and its + * change {@link Status} relative to the previous build. + * + * @since 4.0.0 + */ +@Experimental +@Immutable +public final class FileState { + + private final Path path; + private final FileTime lastModified; + private final long size; + private final Status status; + + /** + * Creates a file state with explicit attributes. + * + * @param path the file path + * @param lastModified the last-modified time, or {@code null} for removed files + * @param size the file size in bytes + * @param status the change status + */ + public FileState(@Nonnull Path path, @Nullable FileTime lastModified, long size, @Nonnull Status status) { + this.path = path; + this.lastModified = lastModified; + this.size = size; + this.status = status; + } + + /** + * Creates a file state by reading attributes from the file system. + * For {@link Status#REMOVED} files, the last-modified time is set to {@code null} + * and the size to {@code 0}. + * + * @param path the file path + * @param status the change status + * @throws BuildContextException if the file attributes cannot be read + */ + public FileState(@Nonnull Path path, @Nonnull Status status) { + this.path = path; + this.status = status; + if (status == Status.REMOVED) { + lastModified = null; + size = 0; + } else { + try { + BasicFileAttributes attrs = Files.readAttributes(path, BasicFileAttributes.class); + this.lastModified = attrs.lastModifiedTime(); + this.size = attrs.size(); + } catch (IOException e) { + throw new BuildContextException(e); + } + } + } + + /** + * {@return the file path} + */ + @Nonnull + public Path getPath() { + return path; + } + + /** + * {@return the last-modified time, or {@code null} for removed files} + */ + @Nullable + public FileTime getLastModified() { + return lastModified; + } + + /** + * {@return the file size in bytes} + */ + public long getSize() { + return size; + } + + /** + * {@return the change status relative to the previous build} + */ + @Nonnull + public Status getStatus() { + return status; + } +} diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/Message.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/Message.java new file mode 100644 index 000000000000..6d2116b0c27b --- /dev/null +++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/Message.java @@ -0,0 +1,126 @@ +/* + * 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.api.build.context.spi; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Objects; + +import org.apache.maven.api.annotations.Experimental; +import org.apache.maven.api.annotations.Immutable; +import org.apache.maven.api.annotations.Nonnull; +import org.apache.maven.api.annotations.Nullable; +import org.apache.maven.api.build.context.Severity; + +/** + * An immutable diagnostic message attached to a build resource. + * + * @since 4.0.0 + */ +@Experimental +@Immutable +public class Message implements Serializable { + + @Serial + private static final long serialVersionUID = 7798138299696868415L; + + private final int line; + private final int column; + private final String message; + private final Severity severity; + private final Throwable cause; + private final int hashCode; + + /** + * Creates a new message. + * + * @param line the 1-based line number, or {@code 0} if unknown + * @param column the 1-based column number, or {@code 0} if unknown + * @param message the human-readable message text + * @param severity the severity level + * @param cause the underlying cause, or {@code null} + */ + public Message( + int line, int column, @Nonnull String message, @Nonnull Severity severity, @Nullable Throwable cause) { + this.line = line; + this.column = column; + this.message = message; + this.severity = severity; + this.cause = cause; + this.hashCode = Objects.hash(line, column, message, severity, cause); + } + + /** + * {@return the 1-based line number, or {@code 0} if unknown} + */ + public int getLine() { + return line; + } + + /** + * {@return the 1-based column number, or {@code 0} if unknown} + */ + public int getColumn() { + return column; + } + + /** + * {@return the human-readable message text} + */ + @Nonnull + public String getMessage() { + return message; + } + + /** + * {@return the severity level} + */ + @Nonnull + public Severity getSeverity() { + return severity; + } + + /** + * {@return the underlying cause, or {@code null}} + */ + @Nullable + public Throwable getCause() { + return cause; + } + + @Override + public int hashCode() { + return hashCode; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (!(obj instanceof Message other)) { + return false; + } + return line == other.line + && column == other.column + && Objects.equals(message, other.message) + && Objects.equals(severity, other.severity) + && Objects.equals(cause, other.cause); + } +} diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/Sink.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/Sink.java new file mode 100644 index 000000000000..756235ecd079 --- /dev/null +++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/Sink.java @@ -0,0 +1,55 @@ +/* + * 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.api.build.context.spi; + +import java.nio.file.Path; +import java.util.Collection; + +import org.apache.maven.api.annotations.Consumer; +import org.apache.maven.api.annotations.Experimental; +import org.apache.maven.api.annotations.Nonnull; +import org.apache.maven.api.annotations.NotThreadSafe; + +/** + * Receives diagnostic messages produced during a build context commit. + * Implementations typically forward these to the IDE or build log. + * + * @since 4.0.0 + */ +@Experimental +@NotThreadSafe +@Consumer +public interface Sink { + + /** + * Reports messages for a resource. + * + * @param resource the resource path the messages belong to + * @param isNew {@code true} if the resource is new in this build + * @param messages the diagnostic messages to report + */ + void messages(@Nonnull Path resource, boolean isNew, @Nonnull Collection messages); + + /** + * Clears all previously reported messages for a resource. + * + * @param resource the resource path to clear messages for + */ + void clear(@Nonnull Path resource); +} diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/Workspace.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/Workspace.java new file mode 100644 index 000000000000..343861f29a21 --- /dev/null +++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/Workspace.java @@ -0,0 +1,154 @@ +/* + * 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.api.build.context.spi; + +import java.io.OutputStream; +import java.nio.file.Path; +import java.nio.file.attribute.FileTime; +import java.util.stream.Stream; + +import org.apache.maven.api.annotations.Consumer; +import org.apache.maven.api.annotations.Experimental; +import org.apache.maven.api.annotations.Nonnull; +import org.apache.maven.api.annotations.ThreadSafe; +import org.apache.maven.api.build.context.Status; + +/** + * Provides a layer of indirection between the {@link org.apache.maven.api.build.context.BuildContext} + * and the underlying file store. + *

+ * IDE integrations typically supply a workspace implementation that is aware of the IDE's + * virtual file system, while command-line builds use a direct filesystem workspace. + * + * @since 4.0.0 + */ +@Experimental +@ThreadSafe +@Consumer +public interface Workspace { + + /** + * {@return the current workspace mode} + */ + @Nonnull + Mode getMode(); + + /** + * Returns an escalated view of this workspace, where all files are treated as new. + * + * @return the escalated workspace + */ + @Nonnull + Workspace escalate(); + + /** + * {@return {@code true} if the file exists in this workspace} + * + * @param file the file path to check + */ + boolean isPresent(@Nonnull Path file); + + /** + * {@return {@code true} if the path is a regular file in this workspace} + * + * @param file the file path to check + */ + boolean isRegularFile(@Nonnull Path file); + + /** + * {@return {@code true} if the path is a directory in this workspace} + * + * @param file the file path to check + */ + boolean isDirectory(@Nonnull Path file); + + /** + * Deletes the specified file from this workspace. + * + * @param file the file to delete + * @throws org.apache.maven.api.build.context.BuildContextException if an I/O error occurs + */ + void deleteFile(@Nonnull Path file); + + /** + * Notifies the workspace that the given output path has been processed. + * + * @param path the output path + */ + void processOutput(@Nonnull Path path); + + /** + * Returns an output stream for the specified file. The workspace may optimize this + * using a caching stream that only overwrites the file when the content changes. + * + * @param path the file to write to + * @return a new output stream + * @throws org.apache.maven.api.build.context.BuildContextException if an I/O error occurs + */ + @Nonnull + OutputStream newOutputStream(@Nonnull Path path); + + /** + * Determines the resource status based on its last-modified time and size. + * + * @param file the file to check + * @param lastModified the previously recorded last-modified time + * @param size the previously recorded file size + * @return the change status + */ + @Nonnull + Status getResourceStatus(@Nonnull Path file, @Nonnull FileTime lastModified, long size); + + /** + * Walks a file tree rooted at the given directory. The files visited and their status + * depend on the workspace {@link Mode}: + *

+ * + * @param basedir the root directory to walk + * @return a stream of file states + * @throws org.apache.maven.api.build.context.BuildContextException if an I/O error occurs + */ + @Nonnull + Stream walk(@Nonnull Path basedir); + + /** + * The workspace operating mode. + * + * @since 4.0.0 + */ + enum Mode { + /** Normal mode — the build context determines resource status. */ + NORMAL, + /** Delta mode — only changed files are visited. */ + DELTA, + /** Escalated mode — all files treated as new (full rebuild). */ + ESCALATED, + /** Suppressed mode — configuration-only build, no outputs expected. */ + SUPPRESSED + } +} diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/MavenBuildContext.java b/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/MavenBuildContext.java new file mode 100644 index 000000000000..79a4e40db109 --- /dev/null +++ b/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/MavenBuildContext.java @@ -0,0 +1,113 @@ +/* + * 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.internal.build.context.impl.maven; + +import javax.inject.Inject; +import javax.inject.Named; +import javax.inject.Provider; + +import java.nio.file.Path; +import java.util.Collection; + +import org.apache.maven.api.build.context.InputSet; +import org.apache.maven.api.build.context.spi.BuildContextEnvironment; +import org.apache.maven.api.build.context.spi.CommitableBuildContext; +import org.apache.maven.api.build.context.spi.Sink; +import org.apache.maven.execution.scope.MojoExecutionScoped; +import org.apache.maven.internal.build.context.impl.DefaultBuildContext; +import org.apache.maven.internal.build.context.impl.DefaultInput; +import org.apache.maven.internal.build.context.impl.DefaultInputMetadata; +import org.apache.maven.internal.build.context.impl.DefaultOutput; +import org.eclipse.sisu.Typed; + +@Named +public class MavenBuildContext implements CommitableBuildContext { + + private final Provider provider; + + @Inject + public MavenBuildContext(Provider delegate) { + this.provider = delegate; + } + + MojoExecutionScopedBuildContext getDelegate() { + return provider.get(); + } + + public boolean getFailOnError() { + return getDelegate().getFailOnError(); + } + + @Override + public boolean isProcessingRequired() { + return getDelegate().isProcessingRequired(); + } + + @Override + public DefaultOutput processOutput(Path outputFile) { + return getDelegate().processOutput(outputFile); + } + + @Override + public InputSet newInputSet() { + return getDelegate().newInputSet(); + } + + @Override + public DefaultInputMetadata registerInput(Path inputFile) { + return getDelegate().registerInput(inputFile); + } + + @Override + public Collection registerInputs( + Path basedir, Collection includes, Collection excludes) { + return getDelegate().registerInputs(basedir, includes, excludes); + } + + @Override + public Collection registerAndProcessInputs( + Path basedir, Collection includes, Collection excludes) { + return getDelegate().registerAndProcessInputs(basedir, includes, excludes); + } + + @Override + public void markSkipExecution() { + getDelegate().markSkipExecution(); + } + + @Override + public void setFailOnError(boolean failOnError) { + getDelegate().setFailOnError(failOnError); + } + + @Override + public void commit(Sink sink) { + getDelegate().commit(sink); + } + + @Named + @Typed(MojoExecutionScopedBuildContext.class) + @MojoExecutionScoped + public static class MojoExecutionScopedBuildContext extends DefaultBuildContext { + @Inject + public MojoExecutionScopedBuildContext(BuildContextEnvironment configuration) { + super(configuration); + } + } +} diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/MavenBuildContextConfiguration.java b/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/MavenBuildContextConfiguration.java new file mode 100644 index 000000000000..c7c34da347d8 --- /dev/null +++ b/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/MavenBuildContextConfiguration.java @@ -0,0 +1,112 @@ +/* + * 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.internal.build.context.impl.maven; + +import javax.inject.Inject; +import javax.inject.Named; + +import java.io.IOException; +import java.io.Serializable; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Map; + +import org.apache.maven.api.MojoExecution; +import org.apache.maven.api.Project; +import org.apache.maven.api.build.context.spi.BuildContextEnvironment; +import org.apache.maven.api.build.context.spi.BuildContextFinalizer; +import org.apache.maven.api.build.context.spi.Workspace; +import org.apache.maven.api.plugin.descriptor.PluginDescriptor; +import org.apache.maven.execution.scope.MojoExecutionScoped; +import org.apache.maven.internal.build.context.impl.maven.digest.MojoConfigurationDigester; + +@Named +@MojoExecutionScoped +public class MavenBuildContextConfiguration implements BuildContextEnvironment { + + private final ProjectWorkspace workspace; + private final Path stateFile; + private final Map parameters; + private final MavenBuildContextFinalizer finalizer; + + @Inject + public MavenBuildContextConfiguration( + ProjectWorkspace workspace, + MojoConfigurationDigester digester, + MavenBuildContextFinalizer finalizer, + Project project, + MojoExecution execution) + throws IOException { + this.workspace = workspace; + this.finalizer = finalizer; + this.stateFile = getExecutionStateLocation(project, execution); + this.parameters = digester.digest(); + } + + @Override + public Path getStateFile() { + return stateFile; + } + + @Override + public Workspace getWorkspace() { + return workspace; + } + + @Override + public Map getParameters() { + return parameters; + } + + @Override + public BuildContextFinalizer getFinalizer() { + return finalizer; + } + + /** + * Returns conventional location of MojoExecution incremental build state + */ + public Path getExecutionStateLocation(Project project, MojoExecution execution) { + Path stateDirectory = getProjectStateLocation(project); + String builderId = getExecutionId(execution); + return stateDirectory.resolve(builderId); + } + + /** + * Returns conventional MojoExecution identifier used by incremental build tools. + */ + public String getExecutionId(MojoExecution execution) { + PluginDescriptor pluginDescriptor = execution.getPlugin().getDescriptor(); + String builderId = pluginDescriptor.getGroupId() + + '_' + + pluginDescriptor.getArtifactId() + + '_' + + execution.getGoal() + + '_' + + execution.getExecutionId(); + return builderId; + } + + /** + * Returns conventional location of MavenProject incremental build state + */ + public Path getProjectStateLocation(Project project) { + return Paths.get(project.getBuild().getDirectory(), "incremental"); + } +} diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/MavenBuildContextFinalizer.java b/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/MavenBuildContextFinalizer.java new file mode 100644 index 000000000000..3e11fed985ae --- /dev/null +++ b/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/MavenBuildContextFinalizer.java @@ -0,0 +1,136 @@ +/* + * 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.internal.build.context.impl.maven; + +import javax.inject.Inject; +import javax.inject.Named; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.apache.maven.api.build.context.BuildContext; +import org.apache.maven.api.build.context.Severity; +import org.apache.maven.api.build.context.spi.BuildContextFinalizer; +import org.apache.maven.api.build.context.spi.CommitableBuildContext; +import org.apache.maven.api.build.context.spi.Message; +import org.apache.maven.api.build.context.spi.Sink; +import org.apache.maven.execution.MojoExecutionEvent; +import org.apache.maven.execution.scope.MojoExecutionScoped; +import org.apache.maven.execution.scope.WeakMojoExecutionListener; +import org.apache.maven.plugin.MojoExecutionException; +import org.eclipse.sisu.Nullable; + +@Named +@MojoExecutionScoped +public class MavenBuildContextFinalizer implements WeakMojoExecutionListener, BuildContextFinalizer { + + private final List contexts = new ArrayList<>(); + + private final Sink sink; + + @Inject + public MavenBuildContextFinalizer(@Nullable Sink sink) { + this.sink = sink; + } + + public void registerContext(CommitableBuildContext context) { + contexts.add(context); + } + + protected List getRegisteredContexts() { + return contexts; + } + + @Override + public void afterMojoExecutionSuccess(MojoExecutionEvent event) throws MojoExecutionException { + try { + final Map> allMessages = new HashMap<>(); + for (CommitableBuildContext context : contexts) { + context.commit(new Sink() { + @Override + public void clear(Path resource) { + if (sink != null) { + sink.clear(resource); + } + } + + @Override + public void messages(Path resource, boolean isNew, Collection messages) { + if (sink != null) { + sink.messages(resource, isNew, messages); + } + allMessages.put(resource, messages); + } + }); + } + + if (sink == null) { + failBuild(allMessages); + } + } catch (Exception e) { + throw new MojoExecutionException("Could not maintain incremental build state", e); + } + } + + protected void failBuild(final Map> messages) throws MojoExecutionException { + // without messageSink, have to raise exception if there were errors + int errorCount = 0; + StringBuilder errors = new StringBuilder(); + for (Map.Entry> entry : messages.entrySet()) { + Object resource = entry.getKey(); + for (Message message : entry.getValue()) { + if (message.getSeverity() == Severity.ERROR) { + errorCount++; + errors.append(String.format( + "%s:[%d:%d] %s\n", + resource.toString(), message.getLine(), message.getColumn(), message.getMessage())); + } + } + } + final Set failOnErrors = extractFailOnErrors(contexts); + if (failOnErrors.size() != 1) { + throw new IllegalStateException("Contexts FailOnError property have different values."); + } + + final Boolean failOnError = failOnErrors.iterator().next(); + if (errorCount > 0 && failOnError) { + throw new MojoExecutionException(errorCount + " error(s) encountered:\n" + errors); + } + } + + private Set extractFailOnErrors(List contexts) { + final Set result = new HashSet<>(); + for (BuildContext context : contexts) { + result.add(context.getFailOnError()); + } + return result; + } + + @Override + public void beforeMojoExecution(MojoExecutionEvent event) throws MojoExecutionException {} + + @Override + public void afterExecutionFailure(MojoExecutionEvent event) {} +} diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/ProjectWorkspace.java b/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/ProjectWorkspace.java new file mode 100644 index 000000000000..b6943f52b593 --- /dev/null +++ b/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/ProjectWorkspace.java @@ -0,0 +1,118 @@ +/* + * 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.internal.build.context.impl.maven; + +import javax.inject.Inject; + +import java.io.OutputStream; +import java.nio.file.Path; +import java.nio.file.attribute.FileTime; +import java.util.stream.Stream; + +import org.apache.maven.api.Project; +import org.apache.maven.api.build.context.Status; +import org.apache.maven.api.build.context.spi.FileState; +import org.apache.maven.api.build.context.spi.Workspace; +import org.apache.maven.execution.scope.MojoExecutionScoped; +import org.apache.maven.internal.build.context.impl.FilesystemWorkspace; +import org.eclipse.sisu.Typed; + +/** + * Eclipse Workspace implementation is scoped to a project and does not "see" resources outside + * project basedir. This implementation dispatches Workspace calls to either Eclipse implementation + * or Filesystem workspace implementation, depending on whether requested resource is inside or + * outside of project basedir. + */ +@Typed(ProjectWorkspace.class) +@MojoExecutionScoped +public class ProjectWorkspace implements Workspace { + + private final Workspace workspace; + + private final Project project; + + private final Path basedir; + + private final FilesystemWorkspace filesystem; + + @Inject + public ProjectWorkspace(Project project, Workspace workspace, FilesystemWorkspace filesystem) { + this.project = project; + this.basedir = project.getBasedir().normalize(); + this.workspace = workspace; + this.filesystem = filesystem; + } + + protected Workspace getWorkspace(Path file) { + if (file.normalize().startsWith(basedir)) { + return workspace; + } + return filesystem; + } + + @Override + public Mode getMode() { + return Mode.NORMAL; + } + + @Override + public Workspace escalate() { + return new ProjectWorkspace(project, workspace.escalate(), filesystem); + } + + @Override + public boolean isPresent(Path file) { + return getWorkspace(file).isPresent(file); + } + + @Override + public boolean isRegularFile(Path file) { + return getWorkspace(file).isRegularFile(file); + } + + @Override + public boolean isDirectory(Path file) { + return getWorkspace(file).isDirectory(file); + } + + @Override + public void deleteFile(Path file) { + getWorkspace(file).deleteFile(file); + } + + @Override + public void processOutput(Path path) { + getWorkspace(path).processOutput(path); + } + + @Override + public OutputStream newOutputStream(Path path) { + return getWorkspace(path).newOutputStream(path); + } + + @Override + public Status getResourceStatus(Path file, FileTime lastModified, long size) { + return getWorkspace(file).getResourceStatus(file, lastModified, size); + } + + @Override + public Stream walk(Path basedir) { + return getWorkspace(basedir).walk(basedir); + } +} diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/digest/BytesHash.java b/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/digest/BytesHash.java new file mode 100644 index 000000000000..e2b6ae74df5c --- /dev/null +++ b/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/digest/BytesHash.java @@ -0,0 +1,52 @@ +/* + * 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.internal.build.context.impl.maven.digest; + +import java.io.Serializable; +import java.util.Arrays; + +@SuppressWarnings("serial") +public class BytesHash implements Serializable { + + // no serialVersionUID, want deserialization to fail if state format changes + + private final byte[] bytes; + + public BytesHash(byte[] bytes) { + this.bytes = bytes; + } + + // TODO toString + + @Override + public boolean equals(Object obj) { + if (obj == this) { + return true; + } + if (!(obj instanceof BytesHash)) { + return false; + } + return Arrays.equals(bytes, ((BytesHash) obj).bytes); + } + + @Override + public int hashCode() { + return Arrays.hashCode(bytes); + } +} diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/digest/ClasspathDigester.java b/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/digest/ClasspathDigester.java new file mode 100644 index 000000000000..1e17adad2d10 --- /dev/null +++ b/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/digest/ClasspathDigester.java @@ -0,0 +1,176 @@ +/* + * 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.internal.build.context.impl.maven.digest; + +import java.io.IOException; +import java.io.InputStream; +import java.io.Serializable; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.util.Comparator; +import java.util.List; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.stream.Stream; +import java.util.zip.ZipEntry; +import java.util.zip.ZipException; +import java.util.zip.ZipFile; + +import org.apache.maven.api.Artifact; +import org.apache.maven.api.Session; +import org.apache.maven.api.SessionData; +import org.apache.maven.api.build.context.BuildContextException; +import org.apache.maven.api.services.ArtifactManager; + +/** + * Specialized digester for Maven plugin classpath dependencies. Uses class file contents and immune + * to file timestamp changes caused by rebuilds of the same sources. + */ +class ClasspathDigester { + + @SuppressWarnings("unchecked") + private static final SessionData.Key> CACHE_KEY = + (SessionData.Key>) + (SessionData.Key) SessionData.key(ConcurrentMap.class, ClasspathDigester.class.getName()); + + private final ConcurrentMap cache; + private final ArtifactManager artifactManager; + + ClasspathDigester(Session session) { + this.cache = getCache(session); + this.artifactManager = session.getService(ArtifactManager.class); + } + + private static ConcurrentMap getCache(Session session) { + // this assumes that session data does not change during reactor build + SessionData sessionData = session.getData(); + return sessionData.computeIfAbsent(CACHE_KEY, ConcurrentHashMap::new); + } + + static void digest(MessageDigest digester, InputStream is) { + try { + byte[] buf = new byte[4 * 1024]; + int r; + while ((r = is.read(buf)) > 0) { + digester.update(buf, 0, r); + } + } catch (IOException e) { + throw new BuildContextException(e); + } + } + + static void digestFile(MessageDigest digester, Path file) { + try (InputStream is = Files.newInputStream(file)) { + digest(digester, is); + } catch (IOException e) { + throw new BuildContextException(e); + } + } + + static void digestZip(MessageDigest digester, Path file) { + try (ZipFile zip = new ZipFile(file.toFile())) { + zip.stream() + // sort entries. + // order of jar/zip entries is not important but may change from one build to the next + .sorted(Comparator.comparing(ZipEntry::getName)) + .forEachOrdered(entry -> { + try (InputStream is = zip.getInputStream(entry)) { + digest(digester, is); + } catch (IOException e) { + throw new BuildContextException(e); + } + }); + } catch (IOException e) { + throw new BuildContextException(e); + } + } + + private static byte[] digestArtifactFile(Path file) { + byte[] hash; + if (Files.isRegularFile(file)) { + hash = digestZipOrFile(file); + } else if (Files.isDirectory(file)) { + hash = digestDirectory(file); + } else { + // does not exist, use token empty array to avoid rechecking + hash = new byte[0]; + } + return hash; + } + + private static byte[] digestZipOrFile(Path file) { + MessageDigest d = SHA1Digester.newInstance(); + try { + digestZip(d, file); + } catch (BuildContextException e) { + if (e.getCause() instanceof ZipException) { + digestFile(d, file); + } + throw e; + } + return d.digest(); + } + + private static byte[] digestDirectory(Path file) { + byte[] hash; + MessageDigest d = SHA1Digester.newInstance(); + try (Stream s = Files.walk(file)) { + s.sorted().forEach(f -> digestFile(d, f)); + } catch (IOException e) { + throw new BuildContextException(e); + } + hash = d.digest(); + return hash; + } + + public Serializable digest(List artifacts) { + MessageDigest digester = SHA1Digester.newInstance(); + for (Artifact artifact : artifacts) { + Path file = artifactManager.getPath(artifact).get(); + String cacheKey = getArtifactKey(artifact); + byte[] cached = cache.get(cacheKey); + if (cached == null) { + byte[] hash = digestArtifactFile(file); + cached = cache.putIfAbsent(cacheKey, hash); + if (cached == null) { + cached = hash; + } + } + digester.update(cached); + } + return new BytesHash(digester.digest()); + } + + private String getArtifactKey(Artifact artifact) { + StringBuilder sb = new StringBuilder(); + sb.append(artifact.getGroupId()); + sb.append(':'); + sb.append(artifact.getArtifactId()); + sb.append(':'); + sb.append(artifact.getExtension()); + sb.append(':'); + sb.append(artifact.getVersion()); + if (artifact.getClassifier() != null) { + sb.append(':'); + sb.append(artifact.getClassifier()); + } + return sb.toString(); + } +} diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/digest/Digesters.java b/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/digest/Digesters.java new file mode 100644 index 000000000000..9520d71f6f47 --- /dev/null +++ b/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/digest/Digesters.java @@ -0,0 +1,197 @@ +/* + * 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.internal.build.context.impl.maven.digest; + +import javax.xml.stream.XMLStreamException; + +import java.io.IOException; +import java.io.OutputStream; +import java.io.Serializable; +import java.lang.reflect.AnnotatedElement; +import java.lang.reflect.Member; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Properties; +import java.util.SortedMap; +import java.util.TreeMap; + +import org.apache.maven.api.Project; +import org.apache.maven.api.RemoteRepository; +import org.apache.maven.api.Session; +import org.apache.maven.api.build.context.Incremental; +import org.apache.maven.model.v4.MavenStaxWriter; + +class Digesters { + + private static final Map, Digester> DIGESTERS; + + static { + Map, Digester> digesters = new LinkedHashMap<>(); + // common Maven objects + digesters.put(RemoteRepository.class, (Digester) Digesters::digestRemoteRepository); + // digesters.put(Artifact.class, (Digester) Digesters::digestArtifact); + digesters.put(Project.class, (Digester) Digesters::digestProject); + digesters.put(Session.class, (Digester) Digesters::digestSession); + // + digesters.put(Collection.class, (Digester>) Digesters::digestCollection); + // + digesters.put(Serializable.class, (Digester) (member, value) -> value); + DIGESTERS = Collections.unmodifiableMap(digesters); + } + + public static Serializable digest(Member member, Object value) { + // TODO: check on mojo as a default + Incremental configuration = getConfiguration(member); + if (configuration != null && !configuration.consider()) { + return null; // no digest, ignore + } + + return rawtypesDigest(member, value); + } + + static Incremental getConfiguration(Member member) { + if (member instanceof AnnotatedElement) { + return ((AnnotatedElement) member).getAnnotation(Incremental.class); + } + return null; + } + + @SuppressWarnings({"unchecked", "rawtypes"}) + private static Serializable rawtypesDigest(Member member, Object value) { + return ((Digester) getDigester(value)).digest(member, value); + } + + private static Digester getDigester(Object value) { + Digester digester = null; + for (Map.Entry, Digester> entry : DIGESTERS.entrySet()) { + if (entry.getKey().isInstance(value)) { + digester = entry.getValue(); + break; + } + } + if (digester == null) { + throw new UnsupportedParameterTypeException(value.getClass()); + } + return digester; + } + + private static Serializable digestProject(Member member, Project value) { + if (getConfiguration(member) == null) { + throw new IllegalArgumentException("Explicit @Incremental required: " + member); + } + + final MessageDigest digester = SHA1Digester.newInstance(); + + // effective pom.xml defines project configuration, rebuild whenever project configuration + // changes we can't be more specific here because mojo can access entire project model, not + // just its own configuration + try { + new MavenStaxWriter() + .write( + new OutputStream() { + @Override + public void write(int b) throws IOException { + digester.update((byte) b); + } + }, + value.getModel()); + } catch (IOException | XMLStreamException e) { + // can't happen + } + + return new BytesHash(digester.digest()); + } + + private static Serializable digestSession(Member member, Session session) { + if (getConfiguration(member) == null) { + throw new IllegalArgumentException("Explicit @Incremental required: " + member); + } + + // execution properties define build parameters passed in from command line and jvm used + SortedMap executionProperties = new TreeMap<>(); + Properties props = new Properties(); + props.putAll(session.getSystemProperties()); + props.putAll(session.getUserProperties()); + for (Map.Entry property : props.entrySet()) { + // TODO unit test non-string keys do not cause problems at runtime + // TODO test if non-string values can or cannot be used + Object key = property.getKey(); + Object value = property.getValue(); + if (key instanceof String && value instanceof String) { + executionProperties.put(key.toString(), value.toString()); + } + } + + // m2e workspace launch + executionProperties.remove("classworlds.conf"); + + // Environment has PID of java process (env.JAVA_MAIN_CLASS_), SSH_AGENT_PID, + // unique TMPDIR (on OSX) and other volatile variables. + executionProperties.entrySet().removeIf(property -> property.getKey().startsWith("env.")); + + MessageDigest digester = SHA1Digester.newInstance(); + + for (Map.Entry property : executionProperties.entrySet()) { + digester.update(property.getKey().getBytes(StandardCharsets.UTF_8)); + digester.update(property.getValue().getBytes(StandardCharsets.UTF_8)); + } + + return new BytesHash(digester.digest()); + } + + // private static Serializable digestArtifact(Member member, Artifact value) { + // return value.getPath().get().toFile(); + // } + + private static Serializable digestRemoteRepository(Member member, RemoteRepository value) { + return value.getUrl(); + } + + private static Serializable digestCollection(Member member, Collection collection) { + // TODO consider collapsing to single SHA1 hash + ArrayList digest = new ArrayList<>(); + for (Object element : collection) { + Serializable elementDigest = rawtypesDigest(member, element); + if (elementDigest != null) { + digest.add(elementDigest); + } + } + return digest; + } + + interface Digester { + Serializable digest(Member member, T value); + } + + public static class UnsupportedParameterTypeException extends IllegalArgumentException { + + private static final long serialVersionUID = 1L; + + final Class type; + + UnsupportedParameterTypeException(Class type) { + this.type = type; + } + } +} diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/digest/MojoConfigurationDigester.java b/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/digest/MojoConfigurationDigester.java new file mode 100644 index 000000000000..7e31524cac85 --- /dev/null +++ b/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/digest/MojoConfigurationDigester.java @@ -0,0 +1,176 @@ +/* + * 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.internal.build.context.impl.maven.digest; + +import javax.inject.Inject; +import javax.inject.Named; +import javax.xml.stream.XMLStreamException; + +import java.io.IOException; +import java.io.Serializable; +import java.io.StringWriter; +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.apache.maven.api.Artifact; +import org.apache.maven.api.MojoExecution; +import org.apache.maven.api.Project; +import org.apache.maven.api.Session; +import org.apache.maven.api.di.MojoExecutionScoped; +import org.apache.maven.api.plugin.descriptor.MojoDescriptor; +import org.apache.maven.api.xml.XmlNode; +import org.apache.maven.internal.xml.XmlNodeWriter; +import org.apache.maven.plugin.PluginParameterExpressionEvaluatorV4; +import org.codehaus.plexus.component.configurator.expression.ExpressionEvaluationException; +import org.codehaus.plexus.component.configurator.expression.ExpressionEvaluator; + +@Named +@MojoExecutionScoped +public class MojoConfigurationDigester { + + private final ClasspathDigester classpathDigester; + private final Session session; + private final Project project; + private final MojoExecution execution; + + @Inject + public MojoConfigurationDigester(Session session, Project project, MojoExecution execution) { + this.session = session; + this.project = project; + this.execution = execution; + this.classpathDigester = new ClasspathDigester(session); + } + + public Map digest() throws IOException { + Map result = new LinkedHashMap<>(); + + MojoDescriptor mojoDescriptor = execution.getDescriptor(); + List classpath = new ArrayList<>(execution.getPlugin().getDependencies()); + result.put("mojo.classpath", classpathDigester.digest(classpath)); + + XmlNode node = execution.getConfiguration().orElse(null); + if (node != null) { + List errors = new ArrayList<>(); + ExpressionEvaluator evaluator = new PluginParameterExpressionEvaluatorV4(session, project); + Class mojoClass; + try { + mojoClass = execution.getPlugin().getClassLoader().loadClass(mojoDescriptor.getImplementation()); + } catch (ClassNotFoundException e) { + errors.add("mojo class not found " + mojoDescriptor.getImplementation()); + mojoClass = null; + } + if (mojoClass != null) { + for (XmlNode child : node.getChildren()) { + String name = fromXML(child.getName()); + try { + Field field = getField(mojoClass, name); + if (field != null) { + String expression = child.getValue(); + if (expression == null) { + expression = getChildrenXml(child); + } + if (expression == null) { + expression = child.getAttribute("default-value"); + } + if (expression != null) { + Object value = evaluator.evaluate(expression); + if (value != null) { + Serializable digest = Digesters.digest(field, value); + if (digest != null) { + result.put("mojo.parameter." + name, digest); + } + } + } + } + } catch (Digesters.UnsupportedParameterTypeException e) { + errors.add("parameter " + name + " has unsupported type " + e.type.getName()); + } catch (ExpressionEvaluationException | XMLStreamException e) { + errors.add("parameter " + name + " " + e.getMessage()); + } + } + } + if (!errors.isEmpty()) { + StringBuilder sb = new StringBuilder(); + sb.append(project.toString()); + sb.append(" could not digest configuration of ").append(execution); + for (String error : errors) { + sb.append("\n ").append(error); + } + throw new IllegalArgumentException(sb.toString()); + } + } + return result; + } + + private String getChildrenXml(XmlNode node) throws XMLStreamException { + List children = node.getChildren(); + if (children.isEmpty()) { + return null; + } + StringBuilder sb = new StringBuilder(); + for (XmlNode child : children) { + append(sb, child); + } + return sb.toString(); + } + + private void append(StringBuilder sb, XmlNode node) throws XMLStreamException { + StringWriter sw = new StringWriter(); + XmlNodeWriter.write(sw, node); + sb.append(sw.toString()); + } + + private Field getField(Class clazz, String name) { + for (Field field : clazz.getDeclaredFields()) { + if (name.equals(field.getName())) { + return field; + } + } + if (clazz.getSuperclass() != null) { + return getField(clazz.getSuperclass(), name); + } + return null; + } + + // first-name --> firstName + protected String fromXML(final String elementName) { + boolean firstToken = true; + boolean firstLetter = true; + int rindex = 0; + int windex = 0; + int[] codepoints = elementName.codePoints().toArray(); + while (rindex < codepoints.length) { + int cp = codepoints[rindex++]; + if (cp == '-') { + firstToken = false; + firstLetter = true; + } else { + if (firstLetter) { + cp = firstToken ? Character.toLowerCase(cp) : Character.toTitleCase(cp); + firstLetter = false; + } + codepoints[windex++] = cp; + } + } + return new String(codepoints, 0, windex); + } +} diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/digest/SHA1Digester.java b/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/digest/SHA1Digester.java new file mode 100644 index 000000000000..144c77c1dccc --- /dev/null +++ b/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/digest/SHA1Digester.java @@ -0,0 +1,52 @@ +/* + * 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.internal.build.context.impl.maven.digest; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; + +// CHECKSTYLE_OFF: LineLength + +/** + * TODO: replace somehow with + * org.apache.maven.buildcache.hash.HashFactory + */ +public class SHA1Digester { + + public static MessageDigest newInstance() { + try { + return MessageDigest.getInstance("SHA1"); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("Unsupported JVM", e); + } + } + + // + // convenient helpers + // + + public static BytesHash digest(String string) { + MessageDigest digest = newInstance(); + if (string != null) { + digest.update(string.getBytes(StandardCharsets.UTF_8)); + } + return new BytesHash(digest.digest()); + } +} diff --git a/impl/maven-impl/pom.xml b/impl/maven-impl/pom.xml index 693e1ac66b2f..11f719ff7889 100644 --- a/impl/maven-impl/pom.xml +++ b/impl/maven-impl/pom.xml @@ -130,6 +130,11 @@ under the License. junit-jupiter-api test + + org.junit.jupiter + junit-jupiter-params + test + org.apache.maven maven-logging diff --git a/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultBuildContext.java b/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultBuildContext.java new file mode 100644 index 000000000000..f3ee4718dff1 --- /dev/null +++ b/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultBuildContext.java @@ -0,0 +1,813 @@ +/* + * 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.internal.build.context.impl; + +import java.io.IOException; +import java.io.OutputStream; +import java.io.Serializable; +import java.nio.file.Files; +import java.nio.file.NoSuchFileException; +import java.nio.file.Path; +import java.nio.file.attribute.BasicFileAttributes; +import java.nio.file.attribute.FileTime; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.function.BiConsumer; +import java.util.function.BinaryOperator; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import org.apache.maven.api.annotations.Nullable; +import org.apache.maven.api.build.context.BuildContextException; +import org.apache.maven.api.build.context.Input; +import org.apache.maven.api.build.context.Metadata; +import org.apache.maven.api.build.context.Output; +import org.apache.maven.api.build.context.Severity; +import org.apache.maven.api.build.context.Status; +import org.apache.maven.api.build.context.spi.BuildContextEnvironment; +import org.apache.maven.api.build.context.spi.BuildContextFinalizer; +import org.apache.maven.api.build.context.spi.CommitableBuildContext; +import org.apache.maven.api.build.context.spi.Message; +import org.apache.maven.api.build.context.spi.Sink; +import org.apache.maven.api.build.context.spi.Workspace; + +public class DefaultBuildContext implements CommitableBuildContext { + final Workspace workspace; + final Path stateFile; + final DefaultBuildContextState state; + final DefaultBuildContextState oldState; + /** + * Previous build state does not exist, cannot be read or configuration has changed. When + * escalated, all input files are considered require processing. + */ + private final boolean escalated; + /** + * Resources known to be deleted since previous build. Includes both resources reported as deleted + * by Workspace and resources explicitly delete through this build context. + */ + private final Set deletedResources = new HashSet<>(); + /** + * Resources selected for processing during this build. This includes resources created, changed + * and deleted through this build context. + */ + private final Set processedResources = new HashSet<>(); + /** + * Indicates that no further modifications to this build context are allowed. + */ + private boolean closed; + /** + * Indicates whether the build will continue even if there are compilation errors. + */ + private boolean failOnError = true; + + public DefaultBuildContext(BuildContextEnvironment env) { + this(env.getWorkspace(), env.getStateFile(), env.getParameters(), env.getFinalizer()); + } + + protected DefaultBuildContext( + Workspace workspace, + Path stateFile, + Map configuration, + BuildContextFinalizer finalizer) { + // preconditions + if (workspace == null) { + throw new NullPointerException(); + } + if (configuration == null) { + throw new NullPointerException(); + } + + this.stateFile = stateFile != null ? stateFile.toAbsolutePath() : null; + this.state = DefaultBuildContextState.withConfiguration(configuration); + this.oldState = DefaultBuildContextState.loadFrom(this.stateFile); + + final boolean configurationChanged = getConfigurationChanged(); + if (workspace.getMode() == Workspace.Mode.ESCALATED) { + this.escalated = true; + this.workspace = workspace; + } else if (workspace.getMode() == Workspace.Mode.SUPPRESSED) { + this.escalated = false; + this.workspace = workspace; + } else if (configurationChanged || !isPresent(oldState.getOutputs())) { + this.escalated = true; + this.workspace = workspace.escalate(); + } else { + this.escalated = false; + this.workspace = workspace; + } + + if (escalated && this.stateFile != null) { + if (!Files.isReadable(this.stateFile)) { + logInfo("Previous incremental build state does not exist, performing full build"); + } else { + logInfo("Incremental build configuration change detected, performing full build"); + } + } else { + logInfo("Performing incremental build"); + } + + if (finalizer != null) { + finalizer.registerContext(this); + } + } + + private static boolean containsOnly(Collection collection, Path element) { + return collection.stream().allMatch(element::equals); + } + + static Path normalize(Path input) { + if (input == null) { + throw new IllegalArgumentException(); + } + return FileMatcher.getCanonicalPath(input); + } + + public boolean getFailOnError() { + return failOnError; + } + + @Override + public void setFailOnError(boolean failOnError) { + this.failOnError = failOnError; + } + + protected void logInfo(String message) { + System.out.println(message); + } + + private boolean isPresent(Collection outputs) { + // in some scenarios, notable classpath change caused by changes to pom.xml, + // jdt builder deletes all files from target/classes directory during incremental workspace + // build. this behaviour is not communicated to m2e (or any other workspace builder) and thus + // m2e does not recreate deleted outputs + // this workaround escalates the build if any of the old outputs were deleted + return outputs.stream().allMatch(Files::isRegularFile); + } + + private boolean getConfigurationChanged() { + Map configuration = state.configuration; + Map oldConfiguration = oldState.configuration; + return Stream.concat(configuration.keySet().stream(), oldConfiguration.keySet().stream()) + .distinct() + .anyMatch(k -> !Objects.equals(configuration.get(k), oldConfiguration.get(k))); + } + + @Override + public boolean isProcessingRequired() { + return isEscalated() + || state.getResources().keySet().stream() + .anyMatch(resource -> + !state.isOutput(resource) && getResourceStatus(resource) != Status.UNMODIFIED) + || oldState.getResources().keySet().stream() + .anyMatch(resource -> !oldState.isOutput(resource) && !state.isResource(resource)); + } + + @Override + public DefaultOutput processOutput(Path outputFile) { + outputFile = normalize(outputFile); + DefaultOutputMetadata metadata = registerNormalizedOutput(outputFile); + return processOutput(metadata); + } + + protected DefaultOutput processOutput(DefaultOutputMetadata metadata) { + processResource(metadata.getPath()); + workspace.processOutput(metadata.getPath()); + return newOutput(metadata); + } + + @Override + public DefaultInputSet newInputSet() { + return new DefaultInputSet(this); + } + + @Override + public DefaultInputMetadata registerInput(Path inputFile) { + inputFile = normalize(inputFile); + BasicFileAttributes attrs = readAttributes(inputFile); + return registerNormalizedInput(inputFile, attrs.lastModifiedTime(), attrs.size()); + } + + static BasicFileAttributes readAttributes(Path inputFile) { + try { + return Files.readAttributes(inputFile, BasicFileAttributes.class); + } catch (NoSuchFileException e) { + return new BasicFileAttributes() { + @Override + public FileTime lastModifiedTime() { + return null; + } + + @Override + public FileTime lastAccessTime() { + return null; + } + + @Override + public FileTime creationTime() { + return null; + } + + @Override + public boolean isRegularFile() { + return false; + } + + @Override + public boolean isDirectory() { + return false; + } + + @Override + public boolean isSymbolicLink() { + return false; + } + + @Override + public boolean isOther() { + return false; + } + + @Override + public long size() { + return 0; + } + + @Override + public Object fileKey() { + return null; + } + }; + } catch (IOException e) { + throw new BuildContextException(e); + } + } + + @Override + public Collection registerInputs( + Path basedir, Collection includes, Collection excludes) { + basedir = normalize(basedir); + Map matchers = FileMatcher.createMatchers(basedir, includes, excludes); + List result = matchers.entrySet().stream() + .flatMap(e -> workspace + .walk(e.getKey()) + .filter(s -> + !Files.isDirectory(s.getPath()) && e.getValue().matches(s.getPath()))) + .map(s -> { + if (s.getStatus() == Status.REMOVED) { + deletedResources.add(s.getPath()); + } else { + registerInput(new FileState(s.getPath(), s.getLastModified(), s.getSize())); + } + return new DefaultInputMetadata(DefaultBuildContext.this, oldState, s.getPath()); + }) + .collect(Collectors.toList()); + if (workspace.getMode() == Workspace.Mode.DELTA) { + // only NEW, MODIFIED and REMOVED resources are reported in DELTA mode + // need to find any UNMODIFIED + final FileMatcher absoluteMatcher = FileMatcher.createMatcher(basedir, includes, excludes); + for (FileState fileState : oldState.getResources().values()) { + Path path = fileState.getPath(); + if (!state.isResource(path) && !deletedResources.contains(path) && absoluteMatcher.matches(path)) { + result.add(registerNormalizedInput(path, fileState.getLastModified(), fileState.getSize())); + } + } + } + return result; + } + + @Override + public Collection registerAndProcessInputs( + Path basedir, Collection includes, Collection excludes) { + return registerInputs(basedir, includes, excludes).stream() + .map(m -> { + switch (m.getStatus()) { + case NEW: + case MODIFIED: + return processInput(m); + default: + return new DefaultInput(this, state, m.getPath()); + } + }) + .collect(Collectors.toList()); + } + + /** + * Marks skipped build execution. All inputs, outputs and their associated metadata are carried + * over to the next build as-is. No context modification operations (register* or process) are + * permitted after this call. + */ + @Override + public void markSkipExecution() { + if (!processedResources.isEmpty()) { + throw new IllegalStateException(); + } + closed = true; + } + + protected DefaultInputMetadata registerNormalizedInput(Path resourceFile, FileTime lastModified, long length) { + assertOpen(); + if (!state.isResource(resourceFile)) { + registerInput(newFileState(resourceFile, lastModified, length)); + } + return new DefaultInputMetadata(this, oldState, resourceFile); + } + + private FileState newFileState(Path path) { + BasicFileAttributes attrs = readAttributes(path); + return newFileState(path, attrs.lastModifiedTime(), attrs.size()); + } + + private FileState newFileState(Path file, FileTime lastModified, long size) { + if (!workspace.isPresent(file)) { + throw new IllegalArgumentException("File does not exist or cannot be read " + file); + } + return new FileState(file, lastModified, size); + } + + protected DefaultOutputMetadata registerNormalizedOutput(Path outputFile) { + assertOpen(); + if (!state.isResource(outputFile)) { + state.putResource(outputFile, null); // placeholder + state.addOutput(outputFile); + } else { + if (!state.isOutput(outputFile)) { + throw new IllegalStateException("Already registered as input " + outputFile); + } + } + return new DefaultOutputMetadata(this, oldState, outputFile); + } + + public boolean aggregate( + Collection inputs, + Path outputFile, + BiConsumer> creator) { + DefaultOutputMetadata output = registerOutput(outputFile); + return aggregate(inputs, output, creator); + } + + public boolean aggregate( + Collection inputs, + DefaultOutputMetadata output, + BiConsumer> creator) { + associate(inputs, output); + boolean processingRequired = isEscalated(); + if (!processingRequired) { + processingRequired = isProcessingRequired(inputs, output); + } + if (processingRequired) { + DefaultOutput outputResource = processOutput(output); + List inputResources = inputs.stream().map(this::processInput).collect(Collectors.toList()); + creator.accept(outputResource, inputResources); + } else { + markUptodateOutput(output.getPath()); + } + return processingRequired; + } + + public boolean aggregate( + Collection inputs, + Path outputFile, + String stepId, + T identity, + Function mapper, + BinaryOperator accumulator, + BiConsumer writer) { + DefaultOutputMetadata output = registerOutput(outputFile); + associate(inputs, output); + boolean processingRequired = isEscalated() || isProcessingRequired(inputs, output); + if (processingRequired) { + T metadata = inputs.stream() + .map(input -> getMetadata(input, stepId, mapper)) + .reduce(identity, accumulator); + T oldMetadata = getOutputInputs(oldState, outputFile).stream() + .map(inputFile -> oldState.getResourceAttribute(inputFile, stepId)) + .reduce(identity, accumulator); + if (!Objects.equals(metadata, oldMetadata)) { + DefaultOutput outputResource = processOutput(output); + writer.accept(outputResource, metadata); + return true; + } + } else { + markUptodateOutput(output.getPath()); + } + return false; + } + + private T getMetadata( + DefaultMetadata input, String stepId, Function mapper) { + if (input.getStatus() != Status.UNMODIFIED) { + return mapper.apply(input.process()); + } else { + return oldState.getResourceAttribute(input.getPath(), stepId); + } + } + + protected void finalizeContext() { + // only supports simple input --> output associations + // outputs are carried over iff their input is carried over + + // TODO harden the implementation + // + // things can get tricky even with such simple model. consider the following + // build-1: inputA --> outputA + // build-2: inputA unchanged. inputB --> outputA + // now outputA has multiple inputs, which is not supported by this context + // + // another tricky example + // build-1: inputA --> outputA + // build-2: inputA unchanged before the build, inputB --> inputA + // now inputA is both input and output, which is not supported by this context + + // multi-pass implementation + // pass 1, carry-over up-to-date inputs and collect all up-to-date outputs + // pass 2, carry-over all up-to-date outputs + // pass 3, remove obsolete and orphaned outputs + + Set uptodateOldOutputs = new HashSet<>(); + Set uptodateOldInputs = new HashSet<>(); + for (Path resource : oldState.getResources().keySet()) { + if (oldState.isOutput(resource)) { + continue; + } + + if (isProcessedResource(resource) || isDeletedResource(resource) || !isRegisteredResource(resource)) { + // deleted or processed resource, nothing to carry over + continue; + } + + if (state.isOutput(resource)) { + // resource flipped from input to output without going through delete + throw new BuildContextException( + new IllegalStateException("Inconsistent resource type change " + resource)); + } + + // carry over + state.putResource(resource, oldState.getResource(resource)); + state.setResourceMessages(resource, oldState.getResourceMessages(resource)); + state.setResourceAttributes(resource, oldState.getResourceAttributes(resource)); + state.setResourceOutputs(resource, oldState.getResourceOutputs(resource)); + uptodateOldInputs.add(resource); + } + + for (Path oldOutput : oldState.getOutputs()) { + Collection outputInputs = oldState.getOutputInputs(oldOutput); + if (outputInputs != null && uptodateOldInputs.containsAll(outputInputs)) { + uptodateOldOutputs.add(oldOutput); + } + } + + for (Path output : uptodateOldOutputs) { + if (state.isResource(output)) { + // can't carry-over registered resources + // throw new IllegalStateException( "Can't carry over " + output ); + } + + state.putResource(output, oldState.getResource(output)); + state.addOutput(output); + state.setResourceMessages(output, oldState.getResourceMessages(output)); + state.setResourceAttributes(output, oldState.getResourceAttributes(output)); + } + + for (Path output : oldState.getOutputs()) { + if (!state.isOutput(output)) { + deleteOutput(output); + } + } + } + + protected void deleteOutput(Path resource) { + if (!oldState.isOutput(resource) && !state.isOutput(resource)) { + // not an output known to this build context + throw new IllegalArgumentException(); + } + + workspace.deleteFile(resource); + + deletedResources.add(resource); + processedResources.add(resource); + + state.removeResource(resource); + state.removeOutput(resource); + + state.removeResourceAttributes(resource); + state.removeResourceMessages(resource); + state.removeResourceOutputs(resource); + } + + protected boolean isEscalated() { + return escalated; + } + + // re-create output if any its inputs were added, changed or deleted since previous build + private boolean isProcessingRequired( + Collection inputs, DefaultOutputMetadata output) { + if (getResourceStatus(output.getPath()) == Status.MODIFIED) { + return true; + } + if (inputs.stream().anyMatch(r -> r.getStatus() != Status.UNMODIFIED)) { + return true; + } + List inputFiles = inputs.stream().map(Metadata::getPath).collect(Collectors.toList()); + return getOutputInputs(oldState, output.getPath()).stream().anyMatch(r -> !inputFiles.contains(r)); + } + + protected boolean isProcessedResource(Path resource) { + return processedResources.contains(resource); + } + + protected Set getProcessedResources() { + return processedResources; + } + + protected boolean isProcessed() { + return !processedResources.isEmpty(); + } + + protected void markProcessedResource(Path resource) { + processedResources.add(resource); + } + + private DefaultOutputMetadata registerOutput(Path outputFile) { + outputFile = normalize(outputFile); + if (isRegisteredResource(outputFile)) { + // only allow single registration of the same output. not sure why/if multiple will be needed + throw new BuildContextException(new IllegalStateException("Output already registered " + outputFile)); + } + return registerNormalizedOutput(outputFile); + } + + private Collection getOutputInputs(DefaultBuildContextState contextState, Path outputFile) { + Collection inputs = contextState.getOutputInputs(outputFile); + return inputs != null && !inputs.isEmpty() ? inputs : Collections.emptyList(); + } + + protected boolean isRegisteredResource(Path resource) { + return state.isResource(resource); + } + + protected boolean isDeletedResource(Path resource) { + return deletedResources.contains(resource); + } + + protected void markUptodateOutput(Path outputFile) { + if (!oldState.isOutput(outputFile)) { + throw new IllegalArgumentException(); + } + state.putResource(outputFile, oldState.getResource(outputFile)); + state.addOutput(outputFile); + } + + /** + * Adds the resource to this build's resource set. The resource must exist, i.e. it's status must + * not be REMOVED. + * + * @param holder the file state of the resource to register + * @return the normalized path of the registered resource + */ + protected Path registerInput(FileState holder) { + Path resource = holder.getPath(); + FileState other = state.getResource(resource); + if (other == null) { + if (getResourceStatus(holder) == Status.REMOVED) { + throw new BuildContextException(new IllegalArgumentException("Resource does not exist " + resource)); + } + state.putResource(resource, holder); + } else { + if (state.isOutput(resource)) { + throw new BuildContextException(new IllegalStateException("Already registered as output " + resource)); + } + if (!holder.equals(other)) { + throw new BuildContextException( + new IllegalArgumentException("Inconsistent resource state " + resource)); + } + state.putResource(resource, holder); + } + return resource; + } + + private Status getResourceStatus(FileState fileState) { + return workspace.getResourceStatus(fileState.getPath(), fileState.getLastModified(), fileState.getSize()); + } + + private void assertOpen() { + if (closed) { + throw new IllegalStateException(); + } + } + + protected DefaultInput processInput(DefaultInputMetadata metadata) { + final Path resource = metadata.getPath(); + if (metadata.context != this || !state.isResource(resource)) { + throw new IllegalArgumentException(); + } + processResource(resource); + return new DefaultInput(this, state, resource); + } + + private void processResource(final Path resource) { + processedResources.add(resource); + + // reset all metadata associated with the resource during this build + // state.removeResourceAttributes( resource ); + // state.removeResourceMessages( resource ); + // state.removeResourceOutputs( resource ); + } + + protected Status getResourceStatus(Path resource) { + if (deletedResources.contains(resource)) { + return Status.REMOVED; + } + + FileState oldResourceState = oldState.getResource(resource); + if (oldResourceState == null) { + return Status.NEW; + } + + Status status = getResourceStatus(oldResourceState); + + if (status == Status.UNMODIFIED && escalated) { + status = Status.MODIFIED; + } + + return status; + } + + protected DefaultOutput associate(DefaultInput input, DefaultOutput output) { + if (input.context != this) { + throw new BuildContextException(new IllegalArgumentException()); + } + if (output.context != this) { + throw new BuildContextException(new IllegalArgumentException()); + } + + assertAssociation(input, output); + + state.putResourceOutput(input.getPath(), output.getPath()); + return output; + } + + private void associate(Iterable inputs, DefaultOutputMetadata output) { + inputs.forEach(r -> state.putResourceOutput(r.getPath(), output.getPath())); + } + + protected Collection getAssociatedOutputs( + DefaultBuildContextState contextState, Path resource) { + Collection outputFiles = contextState.getResourceOutputs(resource); + if (outputFiles == null || outputFiles.isEmpty()) { + return Collections.emptyList(); + } + List outputs = new ArrayList<>(); + for (Path outputFile : outputFiles) { + outputs.add(new DefaultOutputMetadata(this, contextState, outputFile)); + } + return outputs; + } + + protected void assertAssociation(DefaultInput resource, DefaultOutput output) { + Path input = resource.getPath(); + Path outputFile = output.getPath(); + + // input --> output --> output2 is not supported (until somebody provides a usecase) + if (state.isOutput(input)) { + throw new BuildContextException(new UnsupportedOperationException()); + } + + // each output can only be associated with a single input + Collection inputs = state.getOutputInputs(outputFile); + if (inputs != null && !inputs.isEmpty() && !containsOnly(inputs, input)) { + throw new BuildContextException(new UnsupportedOperationException()); + } + } + + protected Serializable setResourceAttribute(Path resource, String key, T value) { + state.putResourceAttribute(resource, key, value); + // TODO odd this always returns previous build state. need to think about it + return oldState.getResourceAttribute(resource, key); + } + + protected T getResourceAttribute( + DefaultBuildContextState contextState, Path resource, String key, Class clazz) { + Map attributes = contextState.getResourceAttributes(resource); + return attributes != null ? clazz.cast(attributes.get(key)) : null; + } + + void addMessage(Path resource, int line, int column, String message, Severity severity, Throwable cause) { + // this is likely called as part of builder error handling logic. + // to make IAE easier to troubleshoot, link cause to the exception thrown + if (resource == null) { + throw new IllegalArgumentException("resource cannot be null", cause); + } + if (severity == null) { + throw new IllegalArgumentException("severity cannot be null", cause); + } + state.addResourceMessage(resource, new Message(line, column, message, severity, cause)); + log(resource, line, column, message, severity, cause); + } + + OutputStream newOutputStream(DefaultOutput output) { + return workspace.newOutputStream(output.getPath()); + } + + DefaultOutput newOutput(DefaultOutputMetadata resource) { + return new DefaultOutput(this, state, resource.resource); + } + + public void commit(@Nullable Sink sink) { + if (closed) { + return; + } + this.closed = true; + + // messages recorded during this build + Map> newMessages = new HashMap<>(state.getResourceMessages()); + + finalizeContext(); + + // assert inputs didn't change + for (Map.Entry entry : state.getResources().entrySet()) { + Path resource = entry.getKey(); + FileState holder = entry.getValue(); + if (!state.isOutput(resource) && holder.getStatus() != Status.UNMODIFIED) { + throw new BuildContextException(new IllegalStateException("Unexpected input change " + resource)); + } + } + + // timestamp new outputs + state.getOutputs().forEach(outputFile -> state.computeResourceIfAbsent(outputFile, this::newFileState)); + + if (stateFile != null) { + try (OutputStream os = workspace.newOutputStream(stateFile)) { + state.storeTo(os); + } catch (IOException e) { + throw new BuildContextException(e); + } + } + + // new messages are logged as soon as they are reported during the build + // replay old messages so the user can still see them + Map> allMessages = new HashMap<>(state.getResourceMessages()); + + if (!allMessages.keySet().equals(newMessages.keySet())) { + for (Map.Entry> entry : allMessages.entrySet()) { + Path resource = entry.getKey(); + if (!newMessages.containsKey(resource)) { + for (Message message : entry.getValue()) { + log( + resource, + message.getLine(), + message.getColumn(), + message.getMessage(), + message.getSeverity(), + message.getCause()); + } + } + } + } + + // processedResources includes resources added, changed and deleted during this build + // clear all old messages associated with the processed resources during previous builds + if (sink != null) { + for (Path resource : processedResources) { + sink.clear(resource); + } + for (Path resource : oldState.getResources().keySet()) { + if (!state.isResource(resource)) { + sink.clear(resource); + } + } + for (Map.Entry> entry : allMessages.entrySet()) { + Path resource = entry.getKey(); + boolean isNew = newMessages.containsKey(resource); + sink.messages(resource, isNew, entry.getValue()); + } + } + } + + private void log(Path resource, int line, int column, String message, Severity severity, Throwable cause) { + // TODO + } +} diff --git a/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultBuildContextState.java b/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultBuildContextState.java new file mode 100644 index 000000000000..40a62b4bad05 --- /dev/null +++ b/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultBuildContextState.java @@ -0,0 +1,520 @@ +/* + * 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.internal.build.context.impl; + +import java.io.BufferedInputStream; +import java.io.BufferedOutputStream; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.io.ObjectInputStream; +import java.io.ObjectOutputStream; +import java.io.ObjectStreamClass; +import java.io.OutputStream; +import java.io.Serializable; +import java.nio.file.FileSystem; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.FileTime; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; + +import org.apache.maven.api.build.context.spi.Message; + +public class DefaultBuildContextState implements Serializable { + + private static final long serialVersionUID = 6195150574931820441L; + + final Map configuration; + + private final Set outputs; + + private final Map resources; + + private final Map> resourceOutputs; + + // pure in-memory performance optimization, always reflects contents of resourceOutputs + private final Map> outputInputs; + + private final Map> resourceAttributes; + + private final Map> resourceMessages; + + private DefaultBuildContextState( + Map configuration, + Map inputs, + Set outputs, + Map> resourceOutputs, + Map> outputInputs, + Map> resourceAttributes, + Map> resourceMessages) { + this.configuration = configuration; + this.resources = inputs; + this.outputs = outputs; + this.resourceOutputs = resourceOutputs; + this.outputInputs = outputInputs; + this.resourceAttributes = resourceAttributes; + this.resourceMessages = resourceMessages; + } + + public static DefaultBuildContextState withConfiguration(Map configuration) { + HashMap copy = new HashMap<>(configuration); + // configuration marker used to distinguish between empty and new state + copy.put("incremental", Boolean.TRUE); + return new DefaultBuildContextState( + Collections.unmodifiableMap(copy), // configuration + new HashMap<>(), // inputs + new HashSet<>(), // outputs + new HashMap<>(), // inputOutputs + new HashMap<>(), // outputInputs + new HashMap<>(), // resourceAttributes + new HashMap<>() // messages + ); + } + + public static DefaultBuildContextState emptyState() { + return new DefaultBuildContextState( + Collections.emptyMap(), // configuration + Collections.emptyMap(), // inputs + Collections.emptySet(), // outputs + Collections.emptyMap(), // inputOutputs + Collections.emptyMap(), // outputInputs + Collections.emptyMap(), // resourceAttributes + Collections.emptyMap() // messages + ); + } + + private static void writeMap(ObjectOutputStream oos, Map map) throws IOException { + oos.writeInt(map.size()); + for (Map.Entry entry : map.entrySet()) { + oos.writeObject(entry.getKey()); + oos.writeObject(entry.getValue()); + } + } + + private static void writeMultimap(ObjectOutputStream oos, Map> mmap) throws IOException { + oos.writeInt(mmap.size()); + for (Map.Entry> entry : mmap.entrySet()) { + oos.writeObject(entry.getKey()); + writeCollection(oos, entry.getValue()); + } + } + + private static void writeCollection(ObjectOutputStream oos, Collection collection) throws IOException { + if (collection == null || collection.isEmpty()) { + oos.writeInt(0); + } else { + oos.writeInt(collection.size()); + for (Object element : collection) { + oos.writeObject(element); + } + } + } + + private static void writeDoublemap(ObjectOutputStream oos, Map> dmap) throws IOException { + oos.writeInt(dmap.size()); + for (Map.Entry> entry : dmap.entrySet()) { + oos.writeObject(entry.getKey()); + writeMap(oos, entry.getValue()); + } + } + + public static DefaultBuildContextState loadFrom(Path stateFile) { + // TODO verify stateFile location has not changed since last build + // TODO wrap collections in corresponding immutable collections + + if (stateFile == null) { + // transient build context + return DefaultBuildContextState.emptyState(); + } + + try { + // TODO does it matter if TCCL or super is called first? + try (ObjectInputStream is = + new PathAwareInputStream( + stateFile.getFileSystem(), new BufferedInputStream(Files.newInputStream(stateFile))) { + @Override + protected Class resolveClass(ObjectStreamClass desc) + throws IOException, ClassNotFoundException { + // TODO does it matter if TCCL or super is called first? + try { + ClassLoader tccl = Thread.currentThread().getContextClassLoader(); + return tccl.loadClass(desc.getName()); + } catch (ClassNotFoundException e) { + return super.resolveClass(desc); + } + } + }) { + Map configuration = readMap(is); + Set outputs = readSet(is); + Map resources = readMap(is); + Map> resourceOutputs = readMultimap(is); + Map> outputInputs = invertMultimap(resourceOutputs); + Map> resourceAttributes = readDoublemap(is); + Map> messages = readMultimap(is); + + DefaultBuildContextState state = new DefaultBuildContextState( + configuration, resources, outputs, resourceOutputs, outputInputs, resourceAttributes, messages); + return state; + } + // ignore secondary exceptions + } catch (FileNotFoundException e) { + // this is expected, silently ignore + } catch (RuntimeException e) { + // this is a bug in our code, let it bubble up as build failure + throw e; + } catch (Exception e) { + // this is almost certainly caused by incompatible state file, log and continue + } + return DefaultBuildContextState.emptyState(); + } + + @SuppressWarnings("unchecked") + private static Map readMap(ObjectInputStream ois) throws IOException, ClassNotFoundException { + Map map = new HashMap<>(); + int size = ois.readInt(); + for (int i = 0; i < size; i++) { + K key = (K) ois.readObject(); + V value = (V) ois.readObject(); + map.put(key, value); + } + return Collections.unmodifiableMap(map); + } + + @SuppressWarnings("unchecked") + private static Map> readMultimap(ObjectInputStream ois) + throws IOException, ClassNotFoundException { + Map> mmap = new HashMap<>(); + int size = ois.readInt(); + for (int i = 0; i < size; i++) { + K key = (K) ois.readObject(); + Collection value = readCollection(ois); + mmap.put(key, value); + } + return Collections.unmodifiableMap(mmap); + } + + @SuppressWarnings("unchecked") + private static Collection readCollection(ObjectInputStream ois) throws IOException, ClassNotFoundException { + int size = ois.readInt(); + if (size == 0) { + return null; + } + Collection collection = new ArrayList(); + for (int i = 0; i < size; i++) { + collection.add((V) ois.readObject()); + } + return Collections.unmodifiableCollection(collection); + } + + private static Set readSet(ObjectInputStream ois) throws IOException, ClassNotFoundException { + Collection collection = readCollection(ois); + return collection != null ? Collections.unmodifiableSet(new HashSet(collection)) : Collections.emptySet(); + } + + @SuppressWarnings("unchecked") + private static Map> readDoublemap(ObjectInputStream ois) + throws IOException, ClassNotFoundException { + int size = ois.readInt(); + Map> dmap = new HashMap<>(); + for (int i = 0; i < size; i++) { + K key = (K) ois.readObject(); + Map value = readMap(ois); + dmap.put(key, value); + } + return Collections.unmodifiableMap(dmap); + } + + private static Map> invertMultimap(Map> mmap) { + Map> inverted = new HashMap<>(); + for (Map.Entry> entry : mmap.entrySet()) { + for (V value : entry.getValue()) { + Collection keys = inverted.computeIfAbsent(value, k -> new ArrayList<>()); + keys.add(entry.getKey()); + } + } + return Collections.unmodifiableMap(inverted); + } + + private static boolean put(Map> multimap, K key, V value) { + Collection values = multimap.computeIfAbsent(key, k -> new LinkedHashSet()); + return values.add(value); + } + + public String getStats() { + return String.valueOf(configuration.size()) + + ' ' + + resources.size() + + ' ' + + outputs.size() + + ' ' + + resourceOutputs.size() + + ' ' + + outputInputs.size() + + ' ' + + resourceAttributes.size() + + ' ' + + resourceMessages.size() + + ' '; + } + + // + // getters and settings + // + + // resources + + public void storeTo(OutputStream os) throws IOException { + ObjectOutputStream oos = new PathAwareOutputStream(os); + try { + writeMap(oos, this.configuration); + writeCollection(oos, this.outputs); + writeMap(oos, this.resources); + + writeMultimap(oos, resourceOutputs); + writeDoublemap(oos, resourceAttributes); + writeMultimap(oos, resourceMessages); + + } finally { + oos.flush(); + } + } + + public void putResource(Path resource, FileState holder) { + resources.put(resource, holder); + } + + public FileState getResource(Path resource) { + return resources.get(resource); + } + + public void computeResourceIfAbsent(Path resource, Function supplier) { + resources.computeIfAbsent(resource, supplier); + } + + public boolean isResource(Path resource) { + return resources.containsKey(resource); + } + + public FileState removeResource(Path resource) { + return resources.remove(resource); + } + + // outputInputs + + public Map getResources() { + return Collections.unmodifiableMap(resources); + } + + // outputs + + public Collection getOutputInputs(Path outputFile) { + return outputInputs.get(outputFile); + } + + public Collection getOutputs() { + return Collections.unmodifiableCollection(outputs); + } + + public boolean isOutput(Path outputFile) { + return outputs.contains(outputFile); + } + + public boolean addOutput(Path output) { + return outputs.add(output); + } + + // resourceOutputs + + public boolean removeOutput(Path output) { + return outputs.remove(output); + } + + public boolean putResourceOutput(Path resource, Path output) { + put(outputInputs, output, resource); + return put(resourceOutputs, resource, output); + } + + public Collection getResourceOutputs(Path resource) { + return resourceOutputs.get(resource); + } + + public Collection setResourceOutputs(Path resource, Collection newOutputs) { + if (newOutputs == null || newOutputs.isEmpty()) { + return resourceOutputs.remove(resource); + } + return resourceOutputs.put(resource, newOutputs); + } + + public Collection removeResourceOutputs(Path resource) { + Collection removedOutputs = resourceOutputs.remove(resource); + removeOutputInputs(removedOutputs, resource); + return removedOutputs; + } + + // resourceAttributes + + private void removeOutputInputs(Collection outputPaths, Path resource) { + if (outputPaths == null) { + return; + } + for (Path output : outputPaths) { + Collection inputs = outputInputs.get(output); + if (inputs == null || !inputs.remove(resource)) { + throw new IllegalStateException(); + } + if (inputs.isEmpty()) { + outputInputs.remove(output); + } + } + } + + public Map removeResourceAttributes(Path resource) { + return resourceAttributes.remove(resource); + } + + public Map getResourceAttributes(Path resource) { + return resourceAttributes.get(resource); + } + + public Serializable putResourceAttribute(Path resource, String key, Serializable value) { + return resourceAttributes + .computeIfAbsent(resource, k -> new LinkedHashMap<>()) + .put(key, value); + } + + @SuppressWarnings("unchecked") + public T getResourceAttribute(Path resource, String key) { + return (T) resourceAttributes + .getOrDefault(resource, Collections.emptyMap()) + .get(key); + } + + // resourceMessages + + public Map setResourceAttributes(Path resource, Map attributes) { + if (attributes == null || attributes.isEmpty()) { + return resourceAttributes.remove(resource); + } + return resourceAttributes.put(resource, attributes); + } + + public Collection removeResourceMessages(Path resource) { + return resourceMessages.remove(resource); + } + + public Collection getResourceMessages(Path resource) { + return resourceMessages.get(resource); + } + + public Collection setResourceMessages(Path resource, Collection messages) { + if (messages == null || messages.isEmpty()) { + return resourceMessages.remove(resource); + } + return resourceMessages.put(resource, messages); + } + + public boolean addResourceMessage(Path resource, Message message) { + return put(resourceMessages, resource, message); + } + + public Map> getResourceMessages() { + return Collections.unmodifiableMap(resourceMessages); + } + + /** + * Path is not serializable + * so this class is used as a workaround during serialization. + */ + static class StoredPath implements Serializable { + private final String path; + + StoredPath(Path path) { + this.path = path.toString(); + } + + public Path toPath(FileSystem fileSystem) { + return fileSystem.getPath(path); + } + } + + /** + * FileTime is not serializable and Instant can not be used as a replacement, + * so this class is used as a workaround during serialization. + */ + static class StoredInstant implements Serializable { + private final long seconds; + private final int nanos; + + StoredInstant(FileTime time) { + Instant instant = time.toInstant(); + this.seconds = instant.getEpochSecond(); + this.nanos = instant.getNano(); + } + + public FileTime toFileTime() { + return FileTime.from(Instant.ofEpochSecond(seconds, nanos)); + } + } + + static class PathAwareInputStream extends ObjectInputStream { + private final FileSystem fileSystem; + + PathAwareInputStream(FileSystem fileSystem, InputStream in) throws IOException { + super(in); + this.fileSystem = fileSystem; + enableResolveObject(true); + } + + @Override + protected Object resolveObject(Object obj) { + return obj instanceof StoredPath + ? ((StoredPath) obj).toPath(fileSystem) + : obj instanceof StoredInstant ? ((StoredInstant) obj).toFileTime() : obj; + } + } + + static class PathAwareOutputStream extends ObjectOutputStream { + PathAwareOutputStream(OutputStream os) throws IOException { + super(new BufferedOutputStream(os)); + enableReplaceObject(true); + } + + @Override + protected Object replaceObject(Object obj) { + return obj instanceof Path + ? new StoredPath((Path) obj) + : obj instanceof FileTime ? new StoredInstant((FileTime) obj) : obj; + } + + @Override + protected void writeObjectOverride(Object obj) throws IOException { + super.writeObjectOverride(obj); + } + } +} diff --git a/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultInput.java b/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultInput.java new file mode 100644 index 000000000000..bf89ebfdb4f5 --- /dev/null +++ b/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultInput.java @@ -0,0 +1,34 @@ +/* + * 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.internal.build.context.impl; + +import java.nio.file.Path; + +import org.apache.maven.api.build.context.Input; + +public class DefaultInput extends DefaultResource implements Input { + public DefaultInput(DefaultBuildContext context, DefaultBuildContextState state, Path resource) { + super(context, state, resource); + } + + @Override + public DefaultOutput associateOutput(Path outputFile) { + return context.associate(this, context.processOutput(outputFile)); + } +} diff --git a/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultInputMetadata.java b/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultInputMetadata.java new file mode 100644 index 000000000000..0e60b0d8ad22 --- /dev/null +++ b/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultInputMetadata.java @@ -0,0 +1,34 @@ +/* + * 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.internal.build.context.impl; + +import java.nio.file.Path; + +import org.apache.maven.api.build.context.Input; + +public class DefaultInputMetadata extends DefaultMetadata { + public DefaultInputMetadata(DefaultBuildContext context, DefaultBuildContextState state, Path resource) { + super(context, state, resource); + } + + @Override + public DefaultInput process() { + return context.processInput(this); + } +} diff --git a/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultInputSet.java b/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultInputSet.java new file mode 100644 index 000000000000..c61c651f13e6 --- /dev/null +++ b/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultInputSet.java @@ -0,0 +1,83 @@ +/* + * 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.internal.build.context.impl; + +import java.io.Serializable; +import java.nio.file.Path; +import java.util.Collection; +import java.util.LinkedHashSet; +import java.util.Set; +import java.util.function.BiConsumer; +import java.util.function.BinaryOperator; +import java.util.function.Function; + +import org.apache.maven.api.build.context.Input; +import org.apache.maven.api.build.context.InputSet; +import org.apache.maven.api.build.context.Metadata; +import org.apache.maven.api.build.context.Output; + +public class DefaultInputSet implements InputSet { + + private final DefaultBuildContext context; + + private final Set inputs = new LinkedHashSet<>(); + + DefaultInputSet(DefaultBuildContext context) { + this.context = context; + } + + @Override + public void addInput(Metadata inputMetadata) { + if (!(inputMetadata instanceof DefaultInputMetadata)) { + throw new IllegalArgumentException("inputMetadata is not an instance of " + DefaultInputMetadata.class); + } + inputs.add((DefaultInputMetadata) inputMetadata); + } + + @Override + public Metadata registerInput(Path inputFile) { + DefaultInputMetadata input = context.registerInput(inputFile); + inputs.add(input); + return input; + } + + @Override + public Collection> registerInputs( + Path basedir, Collection includes, Collection excludes) { + Collection newInputs = context.registerInputs(basedir, includes, excludes); + this.inputs.addAll(newInputs); + return newInputs; + } + + @Override + public boolean aggregate(Path outputFile, BiConsumer> aggregator) { + return context.aggregate(inputs, outputFile, aggregator); + } + + @Override + public boolean aggregate( + Path outputFile, + String stepId, + T identity, + Function mapper, + BinaryOperator accumulator, + BiConsumer writer) { + return context.aggregate(inputs, outputFile, stepId, identity, mapper, accumulator, writer); + } +} diff --git a/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultMetadata.java b/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultMetadata.java new file mode 100644 index 000000000000..23de290eb653 --- /dev/null +++ b/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultMetadata.java @@ -0,0 +1,73 @@ +/* + * 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.internal.build.context.impl; + +import java.nio.file.Path; +import java.util.Objects; + +import org.apache.maven.api.build.context.Metadata; +import org.apache.maven.api.build.context.Resource; +import org.apache.maven.api.build.context.Status; + +public abstract class DefaultMetadata implements Metadata { + /** The build context that owns this metadata. */ + protected final DefaultBuildContext context; + /** The build context state associated with this metadata. */ + protected final DefaultBuildContextState state; + /** The path of the resource described by this metadata. */ + protected final Path resource; + + public DefaultMetadata(DefaultBuildContext context, DefaultBuildContextState state, Path resource) { + this.context = context; + this.state = state; + this.resource = resource; + } + + @Override + public Path getPath() { + return resource; + } + + @Override + public Status getStatus() { + return context.getResourceStatus(resource); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + DefaultMetadata that = (DefaultMetadata) o; + return context == that.context && state == that.state && resource.equals(that.resource); + } + + @Override + public int hashCode() { + return Objects.hash(resource); + } + + @Override + public String toString() { + return resource.toString(); + } +} diff --git a/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultOutput.java b/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultOutput.java new file mode 100644 index 000000000000..6b0807b7957f --- /dev/null +++ b/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultOutput.java @@ -0,0 +1,35 @@ +/* + * 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.internal.build.context.impl; + +import java.io.OutputStream; +import java.nio.file.Path; + +import org.apache.maven.api.build.context.Output; + +public class DefaultOutput extends DefaultResource implements Output { + public DefaultOutput(DefaultBuildContext context, DefaultBuildContextState state, Path resource) { + super(context, state, resource); + } + + @Override + public OutputStream newOutputStream() { + return context.newOutputStream(this); + } +} diff --git a/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultOutputMetadata.java b/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultOutputMetadata.java new file mode 100644 index 000000000000..2068ecb3b45e --- /dev/null +++ b/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultOutputMetadata.java @@ -0,0 +1,34 @@ +/* + * 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.internal.build.context.impl; + +import java.nio.file.Path; + +import org.apache.maven.api.build.context.Output; + +public class DefaultOutputMetadata extends DefaultMetadata { + public DefaultOutputMetadata(DefaultBuildContext context, DefaultBuildContextState state, Path resource) { + super(context, state, resource); + } + + @Override + public DefaultOutput process() { + return context.processOutput(this); + } +} diff --git a/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultResource.java b/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultResource.java new file mode 100644 index 000000000000..94e0f3d8fdf6 --- /dev/null +++ b/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultResource.java @@ -0,0 +1,78 @@ +/* + * 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.internal.build.context.impl; + +import java.nio.file.Path; +import java.util.Objects; + +import org.apache.maven.api.build.context.Resource; +import org.apache.maven.api.build.context.Severity; +import org.apache.maven.api.build.context.Status; + +public abstract class DefaultResource implements Resource { + /** The build context that owns this resource. */ + protected final DefaultBuildContext context; + /** The build context state associated with this resource. */ + protected final DefaultBuildContextState state; + /** The path of this resource. */ + protected final Path resource; + + public DefaultResource(DefaultBuildContext context, DefaultBuildContextState state, Path resource) { + this.context = context; + this.state = state; + this.resource = resource; + } + + @Override + public Path getPath() { + return resource; + } + + @Override + public Status getStatus() { + return context.getResourceStatus(resource); + } + + @Override + public void addMessage(int line, int column, String message, Severity severity, Throwable cause) { + context.addMessage(getPath(), line, column, message, severity, cause); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + DefaultMetadata that = (DefaultMetadata) o; + return context == that.context && state == that.state && resource.equals(that.resource); + } + + @Override + public int hashCode() { + return Objects.hash(resource); + } + + @Override + public String toString() { + return resource.toString(); + } +} diff --git a/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/FileMatcher.java b/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/FileMatcher.java new file mode 100644 index 000000000000..ca9bdad2113c --- /dev/null +++ b/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/FileMatcher.java @@ -0,0 +1,364 @@ +/* + * 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.internal.build.context.impl; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.StringTokenizer; +import java.util.function.Function; +import java.util.function.Predicate; +import java.util.regex.Pattern; + +public class FileMatcher { + private static final Matcher MATCH_EVERYTHING = p -> true; + final Matcher includesMatcher; + final Matcher excludesMatcher; + private final String basedir; + + private FileMatcher(String basedir, Matcher includesMatcher, Matcher excludesMatcher) { + this.basedir = basedir; + this.includesMatcher = includesMatcher; + this.excludesMatcher = excludesMatcher; + } + + private static Matcher fromStrings(String basepath, Collection globs, Matcher everything) { + if (globs == null || globs.isEmpty()) { + return null; // default behaviour appropriate for includes/excludes pattern + } + final ArrayList normalized = new ArrayList<>(); + for (String glob : globs) { + if ("*".equals(glob) || "**".equals(glob) || "**/*".equals(glob)) { + return everything; // matches everything + } + + StringBuilder gb = new StringBuilder(); + if (!basepath.endsWith("/")) { + gb.append(basepath).append('/'); + } + gb.append(glob.startsWith("/") ? glob.substring(1) : glob); + + // from https://ant.apache.org/manual/dirtasks.html#patterns + // There is one "shorthand": if a pattern ends with / or \, then ** is appended + if (glob.endsWith("/")) { + gb.append("**"); + } + normalized.add(gb.toString()); + } + final List patterns = + normalized.stream().map(FileMatcher::antPatternToRegex).collect(java.util.stream.Collectors.toList()); + return path -> patterns.stream().anyMatch(p -> p.matcher(path).matches()); + } + + /** + * Converts an Ant-style glob pattern to a compiled {@link Pattern regex}. + * Supports {@code *} (any chars within a path segment), {@code **} (zero or more path segments), + * and {@code ?} (single char within a segment). + */ + private static Pattern antPatternToRegex(String antPattern) { + StringBuilder regex = new StringBuilder("^"); + int i = 0; + int len = antPattern.length(); + while (i < len) { + char c = antPattern.charAt(i); + if (c == '*') { + if (i + 1 < len && antPattern.charAt(i + 1) == '*') { + // ** matches zero or more path segments + i += 2; + if (i < len && antPattern.charAt(i) == '/') { + // **/ → zero or more directory segments followed by / + regex.append("(?:.+/)?"); + i++; + } else { + // ** at end → match anything remaining + regex.append(".*"); + } + } else { + // * matches within a single segment + regex.append("[^/]*"); + i++; + } + } else if (c == '?') { + regex.append("[^/]"); + i++; + } else { + if (".+^$|(){}[]\\".indexOf(c) >= 0) { + regex.append('\\'); + } + regex.append(c); + i++; + } + } + regex.append("$"); + return Pattern.compile(regex.toString()); + } + + /** + * Given a directory, returns a map of location to FileMatcher that will optimize the lookup. The + * key can either be a file (if it is a single path matcher) or a directory (if it is an + * open-ended matcher). The associated matcher will still be relative to the basedir, not to the + * key, but will only match paths that start with the key. + * + * @param basedir the base directory for resolving paths + * @param includes the include patterns, may be {@code null} + * @param excludes the exclude patterns, may be {@code null} + * @return a map of paths to their corresponding file matchers + */ + public static Map createMatchers( + final Path basedir, Collection includes, Collection excludes) { + String basepath = normalize0(basedir); + if (includes == null || includes.isEmpty()) { + return Collections.singletonMap(basedir, createMatcher(basepath, includes, excludes)); + } + return createMatchers(basepath, includes, excludes, file -> { + String sep = basedir.getFileSystem().getSeparator(); + if (!"/".equals(sep)) { + file = file.substring(1).replace("/", sep); + } + return basedir.getFileSystem().getPath(file); + }); + } + + /** + * Returns a map of location to FileMatcher that will optimize the lookup. The key is a path of a + * file or directory in a logical filesystem that uses '/' as file separator. The associated + * matcher is relative to the root of the logical filesystem, not to the ley, but will only match + * paths that start with the key. + * + * @param includes the include patterns, may be {@code null} + * @param excludes the exclude patterns, may be {@code null} + * @return a map of string paths to their corresponding file matchers + */ + public static Map createMatchers(Collection includes, Collection excludes) { + String basepath = ""; + if (includes == null || includes.isEmpty()) { + return Collections.singletonMap(basepath, createMatcher(basepath, includes, excludes)); + } + return createMatchers(basepath, includes, excludes, Function.identity()); + } + + private static Map createMatchers( + String basepath, Collection includes, Collection excludes, Function fromString) { + final Matcher excludesMatcher = fromStrings(basepath, excludes, MATCH_EVERYTHING); + Map matchers = new HashMap<>(); + newIncludesTrie(includes).subdirs().forEach((relpath, globs) -> { + String path = relpath != null ? basepath + "/" + relpath : basepath; + FileMatcher matcher = globs != null // + ? createMatcher(path, globs, excludesMatcher) // + : createSinglePathMatcher(path); + matchers.put(fromString.apply(path), matcher); + }); + return matchers; + } + + private static Trie newIncludesTrie(Collection includes) { + Trie root = new Trie(); + for (String include : includes) { + // ant shorthand syntax + if (include.endsWith("/")) { + include = include + "**"; // chuck norris should approve + } + Trie trie = root; + StringTokenizer st = new StringTokenizer(include, "/"); + while (st.hasMoreTokens()) { + String name = st.nextToken(); + if (name.contains("*") || name.contains("?")) { + trie.addIncludes(subglob(name, st)); + break; + } + trie = trie.child(name); + } + } + return root; + } + + private static FileMatcher createMatcher(String basedir, Collection includes, Matcher excludesMatcher) { + final Matcher includesMatcher = fromStrings(basedir, includes, null); + return new FileMatcher(toDirectoryPath(basedir), includesMatcher, excludesMatcher); + } + + private static FileMatcher createSinglePathMatcher(String path) { + return new FileMatcher(null, new SinglePathMatcher(path) /* includesMatcher */, null /* excludesMatcher */); + } + + private static String subglob(String name, StringTokenizer st) { + StringBuilder glob = new StringBuilder(name); + while (st.hasMoreTokens()) { + glob.append('/').append(st.nextToken()); + } + return glob.toString(); + } + + /** + * Creates and returns new matcher for files under specified {@code basedir} that satisfy + * specified includes/excludes patterns. + * + * @param basedir the base directory for resolving paths + * @param includes the include patterns, may be {@code null} + * @param excludes the exclude patterns, may be {@code null} + * @return the file matcher for the given patterns + */ + public static FileMatcher createMatcher( + final Path basedir, Collection includes, Collection excludes) { + return createMatcher(normalize0(basedir), includes, excludes); + } + + public static FileMatcher createMatcher(Collection includes, Collection excludes) { + return createMatcher("", includes, excludes); + } + + private static FileMatcher createMatcher( + final String basepath, Collection includes, Collection excludes) { + final Matcher includesMatcher = fromStrings(basepath, includes, null); + final Matcher excludesMatcher = fromStrings(basepath, excludes, MATCH_EVERYTHING); + return new FileMatcher(toDirectoryPath(basepath), includesMatcher, excludesMatcher); + } + + protected static String toDirectoryPath(final String basepath) { + return basepath.endsWith("/") ? basepath : basepath + "/"; + } + + static Path getCanonicalPath(Path path) { + try { + return path.toRealPath(); + } catch (IOException e) { + return getCanonicalPath(path.getParent()).resolve(path.getFileName()); + } + } + + private static String normalize0(Path basedir) { + String separator = basedir.getFileSystem().getSeparator(); + String path = getCanonicalPath(basedir).toString(); + if (!"/".equals(separator)) { + path = "/" + path.replace(separator, "/"); + } + return path; + } + + /** + * Returns {@code true} if provided path is under this matcher's basedir and satisfies + * includes/excludes patterns. The provided path is assumed to be normalized according to + * {@link #normalize0(Path)}. + * + * @param path the normalized path to test + * @return {@code true} if the path matches the include/exclude patterns + */ + public boolean matches(String path) { + if (basedir != null && !path.startsWith(basedir)) { + return false; + } + if (excludesMatcher != null && excludesMatcher.matches(path)) { + return false; + } + if (includesMatcher != null) { + return includesMatcher.matches(path); + } + return true; + } + + public boolean matches(Path file) { + return matches(normalize0(file)); + } + + private interface Matcher extends Predicate { + boolean matches(String path); + + @Override + default boolean test(String s) { + return matches(s); + } + } + + static class SinglePathMatcher implements Matcher { + + final String path; + + SinglePathMatcher(String path) { + this.path = path; + } + + @Override + public boolean matches(String pathToMatch) { + return this.path.equals(pathToMatch); + } + } + + static class Trie { + Map children; + Collection includes; + + private static String childname(String basedir, String name) { + return basedir != null ? basedir + "/" + name : name; + } + + public void addIncludes(String glob) { + if (includes == null) { + includes = new LinkedHashSet<>(); + } + includes.add(glob); + if (children != null) { + children.values().forEach(child -> child.addIncludesTo(includes)); + children = null; + } + } + + private void addIncludesTo(Collection other) { + if (children != null) { + children.values().forEach(child -> child.addIncludesTo(other)); + } + if (includes != null) { + other.addAll(includes); + } + } + + public Trie child(String name) { + if (includes != null) { + return this; + } + if (children == null) { + children = new HashMap<>(); + } + Trie child = children.get(name); + if (child == null) { + child = new Trie(); + children.put(name, child); + } + return child; + } + + public Map> subdirs() { + return addSubdirs(null, new HashMap<>()); + } + + private Map> addSubdirs(String path, Map> subdirs) { + if (children != null) { + children.forEach((name, child) -> child.addSubdirs(childname(path, name), subdirs)); + } else { + subdirs.put(path, includes); + } + return subdirs; + } + } +} diff --git a/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/FileState.java b/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/FileState.java new file mode 100644 index 000000000000..bdcb96580e83 --- /dev/null +++ b/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/FileState.java @@ -0,0 +1,100 @@ +/* + * 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.internal.build.context.impl; + +import java.io.IOException; +import java.io.Serializable; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.BasicFileAttributes; +import java.nio.file.attribute.FileTime; +import java.util.Objects; + +import org.apache.maven.api.annotations.Nonnull; +import org.apache.maven.api.build.context.BuildContextException; +import org.apache.maven.api.build.context.Status; + +public class FileState implements Serializable { + + private static final long serialVersionUID = 1; + + @Nonnull + private final Path path; + + private final FileTime lastModified; + private final long size; + + public FileState(@Nonnull Path path, FileTime lastModified, long size) { + this.path = Objects.requireNonNull(path, "path can not be null"); + this.lastModified = lastModified; + this.size = size; + } + + @Nonnull + public Path getPath() { + return path; + } + + public FileTime getLastModified() { + return lastModified; + } + + public long getSize() { + return size; + } + + public Status getStatus() { + try { + if (!Files.isRegularFile(path) || !Files.isReadable(path)) { + return Status.REMOVED; + } + BasicFileAttributes attrs = Files.readAttributes(path, BasicFileAttributes.class); + if (size == attrs.size() && Objects.equals(lastModified, attrs.lastModifiedTime())) { + return Status.UNMODIFIED; + } + return Status.MODIFIED; + } catch (IOException e) { + throw new BuildContextException(e); + } + } + + @Override + public int hashCode() { + return Objects.hash(path, lastModified, size); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (!(obj instanceof FileState)) { + return false; + } + FileState other = (FileState) obj; + return Objects.equals(path, other.path) + && Objects.equals(lastModified, other.lastModified) + && size == other.size; + } + + @Override + public String toString() { + return "FileState[path=" + path + ", lastModified=" + lastModified + ", size=" + size + "]"; + } +} diff --git a/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/FilesystemWorkspace.java b/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/FilesystemWorkspace.java new file mode 100644 index 000000000000..5a8361e08fcb --- /dev/null +++ b/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/FilesystemWorkspace.java @@ -0,0 +1,114 @@ +/* + * 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.internal.build.context.impl; + +import java.io.BufferedOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.BasicFileAttributes; +import java.nio.file.attribute.FileTime; +import java.util.Objects; +import java.util.stream.Stream; + +import org.apache.maven.api.build.context.BuildContextException; +import org.apache.maven.api.build.context.Status; +import org.apache.maven.api.build.context.spi.FileState; +import org.apache.maven.api.build.context.spi.Workspace; +import org.apache.maven.api.di.Named; + +@Named +public class FilesystemWorkspace implements Workspace { + + @Override + public Mode getMode() { + return Mode.NORMAL; + } + + @Override + public Workspace escalate() { + return this; + } + + @Override + public void deleteFile(Path file) { + try { + Files.deleteIfExists(file); + } catch (IOException e) { + throw new BuildContextException(e); + } + } + + @Override + public void processOutput(Path path) {} + + @Override + public OutputStream newOutputStream(Path path) { + try { + Files.createDirectories(path.getParent()); + return new BufferedOutputStream(Files.newOutputStream(path)); + } catch (IOException e) { + throw new BuildContextException(e); + } + } + + @Override + public Status getResourceStatus(Path file, FileTime lastModified, long length) { + if (!isRegularFile(file) && !isDirectory(file)) { + return Status.REMOVED; + } + try { + BasicFileAttributes attrs = Files.readAttributes(file, BasicFileAttributes.class); + return Objects.equals(length, attrs.size()) && Objects.equals(lastModified, attrs.lastModifiedTime()) + ? Status.UNMODIFIED + : Status.MODIFIED; + } catch (IOException e) { + throw new BuildContextException(e); + } + } + + @Override + public boolean isPresent(Path file) { + return isRegularFile(file) && Files.isReadable(file); + } + + @Override + public boolean isRegularFile(Path file) { + return Files.isRegularFile(file); + } + + @Override + public boolean isDirectory(Path file) { + return Files.isDirectory(file); + } + + @Override + public Stream walk(Path basedir) { + if (Files.isDirectory(basedir)) { + try { + return Files.walk(basedir).map(path -> new FileState(path, Status.NEW)); + } catch (IOException e) { + throw new BuildContextException(e); + } + } else { + return Stream.empty(); + } + } +} diff --git a/impl/maven-impl/src/test/java/org/apache/maven/internal/build/context/impl/AbstractBuildContextTest.java b/impl/maven-impl/src/test/java/org/apache/maven/internal/build/context/impl/AbstractBuildContextTest.java new file mode 100644 index 000000000000..79550042d632 --- /dev/null +++ b/impl/maven-impl/src/test/java/org/apache/maven/internal/build/context/impl/AbstractBuildContextTest.java @@ -0,0 +1,60 @@ +/* + * 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.internal.build.context.impl; + +import java.io.Serializable; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import org.apache.maven.api.build.context.spi.Workspace; +import org.junit.jupiter.api.io.TempDir; + +public abstract class AbstractBuildContextTest { + /** Temporary directory for tests. */ + @TempDir + @SuppressWarnings("checkstyle:VisibilityModifier") + protected Path temp; + + protected static List toList(Iterable iterable) { + if (iterable == null) { + return null; + } + + List result = new ArrayList(); + for (T t : iterable) { + result.add(t); + } + return result; + } + + protected TestBuildContext newBuildContext() { + return newBuildContext(Collections.emptyMap()); + } + + protected TestBuildContext newBuildContext(Map config) { + return new TestBuildContext(temp.resolve("buildstate.ctx"), config); + } + + protected TestBuildContext newBuildContext(Workspace workspace) { + return new TestBuildContext(workspace, temp.resolve("buildstate.ctx"), Collections.emptyMap()); + } +} diff --git a/impl/maven-impl/src/test/java/org/apache/maven/internal/build/context/impl/DefaultAggregatorBuildContextTest.java b/impl/maven-impl/src/test/java/org/apache/maven/internal/build/context/impl/DefaultAggregatorBuildContextTest.java new file mode 100644 index 000000000000..16f477f6a3b4 --- /dev/null +++ b/impl/maven-impl/src/test/java/org/apache/maven/internal/build/context/impl/DefaultAggregatorBuildContextTest.java @@ -0,0 +1,181 @@ +/* + * 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.internal.build.context.impl; + +import java.io.BufferedWriter; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.function.BiConsumer; + +import org.apache.maven.api.build.context.BuildContextException; +import org.apache.maven.api.build.context.Input; +import org.apache.maven.api.build.context.Output; +import org.apache.maven.api.build.context.Resource; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class DefaultAggregatorBuildContextTest extends AbstractBuildContextTest { + + @Test + public void testBasic() throws Exception { + FileMatcher.getCanonicalPath(Paths.get("/oo/bar")); + + Path outputFile = temp.resolve("output"); + + Path basedir = Files.createTempDirectory(temp, "").toRealPath(); + Path a = basedir.resolve("a"); + Files.createFile(a); + + // initial build + FileIndexer indexer = new FileIndexer(); + DefaultBuildContext actx = newContext(); + DefaultInputSet inputSet = actx.newInputSet(); + inputSet.registerInputs(basedir, null, null); + inputSet.aggregate(outputFile, indexer); + actx.commit(null); + assertTrue(Files.isReadable(outputFile)); + assertEquals(1, indexer.outputs.size()); + assertEquals(1, indexer.inputs.size()); + + // no-change rebuild + indexer = new FileIndexer(); + actx = newContext(); + inputSet = actx.newInputSet(); + inputSet.registerInputs(basedir, null, null); + inputSet.aggregate(outputFile, indexer); + actx.commit(null); + assertTrue(Files.isReadable(outputFile)); + assertEquals(0, indexer.outputs.size()); + + // no-change rebuild + indexer = new FileIndexer(); + actx = newContext(); + + inputSet = actx.newInputSet(); + inputSet.registerInputs(basedir, null, null); + inputSet.aggregate(outputFile, indexer); + actx.commit(null); + assertTrue(Files.isReadable(outputFile)); + assertEquals(0, indexer.outputs.size()); + + // new input + Path b = basedir.resolve("b"); + Files.createFile(b); + indexer = new FileIndexer(); + actx = newContext(); + inputSet = actx.newInputSet(); + inputSet.registerInputs(basedir, null, null); + inputSet.aggregate(outputFile, indexer); + actx.commit(null); + assertTrue(Files.isReadable(outputFile)); + assertEquals(1, indexer.outputs.size()); + + // removed input + Files.delete(a); + indexer = new FileIndexer(); + actx = newContext(); + inputSet = actx.newInputSet(); + inputSet.registerInputs(basedir, null, null); + inputSet.aggregate(outputFile, indexer); + actx.commit(null); + assertTrue(Files.isReadable(outputFile)); + assertEquals(1, indexer.outputs.size()); + + // no-change rebuild + indexer = new FileIndexer(); + actx = newContext(); + inputSet = actx.newInputSet(); + inputSet.registerInputs(basedir, null, null); + inputSet.aggregate(outputFile, indexer); + actx.commit(null); + assertTrue(Files.isReadable(outputFile)); + assertEquals(0, indexer.outputs.size()); + + // removed output + Files.delete(outputFile); + indexer = new FileIndexer(); + actx = newContext(); + inputSet = actx.newInputSet(); + inputSet.registerInputs(basedir, null, null); + inputSet.aggregate(outputFile, indexer); + actx.commit(null); + assertTrue(Files.isReadable(outputFile)); + assertEquals(1, indexer.outputs.size()); + + // no-change rebuild + indexer = new FileIndexer(); + actx = newContext(); + inputSet = actx.newInputSet(); + inputSet.registerInputs(basedir, null, null); + inputSet.aggregate(outputFile, indexer); + actx.commit(null); + assertTrue(Files.isReadable(outputFile)); + assertEquals(0, indexer.outputs.size()); + } + + private DefaultBuildContext newContext() { + Path stateFile = temp.resolve("buildstate.ctx"); + return new DefaultBuildContext(new FilesystemWorkspace(), stateFile, new HashMap<>(), null); + } + + @Test + public void testEmpty() throws Exception { + Path outputFile = temp.resolve("output"); + Path basedir = Files.createTempDirectory(temp, ""); + + FileIndexer indexer = new FileIndexer(); + DefaultBuildContext actx = newContext(); + DefaultInputSet output = actx.newInputSet(); + output.registerInputs(basedir, null, null); + output.aggregate(outputFile, indexer); + actx.commit(null); + assertTrue(Files.isReadable(outputFile)); + assertEquals(1, indexer.outputs.size()); + } + + @SuppressWarnings("checkstyle:VisibilityModifier") + private static class FileIndexer implements BiConsumer> { + final List inputs = new ArrayList<>(); + final List outputs = new ArrayList<>(); + + @Override + public void accept(Output output, Collection inputCollection) { + outputs.add(output.getPath()); + try (BufferedWriter w = output.newBufferedWriter(StandardCharsets.UTF_8)) { + for (Resource input : inputCollection) { + Path path = input.getPath(); + this.inputs.add(path); + w.write(path.toAbsolutePath().toString()); + w.newLine(); + } + } catch (IOException e) { + throw new BuildContextException(e); + } + } + } +} diff --git a/impl/maven-impl/src/test/java/org/apache/maven/internal/build/context/impl/DefaultBasicBuildContextTest.java b/impl/maven-impl/src/test/java/org/apache/maven/internal/build/context/impl/DefaultBasicBuildContextTest.java new file mode 100644 index 000000000000..a7df8c3c1d38 --- /dev/null +++ b/impl/maven-impl/src/test/java/org/apache/maven/internal/build/context/impl/DefaultBasicBuildContextTest.java @@ -0,0 +1,57 @@ +/* + * 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.internal.build.context.impl; + +import java.io.Serializable; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +public class DefaultBasicBuildContextTest { + /** Temporary directory for tests. */ + @TempDir + private Path temp; + + private DefaultBuildContext newContext() { + Path stateFile = temp.resolve("buildstate.ctx"); + return new DefaultBuildContext(new FilesystemWorkspace(), stateFile, new HashMap(), null); + } + + @Test + public void testDeletedOutput() throws Exception { + Path input = Files.createFile(temp.resolve("input")); + Path output = Files.createFile(temp.resolve("output")); + + DefaultBuildContext ctx; + + ctx = newContext(); + ctx.registerInput(input); + ctx.processOutput(output); + ctx.commit(null); + + Files.delete(output); + + ctx = newContext(); + ctx.registerInput(input); + ctx.commit(null); + } +} diff --git a/impl/maven-impl/src/test/java/org/apache/maven/internal/build/context/impl/DefaultBuildContextStateTest.java b/impl/maven-impl/src/test/java/org/apache/maven/internal/build/context/impl/DefaultBuildContextStateTest.java new file mode 100644 index 000000000000..6a8f39bce929 --- /dev/null +++ b/impl/maven-impl/src/test/java/org/apache/maven/internal/build/context/impl/DefaultBuildContextStateTest.java @@ -0,0 +1,85 @@ +/* + * 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.internal.build.context.impl; + +import java.io.ObjectOutputStream; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.Collections; +import java.util.HashMap; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class DefaultBuildContextStateTest { + /** Temporary directory for tests. */ + @TempDir + private Path temp; + + @Test + public void testRoundtrip() throws Exception { + Path file = Files.createTempFile(temp, "", ""); + DefaultBuildContextState state = DefaultBuildContextState.withConfiguration(new HashMap<>()); + BasicFileAttributes attrs = DefaultBuildContext.readAttributes(file); + state.putResource(file, new FileState(file, attrs.lastModifiedTime(), attrs.size())); + + Path stateFile = Files.createTempFile(temp, "", ""); + try (OutputStream os = Files.newOutputStream(stateFile)) { + state.storeTo(os); + } + + state = DefaultBuildContextState.loadFrom(stateFile); + + assertNotNull(state.getResource(file)); + } + + @Test + public void testStateDoesNotExist() throws Exception { + DefaultBuildContextState state = DefaultBuildContextState.loadFrom(temp.resolve("does-not-exist")); + assertTrue(state.configuration.isEmpty()); + } + + @Test + public void testEmptyState() throws Exception { + Path stateFile = Files.createTempFile(temp, "", ""); + assertTrue(DefaultBuildContextState.loadFrom(stateFile).configuration.isEmpty()); + } + + @Test + public void testCorruptedState() throws Exception { + Path corrupted = Files.createTempFile(temp, "", ""); + Files.write(corrupted, Collections.singletonList("test"), StandardOpenOption.APPEND); + assertTrue(DefaultBuildContextState.loadFrom(corrupted).configuration.isEmpty()); + } + + @Test + public void testIncompatibleState() throws Exception { + Path incompatible = Files.createTempFile(temp, "", ""); + ObjectOutputStream oos = new ObjectOutputStream(Files.newOutputStream(incompatible)); + oos.writeUTF("incompatible"); + oos.close(); + assertTrue(DefaultBuildContextState.loadFrom(incompatible).configuration.isEmpty()); + } +} diff --git a/impl/maven-impl/src/test/java/org/apache/maven/internal/build/context/impl/DefaultBuildContextTest.java b/impl/maven-impl/src/test/java/org/apache/maven/internal/build/context/impl/DefaultBuildContextTest.java new file mode 100644 index 000000000000..f409e921a3f3 --- /dev/null +++ b/impl/maven-impl/src/test/java/org/apache/maven/internal/build/context/impl/DefaultBuildContextTest.java @@ -0,0 +1,605 @@ +/* + * 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.internal.build.context.impl; + +import java.io.File; +import java.io.IOException; +import java.io.Serializable; +import java.net.URL; +import java.net.URLClassLoader; +import java.nio.file.FileSystem; +import java.nio.file.FileSystems; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardOpenOption; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; +import java.util.function.Supplier; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import com.google.common.jimfs.Configuration; +import com.google.common.jimfs.Jimfs; +import org.apache.maven.api.build.context.BuildContextException; +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.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import static org.apache.maven.api.build.context.Status.MODIFIED; +import static org.apache.maven.api.build.context.Status.NEW; +import static org.apache.maven.api.build.context.Status.UNMODIFIED; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class DefaultBuildContextTest extends AbstractBuildContextTest { + + private static void assertIncludedPaths(Collection expected, Collection actual) throws IOException { + assertEquals(toString(expected), toString(actual)); + } + + private static String toString(Collection files) throws IOException { + return files.stream() + .map(DefaultBuildContext::normalize) + .map(Path::toString) + .sorted() + .collect(Collectors.joining("\n", "", "\n")); + } + + @Test + public void testRegisterInputInputFileDoesNotExist() throws Exception { + Path file = Paths.get("target/does_not_exist"); + assertTrue(!Files.exists(file) && !Files.isReadable(file)); + assertThrows(IllegalArgumentException.class, () -> newBuildContext().registerInput(file)); + } + + @Test + public void testRegisterInput() throws Exception { + // this is NOT part of API but rather currently implemented behaviour + // API allows #registerInput return different instances + + Path file = Paths.get("src/test/resources/simplelogger.properties"); + assertTrue(Files.exists(file) && Files.isReadable(file)); + TestBuildContext context = newBuildContext(); + assertNotNull(context.registerInput(file)); + assertNotNull(context.registerInput(file)); + } + + @Test + public void testOutputWithoutInputs() throws Exception { + TestBuildContext context = newBuildContext(); + + Path outputFile = Files.createFile(temp.resolve("output_without_inputs")); + context.processOutput(outputFile); + + // is not deleted by commit + context.commit(); + assertTrue(Files.isReadable(outputFile)); + + // is not deleted after rebuild with re-registration + context = newBuildContext(); + context.processOutput(outputFile); + context.commit(); + assertTrue(Files.isReadable(outputFile)); + + // deleted after rebuild without re-registration + context = newBuildContext(); + context.commit(); + assertFalse(Files.isReadable(outputFile)); + } + + @Test + public void testGetInputStatus() throws Exception { + Path inputFile = Files.createFile(temp.resolve("inputFile")); + + // initial build + TestBuildContext context = newBuildContext(); + // first time invocation returns Input for processing + assertEquals(NEW, context.registerInput(inputFile).getStatus()); + // second invocation still returns NEW + assertEquals(NEW, context.registerInput(inputFile).getStatus()); + context.commit(); + + // new build + context = newBuildContext(); + // input file was not modified since last build + assertEquals(UNMODIFIED, context.registerInput(inputFile).getStatus()); + context.commit(); + + // new build + Files.write(inputFile, Collections.singletonList("test"), StandardOpenOption.APPEND); + context = newBuildContext(); + // input file was modified since last build + assertEquals(MODIFIED, context.registerInput(inputFile).getStatus()); + } + + @Test + public void testInputModifiedAfterRegistration() throws Exception { + Path inputFile = Files.createFile(temp.resolve("inputFile")); + Path outputFile = Files.createFile(temp.resolve("outputFile")); + + TestBuildContext context = newBuildContext(); + DefaultInput input = context.registerInput(inputFile).process(); + input.associateOutput(outputFile); + context.commit(); + + TestBuildContext context2 = newBuildContext(); + context2.registerInput(inputFile); + // this is incorrect use of build-avoidance API + // input has changed after it was registered for processing + // IllegalStateException is raised to prevent unexpected process/not-process flip-flop + Files.write(inputFile, Collections.singletonList("test"), StandardOpenOption.APPEND); + assertThrows(BuildContextException.class, context2::commit); + assertTrue(Files.isReadable(outputFile)); + } + + @Test + public void testCommitOrphanedOutputsCleanup() throws Exception { + Path inputFile = Files.createFile(temp.resolve("inputFile")); + Path outputFile = Files.createFile(temp.resolve("outputFile")); + + TestBuildContext context = newBuildContext(); + DefaultInput input = context.registerInput(inputFile).process(); + input.associateOutput(outputFile); + context.commit(); + + // input is not part of input set any more + // associated output must be cleaned up + context = newBuildContext(); + context.commit(); + assertFalse(Files.isReadable(outputFile)); + } + + @Test + public void testCommitStaleOutputCleanup() throws Exception { + Path inputFile = Files.createFile(temp.resolve("inputFile")); + Path outputFile1 = Files.createFile(temp.resolve("outputFile1")); + Path outputFile2 = Files.createFile(temp.resolve("outputFile2")); + + TestBuildContext context = newBuildContext(); + DefaultInput input = context.registerInput(inputFile).process(); + input.associateOutput(outputFile1); + input.associateOutput(outputFile2); + context.commit(); + + context = newBuildContext(); + input = context.registerInput(inputFile).process(); + input.associateOutput(outputFile1); + context.commit(); + assertFalse(Files.isReadable(outputFile2)); + + context = newBuildContext(); + DefaultInputMetadata metadata = context.registerInput(inputFile); + assertEquals(1, toList(context.getAssociatedOutputs(metadata)).size()); + context.commit(); + } + + @Test + public void testCreateStateParentDirectory() throws Exception { + Path stateFile = temp.resolve("sub/dir/buildstate.ctx"); + TestBuildContext context = new TestBuildContext(stateFile, Collections.emptyMap()); + context.commit(); + assertTrue(Files.isReadable(stateFile)); + } + + @Test + public void testRegisterInputNullInput() throws Exception { + assertThrows(IllegalArgumentException.class, () -> newBuildContext().registerInput((Path) null)); + } + + @ParameterizedTest + @MethodSource("fileSystems") + public void testRegisterAndProcessInputs(String type, Supplier fs) throws Exception { + Path target = fs.get().getPath("target"); + Files.createDirectories(target); + temp = Files.createTempDirectory(target, "tmp"); + + Path inputFile = Files.createFile(temp.resolve("inputFile")); + Path outputFile = Files.createFile(temp.resolve("outputFile")); + List includes = Collections.singletonList("**/" + inputFile.getFileName()); + + TestBuildContext context = newBuildContext(); + List inputs = toList(context.registerAndProcessInputs(temp, includes, null)); + assertEquals(1, inputs.size()); + assertEquals(NEW, inputs.get(0).getStatus()); + inputs.get(0).associateOutput(outputFile); + context.commit(); + + // no change rebuild + context = newBuildContext(); + inputs = toList(context.registerAndProcessInputs(temp, includes, null)); + assertEquals(1, inputs.size()); + assertEquals(UNMODIFIED, inputs.get(0).getStatus()); + context.commit(); + } + + @Test + void testInputDeleted() throws Exception { + Path inputFile = Files.createFile(temp.resolve("inputFile")); + Path outputFile = Files.createFile(temp.resolve("outputFile")); + + TestBuildContext context = newBuildContext(); + context.registerInput(inputFile).process().associateOutput(outputFile); + context.commit(); + + // + Files.delete(inputFile); + context = newBuildContext(); + context.commit(); + assertFalse(Files.exists(outputFile)); + } + + @Test + void testInputInListDeleted() throws Exception { + Path input = temp.resolve("input"); + Path output = temp.resolve("output"); + Files.createDirectories(input); + Files.createDirectories(output); + + Path inputFile = Files.createFile(input.resolve("file")); + Path outputFile = Files.createFile(output.resolve("file")); + + TestBuildContext context = newBuildContext(); + Collection inputs = context.registerAndProcessInputs(input, null, null); + assertNotNull(inputs); + assertFalse(inputs.isEmpty()); + Input i = inputs.iterator().next(); + i.associateOutput(outputFile); + context.commit(); + + // + Files.delete(inputFile); + context = newBuildContext(); + context.commit(); + assertFalse(Files.exists(outputFile)); + } + + @Test + public void testGetAssociatedOutputs() throws Exception { + Path inputFile = Files.createFile(temp.resolve("inputFile")); + Path outputFile = Files.createFile(temp.resolve("outputFile")); + + TestBuildContext context = newBuildContext(); + context.registerInput(inputFile).process().associateOutput(outputFile); + context.commit(); + + // + context = newBuildContext(); + DefaultInputMetadata metadata = context.registerInput(inputFile); + List outputs = toList(context.getAssociatedOutputs(metadata)); + assertEquals(1, outputs.size()); + assertEquals(Status.UNMODIFIED, outputs.get(0).getStatus()); + context.commit(); + + // + Files.write(outputFile, Collections.singletonList("test"), StandardOpenOption.APPEND); + context = newBuildContext(); + metadata = context.registerInput(inputFile); + outputs = toList(context.getAssociatedOutputs(metadata)); + assertEquals(1, outputs.size()); + assertEquals(Status.MODIFIED, outputs.get(0).getStatus()); + context.commit(); + + // + Files.delete(outputFile); + context = newBuildContext(); + metadata = context.registerInput(inputFile); + outputs = toList(context.getAssociatedOutputs(metadata)); + assertEquals(1, outputs.size()); + assertEquals(Status.REMOVED, outputs.get(0).getStatus()); + context.commit(); + } + + @Test + public void testGetRegisteredInputs() throws Exception { + Path inputFile1 = Files.createFile(temp.resolve("inputFile1")); + Path inputFile2 = Files.createFile(temp.resolve("inputFile2")); + Path inputFile3 = Files.createFile(temp.resolve("inputFile3")); + Path inputFile4 = Files.createFile(temp.resolve("inputFile4")); + + TestBuildContext context = newBuildContext(); + inputFile1 = context.registerInput(inputFile1).getPath(); + inputFile2 = context.registerInput(inputFile2).getPath(); + inputFile3 = context.registerInput(inputFile3).getPath(); + context.commit(); + + Files.write(inputFile3, Collections.singletonList("test"), StandardOpenOption.APPEND); + + context = newBuildContext(); + + // context.registerInput(inputFile1); DELETED + context.registerInput(inputFile2); // UNMODIFIED + context.registerInput(inputFile3); // MODIFIED + inputFile4 = context.registerInput(inputFile4).getPath(); // NEW + + Map inputs = new TreeMap<>(); + for (Metadata input : context.getRegisteredInputs()) { + inputs.put(input.getPath(), input); + } + + assertEquals(4, inputs.size()); + // assertEquals(ResourceStatus.REMOVED, inputs.get(inputFile1).getStatus()); + assertEquals(Status.UNMODIFIED, inputs.get(inputFile2).getStatus()); + assertEquals(Status.MODIFIED, inputs.get(inputFile3).getStatus()); + assertEquals(Status.NEW, inputs.get(inputFile4).getStatus()); + } + + @Test + public void testInputAttributes() throws Exception { + Path inputFile = Files.createFile(temp.resolve("inputFile")); + + TestBuildContext context = newBuildContext(); + DefaultInputMetadata metadata = context.registerInput(inputFile); + assertNull(context.getAttribute(metadata, "key", String.class)); + DefaultInput input = metadata.process(); + assertNull(context.setAttribute(input, "key", "value")); + context.commit(); + + context = newBuildContext(); + metadata = context.registerInput(inputFile); + assertEquals("value", context.getAttribute(metadata, "key", String.class)); + context.commit(); + + context = newBuildContext(); + metadata = context.registerInput(inputFile); + assertEquals("value", context.getAttribute(metadata, "key", String.class)); + input = metadata.process(); + assertNull(context.getAttribute(input, "key", String.class)); + assertEquals("value", context.setAttribute(input, "key", "newValue")); + assertEquals("value", context.setAttribute(input, "key", "newValue")); + assertEquals("newValue", context.getAttribute(input, "key", String.class)); + context.commit(); + + context = newBuildContext(); + metadata = context.registerInput(inputFile); + assertEquals("newValue", context.getAttribute(metadata, "key", String.class)); + context.commit(); + } + + @Test + public void testOutputStatus() throws Exception { + Path inputFile = Files.createFile(temp.resolve("inputFile")); + Path outputFile = temp.resolve("outputFile"); + + assertFalse(Files.isReadable(outputFile)); + + TestBuildContext context = newBuildContext(); + DefaultOutput output = context.registerInput(inputFile).process().associateOutput(outputFile); + assertEquals(Status.NEW, output.getStatus()); + output.newOutputStream().close(); + context.commit(); + + // no-change rebuild + context = newBuildContext(); + output = context.registerInput(inputFile).process().associateOutput(outputFile); + assertEquals(Status.UNMODIFIED, output.getStatus()); + context.commit(); + + // modified output + Files.write(outputFile, Collections.singletonList("test")); + context = newBuildContext(); + output = context.registerInput(inputFile).process().associateOutput(outputFile); + assertEquals(Status.MODIFIED, output.getStatus()); + context.commit(); + + // no-change rebuild + context = newBuildContext(); + output = context.registerInput(inputFile).process().associateOutput(outputFile); + assertEquals(Status.UNMODIFIED, output.getStatus()); + context.commit(); + + // deleted output + Files.delete(outputFile); + context = newBuildContext(); + output = context.registerInput(inputFile).process().associateOutput(outputFile); + assertEquals(Status.REMOVED, output.getStatus()); + output.newOutputStream().close(); // processed outputs must exit or commit fails + context.commit(); + } + + @Test + public void testStateSerializationUseTCCL() throws Exception { + Path inputFile = Files.createFile(temp.resolve("inputFile")); + + TestBuildContext context = newBuildContext(); + + URL dummyJar = new File("src/test/projects/dummy/dummy-1.0.jar").toURI().toURL(); + ClassLoader tccl = new URLClassLoader(new URL[] {dummyJar}); + ClassLoader origTCCL = Thread.currentThread().getContextClassLoader(); + try { + Thread.currentThread().setContextClassLoader(tccl); + + Object dummy = tccl.loadClass("dummy.Dummy").newInstance(); + + DefaultResource input = context.registerInput(inputFile).process(); + context.setAttribute(input, "dummy", (Serializable) dummy); + context.commit(); + + context = newBuildContext(); + assertFalse(context.isEscalated()); + assertNotNull(context.getAttribute(context.registerInput(inputFile), "dummy", Serializable.class)); + // no commit + } finally { + Thread.currentThread().setContextClassLoader(origTCCL); + } + + // sanity check, make sure empty state is loaded without proper TCCL + context = newBuildContext(); + assertTrue(context.isEscalated()); + } + + @Test + public void testConfigurationChange() throws Exception { + Path inputFile = Files.createFile(temp.resolve("input")); + Path outputFile = Files.createFile(temp.resolve("output")); + Path looseOutputFile = Files.createFile(temp.resolve("looseOutputFile")); + + TestBuildContext context = newBuildContext(); + context.registerInput(inputFile).process().associateOutput(outputFile); + context.processOutput(looseOutputFile); + context.commit(); + + context = newBuildContext(Collections.singletonMap("config", "parameter")); + DefaultInputMetadata metadata = context.registerInput(inputFile); + assertEquals(Status.MODIFIED, metadata.getStatus()); + DefaultInput input = metadata.process(); + assertEquals(Status.MODIFIED, input.getStatus()); + DefaultOutput output = input.associateOutput(outputFile); + assertEquals(Status.MODIFIED, output.getStatus()); + DefaultOutput looseOutput = context.processOutput(looseOutputFile); + assertEquals(Status.MODIFIED, looseOutput.getStatus()); + } + + @Test + public void testRegisterInputsIncludesExcludes() throws Exception { + Files.createDirectory(temp.resolve("folder")); + Path f1 = Files.createFile(temp.resolve("input1.txt")); + Path f2 = Files.createFile(temp.resolve("folder/input2.txt")); + Path f3 = Files.createFile(temp.resolve("folder/input3.log")); + + TestBuildContext context = newBuildContext(); + List actual; + + actual = toFileList(context.registerInputs(temp, null, Collections.singletonList("**"))); + assertIncludedPaths(Collections.emptyList(), actual); + + actual = toFileList(context.registerInputs(temp, null, null)); + assertIncludedPaths(Arrays.asList(f1, f2, f3), actual); + + actual = toFileList(context.registerInputs(temp, Collections.singletonList("**/*.txt"), null)); + assertIncludedPaths(Arrays.asList(f1, f2), actual); + + actual = toFileList( + context.registerInputs(temp, Collections.singletonList("**"), Collections.singletonList("**/*.log"))); + assertIncludedPaths(Arrays.asList(f1, f2), actual); + } + + @Test + public void testRegisterInputsDirectoryMatching() throws Exception { + Files.createDirectory(temp.resolve("folder")); + Files.createDirectory(temp.resolve("folder/subfolder")); + Path f1 = Files.createFile(temp.resolve("input1.txt")); + Path f2 = Files.createFile(temp.resolve("folder/input2.txt")); + Path f3 = Files.createFile(temp.resolve("folder/subfolder/input3.txt")); + + TestBuildContext context = newBuildContext(); + List actual; + + // from http://ant.apache.org/manual/dirtasks.html#patterns + // When ** is used as the name of a directory in the pattern, it matches zero or more + // directories. + + actual = toFileList(context.registerInputs(temp, Collections.singletonList("**/*.txt"), null)); + assertIncludedPaths(Arrays.asList(f1, f2, f3), actual); + + actual = toFileList(context.registerInputs(temp, Collections.singletonList("folder/**/*.txt"), null)); + assertIncludedPaths(Arrays.asList(f2, f3), actual); + + actual = toFileList(context.registerInputs(temp, Collections.singletonList("folder/*.txt"), null)); + assertIncludedPaths(Collections.singletonList(f2), actual); + + // / is a shortcut for /** + actual = toFileList(context.registerInputs(temp, Collections.singletonList("/"), null)); + assertIncludedPaths(Arrays.asList(f1, f2, f3), actual); + actual = toFileList(context.registerInputs(temp, Collections.singletonList("folder/"), null)); + assertIncludedPaths(Arrays.asList(f2, f3), actual); + + // leading / does not matter + actual = toFileList(context.registerInputs(temp, Collections.singletonList("/folder/"), null)); + assertIncludedPaths(Arrays.asList(f2, f3), actual); + } + + private List toFileList(Iterable inputs) { + List files = new ArrayList<>(); + for (DefaultMetadata input : inputs) { + files.add(input.getPath()); + } + return files; + } + + @Test + public void testClosedContext() throws Exception { + TestBuildContext context = newBuildContext(); + + context.commit(); + assertThrows(IllegalStateException.class, () -> context.registerInput(Files.createTempFile(temp, "", ""))); + assertThrows(IllegalStateException.class, () -> context.processOutput(Files.createTempFile(temp, "", ""))); + } + + @Test + public void testSkipExecution() throws Exception { + Path inputFile = Files.createFile(temp.resolve("inputFile")); + Path outputFile = Files.createFile(temp.resolve("outputFile")); + + TestBuildContext context = newBuildContext(); + DefaultInput input = context.registerInput(inputFile).process(); + input.associateOutput(outputFile); + context.commit(); + + // make a change + Files.write(inputFile, Collections.singletonList("test"), StandardOpenOption.APPEND); + + // skip execution + context = newBuildContext(); + context.markSkipExecution(); + context.commit(); + assertTrue(Files.isReadable(outputFile)); + + // + context = newBuildContext(); + DefaultInputMetadata inputMetadata = context.registerInput(inputFile); + assertEquals(Status.MODIFIED, inputMetadata.getStatus()); + inputMetadata.process(); + context.commit(); + assertFalse(Files.isReadable(outputFile)); + } + + @Test + public void testSkipExecutionModifiedContext() throws Exception { + Path inputFile = Files.createFile(temp.resolve("inputFile")); + Path outputFile = Files.createFile(temp.resolve("outputFile")); + + TestBuildContext context = newBuildContext(); + DefaultInput input = context.registerInput(inputFile).process(); + input.associateOutput(outputFile); + + assertThrows(IllegalStateException.class, context::markSkipExecution); + } + + static Stream fileSystems() { + return Stream.of( + Arguments.of("Windows", (Supplier) () -> Jimfs.newFileSystem(Configuration.windows())), + Arguments.of("Unix", (Supplier) () -> Jimfs.newFileSystem(Configuration.unix())), + Arguments.of("MacOS", (Supplier) () -> Jimfs.newFileSystem(Configuration.osX())), + Arguments.of("Native", (Supplier) FileSystems::getDefault)); + } +} diff --git a/impl/maven-impl/src/test/java/org/apache/maven/internal/build/context/impl/DefaultOutputTest.java b/impl/maven-impl/src/test/java/org/apache/maven/internal/build/context/impl/DefaultOutputTest.java new file mode 100644 index 000000000000..d59e54a8e42a --- /dev/null +++ b/impl/maven-impl/src/test/java/org/apache/maven/internal/build/context/impl/DefaultOutputTest.java @@ -0,0 +1,52 @@ +/* + * 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.internal.build.context.impl; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collections; + +import org.apache.maven.api.build.context.Output; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class DefaultOutputTest { + + /** Temporary directory for tests. */ + @TempDir + private Path temp; + + private TestBuildContext newBuildContext() { + Path stateFile = temp.resolve("buildstate.ctx"); + return new TestBuildContext(stateFile, Collections.emptyMap()); + } + + @Test + public void testOutputStreamCreateParentDirectories() throws Exception { + Path outputFile = temp.resolve("sub/dir/outputFile"); + + TestBuildContext context = newBuildContext(); + Output output = context.processOutput(outputFile); + output.newOutputStream().close(); + + assertTrue(Files.isReadable(outputFile)); + } +} diff --git a/impl/maven-impl/src/test/java/org/apache/maven/internal/build/context/impl/DeltaWorkspaceTest.java b/impl/maven-impl/src/test/java/org/apache/maven/internal/build/context/impl/DeltaWorkspaceTest.java new file mode 100644 index 000000000000..965ece5ef361 --- /dev/null +++ b/impl/maven-impl/src/test/java/org/apache/maven/internal/build/context/impl/DeltaWorkspaceTest.java @@ -0,0 +1,289 @@ +/* + * 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.internal.build.context.impl; + +import java.io.BufferedOutputStream; +import java.io.File; +import java.io.IOException; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.FileTime; +import java.util.Collection; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Stream; + +import org.apache.maven.api.build.context.BuildContextException; +import org.apache.maven.api.build.context.Metadata; +import org.apache.maven.api.build.context.Status; +import org.apache.maven.api.build.context.spi.FileState; +import org.apache.maven.api.build.context.spi.Workspace; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class DeltaWorkspaceTest extends AbstractBuildContextTest { + + public static void touch(Path path) throws InterruptedException { + if (!Files.isRegularFile(path)) { + throw new IllegalArgumentException("Not a file " + path); + } else { + File file = path.toFile(); + long lastModified = file.lastModified(); + file.setLastModified(System.currentTimeMillis()); + if (lastModified == file.lastModified()) { + Thread.sleep(1000L); + file.setLastModified(System.currentTimeMillis()); + } + } + } + + @Test + public void testGetRegisteredInputs() throws Exception { + DeltaWorkspace workspace; + TestBuildContext ctx; + + // initial build + ctx = newBuildContext(); + Path basedir = Files.createDirectory(temp.resolve("basedir")); + Path a = Files.createFile(temp.resolve("basedir/a")); + assertEquals( + 1, toList(ctx.registerAndProcessInputs(basedir, null, null)).size()); + assertEquals(1, toList(ctx.getRegisteredInputs()).size()); + ctx.commit(); + + // no change rebuild + workspace = new DeltaWorkspace(); + ctx = newBuildContext(workspace); + assertEquals( + 1, toList(ctx.registerAndProcessInputs(basedir, null, null)).size()); + assertEquals(1, toList(ctx.getRegisteredInputs()).size()); + assertEquals(0, toList(ctx.getProcessedResources()).size()); + ctx.commit(); + + // add input + workspace = new DeltaWorkspace(); + Path b = Files.createFile(temp.resolve("basedir/b")); + workspace.added.add(DefaultBuildContext.normalize(b)); + ctx = newBuildContext(workspace); + assertEquals( + 2, toList(ctx.registerAndProcessInputs(basedir, null, null)).size()); + assertEquals(2, toList(ctx.getRegisteredInputs()).size()); + assertEquals(1, toList(ctx.getProcessedResources()).size()); + ctx.commit(); + + // modify input + workspace = new DeltaWorkspace(); + workspace.modified.add(DefaultBuildContext.normalize(a)); + ctx = newBuildContext(workspace); + assertEquals( + 2, toList(ctx.registerAndProcessInputs(basedir, null, null)).size()); + assertEquals(2, toList(ctx.getRegisteredInputs()).size()); + assertEquals(1, toList(ctx.getProcessedResources()).size()); + ctx.commit(); + + // remove input + workspace = new DeltaWorkspace(); + Files.delete(a); + workspace.removed.add(DefaultBuildContext.normalize(a)); + ctx = newBuildContext(workspace); + assertEquals( + 2, toList(ctx.registerAndProcessInputs(basedir, null, null)).size()); + assertEquals(2, toList(ctx.getRegisteredInputs()).size()); + assertEquals(0, toList(ctx.getProcessedResources()).size()); + ctx.commit(); + } + + @Test + public void testResourceStatus() throws Exception { + Path basedir = Files.createDirectory(temp.resolve("basedir")); + + DeltaWorkspace workspace; + TestBuildContext ctx; + + // initial build + newBuildContext().commit(); + + // new input + workspace = new DeltaWorkspace(); + Path a = DefaultBuildContext.normalize(Files.createFile(temp.resolve("basedir/a"))); + workspace.added.add(a); + ctx = newBuildContext(workspace); + Metadata input = only(ctx.registerInputs(basedir, null, null)); + assertEquals(Status.NEW, input.getStatus()); + input.process(); + ctx.commit(); + + // no-change rebuild + workspace = new DeltaWorkspace(); + ctx = newBuildContext(workspace); + input = only(ctx.registerInputs(basedir, null, null)); + assertEquals(Status.UNMODIFIED, input.getStatus()); + ctx.commit(); + + // modified input + workspace = new DeltaWorkspace(); + touch(a); + workspace.modified.add(a); + ctx = newBuildContext(workspace); + input = only(ctx.registerInputs(basedir, null, null)); + assertEquals(Status.MODIFIED, input.getStatus()); + input.process(); + ctx.commit(); + + // removed input + workspace = new DeltaWorkspace(); + Files.delete(a); + workspace.removed.add(a); + ctx = newBuildContext(workspace); + assertEquals(1, toList(ctx.registerInputs(basedir, null, null)).size()); + assertEquals(Status.REMOVED, ctx.getResourceStatus(a)); + input.process(); + ctx.commit(); + } + + @Test + public void testCarryOverAndCleanup() throws Exception { + Files.createDirectory(temp.resolve("basedir")); + Path inputdir = Files.createDirectory(temp.resolve("basedir/inputdir")); + Path outputdir = Files.createDirectory(temp.resolve("basedir/outputdir")); + Path file = Files.createFile(temp.resolve("basedir/inputdir/file.txt")); + + DeltaWorkspace workspace; + TestBuildContext ctx; + Collection inputs; + + // initial build + ctx = newBuildContext(); + inputs = ctx.registerAndProcessInputs(inputdir, null, null); + assertEquals(1, inputs.size()); + inputs.iterator().next().associateOutput(Files.createFile(temp.resolve("basedir/outputdir/file.out"))); + ctx.commit(); + + // no-change rebuild + workspace = new DeltaWorkspace(); + ctx = newBuildContext(workspace); + ctx.registerAndProcessInputs(inputdir, null, null); + ctx.commit(); + assertTrue(Files.exists(outputdir.resolve("file.out"))); + + // "delete" input + workspace = new DeltaWorkspace(); + workspace.removed.add(DefaultBuildContext.normalize(file)); + ctx = newBuildContext(workspace); + ctx.registerAndProcessInputs(inputdir, null, null); + ctx.commit(); + assertFalse(Files.exists(outputdir.resolve("file.out"))); + } + + private T only(Iterable values) { + List list = toList(values); + assertEquals(1, list.size()); + return list.get(0); + } + + @SuppressWarnings("checkstyle:VisibilityModifier") + private static class DeltaWorkspace implements Workspace { + + final Set added = new HashSet<>(); + final Set modified = new HashSet<>(); + final Set removed = new HashSet<>(); + + @Override + public Mode getMode() { + return Mode.DELTA; + } + + @Override + public Workspace escalate() { + return new FilesystemWorkspace() { + @Override + public Mode getMode() { + return Mode.ESCALATED; + } + }; + } + + @Override + public boolean isPresent(Path file) { + return Files.exists(file); + } + + @Override + public boolean isRegularFile(Path file) { + return Files.isRegularFile(file); + } + + @Override + public boolean isDirectory(Path file) { + return Files.isDirectory(file); + } + + @Override + public void deleteFile(Path file) { + try { + if (Files.exists(file)) { + Files.delete(file); + } + } catch (IOException e) { + throw new BuildContextException(e); + } + } + + @Override + public void processOutput(Path path) {} + + @Override + public OutputStream newOutputStream(Path path) { + try { + return new BufferedOutputStream(Files.newOutputStream(path)); + } catch (IOException e) { + throw new BuildContextException(e); + } + } + + @Override + public Status getResourceStatus(Path file, FileTime lastModified, long size) { + // delta workspace returns resource status compared to the previous build + if (added.contains(file)) { + return Status.NEW; + } + if (modified.contains(file)) { + return Status.MODIFIED; + } + if (removed.contains(file)) { + return Status.REMOVED; + } + return Status.UNMODIFIED; + } + + @Override + public Stream walk(Path basedir) { + return Stream.concat( + Stream.concat( + added.stream().map(p -> new FileState(p, Status.NEW)), + modified.stream().map(p -> new FileState(p, Status.MODIFIED))), + removed.stream().map(p -> new FileState(p, Status.REMOVED))); + } + } +} diff --git a/impl/maven-impl/src/test/java/org/apache/maven/internal/build/context/impl/ResourceMessagesTest.java b/impl/maven-impl/src/test/java/org/apache/maven/internal/build/context/impl/ResourceMessagesTest.java new file mode 100644 index 000000000000..a8b14ebc2213 --- /dev/null +++ b/impl/maven-impl/src/test/java/org/apache/maven/internal/build/context/impl/ResourceMessagesTest.java @@ -0,0 +1,167 @@ +/* + * 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.internal.build.context.impl; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; + +import org.apache.maven.api.build.context.Severity; +import org.apache.maven.api.build.context.spi.Message; +import org.apache.maven.api.build.context.spi.Sink; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class ResourceMessagesTest { + /** Temporary directory for tests. */ + @TempDir + private Path temp; + + private TestBuildContext newBuildContext() throws IOException { + return new TestBuildContext(); + } + + @Test + public void testInputMessages() throws Exception { + Path inputFile = Files.createFile(temp.resolve("inputFile")); + + // initial message + TestBuildContext context = newBuildContext(); + DefaultInputMetadata metadata = context.registerInput(inputFile); + inputFile = metadata.getPath(); + DefaultInput input = metadata.process(); + input.addMessage(0, 0, "message", Severity.WARNING, null); + context.commit(); + + // the message is retained during no-change rebuild + context = newBuildContext(); + metadata = context.registerInput(inputFile); + List messages = context.getMessages(inputFile); + assertEquals(1, messages.size()); + assertEquals("message", messages.get(0).getMessage()); + context.commit(); + + // the message is retained during second no-change rebuild + context = newBuildContext(); + metadata = context.registerInput(inputFile); + messages = context.getMessages(inputFile); + assertEquals(1, messages.size()); + assertEquals("message", messages.get(0).getMessage()); + context.commit(); + + // new message + context = newBuildContext(); + metadata = context.registerInput(inputFile); + input = metadata.process(); + input.addMessage(0, 0, "newMessage", Severity.WARNING, null); + context.commit(); + context = newBuildContext(); + metadata = context.registerInput(inputFile); + messages = context.getMessages(inputFile); + assertEquals(1, messages.size()); + assertEquals("newMessage", messages.get(0).getMessage()); + context.commit(); + + // removed message + context = newBuildContext(); + metadata = context.registerInput(inputFile); + input = metadata.process(); + context.commit(); + context = newBuildContext(); + metadata = context.registerInput(inputFile); + assertNull(context.getMessages(inputFile)); + context.commit(); + } + + @Test + public void testInputMessagesNullMessageText() throws Exception { + Path inputFile = Files.createFile(temp.resolve("inputFile")); + + // initial message + TestBuildContext context = newBuildContext(); + DefaultInputMetadata metadata = context.registerInput(inputFile); + inputFile = metadata.getPath(); + DefaultInput input = metadata.process(); + input.addMessage(0, 0, null, Severity.WARNING, null); + context.commit(); + + context = newBuildContext(); + metadata = context.registerInput(inputFile); + List messages = context.getMessages(inputFile); + assertEquals(1, messages.size()); + assertNull(messages.get(0).getMessage()); + context.commit(); + } + + @Test + public void testExcludedInputMessageCleanup() throws Exception { + Path inputFile = Files.createFile(temp.resolve("inputFile")); + + // initial message + TestBuildContext context = newBuildContext(); + DefaultInputMetadata metadata = context.registerInput(inputFile); + inputFile = metadata.getPath(); + DefaultInput input = metadata.process(); + input.addMessage(0, 0, "message", Severity.WARNING, null); + context.commit(); + + // input is removed from input set, make sure input messages are cleaned up + final List cleared = new ArrayList<>(); + newBuildContext().commit(new Sink() { + @Override + public void messages(Path resource, boolean isNew, Collection messages) { + assertTrue(messages.isEmpty()); + } + + public void clear(Path resource) { + cleared.add(resource); + } + }); + + assertEquals(1, cleared.size()); + assertEquals(inputFile, cleared.get(0)); + } + + private class TestBuildContext extends DefaultBuildContext { + protected TestBuildContext() throws IOException { + super(new FilesystemWorkspace(), temp.resolve("buildstate.ctx"), Collections.emptyMap(), null); + } + + public void commit() throws IOException { + super.commit(null); + } + + public List getMessages(Path resource) { + Collection messages = getState(resource).getResourceMessages(resource); + return messages != null ? new ArrayList<>(messages) : null; + } + + protected DefaultBuildContextState getState(Path source) { + return isProcessedResource(source) ? this.state : this.oldState; + } + } +} diff --git a/impl/maven-impl/src/test/java/org/apache/maven/internal/build/context/impl/Snippets.java b/impl/maven-impl/src/test/java/org/apache/maven/internal/build/context/impl/Snippets.java new file mode 100644 index 000000000000..8379c16e980a --- /dev/null +++ b/impl/maven-impl/src/test/java/org/apache/maven/internal/build/context/impl/Snippets.java @@ -0,0 +1,137 @@ +/* + * 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.internal.build.context.impl; + +import java.io.DataOutputStream; +import java.io.IOException; +import java.io.ObjectOutputStream; +import java.io.OutputStream; +import java.io.Serializable; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; + +import org.apache.maven.api.build.context.BuildContext; +import org.apache.maven.api.build.context.BuildContextException; +import org.apache.maven.api.build.context.Input; +import org.apache.maven.api.build.context.InputSet; +import org.apache.maven.api.build.context.Output; +import org.apache.maven.api.build.context.Resource; +import org.junit.jupiter.api.Test; + +class Snippets extends AbstractBuildContextTest { + + @Test + void snippet1To1Mapping() throws IOException { + BuildContext buildContext = newBuildContext(); + Path sourceDirectory = Files.createDirectory(temp.resolve("src")); + Path targetDirectory = Files.createDirectory(temp.resolve("out")); + List includes = Collections.emptyList(); + List excludes = Collections.emptyList(); + + for (Input input : buildContext.registerAndProcessInputs(sourceDirectory, includes, excludes)) { + Path outputPath = targetDirectory.resolve(sourceDirectory.relativize(input.getPath())); + Output output = input.associateOutput(outputPath); + try (OutputStream os = output.newOutputStream()) { + Files.copy(input.getPath(), os); + } + } + } + + @Test + void snippetNTo1Aggregation() throws IOException { + BuildContext buildContext = newBuildContext(); + Path sourceDirectory = Files.createDirectory(temp.resolve("src")); + Path targetDirectory = Files.createDirectory(temp.resolve("out")); + List includes = Collections.emptyList(); + List excludes = Collections.emptyList(); + Path outputPath = targetDirectory.resolve("output.jar"); + + InputSet registeredOutput = buildContext.newInputSet(); + registeredOutput.registerInputs(sourceDirectory, includes, excludes); + // re-create output if any the inputs were added, changed or deleted since previous build + registeredOutput.aggregate(outputPath, this::aggregate); + } + + @Test + void snippetNTo1AggregationWithMetadata() throws IOException { + BuildContext buildContext = newBuildContext(); + Path sourceDirectory = Files.createDirectory(temp.resolve("src")); + Path targetDirectory = Files.createDirectory(temp.resolve("out")); + List includes = Collections.emptyList(); + List excludes = Collections.emptyList(); + Path outputPath = targetDirectory.resolve("output.jar"); + + InputSet registeredOutput = buildContext.newInputSet(); + registeredOutput.registerInputs(sourceDirectory, includes, excludes); + registeredOutput.aggregate(outputPath, "myStep", new HashMap<>(), this::glean, this::merge, this::write); + } + + @Test + void snippetNTo1AggregationWithMetadataManual() throws IOException { + BuildContext buildContext = newBuildContext(); + Path sourceDirectory = Files.createDirectory(temp.resolve("src")); + Path targetDirectory = Files.createDirectory(temp.resolve("out")); + List includes = Collections.emptyList(); + List excludes = Collections.emptyList(); + Path outputPath = targetDirectory.resolve("output.jar"); + + InputSet registeredOutput = buildContext.newInputSet(); + registeredOutput.registerInputs(sourceDirectory, includes, excludes); + registeredOutput.aggregate( + outputPath, + (output, inputs) -> + write(output, inputs.stream().map(this::glean).reduce(new HashMap<>(), this::merge))); + } + + private HashMap glean(Resource resource) { + HashMap m = new HashMap<>(); + m.put("name", resource.getPath().toString()); + return m; + } + + private HashMap merge(HashMap m1, HashMap m2) { + HashMap m = new HashMap<>(m1); + m.putAll(m2); + return m; + } + + private void write(Output output, HashMap metadata) { + try { + try (ObjectOutputStream os = new ObjectOutputStream(new DataOutputStream(output.newOutputStream()))) { + os.writeObject(metadata); + } + } catch (IOException e) { + throw new BuildContextException(e); + } + } + + private void aggregate(Output output, Collection inputs) { + try (OutputStream os = output.newOutputStream()) { + for (Input input : inputs) { + Files.copy(input.getPath(), os); + } + } catch (IOException e) { + throw new BuildContextException(e); + } + } +} diff --git a/impl/maven-impl/src/test/java/org/apache/maven/internal/build/context/impl/TestBuildContext.java b/impl/maven-impl/src/test/java/org/apache/maven/internal/build/context/impl/TestBuildContext.java new file mode 100644 index 000000000000..5692cc0386c5 --- /dev/null +++ b/impl/maven-impl/src/test/java/org/apache/maven/internal/build/context/impl/TestBuildContext.java @@ -0,0 +1,89 @@ +/* + * 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.internal.build.context.impl; + +import java.io.IOException; +import java.io.Serializable; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.apache.maven.api.build.context.Status; +import org.apache.maven.api.build.context.spi.Workspace; + +class TestBuildContext extends DefaultBuildContext { + + TestBuildContext(Path stateFile, Map configuration) { + this(new FilesystemWorkspace(), stateFile, configuration); + } + + TestBuildContext(Workspace workspace, Path stateFile, Map configuration) { + super(workspace, stateFile, configuration, null); + } + + public void commit() throws IOException { + super.commit(null); + } + + public Collection getRegisteredInputs() { + List result = new ArrayList<>(); + for (Path resource : state.getResources().keySet()) { + result.add(new DefaultInputMetadata(this, state, resource)); + } + for (Path resource : oldState.getResources().keySet()) { + if (!state.isResource(resource)) { + result.add(new DefaultInputMetadata(this, oldState, resource)); + } + } + return result; + } + + @Override + public Set getProcessedResources() { + return super.getProcessedResources(); + } + + public Status getResourceStatus(Path resource) { + return super.getResourceStatus(resource); + } + + public Collection getAssociatedOutputs(DefaultInputMetadata metadata) { + Path resource = metadata.getPath(); + return super.getAssociatedOutputs(getState(resource), resource); + } + + public Serializable setAttribute(DefaultResource resource, String key, T value) { + return super.setResourceAttribute(resource.getPath(), key, value); + } + + public V getAttribute(DefaultMetadata resource, String key, Class clazz) { + return super.getResourceAttribute(getState(resource.getPath()), resource.getPath(), key, clazz); + } + + public V getAttribute(DefaultResource resource, String key, Class clazz) { + return super.getResourceAttribute(getState(resource.getPath()), resource.getPath(), key, clazz); + } + + protected DefaultBuildContextState getState(Path source) { + return isProcessedResource(source) ? this.state : this.oldState; + } +} diff --git a/impl/maven-impl/src/test/projects/dummy-1.0.jar b/impl/maven-impl/src/test/projects/dummy-1.0.jar new file mode 100644 index 0000000000000000000000000000000000000000..740f149a1460e48eab3956cd14cd85c5acfe4a7a GIT binary patch literal 1902 zcmWIWW@h1H00AS1C>JmTN^k;cU)K;vT~9wZ{Q#&k4hBP@GOb5GrCC7fMj#eJR_5#I z=jrAe9HQsz_SyH$X&-N0y^Flwx?1PXoZlQ|aK-q+(;^+Ob0>7Xb)7iapDYQRBBp&n zrDErt?jT|Bpx~0ZT5CmQxP>Mg2Z`1QpAPyAl<_hy_B!y$VR7)M?x4?`J}WNL+!d#J zQ$hR8SDr_`Je4~ub~3=+#sGF3&^d6x24tm_=H^zSxj%l(Oy_GrISEDv20^$Q7m!N5 zG_t>N zUKl6ccIwha+xh*qZ|)yIFMEK+mOpgQ&KEmg?5a?=FrNGB(c}QxKC_lvHn+8$)Gw@z zn&!FU(^j5jwY>`6DSIaFH2cSzeKby<^KC-3+Q~?r9zV`|TH2pIYBMK))cI?1V)B>3 zcIS-0-om@m+>aRD?R@`X!J?)M+da~gmhRBICVsh%QR`2c-qU^o{e9h6Q`Oh7zj`eF z;*>KhZ`gCriz#2@H@8o-eRn(LH$Nhv(BqmP7zpsV&P^;!&4Y&+X2Q_LRGgWwSCW{W zSd^)kRGOKSl313XnUa{7oT^unT3mw1C}UEMLXS;R+bEZ}dYjK402&kuj7=-@3@pgc z)vL(OsrBE@cgR5C_Gi)1@=0My2_Ba@Rmu|VCoT4hUwLUu(|+}r%%=`z%zwMN@?XxY z442#4m(vQpCR-c|lUjOPseAqO%kKo9HwoN}3|>^yF+-mnW^OZcjf-_+t3+u&#KV8ws+f{Fe)GamsRS68XHbqX?8 z6GTP!ZP7Sb^sO~mG=87+{FYjQ^DYs_=8N`9t~v79DB5Rr?J;{_kJQ@{pA0!ZvB4r3 zl1pc1osuX3hDRw7JCYwR1x5JyUvb$_(tm-_N{ec~3kP11S2YqouWHzSh>Gw#v}=qDf$ zV0a7i9U}cAv|%r;K#DJmTN^k;cU)K;vT~9wZ{Q#&k4hBP@GOb5GrCC7fMj#eJR_5#I z=jrAe9HQsz_SyH$X&-N0y^Flwx?1PXoZlQ|aK-q+(;^+Ob0>7Xb)7iapDYQRBBp&n zrDErt?jT|Bpx~0ZT5CmQxP>Mg2Z`1QpAPyAl<_hy_B!y$VR7)M?x4?`J}WNL+!d#J zQ$hR8SDr_`Je4~ub~3=+#sGF3&^d6x24tm_=H^zSxj%l(Oy_GrISEDv20^$Q7m!N5 zG_t>N zUKl6ccIwha+xh*qZ|)yIFMEK+mOpgQ&KEmg?5a?=FrNGB(c}QxKC_lvHn+8$)Gw@z zn&!FU(^j5jwY>`6DSIaFH2cSzeKby<^KC-3+Q~?r9zV`|TH2pIYBMK))cI?1V)B>3 zcIS-0-om@m+>aRD?R@`X!J?)M+da~gmhRBICVsh%QR`2c-qU^o{e9h6Q`Oh7zj`eF z;*>KhZ`gCriz#2@H@8o-eRn(LH$Nhv(BqmP7zpsV&P^;!&4Y&+X2Q_LRGgWwSCW{W zSd^)kRGOKSl313XnUa{7oT^unT3mw1C}UEMLXS;R+bEZ}dYjK402&kuj7=-@3@pgc z)vL(OsrBE@cgR5C_Gi)1@=0My2_Ba@Rmu|VCoT4hUwLUu(|+}r%%=`z%zwMN@?XxY z442#4m(vQpCR-c|lUjOPseAqO%kKo9HwoN}3|>^yF+-mnW^OZcjf-_+t3+u&#KV8ws+f{Fe)GamsRS68XHbqX?8 z6GTP!ZP7Sb^sO~mG=87+{FYjQ^DYs_=8N`9t~v79DB5Rr?J;{_kJQ@{pA0!ZvB4r3 zl1pc1osuX3hDRw7JCYwR1x5JyUvb$_(tm-_N{ec~3kP11S2YqouWHzSh>Gw#v}=qDf$ zV0a7i9U}cAv|%r;K#D + + + 4.0.0 + + io.takari.buildavoidance.test + dummy + 1.0 + jar + + + UTF-8 + + + diff --git a/impl/maven-impl/src/test/projects/dummy/src/main/java/dummy/Dummy.java b/impl/maven-impl/src/test/projects/dummy/src/main/java/dummy/Dummy.java new file mode 100644 index 000000000000..cf70f3182ea8 --- /dev/null +++ b/impl/maven-impl/src/test/projects/dummy/src/main/java/dummy/Dummy.java @@ -0,0 +1,7 @@ +package dummy; + +import java.io.Serializable; + +public class Dummy implements Serializable { + +} diff --git a/impl/maven-impl/src/test/projects/pom.xml b/impl/maven-impl/src/test/projects/pom.xml new file mode 100644 index 000000000000..36ec2f1f61a2 --- /dev/null +++ b/impl/maven-impl/src/test/projects/pom.xml @@ -0,0 +1,15 @@ + + + + 4.0.0 + + io.takari.buildavoidance.test + dummy + 1.0 + jar + + + UTF-8 + + + diff --git a/impl/maven-impl/src/test/projects/src/main/java/dummy/Dummy.java b/impl/maven-impl/src/test/projects/src/main/java/dummy/Dummy.java new file mode 100644 index 000000000000..cf70f3182ea8 --- /dev/null +++ b/impl/maven-impl/src/test/projects/src/main/java/dummy/Dummy.java @@ -0,0 +1,7 @@ +package dummy; + +import java.io.Serializable; + +public class Dummy implements Serializable { + +} diff --git a/impl/maven-impl/src/test/resources/simplelogger.properties b/impl/maven-impl/src/test/resources/simplelogger.properties new file mode 100644 index 000000000000..1827220b3e8e --- /dev/null +++ b/impl/maven-impl/src/test/resources/simplelogger.properties @@ -0,0 +1,18 @@ +# 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. + +org.slf4j.simpleLogger.showDateTime=true From 0ea77eaa59ae8dcd150b26a00eea0ccf1d7036a2 Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Wed, 29 Jul 2026 00:06:25 +0200 Subject: [PATCH 02/16] Add comprehensive Javadoc with use cases, examples, and package docs - Add package-info.java for both context and context.spi packages with architecture overview, use case examples, and SPI integration guide - Enhance BuildContext Javadoc with three usage patterns: one-to-one transformation, two-pass status inspection, and aggregated builds - Add code examples to Input, Output, InputSet, Metadata, Resource - Add @see cross-references between all related types - Enrich Incremental annotation with configuration parameter example - Document SPI lifecycle: environment, workspace modes, finalizer, sink message flow Co-Authored-By: Claude Opus 4.6 --- .../maven/api/build/context/BuildContext.java | 81 ++++++-- .../build/context/BuildContextException.java | 6 + .../maven/api/build/context/Incremental.java | 28 ++- .../apache/maven/api/build/context/Input.java | 28 ++- .../maven/api/build/context/InputSet.java | 47 ++++- .../maven/api/build/context/Metadata.java | 32 +++ .../maven/api/build/context/Output.java | 32 ++- .../maven/api/build/context/Resource.java | 21 ++ .../maven/api/build/context/Severity.java | 6 + .../maven/api/build/context/Status.java | 7 + .../maven/api/build/context/package-info.java | 183 ++++++++++++++++++ .../context/spi/BuildContextEnvironment.java | 16 ++ .../context/spi/BuildContextFinalizer.java | 15 ++ .../context/spi/CommitableBuildContext.java | 12 ++ .../api/build/context/spi/FileState.java | 8 + .../maven/api/build/context/spi/Message.java | 15 ++ .../maven/api/build/context/spi/Sink.java | 18 +- .../api/build/context/spi/Workspace.java | 27 ++- .../api/build/context/spi/package-info.java | 124 ++++++++++++ 19 files changed, 678 insertions(+), 28 deletions(-) create mode 100644 api/maven-api-core/src/main/java/org/apache/maven/api/build/context/package-info.java create mode 100644 api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/package-info.java diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/BuildContext.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/BuildContext.java index dac07135867d..fd66d93a51d8 100644 --- a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/BuildContext.java +++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/BuildContext.java @@ -29,19 +29,78 @@ /** * Provides incremental build support by tracking inputs, outputs and their relationships. - *

- * A first use case is a basic build where inputs and outputs are identified but without - * any relationship between those. For such cases, the code would look like: - *

- *     context.registerInput(path1);
- *     context.registerInput(path2);
- * 
- *

- * A second use case is an aggregated build where multiple inputs contribute to a single - * output. Use {@link #newInputSet()} to create an {@link InputSet} and register inputs that are then - * aggregated into outputs. + * + *

This is the primary entry point for mojos that want to participate in incremental builds. + * The build context tracks which files were processed in the previous build, detects changes + * (new, modified, removed), and automatically cleans up stale outputs whose inputs no longer + * exist.

+ * + *

One-to-one transformation

+ * + *

The most common pattern: each input file produces one output file. Use + * {@link #registerAndProcessInputs(Path, Collection, Collection)} to register and filter + * in a single call — only changed inputs are returned:

+ * + *
{@code
+ * @Inject
+ * BuildContext buildContext;
+ *
+ * public void execute() {
+ *     for (Input input : buildContext.registerAndProcessInputs(
+ *             sourceDir, List.of("**/*.xml"), null)) {
+ *
+ *         Path outPath = outputDir.resolve(sourceDir.relativize(input.getPath()));
+ *         Output output = input.associateOutput(outPath);
+ *         try (OutputStream os = output.newOutputStream()) {
+ *             transform(input.getPath(), os);
+ *         }
+ *     }
+ * }
+ * }
+ * + *

Two-pass processing with status inspection

+ * + *

When you need to inspect the change status before deciding what to do, use + * {@link #registerInputs(Path, Collection, Collection)} to get {@link Metadata} wrappers, + * then call {@link Metadata#process()} on the ones you want to process:

+ * + *
{@code
+ * for (Metadata meta : buildContext.registerInputs(sourceDir, null, null)) {
+ *     if (meta.getStatus() != Status.UNMODIFIED) {
+ *         Input input = meta.process();
+ *         // ... process the changed input
+ *     }
+ * }
+ * }
+ * + *

Aggregated builds

+ * + *

When multiple inputs contribute to a single output (e.g., merging property files, + * generating an index), use {@link #newInputSet()} to create an {@link InputSet}:

+ * + *
{@code
+ * InputSet inputSet = buildContext.newInputSet();
+ * inputSet.registerInputs(sourceDir, List.of("**/*.properties"), null);
+ * inputSet.aggregate(mergedOutput, (output, inputs) -> {
+ *     // only called if any input changed since the last build
+ *     Properties merged = new Properties();
+ *     for (Input input : inputs) {
+ *         try (InputStream is = Files.newInputStream(input.getPath())) {
+ *             merged.load(is);
+ *         }
+ *     }
+ *     try (OutputStream os = output.newOutputStream()) {
+ *         merged.store(os, null);
+ *     }
+ * });
+ * }
* * @since 4.0.0 + * @see Input + * @see Output + * @see InputSet + * @see Metadata + * @see Status */ @Experimental @NotThreadSafe diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/BuildContextException.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/BuildContextException.java index e3951420da62..666e3a882f5c 100644 --- a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/BuildContextException.java +++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/BuildContextException.java @@ -26,7 +26,13 @@ /** * Exception thrown when an error occurs during incremental build context operations. * + *

This unchecked exception wraps I/O errors and other failures that occur while + * registering inputs, writing outputs, persisting state, or walking the file system. + * It extends {@link MavenException} so that it can be caught alongside other Maven + * API exceptions.

+ * * @since 4.0.0 + * @see BuildContext */ @Experimental public class BuildContextException extends MavenException { diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Incremental.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Incremental.java index 4251f1034e08..35f50a9463e2 100644 --- a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Incremental.java +++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Incremental.java @@ -31,13 +31,31 @@ /** * Optional annotation that customizes how the incremental build implementation handles - * configuration parameters. - *

- * This annotation is mandatory on {@link org.apache.maven.api.Project} - * and {@link org.apache.maven.api.Session} attributes to indicate whether - * they should be considered for change detection. + * configuration parameters for change detection. + * + *

When a mojo is re-executed, the build context compares the current configuration + * parameter values against those from the previous build. If any tracked parameter has + * changed, all inputs are treated as modified, forcing a full rebuild. This annotation + * controls which parameters participate in that comparison.

+ * + *

By default, all mojo parameters are considered. Use {@code @Incremental(consider = false)} + * to exclude parameters that do not affect the output (e.g., logging verbosity, thread count). + * This annotation is mandatory on {@link org.apache.maven.api.Project} + * and {@link org.apache.maven.api.Session} attributes to explicitly indicate whether + * they should be considered:

+ * + *
{@code
+ * @Parameter(defaultValue = "${project}", readonly = true)
+ * @Incremental(consider = false)
+ * private Project project;
+ *
+ * @Parameter(property = "outputDirectory", required = true)
+ * @Incremental  // considered by default
+ * private Path outputDirectory;
+ * }
* * @since 4.0.0 + * @see BuildContext */ @Experimental @Retention(RUNTIME) diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Input.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Input.java index 3f00ef71c0d8..19d5b70e1b69 100644 --- a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Input.java +++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Input.java @@ -27,9 +27,35 @@ /** * Represents an input resource in the incremental build context. - * An input can be associated with one or more {@link Output} resources. + * + *

An {@code Input} is obtained either by calling {@link Metadata#process()} on a registered + * input metadata, or directly from + * {@link BuildContext#registerAndProcessInputs(java.nio.file.Path, java.util.Collection, java.util.Collection)}. + * Once you have an {@code Input}, you can read its file and associate it with one or more + * {@link Output} resources to establish the input-to-output relationship used for + * stale-output cleanup:

+ * + *
{@code
+ * Input input = buildContext.registerInput(sourceFile).process();
+ *
+ * // One input -> one output
+ * Output output = input.associateOutput(targetFile);
+ * try (OutputStream os = output.newOutputStream()) {
+ *     transform(input.getPath(), os);
+ * }
+ *
+ * // One input -> multiple outputs
+ * Output header = input.associateOutput(headerFile);
+ * Output body   = input.associateOutput(bodyFile);
+ * }
+ * + *

When an input is removed in a subsequent build, the build context automatically deletes + * all outputs that were associated with it in the previous build.

* * @since 4.0.0 + * @see BuildContext#registerInput(java.nio.file.Path) + * @see Output + * @see Metadata#process() */ @Experimental @NotThreadSafe diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/InputSet.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/InputSet.java index b06a7acdef87..08a267d74b34 100644 --- a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/InputSet.java +++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/InputSet.java @@ -33,11 +33,52 @@ /** * Represents a set of inputs being aggregated into one or more outputs. - *

- * An input set allows registering multiple input files and then aggregating them - * into an output file, with automatic change detection across builds. + * + *

An input set is the mechanism for many-to-one (or many-to-few) + * transformations, where multiple input files contribute to a single output file. The + * build context tracks which inputs belong to the set and only invokes the aggregation + * callback when at least one input has changed since the previous build.

+ * + *

Typical use cases include:

+ *
    + *
  • Merging multiple property files or configuration fragments into one
  • + *
  • Generating an index or manifest from a set of source files
  • + *
  • Building a ZIP or JAR from a directory tree
  • + *
  • Computing aggregate statistics or checksums
  • + *
+ * + *

Basic aggregation

+ * + *
{@code
+ * InputSet inputSet = buildContext.newInputSet();
+ * inputSet.registerInputs(sourceDir, List.of("**/*.properties"), null);
+ *
+ * // The callback is only invoked if any input changed
+ * boolean written = inputSet.aggregate(mergedOutput, (output, inputs) -> {
+ *     Properties merged = new Properties();
+ *     for (Input input : inputs) {
+ *         try (InputStream is = Files.newInputStream(input.getPath())) {
+ *             merged.load(is);
+ *         }
+ *     }
+ *     try (OutputStream os = output.newOutputStream()) {
+ *         merged.store(os, null);
+ *     }
+ * });
+ * }
+ * + *

Indirect metadata aggregation

+ * + *

The second {@link #aggregate(java.nio.file.Path, String, Serializable, Function, + * BinaryOperator, BiConsumer) aggregate} overload supports caching per-input metadata + * across builds. This is useful when extracting metadata from each input is expensive + * (e.g., parsing an AST). Only changed inputs are re-processed; cached metadata is + * reused for unchanged inputs.

* * @since 4.0.0 + * @see BuildContext#newInputSet() + * @see Input + * @see Output */ @Experimental @NotThreadSafe diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Metadata.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Metadata.java index e7b4900e440b..57a97ad9da4c 100644 --- a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Metadata.java +++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Metadata.java @@ -29,8 +29,40 @@ * Wraps a registered resource with its file path and change status, * and provides a {@link #process()} method to obtain the full resource handle. * + *

{@code Metadata} is a lightweight wrapper returned by + * {@link BuildContext#registerInput(java.nio.file.Path)} and + * {@link BuildContext#registerInputs(java.nio.file.Path, java.util.Collection, java.util.Collection)}. + * It allows mojos to inspect a resource's {@link Status} before deciding whether to + * process it, enabling selective processing patterns:

+ * + *
{@code
+ * for (Metadata meta : buildContext.registerInputs(sourceDir, null, null)) {
+ *     switch (meta.getStatus()) {
+ *         case NEW:
+ *         case MODIFIED:
+ *             Input input = meta.process();
+ *             compile(input.getPath());
+ *             break;
+ *         case REMOVED:
+ *             // The build context handles cleanup of associated outputs
+ *             break;
+ *         case UNMODIFIED:
+ *             // Nothing to do — skip this input
+ *             break;
+ *     }
+ * }
+ * }
+ * + *

Calling {@link #process()} marks the resource as "processed" in this build. The build + * context uses this information to determine which outputs are stale: if an input was + * registered but not processed, its associated outputs from the previous build are + * carried over unchanged.

+ * * @param the resource type ({@link Input} or {@link Output}) * @since 4.0.0 + * @see BuildContext#registerInput(java.nio.file.Path) + * @see BuildContext#registerInputs(java.nio.file.Path, java.util.Collection, java.util.Collection) + * @see Status */ @Experimental @NotThreadSafe diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Output.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Output.java index 70d9a6e4a5e5..76b20d8983a0 100644 --- a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Output.java +++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Output.java @@ -30,12 +30,36 @@ /** * Represents an output resource in the incremental build context. - *

- * The output stream returned by {@link #newOutputStream()} may be a caching stream that only - * overwrites the target file when the content has actually changed, helping IDEs and downstream - * tools avoid unnecessary rebuilds. + * + *

An {@code Output} is obtained by calling {@link Input#associateOutput(java.nio.file.Path)} + * or {@link BuildContext#processOutput(java.nio.file.Path)}. It provides methods to write the + * output content:

+ * + *
{@code
+ * Output output = input.associateOutput(targetPath);
+ *
+ * // Binary output
+ * try (OutputStream os = output.newOutputStream()) {
+ *     writeBytes(os);
+ * }
+ *
+ * // Text output
+ * try (BufferedWriter w = output.newBufferedWriter(StandardCharsets.UTF_8)) {
+ *     w.write("generated content");
+ * }
+ * }
+ * + *

The output stream returned by {@link #newOutputStream()} may be a caching stream + * that only overwrites the target file when the content has actually changed. This prevents + * downstream tools and IDEs from seeing a modified timestamp on an unchanged file, avoiding + * unnecessary cascading rebuilds.

+ * + *

The build context automatically creates parent directories for the output file + * if they do not exist.

* * @since 4.0.0 + * @see Input#associateOutput(java.nio.file.Path) + * @see BuildContext#processOutput(java.nio.file.Path) */ @Experimental @NotThreadSafe diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Resource.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Resource.java index 6002f98562a0..c7158a0e223b 100644 --- a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Resource.java +++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Resource.java @@ -29,7 +29,28 @@ /** * Represents a build resource (input or output file) tracked by the incremental build context. * + *

This is the base interface for both {@link Input} and {@link Output}. Every resource has + * a file {@link #getPath() path}, a change {@link #getStatus() status}, and the ability to + * carry diagnostic {@link #addMessage messages} that are reported to the user at the end + * of the build.

+ * + *

Diagnostic messages are attached to resources rather than logged directly so that IDEs + * can display them at the correct file and line location:

+ * + *
{@code
+ * try {
+ *     compile(input.getPath());
+ * } catch (CompileError e) {
+ *     input.addMessage(e.getLine(), e.getColumn(),
+ *             e.getMessage(), Severity.ERROR, e);
+ * }
+ * }
+ * * @since 4.0.0 + * @see Input + * @see Output + * @see Severity + * @see Status */ @Experimental @NotThreadSafe diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Severity.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Severity.java index dd0236afc7ee..620fc5123b42 100644 --- a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Severity.java +++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Severity.java @@ -24,7 +24,13 @@ /** * Severity level for build messages attached to resources. * + *

Used with {@link Resource#addMessage(int, int, String, Severity, Throwable)} to + * classify diagnostic messages. Messages are collected during the build and reported + * through the {@link org.apache.maven.api.build.context.spi.Sink} at commit time.

+ * * @since 4.0.0 + * @see Resource#addMessage(int, int, String, Severity, Throwable) + * @see org.apache.maven.api.build.context.spi.Message */ @Experimental @Immutable diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Status.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Status.java index a83592de3033..fc70e57ef62f 100644 --- a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Status.java +++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Status.java @@ -24,7 +24,14 @@ /** * Indicates the change status of a resource between the current and previous build. * + *

The status is determined by comparing the current file metadata (timestamp, size) + * against the state saved from the previous build. It is available on both + * {@link Metadata#getStatus()} (before processing) and {@link Resource#getStatus()} + * (after processing).

+ * * @since 4.0.0 + * @see Metadata#getStatus() + * @see Resource#getStatus() */ @Experimental @Immutable diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/package-info.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/package-info.java new file mode 100644 index 000000000000..54f3245dc0cf --- /dev/null +++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/package-info.java @@ -0,0 +1,183 @@ +/* + * 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. + */ + +/** + * Incremental build context API for Apache Maven 4. + * + *

Overview

+ * + *

This package provides the API for incremental builds in Maven. An incremental + * build skips work that is already up-to-date, avoiding redundant processing when only a + * subset of source files have changed. The central abstraction is {@link BuildContext}, + * which tracks inputs (source files), outputs (generated + * files), and the relationships between them across successive builds.

+ * + *

Mojos that perform file transformations (compilation, code generation, resource + * filtering, etc.) can use this API to:

+ *
    + *
  • Detect which input files are new, modified, or removed since the last build
  • + *
  • Process only the changed inputs and regenerate only the affected outputs
  • + *
  • Automatically clean up stale outputs whose inputs have been removed
  • + *
  • Report diagnostic messages (errors, warnings) attached to specific resources
  • + *
+ * + *

Architecture

+ * + *

The API is organized around four key abstractions:

+ * + *
+ *  BuildContext              The entry point. Registers inputs, creates outputs,
+ *   +-- Input                tracks relationships between them.
+ *   +-- Output
+ *   +-- InputSet             Groups inputs for aggregated operations
+ *
+ *  Metadata<R>              Wraps a resource with its change status before
+ *                            it is "processed" into a full Input or Output handle.
+ *
+ *  Status                   Change status: NEW, MODIFIED, UNMODIFIED, REMOVED.
+ *
+ *  Severity                 Diagnostic level: ERROR, WARNING, INFO.
+ * 
+ * + *

Use Case 1: One-to-one file transformation

+ * + *

The simplest pattern: each input file produces exactly one output file. + * Only changed inputs are processed; stale outputs are cleaned up automatically.

+ * + *
{@code
+ * @Inject
+ * BuildContext buildContext;
+ *
+ * public void execute() {
+ *     // Register all .xml files under src/main/resources, excluding tests
+ *     for (Input input : buildContext.registerAndProcessInputs(
+ *             sourceDir,
+ *             List.of("**/*.xml"),
+ *             List.of("**/test-*"))) {
+ *
+ *         Path outputPath = outputDir.resolve(
+ *                 sourceDir.relativize(input.getPath()));
+ *
+ *         Output output = input.associateOutput(outputPath);
+ *         try (OutputStream os = output.newOutputStream()) {
+ *             transform(input.getPath(), os);
+ *         }
+ *     }
+ * }
+ * }
+ * + *

Use Case 2: Aggregated output (many inputs to one output)

+ * + *

Some operations aggregate multiple inputs into a single output (e.g., generating + * an index, merging property files, building a ZIP archive). Use {@link InputSet} for this:

+ * + *
{@code
+ * @Inject
+ * BuildContext buildContext;
+ *
+ * public void execute() {
+ *     InputSet inputSet = buildContext.newInputSet();
+ *     inputSet.registerInputs(sourceDir, List.of("**/*.properties"), null);
+ *
+ *     // aggregate() only invokes the callback if any input changed
+ *     inputSet.aggregate(mergedOutput, (output, inputs) -> {
+ *         Properties merged = new Properties();
+ *         for (Input input : inputs) {
+ *             try (InputStream is = Files.newInputStream(input.getPath())) {
+ *                 merged.load(is);
+ *             }
+ *         }
+ *         try (OutputStream os = output.newOutputStream()) {
+ *             merged.store(os, "Merged properties");
+ *         }
+ *     });
+ * }
+ * }
+ * + *

Use Case 3: Conditional execution with status checks

+ * + *

When a mojo does not produce individual output files but performs an action + * (e.g., deploying, validating), use {@link BuildContext#isProcessingRequired()} + * or inspect the {@link Status} of individual resources to decide whether to act:

+ * + *
{@code
+ * @Inject
+ * BuildContext buildContext;
+ *
+ * public void execute() {
+ *     Iterable> inputs =
+ *             buildContext.registerInputs(sourceDir, null, null);
+ *
+ *     boolean hasChanges = false;
+ *     for (Metadata meta : inputs) {
+ *         if (meta.getStatus() != Status.UNMODIFIED) {
+ *             hasChanges = true;
+ *             Input input = meta.process();
+ *             validate(input.getPath());
+ *         }
+ *     }
+ *
+ *     if (!hasChanges) {
+ *         buildContext.markSkipExecution();
+ *     }
+ * }
+ * }
+ * + *

Use Case 4: Reporting diagnostics

+ * + *

Attach diagnostic messages to resources so that IDEs and build tools can + * display them at the correct location:

+ * + *
{@code
+ * Input input = buildContext.registerInput(sourceFile);
+ * try {
+ *     compile(input.getPath());
+ * } catch (CompileError e) {
+ *     input.addMessage(e.getLine(), e.getColumn(),
+ *             e.getMessage(), Severity.ERROR, e);
+ * }
+ * }
+ * + *

Lifecycle and state persistence

+ * + *

The build context persists its state (file timestamps, input-output associations, + * resource attributes) between builds in a state file managed by the implementation. + * On each build:

+ *
    + *
  1. The context loads the previous state (if any) and compares it to current files
  2. + *
  3. The mojo registers inputs and creates outputs through the API
  4. + *
  5. At commit time, the context saves the new state and cleans up stale outputs
  6. + *
+ * + *

Mojos do not manage the state file directly. The Maven runtime (or IDE integration) + * handles initialization and commit through the + * {@link org.apache.maven.api.build.context.spi SPI} interfaces.

+ * + * @since 4.0.0 + * @see BuildContext + * @see Input + * @see Output + * @see InputSet + * @see Metadata + * @see org.apache.maven.api.build.context.spi + */ +@Experimental +package org.apache.maven.api.build.context; + +import org.apache.maven.api.annotations.Experimental; diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/BuildContextEnvironment.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/BuildContextEnvironment.java index e7ae06411905..0a4a008050a2 100644 --- a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/BuildContextEnvironment.java +++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/BuildContextEnvironment.java @@ -32,7 +32,23 @@ * Provides the environment configuration needed to initialize a build context, * including state file location, workspace, parameters, and an optional finalizer. * + *

This interface is implemented by the Maven runtime (or IDE integration) and + * passed to the build context constructor. It bundles everything the context needs + * to initialize:

+ *
    + *
  • State file — where to persist input/output relationships between + * builds. Typically located under {@code target/} so that a clean build starts fresh.
  • + *
  • Workspace — the file system abstraction (see {@link Workspace}).
  • + *
  • Parameters — mojo configuration values. The build context compares + * these against the previous build; if any change, all inputs are treated as modified.
  • + *
  • Finalizer — optional callback that commits the context after + * mojo execution (see {@link BuildContextFinalizer}).
  • + *
+ * * @since 4.0.0 + * @see Workspace + * @see BuildContextFinalizer + * @see CommitableBuildContext */ @Experimental @ThreadSafe diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/BuildContextFinalizer.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/BuildContextFinalizer.java index 8c96811e59b5..389ba1f28842 100644 --- a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/BuildContextFinalizer.java +++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/BuildContextFinalizer.java @@ -27,7 +27,22 @@ * Callback interface for registering build contexts that should be committed * at the end of a mojo execution. * + *

The Maven runtime implements this interface to collect all + * {@link CommitableBuildContext} instances created during a mojo's execution and + * commit them in a single batch after the mojo completes. This ensures that:

+ *
    + *
  • State is persisted only after the mojo succeeds (no partial state on failure)
  • + *
  • Diagnostic messages from all contexts are reported together
  • + *
  • Stale output cleanup happens atomically
  • + *
+ * + *

A mojo that uses multiple {@link org.apache.maven.api.build.context.BuildContext} + * instances (e.g., for different source roots) will have each context registered + * individually through this finalizer.

+ * * @since 4.0.0 + * @see CommitableBuildContext#commit(Sink) + * @see BuildContextEnvironment#getFinalizer() */ @Experimental @ThreadSafe diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/CommitableBuildContext.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/CommitableBuildContext.java index 2869db964b9d..b34ba25f9502 100644 --- a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/CommitableBuildContext.java +++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/CommitableBuildContext.java @@ -28,7 +28,19 @@ * Extended {@link BuildContext} that supports committing state changes and * reporting messages through a {@link Sink}. * + *

This interface is implemented by the build context implementation, not by mojos. + * The Maven runtime (or IDE integration) uses it to commit all state changes at the + * end of a mojo execution, typically through a {@link BuildContextFinalizer}:

+ *
    + *
  1. The mojo registers inputs, creates outputs, and attaches messages via + * the {@link BuildContext} API
  2. + *
  3. The finalizer calls {@link #commit(Sink)} which persists the new state, + * deletes stale outputs, and reports messages through the sink
  4. + *
+ * * @since 4.0.0 + * @see BuildContextFinalizer + * @see Sink */ @Experimental @NotThreadSafe diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/FileState.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/FileState.java index 07bfd2e09499..8c604f9670d8 100644 --- a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/FileState.java +++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/FileState.java @@ -35,7 +35,15 @@ * Immutable snapshot of a file's state (path, last-modified time, size) and its * change {@link Status} relative to the previous build. * + *

Instances are produced by {@link Workspace#walk(java.nio.file.Path)} and consumed + * by the build context implementation to determine which inputs have changed. The + * two-argument constructor reads file attributes from the filesystem automatically; + * the four-argument constructor allows the workspace to supply pre-computed values + * (e.g., from an IDE's file-watcher cache).

+ * * @since 4.0.0 + * @see Workspace#walk(java.nio.file.Path) + * @see Status */ @Experimental @Immutable diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/Message.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/Message.java index 6d2116b0c27b..3bb886f07c6d 100644 --- a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/Message.java +++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/Message.java @@ -31,7 +31,22 @@ /** * An immutable diagnostic message attached to a build resource. * + *

Messages are created by + * {@link org.apache.maven.api.build.context.Resource#addMessage(int, int, String, + * org.apache.maven.api.build.context.Severity, Throwable) Resource.addMessage()}, + * stored internally by the build context, and delivered to the {@link Sink} at + * commit time. Each message carries a source location (line/column), severity, + * human-readable text, and an optional root cause.

+ * + *

This class implements {@link java.io.Serializable} so that messages can be + * persisted as part of the build context state and re-reported on subsequent builds + * if the resource has not been reprocessed.

+ * * @since 4.0.0 + * @see org.apache.maven.api.build.context.Resource#addMessage(int, int, String, + * org.apache.maven.api.build.context.Severity, Throwable) + * @see Sink#messages(java.nio.file.Path, boolean, java.util.Collection) + * @see org.apache.maven.api.build.context.Severity */ @Experimental @Immutable diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/Sink.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/Sink.java index 756235ecd079..ac00f7ccd21d 100644 --- a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/Sink.java +++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/Sink.java @@ -28,9 +28,25 @@ /** * Receives diagnostic messages produced during a build context commit. - * Implementations typically forward these to the IDE or build log. + * + *

When {@link CommitableBuildContext#commit(Sink)} is called, the build context + * iterates over all resources and delivers their attached messages to this sink. + * Implementations typically bridge to the IDE's problem/markers view, the Maven + * build log, or an error reporter:

+ *
    + *
  • {@link #messages(java.nio.file.Path, boolean, java.util.Collection)} — delivers + * messages for a resource. The {@code isNew} flag lets the sink decide whether + * to append or replace existing markers.
  • + *
  • {@link #clear(java.nio.file.Path)} — removes all previously reported messages + * for a resource (e.g., when the resource is no longer an input or all errors + * have been fixed).
  • + *
* * @since 4.0.0 + * @see CommitableBuildContext#commit(Sink) + * @see Message + * @see org.apache.maven.api.build.context.Resource#addMessage(int, int, String, + * org.apache.maven.api.build.context.Severity, Throwable) */ @Experimental @NotThreadSafe diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/Workspace.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/Workspace.java index 343861f29a21..ad8251f6e33e 100644 --- a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/Workspace.java +++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/Workspace.java @@ -32,11 +32,32 @@ /** * Provides a layer of indirection between the {@link org.apache.maven.api.build.context.BuildContext} * and the underlying file store. - *

- * IDE integrations typically supply a workspace implementation that is aware of the IDE's - * virtual file system, while command-line builds use a direct filesystem workspace. + * + *

This is the most important SPI interface. IDE integrations supply a workspace implementation + * that is aware of the IDE's virtual file system (e.g., Eclipse's {@code IWorkspace}), while + * command-line Maven builds use a direct filesystem implementation. The workspace determines:

+ *
    + *
  • How files are read and written (allowing the IDE to intercept file operations)
  • + *
  • How change detection works (full scan vs. IDE-provided delta)
  • + *
  • When output files should be deleted (notifying the IDE's resource tracker)
  • + *
+ * + *

The {@link Mode} controls how {@link #walk(java.nio.file.Path)} behaves, which in turn + * controls how aggressively the build context scans for changes:

+ *
    + *
  • {@link Mode#NORMAL} — Full scan; the build context compares timestamps + * against saved state. Used by command-line Maven.
  • + *
  • {@link Mode#DELTA} — The workspace already knows the changed files + * (e.g., from an IDE file watcher). Only the delta is returned, making builds fast.
  • + *
  • {@link Mode#ESCALATED} — Fallback from DELTA when the change set may + * be incomplete (e.g., after an IDE crash). Forces a full scan to recover.
  • + *
  • {@link Mode#SUPPRESSED} — No processing; all inputs appear unmodified. + * Used for configuration-only builds or when incremental support is disabled.
  • + *
* * @since 4.0.0 + * @see BuildContextEnvironment#getWorkspace() + * @see org.apache.maven.api.build.context.BuildContext */ @Experimental @ThreadSafe diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/package-info.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/package-info.java new file mode 100644 index 000000000000..bc501be63a19 --- /dev/null +++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/package-info.java @@ -0,0 +1,124 @@ +/* + * 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. + */ + +/** + * Service Provider Interface (SPI) for the incremental build context. + * + *

Overview

+ * + *

This package contains the interfaces that integrators implement + * to connect the incremental build context to a specific runtime environment (Maven CLI, + * an IDE, a custom build tool). Mojo authors typically do not implement these interfaces; + * they use the consumer API in {@link org.apache.maven.api.build.context} instead.

+ * + *

Key SPI interfaces

+ * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + *
SPI roles and their implementors
InterfaceRoleTypical implementor
{@link Workspace}File-system abstraction: read/write/delete files, detect changesIDE workspace adapter, filesystem implementation
{@link BuildContextEnvironment}Provides the state file path, workspace, and mojo parametersMaven runtime, IDE plugin
{@link BuildContextFinalizer}Commits all registered build contexts at the end of a mojo executionMaven runtime
{@link CommitableBuildContext}Extends {@link org.apache.maven.api.build.context.BuildContext} with commit + * and message-reporting capabilitiesBuild context implementation
{@link Sink}Receives diagnostic messages during commitIDE error reporter, Maven log bridge
+ * + *

How the pieces fit together

+ * + *
+ *                +-----------------------+
+ *                | BuildContextEnvironment|
+ *                |  stateFile, workspace, |
+ *                |  parameters, finalizer |
+ *                +-----------+-----------+
+ *                            |
+ *                            v creates
+ *                 +--------------------+
+ *                 | CommitableBuildContext|
+ *                 |  (extends BuildContext)|
+ *                 +----------+---------+
+ *                            |
+ *           +----------------+----------------+
+ *           |                                 |
+ *           v reads/writes via                v at end of mojo
+ *     +----------+                  +-------------------+
+ *     | Workspace |                  | BuildContextFinalizer|
+ *     |  file ops |                  |  commit(Sink)        |
+ *     +----------+                  +-------------------+
+ * 
+ * + *

Workspace modes

+ * + *

The {@link Workspace} abstraction supports four operating modes, allowing the + * same mojo code to behave correctly in different environments:

+ *
    + *
  • {@link Workspace.Mode#NORMAL NORMAL} — Full filesystem + * scan with timestamp/size comparison (command-line Maven).
  • + *
  • {@link Workspace.Mode#DELTA DELTA} — The workspace already + * knows which files changed (IDE with file-watcher). Only the delta is scanned, + * avoiding a full directory walk.
  • + *
  • {@link Workspace.Mode#ESCALATED ESCALATED} — Fallback from + * DELTA mode when the change set is incomplete (e.g., after an IDE crash). Performs + * a full scan to recover correct state.
  • + *
  • {@link Workspace.Mode#SUPPRESSED SUPPRESSED} — All inputs + * appear unmodified. Used for read-only analysis or when incremental support is + * explicitly disabled.
  • + *
+ * + *

Data classes

+ * + *

{@link FileState} and {@link Message} are immutable value objects used to + * communicate file metadata and diagnostic messages between the build context + * implementation and the workspace / sink.

+ * + * @since 4.0.0 + * @see org.apache.maven.api.build.context + * @see Workspace + * @see BuildContextEnvironment + * @see CommitableBuildContext + */ +@Experimental +package org.apache.maven.api.build.context.spi; + +import org.apache.maven.api.annotations.Experimental; From 838c3b736f0dcb0413a5c0471d4864ba9a39d28e Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Wed, 29 Jul 2026 00:16:16 +0200 Subject: [PATCH 03/16] Replace javax/sisu with Maven 4 DI, remove public from tests, make FileMatcher package-private - Convert all javax.inject.* and org.eclipse.sisu.* annotations to Maven 4 DI equivalents (org.apache.maven.api.di.*) in maven-core integration files - Replace javax.inject.Provider with java.util.function.Supplier - Remove unnecessary public modifiers from all test classes and methods - Make FileMatcher package-private, add TODO noting overlap with PathSelector (createMatchers trie has no PathSelector equivalent) Co-Authored-By: Claude Opus 4.6 --- .../context/impl/maven/MavenBuildContext.java | 15 +++---- .../maven/MavenBuildContextConfiguration.java | 7 ++- .../maven/MavenBuildContextFinalizer.java | 9 ++-- .../context/impl/maven/ProjectWorkspace.java | 7 ++- .../digest/MojoConfigurationDigester.java | 4 +- .../build/context/impl/FileMatcher.java | 7 ++- .../impl/AbstractBuildContextTest.java | 2 +- .../DefaultAggregatorBuildContextTest.java | 6 +-- .../impl/DefaultBasicBuildContextTest.java | 4 +- .../impl/DefaultBuildContextStateTest.java | 12 ++--- .../context/impl/DefaultBuildContextTest.java | 44 +++++++++---------- .../build/context/impl/DefaultOutputTest.java | 4 +- .../context/impl/DeltaWorkspaceTest.java | 10 ++--- .../context/impl/ResourceMessagesTest.java | 8 ++-- 14 files changed, 70 insertions(+), 69 deletions(-) diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/MavenBuildContext.java b/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/MavenBuildContext.java index 79a4e40db109..0bc5340109b1 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/MavenBuildContext.java +++ b/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/MavenBuildContext.java @@ -18,31 +18,30 @@ */ package org.apache.maven.internal.build.context.impl.maven; -import javax.inject.Inject; -import javax.inject.Named; -import javax.inject.Provider; - import java.nio.file.Path; import java.util.Collection; +import java.util.function.Supplier; import org.apache.maven.api.build.context.InputSet; import org.apache.maven.api.build.context.spi.BuildContextEnvironment; import org.apache.maven.api.build.context.spi.CommitableBuildContext; import org.apache.maven.api.build.context.spi.Sink; -import org.apache.maven.execution.scope.MojoExecutionScoped; +import org.apache.maven.api.di.Inject; +import org.apache.maven.api.di.MojoExecutionScoped; +import org.apache.maven.api.di.Named; +import org.apache.maven.api.di.Typed; import org.apache.maven.internal.build.context.impl.DefaultBuildContext; import org.apache.maven.internal.build.context.impl.DefaultInput; import org.apache.maven.internal.build.context.impl.DefaultInputMetadata; import org.apache.maven.internal.build.context.impl.DefaultOutput; -import org.eclipse.sisu.Typed; @Named public class MavenBuildContext implements CommitableBuildContext { - private final Provider provider; + private final Supplier provider; @Inject - public MavenBuildContext(Provider delegate) { + public MavenBuildContext(Supplier delegate) { this.provider = delegate; } diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/MavenBuildContextConfiguration.java b/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/MavenBuildContextConfiguration.java index c7c34da347d8..447f38150279 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/MavenBuildContextConfiguration.java +++ b/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/MavenBuildContextConfiguration.java @@ -18,9 +18,6 @@ */ package org.apache.maven.internal.build.context.impl.maven; -import javax.inject.Inject; -import javax.inject.Named; - import java.io.IOException; import java.io.Serializable; import java.nio.file.Path; @@ -32,8 +29,10 @@ import org.apache.maven.api.build.context.spi.BuildContextEnvironment; import org.apache.maven.api.build.context.spi.BuildContextFinalizer; import org.apache.maven.api.build.context.spi.Workspace; +import org.apache.maven.api.di.Inject; +import org.apache.maven.api.di.MojoExecutionScoped; +import org.apache.maven.api.di.Named; import org.apache.maven.api.plugin.descriptor.PluginDescriptor; -import org.apache.maven.execution.scope.MojoExecutionScoped; import org.apache.maven.internal.build.context.impl.maven.digest.MojoConfigurationDigester; @Named diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/MavenBuildContextFinalizer.java b/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/MavenBuildContextFinalizer.java index 3e11fed985ae..8c02437ab736 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/MavenBuildContextFinalizer.java +++ b/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/MavenBuildContextFinalizer.java @@ -18,9 +18,6 @@ */ package org.apache.maven.internal.build.context.impl.maven; -import javax.inject.Inject; -import javax.inject.Named; - import java.nio.file.Path; import java.util.ArrayList; import java.util.Collection; @@ -30,17 +27,19 @@ import java.util.Map; import java.util.Set; +import org.apache.maven.api.annotations.Nullable; import org.apache.maven.api.build.context.BuildContext; import org.apache.maven.api.build.context.Severity; import org.apache.maven.api.build.context.spi.BuildContextFinalizer; import org.apache.maven.api.build.context.spi.CommitableBuildContext; import org.apache.maven.api.build.context.spi.Message; import org.apache.maven.api.build.context.spi.Sink; +import org.apache.maven.api.di.Inject; +import org.apache.maven.api.di.MojoExecutionScoped; +import org.apache.maven.api.di.Named; import org.apache.maven.execution.MojoExecutionEvent; -import org.apache.maven.execution.scope.MojoExecutionScoped; import org.apache.maven.execution.scope.WeakMojoExecutionListener; import org.apache.maven.plugin.MojoExecutionException; -import org.eclipse.sisu.Nullable; @Named @MojoExecutionScoped diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/ProjectWorkspace.java b/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/ProjectWorkspace.java index b6943f52b593..9a8a62ffba3a 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/ProjectWorkspace.java +++ b/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/ProjectWorkspace.java @@ -18,8 +18,6 @@ */ package org.apache.maven.internal.build.context.impl.maven; -import javax.inject.Inject; - import java.io.OutputStream; import java.nio.file.Path; import java.nio.file.attribute.FileTime; @@ -29,9 +27,10 @@ import org.apache.maven.api.build.context.Status; import org.apache.maven.api.build.context.spi.FileState; import org.apache.maven.api.build.context.spi.Workspace; -import org.apache.maven.execution.scope.MojoExecutionScoped; +import org.apache.maven.api.di.Inject; +import org.apache.maven.api.di.MojoExecutionScoped; +import org.apache.maven.api.di.Typed; import org.apache.maven.internal.build.context.impl.FilesystemWorkspace; -import org.eclipse.sisu.Typed; /** * Eclipse Workspace implementation is scoped to a project and does not "see" resources outside diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/digest/MojoConfigurationDigester.java b/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/digest/MojoConfigurationDigester.java index 7e31524cac85..36e09bbff525 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/digest/MojoConfigurationDigester.java +++ b/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/digest/MojoConfigurationDigester.java @@ -18,8 +18,6 @@ */ package org.apache.maven.internal.build.context.impl.maven.digest; -import javax.inject.Inject; -import javax.inject.Named; import javax.xml.stream.XMLStreamException; import java.io.IOException; @@ -35,7 +33,9 @@ import org.apache.maven.api.MojoExecution; import org.apache.maven.api.Project; import org.apache.maven.api.Session; +import org.apache.maven.api.di.Inject; import org.apache.maven.api.di.MojoExecutionScoped; +import org.apache.maven.api.di.Named; import org.apache.maven.api.plugin.descriptor.MojoDescriptor; import org.apache.maven.api.xml.XmlNode; import org.apache.maven.internal.xml.XmlNodeWriter; diff --git a/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/FileMatcher.java b/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/FileMatcher.java index ca9bdad2113c..cd875cdb943f 100644 --- a/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/FileMatcher.java +++ b/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/FileMatcher.java @@ -32,7 +32,12 @@ import java.util.function.Predicate; import java.util.regex.Pattern; -public class FileMatcher { +// TODO: evaluate consolidation with org.apache.maven.impl.PathSelector which handles +// similar glob/Ant-style pattern matching. The createMatchers() trie optimization +// (splitting include patterns into per-subdirectory matchers for targeted workspace walks) +// has no PathSelector equivalent, so a full replacement would require adding a +// createSubdirectoryMatchers() method to PathSelector or DefaultPathMatcherFactory. +class FileMatcher { private static final Matcher MATCH_EVERYTHING = p -> true; final Matcher includesMatcher; final Matcher excludesMatcher; diff --git a/impl/maven-impl/src/test/java/org/apache/maven/internal/build/context/impl/AbstractBuildContextTest.java b/impl/maven-impl/src/test/java/org/apache/maven/internal/build/context/impl/AbstractBuildContextTest.java index 79550042d632..a9d4e3bb64d5 100644 --- a/impl/maven-impl/src/test/java/org/apache/maven/internal/build/context/impl/AbstractBuildContextTest.java +++ b/impl/maven-impl/src/test/java/org/apache/maven/internal/build/context/impl/AbstractBuildContextTest.java @@ -28,7 +28,7 @@ import org.apache.maven.api.build.context.spi.Workspace; import org.junit.jupiter.api.io.TempDir; -public abstract class AbstractBuildContextTest { +abstract class AbstractBuildContextTest { /** Temporary directory for tests. */ @TempDir @SuppressWarnings("checkstyle:VisibilityModifier") diff --git a/impl/maven-impl/src/test/java/org/apache/maven/internal/build/context/impl/DefaultAggregatorBuildContextTest.java b/impl/maven-impl/src/test/java/org/apache/maven/internal/build/context/impl/DefaultAggregatorBuildContextTest.java index 16f477f6a3b4..5b89e5513812 100644 --- a/impl/maven-impl/src/test/java/org/apache/maven/internal/build/context/impl/DefaultAggregatorBuildContextTest.java +++ b/impl/maven-impl/src/test/java/org/apache/maven/internal/build/context/impl/DefaultAggregatorBuildContextTest.java @@ -39,10 +39,10 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; -public class DefaultAggregatorBuildContextTest extends AbstractBuildContextTest { +class DefaultAggregatorBuildContextTest extends AbstractBuildContextTest { @Test - public void testBasic() throws Exception { + void testBasic() throws Exception { FileMatcher.getCanonicalPath(Paths.get("/oo/bar")); Path outputFile = temp.resolve("output"); @@ -144,7 +144,7 @@ private DefaultBuildContext newContext() { } @Test - public void testEmpty() throws Exception { + void testEmpty() throws Exception { Path outputFile = temp.resolve("output"); Path basedir = Files.createTempDirectory(temp, ""); diff --git a/impl/maven-impl/src/test/java/org/apache/maven/internal/build/context/impl/DefaultBasicBuildContextTest.java b/impl/maven-impl/src/test/java/org/apache/maven/internal/build/context/impl/DefaultBasicBuildContextTest.java index a7df8c3c1d38..a1d3bf38db48 100644 --- a/impl/maven-impl/src/test/java/org/apache/maven/internal/build/context/impl/DefaultBasicBuildContextTest.java +++ b/impl/maven-impl/src/test/java/org/apache/maven/internal/build/context/impl/DefaultBasicBuildContextTest.java @@ -26,7 +26,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; -public class DefaultBasicBuildContextTest { +class DefaultBasicBuildContextTest { /** Temporary directory for tests. */ @TempDir private Path temp; @@ -37,7 +37,7 @@ private DefaultBuildContext newContext() { } @Test - public void testDeletedOutput() throws Exception { + void testDeletedOutput() throws Exception { Path input = Files.createFile(temp.resolve("input")); Path output = Files.createFile(temp.resolve("output")); diff --git a/impl/maven-impl/src/test/java/org/apache/maven/internal/build/context/impl/DefaultBuildContextStateTest.java b/impl/maven-impl/src/test/java/org/apache/maven/internal/build/context/impl/DefaultBuildContextStateTest.java index 6a8f39bce929..62af3292dccf 100644 --- a/impl/maven-impl/src/test/java/org/apache/maven/internal/build/context/impl/DefaultBuildContextStateTest.java +++ b/impl/maven-impl/src/test/java/org/apache/maven/internal/build/context/impl/DefaultBuildContextStateTest.java @@ -33,13 +33,13 @@ import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; -public class DefaultBuildContextStateTest { +class DefaultBuildContextStateTest { /** Temporary directory for tests. */ @TempDir private Path temp; @Test - public void testRoundtrip() throws Exception { + void testRoundtrip() throws Exception { Path file = Files.createTempFile(temp, "", ""); DefaultBuildContextState state = DefaultBuildContextState.withConfiguration(new HashMap<>()); BasicFileAttributes attrs = DefaultBuildContext.readAttributes(file); @@ -56,26 +56,26 @@ public void testRoundtrip() throws Exception { } @Test - public void testStateDoesNotExist() throws Exception { + void testStateDoesNotExist() throws Exception { DefaultBuildContextState state = DefaultBuildContextState.loadFrom(temp.resolve("does-not-exist")); assertTrue(state.configuration.isEmpty()); } @Test - public void testEmptyState() throws Exception { + void testEmptyState() throws Exception { Path stateFile = Files.createTempFile(temp, "", ""); assertTrue(DefaultBuildContextState.loadFrom(stateFile).configuration.isEmpty()); } @Test - public void testCorruptedState() throws Exception { + void testCorruptedState() throws Exception { Path corrupted = Files.createTempFile(temp, "", ""); Files.write(corrupted, Collections.singletonList("test"), StandardOpenOption.APPEND); assertTrue(DefaultBuildContextState.loadFrom(corrupted).configuration.isEmpty()); } @Test - public void testIncompatibleState() throws Exception { + void testIncompatibleState() throws Exception { Path incompatible = Files.createTempFile(temp, "", ""); ObjectOutputStream oos = new ObjectOutputStream(Files.newOutputStream(incompatible)); oos.writeUTF("incompatible"); diff --git a/impl/maven-impl/src/test/java/org/apache/maven/internal/build/context/impl/DefaultBuildContextTest.java b/impl/maven-impl/src/test/java/org/apache/maven/internal/build/context/impl/DefaultBuildContextTest.java index f409e921a3f3..5c43d08f59fe 100644 --- a/impl/maven-impl/src/test/java/org/apache/maven/internal/build/context/impl/DefaultBuildContextTest.java +++ b/impl/maven-impl/src/test/java/org/apache/maven/internal/build/context/impl/DefaultBuildContextTest.java @@ -61,7 +61,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; -public class DefaultBuildContextTest extends AbstractBuildContextTest { +class DefaultBuildContextTest extends AbstractBuildContextTest { private static void assertIncludedPaths(Collection expected, Collection actual) throws IOException { assertEquals(toString(expected), toString(actual)); @@ -76,14 +76,14 @@ private static String toString(Collection files) throws IOException { } @Test - public void testRegisterInputInputFileDoesNotExist() throws Exception { + void testRegisterInputInputFileDoesNotExist() throws Exception { Path file = Paths.get("target/does_not_exist"); assertTrue(!Files.exists(file) && !Files.isReadable(file)); assertThrows(IllegalArgumentException.class, () -> newBuildContext().registerInput(file)); } @Test - public void testRegisterInput() throws Exception { + void testRegisterInput() throws Exception { // this is NOT part of API but rather currently implemented behaviour // API allows #registerInput return different instances @@ -95,7 +95,7 @@ public void testRegisterInput() throws Exception { } @Test - public void testOutputWithoutInputs() throws Exception { + void testOutputWithoutInputs() throws Exception { TestBuildContext context = newBuildContext(); Path outputFile = Files.createFile(temp.resolve("output_without_inputs")); @@ -118,7 +118,7 @@ public void testOutputWithoutInputs() throws Exception { } @Test - public void testGetInputStatus() throws Exception { + void testGetInputStatus() throws Exception { Path inputFile = Files.createFile(temp.resolve("inputFile")); // initial build @@ -143,7 +143,7 @@ public void testGetInputStatus() throws Exception { } @Test - public void testInputModifiedAfterRegistration() throws Exception { + void testInputModifiedAfterRegistration() throws Exception { Path inputFile = Files.createFile(temp.resolve("inputFile")); Path outputFile = Files.createFile(temp.resolve("outputFile")); @@ -163,7 +163,7 @@ public void testInputModifiedAfterRegistration() throws Exception { } @Test - public void testCommitOrphanedOutputsCleanup() throws Exception { + void testCommitOrphanedOutputsCleanup() throws Exception { Path inputFile = Files.createFile(temp.resolve("inputFile")); Path outputFile = Files.createFile(temp.resolve("outputFile")); @@ -180,7 +180,7 @@ public void testCommitOrphanedOutputsCleanup() throws Exception { } @Test - public void testCommitStaleOutputCleanup() throws Exception { + void testCommitStaleOutputCleanup() throws Exception { Path inputFile = Files.createFile(temp.resolve("inputFile")); Path outputFile1 = Files.createFile(temp.resolve("outputFile1")); Path outputFile2 = Files.createFile(temp.resolve("outputFile2")); @@ -204,7 +204,7 @@ public void testCommitStaleOutputCleanup() throws Exception { } @Test - public void testCreateStateParentDirectory() throws Exception { + void testCreateStateParentDirectory() throws Exception { Path stateFile = temp.resolve("sub/dir/buildstate.ctx"); TestBuildContext context = new TestBuildContext(stateFile, Collections.emptyMap()); context.commit(); @@ -212,13 +212,13 @@ public void testCreateStateParentDirectory() throws Exception { } @Test - public void testRegisterInputNullInput() throws Exception { + void testRegisterInputNullInput() throws Exception { assertThrows(IllegalArgumentException.class, () -> newBuildContext().registerInput((Path) null)); } @ParameterizedTest @MethodSource("fileSystems") - public void testRegisterAndProcessInputs(String type, Supplier fs) throws Exception { + void testRegisterAndProcessInputs(String type, Supplier fs) throws Exception { Path target = fs.get().getPath("target"); Files.createDirectories(target); temp = Files.createTempDirectory(target, "tmp"); @@ -284,7 +284,7 @@ void testInputInListDeleted() throws Exception { } @Test - public void testGetAssociatedOutputs() throws Exception { + void testGetAssociatedOutputs() throws Exception { Path inputFile = Files.createFile(temp.resolve("inputFile")); Path outputFile = Files.createFile(temp.resolve("outputFile")); @@ -320,7 +320,7 @@ public void testGetAssociatedOutputs() throws Exception { } @Test - public void testGetRegisteredInputs() throws Exception { + void testGetRegisteredInputs() throws Exception { Path inputFile1 = Files.createFile(temp.resolve("inputFile1")); Path inputFile2 = Files.createFile(temp.resolve("inputFile2")); Path inputFile3 = Files.createFile(temp.resolve("inputFile3")); @@ -354,7 +354,7 @@ public void testGetRegisteredInputs() throws Exception { } @Test - public void testInputAttributes() throws Exception { + void testInputAttributes() throws Exception { Path inputFile = Files.createFile(temp.resolve("inputFile")); TestBuildContext context = newBuildContext(); @@ -386,7 +386,7 @@ public void testInputAttributes() throws Exception { } @Test - public void testOutputStatus() throws Exception { + void testOutputStatus() throws Exception { Path inputFile = Files.createFile(temp.resolve("inputFile")); Path outputFile = temp.resolve("outputFile"); @@ -427,7 +427,7 @@ public void testOutputStatus() throws Exception { } @Test - public void testStateSerializationUseTCCL() throws Exception { + void testStateSerializationUseTCCL() throws Exception { Path inputFile = Files.createFile(temp.resolve("inputFile")); TestBuildContext context = newBuildContext(); @@ -458,7 +458,7 @@ public void testStateSerializationUseTCCL() throws Exception { } @Test - public void testConfigurationChange() throws Exception { + void testConfigurationChange() throws Exception { Path inputFile = Files.createFile(temp.resolve("input")); Path outputFile = Files.createFile(temp.resolve("output")); Path looseOutputFile = Files.createFile(temp.resolve("looseOutputFile")); @@ -480,7 +480,7 @@ public void testConfigurationChange() throws Exception { } @Test - public void testRegisterInputsIncludesExcludes() throws Exception { + void testRegisterInputsIncludesExcludes() throws Exception { Files.createDirectory(temp.resolve("folder")); Path f1 = Files.createFile(temp.resolve("input1.txt")); Path f2 = Files.createFile(temp.resolve("folder/input2.txt")); @@ -504,7 +504,7 @@ public void testRegisterInputsIncludesExcludes() throws Exception { } @Test - public void testRegisterInputsDirectoryMatching() throws Exception { + void testRegisterInputsDirectoryMatching() throws Exception { Files.createDirectory(temp.resolve("folder")); Files.createDirectory(temp.resolve("folder/subfolder")); Path f1 = Files.createFile(temp.resolve("input1.txt")); @@ -547,7 +547,7 @@ private List toFileList(Iterable inputs) { } @Test - public void testClosedContext() throws Exception { + void testClosedContext() throws Exception { TestBuildContext context = newBuildContext(); context.commit(); @@ -556,7 +556,7 @@ public void testClosedContext() throws Exception { } @Test - public void testSkipExecution() throws Exception { + void testSkipExecution() throws Exception { Path inputFile = Files.createFile(temp.resolve("inputFile")); Path outputFile = Files.createFile(temp.resolve("outputFile")); @@ -584,7 +584,7 @@ public void testSkipExecution() throws Exception { } @Test - public void testSkipExecutionModifiedContext() throws Exception { + void testSkipExecutionModifiedContext() throws Exception { Path inputFile = Files.createFile(temp.resolve("inputFile")); Path outputFile = Files.createFile(temp.resolve("outputFile")); diff --git a/impl/maven-impl/src/test/java/org/apache/maven/internal/build/context/impl/DefaultOutputTest.java b/impl/maven-impl/src/test/java/org/apache/maven/internal/build/context/impl/DefaultOutputTest.java index d59e54a8e42a..e62f0e3cded7 100644 --- a/impl/maven-impl/src/test/java/org/apache/maven/internal/build/context/impl/DefaultOutputTest.java +++ b/impl/maven-impl/src/test/java/org/apache/maven/internal/build/context/impl/DefaultOutputTest.java @@ -28,7 +28,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue; -public class DefaultOutputTest { +class DefaultOutputTest { /** Temporary directory for tests. */ @TempDir @@ -40,7 +40,7 @@ private TestBuildContext newBuildContext() { } @Test - public void testOutputStreamCreateParentDirectories() throws Exception { + void testOutputStreamCreateParentDirectories() throws Exception { Path outputFile = temp.resolve("sub/dir/outputFile"); TestBuildContext context = newBuildContext(); diff --git a/impl/maven-impl/src/test/java/org/apache/maven/internal/build/context/impl/DeltaWorkspaceTest.java b/impl/maven-impl/src/test/java/org/apache/maven/internal/build/context/impl/DeltaWorkspaceTest.java index 965ece5ef361..de50ea454fa5 100644 --- a/impl/maven-impl/src/test/java/org/apache/maven/internal/build/context/impl/DeltaWorkspaceTest.java +++ b/impl/maven-impl/src/test/java/org/apache/maven/internal/build/context/impl/DeltaWorkspaceTest.java @@ -42,9 +42,9 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; -public class DeltaWorkspaceTest extends AbstractBuildContextTest { +class DeltaWorkspaceTest extends AbstractBuildContextTest { - public static void touch(Path path) throws InterruptedException { + static void touch(Path path) throws InterruptedException { if (!Files.isRegularFile(path)) { throw new IllegalArgumentException("Not a file " + path); } else { @@ -59,7 +59,7 @@ public static void touch(Path path) throws InterruptedException { } @Test - public void testGetRegisteredInputs() throws Exception { + void testGetRegisteredInputs() throws Exception { DeltaWorkspace workspace; TestBuildContext ctx; @@ -115,7 +115,7 @@ public void testGetRegisteredInputs() throws Exception { } @Test - public void testResourceStatus() throws Exception { + void testResourceStatus() throws Exception { Path basedir = Files.createDirectory(temp.resolve("basedir")); DeltaWorkspace workspace; @@ -163,7 +163,7 @@ public void testResourceStatus() throws Exception { } @Test - public void testCarryOverAndCleanup() throws Exception { + void testCarryOverAndCleanup() throws Exception { Files.createDirectory(temp.resolve("basedir")); Path inputdir = Files.createDirectory(temp.resolve("basedir/inputdir")); Path outputdir = Files.createDirectory(temp.resolve("basedir/outputdir")); diff --git a/impl/maven-impl/src/test/java/org/apache/maven/internal/build/context/impl/ResourceMessagesTest.java b/impl/maven-impl/src/test/java/org/apache/maven/internal/build/context/impl/ResourceMessagesTest.java index a8b14ebc2213..657b6396921d 100644 --- a/impl/maven-impl/src/test/java/org/apache/maven/internal/build/context/impl/ResourceMessagesTest.java +++ b/impl/maven-impl/src/test/java/org/apache/maven/internal/build/context/impl/ResourceMessagesTest.java @@ -36,7 +36,7 @@ import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; -public class ResourceMessagesTest { +class ResourceMessagesTest { /** Temporary directory for tests. */ @TempDir private Path temp; @@ -46,7 +46,7 @@ private TestBuildContext newBuildContext() throws IOException { } @Test - public void testInputMessages() throws Exception { + void testInputMessages() throws Exception { Path inputFile = Files.createFile(temp.resolve("inputFile")); // initial message @@ -98,7 +98,7 @@ public void testInputMessages() throws Exception { } @Test - public void testInputMessagesNullMessageText() throws Exception { + void testInputMessagesNullMessageText() throws Exception { Path inputFile = Files.createFile(temp.resolve("inputFile")); // initial message @@ -118,7 +118,7 @@ public void testInputMessagesNullMessageText() throws Exception { } @Test - public void testExcludedInputMessageCleanup() throws Exception { + void testExcludedInputMessageCleanup() throws Exception { Path inputFile = Files.createFile(temp.resolve("inputFile")); // initial message From 52cf19d7d0ae29cf02b6a3694fa9b4f3c045623b Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Wed, 29 Jul 2026 00:26:48 +0200 Subject: [PATCH 04/16] Update @since tags from 4.0.0 to 4.1.0 for incremental build context API Co-Authored-By: Claude Opus 4.6 --- .../java/org/apache/maven/api/build/context/BuildContext.java | 2 +- .../apache/maven/api/build/context/BuildContextException.java | 2 +- .../java/org/apache/maven/api/build/context/Incremental.java | 2 +- .../main/java/org/apache/maven/api/build/context/Input.java | 2 +- .../java/org/apache/maven/api/build/context/InputSet.java | 2 +- .../java/org/apache/maven/api/build/context/Metadata.java | 2 +- .../main/java/org/apache/maven/api/build/context/Output.java | 2 +- .../java/org/apache/maven/api/build/context/Resource.java | 2 +- .../java/org/apache/maven/api/build/context/Severity.java | 2 +- .../main/java/org/apache/maven/api/build/context/Status.java | 2 +- .../java/org/apache/maven/api/build/context/package-info.java | 2 +- .../maven/api/build/context/spi/BuildContextEnvironment.java | 2 +- .../maven/api/build/context/spi/BuildContextFinalizer.java | 2 +- .../maven/api/build/context/spi/CommitableBuildContext.java | 2 +- .../org/apache/maven/api/build/context/spi/FileState.java | 2 +- .../java/org/apache/maven/api/build/context/spi/Message.java | 2 +- .../java/org/apache/maven/api/build/context/spi/Sink.java | 2 +- .../org/apache/maven/api/build/context/spi/Workspace.java | 4 ++-- .../org/apache/maven/api/build/context/spi/package-info.java | 2 +- 19 files changed, 20 insertions(+), 20 deletions(-) diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/BuildContext.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/BuildContext.java index fd66d93a51d8..65d2ac1a708f 100644 --- a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/BuildContext.java +++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/BuildContext.java @@ -95,7 +95,7 @@ * }); * } * - * @since 4.0.0 + * @since 4.1.0 * @see Input * @see Output * @see InputSet diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/BuildContextException.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/BuildContextException.java index 666e3a882f5c..b4aaf5c120aa 100644 --- a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/BuildContextException.java +++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/BuildContextException.java @@ -31,7 +31,7 @@ * It extends {@link MavenException} so that it can be caught alongside other Maven * API exceptions.

* - * @since 4.0.0 + * @since 4.1.0 * @see BuildContext */ @Experimental diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Incremental.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Incremental.java index 35f50a9463e2..3d3ba612c2b1 100644 --- a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Incremental.java +++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Incremental.java @@ -54,7 +54,7 @@ * private Path outputDirectory; * } * - * @since 4.0.0 + * @since 4.1.0 * @see BuildContext */ @Experimental diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Input.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Input.java index 19d5b70e1b69..e5f29c31f746 100644 --- a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Input.java +++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Input.java @@ -52,7 +52,7 @@ *

When an input is removed in a subsequent build, the build context automatically deletes * all outputs that were associated with it in the previous build.

* - * @since 4.0.0 + * @since 4.1.0 * @see BuildContext#registerInput(java.nio.file.Path) * @see Output * @see Metadata#process() diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/InputSet.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/InputSet.java index 08a267d74b34..4f0ec7c5d278 100644 --- a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/InputSet.java +++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/InputSet.java @@ -75,7 +75,7 @@ * (e.g., parsing an AST). Only changed inputs are re-processed; cached metadata is * reused for unchanged inputs.

* - * @since 4.0.0 + * @since 4.1.0 * @see BuildContext#newInputSet() * @see Input * @see Output diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Metadata.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Metadata.java index 57a97ad9da4c..1c70355f3656 100644 --- a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Metadata.java +++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Metadata.java @@ -59,7 +59,7 @@ * carried over unchanged.

* * @param the resource type ({@link Input} or {@link Output}) - * @since 4.0.0 + * @since 4.1.0 * @see BuildContext#registerInput(java.nio.file.Path) * @see BuildContext#registerInputs(java.nio.file.Path, java.util.Collection, java.util.Collection) * @see Status diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Output.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Output.java index 76b20d8983a0..60f14d349c91 100644 --- a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Output.java +++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Output.java @@ -57,7 +57,7 @@ *

The build context automatically creates parent directories for the output file * if they do not exist.

* - * @since 4.0.0 + * @since 4.1.0 * @see Input#associateOutput(java.nio.file.Path) * @see BuildContext#processOutput(java.nio.file.Path) */ diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Resource.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Resource.java index c7158a0e223b..26470afe3fe6 100644 --- a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Resource.java +++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Resource.java @@ -46,7 +46,7 @@ * } * } * - * @since 4.0.0 + * @since 4.1.0 * @see Input * @see Output * @see Severity diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Severity.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Severity.java index 620fc5123b42..6f5408530baa 100644 --- a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Severity.java +++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Severity.java @@ -28,7 +28,7 @@ * classify diagnostic messages. Messages are collected during the build and reported * through the {@link org.apache.maven.api.build.context.spi.Sink} at commit time.

* - * @since 4.0.0 + * @since 4.1.0 * @see Resource#addMessage(int, int, String, Severity, Throwable) * @see org.apache.maven.api.build.context.spi.Message */ diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Status.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Status.java index fc70e57ef62f..c2fd81ad1345 100644 --- a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Status.java +++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Status.java @@ -29,7 +29,7 @@ * {@link Metadata#getStatus()} (before processing) and {@link Resource#getStatus()} * (after processing).

* - * @since 4.0.0 + * @since 4.1.0 * @see Metadata#getStatus() * @see Resource#getStatus() */ diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/package-info.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/package-info.java index 54f3245dc0cf..bd10ef0cf16a 100644 --- a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/package-info.java +++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/package-info.java @@ -169,7 +169,7 @@ * handles initialization and commit through the * {@link org.apache.maven.api.build.context.spi SPI} interfaces.

* - * @since 4.0.0 + * @since 4.1.0 * @see BuildContext * @see Input * @see Output diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/BuildContextEnvironment.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/BuildContextEnvironment.java index 0a4a008050a2..18c0246d50b9 100644 --- a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/BuildContextEnvironment.java +++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/BuildContextEnvironment.java @@ -45,7 +45,7 @@ * mojo execution (see {@link BuildContextFinalizer}). * * - * @since 4.0.0 + * @since 4.1.0 * @see Workspace * @see BuildContextFinalizer * @see CommitableBuildContext diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/BuildContextFinalizer.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/BuildContextFinalizer.java index 389ba1f28842..860cdcd8ab63 100644 --- a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/BuildContextFinalizer.java +++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/BuildContextFinalizer.java @@ -40,7 +40,7 @@ * instances (e.g., for different source roots) will have each context registered * individually through this finalizer.

* - * @since 4.0.0 + * @since 4.1.0 * @see CommitableBuildContext#commit(Sink) * @see BuildContextEnvironment#getFinalizer() */ diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/CommitableBuildContext.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/CommitableBuildContext.java index b34ba25f9502..4fecf7e7d814 100644 --- a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/CommitableBuildContext.java +++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/CommitableBuildContext.java @@ -38,7 +38,7 @@ * deletes stale outputs, and reports messages through the sink * * - * @since 4.0.0 + * @since 4.1.0 * @see BuildContextFinalizer * @see Sink */ diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/FileState.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/FileState.java index 8c604f9670d8..86f873b8efea 100644 --- a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/FileState.java +++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/FileState.java @@ -41,7 +41,7 @@ * the four-argument constructor allows the workspace to supply pre-computed values * (e.g., from an IDE's file-watcher cache).

* - * @since 4.0.0 + * @since 4.1.0 * @see Workspace#walk(java.nio.file.Path) * @see Status */ diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/Message.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/Message.java index 3bb886f07c6d..9006e7eb9ef6 100644 --- a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/Message.java +++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/Message.java @@ -42,7 +42,7 @@ * persisted as part of the build context state and re-reported on subsequent builds * if the resource has not been reprocessed.

* - * @since 4.0.0 + * @since 4.1.0 * @see org.apache.maven.api.build.context.Resource#addMessage(int, int, String, * org.apache.maven.api.build.context.Severity, Throwable) * @see Sink#messages(java.nio.file.Path, boolean, java.util.Collection) diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/Sink.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/Sink.java index ac00f7ccd21d..be9c5f131368 100644 --- a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/Sink.java +++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/Sink.java @@ -42,7 +42,7 @@ * have been fixed). * * - * @since 4.0.0 + * @since 4.1.0 * @see CommitableBuildContext#commit(Sink) * @see Message * @see org.apache.maven.api.build.context.Resource#addMessage(int, int, String, diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/Workspace.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/Workspace.java index ad8251f6e33e..48cf73300968 100644 --- a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/Workspace.java +++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/Workspace.java @@ -55,7 +55,7 @@ * Used for configuration-only builds or when incremental support is disabled. * * - * @since 4.0.0 + * @since 4.1.0 * @see BuildContextEnvironment#getWorkspace() * @see org.apache.maven.api.build.context.BuildContext */ @@ -160,7 +160,7 @@ public interface Workspace { /** * The workspace operating mode. * - * @since 4.0.0 + * @since 4.1.0 */ enum Mode { /** Normal mode — the build context determines resource status. */ diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/package-info.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/package-info.java index bc501be63a19..03b716fe14c0 100644 --- a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/package-info.java +++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/package-info.java @@ -112,7 +112,7 @@ * communicate file metadata and diagnostic messages between the build context * implementation and the workspace / sink.

* - * @since 4.0.0 + * @since 4.1.0 * @see org.apache.maven.api.build.context * @see Workspace * @see BuildContextEnvironment From 52ec6c630546e25096346c3ca50ea2885354636f Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Wed, 29 Jul 2026 01:30:37 +0200 Subject: [PATCH 05/16] Fix review findings: bugs, API naming, and defensive coding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix ClasspathDigester.digestZipOrFile() always re-throwing after non-ZIP fallback, and reset MessageDigest after partial update - Fix ClasspathDigester.digestDirectory() passing subdirectories to digestFile() by filtering to regular files only - Fix DefaultResource.equals() casting to wrong type (DefaultMetadata instead of DefaultResource) - Rename CommitableBuildContext → CommittableBuildContext (correct English spelling for public API type) - Fix commit() @Nonnull → @Nullable on Sink parameter to match implementation behavior - Rename getFailOnError() → isFailOnError() for boolean convention - Fix FileMatcher.getCanonicalPath() NPE when path has no parent - Fix readCollection() returning null for empty collections (NPE risk in invertMultimap) - Replace bare NullPointerException with Objects.requireNonNull - Clarify registerAndProcessInputs() Javadoc: returns all inputs, not just processed ones Co-Authored-By: Claude Opus 4.6 --- .../maven/api/build/context/BuildContext.java | 12 +++++++++--- .../build/context/spi/BuildContextEnvironment.java | 2 +- .../build/context/spi/BuildContextFinalizer.java | 6 +++--- ...ldContext.java => CommittableBuildContext.java} | 8 ++++---- .../apache/maven/api/build/context/spi/Sink.java | 4 ++-- .../maven/api/build/context/spi/package-info.java | 6 +++--- .../context/impl/maven/MavenBuildContext.java | 8 ++++---- .../impl/maven/MavenBuildContextFinalizer.java | 12 ++++++------ .../impl/maven/digest/ClasspathDigester.java | 11 ++++++----- .../build/context/impl/DefaultBuildContext.java | 14 +++++--------- .../context/impl/DefaultBuildContextState.java | 2 +- .../build/context/impl/DefaultResource.java | 2 +- .../internal/build/context/impl/FileMatcher.java | 6 +++++- 13 files changed, 50 insertions(+), 43 deletions(-) rename api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/{CommitableBuildContext.java => CommittableBuildContext.java} (88%) diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/BuildContext.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/BuildContext.java index 65d2ac1a708f..ade393e5a8ca 100644 --- a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/BuildContext.java +++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/BuildContext.java @@ -164,12 +164,18 @@ Collection> registerInputs( /** * Registers inputs identified by {@code basedir} and {@code includes}/{@code excludes} ant - * patterns, then processes inputs that are new or modified since the previous build. + * patterns, then marks inputs that are {@link Status#NEW NEW} or {@link Status#MODIFIED MODIFIED} + * as processed. + * + *

The returned collection contains all matching inputs, including + * {@link Status#UNMODIFIED UNMODIFIED} ones. Unmodified inputs are included so that + * output associations and metadata can be carried forward. Only new and modified inputs + * are marked as processed internally.

* * @param basedir the base directory to look for inputs, must not be {@code null} * @param includes patterns of the files to register, may be {@code null} * @param excludes patterns of the files to ignore, may be {@code null} - * @return the processed inputs + * @return all registered inputs (new, modified, and unmodified) * @throws BuildContextException if an I/O error occurs */ @Nonnull @@ -193,5 +199,5 @@ Collection registerAndProcessInputs( /** * {@return whether the build should fail on error} */ - boolean getFailOnError(); + boolean isFailOnError(); } diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/BuildContextEnvironment.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/BuildContextEnvironment.java index 18c0246d50b9..36b6490a3f9a 100644 --- a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/BuildContextEnvironment.java +++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/BuildContextEnvironment.java @@ -48,7 +48,7 @@ * @since 4.1.0 * @see Workspace * @see BuildContextFinalizer - * @see CommitableBuildContext + * @see CommittableBuildContext */ @Experimental @ThreadSafe diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/BuildContextFinalizer.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/BuildContextFinalizer.java index 860cdcd8ab63..f74c87bd738b 100644 --- a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/BuildContextFinalizer.java +++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/BuildContextFinalizer.java @@ -28,7 +28,7 @@ * at the end of a mojo execution. * *

The Maven runtime implements this interface to collect all - * {@link CommitableBuildContext} instances created during a mojo's execution and + * {@link CommittableBuildContext} instances created during a mojo's execution and * commit them in a single batch after the mojo completes. This ensures that:

*
    *
  • State is persisted only after the mojo succeeds (no partial state on failure)
  • @@ -41,7 +41,7 @@ * individually through this finalizer.

    * * @since 4.1.0 - * @see CommitableBuildContext#commit(Sink) + * @see CommittableBuildContext#commit(Sink) * @see BuildContextEnvironment#getFinalizer() */ @Experimental @@ -54,5 +54,5 @@ public interface BuildContextFinalizer { * * @param context the build context to register */ - void registerContext(@Nonnull CommitableBuildContext context); + void registerContext(@Nonnull CommittableBuildContext context); } diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/CommitableBuildContext.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/CommittableBuildContext.java similarity index 88% rename from api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/CommitableBuildContext.java rename to api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/CommittableBuildContext.java index 4fecf7e7d814..9bb197fe24e9 100644 --- a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/CommitableBuildContext.java +++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/CommittableBuildContext.java @@ -19,8 +19,8 @@ package org.apache.maven.api.build.context.spi; import org.apache.maven.api.annotations.Experimental; -import org.apache.maven.api.annotations.Nonnull; import org.apache.maven.api.annotations.NotThreadSafe; +import org.apache.maven.api.annotations.Nullable; import org.apache.maven.api.annotations.Provider; import org.apache.maven.api.build.context.BuildContext; @@ -45,12 +45,12 @@ @Experimental @NotThreadSafe @Provider -public interface CommitableBuildContext extends BuildContext { +public interface CommittableBuildContext extends BuildContext { /** * Commits all changes in this build context and reports messages to the given sink. * - * @param sink the sink that receives build messages + * @param sink the sink that receives build messages, or {@code null} if no message reporting is needed */ - void commit(@Nonnull Sink sink); + void commit(@Nullable Sink sink); } diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/Sink.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/Sink.java index be9c5f131368..33452b2d0844 100644 --- a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/Sink.java +++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/Sink.java @@ -29,7 +29,7 @@ /** * Receives diagnostic messages produced during a build context commit. * - *

    When {@link CommitableBuildContext#commit(Sink)} is called, the build context + *

    When {@link CommittableBuildContext#commit(Sink)} is called, the build context * iterates over all resources and delivers their attached messages to this sink. * Implementations typically bridge to the IDE's problem/markers view, the Maven * build log, or an error reporter:

    @@ -43,7 +43,7 @@ *
* * @since 4.1.0 - * @see CommitableBuildContext#commit(Sink) + * @see CommittableBuildContext#commit(Sink) * @see Message * @see org.apache.maven.api.build.context.Resource#addMessage(int, int, String, * org.apache.maven.api.build.context.Severity, Throwable) diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/package-info.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/package-info.java index 03b716fe14c0..ede767dc9dee 100644 --- a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/package-info.java +++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/package-info.java @@ -52,7 +52,7 @@ * Maven runtime * * - * {@link CommitableBuildContext} + * {@link CommittableBuildContext} * Extends {@link org.apache.maven.api.build.context.BuildContext} with commit * and message-reporting capabilities * Build context implementation @@ -75,7 +75,7 @@ * | * v creates * +--------------------+ - * | CommitableBuildContext| + * | CommittableBuildContext| * | (extends BuildContext)| * +----------+---------+ * | @@ -116,7 +116,7 @@ * @see org.apache.maven.api.build.context * @see Workspace * @see BuildContextEnvironment - * @see CommitableBuildContext + * @see CommittableBuildContext */ @Experimental package org.apache.maven.api.build.context.spi; diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/MavenBuildContext.java b/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/MavenBuildContext.java index 0bc5340109b1..789a913f387f 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/MavenBuildContext.java +++ b/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/MavenBuildContext.java @@ -24,7 +24,7 @@ import org.apache.maven.api.build.context.InputSet; import org.apache.maven.api.build.context.spi.BuildContextEnvironment; -import org.apache.maven.api.build.context.spi.CommitableBuildContext; +import org.apache.maven.api.build.context.spi.CommittableBuildContext; import org.apache.maven.api.build.context.spi.Sink; import org.apache.maven.api.di.Inject; import org.apache.maven.api.di.MojoExecutionScoped; @@ -36,7 +36,7 @@ import org.apache.maven.internal.build.context.impl.DefaultOutput; @Named -public class MavenBuildContext implements CommitableBuildContext { +public class MavenBuildContext implements CommittableBuildContext { private final Supplier provider; @@ -49,8 +49,8 @@ MojoExecutionScopedBuildContext getDelegate() { return provider.get(); } - public boolean getFailOnError() { - return getDelegate().getFailOnError(); + public boolean isFailOnError() { + return getDelegate().isFailOnError(); } @Override diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/MavenBuildContextFinalizer.java b/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/MavenBuildContextFinalizer.java index 8c02437ab736..cff58c5f3747 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/MavenBuildContextFinalizer.java +++ b/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/MavenBuildContextFinalizer.java @@ -31,7 +31,7 @@ import org.apache.maven.api.build.context.BuildContext; import org.apache.maven.api.build.context.Severity; import org.apache.maven.api.build.context.spi.BuildContextFinalizer; -import org.apache.maven.api.build.context.spi.CommitableBuildContext; +import org.apache.maven.api.build.context.spi.CommittableBuildContext; import org.apache.maven.api.build.context.spi.Message; import org.apache.maven.api.build.context.spi.Sink; import org.apache.maven.api.di.Inject; @@ -45,7 +45,7 @@ @MojoExecutionScoped public class MavenBuildContextFinalizer implements WeakMojoExecutionListener, BuildContextFinalizer { - private final List contexts = new ArrayList<>(); + private final List contexts = new ArrayList<>(); private final Sink sink; @@ -54,7 +54,7 @@ public MavenBuildContextFinalizer(@Nullable Sink sink) { this.sink = sink; } - public void registerContext(CommitableBuildContext context) { + public void registerContext(CommittableBuildContext context) { contexts.add(context); } @@ -66,7 +66,7 @@ protected List getRegisteredContexts() { public void afterMojoExecutionSuccess(MojoExecutionEvent event) throws MojoExecutionException { try { final Map> allMessages = new HashMap<>(); - for (CommitableBuildContext context : contexts) { + for (CommittableBuildContext context : contexts) { context.commit(new Sink() { @Override public void clear(Path resource) { @@ -119,10 +119,10 @@ protected void failBuild(final Map> messages) throws M } } - private Set extractFailOnErrors(List contexts) { + private Set extractFailOnErrors(List contexts) { final Set result = new HashSet<>(); for (BuildContext context : contexts) { - result.add(context.getFailOnError()); + result.add(context.isFailOnError()); } return result; } diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/digest/ClasspathDigester.java b/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/digest/ClasspathDigester.java index 1e17adad2d10..43a1aedb26d2 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/digest/ClasspathDigester.java +++ b/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/digest/ClasspathDigester.java @@ -121,23 +121,24 @@ private static byte[] digestZipOrFile(Path file) { digestZip(d, file); } catch (BuildContextException e) { if (e.getCause() instanceof ZipException) { + // not a valid ZIP/JAR, fall back to plain file digest + d = SHA1Digester.newInstance(); digestFile(d, file); + } else { + throw e; } - throw e; } return d.digest(); } private static byte[] digestDirectory(Path file) { - byte[] hash; MessageDigest d = SHA1Digester.newInstance(); try (Stream s = Files.walk(file)) { - s.sorted().forEach(f -> digestFile(d, f)); + s.filter(Files::isRegularFile).sorted().forEach(f -> digestFile(d, f)); } catch (IOException e) { throw new BuildContextException(e); } - hash = d.digest(); - return hash; + return d.digest(); } public Serializable digest(List artifacts) { diff --git a/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultBuildContext.java b/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultBuildContext.java index f3ee4718dff1..8acd09bc1a0b 100644 --- a/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultBuildContext.java +++ b/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultBuildContext.java @@ -50,12 +50,12 @@ import org.apache.maven.api.build.context.Status; import org.apache.maven.api.build.context.spi.BuildContextEnvironment; import org.apache.maven.api.build.context.spi.BuildContextFinalizer; -import org.apache.maven.api.build.context.spi.CommitableBuildContext; +import org.apache.maven.api.build.context.spi.CommittableBuildContext; import org.apache.maven.api.build.context.spi.Message; import org.apache.maven.api.build.context.spi.Sink; import org.apache.maven.api.build.context.spi.Workspace; -public class DefaultBuildContext implements CommitableBuildContext { +public class DefaultBuildContext implements CommittableBuildContext { final Workspace workspace; final Path stateFile; final DefaultBuildContextState state; @@ -94,12 +94,8 @@ protected DefaultBuildContext( Map configuration, BuildContextFinalizer finalizer) { // preconditions - if (workspace == null) { - throw new NullPointerException(); - } - if (configuration == null) { - throw new NullPointerException(); - } + Objects.requireNonNull(workspace, "workspace"); + Objects.requireNonNull(configuration, "configuration"); this.stateFile = stateFile != null ? stateFile.toAbsolutePath() : null; this.state = DefaultBuildContextState.withConfiguration(configuration); @@ -146,7 +142,7 @@ static Path normalize(Path input) { return FileMatcher.getCanonicalPath(input); } - public boolean getFailOnError() { + public boolean isFailOnError() { return failOnError; } diff --git a/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultBuildContextState.java b/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultBuildContextState.java index 40a62b4bad05..ea87d076e252 100644 --- a/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultBuildContextState.java +++ b/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultBuildContextState.java @@ -223,7 +223,7 @@ private static Map> readMultimap(ObjectInputStream ois) private static Collection readCollection(ObjectInputStream ois) throws IOException, ClassNotFoundException { int size = ois.readInt(); if (size == 0) { - return null; + return Collections.emptyList(); } Collection collection = new ArrayList(); for (int i = 0; i < size; i++) { diff --git a/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultResource.java b/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultResource.java index 94e0f3d8fdf6..20c9f360cec1 100644 --- a/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultResource.java +++ b/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultResource.java @@ -62,7 +62,7 @@ public boolean equals(Object o) { if (o == null || getClass() != o.getClass()) { return false; } - DefaultMetadata that = (DefaultMetadata) o; + DefaultResource that = (DefaultResource) o; return context == that.context && state == that.state && resource.equals(that.resource); } diff --git a/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/FileMatcher.java b/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/FileMatcher.java index cd875cdb943f..ebcb6203d5dc 100644 --- a/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/FileMatcher.java +++ b/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/FileMatcher.java @@ -249,7 +249,11 @@ static Path getCanonicalPath(Path path) { try { return path.toRealPath(); } catch (IOException e) { - return getCanonicalPath(path.getParent()).resolve(path.getFileName()); + Path parent = path.getParent(); + if (parent == null) { + return path.toAbsolutePath().normalize(); + } + return getCanonicalPath(parent).resolve(path.getFileName()); } } From 1cf446e75486fec1380c46cffa7c475f4e9159f8 Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Wed, 29 Jul 2026 02:45:15 +0200 Subject: [PATCH 06/16] Add IT for V4 incremental build context and wire BuildContext DI for V4 mojos MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add MavenITgh12576IncrementalBuildContextTest with a real V4 plugin (IncrementalCopyMojo) that uses @Inject BuildContext for incremental file copying across 3 build scenarios: initial, no-change, modified - Wire @MojoExecutionScoped scope, BuildContext impl bindings, and explicit BuildContext→MojoExecutionScopedBuildContext supplier in V4 mojo injector (configureV4MojoInjector) - Wrap V4 mojos to call MavenBuildContextFinalizer after execution since WeakMojoExecutionListener lifecycle doesn't apply to V4 mojos - Fix ClasspathDigester to skip artifacts with unresolved paths instead of throwing NoSuchElementException Co-Authored-By: Claude Opus 4.6 --- .../impl/maven/digest/ClasspathDigester.java | 9 +- .../internal/DefaultMavenPluginManager.java | 83 +++++++++++++++- ...nITgh12576IncrementalBuildContextTest.java | 85 +++++++++++++++++ .../consumer/pom.xml | 46 +++++++++ .../consumer/src/main/data/file1.txt | 1 + .../consumer/src/main/data/file2.txt | 1 + .../plugin/pom.xml | 62 ++++++++++++ .../its/gh12576/IncrementalCopyMojo.java | 95 +++++++++++++++++++ 8 files changed, 379 insertions(+), 3 deletions(-) create mode 100644 its/core-it-suite/src/test/java/org/apache/maven/it/MavenITgh12576IncrementalBuildContextTest.java create mode 100644 its/core-it-suite/src/test/resources/gh-12576-incremental-build-context/consumer/pom.xml create mode 100644 its/core-it-suite/src/test/resources/gh-12576-incremental-build-context/consumer/src/main/data/file1.txt create mode 100644 its/core-it-suite/src/test/resources/gh-12576-incremental-build-context/consumer/src/main/data/file2.txt create mode 100644 its/core-it-suite/src/test/resources/gh-12576-incremental-build-context/plugin/pom.xml create mode 100644 its/core-it-suite/src/test/resources/gh-12576-incremental-build-context/plugin/src/main/java/org/apache/maven/its/gh12576/IncrementalCopyMojo.java diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/digest/ClasspathDigester.java b/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/digest/ClasspathDigester.java index 43a1aedb26d2..1ff00d585563 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/digest/ClasspathDigester.java +++ b/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/digest/ClasspathDigester.java @@ -26,6 +26,7 @@ import java.security.MessageDigest; import java.util.Comparator; import java.util.List; +import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import java.util.stream.Stream; @@ -144,7 +145,13 @@ private static byte[] digestDirectory(Path file) { public Serializable digest(List artifacts) { MessageDigest digester = SHA1Digester.newInstance(); for (Artifact artifact : artifacts) { - Path file = artifactManager.getPath(artifact).get(); + Optional pathOpt = artifactManager.getPath(artifact); + if (pathOpt.isEmpty()) { + // Skip artifacts whose path is not resolved (e.g. the root dependency node + // in the plugin's dependency tree, or provided-scope API artifacts) + continue; + } + Path file = pathOpt.get(); String cacheKey = getArtifactKey(artifact); byte[] cached = cache.get(cacheKey); if (cached == null) { diff --git a/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/DefaultMavenPluginManager.java b/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/DefaultMavenPluginManager.java index 15f3d8df7b64..5882d883b0cf 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/DefaultMavenPluginManager.java +++ b/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/DefaultMavenPluginManager.java @@ -60,6 +60,7 @@ import org.apache.maven.di.Injector; import org.apache.maven.di.Key; import org.apache.maven.execution.MavenSession; +import org.apache.maven.execution.MojoExecutionEvent; import org.apache.maven.execution.scope.internal.MojoExecutionScope; import org.apache.maven.execution.scope.internal.MojoExecutionScopeModule; import org.apache.maven.internal.impl.DefaultLog; @@ -556,9 +557,11 @@ private T loadV4Mojo( org.apache.maven.api.MojoExecution execution = new DefaultMojoExecution(sessionV4, mojoExecution); org.apache.maven.api.plugin.Log log = new DefaultLog( LoggerFactory.getLogger(mojoExecution.getMojoDescriptor().getFullGoalName())); + Injector injector; try { - Injector injector = Injector.create(); + injector = Injector.create(); injector.discover(pluginRealm); + configureV4MojoInjector(injector); // Add known classes // TODO: get those from the existing plexus scopes ? injector.bindInstance(Session.class, sessionV4); @@ -676,7 +679,83 @@ private T loadV4Mojo( } } - return mojo; + return wrapV4MojoWithBuildContextFinalizer(mojoInterface, mojo, injector, session, mojoExecution); + } + + /** + * Configures scope handling and core implementation bindings for the V4 mojo injector. + * Registers {@code @MojoExecutionScoped} as a singleton scope (safe because one injector + * is created per mojo execution) and makes the incremental {@code BuildContext} + * implementation available for injection into V4 mojos. + */ + private void configureV4MojoInjector(Injector injector) { + // Treat @MojoExecutionScoped as a singleton scope since each V4 injector + // is created per mojo execution. Use a shared key-based cache so that different + // injection points for the same type get the same instance (the DI framework may + // compile the same binding multiple times for qualified vs unqualified keys). + java.util.concurrent.ConcurrentHashMap, Object> scopeCache = + new java.util.concurrent.ConcurrentHashMap<>(); + injector.bindScope(org.apache.maven.api.di.MojoExecutionScoped.class, new org.apache.maven.di.Scope() { + @SuppressWarnings("unchecked") + @Override + public Supplier scope(Key key, Supplier unscoped) { + return () -> (U) scopeCache.computeIfAbsent(key, k -> unscoped.get()); + } + }); + + // Bind incremental BuildContext implementation classes. + // We skip MavenBuildContext here because it requires Supplier + // for lazy per-execution creation in the V3 (Plexus/Sisu) path, and the Maven DI system does + // not auto-wrap T bindings into Supplier. Since each V4 injector is already per-execution, + // we bind BuildContext directly to MojoExecutionScopedBuildContext instead. + injector.bindImplicit(org.apache.maven.internal.build.context.impl.FilesystemWorkspace.class); + injector.bindImplicit(org.apache.maven.internal.build.context.impl.maven.ProjectWorkspace.class); + injector.bindImplicit( + org.apache.maven.internal.build.context.impl.maven.digest.MojoConfigurationDigester.class); + injector.bindImplicit(org.apache.maven.internal.build.context.impl.maven.MavenBuildContextFinalizer.class); + injector.bindImplicit(org.apache.maven.internal.build.context.impl.maven.MavenBuildContextConfiguration.class); + injector.bindImplicit( + org.apache.maven.internal.build.context.impl.maven.MavenBuildContext.MojoExecutionScopedBuildContext + .class); + // Expose the scoped MojoExecutionScopedBuildContext as BuildContext for plugin injection. + // @Typed on the inner class restricts its implicit bindings to its own type only, + // so we need an explicit binding to make it available as the BuildContext interface. + injector.bindSupplier( + org.apache.maven.api.build.context.BuildContext.class, + () -> injector.getInstance( + org.apache.maven.internal.build.context.impl.maven.MavenBuildContext + .MojoExecutionScopedBuildContext.class)); + } + + /** + * Wraps a V4 mojo to call the BuildContext finalizer after successful execution. + *

+ * V4 mojos bypass the Sisu {@code MojoExecutionScope}, so {@code WeakMojoExecutionListener} + * instances created by the V4 injector are never notified by the normal lifecycle. This method + * detects when a {@code MavenBuildContextFinalizer} was created (because the mojo injects + * {@code BuildContext}) and wraps the mojo's {@code execute()} to call + * {@code afterMojoExecutionSuccess()} afterward, ensuring incremental build state is persisted. + */ + @SuppressWarnings("unchecked") + private T wrapV4MojoWithBuildContextFinalizer( + Class mojoInterface, T mojo, Injector injector, MavenSession session, MojoExecution mojoExecution) { + if (mojoInterface != org.apache.maven.api.plugin.Mojo.class) { + return mojo; + } + org.apache.maven.internal.build.context.impl.maven.MavenBuildContextFinalizer finalizer; + try { + finalizer = injector.getInstance( + org.apache.maven.internal.build.context.impl.maven.MavenBuildContextFinalizer.class); + } catch (org.apache.maven.di.impl.DIException ignored) { + return mojo; + } + org.apache.maven.internal.build.context.impl.maven.MavenBuildContextFinalizer f = finalizer; + org.apache.maven.api.plugin.Mojo realMojo = (org.apache.maven.api.plugin.Mojo) mojo; + return (T) (org.apache.maven.api.plugin.Mojo) () -> { + realMojo.execute(); + f.afterMojoExecutionSuccess( + new MojoExecutionEvent(session, session.getCurrentProject(), mojoExecution, null)); + }; } private T loadV3Mojo( diff --git a/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITgh12576IncrementalBuildContextTest.java b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITgh12576IncrementalBuildContextTest.java new file mode 100644 index 000000000000..b1825388b114 --- /dev/null +++ b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITgh12576IncrementalBuildContextTest.java @@ -0,0 +1,85 @@ +/* + * 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.it; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import org.junit.jupiter.api.Test; + +/** + * Integration test for the incremental build context API + * (#12576). + *

+ * Verifies end-to-end incremental build behavior using a test plugin that + * injects {@code BuildContext} and uses it to copy files incrementally: + *

    + *
  1. Initial build: all source files are NEW and get processed
  2. + *
  3. No-change rebuild: all files are UNMODIFIED and get skipped
  4. + *
  5. After modification: only the changed file is re-processed
  6. + *
+ */ +class MavenITgh12576IncrementalBuildContextTest extends AbstractMavenIntegrationTestCase { + + @Test + void testIncrementalBuildContext() throws Exception { + Path testDir = extractResources("/gh-12576-incremental-build-context"); + + // Step 1: install the incremental-copy test plugin + Verifier pluginVerifier = newVerifier(testDir.resolve("plugin")); + pluginVerifier.addCliArgument("install"); + pluginVerifier.execute(); + pluginVerifier.verifyErrorFreeLog(); + + // Step 2: initial build — all files should be processed as NEW + Verifier v1 = newVerifier(testDir.resolve("consumer")); + v1.setAutoclean(false); + v1.addCliArgument("process-sources"); + v1.execute(); + v1.verifyErrorFreeLog(); + v1.verifyTextInLog("[incremental] Processed file1.txt (NEW)"); + v1.verifyTextInLog("[incremental] Processed file2.txt (NEW)"); + v1.verifyTextInLog("[incremental] Summary: 2 processed, 0 skipped"); + v1.verifyFilePresent("target/data/file1.txt"); + v1.verifyFilePresent("target/data/file2.txt"); + + // Step 3: no-change rebuild — all files should be skipped + Verifier v2 = newVerifier(testDir.resolve("consumer")); + v2.setAutoclean(false); + v2.addCliArgument("process-sources"); + v2.execute(); + v2.verifyErrorFreeLog(); + v2.verifyTextInLog("[incremental] Summary: 0 processed, 2 skipped"); + + // Step 4: modify one file, then rebuild — only the modified file should be re-processed + Path file1 = testDir.resolve("consumer/src/main/data/file1.txt"); + // Ensure the timestamp actually changes (some filesystems have 1-second granularity) + Thread.sleep(1100); + Files.write(file1, "modified content for incremental rebuild test\n".getBytes(StandardCharsets.UTF_8)); + + Verifier v3 = newVerifier(testDir.resolve("consumer")); + v3.setAutoclean(false); + v3.addCliArgument("process-sources"); + v3.execute(); + v3.verifyErrorFreeLog(); + v3.verifyTextInLog("[incremental] Processed file1.txt (MODIFIED)"); + v3.verifyTextInLog("[incremental] Summary: 1 processed, 1 skipped"); + } +} diff --git a/its/core-it-suite/src/test/resources/gh-12576-incremental-build-context/consumer/pom.xml b/its/core-it-suite/src/test/resources/gh-12576-incremental-build-context/consumer/pom.xml new file mode 100644 index 000000000000..762a76947ed2 --- /dev/null +++ b/its/core-it-suite/src/test/resources/gh-12576-incremental-build-context/consumer/pom.xml @@ -0,0 +1,46 @@ + + + + 4.0.0 + + org.apache.maven.its.gh12576 + consumer + 1.0-SNAPSHOT + + Incremental Build Context IT Consumer + + + + + org.apache.maven.its.gh12576 + incremental-copy-maven-plugin + 1.0-SNAPSHOT + + + copy-data + + copy + + + + + + + diff --git a/its/core-it-suite/src/test/resources/gh-12576-incremental-build-context/consumer/src/main/data/file1.txt b/its/core-it-suite/src/test/resources/gh-12576-incremental-build-context/consumer/src/main/data/file1.txt new file mode 100644 index 000000000000..c5f3776eea9f --- /dev/null +++ b/its/core-it-suite/src/test/resources/gh-12576-incremental-build-context/consumer/src/main/data/file1.txt @@ -0,0 +1 @@ +This is the first test file for incremental build context IT. diff --git a/its/core-it-suite/src/test/resources/gh-12576-incremental-build-context/consumer/src/main/data/file2.txt b/its/core-it-suite/src/test/resources/gh-12576-incremental-build-context/consumer/src/main/data/file2.txt new file mode 100644 index 000000000000..0cf92a053d33 --- /dev/null +++ b/its/core-it-suite/src/test/resources/gh-12576-incremental-build-context/consumer/src/main/data/file2.txt @@ -0,0 +1 @@ +This is the second test file for incremental build context IT. diff --git a/its/core-it-suite/src/test/resources/gh-12576-incremental-build-context/plugin/pom.xml b/its/core-it-suite/src/test/resources/gh-12576-incremental-build-context/plugin/pom.xml new file mode 100644 index 000000000000..6267ef5e3027 --- /dev/null +++ b/its/core-it-suite/src/test/resources/gh-12576-incremental-build-context/plugin/pom.xml @@ -0,0 +1,62 @@ + + + + 4.1.0 + + org.apache.maven.its.gh12576 + incremental-copy-maven-plugin + 1.0-SNAPSHOT + maven-plugin + + Incremental Copy Plugin (IT for gh-12576) + Test plugin that uses the BuildContext API for incremental file copying + + + UTF-8 + 17 + 4.1.0-SNAPSHOT + 4.0.0-beta-1 + + + + + org.apache.maven + maven-api-core + ${mavenVersion} + provided + + + org.apache.maven + maven-api-di + ${mavenVersion} + provided + + + + + + + org.apache.maven.plugins + maven-plugin-plugin + ${mavenPluginPluginVersion} + + + + diff --git a/its/core-it-suite/src/test/resources/gh-12576-incremental-build-context/plugin/src/main/java/org/apache/maven/its/gh12576/IncrementalCopyMojo.java b/its/core-it-suite/src/test/resources/gh-12576-incremental-build-context/plugin/src/main/java/org/apache/maven/its/gh12576/IncrementalCopyMojo.java new file mode 100644 index 000000000000..eb9730cc3169 --- /dev/null +++ b/its/core-it-suite/src/test/resources/gh-12576-incremental-build-context/plugin/src/main/java/org/apache/maven/its/gh12576/IncrementalCopyMojo.java @@ -0,0 +1,95 @@ +/* + * 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.its.gh12576; + +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Collection; + +import org.apache.maven.api.Project; +import org.apache.maven.api.build.context.BuildContext; +import org.apache.maven.api.build.context.Input; +import org.apache.maven.api.build.context.Output; +import org.apache.maven.api.build.context.Status; +import org.apache.maven.api.di.Inject; +import org.apache.maven.api.plugin.Log; +import org.apache.maven.api.plugin.annotations.Mojo; +import org.apache.maven.api.plugin.annotations.Parameter; + +/** + * A simple incremental file-copy mojo that uses the BuildContext API. + *

+ * On initial build, all source files are NEW and get copied. + * On subsequent builds with no changes, all files are UNMODIFIED and skipped. + * When a source file is modified, only that file is re-copied. + */ +@Mojo(name = "copy", defaultPhase = "process-sources") +public class IncrementalCopyMojo implements org.apache.maven.api.plugin.Mojo { + + @Inject + private BuildContext buildContext; + + @Inject + private Log log; + + @Inject + private Project project; + + @Parameter(defaultValue = "src/main/data") + private String sourceDirectory; + + @Parameter(defaultValue = "${project.build.directory}/data") + private String outputDirectory; + + @Override + public void execute() { + Path srcDir = project.getBasedir().resolve(sourceDirectory); + Path outDir = Paths.get(outputDirectory); + + if (!Files.isDirectory(srcDir)) { + log.info("[incremental] Source directory does not exist: " + srcDir); + return; + } + + Collection inputs = buildContext.registerAndProcessInputs(srcDir, null, null); + + int processed = 0; + int skipped = 0; + for (Input input : inputs) { + Path relativePath = srcDir.relativize(input.getPath()); + if (input.getStatus() == Status.NEW || input.getStatus() == Status.MODIFIED) { + Path outputPath = outDir.resolve(relativePath); + Output output = input.associateOutput(outputPath); + try (OutputStream os = output.newOutputStream()) { + Files.copy(input.getPath(), os); + } catch (Exception e) { + throw new RuntimeException("Failed to copy " + input.getPath(), e); + } + log.info("[incremental] Processed " + relativePath + " (" + input.getStatus() + ")"); + processed++; + } else { + skipped++; + } + } + + log.info("[incremental] Summary: " + processed + " processed, " + skipped + " skipped"); + } +} From b78ca846713bc2cc47deca82a06c152fd6d4c24d Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Wed, 29 Jul 2026 10:23:54 +0200 Subject: [PATCH 07/16] Fix V4 mojo DI scope and BuildContext finalizer for CI stability - Replace ConcurrentHashMap with HashMap in V4 @MojoExecutionScoped scope cache: ConcurrentHashMap.computeIfAbsent throws "Recursive update" when scoped beans depend on each other and hash to the same bucket; V4 mojo execution is single-threaded so HashMap is safe - Add early return in MavenBuildContextFinalizer.afterMojoExecutionSuccess when no contexts are registered: prevents "Contexts FailOnError property have different values" error for V4 mojos that don't inject BuildContext (empty context list + null Sink triggered failBuild with an empty failOnError set) - Fix IT to use per-build log files (setLogFileName) and clean on first build to avoid stale incremental state from prior test runs Co-Authored-By: Claude Opus 4.6 --- .../impl/maven/MavenBuildContextFinalizer.java | 3 +++ .../internal/DefaultMavenPluginManager.java | 15 ++++++++++++--- ...MavenITgh12576IncrementalBuildContextTest.java | 6 +++++- 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/MavenBuildContextFinalizer.java b/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/MavenBuildContextFinalizer.java index cff58c5f3747..c49572dba3e3 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/MavenBuildContextFinalizer.java +++ b/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/MavenBuildContextFinalizer.java @@ -64,6 +64,9 @@ protected List getRegisteredContexts() { @Override public void afterMojoExecutionSuccess(MojoExecutionEvent event) throws MojoExecutionException { + if (contexts.isEmpty()) { + return; + } try { final Map> allMessages = new HashMap<>(); for (CommittableBuildContext context : contexts) { diff --git a/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/DefaultMavenPluginManager.java b/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/DefaultMavenPluginManager.java index 5882d883b0cf..1a9a2ed79e7d 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/DefaultMavenPluginManager.java +++ b/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/DefaultMavenPluginManager.java @@ -693,13 +693,22 @@ private void configureV4MojoInjector(Injector injector) { // is created per mojo execution. Use a shared key-based cache so that different // injection points for the same type get the same instance (the DI framework may // compile the same binding multiple times for qualified vs unqualified keys). - java.util.concurrent.ConcurrentHashMap, Object> scopeCache = - new java.util.concurrent.ConcurrentHashMap<>(); + // Use HashMap (not ConcurrentHashMap) because ConcurrentHashMap.computeIfAbsent + // throws "Recursive update" when scoped beans depend on each other and hash to + // the same bucket. V4 mojo execution is single-threaded, so HashMap is safe. + HashMap, Object> scopeCache = new HashMap<>(); injector.bindScope(org.apache.maven.api.di.MojoExecutionScoped.class, new org.apache.maven.di.Scope() { @SuppressWarnings("unchecked") @Override public Supplier scope(Key key, Supplier unscoped) { - return () -> (U) scopeCache.computeIfAbsent(key, k -> unscoped.get()); + return () -> { + U existing = (U) scopeCache.get(key); + if (existing == null) { + existing = unscoped.get(); + scopeCache.put(key, existing); + } + return existing; + }; } }); diff --git a/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITgh12576IncrementalBuildContextTest.java b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITgh12576IncrementalBuildContextTest.java index b1825388b114..934b04fce391 100644 --- a/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITgh12576IncrementalBuildContextTest.java +++ b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITgh12576IncrementalBuildContextTest.java @@ -49,8 +49,10 @@ void testIncrementalBuildContext() throws Exception { pluginVerifier.verifyErrorFreeLog(); // Step 2: initial build — all files should be processed as NEW + // Use clean to ensure no stale incremental state from prior runs Verifier v1 = newVerifier(testDir.resolve("consumer")); - v1.setAutoclean(false); + v1.setLogFileName("log-build1.txt"); + v1.addCliArgument("clean"); v1.addCliArgument("process-sources"); v1.execute(); v1.verifyErrorFreeLog(); @@ -63,6 +65,7 @@ void testIncrementalBuildContext() throws Exception { // Step 3: no-change rebuild — all files should be skipped Verifier v2 = newVerifier(testDir.resolve("consumer")); v2.setAutoclean(false); + v2.setLogFileName("log-build2.txt"); v2.addCliArgument("process-sources"); v2.execute(); v2.verifyErrorFreeLog(); @@ -76,6 +79,7 @@ void testIncrementalBuildContext() throws Exception { Verifier v3 = newVerifier(testDir.resolve("consumer")); v3.setAutoclean(false); + v3.setLogFileName("log-build3.txt"); v3.addCliArgument("process-sources"); v3.execute(); v3.verifyErrorFreeLog(); From 5449c2e80e4380b685839281565661dab00c69a8 Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Wed, 29 Jul 2026 14:44:20 +0200 Subject: [PATCH 08/16] Consolidate FileMatcher into PathSelector via PathMatcherFactory Replace the standalone FileMatcher class (373 lines) with the existing PathSelector infrastructure, exposed through the PathMatcherFactory API. - Add createSubdirectoryMatchers() to PathMatcherFactory API for targeted directory walks using trie-based pattern decomposition - Port trie logic (IncludesTrie, subdirectory decomposition) into PathSelector.ofSubdirectories() - Update DefaultBuildContext to use PathMatcherFactory instead of FileMatcher - Move getCanonicalPath() from FileMatcher to DefaultBuildContext - Delete FileMatcher.java entirely This avoids duplicating Ant-to-glob pattern matching logic and centralizes all path matching in PathSelector, the single implementation behind the PathMatcherFactory service. Co-Authored-By: Claude Opus 4.6 --- .../api/services/PathMatcherFactory.java | 28 ++ .../maven/impl/DefaultPathMatcherFactory.java | 10 + .../org/apache/maven/impl/PathSelector.java | 147 +++++++ .../context/impl/DefaultBuildContext.java | 27 +- .../build/context/impl/FileMatcher.java | 373 ------------------ .../DefaultAggregatorBuildContextTest.java | 2 +- 6 files changed, 210 insertions(+), 377 deletions(-) delete mode 100644 impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/FileMatcher.java diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/services/PathMatcherFactory.java b/api/maven-api-core/src/main/java/org/apache/maven/api/services/PathMatcherFactory.java index 9f83e2e0f8bf..9977bc5ad4f5 100644 --- a/api/maven-api-core/src/main/java/org/apache/maven/api/services/PathMatcherFactory.java +++ b/api/maven-api-core/src/main/java/org/apache/maven/api/services/PathMatcherFactory.java @@ -21,6 +21,7 @@ import java.nio.file.Path; import java.nio.file.PathMatcher; import java.util.Collection; +import java.util.Map; import java.util.Objects; import org.apache.maven.api.Service; @@ -123,6 +124,33 @@ default PathMatcher createIncludeOnlyMatcher(@Nonnull Path baseDirectory, Collec return createPathMatcher(baseDirectory, includes, null, false); } + /** + * Creates a map of subdirectory paths to path matchers, optimized for targeted directory walks. + *

+ * This method decomposes include patterns by parsing their literal leading path segments + * to determine the narrowest possible subdirectories that need to be walked. For example, + * includes {@code ["src/main/java/**/*.java", "src/test/**/*.java"]} produces two entries + * keyed by {@code src/main/java} and {@code src/test}, each with a matcher scoped to only + * those patterns. + *

+ * Callers can then walk each key directory independently, applying only the associated matcher, + * instead of walking the entire base directory. For patterns with no literal prefix (e.g. + * {@code "**/*.xml"}), the base directory itself is used as the key. + *

+ * For single-file patterns with no wildcards at all, the map key is the file path itself + * and the matcher does a direct equality check. + * + * @param baseDirectory the base directory for resolving paths + * @param includes the patterns of files to include, or null/empty for including all files + * @param excludes the patterns of files to exclude, or null/empty for no exclusion + * @return a map of subdirectory (or file) paths to their corresponding path matchers + * @throws NullPointerException if baseDirectory is null + * @since 4.1.0 + */ + @Nonnull + Map createSubdirectoryMatchers( + @Nonnull Path baseDirectory, Collection includes, Collection excludes); + /** * Returns a filter for directories that may contain paths accepted by the given matcher. * The given path matcher should be an instance created by this service. diff --git a/impl/maven-impl/src/main/java/org/apache/maven/impl/DefaultPathMatcherFactory.java b/impl/maven-impl/src/main/java/org/apache/maven/impl/DefaultPathMatcherFactory.java index f26f6cde068d..aa29b95213cb 100644 --- a/impl/maven-impl/src/main/java/org/apache/maven/impl/DefaultPathMatcherFactory.java +++ b/impl/maven-impl/src/main/java/org/apache/maven/impl/DefaultPathMatcherFactory.java @@ -21,6 +21,7 @@ import java.nio.file.Path; import java.nio.file.PathMatcher; import java.util.Collection; +import java.util.Map; import java.util.Objects; import org.apache.maven.api.annotations.Nonnull; @@ -55,6 +56,15 @@ public PathMatcher createPathMatcher( return PathSelector.of(baseDirectory, includes, excludes, useDefaultExcludes); } + @Nonnull + @Override + public Map createSubdirectoryMatchers( + @Nonnull Path baseDirectory, Collection includes, Collection excludes) { + requireNonNull(baseDirectory, "baseDirectory cannot be null"); + + return PathSelector.ofSubdirectories(baseDirectory, includes, excludes); + } + @Nonnull @Override public PathMatcher createExcludeOnlyMatcher( diff --git a/impl/maven-impl/src/main/java/org/apache/maven/impl/PathSelector.java b/impl/maven-impl/src/main/java/org/apache/maven/impl/PathSelector.java index 0f3d1a3c1259..34694f90c769 100644 --- a/impl/maven-impl/src/main/java/org/apache/maven/impl/PathSelector.java +++ b/impl/maven-impl/src/main/java/org/apache/maven/impl/PathSelector.java @@ -25,10 +25,13 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; +import java.util.HashMap; import java.util.Iterator; import java.util.LinkedHashSet; import java.util.List; +import java.util.Map; import java.util.Objects; +import java.util.StringTokenizer; import org.apache.maven.api.annotations.Nonnull; @@ -261,6 +264,150 @@ public static PathMatcher of( return new PathSelector(directory, includes, excludes, useDefaultExcludes).simplify(); } + /** + * Creates a map of subdirectory paths to path matchers, optimized for targeted directory walks. + *

+ * This method decomposes include patterns by parsing their literal leading path segments + * (the segments before the first wildcard) to determine the narrowest possible subdirectories + * that need to be walked. For example, includes {@code ["src/main/java/**/*.java", + * "src/test/**/*.java"]} produces two entries keyed by {@code src/main/java} and + * {@code src/test}, each with a matcher scoped to only those patterns. + *

+ * For patterns with no literal prefix (e.g. {@code "**/*.xml"}), the base directory + * itself is used as the key. For single-file patterns with no wildcards at all, the map + * key is the file path itself and the matcher does a direct equality check. + * + * @param basedir the base directory for resolving paths + * @param includes the include patterns, may be {@code null} or empty (meaning include all) + * @param excludes the exclude patterns, may be {@code null} or empty (meaning exclude none) + * @return a map of subdirectory (or file) paths to their corresponding path matchers + * @throws NullPointerException if basedir is null + * @since 4.1.0 + */ + public static Map ofSubdirectories( + @Nonnull Path basedir, Collection includes, Collection excludes) { + Objects.requireNonNull(basedir, "basedir cannot be null"); + if (includes == null || includes.isEmpty()) { + // No includes → match everything under basedir + return Map.of(basedir, of(basedir, includes, excludes, false)); + } + + // Build a trie from include patterns, splitting on literal path segments. + // Each leaf holds the glob portion (from the first wildcard segment onward). + IncludesTrie root = new IncludesTrie(); + for (String include : includes) { + // Ant shorthand: trailing "/" means "everything under this directory" + if (include.endsWith("/")) { + include = include + "**"; + } + IncludesTrie trie = root; + StringTokenizer st = new StringTokenizer(include, "/"); + while (st.hasMoreTokens()) { + String segment = st.nextToken(); + if (segment.contains("*") || segment.contains("?")) { + // This segment has wildcards — collect it and remaining segments as a glob + StringBuilder glob = new StringBuilder(segment); + while (st.hasMoreTokens()) { + glob.append('/').append(st.nextToken()); + } + trie.addPattern(glob.toString()); + break; + } + trie = trie.child(segment); + } + } + + // Convert trie leaves into Path → PathMatcher entries + Map result = new HashMap<>(); + trie2map(root, basedir, null, excludes, result); + return result; + } + + /** + * Recursively converts an {@link IncludesTrie} into map entries. + */ + private static void trie2map( + IncludesTrie node, + Path basedir, + String relpath, + Collection excludes, + Map result) { + if (node.children != null) { + for (Map.Entry entry : node.children.entrySet()) { + String childPath = relpath != null ? relpath + "/" + entry.getKey() : entry.getKey(); + trie2map(entry.getValue(), basedir, childPath, excludes, result); + } + } else { + Path entryPath = relpath != null ? basedir.resolve(relpath) : basedir; + if (node.patterns != null) { + // Reconstruct full include patterns by prepending the literal prefix + List scopedIncludes = new ArrayList<>(node.patterns.size()); + for (String pattern : node.patterns) { + scopedIncludes.add(relpath != null ? relpath + "/" + pattern : pattern); + } + result.put(entryPath, of(basedir, scopedIncludes, excludes, false)); + } else { + // No wildcards at all — this is a single exact file path + result.put(entryPath, singlePathMatcher(basedir, relpath)); + } + } + } + + /** + * Creates a matcher that matches exactly one file by path equality. + */ + private static PathMatcher singlePathMatcher(Path basedir, String relpath) { + Path target = relpath != null ? basedir.resolve(relpath) : basedir; + return path -> path.equals(target); + } + + /** + * A trie for decomposing include patterns into per-subdirectory groups. + * Each non-leaf node represents a literal directory segment; each leaf + * holds the wildcard-containing glob tail(s) for that subtree. + */ + private static class IncludesTrie { + Map children; + Collection patterns; + + void addPattern(String glob) { + if (patterns == null) { + patterns = new LinkedHashSet<>(); + } + patterns.add(glob); + // Once we have patterns at this level, collapse any children into patterns too + if (children != null) { + for (Map.Entry entry : children.entrySet()) { + entry.getValue().collectInto(entry.getKey(), patterns); + } + children = null; + } + } + + private void collectInto(String prefix, Collection target) { + if (children != null) { + for (Map.Entry entry : children.entrySet()) { + entry.getValue().collectInto(prefix + "/" + entry.getKey(), target); + } + } + if (patterns != null) { + for (String pattern : patterns) { + target.add(prefix + "/" + pattern); + } + } + } + + IncludesTrie child(String name) { + if (patterns != null) { + return this; // already collecting patterns at this level + } + if (children == null) { + children = new HashMap<>(); + } + return children.computeIfAbsent(name, k -> new IncludesTrie()); + } + } + /** * Returns the given array of excludes, optionally expanded with a default set of excludes, * then with unnecessary excludes omitted. An unnecessary exclude is an exclude which will never diff --git a/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultBuildContext.java b/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultBuildContext.java index 8acd09bc1a0b..fbab14bebfd3 100644 --- a/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultBuildContext.java +++ b/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultBuildContext.java @@ -24,6 +24,7 @@ import java.nio.file.Files; import java.nio.file.NoSuchFileException; import java.nio.file.Path; +import java.nio.file.PathMatcher; import java.nio.file.attribute.BasicFileAttributes; import java.nio.file.attribute.FileTime; import java.util.ArrayList; @@ -54,6 +55,8 @@ import org.apache.maven.api.build.context.spi.Message; import org.apache.maven.api.build.context.spi.Sink; import org.apache.maven.api.build.context.spi.Workspace; +import org.apache.maven.api.services.PathMatcherFactory; +import org.apache.maven.impl.DefaultPathMatcherFactory; public class DefaultBuildContext implements CommittableBuildContext { final Workspace workspace; @@ -84,6 +87,11 @@ public class DefaultBuildContext implements CommittableBuildContext { */ private boolean failOnError = true; + /** + * Factory for creating path matchers with Ant-style pattern support. + */ + private final PathMatcherFactory pathMatcherFactory; + public DefaultBuildContext(BuildContextEnvironment env) { this(env.getWorkspace(), env.getStateFile(), env.getParameters(), env.getFinalizer()); } @@ -97,6 +105,7 @@ protected DefaultBuildContext( Objects.requireNonNull(workspace, "workspace"); Objects.requireNonNull(configuration, "configuration"); + this.pathMatcherFactory = new DefaultPathMatcherFactory(); this.stateFile = stateFile != null ? stateFile.toAbsolutePath() : null; this.state = DefaultBuildContextState.withConfiguration(configuration); this.oldState = DefaultBuildContextState.loadFrom(this.stateFile); @@ -139,7 +148,19 @@ static Path normalize(Path input) { if (input == null) { throw new IllegalArgumentException(); } - return FileMatcher.getCanonicalPath(input); + return getCanonicalPath(input); + } + + static Path getCanonicalPath(Path path) { + try { + return path.toRealPath(); + } catch (IOException e) { + Path parent = path.getParent(); + if (parent == null) { + return path.toAbsolutePath().normalize(); + } + return getCanonicalPath(parent).resolve(path.getFileName()); + } } public boolean isFailOnError() { @@ -266,7 +287,7 @@ public Object fileKey() { public Collection registerInputs( Path basedir, Collection includes, Collection excludes) { basedir = normalize(basedir); - Map matchers = FileMatcher.createMatchers(basedir, includes, excludes); + Map matchers = pathMatcherFactory.createSubdirectoryMatchers(basedir, includes, excludes); List result = matchers.entrySet().stream() .flatMap(e -> workspace .walk(e.getKey()) @@ -284,7 +305,7 @@ public Collection registerInputs( if (workspace.getMode() == Workspace.Mode.DELTA) { // only NEW, MODIFIED and REMOVED resources are reported in DELTA mode // need to find any UNMODIFIED - final FileMatcher absoluteMatcher = FileMatcher.createMatcher(basedir, includes, excludes); + final PathMatcher absoluteMatcher = pathMatcherFactory.createPathMatcher(basedir, includes, excludes); for (FileState fileState : oldState.getResources().values()) { Path path = fileState.getPath(); if (!state.isResource(path) && !deletedResources.contains(path) && absoluteMatcher.matches(path)) { diff --git a/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/FileMatcher.java b/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/FileMatcher.java deleted file mode 100644 index ebcb6203d5dc..000000000000 --- a/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/FileMatcher.java +++ /dev/null @@ -1,373 +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.internal.build.context.impl; - -import java.io.IOException; -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.Collection; -import java.util.Collections; -import java.util.HashMap; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Map; -import java.util.StringTokenizer; -import java.util.function.Function; -import java.util.function.Predicate; -import java.util.regex.Pattern; - -// TODO: evaluate consolidation with org.apache.maven.impl.PathSelector which handles -// similar glob/Ant-style pattern matching. The createMatchers() trie optimization -// (splitting include patterns into per-subdirectory matchers for targeted workspace walks) -// has no PathSelector equivalent, so a full replacement would require adding a -// createSubdirectoryMatchers() method to PathSelector or DefaultPathMatcherFactory. -class FileMatcher { - private static final Matcher MATCH_EVERYTHING = p -> true; - final Matcher includesMatcher; - final Matcher excludesMatcher; - private final String basedir; - - private FileMatcher(String basedir, Matcher includesMatcher, Matcher excludesMatcher) { - this.basedir = basedir; - this.includesMatcher = includesMatcher; - this.excludesMatcher = excludesMatcher; - } - - private static Matcher fromStrings(String basepath, Collection globs, Matcher everything) { - if (globs == null || globs.isEmpty()) { - return null; // default behaviour appropriate for includes/excludes pattern - } - final ArrayList normalized = new ArrayList<>(); - for (String glob : globs) { - if ("*".equals(glob) || "**".equals(glob) || "**/*".equals(glob)) { - return everything; // matches everything - } - - StringBuilder gb = new StringBuilder(); - if (!basepath.endsWith("/")) { - gb.append(basepath).append('/'); - } - gb.append(glob.startsWith("/") ? glob.substring(1) : glob); - - // from https://ant.apache.org/manual/dirtasks.html#patterns - // There is one "shorthand": if a pattern ends with / or \, then ** is appended - if (glob.endsWith("/")) { - gb.append("**"); - } - normalized.add(gb.toString()); - } - final List patterns = - normalized.stream().map(FileMatcher::antPatternToRegex).collect(java.util.stream.Collectors.toList()); - return path -> patterns.stream().anyMatch(p -> p.matcher(path).matches()); - } - - /** - * Converts an Ant-style glob pattern to a compiled {@link Pattern regex}. - * Supports {@code *} (any chars within a path segment), {@code **} (zero or more path segments), - * and {@code ?} (single char within a segment). - */ - private static Pattern antPatternToRegex(String antPattern) { - StringBuilder regex = new StringBuilder("^"); - int i = 0; - int len = antPattern.length(); - while (i < len) { - char c = antPattern.charAt(i); - if (c == '*') { - if (i + 1 < len && antPattern.charAt(i + 1) == '*') { - // ** matches zero or more path segments - i += 2; - if (i < len && antPattern.charAt(i) == '/') { - // **/ → zero or more directory segments followed by / - regex.append("(?:.+/)?"); - i++; - } else { - // ** at end → match anything remaining - regex.append(".*"); - } - } else { - // * matches within a single segment - regex.append("[^/]*"); - i++; - } - } else if (c == '?') { - regex.append("[^/]"); - i++; - } else { - if (".+^$|(){}[]\\".indexOf(c) >= 0) { - regex.append('\\'); - } - regex.append(c); - i++; - } - } - regex.append("$"); - return Pattern.compile(regex.toString()); - } - - /** - * Given a directory, returns a map of location to FileMatcher that will optimize the lookup. The - * key can either be a file (if it is a single path matcher) or a directory (if it is an - * open-ended matcher). The associated matcher will still be relative to the basedir, not to the - * key, but will only match paths that start with the key. - * - * @param basedir the base directory for resolving paths - * @param includes the include patterns, may be {@code null} - * @param excludes the exclude patterns, may be {@code null} - * @return a map of paths to their corresponding file matchers - */ - public static Map createMatchers( - final Path basedir, Collection includes, Collection excludes) { - String basepath = normalize0(basedir); - if (includes == null || includes.isEmpty()) { - return Collections.singletonMap(basedir, createMatcher(basepath, includes, excludes)); - } - return createMatchers(basepath, includes, excludes, file -> { - String sep = basedir.getFileSystem().getSeparator(); - if (!"/".equals(sep)) { - file = file.substring(1).replace("/", sep); - } - return basedir.getFileSystem().getPath(file); - }); - } - - /** - * Returns a map of location to FileMatcher that will optimize the lookup. The key is a path of a - * file or directory in a logical filesystem that uses '/' as file separator. The associated - * matcher is relative to the root of the logical filesystem, not to the ley, but will only match - * paths that start with the key. - * - * @param includes the include patterns, may be {@code null} - * @param excludes the exclude patterns, may be {@code null} - * @return a map of string paths to their corresponding file matchers - */ - public static Map createMatchers(Collection includes, Collection excludes) { - String basepath = ""; - if (includes == null || includes.isEmpty()) { - return Collections.singletonMap(basepath, createMatcher(basepath, includes, excludes)); - } - return createMatchers(basepath, includes, excludes, Function.identity()); - } - - private static Map createMatchers( - String basepath, Collection includes, Collection excludes, Function fromString) { - final Matcher excludesMatcher = fromStrings(basepath, excludes, MATCH_EVERYTHING); - Map matchers = new HashMap<>(); - newIncludesTrie(includes).subdirs().forEach((relpath, globs) -> { - String path = relpath != null ? basepath + "/" + relpath : basepath; - FileMatcher matcher = globs != null // - ? createMatcher(path, globs, excludesMatcher) // - : createSinglePathMatcher(path); - matchers.put(fromString.apply(path), matcher); - }); - return matchers; - } - - private static Trie newIncludesTrie(Collection includes) { - Trie root = new Trie(); - for (String include : includes) { - // ant shorthand syntax - if (include.endsWith("/")) { - include = include + "**"; // chuck norris should approve - } - Trie trie = root; - StringTokenizer st = new StringTokenizer(include, "/"); - while (st.hasMoreTokens()) { - String name = st.nextToken(); - if (name.contains("*") || name.contains("?")) { - trie.addIncludes(subglob(name, st)); - break; - } - trie = trie.child(name); - } - } - return root; - } - - private static FileMatcher createMatcher(String basedir, Collection includes, Matcher excludesMatcher) { - final Matcher includesMatcher = fromStrings(basedir, includes, null); - return new FileMatcher(toDirectoryPath(basedir), includesMatcher, excludesMatcher); - } - - private static FileMatcher createSinglePathMatcher(String path) { - return new FileMatcher(null, new SinglePathMatcher(path) /* includesMatcher */, null /* excludesMatcher */); - } - - private static String subglob(String name, StringTokenizer st) { - StringBuilder glob = new StringBuilder(name); - while (st.hasMoreTokens()) { - glob.append('/').append(st.nextToken()); - } - return glob.toString(); - } - - /** - * Creates and returns new matcher for files under specified {@code basedir} that satisfy - * specified includes/excludes patterns. - * - * @param basedir the base directory for resolving paths - * @param includes the include patterns, may be {@code null} - * @param excludes the exclude patterns, may be {@code null} - * @return the file matcher for the given patterns - */ - public static FileMatcher createMatcher( - final Path basedir, Collection includes, Collection excludes) { - return createMatcher(normalize0(basedir), includes, excludes); - } - - public static FileMatcher createMatcher(Collection includes, Collection excludes) { - return createMatcher("", includes, excludes); - } - - private static FileMatcher createMatcher( - final String basepath, Collection includes, Collection excludes) { - final Matcher includesMatcher = fromStrings(basepath, includes, null); - final Matcher excludesMatcher = fromStrings(basepath, excludes, MATCH_EVERYTHING); - return new FileMatcher(toDirectoryPath(basepath), includesMatcher, excludesMatcher); - } - - protected static String toDirectoryPath(final String basepath) { - return basepath.endsWith("/") ? basepath : basepath + "/"; - } - - static Path getCanonicalPath(Path path) { - try { - return path.toRealPath(); - } catch (IOException e) { - Path parent = path.getParent(); - if (parent == null) { - return path.toAbsolutePath().normalize(); - } - return getCanonicalPath(parent).resolve(path.getFileName()); - } - } - - private static String normalize0(Path basedir) { - String separator = basedir.getFileSystem().getSeparator(); - String path = getCanonicalPath(basedir).toString(); - if (!"/".equals(separator)) { - path = "/" + path.replace(separator, "/"); - } - return path; - } - - /** - * Returns {@code true} if provided path is under this matcher's basedir and satisfies - * includes/excludes patterns. The provided path is assumed to be normalized according to - * {@link #normalize0(Path)}. - * - * @param path the normalized path to test - * @return {@code true} if the path matches the include/exclude patterns - */ - public boolean matches(String path) { - if (basedir != null && !path.startsWith(basedir)) { - return false; - } - if (excludesMatcher != null && excludesMatcher.matches(path)) { - return false; - } - if (includesMatcher != null) { - return includesMatcher.matches(path); - } - return true; - } - - public boolean matches(Path file) { - return matches(normalize0(file)); - } - - private interface Matcher extends Predicate { - boolean matches(String path); - - @Override - default boolean test(String s) { - return matches(s); - } - } - - static class SinglePathMatcher implements Matcher { - - final String path; - - SinglePathMatcher(String path) { - this.path = path; - } - - @Override - public boolean matches(String pathToMatch) { - return this.path.equals(pathToMatch); - } - } - - static class Trie { - Map children; - Collection includes; - - private static String childname(String basedir, String name) { - return basedir != null ? basedir + "/" + name : name; - } - - public void addIncludes(String glob) { - if (includes == null) { - includes = new LinkedHashSet<>(); - } - includes.add(glob); - if (children != null) { - children.values().forEach(child -> child.addIncludesTo(includes)); - children = null; - } - } - - private void addIncludesTo(Collection other) { - if (children != null) { - children.values().forEach(child -> child.addIncludesTo(other)); - } - if (includes != null) { - other.addAll(includes); - } - } - - public Trie child(String name) { - if (includes != null) { - return this; - } - if (children == null) { - children = new HashMap<>(); - } - Trie child = children.get(name); - if (child == null) { - child = new Trie(); - children.put(name, child); - } - return child; - } - - public Map> subdirs() { - return addSubdirs(null, new HashMap<>()); - } - - private Map> addSubdirs(String path, Map> subdirs) { - if (children != null) { - children.forEach((name, child) -> child.addSubdirs(childname(path, name), subdirs)); - } else { - subdirs.put(path, includes); - } - return subdirs; - } - } -} diff --git a/impl/maven-impl/src/test/java/org/apache/maven/internal/build/context/impl/DefaultAggregatorBuildContextTest.java b/impl/maven-impl/src/test/java/org/apache/maven/internal/build/context/impl/DefaultAggregatorBuildContextTest.java index 5b89e5513812..51cf8176e507 100644 --- a/impl/maven-impl/src/test/java/org/apache/maven/internal/build/context/impl/DefaultAggregatorBuildContextTest.java +++ b/impl/maven-impl/src/test/java/org/apache/maven/internal/build/context/impl/DefaultAggregatorBuildContextTest.java @@ -43,7 +43,7 @@ class DefaultAggregatorBuildContextTest extends AbstractBuildContextTest { @Test void testBasic() throws Exception { - FileMatcher.getCanonicalPath(Paths.get("/oo/bar")); + DefaultBuildContext.getCanonicalPath(Paths.get("/oo/bar")); Path outputFile = temp.resolve("output"); From b8c063666b6da98adc0e3455860fec16247db467 Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Wed, 29 Jul 2026 14:56:25 +0200 Subject: [PATCH 09/16] Inject PathMatcherFactory into DefaultBuildContext via DI Instead of hardcoding `new DefaultPathMatcherFactory()`, accept PathMatcherFactory as a constructor parameter so it flows through Maven's DI container. MojoExecutionScopedBuildContext now receives the factory via @Inject alongside BuildContextEnvironment. The 4-arg test constructor retains a null-safe fallback to DefaultPathMatcherFactory for backward compatibility. Co-Authored-By: Claude Opus 4.6 --- .../context/impl/maven/MavenBuildContext.java | 6 ++++-- .../build/context/impl/DefaultBuildContext.java | 17 +++++++++++++++-- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/MavenBuildContext.java b/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/MavenBuildContext.java index 789a913f387f..cb5cb43f572c 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/MavenBuildContext.java +++ b/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/MavenBuildContext.java @@ -30,6 +30,7 @@ import org.apache.maven.api.di.MojoExecutionScoped; import org.apache.maven.api.di.Named; import org.apache.maven.api.di.Typed; +import org.apache.maven.api.services.PathMatcherFactory; import org.apache.maven.internal.build.context.impl.DefaultBuildContext; import org.apache.maven.internal.build.context.impl.DefaultInput; import org.apache.maven.internal.build.context.impl.DefaultInputMetadata; @@ -105,8 +106,9 @@ public void commit(Sink sink) { @MojoExecutionScoped public static class MojoExecutionScopedBuildContext extends DefaultBuildContext { @Inject - public MojoExecutionScopedBuildContext(BuildContextEnvironment configuration) { - super(configuration); + public MojoExecutionScopedBuildContext( + BuildContextEnvironment configuration, PathMatcherFactory pathMatcherFactory) { + super(configuration, pathMatcherFactory); } } } diff --git a/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultBuildContext.java b/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultBuildContext.java index fbab14bebfd3..cb24cb135bac 100644 --- a/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultBuildContext.java +++ b/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultBuildContext.java @@ -93,7 +93,11 @@ public class DefaultBuildContext implements CommittableBuildContext { private final PathMatcherFactory pathMatcherFactory; public DefaultBuildContext(BuildContextEnvironment env) { - this(env.getWorkspace(), env.getStateFile(), env.getParameters(), env.getFinalizer()); + this(env.getWorkspace(), env.getStateFile(), env.getParameters(), env.getFinalizer(), null); + } + + public DefaultBuildContext(BuildContextEnvironment env, @Nullable PathMatcherFactory pathMatcherFactory) { + this(env.getWorkspace(), env.getStateFile(), env.getParameters(), env.getFinalizer(), pathMatcherFactory); } protected DefaultBuildContext( @@ -101,11 +105,20 @@ protected DefaultBuildContext( Path stateFile, Map configuration, BuildContextFinalizer finalizer) { + this(workspace, stateFile, configuration, finalizer, null); + } + + protected DefaultBuildContext( + Workspace workspace, + Path stateFile, + Map configuration, + BuildContextFinalizer finalizer, + @Nullable PathMatcherFactory pathMatcherFactory) { // preconditions Objects.requireNonNull(workspace, "workspace"); Objects.requireNonNull(configuration, "configuration"); - this.pathMatcherFactory = new DefaultPathMatcherFactory(); + this.pathMatcherFactory = pathMatcherFactory != null ? pathMatcherFactory : new DefaultPathMatcherFactory(); this.stateFile = stateFile != null ? stateFile.toAbsolutePath() : null; this.state = DefaultBuildContextState.withConfiguration(configuration); this.oldState = DefaultBuildContextState.loadFrom(this.stateFile); From 76ce40939cf9e39912ffe671f9e0e1ab6a0c567e Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Wed, 29 Jul 2026 15:20:35 +0200 Subject: [PATCH 10/16] Make DefaultBuildContext constructors public for downstream test usage Change the protected multi-arg constructors to public so that downstream projects (maven-filtering, maven-resources-plugin) can create test instances with a FilesystemWorkspace and no state file to exercise incremental build context behavior in unit tests. Co-Authored-By: Claude Opus 4.6 --- .../internal/build/context/impl/DefaultBuildContext.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultBuildContext.java b/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultBuildContext.java index cb24cb135bac..f45cc878c5dc 100644 --- a/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultBuildContext.java +++ b/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultBuildContext.java @@ -100,7 +100,7 @@ public DefaultBuildContext(BuildContextEnvironment env, @Nullable PathMatcherFac this(env.getWorkspace(), env.getStateFile(), env.getParameters(), env.getFinalizer(), pathMatcherFactory); } - protected DefaultBuildContext( + public DefaultBuildContext( Workspace workspace, Path stateFile, Map configuration, @@ -108,7 +108,7 @@ protected DefaultBuildContext( this(workspace, stateFile, configuration, finalizer, null); } - protected DefaultBuildContext( + public DefaultBuildContext( Workspace workspace, Path stateFile, Map configuration, From 8f787668884b5cfe5f34f0134ec5f5f960d23ab5 Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Wed, 29 Jul 2026 16:51:05 +0200 Subject: [PATCH 11/16] Document configuration change detection, escalation, and two-pass pattern Expand the BuildContext API Javadoc to cover capabilities that were implemented but not documented: - Configuration change detection: explain that Maven automatically digests all mojo parameters and the plugin classpath, and that any change triggers escalation to a full build. Mojos do not need custom options-change detection. - Escalation semantics: document when and why the build context treats all inputs as modified (missing state file, config change, missing outputs, workspace escalation). - Two-pass processing pattern: add Use Case 5 showing how compilers and similar tools can register inputs, run the tool, then associate unpredictable outputs (inner classes) after processing. - registerInput(Path): clarify its use for individual dependency JARs and other non-directory-tree inputs. - BuildContextEnvironment.getParameters(): expand from a one-liner to a full explanation of the automatic mojo parameter digestion and plugin classpath hashing. Co-Authored-By: Claude Opus 4.6 --- .../maven/api/build/context/BuildContext.java | 18 ++- .../maven/api/build/context/Metadata.java | 27 +++++ .../maven/api/build/context/package-info.java | 105 +++++++++++++++++- .../context/spi/BuildContextEnvironment.java | 25 ++++- 4 files changed, 169 insertions(+), 6 deletions(-) diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/BuildContext.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/BuildContext.java index ade393e5a8ca..540cda6bcedc 100644 --- a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/BuildContext.java +++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/BuildContext.java @@ -73,6 +73,16 @@ * } * } * + *

Configuration and non-file state

+ * + *

The build context automatically tracks non-file state that affects + * outputs: mojo parameter values, plugin classpath contents, and any other configuration + * provided through the + * {@link org.apache.maven.api.build.context.spi.BuildContextEnvironment#getParameters() + * environment parameters}. If any configuration value changes between builds, the context + * escalates — all inputs are treated as modified, forcing a full rebuild. + * Mojos do not need to implement their own options-change detection.

+ * *

Aggregated builds

* *

When multiple inputs contribute to a single output (e.g., merging property files, @@ -130,7 +140,13 @@ public interface BuildContext { InputSet newInputSet(); /** - * Registers the specified input {@code Path} with this build context. + * Registers a single input file with this build context. + * + *

This method is useful for registering individual files that are not part of a + * directory tree — for example, dependency JARs, configuration files, or other + * external inputs whose change should trigger reprocessing. Like directory-based + * registration, the returned {@link Metadata} provides the file's change + * {@link Status} relative to the previous build.

* * @param inputFile the input file to register * @return the metadata representing the input file, never {@code null} diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Metadata.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Metadata.java index 1c70355f3656..3773cca79fbe 100644 --- a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Metadata.java +++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Metadata.java @@ -58,6 +58,33 @@ * registered but not processed, its associated outputs from the previous build are * carried over unchanged.

* + *

Two-pass processing

+ * + *

The separation between registration and processing enables a two-pass + * pattern useful for tools whose outputs cannot be predicted in advance (e.g., a Java + * compiler producing inner-class files). In the first pass, register inputs and inspect + * their status to decide what to process. In the second pass — after the tool has run — + * call {@link #process()} and associate the actual outputs:

+ * + *
{@code
+ * // Pass 1: determine what changed
+ * var all = buildContext.registerInputs(sourceDir, null, null);
+ * var changed = all.stream()
+ *         .filter(m -> m.getStatus() != Status.UNMODIFIED)
+ *         .toList();
+ *
+ * // Run the tool on changed inputs only
+ * tool.process(changed.stream().map(Metadata::getPath).toList());
+ *
+ * // Pass 2: associate outputs discovered after processing
+ * for (Metadata meta : all) {
+ *     Input input = meta.process();
+ *     for (Path output : discoverOutputs(input.getPath())) {
+ *         input.associateOutput(output);
+ *     }
+ * }
+ * }
+ * * @param the resource type ({@link Input} or {@link Output}) * @since 4.1.0 * @see BuildContext#registerInput(java.nio.file.Path) diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/package-info.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/package-info.java index bd10ef0cf16a..61c69c15fe92 100644 --- a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/package-info.java +++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/package-info.java @@ -154,15 +154,112 @@ * } * } * + *

Use Case 5: Two-pass processing (compile, then associate outputs)

+ * + *

When a tool produces outputs that cannot be predicted before processing + * (e.g., a Java compiler generating inner-class files like {@code Foo$1.class}, + * {@code Foo$Inner.class}), use the two-pass API: register inputs first to determine + * what changed, run the tool, then associate the actual outputs afterward:

+ * + *
{@code
+ * @Inject
+ * BuildContext buildContext;
+ *
+ * public void execute() {
+ *     // Pass 1: register sources and inspect their status
+ *     var allInputs = buildContext.registerInputs(sourceDir, List.of("**/*.java"), null);
+ *     List> toCompile = new ArrayList<>();
+ *     for (Metadata meta : allInputs) {
+ *         if (meta.getStatus() != Status.UNMODIFIED) {
+ *             toCompile.add(meta);
+ *         }
+ *     }
+ *
+ *     if (toCompile.isEmpty()) {
+ *         buildContext.markSkipExecution();
+ *         return;
+ *     }
+ *
+ *     // Compile the changed sources
+ *     List sourceFiles = toCompile.stream()
+ *             .map(Metadata::getPath).toList();
+ *     compiler.compile(sourceFiles, outputDir);
+ *
+ *     // Pass 2: associate the actual outputs (now known after compilation)
+ *     for (Metadata meta : allInputs) {
+ *         Input input = meta.process();
+ *         String baseName = getClassName(input.getPath()); // Foo.java → Foo
+ *         // Scan for Foo.class, Foo$Inner.class, Foo$1.class, etc.
+ *         for (Path classFile : findClassFiles(outputDir, baseName)) {
+ *             input.associateOutput(classFile);
+ *         }
+ *     }
+ * }
+ * }
+ * + *

When a previously registered input is removed in a subsequent build, the build + * context automatically deletes all outputs that were associated with it — including + * any inner-class files discovered during the previous build's pass 2.

+ * + *

Configuration change detection (non-file state)

+ * + *

Incremental builds must consider more than just file changes. A mojo's behavior + * also depends on its configuration — compiler flags, plugin versions, + * dependency classpath, and other parameters. If any of these change between builds, + * all inputs must be reprocessed even if no source files were modified.

+ * + *

The Maven runtime handles this automatically. Before each mojo + * execution, Maven:

+ *
    + *
  1. Digests all mojo parameters — every {@code @Parameter}-annotated + * field is reflected, its value evaluated, and a digest computed. This covers + * compiler options, output directories, filter configurations, etc.
  2. + *
  3. Digests the plugin classpath — the SHA-1 hash of every plugin + * dependency JAR's contents (not just timestamps) is computed and cached per + * session.
  4. + *
  5. Compares with the previous build — if any digest differs from + * the value stored in the state file, the build context escalates: all + * inputs are treated as modified, forcing a full rebuild.
  6. + *
+ * + *

This means mojos do not need to implement their own + * options-change or classpath-change detection. The build context infrastructure + * handles it transparently. A mojo that only uses {@code registerInputs()} and + * {@code associateOutput()} will automatically get correct incremental behavior + * even when configuration changes — no additional code required.

+ * + *

The configuration digest is provided through + * {@link org.apache.maven.api.build.context.spi.BuildContextEnvironment#getParameters() + * BuildContextEnvironment.getParameters()}, which the Maven runtime populates via + * a {@code MojoConfigurationDigester}.

+ * + *

Escalation

+ * + *

The build context escalates to a full build (treating all inputs as + * modified) in several situations:

+ *
    + *
  • No previous state file exists (first build, or after a clean)
  • + *
  • The configuration digest has changed (mojo parameters or plugin classpath)
  • + *
  • Previously tracked output files are missing on disk
  • + *
  • The workspace explicitly requests escalation + * ({@link org.apache.maven.api.build.context.spi.Workspace.Mode#ESCALATED})
  • + *
+ * + *

Escalation is transparent to mojos — they use the same API regardless. The only + * visible effect is that {@link Status#UNMODIFIED} inputs become rare or absent, + * so the mojo ends up processing everything.

+ * *

Lifecycle and state persistence

* *

The build context persists its state (file timestamps, input-output associations, - * resource attributes) between builds in a state file managed by the implementation. - * On each build:

+ * resource attributes, and configuration digests) between builds in a state file + * managed by the implementation. On each build:

*
    - *
  1. The context loads the previous state (if any) and compares it to current files
  2. + *
  3. The context loads the previous state (if any), compares configuration digests, + * and compares file timestamps to determine each input's {@link Status}
  4. *
  5. The mojo registers inputs and creates outputs through the API
  6. - *
  7. At commit time, the context saves the new state and cleans up stale outputs
  8. + *
  9. At commit time, the context saves the new state and cleans up stale outputs + * (outputs associated with inputs that no longer exist)
  10. *
* *

Mojos do not manage the state file directly. The Maven runtime (or IDE integration) diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/BuildContextEnvironment.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/BuildContextEnvironment.java index 36b6490a3f9a..fbda5b8bc659 100644 --- a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/BuildContextEnvironment.java +++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/BuildContextEnvironment.java @@ -68,7 +68,30 @@ public interface BuildContextEnvironment { Workspace getWorkspace(); /** - * {@return the configuration parameters for the build context} + * Returns the configuration parameters for this build context. + * + *

These parameters represent non-file state that affects the build + * output — mojo configuration values, dependency digests, and any other context that + * determines what the mojo will produce. The build context compares these values against + * the previous build's stored parameters; if any value has changed, all inputs + * are treated as modified (escalation), forcing a full rebuild.

+ * + *

In the Maven runtime, this map is populated automatically by a + * {@code MojoConfigurationDigester} that:

+ *
    + *
  • Reflects on every {@code @Parameter}-annotated field of the mojo class, + * evaluates its expression, and computes a digest of the resolved value
  • + *
  • Computes a SHA-1 digest of the plugin's classpath JARs (by content, + * not by timestamp, so rebuilding unchanged sources does not trigger + * false escalation)
  • + *
+ * + *

This means mojos automatically get correct incremental behavior when their + * configuration changes — compiler flags, output directories, filter tokens, + * plugin dependency versions — without any extra code. The mojo only needs to + * register its file inputs and associate outputs; the build context handles the rest.

+ * + * @return the configuration parameters, never {@code null} */ @Nonnull Map getParameters(); From a9c168dbe9c34fb5b7dc6f5a8048ed355548336e Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Wed, 29 Jul 2026 16:58:22 +0200 Subject: [PATCH 12/16] Document implementation classes for configuration digestion and escalation Add comprehensive Javadoc to the implementation classes that were undocumented despite being key to the BuildContext's capabilities: - DefaultBuildContext: document the class, the main constructor's configuration comparison and escalation logic, getConfigurationChanged() key-by-key comparison, and the three-pass finalizeContext() stale output cleanup algorithm. - MojoConfigurationDigester: document the two-category digest (plugin classpath + mojo parameters), how each @Parameter field is reflected and evaluated, and how unsupported types are reported as errors. - ClasspathDigester: expand the one-liner to explain SHA-1 content hashing (not timestamps), sorted ZIP entry ordering, session-level caching, and immunity to SNAPSHOT timestamp changes. - MavenBuildContextConfiguration: document how it wires together the digester, workspace, finalizer, and state file location (target/incremental/___). - MavenBuildContext + MojoExecutionScopedBuildContext: document the scoping pattern and per-execution isolation. Co-Authored-By: Claude Opus 4.6 --- .../context/impl/maven/MavenBuildContext.java | 26 ++++ .../maven/MavenBuildContextConfiguration.java | 22 ++++ .../impl/maven/digest/ClasspathDigester.java | 20 ++- .../digest/MojoConfigurationDigester.java | 40 ++++++ .../context/impl/DefaultBuildContext.java | 115 ++++++++++++++---- 5 files changed, 200 insertions(+), 23 deletions(-) diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/MavenBuildContext.java b/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/MavenBuildContext.java index cb5cb43f572c..7747acd3ca98 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/MavenBuildContext.java +++ b/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/MavenBuildContext.java @@ -36,6 +36,20 @@ import org.apache.maven.internal.build.context.impl.DefaultInputMetadata; import org.apache.maven.internal.build.context.impl.DefaultOutput; +/** + * The Maven runtime's {@link org.apache.maven.api.build.context.BuildContext BuildContext} + * implementation, delegating to a {@link MojoExecutionScopedBuildContext} that is created + * fresh for each mojo execution. + * + *

This class is injected into mojos as the {@code BuildContext} binding. It uses a + * {@link Supplier} to lazily obtain the scoped delegate, ensuring that each mojo execution + * gets its own isolated build context with its own state file, configuration digest, and + * input/output tracking.

+ * + * @since 4.1.0 + * @see MojoExecutionScopedBuildContext + * @see MavenBuildContextConfiguration + */ @Named public class MavenBuildContext implements CommittableBuildContext { @@ -101,6 +115,18 @@ public void commit(Sink sink) { getDelegate().commit(sink); } + /** + * The per-mojo-execution build context instance. Created once per mojo execution via + * the {@link MojoExecutionScoped} DI scope, initialized from a + * {@link MavenBuildContextConfiguration} that provides the state file location, + * configuration digest, workspace, and finalizer. + * + *

At construction time, the context loads the previous build state (if any), + * compares the configuration digest, and determines whether to escalate to a full + * build. All subsequent {@code registerInputs} / {@code associateOutput} calls + * operate on this instance's state, which is committed at the end of mojo execution + * by the {@link MavenBuildContextFinalizer}.

+ */ @Named @Typed(MojoExecutionScopedBuildContext.class) @MojoExecutionScoped diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/MavenBuildContextConfiguration.java b/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/MavenBuildContextConfiguration.java index 447f38150279..9188c2c2ea71 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/MavenBuildContextConfiguration.java +++ b/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/MavenBuildContextConfiguration.java @@ -35,6 +35,28 @@ import org.apache.maven.api.plugin.descriptor.PluginDescriptor; import org.apache.maven.internal.build.context.impl.maven.digest.MojoConfigurationDigester; +/** + * Provides the {@link BuildContextEnvironment} for a single mojo execution. + * + *

This class wires together the pieces that initialize a + * {@link org.apache.maven.internal.build.context.impl.DefaultBuildContext DefaultBuildContext}:

+ *
    + *
  • State file — persisted at + * {@code ${project.build.directory}/incremental/___}. + * A {@code clean} build deletes the entire {@code target/} directory, so the context + * starts fresh.
  • + *
  • Parameters — computed by {@link MojoConfigurationDigester}, which + * digests all mojo parameters and the plugin classpath. Changes between builds + * trigger escalation.
  • + *
  • Workspace — the {@link ProjectWorkspace} for file-system access.
  • + *
  • Finalizer — the {@link MavenBuildContextFinalizer} that commits + * the context after mojo execution.
  • + *
+ * + * @since 4.1.0 + * @see MojoConfigurationDigester + * @see MavenBuildContextFinalizer + */ @Named @MojoExecutionScoped public class MavenBuildContextConfiguration implements BuildContextEnvironment { diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/digest/ClasspathDigester.java b/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/digest/ClasspathDigester.java index 1ff00d585563..4ec72c70aad6 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/digest/ClasspathDigester.java +++ b/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/digest/ClasspathDigester.java @@ -41,8 +41,24 @@ import org.apache.maven.api.services.ArtifactManager; /** - * Specialized digester for Maven plugin classpath dependencies. Uses class file contents and immune - * to file timestamp changes caused by rebuilds of the same sources. + * Computes a content-based SHA-1 digest of Maven plugin classpath dependencies. + * + *

Unlike simple timestamp comparison, this digester hashes the actual contents + * of each dependency artifact (JAR entries sorted by name, or recursive file contents for + * exploded directories). This makes it immune to file timestamp changes caused by rebuilding + * the same sources — a common scenario with SNAPSHOT dependencies in a reactor build.

+ * + *

Digests are cached per session via {@link SessionData} so that the same artifact is not + * re-hashed for every mojo execution that depends on it. The cache key is the artifact's + * GAV coordinate string.

+ * + *

This digester is used by {@link MojoConfigurationDigester} to produce the + * {@code mojo.classpath} entry in the build context configuration parameters. When the + * combined classpath digest changes between builds, the build context escalates to a full + * rebuild.

+ * + * @since 4.1.0 + * @see MojoConfigurationDigester */ class ClasspathDigester { diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/digest/MojoConfigurationDigester.java b/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/digest/MojoConfigurationDigester.java index 36e09bbff525..ce88e98c0f9e 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/digest/MojoConfigurationDigester.java +++ b/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/digest/MojoConfigurationDigester.java @@ -43,6 +43,34 @@ import org.codehaus.plexus.component.configurator.expression.ExpressionEvaluationException; import org.codehaus.plexus.component.configurator.expression.ExpressionEvaluator; +/** + * Produces a deterministic digest of a mojo execution's configuration for incremental build + * change detection. The digest is stored in the + * {@link org.apache.maven.api.build.context.spi.BuildContextEnvironment#getParameters() build + * context parameters}; if any value changes between builds, the build context escalates to a + * full rebuild. + * + *

The digester captures two categories of non-file state:

+ *
    + *
  1. Plugin classpath ({@code mojo.classpath}) — a SHA-1 hash of every + * plugin dependency JAR's contents (delegated to {@link ClasspathDigester}). This + * detects plugin upgrades or SNAPSHOT rebuilds.
  2. + *
  3. Mojo parameters ({@code mojo.parameter.}) — each + * {@code @Parameter}-annotated field of the mojo class is reflected, its configured + * expression is evaluated, and the resolved value is digested through + * {@link Digesters#digest(java.lang.reflect.Field, Object)}. This detects changes + * to compiler flags, output directories, filter tokens, and any other configuration.
  4. + *
+ * + *

Parameters whose types are not supported by {@link Digesters} are reported as errors + * rather than silently ignored, ensuring that the build context does not miss a configuration + * change.

+ * + * @since 4.1.0 + * @see ClasspathDigester + * @see Digesters + * @see org.apache.maven.api.build.context.spi.BuildContextEnvironment#getParameters() + */ @Named @MojoExecutionScoped public class MojoConfigurationDigester { @@ -60,6 +88,18 @@ public MojoConfigurationDigester(Session session, Project project, MojoExecution this.classpathDigester = new ClasspathDigester(session); } + /** + * Computes the configuration digest for the current mojo execution. + * + *

The returned map contains:

+ *
    + *
  • {@code mojo.classpath} — the combined SHA-1 of all plugin dependency JARs
  • + *
  • {@code mojo.parameter.} — a digest for each resolved mojo parameter
  • + *
+ * + * @return an ordered map of digest keys to their serializable digest values + * @throws IOException if an error occurs while reading plugin JARs or evaluating parameters + */ public Map digest() throws IOException { Map result = new LinkedHashMap<>(); diff --git a/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultBuildContext.java b/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultBuildContext.java index f45cc878c5dc..e719e5db0f09 100644 --- a/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultBuildContext.java +++ b/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultBuildContext.java @@ -58,14 +58,47 @@ import org.apache.maven.api.services.PathMatcherFactory; import org.apache.maven.impl.DefaultPathMatcherFactory; +/** + * Default implementation of the incremental build context. + * + *

This class manages the full lifecycle of an incremental build: loading previous state, + * comparing configuration and file timestamps, tracking input-output associations, and + * cleaning up stale outputs at commit time.

+ * + *

Escalation

+ * + *

At construction, the context decides whether to escalate to a full build + * (treating all inputs as modified). Escalation is triggered when:

+ *
    + *
  • The previous state file does not exist or cannot be read (first build or after clean)
  • + *
  • The {@linkplain #getConfigurationChanged() configuration has changed} — any entry + * in the configuration map differs from the previous build's stored configuration
  • + *
  • Any previously tracked output file is missing on disk
  • + *
  • The workspace explicitly requests escalation + * ({@link Workspace.Mode#ESCALATED})
  • + *
+ * + *

Stale output cleanup

+ * + *

At {@linkplain #finalizeContext() commit time}, the context performs a three-pass cleanup:

+ *
    + *
  1. Carry over unprocessed (up-to-date) inputs and collect their associated outputs
  2. + *
  3. Carry over outputs whose inputs are all up-to-date
  4. + *
  5. Delete outputs from the previous build that are not carried over — these are + * stale outputs whose inputs were removed, modified, or re-associated
  6. + *
+ * + * @since 4.1.0 + */ public class DefaultBuildContext implements CommittableBuildContext { final Workspace workspace; final Path stateFile; final DefaultBuildContextState state; final DefaultBuildContextState oldState; /** - * Previous build state does not exist, cannot be read or configuration has changed. When - * escalated, all input files are considered require processing. + * Whether the build has been escalated to a full rebuild. When escalated, all input + * files are treated as requiring processing regardless of their actual file timestamps. + * Escalation is triggered by configuration changes, missing state, or missing outputs. */ private final boolean escalated; /** @@ -108,6 +141,27 @@ public DefaultBuildContext( this(workspace, stateFile, configuration, finalizer, null); } + /** + * Creates a new build context with the given workspace, state file, and configuration. + * + *

The constructor loads the previous build state from {@code stateFile} (if it exists), + * then compares each entry in {@code configuration} against the stored values. If any + * configuration value has changed — or if the state file is missing or any previously + * tracked output has been deleted — the context escalates to a full build.

+ * + *

The {@code configuration} map typically contains digested mojo parameters and + * plugin classpath hashes, provided by the Maven runtime. See + * {@link org.apache.maven.api.build.context.spi.BuildContextEnvironment#getParameters() + * BuildContextEnvironment.getParameters()} for details.

+ * + * @param workspace the file-system abstraction for reading, writing, and deleting files + * @param stateFile the path to the binary state file, or {@code null} for a + * stateless (always-escalated) context + * @param configuration the configuration parameters to compare against the previous build + * @param finalizer optional callback that commits the context after mojo execution + * @param pathMatcherFactory optional factory for Ant-style path matchers; defaults to + * {@link DefaultPathMatcherFactory} if {@code null} + */ public DefaultBuildContext( Workspace workspace, Path stateFile, @@ -198,6 +252,17 @@ private boolean isPresent(Collection outputs) { return outputs.stream().allMatch(Files::isRegularFile); } + /** + * Determines whether the build configuration has changed since the previous build. + * + *

Compares every key in the union of the current and previous configuration maps. + * A change in any value (including keys present in one map but not the other) triggers + * escalation to a full build. Values are compared using {@link Objects#equals}, so + * the digest values must implement {@code equals()} correctly (e.g., byte-array wrappers + * like {@code BytesHash}).

+ * + * @return {@code true} if any configuration entry differs from the previous build + */ private boolean getConfigurationChanged() { Map configuration = state.configuration; Map oldConfiguration = oldState.configuration; @@ -456,26 +521,34 @@ private T getMetadata( } } + /** + * Finalizes the build context by carrying over up-to-date state and cleaning up stale outputs. + * + *

This method implements a three-pass algorithm:

+ *
    + *
  1. Pass 1 — carry over up-to-date inputs. For each input from the + * previous build that was neither processed nor deleted in this build (and is still + * registered), carry over its state (timestamps, messages, attributes, output + * associations). Collect the set of outputs associated with these carried-over inputs.
  2. + *
  3. Pass 2 — carry over up-to-date outputs. For each output from the + * previous build whose all associated inputs were carried over, carry over + * the output's state as well.
  4. + *
  5. Pass 3 — delete stale outputs. Any output from the previous build + * that was not carried over (because its input was deleted, modified, or + * re-associated to a different output) is deleted from disk via the workspace.
  6. + *
+ * + *

This is the mechanism that provides automatic stale-output cleanup: when a source file + * is removed, all output files that were associated with it via + * {@link org.apache.maven.api.build.context.Input#associateOutput(Path)} in the previous + * build are automatically deleted. For example, deleting {@code Foo.java} will clean up + * {@code Foo.class}, {@code Foo$Inner.class}, and any other outputs associated with it.

+ * + *

Limitation: only simple input → output associations are supported. + * An output with multiple inputs, or a resource that is both input and output, may produce + * unexpected results.

+ */ protected void finalizeContext() { - // only supports simple input --> output associations - // outputs are carried over iff their input is carried over - - // TODO harden the implementation - // - // things can get tricky even with such simple model. consider the following - // build-1: inputA --> outputA - // build-2: inputA unchanged. inputB --> outputA - // now outputA has multiple inputs, which is not supported by this context - // - // another tricky example - // build-1: inputA --> outputA - // build-2: inputA unchanged before the build, inputB --> inputA - // now inputA is both input and output, which is not supported by this context - - // multi-pass implementation - // pass 1, carry-over up-to-date inputs and collect all up-to-date outputs - // pass 2, carry-over all up-to-date outputs - // pass 3, remove obsolete and orphaned outputs Set uptodateOldOutputs = new HashSet<>(); Set uptodateOldInputs = new HashSet<>(); From 4c6ef88d2630fe27df1d2506fa36d534828c63c3 Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Fri, 31 Jul 2026 13:49:25 +0200 Subject: [PATCH 13/16] Use CachingOutputStream in FilesystemWorkspace Replace the plain BufferedOutputStream in FilesystemWorkspace.newOutputStream() with plexus-utils CachingOutputStream. This compares written content against the existing file and only overwrites when the content actually changed, preserving timestamps on unchanged outputs and preventing unnecessary cascading rebuilds in downstream plugins. Co-Authored-By: Claude Opus 4.6 --- impl/maven-impl/pom.xml | 4 ++++ .../internal/build/context/impl/FilesystemWorkspace.java | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/impl/maven-impl/pom.xml b/impl/maven-impl/pom.xml index 11f719ff7889..a0f8f766dee7 100644 --- a/impl/maven-impl/pom.xml +++ b/impl/maven-impl/pom.xml @@ -124,6 +124,10 @@ under the License. org.codehaus.plexus plexus-sec-dispatcher + + org.codehaus.plexus + plexus-utils + org.junit.jupiter diff --git a/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/FilesystemWorkspace.java b/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/FilesystemWorkspace.java index 5a8361e08fcb..fcb2e288a498 100644 --- a/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/FilesystemWorkspace.java +++ b/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/FilesystemWorkspace.java @@ -18,7 +18,6 @@ */ package org.apache.maven.internal.build.context.impl; -import java.io.BufferedOutputStream; import java.io.IOException; import java.io.OutputStream; import java.nio.file.Files; @@ -33,6 +32,7 @@ import org.apache.maven.api.build.context.spi.FileState; import org.apache.maven.api.build.context.spi.Workspace; import org.apache.maven.api.di.Named; +import org.codehaus.plexus.util.io.CachingOutputStream; @Named public class FilesystemWorkspace implements Workspace { @@ -63,7 +63,7 @@ public void processOutput(Path path) {} public OutputStream newOutputStream(Path path) { try { Files.createDirectories(path.getParent()); - return new BufferedOutputStream(Files.newOutputStream(path)); + return new CachingOutputStream(path); } catch (IOException e) { throw new BuildContextException(e); } From 368ec4b2aac2a7087ad6cff8a2d7b235e74b83e7 Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Fri, 31 Jul 2026 14:54:44 +0200 Subject: [PATCH 14/16] Fix reactor build assertion failure and add missing digester types The concurrent-modification assertion in DefaultBuildContext.commit() was failing in reactor builds because finalizeContext() would overwrite freshly registered FileState (with current mtime) with old state from the previous build. This caused dependency JARs that were legitimately rebuilt by earlier modules to appear as unexpectedly modified. Fix: track resources registered during this build in a dedicated registeredResources set. In finalizeContext(), skip FileState overwrite for resources that were re-registered during this build, preserving their current filesystem state. In commit(), scope the assertion to only check registeredResources. Also add missing Artifact and Path digester types to Digesters, and handle NPE when Plugin.getDependencies() returns null during early lifecycle phases. Co-Authored-By: Claude Opus 4.6 --- .../context/impl/maven/digest/Digesters.java | 12 ++++--- .../digest/MojoConfigurationDigester.java | 13 ++++++-- .../context/impl/DefaultBuildContext.java | 33 +++++++++++++++---- 3 files changed, 45 insertions(+), 13 deletions(-) diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/digest/Digesters.java b/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/digest/Digesters.java index 9520d71f6f47..ce1edda1255a 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/digest/Digesters.java +++ b/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/digest/Digesters.java @@ -26,6 +26,7 @@ import java.lang.reflect.AnnotatedElement; import java.lang.reflect.Member; import java.nio.charset.StandardCharsets; +import java.nio.file.Path; import java.security.MessageDigest; import java.util.ArrayList; import java.util.Collection; @@ -36,6 +37,7 @@ import java.util.SortedMap; import java.util.TreeMap; +import org.apache.maven.api.Artifact; import org.apache.maven.api.Project; import org.apache.maven.api.RemoteRepository; import org.apache.maven.api.Session; @@ -50,9 +52,11 @@ class Digesters { Map, Digester> digesters = new LinkedHashMap<>(); // common Maven objects digesters.put(RemoteRepository.class, (Digester) Digesters::digestRemoteRepository); - // digesters.put(Artifact.class, (Digester) Digesters::digestArtifact); + digesters.put(Artifact.class, (Digester) Digesters::digestArtifact); digesters.put(Project.class, (Digester) Digesters::digestProject); digesters.put(Session.class, (Digester) Digesters::digestSession); + // filesystem + digesters.put(Path.class, (Digester) (member, value) -> value.toString()); // digesters.put(Collection.class, (Digester>) Digesters::digestCollection); // @@ -160,9 +164,9 @@ private static Serializable digestSession(Member member, Session session) { return new BytesHash(digester.digest()); } - // private static Serializable digestArtifact(Member member, Artifact value) { - // return value.getPath().get().toFile(); - // } + private static Serializable digestArtifact(Member member, Artifact value) { + return value.key().toString(); + } private static Serializable digestRemoteRepository(Member member, RemoteRepository value) { return value.getUrl(); diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/digest/MojoConfigurationDigester.java b/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/digest/MojoConfigurationDigester.java index ce88e98c0f9e..6c70659e6d4d 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/digest/MojoConfigurationDigester.java +++ b/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/digest/MojoConfigurationDigester.java @@ -104,8 +104,17 @@ public Map digest() throws IOException { Map result = new LinkedHashMap<>(); MojoDescriptor mojoDescriptor = execution.getDescriptor(); - List classpath = new ArrayList<>(execution.getPlugin().getDependencies()); - result.put("mojo.classpath", classpathDigester.digest(classpath)); + try { + List classpath = new ArrayList<>(execution.getPlugin().getDependencies()); + result.put("mojo.classpath", classpathDigester.digest(classpath)); + } catch (NullPointerException e) { + // Plugin dependency node may not be resolved yet (e.g. during early lifecycle phases). + // Fall back to using the plugin artifact's GAV as a less precise digest. + Artifact pluginArtifact = execution.getPlugin().getArtifact(); + if (pluginArtifact != null) { + result.put("mojo.classpath", pluginArtifact.key().toString()); + } + } XmlNode node = execution.getConfiguration().orElse(null); if (node != null) { diff --git a/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultBuildContext.java b/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultBuildContext.java index e719e5db0f09..5e9aaae58af5 100644 --- a/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultBuildContext.java +++ b/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultBuildContext.java @@ -111,6 +111,14 @@ public class DefaultBuildContext implements CommittableBuildContext { * and deleted through this build context. */ private final Set processedResources = new HashSet<>(); + /** + * Resources registered as inputs during this build via {@link #registerInput}. + * Used to scope the concurrent-modification assertion in {@link #commit} — only resources + * registered during this build are checked; resources carried over from a previous build's + * state (e.g. dependency JARs from other reactor modules) are not, since they may legitimately + * change between registration and commit in a reactor build. + */ + private final Set registeredResources = new HashSet<>(); /** * Indicates that no further modifications to this build context are allowed. */ @@ -568,8 +576,16 @@ protected void finalizeContext() { new IllegalStateException("Inconsistent resource type change " + resource)); } - // carry over - state.putResource(resource, oldState.getResource(resource)); + // carry over metadata (messages, attributes, output associations) from previous build + if (!registeredResources.contains(resource)) { + // Resource was not re-registered during this build — carry over FileState too. + // This preserves the previous build's mtime/size for unmodified resources. + state.putResource(resource, oldState.getResource(resource)); + } + // else: Resource was re-registered via registerInput() during this build — keep + // the fresh FileState (with current filesystem mtime/size) rather than overwriting + // with the old state. This is important in reactor builds where dependency JARs + // from earlier modules are legitimately rebuilt with new timestamps. state.setResourceMessages(resource, oldState.getResourceMessages(resource)); state.setResourceAttributes(resource, oldState.getResourceAttributes(resource)); state.setResourceOutputs(resource, oldState.getResourceOutputs(resource)); @@ -709,6 +725,7 @@ protected Path registerInput(FileState holder) { } state.putResource(resource, holder); } + registeredResources.add(resource); return resource; } @@ -850,11 +867,13 @@ public void commit(@Nullable Sink sink) { finalizeContext(); - // assert inputs didn't change - for (Map.Entry entry : state.getResources().entrySet()) { - Path resource = entry.getKey(); - FileState holder = entry.getValue(); - if (!state.isOutput(resource) && holder.getStatus() != Status.UNMODIFIED) { + // Assert that registered inputs were not concurrently modified during this build. + // Only check resources registered during THIS build — resources carried over from a + // previous build's state (e.g. dependency JARs from other reactor modules) are not + // checked, since they may legitimately change between builds. + for (Path resource : registeredResources) { + FileState holder = state.getResource(resource); + if (holder != null && !state.isOutput(resource) && holder.getStatus() != Status.UNMODIFIED) { throw new BuildContextException(new IllegalStateException("Unexpected input change " + resource)); } } From efb267b73f646755f0dd795daa2d32171f02fd17 Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Sat, 1 Aug 2026 00:05:41 +0200 Subject: [PATCH 15/16] Optimize BuildContext for incremental build performance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four optimizations that make incremental builds faster than stock 3.x plugins in absolute wall-clock time: 1. ClasspathDigester: skip SHA-1 content hashing for released (non-SNAPSHOT) artifacts — their GAV uniquely identifies the content. Also increase I/O buffer from 4KB to 64KB for SNAPSHOT hashing. 2. DefaultBuildContext: skip state file writes on no-op builds — when nothing was processed or deleted, the state is identical to what was loaded. This eliminates serialization overhead on the most common case. 3. FileState: reduce getStatus() from three syscalls (isRegularFile + isReadable + readAttributes) to a single readAttributes call with catch for NoSuchFileException. 4. MojoConfigurationDigester: cache reflected field maps per mojo class to avoid repeated class hierarchy walks across reactor modules. Benchmark (20 modules, 4000 sources, 600 resources): No-op rebuild: 15.7s (3.x) → 8.7s (patched) = −44% Leaf change: 14.8s (3.x) → 8.9s (patched) = −40% Co-Authored-By: Claude Opus 4.6 --- .../impl/maven/digest/ClasspathDigester.java | 13 ++++++++-- .../digest/MojoConfigurationDigester.java | 24 +++++++++++++------ .../context/impl/DefaultBuildContext.java | 8 +++++-- .../build/context/impl/FileState.java | 6 +++-- 4 files changed, 38 insertions(+), 13 deletions(-) diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/digest/ClasspathDigester.java b/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/digest/ClasspathDigester.java index 4ec72c70aad6..80ae72b30d46 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/digest/ClasspathDigester.java +++ b/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/digest/ClasspathDigester.java @@ -83,7 +83,7 @@ private static ConcurrentMap getCache(Session session) { static void digest(MessageDigest digester, InputStream is) { try { - byte[] buf = new byte[4 * 1024]; + byte[] buf = new byte[64 * 1024]; int r; while ((r = is.read(buf)) > 0) { digester.update(buf, 0, r); @@ -161,6 +161,16 @@ private static byte[] digestDirectory(Path file) { public Serializable digest(List artifacts) { MessageDigest digester = SHA1Digester.newInstance(); for (Artifact artifact : artifacts) { + String cacheKey = getArtifactKey(artifact); + + // Released (non-SNAPSHOT) artifacts have immutable content — their GAV uniquely + // identifies the bytes. Use the GAV string as the digest directly, skipping all + // I/O. Only SNAPSHOT artifacts need content-based hashing. + if (!artifact.isSnapshot()) { + digester.update(cacheKey.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + continue; + } + Optional pathOpt = artifactManager.getPath(artifact); if (pathOpt.isEmpty()) { // Skip artifacts whose path is not resolved (e.g. the root dependency node @@ -168,7 +178,6 @@ public Serializable digest(List artifacts) { continue; } Path file = pathOpt.get(); - String cacheKey = getArtifactKey(artifact); byte[] cached = cache.get(cacheKey); if (cached == null) { byte[] hash = digestArtifactFile(file); diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/digest/MojoConfigurationDigester.java b/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/digest/MojoConfigurationDigester.java index 6c70659e6d4d..ffaa12978f72 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/digest/MojoConfigurationDigester.java +++ b/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/digest/MojoConfigurationDigester.java @@ -188,16 +188,26 @@ private void append(StringBuilder sb, XmlNode node) throws XMLStreamException { sb.append(sw.toString()); } + /** + * Cache of reflected fields by class. Same mojo class is reflected for every execution + * in a reactor build — caching avoids repeated class hierarchy walks. + */ + private static final java.util.concurrent.ConcurrentHashMap, Map> FIELD_CACHE = + new java.util.concurrent.ConcurrentHashMap<>(); + private Field getField(Class clazz, String name) { - for (Field field : clazz.getDeclaredFields()) { - if (name.equals(field.getName())) { - return field; + Map fields = FIELD_CACHE.computeIfAbsent(clazz, MojoConfigurationDigester::buildFieldMap); + return fields.get(name); + } + + private static Map buildFieldMap(Class clazz) { + Map map = new java.util.HashMap<>(); + for (Class c = clazz; c != null; c = c.getSuperclass()) { + for (Field field : c.getDeclaredFields()) { + map.putIfAbsent(field.getName(), field); } } - if (clazz.getSuperclass() != null) { - return getField(clazz.getSuperclass(), name); - } - return null; + return map; } // first-name --> firstName diff --git a/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultBuildContext.java b/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultBuildContext.java index 5e9aaae58af5..44bd79938bb9 100644 --- a/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultBuildContext.java +++ b/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultBuildContext.java @@ -192,7 +192,7 @@ public DefaultBuildContext( } else if (workspace.getMode() == Workspace.Mode.SUPPRESSED) { this.escalated = false; this.workspace = workspace; - } else if (configurationChanged || !isPresent(oldState.getOutputs())) { + } else if (configurationChanged || !oldState.getOutputs().isEmpty() && !isPresent(oldState.getOutputs())) { this.escalated = true; this.workspace = workspace.escalate(); } else { @@ -881,7 +881,11 @@ public void commit(@Nullable Sink sink) { // timestamp new outputs state.getOutputs().forEach(outputFile -> state.computeResourceIfAbsent(outputFile, this::newFileState)); - if (stateFile != null) { + // Skip state file write on no-op builds — if nothing was processed or deleted, + // the state is identical to what was loaded. This saves significant I/O on the + // most common incremental build scenario. + boolean stateChanged = !processedResources.isEmpty() || !deletedResources.isEmpty() || escalated; + if (stateFile != null && stateChanged) { try (OutputStream os = workspace.newOutputStream(stateFile)) { state.storeTo(os); } catch (IOException e) { diff --git a/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/FileState.java b/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/FileState.java index bdcb96580e83..eae59f09be17 100644 --- a/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/FileState.java +++ b/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/FileState.java @@ -61,14 +61,16 @@ public long getSize() { public Status getStatus() { try { - if (!Files.isRegularFile(path) || !Files.isReadable(path)) { + BasicFileAttributes attrs = Files.readAttributes(path, BasicFileAttributes.class); + if (!attrs.isRegularFile()) { return Status.REMOVED; } - BasicFileAttributes attrs = Files.readAttributes(path, BasicFileAttributes.class); if (size == attrs.size() && Objects.equals(lastModified, attrs.lastModifiedTime())) { return Status.UNMODIFIED; } return Status.MODIFIED; + } catch (java.nio.file.NoSuchFileException e) { + return Status.REMOVED; } catch (IOException e) { throw new BuildContextException(e); } From 1ac5eb0c28b68fa6565852469d84e9698c2d0118 Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Sat, 1 Aug 2026 00:31:53 +0200 Subject: [PATCH 16/16] Add maven.buildcontext.skip property to disable incremental build tracking When -Dmaven.buildcontext.skip=true is set, the configuration digester is not run and no state file is written. Plugins still execute normally (full build) but without the overhead of classpath hashing, parameter reflection, and state persistence. This is useful for CI clean builds or release builds where the incremental build overhead provides no benefit and will never be followed by an incremental rebuild. Co-Authored-By: Claude Opus 4.6 --- .../maven/MavenBuildContextConfiguration.java | 27 +++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/MavenBuildContextConfiguration.java b/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/MavenBuildContextConfiguration.java index 9188c2c2ea71..cb6fbe6269b7 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/MavenBuildContextConfiguration.java +++ b/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/MavenBuildContextConfiguration.java @@ -22,10 +22,12 @@ import java.io.Serializable; import java.nio.file.Path; import java.nio.file.Paths; +import java.util.Collections; import java.util.Map; import org.apache.maven.api.MojoExecution; import org.apache.maven.api.Project; +import org.apache.maven.api.Session; import org.apache.maven.api.build.context.spi.BuildContextEnvironment; import org.apache.maven.api.build.context.spi.BuildContextFinalizer; import org.apache.maven.api.build.context.spi.Workspace; @@ -61,6 +63,17 @@ @MojoExecutionScoped public class MavenBuildContextConfiguration implements BuildContextEnvironment { + /** + * User property that disables the build context entirely. + * + *

When set to {@code true} (via {@code -Dmaven.buildcontext.skip=true}), + * the configuration digester is not run and no state file is written. This + * means every mojo execution performs a full build and the next build will + * also start from scratch. Useful for CI clean builds or release builds + * where incremental overhead is wasted.

+ */ + static final String SKIP_PROPERTY = "maven.buildcontext.skip"; + private final ProjectWorkspace workspace; private final Path stateFile; private final Map parameters; @@ -71,13 +84,23 @@ public MavenBuildContextConfiguration( ProjectWorkspace workspace, MojoConfigurationDigester digester, MavenBuildContextFinalizer finalizer, + Session session, Project project, MojoExecution execution) throws IOException { this.workspace = workspace; this.finalizer = finalizer; - this.stateFile = getExecutionStateLocation(project, execution); - this.parameters = digester.digest(); + + boolean skip = Boolean.parseBoolean(session.getUserProperties().get(SKIP_PROPERTY)); + if (skip) { + // Skip all digester computation and state persistence. + // DefaultBuildContext will see no old state and escalate to a full build. + this.stateFile = null; + this.parameters = Collections.emptyMap(); + } else { + this.stateFile = getExecutionStateLocation(project, execution); + this.parameters = digester.digest(); + } } @Override