Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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) { }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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) { }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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) { }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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) { }
}
}
92 changes: 92 additions & 0 deletions Pathy.Globbing/PathyGlobbing.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -72,5 +73,96 @@ public static ChainablePath[] GlobFiles(this ChainablePath path, params string[]
.Select(file => ChainablePath.From(path / file.Path))
.ToArray();
}

/// <summary>
/// Determines whether this path matches the provided glob pattern.
/// </summary>
/// <remarks>
/// <para>
/// Unlike <see cref="GlobFiles(ChainablePath, string)"/>, 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 <paramref name="path"/> (as returned by <see cref="ChainablePath.ToString"/>, which uses
/// the platform's directory separator), using
/// <see cref="global::Microsoft.Extensions.FileSystemGlobbing.MatcherExtensions.Match(Matcher, string, string)"/>.
/// That overload performs a pure in-memory string match (via <c>InMemoryDirectoryInfo</c>) and never
/// enumerates or reads from disk. For a rooted/absolute <paramref name="path"/> the pattern is matched
/// relative to its drive/volume root (so e.g. <c>src/**/*.cs</c> matches anywhere under that drive); for a
/// relative <paramref name="path"/> it is matched relative to the current working directory, same as
/// <see cref="GlobFiles(ChainablePath, string)"/> does.
/// </para>
/// <para>
/// This addresses the capability requested (but not delivered as a public API) in the closed issue #35: a way
/// to test whether a <see cref="ChainablePath"/> matches a wildcard/glob pattern without listing a directory.
/// It is named <c>Matches</c> here (plural-safe, with a multi-pattern overload) rather than reusing the
/// originally proposed <c>Match</c> name.
/// </para>
/// See also <seealso href="https://learn.microsoft.com/en-us/dotnet/core/extensions/file-globbing"/>
/// </remarks>
/// <param name="path">The path to test against the glob pattern.</param>
/// <param name="globPattern">The glob pattern used to match the path, e.g. **/*.cs or **/bin/**</param>
public static bool Matches(this ChainablePath path, string globPattern)
{
return Matches(path, new[] { globPattern });
}

/// <summary>
/// Determines whether this path matches any of the provided glob patterns.
/// </summary>
/// <remarks>
/// <para>
/// Unlike <see cref="GlobFiles(ChainablePath, string[])"/>, 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 <paramref name="path"/> (as returned by <see cref="ChainablePath.ToString"/>,
/// which uses the platform's directory separator), using
/// <see cref="global::Microsoft.Extensions.FileSystemGlobbing.MatcherExtensions.Match(Matcher, string, string)"/>.
/// That overload performs a pure in-memory string match (via <c>InMemoryDirectoryInfo</c>) and never
/// enumerates or reads from disk. For a rooted/absolute <paramref name="path"/> the pattern is matched
/// relative to its drive/volume root (so e.g. <c>src/**/*.cs</c> matches anywhere under that drive); for a
/// relative <paramref name="path"/> it is matched relative to the current working directory, same as
/// <see cref="GlobFiles(ChainablePath, string[])"/> does.
/// </para>
/// <para>
/// This addresses the capability requested (but not delivered as a public API) in the closed issue #35: a way
/// to test whether a <see cref="ChainablePath"/> matches a wildcard/glob pattern without listing a directory.
/// It is named <c>Matches</c> here (plural-safe, with this multi-pattern overload) rather than reusing the
/// originally proposed <c>Match</c> name. This overload returns <see langword="true"/> if any of the
/// provided patterns matches.
/// </para>
/// See also <seealso href="https://learn.microsoft.com/en-us/dotnet/core/extensions/file-globbing"/>
/// </remarks>
/// <param name="path">The path to test against the glob patterns.</param>
/// <param name="globPatterns">One or more glob patterns used to match the path, e.g. **/*.cs or **/bin/**</param>
/// <exception cref="ArgumentException">Thrown if no glob patterns are provided or if any pattern is null or empty.</exception>
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;
}
}
}
119 changes: 119 additions & 0 deletions Pathy.Specs/ChainablePathSpecs.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ArgumentException>()
.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<ArgumentException>()
.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<ArgumentException>()
.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<ArgumentException>()
.WithMessage("*Glob patterns cannot be null or empty*")
.WithParameterName("globPatterns");
}

[Fact]
public void Can_convert_to_directory_info()
{
Expand Down
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
<h1 align="center">


<br>
<img src="./logo.png" style="width:300px" alt="Pathy"/>
<br>
Expand Down Expand Up @@ -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:
Expand Down