From 1007c055ef04b8772255f2b84da69aa4cf71707b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 02:24:55 +0000 Subject: [PATCH] Match a glob token made only of **/ segments [patch] ExpandGlobStarSegments expands a bare "**/" both as "*/" and as the empty string. Glob.Parse throws on the empty pattern, so ResolveGlob marked the whole token unparseable and threw away the valid "*/" expansion: "**/" matched every item and "-**/" hid nothing. Drop empty expansions before parsing, and still treat a token with nothing left as unparseable. Fixes #181 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017vfJW4QsgMfJRhwEBdboKd --- TextFilter.Test/TextFilterTests.cs | 33 ++++++++++++++++++++++++++++++ TextFilter/TextFilter.cs | 13 +++++++++--- 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/TextFilter.Test/TextFilterTests.cs b/TextFilter.Test/TextFilterTests.cs index 3250a64..f43aacb 100644 --- a/TextFilter.Test/TextFilterTests.cs +++ b/TextFilter.Test/TextFilterTests.cs @@ -1190,6 +1190,39 @@ public void GlobStarSlashMatchesCaseInsensitively() Assert.IsTrue(TextFilter.IsMatch("Docs/README.md", "**/readme.md", TextFilterType.Glob, TextFilterMatchOptions.ByWholeString, TextFilterCaseSensitivity.CaseInsensitive)); } + [TestMethod] + [DataRow("**/")] + [DataRow("**/**/")] + public void GlobTokenOfOnlyGlobStarSlashMatchesItemsEndingInSeparator(string filter) + { + List result = [.. TextFilter.Filter(["readme.md", "src/a.cs", "docs/", "a b"], filter)]; + + Assert.AreEqual(1, result.Count); + Assert.AreEqual("docs/", result[0]); + } + + [TestMethod] + [DataRow("-**/")] + [DataRow("-**/**/")] + public void GlobExcludedTokenOfOnlyGlobStarSlashHidesItemsEndingInSeparator(string filter) + { + List result = [.. TextFilter.Filter(["readme.md", "src/a.cs", "docs/", "a b"], filter)]; + + Assert.AreEqual(3, result.Count); + Assert.AreEqual("readme.md", result[0]); + Assert.AreEqual("src/a.cs", result[1]); + Assert.AreEqual("a b", result[2]); + } + + [TestMethod] + public void GlobRequiredTokenOfOnlyGlobStarSlashMatchesOnlyItemsEndingInSeparator() + { + List result = [.. TextFilter.Filter(["readme.md", "src/a.cs", "docs/"], "+**/")]; + + Assert.AreEqual(1, result.Count); + Assert.AreEqual("docs/", result[0]); + } + [TestMethod] public void GlobLiteralSlashInPatternStillMatchesSlashInText() { diff --git a/TextFilter/TextFilter.cs b/TextFilter/TextFilter.cs index 927c00c..d474907 100644 --- a/TextFilter/TextFilter.cs +++ b/TextFilter/TextFilter.cs @@ -643,9 +643,16 @@ private static bool SpansPathSeparator(char first, char last) => try { string? expandedToken = ExpandGlobClasses(filterToken); - globs = expandedToken is null ? null : [.. ExpandGlobStarSegments(MaskPathSeparators(expandedToken)).Select(pattern => caseSensitivity is TextFilterCaseSensitivity.CaseInsensitive - ? Glob.Parse(pattern, CaseInsensitiveGlobOptions) - : Glob.Parse(pattern))]; + // A token made only of "**/" segments expands to the empty string as well as to "*/". + // Glob.Parse throws on an empty pattern, which would mark the whole token unparseable and + // discard the valid expansions with it. An empty pattern could only match empty text, which + // never reaches the matcher, so drop it; a token with nothing left is still unparseable. + Glob[]? parsed = expandedToken is null ? null : [.. ExpandGlobStarSegments(MaskPathSeparators(expandedToken)) + .Where(pattern => pattern.Length != 0) + .Select(pattern => caseSensitivity is TextFilterCaseSensitivity.CaseInsensitive + ? Glob.Parse(pattern, CaseInsensitiveGlobOptions) + : Glob.Parse(pattern))]; + globs = parsed is { Length: > 0 } ? parsed : null; } catch (Exception ex) when (ex is not OutOfMemoryException) {