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)); }