[API Proposal]: Add glob matching and enumeration to System.IO
- Dominant language
- C#
- Stars
- 18.3k
- Forks
- 5.6k
- PR merge metrics
- PR metrics pending
Description
### Background and motivation
.NET has simple wildcard matching in `System.IO.Enumeration.FileSystemName` and in the `searchPattern` parameters on `Directory`, but it has no inbox API for compiling and reusing a path glob such as `src/**/*.cs`, matching paths without touching the file system, composing include and exclude rules, or enumerating a directory tree with a glob.
This has been requested directly in dotnet/runtime:
- [dotnet/runtime#21362](https://github.com/dotnet/runtime/issues/21362), opened in 2017, asks for file and directory enumeration with glob patterns. It remains open with 89 thumbs-up reactions, 108 total reactions, and 29 comments as of August 6, 2026. The discussion specifically asks for `**`, a reusable matcher independent of physical file-system access, exclusion, explicit cross-platform semantics, and ordered rules in which a later include can override an earlier exclude.
- [dotnet/runtime#52868](https://github.com/dotnet/runtime/issues/52868) asks why `Directory.EnumerateFiles` does not support Bash-style glob patterns. The answer points callers to `Microsoft.Extensions.FileSystemGlobbing`, which confirms the scenario but still requires an out-of-band package and a separate abstraction.
The original discussion noted that globbing first needed a low-allocation enumeration extension point. That work was approved and completed in [dotnet/runtime#24429](https://github.com/dotnet/runtime/issues/24429), which introduced the `FileSystemEnumerator` foundation in .NET Core 2.1. This proposal supplies the reusable matching, rule-composition, and convenience-enumeration layer on top of that existing primitive rather than introducing a second traversal stack.
Demand is also visible in package adoption. NuGet reports [`Microsoft.Extensions.FileSystemGlobbing`](https://www.nuget.org/packages/Microsoft.Extensions.FileSystemGlobbing/) at 3.8 billion total downloads and an average of 21.4 million downloads per day as of August 6, 2026. Some of those downloads are transitive, so this is not a count of individual users, but it demonstrates that globbing is foundational infrastructure in the .NET ecosystem rather than a niche convenience.
The proposed API is based on the public, tested implementation in [`Touki.Io.Globbing`](https://github.com/JeremyKuhne/touki/tree/main/touki/Touki/Io/Globbing) and its enumeration integration in [`Touki.Io`](https://github.com/JeremyKuhne/touki/tree/main/touki/Touki/Io). It separates three concerns:
1. `GlobSpecification` compiles an immutable, root-independent pattern that can be reused concurrently and matched directly against spans.
2. `IFileSystemMatcher` is a reusable definition; each enumeration owns an independent `IFileSystemMatcherSession` whose split-span callbacks can be driven by `FileSystemEnumerator`, recorded enumeration data, or a virtual file system.
3. `FileSystemMatcher`, `FileSystemMatchEnumerator`, and `FileSystemPathEnumerator` provide immutable mixed-matcher composition and generic traversal without exposing framework implementation strategies.
The dialect is explicit because there is no single portable glob standard. The same compiler supports POSIX, path-aware POSIX, Bash (including opt-in globstar and extglob), Git, MSBuild item wildcards, `Microsoft.Extensions.FileSystemGlobbing`, `FileSystemName` simple expressions, and PowerShell wildcard behavior. The MSBuild dialect is included, but MSBuild-specific item parsing, result policy, and enumeration types are not part of this proposal.
Because these APIs are proposed directly in `System.IO`, the platform scope is current and future .NET. Touki's .NET Framework support demonstrates that the implementation can be carried downlevel, but adding these APIs to .NET Framework is not part of this proposal.
This shape has several advantages over moving the existing `Microsoft.Extensions.FileSystemGlobbing` implementation into `System.IO`:
- Compilation is separate from matching and enumeration. A specification can be cached and matched against `ReadOnlySpan` without constructing a virtual file-system object graph or a result collection.
- Common compiled match paths allocate no managed memory. Matching is implemented directly rather than by translating patterns to regular expressions. Complex extglob matches can rent pooled work buffers and allocate bounded memoization state.
- Enumeration streams through `FileSystemEnumerator`, uses the existing `EnumerationOptions` policy surface, caches directory state, and can prune a provably nonmatching subtree before visiting its files.
- Dialect selection makes semantic differences explicit instead of extending one package-specific syntax into an implicit universal standard.
- Immutable exclusion-wins and ordered definitions compose any `IFileSystemMatcher`, including compiled globs, regex adapters, generated matchers, and application predicates. FileSystemGlobbing 10 also added [ordered evaluation](https://learn.microsoft.com/dotnet/core/extensions/file-globbing#ordered-evaluation-of-includeexclude) through `preserveFilterOrder`; the distinction here is a reusable composition boundary rather than that evaluation policy by itself.
- Compilation has structured errors, a caller-provided pattern-length bound, and fixed extglob nesting and alternative limits. The extglob evaluator is iterative and memoized rather than recursively expanding untrusted input. The default `maxPatternLength` of `-1` does not impose an application-level length bound.
- The existing `FileSystemGlobbing` dialect provides a compatibility path for callers that need those semantics without making that dialect the architecture of the new API.
### Performance
Measurements from the existing Touki implementation provide evidence that the proposed architecture can reduce matching and enumeration costs relative to `Microsoft.Extensions.FileSystemGlobbing`:
| Scenario | Result |
| --- | --- |
| Reusable matching, four FileSystemGlobbing cases | 13.32-88.98x faster; saves 2,424-5,400 B per match |
| Existing SDK-style C# enumeration implementation | 1.27x faster; 93.6% less managed allocation |
The first two rows compare corresponding public scenarios rather than identical internal work. All compared paths produced equivalent results. These observations are workload-specific; see [Performance Details](#performance-details) for methodology, measurements, and limitations.
### API Proposal
Glob-specific types below are proposed in the `System.IO.Globbing` namespace. Reusable file-system matcher contracts, composition types, and generic enumerators are proposed in the `System.IO.Enumeration` namespace. Touki's `StringSegment` parameters and
properties are represented as `string`; no Touki helper types are proposed for the BCL.
The proposed contract matches the implemented replacement architecture: normalized enumeration roots, borrowed reusable definitions, independently owned sessions, role-neutral directory classifications, immutable composition, non-disposable compiled
specifications, and a discoverable `Glob.EnumerateFiles` convenience entry point.
```csharp
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.IO.Enumeration;
namespace System.IO.Globbing;
public static class Glob
{
public static bool IsMatch(
string pattern,
ReadOnlySpan input,
GlobDialect dialect,
GlobOptions options = GlobOptions.None);
// Compiles once and lazily enumerates canonical root-relative matching file paths.
public static IEnumerable EnumerateFiles(
string rootDirectory,
string pattern,
GlobDialect dialect,
GlobOptions options = GlobOptions.None,
EnumerationOptions? enumerationOptions = null);
}
public enum GlobDialect
{
// POSIX fnmatch semantics without path-separator awareness.
Posix = 0,
// POSIX fnmatch semantics where ordinary wildcards do not cross path separators.
PosixPath = 1,
// Bash pattern semantics, with globstar and extended globs enabled through options.
Bash = 2,
// Git wildmatch plus single-pattern !, leading /, and trailing / markers.
Git = 3,
// MSBuild item-wildcard semantics.
MSBuild = 4,
// Microsoft.Extensions.FileSystemGlobbing.Matcher semantics.
FileSystemGlobbing = 5,
// FileSystemName simple-expression semantics for * and ?.
Simple = 6,
// PowerShell WildcardPattern and -like semantics.
PowerShell = 7
}
[Flags]
public enum GlobOptions
{
// Uses the selected dialect's default behavior.
None = 0,
// Matches case-insensitively using the selected dialect's case-folding rules.
IgnoreCase = 1 << 0,
// Allows wildcards to consume a leading period.
MatchLeadingDot = 1 << 1,
// Treats the selected dialect's escape character as a literal.
NoEscape = 1 << 2,
// Enables the ** wildcard for matching across path segments.
AllowGlobStar = 1 << 3,
// Enables ?(), *(), +(), @(), and !() extended-glob constructs, up to
// eight nested constructs and 32 alternatives in one construct.
AllowExtGlob = 1 << 4
}
public enum GlobPathSeparator
{
// Uses the selected dialect's documented default separator.
DialectDefault = 0,
// Uses Path.DirectorySeparatorChar for the current operating system.
OSDefault = 1,
// Uses / as the path separator on every operating system.
ForwardSlash = 2,
// Uses \ as the path separator on every operating system.
Backslash = 3
}
public enum GlobCompileErrorCode
{
// No compilation error occurred.
None = 0,
// A character class is missing its closing bracket.
UnterminatedClass = 1,
// An extended-glob construct is missing its closing parenthesis.
UnterminatedExtGlob = 2,
// An escape character appears at the end of the pattern.
DanglingEscape = 3,
// A character-class range has invalid or reversed endpoints.
InvalidClassRange = 4,
// The selected dialect or options do not enable a pattern feature.
FeatureNotEnabled = 5,
// The pattern exceeds a configured or encoded-size limit.
PatternTooLarge = 6,
// An extended-glob body is malformed.
InvalidExtGlobBody = 7,
// A pattern exceeds an extended-glob depth or alternative-count limit.
FeatureLimitExceeded = 8,
// A FileSystemGlobbing parent segment appears after a non-parent segment.
ParentSegmentNotAtBeginning = 9
}
public readonly struct GlobCompileError
{
public GlobCompileError(GlobCompileErrorCode code, int position, string message);
public GlobCompileError(GlobCompileErrorCode code, string message);
public GlobCompileErrorCode Code { get; }
public int Position { get; }
public string Message { get; }
public bool IsError { get; }
}
public sealed class GlobFormatException : FormatException
{
public GlobFormatException(GlobCompileError error);
public GlobCompileError Error { get; }
}
// Represents an immutable, reusable compiled glob independent of an enumeration root.
public sealed class GlobSpecification
{
// Throws GlobFormatException for compilation errors.
public static GlobSpecification Compile(
string pattern,
GlobDialect dialect,
GlobOptions options = GlobOptions.None,
GlobPathSeparator separator = GlobPathSeparator.DialectDefault,
int maxPatternLength = -1);
// Convenience overloads default options while preserving trailing out parameters.
public static bool TryCompile(
string pattern,
GlobDialect dialect,
[NotNullWhen(true)] out GlobSpecification? result,
out GlobCompileError error);
public static bool TryCompile(
string pattern,
GlobDialect dialect,
GlobOptions options,
[NotNullWhen(true)] out GlobSpecification? result,
out GlobCompileError error);
public static bool TryCompile(
string pattern,
GlobDialect dialect,
GlobOptions options,
GlobPathSeparator separator,
int maxPatternLength,
[NotNullWhen(true)] out GlobSpecification? result,
out GlobCompileError error);
// Gets the original pattern text supplied for compilation.
public string Pattern { get; }
// Gets the dialect used to compile the pattern.
public GlobDialect Dialect { get; }
// Gets the options used to compile the pattern.
public GlobOptions Options { get; }
// Gets the resolved path separator, or \0 when the dialect is path-unaware.
public char Separator { get; }
// Gets whether a Git-style leading ! complements the single-pattern result.
public bool Negated { get; }
// Gets whether a Git-style leading / anchors matching to the conceptual root.
public bool RootAnchored { get; }
// Gets whether a Git-style trailing / requires directory context.
public bool DirectoryOnly { get; }
public bool IsMatch(ReadOnlySpan input);
public IFileSystemMatcher CreateFileSystemMatcher();
}
```
```csharp
using System;
using System.Collections.Generic;
namespace System.IO.Enumeration;
public enum DirectoryMatchType : byte
{
// Some descendant files may match, or the session cannot prove otherwise.
MayContainMatchingFiles = 0,
// No descendant file can match.
NoDescendantFilesMatch = 1,
// Every descendant file matches.
AllDescendantFilesMatch = 2
}
// Reusable, caller-owned definition. Each enumeration creates an independent session.
public interface IFileSystemMatcher
{
IFileSystemMatcherSession CreateSession(string rootDirectory);
}
// Root-bound mutable state owned by one enumeration.
public interface IFileSystemMatcherSession : IDisposable
{
bool MatchesFile(
ReadOnlySpan currentDirectory,
ReadOnlySpan fileName);
DirectoryMatchType MatchesDirectory(
ReadOnlySpan currentDirectory,
ReadOnlySpan directoryName);
void DirectoryFinished(ReadOnlySpan directory);
}
// Conservative base for custom sessions.
public abstract class FileSystemMatcherSession : IFileSystemMatcherSession
{
public abstract bool MatchesFile(
ReadOnlySpan currentDirectory,
ReadOnlySpan fileName);
public virtual DirectoryMatchType MatchesDirectory(
ReadOnlySpan currentDirectory,
ReadOnlySpan directoryName);
public virtual void DirectoryFinished(ReadOnlySpan directory);
public virtual void Dispose();
}
public delegate bool FileSystemMatchPredicate(
ReadOnlySpan currentDirectory,
ReadOnlySpan fileName);
public delegate bool PathMatchPredicate(ReadOnlySpan rootRelativePath);
public enum FileSystemMatchAction : byte
{
Include = 0,
Exclude = 1
}
public readonly struct FileSystemMatchRule
{
public FileSystemMatchRule(
IFileSystemMatcher matcher,
FileSystemMatchAction action);
public IFileSystemMatcher Matcher { get; }
public FileSystemMatchAction Action { get; }
}
public static class FileSystemMatcher
{
// Callback-native adapter; no path is joined.
public static IFileSystemMatcher Create(FileSystemMatchPredicate predicate);
// Opt-in canonical '/'-separated root-relative path adapter.
public static IFileSystemMatcher CreatePath(PathMatchPredicate predicate);
// Immutable Any(include) AND NOT Any(exclude) composition.
public static IFileSystemMatcher CreateExclusionWins(
IReadOnlyList includes,
IReadOnlyList? excludes = null);
// Immutable last-matching-rule-wins composition.
public static IFileSystemMatcher CreateOrdered(
IReadOnlyList rules,
bool includeUnmatched = false);
}
// Borrows a reusable definition and lazily owns one session.
public abstract class FileSystemMatchEnumerator : FileSystemEnumerator
{
protected FileSystemMatchEnumerator(
string rootDirectory,
IFileSystemMatcher matcher,
EnumerationOptions? options = null);
protected string EnumerationRootDirectory { get; }
protected sealed override bool ShouldIncludeEntry(ref FileSystemEntry entry);
protected sealed override bool ShouldRecurseIntoEntry(ref FileSystemEntry entry);
protected sealed override void OnDirectoryFinished(ReadOnlySpan directory);
protected virtual bool ShouldIncludeMatchedFile(ref FileSystemEntry entry);
protected virtual void DisposeAdditionalResources(bool disposing);
protected sealed override void Dispose(bool disposing);
}
public sealed class FileSystemPathEnumerator : FileSystemMatchEnumerator
{
public static FileSystemPathEnumerator Create(
string rootDirectory,
IFileSystemMatcher matcher,
EnumerationOptions? options = null);
protected override string TransformEntry(ref FileSystemEntry entry);
}
```
### Compilation and matching contracts
- `Compile` throws `GlobFormatException` for a pattern compilation error. `TryCompile` reports the same condition by returning `false`, setting `result` to `null`, and returning an error whose `IsError` is `true`. On success, `result` is non-null and `error` is `default`; `default(GlobCompileError).Message` returns `string.Empty`. Null arguments, undefined enum values or option bits, and a `maxPatternLength` below `-1` remain argument errors for both entry points.
- `maxPatternLength: -1` imposes no caller-selected bound; a nonnegative value limits the supplied pattern length. Extglob compilation additionally permits at most eight nested constructs and 32 alternatives in any one construct. Exceeding either fixed limit reports `FeatureLimitExceeded`.
- `IsMatch` evaluates only the supplied span and performs no file-system access or separator normalization. A path-aware dialect expects its resolved `Separator`; callers normalize mixed-separator input themselves. A path-unaware dialect treats the input as one string and allows ordinary wildcards to consume separator characters.
- For one Git pattern, a leading `!` complements the result and a leading `/` anchors matching to the start of the supplied root-relative input. A trailing `/` sets `DirectoryOnly`, but `IsMatch` has no entry-kind input and therefore evaluates only the lexical pattern body. Callers doing flat matching inspect `DirectoryOnly` when entry kind matters; file-system sessions enforce it.
- `CreateFileSystemMatcher` creates a reusable file-system definition. Its path-aware sessions match canonical paths relative to the session root, translate native separators to the compiled separator, and enforce Git directory-only behavior. Path-unaware sessions match only `fileName` and classify directories conservatively.
### Dialect defaults
`GlobOptions.None` means "use the selected dialect's defaults", not that every feature is disabled:
| Dialect | Path-aware | Default separator | Default casing | Globstar | Escape |
| --- | --- | --- | --- | --- | --- |
| `Posix` | No | N/A | Sensitive, ASCII fold when requested | N/A | `\` |
| `PosixPath` | Yes | `/` | Sensitive, ASCII fold when requested | Opt-in | `\` |
| `Bash` | Yes | `/` | Sensitive, ASCII fold when requested | Opt-in | `\` |
| `Git` | Yes | `/` | Sensitive, ASCII fold when requested | Enabled | `\` |
| `MSBuild` | Yes | `/` | Unicode ordinal-insensitive | Enabled | None |
| `FileSystemGlobbing` | Yes | `/` | Sensitive; use `IgnoreCase` to match parameterless `Matcher` | Enabled | None |
| `Simple` | No | N/A | Sensitive | N/A | None |
| `PowerShell` | No | N/A | Sensitive; use `IgnoreCase` for `-like` casing | N/A | Backtick |
The table and differences below define the currently proposed behavior. Dialect names identify the ecosystems being modeled; they do not promise exact parity with every version. Unless API review deliberately changes one, the proposal preserves these tested differences:
- **`Posix` / `PosixPath`:** Leading-dot protection is enabled by default rather than selected by an `FNM_PERIOD` option. Path mode currently applies it only at the start of the complete input, not after every separator.
- **`Bash`:** Globstar and extglob are opt-in. `!(*)` does not match an empty input, unlike Bash 5.x. Extglob alternatives that mix a literal-dot arm with wildcard-led arms have a documented leading-dot limitation.
- **`Git`:** Leading-dot protection is enabled by default, unlike ordinary Git `wildmatch`; `MatchLeadingDot` selects Git's ordinary behavior. A trailing run such as `a/***` is normalized to `a/**` and matches `a/`, while Git requires at least one descendant segment.
- **`MSBuild`:** The dialect models item-wildcard matching only. MSBuild item-list parsing, escaping, invalid-spec policy, and drive-root search policy are out of scope.
- **`FileSystemGlobbing`:** Matching defaults to case-sensitive, unlike parameterless `Matcher`, and `?` remains literal. `AllowExtGlob` is an extension to this dialect and disables several whole-pattern compatibility rewrites because the reference package has no extglob composition to model.
- **`Simple`:** Matching defaults to case-sensitive and treats backslash literally; `FileSystemName.MatchesSimpleExpression` defaults to case-insensitive matching and treats backslash as an escape.
- **`PowerShell`:** Matching defaults to the case-sensitive behavior of a bare `WildcardPattern`; use `IgnoreCase` for `-like`. POSIX-style bracket negation and named classes are accepted even though PowerShell does not support them.
`GlobPathSeparator` selects one separator for a path-aware compiled specification. Inputs that mix separators must be normalized by the caller; the optimized file-system session performs native-to-dialect separator translation
at the enumeration boundary.
### Enumeration and ownership contracts
- `Glob.EnumerateFiles` and the framework-provided enumerators normalize physical roots once with `Path.GetFullPath`; a trailing separator remains only for a file-system root. Relative roots resolve against the current directory at construction time.
- `Glob.EnumerateFiles` validates arguments, compiles the pattern, normalizes the root, and snapshots a supplied `EnumerationOptions` when called. File-system access begins when the returned sequence is enumerated. The sequence is restartable and may be enumerated concurrently; each enumerator creates and owns an independent matcher session when matching begins. A null `enumerationOptions` is equivalent to a new `EnumerationOptions` with `RecurseSubdirectories` and `IgnoreInaccessible` set to `true`.
- `Glob.EnumerateFiles` and `FileSystemPathEnumerator` yield files as root-relative paths with no leading separator and `/` between components. A literal backslash in a Unix file name remains a backslash.
- `IFileSystemMatcher` definitions are immutable, reusable, and borrowed. Every successful `CreateSession` call returns independently owned mutable state for one enumeration. Composition snapshots definition references and rule order but never disposes definitions.
- A session root establishes the lexical prefix used by its callbacks. The physical enumerator supplies a normalized absolute root, an absolute native-separated `currentDirectory` equal to that root or one of its descendants, and a one-segment `fileName` or `directoryName`. Replay and virtual hosts provide the same prefix and one-segment invariants with a consistent separator convention; built-in glob sessions expect current-platform separators in `rootDirectory` and `currentDirectory`. Input spans are valid only for the call.
- Session calls are single-threaded. All entry callbacks for a visited directory precede one `DirectoryFinished` call after normal exhaustion. A directory that is pruned is not visited, and early disposal or an exception does not promise completion callbacks for unfinished directories. Disposing the enumerator disposes its session; callers that drive `MoveNext` directly must retain the usual `using` or `try/finally` discipline.
- `MatchesDirectory` is role-neutral. `NoDescendantFilesMatch` permits pruning, `AllDescendantFilesMatch` is a complete-match proof used by composition, and unknown enum values normalize conservatively to `MayContainMatchingFiles`.
- Live enumeration forwards `FileSystemEntry.Directory` and `FileName` to sessions without allocating. Because the matcher protocol itself takes spans, CSV replay and custom virtual filesystems can supply equivalent directory/name pairs without synthesizing a `FileSystemEntry`. `CreatePath` is an explicit adapter to canonical root-relative `/` paths. Compatible path-predicate children in one framework composition share one path build; split-span-only graphs never construct that path.
- Existing `FileSystemEnumerable` predicates remain the smaller surface for one-off live filtering and projection. They do not replace reusable matcher definitions, three-state subtree proofs, cache-bearing sessions, or host-neutral replay; the distinction is detailed under Alternative Designs.
- `CreateExclusionWins` implements `Any(include) AND NOT Any(exclude)` independent of insertion order. `CreateOrdered` applies source-order last-match-wins. Both are immutable definitions and can mix globs, regex adapters, custom sessions, and nested definitions. Factories reject null children, undefined `FileSystemMatchAction` values, and a default `FileSystemMatchRule` whose matcher is null.
- A rule action is applied to the child matcher's final result. In particular, a Git leading `!` has already complemented its specification before an ordered rule's `Include` or `Exclude` action is applied.
- Generic ordered composition is not full `.gitignore` evaluation. It does not parse ignore files, apply per-directory source precedence, or prevent a descendant from rescuing itself through an ignored parent.
- `Glob.EnumerateFiles` has no pattern-length bound and is intended for trusted or prevalidated patterns. For untrusted patterns, callers compile with a finite `maxPatternLength`, obtain a definition with `CreateFileSystemMatcher`, and enumerate it through `FileSystemPathEnumerator`.
### Scope
MSBuild-specific item parsing, planning, result policy, and enumeration APIs are out of scope. `GlobDialect.MSBuild` remains so callers can compile and enumerate ordinary MSBuild wildcard patterns through the general APIs.
Full `.gitignore` file parsing, per-directory source precedence, and ancestor-aware re-inclusion are also out of scope. `GlobDialect.Git` compiles one Git wildmatch pattern; `FileSystemMatcher.CreateOrdered` remains generic last-matching-rule-wins composition and
does not claim `.gitignore` parity.
### API Usage
The examples assume SDK-style implicit `System` usings.
### Enumerate files with POSIX path semantics
```csharp
using System.IO.Globbing;
foreach (string file in Glob.EnumerateFiles(
rootDirectory: repositoryRoot,
pattern: "**/*.cs",
dialect: GlobDialect.PosixPath,
options: GlobOptions.AllowGlobStar))
{
Console.WriteLine(file); // Canonical root-relative path.
}
```
### Compile once and match without touching the file system
```csharp
using System.IO.Globbing;
GlobSpecification specification = GlobSpecification.Compile(
pattern: "src/**/*.cs",
dialect: GlobDialect.PosixPath,
options: GlobOptions.AllowGlobStar);
bool sourceFile = specification.IsMatch("src/Compilers/Parser.cs");
bool generatedFile = specification.IsMatch("artifacts/Parser.g.cs");
```
### Handle a user-supplied pattern without exceptions
```csharp
using System.IO.Globbing;
if (!GlobSpecification.TryCompile(
pattern,
GlobDialect.Bash,
GlobOptions.AllowGlobStar | GlobOptions.AllowExtGlob,
GlobPathSeparator.ForwardSlash,
maxPatternLength: 4_096,
out GlobSpecification? specification,
out GlobCompileError error))
{
Console.Error.WriteLine($"Invalid glob at {error.Position}: {error.Message}");
return;
}
Console.WriteLine(specification.IsMatch(candidate));
```
### Implement a reusable matcher session
```csharp
using System.IO.Enumeration;
using System.Text.RegularExpressions;
IFileSystemMatcher matcher = new RegexFileSystemMatcher(
new Regex(@"^Generated.*\.cs$", RegexOptions.CultureInvariant));
using FileSystemPathEnumerator enumerator = FileSystemPathEnumerator.Create(
repositoryRoot,
matcher);
while (enumerator.MoveNext())
{
Console.WriteLine(enumerator.Current);
}
sealed class RegexFileSystemMatcher(Regex regex) : IFileSystemMatcher
{
public IFileSystemMatcherSession CreateSession(string rootDirectory)
{
ArgumentNullException.ThrowIfNull(rootDirectory);
return new RegexFileSystemMatcherSession(regex);
}
}
sealed class RegexFileSystemMatcherSession(Regex regex) : FileSystemMatcherSession
{
public override bool MatchesFile(
ReadOnlySpan currentDirectory,
ReadOnlySpan fileName) => regex.IsMatch(fileName);
}
```
Each `CreateSession` call returns a separately owned session. This example matches only the supplied file-name span, so it does not construct a path. The inherited `MatchesDirectory` conservatively permits recursion, and the inherited `Dispose` has nothing to release; stateful implementations can override either member. For a one-off filter that does not need reuse or composition, construct a
`FileSystemEnumerable` and set its `ShouldIncludePredicate` instead.
### Use MSBuild wildcard semantics without MSBuild-specific APIs
```csharp
using System.IO.Globbing;
GlobSpecification specification = GlobSpecification.Compile(
"**/*.cs",
GlobDialect.MSBuild);
bool match = specification.IsMatch("src/Program.cs");
```
### Alternative Designs
### Extend the existing `Directory` search-pattern behavior
Changing the meaning of existing `searchPattern` parameters risks breaking callers that depend on the current Win32/simple wildcard behavior. It also would not provide a file-system-independent compiled matcher, mixed include/exclude composition, dialect
selection, or custom `FileSystemEnumerator` integration. New APIs avoid that compatibility risk while allowing existing `Directory` methods to remain unchanged.
### Use only `FileSystemEnumerable` predicates
`ShouldIncludePredicate` and `ShouldRecursePredicate` are the preferred existing surface for one-off live filtering and traversal decisions. They cannot directly serve as matcher definitions: callers cannot populate a `FileSystemEntry` with recorded or virtual directory/name data; `FindPredicate` is nested in `FileSystemEnumerable` even though matching is independent of `TResult`; predicate properties are shared by the enumerable; and the internal enumerator provides no predicate hook for per-enumeration construction, disposal, or `OnDirectoryFinished`. A Boolean recurse predicate also cannot carry the `AllDescendantFilesMatch` proof needed
by composition. The proposal therefore retains host-neutral split-span callbacks and a matching delegate adapter for reusable, replayable, composable sessions.
### Move `Microsoft.Extensions.FileSystemGlobbing` into `System.IO`
This would preserve a familiar API and one widely used dialect, including the ordered evaluation mode added in version 10 through `preserveFilterOrder`. It would also make the existing package's architecture and compatibility constraints the permanent BCL
shape. In particular, `Matcher` is a mutable include/exclude builder centered on `DirectoryInfoBase`/`FileInfoBase` abstractions and collection-returning match results; it does not expose a reusable span matcher, mixed-dialect matcher composition, or a
`FileSystemEnumerator` policy boundary.
The proposal instead includes `GlobDialect.FileSystemGlobbing`. The existing package can remain compatible and independently serviced, and could later delegate matching to the inbox engine where doing so is behaviorally safe.
### Add only compiled matching
A smaller first phase could omit `Glob.EnumerateFiles` and the matcher/enumerator types, exposing only `Glob.IsMatch`, `GlobSpecification`, the options, and errors. That would address file-system-independent matching but leave the original enumeration request unresolved and encourage each caller to reimplement traversal, exclusion ordering, root handling, and pruning. The proposal is structured so API review or implementation can still be staged along the compilation, matching, composition, and enumeration boundaries.
### Retain a dedicated `GlobEnumerator`
A dedicated concrete enumerator and `GlobEnumerationOptions` class could combine pattern compilation, exclusion, and traversal. Those two types duplicate the generic `FileSystemPathEnumerator` and matcher-composition surface, however, while still exposing
the lower-level `MoveNext` and disposal pattern for the simplest use case. The proposed `Glob.EnumerateFiles` method makes one-pattern enumeration discoverable and idiomatic; callers that need exclusions or mixed matcher types use the generic composition APIs.
### Add full `.gitignore` rule-set evaluation
An opaque rules type could parse complete ignore files, combine sources rooted at different directories, apply source precedence, and enforce Git's rule that a file cannot be re-included while a parent directory remains excluded. That behavior cannot be reproduced by generic last-match-wins composition alone. It is nevertheless a specialized version-control policy rather than a requirement for matching one Git wildmatch pattern or enumerating globbed files. Deferring it avoids dedicated rule-set types and avoids implying that the general globbing API is also a complete Git ignore engine.
### Define one new universal glob syntax
There is no consensus syntax to select. POSIX, Bash, Git, MSBuild, `FileSystemGlobbing`, `FileSystemName`, and PowerShell differ in separator handling, escaping, casing, leading-dot behavior, `?`, repeated `*`, and globstar. Making the dialect explicit is more predictable than silently choosing one ecosystem's behavior.
### Keep the matcher abstraction in `System.IO.Globbing`
The reusable matcher contracts, composition types, and generic enumerators could live alongside the glob-specific types in `System.IO.Globbing`. Keeping them together would make this proposal's complete surface discoverable through one namespace, but would tie general file-system enumeration extension points to one matching syntax. The proposal instead places the non-glob-specific surface in `System.IO.Enumeration`, where it directly extends `FileSystemEnumerator` and can support other matcher
implementations without a globbing namespace dependency.
### Offer a downlevel compatibility package
Adding the API directly to `System.IO` would not add it to .NET Framework. A separate compatibility package could multi-target and reuse the same matcher sources while building its .NET Framework enumeration layer on [`Microsoft.IO.Redist`](https://www.nuget.org/packages/Microsoft.IO.Redist/). That package backports the modern IO enumeration surface under `Microsoft.IO` and `Microsoft.IO.Enumeration`, including `FileSystemEnumerator`, without colliding with .NET Framework's existing `System.IO` types.
Touki uses this design today: its [.NET Framework target](https://github.com/JeremyKuhne/touki/blob/main/touki/touki.csproj) conditionally references `Microsoft.IO.Redist`, and its [global aliases]https://github.com/JeremyKuhne/touki/blob/main/touki/GlobalUsings.cs)
select `Microsoft.IO` / `Microsoft.IO.Enumeration` where modern targets use `System.IO` / `System.IO.Enumeration`. This keeps the matching and enumeration implementation shared across targets. Such a package could provide a downlevel polyfill for consumers that need it, but it is independent of the inbox API and its support lifecycle.
### Risks
- This is a large API surface with policy-rich behavior. It likely warrants a dedicated API review and may be implemented in phases even if reviewed as one coherent proposal.
- Dialect names become long-lived behavioral contracts while Bash, Git, MSBuild, PowerShell, and `Microsoft.Extensions.FileSystemGlobbing` can evolve. Documentation must identify the modeled behavior and intentional differences, and tests should continue to use those implementations as parity oracles where practical.
- The API overlaps with `Microsoft.Extensions.FileSystemGlobbing`. The existing package cannot be removed, and adapters or migration guidance may be needed to avoid ecosystem confusion.
- Glob matching can be exposed to untrusted patterns and paths. The implementation has fixed extglob structural and encoded-body limits plus an iterative memoized engine with bounded recursive probes. Not every buffer-growth calculation is checked, so
overflow guards remain part of the port's security review. Pattern length is caller-bounded: `maxPatternLength: -1` and `Glob.IsMatch` impose no application-level limit. Callers accepting untrusted patterns should set a finite bound and separately
constrain input length and execution time. `Glob.EnumerateFiles` does not expose a pattern-length bound, so untrusted patterns must be compiled separately. The fixed extglob limits become compatibility-sensitive once shipped.
- Sessions are stateful and single-threaded during enumeration, while definitions and `GlobSpecification` are concurrently reusable. The distinction and ownership model must be prominent in the documentation.
- Composite definitions borrow child definitions and own only the child sessions they create. Partial session construction and multi-child disposal must remain exception-safe.
- `GlobPathSeparator` deliberately selects one separator. Flat matching callers must normalize mixed-separator inputs; changing this later could alter both semantics and performance.
- The `string` substitution for Touki's `StringSegment` is idiomatic for the BCL but gives up allocation-free retention of slices from larger configuration strings. A `ReadOnlyMemory` overload could preserve that scenario at the cost of a larger and less familiar API. In the Touki prototype, retaining its package-only partial-`StringSegment` overload while exposing `Pattern` as `string` added exactly 40 B per compile to materialize a seven-character slice. Modern .NET RyuJIT measured 96.81 ns versus 93.48 ns for a full string; .NET Framework 4.8.1 RyuJIT measured 179.7 ns versus 179.9 ns. The BCL surface itself accepts only `string`, so ordinary callers do not pay this compatibility-overload cost.
- `GlobCompileError` is a value type with a non-nullable message. The implementation must guarantee that `default(GlobCompileError).Message` returns `string.Empty`.
## Performance Details
### Compiled matching
The existing
[`FileSystemGlobbingParityMatchPerf`](https://github.com/JeremyKuhne/touki/blob/main/touki.perf/FileSystemGlobbingParityMatchPerf.cs) benchmark compiles both matchers once, verifies that they agree, reuses the same input, and compares `GlobSpecification.IsMatch` with the public `Matcher.Match` API. The four cases cover a literal question mark, a trailing separator, a recursive suffix, and sequential separators. For this proposal, the benchmark was rerun after temporarily updating its oracle from the repository's 9.0.11 pin to the current stable `Microsoft.Extensions.FileSystemGlobbing` 10.0.10 package. BenchmarkDotNet results collected on August 6, 2026 on modern .NET were:
| Runtime | Touki mean range | `Matcher.Match` mean range | Speedup range | Managed allocation per match |
| --- | ---: | ---: | ---: | ---: |
| .NET 10 | 8.51-75.30 ns | 596.05-1,209.99 ns | 13.32-88.98x | 0 B vs. 2,424-5,400 B |
These numbers compare the closest reusable public matching scenarios, not internal parser kernels. The operations do not return the same result shape: `Matcher.Match` constructs its public match result while `GlobSpecification.IsMatch` returns a Boolean. The allocation difference is still relevant to the proposed Boolean span-matching API, for which the existing package has no equivalent public operation.
### Enumeration
The separate [`GlobEnumerateFsgPerf`](https://github.com/JeremyKuhne/touki/blob/main/touki.perf/GlobEnumerateFsgPerf.cs) benchmark measures Touki's existing public `GlobEnumerator` path using the glob workload that an SDK-style project created from the default C# template uses to discover its implicit `Compile` items. The [SDK default-items declaration][sdk-default-items] uses `Compile Include="**/*$(DefaultLanguageSourceExtension)"` with `DefaultItemExcludes` and `DefaultExcludesInProjectFolder`. Its [default exclusion pipeline][sdk-default-excludes] adds output and intermediate directories plus project metadata such as `*.user`, `*.*proj`, solution files, source-control files, and `.DS_Store`. The benchmark resolves that workload for C# and the Touki tree to `**/*.cs` plus ten exclusion patterns, then materializes the relative results in a list.
[sdk-default-items]: https://github.com/dotnet/sdk/blob/main/src/Tasks/Microsoft.NET.Build.Tasks/targets/Microsoft.NET.Sdk.DefaultItems.props
[sdk-default-excludes]: https://github.com/dotnet/sdk/blob/main/src/Tasks/Microsoft.NET.Build.Tasks/targets/Microsoft.NET.Sdk.DefaultItems.targets
Touki's `GlobEnumerator` compiles every supplied pattern and composes their sessions without raw textual subsumption. This is therefore an end-to-end implementation-path comparison rather than an isolated matcher-kernel comparison. A separate validation invocation normalized path separators and compared the complete result sets: it invoked both benchmark methods after `GlobalSetup`, replaced `\` with `/`, sorted with `StringComparer.Ordinal`, and compared with `SequenceEqual`. Both implementations
returned the same 820 files.
| Runtime | `Matcher.Execute` | Touki `GlobEnumerator` | Throughput | Managed allocation |
| --- | ---: | ---: | ---: | ---: |
| .NET 10 | 24.98 ms / 5,589.55 KB | 19.71 ms / 359.69 KB | 1.27x faster | 93.6% less |
The proposed `Glob.EnumerateFiles` convenience method was also measured directly against Touki's dedicated `GlobEnumerator` in `GlobEnumeratorApiPerf`. This checks whether the simpler public API adds overhead to the one-pattern case. The benchmark used a fixed
64-module tree (192 matching files); each row compiled the pattern and materialized every returned path. Three launches with three warmups and ten measured iterations produced:
| Runtime / JIT | Existing `GlobEnumerator` | `Glob.EnumerateFiles` | Ratio / variation | Managed allocation |
| --- | ---: | ---: | ---: | ---: |
| .NET 10.0.10, modern .NET RyuJIT x64 | 5.975 ms | 6.117 ms | 1.03, ratio SD 0.08 | 82.04 KB vs. 82.25 KB |
| .NET Framework 4.8.1 RyuJIT x64 | 6.371 ms | 6.349 ms | 1.00, ratio SD 0.09 | 95.33 KB vs. 84.98 KB |
The throughput differences are inside the measured variation on both JITs. The convenience method adds about 0.21 KB per 192-result operation on modern .NET and allocates about 10.35 KB less than the legacy concrete enumerator on .NET Framework. An output-equivalent direct `FileSystemPathEnumerator` row was included to distinguish canonical-path conversion from convenience-method overhead; its timing was noisier and did not change the conclusion.
These totals include the returned path strings. This aggregate benchmark does not attribute the remaining Touki `GlobEnumerator` allocation or establish allocation behavior for other dialects and extglob shapes; the preceding `IsMatch` benchmark is the allocation measurement for its four FileSystemGlobbing cases. The current implementation compiles all supplied patterns and applies no raw textual exclude-elimination shortcut.
Contributor guide
Research direction
Start with the proposed System.IO.Globbing public contract and the existing FileSystemEnumerator foundation referenced in the issue. Compare the matcher, session, and enumerator responsibilities with the Touki.Io.Globbing and Touki.Io implementations named there. Done means a settled, reviewable API design covering matching, composition, and enumeration semantics.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- csharp
- Domain
- api, operating-systems
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 35/100