From d5b3a367e9a77d8d403f97875b173fa08ca19851 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 12 Nov 2026 03:25:38 +0000 Subject: [PATCH] Cap a whole-segment run of stars at ** in glob filters [patch] A whole-segment run of three or more stars (`***/x.cs`, `a/***/c`) was kept at full length, so ExpandGlobStarSegments left a stray joined star in front of `**` and the pattern matched nothing. Odd runs failed while even runs happened to split into clean `**` segments. Cap the run at two stars. Fixes ktsu-dev/TextFilter#205 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017vfJW41ENCbvstBVNMiaBJ --- TextFilter.Test/TextFilterTests.cs | 40 +++++++++++++++++++++++++++++- TextFilter/TextFilter.cs | 7 +++--- 2 files changed, 43 insertions(+), 4 deletions(-) diff --git a/TextFilter.Test/TextFilterTests.cs b/TextFilter.Test/TextFilterTests.cs index 7233649..16e79ba 100644 --- a/TextFilter.Test/TextFilterTests.cs +++ b/TextFilter.Test/TextFilterTests.cs @@ -1424,7 +1424,45 @@ public void GlobStarJoinedToLiteralFiltersItems() } [TestMethod] - [DataRow("x[**]", "x[**]")] + [DataRow("a/x.cs", "***/x.cs")] + [DataRow("x.cs", "***/x.cs")] + [DataRow("a/b", "***/b")] + [DataRow("a/b", "****/b")] + [DataRow("a/b", "*****/b")] + [DataRow("b", "*****/b")] + [DataRow("a/b/c", "a/***/c")] + [DataRow("a/c", "a/***/c")] + [DataRow("a/b/c", "a/*****/c")] + [DataRow("x.cs", "***x.cs")] + public void WholeSegmentStarRunMatchesLikeGlobStar(string text, string filter) + { + Assert.IsTrue(TextFilter.IsMatch(text, filter, TextFilterType.Glob, TextFilterMatchOptions.ByWholeString)); + Assert.IsTrue(TextFilter.IsMatch(text, filter)); + } + + [TestMethod] + [DataRow("a/y.cs", "***/x.cs")] + [DataRow("a/b/d", "a/***/c")] + public void WholeSegmentStarRunStillRequiresTheLiteral(string text, string filter) + { + Assert.IsFalse(TextFilter.IsMatch(text, filter, TextFilterType.Glob, TextFilterMatchOptions.ByWholeString)); + } + + [TestMethod] + public void WholeSegmentStarRunExclusionExcludesItems() + { + List result = [.. TextFilter.Filter(["a/tmp", "a/keep"], "-***/tmp")]; + + Assert.AreEqual(1, result.Count); + Assert.AreEqual("a/keep", result[0]); + } + + [TestMethod] + [DataRow("***/x.cs", "**/x.cs")] + [DataRow("a/*****/c", "a/**/c")] + [DataRow("***", "**")] + [DataRow("x[***]", "x[***]")] + [DataRow("x[!**]", "x[!**]")] [DataRow("**/a", "**/a")] [DataRow("a/**", "a/**")] diff --git a/TextFilter/TextFilter.cs b/TextFilter/TextFilter.cs index bbb35c4..70b530f 100644 --- a/TextFilter/TextFilter.cs +++ b/TextFilter/TextFilter.cs @@ -523,8 +523,9 @@ internal static IReadOnlyList ExpandGlobStarSegments(string maskedToken) // DotNet.Glob only reads "**" as a wildcard when it is a whole path segment; joined to anything // else, as in "**foo" or "a**b", it matches no characters at all, so a doubled star // would empty the list where "*" matches. Outside a whole segment a run of stars means no more than - // one star does, so each such run is collapsed to "*". A whole-segment "**" ("**/x", "a/**") is left - // for ExpandGlobStarSegments, and stars inside a bracket class are members, so they are left too. + // one star does, so each such run is collapsed to "*". A whole-segment run ("**/x", "a/**") is capped at + // "**" and left for ExpandGlobStarSegments: a longer run such as "***/" would otherwise leave a stray "*" + // joined to the "**" and match nothing. Stars inside a bracket class are members, so they are left too. internal static string CollapseJoinedGlobStars(string token) { if (!token.Contains("**", StringComparison.Ordinal)) @@ -564,7 +565,7 @@ internal static string CollapseJoinedGlobStars(string token) bool startsSegment = index == 0 || IsPathSeparator(token[index - 1]); bool endsSegment = runEnd == token.Length || IsPathSeparator(token[runEnd]); int runLength = runEnd - index; - result.Append('*', runLength > 1 && !(startsSegment && endsSegment) ? 1 : runLength); + result.Append('*', runLength > 1 && !(startsSegment && endsSegment) ? 1 : Math.Min(runLength, 2)); index = runEnd; }