This is an automated email from the ASF dual-hosted git repository. desruisseaux pushed a commit to branch master in repository https://gitbox.apache.org/repos/asf/maven-clean-plugin.git
commit 00107cb437b722cef2ffe4d9b57619bc35504c6f Author: Martin Desruisseaux <[email protected]> AuthorDate: Thu Oct 16 20:28:38 2025 +0200 Replace the `PathSelector` class by the implementation provided by Maven core. --- .../org/apache/maven/plugins/clean/CleanMojo.java | 18 +- .../org/apache/maven/plugins/clean/Cleaner.java | 112 ++-- .../org/apache/maven/plugins/clean/Fileset.java | 53 +- .../apache/maven/plugins/clean/PathSelector.java | 640 --------------------- .../apache/maven/plugins/clean/CleanMojoTest.java | 66 ++- .../apache/maven/plugins/clean/CleanerTest.java | 21 +- 6 files changed, 185 insertions(+), 725 deletions(-) diff --git a/src/main/java/org/apache/maven/plugins/clean/CleanMojo.java b/src/main/java/org/apache/maven/plugins/clean/CleanMojo.java index 090e804..9ac88c4 100644 --- a/src/main/java/org/apache/maven/plugins/clean/CleanMojo.java +++ b/src/main/java/org/apache/maven/plugins/clean/CleanMojo.java @@ -34,6 +34,7 @@ import org.apache.maven.api.plugin.Log; import org.apache.maven.api.plugin.MojoException; import org.apache.maven.api.plugin.annotations.Mojo; import org.apache.maven.api.plugin.annotations.Parameter; +import org.apache.maven.api.services.PathMatcherFactory; import org.apache.maven.api.services.ProjectManager; /** @@ -235,6 +236,12 @@ public class CleanMojo implements org.apache.maven.api.plugin.Mojo { @Inject private Project project; + /** + * The service to use for creating include and exclude filters. + */ + @Inject + private PathMatcherFactory matcherFactory; + /** * Deletes build directories and file-sets. * Directories are deleted in the following order: @@ -284,7 +291,16 @@ public class CleanMojo implements org.apache.maven.api.plugin.Mojo { + FAST_MODE_BACKGROUND + "', '" + FAST_MODE_AT_END + "' and '" + FAST_MODE_DEFER + "'."); } final var cleaner = new Cleaner( - session, logger, isVerbose(), fastDir, fastMode, followSymLinks, force, failOnError, retryOnError); + session, + matcherFactory, + logger, + isVerbose(), + fastDir, + fastMode, + followSymLinks, + force, + failOnError, + retryOnError); try { for (Path directoryItem : getDirectories()) { cleaner.delete(directoryItem); diff --git a/src/main/java/org/apache/maven/plugins/clean/Cleaner.java b/src/main/java/org/apache/maven/plugins/clean/Cleaner.java index 2ad8276..2cfce01 100644 --- a/src/main/java/org/apache/maven/plugins/clean/Cleaner.java +++ b/src/main/java/org/apache/maven/plugins/clean/Cleaner.java @@ -25,14 +25,15 @@ import java.nio.file.FileVisitOption; import java.nio.file.FileVisitResult; import java.nio.file.FileVisitor; import java.nio.file.Files; +import java.nio.file.NotDirectoryException; import java.nio.file.Path; +import java.nio.file.PathMatcher; import java.nio.file.StandardCopyOption; import java.nio.file.attribute.BasicFileAttributes; import java.nio.file.attribute.DosFileAttributeView; import java.nio.file.attribute.PosixFileAttributeView; import java.nio.file.attribute.PosixFilePermission; import java.util.ArrayDeque; -import java.util.Arrays; import java.util.BitSet; import java.util.Deque; import java.util.EnumSet; @@ -50,6 +51,7 @@ import org.apache.maven.api.SessionData; import org.apache.maven.api.annotations.Nonnull; import org.apache.maven.api.annotations.Nullable; import org.apache.maven.api.plugin.Log; +import org.apache.maven.api.services.PathMatcherFactory; /** * Cleans directories. @@ -107,11 +109,23 @@ final class Cleaner implements FileVisitor<Path> { private final String fastMode; /** - * Combination of includes and excludes path matchers. - * A {@code null} value means to include everything. + * The service to use for creating include and exclude filters. + * Used for setting a value to {@link #fileMatcher} and {@link #directoryMatcher}. */ - @Nullable - private PathSelector selector; + @Nonnull + private final PathMatcherFactory matcherFactory; + + /** + * Combination of includes and excludes path matchers applied on files. + */ + @Nonnull + private PathMatcher fileMatcher; + + /** + * Combination of includes and excludes path matchers applied on directories. + */ + @Nonnull + private PathMatcher directoryMatcher; /** * Whether the base directory is excluded from the set of directories to delete. @@ -120,6 +134,14 @@ final class Cleaner implements FileVisitor<Path> { */ private boolean isBaseDirectoryExcluded; + /** + * Whether to follow symbolic links while deleting files from the directories. + * This value is specified by the <abbr>MOJO</abbr> plugin configuration, + * but can be overridden by {@link Fileset}. + * + * @see CleanMojo#followSymLinks + * @see Fileset#followSymlinks + */ private boolean followSymlinks; /** @@ -168,29 +190,35 @@ final class Cleaner implements FileVisitor<Path> { /** * Creates a new cleaner. + * By default, the cleaner has no include or exclude filters, + * does not exclude the base directory and does not follow symbolic links. + * These properties can be modified by {@link #delete(Fileset)}. * - * @param session the Maven session to be used - * @param logger the logger to use - * @param verbose whether to perform verbose logging - * @param fastDir the explicit configured directory or to be deleted in fast mode - * @param fastMode the fast deletion mode - * @param followSymlinks whether to follow symlinks - * @param force whether to force the deletion of read-only files - * @param failOnError whether to abort with an exception in case a selected file/directory could not be deleted - * @param retryOnError whether to undertake additional delete attempts in case the first attempt failed + * @param session the Maven session to be used + * @param matcherFactory the service to use for creating include and exclude filters. + * @param logger the logger to use + * @param verbose whether to perform verbose logging + * @param fastDir the explicit configured directory or to be deleted in fast mode + * @param fastMode the fast deletion mode + * @param followSymlinks whether to follow symlinks + * @param force whether to force the deletion of read-only files + * @param failOnError whether to abort with an exception in case a selected file/directory could not be deleted + * @param retryOnError whether to undertake additional delete attempts in case the first attempt failed */ @SuppressWarnings("checkstyle:ParameterNumber") Cleaner( - @Nonnull Session session, + @Nullable Session session, + @Nonnull PathMatcherFactory matcherFactory, @Nonnull Log logger, boolean verbose, - @Nonnull Path fastDir, + @Nullable Path fastDir, @Nonnull String fastMode, boolean followSymlinks, boolean force, boolean failOnError, boolean retryOnError) { this.session = session; + this.matcherFactory = matcherFactory; this.logger = logger; this.verbose = verbose; this.fastDir = fastDir; @@ -201,32 +229,38 @@ final class Cleaner implements FileVisitor<Path> { this.retryOnError = retryOnError; listDeletedFiles = verbose ? logger.isInfoEnabled() : logger.isDebugEnabled(); nonEmptyDirectoryLevels = new BitSet(); + fileMatcher = matcherFactory.includesAll(); + directoryMatcher = fileMatcher; } /** * Deletes the specified fileset. + * This method modifies the include and exclude filters, + * whether to exclude the base directory and whether to follow symbolic links. * - * @param fileset the fileset to delete, must not be {@code null} - * @throws IOException if a file/directory could not be deleted and {@code failOnError} is {@code true} + * @param fileset the fileset to delete + * @throws IOException if a file/directory could not be deleted and {@link #failOnError} is {@code true} */ public void delete(@Nonnull Fileset fileset) throws IOException { - selector = new PathSelector( - fileset.getDirectory(), - Arrays.asList(fileset.getIncludes()), - Arrays.asList(fileset.getExcludes()), - fileset.isUseDefaultExcludes()); - if (selector.isEmpty()) { - selector = null; - } + fileMatcher = matcherFactory.createPathMatcher( + fileset.getDirectory(), fileset.getIncludes(), fileset.getExcludes(), fileset.useDefaultExcludes()); + directoryMatcher = matcherFactory.deriveDirectoryMatcher(fileMatcher); isBaseDirectoryExcluded = fileset.isBaseDirectoryExcluded(); - followSymlinks = fileset.isFollowSymlinks(); + followSymlinks = fileset.followSymlinks(); delete(fileset.getDirectory()); } /** - * Deletes the specified directory and its contents. + * Deletes the specified directory and its contents using the current configuration. * Non-existing directories will be silently ignored. * + * <h4>Configuration</h4> + * The behavior of this method depends on the {@code Cleaner} configuration. + * Some configuration can be modified by calls to {@link #delete(Fileset)}. + * Therefore, for deleting files with the default configuration (no include + * or exclude filters, not following symbolic links), this method should be + * invoked first. + * * @param basedir the directory to delete, must not be {@code null} * @throws IOException if a file/directory could not be deleted and {@code failOnError} is {@code true} */ @@ -238,17 +272,17 @@ final class Cleaner implements FileVisitor<Path> { } return; } - throw new IOException("Invalid base directory " + basedir); + throw new NotDirectoryException("Invalid base directory " + basedir); } if (logger.isInfoEnabled()) { - logger.info("Deleting " + basedir + (selector != null ? " (" + selector + ")" : "")); + logger.info("Deleting " + basedir + (isClearAll() ? "" : " (" + fileMatcher + ')')); } var options = EnumSet.noneOf(FileVisitOption.class); if (followSymlinks) { options.add(FileVisitOption.FOLLOW_LINKS); basedir = getCanonicalPath(basedir, null); } - if (selector == null && !followSymlinks && fastDir != null && session != null) { + if (isClearAll() && !followSymlinks && fastDir != null && session != null) { // If anything wrong happens, we'll just use the usual deletion mechanism if (fastDelete(basedir)) { return; @@ -257,6 +291,14 @@ final class Cleaner implements FileVisitor<Path> { Files.walkFileTree(basedir, options, Integer.MAX_VALUE, this); } + /** + * {@return whether {@link #fileMatcher} matches all files}. + * This is a required condition for allowing the use of {@link #fastDelete(Path)}. + */ + private boolean isClearAll() { + return fileMatcher == matcherFactory.includesAll(); + } + private boolean fastDelete(Path baseDir) { // Handle the case where we use ${maven.multiModuleProjectDirectory}/target/.clean for example if (fastDir.toAbsolutePath().startsWith(baseDir.toAbsolutePath())) { @@ -320,7 +362,7 @@ final class Cleaner implements FileVisitor<Path> { visitFile(dir, attrs); return FileVisitResult.SKIP_SUBTREE; } - if (selector == null || selector.couldHoldSelected(dir)) { + if (directoryMatcher.matches(dir)) { nonEmptyDirectoryLevels.clear(++currentDepth); return FileVisitResult.CONTINUE; } else { @@ -336,7 +378,7 @@ final class Cleaner implements FileVisitor<Path> { */ @Override public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException { - if ((selector == null || selector.matches(file)) && tryDelete(file)) { + if (fileMatcher.matches(file) && tryDelete(file)) { if (listDeletedFiles) { logDelete(file, attrs); } @@ -377,8 +419,8 @@ final class Cleaner implements FileVisitor<Path> { canDelete = false; } else { canDelete &= (currentDepth != 0 || !isBaseDirectoryExcluded); - if (canDelete && selector != null) { - canDelete = selector.matches(dir); + if (canDelete) { + canDelete = fileMatcher.matches(dir); } } if (canDelete && tryDelete(dir)) { diff --git a/src/main/java/org/apache/maven/plugins/clean/Fileset.java b/src/main/java/org/apache/maven/plugins/clean/Fileset.java index de497f8..b55dcc4 100644 --- a/src/main/java/org/apache/maven/plugins/clean/Fileset.java +++ b/src/main/java/org/apache/maven/plugins/clean/Fileset.java @@ -19,10 +19,12 @@ package org.apache.maven.plugins.clean; import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; /** * Customizes the string representation of - * {@code org.apache.maven.shared.model.fileset.FileSet} to return the + * {@code org.apache.maven.api.model.FileSet} to return the * included and excluded files from the file-set's directory. Specifically, * <code>"file-set: <I>[directory]</I> (included: <I>[included files]</I>, * excluded: <I>[excluded files]</I>)"</code> @@ -54,17 +56,37 @@ public class Fileset { } /** - * {@return the patterns of the file to include, or an empty array if unspecified}. + * {@return the patterns of the file to include, or an empty list if unspecified}. */ - public String[] getIncludes() { - return (includes != null) ? includes : new String[0]; + public List<String> getIncludes() { + return listWithoutNull(includes); } /** - * {@return the patterns of the file to exclude, or an empty array if unspecified}. + * {@return the patterns of the file to exclude, or an empty list if unspecified}. */ - public String[] getExcludes() { - return (excludes != null) ? excludes : new String[0]; + public List<String> getExcludes() { + return listWithoutNull(excludes); + } + + /** + * {@return the content of the given array without null elements}. + * The existence of null elements has been observed in practice, + * not sure where they come from. + * + * @param patterns the {@link #includes} or {@link #excludes} array, or {@code null} if none + */ + private static List<String> listWithoutNull(String[] patterns) { + if (patterns == null) { + return List.of(); + } + var list = new ArrayList<String>(patterns.length); + for (String pattern : patterns) { + if (pattern != null) { + list.add(pattern); + } + } + return list; } /** @@ -86,14 +108,14 @@ public class Fileset { /** * {@return whether to follow symbolic links}. */ - public boolean isFollowSymlinks() { + public boolean followSymlinks() { return followSymlinks; } /** * {@return whether to use a default set of excludes}. */ - public boolean isUseDefaultExcludes() { + public boolean useDefaultExcludes() { return useDefaultExcludes; } @@ -105,15 +127,12 @@ public class Fileset { * @param label label identifying the array of elements to add * @param patterns the elements to append, or {@code null} if none */ - static void append(StringBuilder buffer, String label, String[] patterns) { + private static void append(StringBuilder buffer, String label, List<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]); - } + String separator = ""; + for (String pattern : patterns) { + buffer.append(separator).append(pattern); + separator = ", "; } buffer.append(']'); } diff --git a/src/main/java/org/apache/maven/plugins/clean/PathSelector.java b/src/main/java/org/apache/maven/plugins/clean/PathSelector.java deleted file mode 100644 index 2d81082..0000000 --- a/src/main/java/org/apache/maven/plugins/clean/PathSelector.java +++ /dev/null @@ -1,640 +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.plugins.clean; - -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. - * - * <h2>Syntax</h2> - * 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: - * - * <ul> - * <li>The platform-specific separator ({@code '\\'} on Windows) is replaced by {@code '/'}. - * Note that it means that the backslash cannot be used for escaping characters.</li> - * <li>Trailing {@code "/"} is completed as {@code "/**"}.</li> - * <li>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.</li> - * <li>Bracket characters [ ] and { } are escaped.</li> - * <li>On Unix only, the escape character {@code '\\'} is itself escaped.</li> - * </ul> - * - * 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 { - /** - * Patterns which should be excluded by default, like <abbr>SCM</abbr> files. - * - * <p><b>Source:</b> this list is copied from {@code plexus-utils-4.0.2} (released in - * September 23, 2024), class {@code org.codehaus.plexus.util.AbstractScanner}.</p> - */ - private static final List<String> DEFAULT_EXCLUDES = List.of( - // Miscellaneous typical temporary files - "**/*~", - "**/#*#", - "**/.#*", - "**/%*%", - "**/._*", - - // CVS - "**/CVS", - "**/CVS/**", - "**/.cvsignore", - - // RCS - "**/RCS", - "**/RCS/**", - - // SCCS - "**/SCCS", - "**/SCCS/**", - - // Visual SourceSafe - "**/vssver.scc", - - // MKS - "**/project.pj", - - // Subversion - "**/.svn", - "**/.svn/**", - - // Arch - "**/.arch-ids", - "**/.arch-ids/**", - - // Bazaar - "**/.bzr", - "**/.bzr/**", - - // SurroundSCM - "**/.MySCMServerInfo", - - // Mac - "**/.DS_Store", - - // Serena Dimensions Version 10 - "**/.metadata", - "**/.metadata/**", - - // Mercurial - "**/.hg", - "**/.hg/**", - - // git - "**/.git", - "**/.git/**", - "**/.gitignore", - - // BitKeeper - "**/BitKeeper", - "**/BitKeeper/**", - "**/ChangeSet", - "**/ChangeSet/**", - - // darcs - "**/_darcs", - "**/_darcs/**", - "**/.darcsrepo", - "**/.darcsrepo/**", - "**/-darcs-backup*", - "**/.darcs-temp-mail"); - - /** - * 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 <em>not</em> 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; - - /** - * 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. - * - * @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 - * @param useDefaultExcludes whether to augment the excludes with a default set of <abbr>SCM</abbr> patterns - */ - PathSelector(Path directory, Collection<String> includes, Collection<String> excludes, boolean useDefaultExcludes) { - includePatterns = normalizePatterns(includes, false); - excludePatterns = normalizePatterns(effectiveExcludes(excludes, includePatterns, useDefaultExcludes), 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)); - needRelativize = needRelativize(includePatterns) || needRelativize(excludePatterns); - } - - /** - * 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 <code>"**/project.pj"</code>, - * <code>"**/.DS_Store"</code> 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. - * - * <h4>Implementation note</h4> - * 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<String> effectiveExcludes( - Collection<String> excludes, final String[] includes, final boolean useDefaultExcludes) { - if (excludes == null || excludes.isEmpty()) { - if (useDefaultExcludes) { - excludes = new ArrayList<>(DEFAULT_EXCLUDES); - } else { - return List.of(); - } - } else { - excludes = new ArrayList<>(excludes); - excludes.removeIf(Objects::isNull); - if (useDefaultExcludes) { - excludes.addAll(DEFAULT_EXCLUDES); - } - } - 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<String> 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<String> 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<String>(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<String> 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<String> 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<String>(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); - } - - /** - * 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. - */ - 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 whether there is no include or exclude filters}. - * In such case, this {@code PathSelector} instance should be ignored. - */ - public boolean isEmpty() { - return (includes.length | excludes.length) == 0; - } - - /** - * {@return a potentially simpler matcher equivalent to this matcher}. - */ - @SuppressWarnings("checkstyle:MissingSwitchDefault") - public PathMatcher simplify() { - if (!needRelativize && 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) { - if (needRelativize) { - 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)); - } - - /** - * {@return a string representation for logging purposes}. - */ - @Override - public String toString() { - var buffer = new StringBuilder(); - Fileset.append(buffer, "includes", includePatterns); - Fileset.append(buffer.append(", "), "excludes", excludePatterns); - return buffer.toString(); - } -} diff --git a/src/test/java/org/apache/maven/plugins/clean/CleanMojoTest.java b/src/test/java/org/apache/maven/plugins/clean/CleanMojoTest.java index 3f0f7cd..c7935de 100644 --- a/src/test/java/org/apache/maven/plugins/clean/CleanMojoTest.java +++ b/src/test/java/org/apache/maven/plugins/clean/CleanMojoTest.java @@ -34,6 +34,8 @@ import org.apache.maven.api.plugin.MojoException; import org.apache.maven.api.plugin.testing.Basedir; import org.apache.maven.api.plugin.testing.InjectMojo; import org.apache.maven.api.plugin.testing.MojoTest; +import org.apache.maven.api.services.PathMatcherFactory; +import org.apache.maven.impl.DefaultPathMatcherFactory; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.condition.DisabledOnOs; import org.junit.jupiter.api.condition.EnabledOnOs; @@ -55,6 +57,15 @@ class CleanMojoTest { private final Log log = mock(Log.class); + /** + * The factory to use for creating patch matcher. + * The actual implementation is used rather than a mock because filtering is an important part + * of this plugin and is tedious to test. Therefore, it is hard to guarantee that the tests of + * {@code PathMatcherFactory} in Maven core are sufficient, and we want this plugin to test it + * more. + */ + private final PathMatcherFactory matcherFactory = new DefaultPathMatcherFactory(); + /** * Tests the simple removal of directories * @@ -66,9 +77,9 @@ class CleanMojoTest { void basicClean(CleanMojo mojo) throws Exception { mojo.execute(); - assertFalse(checkExists(getBasedir() + "/buildDirectory"), "Directory exists"); - assertFalse(checkExists(getBasedir() + "/buildOutputDirectory"), "Directory exists"); - assertFalse(checkExists(getBasedir() + "/buildTestDirectory"), "Directory exists"); + assertFalse(checkExists("buildDirectory"), "Directory exists"); + assertFalse(checkExists("buildOutputDirectory"), "Directory exists"); + assertFalse(checkExists("buildTestDirectory"), "Directory exists"); } /** @@ -82,9 +93,9 @@ class CleanMojoTest { void cleanNestedStructure(CleanMojo mojo) throws Exception { mojo.execute(); - assertFalse(checkExists(getBasedir() + "/target")); - assertFalse(checkExists(getBasedir() + "/target/classes")); - assertFalse(checkExists(getBasedir() + "/target/test-classes")); + assertFalse(checkExists("target")); + assertFalse(checkExists("target/classes")); + assertFalse(checkExists("target/test-classes")); } /** @@ -99,10 +110,10 @@ class CleanMojoTest { void cleanEmptyDirectories(CleanMojo mojo) throws Exception { mojo.execute(); - assertTrue(checkExists(getBasedir() + "/testDirectoryStructure")); - assertTrue(checkExists(getBasedir() + "/testDirectoryStructure/file.txt")); - assertTrue(checkExists(getBasedir() + "/testDirectoryStructure/outputDirectory")); - assertTrue(checkExists(getBasedir() + "/testDirectoryStructure/outputDirectory/file.txt")); + assertTrue(checkExists("testDirectoryStructure")); + assertTrue(checkExists("testDirectoryStructure/file.txt")); + assertTrue(checkExists("testDirectoryStructure/outputDirectory")); + assertTrue(checkExists("testDirectoryStructure/outputDirectory/file.txt")); } /** @@ -117,18 +128,18 @@ class CleanMojoTest { mojo.execute(); // fileset 1 - assertTrue(checkExists(getBasedir() + "/target")); - assertTrue(checkExists(getBasedir() + "/target/classes")); - assertFalse(checkExists(getBasedir() + "/target/test-classes")); - assertTrue(checkExists(getBasedir() + "/target/subdir")); - assertFalse(checkExists(getBasedir() + "/target/classes/file.txt")); - assertTrue(checkEmpty(getBasedir() + "/target/classes")); - assertFalse(checkEmpty(getBasedir() + "/target/subdir")); - assertTrue(checkExists(getBasedir() + "/target/subdir/file.txt")); + assertTrue(checkExists("target")); + assertTrue(checkExists("target/classes")); + assertFalse(checkExists("target/test-classes")); + assertTrue(checkExists("target/subdir")); + assertFalse(checkExists("target/classes/file.txt")); + assertTrue(checkEmpty("target/classes")); + assertFalse(checkEmpty("target/subdir")); + assertTrue(checkExists("target/subdir/file.txt")); // fileset 2 - assertTrue(checkExists(getBasedir() + "/" + "buildOutputDirectory")); - assertFalse(checkExists(getBasedir() + "/" + "buildOutputDirectory/file.txt")); + assertTrue(checkExists("buildOutputDirectory")); + assertFalse(checkExists("buildOutputDirectory/file.txt")); } /** @@ -154,7 +165,7 @@ class CleanMojoTest { void missingDirectory(CleanMojo mojo) throws Exception { mojo.execute(); - assertFalse(checkExists(getBasedir() + "/does-not-exist")); + assertFalse(checkExists("does-not-exist")); } /** @@ -244,7 +255,7 @@ class CleanMojoTest { } private void testSymlink(LinkCreator linkCreator) throws Exception { - Cleaner cleaner = new Cleaner(null, log, false, null, null, false, false, true, false); + Cleaner cleaner = new Cleaner(null, matcherFactory, log, false, null, null, false, false, true, false); Path testDir = Paths.get("target/test-classes/unit/test-dir").toAbsolutePath(); Path dirWithLnk = testDir.resolve("dir"); Path orgDir = testDir.resolve("org-dir"); @@ -270,7 +281,7 @@ class CleanMojoTest { Files.write(file, Collections.singleton("Hello world")); linkCreator.createLink(jctDir, orgDir); // delete - cleaner = new Cleaner(null, log, false, null, null, true, false, true, false); + cleaner = new Cleaner(null, matcherFactory, log, false, null, null, true, false, true, false); cleaner.delete(dirWithLnk); // verify assertFalse(Files.exists(file)); @@ -283,16 +294,17 @@ class CleanMojoTest { * @param dir a dir or a file * @return true if a file/dir exists, false otherwise */ - private boolean checkExists(String dir) { - return new File(new File(dir).getAbsolutePath()).exists(); + private static boolean checkExists(String dir) { + return Files.exists(Path.of(getBasedir(), dir)); } /** * @param dir a directory * @return true if a dir is empty, false otherwise */ - private boolean checkEmpty(String dir) { - File[] files = new File(dir).listFiles(); + private static boolean checkEmpty(String dir) { + Path path = Path.of(getBasedir(), dir); + File[] files = path.toFile().listFiles(); return files == null || files.length == 0; } } diff --git a/src/test/java/org/apache/maven/plugins/clean/CleanerTest.java b/src/test/java/org/apache/maven/plugins/clean/CleanerTest.java index 8de70d1..8c84cee 100644 --- a/src/test/java/org/apache/maven/plugins/clean/CleanerTest.java +++ b/src/test/java/org/apache/maven/plugins/clean/CleanerTest.java @@ -25,6 +25,8 @@ import java.nio.file.attribute.PosixFilePermissions; import java.util.Set; import org.apache.maven.api.plugin.Log; +import org.apache.maven.api.services.PathMatcherFactory; +import org.apache.maven.impl.DefaultPathMatcherFactory; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.condition.DisabledOnOs; import org.junit.jupiter.api.condition.OS; @@ -54,12 +56,21 @@ class CleanerTest { private final Log log = mock(Log.class); + /** + * The factory to use for creating patch matcher. + * The actual implementation is used rather than a mock because filtering is an important part + * of this plugin and is tedious to test. Therefore, it is hard to guarantee that the tests of + * {@code PathMatcherFactory} in Maven core are sufficient, and we want this plugin to test it + * more. + */ + private final PathMatcherFactory matcherFactory = new DefaultPathMatcherFactory(); + @Test @DisabledOnOs(OS.WINDOWS) void deleteSucceedsDeeply(@TempDir Path tempDir) throws Exception { final Path basedir = createDirectory(tempDir.resolve("target")).toRealPath(); final Path file = createFile(basedir.resolve("file")); - final var cleaner = new Cleaner(null, log, false, null, null, false, false, true, false); + final var cleaner = new Cleaner(null, matcherFactory, log, false, null, null, false, false, true, false); cleaner.delete(basedir); assertFalse(exists(basedir)); assertFalse(exists(file)); @@ -74,7 +85,7 @@ class CleanerTest { // Remove the executable flag to prevent directory listing, which will result in a DirectoryNotEmptyException. final Set<PosixFilePermission> permissions = PosixFilePermissions.fromString("rw-rw-r--"); setPosixFilePermissions(basedir, permissions); - final var cleaner = new Cleaner(null, log, false, null, null, false, false, true, false); + final var cleaner = new Cleaner(null, matcherFactory, log, false, null, null, false, false, true, false); final var exception = assertThrows(AccessDeniedException.class, () -> cleaner.delete(basedir)); verify(log, times(1)).warn(any(CharSequence.class), any(Throwable.class)); assertTrue(exception.getMessage().contains(basedir.toString())); @@ -88,7 +99,7 @@ class CleanerTest { // Remove the executable flag to prevent directory listing, which will result in a DirectoryNotEmptyException. final Set<PosixFilePermission> permissions = PosixFilePermissions.fromString("rw-rw-r--"); setPosixFilePermissions(basedir, permissions); - final var cleaner = new Cleaner(null, log, false, null, null, false, false, true, true); + final var cleaner = new Cleaner(null, matcherFactory, log, false, null, null, false, false, true, true); final var exception = assertThrows(AccessDeniedException.class, () -> cleaner.delete(basedir)); assertTrue(exception.getMessage().contains(basedir.toString())); } @@ -102,7 +113,7 @@ class CleanerTest { // Remove the writable flag to prevent deletion of the file, which will result in an AccessDeniedException. final Set<PosixFilePermission> permissions = PosixFilePermissions.fromString("r-xr-xr-x"); setPosixFilePermissions(basedir, permissions); - final var cleaner = new Cleaner(null, log, false, null, null, false, false, false, false); + final var cleaner = new Cleaner(null, matcherFactory, log, false, null, null, false, false, false, false); assertDoesNotThrow(() -> cleaner.delete(basedir)); verify(log, times(1)).warn(any(CharSequence.class), any(Throwable.class)); InOrder inOrder = inOrder(log); @@ -120,7 +131,7 @@ class CleanerTest { // Remove the writable flag to prevent deletion of the file, which will result in an AccessDeniedException. final Set<PosixFilePermission> permissions = PosixFilePermissions.fromString("r-xr-xr-x"); setPosixFilePermissions(basedir, permissions); - final var cleaner = new Cleaner(null, log, false, null, null, false, false, false, false); + final var cleaner = new Cleaner(null, matcherFactory, log, false, null, null, false, false, false, false); assertDoesNotThrow(() -> cleaner.delete(basedir)); verify(log, never()).warn(any(CharSequence.class), any(Throwable.class)); }
