From 1549673553b9de16f81f90bac31ce8e9f251fdb3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 09:31:48 +0000 Subject: [PATCH 1/2] Match the empty remainder with a trailing whole-segment ** [patch] ExpandGlobStarSegments only rewrote a "**" followed by a separator, so a trailing "**" (as in "src/**") reached DotNet.Glob as "**" joined to the masked separator, which needs at least one more character. "src/**" then missed "src/" though "src/*" matched it, and "-build/**" left "build/" visible. A trailing "**" that starts the token or follows a separator is now written as "*", which already crosses the masked separator and also matches nothing. Fixes ktsu-dev/TextFilter#191 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017vfJW3JF2xHwwm6qrw3Bh7 --- TextFilter.Test/TextFilterTests.cs | 38 ++++++++++++++++++++++++++++++ TextFilter/TextFilter.cs | 18 +++++++++++++- 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/TextFilter.Test/TextFilterTests.cs b/TextFilter.Test/TextFilterTests.cs index 886b9cd..697463f 100644 --- a/TextFilter.Test/TextFilterTests.cs +++ b/TextFilter.Test/TextFilterTests.cs @@ -1313,6 +1313,44 @@ public void GlobStarSlashFiltersPathItems() Assert.AreEqual("src/x/a.cs", result[1]); } + [TestMethod] + [DataRow("src/", "src/**")] + [DataRow("src/a.cs", "src/**")] + [DataRow("src/lib/b.cs", "src/**")] + [DataRow("a/b/", "a/**/b/**")] + [DataRow("/", "/**")] + [DataRow("-b/", "*/**")] + [DataRow("-/", "?/**")] + [DataRow("b-/", "b-/**")] + [DataRow(@"src\", @"src\**")] + public void TrailingGlobStarSegmentMatchesTheEmptyRemainder(string text, string filter) + { + Assert.IsTrue(TextFilter.IsMatch(text, filter, TextFilterType.Glob, TextFilterMatchOptions.ByWholeString)); + Assert.IsTrue(TextFilter.IsMatch(text, filter)); + } + + [TestMethod] + [DataRow("src", "src/**")] + [DataRow("docs/a.cs", "src/**")] + [DataRow("foo", "foo**")] + public void TrailingGlobStarSegmentStillNeedsItsDirectory(string text, string filter) + { + Assert.IsFalse(TextFilter.IsMatch(text, filter, TextFilterType.Glob, TextFilterMatchOptions.ByWholeString)); + } + + [TestMethod] + public void TrailingGlobStarSegmentFiltersLikeASingleStar() + { + string[] items = ["src/", "src/a.cs", "src/lib/b.cs", "build/", "build/out.dll", "readme.md"]; + + List included = ["src/", "src/a.cs", "src/lib/b.cs"]; + List excluded = ["src/", "src/a.cs", "src/lib/b.cs", "readme.md"]; + + CollectionAssert.AreEqual(included, TextFilter.Filter(items, "src/**").ToList()); + CollectionAssert.AreEqual(TextFilter.Filter(items, "src/*").ToList(), TextFilter.Filter(items, "src/**").ToList()); + CollectionAssert.AreEqual(excluded, TextFilter.Filter(items, "-build/**").ToList()); + } + [TestMethod] public void GlobStarSlashMatchesCaseInsensitively() { diff --git a/TextFilter/TextFilter.cs b/TextFilter/TextFilter.cs index 16701d8..83140e1 100644 --- a/TextFilter/TextFilter.cs +++ b/TextFilter/TextFilter.cs @@ -492,7 +492,23 @@ internal static IReadOnlyList ExpandGlobStarSegments(string maskedToken) } string tail = maskedToken[start..]; - return [.. expansions.Select(prefix => prefix + tail).Distinct(StringComparer.Ordinal)]; + return [.. expansions.Select(prefix => ExpandTrailingGlobStar(prefix + tail)).Distinct(StringComparer.Ordinal)]; + } + + // A trailing whole-segment "**", as in "src/**", should match the empty remainder too, so that + // "dir/**" matches at least what "dir/*" does, "dir/" included. With the separator masked, + // DotNet.Glob reads it as "**" joined to a literal, which needs at least one more character. Since + // * already crosses the masked separator, a plain "*" means the same and also matches nothing. + private static string ExpandTrailingGlobStar(string expansion) + { + if (!expansion.EndsWith("**", StringComparison.Ordinal)) + { + return expansion; + } + + int globStarStart = expansion.Length - 2; + bool isWholeSegment = globStarStart == 0 || expansion[globStarStart - 1] == MaskedPathSeparator; + return isWholeSegment ? expansion[..globStarStart] + "*" : expansion; } // DotNet.Glob reads a bracket class as exactly one token: a single range when it starts "x-y" with From 0f17af114423aaf633b7d36e6d6d6e9319344c18 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 04:25:55 +0000 Subject: [PATCH 2/2] Hold only the visible overloads to the README's documented defaults [patch] The README API test required every overload's same-named parameter to carry the documented default. The binary-compatibility overloads from #166 have no defaults by design, so main has been red since #178. They are marked [EditorBrowsable(Never)] and are not what the README documents, so the default check now skips them. A wrong default on a visible overload still fails. Fixes #183 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017vfJW6FzBtSrY5LKUuDYce --- TextFilter.Test/ReadmeTests.cs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/TextFilter.Test/ReadmeTests.cs b/TextFilter.Test/ReadmeTests.cs index 7ced909..17fc9c2 100644 --- a/TextFilter.Test/ReadmeTests.cs +++ b/TextFilter.Test/ReadmeTests.cs @@ -4,6 +4,7 @@ namespace TextFilter.Test; using System; using System.Collections.Generic; +using System.ComponentModel; using System.IO; using System.Linq; using System.Reflection; @@ -89,7 +90,10 @@ public void EveryMethodInTheReadmeApiReferenceExistsWithItsDocumentedDefaults() foreach (Match row in rows) { string methodName = row.Groups["method"].Value; - MethodInfo[] overloads = [.. methods.Where(m => m.Name == methodName)]; + // The binary-compatibility overloads are hidden from IntelliSense and deliberately carry no + // defaults, since defaults on both sets would make calls ambiguous. The README documents the + // visible overloads, so only those are held to its defaults. + MethodInfo[] overloads = [.. methods.Where(m => m.Name == methodName && !IsHiddenFromCallers(m))]; if (overloads.Length == 0) { problems.Add($"{methodName} does not exist"); @@ -115,6 +119,9 @@ public void EveryMethodInTheReadmeApiReferenceExistsWithItsDocumentedDefaults() Assert.IsEmpty(problems, string.Join("; ", problems)); } + private static bool IsHiddenFromCallers(MethodInfo method) => + method.GetCustomAttribute()?.State == EditorBrowsableState.Never; + [TestMethod] public void ReadmeMatchOptionsSampleGivesTheDocumentedResults() {