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 @@

+ +
Pathy
@@ -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: