diff --git a/TextFilter.Test/TextFilterTests.cs b/TextFilter.Test/TextFilterTests.cs index 6c6550b..80f8770 100644 --- a/TextFilter.Test/TextFilterTests.cs +++ b/TextFilter.Test/TextFilterTests.cs @@ -922,7 +922,6 @@ public void ACachedPatternStillMatchesAfterTheCacheHasReset() [DataRow("a[b-")] [DataRow("[!a-")] [DataRow("*[0-")] - [DataRow("[a-]")] [DataRow("-file[0-")] [DataRow("!file[0-")] [DataRow("^file[0-")] @@ -940,6 +939,44 @@ public void GlobWithAHalfTypedRangeDoesNotThrow(string pattern) } } + [TestMethod] + public void GlobClassWithATrailingDashMatchesTheDashLiterally() + { + // A "-" just before "]" is a literal dash, so "file[a-]" is a finished pattern matching "filea" + // and "file-". DotNet.Glob's tokeniser threw on it, and the token was then ignored as if half + // typed: the filter kept every item and the exclusion removed none. + List items = ["filea", "file-", "fileZ", "report"]; + foreach (TextFilterCaseSensitivity caseSensitivity in new[] { TextFilterCaseSensitivity.CaseSensitive, TextFilterCaseSensitivity.CaseInsensitive }) + { + CollectionAssert.AreEqual( + new List { "filea", "file-" }, + TextFilter.Filter(items, "file[a-]", TextFilterType.Glob, TextFilterMatchOptions.ByWordAny, caseSensitivity).ToList()); + CollectionAssert.AreEqual( + new List { "fileZ", "report" }, + TextFilter.Filter(items, "-file[a-]", TextFilterType.Glob, TextFilterMatchOptions.ByWordAny, caseSensitivity).ToList()); + CollectionAssert.AreEqual( + new List { "fileZ" }, + TextFilter.Filter(items, "file[!a-]", TextFilterType.Glob, TextFilterMatchOptions.ByWordAny, caseSensitivity).ToList()); + } + + // A half-typed range is still unparseable and still ignored. + CollectionAssert.AreEqual(items, TextFilter.Filter(items, "file[0-", TextFilterType.Glob).ToList()); + CollectionAssert.AreEqual(items, TextFilter.Filter(items, "-file[0-", TextFilterType.Glob).ToList()); + } + + [TestMethod] + [DataRow("file[a-]", "file[-a]")] + [DataRow("file[!a-]", "file[!-a]")] + [DataRow("[0-9a-]x[b-]", "[-0-9a]x[-b]")] + [DataRow("[]-]", "[-]]")] + [DataRow("[-]", "[-]")] + [DataRow("[a-z]", "[a-z]")] + [DataRow("file[0-", "file[0-")] + [DataRow("a]b", "a]b")] + [DataRow("[a-]x[0-", "[-a]x[0-")] + public void MoveTrailingClassDashesRewritesOnlyClosedClasses(string token, string expected) => + Assert.AreEqual(expected, TextFilter.MoveTrailingClassDashes(token)); + [TestMethod] [DataRow("-readme[a-")] [DataRow("!readme[a-")] diff --git a/TextFilter/TextFilter.cs b/TextFilter/TextFilter.cs index 1c5133d..865848f 100644 --- a/TextFilter/TextFilter.cs +++ b/TextFilter/TextFilter.cs @@ -3,6 +3,7 @@ namespace ktsu.TextFilter; using System.Collections.Concurrent; +using System.Text; using System.Text.RegularExpressions; using DotNet.Globbing; @@ -466,6 +467,56 @@ internal static IReadOnlyList ExpandGlobStarSegments(string maskedToken) return [.. expansions.Select(prefix => prefix + tail).Distinct(StringComparer.Ordinal)]; } + // In glob syntax a "-" just before the closing "]" is a literal dash, so "[a-]" matches "a" or "-". + // DotNet.Glob's tokeniser reads it as a range left open and throws, as it does for the half-typed + // "[a-", so the dash is moved to the front of the class ("[-a]", "[!-a]"), a form it does parse. + // A class with no closing "]" is left alone and stays unparseable. + internal static string MoveTrailingClassDashes(string token) + { + if (!token.Contains(']')) + { + return token; + } + + StringBuilder result = new(token.Length); + int index = 0; + while (index < token.Length) + { + int open = token.IndexOf('[', index); + if (open < 0) + { + break; + } + + int contentStart = open + 1; + string negation = contentStart < token.Length && token[contentStart] == '!' ? "!" : string.Empty; + contentStart += negation.Length; + + // A "]" straight after the opening bracket is a member of the class rather than its end. + int close = contentStart < token.Length ? token.IndexOf(']', contentStart + 1) : -1; + if (close < 0) + { + break; + } + + string content = token[contentStart..close]; + result.Append(token, index, open - index).Append('[').Append(negation); + if (content.Length > 1 && content[^1] == '-') + { + result.Append('-').Append(content, 0, content.Length - 1); + } + else + { + result.Append(content); + } + + result.Append(']'); + index = close + 1; + } + + return result.Append(token, index, token.Length - index).ToString(); + } + // Returns null for a token that cannot be parsed, so each caller can decide what ignoring it means. private static Glob[]? ResolveGlob(string filterToken, TextFilterCaseSensitivity caseSensitivity) { @@ -475,7 +526,7 @@ internal static IReadOnlyList ExpandGlobStarSegments(string maskedToken) { try { - string maskedToken = MaskPathSeparators(filterToken); + string maskedToken = MaskPathSeparators(MoveTrailingClassDashes(filterToken)); globs = [.. ExpandGlobStarSegments(maskedToken).Select(pattern => caseSensitivity is TextFilterCaseSensitivity.CaseInsensitive ? Glob.Parse(pattern, CaseInsensitiveGlobOptions) : Glob.Parse(pattern))];