From f977df93f7f4cd47b7edf2730f72755108d584c9 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 23:26:03 +0000 Subject: [PATCH 1/2] Match a mixed-case glob range the same under both case sensitivities [patch] DotNet.Glob lowercases a range's endpoints under CaseInsensitive, so a lone native range whose endpoints differ in case changed meaning: "A-z" became "a-z" and stopped matching "_" and "[", "Z-a" became the empty "z-a", and the reversed "a-Z" became "a-z". Only same-case letter and digit ranges are now left to DotNet.Glob; a mixed-case range is listed explicitly, and a reversed letter range is written as the empty "[9-0]", which matches nothing under either sensitivity, as it already did under CaseSensitive. Fixes ktsu-dev/TextFilter#167 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017vfJW6QSBbC4yjv9GxYCN1 --- TextFilter.Test/TextFilterTests.cs | 47 ++++++++++++++++++++++++++++++ TextFilter/TextFilter.cs | 16 ++++++++-- 2 files changed, 61 insertions(+), 2 deletions(-) diff --git a/TextFilter.Test/TextFilterTests.cs b/TextFilter.Test/TextFilterTests.cs index b24bfe7..727724d 100644 --- a/TextFilter.Test/TextFilterTests.cs +++ b/TextFilter.Test/TextFilterTests.cs @@ -939,6 +939,53 @@ 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]", "[!A-z]", "[!Z-a]", "[!a-z]", "[!A-Z]"]; + for (char c = ' '; c <= '~'; c++) + { + string text = c.ToString(); + foreach (string pattern in patterns) + { + if (TextFilter.IsMatch(text, pattern, TextFilterType.Glob, TextFilterMatchOptions.ByWholeString, TextFilterCaseSensitivity.CaseSensitive) + && !pattern.StartsWith("[!", StringComparison.Ordinal)) + { + 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 441c504..0ab2f72 100644 --- a/TextFilter/TextFilter.cs +++ b/TextFilter/TextFilter.cs @@ -521,7 +521,9 @@ internal static IReadOnlyList ExpandGlobStarSegments(string maskedToken) string content = token[contentStart..close]; string? rewritten = !content.Contains('-') || IsNativeGlobRange(content) ? token[open..(close + 1)] - : ToGlobCharacterList(content, negated); + : IsReversedLetterRange(content) + ? negated ? "[!9-0]" : "[9-0]" + : ToGlobCharacterList(content, negated); if (rewritten is null) { return null; @@ -548,9 +550,19 @@ private static int FindGlobClassEnd(string token, int open, out bool negated, ou return contentStart < token.Length ? token.IndexOf(']', contentStart + 1) : -1; } + // 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. 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]))); + + // 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: From f8898565a531b2fcd72f28ed085d56c779efed2c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 23:35:54 +0000 Subject: [PATCH 2/2] Move the glob class rewrite choice into RewriteGlobClass [patch] Replaces the nested ternary in ExpandGlobClasses with a helper, which SonarCloud flagged (S3358, and S3776 cognitive complexity). The case-insensitivity property test now filters its patterns with Where and drops the negated entries it never asserted on. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017vfJW6QSBbC4yjv9GxYCN1 --- TextFilter.Test/TextFilterTests.cs | 10 +++------- TextFilter/TextFilter.cs | 22 +++++++++++++++++----- 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/TextFilter.Test/TextFilterTests.cs b/TextFilter.Test/TextFilterTests.cs index 727724d..63aa386 100644 --- a/TextFilter.Test/TextFilterTests.cs +++ b/TextFilter.Test/TextFilterTests.cs @@ -971,17 +971,13 @@ public void GlobRangeWithSameCaseEndpointsStillFoldsUnderCaseInsensitive() [TestMethod] public void CaseInsensitiveGlobClassMatchesEverythingCaseSensitiveDoes() { - string[] patterns = ["[A-z]", "[Z-a]", "[a-Z]", "[a-z]", "[A-Z]", "[0-9]", "[!A-z]", "[!Z-a]", "[!a-z]", "[!A-Z]"]; + 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) + foreach (string pattern in patterns.Where(p => TextFilter.IsMatch(text, p, TextFilterType.Glob, TextFilterMatchOptions.ByWholeString, TextFilterCaseSensitivity.CaseSensitive))) { - if (TextFilter.IsMatch(text, pattern, TextFilterType.Glob, TextFilterMatchOptions.ByWholeString, TextFilterCaseSensitivity.CaseSensitive) - && !pattern.StartsWith("[!", StringComparison.Ordinal)) - { - Assert.IsTrue(TextFilter.IsMatch(text, pattern, TextFilterType.Glob, TextFilterMatchOptions.ByWholeString, TextFilterCaseSensitivity.CaseInsensitive), $"{pattern} on '{text}'"); - } + Assert.IsTrue(TextFilter.IsMatch(text, pattern, TextFilterType.Glob, TextFilterMatchOptions.ByWholeString, TextFilterCaseSensitivity.CaseInsensitive), $"{pattern} on '{text}'"); } } } diff --git a/TextFilter/TextFilter.cs b/TextFilter/TextFilter.cs index 0ab2f72..6d897fd 100644 --- a/TextFilter/TextFilter.cs +++ b/TextFilter/TextFilter.cs @@ -519,11 +519,7 @@ internal static IReadOnlyList ExpandGlobStarSegments(string maskedToken) } string content = token[contentStart..close]; - string? rewritten = !content.Contains('-') || IsNativeGlobRange(content) - ? token[open..(close + 1)] - : IsReversedLetterRange(content) - ? negated ? "[!9-0]" : "[9-0]" - : ToGlobCharacterList(content, negated); + string? rewritten = RewriteGlobClass(token[open..(close + 1)], content, negated); if (rewritten is null) { return null; @@ -536,6 +532,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 globClass, string content, bool negated) + { + if (!content.Contains('-') || IsNativeGlobRange(content)) + { + return globClass; + } + + 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. private static int FindGlobClassEnd(string token, int open, out bool negated, out int contentStart) {