diff --git a/GitBranchStateCache.Tests/Refs/BranchPatternTests.cs b/GitBranchStateCache.Tests/Refs/BranchPatternTests.cs index d29108f..ed47fcd 100644 --- a/GitBranchStateCache.Tests/Refs/BranchPatternTests.cs +++ b/GitBranchStateCache.Tests/Refs/BranchPatternTests.cs @@ -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() { diff --git a/GitBranchStateCache/Refs/BranchPattern.cs b/GitBranchStateCache/Refs/BranchPattern.cs index 4584985..ceda248 100644 --- a/GitBranchStateCache/Refs/BranchPattern.cs +++ b/GitBranchStateCache/Refs/BranchPattern.cs @@ -19,6 +19,12 @@ namespace ktsu.GitBranchStateCache.Refs; /// matcher backtracks at most once per wildcard and cannot be made to run long. /// /// +/// The same constructs git's wildmatch honours for git branch --list are honoured here: +/// *, ?, bracket expressions such as [0-9], [abc], [!x] and +/// [^x] (a ] placed first is literal), and \ to escape the next character. A +/// [ with no closing ] is an ordinary character. +/// +/// /// Matching is case sensitive, because git ref names are. /// /// @@ -81,8 +87,9 @@ public bool Matches(string branchName) /// /// 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. /// private static bool Matches(ReadOnlySpan pattern, ReadOnlySpan name) { @@ -93,17 +100,17 @@ private static bool Matches(ReadOnlySpan pattern, ReadOnlySpan 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; @@ -123,4 +130,149 @@ private static bool Matches(ReadOnlySpan pattern, ReadOnlySpan name) return patternIndex == pattern.Length; } + + /// + /// Matches the one pattern element starting at against one character. + /// + /// The whole pattern. + /// Where the element starts. It is not a star. + /// The character from the name. + /// How many pattern characters the element spans. + /// when the element matches the character. + private static bool MatchesOne(ReadOnlySpan 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; + } + + /// + /// Matches a bracket expression such as [0-9] or [!abc] against one character. + /// + /// 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. + /// + /// 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) + { + 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; + } + + /// + /// Reads one member of a bracket expression: a single character, or a range such as a-z. + /// + /// The whole pattern. + /// Where the member starts; moved past it. + /// The first character the member covers. + /// The last character the member covers; equal to for a single character. + /// + /// when the pattern ends in a lone backslash, which leaves the bracket + /// unclosed. + /// + /// + /// A - directly before the closing ] is an ordinary member, not the start of a range. + /// + private static bool TryReadRange(ReadOnlySpan 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; + } + + /// + /// Reads one member character of a bracket expression, honouring a backslash escape. + /// + /// The whole pattern. + /// Where the member starts; moved past it. + /// The member character. + /// + /// when the pattern ends in a lone backslash, which leaves the bracket + /// unclosed. + /// + private static bool TryReadMember(ReadOnlySpan 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; + } }