From 29b809263ee2ee3a0ee2947335e022521f3f8cb0 Mon Sep 17 00:00:00 2001 From: Matthew Edmondson Date: Sat, 31 Oct 2026 17:27:34 +0000 Subject: [PATCH] Mask / and \ to different characters in glob matching [patch] Both separators were masked to U+E000, so after masking a glob could not tell them apart: "1/2" also matched "1\2", and "-*/*" excluded Windows paths along with URLs. "\" now masks to U+E001, and a "**" segment keeps the separator the pattern wrote. Fixes ktsu-dev/TextFilter#132 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017vfJW2nwQnKNnZqSnbuUxe --- TextFilter.Test/TextFilterTests.cs | 47 +++++++++++++++++++++++++----- TextFilter/TextFilter.cs | 42 ++++++++++++++++++++------ 2 files changed, 73 insertions(+), 16 deletions(-) diff --git a/TextFilter.Test/TextFilterTests.cs b/TextFilter.Test/TextFilterTests.cs index 3250a64..c488d56 100644 --- a/TextFilter.Test/TextFilterTests.cs +++ b/TextFilter.Test/TextFilterTests.cs @@ -1040,17 +1040,17 @@ public void GlobClassWithBothLetterCasesMatchesEachCase() } [TestMethod] - [DataRow("[.-0]", "/ . 0", "a -")] - [DataRow("[!.-0]", "a -", "/ . 0")] - [DataRow("[A-z]", "\\ A _ z", "0 {")] - [DataRow("[!A-z]", "0 {", "\\ A _ z")] - [DataRow("[*-0]", "/ \\ * 0", "a")] + [DataRow("[.-0]", "/ . 0", "a - \\")] + [DataRow("[!.-0]", "a - \\", "/ . 0")] + [DataRow("[A-z]", "\\ A _ z", "0 { /")] + [DataRow("[!A-z]", "0 { /", "\\ A _ z")] + [DataRow("[*-0]", "/ * 0", "a \\")] [DataRow("[a-z]", "a q z", "/ \\ A")] public void GlobClassRangeSpanningAPathSeparatorMatchesIt(string pattern, string matching, string notMatching) { // Path separators are masked to a private-use character in the pattern and the text, so a range - // left for DotNet.Glob to match natively never contained a masked "/" or "\" (#157). The two - // separators share one mask (#132), so these rows don't try to tell "/" and "\" apart. + // left for DotNet.Glob to match natively never contained a masked "/" or "\" (#157). Each + // separator has its own mask (#132), so a range holding one of them doesn't match the other. ArgumentNullException.ThrowIfNull(matching); ArgumentNullException.ThrowIfNull(notMatching); foreach (string text in matching.Split(' ')) @@ -1190,6 +1190,39 @@ public void GlobStarSlashMatchesCaseInsensitively() Assert.IsTrue(TextFilter.IsMatch("Docs/README.md", "**/readme.md", TextFilterType.Glob, TextFilterMatchOptions.ByWholeString, TextFilterCaseSensitivity.CaseInsensitive)); } + [TestMethod] + [DataRow(TextFilterCaseSensitivity.CaseSensitive)] + [DataRow(TextFilterCaseSensitivity.CaseInsensitive)] + public void GlobTellsSlashAndBackslashApart(TextFilterCaseSensitivity caseSensitivity) + { + // Both separators used to be masked to the same character, so either one matched the other (#132). + CollectionAssert.AreEqual( + new List { "1/2" }, + TextFilter.Filter(["C:\\temp\\x", "1/2", "1\\2"], "1/2", TextFilterType.Glob, caseSensitivity: caseSensitivity).ToList()); + CollectionAssert.AreEqual( + new List { "1\\2" }, + TextFilter.Filter(["1/2", "1\\2"], "1\\2", TextFilterType.Glob, caseSensitivity: caseSensitivity).ToList()); + Assert.IsFalse(TextFilter.IsMatch("a\\b", "a/b", TextFilterType.Glob, TextFilterMatchOptions.ByWholeString, caseSensitivity)); + Assert.IsFalse(TextFilter.IsMatch("a/b", "a\\b", TextFilterType.Glob, TextFilterMatchOptions.ByWholeString, caseSensitivity)); + + // Excluding URLs keeps Windows paths. + CollectionAssert.AreEqual( + new List { "C:\\temp", "plain" }, + TextFilter.Filter(["C:\\temp", "http://x", "plain"], "-*/*", TextFilterType.Glob, caseSensitivity: caseSensitivity).ToList()); + + // Wildcards still cross either separator (#114). + Assert.IsTrue(TextFilter.IsMatch("a/b\\c", "a*c", TextFilterType.Glob, TextFilterMatchOptions.ByWholeString, caseSensitivity)); + Assert.IsTrue(TextFilter.IsMatch("a\\b", "a?b", TextFilterType.Glob, TextFilterMatchOptions.ByWholeString, caseSensitivity)); + } + + [TestMethod] + [DataRow(@"src\x\a.cs", @"src/**/*.cs")] + [DataRow(@"src/x/a.cs", @"src\**\*.cs")] + public void GlobStarSegmentEndsAtTheSeparatorThePatternWrote(string text, string filter) + { + Assert.IsFalse(TextFilter.IsMatch(text, filter, TextFilterType.Glob, TextFilterMatchOptions.ByWholeString)); + } + [TestMethod] public void GlobLiteralSlashInPatternStillMatchesSlashInText() { diff --git a/TextFilter/TextFilter.cs b/TextFilter/TextFilter.cs index 927c00c..790ddf1 100644 --- a/TextFilter/TextFilter.cs +++ b/TextFilter/TextFilter.cs @@ -433,12 +433,15 @@ public static bool AllTokensMatchGlobFilter(string filterToken, HashSet } // DotNet.Glob is a file-path glob, so its * and ? stop at / and \. TextFilter filters arbitrary - // text, where a slash is an ordinary character, so both separators are swapped for a private-use - // character in the pattern and in the text before DotNet.Glob sees either. - private const char MaskedPathSeparator = '\uE000'; + // text, where a slash is an ordinary character, so both separators are swapped for private-use + // characters in the pattern and in the text before DotNet.Glob sees either. Each separator gets + // its own character: sharing one would make them interchangeable, so "1/2" would also match + // "1\2", and excluding URLs with "-*/*" would hide every Windows path too. + private const char MaskedSlash = '\uE000'; + private const char MaskedBackslash = '\uE001'; private static string MaskPathSeparators(string value) => - value.Replace('/', MaskedPathSeparator).Replace('\\', MaskedPathSeparator); + value.Replace('/', MaskedSlash).Replace('\\', MaskedBackslash); private static bool IsGlobMatch(Glob[] globs, string textToken) { @@ -449,8 +452,28 @@ private static bool IsGlobMatch(Glob[] globs, string textToken) // Masking also hides the separators DotNet.Glob needs to recognise "**/" as "zero or more path // segments", leaving "**" followed by a literal character. Since * already crosses the masked // separator, each "**/" is expanded here instead: dropped, for the zero-segment case, or kept as - // "*/", for "anything ending at a separator". A token matches if any expansion does. - private const string MaskedGlobStarSegment = "**\uE000"; + // "*/", for "anything ending at a separator". A token matches if any expansion does. The kept form + // ends at the same separator the pattern wrote, so "a/**/b" still needs a "/" before the "b". + private const string MaskedGlobStar = "**"; + + private static bool IsMaskedPathSeparator(char value) => value is MaskedSlash or MaskedBackslash; + + private static int IndexOfMaskedGlobStarSegment(string maskedToken, int start) + { + int index = start; + while ((index = maskedToken.IndexOf(MaskedGlobStar, index, StringComparison.Ordinal)) >= 0) + { + int separator = index + MaskedGlobStar.Length; + if (separator < maskedToken.Length && IsMaskedPathSeparator(maskedToken[separator])) + { + return index; + } + + index++; + } + + return -1; + } // Each "**/" doubles the expansions, so only this many are expanded both ways; any beyond it keep // only the "*/" form and so need at least one segment. @@ -463,15 +486,16 @@ internal static IReadOnlyList ExpandGlobStarSegments(string maskedToken) int expanded = 0; int index; - while ((index = maskedToken.IndexOf(MaskedGlobStarSegment, start, StringComparison.Ordinal)) >= 0) + while ((index = IndexOfMaskedGlobStarSegment(maskedToken, start)) >= 0) { string literal = maskedToken[start..index]; + char separator = maskedToken[index + MaskedGlobStar.Length]; bool expandBothWays = expanded < MaxExpandedGlobStarSegments; List next = new(expansions.Count * 2); foreach (string prefix in expansions) { - next.Add(prefix + literal + "*" + MaskedPathSeparator); + next.Add(prefix + literal + "*" + separator); if (expandBothWays) { next.Add(prefix + literal); @@ -480,7 +504,7 @@ internal static IReadOnlyList ExpandGlobStarSegments(string maskedToken) expansions = next; expanded++; - start = index + MaskedGlobStarSegment.Length; + start = index + MaskedGlobStar.Length + 1; } string tail = maskedToken[start..];