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 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.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/Grammar/StateStackTests.cs b/src/TextMateSharp.Tests/Grammar/StateStackTests.cs new file mode 100644 index 0000000..bfe7425 --- /dev/null +++ b/src/TextMateSharp.Tests/Grammar/StateStackTests.cs @@ -0,0 +1,2120 @@ +using NUnit.Framework; +using System; +using System.Collections.Generic; +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; + + #region ToString tests + + [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); + } + + #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_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() + { + // 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() + { + 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 diff --git a/src/TextMateSharp.Tests/Internal/Grammars/AttributedScopeStackTests.cs b/src/TextMateSharp.Tests/Internal/Grammars/AttributedScopeStackTests.cs index 590933d..c203d79 100644 --- a/src/TextMateSharp.Tests/Internal/Grammars/AttributedScopeStackTests.cs +++ b/src/TextMateSharp.Tests/Internal/Grammars/AttributedScopeStackTests.cs @@ -1,11 +1,31 @@ +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 { [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 +61,1750 @@ 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_ReturnsFalse() + { + // 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_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() + { + // 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, [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, [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, [null, null]); + + // assert + Assert.NotNull(result); + Assert.IsTrue((bool)result); + } + + #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_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() + { + // 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_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 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_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() + { + // 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_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() + { + // 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] + 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 = []; + 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 = ["nonexistent"]; + ThemeTrieElementRule rule2 = new ThemeTrieElementRule( + "r2", + 1, + rule2ParentScopes, + FontStyle.Underline, + 99, + 98); + + List themeData = [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 = ["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 = ["meta.using", "source.csharp"]; + ThemeTrieElementRule rule2 = new ThemeTrieElementRule( + "r2", + 1, + rule2ParentScopes, + rule2FontStyle, + rule2Foreground, + rule2Background); + + List themeData = [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 = [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 = ["meta"]; + ThemeTrieElementRule rule = new ThemeTrieElementRule( + "prefix-parent", + 1, + parentScopes, + expectedFontStyle, + expectedForeground, + expectedBackground); + + List themeData = [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)); + } + + // 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_PreservesExistingStyle() + { + // arrange + int existing = CreateNonDefaultEncodedMetadata(); + AttributedScopeStack scopesList = new AttributedScopeStack(null, AnyScopePath, existing); + + const int expectedForeground = 123; + const int expectedBackground = 124; + + List emptyParentScopes = []; + ThemeTrieElementRule rule = new ThemeTrieElementRule( + "empty-parents", + 1, + emptyParentScopes, + ExistingFontStyle, + expectedForeground, + expectedBackground); + + List themeData = [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_RuleFontStyleNotSet_PreservesExistingFontStyle() + { + // arrange + int existing = CreateNonDefaultEncodedMetadata(); + AttributedScopeStack scopesList = new AttributedScopeStack(null, AnyScopePath, existing); + + const int expectedForeground = 123; + const int expectedBackground = 124; + + List parentScopes = [AnyScopePath]; + ThemeTrieElementRule rule = new ThemeTrieElementRule( + "preserve-style", + 1, + parentScopes, + FontStyle.NotSet, + expectedForeground, + expectedBackground); + + List themeData = [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; + + List parentScopes = [AnyScopePath]; + ThemeTrieElementRule rule = new ThemeTrieElementRule( + "preserve-fg", + 1, + parentScopes, + expectedFontStyle, + 0, + expectedBackground); + + List themeData = [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 = [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 = ["does.not.exist"]; + ThemeTrieElementRule nonMatchingRule = new ThemeTrieElementRule( + "non-match", + 1, + nonMatchingParentScopes, + FontStyle.Italic, + 200, + 201); + + List themeData = [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 = ["source.csharp"]; + ThemeTrieElementRule rule = new ThemeTrieElementRule( + "requires-parent", + 1, + parentScopes, + FontStyle.Italic, + 200, + 201); + + List themeData = [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 = ["meta"]; + ThemeTrieElementRule rule = new ThemeTrieElementRule( + "prefix-dot-boundary", + 1, + parentScopes, + FontStyle.Italic, + 200, + 201); + + List themeData = [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 = ["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 = [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 + 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 = ["meta"]; + ThemeTrieElementRule rule = new ThemeTrieElementRule("null-scopepath", 1, parentScopes, FontStyle.Italic, 11, 12); + + List themeData = [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 MatchesScope tests + + [Test] + public void MergeAttributes_MatchesScope_NullScope_DoesNotMatch() + { + // arrange + int existing = CreateNonDefaultEncodedMetadata(); + AttributedScopeStack scopesList = new AttributedScopeStack(null, null, existing); + + List parentScopes = ["source"]; + ThemeTrieElementRule rule = new ThemeTrieElementRule("null-scope", 1, parentScopes, FontStyle.Italic, 101, 102); + BasicScopeAttributes attrs = new BasicScopeAttributes(NewLanguageId, NewTokenType, [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 = [null]; + ThemeTrieElementRule rule = new ThemeTrieElementRule("null-selector", 1, parentScopes, FontStyle.Italic, 111, 112); + BasicScopeAttributes attrs = new BasicScopeAttributes(NewLanguageId, NewTokenType, [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 = ["source.js"]; + ThemeTrieElementRule rule = new ThemeTrieElementRule("exact-match", 1, parentScopes, expectedFontStyle, expectedForeground, expectedBackground); + BasicScopeAttributes attrs = new BasicScopeAttributes(NewLanguageId, NewTokenType, [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 = ["source"]; + ThemeTrieElementRule rule = new ThemeTrieElementRule("prefix-dot", 1, parentScopes, expectedFontStyle, expectedForeground, expectedBackground); + BasicScopeAttributes attrs = new BasicScopeAttributes(NewLanguageId, NewTokenType, [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 = ["source"]; + ThemeTrieElementRule rule = new ThemeTrieElementRule("prefix-no-dot", 1, parentScopes, FontStyle.Italic, 221, 222); + BasicScopeAttributes attrs = new BasicScopeAttributes(NewLanguageId, NewTokenType, [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] + 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)); + } + + [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 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() + { + 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([]); + + return new TextMateSharp.Internal.Grammars.Grammar( + scopeName, + rawGrammar, + 0, + null, + null, + new BalancedBracketSelectors([], []), + new Mock().Object, + themeProvider.Object); + } + + 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); + } + + // 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 } } 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..34530fb --- /dev/null +++ b/src/TextMateSharp.Tests/Internal/Grammars/Parser/RawTests.cs @@ -0,0 +1,962 @@ +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; + // don't use AddRange here, we want to test the enumerator directly + 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 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 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"), diff --git a/src/TextMateSharp.Tests/Themes/ParsedThemeTests.cs b/src/TextMateSharp.Tests/Themes/ParsedThemeTests.cs new file mode 100644 index 0000000..fad92b4 --- /dev/null +++ b/src/TextMateSharp.Tests/Themes/ParsedThemeTests.cs @@ -0,0 +1,2722 @@ +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 + { + #region ParseInclude tests + + [Test] + public void ParseInclude_SourceGetIncludeReturnsNull_ReturnsEmptyListAndSetsThemeIncludeToNull() + { + // Arrange + Mock mockSource = new Mock(); + mockSource.Setup(s => s.GetInclude()).Returns((string)null); + Mock mockRegistryOptions = new Mock(); + + // Act + List result = ParsedTheme.ParseInclude(mockSource.Object, mockRegistryOptions.Object, 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(); + + // Act + List result = ParsedTheme.ParseInclude(mockSource.Object, mockRegistryOptions.Object, 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); + + // Act + List result = ParsedTheme.ParseInclude(mockSource.Object, mockRegistryOptions.Object, 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); + + // Act + List result = ParsedTheme.ParseInclude(mockSource.Object, mockRegistryOptions.Object, 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, + 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); + + // Act + List result = ParsedTheme.ParseInclude(mockSource.Object, mockRegistryOptions.Object, 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); + + // Act + List result = ParsedTheme.ParseInclude(mockSource.Object, mockRegistryOptions.Object, 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); + } + + readyEvent.Wait(); // Wait for all threads to be ready + + // Get expected result AFTER threads are ready but BEFORE they start + // This tests concurrent cache misses on first access + List expectedResult = parsedTheme.Match(testScope); + + startEvent.Set(); // Release all threads simultaneously + Task.WaitAll(tasks.ToArray()); + + readyEvent.Dispose(); + startEvent.Dispose(); + + // 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"); + } + } + + [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, 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) + 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(); + + // Act + ParsedTheme.ParsedGuiColors(mockTheme.Object, colorDictionary); + + // 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); + + // 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); + + // 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); + + // 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); + + // 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); + + // 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); + + // 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); + + // 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); + + // 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); + + // 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); + + // 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); + + // 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); + + // 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); + + // 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); + + // 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); + + // 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); + + // 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); + + // 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); + + // 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); + + // 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); + + // 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); + + // 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); + + // 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); + + // 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); + + // 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); + + // 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); + + // 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); + + // 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); + + // 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); + + // 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); + + // 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); + + // 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); + + // 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); + + // 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); + + // 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); + + // 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); + + // 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); + + // 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); + + // 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); + + // 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); + + // 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); + + // 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); + + // 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); + + // 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); + + // 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); + + // 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); + + // 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); + + // 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); + + // Assert + 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"); + } + + [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); + + // 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); + + // 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); + + // 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; + + // Compare public properties for equivalence + return rule1.fontStyle == rule2.fontStyle && + rule1.foreground == rule2.foreground && + rule1.background == rule2.background; + } + + #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 new file mode 100644 index 0000000..aa55fab --- /dev/null +++ b/src/TextMateSharp.Tests/Themes/ThemeTests.cs @@ -0,0 +1,1078 @@ +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; + + #region GetColorId tests + + [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 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() + { + // 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); + } + + #endregion GetGuiColorDictionary tests + + #region GetColorMap tests + + [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); + } + + #endregion GetColorMap tests + + #region GetColor tests + + [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); + } + + #endregion GetColor tests + + #region Match tests + + [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"); + } + } + + #endregion Match tests + + #region ThemeTrieElementRule tests + + [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"); + } + + #endregion ThemeTrieElementRule tests + + #region Default rule processing tests (Add to existing ThemeTests.cs) + + [Test] + public void CreateFromRawTheme_WithEmptyScopeRule_SetsDefaultFontStyle() + { + // Arrange + const string foregroundColor = "#FFFFFF"; + const string backgroundColor = "#000000"; + + 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 + Theme theme = Theme.CreateFromRawTheme(rawTheme, mockRegistryOptions.Object); + + // 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 CreateFromRawTheme_WithMultipleEmptyScopeRules_LastRuleWinsForEachProperty() + { + // Arrange + const string firstForeground = "#111111"; + const string secondForeground = "#222222"; + const string finalForeground = "#333333"; + const string finalBackground = "#444444"; + + 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 + Theme theme = Theme.CreateFromRawTheme(rawTheme, mockRegistryOptions.Object); + + // 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 CreateFromRawTheme_EmptyScopeWithNotSetFontStyle_KeepsDefaultFontStyle() + { + // 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 + Theme theme = Theme.CreateFromRawTheme(rawTheme, mockRegistryOptions.Object); + + // 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 CreateFromRawTheme_EmptyScopeWithNullColors_KeepsDefaultColors() + { + // Arrange + 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 + Theme theme = Theme.CreateFromRawTheme(rawTheme, mockRegistryOptions.Object); + + // 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 CreateFromRawTheme_EmptyScopeWithOnlyForeground_OverridesForegroundOnly() + { + // Arrange + 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 + Theme theme = Theme.CreateFromRawTheme(rawTheme, mockRegistryOptions.Object); + + // Assert + 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 CreateFromRawTheme_EmptyScopeWithOnlyBackground_OverridesBackgroundOnly() + { + // Arrange + 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 + Theme theme = Theme.CreateFromRawTheme(rawTheme, mockRegistryOptions.Object); + + // Assert + 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 CreateFromRawTheme_NoEmptyScopeRules_UsesHardcodedDefaults() + { + // 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 + Theme theme = Theme.CreateFromRawTheme(rawTheme, mockRegistryOptions.Object); + + // 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() + { + 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 diff --git a/src/TextMateSharp/Grammar/StateStack.cs b/src/TextMateSharp/Grammar/StateStack.cs index aaa248b..f5d4528 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; @@ -13,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, @@ -36,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, @@ -47,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; @@ -56,51 +62,171 @@ 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; } - if (!(other is StateStack)) { + + // 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; } - StateStack stackElement = (StateStack)other; - return StructuralEquals(this, stackElement) && this.ContentNameScopesList.Equals(stackElement.ContentNameScopesList); + + 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 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() { StateStack el = this; - while (el != null) + // Use ReferenceEquals to bypass overloaded != operator for performance + while (!ReferenceEquals(el, null)) { el._enterPos = -1; el._anchorPos = -1; @@ -115,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; } @@ -157,26 +284,42 @@ 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]); //, TODO-${this.nameScopesList}, TODO-${this.contentNameScopesList})`; + builder.Append(')'); + } + + builder.Append(']'); + return builder.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; } @@ -207,9 +350,31 @@ 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; + + // Use ReferenceEquals to bypass overloaded != operator for performance + while (!ReferenceEquals(el, null) && el._enterPos == other._enterPos) + { + if (el.RuleId == other.RuleId) + { + return true; + } + el = el.Parent; + } + return false; } } } \ No newline at end of file diff --git a/src/TextMateSharp/Internal/Grammars/AttributedScopeStack.cs b/src/TextMateSharp/Internal/Grammars/AttributedScopeStack.cs index 70ab106..c44dd64 100644 --- a/src/TextMateSharp/Internal/Grammars/AttributedScopeStack.cs +++ b/src/TextMateSharp/Internal/Grammars/AttributedScopeStack.cs @@ -5,42 +5,42 @@ namespace TextMateSharp.Internal.Grammars { - public class AttributedScopeStack + public class AttributedScopeStack : IEquatable { public AttributedScopeStack Parent { get; private set; } public string ScopePath { get; private set; } public int TokenAttributes { get; private set; } private List _cachedScopeNames; + // 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) { - do + while (true) { - if (a == b) - { - return true; - } - - if (a == null && b == null) + // Use ReferenceEquals to avoid infinite recursion through operator == + if (ReferenceEquals(a, b)) { - // End of list reached for both return true; } - if (a == null || b == null) + if (a is null || b is null) { // End of list reached only for one 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; } @@ -48,46 +48,136 @@ 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) + // 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; } + + // 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); } + /// + /// 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 == null || !(other is AttributedScopeStack)) - return false; + if (other is AttributedScopeStack attributedScopeStack) + return Equals(this, attributedScopeStack); - return Equals(this, (AttributedScopeStack)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 Parent.GetHashCode() + - ScopePath.GetHashCode() + - TokenAttributes.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); + } - static bool MatchesScope(string scope, string selector, string selectorWithDot) + /// + /// 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 (selector.Equals(scope) || scope.StartsWith(selectorWithDot)); + 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 ?? seed; + hash = (hash * primeFactor) + tokenAttributes; + + int scopeHashCode = scopePath == null ? 0 : StringComparer.Ordinal.GetHashCode(scopePath); + return (hash * primeFactor) + scopeHashCode; + } + } + + static bool MatchesScope(string scope, string selector) + { + if (scope is null || selector is 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) { - if (parentScopes == null) + if (parentScopes is null || parentScopes.Count == 0) { return true; } @@ -95,11 +185,11 @@ static bool Matches(AttributedScopeStack target, List parentScopes) int len = parentScopes.Count; int index = 0; string selector = parentScopes[index]; - string selectorWithDot = selector + "."; - while (target != null) + // Use ReferenceEquals to bypass overloaded != operator for performance + while (!ReferenceEquals(target, null)) { - if (MatchesScope(target.ScopePath, selector, selectorWithDot)) + if (MatchesScope(target.ScopePath, selector)) { index++; if (index == len) @@ -107,7 +197,6 @@ static bool Matches(AttributedScopeStack target, List parentScopes) return true; } selector = parentScopes[index]; - selectorWithDot = selector + '.'; } target = target.Parent; } @@ -132,8 +221,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; @@ -154,15 +245,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); @@ -176,10 +296,12 @@ 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 - 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); @@ -194,16 +316,41 @@ 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) { - List result = new List(); - while (scopesList != null) + // First pass: count depth to pre-size the list + int depth = 0; + AttributedScopeStack current = scopesList; + + // Use ReferenceEquals to bypass overloaded != operator for performance + while (!ReferenceEquals(current, null)) { - result.Add(scopesList.ScopePath); - scopesList = scopesList.Parent; + depth++; + current = current.Parent; } + + // initialize exact capacity to avoid resizing + List result = new List(depth); + current = scopesList; + + // Use ReferenceEquals to bypass overloaded != operator for performance + while (!ReferenceEquals(current, null)) + { + result.Add(current.ScopePath); + current = current.Parent; + } + result.Reverse(); return result; } } -} +} \ No newline at end of file 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]); diff --git a/src/TextMateSharp/Internal/Matcher/IMatchesName.cs b/src/TextMateSharp/Internal/Matcher/IMatchesName.cs index c554b46..3c36bfb 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 @@ -15,28 +15,39 @@ 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; } 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; + // Start the next search immediately after the current match + lastIndex = i + 1; + found = true; + break; } } - return false; - }); + if (!found) + { + return false; + } + } + + return true; } - private bool ScopesAreMatching(string thisScopeName, string scopeName) + private static bool ScopesAreMatching(string thisScopeName, string scopeName) { if (thisScopeName == null) { @@ -47,8 +58,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..753e5ba 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() { - List> matchers = new List>(); - Predicate matcher = ParseConjunction(); - while (matcher != null) + 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>(4); + 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() { - List> matchers = new List>(); + 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>(4); + 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/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() 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()); 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; } diff --git a/src/TextMateSharp/Themes/Theme.cs b/src/TextMateSharp/Themes/Theme.cs index e179c31..338d0b7 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,15 +23,15 @@ 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, colorMap); 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.. @@ -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,27 +102,42 @@ 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 = ' '; - internal static List ParseTheme(IRawTheme source, int priority) + // 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) { 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; } - internal static void ParsedGuiColors(IRawTheme source, Dictionary colorDictionary) + internal static void ParsedGuiColors(IRawTheme source, Dictionary colorDictionary) { var colors = source.GetGuiColors(); if (colors == null) @@ -129,32 +154,27 @@ internal static void ParsedGuiColors(IRawTheme source, Dictionary internal static List ParseInclude( IRawTheme source, IRegistryOptions registryOptions, - 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 ParseTheme(themeInclude, priority); + return new List(); + return ParseTheme(themeInclude); } static void LookupThemeRules( ICollection settings, - List parsedThemeRules, - int priority) + List parsedThemeRules) { if (settings == null) return; @@ -168,77 +188,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 +291,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 +406,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 +438,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 +453,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() 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());