Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions TextFilter.Test/TextFilterTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -452,6 +452,39 @@
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<string> strings = ["New York City", "York", "Newark"];
List<string> result = [.. TextFilter.Filter(strings, "New York", TextFilterType.Regex)];
CollectionAssert.AreEqual(new List<string> { "New York City" }, result);

Check warning on line 477 in TextFilter.Test/TextFilterTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'Assert.AreSequenceEqual' instead of 'CollectionAssert.AreEqual'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_TextFilter&issues=AaDklJCvHLoqijiD39FY&open=AaDklJCvHLoqijiD39FY&pullRequest=125
}

[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()
{
Expand Down
45 changes: 31 additions & 14 deletions TextFilter/TextFilter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@
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);

Check warning on line 127 in TextFilter/TextFilter.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Pass a timeout to limit the execution time.

Check warning on line 127 in TextFilter/TextFilter.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Pass a timeout to limit the execution time.

Check warning on line 127 in TextFilter/TextFilter.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Pass a timeout to limit the execution time.

Check warning on line 127 in TextFilter/TextFilter.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Pass a timeout to limit the execution time.

/// <summary>
/// Gets a hint for the specified filter type.
Expand Down Expand Up @@ -276,7 +276,7 @@

return ExcludedTokenPrefixes.Contains(prefix)
? TextFilterTokenType.Excluded
: RequiredTokenPrefixes.Contains(prefix)

Check warning on line 279 in TextFilter/TextFilter.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Extract this nested ternary operation into an independent statement.

Check warning on line 279 in TextFilter/TextFilter.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Extract this nested ternary operation into an independent statement.

Check warning on line 279 in TextFilter/TextFilter.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Extract this nested ternary operation into an independent statement.

Check warning on line 279 in TextFilter/TextFilter.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Extract this nested ternary operation into an independent statement.

Check warning on line 279 in TextFilter/TextFilter.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Extract this nested ternary operation into an independent statement.

Check warning on line 279 in TextFilter/TextFilter.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Extract this nested ternary operation into an independent statement.
? TextFilterTokenType.Required
: TextFilterTokenType.Optional;
})
Expand Down Expand Up @@ -442,6 +442,13 @@
/// 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.
/// <para>
/// Under <see cref="TextFilterMatchOptions.ByWordAny"/> the pattern is tried against the whole text
/// before each word, so a pattern that spans whitespace, such as <c>New York</c> or <c>\d+\s+items</c>,
/// still matches. Under <see cref="TextFilterMatchOptions.ByWordAll"/> every word must match on its
/// own, so a pattern that spans whitespace never matches there; use
/// <see cref="TextFilterMatchOptions.ByWholeString"/> for those.
/// </para>
/// </remarks>
public static bool DoesMatchRegex(string text, string filter, TextFilterMatchOptions textFilterMatchOptions, TextFilterCaseSensitivity caseSensitivity = TextFilterCaseSensitivity.CaseSensitive)
{
Expand Down Expand Up @@ -485,24 +492,34 @@
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<IEnumerable<string>, Func<string, bool>, 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;
}
}
}
Loading