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
43 changes: 43 additions & 0 deletions TextFilter.Test/TextFilterTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -977,6 +977,49 @@ public void GlobWithAHalfTypedRangeDoesNotThrow(string pattern)
}
}

[TestMethod]
[DataRow("[A-z]", "_", true)]
[DataRow("[A-z]", "[", true)]
[DataRow("[A-z]", "m", true)]
[DataRow("[Z-a]", "a", true)]
[DataRow("[Z-a]", "_", true)]
[DataRow("[Z-a]", "b", false)]
[DataRow("[!A-z]", "_", false)]
[DataRow("[!A-z]", "{", true)]
[DataRow("[a-Z]", "z", false)]
[DataRow("[!a-Z]", "z", true)]
public void GlobRangeWithMixedCaseEndpointsMatchesTheSameUnderBothSensitivities(string pattern, string text, bool expected)
{
// Under CaseInsensitive, DotNet.Glob lowercases a range's endpoints, so "A-z" became "a-z" and
// lost "_" and "[", "Z-a" became the empty "z-a", and the reversed "a-Z" became "a-z".
foreach (TextFilterCaseSensitivity caseSensitivity in new[] { TextFilterCaseSensitivity.CaseSensitive, TextFilterCaseSensitivity.CaseInsensitive })
{
Assert.AreEqual(expected, TextFilter.IsMatch(text, pattern, TextFilterType.Glob, TextFilterMatchOptions.ByWholeString, caseSensitivity), caseSensitivity.ToString());
}
}

[TestMethod]
public void GlobRangeWithSameCaseEndpointsStillFoldsUnderCaseInsensitive()
{
Assert.IsTrue(TextFilter.IsMatch("M", "[a-z]", TextFilterType.Glob, TextFilterMatchOptions.ByWholeString, TextFilterCaseSensitivity.CaseInsensitive));
Assert.IsFalse(TextFilter.IsMatch("M", "[a-z]", TextFilterType.Glob, TextFilterMatchOptions.ByWholeString, TextFilterCaseSensitivity.CaseSensitive));
Assert.IsTrue(TextFilter.IsMatch("m", "[A-Z]", TextFilterType.Glob, TextFilterMatchOptions.ByWholeString, TextFilterCaseSensitivity.CaseInsensitive));
}

[TestMethod]
public void CaseInsensitiveGlobClassMatchesEverythingCaseSensitiveDoes()
{
string[] patterns = ["[A-z]", "[Z-a]", "[a-Z]", "[a-z]", "[A-Z]", "[0-9]"];
for (char c = ' '; c <= '~'; c++)
{
string text = c.ToString();
foreach (string pattern in patterns.Where(p => TextFilter.IsMatch(text, p, TextFilterType.Glob, TextFilterMatchOptions.ByWholeString, TextFilterCaseSensitivity.CaseSensitive)))
{
Assert.IsTrue(TextFilter.IsMatch(text, pattern, TextFilterType.Glob, TextFilterMatchOptions.ByWholeString, TextFilterCaseSensitivity.CaseInsensitive), $"{pattern} on '{text}'");
}
}
}

[TestMethod]
public void GlobClassWithATrailingDashMatchesTheDashLiterally()
{
Expand Down
37 changes: 30 additions & 7 deletions TextFilter/TextFilter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -533,10 +533,7 @@ internal static IReadOnlyList<string> ExpandGlobStarSegments(string maskedToken)
}

string content = token[contentStart..close];
string opening = negated ? "[!" : "[";
string? rewritten = !content.Contains('-') || IsNativeGlobRange(content)
? opening + content + "]"
: ToGlobCharacterList(content, negated);
string? rewritten = RewriteGlobClass(content, negated);
if (rewritten is null)
{
return null;
Expand All @@ -558,6 +555,22 @@ internal static IReadOnlyList<string> ExpandGlobStarSegments(string maskedToken)
return result.Append(token, index, token.Length - index).ToString();
}

// Returns the class as DotNet.Glob should see it, or null if it cannot be listed.
private static string? RewriteGlobClass(string content, bool negated)
{
if (!content.Contains('-') || IsNativeGlobRange(content))
{
return (negated ? "[!" : "[") + content + "]";
}

if (IsReversedLetterRange(content))
{
return negated ? "[!9-0]" : "[9-0]";
}

return ToGlobCharacterList(content, negated);
}

// Returns the index of the "]" closing the class opened at "open", or -1 if it is never closed.
// A class opening with "^" is negated, as in bash, gitignore and regex, the same as one opening with
// "!". DotNet.Glob only knows "!", so the caller writes the negation back out as "!".
Expand All @@ -574,16 +587,26 @@ private static int FindGlobClassEnd(string token, int open, out bool negated, ou
return contentStart < token.Length ? token.IndexOf(']', contentStart + 1) : -1;
}

// A range that spans "/" or "\" is listed instead, so the separator in it is masked like any other
// literal one; left native, it would never contain the masked separator in the text ("[A-z]").
// A lone range is left for DotNet.Glob only when its endpoints share a case or are both digits. Under
// CaseInsensitive, DotNet.Glob lowercases the endpoints, which would turn the mixed-case "A-z" into
// "a-z" (losing "_" and "["), so a mixed-case range is listed by ToGlobCharacterList instead.
// A range that spans "/" or "\" is listed too, so the separator in it is masked like any other
// literal one; left native, it would never contain the masked separator in the text.
private static bool IsNativeGlobRange(string content) =>
content.Length == 3 && content[1] == '-' && content[0] != ']'
&& ((char.IsLetter(content[0]) && char.IsLetter(content[2])) || (char.IsAsciiDigit(content[0]) && char.IsAsciiDigit(content[2])))
&& ((char.IsLower(content[0]) && char.IsLower(content[2]))
|| (char.IsUpper(content[0]) && char.IsUpper(content[2]))
|| (char.IsAsciiDigit(content[0]) && char.IsAsciiDigit(content[2])))
&& !SpansPathSeparator(content[0], content[2]);

private static bool SpansPathSeparator(char first, char last) =>
(first <= '/' && '/' <= last) || (first <= '\\' && '\\' <= last);

// A lone reversed letter range, such as "a-Z", matches nothing, but DotNet.Glob would fold it to "a-z"
// under CaseInsensitive. It is written as a reversed digit range, which case folding leaves alone.
private static bool IsReversedLetterRange(string content) =>
content.Length == 3 && content[1] == '-' && char.IsLetter(content[0]) && char.IsLetter(content[2]) && content[0] > content[2];

// Reads the class members with the usual glob rules (a leading "]" and a leading or trailing "-" are
// literal, "x-y" is an inclusive range) and writes them in an order DotNet.Glob reads as a plain list:
// "]" first, so it does not close the class, then "-", so no member pair looks like a range, and "!"
Expand Down
Loading