diff --git a/TextFilter.Test/TextFilterTests.cs b/TextFilter.Test/TextFilterTests.cs index 886b9cd..fe623f3 100644 --- a/TextFilter.Test/TextFilterTests.cs +++ b/TextFilter.Test/TextFilterTests.cs @@ -1002,6 +1002,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]")] @@ -1023,13 +1048,18 @@ 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")] [DataRow("x[^a]", "x[!a]")] [DataRow("[^a-]", "[!-a]")] [DataRow("[^0-9]", "[!0-9]")] [DataRow("[^0-9a-f]", "[!0123456789abcdef]")] [DataRow("[^]a]", "[!]a]")] [DataRow("x[a^]", "x[a^]")] - [DataRow("x[^a", "x[^a")] + [DataRow("x[^a", "x[[]^a")] public void ExpandGlobClassesRewritesOnlyClassesDotNetGlobMisreads(string token, string expected) => Assert.AreEqual(expected, TextFilter.ExpandGlobClasses(token)); diff --git a/TextFilter/TextFilter.cs b/TextFilter/TextFilter.cs index 16701d8..8c57e68 100644 --- a/TextFilter/TextFilter.cs +++ b/TextFilter/TextFilter.cs @@ -333,6 +333,10 @@ internal static Dictionary> ExtractGlobFilt /// rather than throwing: as a plain token it is left out of the optional tokens, so it neither /// matches nor rejects anything; as a 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) { @@ -503,15 +507,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; } @@ -529,7 +537,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];