diff --git a/TextFilter.Test/TextFilterTests.cs b/TextFilter.Test/TextFilterTests.cs index 5225dc1..9c20e60 100644 --- a/TextFilter.Test/TextFilterTests.cs +++ b/TextFilter.Test/TextFilterTests.cs @@ -771,4 +771,25 @@ public void ACachedPatternStillMatchesAfterTheCacheHasReset() "A pattern evicted by the bound should simply be recompiled, not answer differently."); Assert.IsFalse(TextFilter.IsMatch("hello world", "^goodbye", TextFilterType.Regex, TextFilterMatchOptions.ByWholeString)); } + + [TestMethod] + [DataRow("file[0-")] + [DataRow("[a-")] + [DataRow("a[b-")] + [DataRow("[!a-")] + [DataRow("*[0-")] + [DataRow("[a-]")] + 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. + foreach (TextFilterCaseSensitivity caseSensitivity in new[] { TextFilterCaseSensitivity.CaseSensitive, TextFilterCaseSensitivity.CaseInsensitive }) + { + Assert.IsTrue(TextFilter.IsMatch("file1.txt", pattern, TextFilterType.Glob, TextFilterMatchOptions.ByWordAny, caseSensitivity)); + CollectionAssert.AreEqual( + new List { "file1.txt" }, + TextFilter.Filter(["file1.txt"], pattern, TextFilterType.Glob, TextFilterMatchOptions.ByWordAny, caseSensitivity).ToList()); + } + } } diff --git a/TextFilter/TextFilter.cs b/TextFilter/TextFilter.cs index e8d698c..7b7f1fd 100644 --- a/TextFilter/TextFilter.cs +++ b/TextFilter/TextFilter.cs @@ -125,6 +125,8 @@ 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. /// @@ -300,6 +302,10 @@ internal static Dictionary> ExtractGlobFilt /// The options for matching text filters. /// 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. + /// public static bool DoesMatchGlob(string text, string filter, TextFilterMatchOptions textFilterMatchOptions, TextFilterCaseSensitivity caseSensitivity = TextFilterCaseSensitivity.CaseSensitive) { Ensure.NotNull(text); @@ -400,9 +406,21 @@ private static Glob ResolveGlob(string filterToken, TextFilterCaseSensitivity ca if (!GlobCache.TryGetValue(cacheKey, out Glob? glob)) { - glob = caseSensitivity is TextFilterCaseSensitivity.CaseInsensitive - ? Glob.Parse(filterToken, CaseInsensitiveGlobOptions) - : Glob.Parse(filterToken); + try + { + glob = caseSensitivity is TextFilterCaseSensitivity.CaseInsensitive + ? Glob.Parse(filterToken, CaseInsensitiveGlobOptions) + : Glob.Parse(filterToken); + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + // 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(); + } AddBounded(GlobCache, cacheKey, glob); }