From b4ba5b9487458c174eb33eee4e19f71a8af3e6d1 Mon Sep 17 00:00:00 2001 From: Dennis Doomen Date: Wed, 26 Nov 2025 15:21:21 +0100 Subject: [PATCH 1/2] Test --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 1f55773..49022cc 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,6 @@

+ +
Pathy
From 835b4799ed8ae5b2e6d041594f8fe7f133b86f6a Mon Sep 17 00:00:00 2001 From: Dennis Doomen Date: Mon, 10 Aug 2026 16:53:35 +0200 Subject: [PATCH 2/2] Add Matches(pattern) for pure in-memory glob matching (#142) Adds ChainablePathGlobbingExtensions.Matches(string) and Matches(params string[]) to Pathy.Globbing, letting a ChainablePath be tested against one or more glob patterns without touching the file system (the path does not need to exist). Implementation uses Matcher.Match(root, file), which performs a pure in-memory string match via InMemoryDirectoryInfo and never enumerates or reads from disk. Rooted paths are matched relative to their drive/volume root so patterns like `src/**/*.cs` match regardless of where on that drive the path lives; relative paths fall back to the current working directory, consistent with GlobFiles. Matching uses the same StringComparison.OrdinalIgnoreCase as GlobFiles. This delivers the capability requested (but not shipped as public API) in the closed issue #35, using the plural-safe name Matches with a multi-pattern overload that returns true if any pattern matches. Fixes #142 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../pathy.globbing.net47.verified.txt | 2 + .../pathy.globbing.net8.0.verified.txt | 2 + ...pathy.globbing.netstandard2.0.verified.txt | 2 + ...pathy.globbing.netstandard2.1.verified.txt | 2 + Pathy.Globbing/PathyGlobbing.cs | 92 ++++++++++++++ Pathy.Specs/ChainablePathSpecs.cs | 119 ++++++++++++++++++ README.md | 9 ++ 7 files changed, 228 insertions(+) diff --git a/Pathy.ApiVerificationTests/ApprovedApi/pathy.globbing.net47.verified.txt b/Pathy.ApiVerificationTests/ApprovedApi/pathy.globbing.net47.verified.txt index c498f2a..6d2d6c7 100644 --- a/Pathy.ApiVerificationTests/ApprovedApi/pathy.globbing.net47.verified.txt +++ b/Pathy.ApiVerificationTests/ApprovedApi/pathy.globbing.net47.verified.txt @@ -5,5 +5,7 @@ namespace Pathy { public static Pathy.ChainablePath[] GlobFiles(this Pathy.ChainablePath path, string globPattern) { } public static Pathy.ChainablePath[] GlobFiles(this Pathy.ChainablePath path, params string[] globPatterns) { } + public static bool Matches(this Pathy.ChainablePath path, string globPattern) { } + public static bool Matches(this Pathy.ChainablePath path, params string[] globPatterns) { } } } \ No newline at end of file diff --git a/Pathy.ApiVerificationTests/ApprovedApi/pathy.globbing.net8.0.verified.txt b/Pathy.ApiVerificationTests/ApprovedApi/pathy.globbing.net8.0.verified.txt index c498f2a..6d2d6c7 100644 --- a/Pathy.ApiVerificationTests/ApprovedApi/pathy.globbing.net8.0.verified.txt +++ b/Pathy.ApiVerificationTests/ApprovedApi/pathy.globbing.net8.0.verified.txt @@ -5,5 +5,7 @@ namespace Pathy { public static Pathy.ChainablePath[] GlobFiles(this Pathy.ChainablePath path, string globPattern) { } public static Pathy.ChainablePath[] GlobFiles(this Pathy.ChainablePath path, params string[] globPatterns) { } + public static bool Matches(this Pathy.ChainablePath path, string globPattern) { } + public static bool Matches(this Pathy.ChainablePath path, params string[] globPatterns) { } } } \ No newline at end of file diff --git a/Pathy.ApiVerificationTests/ApprovedApi/pathy.globbing.netstandard2.0.verified.txt b/Pathy.ApiVerificationTests/ApprovedApi/pathy.globbing.netstandard2.0.verified.txt index c498f2a..6d2d6c7 100644 --- a/Pathy.ApiVerificationTests/ApprovedApi/pathy.globbing.netstandard2.0.verified.txt +++ b/Pathy.ApiVerificationTests/ApprovedApi/pathy.globbing.netstandard2.0.verified.txt @@ -5,5 +5,7 @@ namespace Pathy { public static Pathy.ChainablePath[] GlobFiles(this Pathy.ChainablePath path, string globPattern) { } public static Pathy.ChainablePath[] GlobFiles(this Pathy.ChainablePath path, params string[] globPatterns) { } + public static bool Matches(this Pathy.ChainablePath path, string globPattern) { } + public static bool Matches(this Pathy.ChainablePath path, params string[] globPatterns) { } } } \ No newline at end of file diff --git a/Pathy.ApiVerificationTests/ApprovedApi/pathy.globbing.netstandard2.1.verified.txt b/Pathy.ApiVerificationTests/ApprovedApi/pathy.globbing.netstandard2.1.verified.txt index c498f2a..6d2d6c7 100644 --- a/Pathy.ApiVerificationTests/ApprovedApi/pathy.globbing.netstandard2.1.verified.txt +++ b/Pathy.ApiVerificationTests/ApprovedApi/pathy.globbing.netstandard2.1.verified.txt @@ -5,5 +5,7 @@ namespace Pathy { public static Pathy.ChainablePath[] GlobFiles(this Pathy.ChainablePath path, string globPattern) { } public static Pathy.ChainablePath[] GlobFiles(this Pathy.ChainablePath path, params string[] globPatterns) { } + public static bool Matches(this Pathy.ChainablePath path, string globPattern) { } + public static bool Matches(this Pathy.ChainablePath path, params string[] globPatterns) { } } } \ No newline at end of file diff --git a/Pathy.Globbing/PathyGlobbing.cs b/Pathy.Globbing/PathyGlobbing.cs index b8ece0a..3f25dbe 100644 --- a/Pathy.Globbing/PathyGlobbing.cs +++ b/Pathy.Globbing/PathyGlobbing.cs @@ -2,6 +2,7 @@ using System; using System.Diagnostics.CodeAnalysis; +using System.IO; using System.Linq; using Microsoft.Extensions.FileSystemGlobbing; using Microsoft.Extensions.FileSystemGlobbing.Abstractions; @@ -72,5 +73,96 @@ public static ChainablePath[] GlobFiles(this ChainablePath path, params string[] .Select(file => ChainablePath.From(path / file.Path)) .ToArray(); } + + /// + /// Determines whether this path matches the provided glob pattern. + /// + /// + /// + /// Unlike , this does not touch the file system at all: the path + /// does not need to exist, and no directory is enumerated. The pattern is matched purely against the string + /// representation of (as returned by , which uses + /// the platform's directory separator), using + /// . + /// That overload performs a pure in-memory string match (via InMemoryDirectoryInfo) and never + /// enumerates or reads from disk. For a rooted/absolute the pattern is matched + /// relative to its drive/volume root (so e.g. src/**/*.cs matches anywhere under that drive); for a + /// relative it is matched relative to the current working directory, same as + /// does. + /// + /// + /// This addresses the capability requested (but not delivered as a public API) in the closed issue #35: a way + /// to test whether a matches a wildcard/glob pattern without listing a directory. + /// It is named Matches here (plural-safe, with a multi-pattern overload) rather than reusing the + /// originally proposed Match name. + /// + /// See also + /// + /// The path to test against the glob pattern. + /// The glob pattern used to match the path, e.g. **/*.cs or **/bin/** + public static bool Matches(this ChainablePath path, string globPattern) + { + return Matches(path, new[] { globPattern }); + } + + /// + /// Determines whether this path matches any of the provided glob patterns. + /// + /// + /// + /// Unlike , this does not touch the file system at all: the + /// path does not need to exist, and no directory is enumerated. The pattern is matched purely against the + /// string representation of (as returned by , + /// which uses the platform's directory separator), using + /// . + /// That overload performs a pure in-memory string match (via InMemoryDirectoryInfo) and never + /// enumerates or reads from disk. For a rooted/absolute the pattern is matched + /// relative to its drive/volume root (so e.g. src/**/*.cs matches anywhere under that drive); for a + /// relative it is matched relative to the current working directory, same as + /// does. + /// + /// + /// This addresses the capability requested (but not delivered as a public API) in the closed issue #35: a way + /// to test whether a matches a wildcard/glob pattern without listing a directory. + /// It is named Matches here (plural-safe, with this multi-pattern overload) rather than reusing the + /// originally proposed Match name. This overload returns if any of the + /// provided patterns matches. + /// + /// See also + /// + /// The path to test against the glob patterns. + /// One or more glob patterns used to match the path, e.g. **/*.cs or **/bin/** + /// Thrown if no glob patterns are provided or if any pattern is null or empty. + public static bool Matches(this ChainablePath path, params string[] globPatterns) + { + if (globPatterns == null || globPatterns.Length == 0) + { + throw new ArgumentException("At least one glob pattern must be provided", nameof(globPatterns)); + } + + foreach (string pattern in globPatterns) + { + if (string.IsNullOrWhiteSpace(pattern)) + { + throw new ArgumentException("Glob patterns cannot be null or empty", nameof(globPatterns)); + } + } + + Matcher matcher = new(StringComparison.OrdinalIgnoreCase); + foreach (string pattern in globPatterns) + { + matcher.AddInclude(pattern); + } + + string file = path.ToString(); + + // Match() never touches disk (Path.IsPathRooted/GetPathRoot are pure string operations), but the + // matcher still needs a root to compute file paths relative to. Anchoring to the drive/volume root for + // rooted paths ensures patterns like "src/**/*.cs" match wherever they occur on that drive, without + // requiring the path (or any directory) to actually exist. + string root = Path.IsPathRooted(file) ? Path.GetPathRoot(file) : Directory.GetCurrentDirectory(); + + return matcher.Match(root, file).HasMatches; + } } } diff --git a/Pathy.Specs/ChainablePathSpecs.cs b/Pathy.Specs/ChainablePathSpecs.cs index 3122135..4af2fb8 100644 --- a/Pathy.Specs/ChainablePathSpecs.cs +++ b/Pathy.Specs/ChainablePathSpecs.cs @@ -597,6 +597,125 @@ public void GlobFiles_with_multiple_patterns_throws_when_pattern_is_empty() .WithParameterName("globPatterns"); } + // Issue #35 asked for a `Match(wildcard)` method and was closed without shipping a public API. `Matches` + // (below) is what actually delivers that capability - purely in-memory, without ever touching the file + // system or requiring the path to exist. + [Fact] + public void An_absolute_path_matches_a_pattern_that_corresponds_to_its_suffix() + { + // Arrange + var path = ChainablePath.Temp / "src" / "Pathy" / "ChainablePath.cs"; + + // Act & Assert + path.Matches("**/*.cs").Should().BeTrue(); + } + + [Fact] + public void An_absolute_path_does_not_match_an_unrelated_pattern() + { + // Arrange + var path = ChainablePath.Temp / "src" / "Pathy" / "ChainablePath.cs"; + + // Act & Assert + path.Matches("**/*.md").Should().BeFalse(); + } + + [Fact] + public void Matches_does_not_require_the_path_to_exist_on_disk() + { + // Arrange + var path = ChainablePath.Temp / Guid.NewGuid().ToString("N") / "does-not-exist.cs"; + + // Act & Assert + path.Matches("**/*.cs").Should().BeTrue(); + } + + [Fact] + public void Matches_with_multiple_patterns_returns_true_if_any_pattern_matches() + { + // Arrange + var path = ChainablePath.Temp / "src" / "Pathy" / "ChainablePath.cs"; + + // Act & Assert + path.Matches("**/bin/**", "**/obj/**", "**/*.cs").Should().BeTrue(); + } + + [Fact] + public void Matches_with_multiple_patterns_returns_false_if_none_match() + { + // Arrange + var path = ChainablePath.Temp / "src" / "Pathy" / "ChainablePath.cs"; + + // Act & Assert + path.Matches("**/bin/**", "**/obj/**").Should().BeFalse(); + } + + [Fact] + public void Matches_is_case_insensitive() + { + // Arrange + var path = ChainablePath.Temp / "src" / "Pathy" / "ChainablePath.cs"; + + // Act & Assert + path.Matches("**/*.CS").Should().BeTrue(); + } + + [Fact] + public void Matches_with_single_pattern_throws_when_pattern_is_null() + { + // Arrange + var path = ChainablePath.Temp / "file.cs"; + + // Act & Assert + var act = () => path.Matches((string)null); + + act.Should().Throw() + .WithMessage("*Glob patterns cannot be null or empty*") + .WithParameterName("globPatterns"); + } + + [Fact] + public void Matches_with_multiple_patterns_throws_when_no_patterns_provided() + { + // Arrange + var path = ChainablePath.Temp / "file.cs"; + + // Act & Assert + var act = () => path.Matches(new string[0]); + + act.Should().Throw() + .WithMessage("*At least one glob pattern must be provided*") + .WithParameterName("globPatterns"); + } + + [Fact] + public void Matches_with_multiple_patterns_throws_when_a_pattern_is_null() + { + // Arrange + var path = ChainablePath.Temp / "file.cs"; + + // Act & Assert + var act = () => path.Matches("**/*.cs", null, "**/*.doc"); + + act.Should().Throw() + .WithMessage("*Glob patterns cannot be null or empty*") + .WithParameterName("globPatterns"); + } + + [Fact] + public void Matches_with_multiple_patterns_throws_when_a_pattern_is_empty() + { + // Arrange + var path = ChainablePath.Temp / "file.cs"; + + // Act & Assert + var act = () => path.Matches("**/*.cs", "", "**/*.doc"); + + act.Should().Throw() + .WithMessage("*Glob patterns cannot be null or empty*") + .WithParameterName("globPatterns"); + } + [Fact] public void Can_convert_to_directory_info() { diff --git a/README.md b/README.md index 49022cc..764d947 100644 --- a/README.md +++ b/README.md @@ -165,6 +165,15 @@ ChainablePath[] files = (ChainablePath.Current / "Artifacts").GlobFiles("**/*.js ChainablePath[] files = (ChainablePath.Current / "Artifacts").GlobFiles("**/*.txt", "**/*.md", "**/*.json"); ``` +The same package also provides `Matches`, which tests whether a path matches a glob pattern without touching +the file system at all - the path doesn't need to exist and no directory is enumerated: + +```csharp +changedFile.Matches("**/*.cs"); // true for src/Pathy/ChainablePath.cs +changedFile.Matches("**/bin/**", "**/obj/**"); // filter out build output +var relevant = changedFiles.Where(x => x.Matches("src/**/*.cs")).ToArray(); +``` + ### File system operations Next to that, Pathy also provides a bunch of extension methods to operate on the file-system: