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
81 changes: 81 additions & 0 deletions GitBranchStateCache.Tests/Refs/BranchPatternTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,87 @@ public void Matches_RegularExpressionMetacharacters_AreLiteral()
Assert.IsFalse(Parse("origin/v1.0").Matches("origin/v1x0"));
}

[TestMethod]
public void Matches_BracketRange_MatchesTheWayGitBranchListDoes()
{
// The shape a StatusBranchNamePatterns entry takes in the field. git branch --list honours the
// bracket expression, so the service has to, or those branches silently drop out of /state.
BranchPattern pattern = Parse("origin/release-[0-9]*");

Assert.IsTrue(pattern.Matches("origin/release-1.0"));
Assert.IsTrue(pattern.Matches("origin/release-2026"));
Assert.IsFalse(pattern.Matches("origin/release-x"));
}

[TestMethod]
public void Matches_BracketSet_MatchesAnyMember()
{
BranchPattern pattern = Parse("origin/[abc]");

Assert.IsTrue(pattern.Matches("origin/a"));
Assert.IsTrue(pattern.Matches("origin/c"));
Assert.IsFalse(pattern.Matches("origin/d"));
Assert.IsFalse(pattern.Matches("origin/ab"));
}

[TestMethod]
public void Matches_NegatedBracket_AcceptsBangAndCaret()
{
foreach (string text in new[] { "origin/[!x]y", "origin/[^x]y" })
{
BranchPattern pattern = Parse(text);

Assert.IsTrue(pattern.Matches("origin/ay"), text);
Assert.IsFalse(pattern.Matches("origin/xy"), text);
}
}

[TestMethod]
public void Matches_CloseBracketFirst_IsAMember()
{
Assert.IsTrue(Parse("a[]b]c").Matches("a]c"));
Assert.IsTrue(Parse("a[]b]c").Matches("abc"));
Assert.IsFalse(Parse("a[!]]c").Matches("a]c"));
Assert.IsTrue(Parse("a[!]]c").Matches("abc"));
}

[TestMethod]
public void Matches_DashAtEitherEnd_IsAMember()
{
Assert.IsTrue(Parse("v[-.]1").Matches("v-1"));
Assert.IsTrue(Parse("v[.-]1").Matches("v-1"));
Assert.IsFalse(Parse("v[.-]1").Matches("v/1"));
}

[TestMethod]
public void Matches_Backslash_EscapesTheNextCharacter()
{
Assert.IsTrue(Parse(@"origin/a\*b").Matches("origin/a*b"));
Assert.IsFalse(Parse(@"origin/a\*b").Matches("origin/axyb"));
Assert.IsTrue(Parse(@"origin/\[x]").Matches("origin/[x]"));
Assert.IsFalse(Parse(@"origin/\[x]").Matches("origin/x"));
Assert.IsTrue(Parse(@"origin/[\]]").Matches("origin/]"));
}

[TestMethod]
public void Matches_UnclosedBracket_IsLiteral()
{
Assert.IsTrue(Parse("origin/[main").Matches("origin/[main"));
Assert.IsFalse(Parse("origin/[main").Matches("origin/main"));
}

[TestMethod]
public void Matches_BracketAfterWildcard_BacktracksCorrectly() =>
Assert.IsTrue(Parse("*/v[0-9].[0-9]").Matches("origin/feature/v1.2"));

[TestMethod]
public void Matches_PathologicalBracketPattern_StillReturnsPromptly()
{
BranchPattern pattern = Parse(string.Concat(Enumerable.Repeat("*[a-y]", 40)) + "z");

Assert.IsFalse(pattern.Matches(new string('a', 4000)));
}

[TestMethod]
public void Matches_PathologicalPattern_StillReturnsPromptly()
{
Expand Down
168 changes: 160 additions & 8 deletions GitBranchStateCache/Refs/BranchPattern.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@ namespace ktsu.GitBranchStateCache.Refs;
/// matcher backtracks at most once per wildcard and cannot be made to run long.
/// </para>
/// <para>
/// The same constructs git's wildmatch honours for <c>git branch --list</c> are honoured here:
/// <c>*</c>, <c>?</c>, bracket expressions such as <c>[0-9]</c>, <c>[abc]</c>, <c>[!x]</c> and
/// <c>[^x]</c> (a <c>]</c> placed first is literal), and <c>\</c> to escape the next character. A
/// <c>[</c> with no closing <c>]</c> is an ordinary character.
/// </para>
/// <para>
/// Matching is case sensitive, because git ref names are.
/// </para>
/// </remarks>
Expand Down Expand Up @@ -81,8 +87,9 @@ public bool Matches(string branchName)
/// <remarks>
/// The classic linear wildcard match: walk both sides together, and on a mismatch fall back to the
/// most recent star and let it consume one more character. Because only the most recent star is
/// ever revisited, the work is bounded by the product of the two lengths in the worst case and is
/// linear in practice, with no recursion and nothing to backtrack exponentially.
/// ever revisited, the work is bounded by the product of the two lengths (times the length of a
/// bracket expression, which is itself bounded by the pattern) and is linear in practice, with no
/// recursion and nothing to backtrack exponentially.
/// </remarks>
private static bool Matches(ReadOnlySpan<char> pattern, ReadOnlySpan<char> name)
{
Expand All @@ -93,17 +100,17 @@ private static bool Matches(ReadOnlySpan<char> pattern, ReadOnlySpan<char> name)

while (nameIndex < name.Length)
{
if (patternIndex < pattern.Length && (pattern[patternIndex] == '?' || pattern[patternIndex] == name[nameIndex]))
{
patternIndex++;
nameIndex++;
}
else if (patternIndex < pattern.Length && pattern[patternIndex] == '*')
if (patternIndex < pattern.Length && pattern[patternIndex] == '*')
{
starIndex = patternIndex;
resumeIndex = nameIndex;
patternIndex++;
}
else if (patternIndex < pattern.Length && MatchesOne(pattern, patternIndex, name[nameIndex], out int length))
{
patternIndex += length;
nameIndex++;
}
else if (starIndex >= 0)
{
patternIndex = starIndex + 1;
Expand All @@ -123,4 +130,149 @@ private static bool Matches(ReadOnlySpan<char> pattern, ReadOnlySpan<char> name)

return patternIndex == pattern.Length;
}

/// <summary>
/// Matches the one pattern element starting at <paramref name="index"/> against one character.
/// </summary>
/// <param name="pattern">The whole pattern.</param>
/// <param name="index">Where the element starts. It is not a star.</param>
/// <param name="character">The character from the name.</param>
/// <param name="length">How many pattern characters the element spans.</param>
/// <returns><see langword="true"/> when the element matches the character.</returns>
private static bool MatchesOne(ReadOnlySpan<char> pattern, int index, char character, out int length)
{
char element = pattern[index];

if (element == '?')
{
length = 1;
return true;
}

if (element == '\\' && index + 1 < pattern.Length)
{
length = 2;
return pattern[index + 1] == character;
}

if (element == '[' && TryMatchBracket(pattern, index, character, out length, out bool matched))
{
return matched;
}

length = 1;
return element == character;
}

/// <summary>
/// Matches a bracket expression such as <c>[0-9]</c> or <c>[!abc]</c> against one character.
/// </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>
/// <returns>
/// <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)
{
int index = start + 1;
bool negated = index < pattern.Length && (pattern[index] == '!' || pattern[index] == '^');
if (negated)
{
index++;
}

bool found = false;
bool first = true;

// A ] straight after the opening bracket (or its negation) is a member, not the end.
while (index < pattern.Length && (first || pattern[index] != ']'))
{
first = false;

if (!TryReadRange(pattern, ref index, out char low, out char high))
{
break;
}

found |= low <= character && character <= high;
}

if (index >= pattern.Length)
{
length = 1;
matched = false;
return false;
}

length = index + 1 - start;
matched = found != negated;
return true;
}

/// <summary>
/// Reads one member of a bracket expression: a single character, or a range such as <c>a-z</c>.
/// </summary>
/// <param name="pattern">The whole pattern.</param>
/// <param name="index">Where the member starts; moved past it.</param>
/// <param name="low">The first character the member covers.</param>
/// <param name="high">The last character the member covers; equal to <paramref name="low"/> for a single character.</param>
/// <returns>
/// <see langword="false"/> when the pattern ends in a lone backslash, which leaves the bracket
/// unclosed.
/// </returns>
/// <remarks>
/// A <c>-</c> directly before the closing <c>]</c> is an ordinary member, not the start of a range.
/// </remarks>
private static bool TryReadRange(ReadOnlySpan<char> pattern, ref int index, out char low, out char high)
{
if (!TryReadMember(pattern, ref index, out low))
{
high = default;
return false;
}

high = low;
if (index + 1 < pattern.Length && pattern[index] == '-' && pattern[index + 1] != ']')
{
index++;
return TryReadMember(pattern, ref index, out high);
}

return true;
}

/// <summary>
/// Reads one member character of a bracket expression, honouring a backslash escape.
/// </summary>
/// <param name="pattern">The whole pattern.</param>
/// <param name="index">Where the member starts; moved past it.</param>
/// <param name="value">The member character.</param>
/// <returns>
/// <see langword="false"/> when the pattern ends in a lone backslash, which leaves the bracket
/// unclosed.
/// </returns>
private static bool TryReadMember(ReadOnlySpan<char> pattern, ref int index, out char value)
{
if (pattern[index] == '\\')
{
if (index + 1 >= pattern.Length)
{
index = pattern.Length;
value = default;
return false;
}

value = pattern[index + 1];
index += 2;
return true;
}

value = pattern[index];
index++;
return true;
}
}
Loading