From 1e02718dce3afe8bedb45384cbaaa62474c11f7e Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Tue, 28 Jul 2026 23:27:16 +0200 Subject: [PATCH 01/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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<>();