From b18d95ce6fd3305dc34b1b262a8a2287babe8643 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 20:28:12 +0000 Subject: [PATCH] Let a regex that spans whitespace match under ByWordAny [patch] DoesMatchRegex split the text into words for ByWordAny and ByWordAll and ran the pattern against each word. No word contains whitespace, so a pattern such as "hello world", "o\sw" or "New York" could never match, and ByWordAny is the default for Filter and IsMatch. Under ByWordAny the pattern is now tried against the whole text first, then against each word, so word-anchored patterns like "^world" still match. Both evaluations share the existing timeout handling. ByWordAll keeps its per-word meaning, and the remarks now say that a pattern spanning whitespace needs ByWholeString there. Fixes ktsu-dev/TextFilter#122 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01M9aefrfAYJpanQVrUuFpJh --- TextFilter.Test/TextFilterTests.cs | 33 ++++++++++++++++++++++ TextFilter/TextFilter.cs | 45 ++++++++++++++++++++---------- 2 files changed, 64 insertions(+), 14 deletions(-) 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; + } } }