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
22 changes: 21 additions & 1 deletion TextFilter.Test/TextFilterTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -779,11 +779,14 @@
[DataRow("[!a-")]
[DataRow("*[0-")]
[DataRow("[a-]")]
[DataRow("-file[0-")]
[DataRow("!file[0-")]
[DataRow("^file[0-")]
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.
// unparseable token is ignored: plain and required tokens match anything, excluded ones exclude nothing.
foreach (TextFilterCaseSensitivity caseSensitivity in new[] { TextFilterCaseSensitivity.CaseSensitive, TextFilterCaseSensitivity.CaseInsensitive })
{
Assert.IsTrue(TextFilter.IsMatch("file1.txt", pattern, TextFilterType.Glob, TextFilterMatchOptions.ByWordAny, caseSensitivity));
Expand All @@ -793,6 +796,23 @@
}
}

[TestMethod]
[DataRow("-readme[a-")]
[DataRow("!readme[a-")]
[DataRow("^readme[a-")]
public void ExcludedGlobWithAHalfTypedRangeExcludesNothing(string pattern)
{
// An unparseable excluded token is ignored while the user finishes typing it. Treating it as
// match-anything, as unprefixed tokens are, would exclude every item on each keystroke.
List<string> items = ["file1.txt", "file2.txt", "readme.md"];
foreach (TextFilterCaseSensitivity caseSensitivity in new[] { TextFilterCaseSensitivity.CaseSensitive, TextFilterCaseSensitivity.CaseInsensitive })
{
CollectionAssert.AreEqual(
items,
TextFilter.Filter(items, pattern, TextFilterType.Glob, TextFilterMatchOptions.ByWordAny, caseSensitivity).ToList());

Check warning on line 812 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=AaDcODqsqjYdBRs4-n6T&open=AaDcODqsqjYdBRs4-n6T&pullRequest=111
}
}

[TestMethod]
[DataRow("", TextFilterType.Regex)]
[DataRow(" ", TextFilterType.Regex)]
Expand Down
32 changes: 17 additions & 15 deletions TextFilter/TextFilter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,8 @@
private static HashSet<char> ExcludedTokenPrefixes { get; } = ['!', '-', '^'];
private static HashSet<char> RequiredTokenPrefixes { get; } = ['+'];
private static ConcurrentDictionary<string, Regex> RegexCache { get; } = [];
private static ConcurrentDictionary<string, Glob> GlobCache { get; } = [];
// A null entry records a token that could not be parsed, so the parse is not retried on every keystroke.
private static ConcurrentDictionary<string, Glob?> GlobCache { get; } = [];

// Filter patterns are caller-supplied, and in the keystroke-driven filter box this library exists
// for, every prefix of what the user types becomes its own key. Unbounded, the caches therefore
Expand Down Expand Up @@ -123,10 +124,8 @@
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.

private static Glob GlobMatchAnything() => Glob.Parse("*");

/// <summary>
/// Gets a hint for the specified filter type.
/// </summary>
Expand Down Expand Up @@ -277,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 All @@ -303,8 +302,9 @@
/// <param name="caseSensitivity">Whether the match distinguishes uppercase from lowercase.</param>
/// <returns><c>true</c> if the text matches the glob filter pattern; otherwise, <c>false</c>.</returns>
/// <remarks>
/// A token that cannot be parsed as a glob, such as the half-typed range <c>file[0-</c>, matches
/// everything rather than throwing, the same way an invalid regex pattern does.
/// A token that cannot be parsed as a glob, such as the half-typed range <c>file[0-</c>, is ignored
/// rather than throwing: as a plain or required token it matches everything, and as an excluded
/// token it excludes nothing.
/// </remarks>
public static bool DoesMatchGlob(string text, string filter, TextFilterMatchOptions textFilterMatchOptions, TextFilterCaseSensitivity caseSensitivity = TextFilterCaseSensitivity.CaseSensitive)
{
Expand Down Expand Up @@ -334,7 +334,9 @@
optionalTokens = [];
}

bool anyExcludedMatches = excludedTokens.Any(filterToken => AnyTokenMatchesGlobFilter(filterToken, textTokens, caseSensitivity));
// An unparseable excluded token is skipped rather than treated as match-anything, which here
// would exclude every item while the user is still typing the token.
bool anyExcludedMatches = excludedTokens.Any(filterToken => ResolveGlob(filterToken, caseSensitivity) is Glob glob && textTokens.Any(glob.IsMatch));

if (anyExcludedMatches)
{
Expand Down Expand Up @@ -378,9 +380,9 @@
Ensure.NotNull(filterToken);
Ensure.NotNull(textTokens);

Glob glob = ResolveGlob(filterToken, caseSensitivity);
Glob? glob = ResolveGlob(filterToken, caseSensitivity);

return textTokens.Any(glob.IsMatch);
return glob is null || textTokens.Any(glob.IsMatch);
}

/// <summary>
Expand All @@ -395,12 +397,13 @@
Ensure.NotNull(filterToken);
Ensure.NotNull(textTokens);

Glob glob = ResolveGlob(filterToken, caseSensitivity);
Glob? glob = ResolveGlob(filterToken, caseSensitivity);

return textTokens.All(glob.IsMatch);
return glob is null || textTokens.All(glob.IsMatch);
}

private static Glob ResolveGlob(string filterToken, TextFilterCaseSensitivity caseSensitivity)
// Returns null for a token that cannot be parsed, so each caller can decide what ignoring it means.
private static Glob? ResolveGlob(string filterToken, TextFilterCaseSensitivity caseSensitivity)
{
string cacheKey = CacheKey(filterToken, caseSensitivity);

Expand All @@ -416,10 +419,9 @@
{
// 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();
// a filter box. Cache it as unparseable so the exception is not raised again on every
// keystroke. Caught broadly so the next tokeniser bug is contained too.
glob = null;
}

AddBounded(GlobCache, cacheKey, glob);
Expand Down
Loading