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 @@ -1115,17 +1115,17 @@ public void GlobClassWithBothLetterCasesMatchesEachCase()
}

[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 @@ -1319,6 +1319,39 @@ public void GlobStarSlashMatchesCaseInsensitively()
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());
CollectionAssert.AreEqual(
new List<string> { "1\\2" },
TextFilter.Filter(["1/2", "1\\2"], "1\\2", TextFilterType.Glob, caseSensitivity: caseSensitivity).ToList());
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]
[DataRow("**/")]
[DataRow("**/**/")]
Expand Down
46 changes: 35 additions & 11 deletions TextFilter/TextFilter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -436,12 +436,15 @@ public static bool AllTokensMatchGlobFilter(string filterToken, HashSet<string>
}

// 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 @@ -452,9 +455,29 @@ private static bool IsGlobMatch(Glob[] globs, string textToken)
// 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. Only a "**/"
// that starts the token or follows a separator is dropped, since one joined to a literal is a "*".
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". Only
// a "**/" that starts the token or follows a separator is dropped, since one joined to a literal is a "*".
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 @@ -467,28 +490,29 @@ internal static IReadOnlyList<string> ExpandGlobStarSegments(string maskedToken)
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)
{
string before = prefix + literal;
next.Add(before + "*" + MaskedPathSeparator);
next.Add(before + "*" + separator);

// Only a "**" that is a whole segment can match zero segments; joined to a literal, as
// in "foo**/bar", it is just "*", and dropping it would splice "foo" onto "bar".
if (expandBothWays && (before.Length == 0 || before[^1] == MaskedPathSeparator))
if (expandBothWays && (before.Length == 0 || IsMaskedPathSeparator(before[^1])))
{
next.Add(before);
}
}

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

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