From 053f7264b0e6dbe1beed2afd3a00947b0ec2afb4 Mon Sep 17 00:00:00 2001 From: Kevin Thompson Date: Sat, 27 Jun 2026 12:17:42 -0700 Subject: [PATCH] Use System.IO.Enumeration for faster recursive path traversal Replaces the manual BFS traverser with a single FileSystemEnumerable using RecurseSubdirectories. Glob matching is pushed into the enumeration: - ShouldRecursePredicate (PathMatcher.CanRecurse) prunes subtrees that cannot match the pattern before any IO is done on them - ShouldIncludePredicate (PathMatcher.IsFullMatch) performs the full glob match; only matched entries are materialized into FileSystemInfo objects - Each path is visited exactly once -- no HashSet dedup needed PathMatcher uses span-based matching (no string[] allocation per path) mirroring GlobEvaluator's ** backtracking semantics. A bare ** pattern still emits the search root for behavioral parity. Dead code removed: PathTraverserEnumerable (manual BFS), GetFiles/ GetDirectories + _dirCache in TraverseOptions (now a plain flags carrier), MockTraverseOptions/MockFileSystemNode and their System.IO.Abstractions package dependencies. Benchmarks (net8.0, Apple M3) vs origin/develop: PathTraversalSparseMatch 1,622,130 ns -> 714,784 ns (-56%, -70% alloc) PathTraversalDirectories 689,596 ns -> 535,730 ns (-22%, -46% alloc) PathTraversal 29,635 ns -> 30,148 ns (+2%, -18% alloc) Closes #33 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 6 + src/Glob/Matcher.cs | 9 +- src/Glob/PathMatcher.cs | 112 ++++++++++++++++ src/Glob/PathTraverser.cs | 19 +-- src/Glob/PathTraverserEnumerable.cs | 136 -------------------- src/Glob/RecursiveGlobEnumerable.cs | 116 +++++++++++++++++ src/Glob/TraverseOptions.cs | 38 +----- src/GlobApp/Pages/Index.razor | 6 +- test/Glob.Benchmarks/GlobBenchmarks.cs | 60 ++++----- test/Glob.Tests/Glob.Tests.csproj | 2 - test/Glob.Tests/MockFileSystemNode.cs | 83 ------------ test/Glob.Tests/MockTraverseOptions.cs | 42 ------ test/Glob.Tests/MockTraverseOptionsTests.cs | 62 --------- 13 files changed, 277 insertions(+), 414 deletions(-) create mode 100644 src/Glob/PathMatcher.cs delete mode 100644 src/Glob/PathTraverserEnumerable.cs create mode 100644 src/Glob/RecursiveGlobEnumerable.cs delete mode 100644 test/Glob.Tests/MockFileSystemNode.cs delete mode 100644 test/Glob.Tests/MockTraverseOptions.cs delete mode 100644 test/Glob.Tests/MockTraverseOptionsTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index c005067..3f0d5b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,12 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ### Updated - Improved performance from parser internals rework - Updated to .NET 8.0 +- Improved path traversal performance using `System.IO.Enumeration`: a single recursive + `FileSystemEnumerable` now walks the directory tree natively with glob matching pushed into + the enumeration predicates. Subtrees that cannot match the pattern are pruned before any + `FileSystemInfo` objects are allocated. Sparse patterns like `**/*.csproj` are ~56% faster + with ~70% fewer allocations; directory patterns like `**/bin` are ~22% faster with ~46% + fewer allocations. ### Changed - Matching full path is now the default for Glob expressions, GlobOptions.MatchFilenameOnly diff --git a/src/Glob/Matcher.cs b/src/Glob/Matcher.cs index 3bfeda4..ea99abb 100644 --- a/src/Glob/Matcher.cs +++ b/src/Glob/Matcher.cs @@ -9,9 +9,12 @@ namespace GlobExpressions; internal static class Matcher { public static bool MatchesSegment(this DirectorySegment segment, string pathSegment, bool caseSensitive) => + MatchesSegment(segment, pathSegment.AsSpan(), caseSensitive); + + public static bool MatchesSegment(this DirectorySegment segment, ReadOnlySpan pathSegment, bool caseSensitive) => MatchesSubSegment(segment.SubSegments, 0, -1, pathSegment, 0, caseSensitive); - private static bool MatchesSubSegment(SubSegment[] segments, int segmentIndex, int literalSetIndex, string pathSegment, int pathIndex, bool caseSensitive) + private static bool MatchesSubSegment(SubSegment[] segments, int segmentIndex, int literalSetIndex, ReadOnlySpan pathSegment, int pathIndex, bool caseSensitive) { var nextSegment = segmentIndex + 1; if (nextSegment > segments.Length) @@ -67,6 +70,6 @@ private static bool MatchesSubSegment(SubSegment[] segments, int segmentIndex, i } } - private static bool SubstringEquals(string segment, int segmentIndex, string search, bool caseSensitive) => - string.Compare(segment, segmentIndex, search, 0, search.Length, caseSensitive ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase) == 0; + private static bool SubstringEquals(ReadOnlySpan segment, int segmentIndex, string search, bool caseSensitive) => + segment.Slice(segmentIndex, search.Length).Equals(search.AsSpan(), caseSensitive ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase); } \ No newline at end of file diff --git a/src/Glob/PathMatcher.cs b/src/Glob/PathMatcher.cs new file mode 100644 index 0000000..b717230 --- /dev/null +++ b/src/Glob/PathMatcher.cs @@ -0,0 +1,112 @@ +using System; +using GlobExpressions.AST; + +namespace GlobExpressions; + +// Span-based glob matching used by the recursive FileSystemEnumerable traversal. +// Matches an entry's path (relative to the traversal root) against the parsed +// segment array without splitting the path into a string[]. +internal static class PathMatcher +{ + private static readonly char[] Separators = { '/', '\\' }; + + // Full match: does the relative path match the whole segment pattern? + public static bool IsFullMatch(Segment[] segments, ReadOnlySpan relativePath, bool caseSensitive) => + Eval(segments, 0, relativePath, caseSensitive); + + // Prefix match: could any descendant of the directory at relativeDir match? + // Used for ShouldRecursePredicate to prune subtrees that cannot match. + public static bool CanRecurse(Segment[] segments, ReadOnlySpan relativeDir, bool caseSensitive) + { + var si = 0; + while (true) + { + // Pattern fully consumed with no trailing wildcard: a deeper path + // would be longer than the pattern, so nothing below can match. + if (si == segments.Length) + return false; + + // Prefix matched everything so far and segments remain, so a + // descendant may complete the match. + if (relativeDir.IsEmpty) + return true; + + SplitHead(relativeDir, out var head, out var rest); + + switch (segments[si]) + { + case DirectoryWildcard _: + // ** can absorb arbitrary depth, so always worth recursing. + return true; + + case DirectorySegment dir: + if (!dir.MatchesSegment(head, caseSensitive)) + return false; + si++; + relativeDir = rest; + continue; + + default: + return false; + } + } + } + + private static bool Eval(Segment[] segments, int si, ReadOnlySpan path, bool caseSensitive) + { + while (true) + { + if (si == segments.Length) + return path.IsEmpty; + + // Segments remain but the path is exhausted: no match. + if (path.IsEmpty) + return false; + + SplitHead(path, out var head, out var rest); + + switch (segments[si]) + { + case DirectoryWildcard _: + var isLastSegment = si == segments.Length - 1; + + // ** as the final segment matches everything remaining. + if (isLastSegment) + return true; + + // Match zero path components against **. + if (Eval(segments, si + 1, path, caseSensitive)) + return true; + + // Match one component and keep the ** active. + path = rest; + continue; + + case DirectorySegment dir: + if (!dir.MatchesSegment(head, caseSensitive)) + return false; + si++; + path = rest; + continue; + + default: + return false; + } + } + } + + private static void SplitHead(ReadOnlySpan path, out ReadOnlySpan head, out ReadOnlySpan rest) + { + var sep = path.IndexOfAny(Separators[0], Separators[1]); + if (sep < 0) + { + head = path; + rest = ReadOnlySpan.Empty; + } + else + { + head = path.Slice(0, sep); + rest = path.Slice(sep + 1); + } + } +} diff --git a/src/Glob/PathTraverser.cs b/src/Glob/PathTraverser.cs index 44020e9..71c2fd2 100644 --- a/src/Glob/PathTraverser.cs +++ b/src/Glob/PathTraverser.cs @@ -1,8 +1,6 @@ using System; using System.Collections.Generic; using System.IO; -using System.Reflection; -using System.Text; using GlobExpressions.AST; namespace GlobExpressions; @@ -11,17 +9,14 @@ internal static class PathTraverser { public static IEnumerable Traverse(this DirectoryInfo root, string pattern, bool caseSensitive, bool emitFiles, bool emitDirectories) { - var parser = new Parser(pattern); - var segments = parser.ParseTree().Segments; + var parser = new Parser(pattern); + var segments = parser.ParseTree().Segments; - var cache = new TraverseOptions(caseSensitive, emitFiles, emitDirectories); + var options = new TraverseOptions(caseSensitive, emitFiles, emitDirectories); - return segments.Length == 0 ? Array.Empty() : Traverse(root, segments, cache); - } + return segments.Length == 0 ? Array.Empty() : Traverse(root, segments, options); + } internal static IEnumerable Traverse(DirectoryInfo root, Segment[] segments, TraverseOptions options) => - Traverse(new List { root }, segments, options); - - private static IEnumerable Traverse(List roots, Segment[] segments, TraverseOptions options) => - new PathTraverserEnumerable(roots, segments, options); -} \ No newline at end of file + new RecursiveGlobEnumerable(root, segments, options); +} diff --git a/src/Glob/PathTraverserEnumerable.cs b/src/Glob/PathTraverserEnumerable.cs deleted file mode 100644 index 52be0d8..0000000 --- a/src/Glob/PathTraverserEnumerable.cs +++ /dev/null @@ -1,136 +0,0 @@ -using System; -using System.Collections; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using GlobExpressions.AST; - -namespace GlobExpressions; - -internal class PathTraverserEnumerable : IEnumerable -{ - private readonly List _originalRoots; - private readonly Segment[] _segments; - private readonly TraverseOptions _options; - - private static readonly FileSystemInfo[] _emptyFileSystemInfoArray = Array.Empty(); - private static readonly DirectoryInfo[] _emptyPathJobArray = Array.Empty(); - public PathTraverserEnumerable(List roots, Segment[] segments, TraverseOptions options) - { - this._originalRoots = roots; - this._segments = segments; - this._options = options; - } - - public IEnumerator GetEnumerator() - { - var roots = new List(); - var rootCache = new List(); - roots.AddRange(_originalRoots); - - var segmentsLength = _segments.Length; - var nextSegmentRoots = new List(); - var segmentIndex = 0; - var emitDirectories = _options.EmitDirectories; - var emitFiles = _options.EmitFiles; - var cache = new HashSet(); - - void Swap(ref List other) - { - var swap = roots; - roots = other; - other = swap; - } - - while (true) - { - // no more segments. return all current roots - var noMoreSegments = segmentIndex == segmentsLength; - if (emitDirectories && noMoreSegments) - { - foreach (var info in roots.Where(info => !cache.Contains(info.FullName))) - { - cache.Add(info.FullName); - yield return info; - } - } - - // no more roots or no more segments, go to next segment - if (roots.Count == 0 || noMoreSegments) - { - roots.Clear(); - if (nextSegmentRoots.Count > 0) - { - Swap(ref nextSegmentRoots); - segmentIndex++; - continue; - } - - yield break; - } - - var segment = _segments[segmentIndex]; - var onLastSegment = segmentIndex == segmentsLength - 1; - if (emitFiles && onLastSegment) - { - var allFiles = from job in roots - let children = _options.GetFiles(job) - from file in FilesMatchingSegment(children, segment, _options.CaseSensitive) - select file; - - foreach (var info in allFiles) - { - if (!cache.Contains(info.FullName)) - { - cache.Add(info.FullName); - yield return info; - } - } - } - rootCache.Clear(); - rootCache.AddRange(roots.SelectMany(job => JobsMatchingSegment(nextSegmentRoots, job, segment))); - - Swap(ref rootCache); - } - } - - IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); - - private IEnumerable JobsMatchingSegment(List nextSegmentRoots, DirectoryInfo directoryInfo, Segment segment) - { - switch (segment) - { - case DirectorySegment directorySegment: - // consume DirectorySegment - var pathJobs = (from directory in _options.GetDirectories(directoryInfo) - where directorySegment.MatchesSegment(directory.Name, _options.CaseSensitive) - select directory).ToArray(); - - nextSegmentRoots.AddRange(pathJobs); - - return _emptyPathJobArray; - - case DirectoryWildcard _: - { - // match zero path segments, consuming DirectoryWildcard - nextSegmentRoots.Add(directoryInfo); - - // match consume 1 path segment but not the Wildcard - return _options.GetDirectories(directoryInfo); - } - - default: - return _emptyPathJobArray; - } - } - - - - private static IEnumerable FilesMatchingSegment(IEnumerable fileInfos, Segment segment, bool caseSensitive) => - segment switch - { - DirectorySegment directorySegment => (IEnumerable)fileInfos.Where(file => directorySegment.MatchesSegment(file.Name, caseSensitive)), - DirectoryWildcard _ => fileInfos, - _ => _emptyFileSystemInfoArray - }; -} \ No newline at end of file diff --git a/src/Glob/RecursiveGlobEnumerable.cs b/src/Glob/RecursiveGlobEnumerable.cs new file mode 100644 index 0000000..c229b39 --- /dev/null +++ b/src/Glob/RecursiveGlobEnumerable.cs @@ -0,0 +1,116 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.IO; +using System.IO.Enumeration; +using GlobExpressions.AST; + +namespace GlobExpressions; + +// Traverses a directory tree with a single recursive FileSystemEnumerable, +// pushing glob matching into the enumeration: +// ShouldRecursePredicate prunes subtrees that cannot match the pattern, +// ShouldIncludePredicate performs the full glob match against each entry. +// Only matched entries are materialized into FileSystemInfo objects. +internal sealed class RecursiveGlobEnumerable : IEnumerable +{ + private static readonly char[] Separators = { '/', '\\' }; + + private readonly string _root; + private readonly Segment[] _segments; + private readonly bool _caseSensitive; + private readonly bool _emitFiles; + private readonly bool _emitDirectories; + + private static readonly EnumerationOptions _enumerationOptions = new EnumerationOptions + { + RecurseSubdirectories = true, + AttributesToSkip = FileAttributes.None, + IgnoreInaccessible = true, + }; + + public RecursiveGlobEnumerable(DirectoryInfo root, Segment[] segments, TraverseOptions options) + { + _root = root.FullName; + _segments = segments; + _caseSensitive = options.CaseSensitive; + _emitFiles = options.EmitFiles; + _emitDirectories = options.EmitDirectories; + } + + public IEnumerator GetEnumerator() => Enumerate().GetEnumerator(); + + private IEnumerable Enumerate() + { + if (!Directory.Exists(_root)) + yield break; + + // A pattern made up entirely of "**" segments also matches the search + // root itself (each ** can match zero directories). FileSystemEnumerable + // never visits the root, so emit it explicitly to preserve that behavior. + if (_emitDirectories && MatchesRoot(_segments)) + yield return new DirectoryInfo(_root); + + var segments = _segments; + var caseSensitive = _caseSensitive; + var emitFiles = _emitFiles; + var emitDirectories = _emitDirectories; + + var enumerable = new FileSystemEnumerable( + _root, + static (ref FileSystemEntry entry) => entry.ToFileSystemInfo(), + _enumerationOptions) + { + ShouldIncludePredicate = (ref FileSystemEntry entry) => + { + if (entry.IsDirectory ? !emitDirectories : !emitFiles) + return false; + + var relativePath = GetRelativePath(ref entry); + return PathMatcher.IsFullMatch(segments, relativePath, caseSensitive); + }, + ShouldRecursePredicate = (ref FileSystemEntry entry) => + { + var relativeDir = GetRelativePath(ref entry); + return PathMatcher.CanRecurse(segments, relativeDir, caseSensitive); + }, + }; + + foreach (var item in enumerable) + yield return item; + } + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + // True when every segment is a "**" wildcard, in which case the pattern also + // matches the traversal root (zero directories deep). + private static bool MatchesRoot(Segment[] segments) + { + foreach (var segment in segments) + { + if (segment is not DirectoryWildcard) + return false; + } + + return true; + } + + // Builds the entry's path relative to the traversal root, e.g. "a/b/file.txt". + private static string GetRelativePath(ref FileSystemEntry entry) + { + var root = entry.RootDirectory; + var dir = entry.Directory; + + // Portion of the entry's parent directory below the traversal root. + ReadOnlySpan relativeParent = dir.Length > root.Length + ? dir.Slice(root.Length) + : ReadOnlySpan.Empty; + relativeParent = relativeParent.TrimStart(Separators); + + var fileName = entry.FileName; + if (relativeParent.IsEmpty) + return fileName.ToString(); + + return string.Concat(relativeParent, "/".AsSpan(), fileName); + } +} diff --git a/src/Glob/TraverseOptions.cs b/src/Glob/TraverseOptions.cs index 3341003..80c1126 100644 --- a/src/Glob/TraverseOptions.cs +++ b/src/Glob/TraverseOptions.cs @@ -1,14 +1,7 @@ -using System; -using System.Collections.Generic; -using System.IO; +namespace GlobExpressions; -namespace GlobExpressions; - -internal class TraverseOptions +internal sealed class TraverseOptions { - private readonly Dictionary _fileCache = new Dictionary(); - private readonly Dictionary _dirCache = new Dictionary(); - public TraverseOptions(bool caseSensitive, bool emitFiles, bool emitDirectories) { CaseSensitive = caseSensitive; @@ -19,29 +12,4 @@ public TraverseOptions(bool caseSensitive, bool emitFiles, bool emitDirectories) public bool CaseSensitive { get; } public bool EmitFiles { get; } public bool EmitDirectories { get; } - - private static readonly FileInfo[] _emptyFileInfos = Array.Empty(); - private static readonly DirectoryInfo[] _emptyDirectoryInfos = Array.Empty(); - - public virtual FileInfo[] GetFiles(DirectoryInfo root) - { - if (_fileCache.TryGetValue(root.FullName, out var cachedFiles)) - return cachedFiles; - - root.Refresh(); - var files = root.Exists ? root.GetFiles() : _emptyFileInfos; - _fileCache.Add(root.FullName, files); - return files; - } - - public virtual DirectoryInfo[] GetDirectories(DirectoryInfo root) - { - if (_dirCache.TryGetValue(root.FullName, out var cachedFiles)) - return cachedFiles; - - root.Refresh(); - var files = root.Exists ? root.GetDirectories() : _emptyDirectoryInfos; - _dirCache.Add(root.FullName, files); - return files; - } -} \ No newline at end of file +} diff --git a/src/GlobApp/Pages/Index.razor b/src/GlobApp/Pages/Index.razor index e00064b..294da17 100644 --- a/src/GlobApp/Pages/Index.razor +++ b/src/GlobApp/Pages/Index.razor @@ -117,18 +117,16 @@ "./src/Glob/GlobPatternException.cs", "./src/Glob/Matcher.cs", "./src/Glob/Parser.cs", + "./src/Glob/PathMatcher.cs", "./src/Glob/PathTraverser.cs", - "./src/Glob/PathTraverserEnumerable.cs", "./src/Glob/Properties", "./src/Glob/Properties/AssemblyInfo.cs", + "./src/Glob/RecursiveGlobEnumerable.cs", "./src/Glob/TraverseOptions.cs", "./test/Glob.Tests", "./test/Glob.Tests/Glob.Tests.csproj", "./test/Glob.Tests/GlobExtensionTests.cs", "./test/Glob.Tests/GlobTests.cs", - "./test/Glob.Tests/MockFileSystemNode.cs", - "./test/Glob.Tests/MockTraverseOptions.cs", - "./test/Glob.Tests/MockTraverseOptionsTests.cs", "./test/Glob.Tests/ParserTests.cs", "./test/Glob.Tests/PathTests.cs", "./test/Glob.Tests/PathTraverserTests.cs", diff --git a/test/Glob.Benchmarks/GlobBenchmarks.cs b/test/Glob.Benchmarks/GlobBenchmarks.cs index 747a716..537517c 100644 --- a/test/Glob.Benchmarks/GlobBenchmarks.cs +++ b/test/Glob.Benchmarks/GlobBenchmarks.cs @@ -4,6 +4,7 @@ namespace GlobExpressions.Benchmarks; +[MemoryDiagnoser] [CsvExporter] [HtmlExporter] [MarkdownExporterAttribute.GitHub] @@ -16,59 +17,48 @@ public class GlobBenchmarks private const string Pattern1 = "p?th/*a[bcd]b[e-g]a[1-4][!wxyz][!a-c][!1-3].*"; private const string Pattern2 = "**/*a[bcd]b[e-g]a[1-4][!wxyz][!a-c][!1-3].*"; + private static readonly string SourceRoot = Path.Combine("..", "..", "..", "..", ".."); + public GlobBenchmarks() { - this.compiled1 = new Glob(Pattern1, GlobOptions.Compiled); - this.compiled2 = new Glob(Pattern2, GlobOptions.Compiled); - } + this.compiled1 = new Glob(Pattern1, GlobOptions.Compiled); + this.compiled2 = new Glob(Pattern2, GlobOptions.Compiled); + } [Benchmark] public void ParseGlob() { - var parser = new Parser(Pattern1); - parser.Parse(); - } + var parser = new Parser(Pattern1); + parser.Parse(); + } [Benchmark] - public Glob ParseAndCompileGlob() - { - return new Glob(Pattern1, GlobOptions.Compiled); - } + public Glob ParseAndCompileGlob() => new Glob(Pattern1, GlobOptions.Compiled); [Benchmark] - public bool MatchForUncompiledGlob() - { - return new Glob(Pattern1).IsMatch("pAth/fooooacbfa2vd4.txt"); - } + public bool MatchForUncompiledGlob() => new Glob(Pattern1).IsMatch("pAth/fooooacbfa2vd4.txt"); [Benchmark] - public bool MatchForCompiledGlob() - { - return compiled1.IsMatch("pAth/fooooacbfa2vd4.txt"); - } + public bool MatchForCompiledGlob() => compiled1.IsMatch("pAth/fooooacbfa2vd4.txt"); [Benchmark] - public bool MatchForUncompiledGlobDirectoryWildcard() - { - return new Glob(Pattern2).IsMatch("pAth/fooooacbfa2vd4.txt"); - } + public bool MatchForUncompiledGlobDirectoryWildcard() => new Glob(Pattern2).IsMatch("pAth/fooooacbfa2vd4.txt"); [Benchmark] - public bool MatchForCompiledGlobDirectoryWildcard() - { - return compiled1.IsMatch("pAth/fooooacbfa2vd4.txt"); - } + public bool MatchForCompiledGlobDirectoryWildcard() => compiled1.IsMatch("pAth/fooooacbfa2vd4.txt"); [Benchmark] - public object BenchmarkParseToTree() - { - return new Parser(Pattern1).ParseTree(); - } + public object BenchmarkParseToTree() => new Parser(Pattern1).ParseTree(); [Benchmark] - public object PathTraversal() - { - var SourceRoot = Path.Combine("..", "..", "..", "..", ".."); - return Glob.Files(SourceRoot, "test/*Tests/**/*.cs").ToList(); - } + public object PathTraversal() => Glob.Files(SourceRoot, "test/*Tests/**/*.cs").ToList(); + + // Directory wildcard that walks the whole tree but matches very few files. + // Exercises ShouldIncludePredicate where most entries are filtered before any FileInfo is allocated. + [Benchmark] + public object PathTraversalSparseMatch() => Glob.Files(SourceRoot, "**/*.csproj").ToList(); + + // Directory wildcard matching directories rather than files. + [Benchmark] + public object PathTraversalDirectories() => Glob.Directories(SourceRoot, "**/bin").ToList(); } diff --git a/test/Glob.Tests/Glob.Tests.csproj b/test/Glob.Tests/Glob.Tests.csproj index ebbf41b..ed0433f 100644 --- a/test/Glob.Tests/Glob.Tests.csproj +++ b/test/Glob.Tests/Glob.Tests.csproj @@ -27,8 +27,6 @@ - - all runtime; build; native; contentfiles; analyzers diff --git a/test/Glob.Tests/MockFileSystemNode.cs b/test/Glob.Tests/MockFileSystemNode.cs deleted file mode 100644 index 3b3bdf8..0000000 --- a/test/Glob.Tests/MockFileSystemNode.cs +++ /dev/null @@ -1,83 +0,0 @@ -using System.Collections.Generic; - -namespace GlobExpressions.Tests; - -internal class MockFileSystemNode -{ - public MockFileSystemNode? Parent { get; } - - public string Name { get; } - - public bool IsFolder { get; private set; } - - public Dictionary Children { get; } - - public MockFileSystemNode(string name, MockFileSystemNode? parent) - { - this.Name = name; - Parent = parent; - this.Children = new Dictionary(); - } - - public MockFileSystemNode Add(string path) - { - var segments = path.Split('/', '\\'); - return Add(segments, 0); - } - - public MockFileSystemNode? GetNodeAtPath(string fullPath) - { - var segments = fullPath.Split('/', '\\'); - return GetNodeAtPath(segments, 0); - } - - public string GetFullName() - { - var names = new List(); - - MockFileSystemNode? node = this; - while (node != null) - { - names.Add(node.Name); - node = node.Parent; - } - - names.Reverse(); - - return string.Join("/", names); - } - - private MockFileSystemNode Add(string[] pathSegments, int pathSegmentIndex) - { - if (pathSegmentIndex >= pathSegments.Length) - return this; - - this.IsFolder = true; - - var segment = pathSegments[pathSegmentIndex]; - - return GetOrCreateNode(segment).Add(pathSegments, pathSegmentIndex + 1); - } - - private MockFileSystemNode GetOrCreateNode(string name) - { - if (this.Children.TryGetValue(name, out var node)) - return node; - - var newNode = new MockFileSystemNode(name, this); - this.Children.Add(name, newNode); - return newNode; - } - - private MockFileSystemNode? GetNodeAtPath(string[] fullPathSegments, int pathSegment) - { - var segment = fullPathSegments[pathSegment]; - if (!this.Children.TryGetValue(segment, out var node)) - return null; - - if (pathSegment == fullPathSegments.Length - 1) - return node; - - return node.GetNodeAtPath(fullPathSegments, pathSegment + 1); - } -} \ No newline at end of file diff --git a/test/Glob.Tests/MockTraverseOptions.cs b/test/Glob.Tests/MockTraverseOptions.cs deleted file mode 100644 index 4ffe416..0000000 --- a/test/Glob.Tests/MockTraverseOptions.cs +++ /dev/null @@ -1,42 +0,0 @@ -using System; -using System.Collections; -using System.IO; -using System.IO.Abstractions; -using System.IO.Abstractions.TestingHelpers; -using System.Linq; - -namespace GlobExpressions.Tests; - -internal class MockTraverseOptions : TraverseOptions, IEnumerable -{ - private readonly IFileSystem fileSystem; - - public MockTraverseOptions(bool caseSensitive, bool emitFiles, bool emitDirectories, IFileSystem fileSystem) - : base(caseSensitive, emitFiles, emitDirectories) - { - this.fileSystem = fileSystem; - } - - internal readonly MockFileSystemNode _root = new MockFileSystemNode("", null); - - public override FileInfo[] GetFiles(DirectoryInfo root) - { - return fileSystem.DirectoryInfo.FromDirectoryName(root.FullName).GetFiles().Select(x => new FileInfo(x.FullName)).ToArray(); - } - - public override DirectoryInfo[] GetDirectories(DirectoryInfo root) - { - return fileSystem.DirectoryInfo.FromDirectoryName(root.FullName).GetDirectories().Select(x => new DirectoryInfo(x.FullName)).ToArray(); - } - - // fake enumerator - public IEnumerator GetEnumerator() - { - return Array.Empty().GetEnumerator(); - } - - public void Add(string path) - { - _root.Add(path); - } -} \ No newline at end of file diff --git a/test/Glob.Tests/MockTraverseOptionsTests.cs b/test/Glob.Tests/MockTraverseOptionsTests.cs deleted file mode 100644 index b65369f..0000000 --- a/test/Glob.Tests/MockTraverseOptionsTests.cs +++ /dev/null @@ -1,62 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.IO.Abstractions.TestingHelpers; -using System.Text; -using Xunit; -using static GlobExpressions.Tests.TestHelpers; - -namespace GlobExpressions.Tests; - -public class MockTraverseOptionsTests -{ - - [Fact] - public void CanAddPaths() - { - var windowsPath = Path.Combine(FileSystemRoot, "Windows"); - var system32Path = Path.Combine(windowsPath, "System32"); - - var mockFiles = new Dictionary - { - [Path.Combine(windowsPath, "Notepad.exe")] = MockFileData.NullObject, - [Path.Combine(windowsPath, "explorer.exe")] = MockFileData.NullObject, - [Path.Combine(system32Path, "at.exe")] = MockFileData.NullObject, - }; - var mockFileSystem = new MockFileSystem(mockFiles); - var options = new MockTraverseOptions(false, false, false, mockFileSystem); - - var directories = options.GetDirectories(new DirectoryInfo(FileSystemRoot)); - Assert.Collection(directories, info => Assert.Equal(windowsPath, info.FullName)); - - var directories2 = options.GetDirectories(new DirectoryInfo(windowsPath)); - Assert.Collection(directories2, info => Assert.Equal(system32Path, info.FullName)); - - var files = options.GetFiles(new DirectoryInfo(windowsPath)); - Assert.Collection(files, - info => Assert.Equal(Path.Combine(windowsPath, "Notepad.exe"), info.FullName), - info => Assert.Equal(Path.Combine(windowsPath, "explorer.exe"), info.FullName) - ); - } - - [Fact] - public void CtorPassesCaseSensitive() - { - Assert.False(new MockTraverseOptions(false, false, false, new MockFileSystem()).CaseSensitive); - Assert.True(new MockTraverseOptions(true, false, false, new MockFileSystem()).CaseSensitive); - } - - [Fact] - public void CtorPassesEmitFiles() - { - Assert.False(new MockTraverseOptions(false, false, false, new MockFileSystem()).EmitFiles); - Assert.True(new MockTraverseOptions(false, true, false, new MockFileSystem()).EmitFiles); - } - - [Fact] - public void CtorPassesEmitDirectories() - { - Assert.False(new MockTraverseOptions(false, false, false, new MockFileSystem()).EmitDirectories); - Assert.True(new MockTraverseOptions(false, false, true, new MockFileSystem()).EmitDirectories); - } -} \ No newline at end of file