diff --git a/TextFilter.Test/TextFilterTests.cs b/TextFilter.Test/TextFilterTests.cs index 886b9cd..9e526f5 100644 --- a/TextFilter.Test/TextFilterTests.cs +++ b/TextFilter.Test/TextFilterTests.cs @@ -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() { diff --git a/TextFilter/TextFilter.cs b/TextFilter/TextFilter.cs index 16701d8..54b178f 100644 --- a/TextFilter/TextFilter.cs +++ b/TextFilter/TextFilter.cs @@ -533,10 +533,7 @@ internal static IReadOnlyList 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; @@ -558,6 +555,22 @@ internal static IReadOnlyList 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 "!". @@ -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 "!"