Skip to content

Commit 5b2b530

Browse files
Merge pull request #156 from ktsu-dev/fix/152-153-glob-class-ranges
2 parents 89c3765 + ed5df43 commit 5b2b530

2 files changed

Lines changed: 175 additions & 25 deletions

File tree

‎TextFilter.Test/TextFilterTests.cs‎

Lines changed: 64 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -967,15 +967,75 @@ public void GlobClassWithATrailingDashMatchesTheDashLiterally()
967967
[TestMethod]
968968
[DataRow("file[a-]", "file[-a]")]
969969
[DataRow("file[!a-]", "file[!-a]")]
970-
[DataRow("[0-9a-]x[b-]", "[-0-9a]x[-b]")]
971-
[DataRow("[]-]", "[-]]")]
970+
[DataRow("[0-9a-]x[b-]", "[-0123456789a]x[-b]")]
971+
[DataRow("[0-9a-f]", "[0123456789abcdef]")]
972+
[DataRow("[!0-9a-f]", "[!0123456789abcdef]")]
973+
[DataRow("[a-c_]", "[_abc]")]
974+
[DataRow("[]-]", "[]-]")]
975+
[DataRow("[]a-]", "[]-a]")]
976+
[DataRow("[!]a-]", "[!]-a]")]
972977
[DataRow("[-]", "[-]")]
973978
[DataRow("[a-z]", "[a-z]")]
979+
[DataRow("[!0-9]", "[!0-9]")]
980+
[DataRow("[abc]", "[abc]")]
981+
[DataRow("[!-#]", "[!-#]")]
982+
[DataRow("[ !-#]", "[ \"#!]")]
974983
[DataRow("file[0-", "file[0-")]
975984
[DataRow("a]b", "a]b")]
976985
[DataRow("[a-]x[0-", "[-a]x[0-")]
977-
public void MoveTrailingClassDashesRewritesOnlyClosedClasses(string token, string expected) =>
978-
Assert.AreEqual(expected, TextFilter.MoveTrailingClassDashes(token));
986+
public void ExpandGlobClassesRewritesOnlyClassesDotNetGlobMisreads(string token, string expected) =>
987+
Assert.AreEqual(expected, TextFilter.ExpandGlobClasses(token));
988+
989+
[TestMethod]
990+
[DataRow("[#-!]")]
991+
[DataRow("[\u0001-\uffff_]")]
992+
public void ExpandGlobClassesRejectsClassesThatCannotBeListed(string token) =>
993+
Assert.IsNull(TextFilter.ExpandGlobClasses(token));
994+
995+
[TestMethod]
996+
[DataRow("[0-9a-f]", "0 5 9 a c f", "g G - x _")]
997+
[DataRow("[!0-9a-f]", "g - x _", "0 5 a c f")]
998+
[DataRow("[a-z_]", "a q z _", "- 5 #")]
999+
[DataRow("[-0-9]", "- 0 5 9", "a _")]
1000+
[DataRow("[ab-d]", "a b c d", "- e")]
1001+
[DataRow("[a-z-]", "q a z -", "5 _")]
1002+
[DataRow("file[0-9a-]", "file5 filea file-", "fileb file_")]
1003+
[DataRow("[]a-]", "a - ]", "b [")]
1004+
[DataRow("[!]a-]", "b [", "a - ]")]
1005+
[DataRow("[!-#]", "a $ ! \"", "- #")]
1006+
public void GlobClassMatchesEveryMemberOfEveryRange(string pattern, string matching, string notMatching)
1007+
{
1008+
// DotNet.Glob reads a class as one range or one plain list, never both, so a class mixing a range
1009+
// with other members silently dropped some of them (#152), and moving a trailing dash to the front
1010+
// pushed classes it read correctly into list mode (#153).
1011+
ArgumentNullException.ThrowIfNull(matching);
1012+
ArgumentNullException.ThrowIfNull(notMatching);
1013+
foreach (TextFilterCaseSensitivity caseSensitivity in new[] { TextFilterCaseSensitivity.CaseSensitive, TextFilterCaseSensitivity.CaseInsensitive })
1014+
{
1015+
foreach (string text in matching.Split(' '))
1016+
{
1017+
Assert.IsTrue(TextFilter.IsMatch(text, pattern, TextFilterType.Glob, TextFilterMatchOptions.ByWholeString, caseSensitivity), $"{pattern} should match {text} ({caseSensitivity})");
1018+
}
1019+
1020+
foreach (string text in notMatching.Split(' '))
1021+
{
1022+
Assert.IsFalse(TextFilter.IsMatch(text, pattern, TextFilterType.Glob, TextFilterMatchOptions.ByWholeString, caseSensitivity), $"{pattern} should not match {text} ({caseSensitivity})");
1023+
}
1024+
}
1025+
}
1026+
1027+
[TestMethod]
1028+
public void GlobClassWithBothLetterCasesMatchesEachCase()
1029+
{
1030+
Assert.IsTrue(TextFilter.IsMatch("Q", "[a-zA-Z]", TextFilterType.Glob, TextFilterMatchOptions.ByWholeString, TextFilterCaseSensitivity.CaseSensitive));
1031+
Assert.IsTrue(TextFilter.IsMatch("q", "[a-zA-Z]", TextFilterType.Glob, TextFilterMatchOptions.ByWholeString, TextFilterCaseSensitivity.CaseSensitive));
1032+
Assert.IsFalse(TextFilter.IsMatch("_", "[a-zA-Z]", TextFilterType.Glob, TextFilterMatchOptions.ByWholeString, TextFilterCaseSensitivity.CaseSensitive));
1033+
1034+
// Case folding still applies to the expanded list, in both directions.
1035+
Assert.IsTrue(TextFilter.IsMatch("C", "[0-9a-f]", TextFilterType.Glob, TextFilterMatchOptions.ByWholeString, TextFilterCaseSensitivity.CaseInsensitive));
1036+
Assert.IsFalse(TextFilter.IsMatch("C", "[0-9a-f]", TextFilterType.Glob, TextFilterMatchOptions.ByWholeString, TextFilterCaseSensitivity.CaseSensitive));
1037+
Assert.IsFalse(TextFilter.IsMatch("C", "[!0-9a-f]", TextFilterType.Glob, TextFilterMatchOptions.ByWholeString, TextFilterCaseSensitivity.CaseInsensitive));
1038+
}
9791039

9801040
[TestMethod]
9811041
[DataRow("-readme[a-")]

‎TextFilter/TextFilter.cs‎

Lines changed: 111 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -475,11 +475,21 @@ internal static IReadOnlyList<string> ExpandGlobStarSegments(string maskedToken)
475475
return [.. expansions.Select(prefix => prefix + tail).Distinct(StringComparer.Ordinal)];
476476
}
477477

478-
// In glob syntax a "-" just before the closing "]" is a literal dash, so "[a-]" matches "a" or "-".
479-
// DotNet.Glob's tokeniser reads it as a range left open and throws, as it does for the half-typed
480-
// "[a-", so the dash is moved to the front of the class ("[-a]", "[!-a]"), a form it does parse.
481-
// A class with no closing "]" is left alone and stays unparseable.
482-
internal static string MoveTrailingClassDashes(string token)
478+
// DotNet.Glob reads a bracket class as exactly one token: a single range when it starts "x-y" with
479+
// two letters or two digits, otherwise a plain character list in which "-" is a member and no ranges
480+
// are read. It never combines the two, so "[0-9a-f]" stops at the 0-9 range and misses "c", while
481+
// "[-0-9]" and "[ab-d]" become lists that miss "5" and "c". A trailing dash ("[a-]") makes its
482+
// tokeniser throw outright. So every closed class it would misread is rewritten here into the
483+
// explicit character list it means, which it does read correctly, negation and case folding
484+
// included: "[0-9a-f]" becomes "[0123456789abcdef]" and "[a-z-]" becomes "[-abc...z]". A lone
485+
// letter or digit range is left for DotNet.Glob to match natively, since it reads that form
486+
// correctly and it may span far more characters than are worth listing. A class with no closing
487+
// "]" is left alone and stays unparseable.
488+
// Returns null for a class that cannot be written as a list: one whose ranges span more than
489+
// MaxGlobClassMembers characters, or that has no members at all because every range is reversed.
490+
private const int MaxGlobClassMembers = 4096;
491+
492+
internal static string? ExpandGlobClasses(string token)
483493
{
484494
if (!token.Contains(']'))
485495
{
@@ -496,35 +506,115 @@ internal static string MoveTrailingClassDashes(string token)
496506
break;
497507
}
498508

499-
int contentStart = open + 1;
500-
string negation = contentStart < token.Length && token[contentStart] == '!' ? "!" : string.Empty;
501-
contentStart += negation.Length;
502-
503-
// A "]" straight after the opening bracket is a member of the class rather than its end.
504-
int close = contentStart < token.Length ? token.IndexOf(']', contentStart + 1) : -1;
509+
int close = FindGlobClassEnd(token, open, out bool negated, out int contentStart);
505510
if (close < 0)
506511
{
507512
break;
508513
}
509514

510515
string content = token[contentStart..close];
511-
result.Append(token, index, open - index).Append('[').Append(negation);
512-
if (content.Length > 1 && content[^1] == '-')
513-
{
514-
result.Append('-').Append(content, 0, content.Length - 1);
515-
}
516-
else
516+
string? rewritten = !content.Contains('-') || IsNativeGlobRange(content)
517+
? token[open..(close + 1)]
518+
: ToGlobCharacterList(content, negated);
519+
if (rewritten is null)
517520
{
518-
result.Append(content);
521+
return null;
519522
}
520523

521-
result.Append(']');
524+
result.Append(token, index, open - index).Append(rewritten);
522525
index = close + 1;
523526
}
524527

525528
return result.Append(token, index, token.Length - index).ToString();
526529
}
527530

531+
// Returns the index of the "]" closing the class opened at "open", or -1 if it is never closed.
532+
private static int FindGlobClassEnd(string token, int open, out bool negated, out int contentStart)
533+
{
534+
contentStart = open + 1;
535+
negated = contentStart < token.Length && token[contentStart] == '!';
536+
if (negated)
537+
{
538+
contentStart++;
539+
}
540+
541+
// A "]" straight after the opening bracket is a member of the class rather than its end.
542+
return contentStart < token.Length ? token.IndexOf(']', contentStart + 1) : -1;
543+
}
544+
545+
private static bool IsNativeGlobRange(string content) =>
546+
content.Length == 3 && content[1] == '-' && content[0] != ']'
547+
&& ((char.IsLetter(content[0]) && char.IsLetter(content[2])) || (char.IsAsciiDigit(content[0]) && char.IsAsciiDigit(content[2])));
548+
549+
// Reads the class members with the usual glob rules (a leading "]" and a leading or trailing "-" are
550+
// literal, "x-y" is an inclusive range) and writes them in an order DotNet.Glob reads as a plain list:
551+
// "]" first, so it does not close the class, then "-", so no member pair looks like a range, and "!"
552+
// never first, so it is not read as negation.
553+
private static string? ToGlobCharacterList(string content, bool negated)
554+
{
555+
SortedSet<char> members = [];
556+
int i = 0;
557+
while (i < content.Length)
558+
{
559+
char first = content[i];
560+
bool isRange = i + 2 < content.Length && content[i + 1] == '-' && !(i == 0 && first == ']');
561+
if (!isRange)
562+
{
563+
members.Add(first);
564+
i++;
565+
continue;
566+
}
567+
568+
char last = content[i + 2];
569+
if (last - first + members.Count >= MaxGlobClassMembers)
570+
{
571+
return null;
572+
}
573+
574+
for (int member = first; member <= last; member++)
575+
{
576+
members.Add((char)member);
577+
}
578+
579+
i += 3;
580+
}
581+
582+
if (members.Count == 0)
583+
{
584+
return null;
585+
}
586+
587+
StringBuilder list = new(members.Count + 3);
588+
list.Append('[');
589+
if (negated)
590+
{
591+
list.Append('!');
592+
}
593+
594+
if (members.Remove(']'))
595+
{
596+
list.Append(']');
597+
}
598+
599+
if (members.Remove('-'))
600+
{
601+
list.Append('-');
602+
}
603+
604+
bool hasBang = members.Remove('!');
605+
foreach (char member in members)
606+
{
607+
list.Append(member);
608+
}
609+
610+
if (hasBang)
611+
{
612+
list.Append('!');
613+
}
614+
615+
return list.Append(']').ToString();
616+
}
617+
528618
// Returns null for a token that cannot be parsed, so each caller can decide what ignoring it means.
529619
private static Glob[]? ResolveGlob(string filterToken, TextFilterCaseSensitivity caseSensitivity)
530620
{
@@ -534,8 +624,8 @@ internal static string MoveTrailingClassDashes(string token)
534624
{
535625
try
536626
{
537-
string maskedToken = MaskPathSeparators(MoveTrailingClassDashes(filterToken));
538-
globs = [.. ExpandGlobStarSegments(maskedToken).Select(pattern => caseSensitivity is TextFilterCaseSensitivity.CaseInsensitive
627+
string? expandedToken = ExpandGlobClasses(filterToken);
628+
globs = expandedToken is null ? null : [.. ExpandGlobStarSegments(MaskPathSeparators(expandedToken)).Select(pattern => caseSensitivity is TextFilterCaseSensitivity.CaseInsensitive
539629
? Glob.Parse(pattern, CaseInsensitiveGlobOptions)
540630
: Glob.Parse(pattern))];
541631
}

0 commit comments

Comments
 (0)