From df1c960439585ffab205d50e43c396f7c1d952a1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 13:28:41 +0000 Subject: [PATCH] Let glob * and ? match across / and \ [patch] DotNet.Glob is a file-path glob, so its wildcards stopped at path separators and any item containing a slash or backslash could not be matched: "*readme*" dropped "docs/readme.md". Both separators are now swapped for a private-use character in the pattern and the text before DotNet.Glob sees them, so they are ordinary characters. Fixes ktsu-dev/TextFilter#114 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VKxYoUXQqgRKJR6ynbvnms --- TextFilter.Test/TextFilterTests.cs | 51 ++++++++++++++++++++++++++++++ TextFilter/TextFilter.cs | 21 +++++++++--- 2 files changed, 67 insertions(+), 5 deletions(-) diff --git a/TextFilter.Test/TextFilterTests.cs b/TextFilter.Test/TextFilterTests.cs index 40ba5c6..06c31ef 100644 --- a/TextFilter.Test/TextFilterTests.cs +++ b/TextFilter.Test/TextFilterTests.cs @@ -838,4 +838,55 @@ public void RegexByWordAllFilterDropsBlankEntries() new List { "hello" }, TextFilter.Filter(["", "hello"], "hel", TextFilterType.Regex, TextFilterMatchOptions.ByWordAll).ToList()); } + + [TestMethod] + public void GlobStarMatchesAcrossSlash() + { + Assert.IsTrue(TextFilter.IsMatch("AC/DC", "AC*", TextFilterType.Glob, TextFilterMatchOptions.ByWordAny)); + } + + [TestMethod] + public void GlobQuestionMarkMatchesSlash() + { + Assert.IsTrue(TextFilter.IsMatch("1/2", "1?2", TextFilterType.Glob, TextFilterMatchOptions.ByWordAny)); + } + + [TestMethod] + public void GlobStarMatchesPath() + { + Assert.IsTrue(TextFilter.IsMatch("src/Foo.cs", "*.cs", TextFilterType.Glob, TextFilterMatchOptions.ByWholeString)); + } + + [TestMethod] + public void GlobStarMatchesBackslashPath() + { + Assert.IsTrue(TextFilter.IsMatch(@"C:\temp\a.txt", "*.txt", TextFilterType.Glob, TextFilterMatchOptions.ByWholeString)); + } + + [TestMethod] + public void GlobFilterKeepsPathItems() + { + List result = [.. TextFilter.Filter(["docs/readme.md", "readme.md"], "*readme*")]; + + Assert.AreEqual(2, result.Count); + } + + [TestMethod] + public void GlobStarMatchesAcrossSlashCaseInsensitively() + { + Assert.IsTrue(TextFilter.IsMatch("docs/README.md", "*readme*", TextFilterType.Glob, TextFilterMatchOptions.ByWordAny, TextFilterCaseSensitivity.CaseInsensitive)); + } + + [TestMethod] + public void GlobExcludedTokenMatchesAcrossSlash() + { + Assert.IsFalse(TextFilter.IsMatch("docs/readme.md", "-*readme*", TextFilterType.Glob, TextFilterMatchOptions.ByWordAny)); + } + + [TestMethod] + public void GlobLiteralSlashInPatternStillMatchesSlashInText() + { + Assert.IsTrue(TextFilter.IsMatch("docs/readme.md", "docs/*", TextFilterType.Glob, TextFilterMatchOptions.ByWholeString)); + Assert.IsFalse(TextFilter.IsMatch("docsXreadme.md", "docs/*", TextFilterType.Glob, TextFilterMatchOptions.ByWholeString)); + } } diff --git a/TextFilter/TextFilter.cs b/TextFilter/TextFilter.cs index 201095b..40d4f14 100644 --- a/TextFilter/TextFilter.cs +++ b/TextFilter/TextFilter.cs @@ -336,7 +336,7 @@ public static bool DoesMatchGlob(string text, string filter, TextFilterMatchOpti // An unparseable excluded token is skipped rather than treated as match-anything, which here // would exclude every item while the user is still typing the token. - bool anyExcludedMatches = excludedTokens.Any(filterToken => ResolveGlob(filterToken, caseSensitivity) is Glob glob && textTokens.Any(glob.IsMatch)); + bool anyExcludedMatches = excludedTokens.Any(filterToken => ResolveGlob(filterToken, caseSensitivity) is Glob glob && textTokens.Any(token => IsGlobMatch(glob, token))); if (anyExcludedMatches) { @@ -382,7 +382,7 @@ public static bool AnyTokenMatchesGlobFilter(string filterToken, HashSet Glob? glob = ResolveGlob(filterToken, caseSensitivity); - return glob is null || textTokens.Any(glob.IsMatch); + return glob is null || textTokens.Any(token => IsGlobMatch(glob, token)); } /// @@ -399,9 +399,19 @@ public static bool AllTokensMatchGlobFilter(string filterToken, HashSet Glob? glob = ResolveGlob(filterToken, caseSensitivity); - return glob is null || textTokens.All(glob.IsMatch); + return glob is null || textTokens.All(token => IsGlobMatch(glob, token)); } + // DotNet.Glob is a file-path glob, so its * and ? stop at / and \. TextFilter filters arbitrary + // text, where a slash is an ordinary character, so both separators are swapped for a private-use + // character in the pattern and in the text before DotNet.Glob sees either. + private const char MaskedPathSeparator = '\uE000'; + + private static string MaskPathSeparators(string value) => + value.Replace('/', MaskedPathSeparator).Replace('\\', MaskedPathSeparator); + + private static bool IsGlobMatch(Glob glob, string textToken) => glob.IsMatch(MaskPathSeparators(textToken)); + // Returns null for a token that cannot be parsed, so each caller can decide what ignoring it means. private static Glob? ResolveGlob(string filterToken, TextFilterCaseSensitivity caseSensitivity) { @@ -411,9 +421,10 @@ public static bool AllTokensMatchGlobFilter(string filterToken, HashSet { try { + string maskedToken = MaskPathSeparators(filterToken); glob = caseSensitivity is TextFilterCaseSensitivity.CaseInsensitive - ? Glob.Parse(filterToken, CaseInsensitiveGlobOptions) - : Glob.Parse(filterToken); + ? Glob.Parse(maskedToken, CaseInsensitiveGlobOptions) + : Glob.Parse(maskedToken); } catch (Exception ex) when (ex is not OutOfMemoryException) {