diff --git a/TextFilter.Test/ReadmeTests.cs b/TextFilter.Test/ReadmeTests.cs index 7ced909..17fc9c2 100644 --- a/TextFilter.Test/ReadmeTests.cs +++ b/TextFilter.Test/ReadmeTests.cs @@ -4,6 +4,7 @@ namespace TextFilter.Test; using System; using System.Collections.Generic; +using System.ComponentModel; using System.IO; using System.Linq; using System.Reflection; @@ -89,7 +90,10 @@ public void EveryMethodInTheReadmeApiReferenceExistsWithItsDocumentedDefaults() foreach (Match row in rows) { string methodName = row.Groups["method"].Value; - MethodInfo[] overloads = [.. methods.Where(m => m.Name == methodName)]; + // The binary-compatibility overloads are hidden from IntelliSense and deliberately carry no + // defaults, since defaults on both sets would make calls ambiguous. The README documents the + // visible overloads, so only those are held to its defaults. + MethodInfo[] overloads = [.. methods.Where(m => m.Name == methodName && !IsHiddenFromCallers(m))]; if (overloads.Length == 0) { problems.Add($"{methodName} does not exist"); @@ -115,6 +119,9 @@ public void EveryMethodInTheReadmeApiReferenceExistsWithItsDocumentedDefaults() Assert.IsEmpty(problems, string.Join("; ", problems)); } + private static bool IsHiddenFromCallers(MethodInfo method) => + method.GetCustomAttribute()?.State == EditorBrowsableState.Never; + [TestMethod] public void ReadmeMatchOptionsSampleGivesTheDocumentedResults() { diff --git a/TextFilter.Test/TextFilterTests.cs b/TextFilter.Test/TextFilterTests.cs index 886b9cd..4af5016 100644 --- a/TextFilter.Test/TextFilterTests.cs +++ b/TextFilter.Test/TextFilterTests.cs @@ -1205,6 +1205,47 @@ public void ByWordAllDoesNotMatchTextWithNoWords(string text, TextFilterType fil TextFilter.Filter([text], "zzz", filterType, TextFilterMatchOptions.ByWordAll).ToList()); } + [TestMethod] + [DataRow(" ", @"\s")] + [DataRow(" ", @"^\s*$")] + [DataRow(" ", @"\s+")] + [DataRow("", "^$")] + [DataRow("", @"^\s*$")] + [DataRow(" \t ", @"^\s*$")] + public void RegexByWordAnyMatchesBlankTextWithAPatternForBlankText(string text, string pattern) + { + // Blank text splits into no words, and the no-words guard used to return before the whole-text + // attempt, so a filter written to find blank entries missed exactly those under the default. + Assert.IsTrue(TextFilter.IsMatch(text, pattern, TextFilterType.Regex), $"'{pattern}' should match '{text}' under ByWordAny."); + Assert.IsTrue(TextFilter.IsMatch(text, pattern, TextFilterType.Regex, TextFilterMatchOptions.ByWholeString), $"'{pattern}' should match '{text}' under ByWholeString."); + } + + [TestMethod] + [DataRow(" ", @"\s")] + [DataRow("", "^$")] + [DataRow(" ", @"^\s*$")] + public void RegexByWordAllStillMatchesNoBlankText(string text, string pattern) + { + Assert.IsFalse(TextFilter.IsMatch(text, pattern, TextFilterType.Regex, TextFilterMatchOptions.ByWordAll)); + } + + [TestMethod] + [DataRow("")] + [DataRow(" ")] + public void RegexByWordAnyDoesNotMatchBlankTextWithAPatternThatNeedsAWord(string text) + { + Assert.IsFalse(TextFilter.IsMatch(text, "zzz", TextFilterType.Regex)); + Assert.IsFalse(TextFilter.IsMatch(text, @"\w", TextFilterType.Regex)); + } + + [TestMethod] + public void RegexByWordAnyFilterKeepsTheBlankEntries() + { + CollectionAssert.AreEqual( + new List { "", " " }, + TextFilter.Filter(["", "hello", " ", "a b"], @"^\s*$", TextFilterType.Regex).ToList()); + } + [TestMethod] public void RegexByWordAllFilterDropsBlankEntries() { diff --git a/TextFilter/TextFilter.cs b/TextFilter/TextFilter.cs index 16701d8..26fac7f 100644 --- a/TextFilter/TextFilter.cs +++ b/TextFilter/TextFilter.cs @@ -729,6 +729,16 @@ public static bool DoesMatchRegex(string text, string filter, TextFilterMatchOpt // Swapped for RegexMatchNothing if the pattern times out partway through Regex activeRegex = regex; + // 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". It comes before the + // no-words check below because blank text has no words at all, so a pattern written to find + // blank entries, such as "^\s*$" or "\s", can only ever match the whole text. + if (textFilterMatchOptions is TextFilterMatchOptions.ByWordAny && IsMatchWithinTimeout(ref activeRegex, cacheKey, text)) + { + return true; + } + // Text with no words -- "" or " " split by word -- has nothing for the pattern to match. // Without this, Enumerable.All over the empty set is vacuously true, so under ByWordAll blank // text matched every pattern while the glob path reported no match for the same input. @@ -737,14 +747,6 @@ 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(ref activeRegex, cacheKey, text)) - { - return true; - } - Func, Func, bool> matchFunc = textFilterMatchOptions is TextFilterMatchOptions.ByWordAny ? Enumerable.Any : Enumerable.All;