From 06c68fba55925ff88fe86f5e8079bb8ca8c1d6bd Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 05:29:36 +0000 Subject: [PATCH] fix: stop a half-typed excluded glob token hiding every item [patch] A glob token that cannot be parsed (such as "file[0-") became a match-anything glob. That ignores plain and required tokens, but for an excluded token ("-readme[a-") it excluded everything, so the list emptied while the user typed a range. ResolveGlob now caches an unparseable token as null. Excluded tokens skip it; the other checks still treat it as matching anything. Fixes ktsu-dev/TextFilter#110 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012Hhb8knddbu4LRSs2uu9jR --- TextFilter.Test/TextFilterTests.cs | 22 +++++++++++++++++++- TextFilter/TextFilter.cs | 32 ++++++++++++++++-------------- 2 files changed, 38 insertions(+), 16 deletions(-) diff --git a/TextFilter.Test/TextFilterTests.cs b/TextFilter.Test/TextFilterTests.cs index 9c20e60..147426a 100644 --- a/TextFilter.Test/TextFilterTests.cs +++ b/TextFilter.Test/TextFilterTests.cs @@ -779,11 +779,14 @@ public void ACachedPatternStillMatchesAfterTheCacheHasReset() [DataRow("[!a-")] [DataRow("*[0-")] [DataRow("[a-]")] + [DataRow("-file[0-")] + [DataRow("!file[0-")] + [DataRow("^file[0-")] public void GlobWithAHalfTypedRangeDoesNotThrow(string pattern) { // DotNet.Glob's tokeniser throws IndexOutOfRangeException on a range left open after the dash, // which every type-ahead filter passes through while the user types "file[0-9].txt". An - // unparseable token degrades to match-anything, as an invalid regex already does. + // unparseable token is ignored: plain and required tokens match anything, excluded ones exclude nothing. foreach (TextFilterCaseSensitivity caseSensitivity in new[] { TextFilterCaseSensitivity.CaseSensitive, TextFilterCaseSensitivity.CaseInsensitive }) { Assert.IsTrue(TextFilter.IsMatch("file1.txt", pattern, TextFilterType.Glob, TextFilterMatchOptions.ByWordAny, caseSensitivity)); @@ -792,4 +795,21 @@ public void GlobWithAHalfTypedRangeDoesNotThrow(string pattern) TextFilter.Filter(["file1.txt"], pattern, TextFilterType.Glob, TextFilterMatchOptions.ByWordAny, caseSensitivity).ToList()); } } + + [TestMethod] + [DataRow("-readme[a-")] + [DataRow("!readme[a-")] + [DataRow("^readme[a-")] + public void ExcludedGlobWithAHalfTypedRangeExcludesNothing(string pattern) + { + // An unparseable excluded token is ignored while the user finishes typing it. Treating it as + // match-anything, as unprefixed tokens are, would exclude every item on each keystroke. + List items = ["file1.txt", "file2.txt", "readme.md"]; + foreach (TextFilterCaseSensitivity caseSensitivity in new[] { TextFilterCaseSensitivity.CaseSensitive, TextFilterCaseSensitivity.CaseInsensitive }) + { + CollectionAssert.AreEqual( + items, + TextFilter.Filter(items, pattern, TextFilterType.Glob, TextFilterMatchOptions.ByWordAny, caseSensitivity).ToList()); + } + } } diff --git a/TextFilter/TextFilter.cs b/TextFilter/TextFilter.cs index 7b7f1fd..065b132 100644 --- a/TextFilter/TextFilter.cs +++ b/TextFilter/TextFilter.cs @@ -82,7 +82,8 @@ public static partial class TextFilter private static HashSet ExcludedTokenPrefixes { get; } = ['!', '-', '^']; private static HashSet RequiredTokenPrefixes { get; } = ['+']; private static ConcurrentDictionary RegexCache { get; } = []; - private static ConcurrentDictionary GlobCache { get; } = []; + // A null entry records a token that could not be parsed, so the parse is not retried on every keystroke. + private static ConcurrentDictionary GlobCache { get; } = []; // Filter patterns are caller-supplied, and in the keystroke-driven filter box this library exists // for, every prefix of what the user types becomes its own key. Unbounded, the caches therefore @@ -125,8 +126,6 @@ private static string CacheKey(string pattern, TextFilterCaseSensitivity caseSen [System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "SYSLIB1045:Convert to 'GeneratedRegexAttribute'.", Justification = "Not available in older frameworks")] private static Regex RegexMatchAnything() => new(".*", RegexOptions.Compiled); - private static Glob GlobMatchAnything() => Glob.Parse("*"); - /// /// Gets a hint for the specified filter type. /// @@ -303,8 +302,9 @@ internal static Dictionary> ExtractGlobFilt /// Whether the match distinguishes uppercase from lowercase. /// true if the text matches the glob filter pattern; otherwise, false. /// - /// A token that cannot be parsed as a glob, such as the half-typed range file[0-, matches - /// everything rather than throwing, the same way an invalid regex pattern does. + /// A token that cannot be parsed as a glob, such as the half-typed range file[0-, is ignored + /// rather than throwing: as a plain or required token it matches everything, and as an excluded + /// token it excludes nothing. /// public static bool DoesMatchGlob(string text, string filter, TextFilterMatchOptions textFilterMatchOptions, TextFilterCaseSensitivity caseSensitivity = TextFilterCaseSensitivity.CaseSensitive) { @@ -334,7 +334,9 @@ public static bool DoesMatchGlob(string text, string filter, TextFilterMatchOpti optionalTokens = []; } - bool anyExcludedMatches = excludedTokens.Any(filterToken => AnyTokenMatchesGlobFilter(filterToken, textTokens, caseSensitivity)); + // 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)); if (anyExcludedMatches) { @@ -378,9 +380,9 @@ public static bool AnyTokenMatchesGlobFilter(string filterToken, HashSet Ensure.NotNull(filterToken); Ensure.NotNull(textTokens); - Glob glob = ResolveGlob(filterToken, caseSensitivity); + Glob? glob = ResolveGlob(filterToken, caseSensitivity); - return textTokens.Any(glob.IsMatch); + return glob is null || textTokens.Any(glob.IsMatch); } /// @@ -395,12 +397,13 @@ public static bool AllTokensMatchGlobFilter(string filterToken, HashSet Ensure.NotNull(filterToken); Ensure.NotNull(textTokens); - Glob glob = ResolveGlob(filterToken, caseSensitivity); + Glob? glob = ResolveGlob(filterToken, caseSensitivity); - return textTokens.All(glob.IsMatch); + return glob is null || textTokens.All(glob.IsMatch); } - private static Glob ResolveGlob(string filterToken, TextFilterCaseSensitivity caseSensitivity) + // 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) { string cacheKey = CacheKey(filterToken, caseSensitivity); @@ -416,10 +419,9 @@ private static Glob ResolveGlob(string filterToken, TextFilterCaseSensitivity ca { // DotNet.Glob's tokeniser throws IndexOutOfRangeException on a range left open after the // dash ("file[0-"), which is ordinary intermediate input while someone types a range into - // a filter box. Degrade the way an invalid regex does: match anything, and cache that so - // the exception is not raised again on every keystroke. Caught broadly so the next - // tokeniser bug is contained too. - glob = GlobMatchAnything(); + // a filter box. Cache it as unparseable so the exception is not raised again on every + // keystroke. Caught broadly so the next tokeniser bug is contained too. + glob = null; } AddBounded(GlobCache, cacheKey, glob);