From d856d023faeac2f5724be21cbe375233cef5900e Mon Sep 17 00:00:00 2001
From: Martin Desruisseaux
Date: Tue, 8 Jul 2025 18:54:10 +0200
Subject: [PATCH 1/2] Copy the `PathSelector` class from Maven core with
removal of unused features. It allows the include/exclude patterns to
reproduce the behavior of Maven 3 when no syntax is specified. Before this
commit the default syntax was glob, which is not identical to Maven 3
behavior.
A future version should use the `PathSelector` from maven-impl directly.
But it may require to move that class in another module.
---
.../plugin/compiler/AbstractCompilerMojo.java | 6 +-
.../maven/plugin/compiler/PathFilter.java | 219 +++----
.../maven/plugin/compiler/PathSelector.java | 541 ++++++++++++++++++
.../plugin/compiler/SourceDirectory.java | 17 +-
4 files changed, 618 insertions(+), 165 deletions(-)
create mode 100644 src/main/java/org/apache/maven/plugin/compiler/PathSelector.java
diff --git a/src/main/java/org/apache/maven/plugin/compiler/AbstractCompilerMojo.java b/src/main/java/org/apache/maven/plugin/compiler/AbstractCompilerMojo.java
index d2792ba6b..893f1217a 100644
--- a/src/main/java/org/apache/maven/plugin/compiler/AbstractCompilerMojo.java
+++ b/src/main/java/org/apache/maven/plugin/compiler/AbstractCompilerMojo.java
@@ -930,14 +930,16 @@ protected AbstractCompilerMojo(PathScope compileScope) {
/**
* {@return the inclusion filters for the compiler, or an empty list for all Java source files}.
* The filter patterns are described in {@link java.nio.file.FileSystem#getPathMatcher(String)}.
- * If no syntax is specified, the default syntax is "glob".
+ * If no syntax is specified, the default syntax is a derivative of "glob" compatible with the
+ * behavior of Maven 3.
*/
protected abstract Set getIncludes();
/**
* {@return the exclusion filters for the compiler, or an empty list if none}.
* The filter patterns are described in {@link java.nio.file.FileSystem#getPathMatcher(String)}.
- * If no syntax is specified, the default syntax is "glob".
+ * If no syntax is specified, the default syntax is a derivative of "glob" compatible with the
+ * behavior of Maven 3.
*/
protected abstract Set getExcludes();
diff --git a/src/main/java/org/apache/maven/plugin/compiler/PathFilter.java b/src/main/java/org/apache/maven/plugin/compiler/PathFilter.java
index cb2b437b2..0c369db5b 100644
--- a/src/main/java/org/apache/maven/plugin/compiler/PathFilter.java
+++ b/src/main/java/org/apache/maven/plugin/compiler/PathFilter.java
@@ -28,18 +28,18 @@
import java.nio.file.PathMatcher;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.attribute.BasicFileAttributes;
-import java.nio.file.attribute.DosFileAttributes;
import java.util.ArrayList;
+import java.util.Arrays;
import java.util.Collection;
import java.util.EnumSet;
import java.util.List;
-import java.util.function.Predicate;
/**
* Applies inclusion and exclusion filters on paths, and builds a list of files in a directory tree.
* The set of allowed syntax contains at least "glob" and "regex".
* See {@link FileSystem#getPathMatcher(String)} Javadoc for a description of the "glob" syntax.
- * If no syntax is specified, then the default syntax is "glob".
+ * If no syntax is specified, then the default syntax is a derivative of the "glob" syntax which
+ * reproduces the behavior of Maven 3.
*
*
The list of files to process is built by applying the path matcher on each regular (non directory) files.
* The walk in file trees has the following characteristics:
@@ -54,18 +54,19 @@
*
* @author Martin Desruisseaux
*/
-final class PathFilter extends SimpleFileVisitor implements Predicate {
+final class PathFilter extends SimpleFileVisitor {
/**
* Whether to use the default include pattern.
* The pattern depends on the type of source file.
+ *
+ * @see javax.tools.JavaFileObject.Kind#extension
*/
private final boolean useDefaultInclude;
/**
* Inclusion filters for the files in the directories to walk as specified in the plugin configuration.
- * The array should contain at least one element, unless {@link SourceDirectory#includes} is non-empty.
- * If {@link #useDefaultInclude} is {@code true}, then this array length shall be exactly 1 and the
- * single element is overwritten for each directory to walk.
+ * The array should contain at least one element. If {@link #useDefaultInclude} is {@code true}, then
+ * this array length shall be exactly 1 and the single element is overwritten for each directory to walk.
*
* @see SourceDirectory#includes
*/
@@ -79,45 +80,22 @@ final class PathFilter extends SimpleFileVisitor implements Predicate incrementalExcludes;
/**
* The matchers for exclusion filters for incremental build calculation.
- * The array length shall be equal to the {@link #incrementalExcludes} array length.
- * The array is initially null and created when first needed, then when the file system changes.
+ * May be an instance of {@link PathSelector}, or {@code null} if none.
*/
- private PathMatcher[] incrementalExcludeMatchers;
-
- /**
- * Whether paths must be relativized before to be given to a matcher. If {@code true} (the default),
- * then every paths will be made relative to the source root directory for allowing patterns like
- * {@code "foo/bar/*.java"} to work. As a slight optimization, we can skip this step if all patterns
- * start with {@code "**"}.
- */
- private boolean needRelativize;
-
- /**
- * The file system of the path matchers, or {@code null} if not yet determined.
- * This is used in case not all paths are on the same file system.
- */
- private FileSystem fs;
+ private PathMatcher incrementalExcludeMatchers;
/**
* The result of listing all files, or {@code null} if no walking is in progress.
@@ -146,114 +124,25 @@ final class PathFilter extends SimpleFileVisitor implements PredicateThis method should be invoked only once, unless different paths are on different file systems.
- *
- * @param forDirectory the matchers declared in the {@code } element for the current {@link #sourceRoot}
- * @param patterns the matterns declared in the compiler plugin configuration
- * @param hasDefault whether the first element of {@code patterns} is the default pattern
- * @param fs the file system
- * @return all matchers from the source, followed by matchers from the given patterns
- */
- private static PathMatcher[] createMatchers(
- List forDirectory, String[] patterns, boolean hasDefault, FileSystem fs) {
- final int base = forDirectory.size();
- final int skip = (hasDefault && base != 0) ? 1 : 0;
- final var target = forDirectory.toArray(new PathMatcher[base + patterns.length - skip]);
- for (int i = skip; i < patterns.length; i++) {
- String pattern = patterns[i];
- if (pattern.indexOf(':') < 0) {
- pattern = "glob:" + pattern;
- }
- target[base + i] = fs.getPathMatcher(pattern);
- }
- return target;
- }
-
- /**
- * Tests whether the given path should be included according the include/exclude patterns.
- * This method does not perform any I/O operation. For example, it does not check if the file is hidden.
- *
- * @param path the source file to test
- * @return whether the given source file should be included
- */
- @Override
- public boolean test(Path path) {
- @SuppressWarnings("LocalVariableHidesMemberVariable")
- final SourceDirectory sourceRoot = this.sourceRoot; // Protect from changes.
- FileSystem pfs = path.getFileSystem();
- if (pfs != fs) {
- if (useDefaultInclude) {
- includes[0] = "glob:**" + sourceRoot.fileKind.extension;
- }
- includeMatchers = createMatchers(sourceRoot.includes, includes, useDefaultInclude, pfs);
- excludeMatchers = createMatchers(sourceRoot.excludes, excludes, false, pfs);
- incrementalExcludeMatchers = createMatchers(List.of(), incrementalExcludes, false, pfs);
- needRelativize = !(sourceRoot.includes.isEmpty() && sourceRoot.excludes.isEmpty())
- || needRelativize(includes)
- || needRelativize(excludes);
- fs = pfs;
- }
- if (needRelativize) {
- path = sourceRoot.root.relativize(path);
- }
- for (PathMatcher include : includeMatchers) {
- if (include.matches(path)) {
- for (PathMatcher exclude : excludeMatchers) {
- if (exclude.matches(path)) {
- return false;
- }
- }
- return true;
- }
- }
- return false;
+ incrementalExcludes = mojo.getIncrementalExcludes();
}
/**
- * {@return whether to ignore the given file for incremental build calculation}.
- * This method shall be invoked only after {@link #test(Path)} for the same file,
- * because it depends on matcher updates performed by the {@code test} method.
- */
- private boolean ignoreModification(Path path) {
- for (PathMatcher exclude : incrementalExcludeMatchers) {
- if (exclude.matches(path)) {
- return true;
- }
- }
- return false;
- }
-
- /**
- * Invoked for a file in a directory. If the given file is not hidden and pass the include/exclude filters,
+ * Invoked for a file in a directory. If the given file passes the include/exclude filters,
* then it is added to the list of source files.
+ *
+ * @param file the source file to test
+ * @param attrs the file basic attributes
+ * @return {@link FileVisitResult#CONTINUE}
*/
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
- if (!isHidden(file, attrs) && test(file)) {
- sourceFiles.add(new SourceFile(sourceRoot, file, attrs, ignoreModification(file)));
+ if (matchers.matches(file)) {
+ sourceFiles.add(new SourceFile(
+ sourceRoot,
+ file,
+ attrs,
+ (incrementalExcludeMatchers != null) && incrementalExcludeMatchers.matches(file)));
}
return FileVisitResult.CONTINUE;
}
@@ -264,25 +153,13 @@ public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IO
*/
@Override
public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) throws IOException {
- return isHidden(dir, attrs) ? FileVisitResult.SKIP_SUBTREE : FileVisitResult.CONTINUE;
- }
-
- /**
- * {@return whether the given file is hidden}. This method is used instead of {@link Files#isHidden(Path)}
- * because it opportunistically uses the available attributes instead of making another access to the file system.
- */
- private static boolean isHidden(Path file, BasicFileAttributes attrs) {
- if (attrs instanceof DosFileAttributes dos) {
- return dos.isHidden();
- } else {
- return file.getFileName().toString().startsWith(".");
- }
+ return Files.isHidden(dir) ? FileVisitResult.SKIP_SUBTREE : FileVisitResult.CONTINUE;
}
/**
* {@return all source files found in the given root directories}.
* The include and exclude filters specified at construction time are applied.
- * Hidden files and directories are ignored, and symbolic links are followed.
+ * Hidden directories are ignored, and symbolic links are followed.
*
* @param rootDirectories the root directories to scan
* @throws IOException if a root directory cannot be walked
@@ -292,16 +169,50 @@ public List walkSourceFiles(Iterable rootDirectorie
try {
sourceFiles = result;
for (SourceDirectory directory : rootDirectories) {
+ if (!incrementalExcludes.isEmpty()) {
+ incrementalExcludeMatchers = new PathSelector(directory.root, incrementalExcludes, null);
+ }
+ String[] includesOrDefault = includes;
+ if (useDefaultInclude) {
+ if (directory.includes.isEmpty()) {
+ includesOrDefault[0] = "glob:**" + directory.fileKind.extension;
+ } else {
+ includesOrDefault = null;
+ }
+ }
sourceRoot = directory;
+ matchers = new PathSelector(
+ directory.root,
+ concat(directory.includes, includesOrDefault),
+ concat(directory.excludes, excludes))
+ .simplify();
Files.walkFileTree(directory.root, EnumSet.of(FileVisitOption.FOLLOW_LINKS), Integer.MAX_VALUE, this);
- fs = null; // Will force a recalculation of matchers in next iteration.
}
} catch (UncheckedIOException e) {
throw e.getCause();
} finally {
sourceRoot = null;
sourceFiles = null;
+ matchers = null;
}
return result;
}
+
+ /**
+ * Returns the concatenation of patterns specified in the source with the patterns specified in the plugin.
+ * As a side-effect, this method set the {@link #needRelativize} flag to {@code true} if at least one pattern
+ * does not start with {@code "**"}. The latter is a slight optimization for avoiding the need to relativize
+ * each path before to give it to a matcher when this relativization is not necessary.
+ *
+ * @param source the patterns specified in the {@code } element
+ * @param plugin the patterns specified in the {@code } element, or null if none
+ */
+ private static List concat(List source, String[] plugin) {
+ if (plugin == null || plugin.length == 0) {
+ return source;
+ }
+ var patterns = new ArrayList(source);
+ patterns.addAll(Arrays.asList(plugin));
+ return patterns;
+ }
}
diff --git a/src/main/java/org/apache/maven/plugin/compiler/PathSelector.java b/src/main/java/org/apache/maven/plugin/compiler/PathSelector.java
new file mode 100644
index 000000000..a42706612
--- /dev/null
+++ b/src/main/java/org/apache/maven/plugin/compiler/PathSelector.java
@@ -0,0 +1,541 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.maven.plugin.compiler;
+
+import java.io.File;
+import java.nio.file.FileSystem;
+import java.nio.file.Path;
+import java.nio.file.PathMatcher;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Iterator;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Objects;
+import java.util.Set;
+
+/**
+ * Determines whether a path is selected according to include/exclude patterns.
+ * The pathnames used for method parameters will be relative to some base directory
+ * and use {@code '/'} as separator, regardless of the hosting operating system.
+ *
+ *
Syntax
+ * If a pattern contains the {@code ':'} character and the prefix before is longer than 1 character,
+ * then that pattern is given verbatim to {@link FileSystem#getPathMatcher(String)}, which interprets
+ * the part before {@code ':'} as the syntax (usually {@code "glob"} or {@code "regex"}).
+ * If a pattern does not contain the {@code ':'} character, or if the prefix is one character long
+ * (interpreted as a Windows drive), then the syntax defaults to a reproduction of the Maven 3 behavior.
+ * This is implemented as the {@code "glob"} syntax with the following modifications:
+ *
+ *
+ *
The platform-specific separator ({@code '\\'} on Windows) is replaced by {@code '/'}.
+ * Note that it means that the backslash cannot be used for escaping characters.
+ *
Trailing {@code "/"} is completed as {@code "/**"}.
+ *
The {@code "**"} wildcard means "0 or more directories" instead of "1 or more directories".
+ * This is implemented by adding variants of the pattern without the {@code "**"} wildcard.
+ *
Bracket characters [ ] and { } are escaped.
+ *
On Unix only, the escape character {@code '\\'} is itself escaped.
+ *
+ *
+ * If above changes are not desired, put an explicit {@code "glob:"} prefix before the pattern.
+ * Note that putting such a prefix is recommended anyway for better performances.
+ *
+ * @author Benjamin Bentmann
+ * @author Martin Desruisseaux
+ *
+ * @see java.nio.file.FileSystem#getPathMatcher(String)
+ */
+final class PathSelector implements PathMatcher {
+ /**
+ * Maximum number of characters of the prefix before {@code ':'} for handling as a Maven syntax.
+ */
+ private static final int MAVEN_SYNTAX_THRESHOLD = 1;
+
+ /**
+ * The default syntax to use if none was specified. Note that when this default syntax is applied,
+ * the user-provided pattern get some changes as documented in class Javadoc.
+ */
+ private static final String DEFAULT_SYNTAX = "glob:";
+
+ /**
+ * Characters having a special meaning in the glob syntax.
+ *
+ * @see FileSystem#getPathMatcher(String)
+ */
+ private static final String SPECIAL_CHARACTERS = "*?[]{}\\";
+
+ /**
+ * A path matcher which accepts all files.
+ *
+ * @see #simplify()
+ */
+ private static final PathMatcher INCLUDES_ALL = (path) -> true;
+
+ /**
+ * String representations of the normalized include filters.
+ * Each pattern shall be prefixed by its syntax, which is {@value #DEFAULT_SYNTAX} by default.
+ * An empty array means to include all files.
+ *
+ * @see #toString()
+ */
+ private final String[] includePatterns;
+
+ /**
+ * String representations of the normalized exclude filters.
+ * Each pattern shall be prefixed by its syntax. If no syntax is specified,
+ * the default is a Maven 3 syntax similar, but not identical, to {@value #DEFAULT_SYNTAX}.
+ * This array may be longer or shorter than the user-supplied excludes, depending on whether
+ * default excludes have been added and whether some unnecessary excludes have been omitted.
+ *
+ * @see #toString()
+ */
+ private final String[] excludePatterns;
+
+ /**
+ * The matcher for includes. The length of this array is equal to {@link #includePatterns} array length.
+ * An empty array means to include all files.
+ */
+ private final PathMatcher[] includes;
+
+ /**
+ * The matcher for excludes. The length of this array is equal to {@link #excludePatterns} array length.
+ */
+ private final PathMatcher[] excludes;
+
+ /**
+ * The matcher for all directories to include. This array includes the parents of all those directories,
+ * because they need to be accepted before we can walk to the sub-directories.
+ * This is an optimization for skipping whole directories when possible.
+ * An empty array means to include all directories.
+ */
+ private final PathMatcher[] dirIncludes;
+
+ /**
+ * The matcher for directories to exclude. This array does not include the parent directories,
+ * because they may contain other sub-trees that need to be included.
+ * This is an optimization for skipping whole directories when possible.
+ */
+ private final PathMatcher[] dirExcludes;
+
+ /**
+ * The base directory. All files will be relativized to that directory before to be matched.
+ */
+ private final Path baseDirectory;
+
+ /**
+ * Creates a new selector from the given includes and excludes.
+ *
+ * @param directory the base directory of the files to filter
+ * @param includes the patterns of the files to include, or null or empty for including all files
+ * @param excludes the patterns of the files to exclude, or null or empty for no exclusion
+ */
+ PathSelector(Path directory, Collection includes, Collection excludes) {
+ includePatterns = normalizePatterns(includes, false);
+ excludePatterns = normalizePatterns(effectiveExcludes(excludes, includePatterns), true);
+ baseDirectory = directory;
+ FileSystem fs = directory.getFileSystem();
+ this.includes = matchers(fs, includePatterns);
+ this.excludes = matchers(fs, excludePatterns);
+ dirIncludes = matchers(fs, directoryPatterns(includePatterns, false));
+ dirExcludes = matchers(fs, directoryPatterns(excludePatterns, true));
+ }
+
+ /**
+ * Returns the given array of excludes, optionally expanded with a default set of excludes,
+ * then with unnecessary excludes omitted. An unnecessary exclude is an exclude which will never
+ * match a file because there is no include which would accept a file that could match the exclude.
+ * For example, if the only include is {@code "*.java"}, then the "**/project.pj",
+ * "**/.DS_Store" and other excludes will never match a file and can be omitted.
+ * Because the list of {@linkplain #DEFAULT_EXCLUDES default excludes} contains many elements,
+ * removing unnecessary excludes can reduce a lot the number of matches tested on each source file.
+ *
+ *
Implementation note
+ * The removal of unnecessary excludes is done on a best effort basis. The current implementation
+ * compares only the prefixes and suffixes of each pattern, keeping the pattern in case of doubt.
+ * This is not bad, but it does not remove all unnecessary patterns. It would be possible to do
+ * better in the future if benchmarking suggests that it would be worth the effort.
+ *
+ * @param excludes the user-specified excludes, potentially not yet converted to glob syntax
+ * @param includes the include patterns converted to glob syntax
+ * @param useDefaultExcludes whether to expand user exclude with the set of default excludes
+ * @return the potentially expanded or reduced set of excludes to use
+ */
+ private static Collection effectiveExcludes(Collection excludes, final String[] includes) {
+ if (excludes == null || excludes.isEmpty()) {
+ return List.of();
+ } else {
+ excludes = new ArrayList<>(excludes);
+ excludes.removeIf(Objects::isNull);
+ }
+ if (includes.length == 0) {
+ return excludes;
+ }
+ /*
+ * Get the prefixes and suffixes of all includes, stopping at the first special character.
+ * Redundant prefixes and suffixes are omitted.
+ */
+ var prefixes = new String[includes.length];
+ var suffixes = new String[includes.length];
+ for (int i = 0; i < includes.length; i++) {
+ String include = includes[i];
+ if (!include.startsWith(DEFAULT_SYNTAX)) {
+ return excludes; // Do not filter if at least one pattern is too complicated.
+ }
+ include = include.substring(DEFAULT_SYNTAX.length());
+ prefixes[i] = prefixOrSuffix(include, false);
+ suffixes[i] = prefixOrSuffix(include, true);
+ }
+ prefixes = sortByLength(prefixes, false);
+ suffixes = sortByLength(suffixes, true);
+ /*
+ * Keep only the exclude which start with one of the prefixes and end with one of the suffixes.
+ * Note that a prefix or suffix may be the empty string, which match everything.
+ */
+ final Iterator it = excludes.iterator();
+ nextExclude:
+ while (it.hasNext()) {
+ final String exclude = it.next();
+ final int s = exclude.indexOf(':');
+ if (s <= MAVEN_SYNTAX_THRESHOLD || exclude.startsWith(DEFAULT_SYNTAX)) {
+ if (cannotMatch(exclude, prefixes, false) || cannotMatch(exclude, suffixes, true)) {
+ it.remove();
+ }
+ }
+ }
+ return excludes;
+ }
+
+ /**
+ * Returns the maximal amount of ordinary characters at the beginning or end of the given pattern.
+ * The prefix or suffix stops at the first {@linkplain #SPECIAL_CHARACTERS special character}.
+ *
+ * @param include the pattern for which to get a prefix or suffix without special character
+ * @param suffix {@code false} if a prefix is desired, or {@code true} if a suffix is desired
+ */
+ private static String prefixOrSuffix(final String include, boolean suffix) {
+ int s = suffix ? -1 : include.length();
+ for (int i = SPECIAL_CHARACTERS.length(); --i >= 0; ) {
+ char c = SPECIAL_CHARACTERS.charAt(i);
+ if (suffix) {
+ s = Math.max(s, include.lastIndexOf(c));
+ } else {
+ int p = include.indexOf(c);
+ if (p >= 0 && p < s) {
+ s = p;
+ }
+ }
+ }
+ return suffix ? include.substring(s + 1) : include.substring(0, s);
+ }
+
+ /**
+ * Returns {@code true} if the given exclude cannot match any include patterns.
+ * In case of doubt, returns {@code false}.
+ *
+ * @param exclude the exclude pattern to test
+ * @param fragments the prefixes or suffixes (fragments without special characters) of the includes
+ * @param suffix {@code false} if the specified fragments are prefixes, {@code true} if they are suffixes
+ * @return {@code true} if it is certain that the exclude pattern cannot match, or {@code false} in case of doubt
+ */
+ private static boolean cannotMatch(String exclude, final String[] fragments, final boolean suffix) {
+ exclude = prefixOrSuffix(exclude, suffix);
+ for (String fragment : fragments) {
+ int fg = fragment.length();
+ int ex = exclude.length();
+ int length = Math.min(fg, ex);
+ if (suffix) {
+ fg -= length;
+ ex -= length;
+ } else {
+ fg = 0;
+ ex = 0;
+ }
+ if (exclude.regionMatches(ex, fragment, fg, length)) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ /**
+ * Sorts the given patterns by their length. The main intent is to have the empty string first,
+ * while will cause the loops testing for prefixes and suffixes to stop almost immediately.
+ * Short prefixes or suffixes are also more likely to be matched.
+ *
+ * @param fragments the fragments to sort in-place
+ * @param suffix {@code false} if the specified fragments are prefixes, {@code true} if they are suffixes
+ * @return the given array, or a smaller array if some fragments were discarded because redundant
+ */
+ private static String[] sortByLength(final String[] fragments, final boolean suffix) {
+ Arrays.sort(fragments, (s1, s2) -> s1.length() - s2.length());
+ int count = 0;
+ /*
+ * Simplify the array of prefixes or suffixes by removing all redundant elements.
+ * An element is redundant if there is a shorter prefix or suffix with the same characters.
+ */
+ nextBase:
+ for (String fragment : fragments) {
+ for (int i = count; --i >= 0; ) {
+ String base = fragments[i];
+ if (suffix ? fragment.endsWith(base) : fragment.startsWith(base)) {
+ continue nextBase; // Skip this fragment
+ }
+ }
+ fragments[count++] = fragment;
+ }
+ return (fragments.length == count) ? fragments : Arrays.copyOf(fragments, count);
+ }
+
+ /**
+ * Returns the given array of patterns with path separator normalized to {@code '/'}.
+ * Null or empty patterns are ignored, and duplications are removed.
+ *
+ * @param patterns the patterns to normalize
+ * @param excludes whether the patterns are exclude patterns
+ * @return normalized patterns without null, empty or duplicated patterns
+ */
+ private static String[] normalizePatterns(final Collection patterns, final boolean excludes) {
+ if (patterns == null || patterns.isEmpty()) {
+ return new String[0];
+ }
+ // TODO: use `LinkedHashSet.newLinkedHashSet(int)` instead with JDK19.
+ final var normalized = new LinkedHashSet(patterns.size());
+ for (String pattern : patterns) {
+ if (pattern != null && !pattern.isEmpty()) {
+ if (pattern.indexOf(':') <= MAVEN_SYNTAX_THRESHOLD) {
+ pattern = pattern.replace(File.separatorChar, '/');
+ if (pattern.endsWith("/")) {
+ pattern += "**";
+ }
+ // Following are okay only when "**" means "0 or more directories".
+ while (pattern.endsWith("/**/**")) {
+ pattern = pattern.substring(0, pattern.length() - 3);
+ }
+ while (pattern.startsWith("**/**/")) {
+ pattern = pattern.substring(3);
+ }
+ pattern = pattern.replace("/**/**/", "/**/");
+ pattern = pattern.replace("\\", "\\\\")
+ .replace("[", "\\[")
+ .replace("]", "\\]")
+ .replace("{", "\\{")
+ .replace("}", "\\}");
+ normalized.add(DEFAULT_SYNTAX + pattern);
+ /*
+ * If the pattern starts or ends with "**", Java GLOB expects a directory level at
+ * that location while Maven seems to consider that "**" can mean "no directory".
+ * Add another pattern for reproducing this effect.
+ */
+ addPatternsWithOneDirRemoved(normalized, pattern, 0);
+ } else {
+ normalized.add(pattern);
+ }
+ }
+ }
+ return simplify(normalized, excludes);
+ }
+
+ /**
+ * Adds all variants of the given pattern with {@code **} removed.
+ * This is used for simulating the Maven behavior where {@code "**} may match zero directory.
+ * Tests suggest that we need an explicit GLOB pattern with no {@code "**"} for matching an absence of directory.
+ *
+ * @param patterns where to add the derived patterns
+ * @param pattern the pattern for which to add derived forms, without the "glob:" syntax prefix
+ * @param end should be 0 (reserved for recursive invocations of this method)
+ */
+ private static void addPatternsWithOneDirRemoved(final Set patterns, final String pattern, int end) {
+ final int length = pattern.length();
+ int start;
+ while ((start = pattern.indexOf("**", end)) >= 0) {
+ end = start + 2; // 2 is the length of "**".
+ if (end < length) {
+ if (pattern.charAt(end) != '/') {
+ continue;
+ }
+ if (start == 0) {
+ end++; // Ommit the leading slash if there is nothing before it.
+ }
+ }
+ if (start > 0 && pattern.charAt(--start) != '/') {
+ continue;
+ }
+ String reduced = pattern.substring(0, start) + pattern.substring(end);
+ patterns.add(DEFAULT_SYNTAX + reduced);
+ addPatternsWithOneDirRemoved(patterns, reduced, start);
+ }
+ }
+
+ /**
+ * Applies some heuristic rules for simplifying the set of patterns,
+ * then returns the patterns as an array.
+ *
+ * @param patterns the patterns to simplify and return as an array
+ * @param excludes whether the patterns are exclude patterns
+ * @return the set content as an array, after simplification
+ */
+ private static String[] simplify(Set patterns, boolean excludes) {
+ /*
+ * If the "**" pattern is present, it makes all other patterns useless.
+ * In the case of include patterns, an empty set means to include everything.
+ */
+ if (patterns.remove("**")) {
+ patterns.clear();
+ if (excludes) {
+ patterns.add("**");
+ }
+ }
+ return patterns.toArray(String[]::new);
+ }
+
+ /**
+ * Eventually adds the parent directory of the given patterns, without duplicated values.
+ * The patterns given to this method should have been normalized.
+ *
+ * @param patterns the normalized include or exclude patterns
+ * @param excludes whether the patterns are exclude patterns
+ * @return pattens of directories to include or exclude
+ */
+ private static String[] directoryPatterns(final String[] patterns, final boolean excludes) {
+ // TODO: use `LinkedHashSet.newLinkedHashSet(int)` instead with JDK19.
+ final var directories = new LinkedHashSet(patterns.length);
+ for (String pattern : patterns) {
+ if (pattern.startsWith(DEFAULT_SYNTAX)) {
+ if (excludes) {
+ if (pattern.endsWith("/**")) {
+ directories.add(pattern.substring(0, pattern.length() - 3));
+ }
+ } else {
+ int s = pattern.indexOf(':');
+ if (pattern.regionMatches(++s, "**/", 0, 3)) {
+ s = pattern.indexOf('/', s + 3);
+ if (s < 0) {
+ return new String[0]; // Pattern is "**", so we need to accept everything.
+ }
+ directories.add(pattern.substring(0, s));
+ }
+ }
+ }
+ }
+ return simplify(directories, excludes);
+ }
+
+ /**
+ * Creates the path matchers for the given patterns.
+ * The syntax (usually {@value #DEFAULT_SYNTAX}) must be specified for each pattern.
+ */
+ private static PathMatcher[] matchers(final FileSystem fs, final String[] patterns) {
+ final var matchers = new PathMatcher[patterns.length];
+ for (int i = 0; i < patterns.length; i++) {
+ matchers[i] = fs.getPathMatcher(patterns[i]);
+ }
+ return matchers;
+ }
+
+ /**
+ * {@return a potentially simpler matcher equivalent to this matcher}.
+ */
+ @SuppressWarnings("checkstyle:MissingSwitchDefault")
+ public PathMatcher simplify() {
+ if (excludes.length == 0) {
+ switch (includes.length) {
+ case 0:
+ return INCLUDES_ALL;
+ case 1:
+ return includes[0];
+ }
+ }
+ return this;
+ }
+
+ /**
+ * Determines whether a path is selected.
+ * This is true if the given file matches an include pattern and no exclude pattern.
+ *
+ * @param path the pathname to test, must not be {@code null}
+ * @return {@code true} if the given path is selected, {@code false} otherwise
+ */
+ @Override
+ public boolean matches(Path path) {
+ path = baseDirectory.relativize(path);
+ return (includes.length == 0 || isMatched(path, includes))
+ && (excludes.length == 0 || !isMatched(path, excludes));
+ }
+
+ /**
+ * {@return whether the given file matches according to one of the given matchers}.
+ */
+ private static boolean isMatched(Path path, PathMatcher[] matchers) {
+ for (PathMatcher matcher : matchers) {
+ if (matcher.matches(path)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Determines whether a directory could contain selected paths.
+ *
+ * @param directory the directory pathname to test, must not be {@code null}
+ * @return {@code true} if the given directory might contain selected paths, {@code false} if the
+ * directory will definitively not contain selected paths
+ */
+ public boolean couldHoldSelected(Path directory) {
+ if (baseDirectory.equals(directory)) {
+ return true;
+ }
+ directory = baseDirectory.relativize(directory);
+ return (dirIncludes.length == 0 || isMatched(directory, dirIncludes))
+ && (dirExcludes.length == 0 || !isMatched(directory, dirExcludes));
+ }
+
+ /**
+ * Appends the elements of the given array in the given buffer.
+ * This is a helper method for {@link #toString()} implementations.
+ *
+ * @param buffer the buffer to add the elements to
+ * @param label label identifying the array of elements to add
+ * @param patterns the elements to append, or {@code null} if none
+ */
+ private static void append(StringBuilder buffer, String label, String[] patterns) {
+ buffer.append(label).append(": [");
+ if (patterns != null) {
+ for (int i = 0; i < patterns.length; i++) {
+ if (i != 0) {
+ buffer.append(", ");
+ }
+ buffer.append(patterns[i]);
+ }
+ }
+ buffer.append(']');
+ }
+
+ /**
+ * {@return a string representation for logging purposes}.
+ */
+ @Override
+ public String toString() {
+ var buffer = new StringBuilder();
+ append(buffer, "includes", includePatterns);
+ append(buffer.append(", "), "excludes", excludePatterns);
+ return buffer.toString();
+ }
+}
diff --git a/src/main/java/org/apache/maven/plugin/compiler/SourceDirectory.java b/src/main/java/org/apache/maven/plugin/compiler/SourceDirectory.java
index 4429a267f..920981b30 100644
--- a/src/main/java/org/apache/maven/plugin/compiler/SourceDirectory.java
+++ b/src/main/java/org/apache/maven/plugin/compiler/SourceDirectory.java
@@ -21,10 +21,8 @@
import javax.lang.model.SourceVersion;
import javax.tools.JavaFileObject;
-import java.nio.file.FileSystem;
import java.nio.file.Files;
import java.nio.file.Path;
-import java.nio.file.PathMatcher;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
@@ -80,7 +78,7 @@ final class SourceDirectory {
*
* @see PathFilter#includes
*/
- final List includes;
+ final List includes;
/**
* Filter for excluding files below the {@linkplain #root} directory, or an empty list for no exclusion.
@@ -88,7 +86,7 @@ final class SourceDirectory {
*
* @see PathFilter#excludes
*/
- final List excludes;
+ final List excludes;
/**
* Kind of source files in this directory. This is usually {@link JavaFileObject.Kind#SOURCE}.
@@ -161,6 +159,8 @@ final class SourceDirectory {
* Creates a new source directory.
*
* @param root the root directory of all source files
+ * @param includes patterns for selecting files below the root directory, or an empty list for the default filter
+ * @param excludes patterns for excluding files below the root directory, or an empty list for no exclusion
* @param fileKind kind of source files in this directory (usually {@code SOURCE})
* @param moduleName name of the module for which source directories are provided, or {@code null} if none
* @param release Java release for which source directories are provided, or {@code null} for the default release
@@ -170,8 +170,8 @@ final class SourceDirectory {
@SuppressWarnings("checkstyle:ParameterNumber")
private SourceDirectory(
Path root,
- List includes,
- List excludes,
+ List includes,
+ List excludes,
JavaFileObject.Kind fileKind,
String moduleName,
SourceVersion release,
@@ -286,11 +286,10 @@ static List fromProject(
fileKind = JavaFileObject.Kind.SOURCE;
outputFileKind = JavaFileObject.Kind.CLASS;
}
- FileSystem fs = directory.getFileSystem();
roots.add(new SourceDirectory(
directory,
- source.includes().stream().map(fs::getPathMatcher).toList(),
- source.excludes().stream().map(fs::getPathMatcher).toList(),
+ source.includes(),
+ source.excludes(),
fileKind,
source.module().orElse(null),
targetVersion(source).orElse(release),
From 6de93dfa730367471c13a4c5d905b0d11f7ff8a4 Mon Sep 17 00:00:00 2001
From: Martin Desruisseaux
Date: Wed, 9 Jul 2025 21:15:32 +0200
Subject: [PATCH 2/2] `PathSelector` can be simplified to a single matcher only
if there is no need to relativize. Opportunistically check if there is a need
to relativize the rest of the time as well.
---
.../maven/plugin/compiler/PathFilter.java | 2 +-
.../maven/plugin/compiler/PathSelector.java | 29 +++++++++++++++++--
2 files changed, 28 insertions(+), 3 deletions(-)
diff --git a/src/main/java/org/apache/maven/plugin/compiler/PathFilter.java b/src/main/java/org/apache/maven/plugin/compiler/PathFilter.java
index 0c369db5b..7f1995ded 100644
--- a/src/main/java/org/apache/maven/plugin/compiler/PathFilter.java
+++ b/src/main/java/org/apache/maven/plugin/compiler/PathFilter.java
@@ -170,7 +170,7 @@ public List walkSourceFiles(Iterable rootDirectorie
sourceFiles = result;
for (SourceDirectory directory : rootDirectories) {
if (!incrementalExcludes.isEmpty()) {
- incrementalExcludeMatchers = new PathSelector(directory.root, incrementalExcludes, null);
+ incrementalExcludeMatchers = new PathSelector(directory.root, incrementalExcludes, null).simplify();
}
String[] includesOrDefault = includes;
if (useDefaultInclude) {
diff --git a/src/main/java/org/apache/maven/plugin/compiler/PathSelector.java b/src/main/java/org/apache/maven/plugin/compiler/PathSelector.java
index a42706612..509bfdedf 100644
--- a/src/main/java/org/apache/maven/plugin/compiler/PathSelector.java
+++ b/src/main/java/org/apache/maven/plugin/compiler/PathSelector.java
@@ -139,6 +139,13 @@ final class PathSelector implements PathMatcher {
*/
private final Path baseDirectory;
+ /**
+ * Whether paths must be relativized before to be given to a matcher. If {@code true}, then every paths
+ * will be made relative to {@link #baseDirectory} for allowing patterns like {@code "foo/bar/*.java"}
+ * to work. As a slight optimization, we can skip this step if all patterns start with {@code "**"}.
+ */
+ private final boolean needRelativize;
+
/**
* Creates a new selector from the given includes and excludes.
*
@@ -155,6 +162,7 @@ final class PathSelector implements PathMatcher {
this.excludes = matchers(fs, excludePatterns);
dirIncludes = matchers(fs, directoryPatterns(includePatterns, false));
dirExcludes = matchers(fs, directoryPatterns(excludePatterns, true));
+ needRelativize = needRelativize(includePatterns) || needRelativize(excludePatterns);
}
/**
@@ -437,6 +445,21 @@ private static String[] directoryPatterns(final String[] patterns, final boolean
return simplify(directories, excludes);
}
+ /**
+ * Returns {@code true} if at least one pattern requires path to be relativized before to be matched.
+ *
+ * @param patterns include or exclude patterns
+ * @return whether at least one pattern require relativization
+ */
+ private static boolean needRelativize(String[] patterns) {
+ for (String pattern : patterns) {
+ if (!pattern.startsWith(DEFAULT_SYNTAX + "**/")) {
+ return true;
+ }
+ }
+ return false;
+ }
+
/**
* Creates the path matchers for the given patterns.
* The syntax (usually {@value #DEFAULT_SYNTAX}) must be specified for each pattern.
@@ -454,7 +477,7 @@ private static PathMatcher[] matchers(final FileSystem fs, final String[] patter
*/
@SuppressWarnings("checkstyle:MissingSwitchDefault")
public PathMatcher simplify() {
- if (excludes.length == 0) {
+ if (!needRelativize && excludes.length == 0) {
switch (includes.length) {
case 0:
return INCLUDES_ALL;
@@ -474,7 +497,9 @@ public PathMatcher simplify() {
*/
@Override
public boolean matches(Path path) {
- path = baseDirectory.relativize(path);
+ if (needRelativize) {
+ path = baseDirectory.relativize(path);
+ }
return (includes.length == 0 || isMatched(path, includes))
&& (excludes.length == 0 || !isMatched(path, excludes));
}