From 7b795f0aa72177c05043e47127a47b154184009d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 13:32:41 +0000 Subject: [PATCH] Match an unclosed glob bracket literally [patch] DotNet.Glob reads a "[" with no closing "]" as a class running to the end of the token, so "a[b" matched "ab" but not "a[b", and "-log[1" hid "log1" while keeping "log[1". ExpandGlobClasses now writes such a bracket as the literal "[[]", as fnmatch and bash treat it. A class left open after a dash ("file[0-") is still a range being typed, so it stays unparseable and is ignored as DoesMatchGlob documents. Fixes #159 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017vfJW5Cza8WxR4s8ByZ2GX --- TextFilter.Test/TextFilterTests.cs | 30 ++++++++++++++++++++++++++++++ TextFilter/TextFilter.cs | 23 +++++++++++++++++++---- 2 files changed, 49 insertions(+), 4 deletions(-) diff --git a/TextFilter.Test/TextFilterTests.cs b/TextFilter.Test/TextFilterTests.cs index b24bfe7..0af61cd 100644 --- a/TextFilter.Test/TextFilterTests.cs +++ b/TextFilter.Test/TextFilterTests.cs @@ -964,6 +964,31 @@ public void GlobClassWithATrailingDashMatchesTheDashLiterally() CollectionAssert.AreEqual(items, TextFilter.Filter(items, "-file[0-", TextFilterType.Glob).ToList()); } + [TestMethod] + public void GlobWithAnUnclosedBracketMatchesTheBracketLiterally() + { + // A "[" with no closing "]" is a literal, as in fnmatch and bash. DotNet.Glob read it as a class + // running to the end of the token, so "a[b" matched "ab" but not "a[b", and "-log[1" hid "log1". + List items = ["log[", "log[1", "log1", "logb", "a[b", "ab", "readme.md"]; + foreach (TextFilterCaseSensitivity caseSensitivity in new[] { TextFilterCaseSensitivity.CaseSensitive, TextFilterCaseSensitivity.CaseInsensitive }) + { + Assert.IsTrue(TextFilter.IsMatch("a[b", "a[b", TextFilterType.Glob, TextFilterMatchOptions.ByWordAny, caseSensitivity)); + Assert.IsFalse(TextFilter.IsMatch("ab", "a[b", TextFilterType.Glob, TextFilterMatchOptions.ByWordAny, caseSensitivity)); + CollectionAssert.AreEqual( + new List { "log[" }, + TextFilter.Filter(items, "log[", TextFilterType.Glob, TextFilterMatchOptions.ByWordAny, caseSensitivity).ToList()); + CollectionAssert.AreEqual( + new List { "log[1" }, + TextFilter.Filter(items, "log[1", TextFilterType.Glob, TextFilterMatchOptions.ByWordAny, caseSensitivity).ToList()); + CollectionAssert.AreEqual( + new List { "log[", "log1", "logb", "a[b", "ab", "readme.md" }, + TextFilter.Filter(items, "-log[1", TextFilterType.Glob, TextFilterMatchOptions.ByWordAny, caseSensitivity).ToList()); + CollectionAssert.AreEqual( + new List { "a[b" }, + TextFilter.Filter(items, "a[[]b", TextFilterType.Glob, TextFilterMatchOptions.ByWordAny, caseSensitivity).ToList()); + } + } + [TestMethod] [DataRow("file[a-]", "file[-a]")] [DataRow("file[!a-]", "file[!-a]")] @@ -983,6 +1008,11 @@ public void GlobClassWithATrailingDashMatchesTheDashLiterally() [DataRow("file[0-", "file[0-")] [DataRow("a]b", "a]b")] [DataRow("[a-]x[0-", "[-a]x[0-")] + [DataRow("a[b", "a[[]b")] + [DataRow("log[", "log[[]")] + [DataRow("a[b[c", "a[[]b[[]c")] + [DataRow("[ab]x[c", "[ab]x[[]c")] + [DataRow("a[[]b", "a[[]b")] public void ExpandGlobClassesRewritesOnlyClassesDotNetGlobMisreads(string token, string expected) => Assert.AreEqual(expected, TextFilter.ExpandGlobClasses(token)); diff --git a/TextFilter/TextFilter.cs b/TextFilter/TextFilter.cs index 441c504..02b08df 100644 --- a/TextFilter/TextFilter.cs +++ b/TextFilter/TextFilter.cs @@ -329,6 +329,10 @@ internal static Dictionary> ExtractGlobFilt /// A token that cannot be parsed as a glob, such as the half-typed range file[0-, is ignored /// rather than throwing: as a plain or required token it matches everything, and as an excluded /// token it excludes nothing. + /// + /// Any other [ with no closing ] is a literal bracket, so a[b matches the text + /// a[b and not ab, the same as the escaped form a[[]b. + /// /// public static bool DoesMatchGlob(string text, string filter, TextFilterMatchOptions textFilterMatchOptions, TextFilterCaseSensitivity caseSensitivity = TextFilterCaseSensitivity.CaseSensitive) { @@ -489,15 +493,19 @@ internal static IReadOnlyList ExpandGlobStarSegments(string maskedToken) // explicit character list it means, which it does read correctly, negation and case folding // included: "[0-9a-f]" becomes "[0123456789abcdef]" and "[a-z-]" becomes "[-abc...z]". A lone // letter or digit range is left for DotNet.Glob to match natively, since it reads that form - // correctly and it may span far more characters than are worth listing. A class with no closing - // "]" is left alone and stays unparseable. + // correctly and it may span far more characters than are worth listing. + // A "[" with no closing "]" is a literal, as fnmatch and bash treat it, so it is written as "[[]". + // DotNet.Glob would otherwise read it as a class running to the end of the token, and "a[b" would + // match "ab" but not "a[b". The exception is a class left open after a dash ("file[0-"), a range + // still being typed: it is left alone and stays unparseable, so it is ignored as documented on + // DoesMatchGlob. // Returns null for a class that cannot be written as a list: one whose ranges span more than // MaxGlobClassMembers characters, or that has no members at all because every range is reversed. private const int MaxGlobClassMembers = 4096; internal static string? ExpandGlobClasses(string token) { - if (!token.Contains(']')) + if (!token.Contains('[')) { return token; } @@ -515,7 +523,14 @@ internal static IReadOnlyList ExpandGlobStarSegments(string maskedToken) int close = FindGlobClassEnd(token, open, out bool negated, out int contentStart); if (close < 0) { - break; + if (token.EndsWith('-')) + { + break; + } + + result.Append(token, index, open - index).Append("[[]"); + index = open + 1; + continue; } string content = token[contentStart..close];