diff --git a/GitBranchStateCache.Tests/Refs/BranchPatternTests.cs b/GitBranchStateCache.Tests/Refs/BranchPatternTests.cs
index ed47fcd..0307aa7 100644
--- a/GitBranchStateCache.Tests/Refs/BranchPatternTests.cs
+++ b/GitBranchStateCache.Tests/Refs/BranchPatternTests.cs
@@ -169,6 +169,78 @@ public void Matches_PathologicalPattern_StillReturnsPromptly()
Assert.IsFalse(pattern.Matches(new string('a', 4000)));
}
+ [TestMethod]
+ public void Matches_DigitClass_MatchesDigits()
+ {
+ // git branch --list 'release-[[:digit:]]*' lists release-1.0. Read as a plain set, the first ]
+ // would close the bracket and leave { '[', ':', 'd', 'i', 'g', 't' }.
+ BranchPattern pattern = Parse("origin/release-[[:digit:]]*");
+
+ Assert.IsTrue(pattern.Matches("origin/release-1.0"));
+ Assert.IsFalse(pattern.Matches("origin/release-d]x"));
+ Assert.IsFalse(pattern.Matches("origin/release-x"));
+ }
+
+ [TestMethod]
+ public void Matches_AlnumClass_MatchesLettersAndDigits()
+ {
+ BranchPattern pattern = Parse("origin/[[:alnum:]]");
+
+ Assert.IsTrue(pattern.Matches("origin/a"));
+ Assert.IsTrue(pattern.Matches("origin/7"));
+ Assert.IsFalse(pattern.Matches("origin/-"));
+ }
+
+ [TestMethod]
+ public void Matches_ClassMixedWithMembers_MatchesEither()
+ {
+ BranchPattern alpha = Parse("origin/[[:alpha:]_-]");
+
+ Assert.IsTrue(alpha.Matches("origin/Q"));
+ Assert.IsTrue(alpha.Matches("origin/_"));
+ Assert.IsTrue(alpha.Matches("origin/-"));
+ Assert.IsFalse(alpha.Matches("origin/1"));
+
+ BranchPattern mixed = Parse("origin/v[a[:digit:]]");
+
+ Assert.IsTrue(mixed.Matches("origin/va"));
+ Assert.IsTrue(mixed.Matches("origin/v3"));
+ Assert.IsFalse(mixed.Matches("origin/vb"));
+ }
+
+ [TestMethod]
+ public void Matches_NegatedClass_MatchesEverythingElse()
+ {
+ BranchPattern pattern = Parse("origin/[![:upper:]]");
+
+ Assert.IsTrue(pattern.Matches("origin/a"));
+ Assert.IsFalse(pattern.Matches("origin/A"));
+ }
+
+ [TestMethod]
+ public void Matches_ColonBracketWithoutClosingColon_IsAnOrdinarySet()
+ {
+ // After [: the first ] ends a class only when a : precedes it, as in git's wildmatch.
+ BranchPattern pattern = Parse("origin/[[:x]");
+
+ Assert.IsTrue(pattern.Matches("origin/["));
+ Assert.IsTrue(pattern.Matches("origin/:"));
+ Assert.IsTrue(pattern.Matches("origin/x"));
+ }
+
+ [TestMethod]
+ public void TryParse_UnknownClass_IsRefused()
+ {
+ Assert.IsFalse(BranchPattern.TryParse("origin/[[:bogus:]]", out BranchPattern? parsed, out string? failure));
+ Assert.IsNull(parsed);
+ Assert.IsNotNull(failure);
+ StringAssert.Contains(failure, "bogus");
+ }
+
+ [TestMethod]
+ public void TryParse_EscapedBracketThatLooksLikeAnUnknownClass_IsAccepted() =>
+ Assert.IsTrue(BranchPattern.TryParse(@"origin/\[[:bogus:]", out _, out _));
+
[TestMethod]
public void TryParse_Empty_IsRefused() =>
Assert.IsFalse(BranchPattern.TryParse(string.Empty, out _, out _));
diff --git a/GitBranchStateCache/Refs/BranchPattern.cs b/GitBranchStateCache/Refs/BranchPattern.cs
index ceda248..6c72452 100644
--- a/GitBranchStateCache/Refs/BranchPattern.cs
+++ b/GitBranchStateCache/Refs/BranchPattern.cs
@@ -25,6 +25,14 @@ namespace ktsu.GitBranchStateCache.Refs;
/// [ with no closing ] is an ordinary character.
///
///
+/// A bracket expression may also hold the POSIX character classes git knows, alone or mixed with
+/// other members as in [a[:digit:]]: [:alnum:], [:alpha:], [:blank:],
+/// [:cntrl:], [:digit:], [:graph:], [:lower:], [:print:],
+/// [:punct:], [:space:], [:upper:] and [:xdigit:]. Each tests the ASCII
+/// range it names. A class name git does not know, such as [:bogus:], makes git abandon the
+/// match, so such a pattern is refused rather than left to match nothing.
+///
+///
/// Matching is case sensitive, because git ref names are.
///
///
@@ -68,10 +76,50 @@ public static bool TryParse(string? pattern, out BranchPattern? parsed, out stri
return false;
}
+ if (TryFindUnknownClass(pattern.AsSpan(), out string? unknownClass))
+ {
+ failure = $"a branch pattern must not use the unknown character class [:{unknownClass}:]";
+ return false;
+ }
+
parsed = new BranchPattern(pattern);
return true;
}
+ ///
+ /// Looks through every bracket expression in a pattern for a character class git does not know.
+ ///
+ /// The whole pattern.
+ /// The first unknown class name, when there is one.
+ /// when the pattern names an unknown class.
+ private static bool TryFindUnknownClass(ReadOnlySpan pattern, out string? unknownClass)
+ {
+ int index = 0;
+ while (index < pattern.Length)
+ {
+ if (pattern[index] == '\\')
+ {
+ index += 2;
+ }
+ else if (pattern[index] == '[' && TryWalkBracket(pattern, index, '\0', out int length, out _, out unknownClass))
+ {
+ if (unknownClass is not null)
+ {
+ return true;
+ }
+
+ index += length;
+ }
+ else
+ {
+ index++;
+ }
+ }
+
+ unknownClass = null;
+ return false;
+ }
+
/// Reports whether a branch name matches.
/// The branch name, including its remote prefix.
/// when it matches.
@@ -176,8 +224,26 @@ private static bool MatchesOne(ReadOnlySpan pattern, int index, char chara
/// when the bracket is never closed, in which case the [ is an
/// ordinary character.
///
- private static bool TryMatchBracket(ReadOnlySpan pattern, int start, char character, out int length, out bool matched)
+ private static bool TryMatchBracket(ReadOnlySpan pattern, int start, char character, out int length, out bool matched) =>
+ TryWalkBracket(pattern, start, character, out length, out matched, out _);
+
+ ///
+ /// Walks a bracket expression, matching it against one character and noting any unknown
+ /// character class it names.
+ ///
+ /// The whole pattern.
+ /// Where the opening [ is.
+ /// The character from the name.
+ /// How many pattern characters the expression spans, including both brackets.
+ /// Whether the expression matches the character.
+ /// The first class name git does not know, when the expression has one.
+ ///
+ /// when the bracket is never closed, in which case the [ is an
+ /// ordinary character.
+ ///
+ private static bool TryWalkBracket(ReadOnlySpan pattern, int start, char character, out int length, out bool matched, out string? unknownClass)
{
+ unknownClass = null;
int index = start + 1;
bool negated = index < pattern.Length && (pattern[index] == '!' || pattern[index] == '^');
if (negated)
@@ -193,6 +259,21 @@ private static bool TryMatchBracket(ReadOnlySpan pattern, int start, char
{
first = false;
+ if (TryReadClass(pattern, ref index, out string? className))
+ {
+ if (IsInClass(className, character, out bool known))
+ {
+ found = true;
+ }
+
+ if (!known)
+ {
+ unknownClass ??= className;
+ }
+
+ continue;
+ }
+
if (!TryReadRange(pattern, ref index, out char low, out char high))
{
break;
@@ -213,6 +294,86 @@ private static bool TryMatchBracket(ReadOnlySpan pattern, int start, char
return true;
}
+ ///
+ /// Reads a character class such as [:digit:] inside a bracket expression.
+ ///
+ /// The whole pattern.
+ /// Where the member starts; moved past the class when there is one.
+ /// The name between the colons, when there is a class.
+ /// when the member is a class.
+ ///
+ /// Follows git's wildmatch: after [:, the first ] ends the class only when a
+ /// : comes straight before it. Otherwise the [ is an ordinary member and the walk
+ /// carries on from the character after it.
+ ///
+ private static bool TryReadClass(ReadOnlySpan pattern, ref int index, out string? className)
+ {
+ className = null;
+ if (index + 1 >= pattern.Length || pattern[index] != '[' || pattern[index + 1] != ':')
+ {
+ return false;
+ }
+
+ int nameStart = index + 2;
+ int close = pattern[nameStart..].IndexOf(']');
+ if (close < 0)
+ {
+ return false;
+ }
+
+ close += nameStart;
+ if (close - 1 < nameStart || pattern[close - 1] != ':')
+ {
+ return false;
+ }
+
+ className = pattern[nameStart..(close - 1)].ToString();
+ index = close + 1;
+ return true;
+ }
+
+ ///
+ /// Tests a character against a POSIX character class, the ASCII way git does.
+ ///
+ /// The class name, such as digit.
+ /// The character from the name.
+ /// Whether git knows the class name.
+ /// when the class is known and holds the character.
+ private static bool IsInClass(string? className, char character, out bool known)
+ {
+ known = true;
+ switch (className)
+ {
+ case "alnum":
+ return char.IsAsciiLetterOrDigit(character);
+ case "alpha":
+ return char.IsAsciiLetter(character);
+ case "blank":
+ return character is ' ' or '\t';
+ case "cntrl":
+ return character is < ' ' or '\x7f';
+ case "digit":
+ return char.IsAsciiDigit(character);
+ case "graph":
+ return character is > ' ' and < '\x7f';
+ case "lower":
+ return char.IsAsciiLetterLower(character);
+ case "print":
+ return character is >= ' ' and < '\x7f';
+ case "punct":
+ return character is > ' ' and < '\x7f' && !char.IsAsciiLetterOrDigit(character);
+ case "space":
+ return character is ' ' or (>= '\t' and <= '\r');
+ case "upper":
+ return char.IsAsciiLetterUpper(character);
+ case "xdigit":
+ return char.IsAsciiHexDigit(character);
+ default:
+ known = false;
+ return false;
+ }
+ }
+
///
/// Reads one member of a bracket expression: a single character, or a range such as a-z.
///