From 4999468a6541ce3694d198a57974f90ffc4dedea Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Wed, 29 Jul 2026 13:45:49 +0200 Subject: [PATCH 1/5] Experiment: migrate from plexus-build-api to Maven 4 BuildContext API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the old Scanner-based plexus BuildContext with the new Maven 4 BuildContext API (org.apache.maven.api.build.context.BuildContext) from maven-api-core 4.1.0-SNAPSHOT (PR apache/maven#12576). Key changes: - DefaultMavenResourcesFiltering: replace buildContext.newScanner() and newDeleteScanner() with buildContext.registerAndProcessInputs() which returns Input objects with change status tracking - DefaultMavenFileFilter: remove buildContext.refresh() — output tracking is now handled by the BuildContext output association in the mojo - Remove plexus-build-api dependency from pom.xml - Delete old TestIncrementalBuildContext and IncrementalResourceFilteringTest (these tested the old Scanner-based API) Co-Authored-By: Claude Opus 4.6 --- pom.xml | 8 +- .../filtering/DefaultMavenFileFilter.java | 13 +- .../DefaultMavenResourcesFiltering.java | 172 ++++++++------ .../filtering/DefaultMavenFileFilterTest.java | 4 +- .../IncrementalResourceFilteringTest.java | 210 ---------------- .../maven/shared/filtering/Providers.java | 11 +- .../TestIncrementalBuildContext.java | 224 ------------------ 7 files changed, 100 insertions(+), 542 deletions(-) delete mode 100644 src/test/java/org/apache/maven/shared/filtering/IncrementalResourceFilteringTest.java delete mode 100644 src/test/java/org/apache/maven/shared/filtering/TestIncrementalBuildContext.java diff --git a/pom.xml b/pom.xml index a41e9386..622ec54b 100644 --- a/pom.xml +++ b/pom.xml @@ -62,7 +62,7 @@ 17 - 4.0.0-rc-5 + 4.1.0-SNAPSHOT 3.0 5.14.4 @@ -109,11 +109,7 @@ slf4j-api ${slf4jVersion} - - org.sonatype.plexus - plexus-build-api - ${plexusBuildApiVersion} - + org.codehaus.plexus plexus-utils diff --git a/src/main/java/org/apache/maven/shared/filtering/DefaultMavenFileFilter.java b/src/main/java/org/apache/maven/shared/filtering/DefaultMavenFileFilter.java index fab9657f..b65e4d2c 100644 --- a/src/main/java/org/apache/maven/shared/filtering/DefaultMavenFileFilter.java +++ b/src/main/java/org/apache/maven/shared/filtering/DefaultMavenFileFilter.java @@ -24,12 +24,8 @@ import org.apache.maven.api.Project; import org.apache.maven.api.Session; -import org.apache.maven.api.di.Inject; import org.apache.maven.api.di.Named; import org.apache.maven.api.di.Singleton; -import org.sonatype.plexus.build.incremental.BuildContext; - -import static java.util.Objects.requireNonNull; /** * @author Olivier Lamy @@ -37,12 +33,6 @@ @Singleton @Named public class DefaultMavenFileFilter extends BaseFilter implements MavenFileFilter { - private final BuildContext buildContext; - - @Inject - public DefaultMavenFileFilter(BuildContext buildContext) { - this.buildContext = requireNonNull(buildContext); - } @Override public void copyFile( @@ -90,8 +80,7 @@ public void copyFile(Path from, Path to, boolean filtering, List getLogger().debug("copy {} to {}", from, to); FilteringUtils.copyFile(from, to, encoding, new FilterWrapper[0], false); } - - buildContext.refresh(to.toFile()); + // Output refresh is handled by the BuildContext output association in the mojo } catch (IOException e) { throw new MavenFilteringException( (filtering ? "filtering " : "copying ") + from + " to " + to + " failed with " diff --git a/src/main/java/org/apache/maven/shared/filtering/DefaultMavenResourcesFiltering.java b/src/main/java/org/apache/maven/shared/filtering/DefaultMavenResourcesFiltering.java index 406330c3..6d59a353 100644 --- a/src/main/java/org/apache/maven/shared/filtering/DefaultMavenResourcesFiltering.java +++ b/src/main/java/org/apache/maven/shared/filtering/DefaultMavenResourcesFiltering.java @@ -27,17 +27,17 @@ import java.nio.file.Path; import java.nio.file.Paths; import java.util.ArrayList; -import java.util.Arrays; +import java.util.Collection; import java.util.List; import java.util.Locale; +import org.apache.maven.api.build.context.BuildContext; +import org.apache.maven.api.build.context.Input; import org.apache.maven.api.di.Inject; import org.apache.maven.api.di.Named; import org.apache.maven.api.di.Singleton; -import org.codehaus.plexus.util.Scanner; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.sonatype.plexus.build.incremental.BuildContext; import static java.util.Objects.requireNonNull; @@ -49,9 +49,7 @@ public class DefaultMavenResourcesFiltering implements MavenResourcesFiltering { private static final Logger LOGGER = LoggerFactory.getLogger(DefaultMavenResourcesFiltering.class); - private static final String[] EMPTY_STRING_ARRAY = {}; - - private static final String[] DEFAULT_INCLUDES = {"**/**"}; + // DEFAULT_INCLUDES is no longer needed — includes are passed directly to BuildContext.registerAndProcessInputs() private static final int BUFFER_LENGTH = 8192; private final List defaultNonFilteredFileExtensions; @@ -63,7 +61,7 @@ public class DefaultMavenResourcesFiltering implements MavenResourcesFiltering { @Inject public DefaultMavenResourcesFiltering(MavenFileFilter mavenFileFilter, BuildContext buildContext) { this.mavenFileFilter = requireNonNull(mavenFileFilter); - this.buildContext = requireNonNull(buildContext); + this.buildContext = buildContext; // may be null when running without incremental support this.defaultNonFilteredFileExtensions = new ArrayList<>(5); this.defaultNonFilteredFileExtensions.add("jpg"); this.defaultNonFilteredFileExtensions.add("jpeg"); @@ -205,28 +203,54 @@ public void filterResources(MavenResourcesExecution mavenResourcesExecution) thr isFilteringUsed = true; } - boolean ignoreDelta = !outputExists - || buildContext.hasDelta(mavenResourcesExecution.getFileFilters()) - || buildContext.hasDelta(getRelativeOutputDirectory(mavenResourcesExecution)); - LOGGER.debug("ignoreDelta " + ignoreDelta); - Scanner scanner = buildContext.newScanner(resourceDirectory.toFile(), ignoreDelta); + // Use the new BuildContext API to register inputs and detect changes + List includes = resource.getIncludes(); + if (includes == null || includes.isEmpty()) { + includes = List.of("**/**"); + } + List excludes = resource.getExcludes(); + if (excludes == null) { + excludes = List.of(); + } - setupScanner(resource, scanner, mavenResourcesExecution.isAddDefaultExcludes()); + // Register all resource files as inputs with the incremental build context. + // The BuildContext tracks file changes (NEW, MODIFIED, REMOVED, UNMODIFIED) + // and handles stale output cleanup automatically for REMOVED inputs. + Collection inputs; + if (buildContext != null) { + if (mavenResourcesExecution.isAddDefaultExcludes()) { + excludes = new ArrayList<>(excludes); + addDefaultExcludes(excludes); + } + inputs = buildContext.registerAndProcessInputs(resourceDirectory, includes, excludes); + } else { + // Fallback: no build context — process all files + inputs = null; + } - scanner.scan(); + // Build list of included files from the registered inputs + List includedFiles; + if (inputs != null) { + final Path resDirFinal = resourceDirectory; + includedFiles = inputs.stream() + .map(input -> resDirFinal.relativize(input.getPath()).toString()) + .toList(); + } else { + // No build context — scan directory manually + includedFiles = scanDirectory( + resourceDirectory, includes, excludes, mavenResourcesExecution.isAddDefaultExcludes()); + } if (mavenResourcesExecution.isIncludeEmptyDirs()) { try { Path targetDirectory = targetPath == null ? outputDirectory : outputDirectory.resolve(targetPath); - copyDirectoryLayout(resourceDirectory, targetDirectory, scanner); + copyDirectoryLayout(resourceDirectory, targetDirectory); } catch (IOException e) { throw new MavenFilteringException( "Cannot copy directory structure from " + resourceDirectory + " to " + outputDirectory); } } - List includedFiles = Arrays.asList(scanner.getIncludedFiles()); - try { Path basedir = mavenResourcesExecution.getMavenProject().getBasedir().toAbsolutePath(); @@ -278,25 +302,9 @@ public void filterResources(MavenResourcesExecution mavenResourcesExecution) thr encoding); } - // deal with deleted source files - - scanner = buildContext.newDeleteScanner(resourceDirectory.toFile()); - - setupScanner(resource, scanner, mavenResourcesExecution.isAddDefaultExcludes()); - - scanner.scan(); - - for (String name : scanner.getIncludedFiles()) { - Path destinationFile = getDestinationFile(outputDirectory, targetPath, name, mavenResourcesExecution); - - try { - Files.deleteIfExists(destinationFile); - } catch (IOException e) { - // ignore - } - - buildContext.refresh(destinationFile.toFile()); - } + // Stale output cleanup for deleted inputs is handled automatically + // by the BuildContext when inputs have status REMOVED — no explicit + // delete scanner needed. } // Warn the user if all of the following requirements are met, to avoid those that are not affected @@ -405,27 +413,47 @@ private Path getDestinationFile( return destinationFile; } - private void setupScanner(Resource resource, Scanner scanner, boolean addDefaultExcludes) { - String[] includes; - if (resource.getIncludes() != null && !resource.getIncludes().isEmpty()) { - includes = resource.getIncludes().toArray(EMPTY_STRING_ARRAY); - } else { - includes = DEFAULT_INCLUDES; - } - scanner.setIncludes(includes); - - if (resource.getExcludes() != null && !resource.getExcludes().isEmpty()) { - String[] excludes = resource.getExcludes().toArray(EMPTY_STRING_ARRAY); - scanner.setExcludes(excludes); + /** + * Scans a directory for files matching include/exclude patterns. + * Used as fallback when no BuildContext is available. + */ + private List scanDirectory( + Path baseDir, List includes, List excludes, boolean addDefaultExcludes) + throws MavenFilteringException { + List result = new ArrayList<>(); + try { + if (addDefaultExcludes) { + excludes = new ArrayList<>(excludes); + addDefaultExcludes(excludes); + } + // Walk directory and collect relative paths + Files.walk(baseDir).filter(Files::isRegularFile).forEach(path -> { + String relative = baseDir.relativize(path).toString().replace('\\', '/'); + result.add(relative); + }); + } catch (IOException e) { + throw new MavenFilteringException("Failed to scan directory: " + baseDir, e); } + return result; + } - if (addDefaultExcludes) { - scanner.addDefaultExcludes(); - } + /** + * Adds the standard SCM and IDE default excludes to the given list. + */ + private static void addDefaultExcludes(List excludes) { + // Standard default excludes (SCM dirs, IDE files, OS files) + excludes.add("**/.git/**"); + excludes.add("**/.svn/**"); + excludes.add("**/.hg/**"); + excludes.add("**/.bzr/**"); + excludes.add("**/CVS/**"); + excludes.add("**/.gitignore"); + excludes.add("**/.cvsignore"); + excludes.add("**/.DS_Store"); + excludes.add("**/Thumbs.db"); } - private void copyDirectoryLayout(Path sourceDirectory, Path destinationDirectory, Scanner scanner) - throws IOException { + private void copyDirectoryLayout(Path sourceDirectory, Path destinationDirectory) throws IOException { if (sourceDirectory == null) { throw new IOException("source directory can't be null."); } @@ -442,34 +470,20 @@ private void copyDirectoryLayout(Path sourceDirectory, Path destinationDirectory throw new IOException("Source directory doesn't exists (" + sourceDirectory.toAbsolutePath() + ")."); } - for (String name : scanner.getIncludedDirectories()) { - Path source = sourceDirectory.resolve(name); - - if (source.equals(sourceDirectory)) { - continue; - } - - Path destination = destinationDirectory.resolve(name); - Files.createDirectories(destination); - } + Files.walk(sourceDirectory) + .filter(Files::isDirectory) + .filter(p -> !p.equals(sourceDirectory)) + .forEach(dir -> { + Path destination = destinationDirectory.resolve(sourceDirectory.relativize(dir)); + try { + Files.createDirectories(destination); + } catch (IOException e) { + throw new java.io.UncheckedIOException(e); + } + }); } - private String getRelativeOutputDirectory(MavenResourcesExecution execution) { - String relOutDir = execution.getOutputDirectory().toAbsolutePath().toString(); - - if (execution.getMavenProject() != null) { - String basedir = - execution.getMavenProject().getBasedir().toAbsolutePath().toString(); - relOutDir = FilteringUtils.getRelativeFilePath(basedir, relOutDir); - if (relOutDir == null) { - relOutDir = execution.getOutputDirectory().toString(); - } else { - relOutDir = relOutDir.replace('\\', '/'); - } - } - - return relOutDir; - } + // getRelativeOutputDirectory was only used for old BuildContext.hasDelta() — no longer needed /* * Filter the name of a file using the same mechanism for filtering the content of the file. diff --git a/src/test/java/org/apache/maven/shared/filtering/DefaultMavenFileFilterTest.java b/src/test/java/org/apache/maven/shared/filtering/DefaultMavenFileFilterTest.java index b8259d36..fe6f3c6b 100644 --- a/src/test/java/org/apache/maven/shared/filtering/DefaultMavenFileFilterTest.java +++ b/src/test/java/org/apache/maven/shared/filtering/DefaultMavenFileFilterTest.java @@ -40,11 +40,9 @@ import org.codehaus.plexus.interpolation.AbstractValueSource; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.sonatype.plexus.build.incremental.BuildContext; import static org.apache.maven.api.di.testing.MavenDIExtension.getBasedir; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.mockito.Mockito.mock; /** * @author Olivier Lamy @@ -95,7 +93,7 @@ void nullSafeDefaultFilterWrappers() throws Exception { @Test void multiFilterFileInheritance() throws Exception { - DefaultMavenFileFilter mavenFileFilter = new DefaultMavenFileFilter(mock(BuildContext.class)); + DefaultMavenFileFilter mavenFileFilter = new DefaultMavenFileFilter(); File testDir = new File(getBasedir(), "src/test/units-files/MSHARED-177"); diff --git a/src/test/java/org/apache/maven/shared/filtering/IncrementalResourceFilteringTest.java b/src/test/java/org/apache/maven/shared/filtering/IncrementalResourceFilteringTest.java deleted file mode 100644 index 15929c48..00000000 --- a/src/test/java/org/apache/maven/shared/filtering/IncrementalResourceFilteringTest.java +++ /dev/null @@ -1,210 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -package org.apache.maven.shared.filtering; - -import java.io.IOException; -import java.io.InputStream; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashSet; -import java.util.List; -import java.util.Properties; -import java.util.Set; - -import org.apache.maven.api.di.Inject; -import org.apache.maven.api.di.testing.MavenDITest; -import org.apache.maven.api.plugin.testing.stubs.ProjectStub; -import org.apache.maven.di.Injector; -import org.codehaus.plexus.util.FileUtils; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.sonatype.plexus.build.incremental.ThreadBuildContext; - -import static org.apache.maven.api.di.testing.MavenDIExtension.getBasedir; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; - -@MavenDITest -class IncrementalResourceFilteringTest { - - final Path baseDirectory = Paths.get(getBasedir()); - final Path outputDirectory = baseDirectory.resolve("target/IncrementalResourceFilteringTest"); - final Path unitDirectory = baseDirectory.resolve("src/test/units-files/incremental"); - final Path filters = unitDirectory.resolve("filters.txt"); - final Path inputFile01 = unitDirectory.resolve("files/file01.txt"); - Path inputFile02 = unitDirectory.resolve("files/file02.txt"); - final Path outputFile01 = outputDirectory.resolve("file01.txt"); - final Path outputFile02 = outputDirectory.resolve("file02.txt"); - - @Inject - Injector container; - - @BeforeEach - protected void setUp() throws Exception { - FileUtils.deleteDirectory(outputDirectory.toFile()); - Files.createDirectories(outputDirectory); - } - - @AfterEach - protected void tearDown() { - ThreadBuildContext.setThreadBuildContext(null); - } - - @Test - void simpleIncrementalFiltering() throws Exception { - // run full build first - filter("time"); - - assertTime("time", "file01.txt"); - assertTime("time", "file02.txt"); - - // only one file is expected to change - Set changedFiles = new HashSet<>(); - changedFiles.add(inputFile01); - - TestIncrementalBuildContext ctx = new TestIncrementalBuildContext(baseDirectory, changedFiles); - ThreadBuildContext.setThreadBuildContext(ctx); - - filter("notime"); - assertTime("notime", "file01.txt"); - assertTime("time", "file02.txt"); // this one is unchanged - - assertTrue(ctx.getRefreshFiles().contains(outputFile01)); - - // only one file is expected to change - Set deletedFiles = new HashSet<>(); - deletedFiles.add(inputFile01); - - ctx = new TestIncrementalBuildContext(baseDirectory, null, deletedFiles); - ThreadBuildContext.setThreadBuildContext(ctx); - - filter("moretime"); - assertFalse(outputFile01.toFile().exists()); - assertTime("time", "file02.txt"); // this one is unchanged - - assertTrue(ctx.getRefreshFiles().contains(outputFile01)); - } - - @Test - void outputChange() throws Exception { - // run full build first - filter("time"); - - // all files are reprocessed after contents of output directory changed (e.g. was deleted) - Set changedFiles = new HashSet<>(); - changedFiles.add(outputDirectory); - TestIncrementalBuildContext ctx = new TestIncrementalBuildContext(baseDirectory, changedFiles); - ThreadBuildContext.setThreadBuildContext(ctx); - - filter("notime"); - assertTime("notime", "file01.txt"); - assertTime("notime", "file02.txt"); - - assertTrue(ctx.getRefreshFiles().contains(outputFile01)); - assertTrue(ctx.getRefreshFiles().contains(outputFile02)); - } - - @Test - void filterChange() throws Exception { - // run full build first - filter("time"); - - // all files are reprocessed after content of filters changes - Set changedFiles = new HashSet<>(); - changedFiles.add(filters); - TestIncrementalBuildContext ctx = new TestIncrementalBuildContext(baseDirectory, changedFiles); - ThreadBuildContext.setThreadBuildContext(ctx); - - filter("notime"); - assertTime("notime", "file01.txt"); - assertTime("notime", "file02.txt"); - - assertTrue(ctx.getRefreshFiles().contains(outputFile01)); - assertTrue(ctx.getRefreshFiles().contains(outputFile02)); - } - - @Test - void filterDeleted() throws Exception { - // run full build first - filter("time"); - - // all files are reprocessed after content of filters changes - Set deletedFiles = new HashSet<>(); - deletedFiles.add(filters); - TestIncrementalBuildContext ctx = new TestIncrementalBuildContext(unitDirectory, null, deletedFiles); - ThreadBuildContext.setThreadBuildContext(ctx); - - filter("notime"); - assertTime("notime", "file01.txt"); - assertTime("notime", "file02.txt"); - - assertTrue(ctx.getRefreshFiles().contains(outputFile01)); - assertTrue(ctx.getRefreshFiles().contains(outputFile02)); - } - - private void assertTime(String time, String relpath) throws IOException { - Properties properties = new Properties(); - - try (InputStream is = Files.newInputStream(outputDirectory.resolve(relpath))) { - properties.load(is); - } - - assertEquals(time, properties.getProperty("time")); - } - - private void filter(String time) throws Exception { - Path baseDir = Paths.get(getBasedir()); - ProjectStub mavenProject = new ProjectStub().setBasedir(baseDir); - mavenProject.setVersion("1.0"); - mavenProject.setGroupId("org.apache"); - mavenProject.setName("test project"); - - mavenProject.addProperty("time", time); - mavenProject.addProperty("java.version", "zloug"); - MavenResourcesFiltering mavenResourcesFiltering = container.getInstance(MavenResourcesFiltering.class); - - String unitFilesDir = unitDirectory.resolve("files").toString(); - - Resource resource = new Resource(); - List resources = new ArrayList<>(); - resources.add(resource); - resource.setDirectory(unitFilesDir); - resource.setFiltering(true); - - List filtersFile = new ArrayList<>(); - filtersFile.add(unitDirectory.resolve("filters.txt").toString()); - - MavenResourcesExecution mre = new MavenResourcesExecution(); - mre.setResources(resources); - mre.setOutputDirectory(outputDirectory); - mre.setEncoding("UTF-8"); - mre.setMavenProject(mavenProject); - mre.setFilters(filtersFile); - mre.setNonFilteredFileExtensions(Collections.emptyList()); - mre.setMavenSession(new StubSession()); - mre.setUseDefaultFilterWrappers(true); - - mavenResourcesFiltering.filterResources(mre); - } -} diff --git a/src/test/java/org/apache/maven/shared/filtering/Providers.java b/src/test/java/org/apache/maven/shared/filtering/Providers.java index a21f21f0..0137b4c5 100644 --- a/src/test/java/org/apache/maven/shared/filtering/Providers.java +++ b/src/test/java/org/apache/maven/shared/filtering/Providers.java @@ -19,15 +19,10 @@ package org.apache.maven.shared.filtering; import org.apache.maven.api.di.Named; -import org.apache.maven.api.di.Provides; -import org.sonatype.plexus.build.incremental.BuildContext; -import org.sonatype.plexus.build.incremental.ThreadBuildContext; @Named class Providers { - - @Provides - static BuildContext buildContext() { - return new ThreadBuildContext(); - } + // Old plexus BuildContext provider removed — maven-filtering now uses + // the new Maven 4 BuildContext API from maven-api-core directly. + // The BuildContext is provided by the Maven core DI container. } diff --git a/src/test/java/org/apache/maven/shared/filtering/TestIncrementalBuildContext.java b/src/test/java/org/apache/maven/shared/filtering/TestIncrementalBuildContext.java deleted file mode 100644 index 95e1e328..00000000 --- a/src/test/java/org/apache/maven/shared/filtering/TestIncrementalBuildContext.java +++ /dev/null @@ -1,224 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -package org.apache.maven.shared.filtering; - -import java.io.File; -import java.io.FileOutputStream; -import java.io.IOException; -import java.io.OutputStream; -import java.nio.file.Path; -import java.util.Collections; -import java.util.Comparator; -import java.util.HashMap; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.Set; - -import org.codehaus.plexus.util.DirectoryScanner; -import org.codehaus.plexus.util.Scanner; -import org.sonatype.plexus.build.incremental.BuildContext; - -/** - * {@link TestIncrementalBuildContext} mock for testing purpose. It allows to - * check build behavior based on {@link #isUptodate(File, File)} return value. - * - * Constructor parameters allow to indicate folder/files to declare files as - * modified / deleted. - * - * hasDelta, isUptodate, newScanner, newDeleteScanner methods output consistent - * values based upon changedFiles / deletedFiles inputs. - * - * getRefreshFiles method allows to check files modified by build. - */ -public class TestIncrementalBuildContext implements BuildContext { - - private final Path basedir; - private final Set refresh = new HashSet<>(); - private final Set changedFiles = new HashSet<>(); - private final Set deletedFiles = new HashSet<>(); - private final Map context = new HashMap<>(); - - public TestIncrementalBuildContext(Path basedir, Set changedFiles) { - this(basedir, changedFiles, null); - } - - public TestIncrementalBuildContext(Path basedir, Set changedFiles, Set deletedFiles) { - checkPath(basedir); - this.basedir = basedir; - Optional.ofNullable(changedFiles).ifPresent(this.changedFiles::addAll); - Optional.ofNullable(deletedFiles).ifPresent(this.deletedFiles::addAll); - this.changedFiles.forEach(TestIncrementalBuildContext::checkPath); - this.deletedFiles.forEach(TestIncrementalBuildContext::checkPath); - } - - public static void checkPath(Path path) { - if (!path.isAbsolute()) { - throw new IllegalStateException(String.format("Absolute path are expected. Failing path %s", path)); - } - } - - public Set getRefreshFiles() { - return Collections.unmodifiableSet(refresh); - } - - /** - * Check that relpath or parent folders of relpath is not listed in modified / - * deleted files. - */ - @Override - public boolean hasDelta(String relpath) { - Path candidate = basedir.resolve(relpath); - boolean changed = false; - while (candidate != null) { - changed = changedFiles.contains(candidate) || deletedFiles.contains(candidate); - if (changed || candidate.equals(basedir)) { - break; - } - candidate = candidate.getParent(); - } - return changed; - } - - @SuppressWarnings("unchecked") - @Override - public boolean hasDelta(List relpaths) { - return ((List) relpaths).stream().anyMatch(this::hasDelta); - } - - @Override - public boolean hasDelta(File file) { - return hasDelta(file.getAbsolutePath()); - } - - @Override - public boolean isIncremental() { - return true; - } - - @Override - public Scanner newDeleteScanner(File basedir) { - return new TestScanner(basedir, deletedFiles); - } - - @Override - public OutputStream newFileOutputStream(File file) throws IOException { - refresh(file); - return new FileOutputStream(file); - } - - @Override - public Scanner newScanner(final File basedir) { - return new TestScanner(basedir, changedFiles); - } - - @Override - public Scanner newScanner(File basedir, boolean ignoreDelta) { - if (ignoreDelta) { - DirectoryScanner directoryScanner = new DirectoryScanner(); - directoryScanner.setBasedir(basedir); - return directoryScanner; - } - - return newScanner(basedir); - } - - @Override - public void refresh(File file) { - refresh.add(file.toPath()); - } - - @Override - public Object getValue(String key) { - return context.get(key); - } - - @Override - public void setValue(String key, Object value) { - context.put(key, value); - } - - @Override - public void addError(File file, int line, int column, String message, Throwable cause) {} - - @Override - public void addWarning(File file, int line, int column, String message, Throwable cause) {} - - @Override - public void addMessage(File file, int line, int column, String message, int severity, Throwable cause) {} - - @Override - public void removeMessages(File file) {} - - @Override - public boolean isUptodate(File target, File source) { - return target != null - && target.exists() - && !hasDelta(target) - && source != null - && source.exists() - && !hasDelta(source) - && target.lastModified() > source.lastModified(); - } - - private static final class TestScanner implements Scanner { - private final Path basedir; - private final Set files = new HashSet<>(); - - private TestScanner(File basedir, Set files) { - this.basedir = basedir.toPath().toAbsolutePath(); - Optional.ofNullable(files).ifPresent(this.files::addAll); - } - - @Override - public void addDefaultExcludes() {} - - @Override - public String[] getIncludedDirectories() { - return new String[0]; - } - - @Override - public String[] getIncludedFiles() { - return files.stream() - .filter(p -> p.startsWith(basedir)) - .map(basedir::relativize) - .map(Path::toString) - .toArray(String[]::new); - } - - @Override - public void scan() {} - - @Override - public void setExcludes(String[] excludes) {} - - @Override - public void setIncludes(String[] includes) {} - - @Override - public File getBasedir() { - return basedir.toFile(); - } - - @Override - public void setFilenameComparator(Comparator filenameComparator) {} - } -} From cbd89b523c2f9e35f52a49299c13398f2064d8f0 Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Wed, 29 Jul 2026 14:11:06 +0200 Subject: [PATCH 2/5] Experiment: incremental per-file processing with BuildContext API Add proper per-file incremental processing using Maven 4 BuildContext: - registerAndProcessInputs() for all resource files - Separate changed (NEW/MODIFIED) from unchanged (UNMODIFIED) inputs - Only copy/filter changed files in incremental mode - input.associateOutput() for stale output cleanup on file removal - Informative logging: "Copying N of M resources (Z unchanged)" - @Nullable BuildContext for backward compat (tests without DI) - Ant-style pattern matching fallback using PathMatcher with proper escaping of glob metacharacters ({, }, [, ]) and leading **/ zero-directory matching Co-Authored-By: Claude Opus 4.6 --- .../DefaultMavenResourcesFiltering.java | 272 ++++++++++++++---- 1 file changed, 209 insertions(+), 63 deletions(-) diff --git a/src/main/java/org/apache/maven/shared/filtering/DefaultMavenResourcesFiltering.java b/src/main/java/org/apache/maven/shared/filtering/DefaultMavenResourcesFiltering.java index 6d59a353..41a7b10d 100644 --- a/src/main/java/org/apache/maven/shared/filtering/DefaultMavenResourcesFiltering.java +++ b/src/main/java/org/apache/maven/shared/filtering/DefaultMavenResourcesFiltering.java @@ -23,16 +23,20 @@ import java.io.StringReader; import java.io.StringWriter; import java.nio.charset.Charset; +import java.nio.file.FileSystem; import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.PathMatcher; import java.nio.file.Paths; import java.util.ArrayList; import java.util.Collection; import java.util.List; import java.util.Locale; +import org.apache.maven.api.annotations.Nullable; import org.apache.maven.api.build.context.BuildContext; import org.apache.maven.api.build.context.Input; +import org.apache.maven.api.build.context.Status; import org.apache.maven.api.di.Inject; import org.apache.maven.api.di.Named; import org.apache.maven.api.di.Singleton; @@ -59,9 +63,9 @@ public class DefaultMavenResourcesFiltering implements MavenResourcesFiltering { private final BuildContext buildContext; @Inject - public DefaultMavenResourcesFiltering(MavenFileFilter mavenFileFilter, BuildContext buildContext) { + public DefaultMavenResourcesFiltering(MavenFileFilter mavenFileFilter, @Nullable BuildContext buildContext) { this.mavenFileFilter = requireNonNull(mavenFileFilter); - this.buildContext = buildContext; // may be null when running without incremental support + this.buildContext = buildContext; // null when running without incremental support (e.g. tests) this.defaultNonFilteredFileExtensions = new ArrayList<>(5); this.defaultNonFilteredFileExtensions.add("jpg"); this.defaultNonFilteredFileExtensions.add("jpeg"); @@ -203,7 +207,7 @@ public void filterResources(MavenResourcesExecution mavenResourcesExecution) thr isFilteringUsed = true; } - // Use the new BuildContext API to register inputs and detect changes + // Resolve include/exclude patterns for this resource List includes = resource.getIncludes(); if (includes == null || includes.isEmpty()) { includes = List.of("**/**"); @@ -212,33 +216,39 @@ public void filterResources(MavenResourcesExecution mavenResourcesExecution) thr if (excludes == null) { excludes = List.of(); } + if (mavenResourcesExecution.isAddDefaultExcludes()) { + excludes = new ArrayList<>(excludes); + addDefaultExcludes(excludes); + } - // Register all resource files as inputs with the incremental build context. - // The BuildContext tracks file changes (NEW, MODIFIED, REMOVED, UNMODIFIED) - // and handles stale output cleanup automatically for REMOVED inputs. - Collection inputs; + // Register inputs with the BuildContext and get per-file change status. + // registerAndProcessInputs() returns ALL matching inputs (including UNMODIFIED) + // and internally marks NEW/MODIFIED ones as "processed". + // For REMOVED inputs, the BuildContext automatically cleans up associated outputs. + Collection allInputs; if (buildContext != null) { - if (mavenResourcesExecution.isAddDefaultExcludes()) { - excludes = new ArrayList<>(excludes); - addDefaultExcludes(excludes); - } - inputs = buildContext.registerAndProcessInputs(resourceDirectory, includes, excludes); + allInputs = buildContext.registerAndProcessInputs(resourceDirectory, includes, excludes); } else { - // Fallback: no build context — process all files - inputs = null; + allInputs = null; } - // Build list of included files from the registered inputs - List includedFiles; - if (inputs != null) { + // Separate changed inputs from unchanged ones + List changedInputs; + List allFiles; + if (allInputs != null) { final Path resDirFinal = resourceDirectory; - includedFiles = inputs.stream() - .map(input -> resDirFinal.relativize(input.getPath()).toString()) - .toList(); + changedInputs = new ArrayList<>(); + allFiles = new ArrayList<>(); + for (Input input : allInputs) { + allFiles.add(resDirFinal.relativize(input.getPath()).toString()); + if (input.getStatus() != Status.UNMODIFIED) { + changedInputs.add(input); + } + } } else { - // No build context — scan directory manually - includedFiles = scanDirectory( - resourceDirectory, includes, excludes, mavenResourcesExecution.isAddDefaultExcludes()); + // No build context — scan directory manually and process everything + changedInputs = null; + allFiles = scanDirectory(resourceDirectory, includes, excludes, false); } if (mavenResourcesExecution.isIncludeEmptyDirs()) { @@ -251,60 +261,117 @@ public void filterResources(MavenResourcesExecution mavenResourcesExecution) thr } } + // Determine which files to actually process + boolean incremental = changedInputs != null; + int totalCount = allFiles.size(); + int processCount = incremental ? changedInputs.size() : totalCount; + + // Log the processing summary try { Path basedir = mavenResourcesExecution.getMavenProject().getBasedir().toAbsolutePath(); Path destination = getDestinationFile(outputDirectory, targetPath, "", mavenResourcesExecution) .toAbsolutePath(); - LOGGER.info("Copying " + includedFiles.size() + " resource" + (includedFiles.size() > 1 ? "s" : "") - + " from " - + basedir.relativize(resourceDirectory.toAbsolutePath()) - + " to " - + basedir.relativize(destination)); + if (incremental && processCount < totalCount) { + LOGGER.info("Copying " + processCount + " of " + totalCount + " resource" + + (totalCount > 1 ? "s" : "") + " from " + + basedir.relativize(resourceDirectory.toAbsolutePath()) + " to " + + basedir.relativize(destination) + + " (" + (totalCount - processCount) + " unchanged)"); + } else { + LOGGER.info("Copying " + totalCount + " resource" + (totalCount > 1 ? "s" : "") + " from " + + basedir.relativize(resourceDirectory.toAbsolutePath()) + " to " + + basedir.relativize(destination)); + } } catch (Exception e) { // be foolproof: if for ANY reason throws, do not abort, just fall back to old message - LOGGER.info("Copying " + includedFiles.size() + " resource" + (includedFiles.size() > 1 ? "s" : "") + LOGGER.info("Copying " + processCount + " resource" + (processCount > 1 ? "s" : "") + (targetPath == null ? "" : " to " + targetPath)); } - for (String name : includedFiles) { + // Process only changed files (or all files when no BuildContext is available) + if (incremental) { + // Incremental mode: only copy/filter files with status NEW or MODIFIED, + // then register input→output associations for stale output cleanup + for (Input input : changedInputs) { + Path source = input.getPath(); + String name = resourceDirectory.relativize(source).toString(); + + Path destinationFile = + getDestinationFile(outputDirectory, targetPath, name, mavenResourcesExecution); + + if (mavenResourcesExecution.isFlatten() && Files.exists(destinationFile)) { + if (mavenResourcesExecution.isOverwrite()) { + LOGGER.warn("existing file " + destinationFile.getFileName() + " will be overwritten by " + + name); + } else { + throw new MavenFilteringException("existing file " + destinationFile.getFileName() + + " will be overwritten by " + name + " and overwrite was not set to true"); + } + } - LOGGER.debug("Copying file " + name); - Path source = resourceDirectory.resolve(name); + boolean filteredExt = filteredFileExtension( + source.getFileName().toString(), mavenResourcesExecution.getNonFilteredFileExtensions()); + if (resource.isFiltering() && isPropertiesFile(source)) { + propertiesFiles.add(source); + } - Path destinationFile = getDestinationFile(outputDirectory, targetPath, name, mavenResourcesExecution); + String encoding = getEncoding( + source, + mavenResourcesExecution.getEncoding(), + mavenResourcesExecution.getPropertiesEncoding()); + LOGGER.debug("Using '" + encoding + "' encoding to copy filtered resource '" + source.getFileName() + + "'."); + mavenFileFilter.copyFile( + source, + destinationFile, + resource.isFiltering() && filteredExt, + mavenResourcesExecution.getFilterWrappers(), + encoding); + + // Register the input→output association so the BuildContext can: + // 1. Track which outputs belong to which inputs + // 2. Automatically delete stale outputs when their input is removed + input.associateOutput(destinationFile); + } + } else { + // Non-incremental mode: process all files + for (String name : allFiles) { + LOGGER.debug("Copying file " + name); + Path source = resourceDirectory.resolve(name); + Path destinationFile = + getDestinationFile(outputDirectory, targetPath, name, mavenResourcesExecution); + + if (mavenResourcesExecution.isFlatten() && Files.exists(destinationFile)) { + if (mavenResourcesExecution.isOverwrite()) { + LOGGER.warn("existing file " + destinationFile.getFileName() + " will be overwritten by " + + name); + } else { + throw new MavenFilteringException("existing file " + destinationFile.getFileName() + + " will be overwritten by " + name + " and overwrite was not set to true"); + } + } - if (mavenResourcesExecution.isFlatten() && Files.exists(destinationFile)) { - if (mavenResourcesExecution.isOverwrite()) { - LOGGER.warn( - "existing file " + destinationFile.getFileName() + " will be overwritten by " + name); - } else { - throw new MavenFilteringException("existing file " + destinationFile.getFileName() - + " will be overwritten by " + name + " and overwrite was not set to true"); + boolean filteredExt = filteredFileExtension( + source.getFileName().toString(), mavenResourcesExecution.getNonFilteredFileExtensions()); + if (resource.isFiltering() && isPropertiesFile(source)) { + propertiesFiles.add(source); } - } - boolean filteredExt = filteredFileExtension( - source.getFileName().toString(), mavenResourcesExecution.getNonFilteredFileExtensions()); - if (resource.isFiltering() && isPropertiesFile(source)) { - propertiesFiles.add(source); - } - // Determine which encoding to use when filtering this file - String encoding = getEncoding( - source, mavenResourcesExecution.getEncoding(), mavenResourcesExecution.getPropertiesEncoding()); - LOGGER.debug( - "Using '" + encoding + "' encoding to copy filtered resource '" + source.getFileName() + "'."); - mavenFileFilter.copyFile( - source, - destinationFile, - resource.isFiltering() && filteredExt, - mavenResourcesExecution.getFilterWrappers(), - encoding); + String encoding = getEncoding( + source, + mavenResourcesExecution.getEncoding(), + mavenResourcesExecution.getPropertiesEncoding()); + LOGGER.debug("Using '" + encoding + "' encoding to copy filtered resource '" + source.getFileName() + + "'."); + mavenFileFilter.copyFile( + source, + destinationFile, + resource.isFiltering() && filteredExt, + mavenResourcesExecution.getFilterWrappers(), + encoding); + } } - - // Stale output cleanup for deleted inputs is handled automatically - // by the BuildContext when inputs have status REMOVED — no explicit - // delete scanner needed. } // Warn the user if all of the following requirements are met, to avoid those that are not affected @@ -426,10 +493,15 @@ private List scanDirectory( excludes = new ArrayList<>(excludes); addDefaultExcludes(excludes); } - // Walk directory and collect relative paths + List includeMatchers = toPathMatchers(baseDir, includes); + List excludeMatchers = toPathMatchers(baseDir, excludes); + + // Walk directory and collect relative paths that match includes and don't match excludes Files.walk(baseDir).filter(Files::isRegularFile).forEach(path -> { String relative = baseDir.relativize(path).toString().replace('\\', '/'); - result.add(relative); + if (matchesAny(relative, includeMatchers) && !matchesAny(relative, excludeMatchers)) { + result.add(relative); + } }); } catch (IOException e) { throw new MavenFilteringException("Failed to scan directory: " + baseDir, e); @@ -437,6 +509,80 @@ private List scanDirectory( return result; } + /** + * Converts Ant-style include/exclude patterns to Java {@link PathMatcher} instances. + *

+ * Ant-style patterns treat leading {@code ** /} as matching zero or more directories, + * but Java's glob {@code ** /} requires at least one directory segment. This method + * normalizes patterns so that both interpretations are handled correctly. + */ + private static List toPathMatchers(Path baseDir, List patterns) { + if (patterns == null || patterns.isEmpty()) { + return List.of(); + } + FileSystem fs = baseDir.getFileSystem(); + List matchers = new ArrayList<>(patterns.size()); + for (String pattern : patterns) { + // Normalize separators to forward slash for glob matching + String normalized = pattern.replace('\\', '/'); + // Ant-style "**/**" means "all files recursively" — normalize to "**" + if ("**/**".equals(normalized)) { + normalized = "**"; + } + // Escape glob metacharacters that Ant patterns treat as literals. + // Ant only uses *, ?, and ** as wildcards; characters like { } [ ] + // are literal in Ant but have special meaning in Java glob. + normalized = escapeGlobMetachars(normalized); + + matchers.add(fs.getPathMatcher("glob:" + normalized)); + // In Ant, leading "**/" matches zero or more directories, but Java's + // glob requires at least one segment for "**/". Add a second matcher + // without the leading "**/" to handle the zero-directory case. + if (normalized.startsWith("**/")) { + matchers.add(fs.getPathMatcher("glob:" + normalized.substring(3))); + } + } + return matchers; + } + + /** + * Escapes characters that have special meaning in Java's glob syntax but are + * treated as literals in Ant-style patterns. In glob, {@code [c]} matches the + * literal character {@code c}, so we wrap each special char in brackets. + */ + private static String escapeGlobMetachars(String pattern) { + StringBuilder sb = null; + for (int i = 0; i < pattern.length(); i++) { + char c = pattern.charAt(i); + if (c == '{' || c == '}' || c == '[' || c == ']') { + if (sb == null) { + sb = new StringBuilder(pattern.length() + 8); + sb.append(pattern, 0, i); + } + sb.append('[').append(c).append(']'); + } else if (sb != null) { + sb.append(c); + } + } + return sb != null ? sb.toString() : pattern; + } + + /** + * Returns true if the relative path matches any of the given matchers. + */ + private static boolean matchesAny(String relativePath, List matchers) { + if (matchers.isEmpty()) { + return false; + } + Path path = Paths.get(relativePath); + for (PathMatcher matcher : matchers) { + if (matcher.matches(path)) { + return true; + } + } + return false; + } + /** * Adds the standard SCM and IDE default excludes to the given list. */ From 0b07f29e3b085d478417605eae25068c459943fd Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Wed, 29 Jul 2026 14:22:12 +0200 Subject: [PATCH 3/5] Experiment: use PathMatcherFactory instead of hand-rolled glob conversion Replace custom Ant-to-glob pattern conversion (escapeGlobMetachars, toPathMatchers, matchesAny) with Maven 4's PathMatcherFactory service. PathSelector already handles all the nuances: - Escaping {, }, [, ] as literals - Leading **/ matching zero directories - **/** normalization - Comprehensive default SCM excludes Co-Authored-By: Claude Opus 4.6 --- .../DefaultMavenResourcesFiltering.java | 121 ++++-------------- 1 file changed, 28 insertions(+), 93 deletions(-) diff --git a/src/main/java/org/apache/maven/shared/filtering/DefaultMavenResourcesFiltering.java b/src/main/java/org/apache/maven/shared/filtering/DefaultMavenResourcesFiltering.java index 41a7b10d..821fe43c 100644 --- a/src/main/java/org/apache/maven/shared/filtering/DefaultMavenResourcesFiltering.java +++ b/src/main/java/org/apache/maven/shared/filtering/DefaultMavenResourcesFiltering.java @@ -23,7 +23,6 @@ import java.io.StringReader; import java.io.StringWriter; import java.nio.charset.Charset; -import java.nio.file.FileSystem; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.PathMatcher; @@ -40,6 +39,7 @@ import org.apache.maven.api.di.Inject; import org.apache.maven.api.di.Named; import org.apache.maven.api.di.Singleton; +import org.apache.maven.api.services.PathMatcherFactory; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -62,10 +62,16 @@ public class DefaultMavenResourcesFiltering implements MavenResourcesFiltering { private final BuildContext buildContext; + private final PathMatcherFactory pathMatcherFactory; + @Inject - public DefaultMavenResourcesFiltering(MavenFileFilter mavenFileFilter, @Nullable BuildContext buildContext) { + public DefaultMavenResourcesFiltering( + MavenFileFilter mavenFileFilter, + @Nullable BuildContext buildContext, + @Nullable PathMatcherFactory pathMatcherFactory) { this.mavenFileFilter = requireNonNull(mavenFileFilter); this.buildContext = buildContext; // null when running without incremental support (e.g. tests) + this.pathMatcherFactory = pathMatcherFactory; // null when running outside Maven 4 DI this.defaultNonFilteredFileExtensions = new ArrayList<>(5); this.defaultNonFilteredFileExtensions.add("jpg"); this.defaultNonFilteredFileExtensions.add("jpeg"); @@ -216,10 +222,7 @@ public void filterResources(MavenResourcesExecution mavenResourcesExecution) thr if (excludes == null) { excludes = List.of(); } - if (mavenResourcesExecution.isAddDefaultExcludes()) { - excludes = new ArrayList<>(excludes); - addDefaultExcludes(excludes); - } + boolean addDefaultExcludes = mavenResourcesExecution.isAddDefaultExcludes(); // Register inputs with the BuildContext and get per-file change status. // registerAndProcessInputs() returns ALL matching inputs (including UNMODIFIED) @@ -227,7 +230,13 @@ public void filterResources(MavenResourcesExecution mavenResourcesExecution) thr // For REMOVED inputs, the BuildContext automatically cleans up associated outputs. Collection allInputs; if (buildContext != null) { - allInputs = buildContext.registerAndProcessInputs(resourceDirectory, includes, excludes); + // BuildContext needs default excludes in the excludes list directly + List effectiveExcludes = excludes; + if (addDefaultExcludes) { + effectiveExcludes = new ArrayList<>(excludes); + addDefaultExcludes(effectiveExcludes); + } + allInputs = buildContext.registerAndProcessInputs(resourceDirectory, includes, effectiveExcludes); } else { allInputs = null; } @@ -246,9 +255,10 @@ public void filterResources(MavenResourcesExecution mavenResourcesExecution) thr } } } else { - // No build context — scan directory manually and process everything + // No build context — scan directory with PathMatcherFactory (handles + // Ant-style patterns and default excludes) and process everything changedInputs = null; - allFiles = scanDirectory(resourceDirectory, includes, excludes, false); + allFiles = scanDirectory(resourceDirectory, includes, excludes, addDefaultExcludes); } if (mavenResourcesExecution.isIncludeEmptyDirs()) { @@ -482,24 +492,23 @@ private Path getDestinationFile( /** * Scans a directory for files matching include/exclude patterns. - * Used as fallback when no BuildContext is available. + * Uses {@link PathMatcherFactory} when available (Maven 4 API — handles Ant-style + * patterns correctly), otherwise falls back to a simple unfiltered walk. */ private List scanDirectory( Path baseDir, List includes, List excludes, boolean addDefaultExcludes) throws MavenFilteringException { List result = new ArrayList<>(); try { - if (addDefaultExcludes) { - excludes = new ArrayList<>(excludes); - addDefaultExcludes(excludes); + PathMatcher matcher; + if (pathMatcherFactory != null) { + matcher = pathMatcherFactory.createPathMatcher(baseDir, includes, excludes, addDefaultExcludes); + } else { + matcher = null; // no filtering available — include everything } - List includeMatchers = toPathMatchers(baseDir, includes); - List excludeMatchers = toPathMatchers(baseDir, excludes); - - // Walk directory and collect relative paths that match includes and don't match excludes Files.walk(baseDir).filter(Files::isRegularFile).forEach(path -> { - String relative = baseDir.relativize(path).toString().replace('\\', '/'); - if (matchesAny(relative, includeMatchers) && !matchesAny(relative, excludeMatchers)) { + if (matcher == null || matcher.matches(path)) { + String relative = baseDir.relativize(path).toString().replace('\\', '/'); result.add(relative); } }); @@ -509,80 +518,6 @@ private List scanDirectory( return result; } - /** - * Converts Ant-style include/exclude patterns to Java {@link PathMatcher} instances. - *

- * Ant-style patterns treat leading {@code ** /} as matching zero or more directories, - * but Java's glob {@code ** /} requires at least one directory segment. This method - * normalizes patterns so that both interpretations are handled correctly. - */ - private static List toPathMatchers(Path baseDir, List patterns) { - if (patterns == null || patterns.isEmpty()) { - return List.of(); - } - FileSystem fs = baseDir.getFileSystem(); - List matchers = new ArrayList<>(patterns.size()); - for (String pattern : patterns) { - // Normalize separators to forward slash for glob matching - String normalized = pattern.replace('\\', '/'); - // Ant-style "**/**" means "all files recursively" — normalize to "**" - if ("**/**".equals(normalized)) { - normalized = "**"; - } - // Escape glob metacharacters that Ant patterns treat as literals. - // Ant only uses *, ?, and ** as wildcards; characters like { } [ ] - // are literal in Ant but have special meaning in Java glob. - normalized = escapeGlobMetachars(normalized); - - matchers.add(fs.getPathMatcher("glob:" + normalized)); - // In Ant, leading "**/" matches zero or more directories, but Java's - // glob requires at least one segment for "**/". Add a second matcher - // without the leading "**/" to handle the zero-directory case. - if (normalized.startsWith("**/")) { - matchers.add(fs.getPathMatcher("glob:" + normalized.substring(3))); - } - } - return matchers; - } - - /** - * Escapes characters that have special meaning in Java's glob syntax but are - * treated as literals in Ant-style patterns. In glob, {@code [c]} matches the - * literal character {@code c}, so we wrap each special char in brackets. - */ - private static String escapeGlobMetachars(String pattern) { - StringBuilder sb = null; - for (int i = 0; i < pattern.length(); i++) { - char c = pattern.charAt(i); - if (c == '{' || c == '}' || c == '[' || c == ']') { - if (sb == null) { - sb = new StringBuilder(pattern.length() + 8); - sb.append(pattern, 0, i); - } - sb.append('[').append(c).append(']'); - } else if (sb != null) { - sb.append(c); - } - } - return sb != null ? sb.toString() : pattern; - } - - /** - * Returns true if the relative path matches any of the given matchers. - */ - private static boolean matchesAny(String relativePath, List matchers) { - if (matchers.isEmpty()) { - return false; - } - Path path = Paths.get(relativePath); - for (PathMatcher matcher : matchers) { - if (matcher.matches(path)) { - return true; - } - } - return false; - } - /** * Adds the standard SCM and IDE default excludes to the given list. */ From a6a9ec385cbcf36cea30fd86ed4e46138670bbb2 Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Wed, 29 Jul 2026 15:20:42 +0200 Subject: [PATCH 4/5] Experiment: add incremental build context tests and fix edge cases - Add IncrementalResourceFilteringTest (9 tests) exercising the Maven 4 BuildContext incremental code path: initial build, no-change skip, modified input re-processing, stale output cleanup, include/exclude patterns, new file detection, filtering, and subdirectory structure. - Fix path normalization: normalize resourceDirectory to absolute path after existence check so relativize() works correctly with BuildContext's canonicalized paths. - Fix flatten + associateOutput: skip associateOutput() when flattening since multiple inputs can map to the same output, violating the BuildContext's one-input-to-one-output constraint. - Update Providers.java: provide real BuildContext and PathMatcherFactory for test DI with @Priority(10) to override auto-discovered MavenBuildContext from maven-core. - Remove stale plexusBuildApiVersion property from pom.xml. Co-Authored-By: Claude Opus 4.6 --- pom.xml | 1 - .../DefaultMavenResourcesFiltering.java | 10 +- .../IncrementalResourceFilteringTest.java | 384 ++++++++++++++++++ .../maven/shared/filtering/Providers.java | 39 +- 4 files changed, 429 insertions(+), 5 deletions(-) create mode 100644 src/test/java/org/apache/maven/shared/filtering/IncrementalResourceFilteringTest.java diff --git a/pom.xml b/pom.xml index 622ec54b..44fdf60f 100644 --- a/pom.xml +++ b/pom.xml @@ -68,7 +68,6 @@ 5.14.4 5.23.0 4.0.0-beta-4 - 0.0.7 1.29 2.0.13 diff --git a/src/main/java/org/apache/maven/shared/filtering/DefaultMavenResourcesFiltering.java b/src/main/java/org/apache/maven/shared/filtering/DefaultMavenResourcesFiltering.java index 821fe43c..31429dff 100644 --- a/src/main/java/org/apache/maven/shared/filtering/DefaultMavenResourcesFiltering.java +++ b/src/main/java/org/apache/maven/shared/filtering/DefaultMavenResourcesFiltering.java @@ -196,6 +196,10 @@ public void filterResources(MavenResourcesExecution mavenResourcesExecution) thr continue; } + // Normalize to absolute path for consistent path operations + // (BuildContext canonicalizes paths, so relativize() would fail on mixed relative/absolute) + resourceDirectory = resourceDirectory.toAbsolutePath().normalize(); + // this part is required in case the user specified "../something" // as destination // see MNG-1345 @@ -342,7 +346,11 @@ public void filterResources(MavenResourcesExecution mavenResourcesExecution) thr // Register the input→output association so the BuildContext can: // 1. Track which outputs belong to which inputs // 2. Automatically delete stale outputs when their input is removed - input.associateOutput(destinationFile); + // Skip when flattening: multiple inputs may map to the same output file, + // which violates the BuildContext's one-input-to-one-output constraint. + if (!mavenResourcesExecution.isFlatten()) { + input.associateOutput(destinationFile); + } } } else { // Non-incremental mode: process all files diff --git a/src/test/java/org/apache/maven/shared/filtering/IncrementalResourceFilteringTest.java b/src/test/java/org/apache/maven/shared/filtering/IncrementalResourceFilteringTest.java new file mode 100644 index 00000000..3311c902 --- /dev/null +++ b/src/test/java/org/apache/maven/shared/filtering/IncrementalResourceFilteringTest.java @@ -0,0 +1,384 @@ +/* + * 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.shared.filtering; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Properties; + +import org.apache.maven.api.di.Inject; +import org.apache.maven.api.di.testing.MavenDITest; +import org.apache.maven.api.plugin.testing.stubs.ProjectStub; +import org.apache.maven.api.services.PathMatcherFactory; +import org.apache.maven.di.Injector; +import org.apache.maven.impl.DefaultPathMatcherFactory; +import org.apache.maven.internal.build.context.impl.DefaultBuildContext; +import org.apache.maven.internal.build.context.impl.FilesystemWorkspace; +import org.junit.jupiter.api.BeforeEach; +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.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Tests incremental resource filtering using the Maven 4 BuildContext API. + * + *

These tests exercise the incremental code path in {@link DefaultMavenResourcesFiltering} + * by wiring in a real {@link DefaultBuildContext} (backed by a {@link FilesystemWorkspace}). + * This verifies: + *

    + *
  • Initial build processes all files
  • + *
  • Rebuild with no changes processes nothing
  • + *
  • Modified input is re-processed
  • + *
  • Deleted input causes stale output cleanup
  • + *
  • Include/exclude patterns are respected
  • + *
+ */ +@MavenDITest +class IncrementalResourceFilteringTest { + + @Inject + Injector container; + + @TempDir + Path tempDir; + + private Path sourceDir; + private Path outputDir; + private Path stateFile; + private ProjectStub mavenProject; + private PathMatcherFactory pathMatcherFactory; + + @BeforeEach + void setUp() throws Exception { + sourceDir = tempDir.resolve("src"); + outputDir = tempDir.resolve("output"); + stateFile = tempDir.resolve("buildstate.ctx"); + Files.createDirectories(sourceDir); + Files.createDirectories(outputDir); + + mavenProject = new ProjectStub().setBasedir(tempDir); + mavenProject.setVersion("1.0"); + mavenProject.setGroupId("org.apache"); + mavenProject.setName("test project"); + + pathMatcherFactory = new DefaultPathMatcherFactory(); + } + + @Test + void initialBuildProcessesAllFiles() throws Exception { + // Create two source files + writeProperties(sourceDir.resolve("file01.txt"), "time", "initial"); + writeProperties(sourceDir.resolve("file02.txt"), "time", "initial"); + + // First build — all files should be processed + DefaultBuildContext ctx = newBuildContext(); + filterResources(ctx); + ctx.commit(null); + + // Both outputs should exist with correct content + assertPropertyValue("initial", outputDir.resolve("file01.txt"), "time"); + assertPropertyValue("initial", outputDir.resolve("file02.txt"), "time"); + } + + @Test + void noChangeRebuildSkipsProcessing() throws Exception { + // Create source files + writeProperties(sourceDir.resolve("file01.txt"), "time", "initial"); + writeProperties(sourceDir.resolve("file02.txt"), "time", "initial"); + + // Initial build + DefaultBuildContext ctx1 = newBuildContext(); + filterResources(ctx1); + ctx1.commit(null); + + // Record output modification times + long mtime1 = Files.getLastModifiedTime(outputDir.resolve("file01.txt")).toMillis(); + long mtime2 = Files.getLastModifiedTime(outputDir.resolve("file02.txt")).toMillis(); + + // Small delay to ensure mtime would differ if files were rewritten + Thread.sleep(100); + + // Rebuild with no changes — outputs should NOT be rewritten + DefaultBuildContext ctx2 = newBuildContext(); + filterResources(ctx2); + ctx2.commit(null); + + // Outputs should still exist but NOT have been rewritten + assertTrue(Files.exists(outputDir.resolve("file01.txt"))); + assertTrue(Files.exists(outputDir.resolve("file02.txt"))); + assertEquals( + mtime1, + Files.getLastModifiedTime(outputDir.resolve("file01.txt")).toMillis(), + "file01.txt should not have been rewritten"); + assertEquals( + mtime2, + Files.getLastModifiedTime(outputDir.resolve("file02.txt")).toMillis(), + "file02.txt should not have been rewritten"); + } + + @Test + void modifiedInputIsReprocessed() throws Exception { + // Create source files + writeProperties(sourceDir.resolve("file01.txt"), "time", "initial"); + writeProperties(sourceDir.resolve("file02.txt"), "time", "initial"); + + // Initial build + DefaultBuildContext ctx1 = newBuildContext(); + filterResources(ctx1); + ctx1.commit(null); + + assertPropertyValue("initial", outputDir.resolve("file01.txt"), "time"); + assertPropertyValue("initial", outputDir.resolve("file02.txt"), "time"); + + // Modify only file01 + Thread.sleep(100); // ensure different mtime + writeProperties(sourceDir.resolve("file01.txt"), "time", "modified"); + + // Rebuild — only file01 should be re-processed + DefaultBuildContext ctx2 = newBuildContext(); + filterResources(ctx2); + ctx2.commit(null); + + assertPropertyValue("modified", outputDir.resolve("file01.txt"), "time"); + assertPropertyValue("initial", outputDir.resolve("file02.txt"), "time"); + } + + @Test + void deletedInputCausesStaleOutputCleanup() throws Exception { + // Create source files + writeProperties(sourceDir.resolve("file01.txt"), "time", "initial"); + writeProperties(sourceDir.resolve("file02.txt"), "time", "initial"); + + // Initial build + DefaultBuildContext ctx1 = newBuildContext(); + filterResources(ctx1); + ctx1.commit(null); + + assertTrue(Files.exists(outputDir.resolve("file01.txt"))); + assertTrue(Files.exists(outputDir.resolve("file02.txt"))); + + // Delete file01 from source + Files.delete(sourceDir.resolve("file01.txt")); + + // Rebuild — file01's output should be cleaned up + DefaultBuildContext ctx2 = newBuildContext(); + filterResources(ctx2); + ctx2.commit(null); + + assertFalse(Files.exists(outputDir.resolve("file01.txt")), "Stale output should be deleted"); + assertTrue(Files.exists(outputDir.resolve("file02.txt")), "Remaining output should still exist"); + assertPropertyValue("initial", outputDir.resolve("file02.txt"), "time"); + } + + @Test + void includeExcludePatternsRespected() throws Exception { + // Create source files with different extensions + writeProperties(sourceDir.resolve("included.txt"), "key", "included"); + writeProperties(sourceDir.resolve("excluded.xml"), "key", "excluded"); + writeProperties(sourceDir.resolve("also-included.txt"), "key", "also"); + + // Build with include pattern for .txt only + DefaultBuildContext ctx = newBuildContext(); + + Resource resource = new Resource(); + resource.setDirectory(sourceDir.toString()); + resource.setFiltering(false); + resource.setIncludes(List.of("**/*.txt")); + + MavenResourcesExecution mre = createExecution(resource); + MavenResourcesFiltering filtering = createFilteringWithBuildContext(ctx); + filtering.filterResources(mre); + ctx.commit(null); + + assertTrue(Files.exists(outputDir.resolve("included.txt"))); + assertTrue(Files.exists(outputDir.resolve("also-included.txt"))); + assertFalse(Files.exists(outputDir.resolve("excluded.xml")), ".xml should be excluded by pattern"); + } + + @Test + void excludePatternsRespected() throws Exception { + // Create source files + writeProperties(sourceDir.resolve("keep.txt"), "key", "kept"); + Files.createDirectories(sourceDir.resolve(".git")); + writeProperties(sourceDir.resolve(".git/config"), "key", "scm"); + writeProperties(sourceDir.resolve("skip.log"), "key", "skipped"); + + // Build with exclude pattern + DefaultBuildContext ctx = newBuildContext(); + + Resource resource = new Resource(); + resource.setDirectory(sourceDir.toString()); + resource.setFiltering(false); + resource.setExcludes(List.of("**/*.log")); + + MavenResourcesExecution mre = createExecution(resource); + mre.setAddDefaultExcludes(true); + MavenResourcesFiltering filtering = createFilteringWithBuildContext(ctx); + filtering.filterResources(mre); + ctx.commit(null); + + assertTrue(Files.exists(outputDir.resolve("keep.txt"))); + assertFalse(Files.exists(outputDir.resolve("skip.log")), ".log should be excluded"); + assertFalse(Files.exists(outputDir.resolve(".git/config")), ".git should be excluded by default"); + } + + @Test + void newFileAddedIncrementally() throws Exception { + // Start with one file + writeProperties(sourceDir.resolve("file01.txt"), "time", "initial"); + + // Initial build + DefaultBuildContext ctx1 = newBuildContext(); + filterResources(ctx1); + ctx1.commit(null); + + assertTrue(Files.exists(outputDir.resolve("file01.txt"))); + assertFalse(Files.exists(outputDir.resolve("file02.txt"))); + + // Add a new file + writeProperties(sourceDir.resolve("file02.txt"), "time", "new"); + + // Rebuild — new file should be picked up + DefaultBuildContext ctx2 = newBuildContext(); + filterResources(ctx2); + ctx2.commit(null); + + assertTrue(Files.exists(outputDir.resolve("file01.txt"))); + assertTrue(Files.exists(outputDir.resolve("file02.txt"))); + assertPropertyValue("new", outputDir.resolve("file02.txt"), "time"); + } + + @Test + void filteringAppliedIncrementally() throws Exception { + // Create source file with property placeholder + writeProperties(sourceDir.resolve("app.properties"), "version", "${project.version}"); + + mavenProject.addProperty("project.version", "1.0"); + + // Initial build with filtering enabled + DefaultBuildContext ctx1 = newBuildContext(); + Resource resource = new Resource(); + resource.setDirectory(sourceDir.toString()); + resource.setFiltering(true); + + MavenResourcesExecution mre = createExecution(resource); + mre.setUseDefaultFilterWrappers(true); + MavenResourcesFiltering filtering = createFilteringWithBuildContext(ctx1); + filtering.filterResources(mre); + ctx1.commit(null); + + assertPropertyValue("1.0", outputDir.resolve("app.properties"), "version"); + + // Modify the source + Thread.sleep(100); + writeProperties(sourceDir.resolve("app.properties"), "version", "${project.version}-SNAPSHOT"); + + // Rebuild — should re-filter with the property + DefaultBuildContext ctx2 = newBuildContext(); + mre = createExecution(resource); + mre.setUseDefaultFilterWrappers(true); + filtering = createFilteringWithBuildContext(ctx2); + filtering.filterResources(mre); + ctx2.commit(null); + + assertPropertyValue("1.0-SNAPSHOT", outputDir.resolve("app.properties"), "version"); + } + + @Test + void subdirectoryStructurePreserved() throws Exception { + // Create nested source files + Files.createDirectories(sourceDir.resolve("sub/deep")); + writeProperties(sourceDir.resolve("root.txt"), "key", "root"); + writeProperties(sourceDir.resolve("sub/nested.txt"), "key", "nested"); + writeProperties(sourceDir.resolve("sub/deep/deep.txt"), "key", "deep"); + + // Build + DefaultBuildContext ctx = newBuildContext(); + filterResources(ctx); + ctx.commit(null); + + assertTrue(Files.exists(outputDir.resolve("root.txt"))); + assertTrue(Files.exists(outputDir.resolve("sub/nested.txt"))); + assertTrue(Files.exists(outputDir.resolve("sub/deep/deep.txt"))); + assertPropertyValue("deep", outputDir.resolve("sub/deep/deep.txt"), "key"); + } + + // --- Helpers --- + + private DefaultBuildContext newBuildContext() { + return new DefaultBuildContext(new FilesystemWorkspace(), stateFile, new HashMap<>(), null, pathMatcherFactory); + } + + private void filterResources(DefaultBuildContext ctx) throws MavenFilteringException { + Resource resource = new Resource(); + resource.setDirectory(sourceDir.toString()); + resource.setFiltering(false); + + MavenResourcesExecution mre = createExecution(resource); + MavenResourcesFiltering filtering = createFilteringWithBuildContext(ctx); + filtering.filterResources(mre); + } + + private MavenResourcesExecution createExecution(Resource resource) { + List resources = new ArrayList<>(); + resources.add(resource); + + MavenResourcesExecution mre = new MavenResourcesExecution(); + mre.setResources(resources); + mre.setOutputDirectory(outputDir); + mre.setEncoding("UTF-8"); + mre.setMavenProject(mavenProject); + mre.setFilters(Collections.emptyList()); + mre.setNonFilteredFileExtensions(Collections.emptyList()); + mre.setMavenSession(new StubSession()); + return mre; + } + + private MavenResourcesFiltering createFilteringWithBuildContext(DefaultBuildContext ctx) { + MavenFileFilter fileFilter = container.getInstance(MavenFileFilter.class); + return new DefaultMavenResourcesFiltering(fileFilter, ctx, pathMatcherFactory); + } + + private static void writeProperties(Path file, String key, String value) throws IOException { + Files.createDirectories(file.getParent()); + Properties props = new Properties(); + props.setProperty(key, value); + try (var out = Files.newOutputStream(file)) { + props.store(out, null); + } + } + + private static void assertPropertyValue(String expected, Path file, String key) throws IOException { + assertTrue(Files.exists(file), "File should exist: " + file); + Properties props = new Properties(); + try (InputStream is = Files.newInputStream(file)) { + props.load(is); + } + assertEquals(expected, props.getProperty(key), "Property '" + key + "' in " + file.getFileName()); + } +} diff --git a/src/test/java/org/apache/maven/shared/filtering/Providers.java b/src/test/java/org/apache/maven/shared/filtering/Providers.java index 0137b4c5..cf8565a9 100644 --- a/src/test/java/org/apache/maven/shared/filtering/Providers.java +++ b/src/test/java/org/apache/maven/shared/filtering/Providers.java @@ -18,11 +18,44 @@ */ package org.apache.maven.shared.filtering; +import java.util.HashMap; + +import org.apache.maven.api.build.context.BuildContext; import org.apache.maven.api.di.Named; +import org.apache.maven.api.di.Priority; +import org.apache.maven.api.di.Provides; +import org.apache.maven.api.services.PathMatcherFactory; +import org.apache.maven.impl.DefaultPathMatcherFactory; +import org.apache.maven.internal.build.context.impl.DefaultBuildContext; +import org.apache.maven.internal.build.context.impl.FilesystemWorkspace; +/** + * DI provider for test-scoped bindings. + * + *

Provides a real {@link BuildContext} backed by a {@link FilesystemWorkspace} so that + * tests running with {@code @MavenDITest} can inject {@link DefaultMavenResourcesFiltering} + * without requiring the full Maven runtime (which needs mojo execution scope infrastructure). + * The BuildContext created here has no state file, so it treats every build as a full build — + * all files are reported as {@code NEW}.

+ * + *

The {@code @Priority(10)} annotation ensures this binding wins over the auto-discovered + * {@code MavenBuildContext} from maven-core on the test classpath.

+ */ @Named class Providers { - // Old plexus BuildContext provider removed — maven-filtering now uses - // the new Maven 4 BuildContext API from maven-api-core directly. - // The BuildContext is provided by the Maven core DI container. + + @Provides + @Priority(10) + PathMatcherFactory pathMatcherFactory() { + return new DefaultPathMatcherFactory(); + } + + @Provides + @Priority(10) + BuildContext buildContext() { + // No state file (null) → every invocation is a full build (all files are NEW). + // This matches the behavior expected by the existing non-incremental tests. + return new DefaultBuildContext( + new FilesystemWorkspace(), null, new HashMap<>(), null, new DefaultPathMatcherFactory()); + } } From fb1dcc17f1a343f2939de54979c3655d229aea69 Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Wed, 29 Jul 2026 15:57:04 +0200 Subject: [PATCH 5/5] Experiment: remove stale plexus-build-api comment from pom.xml The dependency was already removed; this just cleans up the leftover comment marker. Co-Authored-By: Claude Opus 4.6 --- pom.xml | 1 - 1 file changed, 1 deletion(-) diff --git a/pom.xml b/pom.xml index 44fdf60f..79d7883c 100644 --- a/pom.xml +++ b/pom.xml @@ -108,7 +108,6 @@ slf4j-api ${slf4jVersion}
- org.codehaus.plexus plexus-utils