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. ///