Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions GitBranchStateCache.Tests/Refs/BranchPatternTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,78 @@
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");

Check warning on line 237 in GitBranchStateCache.Tests/Refs/BranchPatternTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'Assert.Contains' instead of 'StringAssert.Contains'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_GitBranchStateCache&issues=AaFYqY3tRoYD4N0zUAJt&open=AaFYqY3tRoYD4N0zUAJt&pullRequest=95
}

[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 _));
Expand Down
163 changes: 162 additions & 1 deletion GitBranchStateCache/Refs/BranchPattern.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,14 @@
/// <c>[</c> with no closing <c>]</c> is an ordinary character.
/// </para>
/// <para>
/// A bracket expression may also hold the POSIX character classes git knows, alone or mixed with
/// other members as in <c>[a[:digit:]]</c>: <c>[:alnum:]</c>, <c>[:alpha:]</c>, <c>[:blank:]</c>,
/// <c>[:cntrl:]</c>, <c>[:digit:]</c>, <c>[:graph:]</c>, <c>[:lower:]</c>, <c>[:print:]</c>,
/// <c>[:punct:]</c>, <c>[:space:]</c>, <c>[:upper:]</c> and <c>[:xdigit:]</c>. Each tests the ASCII
/// range it names. A class name git does not know, such as <c>[:bogus:]</c>, makes git abandon the
/// match, so such a pattern is refused rather than left to match nothing.
/// </para>
/// <para>
/// Matching is case sensitive, because git ref names are.
/// </para>
/// </remarks>
Expand Down Expand Up @@ -68,10 +76,50 @@
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;
}

/// <summary>
/// Looks through every bracket expression in a pattern for a character class git does not know.
/// </summary>
/// <param name="pattern">The whole pattern.</param>
/// <param name="unknownClass">The first unknown class name, when there is one.</param>
/// <returns><see langword="true"/> when the pattern names an unknown class.</returns>
private static bool TryFindUnknownClass(ReadOnlySpan<char> 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;
}

/// <summary>Reports whether a branch name matches.</summary>
/// <param name="branchName">The branch name, including its remote prefix.</param>
/// <returns><see langword="true"/> when it matches.</returns>
Expand Down Expand Up @@ -176,8 +224,26 @@
/// <see langword="false"/> when the bracket is never closed, in which case the <c>[</c> is an
/// ordinary character.
/// </returns>
private static bool TryMatchBracket(ReadOnlySpan<char> pattern, int start, char character, out int length, out bool matched)
private static bool TryMatchBracket(ReadOnlySpan<char> pattern, int start, char character, out int length, out bool matched) =>
TryWalkBracket(pattern, start, character, out length, out matched, out _);

/// <summary>
/// Walks a bracket expression, matching it against one character and noting any unknown
/// character class it names.
/// </summary>
/// <param name="pattern">The whole pattern.</param>
/// <param name="start">Where the opening <c>[</c> is.</param>
/// <param name="character">The character from the name.</param>
/// <param name="length">How many pattern characters the expression spans, including both brackets.</param>
/// <param name="matched">Whether the expression matches the character.</param>
/// <param name="unknownClass">The first class name git does not know, when the expression has one.</param>
/// <returns>
/// <see langword="false"/> when the bracket is never closed, in which case the <c>[</c> is an
/// ordinary character.
/// </returns>
private static bool TryWalkBracket(ReadOnlySpan<char> pattern, int start, char character, out int length, out bool matched, out string? unknownClass)

Check warning on line 244 in GitBranchStateCache/Refs/BranchPattern.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Refactor this method to reduce its Cognitive Complexity from 18 to the 15 allowed.

Check warning on line 244 in GitBranchStateCache/Refs/BranchPattern.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Refactor this method to reduce its Cognitive Complexity from 18 to the 15 allowed.

Check failure on line 244 in GitBranchStateCache/Refs/BranchPattern.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this method to reduce its Cognitive Complexity from 18 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_GitBranchStateCache&issues=AaFYqY02RoYD4N0zUAJs&open=AaFYqY02RoYD4N0zUAJs&pullRequest=95
{
unknownClass = null;
int index = start + 1;
bool negated = index < pattern.Length && (pattern[index] == '!' || pattern[index] == '^');
if (negated)
Expand All @@ -193,6 +259,21 @@
{
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;
Expand All @@ -213,6 +294,86 @@
return true;
}

/// <summary>
/// Reads a character class such as <c>[:digit:]</c> inside a bracket expression.
/// </summary>
/// <param name="pattern">The whole pattern.</param>
/// <param name="index">Where the member starts; moved past the class when there is one.</param>
/// <param name="className">The name between the colons, when there is a class.</param>
/// <returns><see langword="true"/> when the member is a class.</returns>
/// <remarks>
/// Follows git's wildmatch: after <c>[:</c>, the first <c>]</c> ends the class only when a
/// <c>:</c> comes straight before it. Otherwise the <c>[</c> is an ordinary member and the walk
/// carries on from the character after it.
/// </remarks>
private static bool TryReadClass(ReadOnlySpan<char> 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;
}

/// <summary>
/// Tests a character against a POSIX character class, the ASCII way git does.
/// </summary>
/// <param name="className">The class name, such as <c>digit</c>.</param>
/// <param name="character">The character from the name.</param>
/// <param name="known">Whether git knows the class name.</param>
/// <returns><see langword="true"/> when the class is known and holds the character.</returns>
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;
}
}

/// <summary>
/// Reads one member of a bracket expression: a single character, or a range such as <c>a-z</c>.
/// </summary>
Expand Down
Loading