Skip to content
Open
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
47 changes: 40 additions & 7 deletions TextFilter.Test/TextFilterTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1040,17 +1040,17 @@
}

[TestMethod]
[DataRow("[.-0]", "/ . 0", "a -")]
[DataRow("[!.-0]", "a -", "/ . 0")]
[DataRow("[A-z]", "\\ A _ z", "0 {")]
[DataRow("[!A-z]", "0 {", "\\ A _ z")]
[DataRow("[*-0]", "/ \\ * 0", "a")]
[DataRow("[.-0]", "/ . 0", "a - \\")]
[DataRow("[!.-0]", "a - \\", "/ . 0")]
[DataRow("[A-z]", "\\ A _ z", "0 { /")]
[DataRow("[!A-z]", "0 { /", "\\ A _ z")]
[DataRow("[*-0]", "/ * 0", "a \\")]
[DataRow("[a-z]", "a q z", "/ \\ A")]
public void GlobClassRangeSpanningAPathSeparatorMatchesIt(string pattern, string matching, string notMatching)
{
// Path separators are masked to a private-use character in the pattern and the text, so a range
// left for DotNet.Glob to match natively never contained a masked "/" or "\" (#157). The two
// separators share one mask (#132), so these rows don't try to tell "/" and "\" apart.
// left for DotNet.Glob to match natively never contained a masked "/" or "\" (#157). Each
// separator has its own mask (#132), so a range holding one of them doesn't match the other.
ArgumentNullException.ThrowIfNull(matching);
ArgumentNullException.ThrowIfNull(notMatching);
foreach (string text in matching.Split(' '))
Expand Down Expand Up @@ -1190,6 +1190,39 @@
Assert.IsTrue(TextFilter.IsMatch("Docs/README.md", "**/readme.md", TextFilterType.Glob, TextFilterMatchOptions.ByWholeString, TextFilterCaseSensitivity.CaseInsensitive));
}

[TestMethod]
[DataRow(TextFilterCaseSensitivity.CaseSensitive)]
[DataRow(TextFilterCaseSensitivity.CaseInsensitive)]
public void GlobTellsSlashAndBackslashApart(TextFilterCaseSensitivity caseSensitivity)
{
// Both separators used to be masked to the same character, so either one matched the other (#132).
CollectionAssert.AreEqual(
new List<string> { "1/2" },
TextFilter.Filter(["C:\\temp\\x", "1/2", "1\\2"], "1/2", TextFilterType.Glob, caseSensitivity: caseSensitivity).ToList());

Check warning on line 1201 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=AaGTChs8Q4iSPc4VPba5&open=AaGTChs8Q4iSPc4VPba5&pullRequest=175
CollectionAssert.AreEqual(
new List<string> { "1\\2" },
TextFilter.Filter(["1/2", "1\\2"], "1\\2", TextFilterType.Glob, caseSensitivity: caseSensitivity).ToList());

Check warning on line 1204 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=AaGTChs8Q4iSPc4VPba6&open=AaGTChs8Q4iSPc4VPba6&pullRequest=175
Assert.IsFalse(TextFilter.IsMatch("a\\b", "a/b", TextFilterType.Glob, TextFilterMatchOptions.ByWholeString, caseSensitivity));
Assert.IsFalse(TextFilter.IsMatch("a/b", "a\\b", TextFilterType.Glob, TextFilterMatchOptions.ByWholeString, caseSensitivity));

// Excluding URLs keeps Windows paths.
CollectionAssert.AreEqual(
new List<string> { "C:\\temp", "plain" },
TextFilter.Filter(["C:\\temp", "http://x", "plain"], "-*/*", TextFilterType.Glob, caseSensitivity: caseSensitivity).ToList());

// Wildcards still cross either separator (#114).
Assert.IsTrue(TextFilter.IsMatch("a/b\\c", "a*c", TextFilterType.Glob, TextFilterMatchOptions.ByWholeString, caseSensitivity));
Assert.IsTrue(TextFilter.IsMatch("a\\b", "a?b", TextFilterType.Glob, TextFilterMatchOptions.ByWholeString, caseSensitivity));
}

[TestMethod]
[DataRow(@"src\x\a.cs", @"src/**/*.cs")]
[DataRow(@"src/x/a.cs", @"src\**\*.cs")]
public void GlobStarSegmentEndsAtTheSeparatorThePatternWrote(string text, string filter)
{
Assert.IsFalse(TextFilter.IsMatch(text, filter, TextFilterType.Glob, TextFilterMatchOptions.ByWholeString));
}

[TestMethod]
public void GlobLiteralSlashInPatternStillMatchesSlashInText()
{
Expand All @@ -1206,9 +1239,9 @@
// Splitting only on ' ' left a chunk of other whitespace that trimmed to an empty token, and
// the glob tokenizer then read its first character. A pasted trailing tab or newline is enough.
Assert.IsTrue(TextFilter.IsMatch("foo", filter));
CollectionAssert.AreEqual(
new List<string> { "foo" },
TextFilter.Filter(["foo"], filter).ToList());

Check warning on line 1244 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=AaGTChs8Q4iSPc4VPba7&open=AaGTChs8Q4iSPc4VPba7&pullRequest=175
}

[TestMethod]
Expand Down
42 changes: 33 additions & 9 deletions TextFilter/TextFilter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,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 128 in TextFilter/TextFilter.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Pass a timeout to limit the execution time.

Check warning on line 128 in TextFilter/TextFilter.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Pass a timeout to limit the execution time.

Check warning on line 128 in TextFilter/TextFilter.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Pass a timeout to limit the execution time.

Check warning on line 128 in TextFilter/TextFilter.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Pass a timeout to limit the execution time.

Check warning on line 128 in TextFilter/TextFilter.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Pass a timeout to limit the execution time.

// Stands in for a pattern that has already run out of time once. The timeout bounds a single
// IsMatch call, so without this the same pathological pattern would pay it again for every word
Expand Down Expand Up @@ -300,7 +300,7 @@

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

Check warning on line 303 in TextFilter/TextFilter.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Extract this nested ternary operation into an independent statement.

Check warning on line 303 in TextFilter/TextFilter.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Extract this nested ternary operation into an independent statement.

Check warning on line 303 in TextFilter/TextFilter.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Extract this nested ternary operation into an independent statement.

Check warning on line 303 in TextFilter/TextFilter.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Extract this nested ternary operation into an independent statement.

Check warning on line 303 in TextFilter/TextFilter.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Extract this nested ternary operation into an independent statement.
? TextFilterTokenType.Required
: TextFilterTokenType.Optional;
})
Expand Down Expand Up @@ -433,12 +433,15 @@
}

// DotNet.Glob is a file-path glob, so its * and ? stop at / and \. TextFilter filters arbitrary
// text, where a slash is an ordinary character, so both separators are swapped for a private-use
// character in the pattern and in the text before DotNet.Glob sees either.
private const char MaskedPathSeparator = '\uE000';
// text, where a slash is an ordinary character, so both separators are swapped for private-use
// characters in the pattern and in the text before DotNet.Glob sees either. Each separator gets
// its own character: sharing one would make them interchangeable, so "1/2" would also match
// "1\2", and excluding URLs with "-*/*" would hide every Windows path too.
private const char MaskedSlash = '\uE000';
private const char MaskedBackslash = '\uE001';

private static string MaskPathSeparators(string value) =>
value.Replace('/', MaskedPathSeparator).Replace('\\', MaskedPathSeparator);
value.Replace('/', MaskedSlash).Replace('\\', MaskedBackslash);

private static bool IsGlobMatch(Glob[] globs, string textToken)
{
Expand All @@ -449,8 +452,28 @@
// Masking also hides the separators DotNet.Glob needs to recognise "**/" as "zero or more path
// segments", leaving "**" followed by a literal character. Since * already crosses the masked
// separator, each "**/" is expanded here instead: dropped, for the zero-segment case, or kept as
// "*/", for "anything ending at a separator". A token matches if any expansion does.
private const string MaskedGlobStarSegment = "**\uE000";
// "*/", for "anything ending at a separator". A token matches if any expansion does. The kept form
// ends at the same separator the pattern wrote, so "a/**/b" still needs a "/" before the "b".
private const string MaskedGlobStar = "**";

private static bool IsMaskedPathSeparator(char value) => value is MaskedSlash or MaskedBackslash;

private static int IndexOfMaskedGlobStarSegment(string maskedToken, int start)
{
int index = start;
while ((index = maskedToken.IndexOf(MaskedGlobStar, index, StringComparison.Ordinal)) >= 0)
{
int separator = index + MaskedGlobStar.Length;
if (separator < maskedToken.Length && IsMaskedPathSeparator(maskedToken[separator]))
{
return index;
}

index++;
}

return -1;
}

// Each "**/" doubles the expansions, so only this many are expanded both ways; any beyond it keep
// only the "*/" form and so need at least one segment.
Expand All @@ -463,15 +486,16 @@
int expanded = 0;
int index;

while ((index = maskedToken.IndexOf(MaskedGlobStarSegment, start, StringComparison.Ordinal)) >= 0)
while ((index = IndexOfMaskedGlobStarSegment(maskedToken, start)) >= 0)
{
string literal = maskedToken[start..index];
char separator = maskedToken[index + MaskedGlobStar.Length];
bool expandBothWays = expanded < MaxExpandedGlobStarSegments;
List<string> next = new(expansions.Count * 2);

foreach (string prefix in expansions)
{
next.Add(prefix + literal + "*" + MaskedPathSeparator);
next.Add(prefix + literal + "*" + separator);
if (expandBothWays)
{
next.Add(prefix + literal);
Expand All @@ -480,7 +504,7 @@

expansions = next;
expanded++;
start = index + MaskedGlobStarSegment.Length;
start = index + MaskedGlobStar.Length + 1;
}

string tail = maskedToken[start..];
Expand Down
Loading