From 501a93dd7b7598d49b55adc3e6d4e2bf301919ad Mon Sep 17 00:00:00 2001 From: Dave Black <656118+udlose@users.noreply.github.com> Date: Fri, 20 Feb 2026 19:11:50 -0600 Subject: [PATCH 01/33] Expand AttributedScopeStackTests with full coverage Converted test class to public and added extensive unit tests for AttributedScopeStack, covering constructors, equality, hash codes, scope name retrieval, attribute merging, and push operations. Includes edge cases, reflection-based tests, and helpers for stack creation and metadata. Validates robustness and correctness of all key behaviors. --- .../Grammars/AttributedScopeStackTests.cs | 1114 ++++++++++++++++- 1 file changed, 1113 insertions(+), 1 deletion(-) diff --git a/src/TextMateSharp.Tests/Internal/Grammars/AttributedScopeStackTests.cs b/src/TextMateSharp.Tests/Internal/Grammars/AttributedScopeStackTests.cs index 590933d..fbd1433 100644 --- a/src/TextMateSharp.Tests/Internal/Grammars/AttributedScopeStackTests.cs +++ b/src/TextMateSharp.Tests/Internal/Grammars/AttributedScopeStackTests.cs @@ -1,11 +1,28 @@ using NUnit.Framework; +using System; +using System.Collections.Generic; +using System.Reflection; using TextMateSharp.Internal.Grammars; +using TextMateSharp.Themes; namespace TextMateSharp.Tests.Internal.Grammars { [TestFixture] - internal class AttributedScopeStackTests + public class AttributedScopeStackTests { + #region Shared test constants + + private const string AnyScopePath = "any.scope"; + private const int ExistingLanguageId = 7; + private const int ExistingTokenType = 1; + private const FontStyle ExistingFontStyle = FontStyle.Bold; + private const int ExistingForeground = 5; + private const int ExistingBackground = 6; + private const int NewLanguageId = 9; + private const int NewTokenType = 2; + + #endregion + [Test] public void Equals_ShouldMatchEquivalentStacks() { @@ -41,5 +58,1100 @@ public void Equals_ShouldReturnFalseForNull() Assert.IsFalse(stack.Equals(null)); } + + #region Constructor tests + + [Test] + public void Constructor_AssignsProperties() + { + // arrange + const string parentScopePath = "parent.scope"; + const int parentTokenAttributes = 123; + AttributedScopeStack parent = new AttributedScopeStack(null, parentScopePath, parentTokenAttributes); + + const string childScopePath = "child.scope"; + const int childTokenAttributes = 456; + + // act + AttributedScopeStack stack = new AttributedScopeStack(parent, childScopePath, childTokenAttributes); + + // assert + Assert.AreSame(parent, stack.Parent); + Assert.AreEqual(childScopePath, stack.ScopePath); + Assert.AreEqual(childTokenAttributes, stack.TokenAttributes); + } + + [Test] + public void Constructor_AllowsNullScopePath() + { + // arrange + AttributedScopeStack parent = null; + string scopePath = null; + const int tokenAttributes = 0; + + // act + AttributedScopeStack stack = new AttributedScopeStack(parent, scopePath, tokenAttributes); + + // assert + Assert.IsNull(stack.ScopePath); + Assert.AreEqual(tokenAttributes, stack.TokenAttributes); + } + + #endregion Constructor tests + + #region Equals tests + + [Test] + public void Equals_Null_ReturnsFalse() + { + // arrange + AttributedScopeStack stack = new AttributedScopeStack(null, "source.cs", 1); + + // act + bool result = stack.Equals(null); + + // assert + Assert.IsFalse(result); + } + + [Test] + public void Equals_DifferentType_ReturnsFalse() + { + // arrange + AttributedScopeStack stack = new AttributedScopeStack(null, "source.cs", 1); + object other = 42; + + // act + bool result = stack.Equals(other); + + // assert + Assert.IsFalse(result); + } + + [Test] + public void Equals_StructurallyEquivalentStacks_ReturnTrue() + { + // arrange + AttributedScopeStack stack1 = new AttributedScopeStack(new AttributedScopeStack(null, "source.cs", 1), "meta.test", 2); + AttributedScopeStack stack2 = new AttributedScopeStack(new AttributedScopeStack(null, "source.cs", 1), "meta.test", 2); + + // act + bool stack1EqualsStack2 = stack1.Equals(stack2); + bool stack2EqualsStack1 = stack2.Equals(stack1); + + // assert + Assert.IsTrue(stack1EqualsStack2); + Assert.IsTrue(stack2EqualsStack1); + } + + [Test] + public void Equals_DifferentStacks_ReturnFalse() + { + // arrange + AttributedScopeStack stack1 = new AttributedScopeStack(new AttributedScopeStack(null, "source.cs", 1), "meta.test", 2); + AttributedScopeStack stack2 = new AttributedScopeStack(new AttributedScopeStack(null, "source.cs", 1), "meta.other", 2); + + // act + bool stack1EqualsStack2 = stack1.Equals(stack2); + bool stack2EqualsStack1 = stack2.Equals(stack1); + + // assert + Assert.IsFalse(stack1EqualsStack2); + Assert.IsFalse(stack2EqualsStack1); + } + + [Test] + public void Equals_SameReference_ReturnsTrue() + { + // arrange + AttributedScopeStack stack = CreateStack(("a", 1), ("b", 2)); + + // act + bool result = stack.Equals((object)stack); + + // assert + Assert.IsTrue(result); + } + + [Test] + public void Equals_DifferentLengths_ReturnsFalse() + { + // arrange + AttributedScopeStack shorter = CreateStack(("a", 1), ("b", 2)); + AttributedScopeStack longer = CreateStack(("a", 1), ("b", 2), ("c", 3)); + + // act + bool shorterEqualsLonger = shorter.Equals((object)longer); + bool longerEqualsShorter = longer.Equals((object)shorter); + + // assert + Assert.IsFalse(shorterEqualsLonger); + Assert.IsFalse(longerEqualsShorter); + } + + [Test] + public void Equals_ScopePathNullInBothStacksAtSamePosition_ReturnsTrue() + { + // arrange + AttributedScopeStack left = CreateStack((null, 1), ("b", 2)); + AttributedScopeStack right = CreateStack((null, 1), ("b", 2)); + + // act + bool result = left.Equals((object)right); + + // assert + Assert.IsTrue(result); + } + + [Test] + public void Equals_NullScopePathInOneStack_NotEqual() + { + // arrange + AttributedScopeStack left = CreateStack((null, 1), ("b", 2)); + AttributedScopeStack right = CreateStack(("a", 1), ("b", 2)); + + // act + bool result = left.Equals((object)right); + + // assert + Assert.IsFalse(result); + } + + [Test] + public void Equals_EquivalentButDistinctChains_ReturnsTrue() + { + // arrange + AttributedScopeStack left = new AttributedScopeStack( + new AttributedScopeStack(null, "a", 1), + "b", + 2); + + AttributedScopeStack right = new AttributedScopeStack( + new AttributedScopeStack(null, "a", 1), + "b", + 2); + + // act + bool result = left.Equals(right); + + // assert + Assert.IsTrue(result); + } + + [Test] + public void Equals_SameLeafButOneStackHasExtraParent_ReturnsFalse() + { + // arrange + const string sharedLeafScope = "b"; + const int sharedLeafTokenAttributes = 2; + + AttributedScopeStack longer = new AttributedScopeStack( + new AttributedScopeStack(null, "a", 1), + sharedLeafScope, + sharedLeafTokenAttributes); + + AttributedScopeStack shorter = new AttributedScopeStack( + null, + sharedLeafScope, + sharedLeafTokenAttributes); + + // act + bool result = longer.Equals(shorter); + + // assert + Assert.IsFalse(result); + } + + [Test] + public void Equals_SameLeafAndSameParentInstance_ReturnsTrue() + { + // arrange + const string parentScope = "shared.parent"; + const int parentTokenAttributes = 1; + + const string leafScope = "leaf"; + const int leafTokenAttributes = 2; + + AttributedScopeStack sharedParent = new AttributedScopeStack(null, parentScope, parentTokenAttributes); + + AttributedScopeStack left = new AttributedScopeStack(sharedParent, leafScope, leafTokenAttributes); + AttributedScopeStack right = new AttributedScopeStack(sharedParent, leafScope, leafTokenAttributes); + + // act + bool result = left.Equals(right); + + // assert + Assert.IsTrue(result); + } + + [Test] + public void Equals_SameLeafButDifferentParentScope_ReturnsFalse() + { + // arrange + const string leafScope = "leaf"; + const int leafTokenAttributes = 2; + + AttributedScopeStack left = new AttributedScopeStack( + new AttributedScopeStack(null, "parent.a", 1), + leafScope, + leafTokenAttributes); + + AttributedScopeStack right = new AttributedScopeStack( + new AttributedScopeStack(null, "parent.b", 1), + leafScope, + leafTokenAttributes); + + // act + bool result = left.Equals(right); + + // assert + Assert.IsFalse(result); + } + + [Test] + public void Equals_SameLeafButDifferentParentTokenAttributes_ReturnsFalse() + { + // arrange + const string parentScope = "parent"; + const string leafScope = "leaf"; + const int leafTokenAttributes = 2; + + AttributedScopeStack left = new AttributedScopeStack( + new AttributedScopeStack(null, parentScope, 111), + leafScope, + leafTokenAttributes); + + AttributedScopeStack right = new AttributedScopeStack( + new AttributedScopeStack(null, parentScope, 222), + leafScope, + leafTokenAttributes); + + // act + bool result = left.Equals(right); + + // assert + Assert.IsFalse(result); + } + + [Test] + public void Equals_LeftChainEndsFirst_ReturnsFalse() + { + // arrange + const string leafScopePath = "leaf"; + const int leafTokenAttributes = 2; + + const string parentScopePath = "parent"; + const int parentTokenAttributes = 1; + + AttributedScopeStack shorter = new AttributedScopeStack(null, leafScopePath, leafTokenAttributes); + AttributedScopeStack longer = new AttributedScopeStack( + new AttributedScopeStack(null, parentScopePath, parentTokenAttributes), + leafScopePath, + leafTokenAttributes); + + // act + bool result = shorter.Equals(longer); + + // assert + Assert.IsFalse(result); + } + + [Test] + public void Equals_RightChainEndsFirst_ReturnsFalse() + { + // arrange + const string leafScopePath = "leaf"; + const int leafTokenAttributes = 2; + + const string parentScopePath = "parent"; + const int parentTokenAttributes = 1; + + AttributedScopeStack longer = new AttributedScopeStack( + new AttributedScopeStack(null, parentScopePath, parentTokenAttributes), + leafScopePath, + leafTokenAttributes); + + AttributedScopeStack shorter = new AttributedScopeStack(null, leafScopePath, leafTokenAttributes); + + // act + bool result = longer.Equals(shorter); + + // assert + Assert.IsFalse(result); + } + + [Test] + public void Equals_PrivateStatic_FirstArgumentNull_ReturnsFalse() + { + // arrange + MethodInfo equalsMethod = GetPrivateStaticEqualsMethod(); + AttributedScopeStack b = new AttributedScopeStack(null, "x", 1); + + // act + object result = equalsMethod.Invoke(null, new object[] { null, b }); + + // assert + Assert.NotNull(result); + Assert.IsFalse((bool)result); + } + + [Test] + public void Equals_PrivateStatic_SecondArgumentNull_ReturnsFalse() + { + // arrange + MethodInfo equalsMethod = GetPrivateStaticEqualsMethod(); + AttributedScopeStack a = new AttributedScopeStack(null, "x", 1); + + // act + object result = equalsMethod.Invoke(null, new object[] { a, null }); + + // assert + Assert.NotNull(result); + Assert.IsFalse((bool)result); + } + + [Test] + public void Equals_PrivateStatic_BothArgumentsNull_ReturnsTrue() + { + // arrange + MethodInfo equalsMethod = GetPrivateStaticEqualsMethod(); + + // act + object result = equalsMethod.Invoke(null, new object[] { null, null }); + + // assert + Assert.NotNull(result); + Assert.IsTrue((bool)result); + } + + // Normal call paths cannot hit the branches of the static Equals impl, so I'm adding this reflection-based + // helper to help improve the test coverage and cover the branches that cannot otherwise be executed. + private static MethodInfo GetPrivateStaticEqualsMethod() + { + Type type = typeof(AttributedScopeStack); + Type[] parameterTypes = new Type[] { typeof(AttributedScopeStack), typeof(AttributedScopeStack) }; + + MethodInfo methodInfo = type.GetMethod( + nameof(Equals), + BindingFlags.NonPublic | BindingFlags.Static, + null, + parameterTypes, + null); + + Assert.IsNotNull(methodInfo); + return methodInfo; + } + + #endregion Equals tests + + #region GetHashCode tests + + [Test] + public void GetHashCode_WhenParentIsNull_DoesNotThrow_AndIsDeterministic() + { + // arrange + AttributedScopeStack root = new AttributedScopeStack(null, "root", 1); + + // act + int first = root.GetHashCode(); + int second = root.GetHashCode(); + + // assert + Assert.AreEqual(first, second); + } + + [Test] + public void GetHashCode_WhenScopePathIsNull_DoesNotThrow_AndIsDeterministic() + { + // arrange + AttributedScopeStack stack = new AttributedScopeStack(null, null, 1); + + // act + int first = stack.GetHashCode(); + int second = stack.GetHashCode(); + + // assert + Assert.AreEqual(first, second); + } + + [Test] + public void GetHashCode_WhenStacksAreEqual_ReturnsSameValue() + { + // arrange + AttributedScopeStack left = CreateStack(("a", 1), ("b", 2), ("c", 3)); + AttributedScopeStack right = CreateStack(("a", 1), ("b", 2), ("c", 3)); + + // act + bool isEqual = left.Equals((object)right); + int leftHashCode = left.GetHashCode(); + int rightHashCode = right.GetHashCode(); + + // assert + Assert.IsTrue(isEqual); + Assert.AreEqual(leftHashCode, rightHashCode); + } + + [Test] + public void GetHashCode_WhenUsedAsDictionaryKey_AllowsLookupUsingEqualStack() + { + // arrange + AttributedScopeStack key1 = CreateStack(("a", 1), ("b", 2)); + AttributedScopeStack key2 = CreateStack(("a", 1), ("b", 2)); + + Dictionary dictionary = new Dictionary + { + [key1] = "VALUE" + }; + + // act + bool found = dictionary.TryGetValue(key2, out string value); + + // assert + Assert.IsTrue(found); + Assert.AreEqual("VALUE", value); + } + + [Test] + public void GetHashCode_WhenStacksDifferAtAnyFrame_ReturnsDifferentValue_SanityCheck() + { + // arrange + AttributedScopeStack left = CreateStack(("a", 1), ("b", 2), ("c", 3)); + AttributedScopeStack right = CreateStack(("a", 1), ("b", 2), ("x", 3)); + + // act + int leftHashCode = left.GetHashCode(); + int rightHashCode = right.GetHashCode(); + + // assert + // Collisions are allowed, so this is a sanity check rather than a strict contract test. + Assert.AreNotEqual(leftHashCode, rightHashCode); + } + + [Test] + public void GetHashCode_WhenStackIsDeep_DoesNotThrow_AndIsDeterministic() + { + // arrange + const int depth = 10_000; + + AttributedScopeStack current = null; + for (int i = 0; i < depth; i++) + { + current = new AttributedScopeStack(current, "s" + i, i); + } + + // act + int first = current!.GetHashCode(); + int second = current.GetHashCode(); + + // assert + Assert.AreEqual(first, second); + } + + #endregion GetHashCode tests + + #region GetScopeNames tests + + [Test] + public void GetScopeNames_ReturnsRootToLeaf() + { + // arrange + AttributedScopeStack stack = CreateStack(("a", 1), ("b", 2), ("c", 3)); + + // act + List scopes = stack.GetScopeNames(); + + // assert + Assert.AreEqual(3, scopes.Count); + Assert.AreEqual("a", scopes[0]); + Assert.AreEqual("b", scopes[1]); + Assert.AreEqual("c", scopes[2]); + } + + [Test] + public void GetScopeNames_IsCached_ReturnsSameListInstance() + { + // arrange + AttributedScopeStack stack = CreateStack(("a", 1), ("b", 2)); + + // act + List first = stack.GetScopeNames(); + List second = stack.GetScopeNames(); + + // assert + Assert.AreSame(first, second); + } + + [Test] + public void GetScopeNames_CacheIsMutable_MutationsPersistAcrossCalls() + { + // arrange + AttributedScopeStack stack = CreateStack(("a", 1), ("b", 2)); + List first = stack.GetScopeNames(); + + // act + first.Add("MUTATION"); + List second = stack.GetScopeNames(); + + // assert + Assert.AreSame(first, second); + Assert.AreEqual(3, second.Count); + Assert.AreEqual("MUTATION", second[2]); + } + + [Test] + public void GetScopeNames_LongChain_ReturnsCorrectCountAndEndpoints() + { + // arrange + const int count = 2_000; + AttributedScopeStack current = null; + for (int i = 0; i < count; i++) + { + current = new AttributedScopeStack(current, "s" + i, i); + } + + // act + List scopes = current!.GetScopeNames(); + + // assert + Assert.AreEqual(count, scopes.Count); + Assert.AreEqual("s0", scopes[0]); + Assert.AreEqual("s" + (count - 1), scopes[count - 1]); + } + + #endregion GetScopeNames tests + + #region MergeAttributes tests + + [Test] + public void MergeAttributes_NullBasicScopeAttributes_ReturnsExisting() + { + // arrange + int existing = CreateNonDefaultEncodedMetadata(); + + // act + int result = AttributedScopeStack.MergeAttributes(existing, null, null); + + // assert + Assert.AreEqual(existing, result); + } + + [Test] + public void MergeAttributes_ThemeDataNull_PreservesStyleAndColors_ButUpdatesLanguageAndTokenType() + { + // arrange + int existing = CreateNonDefaultEncodedMetadata(); + AttributedScopeStack scopesList = new AttributedScopeStack(null, AnyScopePath, existing); + BasicScopeAttributes attrs = new BasicScopeAttributes(NewLanguageId, NewTokenType, null); + + // act + int result = AttributedScopeStack.MergeAttributes(existing, scopesList, attrs); + + // assert + Assert.AreEqual(NewLanguageId, EncodedTokenAttributes.GetLanguageId(result)); + Assert.AreEqual(NewTokenType, EncodedTokenAttributes.GetTokenType(result)); + Assert.AreEqual(ExistingFontStyle, EncodedTokenAttributes.GetFontStyle(result)); + Assert.AreEqual(ExistingForeground, EncodedTokenAttributes.GetForeground(result)); + Assert.AreEqual(ExistingBackground, EncodedTokenAttributes.GetBackground(result)); + } + + [Test] + public void MergeAttributes_ThemeDataEmpty_PreservesStyleAndColors_ButUpdatesLanguageAndTokenType() + { + // arrange + int existing = CreateNonDefaultEncodedMetadata(); + AttributedScopeStack scopesList = new AttributedScopeStack(null, AnyScopePath, existing); + + List themeData = new List(); + BasicScopeAttributes attrs = new BasicScopeAttributes(NewLanguageId, NewTokenType, themeData); + + // act + int result = AttributedScopeStack.MergeAttributes(existing, scopesList, attrs); + + // assert + Assert.AreEqual(NewLanguageId, EncodedTokenAttributes.GetLanguageId(result)); + Assert.AreEqual(NewTokenType, EncodedTokenAttributes.GetTokenType(result)); + Assert.AreEqual(ExistingFontStyle, EncodedTokenAttributes.GetFontStyle(result)); + Assert.AreEqual(ExistingForeground, EncodedTokenAttributes.GetForeground(result)); + Assert.AreEqual(ExistingBackground, EncodedTokenAttributes.GetBackground(result)); + } + + [Test] + public void MergeAttributes_FirstRuleWithNullParentScopes_IsAlwaysSelected() + { + // arrange + int existing = CreateNonDefaultEncodedMetadata(); + + AttributedScopeStack scopesList = CreateStack( + ("source.csharp", existing), + ("meta.using", existing)); + + const int rule1ScopeDepth = 1; + const int rule1Foreground = 11; + const int rule1Background = 12; + const FontStyle rule1FontStyle = FontStyle.Italic; + + ThemeTrieElementRule rule1 = new ThemeTrieElementRule("r1", rule1ScopeDepth, null, rule1FontStyle, rule1Foreground, rule1Background); + + List rule2ParentScopes = new List { "nonexistent" }; + ThemeTrieElementRule rule2 = new ThemeTrieElementRule("r2", 1, rule2ParentScopes, FontStyle.Underline, 99, 98); + + List themeData = new List { rule1, rule2 }; + BasicScopeAttributes attrs = new BasicScopeAttributes(NewLanguageId, NewTokenType, themeData); + + // act + int result = AttributedScopeStack.MergeAttributes(existing, scopesList, attrs); + + // assert + Assert.AreEqual(rule1FontStyle, EncodedTokenAttributes.GetFontStyle(result)); + Assert.AreEqual(rule1Foreground, EncodedTokenAttributes.GetForeground(result)); + Assert.AreEqual(rule1Background, EncodedTokenAttributes.GetBackground(result)); + } + + [Test] + public void MergeAttributes_FirstRuleDoesNotMatch_SecondRuleMatchesByOrderedParentScopes() + { + // arrange + int existing = CreateNonDefaultEncodedMetadata(); + + AttributedScopeStack scopesList = CreateStack( + ("source.csharp", existing), + ("meta.using", existing), + ("keyword.control", existing)); + + // rule1 should NOT match + List rule1ParentScopes = new List { "source.csharp", "meta.using" }; + ThemeTrieElementRule rule1 = new ThemeTrieElementRule("r1", 1, rule1ParentScopes, FontStyle.Italic, 11, 12); + + const int rule2Foreground = 21; + const int rule2Background = 22; + const FontStyle rule2FontStyle = FontStyle.Underline; + + // rule2 SHOULD match + List rule2ParentScopes = new List { "meta.using", "source.csharp" }; + ThemeTrieElementRule rule2 = new ThemeTrieElementRule("r2", 1, rule2ParentScopes, rule2FontStyle, rule2Foreground, rule2Background); + + List themeData = new List { rule1, rule2 }; + BasicScopeAttributes attrs = new BasicScopeAttributes(NewLanguageId, NewTokenType, themeData); + + // act + int result = AttributedScopeStack.MergeAttributes(existing, scopesList, attrs); + + // assert + Assert.AreEqual(rule2FontStyle, EncodedTokenAttributes.GetFontStyle(result)); + Assert.AreEqual(rule2Foreground, EncodedTokenAttributes.GetForeground(result)); + Assert.AreEqual(rule2Background, EncodedTokenAttributes.GetBackground(result)); + } + + [Test] + public void MergeAttributes_FirstMatchingRuleWins_WhenMultipleRulesMatch() + { + // Arrange + int existing = CreateNonDefaultEncodedMetadata(); + + AttributedScopeStack scopesList = CreateStack( + ("source.csharp", existing), + ("meta.using", existing), + ("keyword.control", existing)); + + ThemeTrieElementRule rule1 = new ThemeTrieElementRule("r1", 1, null, FontStyle.Italic, 11, 12); + ThemeTrieElementRule rule2 = new ThemeTrieElementRule("r2", 1, null, FontStyle.Underline, 21, 22); + + List themeData = new List { rule1, rule2 }; + BasicScopeAttributes attrs = new BasicScopeAttributes(9, 2, themeData); + + // Act + int result = AttributedScopeStack.MergeAttributes(existing, scopesList, attrs); + + // Assert + Assert.AreEqual(FontStyle.Italic, EncodedTokenAttributes.GetFontStyle(result)); + Assert.AreEqual(11, EncodedTokenAttributes.GetForeground(result)); + Assert.AreEqual(12, EncodedTokenAttributes.GetBackground(result)); + } + + [Test] + public void MergeAttributes_ParentScopeSelectorPrefix_MatchesDotSeparatedScope() + { + // arrange + int existing = CreateNonDefaultEncodedMetadata(); + + AttributedScopeStack scopesList = CreateStack( + ("source.csharp", existing), + ("meta.block", existing), + ("keyword.control", existing)); + + const int expectedForeground = 31; + const int expectedBackground = 32; + const FontStyle expectedFontStyle = FontStyle.Italic; + + List parentScopes = new List { "meta" }; + ThemeTrieElementRule rule = new ThemeTrieElementRule("prefix-parent", 1, parentScopes, expectedFontStyle, expectedForeground, expectedBackground); + + List themeData = new List { rule }; + BasicScopeAttributes attrs = new BasicScopeAttributes(NewLanguageId, NewTokenType, themeData); + + // act + int result = AttributedScopeStack.MergeAttributes(existing, scopesList, attrs); + + // assert + Assert.AreEqual(expectedFontStyle, EncodedTokenAttributes.GetFontStyle(result)); + Assert.AreEqual(expectedForeground, EncodedTokenAttributes.GetForeground(result)); + Assert.AreEqual(expectedBackground, EncodedTokenAttributes.GetBackground(result)); + } + + [Test] + public void MergeAttributes_EmptyParentScopesList_ThrowsArgumentOutOfRangeException() + { + // arrange + int existing = CreateNonDefaultEncodedMetadata(); + AttributedScopeStack scopesList = new AttributedScopeStack(null, AnyScopePath, existing); + + List emptyParentScopes = new List(); + ThemeTrieElementRule rule = new ThemeTrieElementRule("empty-parents", 1, emptyParentScopes, FontStyle.Italic, 11, 12); + + List themeData = new List { rule }; + BasicScopeAttributes attrs = new BasicScopeAttributes(NewLanguageId, NewTokenType, themeData); + + // act/assert + Assert.Throws(() => AttributedScopeStack.MergeAttributes(existing, scopesList, attrs)); + } + + [Test] + public void MergeAttributes_RuleFontStyleNotSet_PreservesExistingFontStyle() + { + // arrange + int existing = CreateNonDefaultEncodedMetadata(); + AttributedScopeStack scopesList = new AttributedScopeStack(null, AnyScopePath, existing); + + const int expectedForeground = 123; + const int expectedBackground = 124; + + ThemeTrieElementRule rule = new ThemeTrieElementRule("preserve-style", 1, null, FontStyle.NotSet, expectedForeground, expectedBackground); + + List themeData = new List { rule }; + BasicScopeAttributes attrs = new BasicScopeAttributes(NewLanguageId, NewTokenType, themeData); + + // act + int result = AttributedScopeStack.MergeAttributes(existing, scopesList, attrs); + + // assert + Assert.AreEqual(ExistingFontStyle, EncodedTokenAttributes.GetFontStyle(result)); + Assert.AreEqual(expectedForeground, EncodedTokenAttributes.GetForeground(result)); + Assert.AreEqual(expectedBackground, EncodedTokenAttributes.GetBackground(result)); + } + + [Test] + public void MergeAttributes_RuleForegroundZero_PreservesExistingForeground() + { + // arrange + int existing = CreateNonDefaultEncodedMetadata(); + AttributedScopeStack scopesList = new AttributedScopeStack(null, AnyScopePath, existing); + + const int expectedBackground = 124; + const FontStyle expectedFontStyle = FontStyle.Italic; + + ThemeTrieElementRule rule = new ThemeTrieElementRule("preserve-fg", 1, null, expectedFontStyle, 0, expectedBackground); + + List themeData = new List { rule }; + BasicScopeAttributes attrs = new BasicScopeAttributes(NewLanguageId, NewTokenType, themeData); + + // act + int result = AttributedScopeStack.MergeAttributes(existing, scopesList, attrs); + + // assert + Assert.AreEqual(expectedFontStyle, EncodedTokenAttributes.GetFontStyle(result)); + Assert.AreEqual(ExistingForeground, EncodedTokenAttributes.GetForeground(result)); + Assert.AreEqual(expectedBackground, EncodedTokenAttributes.GetBackground(result)); + } + + [Test] + public void MergeAttributes_RuleBackgroundZero_PreservesExistingBackground() + { + // arrange + int existing = CreateNonDefaultEncodedMetadata(); + AttributedScopeStack scopesList = new AttributedScopeStack(null, AnyScopePath, existing); + + const int expectedForeground = 123; + const FontStyle expectedFontStyle = FontStyle.Italic; + + ThemeTrieElementRule rule = new ThemeTrieElementRule("preserve-bg", 1, null, expectedFontStyle, expectedForeground, 0); + + List themeData = new List { rule }; + BasicScopeAttributes attrs = new BasicScopeAttributes(NewLanguageId, NewTokenType, themeData); + + // act + int result = AttributedScopeStack.MergeAttributes(existing, scopesList, attrs); + + // assert + Assert.AreEqual(expectedFontStyle, EncodedTokenAttributes.GetFontStyle(result)); + Assert.AreEqual(expectedForeground, EncodedTokenAttributes.GetForeground(result)); + Assert.AreEqual(ExistingBackground, EncodedTokenAttributes.GetBackground(result)); + } + + [Test] + public void MergeAttributes_LanguageIdZero_PreservesExistingLanguageId() + { + // arrange + int existing = CreateNonDefaultEncodedMetadata(); + AttributedScopeStack scopesList = new AttributedScopeStack(null, AnyScopePath, existing); + BasicScopeAttributes attrs = new BasicScopeAttributes(0, NewTokenType, null); + + // act + int result = AttributedScopeStack.MergeAttributes(existing, scopesList, attrs); + + // assert + Assert.AreEqual(ExistingLanguageId, EncodedTokenAttributes.GetLanguageId(result)); + } + + [Test] + public void MergeAttributes_NoRuleMatches_PreservesExistingStyleAndColors_ButUpdatesLanguageAndTokenType() + { + // arrange + int existing = CreateNonDefaultEncodedMetadata(); + + AttributedScopeStack scopesList = CreateStack( + ("source.csharp", existing), + ("meta.using", existing), + ("keyword.control", existing)); + + List nonMatchingParentScopes = new List { "does.not.exist" }; + ThemeTrieElementRule nonMatchingRule = new ThemeTrieElementRule("non-match", 1, nonMatchingParentScopes, FontStyle.Italic, 200, 201); + + List themeData = new List { nonMatchingRule }; + BasicScopeAttributes attrs = new BasicScopeAttributes(NewLanguageId, NewTokenType, themeData); + + // act + int result = AttributedScopeStack.MergeAttributes(existing, scopesList, attrs); + + // assert + Assert.AreEqual(NewLanguageId, EncodedTokenAttributes.GetLanguageId(result)); + Assert.AreEqual(NewTokenType, EncodedTokenAttributes.GetTokenType(result)); + Assert.AreEqual(ExistingFontStyle, EncodedTokenAttributes.GetFontStyle(result)); + Assert.AreEqual(ExistingForeground, EncodedTokenAttributes.GetForeground(result)); + Assert.AreEqual(ExistingBackground, EncodedTokenAttributes.GetBackground(result)); + } + + [Test] + public void MergeAttributes_ScopesListNull_RuleWithParentScopes_DoesNotMatch() + { + // arrange + int existing = CreateNonDefaultEncodedMetadata(); + + List parentScopes = new List { "source.csharp" }; + ThemeTrieElementRule rule = new ThemeTrieElementRule("requires-parent", 1, parentScopes, FontStyle.Italic, 200, 201); + + List themeData = new List { rule }; + BasicScopeAttributes attrs = new BasicScopeAttributes(NewLanguageId, NewTokenType, themeData); + + // act + int result = AttributedScopeStack.MergeAttributes(existing, null, attrs); + + // assert + Assert.AreEqual(NewLanguageId, EncodedTokenAttributes.GetLanguageId(result)); + Assert.AreEqual(NewTokenType, EncodedTokenAttributes.GetTokenType(result)); + Assert.AreEqual(ExistingFontStyle, EncodedTokenAttributes.GetFontStyle(result)); + Assert.AreEqual(ExistingForeground, EncodedTokenAttributes.GetForeground(result)); + Assert.AreEqual(ExistingBackground, EncodedTokenAttributes.GetBackground(result)); + } + + [Test] + public void MergeAttributes_PrefixSelector_DoesNotMatch_WhenScopeDoesNotHaveDotBoundary() + { + // arrange + int existing = CreateNonDefaultEncodedMetadata(); + + AttributedScopeStack scopesList = CreateStack( + ("source.csharp", existing), + ("metadata.block", existing)); + + // selector "meta" should match "meta.something" but NOT "metadata.something" + List parentScopes = new List { "meta" }; + ThemeTrieElementRule rule = new ThemeTrieElementRule("prefix-dot-boundary", 1, parentScopes, FontStyle.Italic, 200, 201); + + List themeData = new List { rule }; + BasicScopeAttributes attrs = new BasicScopeAttributes(NewLanguageId, NewTokenType, themeData); + + // act + int result = AttributedScopeStack.MergeAttributes(existing, scopesList, attrs); + + // assert + Assert.AreEqual(NewLanguageId, EncodedTokenAttributes.GetLanguageId(result)); + Assert.AreEqual(NewTokenType, EncodedTokenAttributes.GetTokenType(result)); + Assert.AreEqual(ExistingFontStyle, EncodedTokenAttributes.GetFontStyle(result)); + Assert.AreEqual(ExistingForeground, EncodedTokenAttributes.GetForeground(result)); + Assert.AreEqual(ExistingBackground, EncodedTokenAttributes.GetBackground(result)); + } + + [Test] + public void MergeAttributes_ParentScopesMatchNonContiguously_Works() + { + // arrange + int existing = CreateNonDefaultEncodedMetadata(); + + // leaf -> root traversal will be: c, x, b, y, a + AttributedScopeStack scopesList = CreateStack( + ("a", existing), + ("y", existing), + ("b", existing), + ("x", existing), + ("c", existing)); + + // match "b" then later "a" (non-contiguous) + List parentScopes = new List { "b", "a" }; + const int expectedForeground = 210; + const int expectedBackground = 211; + const FontStyle expectedFontStyle = FontStyle.Underline; + + ThemeTrieElementRule rule = new ThemeTrieElementRule("non-contiguous", 1, parentScopes, expectedFontStyle, expectedForeground, expectedBackground); + + List themeData = new List { rule }; + BasicScopeAttributes attrs = new BasicScopeAttributes(NewLanguageId, NewTokenType, themeData); + + // act + int result = AttributedScopeStack.MergeAttributes(existing, scopesList, attrs); + + // assert + Assert.AreEqual(expectedFontStyle, EncodedTokenAttributes.GetFontStyle(result)); + Assert.AreEqual(expectedForeground, EncodedTokenAttributes.GetForeground(result)); + Assert.AreEqual(expectedBackground, EncodedTokenAttributes.GetBackground(result)); + } + + [Test] + public void MergeAttributes_PreservesBalancedBracketsBit_WhenContainsBalancedBracketsIsNull() + { + // arrange + const int existingLanguageId = ExistingLanguageId; + const int existingTokenType = ExistingTokenType; + const FontStyle existingFontStyle = ExistingFontStyle; + const int existingForeground = ExistingForeground; + const int existingBackground = ExistingBackground; + + int existing = EncodedTokenAttributes.Set( + 0, + existingLanguageId, + existingTokenType, + true, + existingFontStyle, + existingForeground, + existingBackground); + + Assert.IsTrue(EncodedTokenAttributes.ContainsBalancedBrackets(existing)); + + AttributedScopeStack scopesList = new AttributedScopeStack(null, AnyScopePath, existing); + + // ThemeData null => MergeAttributes passes containsBalancedBrackets as null into EncodedTokenAttributes.Set (preserve existing). + BasicScopeAttributes attrs = new BasicScopeAttributes(NewLanguageId, NewTokenType, null); + + // act + int result = AttributedScopeStack.MergeAttributes(existing, scopesList, attrs); + + // assert + Assert.IsTrue(EncodedTokenAttributes.ContainsBalancedBrackets(result)); + } + + [Test] + public void MergeAttributes_WhenScopesListContainsNullScopePath_DoesNotThrow_AndRuleDoesNotMatch() + { + // arrange + int existing = CreateNonDefaultEncodedMetadata(); + + AttributedScopeStack scopesList = CreateStack( + ("source.csharp", existing), + (null, existing), + ("keyword.control", existing)); + + List parentScopes = new List { "meta" }; + ThemeTrieElementRule rule = new ThemeTrieElementRule("null-scopepath", 1, parentScopes, FontStyle.Italic, 11, 12); + + List themeData = new List { rule }; + BasicScopeAttributes attrs = new BasicScopeAttributes(NewLanguageId, NewTokenType, themeData); + + // act + int result = AttributedScopeStack.MergeAttributes(existing, scopesList, attrs); + + // assert + Assert.AreEqual(NewLanguageId, EncodedTokenAttributes.GetLanguageId(result)); + Assert.AreEqual(NewTokenType, EncodedTokenAttributes.GetTokenType(result)); + Assert.AreEqual(ExistingFontStyle, EncodedTokenAttributes.GetFontStyle(result)); + Assert.AreEqual(ExistingForeground, EncodedTokenAttributes.GetForeground(result)); + Assert.AreEqual(ExistingBackground, EncodedTokenAttributes.GetBackground(result)); + } + + #endregion MergeAttributes tests + + #region PushAttributed tests + + [Test] + public void PushAttributed_NullScopePath_ReturnsSameInstance() + { + // arrange + AttributedScopeStack stack = CreateStack(("a", 1), ("b", 2)); + + // act + AttributedScopeStack result = stack.PushAtributed(null, null); + + // assert + Assert.AreSame(stack, result); + } + + [Test] + public void PushAttributed_NonNullScope_WithNullGrammar_ThrowsArgumentNullException() + { + // arrange + AttributedScopeStack stack = CreateStack(("a", 1)); + + // act/assert + Assert.Throws(() => stack.PushAtributed("b", null)); + } + + [Test] + public void PushAttributed_MultiScope_WithNullGrammar_ThrowsArgumentNullException() + { + // arrange + AttributedScopeStack stack = CreateStack(("a", 1)); + + // act/assert + Assert.Throws(() => stack.PushAtributed("b c", null)); + } + + [Test] + public void PushAttributed_EmptyStringScope_WithNullGrammar_ThrowsArgumentNullException() + { + // arrange + AttributedScopeStack stack = CreateStack(("a", 1)); + + // act/assert + Assert.Throws(() => stack.PushAtributed("", null)); + } + + #endregion PushAttributed tests + + #region Helpers + + private static AttributedScopeStack CreateStack(params (string ScopePath, int TokenAttributes)[] frames) + { + AttributedScopeStack current = null; + for (int i = 0; i < frames.Length; i++) + { + (string ScopePath, int TokenAttributes) frame = frames[i]; + current = new AttributedScopeStack(current, frame.ScopePath, frame.TokenAttributes); + } + + return current; + } + + private static int CreateNonDefaultEncodedMetadata() + { + // Choose non-default values so EncodedTokenAttributes.Set "preserve existing" behavior is observable. + return EncodedTokenAttributes.Set( + 0, + ExistingLanguageId, + ExistingTokenType, + null, + ExistingFontStyle, + ExistingForeground, + ExistingBackground); + } + + #endregion Helpers } } From 1c2b5f37aa06af9696cc8a9f81414b93394bd70a Mon Sep 17 00:00:00 2001 From: Dave Black <656118+udlose@users.noreply.github.com> Date: Sat, 21 Feb 2026 19:52:37 -0600 Subject: [PATCH 02/33] Fixes #116 AttributedScopeStack.GetHashCode() throws for normal stacks (null Parent / null ScopePath). Add parameter validation to PushAtributed to throw ArgumetNullException instead of a NullReferenceException --- .../Internal/Grammars/AttributedScopeStack.cs | 62 +++++++++++++++---- 1 file changed, 51 insertions(+), 11 deletions(-) diff --git a/src/TextMateSharp/Internal/Grammars/AttributedScopeStack.cs b/src/TextMateSharp/Internal/Grammars/AttributedScopeStack.cs index 70ab106..f9ae57f 100644 --- a/src/TextMateSharp/Internal/Grammars/AttributedScopeStack.cs +++ b/src/TextMateSharp/Internal/Grammars/AttributedScopeStack.cs @@ -11,6 +11,8 @@ public class AttributedScopeStack public string ScopePath { get; private set; } public int TokenAttributes { get; private set; } private List _cachedScopeNames; + private bool _hasCachedHashCode; + private int _cachedHashCode; public AttributedScopeStack(AttributedScopeStack parent, string scopePath, int tokenAttributes) { @@ -21,19 +23,13 @@ public AttributedScopeStack(AttributedScopeStack parent, string scopePath, int t private static bool StructuralEquals(AttributedScopeStack a, AttributedScopeStack b) { - do + while (true) { if (a == b) { return true; } - if (a == null && b == null) - { - // End of list reached for both - return true; - } - if (a == null || b == null) { // End of list reached only for one @@ -48,7 +44,7 @@ private static bool StructuralEquals(AttributedScopeStack a, AttributedScopeStac // Go to previous pair a = a.Parent; b = b.Parent; - } while (true); + } } private static bool Equals(AttributedScopeStack a, AttributedScopeStack b) @@ -74,11 +70,53 @@ public override bool Equals(object other) public override int GetHashCode() { - return Parent.GetHashCode() + - ScopePath.GetHashCode() + - TokenAttributes.GetHashCode(); + if (_hasCachedHashCode) + { + return _cachedHashCode; + } + + int computedHashCode = ComputeHashCode(); + _cachedHashCode = computedHashCode; + _hasCachedHashCode = true; + + return computedHashCode; } + private int ComputeHashCode() + { + // adding for future implementation if/when support for .NET Standard 2.1 or .NET Core 2.1 is added, which includes System.HashCode +#if NETCOREAPP2_1_OR_GREATER || NETSTANDARD2_1_OR_GREATER + HashCode hashCode = new HashCode(); + + AttributedScopeStack current = this; + while (current != null) + { + hashCode.Add(current.TokenAttributes); + hashCode.Add(current.ScopePath); + current = current.Parent; + } + + return hashCode.ToHashCode(); +#else + unchecked + { + int hash = 17; + + AttributedScopeStack current = this; + while (current != null) + { + hash = (hash * 31) + current.TokenAttributes; + + int scopeHashCode = current.ScopePath?.GetHashCode() ?? 0; + hash = (hash * 31) + scopeHashCode; + + current = current.Parent; + } + + return hash; + } +#endif + } static bool MatchesScope(string scope, string selector, string selectorWithDot) { @@ -176,6 +214,8 @@ public AttributedScopeStack PushAtributed(string scopePath, Grammar grammar) { return this; } + if (grammar == null) throw new ArgumentNullException(nameof(grammar)); + if (scopePath.IndexOf(' ') >= 0) { // there are multiple scopes to push From 8ee9f02ac6641647c26ceb5a04fa72fd477c4284 Mon Sep 17 00:00:00 2001 From: Dave Black <656118+udlose@users.noreply.github.com> Date: Sun, 22 Feb 2026 10:54:25 -0600 Subject: [PATCH 03/33] Refactor AttributedScopeStack and expand test coverage, remove multiple TFM #ifdef for GetHashCode - Refactored AttributedScopeStack for performance and clarity: precomputed hash codes, optimized Equals/GetHashCode, and improved scope matching logic. - Changed handling of empty parentScopes to "always matches" instead of throwing. - Enhanced Push logic to handle multi-scope strings and trailing spaces correctly. - Expanded and clarified unit tests for scope matching and stack manipulation, including new edge cases. - Improved resilience to malformed theme data and overall test coverage. --- .../Grammars/AttributedScopeStackTests.cs | 328 ++++++++++++++++-- .../Internal/Grammars/AttributedScopeStack.cs | 153 ++++---- 2 files changed, 395 insertions(+), 86 deletions(-) diff --git a/src/TextMateSharp.Tests/Internal/Grammars/AttributedScopeStackTests.cs b/src/TextMateSharp.Tests/Internal/Grammars/AttributedScopeStackTests.cs index fbd1433..472a837 100644 --- a/src/TextMateSharp.Tests/Internal/Grammars/AttributedScopeStackTests.cs +++ b/src/TextMateSharp.Tests/Internal/Grammars/AttributedScopeStackTests.cs @@ -1,8 +1,11 @@ +using Moq; using NUnit.Framework; using System; using System.Collections.Generic; using System.Reflection; +using TextMateSharp.Grammars; using TextMateSharp.Internal.Grammars; +using TextMateSharp.Internal.Grammars.Parser; using TextMateSharp.Themes; namespace TextMateSharp.Tests.Internal.Grammars @@ -690,10 +693,22 @@ public void MergeAttributes_FirstRuleWithNullParentScopes_IsAlwaysSelected() const int rule1Background = 12; const FontStyle rule1FontStyle = FontStyle.Italic; - ThemeTrieElementRule rule1 = new ThemeTrieElementRule("r1", rule1ScopeDepth, null, rule1FontStyle, rule1Foreground, rule1Background); + ThemeTrieElementRule rule1 = new ThemeTrieElementRule( + "r1", + rule1ScopeDepth, + null, + rule1FontStyle, + rule1Foreground, + rule1Background); List rule2ParentScopes = new List { "nonexistent" }; - ThemeTrieElementRule rule2 = new ThemeTrieElementRule("r2", 1, rule2ParentScopes, FontStyle.Underline, 99, 98); + ThemeTrieElementRule rule2 = new ThemeTrieElementRule( + "r2", + 1, + rule2ParentScopes, + FontStyle.Underline, + 99, + 98); List themeData = new List { rule1, rule2 }; BasicScopeAttributes attrs = new BasicScopeAttributes(NewLanguageId, NewTokenType, themeData); @@ -728,7 +743,13 @@ public void MergeAttributes_FirstRuleDoesNotMatch_SecondRuleMatchesByOrderedPare // rule2 SHOULD match List rule2ParentScopes = new List { "meta.using", "source.csharp" }; - ThemeTrieElementRule rule2 = new ThemeTrieElementRule("r2", 1, rule2ParentScopes, rule2FontStyle, rule2Foreground, rule2Background); + ThemeTrieElementRule rule2 = new ThemeTrieElementRule( + "r2", + 1, + rule2ParentScopes, + rule2FontStyle, + rule2Foreground, + rule2Background); List themeData = new List { rule1, rule2 }; BasicScopeAttributes attrs = new BasicScopeAttributes(NewLanguageId, NewTokenType, themeData); @@ -784,7 +805,13 @@ public void MergeAttributes_ParentScopeSelectorPrefix_MatchesDotSeparatedScope() const FontStyle expectedFontStyle = FontStyle.Italic; List parentScopes = new List { "meta" }; - ThemeTrieElementRule rule = new ThemeTrieElementRule("prefix-parent", 1, parentScopes, expectedFontStyle, expectedForeground, expectedBackground); + ThemeTrieElementRule rule = new ThemeTrieElementRule( + "prefix-parent", + 1, + parentScopes, + expectedFontStyle, + expectedForeground, + expectedBackground); List themeData = new List { rule }; BasicScopeAttributes attrs = new BasicScopeAttributes(NewLanguageId, NewTokenType, themeData); @@ -798,21 +825,40 @@ public void MergeAttributes_ParentScopeSelectorPrefix_MatchesDotSeparatedScope() Assert.AreEqual(expectedBackground, EncodedTokenAttributes.GetBackground(result)); } + // WARNING: BREAKING CHANGE. currently this throws ArgumentOutOfRangeException, but I'm changing it to allow + // empty parent scopes lists and treat them as "always matches" (similar to null parent scopes) + // in order to be more resilient to malformed theme data. If we want to maintain the old behavior + // of throwing on empty parent scopes, we should add an explicit check for that and throw + // before we get to the point of trying to match against the scopes list. [Test] - public void MergeAttributes_EmptyParentScopesList_ThrowsArgumentOutOfRangeException() + public void MergeAttributes_EmptyParentScopesList_PreservesExistingStyle() { // arrange int existing = CreateNonDefaultEncodedMetadata(); AttributedScopeStack scopesList = new AttributedScopeStack(null, AnyScopePath, existing); + const int expectedForeground = 123; + const int expectedBackground = 124; + List emptyParentScopes = new List(); - ThemeTrieElementRule rule = new ThemeTrieElementRule("empty-parents", 1, emptyParentScopes, FontStyle.Italic, 11, 12); + ThemeTrieElementRule rule = new ThemeTrieElementRule( + "empty-parents", + 1, + emptyParentScopes, + ExistingFontStyle, + expectedForeground, + expectedBackground); List themeData = new List { rule }; BasicScopeAttributes attrs = new BasicScopeAttributes(NewLanguageId, NewTokenType, themeData); - // act/assert - Assert.Throws(() => AttributedScopeStack.MergeAttributes(existing, scopesList, attrs)); + // act + int result = AttributedScopeStack.MergeAttributes(existing, scopesList, attrs); + + // assert + Assert.AreEqual(ExistingFontStyle, EncodedTokenAttributes.GetFontStyle(result)); + Assert.AreEqual(expectedForeground, EncodedTokenAttributes.GetForeground(result)); + Assert.AreEqual(expectedBackground, EncodedTokenAttributes.GetBackground(result)); } [Test] @@ -825,7 +871,14 @@ public void MergeAttributes_RuleFontStyleNotSet_PreservesExistingFontStyle() const int expectedForeground = 123; const int expectedBackground = 124; - ThemeTrieElementRule rule = new ThemeTrieElementRule("preserve-style", 1, null, FontStyle.NotSet, expectedForeground, expectedBackground); + List parentScopes = new List { AnyScopePath }; + ThemeTrieElementRule rule = new ThemeTrieElementRule( + "preserve-style", + 1, + parentScopes, + FontStyle.NotSet, + expectedForeground, + expectedBackground); List themeData = new List { rule }; BasicScopeAttributes attrs = new BasicScopeAttributes(NewLanguageId, NewTokenType, themeData); @@ -849,7 +902,14 @@ public void MergeAttributes_RuleForegroundZero_PreservesExistingForeground() const int expectedBackground = 124; const FontStyle expectedFontStyle = FontStyle.Italic; - ThemeTrieElementRule rule = new ThemeTrieElementRule("preserve-fg", 1, null, expectedFontStyle, 0, expectedBackground); + List parentScopes = new List { AnyScopePath }; + ThemeTrieElementRule rule = new ThemeTrieElementRule( + "preserve-fg", + 1, + parentScopes, + expectedFontStyle, + 0, + expectedBackground); List themeData = new List { rule }; BasicScopeAttributes attrs = new BasicScopeAttributes(NewLanguageId, NewTokenType, themeData); @@ -873,7 +933,13 @@ public void MergeAttributes_RuleBackgroundZero_PreservesExistingBackground() const int expectedForeground = 123; const FontStyle expectedFontStyle = FontStyle.Italic; - ThemeTrieElementRule rule = new ThemeTrieElementRule("preserve-bg", 1, null, expectedFontStyle, expectedForeground, 0); + ThemeTrieElementRule rule = new ThemeTrieElementRule( + "preserve-bg", + 1, + null, + expectedFontStyle, + expectedForeground, + 0); List themeData = new List { rule }; BasicScopeAttributes attrs = new BasicScopeAttributes(NewLanguageId, NewTokenType, themeData); @@ -907,14 +973,19 @@ public void MergeAttributes_NoRuleMatches_PreservesExistingStyleAndColors_ButUpd { // arrange int existing = CreateNonDefaultEncodedMetadata(); - AttributedScopeStack scopesList = CreateStack( ("source.csharp", existing), ("meta.using", existing), ("keyword.control", existing)); List nonMatchingParentScopes = new List { "does.not.exist" }; - ThemeTrieElementRule nonMatchingRule = new ThemeTrieElementRule("non-match", 1, nonMatchingParentScopes, FontStyle.Italic, 200, 201); + ThemeTrieElementRule nonMatchingRule = new ThemeTrieElementRule( + "non-match", + 1, + nonMatchingParentScopes, + FontStyle.Italic, + 200, + 201); List themeData = new List { nonMatchingRule }; BasicScopeAttributes attrs = new BasicScopeAttributes(NewLanguageId, NewTokenType, themeData); @@ -937,7 +1008,13 @@ public void MergeAttributes_ScopesListNull_RuleWithParentScopes_DoesNotMatch() int existing = CreateNonDefaultEncodedMetadata(); List parentScopes = new List { "source.csharp" }; - ThemeTrieElementRule rule = new ThemeTrieElementRule("requires-parent", 1, parentScopes, FontStyle.Italic, 200, 201); + ThemeTrieElementRule rule = new ThemeTrieElementRule( + "requires-parent", + 1, + parentScopes, + FontStyle.Italic, + 200, + 201); List themeData = new List { rule }; BasicScopeAttributes attrs = new BasicScopeAttributes(NewLanguageId, NewTokenType, themeData); @@ -965,7 +1042,13 @@ public void MergeAttributes_PrefixSelector_DoesNotMatch_WhenScopeDoesNotHaveDotB // selector "meta" should match "meta.something" but NOT "metadata.something" List parentScopes = new List { "meta" }; - ThemeTrieElementRule rule = new ThemeTrieElementRule("prefix-dot-boundary", 1, parentScopes, FontStyle.Italic, 200, 201); + ThemeTrieElementRule rule = new ThemeTrieElementRule( + "prefix-dot-boundary", + 1, + parentScopes, + FontStyle.Italic, + 200, + 201); List themeData = new List { rule }; BasicScopeAttributes attrs = new BasicScopeAttributes(NewLanguageId, NewTokenType, themeData); @@ -1001,7 +1084,13 @@ public void MergeAttributes_ParentScopesMatchNonContiguously_Works() const int expectedBackground = 211; const FontStyle expectedFontStyle = FontStyle.Underline; - ThemeTrieElementRule rule = new ThemeTrieElementRule("non-contiguous", 1, parentScopes, expectedFontStyle, expectedForeground, expectedBackground); + ThemeTrieElementRule rule = new ThemeTrieElementRule( + "non-contiguous", + 1, + parentScopes, + expectedFontStyle, + expectedForeground, + expectedBackground); List themeData = new List { rule }; BasicScopeAttributes attrs = new BasicScopeAttributes(NewLanguageId, NewTokenType, themeData); @@ -1019,20 +1108,14 @@ public void MergeAttributes_ParentScopesMatchNonContiguously_Works() public void MergeAttributes_PreservesBalancedBracketsBit_WhenContainsBalancedBracketsIsNull() { // arrange - const int existingLanguageId = ExistingLanguageId; - const int existingTokenType = ExistingTokenType; - const FontStyle existingFontStyle = ExistingFontStyle; - const int existingForeground = ExistingForeground; - const int existingBackground = ExistingBackground; - int existing = EncodedTokenAttributes.Set( 0, - existingLanguageId, - existingTokenType, + ExistingLanguageId, + ExistingTokenType, true, - existingFontStyle, - existingForeground, - existingBackground); + ExistingFontStyle, + ExistingForeground, + ExistingBackground); Assert.IsTrue(EncodedTokenAttributes.ContainsBalancedBrackets(existing)); @@ -1053,7 +1136,6 @@ public void MergeAttributes_WhenScopesListContainsNullScopePath_DoesNotThrow_And { // arrange int existing = CreateNonDefaultEncodedMetadata(); - AttributedScopeStack scopesList = CreateStack( ("source.csharp", existing), (null, existing), @@ -1078,6 +1160,118 @@ public void MergeAttributes_WhenScopesListContainsNullScopePath_DoesNotThrow_And #endregion MergeAttributes tests + #region MatchesScope tests + + [Test] + public void MergeAttributes_MatchesScope_NullScope_DoesNotMatch() + { + // arrange + int existing = CreateNonDefaultEncodedMetadata(); + AttributedScopeStack scopesList = new AttributedScopeStack(null, null, existing); + + List parentScopes = new List { "source" }; + ThemeTrieElementRule rule = new ThemeTrieElementRule("null-scope", 1, parentScopes, FontStyle.Italic, 101, 102); + BasicScopeAttributes attrs = new BasicScopeAttributes(NewLanguageId, NewTokenType, new List { rule }); + + // act + int result = AttributedScopeStack.MergeAttributes(existing, scopesList, attrs); + + // assert + Assert.AreEqual(ExistingFontStyle, EncodedTokenAttributes.GetFontStyle(result)); + Assert.AreEqual(ExistingForeground, EncodedTokenAttributes.GetForeground(result)); + Assert.AreEqual(ExistingBackground, EncodedTokenAttributes.GetBackground(result)); + } + + [Test] + public void MergeAttributes_MatchesScope_NullSelector_DoesNotMatch() + { + // arrange + int existing = CreateNonDefaultEncodedMetadata(); + AttributedScopeStack scopesList = new AttributedScopeStack(null, "source.js", existing); + + List parentScopes = new List { null }; + ThemeTrieElementRule rule = new ThemeTrieElementRule("null-selector", 1, parentScopes, FontStyle.Italic, 111, 112); + BasicScopeAttributes attrs = new BasicScopeAttributes(NewLanguageId, NewTokenType, new List { rule }); + + // act + int result = AttributedScopeStack.MergeAttributes(existing, scopesList, attrs); + + // assert + Assert.AreEqual(ExistingFontStyle, EncodedTokenAttributes.GetFontStyle(result)); + Assert.AreEqual(ExistingForeground, EncodedTokenAttributes.GetForeground(result)); + Assert.AreEqual(ExistingBackground, EncodedTokenAttributes.GetBackground(result)); + } + + [Test] + public void MergeAttributes_MatchesScope_ExactMatch_AppliesRule() + { + // arrange + int existing = CreateNonDefaultEncodedMetadata(); + AttributedScopeStack scopesList = new AttributedScopeStack(null, "source.js", existing); + + const int expectedForeground = 201; + const int expectedBackground = 202; + const FontStyle expectedFontStyle = FontStyle.Underline; + + List parentScopes = new List { "source.js" }; + ThemeTrieElementRule rule = new ThemeTrieElementRule("exact-match", 1, parentScopes, expectedFontStyle, expectedForeground, expectedBackground); + BasicScopeAttributes attrs = new BasicScopeAttributes(NewLanguageId, NewTokenType, new List { rule }); + + // act + int result = AttributedScopeStack.MergeAttributes(existing, scopesList, attrs); + + // assert + Assert.AreEqual(expectedFontStyle, EncodedTokenAttributes.GetFontStyle(result)); + Assert.AreEqual(expectedForeground, EncodedTokenAttributes.GetForeground(result)); + Assert.AreEqual(expectedBackground, EncodedTokenAttributes.GetBackground(result)); + } + + [Test] + public void MergeAttributes_MatchesScope_PrefixWithDot_AppliesRule() + { + // arrange + int existing = CreateNonDefaultEncodedMetadata(); + AttributedScopeStack scopesList = new AttributedScopeStack(null, "source.js", existing); + + const int expectedForeground = 211; + const int expectedBackground = 212; + const FontStyle expectedFontStyle = FontStyle.Italic; + + List parentScopes = new List { "source" }; + ThemeTrieElementRule rule = new ThemeTrieElementRule("prefix-dot", 1, parentScopes, expectedFontStyle, expectedForeground, expectedBackground); + BasicScopeAttributes attrs = new BasicScopeAttributes(NewLanguageId, NewTokenType, new List { rule }); + + // act + int result = AttributedScopeStack.MergeAttributes(existing, scopesList, attrs); + + // assert + Assert.AreEqual(expectedFontStyle, EncodedTokenAttributes.GetFontStyle(result)); + Assert.AreEqual(expectedForeground, EncodedTokenAttributes.GetForeground(result)); + Assert.AreEqual(expectedBackground, EncodedTokenAttributes.GetBackground(result)); + } + + [Test] + public void MergeAttributes_MatchesScope_PrefixWithoutDot_DoesNotMatch() + { + // arrange + int existing = CreateNonDefaultEncodedMetadata(); + AttributedScopeStack scopesList = new AttributedScopeStack(null, "sourcejs", existing); + + List parentScopes = new List { "source" }; + ThemeTrieElementRule rule = new ThemeTrieElementRule("prefix-no-dot", 1, parentScopes, FontStyle.Italic, 221, 222); + BasicScopeAttributes attrs = new BasicScopeAttributes(NewLanguageId, NewTokenType, new List { rule }); + + // act + int result = AttributedScopeStack.MergeAttributes(existing, scopesList, attrs); + + // assert + Assert.AreEqual(ExistingFontStyle, EncodedTokenAttributes.GetFontStyle(result)); + Assert.AreEqual(ExistingForeground, EncodedTokenAttributes.GetForeground(result)); + Assert.AreEqual(ExistingBackground, EncodedTokenAttributes.GetBackground(result)); + } + + #endregion MatchesScope tests + #region PushAttributed tests [Test] @@ -1123,10 +1317,88 @@ public void PushAttributed_EmptyStringScope_WithNullGrammar_ThrowsArgumentNullEx Assert.Throws(() => stack.PushAtributed("", null)); } + [Test] + public void PushAttributed_ShouldHandleTrailingSpacesAndProduceEmptySegment() + { + // Arrange + TextMateSharp.Internal.Grammars.Grammar grammar = CreateTestGrammar(); + AttributedScopeStack initial = new AttributedScopeStack(null, "root", 0); + + // Act + AttributedScopeStack result = initial.PushAtributed("a b ", grammar); + List scopes = result.GetScopeNames(); + + // Assert + CollectionAssert.AreEqual(new List { "root", "a", "b", "" }, scopes); + } + + [Test] + public void PushAttributed_MultiScope_ProducesSegments() + { + // Arrange + TextMateSharp.Internal.Grammars.Grammar grammar = CreateTestGrammar(); + AttributedScopeStack initial = new AttributedScopeStack(null, "root", 0); + + // Act + AttributedScopeStack result = initial.PushAtributed("a b", grammar); + List scopes = result.GetScopeNames(); + + // Assert + CollectionAssert.AreEqual(new List { "root", "a", "b" }, scopes); + } + + [Test] + public void PushAttributed_SingleScope_PreservesScopeStringInstance() + { + // Arrange + TextMateSharp.Internal.Grammars.Grammar grammar = CreateTestGrammar(); + AttributedScopeStack initial = new AttributedScopeStack(null, "root", 0); + const string scopePath = "single.scope"; + + // Act + AttributedScopeStack result = initial.PushAtributed(scopePath, grammar); + + // Assert + Assert.AreSame(scopePath, result.ScopePath); + } + #endregion PushAttributed tests #region Helpers + private static TextMateSharp.Internal.Grammars.Grammar CreateTestGrammar() + { + const string scopeName = "source.test"; + Raw rawGrammar = new Raw + { + ["scopeName"] = scopeName + }; + + ThemeTrieElementRule defaults = new ThemeTrieElementRule( + "defaults", + 0, + null, + ExistingFontStyle, + ExistingForeground, + ExistingBackground); + + Mock themeProvider = new Mock(); + themeProvider.Setup(provider => provider.GetDefaults()).Returns(defaults); + themeProvider + .Setup(provider => provider.ThemeMatch(It.IsAny>())) + .Returns(new List()); + + return new TextMateSharp.Internal.Grammars.Grammar( + scopeName, + rawGrammar, + 0, + null, + null, + new BalancedBracketSelectors(new List(), new List()), + new Mock().Object, + themeProvider.Object); + } + private static AttributedScopeStack CreateStack(params (string ScopePath, int TokenAttributes)[] frames) { AttributedScopeStack current = null; diff --git a/src/TextMateSharp/Internal/Grammars/AttributedScopeStack.cs b/src/TextMateSharp/Internal/Grammars/AttributedScopeStack.cs index f9ae57f..708e52b 100644 --- a/src/TextMateSharp/Internal/Grammars/AttributedScopeStack.cs +++ b/src/TextMateSharp/Internal/Grammars/AttributedScopeStack.cs @@ -11,14 +11,16 @@ public class AttributedScopeStack public string ScopePath { get; private set; } public int TokenAttributes { get; private set; } private List _cachedScopeNames; - private bool _hasCachedHashCode; - private int _cachedHashCode; + + // Precomputed, per-node hash code (persistent structure => safe as long as instances are immutable) + private readonly int _hashCode; public AttributedScopeStack(AttributedScopeStack parent, string scopePath, int tokenAttributes) { Parent = parent; ScopePath = scopePath; TokenAttributes = tokenAttributes; + _hashCode = ComputeHashCode(parent, scopePath, tokenAttributes); } private static bool StructuralEquals(AttributedScopeStack a, AttributedScopeStack b) @@ -36,7 +38,8 @@ private static bool StructuralEquals(AttributedScopeStack a, AttributedScopeStac return false; } - if (a.ScopePath != b.ScopePath || a.TokenAttributes != b.TokenAttributes) + if (!string.Equals(a.ScopePath, b.ScopePath, StringComparison.Ordinal) || + a.TokenAttributes != b.TokenAttributes) { return false; } @@ -62,65 +65,58 @@ private static bool Equals(AttributedScopeStack a, AttributedScopeStack b) public override bool Equals(object other) { - if (other == null || !(other is AttributedScopeStack)) - return false; + if (other is AttributedScopeStack attributedScopeStack) + return Equals(this, attributedScopeStack); - return Equals(this, (AttributedScopeStack)other); + return false; } public override int GetHashCode() { - if (_hasCachedHashCode) - { - return _cachedHashCode; - } - - int computedHashCode = ComputeHashCode(); - _cachedHashCode = computedHashCode; - _hasCachedHashCode = true; - - return computedHashCode; + return _hashCode; } - private int ComputeHashCode() + private static int ComputeHashCode(AttributedScopeStack parent, string scopePath, int tokenAttributes) { - // adding for future implementation if/when support for .NET Standard 2.1 or .NET Core 2.1 is added, which includes System.HashCode -#if NETCOREAPP2_1_OR_GREATER || NETSTANDARD2_1_OR_GREATER - HashCode hashCode = new HashCode(); - - AttributedScopeStack current = this; - while (current != null) - { - hashCode.Add(current.TokenAttributes); - hashCode.Add(current.ScopePath); - current = current.Parent; - } - - return hashCode.ToHashCode(); -#else unchecked { - int hash = 17; + int hash = parent?._hashCode ?? 17; + hash = (hash * 31) + tokenAttributes; - AttributedScopeStack current = this; - while (current != null) + int scopeHashCode; + if (scopePath == null) { - hash = (hash * 31) + current.TokenAttributes; - - int scopeHashCode = current.ScopePath?.GetHashCode() ?? 0; - hash = (hash * 31) + scopeHashCode; - - current = current.Parent; + scopeHashCode = 0; } + else + { + scopeHashCode = StringComparer.Ordinal.GetHashCode(scopePath); + } + + hash = (hash * 31) + scopeHashCode; return hash; } -#endif } - static bool MatchesScope(string scope, string selector, string selectorWithDot) + static bool MatchesScope(string scope, string selector) { - return (selector.Equals(scope) || scope.StartsWith(selectorWithDot)); + if (scope == null || selector == null) + { + return false; + } + + int selectorLen = selector.Length; + int scopeLen = scope.Length; + + if (scopeLen == selectorLen) + return string.Equals(scope, selector, StringComparison.Ordinal); + + // scope must be longer than selector and have a '.' immediately after the selector prefix + if (scopeLen > selectorLen && scope[selectorLen] == '.') + return string.CompareOrdinal(scope, 0, selector, 0, selectorLen) == 0; + + return false; } static bool Matches(AttributedScopeStack target, List parentScopes) @@ -133,11 +129,10 @@ static bool Matches(AttributedScopeStack target, List parentScopes) int len = parentScopes.Count; int index = 0; string selector = parentScopes[index]; - string selectorWithDot = selector + "."; while (target != null) { - if (MatchesScope(target.ScopePath, selector, selectorWithDot)) + if (MatchesScope(target.ScopePath, selector)) { index++; if (index == len) @@ -145,7 +140,6 @@ static bool Matches(AttributedScopeStack target, List parentScopes) return true; } selector = parentScopes[index]; - selectorWithDot = selector + '.'; } target = target.Parent; } @@ -170,8 +164,10 @@ public static int MergeAttributes( if (basicScopeAttributes.ThemeData != null) { // Find the first themeData that matches - foreach (ThemeTrieElementRule themeData in basicScopeAttributes.ThemeData) + List themeDataList = basicScopeAttributes.ThemeData; + for (int i = 0; i < themeDataList.Count; i++) { + ThemeTrieElementRule themeData = themeDataList[i]; if (Matches(scopesList, themeData.parentScopes)) { fontStyle = themeData.fontStyle; @@ -192,15 +188,44 @@ public static int MergeAttributes( background); } - private static AttributedScopeStack Push(AttributedScopeStack target, Grammar grammar, List scopes) + private static AttributedScopeStack Push(AttributedScopeStack target, Grammar grammar, string scopePath) { - foreach (string scope in scopes) + ReadOnlySpan remaining = scopePath.AsSpan(); + + // Use while(true) instead of while(remaining.Length > 0) to match + // StringSplitOptions.None behavior: if the string ends with a space, the final + // slice produces an empty span, and we must still push that empty segment + // (e.g. "a b " => push "a", "b", "") + while (true) { - target = PushSingleScope(target, grammar, scope); + int spaceIndex = remaining.IndexOf(' '); + if (spaceIndex < 0) + { + target = PushSingleScope(target, grammar, GetScopeSlice(scopePath, remaining)); + break; + } + + target = PushSingleScope(target, grammar, GetScopeSlice(scopePath, remaining.Slice(0, spaceIndex))); + remaining = remaining.Slice(spaceIndex + 1); } return target; } + private static string GetScopeSlice(string scopePath, ReadOnlySpan slice) + { + if (slice.IsEmpty) + { + return string.Empty; + } + + if (slice.Length == scopePath.Length) + { + return scopePath; + } + + return slice.ToString(); + } + private static AttributedScopeStack PushSingleScope(AttributedScopeStack target, Grammar grammar, string scope) { BasicScopeAttributes rawMetadata = grammar.GetMetadataForScope(scope); @@ -219,7 +244,7 @@ public AttributedScopeStack PushAtributed(string scopePath, Grammar grammar) if (scopePath.IndexOf(' ') >= 0) { // there are multiple scopes to push - return Push(this, grammar, new List(scopePath.Split(new[] {" "}, StringSplitOptions.None))); + return Push(this, grammar, scopePath); } // there is a single scope to push - avoid List allocation return PushSingleScope(this, grammar, scopePath); @@ -236,14 +261,26 @@ public List GetScopeNames() private static List GenerateScopes(AttributedScopeStack scopesList) { - List result = new List(); - while (scopesList != null) + // First pass: count depth to pre-size the array + int depth = 0; + AttributedScopeStack current = scopesList; + while (current != null) { - result.Add(scopesList.ScopePath); - scopesList = scopesList.Parent; + depth++; + current = current.Parent; } - result.Reverse(); - return result; + + // Second pass: fill backwards directly to avoid a Reverse() call + string[] scopes = new string[depth]; + current = scopesList; + for (int i = depth - 1; i >= 0; i--) + { + scopes[i] = current.ScopePath; + current = current.Parent; + } + + // Construct list from the correctly-ordered array + return new List(scopes); } } -} +} \ No newline at end of file From 22c22158fb5a7f3b562087cfef92c0b876a0b479 Mon Sep 17 00:00:00 2001 From: Dave Black <656118+udlose@users.noreply.github.com> Date: Sun, 22 Feb 2026 11:00:00 -0600 Subject: [PATCH 04/33] ### BREAKING CHANGE: the MergeAttributes method used to (implcitly) throw ArgumentOutOfRangeException thru the Matches method if an empty scopesList param was passed. This was because there was no length check on the collection before the collection was indexed. I opted to change Matches to return true if parentScopes is empty. Let me know if you agree with this change. Updated the Matches method to return true when parentScopes is null or empty, ensuring correct behavior when no parent scopes are provided. --- src/TextMateSharp/Internal/Grammars/AttributedScopeStack.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/TextMateSharp/Internal/Grammars/AttributedScopeStack.cs b/src/TextMateSharp/Internal/Grammars/AttributedScopeStack.cs index 708e52b..cb2b0f9 100644 --- a/src/TextMateSharp/Internal/Grammars/AttributedScopeStack.cs +++ b/src/TextMateSharp/Internal/Grammars/AttributedScopeStack.cs @@ -121,7 +121,7 @@ static bool MatchesScope(string scope, string selector) static bool Matches(AttributedScopeStack target, List parentScopes) { - if (parentScopes == null) + if (parentScopes == null || parentScopes.Count == 0) { return true; } From 6ad857beb40a13a716d5937008843b812b1f5b34 Mon Sep 17 00:00:00 2001 From: Dave Black <656118+udlose@users.noreply.github.com> Date: Sun, 22 Feb 2026 13:35:19 -0600 Subject: [PATCH 05/33] Add .gitignore rules for Visual Studio Live Unit Testing files Updated .gitignore to exclude Visual Studio Live Unit Testing files, specifically those with the .lutconfig extension, to prevent committing generated test artifacts. --- .gitignore | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index e773ddd..649361b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,7 @@ ## Ignore Visual Studio temporary files, build results, and ## files generated by popular Visual Studio add-ons. ## -## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore +## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore # User-specific files *.rsuser @@ -43,6 +43,9 @@ Generated\ Files/ [Tt]est[Rr]esult*/ [Bb]uild[Ll]og.* +# VisualStudio live unit testing +*.lutconfig + # NUnit *.VisualState.xml TestResult.xml From c5ac86e92dd7226eb79dc268921c96ea88a5f808 Mon Sep 17 00:00:00 2001 From: Dave Black <656118+udlose@users.noreply.github.com> Date: Thu, 26 Feb 2026 21:05:51 -0600 Subject: [PATCH 06/33] Refactor dictionary access in SimpleJSON for clarity Simplified dictionary add/update logic by directly assigning values without checking for key existence. Replaced ContainsKey with TryGetValue in Remove(string aKey) for efficiency. Refactored Remove(JSONNode aNode) to avoid LINQ and exception handling, using a straightforward iteration to find and remove the node. These changes improve code clarity and maintainability. --- .../Internal/Parser/Json/SimpleJSON.cs | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/src/TextMateSharp/Internal/Parser/Json/SimpleJSON.cs b/src/TextMateSharp/Internal/Parser/Json/SimpleJSON.cs index 9703d75..e151b88 100644 --- a/src/TextMateSharp/Internal/Parser/Json/SimpleJSON.cs +++ b/src/TextMateSharp/Internal/Parser/Json/SimpleJSON.cs @@ -871,10 +871,8 @@ public override JSONNode this[string aKey] { if (value == null) value = JSONNull.CreateOrGet(); - if (m_Dict.ContainsKey(aKey)) - m_Dict[aKey] = value; - else - m_Dict.Add(aKey, value); + + m_Dict[aKey] = value; } } @@ -909,10 +907,7 @@ public override void Add(string aKey, JSONNode aItem) if (aKey != null) { - if (m_Dict.ContainsKey(aKey)) - m_Dict[aKey] = aItem; - else - m_Dict.Add(aKey, aItem); + m_Dict[aKey] = aItem; } else m_Dict.Add(Guid.NewGuid().ToString(), aItem); @@ -920,9 +915,8 @@ public override void Add(string aKey, JSONNode aItem) public override JSONNode Remove(string aKey) { - if (!m_Dict.ContainsKey(aKey)) + if (!m_Dict.TryGetValue(aKey, out var tmp)) return null; - JSONNode tmp = m_Dict[aKey]; m_Dict.Remove(aKey); return tmp; } @@ -938,16 +932,22 @@ public override JSONNode Remove(int aIndex) public override JSONNode Remove(JSONNode aNode) { - try + string keyToRemove = null; + foreach (var pair in m_Dict) { - var item = m_Dict.Where(k => k.Value == aNode).First(); - m_Dict.Remove(item.Key); - return aNode; + if (pair.Value == aNode) + { + keyToRemove = pair.Key; + break; + } } - catch + + if (keyToRemove != null) { - return null; + m_Dict.Remove(keyToRemove); + return aNode; } + return null; } public override void Clear() From 9a6dc1316456693a28092c88b74022b7c888cde3 Mon Sep 17 00:00:00 2001 From: Dave Black <656118+udlose@users.noreply.github.com> Date: Thu, 26 Feb 2026 21:34:00 -0600 Subject: [PATCH 07/33] Add comprehensive StateStack.ToString() unit tests Introduced StateStackTests with extensive NUnit test coverage for the StateStack.ToString() method. Tests include various stack depths, special rule IDs, boundary conditions, repeated calls, and correct rule ordering. No changes to existing code. --- .../Grammar/StateStackTests.cs | 342 ++++++++++++++++++ 1 file changed, 342 insertions(+) create mode 100644 src/TextMateSharp.Tests/Grammar/StateStackTests.cs diff --git a/src/TextMateSharp.Tests/Grammar/StateStackTests.cs b/src/TextMateSharp.Tests/Grammar/StateStackTests.cs new file mode 100644 index 0000000..fd57b0e --- /dev/null +++ b/src/TextMateSharp.Tests/Grammar/StateStackTests.cs @@ -0,0 +1,342 @@ +using NUnit.Framework; +using TextMateSharp.Grammars; +using TextMateSharp.Internal.Grammars; +using TextMateSharp.Internal.Rules; + +namespace TextMateSharp.Tests.Grammar +{ + [TestFixture] + public class StateStackTests + { + private const int RuleIdSingleDepth = 42; + private const int RuleIdDepthTwo = 100; + private const int RuleIdDepthThree = 200; + private const int EnterPosition = 0; + private const int AnchorPosition = 0; + + [Test] + public void ToString_SingleDepthState_ReturnsFormattedString() + { + // Arrange + StateStack stack = new StateStack( + null, + RuleId.Of(RuleIdSingleDepth), + EnterPosition, + AnchorPosition, + false, + null, + CreateTestScopeStack(), + CreateTestScopeStack()); + + const string expectedOutput = "[(42)]"; + + // Act + string result = stack.ToString(); + + // Assert + Assert.AreEqual(expectedOutput, result); + } + + [Test] + public void ToString_TwoDepthState_ReturnsFormattedStringWithBothRules() + { + // Arrange + StateStack parent = new StateStack( + null, + RuleId.Of(RuleIdSingleDepth), + EnterPosition, + AnchorPosition, + false, + null, + CreateTestScopeStack(), + CreateTestScopeStack()); + + StateStack stack = parent.Push( + RuleId.Of(RuleIdDepthTwo), + EnterPosition, + AnchorPosition, + false, + null, + CreateTestScopeStack(), + CreateTestScopeStack()); + + const string expectedOutput = "[(42), (100)]"; + + // Act + string result = stack.ToString(); + + // Assert + Assert.AreEqual(expectedOutput, result); + } + + [Test] + public void ToString_ThreeDepthState_ReturnsFormattedStringWithAllRules() + { + // Arrange + StateStack level1 = new StateStack( + null, + RuleId.Of(RuleIdSingleDepth), + EnterPosition, + AnchorPosition, + false, + null, + CreateTestScopeStack(), + CreateTestScopeStack()); + + StateStack level2 = level1.Push( + RuleId.Of(RuleIdDepthTwo), + EnterPosition, + AnchorPosition, + false, + null, + CreateTestScopeStack(), + CreateTestScopeStack()); + + StateStack level3 = level2.Push( + RuleId.Of(RuleIdDepthThree), + EnterPosition, + AnchorPosition, + false, + null, + CreateTestScopeStack(), + CreateTestScopeStack()); + + const string expectedOutput = "[(42), (100), (200)]"; + + // Act + string result = level3.ToString(); + + // Assert + Assert.AreEqual(expectedOutput, result); + } + + [Test] + public void ToString_NullStaticInstance_ReturnsFormattedNoRuleString() + { + // Arrange + StateStack stack = StateStack.NULL; + const string expectedOutput = "[(0)]"; + + // Act + string result = stack.ToString(); + + // Assert + Assert.AreEqual(expectedOutput, result); + } + + [Test] + public void ToString_StateWithNoRuleId_ReturnsFormattedNoRuleString() + { + // Arrange + StateStack stack = new StateStack( + null, + RuleId.NO_RULE, + EnterPosition, + AnchorPosition, + false, + null, + CreateTestScopeStack(), + CreateTestScopeStack()); + + const string expectedOutput = "[(0)]"; + + // Act + string result = stack.ToString(); + + // Assert + Assert.AreEqual(expectedOutput, result); + } + + [Test] + public void ToString_StateWithEndRuleId_ReturnsFormattedEndRuleString() + { + // Arrange + StateStack stack = new StateStack( + null, + RuleId.END_RULE, + EnterPosition, + AnchorPosition, + false, + null, + CreateTestScopeStack(), + CreateTestScopeStack()); + + const string expectedOutput = "[(-1)]"; + + // Act + string result = stack.ToString(); + + // Assert + Assert.AreEqual(expectedOutput, result); + } + + [Test] + public void ToString_StateWithWhileRuleId_ReturnsFormattedWhileRuleString() + { + // Arrange + StateStack stack = new StateStack( + null, + RuleId.WHILE_RULE, + EnterPosition, + AnchorPosition, + false, + null, + CreateTestScopeStack(), + CreateTestScopeStack()); + + const string expectedOutput = "[(-2)]"; + + // Act + string result = stack.ToString(); + + // Assert + Assert.AreEqual(expectedOutput, result); + } + + [Test] + public void ToString_BoundaryDepthZero_ReturnsNullStackString() + { + // Arrange - depth 0 returns StateStack.NULL + const int depthZero = 0; + StateStack stack = CreateStateStackWithDepth(depthZero); + const string expectedOutput = "[(0)]"; + + // Act + string result = stack.ToString(); + + // Assert + Assert.AreEqual(expectedOutput, result); + Assert.AreSame(StateStack.NULL, stack); + } + + [Test] + public void ToString_BoundaryDepthOne_ReturnsSinglePushString() + { + // Arrange - depth 1 is one push on NULL + const int depthOne = 1; + StateStack stack = CreateStateStackWithDepth(depthOne); + const string expectedOutput = "[(0), (0)]"; + + // Act + string result = stack.ToString(); + + // Assert + Assert.AreEqual(expectedOutput, result); + } + + [Test] + public void ToString_BoundaryVeryLargeDepth_ReturnsFormattedStringWithAllLevels() + { + // Arrange - test large depth to verify performance and correctness + const int veryLargeDepth = 100; + StateStack stack = CreateStateStackWithDepth(veryLargeDepth); + + // Act + string result = stack.ToString(); + + // Assert + Assert.IsNotNull(result); + Assert.IsTrue(result.StartsWith("[(0)")); + Assert.IsTrue(result.EndsWith("(9900)]")); + + // Verify correct number of elements: NULL (1) + 100 pushes = 101 elements + const int expectedCommaCount = 100; + int actualCommaCount = result.Split(',').Length - 1; + Assert.AreEqual(expectedCommaCount, actualCommaCount); + } + + [Test] + public void ToString_CalledMultipleTimes_ReturnsSameResult() + { + // Arrange + StateStack stack = new StateStack( + null, + RuleId.Of(RuleIdSingleDepth), + EnterPosition, + AnchorPosition, + false, + null, + CreateTestScopeStack(), + CreateTestScopeStack()); + + // Act + string result1 = stack.ToString(); + string result2 = stack.ToString(); + string result3 = stack.ToString(); + + // Assert + Assert.AreEqual(result1, result2); + Assert.AreEqual(result2, result3); + } + + [Test] + public void ToString_StackWithMixedRuleIds_ReturnsCorrectOrderFromRootToCurrent() + { + // Arrange + StateStack root = new StateStack( + null, + RuleId.Of(RuleIdSingleDepth), + EnterPosition, + AnchorPosition, + false, + null, + CreateTestScopeStack(), + CreateTestScopeStack()); + + StateStack middle = root.Push( + RuleId.Of(RuleIdDepthTwo), + EnterPosition, + AnchorPosition, + false, + null, + CreateTestScopeStack(), + CreateTestScopeStack()); + + StateStack current = middle.Push( + RuleId.Of(RuleIdDepthThree), + EnterPosition, + AnchorPosition, + false, + null, + CreateTestScopeStack(), + CreateTestScopeStack()); + + const string expectedOutput = "[(42), (100), (200)]"; + + // Act + string result = current.ToString(); + + // Assert + Assert.AreEqual(expectedOutput, result); + } + + #region Helper Methods + + private static AttributedScopeStack CreateTestScopeStack() + { + return new AttributedScopeStack(null, "test.scope", 0); + } + + private static StateStack CreateStateStackWithDepth(int depth) + { + StateStack stack = StateStack.NULL; + const int ruleIdDepthMultiplier = 100; + for (int depthIndex = 0; depthIndex < depth; depthIndex++) + { + int ruleId = depthIndex * ruleIdDepthMultiplier; + stack = stack.Push( + RuleId.Of(ruleId), + EnterPosition, + AnchorPosition, + false, + null, + CreateTestScopeStack(), + CreateTestScopeStack()); + } + + return stack; + } + + #endregion + } +} \ No newline at end of file From 0e98cd52c3c04855b2a5a347e699e15a265fcea2 Mon Sep 17 00:00:00 2001 From: Dave Black <656118+udlose@users.noreply.github.com> Date: Thu, 26 Feb 2026 21:45:04 -0600 Subject: [PATCH 08/33] Refactor StateStack:ToString methods for allocations Refactored the StateStack class to improve code clarity and performance. Updated using directives, modernized the Equals method with pattern matching, and reimplemented ToString using StringBuilder and an array of RuleIds for efficiency. Removed the old recursive string construction logic. Reformatted GetHashCode for readability. These changes enhance maintainability and performance. --- src/TextMateSharp/Grammar/StateStack.cs | 53 ++++++++++++++++--------- 1 file changed, 35 insertions(+), 18 deletions(-) diff --git a/src/TextMateSharp/Grammar/StateStack.cs b/src/TextMateSharp/Grammar/StateStack.cs index aaa248b..46e0711 100644 --- a/src/TextMateSharp/Grammar/StateStack.cs +++ b/src/TextMateSharp/Grammar/StateStack.cs @@ -1,6 +1,5 @@ using System; -using System.Collections.Generic; - +using System.Text; using TextMateSharp.Internal.Grammars; using TextMateSharp.Internal.Rules; @@ -81,18 +80,21 @@ public override bool Equals(Object other) { return false; } - if (!(other is StateStack)) { - return false; + + if (other is StateStack stackElement) + { + return StructuralEquals(this, stackElement) && + this.ContentNameScopesList.Equals(stackElement.ContentNameScopesList); } - StateStack stackElement = (StateStack)other; - return StructuralEquals(this, stackElement) && this.ContentNameScopesList.Equals(stackElement.ContentNameScopesList); + + return false; } public override int GetHashCode() { - return Depth.GetHashCode() + + return Depth.GetHashCode() + RuleId.GetHashCode() + - EndRule.GetHashCode() + + EndRule.GetHashCode() + Parent.GetHashCode() + ContentNameScopesList.GetHashCode(); } @@ -157,21 +159,36 @@ public Rule GetRule(IRuleRegistry grammar) return grammar.GetRule(this.RuleId); } - private void AppendString(List res) + public override string ToString() { - if (this.Parent != null) + int depth = this.Depth; + RuleId[] ruleIds = new RuleId[depth]; + StateStack current = this; + + for (int i = depth - 1; i >= 0; i--) { - this.Parent.AppendString(res); + ruleIds[i] = current.RuleId; + current = current.Parent; } - res.Add('(' + this.RuleId.ToString() + ')'); //, TODO-${this.nameScopesList}, TODO-${this.contentNameScopesList})`; - } + const int estimatedCharsPerRuleId = 8; + StringBuilder builder = new StringBuilder(16 + (depth * estimatedCharsPerRuleId)); + builder.Append('['); - public override string ToString() - { - List r = new List(); - this.AppendString(r); - return '[' + string.Join(", ", r) + ']'; + for (int i = 0; i < depth; i++) + { + if (i > 0) + { + builder.Append(", "); + } + + builder.Append('('); + builder.Append(ruleIds[i].ToString()); //, TODO-${this.nameScopesList}, TODO-${this.contentNameScopesList})`; + builder.Append(')'); + } + + builder.Append(']'); + return builder.ToString(); } public StateStack WithContentNameScopesList(AttributedScopeStack contentNameScopesList) From a3d00bcc9405013f407d48e0667a7deb603e92d7 Mon Sep 17 00:00:00 2001 From: Dave Black <656118+udlose@users.noreply.github.com> Date: Thu, 26 Feb 2026 22:07:30 -0600 Subject: [PATCH 09/33] Refactor rule type checks to use pattern matching to avoid casts Simplified type checking and casting for IncludeOnlyRule, BeginEndRule, and BeginWhileRule by using C# pattern matching. This improves code readability and removes redundant casts and variable declarations. --- src/TextMateSharp/Internal/Rules/RuleFactory.cs | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/src/TextMateSharp/Internal/Rules/RuleFactory.cs b/src/TextMateSharp/Internal/Rules/RuleFactory.cs index 94ad72c..b13e33f 100644 --- a/src/TextMateSharp/Internal/Rules/RuleFactory.cs +++ b/src/TextMateSharp/Internal/Rules/RuleFactory.cs @@ -227,26 +227,23 @@ private static CompilePatternsResult CompilePatterns(ICollection patte skipRule = false; - if (rule is IncludeOnlyRule) + if (rule is IncludeOnlyRule ior) { - IncludeOnlyRule ior = (IncludeOnlyRule)rule; if (ior.HasMissingPatterns && ior.Patterns.Count == 0) { skipRule = true; } } - else if (rule is BeginEndRule) + else if (rule is BeginEndRule br) { - BeginEndRule br = (BeginEndRule)rule; if (br.HasMissingPatterns && br.Patterns.Count == 0) { skipRule = true; } } - else if (rule is BeginWhileRule) + else if (rule is BeginWhileRule bwRule) { - BeginWhileRule br = (BeginWhileRule)rule; - if (br.HasMissingPatterns && br.Patterns.Count == 0) + if (bwRule.HasMissingPatterns && bwRule.Patterns.Count == 0) { skipRule = true; } From 9eb418545c804e1b1556c533c744588e6e8f4611 Mon Sep 17 00:00:00 2001 From: Dave Black <656118+udlose@users.noreply.github.com> Date: Thu, 26 Feb 2026 23:03:48 -0600 Subject: [PATCH 10/33] Add comprehensive unit tests for theme parsing and logic Introduce ParsedThemeTests and ThemeTests to cover a wide range of scenarios for theme parsing and management. Tests include handling of null/empty/whitespace includes, color ID management for various formats, GUI color dictionary behavior, color map correctness, scope matching, and rule overwriting. Utilizes Moq and NUnit for thorough coverage and reliability. --- .../Themes/ParsedThemeTests.cs | 206 +++++ src/TextMateSharp.Tests/Themes/ThemeTests.cs | 769 ++++++++++++++++++ 2 files changed, 975 insertions(+) create mode 100644 src/TextMateSharp.Tests/Themes/ParsedThemeTests.cs create mode 100644 src/TextMateSharp.Tests/Themes/ThemeTests.cs diff --git a/src/TextMateSharp.Tests/Themes/ParsedThemeTests.cs b/src/TextMateSharp.Tests/Themes/ParsedThemeTests.cs new file mode 100644 index 0000000..3e6eb98 --- /dev/null +++ b/src/TextMateSharp.Tests/Themes/ParsedThemeTests.cs @@ -0,0 +1,206 @@ +using Moq; +using NUnit.Framework; +using System.Collections.Generic; +using TextMateSharp.Internal.Themes; +using TextMateSharp.Registry; +using TextMateSharp.Themes; + +namespace TextMateSharp.Tests.Themes; + +[TestFixture] +public class ParsedThemeTests +{ + [Test] + public void ParseInclude_SourceGetIncludeReturnsNull_ReturnsEmptyListAndSetsThemeIncludeToNull() + { + // Arrange + Mock mockSource = new Mock(); + mockSource.Setup(s => s.GetInclude()).Returns((string)null); + Mock mockRegistryOptions = new Mock(); + const int priority = 5; + + // Act + List result = ParsedTheme.ParseInclude(mockSource.Object, mockRegistryOptions.Object, priority, out IRawTheme themeInclude); + + // Assert + Assert.IsNotNull(result); + CollectionAssert.IsEmpty(result); + Assert.IsNull(themeInclude); + mockRegistryOptions.Verify(r => r.GetTheme(It.IsAny()), Times.Never); + } + + [Test] + public void ParseInclude_SourceGetIncludeReturnsEmpty_ReturnsEmptyListAndSetsThemeIncludeToNull() + { + // Arrange + Mock mockSource = new Mock(); + mockSource.Setup(s => s.GetInclude()).Returns(string.Empty); + Mock mockRegistryOptions = new Mock(); + const int priority = 10; + + // Act + List result = ParsedTheme.ParseInclude(mockSource.Object, mockRegistryOptions.Object, priority, out IRawTheme themeInclude); + + // Assert + Assert.IsNotNull(result); + CollectionAssert.IsEmpty(result); + Assert.IsNull(themeInclude); + mockRegistryOptions.Verify(r => r.GetTheme(It.IsAny()), Times.Never); + } + + [Test] + public void ParseInclude_GetThemeReturnsNull_ReturnsEmptyList() + { + // Arrange + const string includeString = "valid-include-name"; + Mock mockSource = new Mock(); + mockSource.Setup(s => s.GetInclude()).Returns(includeString); + Mock mockRegistryOptions = new Mock(); + mockRegistryOptions.Setup(r => r.GetTheme(includeString)).Returns((IRawTheme)null); + const int priority = 0; + + // Act + List result = ParsedTheme.ParseInclude(mockSource.Object, mockRegistryOptions.Object, priority, out IRawTheme themeInclude); + + // Assert + Assert.IsNotNull(result); + CollectionAssert.IsEmpty(result); + Assert.IsNull(themeInclude); + mockRegistryOptions.Verify(r => r.GetTheme(includeString), Times.Once); + } + + [Test] + public void ParseInclude_ValidIncludeAndTheme_ReturnsParseThemeResult() + { + // Arrange + const string includeString = "dark-theme"; + Mock mockSource = new Mock(); + mockSource.Setup(s => s.GetInclude()).Returns(includeString); + + Mock mockIncludedTheme = new Mock(); + mockIncludedTheme.Setup(t => t.GetSettings()).Returns(new List()); + mockIncludedTheme.Setup(t => t.GetTokenColors()).Returns(new List()); + + Mock mockRegistryOptions = new Mock(); + mockRegistryOptions.Setup(r => r.GetTheme(includeString)).Returns(mockIncludedTheme.Object); + const int priority = 1; + + // Act + List result = ParsedTheme.ParseInclude(mockSource.Object, mockRegistryOptions.Object, priority, out IRawTheme themeInclude); + + // Assert + Assert.IsNotNull(result); + Assert.AreSame(mockIncludedTheme.Object, themeInclude); + mockRegistryOptions.Verify(r => r.GetTheme(includeString), Times.Once); + } + + [TestCase(int.MinValue)] + [TestCase(-1)] + [TestCase(0)] + [TestCase(1)] + [TestCase(100)] + [TestCase(int.MaxValue)] + public void ParseInclude_VariousPriorityValues_PassesPriorityToParseTheme(int priority) + { + // Arrange + const string includeString = "test-theme"; + const string expectedScope = "scope1"; + const string expectedForeground = "#123456"; + const int expectedRuleCount = 1; + const int expectedRuleIndex = 0; + + Mock mockSource = new Mock(); + mockSource.Setup(s => s.GetInclude()).Returns(includeString); + + ThemeRaw includedTheme = new ThemeRaw + { + ["tokenColors"] = new List + { + new ThemeRaw + { + ["scope"] = expectedScope, + ["settings"] = new ThemeRaw + { + ["foreground"] = expectedForeground + } + } + } + }; + + Mock mockRegistryOptions = new Mock(); + mockRegistryOptions.Setup(r => r.GetTheme(includeString)).Returns(includedTheme); + + // Act + List result = ParsedTheme.ParseInclude( + mockSource.Object, + mockRegistryOptions.Object, + priority, + out IRawTheme themeInclude); + + // Assert + Assert.IsNotNull(result); + Assert.AreEqual(expectedRuleCount, result.Count); + Assert.AreSame(includedTheme, themeInclude); + mockRegistryOptions.Verify(r => r.GetTheme(includeString), Times.Once); + + ParsedThemeRule rule = result[0]; + Assert.AreEqual(expectedScope, rule.scope); + Assert.AreEqual(expectedRuleIndex, rule.index); + Assert.AreEqual(expectedForeground, rule.foreground); + Assert.AreEqual(FontStyle.NotSet, rule.fontStyle); + } + + [TestCase(" ")] + [TestCase(" ")] + [TestCase("\t")] + [TestCase("\n")] + [TestCase("\r\n")] + public void ParseInclude_SourceGetIncludeReturnsWhitespace_ReturnsEmptyListAndSetsThemeIncludeToNull(string whitespace) + { + // Arrange + Mock mockSource = new Mock(); + mockSource.Setup(s => s.GetInclude()).Returns(whitespace); + Mock mockRegistryOptions = new Mock(); + mockRegistryOptions.Setup(r => r.GetTheme(whitespace)).Returns((IRawTheme)null); + const int priority = 0; + + // Act + List result = ParsedTheme.ParseInclude(mockSource.Object, mockRegistryOptions.Object, priority, out IRawTheme themeInclude); + + // Assert + Assert.IsNotNull(result); + CollectionAssert.IsEmpty(result); + Assert.IsNull(themeInclude); + // Note: string.IsNullOrEmpty does NOT treat whitespace as empty, so GetTheme should be called + mockRegistryOptions.Verify(r => r.GetTheme(whitespace), Times.Once); + } + + [TestCase("theme-with-dashes")] + [TestCase("theme_with_underscores")] + [TestCase("theme.with.dots")] + [TestCase("theme/with/slashes")] + [TestCase("themeWithMixedCase")] + [TestCase("very-long-theme-name-that-exceeds-normal-length-expectations-for-testing-purposes")] + public void ParseInclude_VariousIncludeStringFormats_PassesCorrectlyToGetTheme(string includeString) + { + // Arrange + Mock mockSource = new Mock(); + mockSource.Setup(s => s.GetInclude()).Returns(includeString); + + Mock mockIncludedTheme = new Mock(); + mockIncludedTheme.Setup(t => t.GetSettings()).Returns(new List()); + mockIncludedTheme.Setup(t => t.GetTokenColors()).Returns(new List()); + + Mock mockRegistryOptions = new Mock(); + mockRegistryOptions.Setup(r => r.GetTheme(includeString)).Returns(mockIncludedTheme.Object); + const int priority = 0; + + // Act + List result = ParsedTheme.ParseInclude(mockSource.Object, mockRegistryOptions.Object, priority, out IRawTheme themeInclude); + + // Assert + Assert.IsNotNull(result); + Assert.AreSame(mockIncludedTheme.Object, themeInclude); + mockRegistryOptions.Verify(r => r.GetTheme(includeString), Times.Once); + } +} \ No newline at end of file diff --git a/src/TextMateSharp.Tests/Themes/ThemeTests.cs b/src/TextMateSharp.Tests/Themes/ThemeTests.cs new file mode 100644 index 0000000..dfd22cf --- /dev/null +++ b/src/TextMateSharp.Tests/Themes/ThemeTests.cs @@ -0,0 +1,769 @@ +using Moq; +using NUnit.Framework; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using TextMateSharp.Internal.Themes; +using TextMateSharp.Registry; +using TextMateSharp.Themes; + +namespace TextMateSharp.Tests.Themes +{ + [TestFixture] + public class ThemeTests + { + private const int NullColorId = 0; + private const int DefaultBackgroundColorId = 2; + private const int FirstCustomColorId = 3; + private const int SingleScopeMatchCount = 1; + private const int DuplicateScopeMatchCount = 2; + + [Test] + public void GetColorId_NullColor_ReturnsZero() + { + // Arrange + IRegistryOptions registryOptions = CreateMockRegistryOptions(CreateDefaultRawTheme(), null); + Theme theme = Theme.CreateFromRawTheme( + registryOptions.GetDefaultTheme(), + registryOptions); + + // Act + int result = theme.GetColorId(null); + + // Assert + Assert.AreEqual(NullColorId, result); + } + + [Test] + public void GetColorId_ValidColor_ReturnsValidId() + { + // Arrange + IRegistryOptions registryOptions = CreateMockRegistryOptions(CreateDefaultRawTheme(), null); + Theme theme = Theme.CreateFromRawTheme( + registryOptions.GetDefaultTheme(), + registryOptions); + + // Act + int result = theme.GetColorId("#FFFFFF"); + + // Assert + Assert.AreEqual(DefaultBackgroundColorId, result); + } + + [Test] + public void GetColorId_SameColorCalledTwice_ReturnsSameId() + { + // Arrange + IRegistryOptions registryOptions = CreateMockRegistryOptions(CreateDefaultRawTheme(), null); + Theme theme = Theme.CreateFromRawTheme( + registryOptions.GetDefaultTheme(), + registryOptions); + const string color = "#FF0000"; + + // Act + int firstCall = theme.GetColorId(color); + int secondCall = theme.GetColorId(color); + + // Assert + Assert.AreEqual(firstCall, secondCall); + } + + [Test] + public void GetColorId_DifferentColors_ReturnsDifferentIds() + { + // Arrange + IRegistryOptions registryOptions = CreateMockRegistryOptions(CreateDefaultRawTheme(), null); + Theme theme = Theme.CreateFromRawTheme( + registryOptions.GetDefaultTheme(), + registryOptions); + + // Act + int id1 = theme.GetColorId("#FF0000"); + int id2 = theme.GetColorId("#00FF00"); + + // Assert + Assert.AreNotEqual(id1, id2); + } + + [TestCase("#ffffff", "#FFFFFF")] + [TestCase("#ff00ff", "#FF00FF")] + [TestCase("#abc123", "#ABC123")] + public void GetColorId_CaseInsensitive_ReturnsSameId(string color1, string color2) + { + // Arrange + IRegistryOptions registryOptions = CreateMockRegistryOptions(CreateDefaultRawTheme(), null); + Theme theme = Theme.CreateFromRawTheme( + registryOptions.GetDefaultTheme(), + registryOptions); + + // Act + int id1 = theme.GetColorId(color1); + int id2 = theme.GetColorId(color2); + + // Assert + Assert.AreEqual(id1, id2); + } + + [Test] + public void GetColorId_EmptyString_ReturnsValidId() + { + // Arrange + IRegistryOptions registryOptions = CreateMockRegistryOptions(CreateDefaultRawTheme(), null); + Theme theme = Theme.CreateFromRawTheme( + registryOptions.GetDefaultTheme(), + registryOptions); + + // Act + int result = theme.GetColorId(string.Empty); + + // Assert + Assert.AreEqual(FirstCustomColorId, result); + } + + [TestCase(" ")] + [TestCase("\t")] + [TestCase("\n")] + [TestCase(" \t\n ")] + public void GetColorId_WhitespaceString_ReturnsValidId(string whitespace) + { + // Arrange + IRegistryOptions registryOptions = CreateMockRegistryOptions(CreateDefaultRawTheme(), null); + Theme theme = Theme.CreateFromRawTheme( + registryOptions.GetDefaultTheme(), + registryOptions); + + // Act + int result = theme.GetColorId(whitespace); + + // Assert + Assert.AreEqual(FirstCustomColorId, result); + } + + [Test] + public void GetColorId_WhitespaceString_StoresUppercaseVersion() + { + // Arrange + IRegistryOptions registryOptions = CreateMockRegistryOptions(CreateDefaultRawTheme(), null); + Theme theme = Theme.CreateFromRawTheme( + registryOptions.GetDefaultTheme(), + registryOptions); + const string whitespace = " "; + + // Act + int id = theme.GetColorId(whitespace); + string storedColor = theme.GetColor(id); + + // Assert + Assert.AreEqual(FirstCustomColorId, id); + Assert.AreEqual(whitespace.ToUpper(), storedColor, + "Whitespace strings should be stored in uppercase as-is without normalization"); + } + + [Test] + public void GetColorId_InvalidColorFormats_StoresAsIsInUppercase() + { + // Arrange + IRegistryOptions registryOptions = CreateMockRegistryOptions(CreateDefaultRawTheme(), null); + Theme theme = Theme.CreateFromRawTheme( + registryOptions.GetDefaultTheme(), + registryOptions); + const string invalidColor = "rgb(255,0,0)"; + + // Act + int id = theme.GetColorId(invalidColor); + string storedColor = theme.GetColor(id); + + // Assert + Assert.AreEqual(FirstCustomColorId, id); + Assert.AreEqual(invalidColor.ToUpper(), storedColor, + "Invalid color formats should be stored as-is in uppercase without validation"); + } + + [TestCase("invalid_color")] + [TestCase("@#$%^&*()")] + public void GetColorId_SpecialCharacters_RoundTripPreservesValue(string color) + { + // Arrange + IRegistryOptions registryOptions = CreateMockRegistryOptions(CreateDefaultRawTheme(), null); + Theme theme = Theme.CreateFromRawTheme( + registryOptions.GetDefaultTheme(), + registryOptions); + + // Act + int id = theme.GetColorId(color); + string retrieved = theme.GetColor(id); + + // Assert + Assert.AreEqual(color.ToUpper(), retrieved, + "Special characters should round-trip correctly in uppercase"); + } + + [Test] + public void GetGuiColorDictionary_WithEmptyGuiColors_ReturnsEmptyReadOnlyDictionary() + { + // Arrange + Mock mockRawTheme = new Mock(); + Dictionary emptyColors = new Dictionary(); + mockRawTheme.Setup(x => x.GetGuiColors()).Returns(emptyColors); + mockRawTheme.Setup(x => x.GetSettings()).Returns(new List()); + + Mock mockRegistryOptions = new Mock(); + mockRegistryOptions.Setup(x => x.GetInjections(It.IsAny())).Returns((List)null); + + Theme theme = Theme.CreateFromRawTheme(mockRawTheme.Object, mockRegistryOptions.Object); + + // Act + ReadOnlyDictionary result = theme.GetGuiColorDictionary(); + + // Assert + Assert.IsNotNull(result); + Assert.IsInstanceOf>(result); + CollectionAssert.IsEmpty(result); + } + + [Test] + public void GetGuiColorDictionary_WithSingleGuiColor_ReturnsDictionaryWithOneEntry() + { + // Arrange + Mock mockRawTheme = new Mock(); + Dictionary guiColors = new Dictionary + { + { "editor.background", "#1E1E1E" } + }; + mockRawTheme.Setup(x => x.GetGuiColors()).Returns(guiColors); + mockRawTheme.Setup(x => x.GetSettings()).Returns(new List()); + + Mock mockRegistryOptions = new Mock(); + mockRegistryOptions.Setup(x => x.GetInjections(It.IsAny())).Returns((List)null); + + Theme theme = Theme.CreateFromRawTheme(mockRawTheme.Object, mockRegistryOptions.Object); + + // Act + ReadOnlyDictionary result = theme.GetGuiColorDictionary(); + + // Assert + Assert.IsNotNull(result); + Assert.AreEqual(1, result.Count); + Assert.IsTrue(result.ContainsKey("editor.background")); + Assert.AreEqual("#1E1E1E", result["editor.background"]); + } + + [Test] + public void GetGuiColorDictionary_WithMultipleGuiColors_ReturnsDictionaryWithAllEntries() + { + // Arrange + Mock mockRawTheme = new Mock(); + Dictionary guiColors = new Dictionary + { + { "editor.background", "#1E1E1E" }, + { "editor.foreground", "#D4D4D4" }, + { "editor.lineHighlightBackground", "#282828" } + }; + mockRawTheme.Setup(x => x.GetGuiColors()).Returns(guiColors); + mockRawTheme.Setup(x => x.GetSettings()).Returns(new List()); + + Mock mockRegistryOptions = new Mock(); + mockRegistryOptions.Setup(x => x.GetInjections(It.IsAny())).Returns((List)null); + + Theme theme = Theme.CreateFromRawTheme(mockRawTheme.Object, mockRegistryOptions.Object); + + // Act + ReadOnlyDictionary result = theme.GetGuiColorDictionary(); + + // Assert + Assert.IsNotNull(result); + Assert.AreEqual(3, result.Count); + Assert.IsTrue(result.ContainsKey("editor.background")); + Assert.AreEqual("#1E1E1E", result["editor.background"]); + Assert.IsTrue(result.ContainsKey("editor.foreground")); + Assert.AreEqual("#D4D4D4", result["editor.foreground"]); + Assert.IsTrue(result.ContainsKey("editor.lineHighlightBackground")); + Assert.AreEqual("#282828", result["editor.lineHighlightBackground"]); + } + + [Test] + public void GetGuiColorDictionary_ReturnsReadOnlyDictionary_CannotBeCastToMutableDictionary() + { + // Arrange + Mock mockRawTheme = new Mock(); + Dictionary guiColors = new Dictionary + { + { "editor.background", "#1E1E1E" } + }; + mockRawTheme.Setup(x => x.GetGuiColors()).Returns(guiColors); + mockRawTheme.Setup(x => x.GetSettings()).Returns(new List()); + + Mock mockRegistryOptions = new Mock(); + mockRegistryOptions.Setup(x => x.GetInjections(It.IsAny())).Returns((List)null); + + Theme theme = Theme.CreateFromRawTheme(mockRawTheme.Object, mockRegistryOptions.Object); + + // Act + ReadOnlyDictionary result = theme.GetGuiColorDictionary(); + + // Assert + Assert.IsNotNull(result); + Assert.IsInstanceOf>(result); + Assert.IsNotInstanceOf>(result); + } + + [Test] + public void GetGuiColorDictionary_WithSpecialCharactersInKeysAndValues_PreservesSpecialCharacters() + { + // Arrange + Mock mockRawTheme = new Mock(); + Dictionary guiColors = new Dictionary + { + { "editor.background", "#1E1E1E" }, + { "editor.selection-background", "#264F78" }, + { "special.key_with.dots-and_underscores", "#FFFFFF" } + }; + mockRawTheme.Setup(x => x.GetGuiColors()).Returns(guiColors); + mockRawTheme.Setup(x => x.GetSettings()).Returns(new List()); + + Mock mockRegistryOptions = new Mock(); + mockRegistryOptions.Setup(x => x.GetInjections(It.IsAny())).Returns((List)null); + + Theme theme = Theme.CreateFromRawTheme(mockRawTheme.Object, mockRegistryOptions.Object); + + // Act + ReadOnlyDictionary result = theme.GetGuiColorDictionary(); + + // Assert + Assert.AreEqual(3, result.Count); + Assert.IsTrue(result.ContainsKey("editor.background")); + Assert.AreEqual("#1E1E1E", result["editor.background"]); + Assert.IsTrue(result.ContainsKey("editor.selection-background")); + Assert.AreEqual("#264F78", result["editor.selection-background"]); + Assert.IsTrue(result.ContainsKey("special.key_with.dots-and_underscores")); + Assert.AreEqual("#FFFFFF", result["special.key_with.dots-and_underscores"]); + } + + [Test] + public void GetGuiColorDictionary_CalledMultipleTimes_ReturnsSameCachedInstance() + { + // Arrange + Mock mockRawTheme = new Mock(); + Dictionary guiColors = new Dictionary + { + { "editor.background", "#1E1E1E" } + }; + mockRawTheme.Setup(x => x.GetGuiColors()).Returns(guiColors); + mockRawTheme.Setup(x => x.GetSettings()).Returns(new List()); + + Mock mockRegistryOptions = new Mock(); + mockRegistryOptions.Setup(x => x.GetInjections(It.IsAny())).Returns((List)null); + + Theme theme = Theme.CreateFromRawTheme(mockRawTheme.Object, mockRegistryOptions.Object); + + // Act + ReadOnlyDictionary result1 = theme.GetGuiColorDictionary(); + ReadOnlyDictionary result2 = theme.GetGuiColorDictionary(); + + // Assert + Assert.AreSame(result1, result2); + } + + [Test] + public void GetColorMap_DefaultColorsPresent_ReturnsMapContainingDefaults() + { + // Arrange + IRegistryOptions registryOptions = CreateMockRegistryOptions(CreateDefaultRawTheme(), null); + Theme theme = Theme.CreateFromRawTheme( + registryOptions.GetDefaultTheme(), + registryOptions); + + // Act + ICollection colorMap = theme.GetColorMap(); + + // Assert + Assert.IsNotNull(colorMap); + CollectionAssert.Contains(colorMap, "#000000"); + CollectionAssert.Contains(colorMap, "#FFFFFF"); + } + + [Test] + public void GetColorMap_AfterAddingColorId_IncludesNewColor() + { + // Arrange + IRegistryOptions registryOptions = CreateMockRegistryOptions(CreateDefaultRawTheme(), null); + Theme theme = Theme.CreateFromRawTheme( + registryOptions.GetDefaultTheme(), + registryOptions); + const string color = "#123456"; + + // Act + theme.GetColorId(color); + ICollection colorMap = theme.GetColorMap(); + + // Assert + CollectionAssert.Contains(colorMap, color); + } + + [Test] + public void GetColor_RoundTrip_ReturnsOriginalColor() + { + // Arrange + IRegistryOptions registryOptions = CreateMockRegistryOptions(CreateDefaultRawTheme(), null); + Theme theme = Theme.CreateFromRawTheme( + registryOptions.GetDefaultTheme(), + registryOptions); + const string color = "#ABCDEF"; + + // Act + int id = theme.GetColorId(color); + string resolved = theme.GetColor(id); + + // Assert + Assert.AreEqual(color, resolved); + } + + [Test] + public void Match_EmptyScopeList_ReturnsEmptyList() + { + // Arrange + IRegistryOptions registryOptions = CreateMockRegistryOptions(CreateDefaultRawTheme(), null); + Theme theme = Theme.CreateFromRawTheme( + registryOptions.GetDefaultTheme(), + registryOptions); + List scopes = new List(); + + // Act + List result = theme.Match(scopes); + + // Assert + Assert.IsNotNull(result); + CollectionAssert.IsEmpty(result); + } + + [Test] + public void Match_NoRulesForScopes_ReturnsEmptyList() + { + // Arrange + IRegistryOptions registryOptions = CreateMockRegistryOptions(CreateDefaultRawTheme(), null); + Theme theme = Theme.CreateFromRawTheme( + registryOptions.GetDefaultTheme(), + registryOptions); + List scopes = new List { "nonexistent.scope" }; + + // Act + List result = theme.Match(scopes); + + // Assert + Assert.IsNotNull(result); + CollectionAssert.IsEmpty(result); + } + + [Test] + public void Match_DuplicateScopes_ReturnsDuplicatedMatches() + { + // Arrange + Theme theme = CreateThemeWithSingleRule("keyword.control", "#FF0000"); + + List singleScope = new List { "keyword.control" }; + List duplicateScopes = new List { "keyword.control", "keyword.control" }; + + // Act + List singleResult = theme.Match(singleScope); + List duplicateResult = theme.Match(duplicateScopes); + + // Assert + Assert.IsNotNull(singleResult); + Assert.IsNotNull(duplicateResult); + Assert.AreEqual(SingleScopeMatchCount, singleResult.Count); + Assert.AreEqual(DuplicateScopeMatchCount, duplicateResult.Count); + } + + [Test] + public void Match_MultipleScopesWithDifferentDepths_OrdersByDepth() + { + // Arrange + Theme theme = CreateThemeWithMultipleRules(); + List scopes = new List { "source.cs", "keyword.control" }; + + // Act + List results = theme.Match(scopes); + + // Assert + Assert.IsNotNull(results); + Assert.Greater(results.Count, 0); + + // Verify ordering - rules should be ordered by scope depth + for (int i = 0; i < results.Count - 1; i++) + { + Assert.LessOrEqual(results[i].scopeDepth, results[i + 1].scopeDepth, + "Results should be ordered by scopeDepth in ascending order"); + } + } + + [Test] + public void ThemeTrieElementRule_AcceptOverwrite_ExtremeScopeDepths_HandlesCorrectly() + { + // Arrange + const string ruleName = "test.rule"; + const int maxDepth = int.MaxValue; + const int minDepth = 0; + + ThemeTrieElementRule rule = new ThemeTrieElementRule( + ruleName, + minDepth, + new List(), + FontStyle.NotSet, + 1, + 2); + + // Act + rule.AcceptOverwrite("overwrite", maxDepth, FontStyle.Bold, 3, 4); + + // Assert + Assert.AreEqual(maxDepth, rule.scopeDepth, + "Should accept int.MaxValue as valid scope depth"); + Assert.AreEqual(FontStyle.Bold, rule.fontStyle); + Assert.AreEqual(3, rule.foreground); + Assert.AreEqual(4, rule.background); + } + + [Test] + public void ThemeTrieElementRule_AcceptOverwrite_LowerScopeDepth_DoesNotDecrease() + { + // Arrange + const int higherDepth = 10; + const int lowerDepth = 5; + + ThemeTrieElementRule rule = new ThemeTrieElementRule( + "test", + higherDepth, + new List(), + FontStyle.Bold, + 1, + 2); + + // Act + rule.AcceptOverwrite("overwrite", lowerDepth, FontStyle.Italic, 3, 4); + + // Assert + Assert.AreEqual(higherDepth, rule.scopeDepth, + "Scope depth should not decrease when overwriting with lower depth"); + } + + [Test] + public void GetColorId_NullAfterValidColor_ReturnsZero() + { + // Arrange + IRegistryOptions registryOptions = CreateMockRegistryOptions(CreateDefaultRawTheme(), null); + Theme theme = Theme.CreateFromRawTheme( + registryOptions.GetDefaultTheme(), + registryOptions); + + theme.GetColorId("#FF0000"); // Add a color first + + // Act + int result = theme.GetColorId(null); + + // Assert + Assert.AreEqual(NullColorId, result, + "Null should always return 0 regardless of other colors added"); + } + + [Test] + public void GetColorId_ManyUniqueColors_AllReceiveUniqueIds() + { + // Arrange + IRegistryOptions registryOptions = CreateMockRegistryOptions(CreateDefaultRawTheme(), null); + Theme theme = Theme.CreateFromRawTheme( + registryOptions.GetDefaultTheme(), + registryOptions); + + const int colorCount = 1_000; + HashSet uniqueIds = new HashSet(); + + // Act + for (int i = 0; i < colorCount; i++) + { + string color = $"#{i:X6}"; + int id = theme.GetColorId(color); + uniqueIds.Add(id); + } + + // Assert + Assert.AreEqual(colorCount, uniqueIds.Count, + "Each unique color should receive a unique ID"); + } + + [Test] + public void GetColorId_HexColorFormat_ReturnsUniqueId() + { + // Arrange + IRegistryOptions registryOptions = CreateMockRegistryOptions(CreateDefaultRawTheme(), null); + Theme theme = Theme.CreateFromRawTheme( + registryOptions.GetDefaultTheme(), + registryOptions); + const string hexColor = "#FF5733"; + + // Act + int id = theme.GetColorId(hexColor); + string storedColor = theme.GetColor(id); + + // Assert + Assert.AreEqual(FirstCustomColorId, id); + Assert.AreEqual(hexColor, storedColor); + } + + [Test] + public void GetColorId_RgbColorFormat_StoresAsUniqueColor() + { + // Arrange + IRegistryOptions registryOptions = CreateMockRegistryOptions(CreateDefaultRawTheme(), null); + Theme theme = Theme.CreateFromRawTheme( + registryOptions.GetDefaultTheme(), + registryOptions); + const string rgbColor = "rgb(255, 87, 51)"; + + // Act + int id = theme.GetColorId(rgbColor); + string storedColor = theme.GetColor(id); + + // Assert + Assert.AreEqual(FirstCustomColorId, id); + Assert.AreEqual(rgbColor.ToUpper(), storedColor, + "RGB format is stored as-is in uppercase without normalization"); + } + + [Test] + public void GetColorId_DifferentFormatsForSameVisualColor_ReturnsDifferentIds() + { + // Arrange + IRegistryOptions registryOptions = CreateMockRegistryOptions(CreateDefaultRawTheme(), null); + Theme theme = Theme.CreateFromRawTheme( + registryOptions.GetDefaultTheme(), + registryOptions); + const string hexColor = "#FF5733"; + const string rgbColor = "rgb(255, 87, 51)"; + + // Act + int hexId = theme.GetColorId(hexColor); + int rgbId = theme.GetColorId(rgbColor); + + // Assert + Assert.AreNotEqual(hexId, rgbId, + "Different color format strings are treated as different colors without normalization"); + } + + [Test] + public void GetColorId_RgbaColorFormat_StoresAsUniqueColor() + { + // Arrange + IRegistryOptions registryOptions = CreateMockRegistryOptions(CreateDefaultRawTheme(), null); + Theme theme = Theme.CreateFromRawTheme( + registryOptions.GetDefaultTheme(), + registryOptions); + const string rgbaColor = "rgba(255, 87, 51, 1)"; + + // Act + int id = theme.GetColorId(rgbaColor); + string storedColor = theme.GetColor(id); + + // Assert + Assert.AreEqual(FirstCustomColorId, id); + Assert.AreEqual(rgbaColor.ToUpper(), storedColor); + } + + [Test] + public void GetColorId_HslColorFormat_StoresAsUniqueColor() + { + // Arrange + IRegistryOptions registryOptions = CreateMockRegistryOptions(CreateDefaultRawTheme(), null); + Theme theme = Theme.CreateFromRawTheme( + registryOptions.GetDefaultTheme(), + registryOptions); + const string hslColor = "hsl(14, 100%, 60%)"; + + // Act + int id = theme.GetColorId(hslColor); + string storedColor = theme.GetColor(id); + + // Assert + Assert.AreEqual(FirstCustomColorId, id); + Assert.AreEqual(hslColor.ToUpper(), storedColor); + } + + #region helpers + + private static IRawTheme CreateDefaultRawTheme() + { + Mock mockRawTheme = new Mock(); + mockRawTheme.Setup(x => x.GetSettings()).Returns(new List()); + mockRawTheme.Setup(x => x.GetTokenColors()).Returns(new List()); + + return mockRawTheme.Object; + } + + private static IRegistryOptions CreateMockRegistryOptions(IRawTheme defaultTheme, List injections) + { + Mock mockRegistryOptions = new Mock(); + mockRegistryOptions.Setup(x => x.GetDefaultTheme()).Returns(defaultTheme); + mockRegistryOptions.Setup(x => x.GetInjections(It.IsAny())).Returns(injections); + + return mockRegistryOptions.Object; + } + + private static Theme CreateThemeWithSingleRule(string scopeName, string foreground) + { + ThemeRaw themeRaw = new ThemeRaw + { + ["tokenColors"] = new List + { + new ThemeRaw + { + ["scope"] = scopeName, + ["settings"] = new ThemeRaw + { + ["foreground"] = foreground + } + } + } + }; + + IRegistryOptions registryOptions = CreateMockRegistryOptions(themeRaw, null); + return Theme.CreateFromRawTheme(themeRaw, registryOptions); + } + + private static Theme CreateThemeWithMultipleRules() + { + ThemeRaw themeRaw = new ThemeRaw + { + ["tokenColors"] = new List + { + new ThemeRaw + { + ["scope"] = "source", + ["settings"] = new ThemeRaw + { + ["foreground"] = "#FF0000" + } + }, + new ThemeRaw + { + ["scope"] = "source.cs", + ["settings"] = new ThemeRaw + { + ["foreground"] = "#00FF00" + } + }, + new ThemeRaw + { + ["scope"] = "keyword.control", + ["settings"] = new ThemeRaw + { + ["foreground"] = "#0000FF" + } + } + } + }; + + IRegistryOptions registryOptions = CreateMockRegistryOptions(themeRaw, null); + return Theme.CreateFromRawTheme(themeRaw, registryOptions); + } + + #endregion helpers + } +} \ No newline at end of file From b20ba91f851c1214451a51015f0222eacc52b750 Mon Sep 17 00:00:00 2001 From: Dave Black <656118+udlose@users.noreply.github.com> Date: Sat, 28 Feb 2026 16:40:06 -0600 Subject: [PATCH 11/33] Expand test coverage for ParsedTheme and Theme classes Added extensive unit tests for ParsedTheme and Theme, covering edge cases, concurrency, color and font style parsing, rule sorting, and default rule handling. Improved test structure with region markers and helper methods. Ensured robust validation of theme logic, including thread-safety and handling of malformed or unusual input. --- .../Themes/ParsedThemeTests.cs | 2867 ++++++++++++++++- src/TextMateSharp.Tests/Themes/ThemeTests.cs | 475 ++- 2 files changed, 3087 insertions(+), 255 deletions(-) diff --git a/src/TextMateSharp.Tests/Themes/ParsedThemeTests.cs b/src/TextMateSharp.Tests/Themes/ParsedThemeTests.cs index 3e6eb98..c2be209 100644 --- a/src/TextMateSharp.Tests/Themes/ParsedThemeTests.cs +++ b/src/TextMateSharp.Tests/Themes/ParsedThemeTests.cs @@ -1,206 +1,2729 @@ using Moq; using NUnit.Framework; +using System; +using System.Collections.Concurrent; using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; using TextMateSharp.Internal.Themes; using TextMateSharp.Registry; using TextMateSharp.Themes; -namespace TextMateSharp.Tests.Themes; - -[TestFixture] -public class ParsedThemeTests +namespace TextMateSharp.Tests.Themes { - [Test] - public void ParseInclude_SourceGetIncludeReturnsNull_ReturnsEmptyListAndSetsThemeIncludeToNull() + [TestFixture] + public class ParsedThemeTests { - // Arrange - Mock mockSource = new Mock(); - mockSource.Setup(s => s.GetInclude()).Returns((string)null); - Mock mockRegistryOptions = new Mock(); - const int priority = 5; - - // Act - List result = ParsedTheme.ParseInclude(mockSource.Object, mockRegistryOptions.Object, priority, out IRawTheme themeInclude); - - // Assert - Assert.IsNotNull(result); - CollectionAssert.IsEmpty(result); - Assert.IsNull(themeInclude); - mockRegistryOptions.Verify(r => r.GetTheme(It.IsAny()), Times.Never); - } + #region ParseInclude tests - [Test] - public void ParseInclude_SourceGetIncludeReturnsEmpty_ReturnsEmptyListAndSetsThemeIncludeToNull() - { - // Arrange - Mock mockSource = new Mock(); - mockSource.Setup(s => s.GetInclude()).Returns(string.Empty); - Mock mockRegistryOptions = new Mock(); - const int priority = 10; - - // Act - List result = ParsedTheme.ParseInclude(mockSource.Object, mockRegistryOptions.Object, priority, out IRawTheme themeInclude); - - // Assert - Assert.IsNotNull(result); - CollectionAssert.IsEmpty(result); - Assert.IsNull(themeInclude); - mockRegistryOptions.Verify(r => r.GetTheme(It.IsAny()), Times.Never); - } + [Test] + public void ParseInclude_SourceGetIncludeReturnsNull_ReturnsEmptyListAndSetsThemeIncludeToNull() + { + // Arrange + Mock mockSource = new Mock(); + mockSource.Setup(s => s.GetInclude()).Returns((string)null); + Mock mockRegistryOptions = new Mock(); + const int priority = 5; - [Test] - public void ParseInclude_GetThemeReturnsNull_ReturnsEmptyList() - { - // Arrange - const string includeString = "valid-include-name"; - Mock mockSource = new Mock(); - mockSource.Setup(s => s.GetInclude()).Returns(includeString); - Mock mockRegistryOptions = new Mock(); - mockRegistryOptions.Setup(r => r.GetTheme(includeString)).Returns((IRawTheme)null); - const int priority = 0; - - // Act - List result = ParsedTheme.ParseInclude(mockSource.Object, mockRegistryOptions.Object, priority, out IRawTheme themeInclude); - - // Assert - Assert.IsNotNull(result); - CollectionAssert.IsEmpty(result); - Assert.IsNull(themeInclude); - mockRegistryOptions.Verify(r => r.GetTheme(includeString), Times.Once); - } + // Act + List result = ParsedTheme.ParseInclude(mockSource.Object, mockRegistryOptions.Object, priority, out IRawTheme themeInclude); - [Test] - public void ParseInclude_ValidIncludeAndTheme_ReturnsParseThemeResult() - { - // Arrange - const string includeString = "dark-theme"; - Mock mockSource = new Mock(); - mockSource.Setup(s => s.GetInclude()).Returns(includeString); - - Mock mockIncludedTheme = new Mock(); - mockIncludedTheme.Setup(t => t.GetSettings()).Returns(new List()); - mockIncludedTheme.Setup(t => t.GetTokenColors()).Returns(new List()); - - Mock mockRegistryOptions = new Mock(); - mockRegistryOptions.Setup(r => r.GetTheme(includeString)).Returns(mockIncludedTheme.Object); - const int priority = 1; - - // Act - List result = ParsedTheme.ParseInclude(mockSource.Object, mockRegistryOptions.Object, priority, out IRawTheme themeInclude); - - // Assert - Assert.IsNotNull(result); - Assert.AreSame(mockIncludedTheme.Object, themeInclude); - mockRegistryOptions.Verify(r => r.GetTheme(includeString), Times.Once); - } + // Assert + Assert.IsNotNull(result); + CollectionAssert.IsEmpty(result); + Assert.IsNull(themeInclude); + mockRegistryOptions.Verify(r => r.GetTheme(It.IsAny()), Times.Never); + } - [TestCase(int.MinValue)] - [TestCase(-1)] - [TestCase(0)] - [TestCase(1)] - [TestCase(100)] - [TestCase(int.MaxValue)] - public void ParseInclude_VariousPriorityValues_PassesPriorityToParseTheme(int priority) - { - // Arrange - const string includeString = "test-theme"; - const string expectedScope = "scope1"; - const string expectedForeground = "#123456"; - const int expectedRuleCount = 1; - const int expectedRuleIndex = 0; + [Test] + public void ParseInclude_SourceGetIncludeReturnsEmpty_ReturnsEmptyListAndSetsThemeIncludeToNull() + { + // Arrange + Mock mockSource = new Mock(); + mockSource.Setup(s => s.GetInclude()).Returns(string.Empty); + Mock mockRegistryOptions = new Mock(); + const int priority = 10; + + // Act + List result = ParsedTheme.ParseInclude(mockSource.Object, mockRegistryOptions.Object, priority, out IRawTheme themeInclude); + + // Assert + Assert.IsNotNull(result); + CollectionAssert.IsEmpty(result); + Assert.IsNull(themeInclude); + mockRegistryOptions.Verify(r => r.GetTheme(It.IsAny()), Times.Never); + } + + [Test] + public void ParseInclude_GetThemeReturnsNull_ReturnsEmptyList() + { + // Arrange + const string includeString = "valid-include-name"; + Mock mockSource = new Mock(); + mockSource.Setup(s => s.GetInclude()).Returns(includeString); + Mock mockRegistryOptions = new Mock(); + mockRegistryOptions.Setup(r => r.GetTheme(includeString)).Returns((IRawTheme)null); + const int priority = 0; + + // Act + List result = ParsedTheme.ParseInclude(mockSource.Object, mockRegistryOptions.Object, priority, out IRawTheme themeInclude); - Mock mockSource = new Mock(); - mockSource.Setup(s => s.GetInclude()).Returns(includeString); + // Assert + Assert.IsNotNull(result); + CollectionAssert.IsEmpty(result); + Assert.IsNull(themeInclude); + mockRegistryOptions.Verify(r => r.GetTheme(includeString), Times.Once); + } - ThemeRaw includedTheme = new ThemeRaw + [Test] + public void ParseInclude_ValidIncludeAndTheme_ReturnsParseThemeResult() { - ["tokenColors"] = new List + // Arrange + const string includeString = "dark-theme"; + Mock mockSource = new Mock(); + mockSource.Setup(s => s.GetInclude()).Returns(includeString); + + Mock mockIncludedTheme = new Mock(); + mockIncludedTheme.Setup(t => t.GetSettings()).Returns(new List()); + mockIncludedTheme.Setup(t => t.GetTokenColors()).Returns(new List()); + + Mock mockRegistryOptions = new Mock(); + mockRegistryOptions.Setup(r => r.GetTheme(includeString)).Returns(mockIncludedTheme.Object); + const int priority = 1; + + // Act + List result = ParsedTheme.ParseInclude(mockSource.Object, mockRegistryOptions.Object, priority, out IRawTheme themeInclude); + + // Assert + Assert.IsNotNull(result); + Assert.AreSame(mockIncludedTheme.Object, themeInclude); + mockRegistryOptions.Verify(r => r.GetTheme(includeString), Times.Once); + } + + [TestCase(int.MinValue)] + [TestCase(-1)] + [TestCase(0)] + [TestCase(1)] + [TestCase(100)] + [TestCase(int.MaxValue)] + public void ParseInclude_VariousPriorityValues_PassesPriorityToParseTheme(int priority) + { + // Arrange + const string includeString = "test-theme"; + const string expectedScope = "scope1"; + const string expectedForeground = "#123456"; + const int expectedRuleCount = 1; + const int expectedRuleIndex = 0; + + Mock mockSource = new Mock(); + mockSource.Setup(s => s.GetInclude()).Returns(includeString); + + ThemeRaw includedTheme = new ThemeRaw { - new ThemeRaw + ["tokenColors"] = new List { - ["scope"] = expectedScope, - ["settings"] = new ThemeRaw + new ThemeRaw { - ["foreground"] = expectedForeground + ["scope"] = expectedScope, + ["settings"] = new ThemeRaw + { + ["foreground"] = expectedForeground + } } } + }; + + Mock mockRegistryOptions = new Mock(); + mockRegistryOptions.Setup(r => r.GetTheme(includeString)).Returns(includedTheme); + + // Act + List result = ParsedTheme.ParseInclude( + mockSource.Object, + mockRegistryOptions.Object, + priority, + out IRawTheme themeInclude); + + // Assert + Assert.IsNotNull(result); + Assert.AreEqual(expectedRuleCount, result.Count); + Assert.AreSame(includedTheme, themeInclude); + mockRegistryOptions.Verify(r => r.GetTheme(includeString), Times.Once); + + ParsedThemeRule rule = result[0]; + Assert.AreEqual(expectedScope, rule.scope); + Assert.AreEqual(expectedRuleIndex, rule.index); + Assert.AreEqual(expectedForeground, rule.foreground); + Assert.AreEqual(FontStyle.NotSet, rule.fontStyle); + } + + [TestCase(" ")] + [TestCase(" ")] + [TestCase("\t")] + [TestCase("\n")] + [TestCase("\r\n")] + public void ParseInclude_SourceGetIncludeReturnsWhitespace_ReturnsEmptyListAndSetsThemeIncludeToNull(string whitespace) + { + // Arrange + Mock mockSource = new Mock(); + mockSource.Setup(s => s.GetInclude()).Returns(whitespace); + Mock mockRegistryOptions = new Mock(); + mockRegistryOptions.Setup(r => r.GetTheme(whitespace)).Returns((IRawTheme)null); + const int priority = 0; + + // Act + List result = ParsedTheme.ParseInclude(mockSource.Object, mockRegistryOptions.Object, priority, out IRawTheme themeInclude); + + // Assert + Assert.IsNotNull(result); + CollectionAssert.IsEmpty(result); + Assert.IsNull(themeInclude); + // Note: string.IsNullOrEmpty does NOT treat whitespace as empty, so GetTheme should be called + mockRegistryOptions.Verify(r => r.GetTheme(whitespace), Times.Once); + } + + [TestCase("theme-with-dashes")] + [TestCase("theme_with_underscores")] + [TestCase("theme.with.dots")] + [TestCase("theme/with/slashes")] + [TestCase("themeWithMixedCase")] + [TestCase("very-long-theme-name-that-exceeds-normal-length-expectations-for-testing-purposes")] + public void ParseInclude_VariousIncludeStringFormats_PassesCorrectlyToGetTheme(string includeString) + { + // Arrange + Mock mockSource = new Mock(); + mockSource.Setup(s => s.GetInclude()).Returns(includeString); + + Mock mockIncludedTheme = new Mock(); + mockIncludedTheme.Setup(t => t.GetSettings()).Returns(new List()); + mockIncludedTheme.Setup(t => t.GetTokenColors()).Returns(new List()); + + Mock mockRegistryOptions = new Mock(); + mockRegistryOptions.Setup(r => r.GetTheme(includeString)).Returns(mockIncludedTheme.Object); + const int priority = 0; + + // Act + List result = ParsedTheme.ParseInclude(mockSource.Object, mockRegistryOptions.Object, priority, out IRawTheme themeInclude); + + // Assert + Assert.IsNotNull(result); + Assert.AreSame(mockIncludedTheme.Object, themeInclude); + mockRegistryOptions.Verify(r => r.GetTheme(includeString), Times.Once); + } + + #endregion ParseInclude tests + + #region Match tests + + [Test] + public void Match_ConcurrentAccess_ReturnsSameOrEquivalentResults() + { + // Arrange + const string testScope = "source.cs"; + const int threadCount = 10; + const int iterationsPerThread = 100; + + ParsedTheme parsedTheme = CreateTestParsedTheme(); + + // Use thread-safe collection to avoid race conditions in test code + ConcurrentBag> allResults = new ConcurrentBag>(); + + // Two-phase synchronization: ready + start + CountdownEvent readyEvent = new CountdownEvent(threadCount); + ManualResetEventSlim startEvent = new ManualResetEventSlim(false); + + // Act - Multiple threads accessing Match concurrently + List tasks = new List(threadCount); + for (int t = 0; t < threadCount; t++) + { + Task task = Task.Run(() => + { + readyEvent.Signal(); // Signal that this thread is ready + startEvent.Wait(); // Wait for start signal + + for (int i = 0; i < iterationsPerThread; i++) + { + List result = parsedTheme.Match(testScope); + allResults.Add(result); + } + }); + tasks.Add(task); } - }; - Mock mockRegistryOptions = new Mock(); - mockRegistryOptions.Setup(r => r.GetTheme(includeString)).Returns(includedTheme); + readyEvent.Wait(); // Wait for all threads to be ready - // Act - List result = ParsedTheme.ParseInclude( - mockSource.Object, - mockRegistryOptions.Object, - priority, - out IRawTheme themeInclude); + // Get expected result AFTER threads are ready but BEFORE they start + // This tests concurrent cache misses on first access + List expectedResult = parsedTheme.Match(testScope); - // Assert - Assert.IsNotNull(result); - Assert.AreEqual(expectedRuleCount, result.Count); - Assert.AreSame(includedTheme, themeInclude); - mockRegistryOptions.Verify(r => r.GetTheme(includeString), Times.Once); + startEvent.Set(); // Release all threads simultaneously + Task.WaitAll(tasks.ToArray()); - ParsedThemeRule rule = result[0]; - Assert.AreEqual(expectedScope, rule.scope); - Assert.AreEqual(expectedRuleIndex, rule.index); - Assert.AreEqual(expectedForeground, rule.foreground); - Assert.AreEqual(FontStyle.NotSet, rule.fontStyle); - } + readyEvent.Dispose(); + startEvent.Dispose(); - [TestCase(" ")] - [TestCase(" ")] - [TestCase("\t")] - [TestCase("\n")] - [TestCase("\r\n")] - public void ParseInclude_SourceGetIncludeReturnsWhitespace_ReturnsEmptyListAndSetsThemeIncludeToNull(string whitespace) - { - // Arrange - Mock mockSource = new Mock(); - mockSource.Setup(s => s.GetInclude()).Returns(whitespace); - Mock mockRegistryOptions = new Mock(); - mockRegistryOptions.Setup(r => r.GetTheme(whitespace)).Returns((IRawTheme)null); - const int priority = 0; - - // Act - List result = ParsedTheme.ParseInclude(mockSource.Object, mockRegistryOptions.Object, priority, out IRawTheme themeInclude); - - // Assert - Assert.IsNotNull(result); - CollectionAssert.IsEmpty(result); - Assert.IsNull(themeInclude); - // Note: string.IsNullOrEmpty does NOT treat whitespace as empty, so GetTheme should be called - mockRegistryOptions.Verify(r => r.GetTheme(whitespace), Times.Once); - } + // Assert - All results should be non-null and either identical reference or equivalent + const int expectedResultCount = threadCount * iterationsPerThread; + Assert.AreEqual(expectedResultCount, allResults.Count); + foreach (List result in allResults) + { + Assert.IsNotNull(result); + // Results should be either the same cached instance or equivalent + Assert.That(result == expectedResult || AreRuleListsEquivalent(result, expectedResult), + "All concurrent Match calls should return same or equivalent results"); + } + } - [TestCase("theme-with-dashes")] - [TestCase("theme_with_underscores")] - [TestCase("theme.with.dots")] - [TestCase("theme/with/slashes")] - [TestCase("themeWithMixedCase")] - [TestCase("very-long-theme-name-that-exceeds-normal-length-expectations-for-testing-purposes")] - public void ParseInclude_VariousIncludeStringFormats_PassesCorrectlyToGetTheme(string includeString) - { - // Arrange - Mock mockSource = new Mock(); - mockSource.Setup(s => s.GetInclude()).Returns(includeString); + [Test] + public void Match_MultipleScopesWithConcurrentAccess_CachesCorrectlyPerScope() + { + // Arrange + const int threadCount = 8; + const int uniqueScopeCount = 5; + const int iterationsPerThread = 50; + + string[] testScopes = new string[uniqueScopeCount]; + for (int i = 0; i < uniqueScopeCount; i++) + { + testScopes[i] = $"scope.test{i}"; + } + + ParsedTheme parsedTheme = CreateTestParsedTheme(); + + // Pre-compute expected results for each scope + Dictionary> expectedResults = new Dictionary>(); + foreach (string scope in testScopes) + { + expectedResults[scope] = parsedTheme.Match(scope); + } + + // Use thread-safe collection for results per scope + ConcurrentDictionary>> resultsByScope = + new ConcurrentDictionary>>(); + foreach (string scope in testScopes) + { + resultsByScope[scope] = new ConcurrentBag>(); + } + + // Two-phase synchronization + CountdownEvent readyEvent = new CountdownEvent(threadCount); + ManualResetEventSlim startEvent = new ManualResetEventSlim(false); + + // Act - Multiple threads accessing different scopes concurrently + List tasks = new List(threadCount); + for (int t = 0; t < threadCount; t++) + { + int threadId = t; + Task task = Task.Run(() => + { + readyEvent.Signal(); + startEvent.Wait(); // Synchronize thread start + + for (int i = 0; i < iterationsPerThread; i++) + { + // Each thread cycles through different scopes to create cache contention + string scope = testScopes[(threadId + i) % uniqueScopeCount]; + List result = parsedTheme.Match(scope); + resultsByScope[scope].Add(result); + } + }); + tasks.Add(task); + } + + readyEvent.Wait(); + startEvent.Set(); + Task.WaitAll(tasks.ToArray()); + + readyEvent.Dispose(); + startEvent.Dispose(); + + // Assert - Each scope should have correct cached results + foreach (string scope in testScopes) + { + ConcurrentBag> scopeResults = resultsByScope[scope]; + Assert.IsNotEmpty(scopeResults, $"Should have results for scope: {scope}"); + + List expected = expectedResults[scope]; + foreach (List result in scopeResults) + { + Assert.IsNotNull(result, $"Result should not be null for scope: {scope}"); + Assert.That(result == expected || AreRuleListsEquivalent(result, expected), + $"Results for scope '{scope}' should be same or equivalent"); + } + } + } + + [Test] + [Repeat(5)] // Run multiple times to increase chance of catching race conditions + public void Match_ConcurrentAccessWithHeavyContention_ReturnsConsistentResults() + { + // Arrange - This test maximizes contention by having all threads access the same scope repeatedly + const string testScope = "source.heavily.contested"; + const int threadCount = 20; + const int iterationsPerThread = 200; + + ParsedTheme parsedTheme = CreateTestParsedTheme(); + + // Use Interlocked for atomic counter operations + int totalCalls = 0; + + // Use thread-safe collection + ConcurrentBag> allResults = new ConcurrentBag>(); + + // Two-phase synchronization + CountdownEvent readyEvent = new CountdownEvent(threadCount); + ManualResetEventSlim startEvent = new ManualResetEventSlim(false); + + // Act + List tasks = new List(threadCount); + for (int t = 0; t < threadCount; t++) + { + Task task = Task.Run(() => + { + readyEvent.Signal(); + startEvent.Wait(); + + for (int i = 0; i < iterationsPerThread; i++) + { + List result = parsedTheme.Match(testScope); + + Interlocked.Increment(ref totalCalls); + allResults.Add(result); + + // Yield to increase chance of interleaving with other threads + if (i % 10 == 0) + { + Thread.Yield(); + } + } + }); + tasks.Add(task); + } + + readyEvent.Wait(); // Wait for all threads to be ready + startEvent.Set(); // Release all threads at once + Task.WaitAll(tasks.ToArray()); + + readyEvent.Dispose(); + startEvent.Dispose(); + + // Assert + const int expectedTotalCalls = threadCount * iterationsPerThread; + Assert.AreEqual(expectedTotalCalls, totalCalls, "All Match calls should have completed"); + Assert.AreEqual(expectedTotalCalls, allResults.Count, "Should have captured all results"); + + // All results must be non-null (critical for the fallback logic being tested) + foreach (List result in allResults) + { + Assert.IsNotNull(result, "Match must never return null, even under heavy concurrent access"); + } + + // Results should all be equivalent (testing cache consistency) + List firstResult = allResults.First(); + foreach (List result in allResults) + { + Assert.That(result == firstResult || AreRuleListsEquivalent(result, firstResult), + "All results should be identical or equivalent"); + } + } + + [Test] + public void Match_FirstCallOnNewScope_ReturnsNonNullResult() + { + // Arrange + const string newScope = "never.before.seen.scope"; + ParsedTheme parsedTheme = CreateTestParsedTheme(); + + // Act + List result = parsedTheme.Match(newScope); + + // Assert + Assert.IsNotNull(result, "Match should return non-null even for uncached scopes"); + } + + [Test] + public void Match_SameScopeMultipleTimes_ReturnsCachedInstance() + { + // Arrange + const string testScope = "source.csharp"; + ParsedTheme parsedTheme = CreateTestParsedTheme(); + + // Act + List firstResult = parsedTheme.Match(testScope); + List secondResult = parsedTheme.Match(testScope); + List thirdResult = parsedTheme.Match(testScope); + + // Assert + Assert.IsNotNull(firstResult); + Assert.IsNotNull(secondResult); + Assert.IsNotNull(thirdResult); + Assert.AreSame(firstResult, secondResult, "Second call should return cached instance"); + Assert.AreSame(firstResult, thirdResult, "Third call should return cached instance"); + } + + [TestCase("")] + public void Match_EmptyScope_ReturnsNonNull(string scopeName) + { + // Arrange + ParsedTheme parsedTheme = CreateTestParsedTheme(); + + // Act + List result = parsedTheme.Match(scopeName); + + // Assert + Assert.IsNotNull(result, "Match should handle empty scope names gracefully"); + } + + [Test] + public void Match_NullScope_ThrowsArgumentNullException() + { + // Arrange + ParsedTheme parsedTheme = CreateTestParsedTheme(); + + // Act & Assert + Assert.Throws(() => parsedTheme.Match(null), + "Match should throw ArgumentNullException for null scope names"); + } + + [Test] + public void Match_LongScopeName_ReturnsNonNull() + { + // Arrange + const int scopeLimitLength = 1_000; + string longScope = new string('a', scopeLimitLength / 2) + "." + new string('b', scopeLimitLength / 2); + ParsedTheme parsedTheme = CreateTestParsedTheme(); + + // Act + List result = parsedTheme.Match(longScope); + + // Assert + Assert.IsNotNull(result, "Match should handle long scope names"); + } + + #endregion Match tests + + #region Rule sorting tests + + [Test] + public void CreateFromParsedTheme_RulesWithSameScope_SortsByParentScopes() + { + // Arrange + const string scope = "keyword"; + + // Rules with same scope but different parent scopes + // "source html" should come after "source" lexicographically + List rules = new List + { + new ParsedThemeRule("rule3", scope, new List { "source", "html" }, 2, FontStyle.None, "#FF0000", null), + new ParsedThemeRule("rule1", scope, null, 0, FontStyle.None, "#00FF00", null), + new ParsedThemeRule("rule2", scope, new List { "source" }, 1, FontStyle.None, "#0000FF", null) + }; + + ColorMap colorMap = new ColorMap(); + + // Act + ParsedTheme parsedTheme = ParsedTheme.CreateFromParsedTheme(rules, colorMap); + + // Assert - Verify sorting occurred by attempting matches + Assert.IsNotNull(parsedTheme); + List matches = parsedTheme.Match(scope); + Assert.IsNotNull(matches, "Should return match results"); + } + + [Test] + public void CreateFromParsedTheme_RulesWithSameScopeAndParentScopes_SortsByIndex() + { + // Arrange + const string scope = "keyword.control"; + List parentScopes = new List { "source", "js" }; + + // Rules with identical scope and parentScopes, different indices + List rules = new List + { + new ParsedThemeRule("rule2", scope, parentScopes, 5, FontStyle.Bold, "#FF0000", null), + new ParsedThemeRule("rule1", scope, parentScopes, 1, FontStyle.Italic, "#00FF00", null), + new ParsedThemeRule("rule3", scope, parentScopes, 10, FontStyle.Underline, "#0000FF", null) + }; + + ColorMap colorMap = new ColorMap(); + + // Act + ParsedTheme parsedTheme = ParsedTheme.CreateFromParsedTheme(rules, colorMap); + + // Assert - Rules should be sorted by index (1, 5, 10) + Assert.IsNotNull(parsedTheme); + List matches = parsedTheme.Match(scope); + Assert.IsNotNull(matches, "Should return match results after sorting by index"); + } + + [Test] + public void CreateFromParsedTheme_RulesWithNullAndNonNullParentScopes_SortsNullFirst() + { + // Arrange + const string scope = "string.quoted"; + + List rules = new List + { + new ParsedThemeRule("rule2", scope, new List { "source" }, 1, FontStyle.None, "#FF0000", null), + new ParsedThemeRule("rule1", scope, null, 0, FontStyle.None, "#00FF00", null) + }; + + ColorMap colorMap = new ColorMap(); + + // Act + ParsedTheme parsedTheme = ParsedTheme.CreateFromParsedTheme(rules, colorMap); + + // Assert + Assert.IsNotNull(parsedTheme); + List matches = parsedTheme.Match(scope); + Assert.IsNotNull(matches); + } + + [Test] + public void CreateFromParsedTheme_RulesWithDifferentParentScopeLengths_SortsByStringComparison() + { + // Arrange + const string scope = "variable"; + + List rules = new List + { + new ParsedThemeRule("rule3", scope, new List { "a", "b", "c" }, 2, FontStyle.None, "#FF0000", null), + new ParsedThemeRule("rule1", scope, new List { "a" }, 0, FontStyle.None, "#00FF00", null), + new ParsedThemeRule("rule2", scope, new List { "a", "b" }, 1, FontStyle.None, "#0000FF", null) + }; + + ColorMap colorMap = new ColorMap(); + + // Act + ParsedTheme parsedTheme = ParsedTheme.CreateFromParsedTheme(rules, colorMap); + + // Assert + Assert.IsNotNull(parsedTheme); + List matches = parsedTheme.Match(scope); + Assert.IsNotNull(matches); + } + + [Test] + public void CreateFromParsedTheme_RulesWithIdenticalParentScopesAndDifferentIndices_MaintainsIndexOrder() + { + // Arrange + const string scope = "comment.line"; + List identicalParentScopes = new List { "source", "python" }; + + List rules = new List + { + new ParsedThemeRule("high-priority", scope, identicalParentScopes, 100, FontStyle.None, "#FF0000", null), + new ParsedThemeRule("low-priority", scope, identicalParentScopes, 1, FontStyle.None, "#00FF00", null), + new ParsedThemeRule("mid-priority", scope, identicalParentScopes, 50, FontStyle.None, "#0000FF", null) + }; + + ColorMap colorMap = new ColorMap(); + + // Act + ParsedTheme parsedTheme = ParsedTheme.CreateFromParsedTheme(rules, colorMap); + + // Assert - Verify rules are processed in index order (1, 50, 100) + Assert.IsNotNull(parsedTheme); + List matches = parsedTheme.Match(scope); + Assert.IsNotNull(matches); + } + + [Test] + public void CreateFromParsedTheme_ComplexSortingScenario_HandlesAllComparisonLevels() + { + // Arrange - Mix of different scopes, parent scopes, and indices + List rules = new List + { + // Different scopes + new ParsedThemeRule("r1", "z.scope", null, 0, FontStyle.None, "#FF0000", null), + new ParsedThemeRule("r2", "a.scope", null, 1, FontStyle.None, "#00FF00", null), + + // Same scope, different parent scopes + new ParsedThemeRule("r3", "keyword", new List { "z" }, 2, FontStyle.None, "#0000FF", null), + new ParsedThemeRule("r4", "keyword", new List { "a" }, 3, FontStyle.None, "#FFFF00", null), + + // Same scope and parent scopes, different indices + new ParsedThemeRule("r5", "string", new List { "source" }, 10, FontStyle.None, "#FF00FF", null), + new ParsedThemeRule("r6", "string", new List { "source" }, 5, FontStyle.None, "#00FFFF", null) + }; + + ColorMap colorMap = new ColorMap(); + + // Act + ParsedTheme parsedTheme = ParsedTheme.CreateFromParsedTheme(rules, colorMap); + + // Assert - All rules should be processed without error + Assert.IsNotNull(parsedTheme); + + // Verify different scopes can be matched + Assert.IsNotNull(parsedTheme.Match("a.scope")); + Assert.IsNotNull(parsedTheme.Match("z.scope")); + Assert.IsNotNull(parsedTheme.Match("keyword")); + Assert.IsNotNull(parsedTheme.Match("string")); + } + + [Test] + public void CreateFromParsedTheme_RulesWithEmptyParentScopes_SortsCorrectly() + { + // Arrange + const string scope = "meta.tag"; + + List rules = new List + { + new ParsedThemeRule("rule2", scope, new List(), 1, FontStyle.None, "#FF0000", null), + new ParsedThemeRule("rule1", scope, null, 0, FontStyle.None, "#00FF00", null) + }; + + ColorMap colorMap = new ColorMap(); + + // Act + ParsedTheme parsedTheme = ParsedTheme.CreateFromParsedTheme(rules, colorMap); + + // Assert + Assert.IsNotNull(parsedTheme); + List matches = parsedTheme.Match(scope); + Assert.IsNotNull(matches); + } + + [Test] + public void CreateFromParsedTheme_RulesWithMaxIntIndex_HandlesWithoutOverflow() + { + // Arrange + const string scope = "boundary.test"; + + List rules = new List + { + new ParsedThemeRule("max", scope, null, int.MaxValue, FontStyle.None, "#FF0000", null), + new ParsedThemeRule("min", scope, null, int.MinValue, FontStyle.None, "#00FF00", null), + new ParsedThemeRule("zero", scope, null, 0, FontStyle.None, "#0000FF", null) + }; + + ColorMap colorMap = new ColorMap(); + + // Act + ParsedTheme parsedTheme = ParsedTheme.CreateFromParsedTheme(rules, colorMap); + + // Assert - Should handle extreme index values + Assert.IsNotNull(parsedTheme); + List matches = parsedTheme.Match(scope); + Assert.IsNotNull(matches); + } + + [Test] + public void CreateFromParsedTheme_RulesWithLexicographicallyCloseParentScopes_SortsCorrectly() + { + // Arrange + const string scope = "entity.name"; + + List rules = new List + { + new ParsedThemeRule("r3", scope, new List { "source", "aaa" }, 2, FontStyle.None, "#FF0000", null), + new ParsedThemeRule("r1", scope, new List { "source", "aaaa" }, 0, FontStyle.None, "#00FF00", null), + new ParsedThemeRule("r2", scope, new List { "source", "aab" }, 1, FontStyle.None, "#0000FF", null) + }; + + ColorMap colorMap = new ColorMap(); + + // Act + ParsedTheme parsedTheme = ParsedTheme.CreateFromParsedTheme(rules, colorMap); + + // Assert + Assert.IsNotNull(parsedTheme); + List matches = parsedTheme.Match(scope); + Assert.IsNotNull(matches); + } + + [Test] + public void CreateFromParsedTheme_SingleRule_ProcessesWithoutSortingIssues() + { + // Arrange + List rules = new List + { + new ParsedThemeRule("only-rule", "single.scope", new List { "parent" }, 42, FontStyle.Bold, "#ABCDEF", "#123456") + }; + + ColorMap colorMap = new ColorMap(); + + // Act + ParsedTheme parsedTheme = ParsedTheme.CreateFromParsedTheme(rules, colorMap); + + // Assert + Assert.IsNotNull(parsedTheme); + List matches = parsedTheme.Match("single.scope"); + Assert.IsNotNull(matches); + } + + [Test] + public void CreateFromParsedTheme_EmptyRuleList_CreatesThemeWithDefaultsOnly() + { + // Arrange + List rules = new List(); + ColorMap colorMap = new ColorMap(); + + // Act + ParsedTheme parsedTheme = ParsedTheme.CreateFromParsedTheme(rules, colorMap); + + // Assert + Assert.IsNotNull(parsedTheme); + Assert.IsNotNull(parsedTheme.GetDefaults()); + } + + #endregion Rule sorting tests + + #region ParsedGuiColors tests + + [Test] + public void ParsedGuiColors_NullColors_DoesNotModifyDictionary() + { + // Arrange + Mock mockTheme = new Mock(); + mockTheme.Setup(t => t.GetGuiColors()).Returns((Dictionary)null); + + Dictionary colorDictionary = new Dictionary(); + + // Act + ParsedTheme.ParsedGuiColors(mockTheme.Object, colorDictionary); + + // Assert + CollectionAssert.IsEmpty(colorDictionary, "Dictionary should remain empty when colors are null"); + } + + [Test] + public void ParsedGuiColors_EmptyColorsDictionary_DoesNotModifyDictionary() + { + // Arrange + Mock mockTheme = new Mock(); + mockTheme.Setup(t => t.GetGuiColors()).Returns(new Dictionary()); + + Dictionary colorDictionary = new Dictionary(); + + // Act + ParsedTheme.ParsedGuiColors(mockTheme.Object, colorDictionary); + + // Assert + CollectionAssert.IsEmpty(colorDictionary, "Dictionary should remain empty when colors dictionary is empty"); + } + + [Test] + public void ParsedGuiColors_SingleColor_AddsColorToDictionary() + { + // Arrange + const string colorKey = "editor.background"; + const string colorValue = "#1E1E1E"; + + Mock mockTheme = new Mock(); + mockTheme.Setup(t => t.GetGuiColors()).Returns(new Dictionary + { + { colorKey, colorValue } + }); + + Dictionary colorDictionary = new Dictionary(); + + // Act + ParsedTheme.ParsedGuiColors(mockTheme.Object, colorDictionary); + + // Assert + Assert.AreEqual(1, colorDictionary.Count); + Assert.IsTrue(colorDictionary.ContainsKey(colorKey)); + Assert.AreEqual(colorValue, colorDictionary[colorKey]); + } + + [Test] + public void ParsedGuiColors_MultipleColors_AddsAllColorsToDictionary() + { + // Arrange + const string key1 = "editor.background"; + const string value1 = "#1E1E1E"; + const string key2 = "editor.foreground"; + const string value2 = "#D4D4D4"; + const string key3 = "editor.lineHighlightBackground"; + const string value3 = "#282828"; + + Mock mockTheme = new Mock(); + mockTheme.Setup(t => t.GetGuiColors()).Returns(new Dictionary + { + { key1, value1 }, + { key2, value2 }, + { key3, value3 } + }); + + Dictionary colorDictionary = new Dictionary(); + + // Act + ParsedTheme.ParsedGuiColors(mockTheme.Object, colorDictionary); + + // Assert + Assert.AreEqual(3, colorDictionary.Count); + Assert.AreEqual(value1, colorDictionary[key1]); + Assert.AreEqual(value2, colorDictionary[key2]); + Assert.AreEqual(value3, colorDictionary[key3]); + } + + [Test] + public void ParsedGuiColors_DuplicateKey_OverwritesExistingValue() + { + // Arrange + const string colorKey = "editor.background"; + const string initialValue = "#000000"; + const string newValue = "#1E1E1E"; + + Mock mockTheme = new Mock(); + mockTheme.Setup(t => t.GetGuiColors()).Returns(new Dictionary + { + { colorKey, newValue } + }); + + Dictionary colorDictionary = new Dictionary + { + { colorKey, initialValue } + }; + + // Act + ParsedTheme.ParsedGuiColors(mockTheme.Object, colorDictionary); + + // Assert + Assert.AreEqual(1, colorDictionary.Count); + Assert.AreEqual(newValue, colorDictionary[colorKey], "New value should overwrite existing value"); + } + + [Test] + public void ParsedGuiColors_KeysWithSpecialCharacters_PreservesKeys() + { + // Arrange + const string key1 = "editor.selection-background"; + const string key2 = "editor_tab.activeBackground"; + const string key3 = "panel.border#top"; + const string value = "#264F78"; + + Mock mockTheme = new Mock(); + mockTheme.Setup(t => t.GetGuiColors()).Returns(new Dictionary + { + { key1, value }, + { key2, value }, + { key3, value } + }); + + Dictionary colorDictionary = new Dictionary(); + + // Act + ParsedTheme.ParsedGuiColors(mockTheme.Object, colorDictionary); + + // Assert + Assert.AreEqual(3, colorDictionary.Count); + Assert.IsTrue(colorDictionary.ContainsKey(key1), "Key with dash should be preserved"); + Assert.IsTrue(colorDictionary.ContainsKey(key2), "Key with underscore should be preserved"); + Assert.IsTrue(colorDictionary.ContainsKey(key3), "Key with hash should be preserved"); + } + + [Test] + public void ParsedGuiColors_EmptyStringKey_AddsToDict() + { + // Arrange + const string emptyKey = ""; + const string value = "#FFFFFF"; + + Mock mockTheme = new Mock(); + mockTheme.Setup(t => t.GetGuiColors()).Returns(new Dictionary + { + { emptyKey, value } + }); + + Dictionary colorDictionary = new Dictionary(); + + // Act + ParsedTheme.ParsedGuiColors(mockTheme.Object, colorDictionary); + + // Assert + Assert.AreEqual(1, colorDictionary.Count); + Assert.IsTrue(colorDictionary.ContainsKey(emptyKey)); + } + + [Test] + public void ParsedGuiColors_EmptyStringValue_AddsEmptyString() + { + // Arrange + const string key = "editor.background"; + const string emptyValue = ""; + + Mock mockTheme = new Mock(); + mockTheme.Setup(t => t.GetGuiColors()).Returns(new Dictionary + { + { key, emptyValue } + }); + + Dictionary colorDictionary = new Dictionary(); - Mock mockIncludedTheme = new Mock(); - mockIncludedTheme.Setup(t => t.GetSettings()).Returns(new List()); - mockIncludedTheme.Setup(t => t.GetTokenColors()).Returns(new List()); + // Act + ParsedTheme.ParsedGuiColors(mockTheme.Object, colorDictionary); - Mock mockRegistryOptions = new Mock(); - mockRegistryOptions.Setup(r => r.GetTheme(includeString)).Returns(mockIncludedTheme.Object); - const int priority = 0; + // Assert + Assert.AreEqual(1, colorDictionary.Count); + Assert.AreEqual(emptyValue, colorDictionary[key], "Empty string value should be preserved"); + } + + [Test] + public void ParsedGuiColors_WhitespaceValue_PreservesWhitespace() + { + // Arrange + const string key = "editor.background"; + const string whitespaceValue = " "; + + Mock mockTheme = new Mock(); + mockTheme.Setup(t => t.GetGuiColors()).Returns(new Dictionary + { + { key, whitespaceValue } + }); + + Dictionary colorDictionary = new Dictionary(); + + // Act + ParsedTheme.ParsedGuiColors(mockTheme.Object, colorDictionary); + + // Assert + Assert.AreEqual(whitespaceValue, colorDictionary[key], "Whitespace value should be preserved as-is"); + } + + [Test] + public void ParsedGuiColors_CalledMultipleTimes_AccumulatesColors() + { + // Arrange + const string key1 = "editor.background"; + const string value1 = "#1E1E1E"; + const string key2 = "editor.foreground"; + const string value2 = "#D4D4D4"; + + Mock mockTheme1 = new Mock(); + mockTheme1.Setup(t => t.GetGuiColors()).Returns(new Dictionary + { + { key1, value1 } + }); + + Mock mockTheme2 = new Mock(); + mockTheme2.Setup(t => t.GetGuiColors()).Returns(new Dictionary + { + { key2, value2 } + }); + + Dictionary colorDictionary = new Dictionary(); + + // Act + ParsedTheme.ParsedGuiColors(mockTheme1.Object, colorDictionary); + ParsedTheme.ParsedGuiColors(mockTheme2.Object, colorDictionary); + + // Assert + Assert.AreEqual(2, colorDictionary.Count); + Assert.AreEqual(value1, colorDictionary[key1]); + Assert.AreEqual(value2, colorDictionary[key2]); + } + + [Test] + public void ParsedGuiColors_CalledMultipleTimesWithSameKey_LastCallWins() + { + // Arrange + const string key = "editor.background"; + const string firstValue = "#000000"; + const string secondValue = "#1E1E1E"; + + Mock mockTheme1 = new Mock(); + mockTheme1.Setup(t => t.GetGuiColors()).Returns(new Dictionary + { + { key, firstValue } + }); + + Mock mockTheme2 = new Mock(); + mockTheme2.Setup(t => t.GetGuiColors()).Returns(new Dictionary + { + { key, secondValue } + }); + + Dictionary colorDictionary = new Dictionary(); + + // Act + ParsedTheme.ParsedGuiColors(mockTheme1.Object, colorDictionary); + ParsedTheme.ParsedGuiColors(mockTheme2.Object, colorDictionary); + + // Assert + Assert.AreEqual(1, colorDictionary.Count); + Assert.AreEqual(secondValue, colorDictionary[key], "Second call should overwrite first value"); + } + + [Test] + public void ParsedGuiColors_LongKeyAndValue_HandlesWithoutIssue() + { + // Arrange + const int keyLength = 1_000; + const int valueLength = 1_000; + string longKey = new string('k', keyLength); + string longValue = new string('v', valueLength); + + Mock mockTheme = new Mock(); + mockTheme.Setup(t => t.GetGuiColors()).Returns(new Dictionary + { + { longKey, longValue } + }); + + Dictionary colorDictionary = new Dictionary(); + + // Act + ParsedTheme.ParsedGuiColors(mockTheme.Object, colorDictionary); + + // Assert + Assert.AreEqual(1, colorDictionary.Count); + Assert.IsTrue(colorDictionary.ContainsKey(longKey)); + Assert.AreEqual(longValue, colorDictionary[longKey]); + } + + [Test] + public void ParsedGuiColors_ManyColors_AddsAllColors() + { + // Arrange + const int colorCount = 100; + Dictionary colors = new Dictionary(); + + for (int i = 0; i < colorCount; i++) + { + colors[$"color.key{i}"] = $"#00{i:X4}"; + } + + Mock mockTheme = new Mock(); + mockTheme.Setup(t => t.GetGuiColors()).Returns(colors); + + Dictionary colorDictionary = new Dictionary(); + + // Act + ParsedTheme.ParsedGuiColors(mockTheme.Object, colorDictionary); + + // Assert + Assert.AreEqual(colorCount, colorDictionary.Count); + + for (int i = 0; i < colorCount; i++) + { + string expectedKey = $"color.key{i}"; + string expectedValue = $"#00{i:X4}"; + Assert.IsTrue(colorDictionary.ContainsKey(expectedKey), $"Should contain key: {expectedKey}"); + Assert.AreEqual(expectedValue, colorDictionary[expectedKey]); + } + } + + [Test] + public void ParsedGuiColors_NonHexColorFormats_StoresAsIs() + { + // Arrange + const string key1 = "color.rgb"; + const string value1 = "rgb(255, 0, 0)"; + const string key2 = "color.rgba"; + const string value2 = "rgba(255, 0, 0, 0.5)"; + const string key3 = "color.hsl"; + const string value3 = "hsl(0, 100%, 50%)"; + + Mock mockTheme = new Mock(); + mockTheme.Setup(t => t.GetGuiColors()).Returns(new Dictionary + { + { key1, value1 }, + { key2, value2 }, + { key3, value3 } + }); + + Dictionary colorDictionary = new Dictionary(); + + // Act + ParsedTheme.ParsedGuiColors(mockTheme.Object, colorDictionary); + + // Assert + Assert.AreEqual(3, colorDictionary.Count); + Assert.AreEqual(value1, colorDictionary[key1], "RGB format should be stored as-is"); + Assert.AreEqual(value2, colorDictionary[key2], "RGBA format should be stored as-is"); + Assert.AreEqual(value3, colorDictionary[key3], "HSL format should be stored as-is"); + } + + [Test] + public void ParsedGuiColors_CaseSensitiveKeys_TreatsAsDifferentKeys() + { + // Arrange + const string lowerKey = "editor.background"; + const string upperKey = "EDITOR.BACKGROUND"; + const string mixedKey = "Editor.Background"; + const string value = "#1E1E1E"; + + Mock mockTheme = new Mock(); + mockTheme.Setup(t => t.GetGuiColors()).Returns(new Dictionary + { + { lowerKey, value }, + { upperKey, value }, + { mixedKey, value } + }); + + Dictionary colorDictionary = new Dictionary(); + + // Act + ParsedTheme.ParsedGuiColors(mockTheme.Object, colorDictionary); + + // Assert + Assert.AreEqual(3, colorDictionary.Count, "Keys with different casing should be treated as different keys"); + Assert.IsTrue(colorDictionary.ContainsKey(lowerKey)); + Assert.IsTrue(colorDictionary.ContainsKey(upperKey)); + Assert.IsTrue(colorDictionary.ContainsKey(mixedKey)); + } + + #endregion ParsedGuiColors tests + #region ParseFontStyle tests (via ParseTheme) + + [Test] + public void ParseTheme_FontStyleEmpty_ReturnsFontStyleNone() + { + // Arrange + ThemeRaw rawTheme = new ThemeRaw + { + ["tokenColors"] = new List + { + new ThemeRaw + { + ["scope"] = "test.scope", + ["settings"] = new ThemeRaw + { + ["fontStyle"] = "", + ["foreground"] = "#FF0000" + } + } + } + }; + + // Act + List rules = ParsedTheme.ParseTheme(rawTheme, 0); + + // Assert + Assert.AreEqual(1, rules.Count); + Assert.AreEqual(FontStyle.None, rules[0].fontStyle, "Empty fontStyle string should parse as None"); + } + + [Test] + public void ParseTheme_FontStyleItalic_ReturnsFontStyleItalic() + { + // Arrange + ThemeRaw rawTheme = new ThemeRaw + { + ["tokenColors"] = new List + { + new ThemeRaw + { + ["scope"] = "test.scope", + ["settings"] = new ThemeRaw + { + ["fontStyle"] = "italic", + ["foreground"] = "#FF0000" + } + } + } + }; + + // Act + List rules = ParsedTheme.ParseTheme(rawTheme, 0); + + // Assert + Assert.AreEqual(1, rules.Count); + Assert.AreEqual(FontStyle.Italic, rules[0].fontStyle); + } + + [Test] + public void ParseTheme_FontStyleBold_ReturnsFontStyleBold() + { + // Arrange + ThemeRaw rawTheme = new ThemeRaw + { + ["tokenColors"] = new List + { + new ThemeRaw + { + ["scope"] = "test.scope", + ["settings"] = new ThemeRaw + { + ["fontStyle"] = "bold", + ["foreground"] = "#FF0000" + } + } + } + }; + + // Act + List rules = ParsedTheme.ParseTheme(rawTheme, 0); + + // Assert + Assert.AreEqual(1, rules.Count); + Assert.AreEqual(FontStyle.Bold, rules[0].fontStyle); + } + + [Test] + public void ParseTheme_FontStyleUnderline_ReturnsFontStyleUnderline() + { + // Arrange + ThemeRaw rawTheme = new ThemeRaw + { + ["tokenColors"] = new List + { + new ThemeRaw + { + ["scope"] = "test.scope", + ["settings"] = new ThemeRaw + { + ["fontStyle"] = "underline", + ["foreground"] = "#FF0000" + } + } + } + }; + + // Act + List rules = ParsedTheme.ParseTheme(rawTheme, 0); + + // Assert + Assert.AreEqual(1, rules.Count); + Assert.AreEqual(FontStyle.Underline, rules[0].fontStyle); + } + + [Test] + public void ParseTheme_FontStyleStrikethrough_ReturnsFontStyleStrikethrough() + { + // Arrange + ThemeRaw rawTheme = new ThemeRaw + { + ["tokenColors"] = new List + { + new ThemeRaw + { + ["scope"] = "test.scope", + ["settings"] = new ThemeRaw + { + ["fontStyle"] = "strikethrough", + ["foreground"] = "#FF0000" + } + } + } + }; + + // Act + List rules = ParsedTheme.ParseTheme(rawTheme, 0); + + // Assert + Assert.AreEqual(1, rules.Count); + Assert.AreEqual(FontStyle.Strikethrough, rules[0].fontStyle); + } + + [Test] + public void ParseTheme_FontStyleItalicBold_CombinesFlags() + { + // Arrange + ThemeRaw rawTheme = new ThemeRaw + { + ["tokenColors"] = new List + { + new ThemeRaw + { + ["scope"] = "test.scope", + ["settings"] = new ThemeRaw + { + ["fontStyle"] = "italic bold", + ["foreground"] = "#FF0000" + } + } + } + }; + + // Act + List rules = ParsedTheme.ParseTheme(rawTheme, 0); + + // Assert + Assert.AreEqual(1, rules.Count); + Assert.AreEqual(FontStyle.Italic | FontStyle.Bold, rules[0].fontStyle); + } + + [Test] + public void ParseTheme_FontStyleAllCombined_CombinesAllFlags() + { + // Arrange + ThemeRaw rawTheme = new ThemeRaw + { + ["tokenColors"] = new List + { + new ThemeRaw + { + ["scope"] = "test.scope", + ["settings"] = new ThemeRaw + { + ["fontStyle"] = "italic bold underline strikethrough", + ["foreground"] = "#FF0000" + } + } + } + }; + + // Act + List rules = ParsedTheme.ParseTheme(rawTheme, 0); + + // Assert + Assert.AreEqual(1, rules.Count); + Assert.AreEqual( + FontStyle.Italic | FontStyle.Bold | FontStyle.Underline | FontStyle.Strikethrough, + rules[0].fontStyle); + } + + [Test] + public void ParseTheme_FontStyleWithExtraSpaces_ParsesCorrectly() + { + // Arrange + ThemeRaw rawTheme = new ThemeRaw + { + ["tokenColors"] = new List + { + new ThemeRaw + { + ["scope"] = "test.scope", + ["settings"] = new ThemeRaw + { + ["fontStyle"] = "italic bold underline", + ["foreground"] = "#FF0000" + } + } + } + }; + + // Act + List rules = ParsedTheme.ParseTheme(rawTheme, 0); + + // Assert + Assert.AreEqual(1, rules.Count); + Assert.AreEqual( + FontStyle.Italic | FontStyle.Bold | FontStyle.Underline, + rules[0].fontStyle, + "Extra spaces between style keywords should be handled correctly"); + } + + [Test] + public void ParseTheme_FontStyleWithLeadingSpace_ParsesCorrectly() + { + // Arrange + ThemeRaw rawTheme = new ThemeRaw + { + ["tokenColors"] = new List + { + new ThemeRaw + { + ["scope"] = "test.scope", + ["settings"] = new ThemeRaw + { + ["fontStyle"] = " italic bold", + ["foreground"] = "#FF0000" + } + } + } + }; + + // Act + List rules = ParsedTheme.ParseTheme(rawTheme, 0); + + // Assert + Assert.AreEqual(1, rules.Count); + Assert.AreEqual( + FontStyle.Italic | FontStyle.Bold, + rules[0].fontStyle, + "Leading space should be handled by creating empty segment which is ignored"); + } + + [Test] + public void ParseTheme_FontStyleWithTrailingSpace_ParsesCorrectly() + { + // Arrange + ThemeRaw rawTheme = new ThemeRaw + { + ["tokenColors"] = new List + { + new ThemeRaw + { + ["scope"] = "test.scope", + ["settings"] = new ThemeRaw + { + ["fontStyle"] = "italic bold ", + ["foreground"] = "#FF0000" + } + } + } + }; + + // Act + List rules = ParsedTheme.ParseTheme(rawTheme, 0); + + // Assert + Assert.AreEqual(1, rules.Count); + Assert.AreEqual( + FontStyle.Italic | FontStyle.Bold, + rules[0].fontStyle, + "Trailing space should not affect parsing"); + } + + [Test] + public void ParseTheme_FontStyleUnknownKeyword_IgnoresUnknown() + { + // Arrange + ThemeRaw rawTheme = new ThemeRaw + { + ["tokenColors"] = new List + { + new ThemeRaw + { + ["scope"] = "test.scope", + ["settings"] = new ThemeRaw + { + ["fontStyle"] = "italic unknown bold", + ["foreground"] = "#FF0000" + } + } + } + }; + + // Act + List rules = ParsedTheme.ParseTheme(rawTheme, 0); + + // Assert + Assert.AreEqual(1, rules.Count); + Assert.AreEqual( + FontStyle.Italic | FontStyle.Bold, + rules[0].fontStyle, + "Unknown keywords should be ignored"); + } + + [TestCase("ITALIC", FontStyle.None, "Uppercase should not match")] + [TestCase("Bold", FontStyle.None, "Mixed case should not match")] + [TestCase("BOLD", FontStyle.None, "Uppercase should not match")] + [TestCase("Underline", FontStyle.None, "Mixed case should not match")] + public void ParseTheme_FontStyleCaseSensitive_RequiresLowercase(string fontStyleString, FontStyle expected, string reason) + { + // Arrange + ThemeRaw rawTheme = new ThemeRaw + { + ["tokenColors"] = new List + { + new ThemeRaw + { + ["scope"] = "test.scope", + ["settings"] = new ThemeRaw + { + ["fontStyle"] = fontStyleString, + ["foreground"] = "#FF0000" + } + } + } + }; + + // Act + List rules = ParsedTheme.ParseTheme(rawTheme, 0); + + // Assert + Assert.AreEqual(1, rules.Count); + Assert.AreEqual(expected, rules[0].fontStyle, reason); + } + + [Test] + public void ParseTheme_FontStylePartialMatch_DoesNotMatch() + { + // Arrange + ThemeRaw rawTheme = new ThemeRaw + { + ["tokenColors"] = new List + { + new ThemeRaw + { + ["scope"] = "test.scope", + ["settings"] = new ThemeRaw + { + ["fontStyle"] = "ital boldy underl", + ["foreground"] = "#FF0000" + } + } + } + }; + + // Act + List rules = ParsedTheme.ParseTheme(rawTheme, 0); + + // Assert + Assert.AreEqual(1, rules.Count); + Assert.AreEqual(FontStyle.None, rules[0].fontStyle, "Partial keyword matches should not be recognized"); + } + + [Test] + public void ParseTheme_FontStyleDuplicateKeywords_AppliesOnce() + { + // Arrange + ThemeRaw rawTheme = new ThemeRaw + { + ["tokenColors"] = new List + { + new ThemeRaw + { + ["scope"] = "test.scope", + ["settings"] = new ThemeRaw + { + ["fontStyle"] = "italic bold italic bold", + ["foreground"] = "#FF0000" + } + } + } + }; + + // Act + List rules = ParsedTheme.ParseTheme(rawTheme, 0); + + // Assert + Assert.AreEqual(1, rules.Count); + Assert.AreEqual( + FontStyle.Italic | FontStyle.Bold, + rules[0].fontStyle, + "Duplicate keywords should result in same flags (bitwise OR is idempotent)"); + } + + [Test] + public void ParseTheme_FontStyleOnlySpaces_ReturnsFontStyleNone() + { + // Arrange + ThemeRaw rawTheme = new ThemeRaw + { + ["tokenColors"] = new List + { + new ThemeRaw + { + ["scope"] = "test.scope", + ["settings"] = new ThemeRaw + { + ["fontStyle"] = " ", + ["foreground"] = "#FF0000" + } + } + } + }; + + // Act + List rules = ParsedTheme.ParseTheme(rawTheme, 0); + + // Assert + Assert.AreEqual(1, rules.Count); + Assert.AreEqual(FontStyle.None, rules[0].fontStyle, "String with only spaces should parse as None"); + } + + [Test] + public void ParseTheme_FontStyleSingleSpace_ReturnsFontStyleNone() + { + // Arrange + ThemeRaw rawTheme = new ThemeRaw + { + ["tokenColors"] = new List + { + new ThemeRaw + { + ["scope"] = "test.scope", + ["settings"] = new ThemeRaw + { + ["fontStyle"] = " ", + ["foreground"] = "#FF0000" + } + } + } + }; + + // Act + List rules = ParsedTheme.ParseTheme(rawTheme, 0); + + // Assert + Assert.AreEqual(1, rules.Count); + Assert.AreEqual(FontStyle.None, rules[0].fontStyle, "Single space should parse as None"); + } + + [Test] + public void ParseTheme_FontStyleVeryLongString_ParsesCorrectly() + { + // Arrange + const int repeatCount = 100; + string longFontStyle = string.Join(" ", Enumerable.Repeat("italic bold", repeatCount)); + + ThemeRaw rawTheme = new ThemeRaw + { + ["tokenColors"] = new List + { + new ThemeRaw + { + ["scope"] = "test.scope", + ["settings"] = new ThemeRaw + { + ["fontStyle"] = longFontStyle, + ["foreground"] = "#FF0000" + } + } + } + }; + + // Act + List rules = ParsedTheme.ParseTheme(rawTheme, 0); + + // Assert + Assert.AreEqual(1, rules.Count); + Assert.AreEqual( + FontStyle.Italic | FontStyle.Bold, + rules[0].fontStyle, + "Very long fontStyle string should parse correctly"); + } + + [Test] + public void ParseTheme_FontStyleMixedValidAndInvalid_ParsesOnlyValid() + { + // Arrange + ThemeRaw rawTheme = new ThemeRaw + { + ["tokenColors"] = new List + { + new ThemeRaw + { + ["scope"] = "test.scope", + ["settings"] = new ThemeRaw + { + ["fontStyle"] = "normal italic invalid bold foo underline bar", + ["foreground"] = "#FF0000" + } + } + } + }; + + // Act + List rules = ParsedTheme.ParseTheme(rawTheme, 0); + + // Assert + Assert.AreEqual(1, rules.Count); + Assert.AreEqual( + FontStyle.Italic | FontStyle.Bold | FontStyle.Underline, + rules[0].fontStyle, + "Should parse only valid keywords and ignore invalid ones"); + } + + [Test] + public void ParseTheme_MultipleScopesWithDifferentFontStyles_ParsesEachCorrectly() + { + // Arrange + ThemeRaw rawTheme = new ThemeRaw + { + ["tokenColors"] = new List + { + new ThemeRaw + { + ["scope"] = "scope1", + ["settings"] = new ThemeRaw + { + ["fontStyle"] = "italic", + ["foreground"] = "#FF0000" + } + }, + new ThemeRaw + { + ["scope"] = "scope2", + ["settings"] = new ThemeRaw + { + ["fontStyle"] = "bold", + ["foreground"] = "#00FF00" + } + }, + new ThemeRaw + { + ["scope"] = "scope3", + ["settings"] = new ThemeRaw + { + ["fontStyle"] = "underline strikethrough", + ["foreground"] = "#0000FF" + } + } + } + }; + + // Act + List rules = ParsedTheme.ParseTheme(rawTheme, 0); + + // Assert + Assert.AreEqual(3, rules.Count); + Assert.AreEqual(FontStyle.Italic, rules[0].fontStyle); + Assert.AreEqual(FontStyle.Bold, rules[1].fontStyle); + Assert.AreEqual(FontStyle.Underline | FontStyle.Strikethrough, rules[2].fontStyle); + } + + #endregion ParseFontStyle tests (via ParseTheme) + + #region ExtractScopeAndParents tests (via ParseTheme) + + [Test] + public void ParseTheme_SingleSegmentScope_ReturnsNullParentScopes() + { + // Arrange + const string singleSegmentScope = "keyword"; + + ThemeRaw rawTheme = new ThemeRaw + { + ["tokenColors"] = new List + { + new ThemeRaw + { + ["scope"] = singleSegmentScope, + ["settings"] = new ThemeRaw { ["foreground"] = "#FF0000" } + } + } + }; + + // Act + List rules = ParsedTheme.ParseTheme(rawTheme, 0); + + // Assert + Assert.AreEqual(1, rules.Count); + Assert.AreEqual(singleSegmentScope, rules[0].scope, "Single segment should be the scope"); + Assert.IsNull(rules[0].parentScopes, "Single segment scope should have null parentScopes (fast path)"); + } + + [Test] + public void ParseTheme_TwoSegmentScope_ExtractsLastAsScope() + { + // Arrange + const string twoSegmentScope = "text html"; + const string expectedScope = "html"; + + ThemeRaw rawTheme = new ThemeRaw + { + ["tokenColors"] = new List + { + new ThemeRaw + { + ["scope"] = twoSegmentScope, + ["settings"] = new ThemeRaw { ["foreground"] = "#FF0000" } + } + } + }; + + // Act + List rules = ParsedTheme.ParseTheme(rawTheme, 0); + + // Assert + Assert.AreEqual(1, rules.Count); + Assert.AreEqual(expectedScope, rules[0].scope, "Last segment should be the scope"); + Assert.IsNotNull(rules[0].parentScopes); + Assert.AreEqual(2, rules[0].parentScopes.Count, "Should have 2 segments in parentScopes"); + CollectionAssert.AreEqual(new[] { "html", "text" }, rules[0].parentScopes, "Parent scopes should be in reverse order"); + } + + [Test] + public void ParseTheme_ThreeSegmentScope_ExtractsInReverseOrder() + { + // Arrange + const string threeSegmentScope = "text html basic"; + const string expectedScope = "basic"; + + ThemeRaw rawTheme = new ThemeRaw + { + ["tokenColors"] = new List + { + new ThemeRaw + { + ["scope"] = threeSegmentScope, + ["settings"] = new ThemeRaw { ["foreground"] = "#FF0000" } + } + } + }; + + // Act + List rules = ParsedTheme.ParseTheme(rawTheme, 0); + + // Assert + Assert.AreEqual(1, rules.Count); + Assert.AreEqual(expectedScope, rules[0].scope); + Assert.IsNotNull(rules[0].parentScopes); + Assert.AreEqual(3, rules[0].parentScopes.Count); + CollectionAssert.AreEqual(new[] { "basic", "html", "text" }, rules[0].parentScopes, + "Parent scopes should be all segments in reverse order"); + } + + [Test] + public void ParseTheme_FourSegmentScope_AllSegmentsReversed() + { + // Arrange + const string fourSegmentScope = "source js meta function"; + const string expectedScope = "function"; + + ThemeRaw rawTheme = new ThemeRaw + { + ["tokenColors"] = new List + { + new ThemeRaw + { + ["scope"] = fourSegmentScope, + ["settings"] = new ThemeRaw { ["foreground"] = "#FF0000" } + } + } + }; + + // Act + List rules = ParsedTheme.ParseTheme(rawTheme, 0); + + // Assert + Assert.AreEqual(1, rules.Count); + Assert.AreEqual(expectedScope, rules[0].scope); + Assert.AreEqual(4, rules[0].parentScopes.Count); + CollectionAssert.AreEqual(new[] { "function", "meta", "js", "source" }, rules[0].parentScopes); + } + + [Test] + public void ParseTheme_ManySegmentScope_HandlesLargeCount() + { + // Arrange + const int segmentCount = 10; + string[] segments = new string[segmentCount]; + for (int i = 0; i < segmentCount; i++) + { + segments[i] = $"segment{i}"; + } + string manySegmentScope = string.Join(" ", segments); + string expectedScope = segments[segmentCount - 1]; + + ThemeRaw rawTheme = new ThemeRaw + { + ["tokenColors"] = new List + { + new ThemeRaw + { + ["scope"] = manySegmentScope, + ["settings"] = new ThemeRaw { ["foreground"] = "#FF0000" } + } + } + }; + + // Act + List rules = ParsedTheme.ParseTheme(rawTheme, 0); + + // Assert + Assert.AreEqual(1, rules.Count); + Assert.AreEqual(expectedScope, rules[0].scope); + Assert.AreEqual(segmentCount, rules[0].parentScopes.Count); + + // Verify reverse order + for (int i = 0; i < segmentCount; i++) + { + Assert.AreEqual(segments[segmentCount - 1 - i], rules[0].parentScopes[i], + $"Parent scope at index {i} should be segment{segmentCount - 1 - i}"); + } + } + + [Test] + public void ParseTheme_ScopeWithConsecutiveSpaces_CreatesEmptySegmentsBetween() + { + // Arrange + const string scopeWithConsecutiveSpaces = "keyword control"; + + ThemeRaw rawTheme = new ThemeRaw + { + ["tokenColors"] = new List + { + new ThemeRaw + { + ["scope"] = scopeWithConsecutiveSpaces, + ["settings"] = new ThemeRaw { ["foreground"] = "#FF0000" } + } + } + }; + + // Act + List rules = ParsedTheme.ParseTheme(rawTheme, 0); + + // Assert + Assert.AreEqual(1, rules.Count); + Assert.AreEqual("control", rules[0].scope); + Assert.IsNotNull(rules[0].parentScopes); + Assert.AreEqual(3, rules[0].parentScopes.Count); + CollectionAssert.AreEqual(new[] { "control", "", "keyword" }, rules[0].parentScopes, + "Consecutive spaces create empty segment between"); + } + + [Test] + public void ParseTheme_ScopeWithSpecialCharacters_PreservesCharacters() + { + // Arrange + const string scopeWithSpecialChars = "meta.tag.custom-element source.js.embedded"; + const string expectedScope = "source.js.embedded"; + + ThemeRaw rawTheme = new ThemeRaw + { + ["tokenColors"] = new List + { + new ThemeRaw + { + ["scope"] = scopeWithSpecialChars, + ["settings"] = new ThemeRaw { ["foreground"] = "#FF0000" } + } + } + }; + + // Act + List rules = ParsedTheme.ParseTheme(rawTheme, 0); + + // Assert + Assert.AreEqual(1, rules.Count); + Assert.AreEqual(expectedScope, rules[0].scope); + Assert.AreEqual(2, rules[0].parentScopes.Count); + CollectionAssert.AreEqual(new[] { "source.js.embedded", "meta.tag.custom-element" }, rules[0].parentScopes, + "Special characters like dots and hyphens should be preserved"); + } + + [Test] + public void ParseTheme_VeryLongScopeString_HandlesWithoutIssue() + { + // Arrange + const int segmentCount = 100; + string[] segments = new string[segmentCount]; + for (int i = 0; i < segmentCount; i++) + { + segments[i] = $"verylongsegmentname{i}withlotsoflongercharacterstomakeit"; + } + string veryLongScope = string.Join(" ", segments); + string expectedScope = segments[segmentCount - 1]; + + ThemeRaw rawTheme = new ThemeRaw + { + ["tokenColors"] = new List + { + new ThemeRaw + { + ["scope"] = veryLongScope, + ["settings"] = new ThemeRaw { ["foreground"] = "#FF0000" } + } + } + }; + + // Act + List rules = ParsedTheme.ParseTheme(rawTheme, 0); + + // Assert + Assert.AreEqual(1, rules.Count); + Assert.AreEqual(expectedScope, rules[0].scope); + Assert.AreEqual(segmentCount, rules[0].parentScopes.Count); + } + + [Test] + public void ParseTheme_ScopeWithMixedSegmentLengths_HandlesCorrectly() + { + // Arrange + const string mixedLengthScope = "a verylongsegment b shortone c"; + const string expectedScope = "c"; + + ThemeRaw rawTheme = new ThemeRaw + { + ["tokenColors"] = new List + { + new ThemeRaw + { + ["scope"] = mixedLengthScope, + ["settings"] = new ThemeRaw { ["foreground"] = "#FF0000" } + } + } + }; + + // Act + List rules = ParsedTheme.ParseTheme(rawTheme, 0); + + // Assert + Assert.AreEqual(1, rules.Count); + Assert.AreEqual(expectedScope, rules[0].scope); + Assert.AreEqual(5, rules[0].parentScopes.Count); + CollectionAssert.AreEqual(new[] { "c", "shortone", "b", "verylongsegment", "a" }, rules[0].parentScopes); + } + + [Test] + public void ParseTheme_ScopeWithNumericSegments_ParsesCorrectly() + { + // Arrange + const string numericScope = "segment1 segment2 segment3"; + const string expectedScope = "segment3"; + + ThemeRaw rawTheme = new ThemeRaw + { + ["tokenColors"] = new List + { + new ThemeRaw + { + ["scope"] = numericScope, + ["settings"] = new ThemeRaw { ["foreground"] = "#FF0000" } + } + } + }; + + // Act + List rules = ParsedTheme.ParseTheme(rawTheme, 0); + + // Assert + Assert.AreEqual(1, rules.Count); + Assert.AreEqual(expectedScope, rules[0].scope); + CollectionAssert.AreEqual(new[] { "segment3", "segment2", "segment1" }, rules[0].parentScopes); + } + + [Test] + public void ParseTheme_MultipleScopesWithDifferentSegments_ParsesEachCorrectly() + { + // Arrange + ThemeRaw rawTheme = new ThemeRaw + { + ["tokenColors"] = new List + { + new ThemeRaw + { + ["scope"] = "single", + ["settings"] = new ThemeRaw { ["foreground"] = "#FF0000" } + }, + new ThemeRaw + { + ["scope"] = "two segments", + ["settings"] = new ThemeRaw { ["foreground"] = "#00FF00" } + }, + new ThemeRaw + { + ["scope"] = "three segment scope", + ["settings"] = new ThemeRaw { ["foreground"] = "#0000FF" } + } + } + }; + + // Act + List rules = ParsedTheme.ParseTheme(rawTheme, 0); + + // Assert + Assert.AreEqual(3, rules.Count); + + // First rule: single segment + Assert.AreEqual("single", rules[0].scope); + Assert.IsNull(rules[0].parentScopes); + + // Second rule: two segments + Assert.AreEqual("segments", rules[1].scope); + Assert.AreEqual(2, rules[1].parentScopes.Count); + CollectionAssert.AreEqual(new[] { "segments", "two" }, rules[1].parentScopes); + + // Third rule: three segments + Assert.AreEqual("scope", rules[2].scope); + Assert.AreEqual(3, rules[2].parentScopes.Count); + CollectionAssert.AreEqual(new[] { "scope", "segment", "three" }, rules[2].parentScopes); + } + + [Test] + public void ParseTheme_ScopeWithUnicodeCharacters_PreservesUnicode() + { + // Arrange + const string unicodeScope = "テキスト HTML 基本"; + const string expectedScope = "基本"; + + ThemeRaw rawTheme = new ThemeRaw + { + ["tokenColors"] = new List + { + new ThemeRaw + { + ["scope"] = unicodeScope, + ["settings"] = new ThemeRaw { ["foreground"] = "#FF0000" } + } + } + }; + + // Act + List rules = ParsedTheme.ParseTheme(rawTheme, 0); + + // Assert + Assert.AreEqual(1, rules.Count); + Assert.AreEqual(expectedScope, rules[0].scope); + Assert.AreEqual(3, rules[0].parentScopes.Count); + CollectionAssert.AreEqual(new[] { "基本", "HTML", "テキスト" }, rules[0].parentScopes, + "Unicode characters should be preserved"); + } + + #endregion ExtractScopeAndParents tests (via ParseTheme) + + #region LookupThemeRules tests (via ParseTheme) + + [Test] + public void ParseTheme_NullSettings_ReturnsEmptyList() + { + // Arrange + Mock mockTheme = new Mock(); + mockTheme.Setup(t => t.GetSettings()).Returns((List)null); + mockTheme.Setup(t => t.GetTokenColors()).Returns((List)null); + + // Act + List rules = ParsedTheme.ParseTheme(mockTheme.Object, 0); + + // Assert + Assert.IsNotNull(rules); + CollectionAssert.IsEmpty(rules); + } + + [Test] + public void ParseTheme_EntryWithNullSettings_SkipsEntry() + { + // Arrange + Mock mockSettingWithNull = new Mock(); + mockSettingWithNull.Setup(s => s.GetSetting()).Returns((IThemeSetting)null); + + ThemeRaw rawTheme = new ThemeRaw + { + ["tokenColors"] = new List + { + mockSettingWithNull.Object, + new ThemeRaw + { + ["scope"] = "valid.scope", + ["settings"] = new ThemeRaw { ["foreground"] = "#FF0000" } + } + } + }; + + // Act + List rules = ParsedTheme.ParseTheme(rawTheme, 0); + + // Assert + Assert.AreEqual(1, rules.Count, "Should skip entry with null settings"); + Assert.AreEqual("valid.scope", rules[0].scope); + } + + [Test] + public void ParseTheme_CommaSeparatedScopes_CreatesMultipleRules() + { + // Arrange + const string commaSeparated = "keyword.control,keyword.operator,keyword.other"; + const string foreground = "#FF0000"; + + ThemeRaw rawTheme = new ThemeRaw + { + ["tokenColors"] = new List + { + new ThemeRaw + { + ["scope"] = commaSeparated, + ["settings"] = new ThemeRaw { ["foreground"] = foreground } + } + } + }; + + // Act + List rules = ParsedTheme.ParseTheme(rawTheme, 0); + + // Assert + Assert.AreEqual(3, rules.Count); + Assert.AreEqual("keyword.control", rules[0].scope); + Assert.AreEqual("keyword.operator", rules[1].scope); + Assert.AreEqual("keyword.other", rules[2].scope); + Assert.AreEqual(foreground, rules[0].foreground); + Assert.AreEqual(foreground, rules[1].foreground); + Assert.AreEqual(foreground, rules[2].foreground); + } + + [Test] + public void ParseTheme_CommaSeparatedScopesWithSpaces_TrimsEachScope() + { + // Arrange + const string commaSeparated = "keyword.control , keyword.operator , keyword.other"; + + ThemeRaw rawTheme = new ThemeRaw + { + ["tokenColors"] = new List + { + new ThemeRaw + { + ["scope"] = commaSeparated, + ["settings"] = new ThemeRaw { ["foreground"] = "#FF0000" } + } + } + }; + + // Act + List rules = ParsedTheme.ParseTheme(rawTheme, 0); + + // Assert + Assert.AreEqual(3, rules.Count); + Assert.AreEqual("keyword.control", rules[0].scope); + Assert.AreEqual("keyword.operator", rules[1].scope); + Assert.AreEqual("keyword.other", rules[2].scope); + } + + [Test] + public void ParseTheme_OnlyCommas_CreatesNoRules() + { + // Arrange + const string onlyCommas = ",,,"; + + ThemeRaw rawTheme = new ThemeRaw + { + ["tokenColors"] = new List + { + new ThemeRaw + { + ["scope"] = onlyCommas, + ["settings"] = new ThemeRaw { ["foreground"] = "#FF0000" } + } + } + }; + + // Act + List rules = ParsedTheme.ParseTheme(rawTheme, 0); + + // Assert + CollectionAssert.IsEmpty(rules, "Only commas should produce no rules (empty after trim)"); + } + + [Test] + public void ParseTheme_LeadingTrailingCommas_IgnoresEmptySegments() + { + // Arrange + const string withCommas = ",keyword.control,keyword.operator,"; + + ThemeRaw rawTheme = new ThemeRaw + { + ["tokenColors"] = new List + { + new ThemeRaw + { + ["scope"] = withCommas, + ["settings"] = new ThemeRaw { ["foreground"] = "#FF0000" } + } + } + }; + + // Act + List rules = ParsedTheme.ParseTheme(rawTheme, 0); + + // Assert + Assert.AreEqual(2, rules.Count, "Leading/trailing commas should be trimmed"); + Assert.AreEqual("keyword.control", rules[0].scope); + Assert.AreEqual("keyword.operator", rules[1].scope); + } + + [Test] + public void ParseTheme_ConsecutiveCommas_CreatesOnlyNonEmptyScopes() + { + // Arrange + const string consecutiveCommas = "keyword.control,,keyword.operator"; + + ThemeRaw rawTheme = new ThemeRaw + { + ["tokenColors"] = new List + { + new ThemeRaw + { + ["scope"] = consecutiveCommas, + ["settings"] = new ThemeRaw { ["foreground"] = "#FF0000" } + } + } + }; + + // Act + List rules = ParsedTheme.ParseTheme(rawTheme, 0); + + // Assert + Assert.AreEqual(2, rules.Count, "Empty segments between commas should be skipped"); + Assert.AreEqual("keyword.control", rules[0].scope); + Assert.AreEqual("keyword.operator", rules[1].scope); + } + + [Test] + public void ParseTheme_ScopeAsListOfStrings_CreatesRuleForEach() + { + // Arrange + Mock mockSetting = new Mock(); + List scopeList = new List { "keyword.control", "keyword.operator", "keyword.other" }; + mockSetting.Setup(s => s.GetScope()).Returns(scopeList); + mockSetting.Setup(s => s.GetSetting()).Returns(new ThemeRaw { ["foreground"] = "#FF0000" }); + + ThemeRaw rawTheme = new ThemeRaw + { + ["tokenColors"] = new List { mockSetting.Object } + }; + + // Act + List rules = ParsedTheme.ParseTheme(rawTheme, 0); + + // Assert + Assert.AreEqual(3, rules.Count); + Assert.AreEqual("keyword.control", rules[0].scope); + Assert.AreEqual("keyword.operator", rules[1].scope); + Assert.AreEqual("keyword.other", rules[2].scope); + } + + [Test] + public void ParseTheme_ScopeAsEmptyList_CreatesNoRules() + { + // Arrange + Mock mockSetting = new Mock(); + List emptyList = new List(); + mockSetting.Setup(s => s.GetScope()).Returns(emptyList); + mockSetting.Setup(s => s.GetSetting()).Returns(new ThemeRaw { ["foreground"] = "#FF0000" }); + + ThemeRaw rawTheme = new ThemeRaw + { + ["tokenColors"] = new List { mockSetting.Object } + }; + + // Act + List rules = ParsedTheme.ParseTheme(rawTheme, 0); + + // Assert + CollectionAssert.IsEmpty(rules, "Empty scope list should produce no rules"); + } + + [Test] + public void ParseTheme_ScopeAsNullOrOtherType_CreatesRuleWithEmptyScope() + { + // Arrange - scope is an integer (not string or IList) + Mock mockSetting = new Mock(); + mockSetting.Setup(s => s.GetScope()).Returns(12345); + mockSetting.Setup(s => s.GetSetting()).Returns(new ThemeRaw { ["foreground"] = "#FF0000" }); + + ThemeRaw rawTheme = new ThemeRaw + { + ["tokenColors"] = new List { mockSetting.Object } + }; + + // Act + List rules = ParsedTheme.ParseTheme(rawTheme, 0); + + // Assert + Assert.AreEqual(1, rules.Count); + Assert.AreEqual("", rules[0].scope, "Non-string/non-list scope should create rule with empty scope"); + } + + [Test] + public void ParseTheme_InvalidForegroundColor_SetsNullForeground() + { + // Arrange + const string invalidColor = "notahexcolor"; + + ThemeRaw rawTheme = new ThemeRaw + { + ["tokenColors"] = new List + { + new ThemeRaw + { + ["scope"] = "test.scope", + ["settings"] = new ThemeRaw { ["foreground"] = invalidColor } + } + } + }; + + // Act + List rules = ParsedTheme.ParseTheme(rawTheme, 0); + + // Assert + Assert.AreEqual(1, rules.Count); + Assert.IsNull(rules[0].foreground, "Invalid hex color should result in null foreground"); + } + + [Test] + public void ParseTheme_InvalidBackgroundColor_SetsNullBackground() + { + // Arrange + const string invalidColor = "rgb(255,0,0)"; + + ThemeRaw rawTheme = new ThemeRaw + { + ["tokenColors"] = new List + { + new ThemeRaw + { + ["scope"] = "test.scope", + ["settings"] = new ThemeRaw { ["background"] = invalidColor } + } + } + }; + + // Act + List rules = ParsedTheme.ParseTheme(rawTheme, 0); + + // Assert + Assert.AreEqual(1, rules.Count); + Assert.IsNull(rules[0].background, "Invalid hex color should result in null background"); + } + + [Test] + public void ParseTheme_MissingForeground_SetsNullForeground() + { + // Arrange + ThemeRaw rawTheme = new ThemeRaw + { + ["tokenColors"] = new List + { + new ThemeRaw + { + ["scope"] = "test.scope", + ["settings"] = new ThemeRaw { ["background"] = "#FFFFFF" } + // No foreground + } + } + }; + + // Act + List rules = ParsedTheme.ParseTheme(rawTheme, 0); + + // Assert + Assert.AreEqual(1, rules.Count); + Assert.IsNull(rules[0].foreground); + } + + [Test] + public void ParseTheme_MissingBackground_SetsNullBackground() + { + // Arrange + ThemeRaw rawTheme = new ThemeRaw + { + ["tokenColors"] = new List + { + new ThemeRaw + { + ["scope"] = "test.scope", + ["settings"] = new ThemeRaw { ["foreground"] = "#000000" } + // No background + } + } + }; + + // Act + List rules = ParsedTheme.ParseTheme(rawTheme, 0); + + // Assert + Assert.AreEqual(1, rules.Count); + Assert.IsNull(rules[0].background); + } + + [Test] + public void ParseTheme_NonStringFontStyle_SetsFontStyleNotSet() + { + // Arrange - fontStyle is not a string + Mock mockSetting = new Mock(); + mockSetting.Setup(s => s.GetScope()).Returns("test.scope"); + mockSetting.Setup(s => s.GetSetting()).Returns(new ThemeRaw + { + ["fontStyle"] = 123, // Not a string + ["foreground"] = "#FF0000" + }); + + ThemeRaw rawTheme = new ThemeRaw + { + ["tokenColors"] = new List { mockSetting.Object } + }; + + // Act + List rules = ParsedTheme.ParseTheme(rawTheme, 0); + + // Assert + Assert.AreEqual(1, rules.Count); + Assert.AreEqual(FontStyle.NotSet, rules[0].fontStyle); + } + + [Test] + public void ParseTheme_RuleIndexIncrementsForEachEntry() + { + // Arrange + ThemeRaw rawTheme = new ThemeRaw + { + ["tokenColors"] = new List + { + new ThemeRaw + { + ["scope"] = "scope1", + ["settings"] = new ThemeRaw { ["foreground"] = "#FF0000" } + }, + new ThemeRaw + { + ["scope"] = "scope2", + ["settings"] = new ThemeRaw { ["foreground"] = "#00FF00" } + }, + new ThemeRaw + { + ["scope"] = "scope3", + ["settings"] = new ThemeRaw { ["foreground"] = "#0000FF" } + } + } + }; + + // Act + List rules = ParsedTheme.ParseTheme(rawTheme, 0); + + // Assert + Assert.AreEqual(3, rules.Count); + Assert.AreEqual(0, rules[0].index); + Assert.AreEqual(1, rules[1].index); + Assert.AreEqual(2, rules[2].index); + } + + [Test] + public void ParseTheme_CommaSeparatedScopesShareSameIndex() + { + // Arrange + ThemeRaw rawTheme = new ThemeRaw + { + ["tokenColors"] = new List + { + new ThemeRaw + { + ["scope"] = "keyword.control,keyword.operator", + ["settings"] = new ThemeRaw { ["foreground"] = "#FF0000" } + } + } + }; + + // Act + List rules = ParsedTheme.ParseTheme(rawTheme, 0); + + // Assert + Assert.AreEqual(2, rules.Count); + Assert.AreEqual(0, rules[0].index, "Both rules from same entry should share index 0"); + Assert.AreEqual(0, rules[1].index, "Both rules from same entry should share index 0"); + } + + [Test] + public void ParseTheme_GetNamePreserved_SetsNameOnRule() + { + // Arrange + const string ruleName = "Custom Rule Name"; + + Mock mockSetting = new Mock(); + mockSetting.Setup(s => s.GetScope()).Returns("test.scope"); + mockSetting.Setup(s => s.GetName()).Returns(ruleName); + mockSetting.Setup(s => s.GetSetting()).Returns(new ThemeRaw { ["foreground"] = "#FF0000" }); + + ThemeRaw rawTheme = new ThemeRaw + { + ["tokenColors"] = new List { mockSetting.Object } + }; + + // Act + List rules = ParsedTheme.ParseTheme(rawTheme, 0); + + // Assert + Assert.AreEqual(1, rules.Count); + Assert.AreEqual(ruleName, rules[0].name); + } + + [Test] + public void ParseTheme_BothSettingsAndTokenColors_ProcessesBoth() + { + // Arrange + Mock mockTheme = new Mock(); + + List settings = new List + { + new ThemeRaw + { + ["scope"] = "from.settings", + ["settings"] = new ThemeRaw { ["foreground"] = "#FF0000" } + } + }; + + List tokenColors = new List + { + new ThemeRaw + { + ["scope"] = "from.tokenColors", + ["settings"] = new ThemeRaw { ["foreground"] = "#00FF00" } + } + }; + + mockTheme.Setup(t => t.GetSettings()).Returns(settings); + mockTheme.Setup(t => t.GetTokenColors()).Returns(tokenColors); + + // Act + List rules = ParsedTheme.ParseTheme(mockTheme.Object, 0); + + // Assert + Assert.AreEqual(2, rules.Count); + Assert.AreEqual("from.settings", rules[0].scope); + Assert.AreEqual("from.tokenColors", rules[1].scope); + } + + [Test] + public void ParseTheme_ValidHexColors_PreservesColors() + { + // Arrange + const string foreground = "#ABCDEF"; + const string background = "#123456"; + + ThemeRaw rawTheme = new ThemeRaw + { + ["tokenColors"] = new List + { + new ThemeRaw + { + ["scope"] = "test.scope", + ["settings"] = new ThemeRaw + { + ["foreground"] = foreground, + ["background"] = background + } + } + } + }; + + // Act + List rules = ParsedTheme.ParseTheme(rawTheme, 0); + + // Assert + Assert.AreEqual(1, rules.Count); + Assert.AreEqual(foreground, rules[0].foreground); + Assert.AreEqual(background, rules[0].background); + } + + #endregion LookupThemeRules tests (via ParseTheme) + #region Helper methods + + /// + /// Creates a test ParsedTheme instance for testing Match behavior. + /// Uses CreateFromParsedTheme to construct through public API without reflection. + /// + private static ParsedTheme CreateTestParsedTheme() + { + List rules = new List + { + new ParsedThemeRule("test", "source", null, 0, FontStyle.None, "#000000", "#ffffff"), + new ParsedThemeRule("test", "comment", null, 1, FontStyle.Italic, "#008000", null), + new ParsedThemeRule("test", "keyword", null, 2, FontStyle.Bold, "#0000ff", null) + }; + + ColorMap colorMap = new ColorMap(); + return ParsedTheme.CreateFromParsedTheme(rules, colorMap); + } + + /// + /// Compares two rule lists for content equivalence (same count and element-wise equality). + /// + private static bool AreRuleListsEquivalent(List list1, List list2) + { + if (list1 == null && list2 == null) return true; + if (list1 == null || list2 == null) return false; + if (list1.Count != list2.Count) return false; + + for (int i = 0; i < list1.Count; i++) + { + if (!AreRulesEquivalent(list1[i], list2[i])) + return false; + } + + return true; + } + + /// + /// Compares two ThemeTrieElementRule instances for equivalence. + /// + private static bool AreRulesEquivalent(ThemeTrieElementRule rule1, ThemeTrieElementRule rule2) + { + if (rule1 == null && rule2 == null) return true; + if (rule1 == null || rule2 == null) return false; - // Act - List result = ParsedTheme.ParseInclude(mockSource.Object, mockRegistryOptions.Object, priority, out IRawTheme themeInclude); + // Compare public properties for equivalence + return rule1.fontStyle == rule2.fontStyle && + rule1.foreground == rule2.foreground && + rule1.background == rule2.background; + } - // Assert - Assert.IsNotNull(result); - Assert.AreSame(mockIncludedTheme.Object, themeInclude); - mockRegistryOptions.Verify(r => r.GetTheme(includeString), Times.Once); + #endregion Helper methods } } \ No newline at end of file diff --git a/src/TextMateSharp.Tests/Themes/ThemeTests.cs b/src/TextMateSharp.Tests/Themes/ThemeTests.cs index dfd22cf..aa55fab 100644 --- a/src/TextMateSharp.Tests/Themes/ThemeTests.cs +++ b/src/TextMateSharp.Tests/Themes/ThemeTests.cs @@ -17,6 +17,8 @@ public class ThemeTests private const int SingleScopeMatchCount = 1; private const int DuplicateScopeMatchCount = 2; + #region GetColorId tests + [Test] public void GetColorId_NullColor_ReturnsZero() { @@ -197,6 +199,151 @@ public void GetColorId_SpecialCharacters_RoundTripPreservesValue(string color) "Special characters should round-trip correctly in uppercase"); } + [Test] + public void GetColorId_NullAfterValidColor_ReturnsZero() + { + // Arrange + IRegistryOptions registryOptions = CreateMockRegistryOptions(CreateDefaultRawTheme(), null); + Theme theme = Theme.CreateFromRawTheme( + registryOptions.GetDefaultTheme(), + registryOptions); + + theme.GetColorId("#FF0000"); // Add a color first + + // Act + int result = theme.GetColorId(null); + + // Assert + Assert.AreEqual(NullColorId, result, + "Null should always return 0 regardless of other colors added"); + } + + [Test] + public void GetColorId_ManyUniqueColors_AllReceiveUniqueIds() + { + // Arrange + IRegistryOptions registryOptions = CreateMockRegistryOptions(CreateDefaultRawTheme(), null); + Theme theme = Theme.CreateFromRawTheme( + registryOptions.GetDefaultTheme(), + registryOptions); + + const int colorCount = 1_000; + HashSet uniqueIds = new HashSet(); + + // Act + for (int i = 0; i < colorCount; i++) + { + string color = $"#{i:X6}"; + int id = theme.GetColorId(color); + uniqueIds.Add(id); + } + + // Assert + Assert.AreEqual(colorCount, uniqueIds.Count, + "Each unique color should receive a unique ID"); + } + + [Test] + public void GetColorId_HexColorFormat_ReturnsUniqueId() + { + // Arrange + IRegistryOptions registryOptions = CreateMockRegistryOptions(CreateDefaultRawTheme(), null); + Theme theme = Theme.CreateFromRawTheme( + registryOptions.GetDefaultTheme(), + registryOptions); + const string hexColor = "#FF5733"; + + // Act + int id = theme.GetColorId(hexColor); + string storedColor = theme.GetColor(id); + + // Assert + Assert.AreEqual(FirstCustomColorId, id); + Assert.AreEqual(hexColor, storedColor); + } + + [Test] + public void GetColorId_RgbColorFormat_StoresAsUniqueColor() + { + // Arrange + IRegistryOptions registryOptions = CreateMockRegistryOptions(CreateDefaultRawTheme(), null); + Theme theme = Theme.CreateFromRawTheme( + registryOptions.GetDefaultTheme(), + registryOptions); + const string rgbColor = "rgb(255, 87, 51)"; + + // Act + int id = theme.GetColorId(rgbColor); + string storedColor = theme.GetColor(id); + + // Assert + Assert.AreEqual(FirstCustomColorId, id); + Assert.AreEqual(rgbColor.ToUpper(), storedColor, + "RGB format is stored as-is in uppercase without normalization"); + } + + [Test] + public void GetColorId_DifferentFormatsForSameVisualColor_ReturnsDifferentIds() + { + // Arrange + IRegistryOptions registryOptions = CreateMockRegistryOptions(CreateDefaultRawTheme(), null); + Theme theme = Theme.CreateFromRawTheme( + registryOptions.GetDefaultTheme(), + registryOptions); + const string hexColor = "#FF5733"; + const string rgbColor = "rgb(255, 87, 51)"; + + // Act + int hexId = theme.GetColorId(hexColor); + int rgbId = theme.GetColorId(rgbColor); + + // Assert + Assert.AreNotEqual(hexId, rgbId, + "Different color format strings are treated as different colors without normalization"); + } + + [Test] + public void GetColorId_RgbaColorFormat_StoresAsUniqueColor() + { + // Arrange + IRegistryOptions registryOptions = CreateMockRegistryOptions(CreateDefaultRawTheme(), null); + Theme theme = Theme.CreateFromRawTheme( + registryOptions.GetDefaultTheme(), + registryOptions); + const string rgbaColor = "rgba(255, 87, 51, 1)"; + + // Act + int id = theme.GetColorId(rgbaColor); + string storedColor = theme.GetColor(id); + + // Assert + Assert.AreEqual(FirstCustomColorId, id); + Assert.AreEqual(rgbaColor.ToUpper(), storedColor); + } + + [Test] + public void GetColorId_HslColorFormat_StoresAsUniqueColor() + { + // Arrange + IRegistryOptions registryOptions = CreateMockRegistryOptions(CreateDefaultRawTheme(), null); + Theme theme = Theme.CreateFromRawTheme( + registryOptions.GetDefaultTheme(), + registryOptions); + const string hslColor = "hsl(14, 100%, 60%)"; + + // Act + int id = theme.GetColorId(hslColor); + string storedColor = theme.GetColor(id); + + // Assert + Assert.AreEqual(FirstCustomColorId, id); + Assert.AreEqual(hslColor.ToUpper(), storedColor); + } + + #endregion GetColorId tests + + #region GetGuiColorDictionary tests + [Test] public void GetGuiColorDictionary_WithEmptyGuiColors_ReturnsEmptyReadOnlyDictionary() { @@ -363,6 +510,10 @@ public void GetGuiColorDictionary_CalledMultipleTimes_ReturnsSameCachedInstance( Assert.AreSame(result1, result2); } + #endregion GetGuiColorDictionary tests + + #region GetColorMap tests + [Test] public void GetColorMap_DefaultColorsPresent_ReturnsMapContainingDefaults() { @@ -399,6 +550,10 @@ public void GetColorMap_AfterAddingColorId_IncludesNewColor() CollectionAssert.Contains(colorMap, color); } + #endregion GetColorMap tests + + #region GetColor tests + [Test] public void GetColor_RoundTrip_ReturnsOriginalColor() { @@ -417,6 +572,10 @@ public void GetColor_RoundTrip_ReturnsOriginalColor() Assert.AreEqual(color, resolved); } + #endregion GetColor tests + + #region Match tests + [Test] public void Match_EmptyScopeList_ReturnsEmptyList() { @@ -495,6 +654,10 @@ public void Match_MultipleScopesWithDifferentDepths_OrdersByDepth() } } + #endregion Match tests + + #region ThemeTrieElementRule tests + [Test] public void ThemeTrieElementRule_AcceptOverwrite_ExtremeScopeDepths_HandlesCorrectly() { @@ -545,147 +708,293 @@ public void ThemeTrieElementRule_AcceptOverwrite_LowerScopeDepth_DoesNotDecrease "Scope depth should not decrease when overwriting with lower depth"); } + #endregion ThemeTrieElementRule tests + + #region Default rule processing tests (Add to existing ThemeTests.cs) + [Test] - public void GetColorId_NullAfterValidColor_ReturnsZero() + public void CreateFromRawTheme_WithEmptyScopeRule_SetsDefaultFontStyle() { // Arrange - IRegistryOptions registryOptions = CreateMockRegistryOptions(CreateDefaultRawTheme(), null); - Theme theme = Theme.CreateFromRawTheme( - registryOptions.GetDefaultTheme(), - registryOptions); + const string foregroundColor = "#FFFFFF"; + const string backgroundColor = "#000000"; - theme.GetColorId("#FF0000"); // Add a color first + ThemeRaw rawTheme = new ThemeRaw + { + ["tokenColors"] = new List + { + new ThemeRaw + { + // A default empty scope is already added + ["settings"] = new ThemeRaw + { + ["fontStyle"] = "bold", + ["foreground"] = foregroundColor, + ["background"] = backgroundColor + } + }, + new ThemeRaw + { + ["scope"] = "source.test", + ["settings"] = new ThemeRaw { ["foreground"] = "#FF0000" } + } + } + }; + + Mock mockRegistryOptions = new Mock(); + mockRegistryOptions.Setup(r => r.GetInjections(It.IsAny())).Returns((List)null); // Act - int result = theme.GetColorId(null); + Theme theme = Theme.CreateFromRawTheme(rawTheme, mockRegistryOptions.Object); - // Assert - Assert.AreEqual(NullColorId, result, - "Null should always return 0 regardless of other colors added"); + // Assert - Verify defaults were set by checking color IDs match expected defaults + int foregroundId = theme.GetColorId(foregroundColor); + int backgroundId = theme.GetColorId(backgroundColor); + + Assert.Greater(foregroundId, NullColorId, "Default foreground should be registered"); + Assert.Greater(backgroundId, NullColorId, "Default background should be registered"); } [Test] - public void GetColorId_ManyUniqueColors_AllReceiveUniqueIds() + public void CreateFromRawTheme_WithMultipleEmptyScopeRules_LastRuleWinsForEachProperty() { // Arrange - IRegistryOptions registryOptions = CreateMockRegistryOptions(CreateDefaultRawTheme(), null); - Theme theme = Theme.CreateFromRawTheme( - registryOptions.GetDefaultTheme(), - registryOptions); + const string firstForeground = "#111111"; + const string secondForeground = "#222222"; + const string finalForeground = "#333333"; + const string finalBackground = "#444444"; - const int colorCount = 1_000; - HashSet uniqueIds = new HashSet(); + ThemeRaw rawTheme = new ThemeRaw + { + ["tokenColors"] = new List + { + new ThemeRaw + { + // A default empty scope is already added + ["settings"] = new ThemeRaw + { + ["fontStyle"] = "italic", + ["foreground"] = firstForeground + } + }, + new ThemeRaw + { + ["scope"] = "", + ["settings"] = new ThemeRaw + { + ["fontStyle"] = "underline", + ["foreground"] = secondForeground + } + }, + new ThemeRaw + { + ["scope"] = "", + ["settings"] = new ThemeRaw + { + ["fontStyle"] = "bold", + ["foreground"] = finalForeground, + ["background"] = finalBackground + } + }, + new ThemeRaw + { + ["scope"] = "test", + ["settings"] = new ThemeRaw { ["foreground"] = "#FF0000" } + } + } + }; + + Mock mockRegistryOptions = new Mock(); + mockRegistryOptions.Setup(r => r.GetInjections(It.IsAny())).Returns((List)null); // Act - for (int i = 0; i < colorCount; i++) - { - string color = $"#{i:X6}"; - int id = theme.GetColorId(color); - uniqueIds.Add(id); - } + Theme theme = Theme.CreateFromRawTheme(rawTheme, mockRegistryOptions.Object); - // Assert - Assert.AreEqual(colorCount, uniqueIds.Count, - "Each unique color should receive a unique ID"); + // Assert - Last rule's colors should be registered + int finalForegroundId = theme.GetColorId(finalForeground); + int finalBackgroundId = theme.GetColorId(finalBackground); + + Assert.Greater(finalForegroundId, NullColorId, "Final foreground should override previous defaults"); + Assert.Greater(finalBackgroundId, NullColorId, "Final background should override previous defaults"); + + // Verify earlier colors are also in the map (they were processed but overridden) + ICollection colorMap = theme.GetColorMap(); + CollectionAssert.Contains(colorMap, finalForeground); + CollectionAssert.Contains(colorMap, finalBackground); } [Test] - public void GetColorId_HexColorFormat_ReturnsUniqueId() + public void CreateFromRawTheme_EmptyScopeWithNotSetFontStyle_KeepsDefaultFontStyle() { - // Arrange - IRegistryOptions registryOptions = CreateMockRegistryOptions(CreateDefaultRawTheme(), null); - Theme theme = Theme.CreateFromRawTheme( - registryOptions.GetDefaultTheme(), - registryOptions); - const string hexColor = "#FF5733"; + // Arrange - FontStyle omitted means NotSet, should not override default + ThemeRaw rawTheme = new ThemeRaw + { + ["tokenColors"] = new List + { + new ThemeRaw + { + // A default empty scope is already added + ["settings"] = new ThemeRaw + { + ["foreground"] = "#FFFFFF" + // No fontStyle property + } + } + } + }; + + Mock mockRegistryOptions = new Mock(); + mockRegistryOptions.Setup(r => r.GetInjections(It.IsAny())).Returns((List)null); // Act - int id = theme.GetColorId(hexColor); - string storedColor = theme.GetColor(id); + Theme theme = Theme.CreateFromRawTheme(rawTheme, mockRegistryOptions.Object); - // Assert - Assert.AreEqual(FirstCustomColorId, id); - Assert.AreEqual(hexColor, storedColor); + // Assert - Should use hardcoded defaults (#000000, #FFFFFF) + ICollection colorMap = theme.GetColorMap(); + CollectionAssert.Contains(colorMap, "#000000", "Default foreground #000000 should be in map"); + CollectionAssert.Contains(colorMap, "#FFFFFF", "Default background #FFFFFF should be in map"); } [Test] - public void GetColorId_RgbColorFormat_StoresAsUniqueColor() + public void CreateFromRawTheme_EmptyScopeWithNullColors_KeepsDefaultColors() { // Arrange - IRegistryOptions registryOptions = CreateMockRegistryOptions(CreateDefaultRawTheme(), null); - Theme theme = Theme.CreateFromRawTheme( - registryOptions.GetDefaultTheme(), - registryOptions); - const string rgbColor = "rgb(255, 87, 51)"; + ThemeRaw rawTheme = new ThemeRaw + { + ["tokenColors"] = new List + { + new ThemeRaw + { + // A default empty scope is already added + ["settings"] = new ThemeRaw + { + ["fontStyle"] = "bold" + // No foreground or background + } + } + } + }; + + Mock mockRegistryOptions = new Mock(); + mockRegistryOptions.Setup(r => r.GetInjections(It.IsAny())).Returns((List)null); // Act - int id = theme.GetColorId(rgbColor); - string storedColor = theme.GetColor(id); + Theme theme = Theme.CreateFromRawTheme(rawTheme, mockRegistryOptions.Object); - // Assert - Assert.AreEqual(FirstCustomColorId, id); - Assert.AreEqual(rgbColor.ToUpper(), storedColor, - "RGB format is stored as-is in uppercase without normalization"); + // Assert - Should use hardcoded default colors + const string defaultForeground = "#000000"; + const string defaultBackground = "#FFFFFF"; + + int foregroundId = theme.GetColorId(defaultForeground); + int backgroundId = theme.GetColorId(defaultBackground); + + Assert.AreEqual(defaultForeground, theme.GetColor(foregroundId)); + Assert.AreEqual(defaultBackground, theme.GetColor(backgroundId)); } [Test] - public void GetColorId_DifferentFormatsForSameVisualColor_ReturnsDifferentIds() + public void CreateFromRawTheme_EmptyScopeWithOnlyForeground_OverridesForegroundOnly() { // Arrange - IRegistryOptions registryOptions = CreateMockRegistryOptions(CreateDefaultRawTheme(), null); - Theme theme = Theme.CreateFromRawTheme( - registryOptions.GetDefaultTheme(), - registryOptions); - const string hexColor = "#FF5733"; - const string rgbColor = "rgb(255, 87, 51)"; + const string customForeground = "#ABCDEF"; + + ThemeRaw rawTheme = new ThemeRaw + { + ["tokenColors"] = new List + { + new ThemeRaw + { + // A default empty scope is already added + ["settings"] = new ThemeRaw + { + ["foreground"] = customForeground + // No background + } + } + } + }; + + Mock mockRegistryOptions = new Mock(); + mockRegistryOptions.Setup(r => r.GetInjections(It.IsAny())).Returns((List)null); // Act - int hexId = theme.GetColorId(hexColor); - int rgbId = theme.GetColorId(rgbColor); + Theme theme = Theme.CreateFromRawTheme(rawTheme, mockRegistryOptions.Object); // Assert - Assert.AreNotEqual(hexId, rgbId, - "Different color format strings are treated as different colors without normalization"); + ICollection colorMap = theme.GetColorMap(); + CollectionAssert.Contains(colorMap, customForeground, "Custom foreground should be in color map"); + CollectionAssert.Contains(colorMap, "#FFFFFF", "Default background #FFFFFF should still be in map"); } [Test] - public void GetColorId_RgbaColorFormat_StoresAsUniqueColor() + public void CreateFromRawTheme_EmptyScopeWithOnlyBackground_OverridesBackgroundOnly() { // Arrange - IRegistryOptions registryOptions = CreateMockRegistryOptions(CreateDefaultRawTheme(), null); - Theme theme = Theme.CreateFromRawTheme( - registryOptions.GetDefaultTheme(), - registryOptions); - const string rgbaColor = "rgba(255, 87, 51, 1)"; + const string customBackground = "#123456"; + + ThemeRaw rawTheme = new ThemeRaw + { + ["tokenColors"] = new List + { + new ThemeRaw + { + // A default empty scope is already added + ["settings"] = new ThemeRaw + { + ["background"] = customBackground + // No foreground + } + } + } + }; + + Mock mockRegistryOptions = new Mock(); + mockRegistryOptions.Setup(r => r.GetInjections(It.IsAny())).Returns((List)null); // Act - int id = theme.GetColorId(rgbaColor); - string storedColor = theme.GetColor(id); + Theme theme = Theme.CreateFromRawTheme(rawTheme, mockRegistryOptions.Object); // Assert - Assert.AreEqual(FirstCustomColorId, id); - Assert.AreEqual(rgbaColor.ToUpper(), storedColor); + ICollection colorMap = theme.GetColorMap(); + CollectionAssert.Contains(colorMap, "#000000", "Default foreground #000000 should still be in map"); + CollectionAssert.Contains(colorMap, customBackground, "Custom background should be in color map"); } [Test] - public void GetColorId_HslColorFormat_StoresAsUniqueColor() + public void CreateFromRawTheme_NoEmptyScopeRules_UsesHardcodedDefaults() { - // Arrange - IRegistryOptions registryOptions = CreateMockRegistryOptions(CreateDefaultRawTheme(), null); - Theme theme = Theme.CreateFromRawTheme( - registryOptions.GetDefaultTheme(), - registryOptions); - const string hslColor = "hsl(14, 100%, 60%)"; + // Arrange - No rules with empty scope + ThemeRaw rawTheme = new ThemeRaw + { + ["tokenColors"] = new List + { + new ThemeRaw + { + ["scope"] = "source.test", + ["settings"] = new ThemeRaw + { + ["fontStyle"] = "bold", + ["foreground"] = "#FF0000", + ["background"] = "#00FF00" + } + } + } + }; + + Mock mockRegistryOptions = new Mock(); + mockRegistryOptions.Setup(r => r.GetInjections(It.IsAny())).Returns((List)null); // Act - int id = theme.GetColorId(hslColor); - string storedColor = theme.GetColor(id); + Theme theme = Theme.CreateFromRawTheme(rawTheme, mockRegistryOptions.Object); - // Assert - Assert.AreEqual(FirstCustomColorId, id); - Assert.AreEqual(hslColor.ToUpper(), storedColor); + // Assert - Should use hardcoded defaults (#000000, #FFFFFF) + ICollection colorMap = theme.GetColorMap(); + CollectionAssert.Contains(colorMap, "#000000", "Hardcoded default foreground should be present"); + CollectionAssert.Contains(colorMap, "#FFFFFF", "Hardcoded default background should be present"); } + #endregion Default rule processing tests + #region helpers private static IRawTheme CreateDefaultRawTheme() From 6b3a8595f7d0eb224c466a61e6d22d394db448ab Mon Sep 17 00:00:00 2001 From: Dave Black <656118+udlose@users.noreply.github.com> Date: Sat, 28 Feb 2026 16:46:17 -0600 Subject: [PATCH 12/33] Optimize theme parsing: reduce allocations, add thread safety Refactored theme parsing and caching to minimize memory allocations and improve performance. - Replaced String.Split/LINQ with span-based parsing for scopes and font styles. - Introduced thread-safe, lock-free caching using ConcurrentDictionary and atomic operations. - Improved sorting and default rule handling to avoid unnecessary allocations. - Enhanced code clarity with comments and better variable naming. These changes make theme handling faster, more scalable, and robust for concurrent use. --- src/TextMateSharp/Themes/Theme.cs | 318 ++++++++++++++++++++++-------- 1 file changed, 237 insertions(+), 81 deletions(-) diff --git a/src/TextMateSharp/Themes/Theme.cs b/src/TextMateSharp/Themes/Theme.cs index e179c31..2231ba9 100644 --- a/src/TextMateSharp/Themes/Theme.cs +++ b/src/TextMateSharp/Themes/Theme.cs @@ -1,7 +1,8 @@ using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Collections.ObjectModel; -using System.Linq; +using System.Threading; using TextMateSharp.Internal.Utils; using TextMateSharp.Registry; @@ -9,10 +10,11 @@ namespace TextMateSharp.Themes { public class Theme { - private ParsedTheme _theme; - private ParsedTheme _include; - private ColorMap _colorMap; - private Dictionary _guiColorDictionary; + private readonly ParsedTheme _theme; + private readonly ParsedTheme _include; + private readonly ColorMap _colorMap; + private readonly Dictionary _guiColorDictionary; + private ReadOnlyDictionary _cachedGuiColorDictionary; public static Theme CreateFromRawTheme( IRawTheme source, @@ -21,8 +23,8 @@ public static Theme CreateFromRawTheme( ColorMap colorMap = new ColorMap(); var guiColorsDictionary = new Dictionary(); - var themeRuleList = ParsedTheme.ParseTheme(source,0); - + var themeRuleList = ParsedTheme.ParseTheme(source, 0); + ParsedTheme theme = ParsedTheme.CreateFromParsedTheme( themeRuleList, colorMap); @@ -43,7 +45,7 @@ public static Theme CreateFromRawTheme( return new Theme(colorMap, theme, include, guiColorsDictionary); } - Theme(ColorMap colorMap, ParsedTheme theme, ParsedTheme include, Dictionary guiColorDictionary) + Theme(ColorMap colorMap, ParsedTheme theme, ParsedTheme include, Dictionary guiColorDictionary) { _colorMap = colorMap; _theme = theme; @@ -66,7 +68,15 @@ public List Match(IList scopeNames) public ReadOnlyDictionary GetGuiColorDictionary() { - return new ReadOnlyDictionary(this._guiColorDictionary); + ReadOnlyDictionary result = Volatile.Read(ref this._cachedGuiColorDictionary); + if (result == null) + { + ReadOnlyDictionary candidate = new ReadOnlyDictionary(this._guiColorDictionary); + result = Interlocked.CompareExchange(ref this._cachedGuiColorDictionary, candidate, null) + ?? candidate; + } + + return result; } public ICollection GetColorMap() @@ -92,10 +102,25 @@ internal ThemeTrieElementRule GetDefaults() class ParsedTheme { - private ThemeTrieElement _root; - private ThemeTrieElementRule _defaults; + private readonly ThemeTrieElement _root; + private readonly ThemeTrieElementRule _defaults; - private Dictionary> _cachedMatchRoot; + private readonly ConcurrentDictionary> _cachedMatchRoot; + private const char SpaceChar = ' '; + + // Static sort comparison to avoid delegate allocation per sort call + private static readonly Comparison _themeRuleComparison = (a, b) => + { + int r = StringUtils.StrCmp(a.scope, b.scope); + if (r != 0) + return r; + + r = StringUtils.StrArrCmp(a.parentScopes, b.parentScopes); + if (r != 0) + return r; + + return a.index.CompareTo(b.index); + }; internal static List ParseTheme(IRawTheme source, int priority) { @@ -112,7 +137,7 @@ internal static List ParseTheme(IRawTheme source, int priority) return result; } - internal static void ParsedGuiColors(IRawTheme source, Dictionary colorDictionary) + internal static void ParsedGuiColors(IRawTheme source, Dictionary colorDictionary) { var colors = source.GetGuiColors(); if (colors == null) @@ -154,7 +179,7 @@ internal static List ParseInclude( static void LookupThemeRules( ICollection settings, List parsedThemeRules, - int priority) + int priority) // TODO: @danipen, 'priority' is currently unused. Is that intentional or is this a missing piece of functionality? { if (settings == null) return; @@ -168,77 +193,101 @@ static void LookupThemeRules( } object settingScope = entry.GetScope(); - List scopes = new List(); - if (settingScope is string) + List scopes; + const char separator = ','; + if (settingScope is string scopeStr) { - string scope = (string)settingScope; // remove leading and trailing commas - scope = scope.Trim(','); - scopes = new List(scope.Split(new[] { "," }, StringSplitOptions.RemoveEmptyEntries)); + ReadOnlySpan trimmedScope = scopeStr.AsSpan().Trim(separator); + + if (trimmedScope.Length == 0) + { + // Matches original behavior: String.Split with RemoveEmptyEntries on an empty + // string returns an empty array, so no scopes are produced and no rules are + // generated for this entry + scopes = new List(); + } + else + { + // Count commas to pre-size list and avoid over-allocation + int commaCount = 0; + for (int k = 0; k < trimmedScope.Length; k++) + { + if (trimmedScope[k] == separator) + commaCount++; + } + + scopes = new List(commaCount + 1); + + // Span-based split avoids intermediate string[] allocation from String.Split + while (trimmedScope.Length > 0) + { + int commaIndex = trimmedScope.IndexOf(separator); + if (commaIndex < 0) + { + // ToString() allocates the final string required by ParsedThemeRule. + // This allocation is necessary as ParsedThemeRule stores string fields. + scopes.Add(trimmedScope.ToString()); + break; + } + + ReadOnlySpan segment = trimmedScope.Slice(0, commaIndex); + if (segment.Length > 0) + // ToString() allocates the final string required by ParsedThemeRule. + // This allocation is necessary as ParsedThemeRule stores string fields. + scopes.Add(segment.ToString()); + + trimmedScope = trimmedScope.Slice(commaIndex + 1); + } + } } - else if (settingScope is IList) + else if (settingScope is IList scopeList) { - scopes = new List(((IList)settingScope).Cast()); + // Direct cast avoids LINQ Cast() iterator/IEnumerable allocation + scopes = new List(scopeList.Count); + for (int k = 0; k < scopeList.Count; k++) + { + scopes.Add((string)scopeList[k]); + } } else { - scopes.Add(""); + scopes = new List(1); + scopes.Add(string.Empty); } FontStyle fontStyle = FontStyle.NotSet; object settingsFontStyle = entry.GetSetting().GetFontStyle(); - if (settingsFontStyle is string) + if (settingsFontStyle is string fontStyleStr) { - fontStyle = FontStyle.None; - - string[] segments = ((string)settingsFontStyle).Split(new[] { " " }, StringSplitOptions.None); - foreach (string segment in segments) - { - switch (segment) - { - case "italic": - fontStyle |= FontStyle.Italic; - break; - case "bold": - fontStyle |= FontStyle.Bold; - break; - case "underline": - fontStyle |= FontStyle.Underline; - break; - case "strikethrough": - fontStyle |= FontStyle.Strikethrough; - break; - } - } + // Span-based parsing avoids string[] allocation from String.Split. + // Uses SequenceEqual for allocation-free keyword matching. + fontStyle = ParseFontStyle(fontStyleStr.AsSpan()); } string foreground = null; object settingsForeground = entry.GetSetting().GetForeground(); - if (settingsForeground is string && StringUtils.IsValidHexColor((string)settingsForeground)) + if (settingsForeground is string fgStr && StringUtils.IsValidHexColor(fgStr)) { - foreground = (string)settingsForeground; + foreground = fgStr; } string background = null; object settingsBackground = entry.GetSetting().GetBackground(); - if (settingsBackground is string && StringUtils.IsValidHexColor((string)settingsBackground)) + if (settingsBackground is string bgStr && StringUtils.IsValidHexColor(bgStr)) { - background = (string)settingsBackground; + background = bgStr; } for (int j = 0, lenJ = scopes.Count; j < lenJ; j++) { string _scope = scopes[j].Trim(); - List segments = new List(_scope.Split(new[] { " " }, StringSplitOptions.None)); + // Extract scope (last segment) and parentScopes (all segments reversed) + // in a single method call, eliminating redundant string scans from the + // previous LastIndexOf + Substring + BuildReversedSegments approach. + ExtractScopeAndParents(_scope, out string scope, out List parentScopes); - string scope = segments[segments.Count - 1]; - List parentScopes = null; - if (segments.Count > 1) - { - parentScopes = new List(segments); - parentScopes.Reverse(); - } - var name = entry.GetName(); + string name = entry.GetName(); ParsedThemeRule t = new ParsedThemeRule(name, scope, parentScopes, i, fontStyle, foreground, background); parsedThemeRules.Add(t); @@ -247,6 +296,106 @@ static void LookupThemeRules( } } + /// + /// Parses a space-delimited font style string (e.g. "italic bold") into a + /// flags value without allocating a string[] from String.Split. + /// Uses for allocation-free keyword matching. + /// SequenceEqual checks length first internally, so no manual length pre-check is needed. + /// + private static FontStyle ParseFontStyle(ReadOnlySpan value) + { + FontStyle fontStyle = FontStyle.None; + while (value.Length > 0) + { + int spaceIndex = value.IndexOf(SpaceChar); + ReadOnlySpan segment; + + if (spaceIndex < 0) + { + segment = value; + value = ReadOnlySpan.Empty; + } + else + { + segment = value.Slice(0, spaceIndex); + value = value.Slice(spaceIndex + 1); + } + + if (segment.SequenceEqual("italic".AsSpan())) + fontStyle |= FontStyle.Italic; + else if (segment.SequenceEqual("bold".AsSpan())) + fontStyle |= FontStyle.Bold; + else if (segment.SequenceEqual("underline".AsSpan())) + fontStyle |= FontStyle.Underline; + else if (segment.SequenceEqual("strikethrough".AsSpan())) + fontStyle |= FontStyle.Strikethrough; + } + + return fontStyle; + } + + /// + /// Extracts the scope (last segment) and parentScopes (all segments in reverse order) + /// from a space-delimited scope string using two linear passes over the input. + /// The first pass counts segments and enables a fast path for single-segment scopes; + /// the second pass walks backward once to extract the scope and parent scopes in reverse order. + /// This replaces the previous three-step approach (LastIndexOf + Substring + BuildReversedSegments), + /// which scanned the string 3 times and allocated an extra Substring for the scope. + /// + /// Note: Substring allocations are necessary here because ParsedThemeRule and ThemeTrieElement + /// store and operate on string fields. While ReadOnlySpan is used for parsing efficiency, + /// the final strings must be allocated for storage in the theme data structures. + /// Further allocation reduction would require architectural changes to use ReadOnlyMemory<char> + /// or string pooling throughout the theme infrastructure. + /// + /// The space-delimited scope string (e.g. "text.html.basic source.js"). + /// The last segment (e.g. "source.js"). + /// All segments in reverse order, or null if single-segment. + private static void ExtractScopeAndParents(string value, out string scope, out List parentScopes) + { + ReadOnlySpan span = value.AsSpan(); + + // Count segments with a single forward pass + int segmentCount = 1; + for (int i = 0; i < span.Length; i++) + { + if (span[i] == SpaceChar) + segmentCount++; + } + + // Fast path: single-segment scope (most common case) avoids all further work + if (segmentCount == 1) + { + scope = value; + parentScopes = null; + return; + } + + parentScopes = new List(segmentCount); + + // Walk backwards through the span to build the reversed segment list. + // The first segment encountered (rightmost in original string) is the scope. + int end = span.Length; + scope = null; + + for (int i = span.Length - 1; i >= 0; i--) + { + if (span[i] == SpaceChar) + { + // Substring allocates a new string. This is necessary because downstream + // consumers (ParsedThemeRule, ThemeTrieElement) store and operate on strings + string segment = value.Substring(i + 1, end - i - 1); + scope ??= segment; + parentScopes.Add(segment); + end = i; + } + } + + // Add first (leftmost) segment. Substring allocation is necessary for storage + string firstSegment = value.Substring(0, end); + parentScopes.Add(firstSegment); + } + public static ParsedTheme CreateFromParsedTheme( List source, ColorMap colorMap) @@ -262,29 +411,20 @@ static ParsedTheme ResolveParsedThemeRules( ColorMap colorMap) { // Sort rules lexicographically, and then by index if necessary - parsedThemeRules.Sort((a, b) => - { - int r = StringUtils.StrCmp(a.scope, b.scope); - if (r != 0) - { - return r; - } - r = StringUtils.StrArrCmp(a.parentScopes, b.parentScopes); - if (r != 0) - { - return r; - } - return a.index.CompareTo(b.index); - }); + parsedThemeRules.Sort(_themeRuleComparison); // Determine defaults FontStyle defaultFontStyle = FontStyle.None; string defaultForeground = "#000000"; string defaultBackground = "#ffffff"; - while (parsedThemeRules.Count >= 1 && "".Equals(parsedThemeRules[0].scope)) + + // Use an index cursor instead of RemoveAt(0) which is O(n) due to array shifting + int startIndex = 0; + while (startIndex < parsedThemeRules.Count && string.IsNullOrEmpty(parsedThemeRules[startIndex].scope)) { - ParsedThemeRule incomingDefaults = parsedThemeRules[0]; - parsedThemeRules.RemoveAt(0); // shift(); + ParsedThemeRule incomingDefaults = parsedThemeRules[startIndex]; + startIndex++; + if (incomingDefaults.fontStyle != FontStyle.NotSet) { defaultFontStyle = incomingDefaults.fontStyle; @@ -303,8 +443,11 @@ static ParsedTheme ResolveParsedThemeRules( ThemeTrieElement root = new ThemeTrieElement(new ThemeTrieElementRule(string.Empty, 0, null, FontStyle.NotSet, 0, 0), new List()); - foreach (ParsedThemeRule rule in parsedThemeRules) + + // Iterate from startIndex to skip already-processed default rules + for (int i = startIndex; i < parsedThemeRules.Count; i++) { + ParsedThemeRule rule = parsedThemeRules[i]; root.Insert(rule.name, 0, rule.scope, rule.parentScopes, rule.fontStyle, colorMap.GetId(rule.foreground), colorMap.GetId(rule.background)); } @@ -315,20 +458,33 @@ static ParsedTheme ResolveParsedThemeRules( { this._root = root; this._defaults = defaults; - _cachedMatchRoot = new Dictionary>(); + this._cachedMatchRoot = new ConcurrentDictionary>(); } internal List Match(string scopeName) { - lock (this._cachedMatchRoot) + if (scopeName == null) throw new ArgumentNullException(nameof(scopeName)); + + // TryGetValue + TryAdd pattern avoids the Func<> delegate allocation that + // ConcurrentDictionary.GetOrAdd(key, factory) would incur on every call + // (even on cache hits) due to the lambda capturing 'this'. + if (!this._cachedMatchRoot.TryGetValue(scopeName, out List value)) { - if (!_cachedMatchRoot.TryGetValue(scopeName, out List value)) + // Compute the value locally, then attempt to cache it. If another thread + // wins the race to add the value for this scopeName, read back the value + // that actually ended up in the cache to ensure consistency. + value = this._root.Match(scopeName); + if (!this._cachedMatchRoot.TryAdd(scopeName, value)) { - value = this._root.Match(scopeName); - this._cachedMatchRoot[scopeName] = value; + if (!this._cachedMatchRoot.TryGetValue(scopeName, out value)) + { + // In the unlikely event the key was removed between TryAdd and TryGetValue, + // recompute to ensure a non-null result is always returned + value = this._root.Match(scopeName); + } } - return value; } + return value; } internal ThemeTrieElementRule GetDefaults() From c36e00c3b33043d539cb950d7b57622c835b3558 Mon Sep 17 00:00:00 2001 From: Dave Black <656118+udlose@users.noreply.github.com> Date: Sun, 1 Mar 2026 22:43:32 -0600 Subject: [PATCH 13/33] Optimize AttributedScopeStack scope building by minimizing List-resizing allocations Added a fast hash code check to AttributedScopeStack equality for O(1) rejection of non-equal stacks. Refactored GenerateScopes to use a preallocated List and reverse it, improving clarity and efficiency. --- .../Internal/Grammars/AttributedScopeStack.cs | 21 ++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/src/TextMateSharp/Internal/Grammars/AttributedScopeStack.cs b/src/TextMateSharp/Internal/Grammars/AttributedScopeStack.cs index cb2b0f9..8815591 100644 --- a/src/TextMateSharp/Internal/Grammars/AttributedScopeStack.cs +++ b/src/TextMateSharp/Internal/Grammars/AttributedScopeStack.cs @@ -60,6 +60,13 @@ private static bool Equals(AttributedScopeStack a, AttributedScopeStack b) { return false; } + + // Precomputed hash codes let us reject non-equal pairs in O(1) + // before walking the O(n) parent chain in StructuralEquals + if (a._hashCode != b._hashCode) + { + return false; + } return StructuralEquals(a, b); } @@ -261,7 +268,7 @@ public List GetScopeNames() private static List GenerateScopes(AttributedScopeStack scopesList) { - // First pass: count depth to pre-size the array + // First pass: count depth to pre-size the list int depth = 0; AttributedScopeStack current = scopesList; while (current != null) @@ -270,17 +277,17 @@ private static List GenerateScopes(AttributedScopeStack scopesList) current = current.Parent; } - // Second pass: fill backwards directly to avoid a Reverse() call - string[] scopes = new string[depth]; + // initialize exact capacity to avoid resizing + List result = new List(depth); current = scopesList; - for (int i = depth - 1; i >= 0; i--) + while (current != null) { - scopes[i] = current.ScopePath; + result.Add(current.ScopePath); current = current.Parent; } - // Construct list from the correctly-ordered array - return new List(scopes); + result.Reverse(); + return result; } } } \ No newline at end of file From a67a7078751d794d99036465d44919824fd28292 Mon Sep 17 00:00:00 2001 From: Dave Black <656118+udlose@users.noreply.github.com> Date: Mon, 2 Mar 2026 06:29:03 -0600 Subject: [PATCH 14/33] Remove unnecessary List allocation on success path in ParseTheme method Removed unnecessary result list variable in ParseTheme. Now returns a new empty list directly on early exit, improving code clarity and efficiency. --- src/TextMateSharp/Themes/Theme.cs | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/TextMateSharp/Themes/Theme.cs b/src/TextMateSharp/Themes/Theme.cs index 2231ba9..d5ef2a9 100644 --- a/src/TextMateSharp/Themes/Theme.cs +++ b/src/TextMateSharp/Themes/Theme.cs @@ -157,23 +157,20 @@ internal static List ParseInclude( int priority, out IRawTheme themeInclude) { - List result = new List(); - string include = source.GetInclude(); if (string.IsNullOrEmpty(include)) { themeInclude = null; - return result; + return new List(); } themeInclude = registryOptions.GetTheme(include); if (themeInclude == null) - return result; + return new List(); return ParseTheme(themeInclude, priority); - } static void LookupThemeRules( From 916274d8cf9870bf4de159dc28bd8aec3fb5dfdf Mon Sep 17 00:00:00 2001 From: Dave Black <656118+udlose@users.noreply.github.com> Date: Mon, 2 Mar 2026 07:50:44 -0600 Subject: [PATCH 15/33] Add comprehensive unit tests for Raw class Introduced RawTests in the TextMateSharp.Tests.Internal.Grammars.Parser namespace using NUnit. The new test suite covers merging, property accessors, captures, patterns, injections, repository, boolean flags, file type handling, deep cloning, and enumeration for the Raw class. These tests ensure correct behavior, deep copy semantics, and robust handling of edge cases, significantly improving test coverage for Raw and related interfaces. --- .../Internal/Grammars/Parser/RawTests.cs | 961 ++++++++++++++++++ 1 file changed, 961 insertions(+) create mode 100644 src/TextMateSharp.Tests/Internal/Grammars/Parser/RawTests.cs diff --git a/src/TextMateSharp.Tests/Internal/Grammars/Parser/RawTests.cs b/src/TextMateSharp.Tests/Internal/Grammars/Parser/RawTests.cs new file mode 100644 index 0000000..471de2f --- /dev/null +++ b/src/TextMateSharp.Tests/Internal/Grammars/Parser/RawTests.cs @@ -0,0 +1,961 @@ +using NUnit.Framework; +using System.Collections.Generic; +using System.Linq; +using TextMateSharp.Internal.Grammars.Parser; +using TextMateSharp.Internal.Rules; +using TextMateSharp.Internal.Types; + +namespace TextMateSharp.Tests.Internal.Grammars.Parser +{ + [TestFixture] + public class RawTests + { + #region Merge tests + + [Test] + public void Merge_SingleSource_ReturnsAllKeys() + { + // arrange + Raw source = new Raw + { + ["key1"] = "value1", + ["key2"] = "value2" + }; + Raw target = new Raw(); + + // act + IRawRepository result = target.Merge(source); + + // assert + Raw resultRaw = (Raw)result; + Assert.AreEqual(2, resultRaw.Count); + Assert.AreEqual("value1", resultRaw["key1"]); + Assert.AreEqual("value2", resultRaw["key2"]); + } + + [Test] + public void Merge_MultipleSources_MergesAllKeys() + { + // arrange + Raw source1 = new Raw { ["key1"] = "value1" }; + Raw source2 = new Raw { ["key2"] = "value2" }; + Raw source3 = new Raw { ["key3"] = "value3" }; + Raw target = new Raw(); + + // act + IRawRepository result = target.Merge(source1, source2, source3); + + // assert + Raw resultRaw = (Raw)result; + Assert.AreEqual(3, resultRaw.Count); + Assert.AreEqual("value1", resultRaw["key1"]); + Assert.AreEqual("value2", resultRaw["key2"]); + Assert.AreEqual("value3", resultRaw["key3"]); + } + + [Test] + public void Merge_OverlappingKeys_LastSourceWins() + { + // arrange + Raw source1 = new Raw { ["key"] = "first" }; + Raw source2 = new Raw { ["key"] = "second" }; + Raw target = new Raw(); + + // act + IRawRepository result = target.Merge(source1, source2); + + // assert + Raw resultRaw = (Raw)result; + Assert.AreEqual("second", resultRaw["key"]); + } + + [Test] + public void Merge_EmptySources_ReturnsEmptyTarget() + { + // arrange + Raw target = new Raw(); + + // act + IRawRepository result = target.Merge(); + + // assert + Raw resultRaw = (Raw)result; + Assert.AreEqual(0, resultRaw.Count); + } + + #endregion Merge tests + + #region Property getter/setter tests + + [Test] + public void GetProp_ExistingKey_ReturnsValue() + { + // arrange + Raw raw = new Raw(); + Raw propValue = new Raw(); + raw["testProp"] = propValue; + + // act + IRawRule result = raw.GetProp("testProp"); + + // assert + Assert.AreSame(propValue, result); + } + + [Test] + public void GetProp_NonExistingKey_ReturnsNull() + { + // arrange + Raw raw = new Raw(); + + // act + IRawRule result = raw.GetProp("nonExistent"); + + // assert + Assert.IsNull(result); + } + + [Test] + public void SetBase_GetBase_ReturnsSetValue() + { + // arrange + Raw raw = new Raw(); + Raw baseRule = new Raw(); + + // act + raw.SetBase(baseRule); + IRawRule result = raw.GetBase(); + + // assert + Assert.AreSame(baseRule, result); + } + + [Test] + public void SetSelf_GetSelf_ReturnsSetValue() + { + // arrange + Raw raw = new Raw(); + Raw selfRule = new Raw(); + + // act + raw.SetSelf(selfRule); + IRawRule result = raw.GetSelf(); + + // assert + Assert.AreSame(selfRule, result); + } + + [Test] + public void SetId_GetId_ReturnsSetValue() + { + // arrange + Raw raw = new Raw(); + RuleId id = RuleId.Of(42); + + // act + raw.SetId(id); + RuleId result = raw.GetId(); + + // assert + Assert.AreEqual(id, result); + } + + [Test] + public void SetName_GetName_ReturnsSetValue() + { + // arrange + Raw raw = new Raw(); + + // act + // TryGetObject is private, but we can test it indirectly through public methods + string initialResult = raw.GetName(); + raw.SetName("test value"); + string result = raw.GetName(); + + // assert + Assert.IsNull(initialResult); + Assert.AreEqual("test value", result); + } + + [Test] + public void GetContentName_ExistingValue_ReturnsValue() + { + // arrange + Raw raw = new Raw { ["contentName"] = "test.content" }; + + // act + string result = raw.GetContentName(); + + // assert + Assert.AreEqual("test.content", result); + } + + [Test] + public void GetMatch_ExistingValue_ReturnsValue() + { + // arrange + Raw raw = new Raw { ["match"] = "\\w+" }; + + // act + string result = raw.GetMatch(); + + // assert + Assert.AreEqual("\\w+", result); + } + + [Test] + public void GetBegin_ExistingValue_ReturnsValue() + { + // arrange + Raw raw = new Raw { ["begin"] = "^\\s*" }; + + // act + string result = raw.GetBegin(); + + // assert + Assert.AreEqual("^\\s*", result); + } + + [Test] + public void GetEnd_ExistingValue_ReturnsValue() + { + // arrange + Raw raw = new Raw { ["end"] = "$" }; + + // act + string result = raw.GetEnd(); + + // assert + Assert.AreEqual("$", result); + } + + [Test] + public void GetWhile_ExistingValue_ReturnsValue() + { + // arrange + Raw raw = new Raw { ["while"] = "\\S" }; + + // act + string result = raw.GetWhile(); + + // assert + Assert.AreEqual("\\S", result); + } + + [Test] + public void SetInclude_GetInclude_ReturnsSetValue() + { + // arrange + Raw raw = new Raw(); + const string include = "#source"; + + // act + raw.SetInclude(include); + string result = raw.GetInclude(); + + // assert + Assert.AreEqual(include, result); + } + + [Test] + public void GetScopeName_ExistingValue_ReturnsValue() + { + // arrange + Raw raw = new Raw { ["scopeName"] = "source.test" }; + + // act + string result = raw.GetScopeName(); + + // assert + Assert.AreEqual("source.test", result); + } + + [Test] + public void GetInjectionSelector_ExistingValue_ReturnsValue() + { + // arrange + Raw raw = new Raw { ["injectionSelector"] = "L:source.js" }; + + // act + string result = raw.GetInjectionSelector(); + + // assert + Assert.AreEqual("L:source.js", result); + } + + [Test] + public void GetFirstLineMatch_ExistingValue_ReturnsValue() + { + // arrange + Raw raw = new Raw { ["firstLineMatch"] = "^#!/bin/bash" }; + + // act + string result = raw.GetFirstLineMatch(); + + // assert + Assert.AreEqual("^#!/bin/bash", result); + } + + #endregion Property getter/setter tests + + #region Captures tests + + [Test] + public void GetCaptures_RawCaptures_ReturnsAsIs() + { + // arrange + Raw captures = new Raw { ["1"] = new Raw() }; + Raw raw = new Raw { ["captures"] = captures }; + + // act + IRawCaptures result = raw.GetCaptures(); + + // assert + Assert.AreSame(captures, result); + } + + [Test] + public void GetCaptures_ListCaptures_ConvertsToRaw_AndMapsObjectsCorrectly() + { + // arrange + Raw capture1 = new Raw { ["foo"] = "bar" }; + Raw capture2 = new Raw { ["baz"] = "qux" }; + List capturesList = new List { capture1, capture2 }; + Raw raw = new Raw { ["captures"] = capturesList }; + + // act + IRawCaptures result = raw.GetCaptures(); + + // assert + Raw resultRaw = (Raw)result; + Assert.AreEqual(2, resultRaw.Count); + Assert.IsTrue(resultRaw.ContainsKey("1")); + Assert.IsTrue(resultRaw.ContainsKey("2")); + Assert.AreSame(capture1, resultRaw["1"]); + Assert.AreSame(capture2, resultRaw["2"]); + } + + [Test] + public void GetCaptures_NonExistent_ReturnsNull() + { + // arrange + Raw raw = new Raw(); + + // act + IRawCaptures result = raw.GetCaptures(); + + // assert + Assert.IsNull(result); + } + + [Test] + public void GetBeginCaptures_RawCaptures_ReturnsAsIs() + { + // arrange + Raw captures = new Raw { ["0"] = new Raw() }; + Raw raw = new Raw { ["beginCaptures"] = captures }; + + // act + IRawCaptures result = raw.GetBeginCaptures(); + + // assert + Assert.AreSame(captures, result); + } + + [Test] + public void GetBeginCaptures_ListCaptures_ConvertsToRaw() + { + // arrange + Raw capture1 = new Raw(); + Raw capture2 = new Raw(); + Raw capture3 = new Raw(); + List capturesList = new List { capture1, capture2, capture3 }; + Raw raw = new Raw { ["beginCaptures"] = capturesList }; + + // act + IRawCaptures result = raw.GetBeginCaptures(); + + // assert + Raw resultRaw = (Raw)result; + Assert.AreEqual(3, resultRaw.Count); + Assert.IsTrue(resultRaw.ContainsKey("1")); + Assert.IsTrue(resultRaw.ContainsKey("2")); + Assert.IsTrue(resultRaw.ContainsKey("3")); + Assert.AreSame(capture1, resultRaw["1"]); + Assert.AreSame(capture2, resultRaw["2"]); + Assert.AreSame(capture3, resultRaw["3"]); + } + + [Test] + public void SetBeginCaptures_GetBeginCaptures_ReturnsSetValue() + { + // arrange + Raw raw = new Raw(); + Raw captures = new Raw { ["1"] = new Raw() }; + + // act + raw.SetBeginCaptures(captures); + IRawCaptures result = raw.GetBeginCaptures(); + + // assert + Assert.AreSame(captures, result); + } + + [Test] + public void GetEndCaptures_RawCaptures_ReturnsAsIs() + { + // arrange + Raw captures = new Raw { ["0"] = new Raw() }; + Raw raw = new Raw { ["endCaptures"] = captures }; + + // act + IRawCaptures result = raw.GetEndCaptures(); + + // assert + Assert.AreSame(captures, result); + } + + [Test] + public void GetEndCaptures_ListCaptures_ConvertsToRaw_AndMapsObjectsCorrectly() + { + // arrange + Raw capture1 = new Raw { ["foo"] = "bar" }; + Raw capture2 = new Raw { ["baz"] = "qux" }; + List capturesList = new List { capture1, capture2 }; + Raw raw = new Raw { ["endCaptures"] = capturesList }; + + // act + IRawCaptures result = raw.GetEndCaptures(); + + // assert + Raw resultRaw = (Raw)result; + Assert.AreEqual(2, resultRaw.Count); + Assert.IsTrue(resultRaw.ContainsKey("1")); + Assert.IsTrue(resultRaw.ContainsKey("2")); + Assert.AreSame(capture1, resultRaw["1"]); + Assert.AreSame(capture2, resultRaw["2"]); + } + + [Test] + public void GetWhileCaptures_ListCaptures_ConvertsToRaw() + { + // arrange + List capturesList = new List { new Raw(), new Raw() }; + Raw raw = new Raw { ["whileCaptures"] = capturesList }; + + // act + IRawCaptures result = raw.GetWhileCaptures(); + + // assert + Raw resultRaw = (Raw)result; + Assert.AreEqual(2, resultRaw.Count); + } + + [Test] + public void GetCapture_ExistingCaptureId_ReturnsCapture() + { + // arrange + Raw capture = new Raw(); + Raw raw = new Raw { ["1"] = capture }; + + // act + IRawRule result = raw.GetCapture("1"); + + // assert + Assert.AreSame(capture, result); + } + + #endregion Captures tests + + #region Patterns tests + + [Test] + public void GetPatterns_ExistingList_ReturnsCollection() + { + // arrange + List patternsList = new List { new Raw(), new Raw() }; + Raw raw = new Raw { ["patterns"] = patternsList }; + + // act + ICollection result = raw.GetPatterns(); + + // assert + Assert.AreEqual(2, result.Count); + } + + [Test] + public void GetPatterns_NonExistent_ReturnsNull() + { + // arrange + Raw raw = new Raw(); + + // act + ICollection result = raw.GetPatterns(); + + // assert + Assert.IsNull(result); + } + + [Test] + public void SetPatterns_GetPatterns_ReturnsSetPatterns() + { + // arrange + Raw raw = new Raw(); + List patterns = new List { new Raw(), new Raw() }; + + // act + raw.SetPatterns(patterns); + ICollection result = raw.GetPatterns(); + + // assert + Assert.AreEqual(2, result.Count); + } + + #endregion Patterns tests + + #region Injections tests + + [Test] + public void GetInjections_ExistingRaw_ReturnsDictionary() + { + // arrange + Raw injectionsRaw = new Raw + { + ["L:source.js"] = new Raw(), + ["L:text.html"] = new Raw() + }; + Raw raw = new Raw { ["injections"] = injectionsRaw }; + + // act + Dictionary result = raw.GetInjections(); + + // assert + Assert.AreEqual(2, result.Count); + Assert.IsTrue(result.ContainsKey("L:source.js")); + Assert.IsTrue(result.ContainsKey("L:text.html")); + } + + [Test] + public void GetInjections_NonExistent_ReturnsNull() + { + // arrange + Raw raw = new Raw(); + + // act + Dictionary result = raw.GetInjections(); + + // assert + Assert.IsNull(result); + } + + #endregion Injections tests + + #region Repository tests + + [Test] + public void SetRepository_GetRepository_ReturnsSetValue() + { + // arrange + Raw raw = new Raw(); + Raw repository = new Raw { ["rule1"] = new Raw() }; + + // act + raw.SetRepository(repository); + IRawRepository result = raw.GetRepository(); + + // assert + Assert.AreSame(repository, result); + } + + [Test] + public void GetRepository_NonExistent_ReturnsNull() + { + // arrange + Raw raw = new Raw(); + + // act + IRawRepository result = raw.GetRepository(); + + // assert + Assert.IsNull(result); + } + + #endregion Repository tests + + #region IsApplyEndPatternLast tests + + [Test] + public void IsApplyEndPatternLast_BoolTrue_ReturnsTrue() + { + // arrange + Raw raw = new Raw { ["applyEndPatternLast"] = true }; + + // act + bool result = raw.IsApplyEndPatternLast(); + + // assert + Assert.IsTrue(result); + } + + [Test] + public void IsApplyEndPatternLast_BoolFalse_ReturnsFalse() + { + // arrange + Raw raw = new Raw { ["applyEndPatternLast"] = false }; + + // act + bool result = raw.IsApplyEndPatternLast(); + + // assert + Assert.IsFalse(result); + } + + [Test] + public void IsApplyEndPatternLast_IntOne_ReturnsTrue() + { + // arrange + Raw raw = new Raw { ["applyEndPatternLast"] = 1 }; + + // act + bool result = raw.IsApplyEndPatternLast(); + + // assert + Assert.IsTrue(result); + } + + [Test] + public void IsApplyEndPatternLast_IntZero_ReturnsFalse() + { + // arrange + Raw raw = new Raw { ["applyEndPatternLast"] = 0 }; + + // act + bool result = raw.IsApplyEndPatternLast(); + + // assert + Assert.IsFalse(result); + } + + [Test] + public void IsApplyEndPatternLast_IntOther_ReturnsFalse() + { + // arrange + Raw raw = new Raw { ["applyEndPatternLast"] = 42 }; + + // act + bool result = raw.IsApplyEndPatternLast(); + + // assert + Assert.IsFalse(result); + } + + [Test] + public void IsApplyEndPatternLast_NonExistent_ReturnsFalse() + { + // arrange + Raw raw = new Raw(); + + // act + bool result = raw.IsApplyEndPatternLast(); + + // assert + Assert.IsFalse(result); + } + + [Test] + public void IsApplyEndPatternLast_StringValue_ReturnsFalse() + { + // arrange + Raw raw = new Raw { ["applyEndPatternLast"] = "true" }; + + // act + bool result = raw.IsApplyEndPatternLast(); + + // assert + Assert.IsFalse(result); + } + + [Test] + public void SetApplyEndPatternLast_IsApplyEndPatternLast_ReturnsTrue() + { + // arrange + Raw raw = new Raw(); + + // act + raw.SetApplyEndPatternLast(true); + bool result = raw.IsApplyEndPatternLast(); + + // assert + Assert.IsTrue(result); + } + + #endregion IsApplyEndPatternLast tests + + #region GetFileTypes tests + + [Test] + public void GetFileTypes_WithLeadingDots_TrimsDotsAndCaches() + { + // arrange + List fileTypes = new List { ".js", ".ts", ".jsx" }; + Raw raw = new Raw { ["fileTypes"] = fileTypes }; + + // act + ICollection result1 = raw.GetFileTypes(); + ICollection result2 = raw.GetFileTypes(); + + // assert + Assert.AreEqual(3, result1.Count); + Assert.IsTrue(result1.Contains("js")); + Assert.IsTrue(result1.Contains("ts")); + Assert.IsTrue(result1.Contains("jsx")); + Assert.AreSame(result1, result2); // Cached + } + + [Test] + public void GetFileTypes_WithoutLeadingDots_ReturnsAsIs() + { + // arrange + List fileTypes = new List { "js", "ts" }; + Raw raw = new Raw { ["fileTypes"] = fileTypes }; + + // act + ICollection result = raw.GetFileTypes(); + + // assert + Assert.AreEqual(2, result.Count); + Assert.IsTrue(result.Contains("js")); + Assert.IsTrue(result.Contains("ts")); + } + + [Test] + public void GetFileTypes_NonExistent_ReturnsEmptyList() + { + // arrange + Raw raw = new Raw(); + + // act + ICollection result = raw.GetFileTypes(); + + // assert + Assert.IsNotNull(result); + Assert.AreEqual(0, result.Count); + } + + [Test] + public void GetFileTypes_EmptyList_ReturnsEmptyList() + { + // arrange + Raw raw = new Raw { ["fileTypes"] = new List() }; + + // act + ICollection result = raw.GetFileTypes(); + + // assert + Assert.IsNotNull(result); + Assert.AreEqual(0, result.Count); + } + + [Test] + public void GetFileTypes_MixedDots_CorrectlyProcessesEach() + { + // arrange + List fileTypes = new List { ".cs", "vb", ".fs" }; + Raw raw = new Raw { ["fileTypes"] = fileTypes }; + + // act + ICollection result = raw.GetFileTypes(); + + // assert + List resultList = result.ToList(); + Assert.AreEqual("cs", resultList[0]); + Assert.AreEqual("vb", resultList[1]); + Assert.AreEqual("fs", resultList[2]); + } + + #endregion GetFileTypes tests + + #region Clone tests + + [Test] + public void Clone_SimpleRaw_CreatesDeepCopy() + { + // arrange + Raw original = new Raw + { + ["name"] = "test", + ["value"] = 42 + }; + + // act + IRawGrammar cloned = original.Clone(); + + // assert + Raw clonedRaw = (Raw)cloned; + Assert.AreNotSame(original, clonedRaw); + Assert.AreEqual("test", clonedRaw["name"]); + Assert.AreEqual(42, clonedRaw["value"]); + } + + [Test] + public void Clone_NestedRaw_CreatesDeepCopy() + { + // arrange + Raw nested = new Raw { ["inner"] = "value" }; + Raw original = new Raw { ["nested"] = nested }; + + // act + IRawGrammar cloned = original.Clone(); + + // assert + Raw clonedRaw = (Raw)cloned; + Raw clonedNested = (Raw)clonedRaw["nested"]; + Assert.AreNotSame(original, clonedRaw); + Assert.AreNotSame(nested, clonedNested); + Assert.AreEqual("value", clonedNested["inner"]); + } + + [Test] + public void Clone_WithList_CreatesDeepCopyOfList() + { + // arrange + List list = new List { "item1", 123 }; + Raw original = new Raw { ["list"] = list }; + + // act + IRawGrammar cloned = original.Clone(); + + // assert + Raw clonedRaw = (Raw)cloned; + List clonedList = (List)clonedRaw["list"]; + Assert.AreNotSame(list, clonedList); + Assert.AreEqual(2, clonedList.Count); + Assert.AreEqual("item1", clonedList[0]); + Assert.AreEqual(123, clonedList[1]); + } + + [Test] + public void Clone_WithNestedList_CreatesDeepCopy() + { + // arrange + Raw innerRaw = new Raw { ["key"] = "value" }; + List list = new List { innerRaw }; + Raw original = new Raw { ["list"] = list }; + + // act + IRawGrammar cloned = original.Clone(); + + // assert + Raw clonedRaw = (Raw)cloned; + List clonedList = (List)clonedRaw["list"]; + Raw clonedInner = (Raw)clonedList[0]; + Assert.AreNotSame(innerRaw, clonedInner); + Assert.AreEqual("value", clonedInner["key"]); + } + + [Test] + public void Clone_WithBool_CopiesValue() + { + // arrange + Raw original = new Raw { ["flag"] = true }; + + // act + IRawGrammar cloned = original.Clone(); + + // assert + Raw clonedRaw = (Raw)cloned; + Assert.AreEqual(true, clonedRaw["flag"]); + } + + [Test] + public void Clone_WithString_PreservesStringReference() + { + // arrange + const string str = "test string"; + Raw original = new Raw { ["str"] = str }; + + // act + IRawGrammar cloned = original.Clone(); + + // assert + Raw clonedRaw = (Raw)cloned; + Assert.AreSame(str, clonedRaw["str"]); // Strings are immutable + } + + [Test] + public void Clone_WithInt_CopiesValue() + { + // arrange + Raw original = new Raw { ["num"] = 42 }; + + // act + IRawGrammar cloned = original.Clone(); + + // assert + Raw clonedRaw = (Raw)cloned; + Assert.AreEqual(42, clonedRaw["num"]); + } + + [Test] + public void Clone_EmptyRaw_CreatesEmptyClone() + { + // arrange + Raw original = new Raw(); + + // act + IRawGrammar cloned = original.Clone(); + + // assert + Raw clonedRaw = (Raw)cloned; + Assert.AreNotSame(original, clonedRaw); + Assert.AreEqual(0, clonedRaw.Count); + } + + [Test] + public void Clone_ModifyingClone_DoesNotAffectOriginal() + { + // arrange + Raw original = new Raw { ["key"] = "original" }; + IRawGrammar cloned = original.Clone(); + + // act + ((Raw)cloned)["key"] = "modified"; + + // assert + Assert.AreEqual("original", original["key"]); + Assert.AreEqual("modified", ((Raw)cloned)["key"]); + } + + #endregion Clone tests + + #region IEnumerable tests + + [Test] + public void GetEnumerator_String_EnumeratesKeys() + { + // arrange + Raw raw = new Raw + { + ["key1"] = "value1", + ["key2"] = "value2", + ["key3"] = "value3" + }; + + // act + List keys = new List(); + IEnumerable enumerable = raw; + foreach (string key in enumerable) + { + keys.Add(key); + } + + // assert + Assert.AreEqual(3, keys.Count); + Assert.IsTrue(keys.Contains("key1")); + Assert.IsTrue(keys.Contains("key2")); + Assert.IsTrue(keys.Contains("key3")); + } + + #endregion IEnumerable tests + } +} \ No newline at end of file From 4ec511503b2520e1c13561e606eacc50aa77a2fc Mon Sep 17 00:00:00 2001 From: Dave Black <656118+udlose@users.noreply.github.com> Date: Mon, 2 Mar 2026 10:06:03 -0600 Subject: [PATCH 16/33] Improve type safety and performance in Raw.cs Refactored Raw.cs to use pattern matching for type checks, replaced string concatenation with ToString(), and ensured proper initialization of file type lists. Optimized list and dictionary allocations, made ConvertToDictionary static, and improved code clarity by removing redundant casts and branches. --- .../Internal/Grammars/Parser/RawTests.cs | 1 + .../Internal/Grammars/parser/Raw.cs | 40 ++++++++++--------- 2 files changed, 23 insertions(+), 18 deletions(-) diff --git a/src/TextMateSharp.Tests/Internal/Grammars/Parser/RawTests.cs b/src/TextMateSharp.Tests/Internal/Grammars/Parser/RawTests.cs index 471de2f..34530fb 100644 --- a/src/TextMateSharp.Tests/Internal/Grammars/Parser/RawTests.cs +++ b/src/TextMateSharp.Tests/Internal/Grammars/Parser/RawTests.cs @@ -944,6 +944,7 @@ public void GetEnumerator_String_EnumeratesKeys() // act List keys = new List(); IEnumerable enumerable = raw; + // don't use AddRange here, we want to test the enumerator directly foreach (string key in enumerable) { keys.Add(key); diff --git a/src/TextMateSharp/Internal/Grammars/parser/Raw.cs b/src/TextMateSharp/Internal/Grammars/parser/Raw.cs index e1f1b47..0e453dc 100644 --- a/src/TextMateSharp/Internal/Grammars/parser/Raw.cs +++ b/src/TextMateSharp/Internal/Grammars/parser/Raw.cs @@ -4,7 +4,6 @@ using TextMateSharp.Internal.Rules; using TextMateSharp.Internal.Types; -using TextMateSharp.Internal.Utils; namespace TextMateSharp.Internal.Grammars.Parser { @@ -112,14 +111,14 @@ public IRawCaptures GetCaptures() private void UpdateCaptures(string name) { object captures = TryGetObject(name); - if (captures is IList) + if (captures is IList capturesList) { Raw rawCaptures = new Raw(); int i = 0; - foreach (object capture in (IList)captures) + foreach (object capture in capturesList) { i++; - rawCaptures[i + ""] = capture; + rawCaptures[i.ToString()] = capture; } this[name] = rawCaptures; } @@ -220,13 +219,13 @@ public bool IsApplyEndPatternLast() { return false; } - if (applyEndPatternLast is bool) + if (applyEndPatternLast is bool applyEndPatternLastBool) { - return (bool)applyEndPatternLast; + return applyEndPatternLastBool; } - if (applyEndPatternLast is int) + if (applyEndPatternLast is int applyEndPatternLastInt) { - return ((int)applyEndPatternLast) == 1; + return applyEndPatternLastInt == 1; } return false; } @@ -245,21 +244,27 @@ public ICollection GetFileTypes() { if (fileTypes == null) { - List list = new List(); + List list; ICollection unparsedFileTypes = TryGetObject(FILE_TYPES); if (unparsedFileTypes != null) { + list = new List(unparsedFileTypes.Count); foreach (object o in unparsedFileTypes) { string str = o.ToString(); // #202 - if (str.StartsWith(".")) + if (!string.IsNullOrEmpty(str) && str[0] == '.') { str = str.Substring(1); } list.Add(str); } } + else + { + list = new List(); + } + fileTypes = list; } return fileTypes; @@ -282,9 +287,8 @@ public IRawGrammar Clone() public object Clone(object value) { - if (value is Raw) + if (value is Raw rawToClone) { - Raw rawToClone = (Raw)value; Raw raw = new Raw(); foreach (string key in rawToClone.Keys) @@ -293,12 +297,12 @@ public object Clone(object value) } return raw; } - else if (value is IList) + else if (value is IList list) { - List result = new List(); - foreach (object obj in (IList)value) + List result = new List(list.Count); + for (int i = 0; i < list.Count; i++) { - result.Add(Clone(obj)); + result.Add(Clone(list[i])); } return result; } @@ -322,9 +326,9 @@ IEnumerator IEnumerable.GetEnumerator() return Keys.GetEnumerator(); } - Dictionary ConvertToDictionary(Raw raw) + static Dictionary ConvertToDictionary(Raw raw) { - Dictionary result = new Dictionary(); + Dictionary result = new Dictionary(raw.Keys.Count); foreach (string key in raw.Keys) result.Add(key, (T)raw[key]); From d9e89aa1cdb27f11512a3514a5621bc10bd09d70 Mon Sep 17 00:00:00 2001 From: Dave Black <656118+udlose@users.noreply.github.com> Date: Mon, 2 Mar 2026 10:40:06 -0600 Subject: [PATCH 17/33] Add extensive unit tests for matcher logic and fix namespaces Added MatcherBuilderTests and NameMatcherTests for thorough coverage of matcher expression parsing, priority, and edge cases using NUnit and Moq. Updated MatcherTests.cs to fix namespace and fully qualify Matcher.CreateMatchers usage. Improves reliability and test coverage for matcher logic. --- .../Internal/Matcher/MatcherBuilderTests.cs | 703 ++++++++++++++++++ .../Internal/Matcher/MatcherTests.cs | 9 +- .../Internal/Matcher/NameMatcherTests.cs | 515 +++++++++++++ 3 files changed, 1221 insertions(+), 6 deletions(-) create mode 100644 src/TextMateSharp.Tests/Internal/Matcher/MatcherBuilderTests.cs create mode 100644 src/TextMateSharp.Tests/Internal/Matcher/NameMatcherTests.cs diff --git a/src/TextMateSharp.Tests/Internal/Matcher/MatcherBuilderTests.cs b/src/TextMateSharp.Tests/Internal/Matcher/MatcherBuilderTests.cs new file mode 100644 index 0000000..06fbb00 --- /dev/null +++ b/src/TextMateSharp.Tests/Internal/Matcher/MatcherBuilderTests.cs @@ -0,0 +1,703 @@ +using Moq; +using NUnit.Framework; +using System.Collections.Generic; +using System.Linq; +using TextMateSharp.Internal.Matcher; + +namespace TextMateSharp.Tests.Internal.Matcher +{ + [TestFixture] + public class MatcherBuilderTests + { + #region Constructor Tests + + [Test] + public void Constructor_EmptyExpression_CreatesEmptyResults() + { + // arrange + Mock> matchesName = CreateMockMatchesName(); + + // act + MatcherBuilder builder = new MatcherBuilder("", matchesName.Object); + + // assert + Assert.IsNotNull(builder.Results); + Assert.AreEqual(0, builder.Results.Count); + } + + [Test] + public void Constructor_NullExpression_ThrowsArgumentNullException() + { + // arrange + Mock> matchesName = CreateMockMatchesName(); + + // act & assert + Assert.Throws(() => new MatcherBuilder(null, matchesName.Object)); + } + + [Test] + public void Constructor_NullMatchesName_ThrowsArgumentNullException() + { + // act & assert + Assert.Throws(() => new MatcherBuilder("identifier", null)); + } + + [Test] + public void Constructor_SingleIdentifier_CreatesOneMatcher() + { + // arrange + Mock> matchesName = CreateMockMatchesName("identifier"); + + // act + MatcherBuilder builder = new MatcherBuilder("identifier", matchesName.Object); + + // assert + Assert.AreEqual(1, builder.Results.Count); + Assert.AreEqual(0, builder.Results[0].Priority); + } + + [Test] + public void Constructor_MultipleIdentifiersWithComma_CreatesMultipleMatchers() + { + // arrange + Mock> matchesName = CreateMockMatchesName("id1", "id2"); + + // act + MatcherBuilder builder = new MatcherBuilder("id1, id2", matchesName.Object); + + // assert + Assert.AreEqual(2, builder.Results.Count); + } + + #endregion Constructor Tests + + #region Priority Tests + + [Test] + public void Constructor_RightPriority_SetsPositivePriority() + { + // arrange + Mock> matchesName = CreateMockMatchesName("identifier"); + + // act + MatcherBuilder builder = new MatcherBuilder("R: identifier", matchesName.Object); + + // assert + Assert.AreEqual(1, builder.Results.Count); + Assert.AreEqual(1, builder.Results[0].Priority); + } + + [Test] + public void Constructor_LeftPriority_SetsNegativePriority() + { + // arrange + Mock> matchesName = CreateMockMatchesName("identifier"); + + // act + MatcherBuilder builder = new MatcherBuilder("L: identifier", matchesName.Object); + + // assert + Assert.AreEqual(1, builder.Results.Count); + Assert.AreEqual(-1, builder.Results[0].Priority); + } + + [Test] + public void Constructor_MultiplePriorities_AppliesEachCorrectly() + { + // arrange + Mock> matchesName = CreateMockMatchesName("a", "b", "c"); + + // act + MatcherBuilder builder = new MatcherBuilder("R: a, L: b, c", matchesName.Object); + + // assert + Assert.AreEqual(3, builder.Results.Count); + Assert.AreEqual(1, builder.Results[0].Priority); // R: a + Assert.AreEqual(-1, builder.Results[1].Priority); // L: b + Assert.AreEqual(0, builder.Results[2].Priority); // c (no priority) + } + + [Test] + public void Constructor_InvalidPriorityPrefix_TreatsAsIdentifier() + { + // arrange + Mock> matchesName = CreateMockMatchesName("X:"); + + // act + MatcherBuilder builder = new MatcherBuilder("X: identifier", matchesName.Object); + + // assert + Assert.AreEqual(1, builder.Results.Count); + Assert.AreEqual(0, builder.Results[0].Priority); + } + + #endregion Priority Tests + + #region Conjunction Tests (AND) + + [Test] + public void Constructor_ConjunctionOfIdentifiers_AllMustMatch() + { + // arrange + Mock> matchesName = new Mock>(); + matchesName.Setup(m => m.Match(It.IsAny>(), It.Is(s => s == "match"))) + .Returns, string>((ids, _) => ids.Contains("a") && ids.Contains("b")); + matchesName.Setup(m => m.Match(It.IsAny>(), It.Is(s => s == "nomatch"))) + .Returns(false); + + // act + MatcherBuilder builder = new MatcherBuilder("a b", matchesName.Object); + + // assert + Assert.AreEqual(1, builder.Results.Count); + Assert.IsTrue(builder.Results[0].Matcher("match")); + Assert.IsFalse(builder.Results[0].Matcher("nomatch")); + } + + [Test] + public void Constructor_MultipleIdentifiersSpaceSeparated_CreatesConjunction() + { + // arrange + Mock> matchesName = CreateMockMatchesName("a", "b", "c"); + + // act + MatcherBuilder builder = new MatcherBuilder("a b c", matchesName.Object); + + // assert + Assert.AreEqual(1, builder.Results.Count); + Assert.IsNotNull(builder.Results[0].Matcher); + } + + #endregion Conjunction Tests + + #region Disjunction Tests (OR) + + [Test] + public void Constructor_DisjunctionWithPipe_CreatesOrMatcher() + { + // arrange + Mock> matchesName = new Mock>(); + matchesName.Setup(m => m.Match(It.IsAny>(), It.Is(s => s == "a"))) + .Returns, string>((ids, _) => ids.Contains("a")); + matchesName.Setup(m => m.Match(It.IsAny>(), It.Is(s => s == "b"))) + .Returns, string>((ids, _) => ids.Contains("b")); + + // act + MatcherBuilder builder = new MatcherBuilder("(a | b)", matchesName.Object); + + // assert + Assert.AreEqual(1, builder.Results.Count); + Assert.IsTrue(builder.Results[0].Matcher("a")); + Assert.IsTrue(builder.Results[0].Matcher("b")); + } + + [Test] + public void Constructor_MultiplePipesIgnoresConsecutive() + { + // arrange + Mock> matchesName = CreateMockMatchesName("a", "b"); + + // act + MatcherBuilder builder = new MatcherBuilder("(a || b)", matchesName.Object); + + // assert + Assert.AreEqual(1, builder.Results.Count); + } + + [Test] + public void Constructor_MultipleCommasIgnoresConsecutive() + { + // arrange + Mock> matchesName = CreateMockMatchesName("a", "b"); + + // act + MatcherBuilder builder = new MatcherBuilder("(a ,, b)", matchesName.Object); + + // assert + Assert.AreEqual(1, builder.Results.Count); + } + + #endregion Disjunction Tests + + #region Negation Tests + + [Test] + public void Constructor_Negation_InvertsMatch() + { + // arrange + Mock> matchesName = new Mock>(); + matchesName.Setup(m => m.Match(It.IsAny>(), It.Is(s => s == "match"))) + .Returns, string>((ids, _) => ids.Contains("a")); + matchesName.Setup(m => m.Match(It.IsAny>(), It.Is(s => s == "nomatch"))) + .Returns, string>((ids, _) => !ids.Contains("a")); + + // act + MatcherBuilder builder = new MatcherBuilder("- a", matchesName.Object); + + // assert + Assert.AreEqual(1, builder.Results.Count); + Assert.IsFalse(builder.Results[0].Matcher("match")); + Assert.IsTrue(builder.Results[0].Matcher("nomatch")); + } + + [Test] + public void Constructor_DoubleNegation_RestoresOriginalMatch() + { + // arrange + Mock> matchesName = new Mock>(); + matchesName.Setup(m => m.Match(It.IsAny>(), It.Is(s => s == "match"))) + .Returns, string>((ids, _) => ids.Contains("a")); + + // act + MatcherBuilder builder = new MatcherBuilder("- - a", matchesName.Object); + + // assert + Assert.AreEqual(1, builder.Results.Count); + Assert.IsTrue(builder.Results[0].Matcher("match")); + } + + [Test] + public void Constructor_NegationOfNull_ReturnsTrue() + { + // arrange + Mock> matchesName = CreateMockMatchesName(); + + // act + MatcherBuilder builder = new MatcherBuilder("- -", matchesName.Object); + + // assert + // The expression "- -" means: negate (negate nothing) + // First "-" consumes and parses second "-" + // Second "-" returns a lambda that checks null and returns false + // First "-" negates that false result, returning true + Assert.AreEqual(1, builder.Results.Count); + Assert.IsTrue(builder.Results[0].Matcher("anything")); + } + + #endregion Negation Tests + + #region Parentheses Tests + + [Test] + public void Constructor_Parentheses_GroupsExpression() + { + // arrange + Mock> matchesName = CreateMockMatchesName("a"); + + // act + MatcherBuilder builder = new MatcherBuilder("(a)", matchesName.Object); + + // assert + Assert.AreEqual(1, builder.Results.Count); + } + + [Test] + public void Constructor_NestedParentheses_HandlesCorrectly() + { + // arrange + Mock> matchesName = CreateMockMatchesName("a", "b"); + + // act + MatcherBuilder builder = new MatcherBuilder("((a) | (b))", matchesName.Object); + + // assert + Assert.AreEqual(1, builder.Results.Count); + } + + [Test] + public void Constructor_UnmatchedOpenParenthesis_HandlesGracefully() + { + // arrange + Mock> matchesName = CreateMockMatchesName("a"); + + // act + MatcherBuilder builder = new MatcherBuilder("(a", matchesName.Object); + + // assert + Assert.AreEqual(1, builder.Results.Count); + } + + #endregion Parentheses Tests + + #region IsIdentifier Tests + + [Test] + public void IsIdentifier_ValidIdentifier_ReturnsTrue() + { + // arrange + Mock> matchesName = CreateMockMatchesName("valid.identifier"); + + // act + MatcherBuilder builder = new MatcherBuilder("valid.identifier", matchesName.Object); + + // assert + Assert.AreEqual(1, builder.Results.Count); + } + + [Test] + public void IsIdentifier_WithDots_ReturnsTrue() + { + // arrange + Mock> matchesName = CreateMockMatchesName("a.b.c"); + + // act + MatcherBuilder builder = new MatcherBuilder("a.b.c", matchesName.Object); + + // assert + Assert.AreEqual(1, builder.Results.Count); + } + + [Test] + public void IsIdentifier_WithColons_ReturnsTrue() + { + // arrange + Mock> matchesName = CreateMockMatchesName("a:b:c"); + + // act + MatcherBuilder builder = new MatcherBuilder("a:b:c", matchesName.Object); + + // assert + Assert.AreEqual(1, builder.Results.Count); + } + + [Test] + public void IsIdentifier_WithUnderscores_ReturnsTrue() + { + // arrange + Mock> matchesName = CreateMockMatchesName("a_b_c"); + + // act + MatcherBuilder builder = new MatcherBuilder("a_b_c", matchesName.Object); + + // assert + Assert.AreEqual(1, builder.Results.Count); + } + + [Test] + public void IsIdentifier_WithNumbers_ReturnsTrue() + { + // arrange + Mock> matchesName = CreateMockMatchesName("abc123"); + + // act + MatcherBuilder builder = new MatcherBuilder("abc123", matchesName.Object); + + // assert + Assert.AreEqual(1, builder.Results.Count); + } + + [Test] + public void Tokenizer_HyphenWithoutSpaces_TokenizedButRejected() + { + // arrange + Mock> matchesName = CreateMockMatchesName("a", "b"); + + // act + // "a-b" (no spaces) is tokenized by the regex as a single token "a-b" + // However, IsIdentifier("a-b") returns false because hyphen is not an allowed character + // So ParseOperand returns null and nothing is added to Results + MatcherBuilder builder = new MatcherBuilder("a-b", matchesName.Object); + + // assert + Assert.AreEqual(0, builder.Results.Count); + } + + #endregion IsIdentifier Tests + + #region Complex Expression Tests + + [Test] + public void Constructor_ComplexExpression_ParsesCorrectly() + { + // arrange + Mock> matchesName = CreateMockMatchesName("a", "b", "c", "d"); + + // act + MatcherBuilder builder = new MatcherBuilder("R: a b, L: (c | d)", matchesName.Object); + + // assert + Assert.AreEqual(2, builder.Results.Count); + Assert.AreEqual(1, builder.Results[0].Priority); + Assert.AreEqual(-1, builder.Results[1].Priority); + } + + [Test] + public void Constructor_ConjunctionAndDisjunction_ParsesCorrectly() + { + // arrange + Mock> matchesName = CreateMockMatchesName("a", "b", "c"); + + // act + MatcherBuilder builder = new MatcherBuilder("a b | c", matchesName.Object); + + // assert + Assert.AreEqual(1, builder.Results.Count); + } + + [Test] + public void Constructor_NegationWithParentheses_ParsesCorrectly() + { + // arrange + Mock> matchesName = CreateMockMatchesName("a", "b"); + + // act + MatcherBuilder builder = new MatcherBuilder("- (a | b)", matchesName.Object); + + // assert + Assert.AreEqual(1, builder.Results.Count); + } + + [Test] + public void Constructor_MultipleMatchers_AllParsed() + { + // arrange + Mock> matchesName = CreateMockMatchesName("a", "b", "c"); + + // act + MatcherBuilder builder = new MatcherBuilder("a, b, c", matchesName.Object); + + // assert + Assert.AreEqual(3, builder.Results.Count); + } + + #endregion Complex Expression Tests + + #region Edge Cases + + [Test] + public void Constructor_OnlyOperators_HandlesGracefully() + { + // arrange + Mock> matchesName = CreateMockMatchesName(); + + // act + MatcherBuilder builder = new MatcherBuilder("| , - ()", matchesName.Object); + + // assert + Assert.IsNotNull(builder.Results); + } + + [Test] + public void Constructor_OnlyParentheses_HandlesGracefully() + { + // arrange + Mock> matchesName = CreateMockMatchesName(); + + // act + MatcherBuilder builder = new MatcherBuilder("()", matchesName.Object); + + // assert + Assert.IsNotNull(builder.Results); + } + + [Test] + public void Constructor_WhitespaceOnly_CreatesEmptyResults() + { + // arrange + Mock> matchesName = CreateMockMatchesName(); + + // act + MatcherBuilder builder = new MatcherBuilder(" ", matchesName.Object); + + // assert + Assert.AreEqual(0, builder.Results.Count); + } + + [Test] + public void Constructor_HyphenAsNegationOperator_CreatesConjunction() + { + // arrange + Mock> matchesName = new Mock>(); + matchesName.Setup(m => m.Match(It.IsAny>(), It.Is(s => s == "match"))) + .Returns, string>((ids, _) => ids.Contains("a") && !ids.Contains("b")); + matchesName.Setup(m => m.Match(It.IsAny>(), It.Is(s => s == "nomatch"))) + .Returns, string>((ids, _) => ids.Contains("b")); + + // act + // "a - b" with spaces is tokenized as three separate tokens: "a", "-", "b" + // This creates: a AND (NOT b) + MatcherBuilder builder = new MatcherBuilder("a - b", matchesName.Object); + + // assert + Assert.AreEqual(1, builder.Results.Count); + Assert.IsTrue(builder.Results[0].Matcher("match")); + Assert.IsFalse(builder.Results[0].Matcher("nomatch")); + } + + [Test] + public void Constructor_ConsecutiveCommas_HandlesGracefully() + { + // arrange + Mock> matchesName = CreateMockMatchesName("a", "b"); + + // act + MatcherBuilder builder = new MatcherBuilder("a,,,b", matchesName.Object); + + // assert + Assert.IsTrue(builder.Results.Count >= 2); + } + + [Test] + public void Constructor_TrailingComma_HandlesGracefully() + { + // arrange + Mock> matchesName = CreateMockMatchesName("a"); + + // act + MatcherBuilder builder = new MatcherBuilder("a,", matchesName.Object); + + // assert + Assert.IsTrue(builder.Results.Count >= 1); + } + + [Test] + public void Constructor_LeadingComma_HandlesGracefully() + { + // arrange + Mock> matchesName = CreateMockMatchesName("a"); + + // act + MatcherBuilder builder = new MatcherBuilder(",a", matchesName.Object); + + // assert + Assert.IsNotNull(builder.Results); + } + + #endregion Edge Cases + + #region Tokenizer Tests + + [Test] + public void Tokenizer_EmptyString_ReturnsNull() + { + // arrange + Mock> matchesName = CreateMockMatchesName(); + + // act + MatcherBuilder builder = new MatcherBuilder("", matchesName.Object); + + // assert + Assert.AreEqual(0, builder.Results.Count); + } + + [Test] + public void Tokenizer_PriorityTokens_ParsedCorrectly() + { + // arrange + Mock> matchesName = CreateMockMatchesName("id"); + + // act + MatcherBuilder builder = new MatcherBuilder("R: id", matchesName.Object); + + // assert + Assert.AreEqual(1, builder.Results.Count); + Assert.AreEqual(1, builder.Results[0].Priority); + } + + [Test] + public void Tokenizer_SpecialCharacters_ParsedAsTokens() + { + // arrange + Mock> matchesName = CreateMockMatchesName("a"); + + // act + MatcherBuilder builder = new MatcherBuilder("(a)|-(a),(a)", matchesName.Object); + + // assert + Assert.IsTrue(builder.Results.Count > 0); + } + + [Test] + public void Tokenizer_MixedIdentifiers_ParsedCorrectly() + { + // arrange + Mock> matchesName = CreateMockMatchesName("abc", "def.ghi", "jkl:mno"); + + // act + MatcherBuilder builder = new MatcherBuilder("abc def.ghi jkl:mno", matchesName.Object); + + // assert + Assert.AreEqual(1, builder.Results.Count); + } + + #endregion Tokenizer Tests + + #region Integration Tests + + [Test] + public void Matcher_RealWorldExample_LanguageScope() + { + // arrange + Mock> matchesName = new Mock>(); + matchesName.Setup(m => m.Match(It.IsAny>(), It.IsAny())) + .Returns, string>((ids, input) => + { + if (input == "source.cs") + { + return ids.Contains("source.cs"); + } + + if (input == "source.js") + { + return ids.Contains("source.js"); + } + + return false; + }); + + // act + MatcherBuilder builder = new MatcherBuilder("R: source.cs, L: source.js", matchesName.Object); + + // assert + Assert.AreEqual(2, builder.Results.Count); + Assert.IsTrue(builder.Results[0].Matcher("source.cs")); + Assert.IsFalse(builder.Results[0].Matcher("source.js")); + Assert.IsTrue(builder.Results[1].Matcher("source.js")); + Assert.IsFalse(builder.Results[1].Matcher("source.cs")); + } + + [Test] + public void Matcher_RealWorldExample_ExcludePattern() + { + // arrange + Mock> matchesName = new Mock>(); + matchesName.Setup(m => m.Match(It.IsAny>(), It.IsAny())) + .Returns, string>(static (ids, input) => + { + if (input == "match") + { + return ids.Contains("text") && !ids.Contains("comment"); + } + + if (input == "excluded") + { + return ids.Contains("comment"); + } + + return false; + }); + + // act + MatcherBuilder builder = new MatcherBuilder("text - comment", matchesName.Object); + + // assert + Assert.AreEqual(1, builder.Results.Count); + Assert.IsTrue(builder.Results[0].Matcher("match")); + Assert.IsFalse(builder.Results[0].Matcher("excluded")); + } + + #endregion Integration Tests + + #region Test Helpers + + private static Mock> CreateMockMatchesName(params string[] matchingIdentifiers) + { + Mock> mock = new Mock>(); + mock.Setup(m => m.Match(It.IsAny>(), It.IsAny())) + .Returns, string>((identifiers, _) => identifiers.Any(matchingIdentifiers.Contains)); + return mock; + } + + #endregion Test Helpers + } +} \ No newline at end of file diff --git a/src/TextMateSharp.Tests/Internal/Matcher/MatcherTests.cs b/src/TextMateSharp.Tests/Internal/Matcher/MatcherTests.cs index 0a6aa99..81cf8b3 100644 --- a/src/TextMateSharp.Tests/Internal/Matcher/MatcherTests.cs +++ b/src/TextMateSharp.Tests/Internal/Matcher/MatcherTests.cs @@ -1,11 +1,8 @@ -using System.Linq; -using NUnit.Framework; +using NUnit.Framework; using System.Collections.Generic; -using TextMateSharp.Internal.Matcher; - -namespace TextMateSharp.Tests.Internal.MatcherTest +namespace TextMateSharp.Tests.Internal.Matcher { [TestFixture] internal class MatcherTests @@ -37,7 +34,7 @@ internal class MatcherTests [TestCase("foo bar - (yo | man)", new string[] { "foo", "bar", "yo" }, false)] public void Matcher_Should_Work(string expression, string[] input, bool expectedResult) { - var matchers = Matcher.CreateMatchers(expression); + var matchers = TextMateSharp.Internal.Matcher.Matcher.CreateMatchers(expression); bool actualResult = false; foreach (var item in matchers) { diff --git a/src/TextMateSharp.Tests/Internal/Matcher/NameMatcherTests.cs b/src/TextMateSharp.Tests/Internal/Matcher/NameMatcherTests.cs new file mode 100644 index 0000000..f2a1d2c --- /dev/null +++ b/src/TextMateSharp.Tests/Internal/Matcher/NameMatcherTests.cs @@ -0,0 +1,515 @@ +using NUnit.Framework; +using System; +using System.Collections.Generic; +using TextMateSharp.Internal.Matcher; + +namespace TextMateSharp.Tests.Internal.Matcher +{ + [TestFixture] + public class NameMatcherTests + { + private NameMatcher _matcher; + + [SetUp] + public void SetUp() + { + _matcher = new NameMatcher(); + } + + #region Default Instance Tests + + [Test] + public void Default_Should_ReturnSingletonInstance() + { + // arrange & act + var instance1 = NameMatcher.Default; + var instance2 = NameMatcher.Default; + + // assert + Assert.IsNotNull(instance1); + Assert.AreSame(instance1, instance2); + } + + #endregion + + #region Null/Empty Tests + + [Test] + public void Match_NullIdentifiers_ThrowsArgumentNullException() + { + // arrange + List scopes = new List { "source.cs" }; + + // act & assert + Assert.Throws(() => _matcher.Match(null, scopes)); + } + + [Test] + public void Match_NullScopes_ThrowsArgumentNullException() + { + // arrange + ICollection identifiers = new List { "source" }; + + // act & assert + Assert.Throws(() => _matcher.Match(identifiers, null)); + } + + [Test] + public void Match_EmptyIdentifiers_ReturnsTrue() + { + // arrange + ICollection identifiers = new List(); + List scopes = new List { "source.cs" }; + + // act + bool result = _matcher.Match(identifiers, scopes); + + // assert + Assert.IsTrue(result); + } + + [Test] + public void Match_EmptyScopes_WithEmptyIdentifiers_ReturnsTrue() + { + // arrange + ICollection identifiers = new List(); + List scopes = new List(); + + // act + bool result = _matcher.Match(identifiers, scopes); + + // assert + Assert.IsTrue(result); + } + + [Test] + public void Match_EmptyScopes_WithNonEmptyIdentifiers_ReturnsFalse() + { + // arrange + ICollection identifiers = new List { "source" }; + List scopes = new List(); + + // act + bool result = _matcher.Match(identifiers, scopes); + + // assert + Assert.IsFalse(result); + } + + #endregion + + #region Exact Match Tests + + [Test] + public void Match_SingleIdentifier_ExactMatch_ReturnsTrue() + { + // arrange + ICollection identifiers = new List { "source.cs" }; + List scopes = new List { "source.cs" }; + + // act + bool result = _matcher.Match(identifiers, scopes); + + // assert + Assert.IsTrue(result); + } + + [Test] + public void Match_MultipleIdentifiers_AllExactMatch_ReturnsTrue() + { + // arrange + ICollection identifiers = new List { "source.cs", "meta.class" }; + List scopes = new List { "source.cs", "meta.class" }; + + // act + bool result = _matcher.Match(identifiers, scopes); + + // assert + Assert.IsTrue(result); + } + + [Test] + public void Match_SingleIdentifier_NoMatch_ReturnsFalse() + { + // arrange + ICollection identifiers = new List { "source.cs" }; + List scopes = new List { "source.java" }; + + // act + bool result = _matcher.Match(identifiers, scopes); + + // assert + Assert.IsFalse(result); + } + + #endregion + + #region Prefix Match Tests + + [Test] + public void Match_PrefixMatch_WithDot_ReturnsTrue() + { + // arrange + ICollection identifiers = new List { "source" }; + List scopes = new List { "source.cs" }; + + // act + bool result = _matcher.Match(identifiers, scopes); + + // assert + Assert.IsTrue(result); + } + + [Test] + public void Match_PrefixMatch_MultipleSegments_ReturnsTrue() + { + // arrange + ICollection identifiers = new List { "meta.class" }; + List scopes = new List { "meta.class.body.cs" }; + + // act + bool result = _matcher.Match(identifiers, scopes); + + // assert + Assert.IsTrue(result); + } + + [Test] + public void Match_PrefixMatch_WithoutDot_ReturnsFalse() + { + // arrange + ICollection identifiers = new List { "source" }; + List scopes = new List { "sourcecontrol" }; + + // act + bool result = _matcher.Match(identifiers, scopes); + + // assert + Assert.IsFalse(result); + } + + [Test] + public void Match_PartialPrefix_NoDot_ReturnsFalse() + { + // arrange + ICollection identifiers = new List { "sour" }; + List scopes = new List { "source.cs" }; + + // act + bool result = _matcher.Match(identifiers, scopes); + + // assert + Assert.IsFalse(result); + } + + #endregion + + #region Sequential Matching Tests + + [Test] + public void Match_SequentialIdentifiers_InOrder_ReturnsTrue() + { + // arrange + ICollection identifiers = new List { "source", "meta" }; + List scopes = new List { "source.cs", "meta.class" }; + + // act + bool result = _matcher.Match(identifiers, scopes); + + // assert + Assert.IsTrue(result); + } + + [Test] + public void Match_SequentialIdentifiers_WithGap_ReturnsTrue() + { + // arrange + ICollection identifiers = new List { "source", "meta.class" }; + List scopes = new List { "source.cs", "region.cs", "meta.class.body" }; + + // act + bool result = _matcher.Match(identifiers, scopes); + + // assert + Assert.IsTrue(result); + } + + [Test] + public void Match_SequentialIdentifiers_OutOfOrder_ReturnsFalse() + { + // arrange + ICollection identifiers = new List { "meta", "source" }; + List scopes = new List { "source.cs", "meta.class" }; + + // act + bool result = _matcher.Match(identifiers, scopes); + + // assert + Assert.IsFalse(result); + } + + [Test] + public void Match_ThreeIdentifiers_InOrder_ReturnsTrue() + { + // arrange + ICollection identifiers = new List { "source.cs", "meta.class", "entity.name" }; + List scopes = new List { "source.cs", "meta.class.body", "entity.name.type" }; + + // act + bool result = _matcher.Match(identifiers, scopes); + + // assert + Assert.IsTrue(result); + } + + [Test] + public void Match_IdentifierNotFound_ReturnsFalse() + { + // arrange + ICollection identifiers = new List { "source", "keyword" }; + List scopes = new List { "source.cs", "meta.class" }; + + // act + bool result = _matcher.Match(identifiers, scopes); + + // assert + Assert.IsFalse(result); + } + + #endregion + + #region Count Comparison Tests + + [Test] + public void Match_MoreIdentifiersThanScopes_ReturnsFalse() + { + // arrange + ICollection identifiers = new List { "source", "meta", "entity" }; + List scopes = new List { "source.cs", "meta.class" }; + + // act + bool result = _matcher.Match(identifiers, scopes); + + // assert + Assert.IsFalse(result); + } + + [Test] + public void Match_FewerIdentifiersThanScopes_ReturnsTrue() + { + // arrange + ICollection identifiers = new List { "source" }; + List scopes = new List { "source.cs", "meta.class", "entity.name" }; + + // act + bool result = _matcher.Match(identifiers, scopes); + + // assert + Assert.IsTrue(result); + } + + #endregion + + #region Null Scope Tests + + [Test] + public void Match_NullScopeInList_SkipsAndContinues() + { + // arrange + ICollection identifiers = new List { "meta" }; + List scopes = new List { "source.cs", null, "meta.class" }; + + // act + bool result = _matcher.Match(identifiers, scopes); + + // assert + Assert.IsTrue(result); + } + + [Test] + public void Match_AllNullScopes_ReturnsFalse() + { + // arrange + ICollection identifiers = new List { "source" }; + List scopes = new List { null, null }; + + // act + bool result = _matcher.Match(identifiers, scopes); + + // assert + Assert.IsFalse(result); + } + + #endregion + + #region Complex Real-World Scenarios + + [Test] + public void Match_CSharpClassDefinition_ReturnsTrue() + { + // arrange + ICollection identifiers = new List { "source.cs", "meta.class", "entity.name.type" }; + List scopes = new List + { + "source.cs", + "meta.class.cs", + "entity.name.type.class.cs" + }; + + // act + bool result = _matcher.Match(identifiers, scopes); + + // assert + Assert.IsTrue(result); + } + + [Test] + public void Match_JavaScriptFunctionCall_ReturnsTrue() + { + // arrange + ICollection identifiers = new List { "source.js", "meta.function-call" }; + List scopes = new List + { + "source.js", + "meta.function-call.js", + "entity.name.function" + }; + + // act + bool result = _matcher.Match(identifiers, scopes); + + // assert + Assert.IsTrue(result); + } + + [Test] + public void Match_HTMLWithEmbeddedCSS_ReturnsTrue() + { + // arrange + ICollection identifiers = new List { "text.html", "source.css" }; + List scopes = new List + { + "text.html.basic", + "source.css.embedded.html", + "meta.property-list.css" + }; + + // act + bool result = _matcher.Match(identifiers, scopes); + + // assert + Assert.IsTrue(result); + } + + [Test] + public void Match_DeepNestedScopes_ReturnsTrue() + { + // arrange + ICollection identifiers = new List { "source", "meta.block", "meta.function" }; + List scopes = new List + { + "source.cs", + "meta.block.cs", + "meta.method.cs", + "meta.function.body.cs", + "keyword.control.cs" + }; + + // act + bool result = _matcher.Match(identifiers, scopes); + + // assert + Assert.IsTrue(result); + } + + #endregion + + #region Edge Cases + + [Test] + public void Match_IdentifierMatchesLastScope_ReturnsTrue() + { + // arrange + ICollection identifiers = new List { "entity.name" }; + List scopes = new List { "source.cs", "meta.class", "entity.name.type" }; + + // act + bool result = _matcher.Match(identifiers, scopes); + + // assert + Assert.IsTrue(result); + } + + [Test] + public void Match_IdentifierMatchesFirstScope_ReturnsTrue() + { + // arrange + ICollection identifiers = new List { "source.cs" }; + List scopes = new List { "source.cs", "meta.class", "entity.name.type" }; + + // act + bool result = _matcher.Match(identifiers, scopes); + + // assert + Assert.IsTrue(result); + } + + [Test] + public void Match_DuplicateIdentifiers_ReturnsTrue() + { + // arrange + ICollection identifiers = new List { "meta", "meta" }; + List scopes = new List { "source.cs", "meta.class", "meta.method" }; + + // act + bool result = _matcher.Match(identifiers, scopes); + + // assert + Assert.IsTrue(result); + } + + [Test] + public void Match_DuplicateScopes_ReturnsTrue() + { + // arrange + ICollection identifiers = new List { "source", "source" }; + List scopes = new List { "source.cs", "source.cs" }; + + // act + bool result = _matcher.Match(identifiers, scopes); + + // assert + Assert.IsTrue(result); + } + + [Test] + public void Match_SingleCharacterIdentifier_ReturnsTrue() + { + // arrange + ICollection identifiers = new List { "a" }; + List scopes = new List { "a.b.c" }; + + // act + bool result = _matcher.Match(identifiers, scopes); + + // assert + Assert.IsTrue(result); + } + + [Test] + public void Match_VeryLongScopeName_ReturnsTrue() + { + // arrange + ICollection identifiers = new List { "source.cs.embedded.html.css.js" }; + List scopes = new List { "source.cs.embedded.html.css.js.meta.function" }; + + // act + bool result = _matcher.Match(identifiers, scopes); + + // assert + Assert.IsTrue(result); + } + + #endregion + } +} \ No newline at end of file From aa5ce9c5e8adbf753dc4d1f916f4080dd6a72fd8 Mon Sep 17 00:00:00 2001 From: Dave Black <656118+udlose@users.noreply.github.com> Date: Mon, 2 Mar 2026 11:03:30 -0600 Subject: [PATCH 18/33] Improve robustness and performance across matcher code Added null checks to constructors and methods for safety. Optimized list initializations with expected capacities. Refactored MatcherBuilder parsing logic for clarity and efficiency. Made utility methods static/private where appropriate. Improved tokenizer immutability and input validation. Enhanced RegExpSource and RegExpSourceList with more efficient iteration and allocation. Removed unnecessary usings and improved code readability. --- .../Internal/Matcher/IMatchesName.cs | 8 +- .../Internal/Matcher/MatcherBuilder.cs | 74 +++++++++++++++---- .../Internal/Rules/RegExpSource.cs | 28 +++---- .../Internal/Rules/RegExpSourceList.cs | 10 +-- 4 files changed, 84 insertions(+), 36 deletions(-) diff --git a/src/TextMateSharp/Internal/Matcher/IMatchesName.cs b/src/TextMateSharp/Internal/Matcher/IMatchesName.cs index c554b46..1c63aea 100644 --- a/src/TextMateSharp/Internal/Matcher/IMatchesName.cs +++ b/src/TextMateSharp/Internal/Matcher/IMatchesName.cs @@ -15,6 +15,9 @@ public class NameMatcher : IMatchesName> public bool Match(ICollection identifers, List scopes) { + if (identifers == null) throw new ArgumentNullException(nameof(identifers)); + if (scopes == null) throw new ArgumentNullException(nameof(scopes)); + if (scopes.Count < identifers.Count) { return false; @@ -36,7 +39,7 @@ public bool Match(ICollection identifers, List scopes) }); } - private bool ScopesAreMatching(string thisScopeName, string scopeName) + private static bool ScopesAreMatching(string thisScopeName, string scopeName) { if (thisScopeName == null) { @@ -47,8 +50,7 @@ private bool ScopesAreMatching(string thisScopeName, string scopeName) return true; } int len = scopeName.Length; - return thisScopeName.Length > len && thisScopeName.SubstringAtIndexes(0, len).Equals(scopeName) - && thisScopeName[len] == '.'; + return (thisScopeName.Length > len) && (thisScopeName[len] == '.') && thisScopeName.SubstringAtIndexes(0, len).Equals(scopeName); } } } \ No newline at end of file diff --git a/src/TextMateSharp/Internal/Matcher/MatcherBuilder.cs b/src/TextMateSharp/Internal/Matcher/MatcherBuilder.cs index ce25ff9..b53465c 100644 --- a/src/TextMateSharp/Internal/Matcher/MatcherBuilder.cs +++ b/src/TextMateSharp/Internal/Matcher/MatcherBuilder.cs @@ -7,12 +7,15 @@ namespace TextMateSharp.Internal.Matcher public class MatcherBuilder { public List> Results; - private Tokenizer _tokenizer; - private IMatchesName _matchesName; + private readonly Tokenizer _tokenizer; + private readonly IMatchesName _matchesName; private string _token; public MatcherBuilder(string expression, IMatchesName matchesName) { + if (expression == null) throw new ArgumentNullException(nameof(expression)); + if (matchesName == null) throw new ArgumentNullException(nameof(matchesName)); + this.Results = new List>(); this._tokenizer = new Tokenizer(expression); this._matchesName = matchesName; @@ -49,11 +52,22 @@ public MatcherBuilder(string expression, IMatchesName matchesName) private Predicate ParseInnerExpression() { + Predicate firstMatcher = ParseConjunction(); + if (firstMatcher == null) + { + return null; + } + + // Fast path: single conjunction, no OR separators. + if (!"|".Equals(_token) && !",".Equals(_token)) + { + return firstMatcher; + } + List> matchers = new List>(); - Predicate matcher = ParseConjunction(); - while (matcher != null) + matchers.Add(firstMatcher); + while (true) { - matchers.Add(matcher); if ("|".Equals(_token) || ",".Equals(_token)) { do @@ -66,14 +80,30 @@ private Predicate ParseInnerExpression() { break; } - matcher = ParseConjunction(); + + Predicate matcher = ParseConjunction(); + if (matcher == null) + { + break; + } + + matchers.Add(matcher); + if (!"|".Equals(_token) && !",".Equals(_token)) + { + break; + } + } + + if (matchers.Count == 1) + { + return matchers[0]; } // some (or) return matcherInput => { - foreach (Predicate matcher1 in matchers) + for (int i = 0; i < matchers.Count; i++) { - if (matcher1.Invoke(matcherInput)) + if (matchers[i].Invoke(matcherInput)) { return true; } @@ -84,7 +114,23 @@ private Predicate ParseInnerExpression() private Predicate ParseConjunction() { + Predicate firstMatcher = ParseOperand(); + if (firstMatcher == null) + { + return null; + } + + // Fast path: single operand, no AND chain. + Predicate secondMatcher = ParseOperand(); + if (secondMatcher == null) + { + return firstMatcher; + } + List> matchers = new List>(); + matchers.Add(firstMatcher); + matchers.Add(secondMatcher); + Predicate matcher = ParseOperand(); while (matcher != null) { @@ -94,9 +140,9 @@ private Predicate ParseConjunction() // every (and) return matcherInput => { - foreach (Predicate matcher1 in matchers) + for (int i = 0; i < matchers.Count; i++) { - if (!matcher1.Invoke(matcherInput)) + if (!matchers[i].Invoke(matcherInput)) { return false; } @@ -132,7 +178,7 @@ private Predicate ParseOperand() } if (IsIdentifier(_token)) { - ICollection identifiers = new List(); + List identifiers = new List(); do { identifiers.Add(_token); @@ -143,7 +189,7 @@ private Predicate ParseOperand() return null; } - private bool IsIdentifier(string token) + private static bool IsIdentifier(string token) { if (string.IsNullOrEmpty(token)) return false; @@ -172,12 +218,12 @@ class Tokenizer { private static Regex REGEXP = new Regex("([LR]:|[\\w\\.:][\\w\\.:\\-]*|[\\,\\|\\-\\(\\)])"); - private string _input; + private readonly string _input; Match _currentMatch; public Tokenizer(string input) { - _input = input; + _input = input ?? throw new ArgumentNullException(nameof(input)); } public string Next() diff --git a/src/TextMateSharp/Internal/Rules/RegExpSource.cs b/src/TextMateSharp/Internal/Rules/RegExpSource.cs index a3dee4a..6d63240 100644 --- a/src/TextMateSharp/Internal/Rules/RegExpSource.cs +++ b/src/TextMateSharp/Internal/Rules/RegExpSource.cs @@ -1,9 +1,8 @@ +using Onigwrap; using System; using System.Collections.Generic; using System.Text; using System.Text.RegularExpressions; -using Onigwrap; - using TextMateSharp.Internal.Utils; namespace TextMateSharp.Internal.Rules @@ -121,12 +120,13 @@ private void HandleAnchors(string regExpSource) public string ResolveBackReferences(ReadOnlyMemory lineText, IOnigCaptureIndex[] captureIndices) { - List capturedValues = new List(); + List capturedValues = new List(captureIndices.Length); try { - foreach (IOnigCaptureIndex captureIndex in captureIndices) + for (int i = 0; i < captureIndices.Length; i++) { + IOnigCaptureIndex captureIndex = captureIndices[i]; capturedValues.Add(lineText.SubstringAtIndexes( captureIndex.Start, captureIndex.End)); @@ -180,16 +180,16 @@ private string EscapeRegExpCharacters(string value) case '(': case ')': case '#': - /* escaping white space chars is actually not necessary: - case ' ': - case '\t': - case '\n': - case '\f': - case '\r': - case 0x0B: // vertical tab \v - */ - sb.Append('\\'); - break; + /* escaping white space chars is actually not necessary: + case ' ': + case '\t': + case '\n': + case '\f': + case '\r': + case 0x0B: // vertical tab \v + */ + sb.Append('\\'); + break; } sb.Append(ch); } diff --git a/src/TextMateSharp/Internal/Rules/RegExpSourceList.cs b/src/TextMateSharp/Internal/Rules/RegExpSourceList.cs index 2e6a042..3fc4b0d 100644 --- a/src/TextMateSharp/Internal/Rules/RegExpSourceList.cs +++ b/src/TextMateSharp/Internal/Rules/RegExpSourceList.cs @@ -1,5 +1,5 @@ -using System.Collections.Generic; using Onigwrap; +using System.Collections.Generic; namespace TextMateSharp.Internal.Rules { @@ -67,7 +67,7 @@ public CompiledRule Compile(bool allowA, bool allowG) { if (this._cached == null) { - List regexps = new List(); + List regexps = new List(_items.Count); foreach (RegExpSource regExpSource in _items) { regexps.Add(regExpSource.GetSource()); @@ -113,7 +113,7 @@ public CompiledRule Compile(bool allowA, bool allowG) private CompiledRule ResolveAnchors(bool allowA, bool allowG) { - List regexps = new List(); + List regexps = new List(_items.Count); foreach (RegExpSource regExpSource in _items) { regexps.Add(regExpSource.ResolveAnchors(allowA, allowG)); @@ -121,14 +121,14 @@ private CompiledRule ResolveAnchors(bool allowA, bool allowG) return new CompiledRule(CreateOnigScanner(regexps.ToArray()), GetRules()); } - private OnigScanner CreateOnigScanner(string[] regexps) + private static OnigScanner CreateOnigScanner(string[] regexps) { return new OnigScanner(regexps); } private IList GetRules() { - List ruleIds = new List(); + List ruleIds = new List(_items.Count); foreach (RegExpSource item in this._items) { ruleIds.Add(item.GetRuleId()); From 9e3543cbf5f19d5a0ba1fb0cfd328b8596165847 Mon Sep 17 00:00:00 2001 From: Dave Black <656118+udlose@users.noreply.github.com> Date: Mon, 2 Mar 2026 11:05:42 -0600 Subject: [PATCH 19/33] Fix scope matching logic in IMatchesName implementation Refactored identifier matching to use a foreach loop instead of LINQ's All, ensuring lastIndex is updated correctly after each match. This prevents re-scanning scopes and improves matching accuracy. Added comments to clarify the bug and the fix. --- .../Internal/Matcher/IMatchesName.cs | 23 ++++++++++++++----- 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/src/TextMateSharp/Internal/Matcher/IMatchesName.cs b/src/TextMateSharp/Internal/Matcher/IMatchesName.cs index 1c63aea..3f409bb 100644 --- a/src/TextMateSharp/Internal/Matcher/IMatchesName.cs +++ b/src/TextMateSharp/Internal/Matcher/IMatchesName.cs @@ -1,5 +1,5 @@ +using System; using System.Collections.Generic; -using System.Linq; using TextMateSharp.Internal.Utils; namespace TextMateSharp.Internal.Matcher @@ -24,19 +24,30 @@ public bool Match(ICollection identifers, List scopes) } int lastIndex = 0; - return identifers.All(identifier => + foreach (string identifier in identifers) { + bool found = false; for (int i = lastIndex; i < scopes.Count; i++) { if (ScopesAreMatching(scopes[i], identifier)) { - lastIndex++; - return true; + // BUG FIX: Original code used lastIndex++ which only incremented by 1 from + // the previous starting position, not from the actual match position. + // This caused the next search to potentially re-scan already checked scopes. + // Correct behavior: Start the next search immediately after the current match + lastIndex = i + 1; + found = true; + break; } } - return false; - }); + if (!found) + { + return false; + } + } + + return true; } private static bool ScopesAreMatching(string thisScopeName, string scopeName) From 3e538dd9b4627ab0cd2ccc726bc9b15efdf6a1d3 Mon Sep 17 00:00:00 2001 From: Dave Black <656118+udlose@users.noreply.github.com> Date: Mon, 2 Mar 2026 11:07:06 -0600 Subject: [PATCH 20/33] Optimize rule list allocation and fix resource naming Improved list initialization with expected capacity in theme rule handling for better performance. Fixed grammar package resource name construction in ResourceLoader. Minor formatting and whitespace adjustments for consistency. --- src/TextMateSharp.Grammars/Resources/ResourceLoader.cs | 5 ++--- src/TextMateSharp/Themes/ThemeTrieElement.cs | 6 +++--- src/TextMateSharp/Themes/ThemeTrieElementRule.cs | 2 +- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/src/TextMateSharp.Grammars/Resources/ResourceLoader.cs b/src/TextMateSharp.Grammars/Resources/ResourceLoader.cs index 494bf7b..944f6a3 100644 --- a/src/TextMateSharp.Grammars/Resources/ResourceLoader.cs +++ b/src/TextMateSharp.Grammars/Resources/ResourceLoader.cs @@ -1,5 +1,4 @@ -using System; -using System.IO; +using System.IO; using System.Reflection; namespace TextMateSharp.Grammars.Resources @@ -12,7 +11,7 @@ internal class ResourceLoader internal static Stream OpenGrammarPackage(string grammarName) { - string grammarPackage = GrammarPrefix + grammarName.ToLowerInvariant() + "." + "package.json"; + string grammarPackage = GrammarPrefix + grammarName.ToLowerInvariant() + ".package.json"; var result = typeof(ResourceLoader).GetTypeInfo().Assembly.GetManifestResourceStream( grammarPackage); diff --git a/src/TextMateSharp/Themes/ThemeTrieElement.cs b/src/TextMateSharp/Themes/ThemeTrieElement.cs index 9def467..268dd8d 100644 --- a/src/TextMateSharp/Themes/ThemeTrieElement.cs +++ b/src/TextMateSharp/Themes/ThemeTrieElement.cs @@ -74,7 +74,7 @@ public List Match(string scope) List arr; if ("".Equals(scope)) { - arr = new List(); + arr = new List(rulesWithParentScopes.Count + 1); arr.Add(this.mainRule); arr.AddRange(this.rulesWithParentScopes); return ThemeTrieElement.SortBySpecificity(arr); @@ -99,7 +99,7 @@ public List Match(string scope) return value.Match(tail); } - arr = new List(); + arr = new List(rulesWithParentScopes.Count + 1); if (this.mainRule.foreground > 0) arr.Add(this.mainRule); arr.AddRange(this.rulesWithParentScopes); @@ -161,7 +161,7 @@ private void DoInsertHere(string name, int scopeDepth, List parentScopes if (StringUtils.StrArrCmp(rule.parentScopes, parentScopes) == 0) { // bingo! => we get to merge this into an existing one - rule.AcceptOverwrite(rule.name, scopeDepth, fontStyle, foreground, background); + rule.AcceptOverwrite(rule.name, scopeDepth, fontStyle, foreground, background); return; } } diff --git a/src/TextMateSharp/Themes/ThemeTrieElementRule.cs b/src/TextMateSharp/Themes/ThemeTrieElementRule.cs index 4d3d36c..284a42b 100644 --- a/src/TextMateSharp/Themes/ThemeTrieElementRule.cs +++ b/src/TextMateSharp/Themes/ThemeTrieElementRule.cs @@ -33,7 +33,7 @@ public ThemeTrieElementRule Clone() public static List cloneArr(List arr) { - List r = new List(); + List r = new List(arr.Count); for (int i = 0, len = arr.Count; i < len; i++) { r.Add(arr[i].Clone()); From 34a167cda9099ec4231cc24f78a503a4d2e77125 Mon Sep 17 00:00:00 2001 From: Dave Black <656118+udlose@users.noreply.github.com> Date: Mon, 2 Mar 2026 11:12:44 -0600 Subject: [PATCH 21/33] fixed race condition in concurrency test. Use Volatile.Read for thread-safe test assertion Updated the test assertion in ParsedThemeTests to use Volatile.Read when checking totalCalls. This ensures thread-safe access to the variable in multi-threaded scenarios, improving reliability of the test. --- src/TextMateSharp.Tests/Themes/ParsedThemeTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/TextMateSharp.Tests/Themes/ParsedThemeTests.cs b/src/TextMateSharp.Tests/Themes/ParsedThemeTests.cs index c2be209..aeaf480 100644 --- a/src/TextMateSharp.Tests/Themes/ParsedThemeTests.cs +++ b/src/TextMateSharp.Tests/Themes/ParsedThemeTests.cs @@ -409,7 +409,7 @@ public void Match_ConcurrentAccessWithHeavyContention_ReturnsConsistentResults() // Assert const int expectedTotalCalls = threadCount * iterationsPerThread; - Assert.AreEqual(expectedTotalCalls, totalCalls, "All Match calls should have completed"); + Assert.AreEqual(expectedTotalCalls, Volatile.Read(ref totalCalls), "All Match calls should have completed"); Assert.AreEqual(expectedTotalCalls, allResults.Count, "Should have captured all results"); // All results must be non-null (critical for the fallback logic being tested) @@ -2575,7 +2575,7 @@ public void ParseTheme_CommaSeparatedScopesShareSameIndex() List rules = ParsedTheme.ParseTheme(rawTheme, 0); // Assert - Assert.AreEqual(2, rules.Count); + Assert.AreEqual(2, rules.Count, "Both rules from same entry should be created"); Assert.AreEqual(0, rules[0].index, "Both rules from same entry should share index 0"); Assert.AreEqual(0, rules[1].index, "Both rules from same entry should share index 0"); } From f023aa2f34e18a44787881ceb39122d0443916ef Mon Sep 17 00:00:00 2001 From: Dave Black <656118+udlose@users.noreply.github.com> Date: Mon, 2 Mar 2026 14:02:32 -0600 Subject: [PATCH 22/33] Remove unused 'priority' parameter from theme parsing Refactored ParsedTheme and Theme classes to eliminate the unused 'priority' parameter from ParseTheme, ParseInclude, and LookupThemeRules methods. Updated all method calls and related test cases accordingly. This cleanup simplifies method signatures and removes dead code. --- .../Themes/ParsedThemeTests.cs | 121 +++++++++--------- src/TextMateSharp/Themes/Theme.cs | 16 +-- 2 files changed, 64 insertions(+), 73 deletions(-) diff --git a/src/TextMateSharp.Tests/Themes/ParsedThemeTests.cs b/src/TextMateSharp.Tests/Themes/ParsedThemeTests.cs index aeaf480..fad92b4 100644 --- a/src/TextMateSharp.Tests/Themes/ParsedThemeTests.cs +++ b/src/TextMateSharp.Tests/Themes/ParsedThemeTests.cs @@ -24,10 +24,9 @@ public void ParseInclude_SourceGetIncludeReturnsNull_ReturnsEmptyListAndSetsThem Mock mockSource = new Mock(); mockSource.Setup(s => s.GetInclude()).Returns((string)null); Mock mockRegistryOptions = new Mock(); - const int priority = 5; // Act - List result = ParsedTheme.ParseInclude(mockSource.Object, mockRegistryOptions.Object, priority, out IRawTheme themeInclude); + List result = ParsedTheme.ParseInclude(mockSource.Object, mockRegistryOptions.Object, out IRawTheme themeInclude); // Assert Assert.IsNotNull(result); @@ -43,10 +42,9 @@ public void ParseInclude_SourceGetIncludeReturnsEmpty_ReturnsEmptyListAndSetsThe Mock mockSource = new Mock(); mockSource.Setup(s => s.GetInclude()).Returns(string.Empty); Mock mockRegistryOptions = new Mock(); - const int priority = 10; // Act - List result = ParsedTheme.ParseInclude(mockSource.Object, mockRegistryOptions.Object, priority, out IRawTheme themeInclude); + List result = ParsedTheme.ParseInclude(mockSource.Object, mockRegistryOptions.Object, out IRawTheme themeInclude); // Assert Assert.IsNotNull(result); @@ -64,10 +62,9 @@ public void ParseInclude_GetThemeReturnsNull_ReturnsEmptyList() mockSource.Setup(s => s.GetInclude()).Returns(includeString); Mock mockRegistryOptions = new Mock(); mockRegistryOptions.Setup(r => r.GetTheme(includeString)).Returns((IRawTheme)null); - const int priority = 0; // Act - List result = ParsedTheme.ParseInclude(mockSource.Object, mockRegistryOptions.Object, priority, out IRawTheme themeInclude); + List result = ParsedTheme.ParseInclude(mockSource.Object, mockRegistryOptions.Object, out IRawTheme themeInclude); // Assert Assert.IsNotNull(result); @@ -90,10 +87,9 @@ public void ParseInclude_ValidIncludeAndTheme_ReturnsParseThemeResult() Mock mockRegistryOptions = new Mock(); mockRegistryOptions.Setup(r => r.GetTheme(includeString)).Returns(mockIncludedTheme.Object); - const int priority = 1; // Act - List result = ParsedTheme.ParseInclude(mockSource.Object, mockRegistryOptions.Object, priority, out IRawTheme themeInclude); + List result = ParsedTheme.ParseInclude(mockSource.Object, mockRegistryOptions.Object, out IRawTheme themeInclude); // Assert Assert.IsNotNull(result); @@ -141,7 +137,6 @@ public void ParseInclude_VariousPriorityValues_PassesPriorityToParseTheme(int pr List result = ParsedTheme.ParseInclude( mockSource.Object, mockRegistryOptions.Object, - priority, out IRawTheme themeInclude); // Assert @@ -169,10 +164,9 @@ public void ParseInclude_SourceGetIncludeReturnsWhitespace_ReturnsEmptyListAndSe mockSource.Setup(s => s.GetInclude()).Returns(whitespace); Mock mockRegistryOptions = new Mock(); mockRegistryOptions.Setup(r => r.GetTheme(whitespace)).Returns((IRawTheme)null); - const int priority = 0; // Act - List result = ParsedTheme.ParseInclude(mockSource.Object, mockRegistryOptions.Object, priority, out IRawTheme themeInclude); + List result = ParsedTheme.ParseInclude(mockSource.Object, mockRegistryOptions.Object, out IRawTheme themeInclude); // Assert Assert.IsNotNull(result); @@ -200,10 +194,9 @@ public void ParseInclude_VariousIncludeStringFormats_PassesCorrectlyToGetTheme(s Mock mockRegistryOptions = new Mock(); mockRegistryOptions.Setup(r => r.GetTheme(includeString)).Returns(mockIncludedTheme.Object); - const int priority = 0; // Act - List result = ParsedTheme.ParseInclude(mockSource.Object, mockRegistryOptions.Object, priority, out IRawTheme themeInclude); + List result = ParsedTheme.ParseInclude(mockSource.Object, mockRegistryOptions.Object, out IRawTheme themeInclude); // Assert Assert.IsNotNull(result); @@ -1189,7 +1182,7 @@ public void ParseTheme_FontStyleEmpty_ReturnsFontStyleNone() }; // Act - List rules = ParsedTheme.ParseTheme(rawTheme, 0); + List rules = ParsedTheme.ParseTheme(rawTheme); // Assert Assert.AreEqual(1, rules.Count); @@ -1217,7 +1210,7 @@ public void ParseTheme_FontStyleItalic_ReturnsFontStyleItalic() }; // Act - List rules = ParsedTheme.ParseTheme(rawTheme, 0); + List rules = ParsedTheme.ParseTheme(rawTheme); // Assert Assert.AreEqual(1, rules.Count); @@ -1245,7 +1238,7 @@ public void ParseTheme_FontStyleBold_ReturnsFontStyleBold() }; // Act - List rules = ParsedTheme.ParseTheme(rawTheme, 0); + List rules = ParsedTheme.ParseTheme(rawTheme); // Assert Assert.AreEqual(1, rules.Count); @@ -1273,7 +1266,7 @@ public void ParseTheme_FontStyleUnderline_ReturnsFontStyleUnderline() }; // Act - List rules = ParsedTheme.ParseTheme(rawTheme, 0); + List rules = ParsedTheme.ParseTheme(rawTheme); // Assert Assert.AreEqual(1, rules.Count); @@ -1301,7 +1294,7 @@ public void ParseTheme_FontStyleStrikethrough_ReturnsFontStyleStrikethrough() }; // Act - List rules = ParsedTheme.ParseTheme(rawTheme, 0); + List rules = ParsedTheme.ParseTheme(rawTheme); // Assert Assert.AreEqual(1, rules.Count); @@ -1329,7 +1322,7 @@ public void ParseTheme_FontStyleItalicBold_CombinesFlags() }; // Act - List rules = ParsedTheme.ParseTheme(rawTheme, 0); + List rules = ParsedTheme.ParseTheme(rawTheme); // Assert Assert.AreEqual(1, rules.Count); @@ -1357,7 +1350,7 @@ public void ParseTheme_FontStyleAllCombined_CombinesAllFlags() }; // Act - List rules = ParsedTheme.ParseTheme(rawTheme, 0); + List rules = ParsedTheme.ParseTheme(rawTheme); // Assert Assert.AreEqual(1, rules.Count); @@ -1387,7 +1380,7 @@ public void ParseTheme_FontStyleWithExtraSpaces_ParsesCorrectly() }; // Act - List rules = ParsedTheme.ParseTheme(rawTheme, 0); + List rules = ParsedTheme.ParseTheme(rawTheme); // Assert Assert.AreEqual(1, rules.Count); @@ -1418,7 +1411,7 @@ public void ParseTheme_FontStyleWithLeadingSpace_ParsesCorrectly() }; // Act - List rules = ParsedTheme.ParseTheme(rawTheme, 0); + List rules = ParsedTheme.ParseTheme(rawTheme); // Assert Assert.AreEqual(1, rules.Count); @@ -1449,7 +1442,7 @@ public void ParseTheme_FontStyleWithTrailingSpace_ParsesCorrectly() }; // Act - List rules = ParsedTheme.ParseTheme(rawTheme, 0); + List rules = ParsedTheme.ParseTheme(rawTheme); // Assert Assert.AreEqual(1, rules.Count); @@ -1480,7 +1473,7 @@ public void ParseTheme_FontStyleUnknownKeyword_IgnoresUnknown() }; // Act - List rules = ParsedTheme.ParseTheme(rawTheme, 0); + List rules = ParsedTheme.ParseTheme(rawTheme); // Assert Assert.AreEqual(1, rules.Count); @@ -1514,7 +1507,7 @@ public void ParseTheme_FontStyleCaseSensitive_RequiresLowercase(string fontStyle }; // Act - List rules = ParsedTheme.ParseTheme(rawTheme, 0); + List rules = ParsedTheme.ParseTheme(rawTheme); // Assert Assert.AreEqual(1, rules.Count); @@ -1542,7 +1535,7 @@ public void ParseTheme_FontStylePartialMatch_DoesNotMatch() }; // Act - List rules = ParsedTheme.ParseTheme(rawTheme, 0); + List rules = ParsedTheme.ParseTheme(rawTheme); // Assert Assert.AreEqual(1, rules.Count); @@ -1570,7 +1563,7 @@ public void ParseTheme_FontStyleDuplicateKeywords_AppliesOnce() }; // Act - List rules = ParsedTheme.ParseTheme(rawTheme, 0); + List rules = ParsedTheme.ParseTheme(rawTheme); // Assert Assert.AreEqual(1, rules.Count); @@ -1601,7 +1594,7 @@ public void ParseTheme_FontStyleOnlySpaces_ReturnsFontStyleNone() }; // Act - List rules = ParsedTheme.ParseTheme(rawTheme, 0); + List rules = ParsedTheme.ParseTheme(rawTheme); // Assert Assert.AreEqual(1, rules.Count); @@ -1629,7 +1622,7 @@ public void ParseTheme_FontStyleSingleSpace_ReturnsFontStyleNone() }; // Act - List rules = ParsedTheme.ParseTheme(rawTheme, 0); + List rules = ParsedTheme.ParseTheme(rawTheme); // Assert Assert.AreEqual(1, rules.Count); @@ -1660,7 +1653,7 @@ public void ParseTheme_FontStyleVeryLongString_ParsesCorrectly() }; // Act - List rules = ParsedTheme.ParseTheme(rawTheme, 0); + List rules = ParsedTheme.ParseTheme(rawTheme); // Assert Assert.AreEqual(1, rules.Count); @@ -1691,7 +1684,7 @@ public void ParseTheme_FontStyleMixedValidAndInvalid_ParsesOnlyValid() }; // Act - List rules = ParsedTheme.ParseTheme(rawTheme, 0); + List rules = ParsedTheme.ParseTheme(rawTheme); // Assert Assert.AreEqual(1, rules.Count); @@ -1740,7 +1733,7 @@ public void ParseTheme_MultipleScopesWithDifferentFontStyles_ParsesEachCorrectly }; // Act - List rules = ParsedTheme.ParseTheme(rawTheme, 0); + List rules = ParsedTheme.ParseTheme(rawTheme); // Assert Assert.AreEqual(3, rules.Count); @@ -1772,7 +1765,7 @@ public void ParseTheme_SingleSegmentScope_ReturnsNullParentScopes() }; // Act - List rules = ParsedTheme.ParseTheme(rawTheme, 0); + List rules = ParsedTheme.ParseTheme(rawTheme); // Assert Assert.AreEqual(1, rules.Count); @@ -1800,7 +1793,7 @@ public void ParseTheme_TwoSegmentScope_ExtractsLastAsScope() }; // Act - List rules = ParsedTheme.ParseTheme(rawTheme, 0); + List rules = ParsedTheme.ParseTheme(rawTheme); // Assert Assert.AreEqual(1, rules.Count); @@ -1830,7 +1823,7 @@ public void ParseTheme_ThreeSegmentScope_ExtractsInReverseOrder() }; // Act - List rules = ParsedTheme.ParseTheme(rawTheme, 0); + List rules = ParsedTheme.ParseTheme(rawTheme); // Assert Assert.AreEqual(1, rules.Count); @@ -1861,7 +1854,7 @@ public void ParseTheme_FourSegmentScope_AllSegmentsReversed() }; // Act - List rules = ParsedTheme.ParseTheme(rawTheme, 0); + List rules = ParsedTheme.ParseTheme(rawTheme); // Assert Assert.AreEqual(1, rules.Count); @@ -1896,7 +1889,7 @@ public void ParseTheme_ManySegmentScope_HandlesLargeCount() }; // Act - List rules = ParsedTheme.ParseTheme(rawTheme, 0); + List rules = ParsedTheme.ParseTheme(rawTheme); // Assert Assert.AreEqual(1, rules.Count); @@ -1930,7 +1923,7 @@ public void ParseTheme_ScopeWithConsecutiveSpaces_CreatesEmptySegmentsBetween() }; // Act - List rules = ParsedTheme.ParseTheme(rawTheme, 0); + List rules = ParsedTheme.ParseTheme(rawTheme); // Assert Assert.AreEqual(1, rules.Count); @@ -1961,7 +1954,7 @@ public void ParseTheme_ScopeWithSpecialCharacters_PreservesCharacters() }; // Act - List rules = ParsedTheme.ParseTheme(rawTheme, 0); + List rules = ParsedTheme.ParseTheme(rawTheme); // Assert Assert.AreEqual(1, rules.Count); @@ -1997,7 +1990,7 @@ public void ParseTheme_VeryLongScopeString_HandlesWithoutIssue() }; // Act - List rules = ParsedTheme.ParseTheme(rawTheme, 0); + List rules = ParsedTheme.ParseTheme(rawTheme); // Assert Assert.AreEqual(1, rules.Count); @@ -2025,7 +2018,7 @@ public void ParseTheme_ScopeWithMixedSegmentLengths_HandlesCorrectly() }; // Act - List rules = ParsedTheme.ParseTheme(rawTheme, 0); + List rules = ParsedTheme.ParseTheme(rawTheme); // Assert Assert.AreEqual(1, rules.Count); @@ -2054,7 +2047,7 @@ public void ParseTheme_ScopeWithNumericSegments_ParsesCorrectly() }; // Act - List rules = ParsedTheme.ParseTheme(rawTheme, 0); + List rules = ParsedTheme.ParseTheme(rawTheme); // Assert Assert.AreEqual(1, rules.Count); @@ -2089,7 +2082,7 @@ public void ParseTheme_MultipleScopesWithDifferentSegments_ParsesEachCorrectly() }; // Act - List rules = ParsedTheme.ParseTheme(rawTheme, 0); + List rules = ParsedTheme.ParseTheme(rawTheme); // Assert Assert.AreEqual(3, rules.Count); @@ -2129,7 +2122,7 @@ public void ParseTheme_ScopeWithUnicodeCharacters_PreservesUnicode() }; // Act - List rules = ParsedTheme.ParseTheme(rawTheme, 0); + List rules = ParsedTheme.ParseTheme(rawTheme); // Assert Assert.AreEqual(1, rules.Count); @@ -2152,7 +2145,7 @@ public void ParseTheme_NullSettings_ReturnsEmptyList() mockTheme.Setup(t => t.GetTokenColors()).Returns((List)null); // Act - List rules = ParsedTheme.ParseTheme(mockTheme.Object, 0); + List rules = ParsedTheme.ParseTheme(mockTheme.Object); // Assert Assert.IsNotNull(rules); @@ -2180,7 +2173,7 @@ public void ParseTheme_EntryWithNullSettings_SkipsEntry() }; // Act - List rules = ParsedTheme.ParseTheme(rawTheme, 0); + List rules = ParsedTheme.ParseTheme(rawTheme); // Assert Assert.AreEqual(1, rules.Count, "Should skip entry with null settings"); @@ -2207,7 +2200,7 @@ public void ParseTheme_CommaSeparatedScopes_CreatesMultipleRules() }; // Act - List rules = ParsedTheme.ParseTheme(rawTheme, 0); + List rules = ParsedTheme.ParseTheme(rawTheme); // Assert Assert.AreEqual(3, rules.Count); @@ -2238,7 +2231,7 @@ public void ParseTheme_CommaSeparatedScopesWithSpaces_TrimsEachScope() }; // Act - List rules = ParsedTheme.ParseTheme(rawTheme, 0); + List rules = ParsedTheme.ParseTheme(rawTheme); // Assert Assert.AreEqual(3, rules.Count); @@ -2266,7 +2259,7 @@ public void ParseTheme_OnlyCommas_CreatesNoRules() }; // Act - List rules = ParsedTheme.ParseTheme(rawTheme, 0); + List rules = ParsedTheme.ParseTheme(rawTheme); // Assert CollectionAssert.IsEmpty(rules, "Only commas should produce no rules (empty after trim)"); @@ -2291,7 +2284,7 @@ public void ParseTheme_LeadingTrailingCommas_IgnoresEmptySegments() }; // Act - List rules = ParsedTheme.ParseTheme(rawTheme, 0); + List rules = ParsedTheme.ParseTheme(rawTheme); // Assert Assert.AreEqual(2, rules.Count, "Leading/trailing commas should be trimmed"); @@ -2318,7 +2311,7 @@ public void ParseTheme_ConsecutiveCommas_CreatesOnlyNonEmptyScopes() }; // Act - List rules = ParsedTheme.ParseTheme(rawTheme, 0); + List rules = ParsedTheme.ParseTheme(rawTheme); // Assert Assert.AreEqual(2, rules.Count, "Empty segments between commas should be skipped"); @@ -2341,7 +2334,7 @@ public void ParseTheme_ScopeAsListOfStrings_CreatesRuleForEach() }; // Act - List rules = ParsedTheme.ParseTheme(rawTheme, 0); + List rules = ParsedTheme.ParseTheme(rawTheme); // Assert Assert.AreEqual(3, rules.Count); @@ -2365,7 +2358,7 @@ public void ParseTheme_ScopeAsEmptyList_CreatesNoRules() }; // Act - List rules = ParsedTheme.ParseTheme(rawTheme, 0); + List rules = ParsedTheme.ParseTheme(rawTheme); // Assert CollectionAssert.IsEmpty(rules, "Empty scope list should produce no rules"); @@ -2385,7 +2378,7 @@ public void ParseTheme_ScopeAsNullOrOtherType_CreatesRuleWithEmptyScope() }; // Act - List rules = ParsedTheme.ParseTheme(rawTheme, 0); + List rules = ParsedTheme.ParseTheme(rawTheme); // Assert Assert.AreEqual(1, rules.Count); @@ -2411,7 +2404,7 @@ public void ParseTheme_InvalidForegroundColor_SetsNullForeground() }; // Act - List rules = ParsedTheme.ParseTheme(rawTheme, 0); + List rules = ParsedTheme.ParseTheme(rawTheme); // Assert Assert.AreEqual(1, rules.Count); @@ -2437,7 +2430,7 @@ public void ParseTheme_InvalidBackgroundColor_SetsNullBackground() }; // Act - List rules = ParsedTheme.ParseTheme(rawTheme, 0); + List rules = ParsedTheme.ParseTheme(rawTheme); // Assert Assert.AreEqual(1, rules.Count); @@ -2462,7 +2455,7 @@ public void ParseTheme_MissingForeground_SetsNullForeground() }; // Act - List rules = ParsedTheme.ParseTheme(rawTheme, 0); + List rules = ParsedTheme.ParseTheme(rawTheme); // Assert Assert.AreEqual(1, rules.Count); @@ -2487,7 +2480,7 @@ public void ParseTheme_MissingBackground_SetsNullBackground() }; // Act - List rules = ParsedTheme.ParseTheme(rawTheme, 0); + List rules = ParsedTheme.ParseTheme(rawTheme); // Assert Assert.AreEqual(1, rules.Count); @@ -2512,7 +2505,7 @@ public void ParseTheme_NonStringFontStyle_SetsFontStyleNotSet() }; // Act - List rules = ParsedTheme.ParseTheme(rawTheme, 0); + List rules = ParsedTheme.ParseTheme(rawTheme); // Assert Assert.AreEqual(1, rules.Count); @@ -2546,7 +2539,7 @@ public void ParseTheme_RuleIndexIncrementsForEachEntry() }; // Act - List rules = ParsedTheme.ParseTheme(rawTheme, 0); + List rules = ParsedTheme.ParseTheme(rawTheme); // Assert Assert.AreEqual(3, rules.Count); @@ -2572,7 +2565,7 @@ public void ParseTheme_CommaSeparatedScopesShareSameIndex() }; // Act - List rules = ParsedTheme.ParseTheme(rawTheme, 0); + List rules = ParsedTheme.ParseTheme(rawTheme); // Assert Assert.AreEqual(2, rules.Count, "Both rules from same entry should be created"); @@ -2597,7 +2590,7 @@ public void ParseTheme_GetNamePreserved_SetsNameOnRule() }; // Act - List rules = ParsedTheme.ParseTheme(rawTheme, 0); + List rules = ParsedTheme.ParseTheme(rawTheme); // Assert Assert.AreEqual(1, rules.Count); @@ -2632,7 +2625,7 @@ public void ParseTheme_BothSettingsAndTokenColors_ProcessesBoth() mockTheme.Setup(t => t.GetTokenColors()).Returns(tokenColors); // Act - List rules = ParsedTheme.ParseTheme(mockTheme.Object, 0); + List rules = ParsedTheme.ParseTheme(mockTheme.Object); // Assert Assert.AreEqual(2, rules.Count); @@ -2664,7 +2657,7 @@ public void ParseTheme_ValidHexColors_PreservesColors() }; // Act - List rules = ParsedTheme.ParseTheme(rawTheme, 0); + List rules = ParsedTheme.ParseTheme(rawTheme); // Assert Assert.AreEqual(1, rules.Count); diff --git a/src/TextMateSharp/Themes/Theme.cs b/src/TextMateSharp/Themes/Theme.cs index d5ef2a9..338d0b7 100644 --- a/src/TextMateSharp/Themes/Theme.cs +++ b/src/TextMateSharp/Themes/Theme.cs @@ -23,7 +23,7 @@ public static Theme CreateFromRawTheme( ColorMap colorMap = new ColorMap(); var guiColorsDictionary = new Dictionary(); - var themeRuleList = ParsedTheme.ParseTheme(source, 0); + var themeRuleList = ParsedTheme.ParseTheme(source); ParsedTheme theme = ParsedTheme.CreateFromParsedTheme( themeRuleList, @@ -31,7 +31,7 @@ public static Theme CreateFromRawTheme( IRawTheme themeInclude; ParsedTheme include = ParsedTheme.CreateFromParsedTheme( - ParsedTheme.ParseInclude(source, registryOptions, 0, out themeInclude), + ParsedTheme.ParseInclude(source, registryOptions, out themeInclude), colorMap); // First get colors from include, then try and overwrite with local colors.. @@ -122,17 +122,17 @@ class ParsedTheme return a.index.CompareTo(b.index); }; - internal static List ParseTheme(IRawTheme source, int priority) + internal static List ParseTheme(IRawTheme source) { List result = new List(); // process theme rules in vscode-textmate format: // see https://github.com/microsoft/vscode-textmate/tree/main/test-cases/themes - LookupThemeRules(source.GetSettings(), result, priority); + LookupThemeRules(source.GetSettings(), result); // process theme rules in vscode format // see https://github.com/microsoft/vscode/tree/main/extensions/theme-defaults/themes - LookupThemeRules(source.GetTokenColors(), result, priority); + LookupThemeRules(source.GetTokenColors(), result); return result; } @@ -154,7 +154,6 @@ internal static void ParsedGuiColors(IRawTheme source, Dictionary ParseInclude( IRawTheme source, IRegistryOptions registryOptions, - int priority, out IRawTheme themeInclude) { string include = source.GetInclude(); @@ -170,13 +169,12 @@ internal static List ParseInclude( if (themeInclude == null) return new List(); - return ParseTheme(themeInclude, priority); + return ParseTheme(themeInclude); } static void LookupThemeRules( ICollection settings, - List parsedThemeRules, - int priority) // TODO: @danipen, 'priority' is currently unused. Is that intentional or is this a missing piece of functionality? + List parsedThemeRules) { if (settings == null) return; From 6423dc5aef2b77866622e2e96d90678b174a74c1 Mon Sep 17 00:00:00 2001 From: Dave Black <656118+udlose@users.noreply.github.com> Date: Mon, 2 Mar 2026 14:03:13 -0600 Subject: [PATCH 23/33] PR review feedback (remove extraneous comment) --- src/TextMateSharp/Internal/Matcher/IMatchesName.cs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/TextMateSharp/Internal/Matcher/IMatchesName.cs b/src/TextMateSharp/Internal/Matcher/IMatchesName.cs index 3f409bb..3c36bfb 100644 --- a/src/TextMateSharp/Internal/Matcher/IMatchesName.cs +++ b/src/TextMateSharp/Internal/Matcher/IMatchesName.cs @@ -31,10 +31,7 @@ public bool Match(ICollection identifers, List scopes) { if (ScopesAreMatching(scopes[i], identifier)) { - // BUG FIX: Original code used lastIndex++ which only incremented by 1 from - // the previous starting position, not from the actual match position. - // This caused the next search to potentially re-scan already checked scopes. - // Correct behavior: Start the next search immediately after the current match + // Start the next search immediately after the current match lastIndex = i + 1; found = true; break; From 467acdedbc6c133f43dcd16b406216ed88c4bb34 Mon Sep 17 00:00:00 2001 From: Dave Black <656118+udlose@users.noreply.github.com> Date: Mon, 2 Mar 2026 14:05:54 -0600 Subject: [PATCH 24/33] Update ParseTheme call to match new method signature Removed the unused second argument (0) from the ParsedTheme.ParseTheme call in ThemeParsingTest.cs, reflecting the updated method signature that now only requires the theme parameter. --- src/TextMateSharp.Tests/Internal/Themes/ThemeParsingTest.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/TextMateSharp.Tests/Internal/Themes/ThemeParsingTest.cs b/src/TextMateSharp.Tests/Internal/Themes/ThemeParsingTest.cs index af0a08f..f1d2338 100644 --- a/src/TextMateSharp.Tests/Internal/Themes/ThemeParsingTest.cs +++ b/src/TextMateSharp.Tests/Internal/Themes/ThemeParsingTest.cs @@ -23,7 +23,7 @@ public void Parse_Theme_Rule_Should_Work() StreamReader reader = new StreamReader(memoryStream); var theme = ThemeReader.ReadThemeSync(reader); - var actualThemeRules = ParsedTheme.ParseTheme(theme, 0); + var actualThemeRules = ParsedTheme.ParseTheme(theme); var expectedThemeRules = new ParsedThemeRule[] { new ParsedThemeRule("", "", null, 0, FontStyle.NotSet, "#F8F8F2", "#272822"), From da8eab443ca601046937e90508791e19ba9c4913 Mon Sep 17 00:00:00 2001 From: Dave Black <656118+udlose@users.noreply.github.com> Date: Wed, 4 Mar 2026 09:11:09 -0600 Subject: [PATCH 25/33] Expand and modernize AttributedScopeStack unit tests, and remove unit tests that could produce hash collisions for inequal objects - Add comprehensive IEquatable and operator ==/!= tests - Remove hash code difference tests for similar content - Use C# collection expressions for cleaner initialization - Refactor array usage in method invocations - Move reflection helper for private Equals to end of file - Improve test naming and coverage; no production code changes --- .../Grammar/LineTextTests.cs | 36 +- .../Grammars/AttributedScopeStackTests.cs | 310 +++++++++++++----- 2 files changed, 240 insertions(+), 106 deletions(-) diff --git a/src/TextMateSharp.Tests/Grammar/LineTextTests.cs b/src/TextMateSharp.Tests/Grammar/LineTextTests.cs index 6f30529..6ff0a59 100644 --- a/src/TextMateSharp.Tests/Grammar/LineTextTests.cs +++ b/src/TextMateSharp.Tests/Grammar/LineTextTests.cs @@ -1,7 +1,5 @@ -using System; - using NUnit.Framework; - +using System; using TextMateSharp.Grammars; namespace TextMateSharp.Tests.Grammar @@ -351,15 +349,6 @@ public void GetHashCode_SameContent_ShouldReturnSameHash() Assert.AreEqual(lineText1.GetHashCode(), lineText2.GetHashCode()); } - [Test] - public void GetHashCode_DifferentContent_ShouldReturnDifferentHash() - { - LineText lineText1 = "hello"; - LineText lineText2 = "world"; - - Assert.AreNotEqual(lineText1.GetHashCode(), lineText2.GetHashCode()); - } - [Test] public void GetHashCode_EmptyLineText_ShouldReturnZero() { @@ -390,10 +379,12 @@ public void GetHashCode_SameInstance_ShouldBeConsistent() } [Test] - public void GetHashCode_DifferentArraysSameContent_ShouldReturnSameHash() + public void GetHashCode_DifferentStringInstances_SameContent_ShouldReturnSameHash() { + // avoid declaring these as const and use this pattern to dodge string interning + // which would make them reference the same object and not test the content-based hash code properly string buffer1 = "hello"; - string buffer2 = "hello"; + string buffer2 = new string("hello".ToCharArray()); LineText lineText1 = buffer1.AsMemory(); LineText lineText2 = buffer2.AsMemory(); @@ -423,27 +414,12 @@ public void GetHashCode_UnicodeContent_ShouldWork() } [Test] - public void GetHashCode_SimilarStrings_ShouldProduceDifferentHashes() - { - // These are similar but should have different hashes - LineText lineText1 = "abc"; - LineText lineText2 = "abd"; - LineText lineText3 = "bbc"; - - Assert.AreNotEqual(lineText1.GetHashCode(), lineText2.GetHashCode()); - Assert.AreNotEqual(lineText1.GetHashCode(), lineText3.GetHashCode()); - Assert.AreNotEqual(lineText2.GetHashCode(), lineText3.GetHashCode()); - } - - [Test] - public void GetHashCode_SingleCharacter_ShouldWork() + public void GetHashCode_SingleCharacter_WithSameContent_ShouldWork() { LineText lineText1 = "a"; LineText lineText2 = "a"; - LineText lineText3 = "b"; Assert.AreEqual(lineText1.GetHashCode(), lineText2.GetHashCode()); - Assert.AreNotEqual(lineText1.GetHashCode(), lineText3.GetHashCode()); } #endregion diff --git a/src/TextMateSharp.Tests/Internal/Grammars/AttributedScopeStackTests.cs b/src/TextMateSharp.Tests/Internal/Grammars/AttributedScopeStackTests.cs index 472a837..2d5b8f8 100644 --- a/src/TextMateSharp.Tests/Internal/Grammars/AttributedScopeStackTests.cs +++ b/src/TextMateSharp.Tests/Internal/Grammars/AttributedScopeStackTests.cs @@ -391,7 +391,7 @@ public void Equals_PrivateStatic_FirstArgumentNull_ReturnsFalse() AttributedScopeStack b = new AttributedScopeStack(null, "x", 1); // act - object result = equalsMethod.Invoke(null, new object[] { null, b }); + object result = equalsMethod.Invoke(null, [null, b]); // assert Assert.NotNull(result); @@ -406,7 +406,7 @@ public void Equals_PrivateStatic_SecondArgumentNull_ReturnsFalse() AttributedScopeStack a = new AttributedScopeStack(null, "x", 1); // act - object result = equalsMethod.Invoke(null, new object[] { a, null }); + object result = equalsMethod.Invoke(null, [a, null]); // assert Assert.NotNull(result); @@ -420,31 +420,13 @@ public void Equals_PrivateStatic_BothArgumentsNull_ReturnsTrue() MethodInfo equalsMethod = GetPrivateStaticEqualsMethod(); // act - object result = equalsMethod.Invoke(null, new object[] { null, null }); + object result = equalsMethod.Invoke(null, [null, null]); // assert Assert.NotNull(result); Assert.IsTrue((bool)result); } - // Normal call paths cannot hit the branches of the static Equals impl, so I'm adding this reflection-based - // helper to help improve the test coverage and cover the branches that cannot otherwise be executed. - private static MethodInfo GetPrivateStaticEqualsMethod() - { - Type type = typeof(AttributedScopeStack); - Type[] parameterTypes = new Type[] { typeof(AttributedScopeStack), typeof(AttributedScopeStack) }; - - MethodInfo methodInfo = type.GetMethod( - nameof(Equals), - BindingFlags.NonPublic | BindingFlags.Static, - null, - parameterTypes, - null); - - Assert.IsNotNull(methodInfo); - return methodInfo; - } - #endregion Equals tests #region GetHashCode tests @@ -513,23 +495,6 @@ public void GetHashCode_WhenUsedAsDictionaryKey_AllowsLookupUsingEqualStack() Assert.IsTrue(found); Assert.AreEqual("VALUE", value); } - - [Test] - public void GetHashCode_WhenStacksDifferAtAnyFrame_ReturnsDifferentValue_SanityCheck() - { - // arrange - AttributedScopeStack left = CreateStack(("a", 1), ("b", 2), ("c", 3)); - AttributedScopeStack right = CreateStack(("a", 1), ("b", 2), ("x", 3)); - - // act - int leftHashCode = left.GetHashCode(); - int rightHashCode = right.GetHashCode(); - - // assert - // Collisions are allowed, so this is a sanity check rather than a strict contract test. - Assert.AreNotEqual(leftHashCode, rightHashCode); - } - [Test] public void GetHashCode_WhenStackIsDeep_DoesNotThrow_AndIsDeterministic() { @@ -552,6 +517,181 @@ public void GetHashCode_WhenStackIsDeep_DoesNotThrow_AndIsDeterministic() #endregion GetHashCode tests + #region IEquatable tests + + [Test] + public void IEquatable_Equals_StructurallyEqualStacks_ReturnsTrue() + { + // arrange + AttributedScopeStack left = CreateStack(("a", 1), ("b", 2)); + AttributedScopeStack right = CreateStack(("a", 1), ("b", 2)); + + // act - calls IEquatable.Equals directly + bool result = left.Equals(right); + + // assert + Assert.IsTrue(result); + } + + [Test] + public void IEquatable_Equals_StacksWithDifferentDepths_ReturnsFalse() + { + // arrange + AttributedScopeStack left = CreateStack(("a", 1), ("b", 2)); + AttributedScopeStack right = CreateStack(("a", 1), ("b", 2), ("c", 3)); + + // act + bool result = left.Equals(right); + + // assert + Assert.IsFalse(result); + } + + [Test] + public void IEquatable_Equals_DifferentStacks_ReturnsFalse() + { + // arrange + AttributedScopeStack left = CreateStack(("a", 1), ("b", 2)); + AttributedScopeStack right = CreateStack(("a", 1), ("x", 2)); + + // act + bool result = left.Equals(right); + + // assert + Assert.IsFalse(result); + } + + [Test] + public void IEquatable_Equals_NullArgument_ReturnsFalse() + { + // arrange + AttributedScopeStack stack = CreateStack(("a", 1)); + + // act + bool result = stack.Equals((AttributedScopeStack)null); + + // assert + Assert.IsFalse(result); + } + + [Test] + public void IEquatable_Equals_SameReference_ReturnsTrue() + { + // arrange + AttributedScopeStack stack = CreateStack(("a", 1), ("b", 2)); + + // act + bool result = stack.Equals(stack); + + // assert + Assert.IsTrue(result); + } + + [Test] + public void IEquatable_Equals_UsedByEqualityComparerDefault() + { + // arrange + AttributedScopeStack key1 = CreateStack(("a", 1), ("b", 2)); + AttributedScopeStack key2 = CreateStack(("a", 1), ("b", 2)); + + EqualityComparer comparer = EqualityComparer.Default; + + // act & assert + Assert.IsTrue(comparer.Equals(key1, key2)); + Assert.AreEqual(comparer.GetHashCode(key1), comparer.GetHashCode(key2)); + } + + #endregion IEquatable tests + + #region Operator == and != tests + + [Test] + public void OperatorEquals_StructurallyEqualStacks_ReturnsTrue() + { + // arrange + AttributedScopeStack left = CreateStack(("a", 1), ("b", 2)); + AttributedScopeStack right = CreateStack(("a", 1), ("b", 2)); + + // act & assert + Assert.IsTrue(left == right); + Assert.IsFalse(left != right); + } + + [Test] + public void OperatorEquals_DifferentStacks_ReturnsFalse() + { + // arrange + AttributedScopeStack left = CreateStack(("a", 1), ("b", 2)); + AttributedScopeStack right = CreateStack(("a", 1), ("x", 2)); + + // act & assert + Assert.IsFalse(left == right); + Assert.IsTrue(left != right); + } + + [Test] + public void OperatorEquals_BothNull_ReturnsTrue() + { + // arrange + AttributedScopeStack left = null; + AttributedScopeStack right = null; + + // act & assert + Assert.IsTrue(left == right); + Assert.IsFalse(left != right); + } + + [Test] + public void OperatorEquals_LeftNull_ReturnsFalse() + { + // arrange + AttributedScopeStack left = null; + AttributedScopeStack right = CreateStack(("a", 1)); + + // act & assert + Assert.IsFalse(left == right); + Assert.IsTrue(left != right); + } + + [Test] + public void OperatorEquals_RightNull_ReturnsFalse() + { + // arrange + AttributedScopeStack left = CreateStack(("a", 1)); + AttributedScopeStack right = null; + + // act & assert + Assert.IsFalse(left == right); + Assert.IsTrue(left != right); + } + + [Test] + public void OperatorEquals_SameReference_ReturnsTrue() + { + // arrange + AttributedScopeStack stack = CreateStack(("a", 1), ("b", 2)); + + // act & assert - deliberately comparing to itself via == +#pragma warning disable CS1718 // Comparison made to same variable + Assert.IsTrue(stack == stack); + Assert.IsFalse(stack != stack); +#pragma warning restore CS1718 + } + + [Test] + public void OperatorEquals_SameScopePathsDifferentTokenAttributes_ReturnsFalse() + { + // arrange + AttributedScopeStack left = CreateStack(("a", 1), ("b", 2)); + AttributedScopeStack right = CreateStack(("a", 1), ("b", 99)); // only token attributes differ + + // act & assert + Assert.IsFalse(left == right); + Assert.IsTrue(left != right); + } + + #endregion Operator == and != tests + #region GetScopeNames tests [Test] @@ -664,7 +804,7 @@ public void MergeAttributes_ThemeDataEmpty_PreservesStyleAndColors_ButUpdatesLan int existing = CreateNonDefaultEncodedMetadata(); AttributedScopeStack scopesList = new AttributedScopeStack(null, AnyScopePath, existing); - List themeData = new List(); + List themeData = []; BasicScopeAttributes attrs = new BasicScopeAttributes(NewLanguageId, NewTokenType, themeData); // act @@ -701,7 +841,7 @@ public void MergeAttributes_FirstRuleWithNullParentScopes_IsAlwaysSelected() rule1Foreground, rule1Background); - List rule2ParentScopes = new List { "nonexistent" }; + List rule2ParentScopes = ["nonexistent"]; ThemeTrieElementRule rule2 = new ThemeTrieElementRule( "r2", 1, @@ -710,7 +850,7 @@ public void MergeAttributes_FirstRuleWithNullParentScopes_IsAlwaysSelected() 99, 98); - List themeData = new List { rule1, rule2 }; + List themeData = [rule1, rule2]; BasicScopeAttributes attrs = new BasicScopeAttributes(NewLanguageId, NewTokenType, themeData); // act @@ -734,7 +874,7 @@ public void MergeAttributes_FirstRuleDoesNotMatch_SecondRuleMatchesByOrderedPare ("keyword.control", existing)); // rule1 should NOT match - List rule1ParentScopes = new List { "source.csharp", "meta.using" }; + List rule1ParentScopes = ["source.csharp", "meta.using"]; ThemeTrieElementRule rule1 = new ThemeTrieElementRule("r1", 1, rule1ParentScopes, FontStyle.Italic, 11, 12); const int rule2Foreground = 21; @@ -742,7 +882,7 @@ public void MergeAttributes_FirstRuleDoesNotMatch_SecondRuleMatchesByOrderedPare const FontStyle rule2FontStyle = FontStyle.Underline; // rule2 SHOULD match - List rule2ParentScopes = new List { "meta.using", "source.csharp" }; + List rule2ParentScopes = ["meta.using", "source.csharp"]; ThemeTrieElementRule rule2 = new ThemeTrieElementRule( "r2", 1, @@ -751,7 +891,7 @@ public void MergeAttributes_FirstRuleDoesNotMatch_SecondRuleMatchesByOrderedPare rule2Foreground, rule2Background); - List themeData = new List { rule1, rule2 }; + List themeData = [rule1, rule2]; BasicScopeAttributes attrs = new BasicScopeAttributes(NewLanguageId, NewTokenType, themeData); // act @@ -777,7 +917,7 @@ public void MergeAttributes_FirstMatchingRuleWins_WhenMultipleRulesMatch() ThemeTrieElementRule rule1 = new ThemeTrieElementRule("r1", 1, null, FontStyle.Italic, 11, 12); ThemeTrieElementRule rule2 = new ThemeTrieElementRule("r2", 1, null, FontStyle.Underline, 21, 22); - List themeData = new List { rule1, rule2 }; + List themeData = [rule1, rule2]; BasicScopeAttributes attrs = new BasicScopeAttributes(9, 2, themeData); // Act @@ -804,7 +944,7 @@ public void MergeAttributes_ParentScopeSelectorPrefix_MatchesDotSeparatedScope() const int expectedBackground = 32; const FontStyle expectedFontStyle = FontStyle.Italic; - List parentScopes = new List { "meta" }; + List parentScopes = ["meta"]; ThemeTrieElementRule rule = new ThemeTrieElementRule( "prefix-parent", 1, @@ -813,7 +953,7 @@ public void MergeAttributes_ParentScopeSelectorPrefix_MatchesDotSeparatedScope() expectedForeground, expectedBackground); - List themeData = new List { rule }; + List themeData = [rule]; BasicScopeAttributes attrs = new BasicScopeAttributes(NewLanguageId, NewTokenType, themeData); // act @@ -840,7 +980,7 @@ public void MergeAttributes_EmptyParentScopesList_PreservesExistingStyle() const int expectedForeground = 123; const int expectedBackground = 124; - List emptyParentScopes = new List(); + List emptyParentScopes = []; ThemeTrieElementRule rule = new ThemeTrieElementRule( "empty-parents", 1, @@ -849,7 +989,7 @@ public void MergeAttributes_EmptyParentScopesList_PreservesExistingStyle() expectedForeground, expectedBackground); - List themeData = new List { rule }; + List themeData = [rule]; BasicScopeAttributes attrs = new BasicScopeAttributes(NewLanguageId, NewTokenType, themeData); // act @@ -871,7 +1011,7 @@ public void MergeAttributes_RuleFontStyleNotSet_PreservesExistingFontStyle() const int expectedForeground = 123; const int expectedBackground = 124; - List parentScopes = new List { AnyScopePath }; + List parentScopes = [AnyScopePath]; ThemeTrieElementRule rule = new ThemeTrieElementRule( "preserve-style", 1, @@ -880,7 +1020,7 @@ public void MergeAttributes_RuleFontStyleNotSet_PreservesExistingFontStyle() expectedForeground, expectedBackground); - List themeData = new List { rule }; + List themeData = [rule]; BasicScopeAttributes attrs = new BasicScopeAttributes(NewLanguageId, NewTokenType, themeData); // act @@ -902,7 +1042,7 @@ public void MergeAttributes_RuleForegroundZero_PreservesExistingForeground() const int expectedBackground = 124; const FontStyle expectedFontStyle = FontStyle.Italic; - List parentScopes = new List { AnyScopePath }; + List parentScopes = [AnyScopePath]; ThemeTrieElementRule rule = new ThemeTrieElementRule( "preserve-fg", 1, @@ -911,7 +1051,7 @@ public void MergeAttributes_RuleForegroundZero_PreservesExistingForeground() 0, expectedBackground); - List themeData = new List { rule }; + List themeData = [rule]; BasicScopeAttributes attrs = new BasicScopeAttributes(NewLanguageId, NewTokenType, themeData); // act @@ -941,7 +1081,7 @@ public void MergeAttributes_RuleBackgroundZero_PreservesExistingBackground() expectedForeground, 0); - List themeData = new List { rule }; + List themeData = [rule]; BasicScopeAttributes attrs = new BasicScopeAttributes(NewLanguageId, NewTokenType, themeData); // act @@ -978,7 +1118,7 @@ public void MergeAttributes_NoRuleMatches_PreservesExistingStyleAndColors_ButUpd ("meta.using", existing), ("keyword.control", existing)); - List nonMatchingParentScopes = new List { "does.not.exist" }; + List nonMatchingParentScopes = ["does.not.exist"]; ThemeTrieElementRule nonMatchingRule = new ThemeTrieElementRule( "non-match", 1, @@ -987,7 +1127,7 @@ public void MergeAttributes_NoRuleMatches_PreservesExistingStyleAndColors_ButUpd 200, 201); - List themeData = new List { nonMatchingRule }; + List themeData = [nonMatchingRule]; BasicScopeAttributes attrs = new BasicScopeAttributes(NewLanguageId, NewTokenType, themeData); // act @@ -1007,7 +1147,7 @@ public void MergeAttributes_ScopesListNull_RuleWithParentScopes_DoesNotMatch() // arrange int existing = CreateNonDefaultEncodedMetadata(); - List parentScopes = new List { "source.csharp" }; + List parentScopes = ["source.csharp"]; ThemeTrieElementRule rule = new ThemeTrieElementRule( "requires-parent", 1, @@ -1016,7 +1156,7 @@ public void MergeAttributes_ScopesListNull_RuleWithParentScopes_DoesNotMatch() 200, 201); - List themeData = new List { rule }; + List themeData = [rule]; BasicScopeAttributes attrs = new BasicScopeAttributes(NewLanguageId, NewTokenType, themeData); // act @@ -1041,7 +1181,7 @@ public void MergeAttributes_PrefixSelector_DoesNotMatch_WhenScopeDoesNotHaveDotB ("metadata.block", existing)); // selector "meta" should match "meta.something" but NOT "metadata.something" - List parentScopes = new List { "meta" }; + List parentScopes = ["meta"]; ThemeTrieElementRule rule = new ThemeTrieElementRule( "prefix-dot-boundary", 1, @@ -1050,7 +1190,7 @@ public void MergeAttributes_PrefixSelector_DoesNotMatch_WhenScopeDoesNotHaveDotB 200, 201); - List themeData = new List { rule }; + List themeData = [rule]; BasicScopeAttributes attrs = new BasicScopeAttributes(NewLanguageId, NewTokenType, themeData); // act @@ -1079,7 +1219,7 @@ public void MergeAttributes_ParentScopesMatchNonContiguously_Works() ("c", existing)); // match "b" then later "a" (non-contiguous) - List parentScopes = new List { "b", "a" }; + List parentScopes = ["b", "a"]; const int expectedForeground = 210; const int expectedBackground = 211; const FontStyle expectedFontStyle = FontStyle.Underline; @@ -1092,7 +1232,7 @@ public void MergeAttributes_ParentScopesMatchNonContiguously_Works() expectedForeground, expectedBackground); - List themeData = new List { rule }; + List themeData = [rule]; BasicScopeAttributes attrs = new BasicScopeAttributes(NewLanguageId, NewTokenType, themeData); // act @@ -1141,10 +1281,10 @@ public void MergeAttributes_WhenScopesListContainsNullScopePath_DoesNotThrow_And (null, existing), ("keyword.control", existing)); - List parentScopes = new List { "meta" }; + List parentScopes = ["meta"]; ThemeTrieElementRule rule = new ThemeTrieElementRule("null-scopepath", 1, parentScopes, FontStyle.Italic, 11, 12); - List themeData = new List { rule }; + List themeData = [rule]; BasicScopeAttributes attrs = new BasicScopeAttributes(NewLanguageId, NewTokenType, themeData); // act @@ -1169,9 +1309,9 @@ public void MergeAttributes_MatchesScope_NullScope_DoesNotMatch() int existing = CreateNonDefaultEncodedMetadata(); AttributedScopeStack scopesList = new AttributedScopeStack(null, null, existing); - List parentScopes = new List { "source" }; + List parentScopes = ["source"]; ThemeTrieElementRule rule = new ThemeTrieElementRule("null-scope", 1, parentScopes, FontStyle.Italic, 101, 102); - BasicScopeAttributes attrs = new BasicScopeAttributes(NewLanguageId, NewTokenType, new List { rule }); + BasicScopeAttributes attrs = new BasicScopeAttributes(NewLanguageId, NewTokenType, [rule]); // act int result = AttributedScopeStack.MergeAttributes(existing, scopesList, attrs); @@ -1189,9 +1329,9 @@ public void MergeAttributes_MatchesScope_NullSelector_DoesNotMatch() int existing = CreateNonDefaultEncodedMetadata(); AttributedScopeStack scopesList = new AttributedScopeStack(null, "source.js", existing); - List parentScopes = new List { null }; + List parentScopes = [null]; ThemeTrieElementRule rule = new ThemeTrieElementRule("null-selector", 1, parentScopes, FontStyle.Italic, 111, 112); - BasicScopeAttributes attrs = new BasicScopeAttributes(NewLanguageId, NewTokenType, new List { rule }); + BasicScopeAttributes attrs = new BasicScopeAttributes(NewLanguageId, NewTokenType, [rule]); // act int result = AttributedScopeStack.MergeAttributes(existing, scopesList, attrs); @@ -1213,9 +1353,9 @@ public void MergeAttributes_MatchesScope_ExactMatch_AppliesRule() const int expectedBackground = 202; const FontStyle expectedFontStyle = FontStyle.Underline; - List parentScopes = new List { "source.js" }; + List parentScopes = ["source.js"]; ThemeTrieElementRule rule = new ThemeTrieElementRule("exact-match", 1, parentScopes, expectedFontStyle, expectedForeground, expectedBackground); - BasicScopeAttributes attrs = new BasicScopeAttributes(NewLanguageId, NewTokenType, new List { rule }); + BasicScopeAttributes attrs = new BasicScopeAttributes(NewLanguageId, NewTokenType, [rule]); // act int result = AttributedScopeStack.MergeAttributes(existing, scopesList, attrs); @@ -1237,9 +1377,9 @@ public void MergeAttributes_MatchesScope_PrefixWithDot_AppliesRule() const int expectedBackground = 212; const FontStyle expectedFontStyle = FontStyle.Italic; - List parentScopes = new List { "source" }; + List parentScopes = ["source"]; ThemeTrieElementRule rule = new ThemeTrieElementRule("prefix-dot", 1, parentScopes, expectedFontStyle, expectedForeground, expectedBackground); - BasicScopeAttributes attrs = new BasicScopeAttributes(NewLanguageId, NewTokenType, new List { rule }); + BasicScopeAttributes attrs = new BasicScopeAttributes(NewLanguageId, NewTokenType, [rule]); // act int result = AttributedScopeStack.MergeAttributes(existing, scopesList, attrs); @@ -1257,9 +1397,9 @@ public void MergeAttributes_MatchesScope_PrefixWithoutDot_DoesNotMatch() int existing = CreateNonDefaultEncodedMetadata(); AttributedScopeStack scopesList = new AttributedScopeStack(null, "sourcejs", existing); - List parentScopes = new List { "source" }; + List parentScopes = ["source"]; ThemeTrieElementRule rule = new ThemeTrieElementRule("prefix-no-dot", 1, parentScopes, FontStyle.Italic, 221, 222); - BasicScopeAttributes attrs = new BasicScopeAttributes(NewLanguageId, NewTokenType, new List { rule }); + BasicScopeAttributes attrs = new BasicScopeAttributes(NewLanguageId, NewTokenType, [rule]); // act int result = AttributedScopeStack.MergeAttributes(existing, scopesList, attrs); @@ -1386,7 +1526,7 @@ private static TextMateSharp.Internal.Grammars.Grammar CreateTestGrammar() themeProvider.Setup(provider => provider.GetDefaults()).Returns(defaults); themeProvider .Setup(provider => provider.ThemeMatch(It.IsAny>())) - .Returns(new List()); + .Returns([]); return new TextMateSharp.Internal.Grammars.Grammar( scopeName, @@ -1394,7 +1534,7 @@ private static TextMateSharp.Internal.Grammars.Grammar CreateTestGrammar() 0, null, null, - new BalancedBracketSelectors(new List(), new List()), + new BalancedBracketSelectors([], []), new Mock().Object, themeProvider.Object); } @@ -1424,6 +1564,24 @@ private static int CreateNonDefaultEncodedMetadata() ExistingBackground); } + // Normal call paths cannot hit the branches of the static Equals impl, so I'm adding this reflection-based + // helper to help improve the test coverage and cover the branches that cannot otherwise be executed. + private static MethodInfo GetPrivateStaticEqualsMethod() + { + Type type = typeof(AttributedScopeStack); + Type[] parameterTypes = [typeof(AttributedScopeStack), typeof(AttributedScopeStack)]; + + MethodInfo methodInfo = type.GetMethod( + nameof(Equals), + BindingFlags.NonPublic | BindingFlags.Static, + null, + parameterTypes, + null); + + Assert.IsNotNull(methodInfo); + return methodInfo; + } + #endregion Helpers } } From d3bc96405324bcd404c15c046cc220e6d499e803 Mon Sep 17 00:00:00 2001 From: Dave Black <656118+udlose@users.noreply.github.com> Date: Wed, 4 Mar 2026 09:40:46 -0600 Subject: [PATCH 26/33] Refactor AttributedScopeStack equality and hash logic Refactored AttributedScopeStack to implement IEquatable and provide strongly-typed Equals methods. Added operator overloads for == and !=, and improved null safety using ReferenceEquals. Refactored ComputeHashCode for clarity and consistency, and added XML documentation to clarify method purposes. These changes improve value equality semantics and support for hash-based collections. --- .../Internal/Grammars/AttributedScopeStack.cs | 91 ++++++++++++++----- 1 file changed, 70 insertions(+), 21 deletions(-) diff --git a/src/TextMateSharp/Internal/Grammars/AttributedScopeStack.cs b/src/TextMateSharp/Internal/Grammars/AttributedScopeStack.cs index 8815591..783173b 100644 --- a/src/TextMateSharp/Internal/Grammars/AttributedScopeStack.cs +++ b/src/TextMateSharp/Internal/Grammars/AttributedScopeStack.cs @@ -5,7 +5,7 @@ namespace TextMateSharp.Internal.Grammars { - public class AttributedScopeStack + public class AttributedScopeStack : IEquatable { public AttributedScopeStack Parent { get; private set; } public string ScopePath { get; private set; } @@ -27,12 +27,13 @@ private static bool StructuralEquals(AttributedScopeStack a, AttributedScopeStac { while (true) { - if (a == b) + // Use ReferenceEquals to avoid infinite recursion through operator == + if (ReferenceEquals(a, b)) { return true; } - if (a == null || b == null) + if (a is null || b is null) { // End of list reached only for one return false; @@ -50,13 +51,18 @@ private static bool StructuralEquals(AttributedScopeStack a, AttributedScopeStac } } - private static bool Equals(AttributedScopeStack a, AttributedScopeStack b) + // Internal so StateStack can perform null-safe equality checks on + // ContentNameScopesList / NameScopesList without going through the + // instance Equals (which would throw on null receivers) + internal static bool Equals(AttributedScopeStack a, AttributedScopeStack b) { - if (a == b) + // Use ReferenceEquals to avoid infinite recursion through operator == + if (ReferenceEquals(a, b)) { return true; } - if (a == null || b == null) + + if (a is null || b is null) { return false; } @@ -67,9 +73,28 @@ private static bool Equals(AttributedScopeStack a, AttributedScopeStack b) { return false; } + return StructuralEquals(a, b); } + /// + /// Determines whether the specified instance is equal to the current + /// instance. + /// + /// The instance to compare with the current instance. + /// true if the specified is equal to the current instance; otherwise, false. + public bool Equals(AttributedScopeStack other) + { + return Equals(this, other); + } + + /// + /// Determines whether the specified object is equal to the current instance. + /// + /// This method overrides the base Object.Equals implementation to provide value equality + /// specific to AttributedScopeStack instances. + /// The object to compare with the current instance. + /// true if the specified object is equal to the current instance; otherwise, false. public override bool Equals(object other) { if (other is AttributedScopeStack attributedScopeStack) @@ -78,31 +103,55 @@ public override bool Equals(object other) return false; } + /// + /// Returns a hash code for the current instance, suitable for use in hashing algorithms and data structures + /// such as hash tables. + /// + /// Equal instances are guaranteed to return the same hash code. This method is typically + /// used to support efficient lookups in hash-based collections. + /// An integer that represents the hash code for this instance. public override int GetHashCode() { return _hashCode; } + /// + /// Determines whether two instances of are equal. + /// + /// This operator uses the method to determine + /// equality. + /// The first instance to compare. + /// The second instance to compare. + /// true if the specified instances are equal; otherwise, false. + public static bool operator ==(AttributedScopeStack left, AttributedScopeStack right) + { + return Equals(left, right); + } + + /// + /// Determines whether two instances of are not equal. + /// + /// This operator uses the + /// method to evaluate equality. + /// The first to compare. + /// The second to compare. + /// true if the specified instances are not equal; otherwise, false. + public static bool operator !=(AttributedScopeStack left, AttributedScopeStack right) + { + return !Equals(left, right); + } + private static int ComputeHashCode(AttributedScopeStack parent, string scopePath, int tokenAttributes) { + const int primeFactor = 31; // Common prime factor for multiply-accumulate hash code + const int seed = 17; // Common seed for hash code computation (different from primeFactor to reduce collisions) unchecked { - int hash = parent?._hashCode ?? 17; - hash = (hash * 31) + tokenAttributes; - - int scopeHashCode; - if (scopePath == null) - { - scopeHashCode = 0; - } - else - { - scopeHashCode = StringComparer.Ordinal.GetHashCode(scopePath); - } - - hash = (hash * 31) + scopeHashCode; + int hash = parent?._hashCode ?? seed; + hash = (hash * primeFactor) + tokenAttributes; - return hash; + var scopeHashCode = scopePath == null ? 0 : StringComparer.Ordinal.GetHashCode(scopePath); + return (hash * primeFactor) + scopeHashCode; } } From 52c78aa7e1cb431f171b189c8fa457085f6bc6b2 Mon Sep 17 00:00:00 2001 From: Dave Black <656118+udlose@users.noreply.github.com> Date: Wed, 4 Mar 2026 10:06:13 -0600 Subject: [PATCH 27/33] Add comprehensive unit tests for StateStack value semantics Expanded StateStackTests to cover GetHashCode, Equals, IEquatable, operator overloads, HasSameRuleAs, WithContentNameScopesList, WithEndRule, Pop/SafePop, and Reset. Tests verify correct behavior for equality, hash code determinism, structural equivalence, null handling, dictionary usage, and edge cases including deep stack scenarios. Helper methods added for test scope stack creation. --- .../Grammar/StateStackTests.cs | 1759 +++++++++++++++++ 1 file changed, 1759 insertions(+) diff --git a/src/TextMateSharp.Tests/Grammar/StateStackTests.cs b/src/TextMateSharp.Tests/Grammar/StateStackTests.cs index fd57b0e..76654af 100644 --- a/src/TextMateSharp.Tests/Grammar/StateStackTests.cs +++ b/src/TextMateSharp.Tests/Grammar/StateStackTests.cs @@ -1,4 +1,5 @@ using NUnit.Framework; +using System.Collections.Generic; using TextMateSharp.Grammars; using TextMateSharp.Internal.Grammars; using TextMateSharp.Internal.Rules; @@ -14,6 +15,8 @@ public class StateStackTests private const int EnterPosition = 0; private const int AnchorPosition = 0; + #region ToString tests + [Test] public void ToString_SingleDepthState_ReturnsFormattedString() { @@ -310,6 +313,1762 @@ public void ToString_StackWithMixedRuleIds_ReturnsCorrectOrderFromRootToCurrent( Assert.AreEqual(expectedOutput, result); } + #endregion ToString tests + + #region GetHashCode tests + + [Test] + public void GetHashCode_NullSentinel_DoesNotThrow() + { + // Arrange + StateStack stack = StateStack.NULL; + + // Act & Assert + // GetHashCode is only guaranteed to be deterministic within a single application run and may vary across runs, + // so this test only verifies that calling it does not throw, without asserting on a specific hash value. + Assert.DoesNotThrow(() => _ = stack.GetHashCode()); + } + + [Test] + public void GetHashCode_NullSentinel_IsDeterministic() + { + // Arrange + StateStack stack = StateStack.NULL; + + // Act + int first = stack.GetHashCode(); + int second = stack.GetHashCode(); + + // Assert + Assert.AreEqual(first, second); + } + + [Test] + public void GetHashCode_NullEndRule_DoesNotThrow() + { + // Arrange + StateStack stack = new StateStack( + null, + RuleId.Of(RuleIdSingleDepth), + EnterPosition, + AnchorPosition, + false, + null, + CreateTestScopeStack(), + CreateTestScopeStack()); + + // Act & Assert + // GetHashCode doesn't produce a deterministic value across application runs, so we can't assert on specific values + Assert.DoesNotThrow(() => _ = stack.GetHashCode()); + } + + [Test] + public void GetHashCode_NullContentNameScopesList_DoesNotThrow() + { + // Arrange + StateStack stack = new StateStack( + null, + RuleId.Of(RuleIdSingleDepth), + EnterPosition, + AnchorPosition, + false, + "endRule", + CreateTestScopeStack(), + null); + + // Act & Assert + // GetHashCode doesn't produce a deterministic value across application runs, so we can't assert on specific values + Assert.DoesNotThrow(() => _ = stack.GetHashCode()); + } + + [Test] + public void GetHashCode_NullParent_DoesNotThrow() + { + // Arrange + StateStack stack = new StateStack( + null, + RuleId.Of(RuleIdSingleDepth), + EnterPosition, + AnchorPosition, + false, + "endRule", + CreateTestScopeStack(), + CreateTestScopeStack()); + + // Act & Assert + // GetHashCode doesn't produce a deterministic value across application runs, so we can't assert on specific values + Assert.DoesNotThrow(() => _ = stack.GetHashCode()); + } + + [Test] + public void GetHashCode_AllFieldsNull_DoesNotThrow() + { + // Arrange - worst-case null scenario + StateStack stack = new StateStack( + null, + RuleId.NO_RULE, + EnterPosition, + AnchorPosition, + false, + null, + null, + null); + + // Act & Assert + // GetHashCode doesn't produce a deterministic value across application runs, so we can't assert on specific values + Assert.DoesNotThrow(() => _ = stack.GetHashCode()); + } + + [Test] + public void GetHashCode_EqualStacks_ReturnSameValue() + { + // Arrange + AttributedScopeStack scopeStack = CreateTestScopeStack(); + + StateStack left = new StateStack( + null, + RuleId.Of(RuleIdSingleDepth), + EnterPosition, + AnchorPosition, + false, + "endRule", + scopeStack, + scopeStack); + + StateStack right = new StateStack( + null, + RuleId.Of(RuleIdSingleDepth), + EnterPosition, + AnchorPosition, + false, + "endRule", + scopeStack, + scopeStack); + + // Act + int leftHash = left.GetHashCode(); + int rightHash = right.GetHashCode(); + + // Assert - equal objects must have the same hash code + Assert.AreEqual(leftHash, rightHash); + } + + [Test] + public void GetHashCode_EqualObjects_ReturnSameValue() + { + // Arrange + AttributedScopeStack scopeStack = CreateTestScopeStack(); + + StateStack left = new StateStack( + null, + RuleId.Of(RuleIdSingleDepth), + EnterPosition, + AnchorPosition, + true, + "endRule", + scopeStack, + scopeStack); + + StateStack right = new StateStack( + null, + RuleId.Of(RuleIdSingleDepth), + EnterPosition, + AnchorPosition, + true, + "endRule", + scopeStack, + scopeStack); + + // Act + int leftHash = left.GetHashCode(); + int rightHash = right.GetHashCode(); + + // Assert - equal objects must have the same hash code + Assert.AreEqual(leftHash, rightHash); + } + + [Test] + public void GetHashCode_UsedAsDictionaryKey_AllowsLookupWithEqualStack() + { + // Arrange + AttributedScopeStack scopeStack = CreateTestScopeStack(); + + StateStack key1 = new StateStack( + null, + RuleId.Of(RuleIdSingleDepth), + EnterPosition, + AnchorPosition, + false, + "endRule", + scopeStack, + scopeStack); + + StateStack key2 = new StateStack( + null, + RuleId.Of(RuleIdSingleDepth), + EnterPosition, + AnchorPosition, + false, + "endRule", + scopeStack, + scopeStack); + + Dictionary dictionary = new Dictionary + { + [key1] = "VALUE" + }; + + // Act + bool found = dictionary.TryGetValue(key2, out string value); + + // Assert + Assert.IsTrue(found); + Assert.AreEqual("VALUE", value); + } + + [Test] + public void GetHashCode_DeepStack_DoesNotThrow_AndIsDeterministic() + { + // Arrange + const int depth = 250; + StateStack stack = CreateStateStackWithDepth(depth); + + // Act + int first = stack.GetHashCode(); + int second = stack.GetHashCode(); + + // Assert + Assert.AreEqual(first, second); + } + + [Test] + public void GetHashCode_DifferentEnterPos_ReturnsSameValue() + { + // Arrange - _enterPos is NOT part of hash/equality + StateStack left = new StateStack( + null, + RuleId.Of(RuleIdSingleDepth), + 0, + AnchorPosition, + false, + null, + CreateTestScopeStack(), + CreateTestScopeStack()); + + StateStack right = new StateStack( + null, + RuleId.Of(RuleIdSingleDepth), + 999, + AnchorPosition, + false, + null, + CreateTestScopeStack(), + CreateTestScopeStack()); + + // Act & Assert - _enterPos should not affect hash + Assert.AreEqual(left.GetHashCode(), right.GetHashCode()); + } + + [Test] + public void GetHashCode_DifferentAnchorPos_ReturnsSameValue() + { + // Arrange - _anchorPos is NOT part of hash/equality + StateStack left = new StateStack( + null, + RuleId.Of(RuleIdSingleDepth), + EnterPosition, + 0, + false, + null, + CreateTestScopeStack(), + CreateTestScopeStack()); + + StateStack right = new StateStack( + null, + RuleId.Of(RuleIdSingleDepth), + EnterPosition, + 999, + false, + null, + CreateTestScopeStack(), + CreateTestScopeStack()); + + // Act & Assert - _anchorPos should not affect hash + Assert.AreEqual(left.GetHashCode(), right.GetHashCode()); + } + + [Test] + public void GetHashCode_DifferentBeginRuleCapturedEOL_ReturnsSameValue() + { + // Arrange - BeginRuleCapturedEOL is NOT part of hash/equality (matches upstream) + StateStack left = new StateStack( + null, + RuleId.Of(RuleIdSingleDepth), + EnterPosition, + AnchorPosition, + false, + null, + CreateTestScopeStack(), + CreateTestScopeStack()); + + StateStack right = new StateStack( + null, + RuleId.Of(RuleIdSingleDepth), + EnterPosition, + AnchorPosition, + true, + null, + CreateTestScopeStack(), + CreateTestScopeStack()); + + // Act & Assert - BeginRuleCapturedEOL should not affect hash + Assert.AreEqual(left.GetHashCode(), right.GetHashCode()); + } + + #endregion GetHashCode tests + + #region Equals (object) tests + + [Test] + public void Equals_SameReference_ReturnsTrue() + { + // Arrange + StateStack stack = new StateStack( + null, + RuleId.Of(RuleIdSingleDepth), + EnterPosition, + AnchorPosition, + false, + null, + CreateTestScopeStack(), + CreateTestScopeStack()); + + // Act + bool result = stack.Equals((object)stack); + + // Assert + Assert.IsTrue(result); + } + + [Test] + public void Equals_EquivalentObjects_ReturnsTrue() + { + // Arrange + AttributedScopeStack scopeStack = CreateTestScopeStack(); + + StateStack left = new StateStack( + null, + RuleId.Of(RuleIdSingleDepth), + EnterPosition, + AnchorPosition, + true, + "endRule", + scopeStack, + scopeStack); + + StateStack right = new StateStack( + null, + RuleId.Of(RuleIdSingleDepth), + EnterPosition, + AnchorPosition, + true, + "endRule", + scopeStack, + scopeStack); + + // Act + bool result = left.Equals(right); + + // Assert + Assert.IsTrue(result); + } + + [Test] + public void Equals_IsReflexive() + { + // Arrange + StateStack stack = new StateStack( + null, + RuleId.Of(RuleIdSingleDepth), + EnterPosition, + AnchorPosition, + false, + null, + CreateTestScopeStack(), + CreateTestScopeStack()); + + // Act + bool result = stack.Equals((object)stack); + + // Assert + Assert.IsTrue(result); + } + + [Test] + public void Equals_IsSymmetric() + { + // Arrange + AttributedScopeStack scopeStack = CreateTestScopeStack(); + StateStack left = new StateStack( + null, + RuleId.Of(RuleIdSingleDepth), + EnterPosition, + AnchorPosition, + false, + "endRule", + scopeStack, + scopeStack); + + StateStack right = new StateStack( + null, + RuleId.Of(RuleIdSingleDepth), + EnterPosition, + AnchorPosition, + false, + "endRule", + scopeStack, + scopeStack); + // Act + bool leftEqualsRight = left.Equals((object)right); + bool rightEqualsLeft = right.Equals((object)left); + + // Assert + Assert.IsTrue(leftEqualsRight); + Assert.IsTrue(rightEqualsLeft); + } + + [Test] + public void Equals_Null_ReturnsFalse() + { + // Arrange + StateStack stack = new StateStack( + null, + RuleId.Of(RuleIdSingleDepth), + EnterPosition, + AnchorPosition, + false, + null, + CreateTestScopeStack(), + CreateTestScopeStack()); + + // Act + bool result = stack.Equals((object)null); + + // Assert + Assert.IsFalse(result); + } + + [Test] + public void Equals_DifferentType_ReturnsFalse() + { + // Arrange + StateStack stack = new StateStack( + null, + RuleId.Of(RuleIdSingleDepth), + EnterPosition, + AnchorPosition, + false, + null, + CreateTestScopeStack(), + CreateTestScopeStack()); + + // Act + bool result = stack.Equals(42); + + // Assert + Assert.IsFalse(result); + } + + [Test] + public void Equals_StructurallyEqualStacks_ReturnsTrue() + { + // Arrange + AttributedScopeStack scopeStack = CreateTestScopeStack(); + + StateStack left = new StateStack( + null, + RuleId.Of(RuleIdSingleDepth), + EnterPosition, + AnchorPosition, + false, + "endRule", + scopeStack, + scopeStack); + + StateStack right = new StateStack( + null, + RuleId.Of(RuleIdSingleDepth), + EnterPosition, + AnchorPosition, + false, + "endRule", + scopeStack, + scopeStack); + + // Act + bool leftEqualsRight = left.Equals((object)right); + bool rightEqualsLeft = right.Equals((object)left); + + // Assert + Assert.IsTrue(leftEqualsRight); + Assert.IsTrue(rightEqualsLeft); + } + + [Test] + public void Equals_DifferentRuleId_ReturnsFalse() + { + // Arrange + StateStack left = new StateStack( + null, + RuleId.Of(1), + EnterPosition, + AnchorPosition, + false, + null, + CreateTestScopeStack(), + CreateTestScopeStack()); + + StateStack right = new StateStack( + null, + RuleId.Of(2), + EnterPosition, + AnchorPosition, + false, + null, + CreateTestScopeStack(), + CreateTestScopeStack()); + + // Act & Assert + Assert.IsFalse(left.Equals(right)); + } + + [Test] + public void Equals_DifferentEndRule_ReturnsFalse() + { + // Arrange + StateStack left = new StateStack( + null, + RuleId.Of(RuleIdSingleDepth), + EnterPosition, + AnchorPosition, + false, + "endRuleA", + CreateTestScopeStack(), + CreateTestScopeStack()); + + StateStack right = new StateStack( + null, + RuleId.Of(RuleIdSingleDepth), + EnterPosition, + AnchorPosition, + false, + "endRuleB", + CreateTestScopeStack(), + CreateTestScopeStack()); + + // Act & Assert + Assert.IsFalse(left.Equals(right)); + } + + [Test] + public void Equals_DifferentDepth_ReturnsFalse() + { + // Arrange + StateStack shallow = new StateStack( + null, + RuleId.Of(RuleIdSingleDepth), + EnterPosition, + AnchorPosition, + false, + null, + CreateTestScopeStack(), + CreateTestScopeStack()); + + StateStack deep = shallow.Push( + RuleId.Of(RuleIdSingleDepth), + EnterPosition, + AnchorPosition, + false, + null, + CreateTestScopeStack(), + CreateTestScopeStack()); + + // Act & Assert + Assert.IsFalse(shallow.Equals(deep)); + Assert.IsFalse(deep.Equals(shallow)); + } + + [Test] + public void Equals_DifferentContentNameScopesList_ReturnsFalse() + { + // Arrange + AttributedScopeStack contentA = new AttributedScopeStack(null, "scope.a", 1); + AttributedScopeStack contentB = new AttributedScopeStack(null, "scope.b", 2); + + StateStack left = new StateStack( + null, + RuleId.Of(RuleIdSingleDepth), + EnterPosition, + AnchorPosition, + false, + null, + CreateTestScopeStack(), + contentA); + + StateStack right = new StateStack( + null, + RuleId.Of(RuleIdSingleDepth), + EnterPosition, + AnchorPosition, + false, + null, + CreateTestScopeStack(), + contentB); + + // Act & Assert + Assert.IsFalse(left.Equals(right)); + } + + [Test] + public void Equals_NullContentNameScopesListOnBothSides_ReturnsTrue() + { + // Arrange - previously would throw NullReferenceException + StateStack left = new StateStack( + null, + RuleId.Of(RuleIdSingleDepth), + EnterPosition, + AnchorPosition, + false, + null, + CreateTestScopeStack(), + null); + + StateStack right = new StateStack( + null, + RuleId.Of(RuleIdSingleDepth), + EnterPosition, + AnchorPosition, + false, + null, + CreateTestScopeStack(), + null); + + // Act & Assert + Assert.IsTrue(left.Equals(right)); + } + + [Test] + public void Equals_NullContentNameScopesListOnOneSide_ReturnsFalse() + { + // Arrange + StateStack left = new StateStack( + null, + RuleId.Of(RuleIdSingleDepth), + EnterPosition, + AnchorPosition, + false, + null, + CreateTestScopeStack(), + null); + + StateStack right = new StateStack( + null, + RuleId.Of(RuleIdSingleDepth), + EnterPosition, + AnchorPosition, + false, + null, + CreateTestScopeStack(), + CreateTestScopeStack()); + + // Act & Assert + Assert.IsFalse(left.Equals(right)); + Assert.IsFalse(right.Equals(left)); + } + + [Test] + public void Equals_NullEndRuleOnBothSides_ReturnsTrue() + { + // Arrange + StateStack left = new StateStack( + null, + RuleId.Of(RuleIdSingleDepth), + EnterPosition, + AnchorPosition, + false, + null, + CreateTestScopeStack(), + CreateTestScopeStack()); + + StateStack right = new StateStack( + null, + RuleId.Of(RuleIdSingleDepth), + EnterPosition, + AnchorPosition, + false, + null, + CreateTestScopeStack(), + CreateTestScopeStack()); + + // Act & Assert + Assert.IsTrue(left.Equals(right)); + } + + [Test] + public void Equals_NullEndRuleOnOneSide_ReturnsFalse() + { + // Arrange + StateStack left = new StateStack( + null, + RuleId.Of(RuleIdSingleDepth), + EnterPosition, + AnchorPosition, + false, + null, + CreateTestScopeStack(), + CreateTestScopeStack()); + + StateStack right = new StateStack( + null, + RuleId.Of(RuleIdSingleDepth), + EnterPosition, + AnchorPosition, + false, + "someEndRule", + CreateTestScopeStack(), + CreateTestScopeStack()); + + // Act & Assert + Assert.IsFalse(left.Equals(right)); + } + + [Test] + public void Equals_NullSentinelToItself_ReturnsTrue() + { + // Arrange + StateStack stack = StateStack.NULL; + + // Act + bool result = stack.Equals((object)stack); + + // Assert + Assert.IsTrue(result); + } + + [Test] + public void Equals_DifferentParentChains_ReturnsFalse() + { + // Arrange - same leaf but different parent structure + StateStack parentA = new StateStack( + null, + RuleId.Of(1), + EnterPosition, + AnchorPosition, + false, + null, + CreateTestScopeStack(), + CreateTestScopeStack()); + + StateStack parentB = new StateStack( + null, + RuleId.Of(2), + EnterPosition, + AnchorPosition, + false, + null, + CreateTestScopeStack(), + CreateTestScopeStack()); + + StateStack left = parentA.Push( + RuleId.Of(RuleIdSingleDepth), + EnterPosition, + AnchorPosition, + false, + null, + CreateTestScopeStack(), + CreateTestScopeStack()); + + StateStack right = parentB.Push( + RuleId.Of(RuleIdSingleDepth), + EnterPosition, + AnchorPosition, + false, + null, + CreateTestScopeStack(), + CreateTestScopeStack()); + + // Act & Assert + Assert.IsFalse(left.Equals(right)); + } + + [Test] + public void Equals_EquivalentDeepStacks_ReturnsTrue() + { + // Arrange + const int depth = 250; + StateStack left = CreateStateStackWithDepth(depth); + StateStack right = CreateStateStackWithDepth(depth); + + // Act & Assert + Assert.IsTrue(left.Equals(right)); + } + + [Test] + public void Equals_DifferentEnterPos_StillReturnsTrue() + { + // Arrange - _enterPos is NOT part of structural equality (matches upstream) + StateStack left = new StateStack( + null, + RuleId.Of(RuleIdSingleDepth), + 0, + AnchorPosition, + false, + null, + CreateTestScopeStack(), + CreateTestScopeStack()); + + StateStack right = new StateStack( + null, + RuleId.Of(RuleIdSingleDepth), + 999, + AnchorPosition, + false, + null, + CreateTestScopeStack(), + CreateTestScopeStack()); + + // Act & Assert + Assert.IsTrue(left.Equals(right)); + } + + #endregion Equals (object) tests + + #region IEquatable tests + + [Test] + public void IEquatable_Equals_IsReflexive() + { + // Arrange + StateStack stack = new StateStack( + null, + RuleId.Of(RuleIdSingleDepth), + EnterPosition, + AnchorPosition, + false, + null, + CreateTestScopeStack(), + CreateTestScopeStack()); + + // Act + bool result = stack.Equals(stack); + + // Assert + Assert.IsTrue(result); + } + + [Test] + public void IEquatable_IsSymmetric() + { + // Arrange + AttributedScopeStack scopeStack = CreateTestScopeStack(); + StateStack left = new StateStack( + null, + RuleId.Of(RuleIdSingleDepth), + EnterPosition, + AnchorPosition, + false, + null, + scopeStack, + scopeStack); + StateStack right = new StateStack( + null, + RuleId.Of(RuleIdSingleDepth), + EnterPosition, + AnchorPosition, + false, + null, + scopeStack, + scopeStack); + + // Act + bool leftEqualsRight = left.Equals(right); + bool rightEqualsLeft = right.Equals(left); + + // Assert + Assert.IsTrue(leftEqualsRight); + Assert.IsTrue(rightEqualsLeft); + } + + [Test] + public void IEquatable_Equals_StructurallyEqualStacks_ReturnsTrue() + { + // Arrange + AttributedScopeStack scopeStack = CreateTestScopeStack(); + + StateStack left = new StateStack( + null, + RuleId.Of(RuleIdSingleDepth), + EnterPosition, + AnchorPosition, + false, + null, + scopeStack, + scopeStack); + + StateStack right = new StateStack( + null, + RuleId.Of(RuleIdSingleDepth), + EnterPosition, + AnchorPosition, + false, + null, + scopeStack, + scopeStack); + + // Act - calls IEquatable.Equals directly + bool result = left.Equals(right); + + // Assert + Assert.IsTrue(result); + } + + [Test] + public void IEquatable_Equals_Null_ReturnsFalse() + { + // Arrange + StateStack stack = new StateStack( + null, + RuleId.Of(RuleIdSingleDepth), + EnterPosition, + AnchorPosition, + false, + null, + CreateTestScopeStack(), + CreateTestScopeStack()); + + // Act + bool result = stack.Equals((StateStack)null); + + // Assert + Assert.IsFalse(result); + } + + [Test] + public void IEquatable_Equals_DifferentStack_ReturnsFalse() + { + // Arrange + StateStack left = new StateStack( + null, + RuleId.Of(1), + EnterPosition, + AnchorPosition, + false, + null, + CreateTestScopeStack(), + CreateTestScopeStack()); + + StateStack right = new StateStack( + null, + RuleId.Of(2), + EnterPosition, + AnchorPosition, + false, + null, + CreateTestScopeStack(), + CreateTestScopeStack()); + + // Act + bool result = left.Equals(right); + + // Assert + Assert.IsFalse(result); + } + + [Test] + public void IEquatable_Equals_UsedByEqualityComparerDefault() + { + // Arrange + AttributedScopeStack scopeStack = CreateTestScopeStack(); + + StateStack key1 = new StateStack( + null, + RuleId.Of(RuleIdSingleDepth), + EnterPosition, + AnchorPosition, + false, + null, + scopeStack, + scopeStack); + + StateStack key2 = new StateStack( + null, + RuleId.Of(RuleIdSingleDepth), + EnterPosition, + AnchorPosition, + false, + null, + scopeStack, + scopeStack); + + EqualityComparer comparer = + EqualityComparer.Default; + + // Act & Assert + Assert.IsTrue(comparer.Equals(key1, key2)); + Assert.AreEqual(comparer.GetHashCode(key1), comparer.GetHashCode(key2)); + } + + #endregion IEquatable tests + + #region Operator == and != tests + + [Test] + public void OperatorEquals_SameInstance_ReturnsTrue() + { + // Arrange + StateStack stack = new StateStack( + null, + RuleId.Of(RuleIdSingleDepth), + EnterPosition, + AnchorPosition, + false, + null, + CreateTestScopeStack(), + CreateTestScopeStack()); + + // Act & Assert +#pragma warning disable CS1718 // Comparison made to same variable + Assert.IsTrue(stack == stack); + Assert.IsFalse(stack != stack); +#pragma warning restore CS1718 // Comparison made to same variable + } + + [Test] + public void OperatorEquals_StructurallyEqualStacks_ReturnsTrue() + { + // Arrange + AttributedScopeStack scopeStack = CreateTestScopeStack(); + + StateStack left = new StateStack( + null, + RuleId.Of(RuleIdSingleDepth), + EnterPosition, + AnchorPosition, + false, + null, + scopeStack, + scopeStack); + + StateStack right = new StateStack( + null, + RuleId.Of(RuleIdSingleDepth), + EnterPosition, + AnchorPosition, + false, + null, + scopeStack, + scopeStack); + + // Act & Assert + Assert.IsTrue(left == right); + Assert.IsFalse(left != right); + } + + [Test] + public void OperatorEquals_DifferentStacks_ReturnsFalse() + { + // Arrange + StateStack left = new StateStack( + null, + RuleId.Of(1), + EnterPosition, + AnchorPosition, + false, + null, + CreateTestScopeStack(), + CreateTestScopeStack()); + + StateStack right = new StateStack( + null, + RuleId.Of(2), + EnterPosition, + AnchorPosition, + false, + null, + CreateTestScopeStack(), + CreateTestScopeStack()); + + // Act & Assert + Assert.IsFalse(left == right); + Assert.IsTrue(left != right); + } + + [Test] + public void OperatorEquals_BothNull_ReturnsTrue() + { + // Arrange + StateStack left = null; + StateStack right = null; + + // Act & Assert + Assert.IsTrue(left == right); + Assert.IsFalse(left != right); + } + + [Test] + public void OperatorEquals_LeftNull_ReturnsFalse() + { + // Arrange + StateStack left = null; + StateStack right = new StateStack( + null, + RuleId.Of(RuleIdSingleDepth), + EnterPosition, + AnchorPosition, + false, + null, + CreateTestScopeStack(), + CreateTestScopeStack()); + + // Act & Assert + Assert.IsFalse(left == right); + Assert.IsTrue(left != right); + } + + [Test] + public void OperatorEquals_RightNull_ReturnsFalse() + { + // Arrange + StateStack left = new StateStack( + null, + RuleId.Of(RuleIdSingleDepth), + EnterPosition, + AnchorPosition, + false, + null, + CreateTestScopeStack(), + CreateTestScopeStack()); + StateStack right = null; + + // Act & Assert + Assert.IsFalse(left == right); + Assert.IsTrue(left != right); + } + + [Test] + public void OperatorEquals_IsReflexive() + { + // Arrange + StateStack stack = new StateStack( + null, + RuleId.Of(RuleIdSingleDepth), + EnterPosition, + AnchorPosition, + false, + null, + CreateTestScopeStack(), + CreateTestScopeStack()); + + // Act & Assert +#pragma warning disable CS1718 // Comparison made to same variable + Assert.IsTrue(stack == stack); +#pragma warning restore CS1718 // Comparison made to same variable + } + + [Test] + public void OperatorEquals_IsSymmetric() + { + // Arrange + AttributedScopeStack scopeStack = CreateTestScopeStack(); + StateStack left = new StateStack( + null, + RuleId.Of(RuleIdSingleDepth), + EnterPosition, + AnchorPosition, + false, + null, + scopeStack, + scopeStack); + StateStack right = new StateStack( + null, + RuleId.Of(RuleIdSingleDepth), + EnterPosition, + AnchorPosition, + false, + null, + scopeStack, + scopeStack); + + // Act & Assert + Assert.IsTrue(left == right); + Assert.IsTrue(right == left); + } + + [Test] + public void OperatorNotEquals_IsSymmetric() + { + // Arrange + AttributedScopeStack scopeStack = CreateTestScopeStack(); + StateStack left = new StateStack( + null, + RuleId.Of(RuleIdSingleDepth), + EnterPosition, + AnchorPosition, + false, + null, + scopeStack, + scopeStack); + StateStack right = new StateStack( + null, + RuleId.Of(RuleIdSingleDepth), + EnterPosition, + AnchorPosition, + false, + null, + scopeStack, + scopeStack); + + // Act & Assert + Assert.IsFalse(left != right); + Assert.IsFalse(right != left); + } + + #endregion Operator == and != tests + + #region HasSameRuleAs tests + + [Test] + public void HasSameRuleAs_SameRuleAtTop_ReturnsTrue() + { + // Arrange - share the RuleId instance to match production behavior, + // where the grammar engine reuses the same RuleId object + RuleId sharedRuleId = RuleId.Of(RuleIdSingleDepth); + + StateStack left = new StateStack( + null, + sharedRuleId, + EnterPosition, + AnchorPosition, + false, + null, + CreateTestScopeStack(), + CreateTestScopeStack()); + + StateStack right = new StateStack( + null, + sharedRuleId, + EnterPosition, + AnchorPosition, + false, + null, + CreateTestScopeStack(), + CreateTestScopeStack()); + + // Act & Assert + Assert.IsTrue(left.HasSameRuleAs(right)); + } + + [Test] + public void HasSameRuleAs_DifferentRuleAtTop_DifferentEnterPos_ReturnsFalse() + { + // Arrange - different enterPos stops the parent walk immediately + StateStack left = new StateStack( + null, + RuleId.Of(1), + 10, + AnchorPosition, + false, + null, + CreateTestScopeStack(), + CreateTestScopeStack()); + + StateStack right = new StateStack( + null, + RuleId.Of(2), + 20, + AnchorPosition, + false, + null, + CreateTestScopeStack(), + CreateTestScopeStack()); + + // Act & Assert + Assert.IsFalse(left.HasSameRuleAs(right)); + } + + [Test] + public void HasSameRuleAs_MatchingAncestorRule_WithSameEnterPos_ReturnsTrue() + { + // Arrange - the matching rule is in the parent, not the top + // Share the RuleId instance to match production behavior, + // where the grammar engine reuses the same RuleId object + RuleId sharedRuleIdOfSingleDepth = RuleId.Of(RuleIdSingleDepth); + const int sharedEnterPos = 5; + + StateStack grandparent = new StateStack( + null, + sharedRuleIdOfSingleDepth, + sharedEnterPos, + AnchorPosition, + false, + null, + CreateTestScopeStack(), + CreateTestScopeStack()); + + StateStack parent = grandparent.Push( + RuleId.Of(RuleIdDepthTwo), + sharedEnterPos, + AnchorPosition, + false, + null, + CreateTestScopeStack(), + CreateTestScopeStack()); + + StateStack other = new StateStack( + null, + sharedRuleIdOfSingleDepth, + sharedEnterPos, + AnchorPosition, + false, + null, + CreateTestScopeStack(), + CreateTestScopeStack()); + + // Act - parent's top rule (100) != other's rule (42), but parent's + // grandparent rule (42) == other's rule (42), and enterPos matches + bool result = parent.HasSameRuleAs(other); + + // Assert + Assert.IsTrue(result); + } + + [Test] + public void HasSameRuleAs_AncestorHasMatchingRule_ButDifferentEnterPos_StopsWalking() + { + // Arrange - ancestor has matching rule but different enterPos + StateStack grandparent = new StateStack( + null, + RuleId.Of(RuleIdSingleDepth), + 10, + AnchorPosition, + false, + null, + CreateTestScopeStack(), + CreateTestScopeStack()); + + StateStack parent = grandparent.Push( + RuleId.Of(RuleIdDepthTwo), + 5, + AnchorPosition, + false, + null, + CreateTestScopeStack(), + CreateTestScopeStack()); + + StateStack other = new StateStack( + null, + RuleId.Of(RuleIdSingleDepth), + 5, + AnchorPosition, + false, + null, + CreateTestScopeStack(), + CreateTestScopeStack()); + + // Act - parent's top (enterPos=5) matches other's enterPos (5), but rule differs (100 vs 42). + // grandparent's enterPos (10) != other's enterPos (5), so walk stops before checking. + bool result = parent.HasSameRuleAs(other); + + // Assert + Assert.IsFalse(result); + } + + [Test] + public void HasSameRuleAs_NullParent_StopsWalkingGracefully() + { + // Arrange - single-node stack, no parent to walk to + StateStack left = new StateStack( + null, + RuleId.Of(1), + EnterPosition, + AnchorPosition, + false, + null, + CreateTestScopeStack(), + CreateTestScopeStack()); + + StateStack right = new StateStack( + null, + RuleId.Of(2), + EnterPosition, + AnchorPosition, + false, + null, + CreateTestScopeStack(), + CreateTestScopeStack()); + + // Act & Assert - walks to null parent, returns false + Assert.IsFalse(left.HasSameRuleAs(right)); + } + + #endregion HasSameRuleAs tests + + #region WithContentNameScopesList tests + + [Test] + public void WithContentNameScopesList_SameValue_ReturnsSameInstance() + { + // Arrange + AttributedScopeStack scopeStack = CreateTestScopeStack(); + + StateStack stack = new StateStack( + StateStack.NULL, + RuleId.Of(RuleIdSingleDepth), + EnterPosition, + AnchorPosition, + false, + null, + scopeStack, + scopeStack); + + // Act + StateStack result = stack.WithContentNameScopesList(scopeStack); + + // Assert + Assert.AreSame(stack, result); + } + + [Test] + public void WithContentNameScopesList_DifferentValue_ReturnsNewInstance() + { + // Arrange + AttributedScopeStack original = new AttributedScopeStack(null, "original", 1); + AttributedScopeStack replacement = new AttributedScopeStack(null, "replacement", 2); + + StateStack stack = new StateStack( + StateStack.NULL, + RuleId.Of(RuleIdSingleDepth), + EnterPosition, + AnchorPosition, + false, + null, + CreateTestScopeStack(), + original); + + // Act + StateStack result = stack.WithContentNameScopesList(replacement); + + // Assert + Assert.AreNotSame(stack, result); + Assert.AreSame(replacement, result.ContentNameScopesList); + } + + [Test] + public void WithContentNameScopesList_NullOnBothSides_ReturnsSameInstance() + { + // Arrange - previously would throw NullReferenceException + StateStack stack = new StateStack( + StateStack.NULL, + RuleId.Of(RuleIdSingleDepth), + EnterPosition, + AnchorPosition, + false, + null, + CreateTestScopeStack(), + null); + + // Act + StateStack result = stack.WithContentNameScopesList(null); + + // Assert + Assert.AreSame(stack, result); + } + + [Test] + public void WithContentNameScopesList_NullToNonNull_ReturnsNewInstance() + { + // Arrange + AttributedScopeStack replacement = new AttributedScopeStack(null, "new", 1); + + StateStack stack = new StateStack( + StateStack.NULL, + RuleId.Of(RuleIdSingleDepth), + EnterPosition, + AnchorPosition, + false, + null, + CreateTestScopeStack(), + null); + + // Act + StateStack result = stack.WithContentNameScopesList(replacement); + + // Assert + Assert.AreNotSame(stack, result); + Assert.AreSame(replacement, result.ContentNameScopesList); + } + + #endregion WithContentNameScopesList tests + + #region WithEndRule tests + + [Test] + public void WithEndRule_SameValue_ReturnsSameInstance() + { + // Arrange + StateStack stack = new StateStack( + null, + RuleId.Of(RuleIdSingleDepth), + EnterPosition, + AnchorPosition, + false, + "endRule", + CreateTestScopeStack(), + CreateTestScopeStack()); + + // Act + StateStack result = stack.WithEndRule("endRule"); + + // Assert + Assert.AreSame(stack, result); + } + + [Test] + public void WithEndRule_DifferentValue_ReturnsNewInstance() + { + // Arrange + StateStack stack = new StateStack( + null, + RuleId.Of(RuleIdSingleDepth), + EnterPosition, + AnchorPosition, + false, + "endRuleA", + CreateTestScopeStack(), + CreateTestScopeStack()); + + // Act + StateStack result = stack.WithEndRule("endRuleB"); + + // Assert + Assert.AreNotSame(stack, result); + Assert.AreEqual("endRuleB", result.EndRule); + } + + [Test] + public void WithEndRule_NullToNonNull_ReturnsNewInstance() + { + // Arrange + StateStack stack = new StateStack( + null, + RuleId.Of(RuleIdSingleDepth), + EnterPosition, + AnchorPosition, + false, + null, + CreateTestScopeStack(), + CreateTestScopeStack()); + + // Act + StateStack result = stack.WithEndRule("newEndRule"); + + // Assert + Assert.AreNotSame(stack, result); + Assert.AreEqual("newEndRule", result.EndRule); + } + + [Test] + public void WithEndRule_NullToBothNull_ReturnsNewInstance_MatchesUpstreamBehavior() + { + // Arrange - note: upstream Java also returns a new instance when both are null, + // because the guard only fires when this.EndRule != null + StateStack stack = new StateStack( + null, + RuleId.Of(RuleIdSingleDepth), + EnterPosition, + AnchorPosition, + false, + null, + CreateTestScopeStack(), + CreateTestScopeStack()); + + // Act + StateStack result = stack.WithEndRule(null); + + // Assert - upstream returns new instance in this case + Assert.AreNotSame(stack, result); + } + + #endregion WithEndRule tests + + #region Pop and SafePop tests + + [Test] + public void Pop_ReturnsParent() + { + // Arrange + StateStack parent = new StateStack( + null, + RuleId.Of(RuleIdSingleDepth), + EnterPosition, + AnchorPosition, + false, + null, + CreateTestScopeStack(), + CreateTestScopeStack()); + + StateStack child = parent.Push( + RuleId.Of(RuleIdDepthTwo), + EnterPosition, + AnchorPosition, + false, + null, + CreateTestScopeStack(), + CreateTestScopeStack()); + + // Act + StateStack result = child.Pop(); + + // Assert + Assert.AreSame(parent, result); + } + + [Test] + public void Pop_RootReturnsNull() + { + // Arrange + StateStack root = new StateStack( + null, + RuleId.Of(RuleIdSingleDepth), + EnterPosition, + AnchorPosition, + false, + null, + CreateTestScopeStack(), + CreateTestScopeStack()); + + // Act + StateStack result = root.Pop(); + + // Assert + Assert.IsNull(result); + } + + [Test] + public void SafePop_RootReturnsSelf() + { + // Arrange + StateStack root = new StateStack( + null, + RuleId.Of(RuleIdSingleDepth), + EnterPosition, + AnchorPosition, + false, + null, + CreateTestScopeStack(), + CreateTestScopeStack()); + + // Act + StateStack result = root.SafePop(); + + // Assert + Assert.AreSame(root, result); + } + + [Test] + public void SafePop_NonRootReturnsParent() + { + // Arrange + StateStack parent = new StateStack( + null, + RuleId.Of(RuleIdSingleDepth), + EnterPosition, + AnchorPosition, + false, + null, + CreateTestScopeStack(), + CreateTestScopeStack()); + + StateStack child = parent.Push( + RuleId.Of(RuleIdDepthTwo), + EnterPosition, + AnchorPosition, + false, + null, + CreateTestScopeStack(), + CreateTestScopeStack()); + + // Act + StateStack result = child.SafePop(); + + // Assert + Assert.AreSame(parent, result); + } + + #endregion Pop and SafePop tests + + #region Reset tests + + [Test] + public void Reset_ClearsEnterPosAndAnchorPos() + { + // Arrange + const int initialEnterPos = 10; + const int initialAnchorPos = 20; + + StateStack stack = new StateStack( + null, + RuleId.Of(RuleIdSingleDepth), + initialEnterPos, + initialAnchorPos, + false, + null, + CreateTestScopeStack(), + CreateTestScopeStack()); + + // Act + stack.Reset(); + + // Assert + Assert.AreEqual(-1, stack.GetEnterPos()); + Assert.AreEqual(-1, stack.GetAnchorPos()); + } + + [Test] + public void Reset_ResetsEntireParentChain() + { + // Arrange + StateStack parent = new StateStack( + null, + RuleId.Of(RuleIdSingleDepth), + 10, + 20, + false, + null, + CreateTestScopeStack(), + CreateTestScopeStack()); + + StateStack child = parent.Push( + RuleId.Of(RuleIdDepthTwo), + 30, + 40, + false, + null, + CreateTestScopeStack(), + CreateTestScopeStack()); + + // Act + child.Reset(); + + // Assert + Assert.AreEqual(-1, child.GetEnterPos()); + Assert.AreEqual(-1, child.GetAnchorPos()); + Assert.AreEqual(-1, parent.GetEnterPos()); + Assert.AreEqual(-1, parent.GetAnchorPos()); + } + + #endregion Reset tests + #region Helper Methods private static AttributedScopeStack CreateTestScopeStack() From 28fdd2f28202ff2b15c1711070028087c01a995a Mon Sep 17 00:00:00 2001 From: Dave Black <656118+udlose@users.noreply.github.com> Date: Wed, 4 Mar 2026 11:06:18 -0600 Subject: [PATCH 28/33] Expand AttributedScopeStack test coverage and edge cases Added comprehensive tests for AttributedScopeStack, including equality (reflexivity, symmetry, deep equivalence, null scope path), hash code consistency, operator overloads (==, !=), and ToString formatting for various stack depths and null values. Improves reliability and correctness in edge scenarios. --- .../Grammars/AttributedScopeStackTests.cs | 225 +++++++++++++++++- 1 file changed, 224 insertions(+), 1 deletion(-) diff --git a/src/TextMateSharp.Tests/Internal/Grammars/AttributedScopeStackTests.cs b/src/TextMateSharp.Tests/Internal/Grammars/AttributedScopeStackTests.cs index 2d5b8f8..c203d79 100644 --- a/src/TextMateSharp.Tests/Internal/Grammars/AttributedScopeStackTests.cs +++ b/src/TextMateSharp.Tests/Internal/Grammars/AttributedScopeStackTests.cs @@ -207,7 +207,7 @@ public void Equals_ScopePathNullInBothStacksAtSamePosition_ReturnsTrue() } [Test] - public void Equals_NullScopePathInOneStack_NotEqual() + public void Equals_NullScopePathInOneStack_ReturnsFalse() { // arrange AttributedScopeStack left = CreateStack((null, 1), ("b", 2)); @@ -220,6 +220,53 @@ public void Equals_NullScopePathInOneStack_NotEqual() Assert.IsFalse(result); } + [Test] + public void Equals_IsReflexive() + { + // arrange + AttributedScopeStack stack = CreateStack(("a", 1), ("b", 2)); + + // act + bool result = stack.Equals((object)stack); + + // assert + Assert.IsTrue(result); + } + + [Test] + public void Equals_IsSymmetric() + { + // arrange + AttributedScopeStack left = CreateStack(("a", 1), ("b", 2)); + AttributedScopeStack right = CreateStack(("a", 1), ("b", 2)); + + // act + bool leftEqualsRight = left.Equals((object)right); + bool rightEqualsLeft = right.Equals((object)left); + + // assert + Assert.IsTrue(leftEqualsRight); + Assert.IsTrue(rightEqualsLeft); + } + + [Test] + public void Equals_EquivalentDeepStacks_ReturnsTrue() + { + // arrange + const int depth = 50; + AttributedScopeStack left = null; + AttributedScopeStack right = null; + + for (int i = 0; i < depth; i++) + { + left = new AttributedScopeStack(left, "s" + i, i); + right = new AttributedScopeStack(right, "s" + i, i); + } + + // act & assert + Assert.IsTrue(left!.Equals(right)); + } + [Test] public void Equals_EquivalentButDistinctChains_ReturnsTrue() { @@ -459,6 +506,22 @@ public void GetHashCode_WhenScopePathIsNull_DoesNotThrow_AndIsDeterministic() Assert.AreEqual(first, second); } + [Test] + public void GetHashCode_EqualObjects_ReturnSameValue() + { + // arrange + AttributedScopeStack left = CreateStack(("a", 1), ("b", 2)); + AttributedScopeStack right = CreateStack(("a", 1), ("b", 2)); + + // act + int leftHash = left.GetHashCode(); + int rightHash = right.GetHashCode(); + + // assert + Assert.IsTrue(left.Equals(right)); + Assert.AreEqual(leftHash, rightHash); + } + [Test] public void GetHashCode_WhenStacksAreEqual_ReturnsSameValue() { @@ -587,6 +650,35 @@ public void IEquatable_Equals_SameReference_ReturnsTrue() Assert.IsTrue(result); } + [Test] + public void IEquatable_IsReflexive() + { + // arrange + AttributedScopeStack stack = CreateStack(("a", 1), ("b", 2)); + + // act + bool result = stack.Equals(stack); + + // assert + Assert.IsTrue(result); + } + + [Test] + public void IEquatable_IsSymmetric() + { + // arrange + AttributedScopeStack left = CreateStack(("a", 1)); + AttributedScopeStack right = CreateStack(("a", 1)); + + // act + bool leftEqualsRight = left.Equals(right); + bool rightEqualsLeft = right.Equals(left); + + // assert + Assert.IsTrue(leftEqualsRight); + Assert.IsTrue(rightEqualsLeft); + } + [Test] public void IEquatable_Equals_UsedByEqualityComparerDefault() { @@ -605,6 +697,42 @@ public void IEquatable_Equals_UsedByEqualityComparerDefault() #region Operator == and != tests + [Test] + public void OperatorEquals_IsReflexive() + { + // arrange + AttributedScopeStack stack = CreateStack(("a", 1)); + + // act & assert +#pragma warning disable CS1718 + Assert.IsTrue(stack == stack); +#pragma warning restore CS1718 + } + + [Test] + public void OperatorEquals_IsSymmetric() + { + // arrange + AttributedScopeStack left = CreateStack(("a", 1)); + AttributedScopeStack right = CreateStack(("a", 1)); + + // act & assert + Assert.IsTrue(left == right); + Assert.IsTrue(right == left); + } + + [Test] + public void OperatorNotEquals_IsSymmetric() + { + // arrange + AttributedScopeStack left = CreateStack(("a", 1)); + AttributedScopeStack right = CreateStack(("a", 1)); + + // act & assert + Assert.IsFalse(left != right); + Assert.IsFalse(right != left); + } + [Test] public void OperatorEquals_StructurallyEqualStacks_ReturnsTrue() { @@ -1504,6 +1632,101 @@ public void PushAttributed_SingleScope_PreservesScopeStringInstance() #endregion PushAttributed tests + #region ToString tests + + [Test] + public void ToString_SingleDepthStack_ReturnsFormattedString() + { + // arrange + AttributedScopeStack stack = new AttributedScopeStack(null, "source.cs", 1); + + // act + string result = stack.ToString(); + + // assert + Assert.AreEqual("source.cs", result); + } + + [Test] + public void ToString_MultiDepthStack_ReturnsSpaceSeparatedScopes() + { + // arrange + AttributedScopeStack stack = CreateStack(("source.cs", 1), ("meta.test", 2)); + + // act + string result = stack.ToString(); + + // assert + Assert.AreEqual("source.cs meta.test", result); + } + + [Test] + public void ToString_ThreeDepthStack_ReturnsCorrectOrder() + { + // arrange + AttributedScopeStack stack = CreateStack(("a", 1), ("b", 2), ("c", 3)); + + // act + string result = stack.ToString(); + + // assert + Assert.AreEqual("a b c", result); + } + + [Test] + public void ToString_CalledMultipleTimes_ReturnsSameResult() + { + // arrange + AttributedScopeStack stack = CreateStack(("a", 1), ("b", 2)); + + // act + string result1 = stack.ToString(); + string result2 = stack.ToString(); + string result3 = stack.ToString(); + + // assert + Assert.AreEqual(result1, result2); + Assert.AreEqual(result2, result3); + Assert.AreEqual("a b", result1); + } + + [Test] + public void ToString_BoundaryVeryLargeDepth_ReturnsCorrectFormat() + { + // arrange + const int veryLargeDepth = 100; + AttributedScopeStack current = null; + for (int i = 0; i < veryLargeDepth; i++) + { + current = new AttributedScopeStack(current, "s" + i, i); + } + + // act + string result = current!.ToString(); + + // assert + Assert.IsNotNull(result); + Assert.IsTrue(result.StartsWith("s0 ")); + Assert.IsTrue(result.EndsWith(" s99")); + Assert.AreEqual(100, result.Split(' ').Length); // 100 scopes + } + + [Test] + public void ToString_WithNullScopePath_IncludesEmptyString() + { + // arrange + AttributedScopeStack stack = CreateStack(("a", 1), (null, 2), ("c", 3)); + + // act + string result = stack.ToString(); + + // assert + // null scope path should appear as empty string in output + Assert.AreEqual("a c", result); // note: two spaces (empty string between) + } + + #endregion ToString tests + #region Helpers private static TextMateSharp.Internal.Grammars.Grammar CreateTestGrammar() From 9f53a61df7770466847cf22013eecd4d43f478f1 Mon Sep 17 00:00:00 2001 From: Dave Black <656118+udlose@users.noreply.github.com> Date: Wed, 4 Mar 2026 11:07:48 -0600 Subject: [PATCH 29/33] Add ToString() to AttributedScopeStack for scope display to match upstream implementation Override ToString() in AttributedScopeStack to return a space-separated string of scope names from root to leaf. Includes XML documentation for the new method. --- .../Internal/Grammars/AttributedScopeStack.cs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/TextMateSharp/Internal/Grammars/AttributedScopeStack.cs b/src/TextMateSharp/Internal/Grammars/AttributedScopeStack.cs index 783173b..d41bac5 100644 --- a/src/TextMateSharp/Internal/Grammars/AttributedScopeStack.cs +++ b/src/TextMateSharp/Internal/Grammars/AttributedScopeStack.cs @@ -315,6 +315,15 @@ public List GetScopeNames() return _cachedScopeNames; } + /// + /// Returns a string representation of this scope stack, with scope names separated by spaces. + /// + /// A space-separated string of scope names from root to leaf. + public override string ToString() + { + return string.Join(" ", GetScopeNames()); + } + private static List GenerateScopes(AttributedScopeStack scopesList) { // First pass: count depth to pre-size the list From abff6a91fa9a1dde0720559c2e5559f72b59dba9 Mon Sep 17 00:00:00 2001 From: Dave Black <656118+udlose@users.noreply.github.com> Date: Wed, 4 Mar 2026 12:06:41 -0600 Subject: [PATCH 30/33] Improve StateStack equality, hashing, and documentation Refactored StateStack to implement IEquatable with efficient, null-safe equality and hash code logic. Equality now uses iterative structural comparison and precomputed hash codes for performance. Added operator overloads for == and !=, and replaced GetHashCode with a multiply-accumulate scheme. WithContentNameScopesList uses null-safe comparison. Enhanced HasSameRuleAs to traverse parent chain and check both RuleId and enter position, throwing ArgumentNullException for null input. Added XML documentation for clarity. --- src/TextMateSharp/Grammar/StateStack.cs | 184 +++++++++++++++++++++--- 1 file changed, 163 insertions(+), 21 deletions(-) diff --git a/src/TextMateSharp/Grammar/StateStack.cs b/src/TextMateSharp/Grammar/StateStack.cs index 46e0711..9dd3ff1 100644 --- a/src/TextMateSharp/Grammar/StateStack.cs +++ b/src/TextMateSharp/Grammar/StateStack.cs @@ -12,7 +12,7 @@ public interface IStateStack string EndRule { get; } } - public class StateStack : IStateStack + public class StateStack : IStateStack, IEquatable { public static StateStack NULL = new StateStack( null, @@ -35,6 +35,11 @@ public class StateStack : IStateStack private int _enterPos; private int _anchorPos; + // Precomputed hash code — uses parent's cached hash to avoid O(n) recursion. + // Safe as long as hash-participating fields (Depth, RuleId, EndRule, Parent, + // ContentNameScopesList) are not mutated after construction + private readonly int _hashCode; + public StateStack( StateStack parent, RuleId ruleId, @@ -55,48 +60,164 @@ public StateStack( _enterPos = enterPos; _anchorPos = anchorPos; + + _hashCode = ComputeHashCode(parent, ruleId, endRule, Depth, contentNameScopesList); } + /// + /// A structural equals check. Does not take into account ContentNameScopesList. + /// The consideration for ContentNameScopesList is handled separately in the AttributedScopeStack.Equals method. + /// Iterative to avoid StackOverflowException on deep stacks. + /// private static bool StructuralEquals(StateStack a, StateStack b) { - if (a == b) - { - return true; - } - if (a == null || b == null) + while (true) { - return false; + // Use ReferenceEquals to avoid infinite recursion through operator == + if (ReferenceEquals(a, b)) + { + return true; + } + if (a is null || b is null) + { + // End of list reached only for one + return false; + } + // Use object.Equals for null-safe value equality on RuleId and EndRule, + // matching Java upstream's Objects.equals() semantics + if (a.Depth != b.Depth || !Equals(a.RuleId, b.RuleId) || !Equals(a.EndRule, b.EndRule)) + { + return false; + } + + // Go to previous pair + a = a.Parent; + b = b.Parent; } - return a.Depth == b.Depth && a.RuleId == b.RuleId && Equals(a.EndRule, b.EndRule) && StructuralEquals(a.Parent, b.Parent); } - public override bool Equals(Object other) + /// + /// Determines whether two StateStack instances are equal by comparing their structure and associated scope + /// lists. + /// + /// This method first checks for reference equality and null values before comparing + /// precomputed hash codes for efficiency. If necessary, it performs a structural comparison of the StateStack + /// instances and their associated scope lists. This method is intended for internal use to support equality + /// operations. + /// The first StateStack instance to compare, or null. + /// The second StateStack instance to compare, or null. + /// true if both StateStack instances are equal; otherwise, false. + private static bool Equals(StateStack a, StateStack b) { - if (other == this) + // Use ReferenceEquals to avoid infinite recursion through operator == + if (ReferenceEquals(a, b)) { return true; } - if (other == null) + if (a is null || b is null) + { + return false; + } + + // Precomputed hash codes let us reject non-equal pairs in O(1) + // before walking the O(n) parent chain in StructuralEquals + if (a._hashCode != b._hashCode) { return false; } + return StructuralEquals(a, b) && + // Null-safe comparison via the internal static method on AttributedScopeStack + AttributedScopeStack.Equals(a.ContentNameScopesList, b.ContentNameScopesList); + } + + /// + /// Determines whether the specified StateStack instance is equal to the current instance. + /// + /// The StateStack instance to compare with the current instance. + /// true if the specified StateStack instance is equal to the current instance; otherwise, false. + public bool Equals(StateStack other) + { + return Equals(this, other); + } + + /// + /// Determines whether the specified object is equal to the current StateStack instance. + /// + /// This method overrides Object.Equals to provide value equality specific to StateStack + /// instances. Use this method to compare StateStack objects for logical equivalence rather than reference + /// equality. + /// The object to compare with the current StateStack. Must be of type StateStack to be considered for equality. + /// true if the specified object is a StateStack and is equal to the current instance; otherwise, false. + public override bool Equals(object other) + { if (other is StateStack stackElement) { - return StructuralEquals(this, stackElement) && - this.ContentNameScopesList.Equals(stackElement.ContentNameScopesList); + return Equals(this, stackElement); } return false; } + /// + /// Returns a hash code for the current instance. + /// + /// The hash code is used in hash-based collections such as hash tables. Equal objects + /// must return the same hash code for correct behavior in these collections. + /// An integer that represents the hash code for this instance. public override int GetHashCode() { - return Depth.GetHashCode() + - RuleId.GetHashCode() + - EndRule.GetHashCode() + - Parent.GetHashCode() + - ContentNameScopesList.GetHashCode(); + return _hashCode; + } + + /// + /// Determines whether two StateStack instances are equal. + /// + /// This operator uses the Equals method to determine equality. When implementing + /// equality operators, it is recommended to also override the Equals(object) and GetHashCode() methods to + /// ensure consistent behavior. + /// The first StateStack instance to compare. + /// The second StateStack instance to compare. + /// true if the specified StateStack instances are equal; otherwise, false. + public static bool operator ==(StateStack left, StateStack right) + { + return Equals(left, right); + } + + /// + /// Determines whether two instances of the StateStack class are not equal. + /// + /// This operator uses the Equals method to compare the specified instances. + /// The first StateStack instance to compare. + /// The second StateStack instance to compare. + /// true if the two StateStack instances are not equal; otherwise, false. + public static bool operator !=(StateStack left, StateStack right) + { + return !Equals(left, right); + } + + /// + /// Computes a hash code using multiply-accumulate (factor 31) for good + /// distribution. References parent._hashCode instead of calling + /// parent.GetHashCode() to keep this O(1) per node rather than O(n). + /// Builds incrementally on parent's hash. + /// + private static int ComputeHashCode( + StateStack parent, + RuleId ruleId, + string endRule, + int depth, + AttributedScopeStack contentNameScopesList) + { + const int primeFactor = 31; // Common prime factor for multiply-accumulate hash code + unchecked + { + int hash = (parent?._hashCode) ?? 0; + hash = (hash * primeFactor) + (contentNameScopesList?.GetHashCode() ?? 0); + hash = (hash * primeFactor) + (endRule?.GetHashCode() ?? 0); + hash = (hash * primeFactor) + (ruleId?.GetHashCode() ?? 0); + return (hash * primeFactor) + depth; + } } public void Reset() @@ -183,7 +304,7 @@ public override string ToString() } builder.Append('('); - builder.Append(ruleIds[i].ToString()); //, TODO-${this.nameScopesList}, TODO-${this.contentNameScopesList})`; + builder.Append(ruleIds[i]); //, TODO-${this.nameScopesList}, TODO-${this.contentNameScopesList})`; builder.Append(')'); } @@ -193,7 +314,8 @@ public override string ToString() public StateStack WithContentNameScopesList(AttributedScopeStack contentNameScopesList) { - if (this.ContentNameScopesList.Equals(contentNameScopesList)) + // Null-safe comparison matching Java upstream's Objects.equals() pattern + if (AttributedScopeStack.Equals(this.ContentNameScopesList, contentNameScopesList)) { return this; } @@ -224,9 +346,29 @@ public StateStack WithEndRule(string endRule) this.ContentNameScopesList); } + /// + /// Determines whether the current state stack shares the same rule as the specified state stack. + /// + /// The comparison traverses the parent state stacks of both instances and checks for a + /// matching rule identifier and entry position. This method can be used to determine if two state stacks are + /// associated with the same parsing rule in a grammar. + /// The state stack to compare with the current instance. This parameter cannot be null. + /// true if the current state stack and the specified state stack share the same rule; otherwise, false. + /// Thrown when the other parameter is null. public bool HasSameRuleAs(StateStack other) { - return this.RuleId == other.RuleId; + if (other is null) throw new ArgumentNullException(nameof(other)); + + StateStack el = this; + while (el is not null && el._enterPos == other._enterPos) + { + if (el.RuleId == other.RuleId) + { + return true; + } + el = el.Parent; + } + return false; } } } \ No newline at end of file From 29e09fe7d290eeeafc3bdc5fcf7ed79d41d46ebd Mon Sep 17 00:00:00 2001 From: Dave Black <656118+udlose@users.noreply.github.com> Date: Wed, 4 Mar 2026 12:22:59 -0600 Subject: [PATCH 31/33] Add unit test for param validation in HasSameRuleAs --- .../Grammar/StateStackTests.cs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/TextMateSharp.Tests/Grammar/StateStackTests.cs b/src/TextMateSharp.Tests/Grammar/StateStackTests.cs index 76654af..bfe7425 100644 --- a/src/TextMateSharp.Tests/Grammar/StateStackTests.cs +++ b/src/TextMateSharp.Tests/Grammar/StateStackTests.cs @@ -1,4 +1,5 @@ using NUnit.Framework; +using System; using System.Collections.Generic; using TextMateSharp.Grammars; using TextMateSharp.Internal.Grammars; @@ -1537,6 +1538,24 @@ public void OperatorNotEquals_IsSymmetric() #region HasSameRuleAs tests + [Test] + public void HasSameRuleAs_ThrowsArgumentNullException_WhenOtherIsNull() + { + // Arrange + StateStack stack = new StateStack( + null, + RuleId.Of(RuleIdSingleDepth), + EnterPosition, + AnchorPosition, + false, + null, + CreateTestScopeStack(), + CreateTestScopeStack()); + + // Act & Assert + Assert.Throws(() => stack.HasSameRuleAs(null)); + } + [Test] public void HasSameRuleAs_SameRuleAtTop_ReturnsTrue() { From b3972dcdf4ea7eb3d656928eebab921908831808 Mon Sep 17 00:00:00 2001 From: Dave Black <656118+udlose@users.noreply.github.com> Date: Wed, 4 Mar 2026 12:23:33 -0600 Subject: [PATCH 32/33] Optimize matcher list allocation with initial capacity Initialized List> matchers with a capacity of 4 in two places to improve memory usage and performance when adding multiple matchers. --- src/TextMateSharp/Internal/Matcher/MatcherBuilder.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/TextMateSharp/Internal/Matcher/MatcherBuilder.cs b/src/TextMateSharp/Internal/Matcher/MatcherBuilder.cs index b53465c..753e5ba 100644 --- a/src/TextMateSharp/Internal/Matcher/MatcherBuilder.cs +++ b/src/TextMateSharp/Internal/Matcher/MatcherBuilder.cs @@ -64,7 +64,7 @@ private Predicate ParseInnerExpression() return firstMatcher; } - List> matchers = new List>(); + List> matchers = new List>(4); matchers.Add(firstMatcher); while (true) { @@ -127,7 +127,7 @@ private Predicate ParseConjunction() return firstMatcher; } - List> matchers = new List>(); + List> matchers = new List>(4); matchers.Add(firstMatcher); matchers.Add(secondMatcher); From 50acd6bfab681469f833242b861601ee47b86351 Mon Sep 17 00:00:00 2001 From: Dave Black <656118+udlose@users.noreply.github.com> Date: Wed, 4 Mar 2026 12:44:56 -0600 Subject: [PATCH 33/33] Use ReferenceEquals for null checks, improve performance to avoid O(n) checks that would be triggered since we overload ==, !=, etc. and are unable to use 'is not' because we are restricted to C# 8.0 Replaced standard null checks with ReferenceEquals to avoid issues with overloaded equality operators and improve performance. Added explanatory comments and made minor code style adjustments for clarity and consistency. --- src/TextMateSharp/Grammar/StateStack.cs | 18 ++++++++++++------ .../Internal/Grammars/AttributedScopeStack.cs | 17 +++++++++++------ 2 files changed, 23 insertions(+), 12 deletions(-) diff --git a/src/TextMateSharp/Grammar/StateStack.cs b/src/TextMateSharp/Grammar/StateStack.cs index 9dd3ff1..f5d4528 100644 --- a/src/TextMateSharp/Grammar/StateStack.cs +++ b/src/TextMateSharp/Grammar/StateStack.cs @@ -35,7 +35,7 @@ public class StateStack : IStateStack, IEquatable private int _enterPos; private int _anchorPos; - // Precomputed hash code — uses parent's cached hash to avoid O(n) recursion. + // Precomputed hash code - uses parent's cached hash to avoid O(n) recursion. // Safe as long as hash-participating fields (Depth, RuleId, EndRule, Parent, // ContentNameScopesList) are not mutated after construction private readonly int _hashCode; @@ -51,7 +51,9 @@ public StateStack( AttributedScopeStack contentNameScopesList) { Parent = parent; - Depth = (this.Parent != null ? this.Parent.Depth + 1 : 1); + + // Use ReferenceEquals to bypass overloaded != operator for performance + Depth = (!ReferenceEquals(this.Parent, null) ? this.Parent.Depth + 1 : 1); RuleId = ruleId; BeginRuleCapturedEOL = beginRuleCapturedEOL; EndRule = endRule; @@ -212,7 +214,7 @@ private static int ComputeHashCode( const int primeFactor = 31; // Common prime factor for multiply-accumulate hash code unchecked { - int hash = (parent?._hashCode) ?? 0; + int hash = parent?._hashCode ?? 0; hash = (hash * primeFactor) + (contentNameScopesList?.GetHashCode() ?? 0); hash = (hash * primeFactor) + (endRule?.GetHashCode() ?? 0); hash = (hash * primeFactor) + (ruleId?.GetHashCode() ?? 0); @@ -223,7 +225,8 @@ private static int ComputeHashCode( public void Reset() { StateStack el = this; - while (el != null) + // Use ReferenceEquals to bypass overloaded != operator for performance + while (!ReferenceEquals(el, null)) { el._enterPos = -1; el._anchorPos = -1; @@ -238,7 +241,8 @@ public StateStack Pop() public StateStack SafePop() { - if (this.Parent != null) + // Use ReferenceEquals to bypass overloaded != operator for performance + if (!ReferenceEquals(this.Parent, null)) { return this.Parent; } @@ -360,7 +364,9 @@ public bool HasSameRuleAs(StateStack other) if (other is null) throw new ArgumentNullException(nameof(other)); StateStack el = this; - while (el is not null && el._enterPos == other._enterPos) + + // Use ReferenceEquals to bypass overloaded != operator for performance + while (!ReferenceEquals(el, null) && el._enterPos == other._enterPos) { if (el.RuleId == other.RuleId) { diff --git a/src/TextMateSharp/Internal/Grammars/AttributedScopeStack.cs b/src/TextMateSharp/Internal/Grammars/AttributedScopeStack.cs index d41bac5..c44dd64 100644 --- a/src/TextMateSharp/Internal/Grammars/AttributedScopeStack.cs +++ b/src/TextMateSharp/Internal/Grammars/AttributedScopeStack.cs @@ -150,14 +150,14 @@ private static int ComputeHashCode(AttributedScopeStack parent, string scopePath int hash = parent?._hashCode ?? seed; hash = (hash * primeFactor) + tokenAttributes; - var scopeHashCode = scopePath == null ? 0 : StringComparer.Ordinal.GetHashCode(scopePath); + int scopeHashCode = scopePath == null ? 0 : StringComparer.Ordinal.GetHashCode(scopePath); return (hash * primeFactor) + scopeHashCode; } } static bool MatchesScope(string scope, string selector) { - if (scope == null || selector == null) + if (scope is null || selector is null) { return false; } @@ -177,7 +177,7 @@ static bool MatchesScope(string scope, string selector) static bool Matches(AttributedScopeStack target, List parentScopes) { - if (parentScopes == null || parentScopes.Count == 0) + if (parentScopes is null || parentScopes.Count == 0) { return true; } @@ -186,7 +186,8 @@ static bool Matches(AttributedScopeStack target, List parentScopes) int index = 0; string selector = parentScopes[index]; - while (target != null) + // Use ReferenceEquals to bypass overloaded != operator for performance + while (!ReferenceEquals(target, null)) { if (MatchesScope(target.ScopePath, selector)) { @@ -329,7 +330,9 @@ private static List GenerateScopes(AttributedScopeStack scopesList) // First pass: count depth to pre-size the list int depth = 0; AttributedScopeStack current = scopesList; - while (current != null) + + // Use ReferenceEquals to bypass overloaded != operator for performance + while (!ReferenceEquals(current, null)) { depth++; current = current.Parent; @@ -338,7 +341,9 @@ private static List GenerateScopes(AttributedScopeStack scopesList) // initialize exact capacity to avoid resizing List result = new List(depth); current = scopesList; - while (current != null) + + // Use ReferenceEquals to bypass overloaded != operator for performance + while (!ReferenceEquals(current, null)) { result.Add(current.ScopePath); current = current.Parent;