diff --git a/README.md b/README.md index 7d90825..6eec78b 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ ktsu.TextFilter is a .NET library that provides methods for filtering text based - **Regular Expression Matching**: Filter text using regular expressions. - **Fuzzy Matching**: Rank text based on how well it matches a fuzzy pattern. - **Customizable Match Options**: Match by whole string, all words, or any word. +- **Case Sensitivity**: Opt into case-insensitive glob and regex matching; case sensitive by default. ## Installation @@ -98,6 +99,30 @@ bool allWordsMatch = TextFilter.Match(text, pattern, MatchOptions.AllWords); bool wholeStringMatch = TextFilter.Match(text, pattern, MatchOptions.WholeString); ``` +### Case Sensitivity + +Glob and regular expression matching are **case sensitive by default**. Pass +`TextFilterCaseSensitivity.CaseInsensitive` to fold case on both the text and the pattern: + +```csharp +using ktsu.TextFilter; + +// Case sensitive (the default) - a camera writes IMG_1234.JPG, so this does not match +bool sensitive = TextFilter.IsMatch("IMG_1234.JPG", "*.jpg", + TextFilterType.Glob, TextFilterMatchOptions.ByWholeString); // false + +// Case insensitive +bool insensitive = TextFilter.IsMatch("IMG_1234.JPG", "*.jpg", + TextFilterType.Glob, TextFilterMatchOptions.ByWholeString, + TextFilterCaseSensitivity.CaseInsensitive); // true +``` + +The setting is available on `IsMatch`, `Filter`, `DoesMatchGlob`, `DoesMatchRegex`, +`AnyTokenMatchesGlobFilter` and `AllTokensMatchGlobFilter`, and applies to required and excluded +tokens as well as optional ones. + +`TextFilterType.Fuzzy` does not take the setting: fuzzy matching is always case insensitive. + ### Filter Types TextFilter supports different filter types: @@ -151,6 +176,13 @@ The primary class for text filtering operations. | `Regex` | Use regular expression matching | | `Fuzzy` | Use fuzzy matching | +#### `TextFilterCaseSensitivity` + +| Value | Description | +|-------|-------------| +| `CaseSensitive` | Uppercase and lowercase are distinct (the default) | +| `CaseInsensitive` | Uppercase and lowercase are equivalent, for glob and regex matching | + ## Contributing Contributions are welcome! For feature requests, bug reports, or questions, please open an issue on GitHub. If you would like to contribute code, please open a pull request with your changes. diff --git a/TextFilter.Test/TextFilterTests.cs b/TextFilter.Test/TextFilterTests.cs index a892f07..310d096 100644 --- a/TextFilter.Test/TextFilterTests.cs +++ b/TextFilter.Test/TextFilterTests.cs @@ -548,4 +548,122 @@ public void DoesMatchGlobHandlesPartialFilter() bool result = TextFilter.DoesMatchGlob("hello world", "-", TextFilterMatchOptions.ByWordAll); Assert.IsTrue(result, "Partial filter with only '-' should return true."); } + + // ---- Case sensitivity (issue #97) ---- + + [TestMethod] + public void GlobIsCaseSensitiveByDefault() + { + bool result = TextFilter.IsMatch("IMG_1234.JPG", "*.jpg", TextFilterType.Glob, TextFilterMatchOptions.ByWholeString); + Assert.IsFalse(result, "Glob matching should remain case sensitive when no sensitivity is requested."); + } + + [TestMethod] + public void GlobMatchesAcrossCaseWhenCaseInsensitiveIsRequested() + { + bool result = TextFilter.IsMatch("IMG_1234.JPG", "*.jpg", TextFilterType.Glob, TextFilterMatchOptions.ByWholeString, TextFilterCaseSensitivity.CaseInsensitive); + Assert.IsTrue(result, "'*.jpg' should match 'IMG_1234.JPG' when case insensitive matching is requested."); + } + + [TestMethod] + public void GlobCaseInsensitivityAppliesToTheFilterAsWellAsTheText() + { + bool result = TextFilter.IsMatch("photo.png", "*.PNG", TextFilterType.Glob, TextFilterMatchOptions.ByWholeString, TextFilterCaseSensitivity.CaseInsensitive); + Assert.IsTrue(result, "An uppercase filter should match lowercase text when case insensitive matching is requested."); + } + + [TestMethod] + public void GlobCaseInsensitivityDoesNotMatchUnrelatedText() + { + bool result = TextFilter.IsMatch("IMG_1234.PNG", "*.jpg", TextFilterType.Glob, TextFilterMatchOptions.ByWholeString, TextFilterCaseSensitivity.CaseInsensitive); + Assert.IsFalse(result, "Case insensitivity should fold case only, not widen the match to a different extension."); + } + + [TestMethod] + public void RegexIsCaseSensitiveByDefault() + { + bool result = TextFilter.IsMatch("IMG_1234.JPG", @".*\.jpg", TextFilterType.Regex, TextFilterMatchOptions.ByWholeString); + Assert.IsFalse(result, "Regex matching should remain case sensitive when no sensitivity is requested."); + } + + [TestMethod] + public void RegexMatchesAcrossCaseWhenCaseInsensitiveIsRequested() + { + bool result = TextFilter.IsMatch("IMG_1234.JPG", @".*\.jpg", TextFilterType.Regex, TextFilterMatchOptions.ByWholeString, TextFilterCaseSensitivity.CaseInsensitive); + Assert.IsTrue(result, "The sensitivity setting should reach the regex path, not only the glob path."); + } + + [TestMethod] + public void TheTwoSensitivitiesDoNotCollideInTheGlobCache() + { + // Both caches are keyed by pattern text. Without the sensitivity in the key, whichever of + // these ran first would decide the answer for the other, in whichever order they ran. + Assert.IsFalse(TextFilter.IsMatch("A.TXT", "*.txt", TextFilterType.Glob, TextFilterMatchOptions.ByWholeString, TextFilterCaseSensitivity.CaseSensitive)); + Assert.IsTrue(TextFilter.IsMatch("A.TXT", "*.txt", TextFilterType.Glob, TextFilterMatchOptions.ByWholeString, TextFilterCaseSensitivity.CaseInsensitive)); + + // Same check with the insensitive variant cached first, on a pattern used nowhere else, so + // the isolation holds in both orders rather than only the one the pair above happens to take. + Assert.IsTrue(TextFilter.IsMatch("B.MD", "*.md", TextFilterType.Glob, TextFilterMatchOptions.ByWholeString, TextFilterCaseSensitivity.CaseInsensitive)); + Assert.IsFalse(TextFilter.IsMatch("B.MD", "*.md", TextFilterType.Glob, TextFilterMatchOptions.ByWholeString, TextFilterCaseSensitivity.CaseSensitive)); + } + + [TestMethod] + public void TheTwoSensitivitiesDoNotCollideInTheRegexCache() + { + Assert.IsFalse(TextFilter.IsMatch("C.TXT", @".*\.txt", TextFilterType.Regex, TextFilterMatchOptions.ByWholeString, TextFilterCaseSensitivity.CaseSensitive)); + Assert.IsTrue(TextFilter.IsMatch("C.TXT", @".*\.txt", TextFilterType.Regex, TextFilterMatchOptions.ByWholeString, TextFilterCaseSensitivity.CaseInsensitive)); + } + + [TestMethod] + public void CaseInsensitivityReachesRequiredAndExcludedTokens() + { + // Required and excluded tokens go through their own call sites, so they need their own guard: + // threading the sensitivity into the optional branch alone would leave these two behind. + Assert.IsTrue(TextFilter.IsMatch("READ ME", "+read", TextFilterType.Glob, TextFilterMatchOptions.ByWordAny, TextFilterCaseSensitivity.CaseInsensitive), + "A required token should honour case insensitivity."); + Assert.IsFalse(TextFilter.IsMatch("READ ME", "-read", TextFilterType.Glob, TextFilterMatchOptions.ByWordAny, TextFilterCaseSensitivity.CaseInsensitive), + "An excluded token should honour case insensitivity."); + } + + [TestMethod] + public void FilterHonoursCaseInsensitivity() + { + List strings = ["IMG_1.JPG", "IMG_2.PNG", "notes.txt"]; + List result = [.. TextFilter.Filter(strings, "*.jpg", TextFilterType.Glob, TextFilterMatchOptions.ByWholeString, TextFilterCaseSensitivity.CaseInsensitive)]; + Assert.AreSequenceEqual(["IMG_1.JPG"], result, "Filter should pass the sensitivity through to IsMatch."); + } + + [TestMethod] + public void FuzzyMatchingIsAlwaysCaseInsensitive() + { + // Pins the claim made in TextFilterCaseSensitivity's own docs. The setting is deliberately + // ignored here, so both values must agree - and both must agree with today's behaviour. + Assert.IsTrue(TextFilter.IsMatch("HELLO", "hello", TextFilterType.Fuzzy, TextFilterMatchOptions.ByWholeString, TextFilterCaseSensitivity.CaseSensitive)); + Assert.IsTrue(TextFilter.IsMatch("HELLO", "hello", TextFilterType.Fuzzy, TextFilterMatchOptions.ByWholeString, TextFilterCaseSensitivity.CaseInsensitive)); + } + + [TestMethod] + public void ACatastrophicallyBacktrackingPatternTimesOutInsteadOfHanging() + { + // Filter patterns are caller-supplied, so this is the ReDoS shape: (a+)+$ against a run of + // 'a' terminated by a non-matching character backtracks exponentially. Without a timeout on + // the Regex this call does not return; with one it must come back quickly and report false. + string pattern = "(a+)+$"; + string text = new string('a', 40) + "X"; + + System.Diagnostics.Stopwatch stopwatch = System.Diagnostics.Stopwatch.StartNew(); + bool result = TextFilter.IsMatch(text, pattern, TextFilterType.Regex, TextFilterMatchOptions.ByWholeString); + stopwatch.Stop(); + + Assert.IsFalse(result, "A pattern that cannot be evaluated in time should report no match, not throw."); + Assert.IsLessThan(15_000, stopwatch.ElapsedMilliseconds, "The match should be bounded by the regex timeout rather than running unbounded."); + } + + [TestMethod] + public void AnOrdinaryRegexIsUnaffectedByTheTimeout() + { + // Guards the direction the timeout could have broken: a normal pattern still matches. + Assert.IsTrue(TextFilter.IsMatch("hello world", "^hello", TextFilterType.Regex, TextFilterMatchOptions.ByWholeString)); + Assert.IsFalse(TextFilter.IsMatch("hello world", "^goodbye", TextFilterType.Regex, TextFilterMatchOptions.ByWholeString)); + } } diff --git a/TextFilter/TextFilter.cs b/TextFilter/TextFilter.cs index 8b0c24a..1cf1c68 100644 --- a/TextFilter/TextFilter.cs +++ b/TextFilter/TextFilter.cs @@ -47,6 +47,26 @@ public enum TextFilterMatchOptions ByWordAny, } +/// +/// Specifies whether a filter distinguishes uppercase from lowercase characters. +/// +/// +/// Applies to the and filter +/// types, which are case sensitive by default. matching is +/// unaffected: it is always case insensitive, and there is no way to make it otherwise. +/// +public enum TextFilterCaseSensitivity +{ + /// + /// Uppercase and lowercase characters are distinct, so *.jpg does not match IMG.JPG. + /// + CaseSensitive, + /// + /// Uppercase and lowercase characters are equivalent, so *.jpg matches IMG.JPG. + /// + CaseInsensitive, +} + internal enum TextFilterTokenType { Optional, @@ -64,6 +84,19 @@ public static partial class TextFilter private static ConcurrentDictionary RegexCache { get; } = []; private static ConcurrentDictionary GlobCache { get; } = []; + // Both caches are keyed by pattern text, so the same pattern compiled at two sensitivities would + // otherwise collide on the first one cached. The sensitivity is folded into the key rather than + // using a tuple key, which netstandard2.0 does not get for free. + private static string CacheKey(string pattern, TextFilterCaseSensitivity caseSensitivity) => + caseSensitivity is TextFilterCaseSensitivity.CaseInsensitive ? "i:" + pattern : "s:" + pattern; + + private static readonly GlobOptions CaseInsensitiveGlobOptions = new() { Evaluation = { CaseInsensitive = true } }; + + // Filter patterns are caller-supplied text, so a pattern with catastrophic backtracking would + // otherwise run unbounded on the calling thread. One second is far longer than any legitimate + // filter needs and short enough that a pathological one cannot wedge a UI. + private static readonly TimeSpan RegexMatchTimeout = TimeSpan.FromSeconds(1); + [System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "SYSLIB1045:Convert to 'GeneratedRegexAttribute'.", Justification = "Not available in older frameworks")] private static Regex RegexMatchAnything() => new(".*", RegexOptions.Compiled); @@ -90,10 +123,11 @@ public static string GetHint(TextFilterType filterType) /// The filter pattern. /// The type of the filter. /// The options for matching text filters. + /// Whether the match distinguishes uppercase from lowercase. Ignored by . /// A collection of strings that match the filter. /// When using fuzzy matching, the strings are sorted by their match score. - public static IEnumerable Filter(IEnumerable strings, string filter, TextFilterType filterType = TextFilterType.Glob, TextFilterMatchOptions textFilterMatchOptions = TextFilterMatchOptions.ByWordAny) => - Filter(strings, s => s, filter, filterType, textFilterMatchOptions); + public static IEnumerable Filter(IEnumerable strings, string filter, TextFilterType filterType = TextFilterType.Glob, TextFilterMatchOptions textFilterMatchOptions = TextFilterMatchOptions.ByWordAny, TextFilterCaseSensitivity caseSensitivity = TextFilterCaseSensitivity.CaseSensitive) => + Filter(strings, s => s, filter, filterType, textFilterMatchOptions, caseSensitivity); /// /// Filters the specified collection of items based on the provided filter and filter type. @@ -104,9 +138,10 @@ public static IEnumerable Filter(IEnumerable strings, string fil /// The filter pattern. /// The type of the filter. /// The options for matching text filters. + /// Whether the match distinguishes uppercase from lowercase. Ignored by . /// A collection of items that match the filter. /// When using fuzzy matching, the items are sorted by their match score. - public static IEnumerable Filter(IEnumerable items, Func keySelector, string filter, TextFilterType filterType = TextFilterType.Glob, TextFilterMatchOptions textFilterMatchOptions = TextFilterMatchOptions.ByWordAny) + public static IEnumerable Filter(IEnumerable items, Func keySelector, string filter, TextFilterType filterType = TextFilterType.Glob, TextFilterMatchOptions textFilterMatchOptions = TextFilterMatchOptions.ByWordAny, TextFilterCaseSensitivity caseSensitivity = TextFilterCaseSensitivity.CaseSensitive) { Ensure.NotNull(items); Ensure.NotNull(keySelector); @@ -114,7 +149,7 @@ public static IEnumerable Filter(IEnumerable items, Func { - bool isMatch = IsMatch(keySelector(item), filter, out int score, filterType, textFilterMatchOptions); + bool isMatch = IsMatch(keySelector(item), filter, out int score, filterType, textFilterMatchOptions, caseSensitivity); return (item, isMatch, score); }) .Where(t => t.isMatch) @@ -164,8 +199,9 @@ public static IEnumerable Rank(IEnumerable items, FuncThe score of the match (used with fuzzy matching). /// The type of the filter. /// The options for matching text filters. + /// Whether the match distinguishes uppercase from lowercase. Ignored by . /// true if the text matches the filter pattern; otherwise, false. - public static bool IsMatch(string text, string filter, out int score, TextFilterType filterType = TextFilterType.Glob, TextFilterMatchOptions textFilterMatchOptions = TextFilterMatchOptions.ByWordAny) + public static bool IsMatch(string text, string filter, out int score, TextFilterType filterType = TextFilterType.Glob, TextFilterMatchOptions textFilterMatchOptions = TextFilterMatchOptions.ByWordAny, TextFilterCaseSensitivity caseSensitivity = TextFilterCaseSensitivity.CaseSensitive) { Ensure.NotNull(text); Ensure.NotNull(filter); @@ -175,8 +211,8 @@ public static bool IsMatch(string text, string filter, out int score, TextFilter return string.IsNullOrWhiteSpace(filter) || filterType switch { - TextFilterType.Glob => DoesMatchGlob(text, filter, textFilterMatchOptions), - TextFilterType.Regex => DoesMatchRegex(text, filter, textFilterMatchOptions), + TextFilterType.Glob => DoesMatchGlob(text, filter, textFilterMatchOptions, caseSensitivity), + TextFilterType.Regex => DoesMatchRegex(text, filter, textFilterMatchOptions, caseSensitivity), TextFilterType.Fuzzy => Fuzzy.Contains(text.AsSpan(), filter.AsSpan(), out score), _ => throw new NotImplementedException($"{nameof(TextFilterType)}.{filterType} has not been implemented"), }; @@ -189,9 +225,10 @@ public static bool IsMatch(string text, string filter, out int score, TextFilter /// The filter pattern. /// The type of the filter. /// The options for matching text filters. + /// Whether the match distinguishes uppercase from lowercase. Ignored by . /// true if the text matches the filter pattern; otherwise, false. - public static bool IsMatch(string text, string filter, TextFilterType filterType = TextFilterType.Glob, TextFilterMatchOptions textFilterMatchOptions = TextFilterMatchOptions.ByWordAny) - => IsMatch(text, filter, out _, filterType, textFilterMatchOptions); + public static bool IsMatch(string text, string filter, TextFilterType filterType = TextFilterType.Glob, TextFilterMatchOptions textFilterMatchOptions = TextFilterMatchOptions.ByWordAny, TextFilterCaseSensitivity caseSensitivity = TextFilterCaseSensitivity.CaseSensitive) + => IsMatch(text, filter, out _, filterType, textFilterMatchOptions, caseSensitivity); internal static HashSet ExtractTextTokens(string text, TextFilterMatchOptions textFilterMatchOptions) { @@ -236,8 +273,9 @@ internal static Dictionary> ExtractGlobFilt /// The text to match. /// The glob filter pattern. /// The options for matching text filters. + /// Whether the match distinguishes uppercase from lowercase. /// true if the text matches the glob filter pattern; otherwise, false. - public static bool DoesMatchGlob(string text, string filter, TextFilterMatchOptions textFilterMatchOptions) + public static bool DoesMatchGlob(string text, string filter, TextFilterMatchOptions textFilterMatchOptions, TextFilterCaseSensitivity caseSensitivity = TextFilterCaseSensitivity.CaseSensitive) { Ensure.NotNull(text); Ensure.NotNull(filter); @@ -265,7 +303,7 @@ public static bool DoesMatchGlob(string text, string filter, TextFilterMatchOpti optionalTokens = []; } - bool anyExcludedMatches = excludedTokens.Any(filterToken => AnyTokenMatchesGlobFilter(filterToken, textTokens)); + bool anyExcludedMatches = excludedTokens.Any(filterToken => AnyTokenMatchesGlobFilter(filterToken, textTokens, caseSensitivity)); if (anyExcludedMatches) { @@ -276,7 +314,7 @@ public static bool DoesMatchGlob(string text, string filter, TextFilterMatchOpti ? Enumerable.Any : Enumerable.All; - bool anyOptionalMatches = optionalMatchFunc(optionalTokens, filterToken => AnyTokenMatchesGlobFilter(filterToken, textTokens)); + bool anyOptionalMatches = optionalMatchFunc(optionalTokens, filterToken => AnyTokenMatchesGlobFilter(filterToken, textTokens, caseSensitivity)); if (optionalTokens.Count != 0 && !anyOptionalMatches) { @@ -287,7 +325,7 @@ public static bool DoesMatchGlob(string text, string filter, TextFilterMatchOpti // question the excluded tokens above ask, so it uses the same function under every match // option. Matching it with AllTokensMatchGlobFilter under ByWordAll would instead demand // that every word in the text match the one required token, which no multi-word text can do. - bool allRequiredMatches = requiredTokens.All(filterToken => AnyTokenMatchesGlobFilter(filterToken, textTokens)); + bool allRequiredMatches = requiredTokens.All(filterToken => AnyTokenMatchesGlobFilter(filterToken, textTokens, caseSensitivity)); if (!allRequiredMatches) { @@ -302,17 +340,14 @@ public static bool DoesMatchGlob(string text, string filter, TextFilterMatchOpti /// /// The glob filter token. /// The set of text tokens to match against. + /// Whether the match distinguishes uppercase from lowercase. /// true if any token matches the glob filter token; otherwise, false. - public static bool AnyTokenMatchesGlobFilter(string filterToken, HashSet textTokens) + public static bool AnyTokenMatchesGlobFilter(string filterToken, HashSet textTokens, TextFilterCaseSensitivity caseSensitivity = TextFilterCaseSensitivity.CaseSensitive) { Ensure.NotNull(filterToken); Ensure.NotNull(textTokens); - if (!GlobCache.TryGetValue(filterToken, out Glob? glob)) - { - glob = Glob.Parse(filterToken); - GlobCache.TryAdd(filterToken, glob); - } + Glob glob = ResolveGlob(filterToken, caseSensitivity); return textTokens.Any(glob.IsMatch); } @@ -322,19 +357,32 @@ public static bool AnyTokenMatchesGlobFilter(string filterToken, HashSet /// /// The glob filter token. /// The set of text tokens to match against. + /// Whether the match distinguishes uppercase from lowercase. /// true if all tokens match the glob filter token; otherwise, false. - public static bool AllTokensMatchGlobFilter(string filterToken, HashSet textTokens) + public static bool AllTokensMatchGlobFilter(string filterToken, HashSet textTokens, TextFilterCaseSensitivity caseSensitivity = TextFilterCaseSensitivity.CaseSensitive) { Ensure.NotNull(filterToken); Ensure.NotNull(textTokens); - if (!GlobCache.TryGetValue(filterToken, out Glob? glob)) + Glob glob = ResolveGlob(filterToken, caseSensitivity); + + return textTokens.All(glob.IsMatch); + } + + private static Glob ResolveGlob(string filterToken, TextFilterCaseSensitivity caseSensitivity) + { + string cacheKey = CacheKey(filterToken, caseSensitivity); + + if (!GlobCache.TryGetValue(cacheKey, out Glob? glob)) { - glob = Glob.Parse(filterToken); - GlobCache.TryAdd(filterToken, glob); + glob = caseSensitivity is TextFilterCaseSensitivity.CaseInsensitive + ? Glob.Parse(filterToken, CaseInsensitiveGlobOptions) + : Glob.Parse(filterToken); + + GlobCache.TryAdd(cacheKey, glob); } - return textTokens.All(glob.IsMatch); + return glob; } /// @@ -343,19 +391,30 @@ public static bool AllTokensMatchGlobFilter(string filterToken, HashSet /// The text to match. /// The regex filter pattern. /// The options for matching text filters. + /// Whether the match distinguishes uppercase from lowercase. /// true if the text matches the regex filter pattern; otherwise, false. - public static bool DoesMatchRegex(string text, string filter, TextFilterMatchOptions textFilterMatchOptions) + /// + /// An invalid pattern matches everything. A pattern that cannot be evaluated within one second — + /// catastrophic backtracking, for instance — reports no match for the token that timed out + /// rather than throwing, so a caller-supplied pattern cannot hang the calling thread. + /// + public static bool DoesMatchRegex(string text, string filter, TextFilterMatchOptions textFilterMatchOptions, TextFilterCaseSensitivity caseSensitivity = TextFilterCaseSensitivity.CaseSensitive) { Ensure.NotNull(text); Ensure.NotNull(filter); // check if regex is valid HashSet textTokens = ExtractTextTokens(text, textFilterMatchOptions); - if (!RegexCache.TryGetValue(filter, out Regex? regex)) + string cacheKey = CacheKey(filter, caseSensitivity); + if (!RegexCache.TryGetValue(cacheKey, out Regex? regex)) { + RegexOptions regexOptions = caseSensitivity is TextFilterCaseSensitivity.CaseInsensitive + ? RegexOptions.Compiled | RegexOptions.IgnoreCase + : RegexOptions.Compiled; + try { - regex = new Regex(filter, RegexOptions.Compiled); + regex = new Regex(filter, regexOptions, RegexMatchTimeout); } catch (ArgumentException) { @@ -364,13 +423,27 @@ public static bool DoesMatchRegex(string text, string filter, TextFilterMatchOpt regex = RegexMatchAnything(); } - RegexCache.TryAdd(filter, regex); + RegexCache.TryAdd(cacheKey, regex); } Func, Func, bool> matchFunc = textFilterMatchOptions is TextFilterMatchOptions.ByWordAny ? Enumerable.Any : Enumerable.All; - return matchFunc(textTokens, textToken => regex.IsMatch(textToken)); + return matchFunc(textTokens, textToken => + { + try + { + return regex.IsMatch(textToken); + } + catch (RegexMatchTimeoutException) + { + // A pattern that cannot be evaluated within the timeout is treated as not matching + // this token rather than thrown at the caller. Filtering is a predicate, and a list + // that throws mid-keystroke on a pathological pattern is a worse contract than one + // that returns nothing for it. This mirrors how an invalid pattern degrades above. + return false; + } + }); } }