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 000000000000..740f149a1460 Binary files /dev/null and b/impl/maven-impl/src/test/projects/dummy-1.0.jar differ diff --git a/impl/maven-impl/src/test/projects/dummy/dummy-1.0.jar b/impl/maven-impl/src/test/projects/dummy/dummy-1.0.jar new file mode 100644 index 000000000000..740f149a1460 Binary files /dev/null and b/impl/maven-impl/src/test/projects/dummy/dummy-1.0.jar differ diff --git a/impl/maven-impl/src/test/projects/dummy/pom.xml b/impl/maven-impl/src/test/projects/dummy/pom.xml new file mode 100644 index 000000000000..36ec2f1f61a2 --- /dev/null +++ b/impl/maven-impl/src/test/projects/dummy/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/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