diff --git a/TextFilter.Test/TextFilterTests.cs b/TextFilter.Test/TextFilterTests.cs index 886b9cd..354536b 100644 --- a/TextFilter.Test/TextFilterTests.cs +++ b/TextFilter.Test/TextFilterTests.cs @@ -1115,17 +1115,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(' ')) @@ -1319,6 +1319,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] [DataRow("**/")] [DataRow("**/**/")] diff --git a/TextFilter/TextFilter.cs b/TextFilter/TextFilter.cs index 16701d8..62bb028 100644 --- a/TextFilter/TextFilter.cs +++ b/TextFilter/TextFilter.cs @@ -436,12 +436,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) { @@ -452,9 +455,29 @@ 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. Only a "**/" - // that starts the token or follows a separator is dropped, since one joined to a literal is a "*". - 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". Only + // a "**/" that starts the token or follows a separator is dropped, since one joined to a literal is a "*". + 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. @@ -467,20 +490,21 @@ 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) { string before = prefix + literal; - next.Add(before + "*" + MaskedPathSeparator); + next.Add(before + "*" + separator); // Only a "**" that is a whole segment can match zero segments; joined to a literal, as // in "foo**/bar", it is just "*", and dropping it would splice "foo" onto "bar". - if (expandBothWays && (before.Length == 0 || before[^1] == MaskedPathSeparator)) + if (expandBothWays && (before.Length == 0 || IsMaskedPathSeparator(before[^1]))) { next.Add(before); } @@ -488,7 +512,7 @@ internal static IReadOnlyList ExpandGlobStarSegments(string maskedToken) expansions = next; expanded++; - start = index + MaskedGlobStarSegment.Length; + start = index + MaskedGlobStar.Length + 1; } string tail = maskedToken[start..];