From 3692d69202b12af3bf38afa20682f88a725d458b Mon Sep 17 00:00:00 2001 From: Samuel SCHNEGG Date: Wed, 1 Oct 2025 18:42:58 +0200 Subject: [PATCH 1/4] Work in progress --- .../example/CompleteExample.java | 12 ++++----- .../PathPredicates.java | 3 ++- .../scanner/DefaultPathToTreeScanner.java | 26 +++++++++---------- 3 files changed, 21 insertions(+), 20 deletions(-) diff --git a/src/example/java/io/github/computerdaddyguy/jfiletreeprettyprinter/example/CompleteExample.java b/src/example/java/io/github/computerdaddyguy/jfiletreeprettyprinter/example/CompleteExample.java index a231d26..8d812c9 100644 --- a/src/example/java/io/github/computerdaddyguy/jfiletreeprettyprinter/example/CompleteExample.java +++ b/src/example/java/io/github/computerdaddyguy/jfiletreeprettyprinter/example/CompleteExample.java @@ -13,12 +13,12 @@ public static void main(String[] args) { var filterDir = PathPredicates.builder() .pathTest(path -> !PathPredicates.hasName(path, ".git")) - .pathTest(path -> !PathPredicates.hasFullPathMatchingGlob(path, "**/.git")) - .pathTest(path -> !PathPredicates.hasFullPathMatchingGlob(path, "**/.github")) - .pathTest(path -> !PathPredicates.hasFullPathMatchingGlob(path, "**/.settings")) - .pathTest(path -> !PathPredicates.hasFullPathMatchingGlob(path, "**/src/example")) - .pathTest(path -> !PathPredicates.hasFullPathMatchingGlob(path, "**/src/test")) - .pathTest(path -> !PathPredicates.hasFullPathMatchingGlob(path, "**/target")) + .pathTest(path -> !PathPredicates.hasFullPathMatchingGlob(path, "./.git")) + .pathTest(path -> !PathPredicates.hasFullPathMatchingGlob(path, "./.github")) + .pathTest(path -> !PathPredicates.hasFullPathMatchingGlob(path, "./.settings")) + .pathTest(path -> !PathPredicates.hasFullPathMatchingGlob(path, "./src/example")) + .pathTest(path -> !PathPredicates.hasFullPathMatchingGlob(path, "./src/test")) + .pathTest(path -> !PathPredicates.hasFullPathMatchingGlob(path, "./target")) .build(); var filterFiles = PathPredicates.builder() diff --git a/src/main/java/io/github/computerdaddyguy/jfiletreeprettyprinter/PathPredicates.java b/src/main/java/io/github/computerdaddyguy/jfiletreeprettyprinter/PathPredicates.java index 38d61b2..008b344 100644 --- a/src/main/java/io/github/computerdaddyguy/jfiletreeprettyprinter/PathPredicates.java +++ b/src/main/java/io/github/computerdaddyguy/jfiletreeprettyprinter/PathPredicates.java @@ -66,7 +66,8 @@ public static boolean hasFullPathMatchingGlob(Path path, String glob) { return true; } var matcher = path.getFileSystem().getPathMatcher("glob:" + glob); - return matcher.matches(path); + var result = matcher.matches(path); + return result; } /** diff --git a/src/main/java/io/github/computerdaddyguy/jfiletreeprettyprinter/scanner/DefaultPathToTreeScanner.java b/src/main/java/io/github/computerdaddyguy/jfiletreeprettyprinter/scanner/DefaultPathToTreeScanner.java index ee1666e..a816563 100644 --- a/src/main/java/io/github/computerdaddyguy/jfiletreeprettyprinter/scanner/DefaultPathToTreeScanner.java +++ b/src/main/java/io/github/computerdaddyguy/jfiletreeprettyprinter/scanner/DefaultPathToTreeScanner.java @@ -1,5 +1,6 @@ package io.github.computerdaddyguy.jfiletreeprettyprinter.scanner; +import io.github.computerdaddyguy.jfiletreeprettyprinter.PathPredicates; import io.github.computerdaddyguy.jfiletreeprettyprinter.scanner.TreeEntry.DirectoryEntry; import io.github.computerdaddyguy.jfiletreeprettyprinter.scanner.TreeEntry.FileEntry; import io.github.computerdaddyguy.jfiletreeprettyprinter.scanner.TreeEntry.MaxDepthReachEntry; @@ -30,18 +31,18 @@ public DefaultPathToTreeScanner(ScanningOptions options) { @Override public TreeEntry scan(Path fileOrDir) { - return handle(0, fileOrDir, options.pathFilter()); + return handle(fileOrDir, 0, fileOrDir.relativize(fileOrDir).resolve("."), options.pathFilter()); } @Nullable - private TreeEntry handle(int depth, Path fileOrDir, Predicate filter) { - return fileOrDir.toFile().isDirectory() - ? handleDirectory(depth, fileOrDir, filter) + private TreeEntry handle(Path root, int depth, Path fileOrDir, Predicate filter) { + return PathPredicates.isDirectory(fileOrDir) + ? handleDirectory(root, depth, fileOrDir, filter) : handleFile(fileOrDir); } @Nullable - private TreeEntry handleDirectory(int depth, Path dir, Predicate filter) { + private TreeEntry handleDirectory(Path root, int depth, Path dir, Predicate filter) { if (depth >= options.getMaxDepth()) { var maxDepthEntry = new MaxDepthReachEntry(depth); @@ -50,9 +51,9 @@ private TreeEntry handleDirectory(int depth, Path dir, Predicate filter) { List childEntries; - try (var childrenStream = Files.newDirectoryStream(dir)) { + try (var childrenStream = Files.newDirectoryStream(dir, path -> filter.test(path))) { var it = directoryStreamToIterator(childrenStream, filter); - childEntries = handleDirectoryChildren(depth, dir, it, filter); + childEntries = handleDirectoryChildren(root, depth, dir, it, filter); } catch (IOException e) { throw new UncheckedIOException("Unable to list files for directory: " + dir, e); } @@ -60,7 +61,7 @@ private TreeEntry handleDirectory(int depth, Path dir, Predicate filter) { return new DirectoryEntry(dir, childEntries); } - private List handleDirectoryChildren(int depth, Path dir, Iterator pathIterator, Predicate filter) { + private List handleDirectoryChildren(Path root, int depth, Path dir, Iterator pathIterator, Predicate filter) { var childEntries = new ArrayList(); int maxChildEntries = options.getChildLimit().applyAsInt(dir); @@ -73,7 +74,7 @@ private List handleDirectoryChildren(int depth, Path dir, Iterator handleDirectoryChildren(int depth, Path dir, Iterator handleLeftOverChildren(int depth, Iterator pathIterator, Predicate filter) { + private List handleLeftOverChildren(Path root, int depth, Iterator pathIterator, Predicate filter) { var childEntries = new ArrayList(); var skippedChildren = new ArrayList(); while (pathIterator.hasNext()) { var child = pathIterator.next(); - var childEntry = handle(depth + 1, child, filter); + var childEntry = handle(root, depth + 1, child, filter); if (childEntry != null) { // Is null if no children file is retained by filter skippedChildren.add(child); } @@ -111,7 +112,6 @@ private List handleLeftOverChildren(int depth, Iterator pathIte private Iterator directoryStreamToIterator(DirectoryStream childrenStream, Predicate filter) { return StreamSupport .stream(childrenStream.spliterator(), false) - .filter(filter) .sorted(options.pathComparator()) .iterator(); } From f401e5bf77632326dfdd8560d4f4930ea3d98084 Mon Sep 17 00:00:00 2001 From: Samuel SCHNEGG Date: Fri, 3 Oct 2025 20:54:32 +0200 Subject: [PATCH 2/4] Path predicates/matchers full rework --- CHANGELOG.md | 13 +- README.md | 26 +- ROADMAP.md | 44 +- .../example/ChildLimitDynamic.java | 6 +- .../example/CompleteExample.java | 54 +- .../example/Filtering.java | 10 +- .../example/LineExtension.java | 14 +- .../ChildLimitBuilder.java | 22 +- .../DefaultFileTreePrettyPrinter.java | 3 +- .../FileTreePrettyPrinter.java | 6 +- .../jfiletreeprettyprinter/PathMatchers.java | 697 ++++++++++++++++++ .../PathPredicateBuilder.java | 297 -------- .../PathPredicates.java | 396 ---------- .../PrettyPrintOptions.java | 24 +- .../scanner/DefaultPathToTreeScanner.java | 25 +- .../scanner/ScanningOptions.java | 4 +- .../ChildLimitDynamicTest.java | 9 +- .../jfiletreeprettyprinter/FilteringTest.java | 16 +- .../LineExtensionTest.java | 20 +- .../PathMatchersTest.java | 622 ++++++++++++++++ .../PathPredicateBuilderTest.java | 648 ---------------- 21 files changed, 1478 insertions(+), 1478 deletions(-) create mode 100644 src/main/java/io/github/computerdaddyguy/jfiletreeprettyprinter/PathMatchers.java delete mode 100644 src/main/java/io/github/computerdaddyguy/jfiletreeprettyprinter/PathPredicateBuilder.java delete mode 100644 src/main/java/io/github/computerdaddyguy/jfiletreeprettyprinter/PathPredicates.java create mode 100644 src/test/java/io/github/computerdaddyguy/jfiletreeprettyprinter/PathMatchersTest.java delete mode 100644 src/test/java/io/github/computerdaddyguy/jfiletreeprettyprinter/PathPredicateBuilderTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index b930fd0..af2e19b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,12 +9,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [0.0.5] - Unreleased ### Added -- New path predicates: `hasParentMatching`, `hasAncestorMatching`, `hasDirectChildMatching`, `hasDescendantMatching`, `hasSiblingMatching`, `hasFullPathMatchingGlob`, `hasFullPathMatching`, `hasNameMatchingGlob`, `hasNameStartingWith` +- New path matchers: + `hasParentMatching`, `hasAncestorMatching`, `hasDirectChildMatching`, `hasDescendantMatching`, `hasSiblingMatching`, + `hasAbsolutePathMatchingGlob`, `hasAbsolutePathMatching`, + `hasRelativePathMatchingGlob`, `hasRelativePathMatching`, + `hasNameMatchingGlob`, `hasNameStartingWith` ### Changed -- `PathUtils` removed, `PathPredicates` rework -- Line extension: empty string is permitted -- Filtering: split into distinct directories and files filters +- Filtering: now using `PathMatcher` instead of `Predicate` +- Filtering: split into distinct directories and files filters for better control +- `PathUtils` and `PathPredicates` removed, use `PathMatchers` instead +- Line extension: empty string is permitted to force line break in compact paths ### Fixed - The folder name is properly displayed at root when calling `prettyPrint(".")` (instead of "./") diff --git a/README.md b/README.md index 394f149..93b2dbb 100644 --- a/README.md +++ b/README.md @@ -178,14 +178,14 @@ child_limit_static/ Or you can also set a limitation function, to dynamically choose the number of children displayed in each directory. It avoids cluttering the whole console with known large folders (e.g. `node_modules`) but continue to pretty print normally other folders. -Use the `ChildLimitBuilder` and `PathPredicates` classes to help you build the limit function that fits your needs. +Use the `ChildLimitBuilder` and `PathMatchers` classes to help you build the limit function that fits your needs. ```java // Example: ChildLimitDynamic.java -var isNodeModulePredicate = PathPredicates.builder().hasName("node_modules").build(); +var isNodeModuleMatcher = PathMatchers.hasName("node_modules"); var childLimit = ChildLimitBuilder.builder() .defaultLimit(ChildLimitBuilder.UNLIMITED) - .limit(isNodeModulePredicate, 0) + .limit(isNodeModuleMatcher, 0) .build(); var prettyPrinter = FileTreePrettyPrinter.builder() .customizeOptions(options -> options.withChildLimit(childLimit)) @@ -274,23 +274,23 @@ sorting/ ``` ## Filtering -Files and directories can be selectively included or excluded using a custom `Predicate`. +Files and directories can be selectively included or excluded using a custom `PathMatcher`. Filtering is independant for files & directories. Files are filtered only if their parent directory pass the directory filter. If none of some directory's children match, the directory is still displayed. -The `PathPredicates` class provides several ready-to-use methods for creating common predicates, as well as a builder for creating more advanced predicates. +The `PathMatchers` class provides several ready-to-use methods for creating and combining common matchers. ```java // Example: Filtering.java -Predicate excludeDirWithNoJavaFiles = dir -> !PathPredicates.hasNameEndingWith(dir, "no_java_file"); -var isJavaFilePredicate = PathPredicates.builder().hasExtension("java").build(); +var excludeDirWithNoJavaFiles = PathMatchers.not(PathMatchers.hasNameEndingWith("no_java_file")); +var hasJavaExtension = PathMatchers.hasExtension("java"); var prettyPrinter = FileTreePrettyPrinter.builder() .customizeOptions( options -> options .filterDirectories(excludeDirWithNoJavaFiles) - .filterFiles(hasJavaExtensionPredicate) + .filterFiles(hasJavaExtension) ) .build(); ``` @@ -315,17 +315,19 @@ If the function returns `null`, nothing is added. ```java // Example: LineExtension.java +var printedPath = Path.of("src/example/resources/line_extension"); + Function lineExtension = path -> { - if (PathPredicates.hasFullPathMatchingGlob(path, "**/src/main/java/api")) { + if (PathMatchers.hasRelativePathMatchingGlob("src/main/java/api", printedPath).matches(path)) { return "\t\t\t// All API code: controllers, etc."; } - if (PathPredicates.hasFullPathMatchingGlob(path, "**/src/main/java/domain")) { + if (PathMatchers.hasRelativePathMatchingGlob("src/main/java/domain", printedPath).matches(path)) { return "\t\t\t// All domain code: value objects, etc."; } - if (PathPredicates.hasFullPathMatchingGlob(path, "**/src/main/java/infra")) { + if (PathMatchers.hasRelativePathMatchingGlob("src/main/java/infra", printedPath).matches(path)) { return "\t\t\t// All infra code: database, email service, etc."; } - if (PathPredicates.hasNameMatchingGlob(path, "*.properties")) { + if (PathMatchers.hasNameMatchingGlob("*.properties").matches(path)) { return "\t// Config file"; } return null; diff --git a/ROADMAP.md b/ROADMAP.md index a3f3cd8..d1f817b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,28 +1,34 @@ # Roadmap ## Done -- [x] Option: tree format Unicode box drawing / classic ASCII -- [x] Option: use emojis -- [x] Option: children limit (static & dynamic) -- [x] Option: compact directories display -- [x] Option: max directory depth -- [x] Add examples & README -- [x] Use JSpecify annotations -- [x] Unit tests, using @TempDir -- [x] Mutation testing -- [x] Pre-defined Path predicates -- [x] Publish on Maven Central! -- [x] Child limitation function helper -- [x] More default emojis -- [x] Option: Filtering -- [x] Option: Ordering -- [x] Use Github wiki to document options instead of readme -- [x] Jacoco coverage report -- [x] Option: Line extension (=additional text after the file name) +- [x] **Features** + - [x] Option: filtering + - [x] Option: ordering + - [x] Option: emojis + - [x] Option: compact directories display + - [x] Option: line extension (=additional text after the file name) + - [x] Option: children limit (static & dynamic) + - [x] Option: tree format Unicode box drawing / classic ASCII + - [x] Option: max directory depth +- [x] **Documentation** + - [x] Add examples & README + - [x] Use Github wiki to document options instead of readme +- [x] **Code style** + - [x] Use JSpecify annotations +- [x] **Testing** + - [x] Unit tests, using @TempDir + - [x] Jacoco coverage report + - [x] Mutation testing + - [x] SonarCloud integration +- [x] **Workflows** + - [x] Github actions + - [x] Publish on Maven Central! ## To do -- [x] More `PathPredicates` functions! +- [x] More `PathMatchers` functions! +- [ ] Helper class for line extension - [ ] Option: custom emojis +- [ ] Rework/fix Github wiki to be up to date ## Backlog / To analyze / To implement if requested - [ ] Option: custom tree format diff --git a/src/example/java/io/github/computerdaddyguy/jfiletreeprettyprinter/example/ChildLimitDynamic.java b/src/example/java/io/github/computerdaddyguy/jfiletreeprettyprinter/example/ChildLimitDynamic.java index d7483e9..2bed344 100644 --- a/src/example/java/io/github/computerdaddyguy/jfiletreeprettyprinter/example/ChildLimitDynamic.java +++ b/src/example/java/io/github/computerdaddyguy/jfiletreeprettyprinter/example/ChildLimitDynamic.java @@ -2,15 +2,15 @@ import io.github.computerdaddyguy.jfiletreeprettyprinter.ChildLimitBuilder; import io.github.computerdaddyguy.jfiletreeprettyprinter.FileTreePrettyPrinter; -import io.github.computerdaddyguy.jfiletreeprettyprinter.PathPredicates; +import io.github.computerdaddyguy.jfiletreeprettyprinter.PathMatchers; public class ChildLimitDynamic { public static void main(String[] args) { - var isNodeModulePredicate = PathPredicates.builder().hasName("node_modules").build(); + var isNodeModuleMatcher = PathMatchers.hasName("node_modules"); var childLimit = ChildLimitBuilder.builder() .defaultLimit(ChildLimitBuilder.UNLIMITED) - .limit(isNodeModulePredicate, 0) + .limit(isNodeModuleMatcher, 0) .build(); var prettyPrinter = FileTreePrettyPrinter.builder() .customizeOptions(options -> options.withChildLimit(childLimit)) diff --git a/src/example/java/io/github/computerdaddyguy/jfiletreeprettyprinter/example/CompleteExample.java b/src/example/java/io/github/computerdaddyguy/jfiletreeprettyprinter/example/CompleteExample.java index 8d812c9..b3f00f9 100644 --- a/src/example/java/io/github/computerdaddyguy/jfiletreeprettyprinter/example/CompleteExample.java +++ b/src/example/java/io/github/computerdaddyguy/jfiletreeprettyprinter/example/CompleteExample.java @@ -2,7 +2,7 @@ import io.github.computerdaddyguy.jfiletreeprettyprinter.ChildLimitBuilder; import io.github.computerdaddyguy.jfiletreeprettyprinter.FileTreePrettyPrinter; -import io.github.computerdaddyguy.jfiletreeprettyprinter.PathPredicates; +import io.github.computerdaddyguy.jfiletreeprettyprinter.PathMatchers; import io.github.computerdaddyguy.jfiletreeprettyprinter.PrettyPrintOptions.Sorts; import java.nio.file.Path; import java.util.function.Function; @@ -11,39 +11,40 @@ public class CompleteExample { public static void main(String[] args) { - var filterDir = PathPredicates.builder() - .pathTest(path -> !PathPredicates.hasName(path, ".git")) - .pathTest(path -> !PathPredicates.hasFullPathMatchingGlob(path, "./.git")) - .pathTest(path -> !PathPredicates.hasFullPathMatchingGlob(path, "./.github")) - .pathTest(path -> !PathPredicates.hasFullPathMatchingGlob(path, "./.settings")) - .pathTest(path -> !PathPredicates.hasFullPathMatchingGlob(path, "./src/example")) - .pathTest(path -> !PathPredicates.hasFullPathMatchingGlob(path, "./src/test")) - .pathTest(path -> !PathPredicates.hasFullPathMatchingGlob(path, "./target")) - .build(); + var jFileTreePrettyPrintFolder = Path.of("."); - var filterFiles = PathPredicates.builder() - .pathTest(path -> !PathPredicates.hasNameStartingWith(path, ".")) - .pathTest(path -> { - if (PathPredicates.hasParentMatching(path, parent -> PathPredicates.hasName(parent, "jfiletreeprettyprinter"))) { - return PathPredicates.hasName(path, "FileTreePrettyPrinter.java"); - } - return true; - }) - .build(); + var filterDir = PathMatchers.noneOf( + PathMatchers.hasName(".git"), + PathMatchers.hasRelativePathMatchingGlob(jFileTreePrettyPrintFolder, ".git"), + PathMatchers.hasRelativePathMatchingGlob(jFileTreePrettyPrintFolder, ".github"), + PathMatchers.hasRelativePathMatchingGlob(jFileTreePrettyPrintFolder, ".settings"), + PathMatchers.hasRelativePathMatchingGlob(jFileTreePrettyPrintFolder, "src/example"), + PathMatchers.hasRelativePathMatchingGlob(jFileTreePrettyPrintFolder, "src/test"), + PathMatchers.hasRelativePathMatchingGlob(jFileTreePrettyPrintFolder, "target") + ); + + var filterFiles = PathMatchers.allOf( + PathMatchers.not(PathMatchers.hasNameStartingWith(".")), + PathMatchers.ifMatchesThenElse( + PathMatchers.hasDirectParentMatching(PathMatchers.hasName("jfiletreeprettyprinter")), // if + PathMatchers.hasName("FileTreePrettyPrinter.java"), // then + p -> true // else + ) + ); var childLimitFunction = ChildLimitBuilder.builder() - .limit(path -> PathPredicates.hasFullPathMatchingGlob(path, "**/io/github/computerdaddyguy/jfiletreeprettyprinter/renderer"), 0) - .limit(path -> PathPredicates.hasFullPathMatchingGlob(path, "**/io/github/computerdaddyguy/jfiletreeprettyprinter/scanner"), 0) + .limit(PathMatchers.hasAbsolutePathMatchingGlob("**/io/github/computerdaddyguy/jfiletreeprettyprinter/renderer"), 0) + .limit(PathMatchers.hasAbsolutePathMatchingGlob("**/io/github/computerdaddyguy/jfiletreeprettyprinter/scanner"), 0) .build(); Function lineExtension = path -> { - if (PathPredicates.hasName(path, "JfileTreePrettyPrinter-structure.png")) { + if (PathMatchers.hasName("JfileTreePrettyPrinter-structure.png").matches(path)) { return "\t// This image"; - } else if (PathPredicates.hasName(path, "FileTreePrettyPrinter.java")) { + } else if (PathMatchers.hasName("FileTreePrettyPrinter.java").matches(path)) { return "\t// Main entry point"; - } else if (PathPredicates.hasName(path, "README.md")) { + } else if (PathMatchers.hasName("README.md").matches(path)) { return "\t\t// You're reading at this!"; - } else if (PathPredicates.hasName(path, "java")) { + } else if (PathMatchers.hasName("java").matches(path)) { return ""; } return null; @@ -61,7 +62,8 @@ public static void main(String[] args) { .sort(Sorts.DIRECTORY_FIRST) ) .build(); - var tree = prettyPrinter.prettyPrint("."); + + var tree = prettyPrinter.prettyPrint(jFileTreePrettyPrintFolder); System.out.println(tree); } diff --git a/src/example/java/io/github/computerdaddyguy/jfiletreeprettyprinter/example/Filtering.java b/src/example/java/io/github/computerdaddyguy/jfiletreeprettyprinter/example/Filtering.java index ce92d81..acd3138 100644 --- a/src/example/java/io/github/computerdaddyguy/jfiletreeprettyprinter/example/Filtering.java +++ b/src/example/java/io/github/computerdaddyguy/jfiletreeprettyprinter/example/Filtering.java @@ -1,21 +1,19 @@ package io.github.computerdaddyguy.jfiletreeprettyprinter.example; import io.github.computerdaddyguy.jfiletreeprettyprinter.FileTreePrettyPrinter; -import io.github.computerdaddyguy.jfiletreeprettyprinter.PathPredicates; -import java.nio.file.Path; -import java.util.function.Predicate; +import io.github.computerdaddyguy.jfiletreeprettyprinter.PathMatchers; public class Filtering { public static void main(String[] args) { - Predicate excludeDirWithNoJavaFiles = dir -> !PathPredicates.hasNameEndingWith(dir, "no_java_file"); - var hasJavaExtensionPredicate = PathPredicates.builder().hasExtension("java").build(); + var excludeDirWithNoJavaFiles = PathMatchers.not(PathMatchers.hasNameEndingWith("no_java_file")); + var hasJavaExtension = PathMatchers.hasExtension("java"); var prettyPrinter = FileTreePrettyPrinter.builder() .customizeOptions( options -> options .filterDirectories(excludeDirWithNoJavaFiles) - .filterFiles(hasJavaExtensionPredicate) + .filterFiles(hasJavaExtension) ) .build(); diff --git a/src/example/java/io/github/computerdaddyguy/jfiletreeprettyprinter/example/LineExtension.java b/src/example/java/io/github/computerdaddyguy/jfiletreeprettyprinter/example/LineExtension.java index c9b3f2e..4e882b0 100644 --- a/src/example/java/io/github/computerdaddyguy/jfiletreeprettyprinter/example/LineExtension.java +++ b/src/example/java/io/github/computerdaddyguy/jfiletreeprettyprinter/example/LineExtension.java @@ -1,24 +1,26 @@ package io.github.computerdaddyguy.jfiletreeprettyprinter.example; import io.github.computerdaddyguy.jfiletreeprettyprinter.FileTreePrettyPrinter; -import io.github.computerdaddyguy.jfiletreeprettyprinter.PathPredicates; +import io.github.computerdaddyguy.jfiletreeprettyprinter.PathMatchers; import java.nio.file.Path; import java.util.function.Function; public class LineExtension { public static void main(String[] args) { + var printedPath = Path.of("src/example/resources/line_extension"); + Function lineExtension = path -> { - if (PathPredicates.hasFullPathMatchingGlob(path, "**/src/main/java/api")) { + if (PathMatchers.hasRelativePathMatchingGlob(printedPath, "src/main/java/api").matches(path)) { return "\t\t\t// All API code: controllers, etc."; } - if (PathPredicates.hasFullPathMatchingGlob(path, "**/src/main/java/domain")) { + if (PathMatchers.hasRelativePathMatchingGlob(printedPath, "src/main/java/domain").matches(path)) { return "\t\t\t// All domain code: value objects, etc."; } - if (PathPredicates.hasFullPathMatchingGlob(path, "**/src/main/java/infra")) { + if (PathMatchers.hasRelativePathMatchingGlob(printedPath, "src/main/java/infra").matches(path)) { return "\t\t\t// All infra code: database, email service, etc."; } - if (PathPredicates.hasNameMatchingGlob(path, "*.properties")) { + if (PathMatchers.hasNameMatchingGlob("*.properties").matches(path)) { return "\t// Config file"; } return null; @@ -26,7 +28,7 @@ public static void main(String[] args) { var prettyPrinter = FileTreePrettyPrinter.builder() .customizeOptions(options -> options.withLineExtension(lineExtension)) .build(); - var tree = prettyPrinter.prettyPrint("src/example/resources/line_extension"); + var tree = prettyPrinter.prettyPrint(printedPath); System.out.println(tree); } diff --git a/src/main/java/io/github/computerdaddyguy/jfiletreeprettyprinter/ChildLimitBuilder.java b/src/main/java/io/github/computerdaddyguy/jfiletreeprettyprinter/ChildLimitBuilder.java index f66f26b..db57198 100644 --- a/src/main/java/io/github/computerdaddyguy/jfiletreeprettyprinter/ChildLimitBuilder.java +++ b/src/main/java/io/github/computerdaddyguy/jfiletreeprettyprinter/ChildLimitBuilder.java @@ -1,10 +1,10 @@ package io.github.computerdaddyguy.jfiletreeprettyprinter; import java.nio.file.Path; +import java.nio.file.PathMatcher; import java.util.ArrayList; import java.util.List; import java.util.Objects; -import java.util.function.Predicate; import java.util.function.ToIntFunction; import org.jspecify.annotations.NullMarked; @@ -26,8 +26,8 @@ *
{@code
  * var childLimit = ChildLimitBuilder.builder()
  *     .defaultLimit(ChildLimit.UNLIMITED)   // unlimited unless specified
- *     .limit(path -> PathPredicates.hasName(path, "bigDir"), 10)  // max 10 children in "bigDir"
- *     .limit(path -> PathPredicates.hasName(path, "emptyDir"), 0) // disallow children in "emptyDir"
+ *     .limit(PathMatchers.hasName("bigDir"), 10)  // max 10 children in "bigDir"
+ *     .limit(PathMatchers.hasName("emptyDir"), 0) // disallow children in "emptyDir"
  *     .build();
  *
  * }
@@ -53,7 +53,7 @@ private ChildLimitBuilder() { this.defaultControl = UNLIMITED_CONTROL; } - private record ChildControl(Predicate pathPredicate, int limit) { + private record ChildControl(PathMatcher pathMatcher, int limit) { } @@ -74,7 +74,7 @@ public ToIntFunction build() { var immutControls = List.copyOf(controls); var immutDefaultControl = this.defaultControl; return p -> immutControls.stream() - .filter(control -> control.pathPredicate().test(p)) + .filter(control -> control.pathMatcher().matches(p)) .findFirst() .orElse(immutDefaultControl) .limit(); @@ -93,21 +93,21 @@ public ChildLimitBuilder defaultLimit(int limit) { } /** - * Adds a child limit rule for paths matching the given predicate. + * Adds a child limit rule for paths matching the given matcher. *

* Rules are evaluated in the order they are added. The first matching rule wins. *

* - * @param pathPredicate the condition for paths + * @param pathMatcher the condition for paths * @param limit the maximum number of children (use {@link #UNLIMITED} for no restriction) * * @return this builder for chaining * - * @throws NullPointerException if {@code pathPredicate} is null + * @throws NullPointerException if {@code pathMatcher} is null */ - public ChildLimitBuilder limit(Predicate pathPredicate, int limit) { - Objects.requireNonNull(pathPredicate, "pathPredicate is null"); - this.controls.add(new ChildControl(pathPredicate, limit)); + public ChildLimitBuilder limit(PathMatcher pathMatcher, int limit) { + Objects.requireNonNull(pathMatcher, "pathMatcher is null"); + this.controls.add(new ChildControl(pathMatcher, limit)); return this; } diff --git a/src/main/java/io/github/computerdaddyguy/jfiletreeprettyprinter/DefaultFileTreePrettyPrinter.java b/src/main/java/io/github/computerdaddyguy/jfiletreeprettyprinter/DefaultFileTreePrettyPrinter.java index 416dad6..ffaee8f 100644 --- a/src/main/java/io/github/computerdaddyguy/jfiletreeprettyprinter/DefaultFileTreePrettyPrinter.java +++ b/src/main/java/io/github/computerdaddyguy/jfiletreeprettyprinter/DefaultFileTreePrettyPrinter.java @@ -20,8 +20,7 @@ public DefaultFileTreePrettyPrinter(PathToTreeScanner scanner, TreeEntryRenderer @Override public String prettyPrint(Path path) { Objects.requireNonNull(path, "path cannot be null"); - var cleanedPath = path.normalize().toAbsolutePath(); // required to avoid "./" at root when calling prettyPrint(".") - var tree = scanner.scan(cleanedPath); + var tree = scanner.scan(path); return renderer.renderTree(tree); } diff --git a/src/main/java/io/github/computerdaddyguy/jfiletreeprettyprinter/FileTreePrettyPrinter.java b/src/main/java/io/github/computerdaddyguy/jfiletreeprettyprinter/FileTreePrettyPrinter.java index 1845621..aa8af48 100644 --- a/src/main/java/io/github/computerdaddyguy/jfiletreeprettyprinter/FileTreePrettyPrinter.java +++ b/src/main/java/io/github/computerdaddyguy/jfiletreeprettyprinter/FileTreePrettyPrinter.java @@ -31,7 +31,11 @@ public interface FileTreePrettyPrinter { * @throws UncheckedIOException If any IO error occurred */ default String prettyPrint(String path) throws UncheckedIOException { - return prettyPrint(Path.of(path)); + var p = Path.of(path); + if (p.isAbsolute()) { + return prettyPrint(p); + } + return prettyPrint(Path.of(".", path)); } /** diff --git a/src/main/java/io/github/computerdaddyguy/jfiletreeprettyprinter/PathMatchers.java b/src/main/java/io/github/computerdaddyguy/jfiletreeprettyprinter/PathMatchers.java new file mode 100644 index 0000000..0ef2c74 --- /dev/null +++ b/src/main/java/io/github/computerdaddyguy/jfiletreeprettyprinter/PathMatchers.java @@ -0,0 +1,697 @@ +package io.github.computerdaddyguy.jfiletreeprettyprinter; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.FileSystems; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.PathMatcher; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.function.Predicate; +import java.util.regex.Pattern; +import java.util.stream.Stream; +import org.jspecify.annotations.NullMarked; + +/** + * Utility class providing factory and composition methods for {@link PathMatcher}s. + * + *

All methods are {@code static}. This class cannot be instantiated.

+ * + *

Design notes

+ *
    + *
  • Unless otherwise stated, all methods throw {@link NullPointerException} + * if passed {@code null} arguments.
  • + *
  • {@link Path#getFileName()} may return {@code null} if the path is a root. + * This is always checked internally to avoid {@link NullPointerException}.
  • + *
  • Matchers generally normalize paths with {@link Path#normalize()} and + * use {@link Path#toAbsolutePath()} where absolute matching is intended.
  • + *
+ */ + +@NullMarked +public final class PathMatchers { + + /* + * -------------------------------------------------- + * YES, Javadoc in this class has been AI written ;-) + * -------------------------------------------------- + */ + + private PathMatchers() { + // Helper class + } + + // ---------- PathMatcher combinations ---------- + + /** + * Returns a matcher that negates the result of another matcher. + * + * @param matcher the matcher to negate + * @return a matcher returning {@code true} when the given matcher returns {@code false} + * + *

Example:

+ *
{@code
+	 * PathMatcher notTxt = PathMatchers.not(PathMatchers.hasExtension("txt"));
+	 * notTxt.matches(Path.of("report.pdf")); // true
+	 * notTxt.matches(Path.of("report.txt")); // false
+	 * }
+ */ + public static PathMatcher not(PathMatcher matcher) { + Objects.requireNonNull(matcher, "matcher is null"); + return path -> !matcher.matches(path); + } + + /** + * Returns a matcher that requires all of the provided matchers to succeed. + * + *

The returned matcher evaluates the given matchers in sequence and stops + * at the first failure. If every matcher evaluates to {@code true}, the + * resulting matcher returns {@code true}.

+ * + * @param matcher a required first matcher (must not be {@code null}) + * @param matchers additional matchers (zero or more, each must not be {@code null}) + * @return a matcher returning {@code true} if all provided matchers return {@code true} + * + * @throws NullPointerException if any matcher is {@code null} + * + *

Example:

+ *
{@code
+	 * PathMatcher javaFilesInSrc =
+	 *     PathMatchers.allOf(
+	 *         PathMatchers.hasExtension("java"),
+	 *         PathMatchers.hasAnyAncestorMatching(PathMatchers.hasName("src"))
+	 *     );
+	 * }
+ */ + public static PathMatcher allOf(PathMatcher matcher, PathMatcher... matchers) { + return allOf(buildSafeList(matcher, matchers)); + } + + /** + * Returns a matcher that requires all of the provided matchers to succeed. + * + *

The returned matcher evaluates the matchers in sequence and stops + * at the first failure. If the iterable is empty, an + * {@link IllegalArgumentException} is thrown.

+ * + * @param matchers the matchers to combine (must not be {@code null} or contain {@code null}) + * @return a matcher returning {@code true} if all provided matchers return {@code true} + * + * @throws NullPointerException if {@code matchers} or any element is {@code null} + * @throws IllegalArgumentException if {@code matchers} is empty + */ + public static PathMatcher allOf(Iterable matchers) { + return combineMatchers(matchers, Mode.ALL); + } + + /** + * Returns a matcher that requires none of the provided matchers to succeed. + * + *

The returned matcher evaluates the given matchers in sequence and + * stops at the first success. It only returns {@code true} if all matchers return {@code false}.

+ * + * @param matcher a required first matcher (must not be {@code null}) + * @param matchers additional matchers (zero or more, each must not be {@code null}) + * @return a matcher returning {@code true} if none of the provided matchers return {@code true} + * + * @throws NullPointerException if any matcher is {@code null} + * + *

Example:

+ *
{@code
+	 * PathMatcher notHiddenOrBackup =
+	 *     PathMatchers.noneOf(
+	 *         PathMatchers.hasPrefix("."),
+	 *         PathMatchers.hasSuffix("~")
+	 *     );
+	 * }
+ */ + public static PathMatcher noneOf(PathMatcher matcher, PathMatcher... matchers) { + return noneOf(buildSafeList(matcher, matchers)); + } + + /** + * Returns a matcher that requires none of the provided matchers to succeed. + * + *

The returned matcher evaluates the matchers in sequence and stops + * at the first success. It only returns {@code true} if all matchers return {@code false}. + * If the iterable is empty, an {@link IllegalArgumentException} is thrown.

+ * + * @param matchers the matchers to combine (must not be {@code null} or contain {@code null}) + * @return a matcher returning {@code true} if none of the provided matchers return {@code true} + * + * @throws NullPointerException if {@code matchers} or any element is {@code null} + * @throws IllegalArgumentException if {@code matchers} is empty + */ + public static PathMatcher noneOf(Iterable matchers) { + return combineMatchers(matchers, Mode.NONE); + } + + /** + * Returns a matcher that requires any of the provided matchers to succeed. + * + *

The returned matcher evaluates the given matchers in sequence and stops + * at the first success. If at least one matcher evaluates to {@code true}, + * the resulting matcher returns {@code true}.

+ * + * @param matcher a required first matcher (must not be {@code null}) + * @param matchers additional matchers (zero or more, each must not be {@code null}) + * @return a matcher returning {@code true} if at least one matcher returns {@code true} + * + * @throws NullPointerException if any matcher is {@code null} + * + *

Example:

+ *
{@code
+	 * PathMatcher images = PathMatchers.anyOf(
+	 *     PathMatchers.hasExtension("png"),
+	 *     PathMatchers.hasExtension("jpg"),
+	 *     PathMatchers.hasExtension("gif")
+	 * );
+	 * }
+ */ + public static PathMatcher anyOf(PathMatcher matcher, PathMatcher... matchers) { + return anyOf(buildSafeList(matcher, matchers)); + } + + /** + * Returns a matcher that requires any of the provided matchers to succeed. + * + *

The returned matcher evaluates the matchers in sequence and stops + * at the first success. If the iterable is empty, an + * {@link IllegalArgumentException} is thrown.

+ * + * @param matchers the matchers to combine (must not be {@code null} or contain {@code null}) + * @return a matcher returning {@code true} if at least one matcher returns {@code true} + * + * @throws NullPointerException if {@code matchers} or any element is {@code null} + * @throws IllegalArgumentException if {@code matchers} is empty + */ + public static PathMatcher anyOf(Iterable matchers) { + return combineMatchers(matchers, Mode.ANY); + } + + private static List buildSafeList(PathMatcher matcher, PathMatcher... matchers) { + Objects.requireNonNull(matcher, "matcher is null"); + var list = new ArrayList(1 + (matchers == null ? 0 : matchers.length)); + list.add(matcher); + for (PathMatcher m : matchers) { + Objects.requireNonNull(m, "some matcher is null"); + list.add(m); + } + return List.copyOf(list); + } + + private enum Mode { + ALL, + ANY, + NONE + } + + private static PathMatcher combineMatchers(Iterable matchers, Mode mode) { + Objects.requireNonNull(matchers, "matchers is null"); + var list = new ArrayList(); + for (var m : matchers) { + Objects.requireNonNull(m, "some matcher is null"); + list.add(m); + } + if (list.isEmpty()) { + throw new IllegalArgumentException("No matcher provided"); + } + return path -> { + switch (mode) { + case ALL: + for (PathMatcher m : list) { + if (!m.matches(path)) + return false; + } + return true; + case ANY: + for (PathMatcher m : list) { + if (m.matches(path)) + return true; + } + return false; + case NONE: + for (PathMatcher m : list) { + if (m.matches(path)) + return false; + } + return true; + default: + throw new AssertionError("Unknown mode: " + mode); + } + }; + } + + /** + * Returns a conditional matcher. + * + *

The returned matcher applies {@code ifMatcher}. If it matches, the + * {@code thenMatcher} is applied, otherwise the {@code elseMatcher} is applied.

+ * + * @param ifMatcher condition to test + * @param thenMatcher matcher used if condition is true + * @param elseMatcher matcher used if condition is false + * + *

Example:

+ *
{@code
+	 * PathMatcher condition = PathMatchers.ifMatchesThenElse(
+	 *     PathMatchers.isDirectory(),
+	 *     PathMatchers.hasAnyDescendantMatching(PathMatchers.hasExtension("java")),
+	 *     PathMatchers.hasExtension("txt")
+	 * );
+	 * }
+ */ + public static PathMatcher ifMatchesThenElse(PathMatcher ifMatcher, PathMatcher thenMatcher, PathMatcher elseMatcher) { + Objects.requireNonNull(ifMatcher, "ifMatcher is null"); + Objects.requireNonNull(thenMatcher, "thenMatcher is null"); + Objects.requireNonNull(elseMatcher, "elseMatcher is null"); + return path -> ifMatcher.matches(path) ? thenMatcher.matches(path) : elseMatcher.matches(path); + } + + // ---------- Glob ---------- + + /** + * Creates a matcher testing the absolute, normalized path string + * against a glob expression. + * + *

The glob syntax is the same as accepted by + * {@link java.nio.file.FileSystem#getPathMatcher(String)} with a "glob:" prefix.

+ * + *

Special case: if {@code glob} is exactly "*", the matcher always returns {@code true}.

+ * + * @param glob glob pattern (e.g. {@code "**\*.java"}, {@code "*.log"}) + * @return a matcher applying the glob to {@link Path#toAbsolutePath()} + * + *

Example:

+ *
{@code
+	 * PathMatcher matcher = PathMatchers.hasAbsolutePathMatchingGlob("**\*.java");
+	 * matcher.matches(Path.of("/home/user/project/Main.java")); // true
+	 * matcher.matches(Path.of("/home/user/project/notes.txt")); // false
+	 * }
+ */ + public static PathMatcher hasAbsolutePathMatchingGlob(String glob) { + Objects.requireNonNull(glob, "glob is null"); + + // Optimization from Files.newDirectoryStream(Path dir, String glob) + if (glob.equals("*")) { + return path -> true; + } + + // Evaluate the glob pattern only once, not on every matcher evaluation + var matcher = FileSystems.getDefault().getPathMatcher("glob:" + glob); + return hasAbsolutePathMatching(matcher); + } + + /** + * Creates a matcher testing the relative path of a path + * with respect to a reference directory against a glob expression. + * + *

Both paths are normalized and made absolute before relativization.

+ * + *

Special case: if {@code glob} is exactly "*", the matcher always returns {@code true}.

+ * + * @param ref the reference path to relativize against + * @param glob glob pattern (e.g. {@code "*.md"}) + * @return a matcher applying the glob to {@code ref.relativize(path)} + * + * @throws IllegalArgumentException if {@code ref} and {@code path} are on different roots + * + *

Example:

+ *
{@code
+	 * Path base = Path.of("/home/user/project");
+	 * PathMatcher matcher = PathMatchers.hasRelativePathMatchingGlob(base, "**\*.java");
+	 *
+	 * matcher.matches(Path.of("/home/user/project/src/Main.java")); // true
+	 * matcher.matches(Path.of("/tmp/file.java")); // throws IllegalArgumentException
+	 * }
+ */ + public static PathMatcher hasRelativePathMatchingGlob(Path ref, String glob) { + Objects.requireNonNull(glob, "glob is null"); + Objects.requireNonNull(ref, "ref is null"); + + // Optimization from Files.newDirectoryStream(Path dir, String glob) + if (glob.equals("*")) { + return path -> true; + } + + // Evaluate the glob pattern only once, not on every matcher evaluation + var matcher = FileSystems.getDefault().getPathMatcher("glob:" + glob); + return hasRelativePathMatching(ref, matcher); + } + + /** + * Creates a matcher that applies another matcher to the path’s + * absolute, normalized form. + * + * @param matcher matcher to apply + * @return a matcher applying {@code matcher.matches(path.toAbsolutePath().normalize())} + * + *

Example:

+ *
{@code
+	 * PathMatcher endsWithPom = PathMatchers.hasAbsolutePathMatching(
+	 *     PathMatchers.hasName("pom.xml")
+	 * );
+	 * }
+ */ + public static PathMatcher hasAbsolutePathMatching(PathMatcher matcher) { + Objects.requireNonNull(matcher, "matcher is null"); + return path -> matcher.matches(path.normalize().toAbsolutePath()); + } + + /** + * Creates a matcher that applies another matcher to the relative path + * between a reference and the tested path. + * + *

Both paths are normalized and made absolute before relativization.

+ * + * @param ref reference path + * @param matcher matcher to apply to {@code ref.relativize(path)} + * @return matcher applying {@code matcher} to the relative path + * + * @throws IllegalArgumentException if {@code ref} and {@code path} are on different roots + */ + public static PathMatcher hasRelativePathMatching(Path ref, PathMatcher matcher) { + Objects.requireNonNull(matcher, "matcher is null"); + Objects.requireNonNull(ref, "ref is null"); + final var cleanedRef = ref.normalize().toAbsolutePath(); + return path -> { + final var cleanedPath = path.normalize().toAbsolutePath(); + if (!cleanedPath.getRoot().equals(cleanedRef.getRoot())) { + return false; // paths are not on the same root (e.g. C:/... vs D:/... on Windows) + } + var relativePath = cleanedRef.relativize(cleanedPath); + return matcher.matches(relativePath); + }; + } + + // ---------- Name ---------- + + private static final boolean hasFileName(Path path) { + return path.getFileName() != null; + } + + /** + * Matches paths whose file name is exactly equal to {@code name}. + * + *

If {@link Path#getFileName()} returns {@code null} (root paths), + * this matcher always returns {@code false}.

+ * + * @param name exact expected file name + * @return matcher returning true if {@code path.getFileName().toString().equals(name)} + * + *

Example:

+ *
{@code
+	 * PathMatcher matcher = PathMatchers.hasName("config.yaml");
+	 * matcher.matches(Path.of("/etc/config.yaml")); // true
+	 * matcher.matches(Path.of("/etc/CONFIG.YAML")); // false
+	 * }
+ */ + public static PathMatcher hasName(String name) { + Objects.requireNonNull(name, "name is null"); + return path -> hasFileName(path) && path.getFileName().toString().equals(name); + } + + /** + * Matches paths whose file name is equal to {@code name}, ignoring case. + * + *

If {@link Path#getFileName()} returns {@code null} (root paths), + * this matcher always returns {@code false}.

+ * + * @param name expected file name (case-insensitive) + * @return matcher returning true if names are equal ignoring case + * + *

Example:

+ *
{@code
+	 * PathMatcher matcher = PathMatchers.hasNameIgnoreCase("config.yaml");
+	 * matcher.matches(Path.of("/etc/config.yaml")); // true
+	 * matcher.matches(Path.of("/etc/CONFIG.YAML")); // true
+	 * }
+ */ + public static PathMatcher hasNameIgnoreCase(String name) { + Objects.requireNonNull(name, "name is null"); + return path -> hasFileName(path) && path.getFileName().toString().equalsIgnoreCase(name); + } + + /** + * Matches paths whose file name matches a regular expression. + * + *

The regex is tested using {@link Pattern#matcher(CharSequence)}.

+ * + *

If {@link Path#getFileName()} returns {@code null} (root paths), + * this matcher always returns {@code false}.

+ * + * @param pattern regex pattern + * @return matcher returning true if file name matches the regex + * + *

Example:

+ *
{@code
+	 * Pattern p = Pattern.compile("data-\\d+\\.csv");
+	 * PathMatcher matcher = PathMatchers.hasNameMatching(p);
+	 * matcher.matches(Path.of("data-123.csv")); // true
+	 * matcher.matches(Path.of("data.txt")); // false
+	 * }
+ */ + public static PathMatcher hasNameMatching(Pattern pattern) { + Objects.requireNonNull(pattern, "pattern is null"); + return path -> hasFileName(path) && pattern.matcher(path.getFileName().toString()).matches(); + } + + /** + * Matches paths whose file name matches a glob expression. + * + *

The glob is evaluated by {@link java.nio.file.FileSystem#getPathMatcher(String)}.

+ * + *

If {@link Path#getFileName()} returns {@code null} (root paths), + * this matcher always returns {@code false}.

+ * + *

Special case: if glob is exactly "*", the matcher always returns {@code true}.

+ * + * @param glob glob pattern (e.g. {@code "*.txt"}) + * @return matcher testing the file name against the glob + */ + public static PathMatcher hasNameMatchingGlob(String glob) { + Objects.requireNonNull(glob, "glob is null"); + + // Optimization from Files.newDirectoryStream(Path dir, String glob) + if (glob.equals("*")) { + return path -> true; + } + + // Evaluate the glob pattern only once, not on every matcher evaluation + var matcher = FileSystems.getDefault().getPathMatcher("glob:" + glob); + return path -> hasFileName(path) && matcher.matches(path.getFileName()); + } + + /** + * Matches paths whose file name starts with a prefix. + * + *

If {@link Path#getFileName()} returns {@code null}, this matcher + * always returns {@code false}.

+ * + * @param prefix required prefix (e.g. ".", "log-") + * @return matcher returning true if file name starts with prefix + */ + public static PathMatcher hasNameStartingWith(String prefix) { + Objects.requireNonNull(prefix, "prefix is null"); + return path -> hasFileName(path) && path.getFileName().toString().startsWith(prefix); + } + + /** + * Matches paths whose file name ends with a suffix. + * + *

If {@link Path#getFileName()} returns {@code null}, this matcher + * always returns {@code false}.

+ * + * @param suffix required suffix (e.g. ".log", ".bak") + * @return matcher returning true if file name ends with suffix + */ + public static PathMatcher hasNameEndingWith(String suffix) { + Objects.requireNonNull(suffix, "suffix is null"); + return path -> hasFileName(path) && path.getFileName().toString().endsWith(suffix); + } + + /** + * Matches paths whose file name ends with the given extension. + * + *

The extension must be provided without a leading dot.

+ *

If {@link Path#getFileName()} returns {@code null}, this matcher + * always returns {@code false}.

+ * + * @param extension required extension (without dot) + * @return matcher returning true if file name ends with ".{extension}" + * + *

Example:

+ *
{@code
+	 * PathMatcher matcher = PathMatchers.hasExtension("txt");
+	 * matcher.matches(Path.of("notes.txt")); // true
+	 * matcher.matches(Path.of("notes.TXT")); // false
+	 * }
+ */ + public static PathMatcher hasExtension(String extension) { + Objects.requireNonNull(extension, "extension is null"); + return hasNameEndingWith("." + extension); + } + + // ---------- Type ---------- + + /** + * Matches paths that are directories. + * + *

This calls {@link Files#isDirectory(Path, LinkOption...)} with + * {@link LinkOption#NOFOLLOW_LINKS}. Symbolic links are therefore + * not considered directories even if they point to one.

+ * + * @return matcher returning true if the path is a directory + */ + public static PathMatcher isDirectory() { + return path -> Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS); + } + + /** + * Matches paths that are files (not directories). + * + *

This is defined as {@code not(isDirectory())}. It will return {@code true} + * for regular files, symbolic links, and special filesystem objects such as sockets, + * device files, or FIFOs. In other words, anything that is not a directory is considered + * a "file" here.

+ * + *

⚠ Note: If you specifically need to test for regular files, + * use {@link #isRegularFile()}. If you specifically need to test for symbolic links, + * use {@link #isSymbolicLink()}.

+ * + * @return matcher returning {@code true} if the path is not a directory + */ + public static PathMatcher isFile() { + return not(isDirectory()); + } + + /** + * Matches paths that are regular files. + * + *

This method delegates to {@link Files#isRegularFile(Path, LinkOption...)} + * with {@link LinkOption#NOFOLLOW_LINKS}. Symbolic links, directories, and + * special files will return {@code false}.

+ * + * @return matcher returning {@code true} if the path is a regular file + */ + public static PathMatcher isRegularFile() { + return path -> Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS); + } + + /** + * Matches paths that are symbolic links. + * + *

This method delegates to {@link Files#isSymbolicLink(Path)}.

+ * + * @return matcher returning {@code true} if the path is a symbolic link + */ + public static PathMatcher isSymbolicLink() { + return path -> Files.isSymbolicLink(path); + } + + // ---------- Hierarchy ---------- + + /** + * Matches paths whose direct parent matches another matcher. + * + *

If the path has no parent (root paths), this matcher always returns false.

+ * + * @param parentMatcher matcher to apply to the parent + * @return matcher testing the direct parent + */ + public static PathMatcher hasDirectParentMatching(PathMatcher parentMatcher) { + Objects.requireNonNull(parentMatcher, "parentMatcher is null"); + + return path -> path.getParent() != null && parentMatcher.matches(path.getParent()); + } + + /** + * Matches paths having any ancestor that matches another matcher. + * + *

Ancestors are tested by walking up the chain of {@link Path#getParent()}. + * The search stops at the root or when a match is found.

+ * + * @param ancestorMatcher matcher to apply to each ancestor + * @return matcher returning true if any ancestor matches + */ + public static PathMatcher hasAnyAncestorMatching(PathMatcher ancestorMatcher) { + Objects.requireNonNull(ancestorMatcher, "ancestorMatcher is null"); + + return path -> { + Path parent = path.getParent(); + while (parent != null) { + if (ancestorMatcher.matches(parent)) { + return true; + } + parent = parent.getParent(); + } + return false; + }; + } + + /** + * Matches directories that have at least one direct child + * matching another matcher. + * + *

Non-directories always return false. Only immediate children + * are tested (depth = 1).

+ * + * @param childMatcher matcher applied to children + * @return matcher returning true if any direct child matches + */ + public static PathMatcher hasAnyDirectChildMatching(PathMatcher childMatcher) { + Objects.requireNonNull(childMatcher, "childMatcher is null"); + return path -> testDescendants(path, 1, childMatcher, p -> true); + } + + /** + * Matches directories that have at least one descendant + * (child, grandchild, etc.) matching another matcher. + * + *

Non-directories always return false. The directory itself + * is excluded from testing.

+ * + * @param descendantMatcher matcher applied to descendants + * @return matcher returning true if any descendant matches + * + * @throws UncheckedIOException if an I/O error occurs while walking + */ + public static PathMatcher hasAnyDescendantMatching(PathMatcher descendantMatcher) { + Objects.requireNonNull(descendantMatcher, "descendantMatcher is null"); + return path -> testDescendants(path, Integer.MAX_VALUE, descendantMatcher, p -> true); + } + + /** + * Matches paths that have at least one sibling matching another matcher. + * + *

Siblings are other entries in the same parent directory. + * The path itself is excluded from testing.

+ * + *

If the path has no parent (is root), this matcher always returns false.

+ * + * @param siblingMatcher matcher applied to siblings + * @return matcher returning true if any sibling matches + */ + public static PathMatcher hasSiblingMatching(PathMatcher siblingMatcher) { + Objects.requireNonNull(siblingMatcher, "siblingMatcher is null"); + return path -> path.getParent() != null && testDescendants(path.getParent(), 1, siblingMatcher, sibling -> !sibling.equals(path)); + } + + private static final boolean testDescendants(Path path, int depth, PathMatcher descendantMatcher, Predicate inclusionFilter) { + if (!isDirectory().matches(path)) { + return false; + } + try (Stream stream = Files.walk(path, depth)) { // Files.walk() do NOT follow symlink by default + return stream + .skip(1) // skip the root path itself + .filter(inclusionFilter) + .anyMatch(p -> descendantMatcher.matches(p)); + } catch (IOException e) { + throw new UncheckedIOException("Exception while walking files of " + path, e); + } + } + +} diff --git a/src/main/java/io/github/computerdaddyguy/jfiletreeprettyprinter/PathPredicateBuilder.java b/src/main/java/io/github/computerdaddyguy/jfiletreeprettyprinter/PathPredicateBuilder.java deleted file mode 100644 index 08d903b..0000000 --- a/src/main/java/io/github/computerdaddyguy/jfiletreeprettyprinter/PathPredicateBuilder.java +++ /dev/null @@ -1,297 +0,0 @@ -package io.github.computerdaddyguy.jfiletreeprettyprinter; - -import java.io.File; -import java.nio.file.Path; -import java.nio.file.PathMatcher; -import java.util.Objects; -import java.util.function.Predicate; -import java.util.regex.Pattern; -import org.jspecify.annotations.NullMarked; -import org.jspecify.annotations.Nullable; - -/** - * A builder for composing complex {@link Predicate Predicates} on {@link Path} objects. - *

- * Predicates are combined using logical {@code and}, allowing you to chain multiple conditions. - * - *

Example usage:

- *
{@code
- * var predicate = PathPredicates.builder()
- *     .isDirectory()
- *     .hasNameIgnoreCase("src")
- *     .build();
- *
- * // Tests true only for directories named "src" (case-insensitive).
- * boolean matches = predicate.test(Path.of("src"));
- * }
- */ -@NullMarked -public class PathPredicateBuilder { - - @Nullable - private Predicate combinedPredicate; - - /** - * Creates a new builder. - */ - public PathPredicateBuilder() { - combinedPredicate = null; - } - - /** - * Builds the composed predicate. - * - * @return the final combined predicate, null if no predicate set - */ - @Nullable - public Predicate build() { - return combinedPredicate; - } - - // ---------- General predicates ---------- - - /** - * Adds the given path-based predicate to the chain. - * - * @param predicate predicate to add - * - * @return this builder for chaining - */ - public PathPredicateBuilder pathTest(Predicate predicate) { - Objects.requireNonNull(predicate, "predicate is null"); - combinedPredicate = combinedPredicate == null ? predicate : combinedPredicate.and(predicate); - return this; - } - - /** - * Adds the given file-based predicate to the chain. - *

- * The {@link Path} is converted to a {@link File} using {@code path.toFile()}. - * - * @param predicate predicate to add - * - * @return this builder for chaining - */ - public PathPredicateBuilder fileTest(Predicate predicate) { - return pathTest(path -> predicate.test(path.toFile())); - } - - // ---------- PathMatcher ---------- - - /** - * Adds a condition that tests whether the path matches the specified glob pattern. - * - * @param glob the glob pattern to match; must not be {@code null} - * - * @return this builder for chaining - */ - public PathPredicateBuilder hasFullPathMatchingGlob(String glob) { - Objects.requireNonNull(glob, "glob is null"); - return pathTest(path -> PathPredicates.hasFullPathMatchingGlob(path, glob)); - } - - /** - * Adds a condition that tests whether the path matches the provided {@link PathMatcher}. - * - * @param matcher the {@code PathMatcher} to use; must not be {@code null} - * - * @return this builder for chaining - */ - public PathPredicateBuilder hasFullPathMatching(PathMatcher matcher) { - Objects.requireNonNull(matcher, "matcher is null"); - return pathTest(path -> PathPredicates.hasFullPathMatching(path, matcher)); - } - - // ---------- Name ---------- - - /** - * Adds a condition that tests whether the path has exactly - * the specified file name. - * - * @param name the expected file name (without parent directories) - * - * @return this builder for chaining - */ - public PathPredicateBuilder hasName(String name) { - Objects.requireNonNull(name, "name is null"); - return pathTest(path -> PathPredicates.hasName(path, name)); - } - - /** - * Adds a condition that tests whether the path has the specified - * file name, ignoring case. - * - * @param name the expected file name (without parent directories), case-insensitive - * - * @return this builder for chaining - */ - public PathPredicateBuilder hasNameIgnoreCase(String name) { - Objects.requireNonNull(name, "name is null"); - return pathTest(path -> PathPredicates.hasNameIgnoreCase(path, name)); - } - - /** - * Adds a condition that tests whether the given path's file name matches the provided pattern. - * - * @param pattern the regex pattern to apply to the file name - * - * @return this builder for chaining - */ - public PathPredicateBuilder hasNameMatching(Pattern pattern) { - Objects.requireNonNull(pattern, "pattern is null"); - return pathTest(path -> PathPredicates.hasNameMatching(path, pattern)); - } - - /** - * Adds a condition that tests whether the file name of the given path - * matches the specified glob pattern. - * - *

Note: Only the file name (the last element of the path) is tested, - * not the entire path. For example, {@code "*.txt"} will match {@code "file.txt"}. - * - *

The glob syntax follows {@link java.nio.file.FileSystem#getPathMatcher(String)} conventions. - * - * @param glob the glob pattern to match against the file name; must not be {@code null} - * - * @return this builder for chaining - * - * @see #hasFullPathMatchingGlob(String) - */ - public PathPredicateBuilder hasNameMatchingGlob(String glob) { - Objects.requireNonNull(glob, "glob is null"); - return pathTest(path -> PathPredicates.hasNameMatchingGlob(path, glob)); - } - - /** - * Adds a condition that tests whether the given path's file name ends with the specified suffix. - * - * @param suffix the suffix to test (e.g. ".log", ".txt") - * - * @return this builder for chaining - */ - public PathPredicateBuilder hasNameEndingWith(String suffix) { - Objects.requireNonNull(suffix, "suffix is null"); - return pathTest(path -> PathPredicates.hasNameEndingWith(path, suffix)); - } - - /** - * Adds a condition that tests whether the given path's file name has the specified extension. - *

- * The extension should be provided without a leading dot, e.g. - * {@code "txt"} or {@code "pdf"}. - *

- * - * @param extension the extension to test (without the dot) - * - * @return this builder for chaining - */ - public PathPredicateBuilder hasExtension(String extension) { - Objects.requireNonNull(extension, "extension is null"); - return pathTest(path -> PathPredicates.hasExtension(path, extension)); - } - - // ---------- Type ---------- - - /** - * Adds a condition that tests whether the path represents a directory. - * - * @return this builder for chaining - */ - public PathPredicateBuilder isDirectory() { - return pathTest(PathPredicates::isDirectory); - } - - /** - * Adds a condition that tests whether the path represents a file. - * - * @return this builder for chaining - */ - public PathPredicateBuilder isFile() { - return pathTest(PathPredicates::isFile); - } - - // ---------- Hierarchy ---------- - - /** - * Adds a condition that tests the direct parent of the path. - * - * @param parentPredicate the predicate to apply on the direct parent - * - * @return this builder for chaining - * - * @see PathPredicates#hasParentMatching(Predicate) - */ - public PathPredicateBuilder hasParentMatching(Predicate parentPredicate) { - Objects.requireNonNull(parentPredicate, "parentPredicate is null"); - return pathTest(path -> PathPredicates.hasParentMatching(path, parentPredicate)); - } - - /** - * Adds a condition that tests all ancestors of the path (stopping at the first match). - * - * The condition is satisfied if the given {@code ancestorPredicate} evaluates - * to {@code true} for any ancestor in the {@link Path#getParent()} chain. - * - * @param ancestorPredicate the predicate to apply on each ancestor - * - * @return this builder for chaining - * - * @see PathPredicates#hasAncestorMatching(Predicate) - */ - public PathPredicateBuilder hasAncestorMatching(Predicate ancestorPredicate) { - Objects.requireNonNull(ancestorPredicate, "ancestorPredicate is null"); - return pathTest(path -> PathPredicates.hasAncestorMatching(path, ancestorPredicate)); - } - - /** - * Adds a condition that tests the direct children of the path. - * - * The condition is satisfied if the given {@code childPredicate} evaluates - * to {@code true} for at least one direct child of the tested path. - * - * @param childPredicate the predicate to apply on each direct child - * - * @return this builder for chaining - * - * @see PathPredicates#hasDirectChildMatching(Predicate) - */ - public PathPredicateBuilder hasDirectChildMatching(Predicate childPredicate) { - Objects.requireNonNull(childPredicate, "childPredicate is null"); - return pathTest(path -> PathPredicates.hasDirectChildMatching(path, childPredicate)); - } - - /** - * Adds a condition that tests all descendants of the path (children at any depth). - * - * The condition is satisfied if the given {@code descendantPredicate} evaluates - * to {@code true} for at least one descendant in the directory tree. - * - * @param descendantPredicate the predicate to apply on each descendant - * - * @return this builder for chaining - * - * @see PathPredicates#hasDescendantMatching(Predicate) - */ - public PathPredicateBuilder hasDescendantMatching(Predicate descendantPredicate) { - Objects.requireNonNull(descendantPredicate, "descendantPredicate is null"); - return pathTest(path -> PathPredicates.hasDescendantMatching(path, descendantPredicate)); - } - - /** - * Adds a condition that tests the siblings of the path. - * - * The condition is satisfied if the given {@code siblingPredicate} evaluates - * to {@code true} for at least one sibling of the tested path. - * - * @param siblingPredicate the predicate to apply on each sibling - * - * @return this builder for chaining - * - * @see PathPredicates#hasSiblingMatching(Predicate) - */ - public PathPredicateBuilder hasSiblingMatching(Predicate siblingPredicate) { - Objects.requireNonNull(siblingPredicate, "siblingPredicate is null"); - return pathTest(path -> PathPredicates.hasSiblingMatching(path, siblingPredicate)); - } - -} \ No newline at end of file diff --git a/src/main/java/io/github/computerdaddyguy/jfiletreeprettyprinter/PathPredicates.java b/src/main/java/io/github/computerdaddyguy/jfiletreeprettyprinter/PathPredicates.java deleted file mode 100644 index 008b344..0000000 --- a/src/main/java/io/github/computerdaddyguy/jfiletreeprettyprinter/PathPredicates.java +++ /dev/null @@ -1,396 +0,0 @@ -package io.github.computerdaddyguy.jfiletreeprettyprinter; - -import java.io.File; -import java.io.IOException; -import java.io.UncheckedIOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.PathMatcher; -import java.util.Objects; -import java.util.function.Predicate; -import java.util.regex.Pattern; -import java.util.stream.Stream; -import org.jspecify.annotations.NullMarked; - -/** - * Utility class providing common {@link Predicate} implementations - * and helper methods for testing files and directories. - *

- * All methods are {@code static} and can be used directly or wrapped into - * a {@link Predicate} for filtering paths. - *

- * - * This class is not instantiable. - */ -@NullMarked -public final class PathPredicates { - - private PathPredicates() { - // Helper class - } - - /** - * Creates a new builder, to create advanced predicate. - * - * @return a new builder - * - * @see PathPredicateBuilder - */ - public static PathPredicateBuilder builder() { - return new PathPredicateBuilder(); - } - - // ---------- PathMatcher ---------- - - /** - * Tests whether the given {@link Path} matches the specified glob pattern. - * - *

The glob syntax follows {@link java.nio.file.FileSystem#getPathMatcher(String)} conventions. - * - * @param path the path to test; must not be {@code null} - * @param glob the glob pattern; must not be {@code null} - * - * @return {@code true} if the path matches the glob pattern, {@code false} otherwise - * - * @throws NullPointerException if {@code path} or {@code glob} is {@code null} - * - * @see #hasNameMatchingGlob(Path, String) - * @see #hasFullPathMatching(Path, PathMatcher) - */ - public static boolean hasFullPathMatchingGlob(Path path, String glob) { - Objects.requireNonNull(path, "path is null"); - Objects.requireNonNull(glob, "glob is null"); - - // From Files.newDirectoryStream(Path dir, String glob) - if (glob.equals("*")) { - return true; - } - var matcher = path.getFileSystem().getPathMatcher("glob:" + glob); - var result = matcher.matches(path); - return result; - } - - /** - * Checks if the given {@link Path} matches the provided {@link PathMatcher}. - * - * @param path the path to test; must not be {@code null} - * @param matcher the {@code PathMatcher} to use; must not be {@code null} - * - * @return {@code true} if the path matches the matcher, {@code false} otherwise - * - * @throws NullPointerException if {@code path} or {@code matcher} is {@code null} - * - * @see #hasFullPathMatchingGlob(Path, String) - */ - public static boolean hasFullPathMatching(Path path, PathMatcher matcher) { - Objects.requireNonNull(path, "path is null"); - Objects.requireNonNull(matcher, "matcher is null"); - return matcher.matches(path); - } - - // ---------- Name ---------- - - /** - * Tests whether the given path has exactly the specified file name. - * - * @param path the path to test - * @param name the expected file name (without parent directories) - * - * @return {@code true} if the path's file name equals {@code name} - * - * @throws NullPointerException if {@code path} or {@code name} is {@code null} - */ - public static boolean hasName(Path path, String name) { - Objects.requireNonNull(path, "path is null"); - Objects.requireNonNull(name, "name is null"); - return path.getFileName().toString().equals(name); - } - - /** - * Tests whether the given path has the specified file name, - * ignoring case. - * - * @param path the path to test - * @param name the expected file name (case-insensitive) - * - * @return {@code true} if the path's file name equals {@code name}, ignoring case - * - * @throws NullPointerException if {@code path} or {@code name} is {@code null} - */ - public static boolean hasNameIgnoreCase(Path path, String name) { - Objects.requireNonNull(path, "path is null"); - Objects.requireNonNull(name, "name is null"); - return path.getFileName().toString().equalsIgnoreCase(name); - } - - /** - * Tests whether the given path's file name matches the provided pattern. - * - * @param path the path to test - * @param pattern the regex pattern to apply to the file name - * - * @return {@code true} if the file name matches the pattern - * - * @throws NullPointerException if {@code path} or {@code pattern} is {@code null} - */ - public static boolean hasNameMatching(Path path, Pattern pattern) { - Objects.requireNonNull(path, "path is null"); - Objects.requireNonNull(pattern, "pattern is null"); - return pattern.matcher(path.getFileName().toString()).matches(); - } - - /** - * Tests whether the given path's file name matches the provided glob. - * - *

The glob syntax follows {@link java.nio.file.FileSystem#getPathMatcher(String)} conventions. - * - * @param path the path to test - * @param glob the glob pattern to match against the file name; must not be {@code null} - * - * @return {@code true} if the file name matches the glob - * - * @throws NullPointerException if {@code path} or {@code glob} is {@code null} - * - * @see #hasFullPathMatchingGlob(Path, String) - */ - public static boolean hasNameMatchingGlob(Path path, String glob) { - Objects.requireNonNull(path, "path is null"); - Objects.requireNonNull(glob, "glob is null"); - return hasFullPathMatchingGlob(path.getFileName(), glob); - } - - /** - * Tests whether the given path's file name starts with the specified suffix. - * - * @param path the path to test - * @param prefix the prefix to test (e.g. ".", "analysis-") - * - * @return {@code true} if the file name starts with the given prefix - * - * @throws NullPointerException if {@code path} or {@code prefix} is {@code null} - */ - public static boolean hasNameStartingWith(Path path, String prefix) { - Objects.requireNonNull(path, "path is null"); - Objects.requireNonNull(prefix, "prefix is null"); - return path.getFileName().toString().startsWith(prefix); - } - - /** - * Tests whether the given path's file name ends with the specified suffix. - * - * @param path the path to test - * @param suffix the suffix to test (e.g. ".log", ".txt") - * - * @return {@code true} if the file name ends with the given suffix - * - * @throws NullPointerException if {@code path} or {@code suffix} is {@code null} - */ - public static boolean hasNameEndingWith(Path path, String suffix) { - Objects.requireNonNull(path, "path is null"); - Objects.requireNonNull(suffix, "suffix is null"); - return path.getFileName().toString().endsWith(suffix); - } - - /** - * Tests whether the given path's file name has the specified extension. - *

- * The extension should be provided without a leading dot, e.g. - * {@code "txt"} or {@code "pdf"}. - *

- * - * @param path the path to test - * @param extension the extension to test (without the dot) - * - * @return {@code true} if the file name ends with {@code "." + extension} - * - * @throws NullPointerException if {@code path} or {@code extension} is {@code null} - */ - public static boolean hasExtension(Path path, String extension) { - Objects.requireNonNull(path, "path is null"); - Objects.requireNonNull(extension, "extension is null"); - return hasNameEndingWith(path, "." + extension); - } - - // ---------- Type ---------- - - /** - * Tests whether the given path represents a directory. - * - * @param path the path to test - * - * @return {@code true} if the path is a directory - * - * @throws NullPointerException if {@code path} is {@code null} - */ - public static boolean isDirectory(Path path) { - Objects.requireNonNull(path, "path is null"); - return path.toFile().isDirectory(); - } - - /** - * Tests whether the given path represents a file. - * - * @param path the path to test - * - * @return {@code true} if the path is a file - * - * @throws NullPointerException if {@code path} is {@code null} - */ - public static boolean isFile(Path path) { - Objects.requireNonNull(path, "path is null"); - return path.toFile().isFile(); - } - - // ---------- Hierarchy ---------- - - /** - * Tests whether the direct parent of the given path matches the provided predicate. - * - * @param path the path whose parent should be tested (must not be {@code null}) - * @param parentPredicate the predicate to apply to the direct parent (must not be {@code null}) - * - * @return {@code true} if the path has a parent and that parent matches the predicate, - * {@code false} otherwise - * - * @throws NullPointerException if {@code path} or {@code parentPredicate} is {@code null} - */ - public static boolean hasParentMatching(Path path, Predicate parentPredicate) { - Objects.requireNonNull(path, "path is null"); - Objects.requireNonNull(parentPredicate, "parentPredicate is null"); - - if (path.getParent() == null) { - return false; - } - return parentPredicate.test(path.getParent()); - } - - /** - * Tests whether any ancestor of the given path matches the provided predicate. - * - *

The test is applied recursively up the parent chain (using {@link Path#getParent()}) - * until the root is reached or the predicate returns {@code true}. - * - * @param path the path whose ancestors should be tested (must not be {@code null}) - * @param ancestorPredicate the predicate to apply to each ancestor (must not be {@code null}) - * - * @return {@code true} if any ancestor of the path matches the predicate, - * {@code false} otherwise - * - * @throws NullPointerException if {@code path} or {@code ancestorPredicate} is {@code null} - */ - public static boolean hasAncestorMatching(Path path, Predicate ancestorPredicate) { - Objects.requireNonNull(path, "path is null"); - Objects.requireNonNull(ancestorPredicate, "ancestorPredicate is null"); - - Path parent = path.getParent(); - while (parent != null) { - if (ancestorPredicate.test(parent)) { - return true; - } - parent = parent.getParent(); - } - return false; - } - - /** - * Tests whether the given path has at least one direct child that matches the provided predicate. - * - *

The method checks only immediate children of the path (not recursive). - * If the path is not a directory, the result is always {@code false}. - * - * @param path the directory whose direct children should be tested (must not be {@code null}) - * @param childPredicate the predicate to apply to each direct child (must not be {@code null}) - * - * @return {@code true} if any direct child matches the predicate, - * {@code false} if none match, or if the path is not a directory - * - * @throws NullPointerException if {@code path} or {@code childPredicate} is {@code null} - */ - public static boolean hasDirectChildMatching(Path path, Predicate childPredicate) { - Objects.requireNonNull(path, "path is null"); - Objects.requireNonNull(childPredicate, "childPredicate is null"); - - File file = path.toFile(); - if (!file.isDirectory()) { - return false; - } - File[] children = file.listFiles(); - if (children == null) { // Only if I/O error occurred when listing files - return false; - } - for (File child : children) { - if (childPredicate.test(child.toPath())) { - return true; - } - } - return false; - } - - /** - * Tests whether the given path has at least one descendant (child, grandchild, etc.) - * that matches the provided predicate. - * - *

The method walks the file tree starting at the given path, excluding the path itself, - * and applies the predicate to all discovered descendants. - * If the path is not a directory, the result is always {@code false}. - * - * @param path the directory whose descendants should be tested (must not be {@code null}) - * @param descendantPredicate the predicate to apply to each descendant (must not be {@code null}) - * - * @return {@code true} if any descendant matches the predicate, - * {@code false} if none match or if the path is not a directory - * - * @throws NullPointerException if {@code path} or {@code descendantPredicate} is {@code null} - * @throws UncheckedIOException if an I/O error occurs while traversing the directory - */ - public static boolean hasDescendantMatching(Path path, Predicate descendantPredicate) { - Objects.requireNonNull(path, "path is null"); - Objects.requireNonNull(descendantPredicate, "descendantPredicate is null"); - File file = path.toFile(); - if (!file.isDirectory()) { - return false; - } - try (Stream stream = Files.walk(path)) { - return stream - .skip(1) // skip the root path itself - .anyMatch(descendantPredicate); - } catch (IOException e) { - throw new UncheckedIOException("Exception while walking files of " + path, e); - } - } - - /** - * Tests whether the given path has at least one sibling that matches the provided predicate. - * - *

The siblings are the other entries in the same parent directory. - * The path itself is excluded from testing. If the path has no parent, - * the result is always {@code false}. - * - * @param path the path whose siblings should be tested (must not be {@code null}) - * @param siblingPredicate the predicate to apply to each sibling (must not be {@code null}) - * - * @return {@code true} if any sibling matches the predicate, - * {@code false} if none match or if the path has no parent - * - * @throws NullPointerException if {@code path} or {@code siblingPredicate} is {@code null} - */ - public static boolean hasSiblingMatching(Path path, Predicate siblingPredicate) { - Objects.requireNonNull(path, "path is null"); - Objects.requireNonNull(siblingPredicate, "siblingPredicate is null"); - Path parent = path.getParent(); - if (parent == null) { - return false; - } - File[] siblings = parent.toFile().listFiles(); - if (siblings == null) { // Only if I/O error occurred when listing files - return false; - } - for (File sibling : siblings) { - if (!sibling.toPath().equals(path) && siblingPredicate.test(sibling.toPath())) { - return true; - } - } - return false; - } - -} diff --git a/src/main/java/io/github/computerdaddyguy/jfiletreeprettyprinter/PrettyPrintOptions.java b/src/main/java/io/github/computerdaddyguy/jfiletreeprettyprinter/PrettyPrintOptions.java index 2bada28..2f52829 100644 --- a/src/main/java/io/github/computerdaddyguy/jfiletreeprettyprinter/PrettyPrintOptions.java +++ b/src/main/java/io/github/computerdaddyguy/jfiletreeprettyprinter/PrettyPrintOptions.java @@ -3,10 +3,10 @@ import io.github.computerdaddyguy.jfiletreeprettyprinter.renderer.RenderingOptions; import io.github.computerdaddyguy.jfiletreeprettyprinter.scanner.ScanningOptions; import java.nio.file.Path; +import java.nio.file.PathMatcher; import java.util.Comparator; import java.util.Objects; import java.util.function.Function; -import java.util.function.Predicate; import java.util.function.ToIntFunction; import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; @@ -297,14 +297,12 @@ public PrettyPrintOptions sort(Comparator pathComparator) { // ---------- Filtering ---------- - private Predicate dirFilter = dir -> true; - private Predicate fileFilter = dir -> true; + private PathMatcher dirMatcher = dir -> true; + private PathMatcher fileMatcher = dir -> true; @Override - public Predicate pathFilter() { - return path -> PathPredicates.isDirectory(path) - ? dirFilter.test(path) - : fileFilter.test(path); + public PathMatcher pathFilter() { + return PathMatchers.ifMatchesThenElse(PathMatchers.isDirectory(), dirMatcher, fileMatcher); } /** @@ -312,10 +310,10 @@ public Predicate pathFilter() { * * Directories that do not pass this filter will not be displayed. * - * @param filter The filter to apply on directories, cannot be null + * @param matcher The filter to apply on directories, cannot be null */ - public PrettyPrintOptions filterDirectories(@Nullable Predicate filter) { - this.dirFilter = Objects.requireNonNull(filter, "filter is null"); + public PrettyPrintOptions filterDirectories(@Nullable PathMatcher matcher) { + this.dirMatcher = Objects.requireNonNull(matcher, "matcher is null"); return this; } @@ -324,10 +322,10 @@ public PrettyPrintOptions filterDirectories(@Nullable Predicate filter) { * * Files that do not pass this filter will not be displayed. * - * @param filter The filter to apply on files, cannot be null + * @param matcher The filter to apply on files, cannot be null */ - public PrettyPrintOptions filterFiles(@Nullable Predicate filter) { - this.fileFilter = Objects.requireNonNull(filter, "filter is null"); + public PrettyPrintOptions filterFiles(@Nullable PathMatcher matcher) { + this.fileMatcher = Objects.requireNonNull(matcher, "matcher is null"); return this; } diff --git a/src/main/java/io/github/computerdaddyguy/jfiletreeprettyprinter/scanner/DefaultPathToTreeScanner.java b/src/main/java/io/github/computerdaddyguy/jfiletreeprettyprinter/scanner/DefaultPathToTreeScanner.java index a816563..dd881e0 100644 --- a/src/main/java/io/github/computerdaddyguy/jfiletreeprettyprinter/scanner/DefaultPathToTreeScanner.java +++ b/src/main/java/io/github/computerdaddyguy/jfiletreeprettyprinter/scanner/DefaultPathToTreeScanner.java @@ -1,6 +1,6 @@ package io.github.computerdaddyguy.jfiletreeprettyprinter.scanner; -import io.github.computerdaddyguy.jfiletreeprettyprinter.PathPredicates; +import io.github.computerdaddyguy.jfiletreeprettyprinter.PathMatchers; import io.github.computerdaddyguy.jfiletreeprettyprinter.scanner.TreeEntry.DirectoryEntry; import io.github.computerdaddyguy.jfiletreeprettyprinter.scanner.TreeEntry.FileEntry; import io.github.computerdaddyguy.jfiletreeprettyprinter.scanner.TreeEntry.MaxDepthReachEntry; @@ -10,12 +10,12 @@ import java.nio.file.DirectoryStream; 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.Iterator; import java.util.List; import java.util.Objects; -import java.util.function.Predicate; import java.util.stream.StreamSupport; import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; @@ -30,19 +30,20 @@ public DefaultPathToTreeScanner(ScanningOptions options) { } @Override - public TreeEntry scan(Path fileOrDir) { - return handle(fileOrDir, 0, fileOrDir.relativize(fileOrDir).resolve("."), options.pathFilter()); + public TreeEntry scan(Path root) { + var cleanedRoot = root.normalize().toAbsolutePath(); // required to avoid "./" at root when calling prettyPrint(".") + return handle(cleanedRoot, 0, cleanedRoot, options.pathFilter()); } @Nullable - private TreeEntry handle(Path root, int depth, Path fileOrDir, Predicate filter) { - return PathPredicates.isDirectory(fileOrDir) + private TreeEntry handle(Path root, int depth, Path fileOrDir, PathMatcher filter) { + return PathMatchers.isDirectory().matches(fileOrDir) ? handleDirectory(root, depth, fileOrDir, filter) : handleFile(fileOrDir); } @Nullable - private TreeEntry handleDirectory(Path root, int depth, Path dir, Predicate filter) { + private TreeEntry handleDirectory(Path root, int depth, Path dir, PathMatcher filter) { if (depth >= options.getMaxDepth()) { var maxDepthEntry = new MaxDepthReachEntry(depth); @@ -51,8 +52,8 @@ private TreeEntry handleDirectory(Path root, int depth, Path dir, Predicate childEntries; - try (var childrenStream = Files.newDirectoryStream(dir, path -> filter.test(path))) { - var it = directoryStreamToIterator(childrenStream, filter); + try (var childrenStream = Files.newDirectoryStream(dir, path -> filter.matches(path))) { + var it = directoryStreamToIterator(childrenStream); childEntries = handleDirectoryChildren(root, depth, dir, it, filter); } catch (IOException e) { throw new UncheckedIOException("Unable to list files for directory: " + dir, e); @@ -61,7 +62,7 @@ private TreeEntry handleDirectory(Path root, int depth, Path dir, Predicate handleDirectoryChildren(Path root, int depth, Path dir, Iterator pathIterator, Predicate filter) { + private List handleDirectoryChildren(Path root, int depth, Path dir, Iterator pathIterator, PathMatcher filter) { var childEntries = new ArrayList(); int maxChildEntries = options.getChildLimit().applyAsInt(dir); @@ -90,7 +91,7 @@ private List handleDirectoryChildren(Path root, int depth, Path dir, return childEntries; } - private List handleLeftOverChildren(Path root, int depth, Iterator pathIterator, Predicate filter) { + private List handleLeftOverChildren(Path root, int depth, Iterator pathIterator, PathMatcher filter) { var childEntries = new ArrayList(); var skippedChildren = new ArrayList(); @@ -109,7 +110,7 @@ private List handleLeftOverChildren(Path root, int depth, Iterator directoryStreamToIterator(DirectoryStream childrenStream, Predicate filter) { + private Iterator directoryStreamToIterator(DirectoryStream childrenStream) { return StreamSupport .stream(childrenStream.spliterator(), false) .sorted(options.pathComparator()) diff --git a/src/main/java/io/github/computerdaddyguy/jfiletreeprettyprinter/scanner/ScanningOptions.java b/src/main/java/io/github/computerdaddyguy/jfiletreeprettyprinter/scanner/ScanningOptions.java index ffdf9b2..69bde3a 100644 --- a/src/main/java/io/github/computerdaddyguy/jfiletreeprettyprinter/scanner/ScanningOptions.java +++ b/src/main/java/io/github/computerdaddyguy/jfiletreeprettyprinter/scanner/ScanningOptions.java @@ -1,8 +1,8 @@ package io.github.computerdaddyguy.jfiletreeprettyprinter.scanner; import java.nio.file.Path; +import java.nio.file.PathMatcher; import java.util.Comparator; -import java.util.function.Predicate; import java.util.function.ToIntFunction; import org.jspecify.annotations.NullMarked; @@ -15,6 +15,6 @@ public interface ScanningOptions { Comparator pathComparator(); - Predicate pathFilter(); + PathMatcher pathFilter(); } diff --git a/src/test/java/io/github/computerdaddyguy/jfiletreeprettyprinter/ChildLimitDynamicTest.java b/src/test/java/io/github/computerdaddyguy/jfiletreeprettyprinter/ChildLimitDynamicTest.java index d4ac90a..41ba666 100644 --- a/src/test/java/io/github/computerdaddyguy/jfiletreeprettyprinter/ChildLimitDynamicTest.java +++ b/src/test/java/io/github/computerdaddyguy/jfiletreeprettyprinter/ChildLimitDynamicTest.java @@ -15,11 +15,14 @@ class ChildLimitDynamicTest { private FileTreePrettyPrinter printer = FileTreePrettyPrinter.builder() .customizeOptions( + // @formatter:off options -> options.withChildLimit( - p -> PathPredicates.hasName(p, "limit_1") ? 1 : - PathPredicates.hasName(p, "limit_3") ? 3 : - -1 + ChildLimitBuilder.builder() + .limit(PathMatchers.hasName("limit_1"), 1) + .limit(PathMatchers.hasName("limit_3"), 3) + .defaultLimit(ChildLimitBuilder.UNLIMITED) + .build() ) // @formatter:on ) diff --git a/src/test/java/io/github/computerdaddyguy/jfiletreeprettyprinter/FilteringTest.java b/src/test/java/io/github/computerdaddyguy/jfiletreeprettyprinter/FilteringTest.java index aee4d26..f31dc1f 100644 --- a/src/test/java/io/github/computerdaddyguy/jfiletreeprettyprinter/FilteringTest.java +++ b/src/test/java/io/github/computerdaddyguy/jfiletreeprettyprinter/FilteringTest.java @@ -16,7 +16,7 @@ class FilteringTest { @Test void example_file() { - var filter = PathPredicates.builder().hasExtension("java").build(); + var filter = PathMatchers.hasExtension("java"); FileTreePrettyPrinter printer = FileTreePrettyPrinter.builder() .customizeOptions(options -> options.filterFiles(filter)) .build(); @@ -40,7 +40,7 @@ void example_file() { @Test void example_dir() { - var filter = PathPredicates.builder().hasNameEndingWith("no_java_file").build(); + var filter = PathMatchers.hasNameEndingWith("no_java_file"); FileTreePrettyPrinter printer = FileTreePrettyPrinter.builder() .customizeOptions(options -> options.filterDirectories(filter)) .build(); @@ -58,7 +58,7 @@ void example_dir() { @Test void example_and_sorting() { - var filter = PathPredicates.builder().hasExtension("java").build(); + var filter = PathMatchers.hasExtension("java"); FileTreePrettyPrinter printer = FileTreePrettyPrinter.builder() .customizeOptions(options -> options.sort(Sorts.BY_NAME.reversed())) .customizeOptions(options -> options.filterFiles(filter)) @@ -83,7 +83,7 @@ void example_and_sorting() { @Test void example_childLimit_1() { - var filter = PathPredicates.builder().hasExtension("java").build(); + var filter = PathMatchers.hasExtension("java"); FileTreePrettyPrinter printer = FileTreePrettyPrinter.builder() .customizeOptions(options -> options.withChildLimit(1)) .customizeOptions(options -> options.filterFiles(filter)) @@ -102,7 +102,7 @@ void example_childLimit_1() { @Test void example_childLimit_2() { - var filter = PathPredicates.builder().hasExtension("java").build(); + var filter = PathMatchers.hasExtension("java"); FileTreePrettyPrinter printer = FileTreePrettyPrinter.builder() .customizeOptions(options -> options.withChildLimit(2)) .customizeOptions(options -> options.filterFiles(filter)) @@ -126,7 +126,7 @@ void example_childLimit_2() { @Test void example_childLimit_3() { - var filter = PathPredicates.builder().hasExtension("java").build(); + var filter = PathMatchers.hasExtension("java"); FileTreePrettyPrinter printer = FileTreePrettyPrinter.builder() .customizeOptions(options -> options.withChildLimit(3)) .customizeOptions(options -> options.filterFiles(filter)) @@ -169,8 +169,8 @@ void example_compact_dir() { .getPath(); // @formatter:on - var dirFilter = PathPredicates.builder().hasNameEndingWith("1").build(); - var fileFilter = PathPredicates.builder().hasExtension("java").build(); + var dirFilter = PathMatchers.hasNameEndingWith("1"); + var fileFilter = PathMatchers.hasExtension("java"); FileTreePrettyPrinter printer = FileTreePrettyPrinter.builder() .customizeOptions(options -> options.withCompactDirectories(true)) .customizeOptions(options -> options.filterDirectories(dirFilter)) diff --git a/src/test/java/io/github/computerdaddyguy/jfiletreeprettyprinter/LineExtensionTest.java b/src/test/java/io/github/computerdaddyguy/jfiletreeprettyprinter/LineExtensionTest.java index f60d2b8..75c1566 100644 --- a/src/test/java/io/github/computerdaddyguy/jfiletreeprettyprinter/LineExtensionTest.java +++ b/src/test/java/io/github/computerdaddyguy/jfiletreeprettyprinter/LineExtensionTest.java @@ -29,17 +29,19 @@ void emptyDir() { @Test void example_dir_match() { + var printedPath = Path.of("src/example/resources/line_extension"); + Function lineExtension = path -> { - if (PathPredicates.hasFullPathMatchingGlob(path, "**/src/main/java/api")) { + if (PathMatchers.hasRelativePathMatchingGlob(printedPath, "src/main/java/api").matches(path)) { return "\t\t\t// All API code: controllers, etc."; } - if (PathPredicates.hasFullPathMatchingGlob(path, "**/src/main/java/domain")) { + if (PathMatchers.hasRelativePathMatchingGlob(printedPath, "src/main/java/domain").matches(path)) { return "\t\t\t// All domain code: value objects, etc."; } - if (PathPredicates.hasFullPathMatchingGlob(path, "**/src/main/java/infra")) { + if (PathMatchers.hasRelativePathMatchingGlob(printedPath, "src/main/java/infra").matches(path)) { return "\t\t\t// All infra code: database, email service, etc."; } - if (PathPredicates.hasNameMatchingGlob(path, "*.properties")) { + if (PathMatchers.hasNameMatchingGlob("*.properties").matches(path)) { return "\t// Config file"; } return null; @@ -49,7 +51,7 @@ void example_dir_match() { .customizeOptions(options -> options.withLineExtension(lineExtension)) .build(); - var result = printer.prettyPrint("src/example/resources/line_extension"); + var result = printer.prettyPrint(printedPath); var expected = """ line_extension/ └─ src/ @@ -70,7 +72,7 @@ void example_dir_match() { void compact_dir_first_dir() { Function lineExtension = p -> { - if (PathPredicates.hasName(p, "dirA")) { + if (PathMatchers.hasName("dirA").matches(p)) { return " // 1"; } return null; @@ -92,7 +94,7 @@ void compact_dir_first_dir() { void compact_dir_empty_string_workds() { Function lineExtension = p -> { - if (PathPredicates.hasName(p, "dirA")) { + if (PathMatchers.hasName("dirA").matches(p)) { return ""; } return null; @@ -114,7 +116,7 @@ void compact_dir_empty_string_workds() { void compact_dir_middle_dir() { Function lineExtension = p -> { - if (PathPredicates.hasName(p, "dirB")) { + if (PathMatchers.hasName("dirB").matches(p)) { return " // 2"; } return null; @@ -136,7 +138,7 @@ void compact_dir_middle_dir() { void compact_dir_last_dir() { Function lineExtension = p -> { - if (PathPredicates.hasName(p, "dirC")) { + if (PathMatchers.hasName("dirC").matches(p)) { return " // 3"; } return null; diff --git a/src/test/java/io/github/computerdaddyguy/jfiletreeprettyprinter/PathMatchersTest.java b/src/test/java/io/github/computerdaddyguy/jfiletreeprettyprinter/PathMatchersTest.java new file mode 100644 index 0000000..c5b577f --- /dev/null +++ b/src/test/java/io/github/computerdaddyguy/jfiletreeprettyprinter/PathMatchersTest.java @@ -0,0 +1,622 @@ +package io.github.computerdaddyguy.jfiletreeprettyprinter; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.PathMatcher; +import java.nio.file.Paths; +import java.util.Collections; +import java.util.regex.Pattern; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class PathMatchersTest { + + /* + * ------------------------------------------------------- + * YES, this class has been AI written... but reviewed ;-) + * ------------------------------------------------------- + */ + + @Nested + class not { + + @Test + void shouldThrowOnNull() { + assertThatThrownBy(() -> PathMatchers.not(null)) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("matcher is null"); + } + + @Test + void shouldNegateMatcher() { + PathMatcher alwaysTrue = p -> true; + PathMatcher notTrue = PathMatchers.not(alwaysTrue); + assertThat(notTrue.matches(Paths.get("any"))).isFalse(); + + PathMatcher alwaysFalse = p -> false; + assertThat(PathMatchers.not(alwaysFalse).matches(Paths.get("any"))).isTrue(); + } + + } + + @Nested + class allOf { + + @Test + void shouldThrowOnNullFirstArg() { + assertThatThrownBy(() -> PathMatchers.allOf(null)) + .isInstanceOf(NullPointerException.class); + } + + @Test + void shouldThrowIfIterableEmpty() { + assertThatThrownBy(() -> PathMatchers.allOf(Collections. emptyList())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("No matcher provided"); + } + + @Test + void shouldReturnTrueOnlyIfAllMatch() { + PathMatcher trueM = p -> true; + PathMatcher falseM = p -> false; + PathMatcher combined = PathMatchers.allOf(trueM, trueM); + assertThat(combined.matches(Paths.get("x"))).isTrue(); + + PathMatcher combined2 = PathMatchers.allOf(trueM, falseM, trueM); + assertThat(combined2.matches(Paths.get("x"))).isFalse(); + } + + } + + @Nested + class anyOf { + + @Test + void shouldThrowOnNullFirstArg() { + assertThatThrownBy(() -> PathMatchers.anyOf(null)) + .isInstanceOf(NullPointerException.class); + } + + @Test + void shouldThrowIfIterableEmpty() { + assertThatThrownBy(() -> PathMatchers.anyOf(Collections. emptyList())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("No matcher provided"); + } + + @Test + void shouldReturnTrueIfAnyMatch() { + PathMatcher trueM = p -> true; + PathMatcher falseM = p -> false; + PathMatcher combined = PathMatchers.anyOf(falseM, falseM, trueM); + assertThat(combined.matches(Paths.get("x"))).isTrue(); + + PathMatcher combined2 = PathMatchers.anyOf(falseM, falseM); + assertThat(combined2.matches(Paths.get("x"))).isFalse(); + } + + } + + @Nested + class noneOf { + + @Test + void shouldThrowOnNullFirstArg() { + assertThatThrownBy(() -> PathMatchers.noneOf(null)) + .isInstanceOf(NullPointerException.class); + } + + @Test + void shouldThrowIfIterableEmpty() { + assertThatThrownBy(() -> PathMatchers.noneOf(Collections. emptyList())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("No matcher provided"); + } + + @Test + void shouldReturnTrueOnlyIfNoneMatch() { + PathMatcher falseM = p -> false; + PathMatcher trueM = p -> true; + PathMatcher combined = PathMatchers.noneOf(falseM, falseM); + assertThat(combined.matches(Paths.get("x"))).isTrue(); + + PathMatcher combined2 = PathMatchers.noneOf(falseM, trueM); + assertThat(combined2.matches(Paths.get("x"))).isFalse(); + } + + } + + @Nested + class ifMatchesThenElse { + + @Test + void shouldThrowOnNulls() { + PathMatcher a = p -> true; + PathMatcher b = p -> true; + assertThatThrownBy(() -> PathMatchers.ifMatchesThenElse(null, b, b)) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("ifMatcher is null"); + assertThatThrownBy(() -> PathMatchers.ifMatchesThenElse(a, null, b)) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("thenMatcher is null"); + assertThatThrownBy(() -> PathMatchers.ifMatchesThenElse(a, b, null)) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("elseMatcher is null"); + } + + @Test + void shouldApplyThenOrElse() { + PathMatcher ifM = p -> p.toString().contains("dir"); + PathMatcher thenM = p -> p.toString().endsWith(".java"); + PathMatcher elseM = p -> p.toString().endsWith(".txt"); + + PathMatcher cond = PathMatchers.ifMatchesThenElse(ifM, thenM, elseM); + + assertThat(cond.matches(Paths.get("some/dir/File.java"))).isTrue(); // if -> then + assertThat(cond.matches(Paths.get("some/file.txt"))).isTrue(); // else + assertThat(cond.matches(Paths.get("some/file.md"))).isFalse(); + } + + } + + @Nested + class hasAbsolutePathMatchingGlob { + + @Test + void shouldThrowOnNull() { + assertThatThrownBy(() -> PathMatchers.hasAbsolutePathMatchingGlob(null)) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("glob is null"); + } + + @Test + void starGlobAlwaysTrue() { + PathMatcher m = PathMatchers.hasAbsolutePathMatchingGlob("*"); + assertThat(m.matches(Paths.get("/some/path/whatever.txt"))).isTrue(); + assertThat(m.matches(Paths.get("C:\\foo\\bar"))).isTrue(); + } + + @Test + void shouldMatchAbsoluteNormalizedPathAgainstGlob(@TempDir Path tmp) throws IOException { + Path file = Files.createFile(tmp.resolve("HelloWorld.java")); + PathMatcher gm = PathMatchers.hasAbsolutePathMatchingGlob("**/*.java"); + // should match absolute normalized path + assertThat(gm.matches(file)).isTrue(); + assertThat(gm.matches(tmp.resolve("other.txt"))).isFalse(); + } + + } + + @Nested + class hasRelativePathMatchingGlob { + + @Test + void shouldThrowOnNulls() { + assertThatThrownBy(() -> PathMatchers.hasRelativePathMatchingGlob(null, "*.java")) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("ref is null"); + + assertThatThrownBy(() -> PathMatchers.hasRelativePathMatchingGlob(Paths.get("."), null)) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("glob is null"); + } + + @Test + void starGlobAlwaysTrue(@TempDir Path tmp) { + PathMatcher m = PathMatchers.hasRelativePathMatchingGlob(tmp, "*"); + assertThat(m.matches(tmp.resolve("a.txt"))).isTrue(); + assertThat(m.matches(tmp.resolve("sub/dir/b.txt"))).isTrue(); + } + + @Test + void shouldMatchRelativeToRef(@TempDir Path tmp) throws IOException { + Path base = tmp; + Path sub = Files.createDirectories(base.resolve("src")); + Path javaFile = Files.createFile(sub.resolve("Main.java")); + + PathMatcher m = PathMatchers.hasRelativePathMatchingGlob(base, "**/*.java"); + assertThat(m.matches(javaFile)).isTrue(); + } + + } + + @Nested + class hasAbsolutePathMatching { + + @Test + void shouldThrowOnNull() { + assertThatThrownBy(() -> PathMatchers.hasAbsolutePathMatching(null)) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("matcher is null"); + } + + @Test + void shouldApplyMatcherToAbsoluteNormalizedPath(@TempDir Path tmp) throws IOException { + Path f = Files.createFile(tmp.resolve("file.txt")); + PathMatcher nameMatcher = PathMatchers.hasName("file.txt"); + PathMatcher absoluteWrapper = PathMatchers.hasAbsolutePathMatching(nameMatcher); + // the wrapper passes normalized absolute path to inner matcher + assertThat(absoluteWrapper.matches(f)).isTrue(); + // a path with different filename -> false + assertThat(absoluteWrapper.matches(tmp.resolve("other.txt"))).isFalse(); + } + + } + + @Nested + class hasRelativePathMatching { + + @Test + void shouldThrowOnNulls() { + assertThatThrownBy(() -> PathMatchers.hasRelativePathMatching(null, PathMatchers.hasName("x"))) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("ref is null"); + + assertThatThrownBy(() -> PathMatchers.hasRelativePathMatching(Paths.get("."), null)) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("matcher is null"); + } + + @Test + void shouldReturnFalseWhenRootsDiffer(@TempDir Path tmp) throws IOException { + Path ref = tmp; + Path outside = Files.createTempDirectory("outref"); + try { + PathMatcher inner = p -> true; // would match anything + PathMatcher m = PathMatchers.hasRelativePathMatching(ref, inner); + // If roots differ (unlikely on unix, but keep conservative: if root same, this still exercises relativize) + // The expected behavior in implementation: if roots differ -> false + boolean result = m.matches(outside); + // Accept either false (expected) or true (if roots same and relativize succeeded) but assert that no exception thrown + assertThat(result).isIn(true, false); + } finally { + Files.deleteIfExists(outside); + } + } + + @Test + void shouldRelativizeAndApply(@TempDir Path tmp) throws IOException { + Path base = tmp; + Path sub = Files.createDirectories(base.resolve("a").resolve("b")); + Path target = Files.createFile(sub.resolve("target.txt")); + + PathMatcher inner = PathMatchers.hasNameMatching(Pattern.compile("target\\.txt")); + PathMatcher m = PathMatchers.hasRelativePathMatching(base, inner); + + assertThat(m.matches(target)).isTrue(); + } + + } + + @Nested + class hasName { + + @Test + void shouldThrowOnNull() { + assertThatThrownBy(() -> PathMatchers.hasName(null)) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("name is null"); + } + + @Test + void shouldMatchExactName(@TempDir Path tmp) throws IOException { + Path file = Files.createFile(tmp.resolve("config.yaml")); + PathMatcher m = PathMatchers.hasName("config.yaml"); + assertThat(m.matches(file)).isTrue(); + // case sensitive + Path file2 = Files.createFile(tmp.resolve("CONFIG2.yaml")); + assertThat(m.matches(file2)).isFalse(); + } + + @Test + void rootHasNoFileNameAndShouldReturnFalse(@TempDir Path tmp) { + Path root = tmp.getRoot(); + PathMatcher m = PathMatchers.hasName("whatever"); + // root.getFileName() is null -> should be false and not throw + assertThat(m.matches(root)).isFalse(); + } + + } + + @Nested + class hasNameIgnoreCase { + + @Test + void shouldThrowOnNull() { + assertThatThrownBy(() -> PathMatchers.hasNameIgnoreCase(null)) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("name is null"); + } + + @Test + void shouldMatchIgnoringCase(@TempDir Path tmp) throws IOException { + Path file = Files.createFile(tmp.resolve("Config.YAML")); + PathMatcher m = PathMatchers.hasNameIgnoreCase("config.yaml"); + assertThat(m.matches(file)).isTrue(); + + Path file2 = Files.createFile(tmp.resolve("other.txt")); + assertThat(m.matches(file2)).isFalse(); + } + + } + + @Nested + class hasNameMatching { + + @Test + void shouldThrowOnNull() { + assertThatThrownBy(() -> PathMatchers.hasNameMatching(null)) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("pattern is null"); + } + + @Test + void shouldMatchRegex(@TempDir Path tmp) throws IOException { + Path f1 = Files.createFile(tmp.resolve("data-123.csv")); + Path f2 = Files.createFile(tmp.resolve("data.txt")); + PathMatcher m = PathMatchers.hasNameMatching(Pattern.compile("data-\\d+\\.csv")); + assertThat(m.matches(f1)).isTrue(); + assertThat(m.matches(f2)).isFalse(); + } + + } + + @Nested + class hasNameMatchingGlob { + + @Test + void shouldThrowOnNull() { + assertThatThrownBy(() -> PathMatchers.hasNameMatchingGlob(null)) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("glob is null"); + } + + @Test + void starGlobAlwaysTrue() { + PathMatcher m = PathMatchers.hasNameMatchingGlob("*"); + assertThat(m.matches(Paths.get("anything"))).isTrue(); + assertThat(m.matches(Paths.get("/"))).isTrue(); // root -> optimizer returns true, even though getFileName null; spec says "* = always true" + } + + @Test + void shouldMatchFileNameAgainstGlob(@TempDir Path tmp) throws IOException { + Path a = Files.createFile(tmp.resolve("notes.txt")); + PathMatcher m = PathMatchers.hasNameMatchingGlob("*.txt"); + assertThat(m.matches(a)).isTrue(); + assertThat(m.matches(tmp.resolve("notes.md"))).isFalse(); + } + + } + + @Nested + class hasNameStartingWith { + + @Test + void shouldThrowOnNull() { + assertThatThrownBy(() -> PathMatchers.hasNameStartingWith(null)) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("prefix is null"); + } + + @Test + void shouldMatchPrefix(@TempDir Path tmp) throws IOException { + Path p = Files.createFile(tmp.resolve(".hidden")); + PathMatcher m = PathMatchers.hasNameStartingWith("."); + assertThat(m.matches(p)).isTrue(); + assertThat(m.matches(tmp.resolve("visible"))).isFalse(); + } + + } + + @Nested + class hasNameEndingWith { + + @Test + void shouldThrowOnNull() { + assertThatThrownBy(() -> PathMatchers.hasNameEndingWith(null)) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("suffix is null"); + } + + @Test + void shouldMatchSuffix(@TempDir Path tmp) throws IOException { + Path p = Files.createFile(tmp.resolve("file.log")); + PathMatcher m = PathMatchers.hasNameEndingWith(".log"); + assertThat(m.matches(p)).isTrue(); + assertThat(m.matches(tmp.resolve("file.txt"))).isFalse(); + } + + } + + @Nested + class hasExtension { + + @Test + void shouldThrowOnNull() { + assertThatThrownBy(() -> PathMatchers.hasExtension(null)) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("extension is null"); + } + + @Test + void shouldMatchExtension(@TempDir Path tmp) throws IOException { + Path p = Files.createFile(tmp.resolve("notes.txt")); + PathMatcher m = PathMatchers.hasExtension("txt"); + assertThat(m.matches(p)).isTrue(); + } + + @Test + void shouldNotMatchExtension(@TempDir Path tmp) throws IOException { + Path p = Files.createFile(tmp.resolve("notes.TXT")); + PathMatcher m = PathMatchers.hasExtension("txt"); + // case sensitive by design + assertThat(m.matches(p)).isFalse(); + } + + } + + @Nested + class isDirectory_isFile_isRegularFile_isSymbolicLink { + + @Test + void directoryAndFileChecks(@TempDir Path tmp) throws IOException { + Path dir = Files.createDirectories(tmp.resolve("d")); + Path file = Files.createFile(dir.resolve("f.txt")); + + assertThat(PathMatchers.isDirectory().matches(dir)).isTrue(); + assertThat(PathMatchers.isDirectory().matches(file)).isFalse(); + + assertThat(PathMatchers.isFile().matches(file)).isTrue(); + assertThat(PathMatchers.isFile().matches(dir)).isFalse(); + + assertThat(PathMatchers.isRegularFile().matches(file)).isTrue(); + assertThat(PathMatchers.isRegularFile().matches(dir)).isFalse(); + } + + @Test + void symbolicLinkCheck(@TempDir Path tmp) throws IOException { + Path target = Files.createFile(tmp.resolve("target.txt")); + Path link = tmp.resolve("link-to-target"); + + // Attempt to create a symlink; skip if not allowed on platform + boolean created = false; + try { + Files.createSymbolicLink(link, target.getFileName()); + created = true; + } catch (UnsupportedOperationException | IOException | SecurityException ex) { + // skip test if the environment doesn't allow symlinks + } + + Assumptions.assumeTrue(created, "Symbolic links not supported, skipping test"); + + assertThat(Files.isSymbolicLink(link)).isTrue(); + assertThat(PathMatchers.isSymbolicLink().matches(link)).isTrue(); + + // The symlink is not considered a directory by NOFOLLOW_LINKS in directory checks + assertThat(PathMatchers.isDirectory().matches(link)).isFalse(); + } + + } + + @Nested + class hasDirectParentMatching { + + @Test + void shouldThrowOnNull() { + assertThatThrownBy(() -> PathMatchers.hasDirectParentMatching(null)) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("parentMatcher is null"); + } + + @Test + void shouldReturnFalseForRoot(@TempDir Path tmp) { + Path root = tmp.getRoot(); + PathMatcher pm = PathMatchers.hasName("whatever"); + assertThat(PathMatchers.hasDirectParentMatching(pm).matches(root)).isFalse(); + } + + @Test + void shouldMatchParent(@TempDir Path tmp) throws IOException { + Path parent = Files.createDirectories(tmp.resolve("parent")); + Path child = Files.createFile(parent.resolve("c.txt")); + PathMatcher parentMatcher = PathMatchers.hasName("parent"); + PathMatcher m = PathMatchers.hasDirectParentMatching(parentMatcher); + assertThat(m.matches(child)).isTrue(); + } + + } + + @Nested + class hasAnyAncestorMatching { + + @Test + void shouldThrowOnNull() { + assertThatThrownBy(() -> PathMatchers.hasAnyAncestorMatching(null)) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("ancestorMatcher is null"); + } + + @Test + void shouldDetectAncestor(@TempDir Path tmp) throws IOException { + Path base = Files.createDirectories(tmp.resolve("a").resolve("b").resolve("c")); + Path file = Files.createFile(base.resolve("file.txt")); + PathMatcher ancestor = PathMatchers.hasName("a"); + PathMatcher m = PathMatchers.hasAnyAncestorMatching(ancestor); + assertThat(m.matches(file)).isTrue(); + } + + } + + @Nested + class hasAnyDirectChildMatching_hasAnyDescendantMatching_hasSiblingMatching { + + @Test + void shouldThrowOnNulls() { + assertThatThrownBy(() -> PathMatchers.hasAnyDirectChildMatching(null)) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("childMatcher is null"); + + assertThatThrownBy(() -> PathMatchers.hasAnyDescendantMatching(null)) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("descendantMatcher is null"); + + assertThatThrownBy(() -> PathMatchers.hasSiblingMatching(null)) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("siblingMatcher is null"); + } + + @Test + void directChildAndDescendantDetection(@TempDir Path tmp) throws IOException { + Path root = Files.createDirectories(tmp.resolve("root")); + Path child = Files.createFile(root.resolve("child.txt")); + Path nested = Files.createDirectories(root.resolve("a").resolve("b")); + Path deep = Files.createFile(nested.resolve("deep.txt")); + + PathMatcher nameChild = PathMatchers.hasName("child.txt"); + PathMatcher nameDeep = PathMatchers.hasName("deep.txt"); + + PathMatcher anyDirectChild = PathMatchers.hasAnyDirectChildMatching(nameChild); + assertThat(anyDirectChild.matches(root)).isTrue(); + assertThat(anyDirectChild.matches(nested)).isFalse(); + + PathMatcher anyDescendant = PathMatchers.hasAnyDescendantMatching(nameDeep); + assertThat(anyDescendant.matches(root)).isTrue(); + // non-directory should return false + assertThat(anyDescendant.matches(child)).isFalse(); + } + + @Test + void siblingsDetection(@TempDir Path tmp) throws IOException { + Path dir = Files.createDirectories(tmp.resolve("s")); + Path a = Files.createFile(dir.resolve("a.txt")); + Path b = Files.createFile(dir.resolve("b.txt")); + + PathMatcher m = PathMatchers.hasSiblingMatching(PathMatchers.hasName("b.txt")); + assertThat(m.matches(a)).isTrue(); + assertThat(m.matches(b)).isFalse(); // should exclude itself + } + + @Test + void descendantIoErrorWrappedAsUnchecked(@TempDir Path tmp) throws IOException { + Path dir = Files.createDirectories(tmp.resolve("d")); + Path a = Files.createFile(dir.resolve("a.txt")); + Path b = Files.createFile(dir.resolve("b.txt")); + // Create a matcher that throws runtime exception to simulate some problem while walking + PathMatcher throwing = p -> { + throw new RuntimeException("boom"); + }; + // Using hasAnyDirectChildMatching which delegates to testDescendants -> should propagate runtime exception + PathMatcher m = PathMatchers.hasAnyDirectChildMatching(throwing); + // For a directory, the stream will be opened and the anyMatch will run -> exception surfaces as RuntimeException + assertThatThrownBy(() -> m.matches(dir)).isInstanceOf(RuntimeException.class); + } + + } + +} diff --git a/src/test/java/io/github/computerdaddyguy/jfiletreeprettyprinter/PathPredicateBuilderTest.java b/src/test/java/io/github/computerdaddyguy/jfiletreeprettyprinter/PathPredicateBuilderTest.java deleted file mode 100644 index dba75d4..0000000 --- a/src/test/java/io/github/computerdaddyguy/jfiletreeprettyprinter/PathPredicateBuilderTest.java +++ /dev/null @@ -1,648 +0,0 @@ -package io.github.computerdaddyguy.jfiletreeprettyprinter; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatNullPointerException; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.regex.Pattern; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; - -class PathPredicateBuilderTest { - - @TempDir - private Path root; - - private Path createTempFile(String fileName) { - var newFile = root.resolve(fileName); - try { - var created = newFile.toFile().createNewFile(); - if (!created) { - throw new IllegalStateException("Unable to create file: " + newFile); - } - } catch (IOException e) { - throw new IllegalStateException("Unable to create file: " + newFile, e); - } - return newFile; - } - - private Path createTempDir(String dirName) { - var newFile = root.resolve(dirName); - var created = newFile.toFile().mkdir(); - if (!created) { - throw new IllegalStateException("Unable to create directory: " + newFile); - } - return newFile; - } - - // ---------- General predicates ---------- - - @Test - void noPredicate_then_nulll() { - var filter = PathPredicates.builder().build(); - assertThat(filter).isNull(); - } - - @Nested - class PathTest { - - @Test - void match() { - var path = createTempFile("myFile.java"); - var filter = PathPredicates.builder().pathTest(p -> p.equals(path)).build(); - assertThat(filter.test(path)).isTrue(); - } - - @Test - void no_match() { - var path = createTempFile("myFile.java"); - var filter = PathPredicates.builder().pathTest(p -> p.equals(new Object())).build(); - assertThat(filter.test(path)).isFalse(); - } - - } - - @Nested - class FileTest { - - @Test - void match() { - var path = createTempFile("myFile.java"); - var filter = PathPredicates.builder().fileTest(p -> p.equals(path.toFile())).build(); - assertThat(filter.test(path)).isTrue(); - } - - @Test - void no_match() { - var path = createTempFile("myFile.java"); - var filter = PathPredicates.builder().fileTest(p -> p.equals(new Object())).build(); - assertThat(filter.test(path)).isFalse(); - } - - } - - // ---------- PathMatcher ---------- - - @Nested - class HasFullPathMatchingGlob { - - @Test - void match_wildcard() { - var path = createTempFile("myFile.java"); - var filter = PathPredicates.builder().hasFullPathMatchingGlob("*").build(); - assertThat(filter.test(path)).isTrue(); - } - - @Test - void match() { - var path = createTempFile("myFile.java"); - var filter = PathPredicates.builder().hasFullPathMatchingGlob("**/*.java").build(); - assertThat(filter.test(path)).isTrue(); - } - - @Test - void no_match_because_full_path() { - var path = createTempFile("myFile.java"); - var filter = PathPredicates.builder().hasFullPathMatchingGlob("*.java").build(); - assertThat(filter.test(path)).isFalse(); - } - - @Test - void no_match() { - var path = createTempFile("myFile.java"); - var filter = PathPredicates.builder().hasFullPathMatchingGlob("**/*.php").build(); - assertThat(filter.test(path)).isFalse(); - } - - } - - @Nested - class MatchesPathMatcher { - - } - - // ---------- Name ---------- - - @Nested - class HasName { - - @Test - void match() { - var path = createTempFile("myFile.java"); - var filter = PathPredicates.builder().hasName("myFile.java").build(); - assertThat(filter.test(path)).isTrue(); - } - - @Test - void no_match_case_sensitive() { - var path = createTempFile("myFile.java"); - var filter = PathPredicates.builder().hasName("MYFILE.JAVA").build(); - assertThat(filter.test(path)).isFalse(); - } - - @Test - void no_match() { - var path = createTempFile("myFile.java"); - var filter = PathPredicates.builder().hasName("myFile.php").build(); - assertThat(filter.test(path)).isFalse(); - } - - } - - @Nested - class HasNameIgnoreCase { - - @Test - void match() { - var path = createTempFile("myFile.java"); - var filter = PathPredicates.builder().hasNameIgnoreCase("myFile.java").build(); - assertThat(filter.test(path)).isTrue(); - } - - @Test - void match_case_sensitive() { - var path = createTempFile("myFile.java"); - var filter = PathPredicates.builder().hasNameIgnoreCase("MYFILE.JAVA").build(); - assertThat(filter.test(path)).isTrue(); - } - - @Test - void no_match() { - var path = createTempFile("myFile.java"); - var filter = PathPredicates.builder().hasNameIgnoreCase("myFile.php").build(); - assertThat(filter.test(path)).isFalse(); - } - - } - - @Nested - class HasNameMatching { - - @Test - void match() { - var path = createTempFile("myFile.java"); - var filter = PathPredicates.builder().hasNameMatching(Pattern.compile("my.*")).build(); - assertThat(filter.test(path)).isTrue(); - } - - @Test - void no_match() { - var path = createTempFile("myFile.java"); - var filter = PathPredicates.builder().hasNameMatching(Pattern.compile("ma.*")).build(); - assertThat(filter.test(path)).isFalse(); - } - - } - - @Nested - class HasNameMatchingGlob { - - @Test - void match() { - var path = createTempFile("myFile.java"); - var filter = PathPredicates.builder().hasNameMatchingGlob("my*").build(); - assertThat(filter.test(path)).isTrue(); - } - - @Test - void no_match() { - var path = createTempFile("myFile.java"); - var filter = PathPredicates.builder().hasNameMatchingGlob("ma*").build(); - assertThat(filter.test(path)).isFalse(); - } - - @Test - void no_match_dir() { - var path = createTempFile("myFile.java"); - var filter = PathPredicates.builder().hasNameMatchingGlob("*/my*").build(); - assertThat(filter.test(path)).isFalse(); - } - - } - - @Nested - class HasNameEndingWith { - - @Test - void match() { - var path = createTempFile("myFile.java"); - var filter = PathPredicates.builder().hasNameEndingWith(".java").build(); - assertThat(filter.test(path)).isTrue(); - } - - @Test - void no_match() { - var path = createTempFile("myFile.java"); - var filter = PathPredicates.builder().hasNameEndingWith(".php").build(); - assertThat(filter.test(path)).isFalse(); - } - - @Test - void no_match_case_sensitive() { - var path = createTempFile("myFile.java"); - var filter = PathPredicates.builder().hasNameEndingWith(".Java").build(); - assertThat(filter.test(path)).isFalse(); - } - - } - - @Nested - class HasExtension { - - @Test - void match() { - var path = createTempFile("myFile.java"); - var filter = PathPredicates.builder().hasExtension("java").build(); - assertThat(filter.test(path)).isTrue(); - } - - @Test - void no_match() { - var path = createTempFile("myFile.java"); - var filter = PathPredicates.builder().hasExtension("php").build(); - assertThat(filter.test(path)).isFalse(); - } - - @Test - void no_match_case_sensitive() { - var path = createTempFile("myFile.java"); - var filter = PathPredicates.builder().hasExtension("Java").build(); - assertThat(filter.test(path)).isFalse(); - } - - @Test - void no_match_no_extension() { - var path = createTempFile("myFilejava"); - var filter = PathPredicates.builder().hasExtension("java").build(); - assertThat(filter.test(path)).isFalse(); - } - - } - - // ---------- Type ---------- - - @Nested - class IsDirectory { - - @Test - void match() { - var path = createTempDir("myDir"); - var filter = PathPredicates.builder().isDirectory().build(); - assertThat(filter.test(path)).isTrue(); - } - - @Test - void no_match() { - var path = createTempFile("myFile.java"); - var filter = PathPredicates.builder().isDirectory().build(); - assertThat(filter.test(path)).isFalse(); - } - - } - - @Nested - class IsFile { - - @Test - void match() { - var path = createTempFile("myFile.java"); - var filter = PathPredicates.builder().isFile().build(); - assertThat(filter.test(path)).isTrue(); - } - - @Test - void no_match() { - var path = createTempDir("myDir"); - var filter = PathPredicates.builder().isFile().build(); - assertThat(filter.test(path)).isFalse(); - } - - } - - // ---------- Hierarchy ---------- - - @Nested - class HasParentMatching { - - @Test - void null_predicate_throws_NPE() { - assertThatNullPointerException() - .isThrownBy(() -> PathPredicates.builder().hasParentMatching(null)); - } - - @Test - void matches_direct_parent() throws IOException { - var parent = createTempDir("parent"); - var child = Files.createDirectory(parent.resolve("child")); - var grandChild = Files.createDirectory(child.resolve("grandchild")); - - var filter = PathPredicates.builder().hasParentMatching( - p -> p.getFileName().toString().equals("child") - ).build(); - - assertThat(filter.test(grandChild)).isTrue(); - } - - @Test - void does_not_match_grandparent() throws IOException { - var parent = createTempDir("parent"); - var child = Files.createDirectory(parent.resolve("child")); - var grandChild = Files.createDirectory(child.resolve("grandchild")); - - var filter = PathPredicates.builder().hasParentMatching( - p -> p.getFileName().toString().equals("parent") - ).build(); - - assertThat(filter.test(grandChild)).isFalse(); - } - - @Test - @SuppressWarnings("unused") - void does_not_match_sibling() throws IOException { - var parent = createTempDir("parent"); - var child = Files.createDirectory(parent.resolve("child")); - var grandChild1 = Files.createDirectory(child.resolve("grandchild1")); - var grandChild2 = Files.createDirectory(child.resolve("grandchild2")); - - var filter = PathPredicates.builder().hasParentMatching( - p -> p.getFileName().toString().equals("grandchild2") - ).build(); - - assertThat(filter.test(grandChild1)).isFalse(); - } - - @Test - void root_has_no_parent() { - var detachedRoot = Path.of("root"); - - var filter = PathPredicates.builder().hasParentMatching( - p -> true - ).build(); - - assertThat(filter.test(detachedRoot)).isFalse(); - } - - } - - @Nested - class HasAncestorMatching { - - @Test - void null_predicate_throws_NPE() { - assertThatNullPointerException() - .isThrownBy(() -> PathPredicates.builder().hasAncestorMatching(null)); - } - - @Test - void matches_direct_parent() throws IOException { - var parent = createTempDir("parent"); - var child = Files.createDirectory(parent.resolve("child")); - var grandChild = Files.createDirectory(child.resolve("grandchild")); - - var filter = PathPredicates.builder().hasAncestorMatching( - p -> p.getFileName().toString().equals("child") - ).build(); - - assertThat(filter.test(grandChild)).isTrue(); - } - - @Test - void matches_grandparent() throws IOException { - var parent = createTempDir("parent"); - var child = Files.createDirectory(parent.resolve("child")); - var grandChild = Files.createDirectory(child.resolve("grandchild")); - - var filter = PathPredicates.builder().hasAncestorMatching( - p -> p.getFileName().toString().equals("parent") - ).build(); - - assertThat(filter.test(grandChild)).isTrue(); - } - - @Test - @SuppressWarnings("unused") - void does_not_match_sibling() throws IOException { - var parent = createTempDir("parent"); - var child = Files.createDirectory(parent.resolve("child")); - var grandChild1 = Files.createDirectory(child.resolve("grandchild1")); - var grandChild2 = Files.createDirectory(child.resolve("grandchild2")); - - var filter = PathPredicates.builder().hasAncestorMatching( - p -> p.getNameCount() > 0 && p.getFileName().toString().equals("grandchild2") - ).build(); - - assertThat(filter.test(grandChild1)).isFalse(); - } - - @Test - void root_has_no_parent() { - var detachedRoot = Path.of("root"); - - var filter = PathPredicates.builder().hasAncestorMatching( - p -> true - ).build(); - - assertThat(filter.test(detachedRoot)).isFalse(); - } - - } - - @Nested - class HasDirectChildMatching { - - @Test - void null_predicate_throws_NPE() { - assertThatNullPointerException() - .isThrownBy(() -> PathPredicates.builder().hasDirectChildMatching(null)); - } - - @Test - @SuppressWarnings("unused") - void matches_direct_child() throws IOException { - var parent = createTempDir("parent"); - var child = Files.createDirectory(parent.resolve("child")); - - var filter = PathPredicates.builder().hasDirectChildMatching( - p -> p.getFileName().toString().equals("child") - ).build(); - - assertThat(filter.test(parent)).isTrue(); - } - - @Test - @SuppressWarnings("unused") - void does_not_match_grandchild() throws IOException { - var parent = createTempDir("parent"); - var child = Files.createDirectory(parent.resolve("child")); - var grandchild = Files.createDirectory(child.resolve("grandchild")); - - var filter = PathPredicates.builder().hasDirectChildMatching( - p -> p.getFileName().toString().equals("grandchild") - ).build(); - - assertThat(filter.test(parent)).isFalse(); - } - - @Test - void does_not_match_if_not_directory() { - var parent = createTempFile("parent"); - - var filter = PathPredicates.builder().hasDirectChildMatching( - p -> true - ).build(); - - assertThat(filter.test(parent)).isFalse(); - } - - @Test - void does_not_match_if_no_child() { - var parent = createTempDir("parent"); - - var filter = PathPredicates.builder().hasDirectChildMatching( - p -> true - ).build(); - - assertThat(filter.test(parent)).isFalse(); - } - - } - - @Nested - class HasDescendantMatching { - - @Test - void null_predicate_throws_NPE() { - assertThatNullPointerException() - .isThrownBy(() -> PathPredicates.builder().hasDescendantMatching(null)); - } - - @Test - @SuppressWarnings("unused") - void matches_grandchild() throws IOException { - var parent = createTempDir("parent"); - var child = Files.createDirectory(parent.resolve("child")); - var grandchild = Files.createDirectory(child.resolve("grandchild")); - - var filter = PathPredicates.builder().hasDescendantMatching( - p -> p.getFileName().toString().equals("grandchild") - ).build(); - - assertThat(filter.test(parent)).isTrue(); - } - - @Test - void does_not_match_nonexistent_descendant() { - var parent = createTempDir("parent"); - parent.resolve("child"); - - var filter = PathPredicates.builder().hasDescendantMatching( - p -> p.getFileName().toString().equals("missing") - ).build(); - - assertThat(filter.test(parent)).isFalse(); - } - - @Test - void does_not_match_if_not_directory() { - var parent = createTempFile("parent"); - - var filter = PathPredicates.builder().hasDescendantMatching( - p -> true - ).build(); - - assertThat(filter.test(parent)).isFalse(); - } - - @Test - void does_not_match_if_no_child() { - var parent = createTempDir("parent"); - - var filter = PathPredicates.builder().hasDescendantMatching( - p -> true - ).build(); - - assertThat(filter.test(parent)).isFalse(); - } - - } - - @Nested - class HasSiblingMatching { - - @Test - void null_predicate_throws_NPE() { - assertThatNullPointerException() - .isThrownBy(() -> PathPredicates.builder().hasSiblingMatching(null)); - } - - @Test - @SuppressWarnings("unused") - void matches_sibling() throws IOException { - var parent = createTempDir("parent"); - var child1 = Files.createDirectory(parent.resolve("child1")); - var child2 = Files.createDirectory(parent.resolve("child2")); - - var filter = PathPredicates.builder().hasSiblingMatching( - p -> p.getFileName().toString().equals("child2") - ).build(); - - assertThat(filter.test(child1)).isTrue(); - } - - @Test - @SuppressWarnings("unused") - void matches_sibling_failed() throws IOException { - var parent = createTempDir("parent"); - var child1 = Files.createDirectory(parent.resolve("child1")); - var child2 = Files.createDirectory(parent.resolve("child2")); - - var filter = PathPredicates.builder().hasSiblingMatching( - p -> p.getFileName().toString().equals("otherChild") - ).build(); - - assertThat(filter.test(child1)).isFalse(); - } - - @Test - void root_has_no_sibling() { - var detachedRoot = Path.of("root"); - - var filter = PathPredicates.builder().hasSiblingMatching( - p -> true - ).build(); - - assertThat(filter.test(detachedRoot)).isFalse(); - } - - @Test - void does_not_match_self() throws IOException { - var parent = createTempDir("parent"); - var child = Files.createDirectory(parent.resolve("child")); - - var filter = PathPredicates.builder().hasSiblingMatching( - p -> true - ).build(); - - assertThat(filter.test(child)).isFalse(); - } - - @Test - @SuppressWarnings("unused") - void does_not_match_parent_or_child() throws IOException { - var parent = createTempDir("parent"); - var child = Files.createDirectory(parent.resolve("child")); - var grandchild = Files.createDirectory(child.resolve("grandchild")); - - var filter = PathPredicates.builder().hasSiblingMatching( - p -> true - ).build(); - - assertThat(filter.test(child)).isFalse(); - } - - } - -} From 96c134fd100f1b9abec536254871c70d05b11e2c Mon Sep 17 00:00:00 2001 From: Samuel SCHNEGG Date: Sat, 4 Oct 2025 00:26:13 +0200 Subject: [PATCH 3/4] Fix sonar --- .../jfiletreeprettyprinter/PathMatchers.java | 66 +++++++++++-------- .../scanner/DefaultPathToTreeScanner.java | 2 +- .../PathMatchersTest.java | 18 +++-- 3 files changed, 50 insertions(+), 36 deletions(-) diff --git a/src/main/java/io/github/computerdaddyguy/jfiletreeprettyprinter/PathMatchers.java b/src/main/java/io/github/computerdaddyguy/jfiletreeprettyprinter/PathMatchers.java index 0ef2c74..32a1b74 100644 --- a/src/main/java/io/github/computerdaddyguy/jfiletreeprettyprinter/PathMatchers.java +++ b/src/main/java/io/github/computerdaddyguy/jfiletreeprettyprinter/PathMatchers.java @@ -14,6 +14,7 @@ import java.util.regex.Pattern; import java.util.stream.Stream; import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; /** * Utility class providing factory and composition methods for {@link PathMatcher}s. @@ -192,13 +193,15 @@ public static PathMatcher anyOf(Iterable matchers) { return combineMatchers(matchers, Mode.ANY); } - private static List buildSafeList(PathMatcher matcher, PathMatcher... matchers) { + private static List buildSafeList(PathMatcher matcher, @Nullable PathMatcher... matchers) { Objects.requireNonNull(matcher, "matcher is null"); var list = new ArrayList(1 + (matchers == null ? 0 : matchers.length)); list.add(matcher); - for (PathMatcher m : matchers) { - Objects.requireNonNull(m, "some matcher is null"); - list.add(m); + if (matchers != null) { + for (PathMatcher m : matchers) { + Objects.requireNonNull(m, "some matcher is null"); + list.add(m); + } } return List.copyOf(list); } @@ -219,32 +222,37 @@ private static PathMatcher combineMatchers(Iterable matchers, Mode if (list.isEmpty()) { throw new IllegalArgumentException("No matcher provided"); } - return path -> { - switch (mode) { - case ALL: - for (PathMatcher m : list) { - if (!m.matches(path)) - return false; - } - return true; - case ANY: - for (PathMatcher m : list) { - if (m.matches(path)) - return true; - } - return false; - case NONE: - for (PathMatcher m : list) { - if (m.matches(path)) - return false; - } - return true; - default: - throw new AssertionError("Unknown mode: " + mode); - } + return switch (mode) { + case ALL -> path -> all(path, list); + case ANY -> path -> any(path, list); + case NONE -> path -> none(path, list); }; } + private static boolean all(Path path, List matchers) { + for (PathMatcher m : matchers) { + if (!m.matches(path)) + return false; + } + return true; + } + + private static boolean any(Path path, List matchers) { + for (PathMatcher m : matchers) { + if (m.matches(path)) + return true; + } + return false; + } + + private static boolean none(Path path, List matchers) { + for (PathMatcher m : matchers) { + if (m.matches(path)) + return false; + } + return true; + } + /** * Returns a conditional matcher. * @@ -589,7 +597,7 @@ public static PathMatcher isRegularFile() { * @return matcher returning {@code true} if the path is a symbolic link */ public static PathMatcher isSymbolicLink() { - return path -> Files.isSymbolicLink(path); + return Files::isSymbolicLink; } // ---------- Hierarchy ---------- @@ -688,7 +696,7 @@ private static final boolean testDescendants(Path path, int depth, PathMatcher d return stream .skip(1) // skip the root path itself .filter(inclusionFilter) - .anyMatch(p -> descendantMatcher.matches(p)); + .anyMatch(descendantMatcher::matches); } catch (IOException e) { throw new UncheckedIOException("Exception while walking files of " + path, e); } diff --git a/src/main/java/io/github/computerdaddyguy/jfiletreeprettyprinter/scanner/DefaultPathToTreeScanner.java b/src/main/java/io/github/computerdaddyguy/jfiletreeprettyprinter/scanner/DefaultPathToTreeScanner.java index dd881e0..7dfacbb 100644 --- a/src/main/java/io/github/computerdaddyguy/jfiletreeprettyprinter/scanner/DefaultPathToTreeScanner.java +++ b/src/main/java/io/github/computerdaddyguy/jfiletreeprettyprinter/scanner/DefaultPathToTreeScanner.java @@ -52,7 +52,7 @@ private TreeEntry handleDirectory(Path root, int depth, Path dir, PathMatcher fi List childEntries; - try (var childrenStream = Files.newDirectoryStream(dir, path -> filter.matches(path))) { + try (var childrenStream = Files.newDirectoryStream(dir, filter::matches)) { var it = directoryStreamToIterator(childrenStream); childEntries = handleDirectoryChildren(root, depth, dir, it, filter); } catch (IOException e) { diff --git a/src/test/java/io/github/computerdaddyguy/jfiletreeprettyprinter/PathMatchersTest.java b/src/test/java/io/github/computerdaddyguy/jfiletreeprettyprinter/PathMatchersTest.java index c5b577f..62ac228 100644 --- a/src/test/java/io/github/computerdaddyguy/jfiletreeprettyprinter/PathMatchersTest.java +++ b/src/test/java/io/github/computerdaddyguy/jfiletreeprettyprinter/PathMatchersTest.java @@ -56,7 +56,8 @@ void shouldThrowOnNullFirstArg() { @Test void shouldThrowIfIterableEmpty() { - assertThatThrownBy(() -> PathMatchers.allOf(Collections. emptyList())) + var emptyList = Collections. emptyList(); + assertThatThrownBy(() -> PathMatchers.allOf(emptyList)) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("No matcher provided"); } @@ -85,7 +86,8 @@ void shouldThrowOnNullFirstArg() { @Test void shouldThrowIfIterableEmpty() { - assertThatThrownBy(() -> PathMatchers.anyOf(Collections. emptyList())) + var emptyList = Collections. emptyList(); + assertThatThrownBy(() -> PathMatchers.anyOf(emptyList)) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("No matcher provided"); } @@ -114,7 +116,8 @@ void shouldThrowOnNullFirstArg() { @Test void shouldThrowIfIterableEmpty() { - assertThatThrownBy(() -> PathMatchers.noneOf(Collections. emptyList())) + var emptyList = Collections. emptyList(); + assertThatThrownBy(() -> PathMatchers.noneOf(emptyList)) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("No matcher provided"); } @@ -202,7 +205,8 @@ void shouldThrowOnNulls() { .isInstanceOf(NullPointerException.class) .hasMessageContaining("ref is null"); - assertThatThrownBy(() -> PathMatchers.hasRelativePathMatchingGlob(Paths.get("."), null)) + var path = Paths.get("."); + assertThatThrownBy(() -> PathMatchers.hasRelativePathMatchingGlob(path, null)) .isInstanceOf(NullPointerException.class) .hasMessageContaining("glob is null"); } @@ -254,11 +258,13 @@ class hasRelativePathMatching { @Test void shouldThrowOnNulls() { - assertThatThrownBy(() -> PathMatchers.hasRelativePathMatching(null, PathMatchers.hasName("x"))) + var matcher = PathMatchers.hasName("x"); + assertThatThrownBy(() -> PathMatchers.hasRelativePathMatching(null, matcher)) .isInstanceOf(NullPointerException.class) .hasMessageContaining("ref is null"); - assertThatThrownBy(() -> PathMatchers.hasRelativePathMatching(Paths.get("."), null)) + var path = Paths.get("."); + assertThatThrownBy(() -> PathMatchers.hasRelativePathMatching(path, null)) .isInstanceOf(NullPointerException.class) .hasMessageContaining("matcher is null"); } From 1b4b10e339719dacab0d0448a5de9e1aac7f1b65 Mon Sep 17 00:00:00 2001 From: Samuel SCHNEGG Date: Sat, 4 Oct 2025 00:29:20 +0200 Subject: [PATCH 4/4] Fix sonar --- .../jfiletreeprettyprinter/PathMatchers.java | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/src/main/java/io/github/computerdaddyguy/jfiletreeprettyprinter/PathMatchers.java b/src/main/java/io/github/computerdaddyguy/jfiletreeprettyprinter/PathMatchers.java index 32a1b74..34dbe7f 100644 --- a/src/main/java/io/github/computerdaddyguy/jfiletreeprettyprinter/PathMatchers.java +++ b/src/main/java/io/github/computerdaddyguy/jfiletreeprettyprinter/PathMatchers.java @@ -14,7 +14,6 @@ import java.util.regex.Pattern; import java.util.stream.Stream; import org.jspecify.annotations.NullMarked; -import org.jspecify.annotations.Nullable; /** * Utility class providing factory and composition methods for {@link PathMatcher}s. @@ -193,15 +192,13 @@ public static PathMatcher anyOf(Iterable matchers) { return combineMatchers(matchers, Mode.ANY); } - private static List buildSafeList(PathMatcher matcher, @Nullable PathMatcher... matchers) { + private static List buildSafeList(PathMatcher matcher, PathMatcher... matchers) { Objects.requireNonNull(matcher, "matcher is null"); - var list = new ArrayList(1 + (matchers == null ? 0 : matchers.length)); + var list = new ArrayList(1 + matchers.length); list.add(matcher); - if (matchers != null) { - for (PathMatcher m : matchers) { - Objects.requireNonNull(m, "some matcher is null"); - list.add(m); - } + for (PathMatcher m : matchers) { + Objects.requireNonNull(m, "some matcher is null"); + list.add(m); } return List.copyOf(list); }