diff --git a/hawkeye-core/src/main/java/io/korandoru/hawkeye/core/Selection.java b/hawkeye-core/src/main/java/io/korandoru/hawkeye/core/Selection.java index 5d113b0a..e770faef 100644 --- a/hawkeye-core/src/main/java/io/korandoru/hawkeye/core/Selection.java +++ b/hawkeye-core/src/main/java/io/korandoru/hawkeye/core/Selection.java @@ -25,13 +25,13 @@ import java.nio.file.FileVisitor; import java.nio.file.Files; import java.nio.file.Path; -import java.nio.file.PathMatcher; import java.nio.file.attribute.BasicFileAttributes; import java.util.ArrayList; import java.util.Arrays; import java.util.EnumSet; import java.util.List; import java.util.Set; +import java.util.StringTokenizer; import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; import java.util.stream.Stream; @@ -75,23 +75,33 @@ public String[] getSelectedFiles() { final Path basePath = basedir.toPath(); - final List excludesList = new ArrayList<>(); - final List invertExcludesList = new ArrayList<>(); + final List excludeList = new ArrayList<>(); + final List includeList = new ArrayList<>(); + final List invertExcludeList = new ArrayList<>(); + for (String exclude : excluded) { + String pattern = exclude; + if (pattern.startsWith("!")) { + pattern = pattern.substring(1); + } + if (pattern.endsWith("/")) { + pattern = pattern.substring(0, pattern.length() - 1); + } + if (pattern.endsWith("/**")) { + pattern = pattern.substring(0, pattern.length() - 3); + } + + final String[] pattenParts = tokenizePathToString(pattern, "/"); if (exclude.startsWith("!")) { - invertExcludesList.add(exclude.substring(1)); + invertExcludeList.add(pattenParts); } else { - excludesList.add(exclude); + excludeList.add(pattenParts); } } - final String[] excludes = excludesList.toArray(new String[0]); - final String[] invertExcludes = invertExcludesList.toArray(new String[0]); - final List folderExcludes = buildFolderPathMaters(excludes); - final List folderInvertExcludes = buildFolderPathMaters(invertExcludes); - final List includedPatterns = buildPathMatchers(included); - final List excludedPatterns = buildPathMatchers(excludes); - final List invertExcludedPatterns = buildPathMatchers(invertExcludes); + for (String include : included) { + includeList.add(tokenizePathToString(include, "/")); + } final List results = new ArrayList<>(); final Set followLinksOption = EnumSet.of(FileVisitOption.FOLLOW_LINKS); @@ -99,17 +109,17 @@ public String[] getSelectedFiles() { @Override public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) { final Path path = basePath.relativize(dir); - final boolean isExcluded = folderExcludes.stream().anyMatch(m -> m.matches(path)); - final boolean isInvertExcluded = folderInvertExcludes.stream().anyMatch(m -> m.matches(path)); + final boolean isExcluded = excludeList.stream().anyMatch(m -> matchPathPattern(m, path)); + final boolean isInvertExcluded = invertExcludeList.stream().anyMatch(m -> matchPathPattern(m, path)); return (isExcluded && !isInvertExcluded) ? FileVisitResult.SKIP_SUBTREE : FileVisitResult.CONTINUE; } @Override public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) { final Path path = basePath.relativize(file); - final boolean isIncluded = includedPatterns.stream().anyMatch(m -> m.matches(path)); - final boolean isExcluded = excludedPatterns.stream().anyMatch(m -> m.matches(path)); - final boolean isInvertExcluded = invertExcludedPatterns.stream().anyMatch(m -> m.matches(path)); + final boolean isIncluded = includeList.stream().anyMatch(m -> matchPathPattern(m, path)); + final boolean isExcluded = excludeList.stream().anyMatch(m -> matchPathPattern(m, path)); + final boolean isInvertExcluded = invertExcludeList.stream().anyMatch(m -> matchPathPattern(m, path)); if (isIncluded && !(isExcluded && !isInvertExcluded)) { results.add(path.toString()); } @@ -134,27 +144,252 @@ public FileVisitResult postVisitDirectory(Path dir, IOException exc) { return files; } - private List buildFolderPathMaters(String[] patterns) { - final List result = new ArrayList<>(); - for (final String pattern : patterns) { - if (pattern.endsWith("/")) { - result.add(pattern.substring(0, pattern.length() - 1)); + private static String[] tokenizePathToString(String path, String separator) { + final List ret = new ArrayList<>(); + final StringTokenizer st = new StringTokenizer(path, separator); + while (st.hasMoreTokens()) { + ret.add(st.nextToken()); + } + return ret.toArray(new String[0]); + } + + private static boolean matchPathPattern(String[] patDirs, Path path) { + final String[] strDirs = tokenizePathToString(path.toString(), File.separator); + return matchPathPattern(patDirs, strDirs); + } + + private static boolean matchPathPattern(String[] patDirs, String[] strDirs) { + int patIdxStart = 0; + int patIdxEnd = patDirs.length - 1; + int strIdxStart = 0; + int strIdxEnd = strDirs.length - 1; + + // up to first '**' + while (patIdxStart <= patIdxEnd && strIdxStart <= strIdxEnd) { + String patDir = patDirs[patIdxStart]; + if (patDir.equals("**")) { + break; + } + if (!match(patDir, strDirs[strIdxStart])) { + return false; + } + patIdxStart++; + strIdxStart++; + } + if (strIdxStart > strIdxEnd) { + // String is exhausted + for (int i = patIdxStart; i <= patIdxEnd; i++) { + if (!patDirs[i].equals("**")) { + return false; + } + } + return true; + } else { + if (patIdxStart > patIdxEnd) { + // String not exhausted, but pattern is. Failure. + return false; + } + } + + // up to last '**' + while (patIdxStart <= patIdxEnd && strIdxStart <= strIdxEnd) { + String patDir = patDirs[patIdxEnd]; + if (patDir.equals("**")) { + break; + } + if (!match(patDir, strDirs[strIdxEnd])) { + return false; + } + patIdxEnd--; + strIdxEnd--; + } + if (strIdxStart > strIdxEnd) { + // String is exhausted + for (int i = patIdxStart; i <= patIdxEnd; i++) { + if (!patDirs[i].equals("**")) { + return false; + } + } + return true; + } + + while (patIdxStart != patIdxEnd && strIdxStart <= strIdxEnd) { + int patIdxTmp = -1; + for (int i = patIdxStart + 1; i <= patIdxEnd; i++) { + if (patDirs[i].equals("**")) { + patIdxTmp = i; + break; + } + } + if (patIdxTmp == patIdxStart + 1) { + // '**/**' situation, so skip one + patIdxStart++; continue; } - if (pattern.endsWith("/**")) { - result.add(pattern.substring(0, pattern.length() - 3)); + // Find the pattern between padIdxStart & padIdxTmp in str between + // strIdxStart & strIdxEnd + int patLength = (patIdxTmp - patIdxStart - 1); + int strLength = (strIdxEnd - strIdxStart + 1); + int foundIdx = -1; + strLoop: + for (int i = 0; i <= strLength - patLength; i++) { + for (int j = 0; j < patLength; j++) { + String subPat = patDirs[patIdxStart + j + 1]; + String subStr = strDirs[strIdxStart + i + j]; + if (!match(subPat, subStr)) { + continue strLoop; + } + } + + foundIdx = strIdxStart + i; + break; } - result.add(pattern); + + if (foundIdx == -1) { + return false; + } + + patIdxStart = patIdxTmp; + strIdxStart = foundIdx + patLength; } - return buildPathMatchers(result.toArray(new String[0])); + + for (int i = patIdxStart; i <= patIdxEnd; i++) { + if (!patDirs[i].equals("**")) { + return false; + } + } + + return true; } - private List buildPathMatchers(String[] patterns) { - // Patch doublestar to match zero or more segments; - // by default, doublestar in PathMatcher match one or more segments. - return stream(patterns) - .map(p -> p.replaceAll("(?=[^/])\\*\\*/", "{,**/}")) - .map(p -> fs.getPathMatcher("glob:" + p)) - .toList(); + @SuppressWarnings("BooleanMethodIsAlwaysInverted") + private static boolean match(String pattern, String str) { + char[] patArr = pattern.toCharArray(); + char[] strArr = str.toCharArray(); + int patIdxStart = 0; + int patIdxEnd = patArr.length - 1; + int strIdxStart = 0; + int strIdxEnd = strArr.length - 1; + char ch; + + boolean containsStar = false; + for (char aPatArr : patArr) { + if (aPatArr == '*') { + containsStar = true; + break; + } + } + + if (!containsStar) { + // No '*'s, so we make a shortcut + if (patIdxEnd != strIdxEnd) { + return false; // Pattern and string do not have the same size + } + for (int i = 0; i <= patIdxEnd; i++) { + ch = patArr[i]; + if (ch != '?' && ch != strArr[i]) { + return false; // Character mismatch + } + } + return true; // String matches against pattern + } + + if (patIdxEnd == 0) { + return true; // Pattern contains only '*', which matches anything + } + + // Process characters before first star + // CHECKSTYLE_OFF: InnerAssignment + while ((ch = patArr[patIdxStart]) != '*' && strIdxStart <= strIdxEnd) + // CHECKSTYLE_ON: InnerAssignment + { + if (ch != '?' && ch != strArr[strIdxStart]) { + return false; // Character mismatch + } + patIdxStart++; + strIdxStart++; + } + if (strIdxStart > strIdxEnd) { + // All characters in the string are used. Check if only '*'s are + // left in the pattern. If so, we succeeded; otherwise, we failed. + for (int i = patIdxStart; i <= patIdxEnd; i++) { + if (patArr[i] != '*') { + return false; + } + } + return true; + } + + // Process characters after last star + // CHECKSTYLE_OFF: InnerAssignment + while ((ch = patArr[patIdxEnd]) != '*' && strIdxStart <= strIdxEnd) + // CHECKSTYLE_ON: InnerAssignment + { + if (ch != '?' && ch != strArr[strIdxEnd]) { + return false; // Character mismatch + } + patIdxEnd--; + strIdxEnd--; + } + if (strIdxStart > strIdxEnd) { + // All characters in the string are used. Check if only '*'s are + // left in the pattern. If so, we succeeded; otherwise, we failed. + for (int i = patIdxStart; i <= patIdxEnd; i++) { + if (patArr[i] != '*') { + return false; + } + } + return true; + } + + // process pattern between stars. padIdxStart and patIdxEnd point + // always to a '*'. + while (patIdxStart != patIdxEnd && strIdxStart <= strIdxEnd) { + int patIdxTmp = -1; + for (int i = patIdxStart + 1; i <= patIdxEnd; i++) { + if (patArr[i] == '*') { + patIdxTmp = i; + break; + } + } + if (patIdxTmp == patIdxStart + 1) { + // Two stars next to each other, skip the first one. + patIdxStart++; + continue; + } + // Find the pattern between padIdxStart & padIdxTmp in str between + // strIdxStart & strIdxEnd + int patLength = (patIdxTmp - patIdxStart - 1); + int strLength = (strIdxEnd - strIdxStart + 1); + int foundIdx = -1; + strLoop: + for (int i = 0; i <= strLength - patLength; i++) { + for (int j = 0; j < patLength; j++) { + ch = patArr[patIdxStart + j + 1]; + if (ch != '?' && ch != strArr[strIdxStart + i + j]) { + continue strLoop; + } + } + + foundIdx = strIdxStart + i; + break; + } + + if (foundIdx == -1) { + return false; + } + + patIdxStart = patIdxTmp; + strIdxStart = foundIdx + patLength; + } + + // All characters in the string are used. Check if only '*'s are left + // in the pattern. If so, we succeeded; otherwise, we failed. + for (int i = patIdxStart; i <= patIdxEnd; i++) { + if (patArr[i] != '*') { + return false; + } + } + return true; } } diff --git a/hawkeye-core/src/test/java/io/korandoru/hawkeye/core/SelectionTest.java b/hawkeye-core/src/test/java/io/korandoru/hawkeye/core/SelectionTest.java index db57ea64..403176f3 100644 --- a/hawkeye-core/src/test/java/io/korandoru/hawkeye/core/SelectionTest.java +++ b/hawkeye-core/src/test/java/io/korandoru/hawkeye/core/SelectionTest.java @@ -56,11 +56,19 @@ void testLimitInclusionAndCheckDefaultExcludes() { @Test void testExclusions(@TempDir Path tempDir) throws IOException { - final File root = createFakeProject(tempDir); + final File root = createFakeProject(tempDir, new String[]{ + "included.txt", + "ignore/ignore.txt", + "target/ignored.txt", + "module/src/main/java/not-ignored.txt", + "module/target/ignored.txt", + "module/sub/subsub/src/main/java/not-ignored.txt", + "module/sub/subsub/target/foo/not-ignored.txt", + }); final Selection selection = new Selection( root, new String[]{"**/*.txt"}, - new String[]{"target/**", "module/**/target/**"}, + new String[]{"ignore", "target/**", "module/**/target/**"}, false); final String[] selectedFiles = selection.getSelectedFiles(); final List expectedSelectedFiles = List.of( @@ -70,14 +78,11 @@ void testExclusions(@TempDir Path tempDir) throws IOException { assertThat(selectedFiles).containsExactlyInAnyOrderElementsOf(expectedSelectedFiles); } - private File createFakeProject(Path tempDir) throws IOException { - File temp = tempDir.toFile(); - FileUtils.touch(new File(temp, "included.txt")); - FileUtils.touch(new File(temp, "target/ignored.txt")); - FileUtils.touch(new File(temp, "module/src/main/java/not-ignored.txt")); - FileUtils.touch(new File(temp, "module/target/ignored.txt")); - FileUtils.touch(new File(temp, "module/sub/subsub/src/main/java/not-ignored.txt")); - FileUtils.touch(new File(temp, "module/sub/subsub/target/foo/not-ignored.txt")); + private File createFakeProject(Path tempDir, String[] paths) throws IOException { + final File temp = tempDir.toFile(); + for (String path : paths) { + FileUtils.touch(new File(temp, path)); + } return temp; } }