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 1f55773..764d947 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,6 @@
+
+
@@ -163,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: