Skip to content

Commit 4fdb0c3

Browse files
Match a glob ** joined to other characters like a single * [patch] (#203)
* Match a glob ** joined to other characters like a single * [patch] DotNet.Glob only reads ** as a wildcard when it is a whole path segment. Joined to other characters, as in **foo or a**b, it matched nothing, so a doubled star emptied the list where * matches. ResolveGlob now collapses each run of stars that is not a whole segment to a single *, leaving whole-segment ** for ExpandGlobStarSegments and stars inside bracket classes alone. Fixes #135 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017vfJW3RphV1aRzPmhMhf5m * 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017vfJW6FzBtSrY5LKUuDYce (cherry picked from commit 9328e00) --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent e3b0a8b commit 4fdb0c3

2 files changed

Lines changed: 99 additions & 1 deletion

File tree

‎TextFilter.Test/TextFilterTests.cs‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1391,6 +1391,51 @@ public void GlobStarInsideNegatedClassExclusionKeepsExcludedItems()
13911391
Assert.AreEqual("x*", result[0]);
13921392
}
13931393

1394+
[TestMethod]
1395+
[DataRow("xfoo", "**foo")]
1396+
[DataRow("foo", "**foo")]
1397+
[DataRow("ab", "a**b")]
1398+
[DataRow("axb", "a**b")]
1399+
[DataRow("a/b", "a**b")]
1400+
[DataRow("foobar", "foo**")]
1401+
[DataRow("xfoo", "***foo")]
1402+
[DataRow("src/xfoo", "src/**foo")]
1403+
public void GlobStarJoinedToLiteralMatchesLikeSingleStar(string text, string filter)
1404+
{
1405+
Assert.IsTrue(TextFilter.IsMatch(text, filter, TextFilterType.Glob, TextFilterMatchOptions.ByWholeString));
1406+
}
1407+
1408+
[TestMethod]
1409+
[DataRow("xbar", "**foo")]
1410+
[DataRow("ac", "a**b")]
1411+
public void GlobStarJoinedToLiteralStillRequiresTheLiteral(string text, string filter)
1412+
{
1413+
Assert.IsFalse(TextFilter.IsMatch(text, filter, TextFilterType.Glob, TextFilterMatchOptions.ByWholeString));
1414+
}
1415+
1416+
[TestMethod]
1417+
public void GlobStarJoinedToLiteralFiltersItems()
1418+
{
1419+
List<string> result = [.. TextFilter.Filter(["xfoo", "foo", "bar"], "**foo")];
1420+
1421+
Assert.AreEqual(2, result.Count);
1422+
Assert.AreEqual("xfoo", result[0]);
1423+
Assert.AreEqual("foo", result[1]);
1424+
}
1425+
1426+
[TestMethod]
1427+
[DataRow("x[**]", "x[**]")]
1428+
[DataRow("x[!**]", "x[!**]")]
1429+
[DataRow("**/a", "**/a")]
1430+
[DataRow("a/**", "a/**")]
1431+
[DataRow("**", "**")]
1432+
[DataRow("a**b", "a*b")]
1433+
[DataRow("foo**/bar", "foo*/bar")]
1434+
public void CollapseJoinedGlobStarsLeavesWholeSegmentsAndClasses(string token, string expected)
1435+
{
1436+
Assert.AreEqual(expected, TextFilter.CollapseJoinedGlobStars(token));
1437+
}
1438+
13941439
[TestMethod]
13951440
public void GlobStarSlashFiltersPathItems()
13961441
{

‎TextFilter/TextFilter.cs‎

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -508,6 +508,59 @@ internal static IReadOnlyList<string> ExpandGlobStarSegments(string maskedToken)
508508
return [.. expansions.Select(prefix => prefix + tail).Distinct(StringComparer.Ordinal)];
509509
}
510510

511+
// DotNet.Glob only reads "**" as a wildcard when it is a whole path segment; joined to anything
512+
// else, as in "**foo" or "a**b", it matches no characters at all, so a doubled star
513+
// would empty the list where "*" matches. Outside a whole segment a run of stars means no more than
514+
// one star does, so each such run is collapsed to "*". A whole-segment "**" ("**/x", "a/**") is left
515+
// for ExpandGlobStarSegments, and stars inside a bracket class are members, so they are left too.
516+
internal static string CollapseJoinedGlobStars(string token)
517+
{
518+
if (!token.Contains("**", StringComparison.Ordinal))
519+
{
520+
return token;
521+
}
522+
523+
StringBuilder result = new(token.Length);
524+
int index = 0;
525+
while (index < token.Length)
526+
{
527+
char current = token[index];
528+
if (current == '[')
529+
{
530+
int close = FindGlobClassEnd(token, index, out _, out _);
531+
if (close >= 0)
532+
{
533+
result.Append(token, index, close + 1 - index);
534+
index = close + 1;
535+
continue;
536+
}
537+
}
538+
539+
if (current != '*')
540+
{
541+
result.Append(current);
542+
index++;
543+
continue;
544+
}
545+
546+
int runEnd = index;
547+
while (runEnd < token.Length && token[runEnd] == '*')
548+
{
549+
runEnd++;
550+
}
551+
552+
bool startsSegment = index == 0 || IsPathSeparator(token[index - 1]);
553+
bool endsSegment = runEnd == token.Length || IsPathSeparator(token[runEnd]);
554+
int runLength = runEnd - index;
555+
result.Append('*', runLength > 1 && !(startsSegment && endsSegment) ? 1 : runLength);
556+
index = runEnd;
557+
}
558+
559+
return result.ToString();
560+
}
561+
562+
private static bool IsPathSeparator(char value) => value is '/' or '\\';
563+
511564
// The spans of the closed bracket classes in a token, read with the same boundary rules as
512565
// ExpandGlobClasses: a leading "]" is a member, and "!" or "^" negate. Scanning stops at a class
513566
// with no closing "]", as ExpandGlobClasses does.
@@ -771,7 +824,7 @@ private static SortedSet<char> CollectGlobClassMembers(List<char> singles, List<
771824
// discard the valid expansions with it. An empty pattern could only match empty text, which
772825
// never reaches the matcher, so drop it; a token with nothing left is still unparseable.
773826
Glob[]? parsed = expandedTokens is null ? null : [.. expandedTokens
774-
.SelectMany(expandedToken => ExpandGlobStarSegments(MaskPathSeparators(expandedToken)))
827+
.SelectMany(expandedToken => ExpandGlobStarSegments(MaskPathSeparators(CollapseJoinedGlobStars(expandedToken))))
775828
.Distinct(StringComparer.Ordinal)
776829
.Where(pattern => pattern.Length != 0)
777830
.Select(pattern => caseSensitivity is TextFilterCaseSensitivity.CaseInsensitive

0 commit comments

Comments
 (0)