diff --git a/TextFilter.Test/TextFilterTests.cs b/TextFilter.Test/TextFilterTests.cs index 66011f5..023478d 100644 --- a/TextFilter.Test/TextFilterTests.cs +++ b/TextFilter.Test/TextFilterTests.cs @@ -452,6 +452,39 @@ public void DoesMatchRegexByWordAnyReturnsFalse() Assert.IsFalse(result, "Non-matching regex should return false for ByWordAny."); } + [TestMethod] + [DataRow("hello world", "hello world")] + [DataRow("hello world", @"o\sw")] + [DataRow("hello world", "o w")] + [DataRow("New York City", "New York")] + [DataRow("12 items left", @"\d+\s+items")] + public void IsMatchRegexSpanningWhitespaceMatchesUnderTheDefaults(string text, string pattern) => + Assert.IsTrue(TextFilter.IsMatch(text, pattern, TextFilterType.Regex)); + + [TestMethod] + public void IsMatchRegexByWordAnyStillMatchesAWordAnchoredPattern() + { + Assert.IsTrue(TextFilter.IsMatch("hello world", "^world", TextFilterType.Regex, TextFilterMatchOptions.ByWordAny)); + Assert.IsTrue(TextFilter.IsMatch("hello world", "hello$", TextFilterType.Regex, TextFilterMatchOptions.ByWordAny)); + Assert.IsFalse(TextFilter.IsMatch("hello world", "hello world", TextFilterType.Regex, TextFilterMatchOptions.ByWordAny)); + } + + [TestMethod] + public void FilterRegexSpanningWhitespaceKeepsMatchingItemsUnderTheDefaults() + { + List strings = ["New York City", "York", "Newark"]; + List result = [.. TextFilter.Filter(strings, "New York", TextFilterType.Regex)]; + CollectionAssert.AreEqual(new List { "New York City" }, result); + } + + [TestMethod] + public void DoesMatchRegexByWordAllStillRequiresEveryWordToMatchOnItsOwn() + { + // Documented: a whitespace-spanning pattern needs ByWholeString when every word must match. + Assert.IsFalse(TextFilter.DoesMatchRegex("hello world", "hello world", TextFilterMatchOptions.ByWordAll)); + Assert.IsTrue(TextFilter.DoesMatchRegex("hello world", "hello world", TextFilterMatchOptions.ByWholeString)); + } + [TestMethod] public void DoesMatchRegexByWordAllReturnsFalse() { diff --git a/TextFilter/TextFilter.cs b/TextFilter/TextFilter.cs index 65c8a36..5197534 100644 --- a/TextFilter/TextFilter.cs +++ b/TextFilter/TextFilter.cs @@ -442,6 +442,13 @@ public static bool AllTokensMatchGlobFilter(string filterToken, HashSet /// 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. + /// + /// Under the pattern is tried against the whole text + /// before each word, so a pattern that spans whitespace, such as New York or \d+\s+items, + /// still matches. Under every word must match on its + /// own, so a pattern that spans whitespace never matches there; use + /// for those. + /// /// public static bool DoesMatchRegex(string text, string filter, TextFilterMatchOptions textFilterMatchOptions, TextFilterCaseSensitivity caseSensitivity = TextFilterCaseSensitivity.CaseSensitive) { @@ -485,24 +492,34 @@ public static bool DoesMatchRegex(string text, string filter, TextFilterMatchOpt return false; } + // No single word contains whitespace, so a pattern like "hello world" or "o\sw" can only ever + // match the whole text. Under ByWordAny, try that first; the per-word pass below still runs, + // so a word-anchored pattern like "^world" keeps matching "hello world". + if (textFilterMatchOptions is TextFilterMatchOptions.ByWordAny && IsMatchWithinTimeout(regex, text)) + { + return true; + } + Func, Func, bool> matchFunc = textFilterMatchOptions is TextFilterMatchOptions.ByWordAny ? Enumerable.Any : Enumerable.All; - return matchFunc(textTokens, textToken => + return matchFunc(textTokens, textToken => IsMatchWithinTimeout(regex, textToken)); + } + + private static bool IsMatchWithinTimeout(Regex regex, string input) + { + try { - 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; - } - }); + return regex.IsMatch(input); + } + catch (RegexMatchTimeoutException) + { + // A pattern that cannot be evaluated within the timeout is treated as not matching + // this input 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 in DoesMatchRegex. + return false; + } } }