From 4ac88b3abcd16c0fcc5f069b9aeccef32510ab37 Mon Sep 17 00:00:00 2001 From: Dave Black <656118+udlose@users.noreply.github.com> Date: Mon, 16 Feb 2026 21:15:20 -0600 Subject: [PATCH 01/13] Add comprehensive unit tests for DecodeMap class Introduce DecodeMapTests with extensive NUnit test coverage for the DecodeMap class, verifying token ID assignment, reuse, string reconstruction, and handling of edge cases such as empty segments and separators. Ensures correct round-trip behavior and robustness across various scenarios. --- .../Model/DecodeMapTests.cs | 312 ++++++++++++++++++ 1 file changed, 312 insertions(+) create mode 100644 src/TextMateSharp.Tests/Model/DecodeMapTests.cs diff --git a/src/TextMateSharp.Tests/Model/DecodeMapTests.cs b/src/TextMateSharp.Tests/Model/DecodeMapTests.cs new file mode 100644 index 0000000..64c9f1c --- /dev/null +++ b/src/TextMateSharp.Tests/Model/DecodeMapTests.cs @@ -0,0 +1,312 @@ +using NUnit.Framework; + +using System.Collections.Generic; + +using TextMateSharp.Model; + +namespace TextMateSharp.Tests.Model +{ + [TestFixture] + internal class DecodeMapTests + { + [Test] + public void DecodeMap_Should_Initialize_PrevToken() + { + // arrange + DecodeMap decodeMap = new DecodeMap(); + + // act + TMTokenDecodeData prevToken = decodeMap.PrevToken; + + // assert + Assert.IsNotNull(prevToken); + } + + [Test] + public void DecodeMap_GetToken_Should_Return_Empty_String_When_No_Tokens_Were_Assigned() + { + // arrange + DecodeMap decodeMap = new DecodeMap(); + Dictionary tokenMap = new Dictionary(); + + // act + string token = decodeMap.GetToken(tokenMap); + + // assert + Assert.AreEqual(string.Empty, token); + } + + [Test] + public void DecodeMap_GetToken_Should_Return_Empty_String_When_TokenMap_Is_Empty_Even_After_Assigning_Tokens() + { + // arrange + DecodeMap decodeMap = new DecodeMap(); + decodeMap.getTokenIds("a.b.c"); + Dictionary tokenMap = new Dictionary(); + + // act + string token = decodeMap.GetToken(tokenMap); + + // assert + Assert.AreEqual(string.Empty, token); + } + + [Test] + public void DecodeMap_getTokenIds_Should_Return_Stable_TokenIds_For_Same_Scope() + { + // arrange + DecodeMap decodeMap = new DecodeMap(); + + // act + int[] ids1 = decodeMap.getTokenIds("source.cs"); + int[] ids2 = decodeMap.getTokenIds("source.cs"); + + // assert + Assert.AreEqual(ids1.Length, ids2.Length); + CollectionAssert.AreEqual(ids1, ids2); + } + + [Test] + public void DecodeMap_getTokenIds_Should_Assign_And_Reuse_TokenIds_Across_Scopes() + { + // arrange + DecodeMap decodeMap = new DecodeMap(); + + // act + int[] idsAbc = decodeMap.getTokenIds("a.b.c"); + int[] idsA = decodeMap.getTokenIds("a"); + int[] idsB = decodeMap.getTokenIds("b"); + int[] idsC = decodeMap.getTokenIds("c"); + + // assert + Assert.AreEqual(3, idsAbc.Length); + + Assert.AreEqual(1, idsA.Length); + Assert.AreEqual(1, idsB.Length); + Assert.AreEqual(1, idsC.Length); + + Assert.AreEqual(idsAbc[0], idsA[0]); + Assert.AreEqual(idsAbc[1], idsB[0]); + Assert.AreEqual(idsAbc[2], idsC[0]); + } + + [Test] + public void DecodeMap_GetToken_Should_Return_Selected_Tokens_Joined_With_Dots() + { + // arrange + DecodeMap decodeMap = new DecodeMap(); + int[] idsAbc = decodeMap.getTokenIds("a.b.c"); + + Dictionary tokenMap = new Dictionary + { + [idsAbc[0]] = true, // a + [idsAbc[2]] = true // c + }; + + // act + string token = decodeMap.GetToken(tokenMap); + + // assert + Assert.AreEqual("a.c", token); + } + + [Test] + public void DecodeMap_GetToken_Should_Return_Tokens_In_AssignedId_Order() + { + // arrange + DecodeMap decodeMap = new DecodeMap(); + + // First assignment order matters because IDs are allocated incrementally. + int[] ids = decodeMap.getTokenIds("c.a"); // c => id1, a => id2 + + Dictionary tokenMap = new Dictionary + { + [ids[1]] = true, // a (higher id) + [ids[0]] = true // c (lower id) + }; + + // act + string token = decodeMap.GetToken(tokenMap); + + // assert + Assert.AreEqual("c.a", token); + } + + [Test] + public void DecodeMap_GetToken_Should_Ignore_Keys_Outside_AssignedId_Range() + { + // arrange + DecodeMap decodeMap = new DecodeMap(); + int[] idsAbc = decodeMap.getTokenIds("a.b.c"); + + Dictionary tokenMap1 = new Dictionary + { + [idsAbc[0]] = true, + [idsAbc[2]] = true + }; + + Dictionary tokenMap2 = new Dictionary + { + [idsAbc[0]] = true, + [idsAbc[2]] = true, + [idsAbc[2] + 1000] = true + }; + + // act + string token1 = decodeMap.GetToken(tokenMap1); + string token2 = decodeMap.GetToken(tokenMap2); + + // assert + Assert.AreEqual(token1, token2); + Assert.AreEqual("a.c", token2); + } + + [Test] + public void DecodeMap_getTokenIds_Should_Handle_Empty_Segments_And_RoundTrip_Via_GetToken() + { + // arrange + DecodeMap decodeMap = new DecodeMap(); + + // act + int[] ids = decodeMap.getTokenIds("a..b"); + + Dictionary tokenMap = new Dictionary + { + [ids[0]] = true, + [ids[1]] = true, // empty segment token + [ids[2]] = true + }; + + string token = decodeMap.GetToken(tokenMap); + + // assert + Assert.AreEqual(3, ids.Length); + Assert.AreEqual("a..b", token); + } + + [Test] + public void DecodeMap_getTokenIds_Should_Reuse_Cached_IntArray_For_Identical_Scope() + { + // arrange + DecodeMap decodeMap = new DecodeMap(); + + // act + int[] ids1 = decodeMap.getTokenIds("a.b.c"); + int[] ids2 = decodeMap.getTokenIds("a.b.c"); + + // assert + Assert.AreSame(ids1, ids2); + Assert.AreEqual(ids1.Length, ids2.Length); + } + + [Test] + public void DecodeMap_getTokenIds_Should_Reuse_TokenIds_When_Scope_Is_Extended() + { + // arrange + DecodeMap decodeMap = new DecodeMap(); + + // act + int[] idsAbc = decodeMap.getTokenIds("a.b.c"); + int[] idsAbcd = decodeMap.getTokenIds("a.b.c.d"); + + // assert + Assert.AreEqual(3, idsAbc.Length); + Assert.AreEqual(4, idsAbcd.Length); + + Assert.AreEqual(idsAbc[0], idsAbcd[0]); + Assert.AreEqual(idsAbc[1], idsAbcd[1]); + Assert.AreEqual(idsAbc[2], idsAbcd[2]); + + Assert.AreNotEqual(idsAbc[0], idsAbcd[3]); + Assert.AreNotEqual(idsAbc[1], idsAbcd[3]); + Assert.AreNotEqual(idsAbc[2], idsAbcd[3]); + } + + [Test] + public void DecodeMap_getTokenIds_Should_Handle_Empty_Scope_And_RoundTrip() + { + // arrange + DecodeMap decodeMap = new DecodeMap(); + + // act + int[] ids = decodeMap.getTokenIds(string.Empty); + + Dictionary tokenMap = new Dictionary + { + [ids[0]] = true + }; + + string token = decodeMap.GetToken(tokenMap); + + // assert + Assert.AreEqual(1, ids.Length); + Assert.AreEqual(string.Empty, token); + } + + [Test] + public void DecodeMap_getTokenIds_Should_Handle_Trailing_Separator_And_RoundTrip() + { + // arrange + DecodeMap decodeMap = new DecodeMap(); + + // act + int[] ids = decodeMap.getTokenIds("a."); + + Dictionary tokenMap = new Dictionary + { + [ids[0]] = true, + [ids[1]] = true + }; + + string token = decodeMap.GetToken(tokenMap); + + // assert + Assert.AreEqual(2, ids.Length); + Assert.AreEqual("a.", token); + } + + [Test] + public void DecodeMap_getTokenIds_Should_Handle_Leading_Separator_And_RoundTrip() + { + // arrange + DecodeMap decodeMap = new DecodeMap(); + + // act + int[] ids = decodeMap.getTokenIds(".a"); + + Dictionary tokenMap = new Dictionary + { + [ids[0]] = true, + [ids[1]] = true + }; + + string token = decodeMap.GetToken(tokenMap); + + // assert + Assert.AreEqual(2, ids.Length); + Assert.AreEqual(".a", token); + } + + [Test] + public void DecodeMap_GetToken_Should_Ignore_False_Values_In_TokenMap() + { + // arrange + DecodeMap decodeMap = new DecodeMap(); + + int[] ids = decodeMap.getTokenIds("a.b.c"); + + Dictionary tokenMap = new Dictionary + { + [ids[0]] = true, + [ids[1]] = false, + [ids[2]] = true + }; + + // act + string token = decodeMap.GetToken(tokenMap); + + // assert + Assert.AreEqual("a.c", token); + } + } +} From adc1c854870021d1a77cab19423aecf4a50acc92 Mon Sep 17 00:00:00 2001 From: Dave Black <656118+udlose@users.noreply.github.com> Date: Mon, 16 Feb 2026 21:48:51 -0600 Subject: [PATCH 02/13] Refactor DecodeMap for efficient token ID and scope parsing Refactored DecodeMap to use non-nullable types and a List for token ID management, replaced string.Split with manual scope parsing for better performance, and optimized token assignment and string reconstruction logic. --- src/TextMateSharp/Model/DecodeMap.cs | 73 +++++++++++++++++++--------- 1 file changed, 49 insertions(+), 24 deletions(-) diff --git a/src/TextMateSharp/Model/DecodeMap.cs b/src/TextMateSharp/Model/DecodeMap.cs index 36b7bd6..ade4eb8 100644 --- a/src/TextMateSharp/Model/DecodeMap.cs +++ b/src/TextMateSharp/Model/DecodeMap.cs @@ -9,18 +9,24 @@ class DecodeMap public TMTokenDecodeData PrevToken { get; set; } private int lastAssignedId; - private Dictionary _scopeToTokenIds; - private Dictionary _tokenToTokenId; - private Dictionary _tokenIdToToken; + private readonly Dictionary _scopeToTokenIds; + private readonly Dictionary _tokenToTokenId; + private readonly List _tokenIdToToken; + private const char ScopeSeparator = '.'; public DecodeMap() { - this.PrevToken = new TMTokenDecodeData(new string[0], new Dictionary>()); + this.PrevToken = new TMTokenDecodeData(Array.Empty(), new Dictionary>()); this.lastAssignedId = 0; this._scopeToTokenIds = new Dictionary(); - this._tokenToTokenId = new Dictionary(); - this._tokenIdToToken = new Dictionary(); + this._tokenToTokenId = new Dictionary(); + + // Index 0 is unused so tokenId can be used directly as the index + this._tokenIdToToken = new List + { + string.Empty // placeholder for index 0 + }; } public int[] getTokenIds(string scope) @@ -32,21 +38,40 @@ public int[] getTokenIds(string scope) return tokens; } - string[] tmpTokens = scope.Split(new string[] { "[.]" }, StringSplitOptions.None); + ReadOnlySpan scopeSpan = scope.AsSpan(); + + int tokenCount = 1; + for (int i = 0; i < scopeSpan.Length; i++) + { + if (scopeSpan[i] == ScopeSeparator) + { + tokenCount++; + } + } + + tokens = new int[tokenCount]; - tokens = new int[tmpTokens.Length]; - for (int i = 0; i < tmpTokens.Length; i++) + int tokenIndex = 0; + int start = 0; + for (int i = 0; i <= scopeSpan.Length; i++) { - string token = tmpTokens[i]; - int? tokenId; - this._tokenToTokenId.TryGetValue(token, out tokenId); - if (tokenId == null) + if (i == scopeSpan.Length || scopeSpan[i] == ScopeSeparator) { - tokenId = (++this.lastAssignedId); - this._tokenToTokenId[token] = tokenId.Value; - this._tokenIdToToken[tokenId.Value] = token; + int length = i - start; + string token = scope.Substring(start, length); + + if (!this._tokenToTokenId.TryGetValue(token, out int tokenId)) + { + tokenId = ++this.lastAssignedId; + this._tokenToTokenId[token] = tokenId; + this._tokenIdToToken.Add(token); + } + + tokens[tokenIndex] = tokenId; + tokenIndex++; + + start = i + 1; } - tokens[i] = tokenId.Value; } this._scopeToTokenIds[scope] = tokens; @@ -59,21 +84,21 @@ public string GetToken(Dictionary tokenMap) bool isFirst = true; for (int i = 1; i <= this.lastAssignedId; i++) { - if (tokenMap.ContainsKey(i)) + if (tokenMap.TryGetValue(i, out bool isPresent) && isPresent) { - if (isFirst) + if (!isFirst) { - isFirst = false; - result.Append(this._tokenIdToToken[i]); + result.Append(ScopeSeparator); } else { - result.Append('.'); - result.Append(this._tokenIdToToken[i]); + isFirst = false; } + + result.Append(this._tokenIdToToken[i]); } } return result.ToString(); } } -} \ No newline at end of file +} From 65589c00e6d64bf1d2b8c8f5c241fe473202d082 Mon Sep 17 00:00:00 2001 From: Dave Black <656118+udlose@users.noreply.github.com> Date: Tue, 17 Feb 2026 16:30:45 -0600 Subject: [PATCH 03/13] Add comprehensive unit tests for StringUtils methods Introduced StringUtilsTests with NUnit covering SubstringAtIndexes, SliceAtIndexes, IsValidHexColor, StrCmp, and StrArrCmp. Tests include normal, edge, and error cases to ensure correct handling of valid/invalid inputs and nulls. --- .../Internal/Utils/StringUtilsTests.cs | 785 ++++++++++++++++++ 1 file changed, 785 insertions(+) create mode 100644 src/TextMateSharp.Tests/Internal/Utils/StringUtilsTests.cs diff --git a/src/TextMateSharp.Tests/Internal/Utils/StringUtilsTests.cs b/src/TextMateSharp.Tests/Internal/Utils/StringUtilsTests.cs new file mode 100644 index 0000000..94469ca --- /dev/null +++ b/src/TextMateSharp.Tests/Internal/Utils/StringUtilsTests.cs @@ -0,0 +1,785 @@ +using NUnit.Framework; +using System; +using System.Collections.Generic; +using System.Globalization; +using TextMateSharp.Internal.Utils; + +namespace TextMateSharp.Tests.Internal.Utils +{ + [TestFixture] + public sealed class StringUtilsTests + { + #region SubstringAtIndexes_String tests + [Test] + public void SubstringAtIndexes_String_ReturnsExpectedSubstring() + { + // arrange + const string input = "abcdef"; + const int startIndex = 1; + const int endIndex = 4; + + // act + string result = input.SubstringAtIndexes(startIndex, endIndex); + + // assert + Assert.AreEqual("bcd", result); + } + + [Test] + public void SubstringAtIndexes_String_EndEqualsStart_ReturnsEmpty() + { + // arrange + const string input = "abcdef"; + const int index = 3; + + // act + string result = input.SubstringAtIndexes(index, index); + + // assert + Assert.AreEqual(string.Empty, result); + } + + [Test] + public void SubstringAtIndexes_String_StartGreaterThanEnd_ThrowsArgumentOutOfRangeException() + { + // arrange + const string input = "abcdef"; + const int startIndex = 4; + const int endIndex = 2; + + // act + TestDelegate act = delegate + { + _ = input.SubstringAtIndexes(startIndex, endIndex); + }; + + // assert + Assert.Throws(act); + } + + [Test] + public void SubstringAtIndexes_String_EndBeyondLength_ThrowsArgumentOutOfRangeException() + { + // arrange + const string input = "abcdef"; + const int startIndex = 2; + const int endIndex = 10; + + // act + TestDelegate act = delegate + { + _ = input.SubstringAtIndexes(startIndex, endIndex); + }; + + // assert + Assert.Throws(act); + } + [Test] + public void SubstringAtIndexes_String_StartNegative_ThrowsArgumentOutOfRangeException() + { + // arrange + const string input = "abcdef"; + const int startIndex = -1; + const int endIndex = 2; + + // act + TestDelegate act = delegate + { + _ = input.SubstringAtIndexes(startIndex, endIndex); + }; + + // assert + Assert.Throws(act); + } + + [Test] + public void SubstringAtIndexes_String_StartZero_EndLength_ReturnsFullString() + { + // arrange + const string input = "abcdef"; + const int startIndex = 0; + int endIndex = input.Length; + + // act + string result = input.SubstringAtIndexes(startIndex, endIndex); + + // assert + Assert.AreEqual("abcdef", result); + } + #endregion SubstringAtIndexes_String tests + + #region SubstringAtIndexes_ReadOnlyMemory tests + [Test] + public void SubstringAtIndexes_ReadOnlyMemory_StartNegative_ThrowsArgumentOutOfRangeException() + { + // arrange + ReadOnlyMemory memory = "abcdef".AsMemory(); + const int startIndex = -1; + const int endIndex = 2; + + // act + TestDelegate act = delegate + { + _ = memory.SubstringAtIndexes(startIndex, endIndex); + }; + + // assert + Assert.Throws(act); + } + + [Test] + public void SubstringAtIndexes_ReadOnlyMemory_EndBeyondLength_ThrowsArgumentOutOfRangeException() + { + // arrange + ReadOnlyMemory memory = "abcdef".AsMemory(); + const int startIndex = 1; + const int endIndex = 10; + + // act + TestDelegate act = delegate + { + _ = memory.SubstringAtIndexes(startIndex, endIndex); + }; + + // assert + Assert.Throws(act); + } + + [Test] + public void SubstringAtIndexes_ReadOnlyMemory_StartGreaterThanEnd_ThrowsArgumentOutOfRangeException() + { + // arrange + ReadOnlyMemory memory = "abcdef".AsMemory(); + const int startIndex = 4; + const int endIndex = 2; + + // act + TestDelegate act = delegate + { + _ = memory.SubstringAtIndexes(startIndex, endIndex); + }; + + // assert + Assert.Throws(act); + } + + [Test] + public void SubstringAtIndexes_ReadOnlyMemory_EndEqualsStart_ReturnsEmpty() + { + // arrange + ReadOnlyMemory memory = "abcdef".AsMemory(); + const int index = 3; + + // act + string result = memory.SubstringAtIndexes(index, index); + + // assert + Assert.AreEqual(string.Empty, result); + } + + [Test] + public void SubstringAtIndexes_ReadOnlyMemory_ReturnsExpectedSubstring() + { + // arrange + ReadOnlyMemory memory = "abcdef".AsMemory(); + + // act + string result = memory.SubstringAtIndexes(2, 5); + + // assert + Assert.AreEqual("cde", result); + } + #endregion SubstringAtIndexes_ReadOnlyMemory tests + + #region SliceAtIndexes tests + [Test] + public void SliceAtIndexes_ReadOnlyMemory_StartNegative_ThrowsArgumentOutOfRangeException() + { + // arrange + ReadOnlyMemory memory = "abcdef".AsMemory(); + const int startIndex = -1; + const int endIndex = 2; + + // act + TestDelegate act = delegate + { + _ = memory.SliceAtIndexes(startIndex, endIndex); + }; + + // assert + Assert.Throws(act); + } + + [Test] + public void SliceAtIndexes_ReadOnlyMemory_StartGreaterThanEnd_ThrowsArgumentOutOfRangeException() + { + // arrange + ReadOnlyMemory memory = "abcdef".AsMemory(); + const int startIndex = 4; + const int endIndex = 2; + + // act + TestDelegate act = delegate + { + _ = memory.SliceAtIndexes(startIndex, endIndex); + }; + + // assert + Assert.Throws(act); + } + + [Test] + public void SliceAtIndexes_ReadOnlyMemory_EndBeyondLength_ThrowsArgumentOutOfRangeException() + { + // arrange + ReadOnlyMemory memory = "abcdef".AsMemory(); + const int startIndex = 1; + const int endIndex = 10; + + // act + TestDelegate act = delegate + { + _ = memory.SliceAtIndexes(startIndex, endIndex); + }; + + // assert + Assert.Throws(act); + } + + [Test] + public void SliceAtIndexes_ReadOnlySpan_StartNegative_ThrowsArgumentOutOfRangeException() + { + // arrange + const int startIndex = -1; + const int endIndex = 2; + + // act + TestDelegate act = delegate + { + ReadOnlySpan span = "abcdef".AsSpan(); + _ = span.SliceAtIndexes(startIndex, endIndex); + }; + + // assert + Assert.Throws(act); + } + + [Test] + public void SliceAtIndexes_ReadOnlySpan_EndBeyondLength_ThrowsArgumentOutOfRangeException() + { + // arrange + const int startIndex = 1; + const int endIndex = 10; + + // act + TestDelegate act = delegate + { + ReadOnlySpan span = "abcdef".AsSpan(); + _ = span.SliceAtIndexes(startIndex, endIndex); + }; + + // assert + Assert.Throws(act); + } + + [Test] + public void SliceAtIndexes_ReadOnlySpan_StartGreaterThanEnd_ThrowsArgumentOutOfRangeException() + { + // arrange + const int startIndex = 4; + const int endIndex = 2; + + // act + TestDelegate act = delegate + { + ReadOnlySpan span = "abcdef".AsSpan(); + _ = span.SliceAtIndexes(startIndex, endIndex); + }; + + // assert + Assert.Throws(act); + } + + [Test] + public void SliceAtIndexes_ReadOnlyMemory_ReturnsExpectedSlice() + { + // arrange + ReadOnlyMemory memory = "abcdef".AsMemory(); + const int startIndex = 1; + const int endIndex = 4; + + // act + ReadOnlyMemory slice = memory.SliceAtIndexes(startIndex, endIndex); + + // assert + Assert.AreEqual("bcd", slice.Span.ToString()); + } + + [Test] + public void SliceAtIndexes_ReadOnlySpan_ReturnsExpectedSlice() + { + // arrange + ReadOnlySpan span = "abcdef".AsSpan(); + const int startIndex = 1; + const int endIndex = 4; + + // act + ReadOnlySpan slice = span.SliceAtIndexes(startIndex, endIndex); + + // assert + Assert.AreEqual("bcd", slice.ToString()); + } + #endregion SliceAtIndexes tests + + #region IsValidHexColor tests + [Test] + public void IsValidHexColor_HashOnly_ReturnsFalse() + { + // arrange + const string hex = "#"; + + // act + bool result = StringUtils.IsValidHexColor(hex); + + // assert + Assert.False(result); + } + + [Test] + public void IsValidHexColor_UppercaseRgb_ReturnsTrue() + { + // arrange + const string hex = "#ABC"; + + // act + bool result = StringUtils.IsValidHexColor(hex); + + // assert + Assert.True(result); + } + + [Test] + public void IsValidHexColor_UppercaseRrggbb_ReturnsTrue() + { + // arrange + const string hex = "#A1B2C3"; + + // act + bool result = StringUtils.IsValidHexColor(hex); + + // assert + Assert.True(result); + } + + [Test] + public void IsValidHexColor_Null_ReturnsFalse() + { + // arrange + const string hex = null; + + // act + bool result = StringUtils.IsValidHexColor(hex); + + // assert + Assert.False(result); + } + + [Test] + public void IsValidHexColor_Empty_ReturnsFalse() + { + // arrange + const string hex = ""; + + // act + bool result = StringUtils.IsValidHexColor(hex); + + // assert + Assert.False(result); + } + + [Test] + public void IsValidHexColor_ValidRgb_ReturnsTrue() + { + // arrange + const string hex = "#abc"; + + // act + bool result = StringUtils.IsValidHexColor(hex); + + // assert + Assert.True(result); + } + + [Test] + public void IsValidHexColor_ValidRgba_ReturnsTrue() + { + // arrange + const string hex = "#abcd"; + + // act + bool result = StringUtils.IsValidHexColor(hex); + + // assert + Assert.True(result); + } + + [Test] + public void IsValidHexColor_ValidRrggbb_ReturnsTrue() + { + // arrange + const string hex = "#a1b2c3"; + + // act + bool result = StringUtils.IsValidHexColor(hex); + + // assert + Assert.True(result); + } + + [Test] + public void IsValidHexColor_ValidRrggbbaa_ReturnsTrue() + { + // arrange + const string hex = "#a1b2c3d4"; + + // act + bool result = StringUtils.IsValidHexColor(hex); + + // assert + Assert.True(result); + } + + [Test] + public void IsValidHexColor_InvalidChars_ReturnsFalse() + { + // arrange + const string hex = "#ggg"; + + // act + bool result = StringUtils.IsValidHexColor(hex); + + // assert + Assert.False(result); + } + + [Test] + public void IsValidHexColor_MissingHash_ReturnsFalse() + { + // arrange + const string hex = "abc"; + + // act + bool result = StringUtils.IsValidHexColor(hex); + + // assert + Assert.False(result); + } + + [Test] + public void IsValidHexColor_TooShort_ReturnsFalse() + { + // arrange + const string hex = "#12"; + + // act + bool result = StringUtils.IsValidHexColor(hex); + + // assert + Assert.False(result); + } + + [Test] + public void IsValidHexColor_PrefixRgb_ReturnsTrue() + { + // arrange + // regex is anchored with '^' but not '$', so a valid prefix returns true + const string hex = "#abcTHIS_IS_NOT_HEX"; + + // act + bool result = StringUtils.IsValidHexColor(hex); + + // assert + Assert.True(result); + } + + [Test] + public void IsValidHexColor_PrefixRrggbb_ReturnsTrue() + { + // arrange + const string hex = "#a1b2c3ZZZ"; + + // act + bool result = StringUtils.IsValidHexColor(hex); + + // assert + Assert.True(result); + } + + [Test] + public void IsValidHexColor_PrefixRrggbbaa_ReturnsTrue() + { + // arrange + const string hex = "#a1b2c3d4MORE"; + + // act + bool result = StringUtils.IsValidHexColor(hex); + + // assert + Assert.True(result); + } + + [Test] + public void IsValidHexColor_PrefixRgba_ReturnsTrue() + { + // arrange + const string hex = "#abcdMORE"; + + // act + bool result = StringUtils.IsValidHexColor(hex); + + // assert + Assert.True(result); + } + #endregion IsValidHexColor tests + + #region StrCmp tests + [Test] + public void StrCmp_BothNull_ReturnsZero() + { + // arrange + const string a = null; + const string b = null; + + // act + int result = StringUtils.StrCmp(a, b); + + // assert + Assert.AreEqual(0, result); + } + + [Test] + public void StrCmp_LeftNull_ReturnsMinusOne() + { + // arrange + const string a = null; + const string b = "a"; + + // act + int result = StringUtils.StrCmp(a, b); + + // assert + Assert.AreEqual(-1, result); + } + + [Test] + public void StrCmp_RightNull_ReturnsOne() + { + // arrange + const string a = "a"; + const string b = null; + + // act + int result = StringUtils.StrCmp(a, b); + + // assert + Assert.AreEqual(1, result); + } + + [Test] + public void StrCmp_EqualStrings_ReturnsZero() + { + // arrange + const string a = "abc"; + const string b = "abc"; + + // act + int result = StringUtils.StrCmp(a, b); + + // assert + Assert.AreEqual(0, result); + } + + [Test] + public void StrCmp_LessThan_ReturnsMinusOne() + { + // arrange + const string a = "a"; + const string b = "b"; + + // act + int result = StringUtils.StrCmp(a, b); + + // assert + Assert.AreEqual(-1, result); + } + + [Test] + public void StrCmp_GreaterThan_ReturnsOne() + { + // arrange + const string a = "b"; + const string b = "a"; + + // act + int result = StringUtils.StrCmp(a, b); + + // assert + Assert.AreEqual(1, result); + } + + [Test] + public void StrCmp_EqualStrings_DifferentInstances_ReturnsZero() + { + // arrange + // these strings must be created at runtime to ensure they are different instances, + // otherwise the CLR may intern them and make them reference equal + string a = new string(['a', 'b', 'c']); + string b = new string(['a', 'b', 'c']); + + // act + int result = StringUtils.StrCmp(a, b); + + // assert + Assert.AreEqual(0, result); + } + + [Test] + public void StrCmp_CultureEquivalentStrings_ReturnsZero() + { + // arrange + CultureInfo originalCulture = CultureInfo.CurrentCulture; + try + { + CultureInfo.CurrentCulture = CultureInfo.InvariantCulture; + const string a = "e\u0301"; // 'e' + combining acute + const string b = "\u00E9"; // precomposed 'é' + + // act + int result = StringUtils.StrCmp(a, b); + + // assert + Assert.False(a == b); + Assert.AreEqual(0, result); + } + finally + { + CultureInfo.CurrentCulture = originalCulture; + } + } + #endregion StrCmp tests + + #region StrArrCmp tests + [Test] + public void StrArrCmp_SameReference_ReturnsZero() + { + // arrange + List a = new List { "a", "b" }; + List b = a; + + // act + int result = StringUtils.StrArrCmp(a, b); + + // assert + Assert.AreEqual(0, result); + } + + [Test] + public void StrArrCmp_BothNull_ReturnsZero() + { + // arrange + List a = null; + List b = null; + + // act + int result = StringUtils.StrArrCmp(a, b); + + // assert + Assert.AreEqual(0, result); + } + + [Test] + public void StrArrCmp_LeftNull_ReturnsMinusOne() + { + // arrange + List a = null; + List b = new List { "a" }; + + // act + int result = StringUtils.StrArrCmp(a, b); + + // assert + Assert.AreEqual(-1, result); + } + + [Test] + public void StrArrCmp_RightNull_ReturnsOne() + { + // arrange + List a = new List { "a" }; + List b = null; + + // act + int result = StringUtils.StrArrCmp(a, b); + + // assert + Assert.AreEqual(1, result); + } + + [Test] + public void StrArrCmp_SameLength_AllEqual_ReturnsZero() + { + // arrange + List a = new List { "a", "b" }; + List b = new List { "a", "b" }; + + // act + int result = StringUtils.StrArrCmp(a, b); + + // assert + Assert.AreEqual(0, result); + } + + [Test] + public void StrArrCmp_SameLength_DiffElement_ReturnsMinusOne() + { + // arrange + List a = new List { "a", "b" }; + List b = new List { "a", "c" }; + + // act + int result = StringUtils.StrArrCmp(a, b); + + // assert + Assert.AreEqual(-1, result); + } + + [Test] + public void StrArrCmp_SameLength_NullElement_UsesStrCmpRules_ReturnsMinusOne() + { + // arrange + List a = new List { "a", null }; + List b = new List { "a", "b" }; + + // act + int result = StringUtils.StrArrCmp(a, b); + + // assert + Assert.AreEqual(-1, result); + } + + [Test] + public void StrArrCmp_DifferentLengths_ReturnsLengthDifference() + { + // arrange + List a = new List { "a", "b", "c" }; + List b = new List { "a" }; + + // act + int result = StringUtils.StrArrCmp(a, b); + + // assert + Assert.AreEqual(2, result); + } + #endregion StrArrCmp tests + } +} From 3c88afb877810003c48013662889c71c549e882e Mon Sep 17 00:00:00 2001 From: Dave Black <656118+udlose@users.noreply.github.com> Date: Tue, 17 Feb 2026 16:45:20 -0600 Subject: [PATCH 04/13] Refactor hex color validation for efficiency Replaced regex-based hex color validation with fast, allocation-free character checks using new helper methods. Added argument validation to substring and slice methods. Updated string comparison logic to use reference equality. Improves performance and reduces allocations. --- .../Internal/Utils/StringUtils.cs | 94 ++++++++++++++++--- 1 file changed, 79 insertions(+), 15 deletions(-) diff --git a/src/TextMateSharp/Internal/Utils/StringUtils.cs b/src/TextMateSharp/Internal/Utils/StringUtils.cs index 0047324..82ed595 100644 --- a/src/TextMateSharp/Internal/Utils/StringUtils.cs +++ b/src/TextMateSharp/Internal/Utils/StringUtils.cs @@ -1,62 +1,82 @@ using System; using System.Collections.Generic; -using System.Text.RegularExpressions; namespace TextMateSharp.Internal.Utils { internal static class StringUtils { - private static Regex rrggbb = new Regex("^#[0-9a-f]{6}", RegexOptions.IgnoreCase); - private static Regex rrggbbaa = new Regex("^#[0-9a-f]{8}", RegexOptions.IgnoreCase); - private static Regex rgb = new Regex("^#[0-9a-f]{3}", RegexOptions.IgnoreCase); - private static Regex rgba = new Regex("^#[0-9a-f]{4}", RegexOptions.IgnoreCase); - internal static string SubstringAtIndexes(this string str, int startIndex, int endIndex) { - return str.Substring(startIndex, endIndex - startIndex); + if (startIndex < 0) throw new ArgumentOutOfRangeException(nameof(startIndex)); + if (endIndex > str.Length) throw new ArgumentOutOfRangeException(nameof(endIndex)); + if (startIndex > endIndex) throw new ArgumentOutOfRangeException(nameof(startIndex)); + + return str.AsSpan(startIndex, endIndex - startIndex).ToString(); } internal static ReadOnlyMemory SliceAtIndexes(this ReadOnlyMemory memory, int startIndex, int endIndex) { + if (startIndex < 0) throw new ArgumentOutOfRangeException(nameof(startIndex)); + if (endIndex > memory.Length) throw new ArgumentOutOfRangeException(nameof(endIndex)); + if (startIndex > endIndex) throw new ArgumentOutOfRangeException(nameof(startIndex)); + return memory.Slice(startIndex, endIndex - startIndex); } internal static ReadOnlySpan SliceAtIndexes(this ReadOnlySpan span, int startIndex, int endIndex) { + if (startIndex < 0) throw new ArgumentOutOfRangeException(nameof(startIndex)); + if (endIndex > span.Length) throw new ArgumentOutOfRangeException(nameof(endIndex)); + if (startIndex > endIndex) throw new ArgumentOutOfRangeException(nameof(startIndex)); + return span.Slice(startIndex, endIndex - startIndex); } internal static string SubstringAtIndexes(this ReadOnlyMemory memory, int startIndex, int endIndex) { + if (startIndex < 0) throw new ArgumentOutOfRangeException(nameof(startIndex)); + if (endIndex > memory.Length) throw new ArgumentOutOfRangeException(nameof(endIndex)); + if (startIndex > endIndex) throw new ArgumentOutOfRangeException(nameof(startIndex)); + return memory.Slice(startIndex, endIndex - startIndex).Span.ToString(); } + /// + /// Determines whether the specified string represents a valid hexadecimal color value. + /// + /// Valid hexadecimal color values can be specified in shorthand (#rgb, #rgba) or full + /// (#rrggbb, #rrggbbaa) formats. The method checks for the presence of valid hexadecimal digits in the + /// appropriate positions based on the length of the input string. + /// The hexadecimal color string to validate. The string must begin with a '#' character and may be in the + /// formats #rgb, #rgba, #rrggbb, or #rrggbbaa. + /// true if the specified string is a valid hexadecimal color; otherwise, false. internal static bool IsValidHexColor(string hex) { - if (hex == null || hex.Length < 1) + if (string.IsNullOrEmpty(hex) || hex[0] != '#') { return false; } - if (rrggbb.Match(hex).Success) + // Keep the same precedence as the original regex checks. + if (hex.Length >= 7 && HasHexDigits(hex, 1, 6)) { // #rrggbb return true; } - if (rrggbbaa.Match(hex).Success) + if (hex.Length >= 9 && HasHexDigits(hex, 1, 8)) { // #rrggbbaa return true; } - if (rgb.Match(hex).Success) + if (hex.Length >= 4 && HasHexDigits(hex, 1, 3)) { // #rgb return true; } - if (rgba.Match(hex).Success) + if (hex.Length >= 5 && HasHexDigits(hex, 1, 4)) { // #rgba return true; @@ -65,9 +85,53 @@ internal static bool IsValidHexColor(string hex) return false; } + /// + /// Determines whether a specified substring of a hexadecimal string contains only valid hexadecimal digits. + /// + /// Valid hexadecimal digits are 0-9, A-F, and a-f. The method does not validate the + /// bounds of the substring; callers should ensure that startIndex and count specify a valid range within the + /// string. + /// The string to evaluate for valid hexadecimal digits. + /// The zero-based index at which to begin evaluating the substring within the hexadecimal string. + /// The number of characters to evaluate from the starting index. + /// true if all characters in the specified substring are valid hexadecimal digits; otherwise, false. + private static bool HasHexDigits(string hex, int startIndex, int count) + { + int end = startIndex + count; + for (int i = startIndex; i < end; i++) + { + if (!IsHexDigit(hex[i])) + { + return false; + } + } + + return true; + } + + /// + /// Determines whether the specified character represents a valid hexadecimal digit. + /// + /// + /// + /// This method performs a case-insensitive check for hexadecimal digits, allowing both uppercase and lowercase letters. + /// + /// Implements a fast hex-digit check without allocations. The expressions: + /// 1. (uint) (c - '0') <= 9 is true for '0' to '9' + /// 2. (uint) ((c | 0x20) - 'a') <= 5 lowercases ASCII letters by setting bit 0x20, then checks 'a' to 'f' + /// 3. using uint avoids branching on negative values and keeps the comparisons simple + /// + /// + /// The character to evaluate as a hexadecimal digit. + /// true if the character is a valid hexadecimal digit (0-9, A-F, or a-f); otherwise, false. + private static bool IsHexDigit(char c) + { + return (uint)(c - '0') <= 9 || (uint)((c | 0x20) - 'a') <= 5; + } + public static int StrCmp(string a, string b) { - if (a == null && b == null) + if (a == b) { return 0; } @@ -93,7 +157,7 @@ public static int StrCmp(string a, string b) public static int StrArrCmp(List a, List b) { - if (a == null && b == null) + if (a == b) { return 0; } @@ -122,4 +186,4 @@ public static int StrArrCmp(List a, List b) return len1 - len2; } } -} +} \ No newline at end of file From 3a797cc22ecf99be0772945443a72ba19ef1999e Mon Sep 17 00:00:00 2001 From: Dave Black <656118+udlose@users.noreply.github.com> Date: Tue, 17 Feb 2026 17:00:56 -0600 Subject: [PATCH 05/13] Update BenchmarkDotNet to version 0.15.8 in benchmarks Upgraded the BenchmarkDotNet NuGet package in TextMateSharp.Benchmarks.csproj from version 0.14.0 to 0.15.8 to ensure compatibility with the latest features and improvements. No other changes were made. --- src/TextMateSharp.Benchmarks/TextMateSharp.Benchmarks.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/TextMateSharp.Benchmarks/TextMateSharp.Benchmarks.csproj b/src/TextMateSharp.Benchmarks/TextMateSharp.Benchmarks.csproj index 403cf07..d6b1eb0 100644 --- a/src/TextMateSharp.Benchmarks/TextMateSharp.Benchmarks.csproj +++ b/src/TextMateSharp.Benchmarks/TextMateSharp.Benchmarks.csproj @@ -8,7 +8,7 @@ - + From 7cbc080cd3eeb299be7eb0ca03535a01b521e13e Mon Sep 17 00:00:00 2001 From: Dave Black <656118+udlose@users.noreply.github.com> Date: Tue, 17 Feb 2026 21:03:20 -0600 Subject: [PATCH 06/13] Add comprehensive unit tests for RuleFactory --- .../Internal/Rules/RuleFactoryTests.cs | 514 ++++++++++++++++++ 1 file changed, 514 insertions(+) create mode 100644 src/TextMateSharp.Tests/Internal/Rules/RuleFactoryTests.cs diff --git a/src/TextMateSharp.Tests/Internal/Rules/RuleFactoryTests.cs b/src/TextMateSharp.Tests/Internal/Rules/RuleFactoryTests.cs new file mode 100644 index 0000000..61fc236 --- /dev/null +++ b/src/TextMateSharp.Tests/Internal/Rules/RuleFactoryTests.cs @@ -0,0 +1,514 @@ +using NUnit.Framework; +using System; +using System.Collections.Generic; +using TextMateSharp.Internal.Grammars.Parser; +using TextMateSharp.Internal.Rules; +using TextMateSharp.Internal.Types; + +namespace TextMateSharp.Tests.Internal.Rules +{ + [TestFixture] + public class RuleFactoryTests + { + [Test] + public void GetCompiledRuleId_NullDesc_ReturnsNull() + { + // arrange + MockRuleFactoryHelper helper = new MockRuleFactoryHelper(); + Raw repository = new Raw(); + + // act + RuleId id = RuleFactory.GetCompiledRuleId(null, helper, repository); + + // assert + Assert.IsNull(id); + } + + [Test] + public void GetCompiledRuleId_ReusesExistingRuleId() + { + // arrange + MockRuleFactoryHelper helper = new MockRuleFactoryHelper(); + Raw repository = new Raw(); + + Raw rule = new Raw(); + rule.SetName("match.rule"); + rule["match"] = "abc"; + + // act + RuleId first = RuleFactory.GetCompiledRuleId(rule, helper, repository); + int countAfterFirst = helper.RuleCount; + RuleId second = RuleFactory.GetCompiledRuleId(rule, helper, repository); + + // assert + Assert.AreEqual(first, second); + Assert.AreEqual(countAfterFirst, helper.RuleCount); + } + + [Test] + public void GetCompiledRuleId_MatchRule_CompilesCapturesWithRetokenizeRule() + { + // arrange + MockRuleFactoryHelper helper = new MockRuleFactoryHelper(); + Raw repository = new Raw(); + + Raw nestedPattern = new Raw(); + nestedPattern["match"] = "def"; + + Raw captureRuleWithPatterns = new Raw(); + captureRuleWithPatterns.SetName("capture.one"); + captureRuleWithPatterns["contentName"] = "capture.one.content"; + captureRuleWithPatterns.SetPatterns(new List { nestedPattern }); + + Raw captureRuleWithoutPatterns = new Raw(); + captureRuleWithoutPatterns.SetName("capture.three"); + Raw captures = new Raw + { + ["1"] = captureRuleWithPatterns, + ["3"] = captureRuleWithoutPatterns + }; + + Raw rule = new Raw(); + rule.SetName("match.rule"); + rule["match"] = "abc"; + rule["captures"] = captures; + + // act + RuleId id = RuleFactory.GetCompiledRuleId(rule, helper, repository); + MatchRule compiledRule = helper.GetRule(id) as MatchRule; + + // assert + Assert.IsNotNull(compiledRule); + Assert.AreEqual(4, compiledRule.Captures.Count); + Assert.IsNull(compiledRule.Captures[2]); + Assert.IsNotNull(compiledRule.Captures[1].RetokenizeCapturedWithRuleId); + Assert.IsNull(compiledRule.Captures[3].RetokenizeCapturedWithRuleId); + } + + [Test] + public void GetCompiledRuleId_IncludeOnlyRule_MergesRepository() + { + // arrange + MockRuleFactoryHelper helper = new MockRuleFactoryHelper(); + Raw repository = new Raw(); + + Raw localRule = new Raw(); + localRule.SetName("local.rule"); + localRule["match"] = "\\d+"; + + Raw descRepository = new Raw(); + descRepository["local"] = localRule; + + Raw includeRule = new Raw(); + includeRule.SetInclude("#local"); + + Raw includeOnlyRule = new Raw(); + includeOnlyRule.SetRepository(descRepository); + includeOnlyRule.SetPatterns(new List { includeRule }); + + // act + RuleId id = RuleFactory.GetCompiledRuleId(includeOnlyRule, helper, repository); + IncludeOnlyRule compiledRule = helper.GetRule(id) as IncludeOnlyRule; + + // assert + Assert.IsNotNull(compiledRule); + Assert.AreEqual(1, compiledRule.Patterns.Count); + Assert.IsInstanceOf(helper.GetRule(compiledRule.Patterns[0])); + } + + [Test] + public void GetCompiledRuleId_BeginWhileRule_UsesCaptureFallbacks() + { + // arrange + MockRuleFactoryHelper helper = new MockRuleFactoryHelper(); + Raw repository = new Raw(); + + Raw captureRule = new Raw(); + captureRule.SetName("cap"); + + Raw captures = new Raw + { + ["1"] = captureRule + }; + + Raw rule = new Raw(); + rule.SetName("begin.while.rule"); + rule["begin"] = "\\{"; + rule["while"] = "\\}"; + rule["captures"] = captures; + rule.SetPatterns(new List()); + + // act + RuleId id = RuleFactory.GetCompiledRuleId(rule, helper, repository); + BeginWhileRule compiledRule = helper.GetRule(id) as BeginWhileRule; + + // assert + Assert.IsNotNull(compiledRule); + Assert.AreEqual(2, compiledRule.BeginCaptures.Count); + Assert.AreEqual(2, compiledRule.WhileCaptures.Count); + } + + [Test] + public void GetCompiledRuleId_BeginEndRule_ResolvesLocalInclude() + { + // arrange + MockRuleFactoryHelper helper = new MockRuleFactoryHelper(); + Raw repository = new Raw(); + + Raw includedRule = new Raw(); + includedRule.SetName("included.rule"); + includedRule["match"] = "\\w+"; + repository["included"] = includedRule; + + Raw includeRule = new Raw(); + includeRule.SetInclude("#included"); + + Raw beginEndRule = new Raw(); + beginEndRule.SetName("block.rule"); + beginEndRule["begin"] = "\\{"; + beginEndRule["end"] = "\\}"; + beginEndRule.SetPatterns(new List { includeRule }); + + // act + RuleId id = RuleFactory.GetCompiledRuleId(beginEndRule, helper, repository); + BeginEndRule compiledRule = helper.GetRule(id) as BeginEndRule; + + // assert + Assert.IsNotNull(compiledRule); + Assert.AreEqual(1, compiledRule.Patterns.Count); + Assert.IsInstanceOf(helper.GetRule(compiledRule.Patterns[0])); + Assert.IsFalse(compiledRule.HasMissingPatterns); + } + + [Test] + public void GetCompiledRuleId_BeginEndRule_ResolvesSelfInclude() + { + // arrange + MockRuleFactoryHelper helper = new MockRuleFactoryHelper(); + Raw repository = new Raw(); + + Raw selfRule = new Raw(); + selfRule.SetName("self.rule"); + selfRule["match"] = "self"; + repository.SetSelf(selfRule); + + Raw includeRule = new Raw(); + includeRule.SetInclude("$self"); + + Raw beginEndRule = new Raw(); + beginEndRule.SetName("self.block"); + beginEndRule["begin"] = "\\("; + beginEndRule["end"] = "\\)"; + beginEndRule.SetPatterns(new List { includeRule }); + + // act + RuleId id = RuleFactory.GetCompiledRuleId(beginEndRule, helper, repository); + BeginEndRule compiledRule = helper.GetRule(id) as BeginEndRule; + + // assert + Assert.IsNotNull(compiledRule); + Assert.AreEqual(1, compiledRule.Patterns.Count); + Assert.IsInstanceOf(helper.GetRule(compiledRule.Patterns[0])); + } + + [Test] + public void GetCompiledRuleId_BeginEndRule_SkipsMissingPatterns() + { + // arrange + MockRuleFactoryHelper helper = new MockRuleFactoryHelper(); + Raw repository = new Raw(); + + Raw missingInclude = new Raw(); + missingInclude.SetInclude("#missing"); + + Raw includeOnlyRule = new Raw(); + includeOnlyRule.SetPatterns(new List { missingInclude }); + + Raw beginEndRule = new Raw(); + beginEndRule.SetName("outer.block"); + beginEndRule["begin"] = "\\["; + beginEndRule["end"] = "\\]"; + beginEndRule.SetPatterns(new List { includeOnlyRule }); + + // act + RuleId id = RuleFactory.GetCompiledRuleId(beginEndRule, helper, repository); + BeginEndRule compiledRule = helper.GetRule(id) as BeginEndRule; + + // assert + Assert.IsNotNull(compiledRule); + Assert.IsTrue(compiledRule.HasMissingPatterns); + Assert.AreEqual(0, compiledRule.Patterns.Count); + } + + [Test] + public void GetCompiledRuleId_BeginEndRule_SkipsMissingNestedBeginEndRule() + { + // arrange + MockRuleFactoryHelper helper = new MockRuleFactoryHelper(); + Raw repository = new Raw(); + + Raw missingInclude = new Raw(); + missingInclude.SetInclude("#missing"); + + Raw nestedBeginEndRule = new Raw(); + nestedBeginEndRule.SetName("nested.block"); + nestedBeginEndRule["begin"] = "\\{"; + nestedBeginEndRule["end"] = "\\}"; + nestedBeginEndRule.SetPatterns(new List { missingInclude }); + + Raw beginEndRule = new Raw(); + beginEndRule.SetName("outer.block"); + beginEndRule["begin"] = "\\("; + beginEndRule["end"] = "\\)"; + beginEndRule.SetPatterns(new List { nestedBeginEndRule }); + + // act + RuleId id = RuleFactory.GetCompiledRuleId(beginEndRule, helper, repository); + BeginEndRule compiledRule = helper.GetRule(id) as BeginEndRule; + + // assert + Assert.IsNotNull(compiledRule); + Assert.IsTrue(compiledRule.HasMissingPatterns); + Assert.AreEqual(0, compiledRule.Patterns.Count); + } + + [Test] + public void GetCompiledRuleId_BeginWhileRule_SkipsMissingPatterns() + { + // arrange + MockRuleFactoryHelper helper = new MockRuleFactoryHelper(); + Raw repository = new Raw(); + + Raw missingInclude = new Raw(); + missingInclude.SetInclude("#missing"); + + Raw beginWhileRule = new Raw(); + beginWhileRule.SetName("outer.while"); + beginWhileRule["begin"] = "\\("; + beginWhileRule["while"] = "\\)"; + beginWhileRule.SetPatterns(new List { missingInclude }); + + // act + RuleId id = RuleFactory.GetCompiledRuleId(beginWhileRule, helper, repository); + BeginWhileRule compiledRule = helper.GetRule(id) as BeginWhileRule; + + // assert + Assert.IsNotNull(compiledRule); + Assert.IsTrue(compiledRule.HasMissingPatterns); + Assert.AreEqual(0, compiledRule.Patterns.Count); + } + + [Test] + public void GetCompiledRuleId_BeginEndRule_SkipsMissingNestedBeginWhileRule() + { + // arrange + MockRuleFactoryHelper helper = new MockRuleFactoryHelper(); + Raw repository = new Raw(); + + Raw missingInclude = new Raw(); + missingInclude.SetInclude("#missing"); + + Raw nestedBeginWhileRule = new Raw(); + nestedBeginWhileRule.SetName("nested.while"); + nestedBeginWhileRule["begin"] = "\\{"; + nestedBeginWhileRule["while"] = "\\}"; + nestedBeginWhileRule.SetPatterns(new List { missingInclude }); + + Raw beginEndRule = new Raw(); + beginEndRule.SetName("outer.block"); + beginEndRule["begin"] = "\\("; + beginEndRule["end"] = "\\)"; + beginEndRule.SetPatterns(new List { nestedBeginWhileRule }); + + // act + RuleId id = RuleFactory.GetCompiledRuleId(beginEndRule, helper, repository); + BeginEndRule compiledRule = helper.GetRule(id) as BeginEndRule; + + // assert + Assert.IsNotNull(compiledRule); + Assert.IsTrue(compiledRule.HasMissingPatterns); + Assert.AreEqual(0, compiledRule.Patterns.Count); + } + + [Test] + public void GetCompiledRuleId_BeginEndRule_ResolvesExternalInclude() + { + // arrange + MockRuleFactoryHelper helper = new MockRuleFactoryHelper(); + Raw repository = new Raw(); + + Raw externalRepository = new Raw(); + + Raw externalInnerRule = new Raw(); + externalInnerRule.SetName("external.inner"); + externalInnerRule["match"] = "xyz"; + externalRepository["inner"] = externalInnerRule; + + Raw externalGrammar = new Raw(); + externalGrammar.SetRepository(externalRepository); + helper.AddExternalGrammar("external.scope", externalGrammar); + + Raw includeRule = new Raw(); + includeRule.SetInclude("external.scope#inner"); + + Raw beginEndRule = new Raw(); + beginEndRule.SetName("external.block"); + beginEndRule["begin"] = "\\("; + beginEndRule["end"] = "\\)"; + beginEndRule.SetPatterns(new List { includeRule }); + + // act + RuleId id = RuleFactory.GetCompiledRuleId(beginEndRule, helper, repository); + BeginEndRule compiledRule = helper.GetRule(id) as BeginEndRule; + + // assert + Assert.IsNotNull(compiledRule); + Assert.AreEqual(1, compiledRule.Patterns.Count); + Assert.IsInstanceOf(helper.GetRule(compiledRule.Patterns[0])); + } + + [Test] + public void GetCompiledRuleId_ExternalIncludeWithoutFragment_UsesExternalSelfRule() + { + // arrange + MockRuleFactoryHelper helper = new MockRuleFactoryHelper(); + Raw repository = new Raw(); + + Raw externalRepository = new Raw(); + Raw externalSelfRule = new Raw(); + externalSelfRule.SetName("external.self"); + externalSelfRule["match"] = "self"; + externalRepository.SetSelf(externalSelfRule); + + Raw externalGrammar = new Raw(); + externalGrammar.SetRepository(externalRepository); + helper.AddExternalGrammar("external.scope", externalGrammar); + + Raw includeRule = new Raw(); + includeRule.SetInclude("external.scope"); + + Raw beginEndRule = new Raw(); + beginEndRule.SetName("external.block"); + beginEndRule["begin"] = "\\("; + beginEndRule["end"] = "\\)"; + beginEndRule.SetPatterns(new List { includeRule }); + + // act + RuleId id = RuleFactory.GetCompiledRuleId(beginEndRule, helper, repository); + BeginEndRule compiledRule = helper.GetRule(id) as BeginEndRule; + + // assert + Assert.IsNotNull(compiledRule); + Assert.AreEqual(1, compiledRule.Patterns.Count); + Assert.IsInstanceOf(helper.GetRule(compiledRule.Patterns[0])); + } + + [Test] + public void GetCompiledRuleId_BeginEndRule_SkipsMissingNestedBeginEndRule_WithMixedPatterns() + { + // arrange + MockRuleFactoryHelper helper = new MockRuleFactoryHelper(); + Raw repository = new Raw(); + + Raw validPattern = new Raw(); + validPattern.SetName("valid.rule"); + validPattern["match"] = "valid"; + + Raw missingInclude = new Raw(); + missingInclude.SetInclude("#missing"); + + Raw nestedBeginEndRule = new Raw(); + nestedBeginEndRule.SetName("nested.block"); + nestedBeginEndRule["begin"] = "\\{"; + nestedBeginEndRule["end"] = "\\}"; + nestedBeginEndRule.SetPatterns(new List { validPattern, missingInclude }); + + Raw beginEndRule = new Raw(); + beginEndRule.SetName("outer.block"); + beginEndRule["begin"] = "\\("; + beginEndRule["end"] = "\\)"; + beginEndRule.SetPatterns(new List { nestedBeginEndRule }); + + // act + RuleId id = RuleFactory.GetCompiledRuleId(beginEndRule, helper, repository); + BeginEndRule compiledRule = helper.GetRule(id) as BeginEndRule; + + // assert + Assert.IsNotNull(compiledRule); + Assert.IsFalse(compiledRule.HasMissingPatterns); + Assert.AreEqual(1, compiledRule.Patterns.Count); + + BeginEndRule nestedRule = helper.GetRule(compiledRule.Patterns[0]) as BeginEndRule; + Assert.IsNotNull(nestedRule); + Assert.IsTrue(nestedRule.HasMissingPatterns); + Assert.AreEqual(1, nestedRule.Patterns.Count); + Assert.IsInstanceOf(helper.GetRule(nestedRule.Patterns[0])); + } + + [Test] + public void GetCompiledRuleId_BeginEndRule_ResolvesBaseInclude() + { + // arrange + MockRuleFactoryHelper helper = new MockRuleFactoryHelper(); + Raw repository = new Raw(); + + Raw baseRule = new Raw(); + baseRule.SetName("base.rule"); + baseRule["match"] = "base"; + repository.SetBase(baseRule); + + Raw includeRule = new Raw(); + includeRule.SetInclude("$base"); + + Raw beginEndRule = new Raw(); + beginEndRule.SetName("base.block"); + beginEndRule["begin"] = "\\("; + beginEndRule["end"] = "\\)"; + beginEndRule.SetPatterns(new List { includeRule }); + + // act + RuleId id = RuleFactory.GetCompiledRuleId(beginEndRule, helper, repository); + BeginEndRule compiledRule = helper.GetRule(id) as BeginEndRule; + + // assert + Assert.IsNotNull(compiledRule); + Assert.AreEqual(1, compiledRule.Patterns.Count); + Assert.IsInstanceOf(helper.GetRule(compiledRule.Patterns[0])); + Assert.IsFalse(compiledRule.HasMissingPatterns); + } + + private sealed class MockRuleFactoryHelper : IRuleFactoryHelper + { + private int _lastRuleId; + private readonly Dictionary _rules = new Dictionary(); + private readonly Dictionary _externalGrammars = new Dictionary(); + + public int RuleCount => _rules.Count; + + public Rule RegisterRule(Func factory) + { + RuleId id = RuleId.Of(++_lastRuleId); + Rule rule = factory(id); + _rules[id] = rule; + return rule; + } + + public Rule GetRule(RuleId patternId) + { + _rules.TryGetValue(patternId, out Rule rule); + return rule; + } + + public IRawGrammar GetExternalGrammar(string scopeName, IRawRepository repository) + { + _externalGrammars.TryGetValue(scopeName, out IRawGrammar grammar); + return grammar; + } + + public void AddExternalGrammar(string scopeName, IRawGrammar grammar) + { + _externalGrammars[scopeName] = grammar; + } + } + } +} \ No newline at end of file From 2e2f9489924b05340759a0f675c30cdefe68552e Mon Sep 17 00:00:00 2001 From: Dave Black <656118+udlose@users.noreply.github.com> Date: Tue, 17 Feb 2026 22:13:29 -0600 Subject: [PATCH 07/13] Refactor RuleFactory for clarity and efficiency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Streamlined variable usage by assigning local variables to reduce repeated method calls and improve readability. Simplified logic in CompileCaptures with early null checks, single-pass maximum ID calculation, and proper list initialization. Improved CompilePatterns by precomputing pattern count, consistent handling of includes, and more efficient return logic. Benchmark results: // Global total time: 00:01:15 (75.82 sec), executed benchmarks: 2 // // * Summary * // // BenchmarkDotNet v0.15.8, Windows 11 (10.0.26200.7840/25H2/2025Update/HudsonValley2) // AMD Ryzen 9 7945HX with Radeon Graphics 2.50GHz, 1 CPU, 32 logical and 16 physical cores // // .NET SDK 10.0.103 // [Host] : .NET 10.0.3 (10.0.3, 10.0.326.7603), X64 RyuJIT x86-64-v4 // //| Method | Mean | Error | StdDev | Ratio | RatioSD | Gen0 | Gen1 | Allocated | Alloc Ratio | //|------------- |---------:|----------:|----------:|-------------:|--------:|-------:|-------:|----------:|------------:| //| PreOptimized | 2.116 μs | 0.0419 μs | 0.0827 μs | baseline | | 0.4158 | 0.0038 | 6.8 KB | | //| Optimized | 2.035 μs | 0.0407 μs | 0.0841 μs | 1.04x faster | 0.06x | 0.4120 | 0.0038 | 6.75 KB | 1.01x less | --- .../Internal/Rules/RuleFactory.cs | 128 +++++++++--------- 1 file changed, 64 insertions(+), 64 deletions(-) diff --git a/src/TextMateSharp/Internal/Rules/RuleFactory.cs b/src/TextMateSharp/Internal/Rules/RuleFactory.cs index c422512..94ad72c 100644 --- a/src/TextMateSharp/Internal/Rules/RuleFactory.cs +++ b/src/TextMateSharp/Internal/Rules/RuleFactory.cs @@ -1,4 +1,3 @@ -using System; using System.Collections.Generic; using TextMateSharp.Internal.Types; @@ -27,48 +26,48 @@ public static RuleId GetCompiledRuleId(IRawRule desc, IRuleFactoryHelper helper, { desc.SetId(id); - if (desc.GetMatch() != null) + string match = desc.GetMatch(); + if (match != null) { - return new MatchRule(desc.GetId(), desc.GetName(), desc.GetMatch(), + return new MatchRule(desc.GetId(), desc.GetName(), match, RuleFactory.CompileCaptures(desc.GetCaptures(), helper, repository)); } - if (desc.GetBegin() == null) + string begin = desc.GetBegin(); + if (begin == null) { IRawRepository r = repository; - if (desc.GetRepository() != null) + IRawRepository descRepository = desc.GetRepository(); + if (descRepository != null) { - r = repository.Merge(desc.GetRepository()); + r = repository.Merge(descRepository); } return new IncludeOnlyRule(desc.GetId(), desc.GetName(), desc.GetContentName(), RuleFactory.CompilePatterns(desc.GetPatterns(), helper, r)); } string ruleWhile = desc.GetWhile(); + IRawCaptures captures = desc.GetCaptures(); + IRawCaptures beginCaptures = desc.GetBeginCaptures() ?? captures; + IRawCaptures whileCaptures = desc.GetWhileCaptures() ?? captures; + IRawCaptures endCaptures = desc.GetEndCaptures() ?? captures; + ICollection patterns = desc.GetPatterns(); if (ruleWhile != null) { return new BeginWhileRule( - desc.GetId(), desc.GetName(), desc.GetContentName(), desc.GetBegin(), - RuleFactory.CompileCaptures( - desc.GetBeginCaptures() != null ? desc.GetBeginCaptures() : desc.GetCaptures(), - helper, repository), + desc.GetId(), desc.GetName(), desc.GetContentName(), begin, + RuleFactory.CompileCaptures(beginCaptures, helper, repository), ruleWhile, - RuleFactory.CompileCaptures( - desc.GetWhileCaptures() != null ? desc.GetWhileCaptures() : desc.GetCaptures(), - helper, repository), - RuleFactory.CompilePatterns(desc.GetPatterns(), helper, repository)); + RuleFactory.CompileCaptures(whileCaptures, helper, repository), + RuleFactory.CompilePatterns(patterns, helper, repository)); } - return new BeginEndRule(desc.GetId(), desc.GetName(), desc.GetContentName(), desc.GetBegin(), - RuleFactory.CompileCaptures( - desc.GetBeginCaptures() != null ? desc.GetBeginCaptures() : desc.GetCaptures(), - helper, repository), + return new BeginEndRule(desc.GetId(), desc.GetName(), desc.GetContentName(), begin, + RuleFactory.CompileCaptures(beginCaptures, helper, repository), desc.GetEnd(), - RuleFactory.CompileCaptures( - desc.GetEndCaptures() != null ? desc.GetEndCaptures() : desc.GetCaptures(), helper, - repository), + RuleFactory.CompileCaptures(endCaptures, helper, repository), desc.IsApplyEndPatternLast(), - RuleFactory.CompilePatterns(desc.GetPatterns(), helper, repository)); + RuleFactory.CompilePatterns(patterns, helper, repository)); }); } @@ -78,45 +77,44 @@ public static RuleId GetCompiledRuleId(IRawRule desc, IRuleFactoryHelper helper, private static List CompileCaptures(IRawCaptures captures, IRuleFactoryHelper helper, IRawRepository repository) { - List r = new List(); + if (captures == null) + { + return new List(); + } + int numericCaptureId; - int maximumCaptureId; - int i; + int maximumCaptureId = 0; - if (captures != null) + // Find the maximum capture id + foreach (string captureId in captures) { - // Find the maximum capture id - maximumCaptureId = 0; - foreach (string captureId in captures) + numericCaptureId = ParseInt(captureId); + if (numericCaptureId > maximumCaptureId) { - numericCaptureId = ParseInt(captureId); - if (numericCaptureId > maximumCaptureId) - { - maximumCaptureId = numericCaptureId; - } + maximumCaptureId = numericCaptureId; } + } - // Initialize result - for (i = 0; i <= maximumCaptureId; i++) - { - r.Add(null); - } + // Initialize result + List r = new List(maximumCaptureId + 1); + for (int i = 0; i <= maximumCaptureId; i++) + { + r.Add(null); + } - // Fill out result - foreach (string captureId in captures) + // Fill out result + foreach (string captureId in captures) + { + numericCaptureId = ParseInt(captureId); + RuleId retokenizeCapturedWithRuleId = null; + IRawRule rule = captures.GetCapture(captureId); + if (rule.GetPatterns() != null) { - numericCaptureId = ParseInt(captureId); - RuleId retokenizeCapturedWithRuleId = null; - IRawRule rule = captures.GetCapture(captureId); - if (rule.GetPatterns() != null) - { - retokenizeCapturedWithRuleId = RuleFactory.GetCompiledRuleId(captures.GetCapture(captureId), helper, - repository); - } - r[numericCaptureId] = RuleFactory.CreateCaptureRule( - helper, rule.GetName(), rule.GetContentName(), - retokenizeCapturedWithRuleId); + retokenizeCapturedWithRuleId = RuleFactory.GetCompiledRuleId(rule, helper, repository); } + r[numericCaptureId] = RuleFactory.CreateCaptureRule( + helper, rule.GetName(), rule.GetContentName(), + retokenizeCapturedWithRuleId); } return r; @@ -132,7 +130,8 @@ private static int ParseInt(string str) private static CompilePatternsResult CompilePatterns(ICollection patterns, IRuleFactoryHelper helper, IRawRepository repository) { - List r = new List(); + int patternCount = patterns != null ? patterns.Count : 0; + List r = new List(patternCount); RuleId patternId; IRawGrammar externalGrammar; Rule rule; @@ -143,13 +142,14 @@ private static CompilePatternsResult CompilePatterns(ICollection patte foreach (IRawRule pattern in patterns) { patternId = null; + string include = pattern.GetInclude(); - if (pattern.GetInclude() != null) + if (include != null) { - if (pattern.GetInclude()[0] == '#') + if (include[0] == '#') { // Local include found in `repository` - IRawRule localIncludedRule = repository.GetProp(pattern.GetInclude().Substring(1)); + IRawRule localIncludedRule = repository.GetProp(include.Substring(1)); if (localIncludedRule != null) { patternId = RuleFactory.GetCompiledRuleId(localIncludedRule, helper, repository); @@ -161,24 +161,24 @@ private static CompilePatternsResult CompilePatterns(ICollection patte // repository['$base'].name); } } - else if (pattern.GetInclude().Equals("$base") || pattern.GetInclude().Equals("$self")) + else if (include.Equals("$base") || include.Equals("$self")) { // Special include also found in `repository` - patternId = RuleFactory.GetCompiledRuleId(repository.GetProp(pattern.GetInclude()), helper, + patternId = RuleFactory.GetCompiledRuleId(repository.GetProp(include), helper, repository); } else { string externalGrammarName = null, externalGrammarInclude = null; - int sharpIndex = pattern.GetInclude().IndexOf('#'); + int sharpIndex = include.IndexOf('#'); if (sharpIndex >= 0) { - externalGrammarName = pattern.GetInclude().SubstringAtIndexes(0, sharpIndex); - externalGrammarInclude = pattern.GetInclude().Substring(sharpIndex + 1); + externalGrammarName = include.SubstringAtIndexes(0, sharpIndex); + externalGrammarInclude = include.Substring(sharpIndex + 1); } else { - externalGrammarName = pattern.GetInclude(); + externalGrammarName = include; } // External include externalGrammar = helper.GetExternalGrammar(externalGrammarName, repository); @@ -264,7 +264,7 @@ private static CompilePatternsResult CompilePatterns(ICollection patte } } - return new CompilePatternsResult(r, ((patterns != null ? patterns.Count : 0) != r.Count)); + return new CompilePatternsResult(r, (patternCount != r.Count)); } - } + } } \ No newline at end of file From e9e7519628bba513619e7c42043a24d56b4e2489 Mon Sep 17 00:00:00 2001 From: Dave Black <656118+udlose@users.noreply.github.com> Date: Wed, 18 Feb 2026 12:09:00 -0600 Subject: [PATCH 08/13] Add comprehensive unit tests for RegexSource utility Introduces RegexSourceTests covering EscapeRegExpCharacters, HasCaptures, and ReplaceCaptures methods. Tests include special character escaping, capture detection, replacement logic, edge cases, and error handling. Utilizes NUnit and Moq for assertions and mocking. No changes to production code. --- .../Internal/Utils/RegexSourceTests.cs | 863 ++++++++++++++++++ 1 file changed, 863 insertions(+) create mode 100644 src/TextMateSharp.Tests/Internal/Utils/RegexSourceTests.cs diff --git a/src/TextMateSharp.Tests/Internal/Utils/RegexSourceTests.cs b/src/TextMateSharp.Tests/Internal/Utils/RegexSourceTests.cs new file mode 100644 index 0000000..13706a1 --- /dev/null +++ b/src/TextMateSharp.Tests/Internal/Utils/RegexSourceTests.cs @@ -0,0 +1,863 @@ +using Moq; +using NUnit.Framework; +using Onigwrap; +using System; +using System.Text; +using TextMateSharp.Internal.Utils; + +namespace TextMateSharp.Tests.Internal.Utils +{ + [TestFixture] + public class RegexSourceTests + { + #region EscapeRegExpCharacters Tests + + [Test] + public void EscapeRegExpCharacters_EscapesAllSpecialCharacters() + { + // arrange + const string input = @"a-b\c{d}e*f+g?h|i^j$k.l,m[n]o(p)q#r"; + const string expected = @"a\-b\\c\{d\}e\*f\+g\?h\|i\^j\$k\.l\,m\[n\]o\(p\)q\#r"; + + // act + string result = RegexSource.EscapeRegExpCharacters(input); + + // assert + Assert.AreEqual(expected, result); + } + + [Test] + public void EscapeRegExpCharacters_DoesNotEscapeWhitespace() + { + // arrange + const string input = "a b\tc\nd"; + const string expected = "a b\tc\nd"; + + // act + string result = RegexSource.EscapeRegExpCharacters(input); + + // assert + Assert.AreEqual(expected, result); + } + + [Test] + public void EscapeRegExpCharacters_OnlySpecialCharacters_EscapesAll() + { + // arrange + const string input = @"-\{}*+?|^$.,[]()#"; + const string expected = @"\-\\\{\}\*\+\?\|\^\$\.\,\[\]\(\)\#"; + + // act + string result = RegexSource.EscapeRegExpCharacters(input); + + // assert + Assert.AreEqual(expected, result); + } + + [Test] + public void EscapeRegExpCharacters_ConsecutiveSpecialCharacters_EscapesEach() + { + // arrange + const string input = "++--"; + const string expected = @"\+\+\-\-"; + + // act + string result = RegexSource.EscapeRegExpCharacters(input); + + // assert + Assert.AreEqual(expected, result); + } + + [TestCase("-", "\\-")] + [TestCase("\\", "\\\\")] + [TestCase("{", "\\{")] + [TestCase("}", "\\}")] + [TestCase("*", "\\*")] + [TestCase("+", "\\+")] + [TestCase("?", "\\?")] + [TestCase("|", "\\|")] + [TestCase("^", "\\^")] + [TestCase("$", "\\$")] + [TestCase(".", "\\.")] + [TestCase(",", "\\,")] + [TestCase("[", "\\[")] + [TestCase("]", "\\]")] + [TestCase("(", "\\(")] + [TestCase(")", "\\)")] + [TestCase("#", "\\#")] + public void EscapeRegExpCharacters_SingleSpecialCharacter_Escapes(string input, string expected) + { + // act + string result = RegexSource.EscapeRegExpCharacters(input); + + // assert + Assert.AreEqual(expected, result); + } + + [Test] + public void EscapeRegExpCharacters_TwoSpecialCharacters_EscapesBoth() + { + // arrange + const string input = "[]"; + const string expected = @"\[\]"; + + // act + string result = RegexSource.EscapeRegExpCharacters(input); + + // assert + Assert.AreEqual(expected, result); + } + + [Test] + public void EscapeRegExpCharacters_ExtremelyLongString_EscapesAll() + { + // arrange + // Build the expected result using a simple StringBuilder-based approach without substring allocations. + // this is intentionally straightforward test code rather than a fully span-optimized implementation + const int length = 10_000; + string input = new string('-', length); + StringBuilder expectedBuilder = new StringBuilder(length * 2); + for (int i = 0; i < length; i++) + { + expectedBuilder.Append('\\'); + expectedBuilder.Append('-'); + } + + // act + string result = RegexSource.EscapeRegExpCharacters(input); + + // assert + Assert.AreEqual(expectedBuilder.ToString(), result); + } + + [Test] + public void EscapeRegExpCharacters_Empty_ReturnsEmpty() + { + // arrange + const string input = ""; + + // act + string result = RegexSource.EscapeRegExpCharacters(input); + + // assert + Assert.AreEqual(string.Empty, result); + } + + [Test] + public void EscapeRegExpCharacters_Null_Throws() + { + Assert.Throws(() => RegexSource.EscapeRegExpCharacters(null)); + } + + [Test] + public void EscapeRegExpCharacters_SingleNonSpecialCharacter_ReturnsUnchanged() + { + // arrange + const string input = "a"; + + // act + string result = RegexSource.EscapeRegExpCharacters(input); + + // assert + Assert.AreEqual("a", result); + } + + #endregion EscapeRegExpCharacters Tests + + #region HasCaptures Tests + + [Test] + public void HasCaptures_NullSource_ReturnsFalse() + { + // arrange + const string input = null; + + // act + bool result = RegexSource.HasCaptures(input); + + // assert + Assert.IsFalse(result); + } + + [Test] + public void HasCaptures_Empty_ReturnsFalse() + { + // arrange + const string input = ""; + + // act + bool result = RegexSource.HasCaptures(input); + + // assert + Assert.IsFalse(result); + } + + [Test] + public void HasCaptures_NoCaptures_ReturnsFalse() + { + // arrange + const string input = "abc$def"; + + // act + bool result = RegexSource.HasCaptures(input); + + // assert + Assert.IsFalse(result); + } + + [Test] + public void HasCaptures_NumericCapture_ReturnsTrue() + { + // arrange + const string input = "value $1 end"; + + // act + bool result = RegexSource.HasCaptures(input); + + // assert + Assert.IsTrue(result); + } + + [Test] + public void HasCaptures_CommandCapture_ReturnsTrue() + { + // arrange + const string input = "value ${2:/downcase} end"; + + // act + bool result = RegexSource.HasCaptures(input); + + // assert + Assert.IsTrue(result); + } + + [TestCase("value $a end")] + [TestCase("value $-1 end")] + [TestCase("value $ end")] + public void HasCaptures_MalformedNumeric_ReturnsFalse(string input) + { + // act + bool result = RegexSource.HasCaptures(input); + + // assert + Assert.IsFalse(result); + } + + [TestCase("value ${2:/invalid} end")] + [TestCase("value ${2:/} end")] + [TestCase("value ${2:/upcase end")] + [TestCase("value ${2:/{downcase}} end")] + public void HasCaptures_MalformedCommand_ReturnsFalse(string input) + { + // act + bool result = RegexSource.HasCaptures(input); + + // assert + Assert.IsFalse(result); + } + + [Test] + public void HasCaptures_NumericCaptureInMalformedCommand_ReturnsTrue() + { + // arrange + const string input = "value $2:/upcase} end"; + + // act + bool result = RegexSource.HasCaptures(input); + + // assert + Assert.IsTrue(result); + } + + #endregion HasCaptures Tests + + #region ReplaceCaptures Tests + + [Test] + public void ReplaceCaptures_NoMatches_ReturnsOriginalString() + { + // arrange + const string regexSource = "plain text"; + ReadOnlyMemory captureSource = "value".AsMemory(); + + // act + string result = RegexSource.ReplaceCaptures(regexSource, captureSource, []); + + // assert + Assert.AreEqual(regexSource, result); + } + + [Test] + public void ReplaceCaptures_ReplacesNumericCaptures() + { + // arrange + const string regexSource = "Hello $1 $2"; + ReadOnlyMemory captureSource = "alpha beta".AsMemory(); + IOnigCaptureIndex[] captureIndices = + [ + CreateCapture(0, 10), + CreateCapture(0, 5), + CreateCapture(6, 10) + ]; + + // act + string result = RegexSource.ReplaceCaptures(regexSource, captureSource, captureIndices); + + // assert + Assert.AreEqual("Hello alpha beta", result); + } + + [Test] + public void ReplaceCaptures_ReplacesCommandCapturesWithCaseTransformations() + { + // arrange + const string regexSource = "Value ${1:/upcase} ${2:/downcase}"; + ReadOnlyMemory captureSource = "MiXeD CaSe".AsMemory(); + IOnigCaptureIndex[] captureIndices = + [ + CreateCapture(0, 10), + CreateCapture(0, 5), + CreateCapture(6, 10) + ]; + + // act + string result = RegexSource.ReplaceCaptures(regexSource, captureSource, captureIndices); + + // assert + Assert.AreEqual("Value MIXED case", result); + } + + [Test] + public void ReplaceCaptures_AllowsCaptureZero() + { + // arrange + const string regexSource = "start $0 end"; + ReadOnlyMemory captureSource = "alpha".AsMemory(); + IOnigCaptureIndex[] captureIndices = + [ + CreateCapture(0, 5) + ]; + + // act + string result = RegexSource.ReplaceCaptures(regexSource, captureSource, captureIndices); + + // assert + Assert.AreEqual("start alpha end", result); + } + + [Test] + public void ReplaceCaptures_ReplacesMaximumCaptureIndex() + { + // arrange + const string regexSource = "value $0 $99"; + ReadOnlyMemory captureSource = "abc".AsMemory(); + IOnigCaptureIndex[] captureIndices = new IOnigCaptureIndex[100]; + captureIndices[0] = CreateCapture(0, 1); + captureIndices[99] = CreateCapture(0, 3); + + // act + string result = RegexSource.ReplaceCaptures(regexSource, captureSource, captureIndices); + + // assert + Assert.AreEqual("value a abc", result); + } + + [Test] + public void ReplaceCaptures_HighNumberedCaptureNullEntry_ReturnsOriginalMatch() + { + // arrange + const string regexSource = "value $98"; + ReadOnlyMemory captureSource = "abc".AsMemory(); + IOnigCaptureIndex[] captureIndices = new IOnigCaptureIndex[99]; + + // act + string result = RegexSource.ReplaceCaptures(regexSource, captureSource, captureIndices); + + // assert + Assert.AreEqual("value $98", result); + } + + [Test] + public void ReplaceCaptures_CaptureIndexEqualToArrayLength_ReturnsOriginalMatch() + { + // arrange + const string regexSource = "value $98"; + ReadOnlyMemory captureSource = "abc".AsMemory(); + IOnigCaptureIndex[] captureIndices = new IOnigCaptureIndex[98]; + + // act + string result = RegexSource.ReplaceCaptures(regexSource, captureSource, captureIndices); + + // assert + Assert.AreEqual("value $98", result); + } + + [Test] + public void ReplaceCaptures_OutOfBoundsCaptureIndex_ReturnsOriginalMatch() + { + // arrange + const string regexSource = "value $100"; + ReadOnlyMemory captureSource = "abc".AsMemory(); + IOnigCaptureIndex[] captureIndices = new IOnigCaptureIndex[100]; + + // act + string result = RegexSource.ReplaceCaptures(regexSource, captureSource, captureIndices); + + // assert + Assert.AreEqual("value $100", result); + } + + [Test] + public void ReplaceCaptures_MinimumLengthArrayWithNullEntry_ReturnsOriginalMatch() + { + // arrange + const string regexSource = "value $1"; + ReadOnlyMemory captureSource = "abc".AsMemory(); + IOnigCaptureIndex[] captureIndices = new IOnigCaptureIndex[2]; + + // act + string result = RegexSource.ReplaceCaptures(regexSource, captureSource, captureIndices); + + // assert + Assert.AreEqual("value $1", result); + } + + [Test] + public void ReplaceCaptures_CommandCaptureZero_UsesTransform() + { + // arrange + const string regexSource = "value ${0:/upcase}"; + ReadOnlyMemory captureSource = "abc".AsMemory(); + IOnigCaptureIndex[] captureIndices = + [ + CreateCapture(0, 3) + ]; + + // act + string result = RegexSource.ReplaceCaptures(regexSource, captureSource, captureIndices); + + // assert + Assert.AreEqual("value ABC", result); + } + + [Test] + public void ReplaceCaptures_RemovesLeadingDotsBeforeReturning() + { + // arrange + const string regexSource = "prefix $1"; + ReadOnlyMemory captureSource = ".Foo".AsMemory(); + IOnigCaptureIndex[] captureIndices = + [ + CreateCapture(0, 4), + CreateCapture(0, 4) + ]; + + // act + string result = RegexSource.ReplaceCaptures(regexSource, captureSource, captureIndices); + + // assert + Assert.AreEqual("prefix Foo", result); + } + + [Test] + public void ReplaceCaptures_RemovesMultipleLeadingDots() + { + // arrange + const string regexSource = "prefix $1"; + ReadOnlyMemory captureSource = "...Foo".AsMemory(); + IOnigCaptureIndex[] captureIndices = + [ + CreateCapture(0, 6), + CreateCapture(0, 6) + ]; + + // act + string result = RegexSource.ReplaceCaptures(regexSource, captureSource, captureIndices); + + // assert + Assert.AreEqual("prefix Foo", result); + } + + [Test] + public void ReplaceCaptures_CaptureWithOnlyLeadingDots_ReturnsEmptyCapture() + { + // arrange + const string regexSource = "prefix $1"; + ReadOnlyMemory captureSource = "...".AsMemory(); + IOnigCaptureIndex[] captureIndices = + [ + CreateCapture(0, 3), + CreateCapture(0, 3) + ]; + + // act + string result = RegexSource.ReplaceCaptures(regexSource, captureSource, captureIndices); + + // assert + Assert.AreEqual("prefix ", result); + } + + [Test] + public void ReplaceCaptures_ZeroLengthCapture_ReplacesWithEmpty() + { + // arrange + const string regexSource = "x$1y"; + ReadOnlyMemory captureSource = "abc".AsMemory(); + IOnigCaptureIndex[] captureIndices = + [ + CreateCapture(0, 3), + CreateCapture(1, 1) + ]; + + // act + string result = RegexSource.ReplaceCaptures(regexSource, captureSource, captureIndices); + + // assert + Assert.AreEqual("xy", result); + } + + [Test] + public void ReplaceCaptures_CaptureReferencesWithEmptyIndices_ReturnsOriginalMatch() + { + // arrange + const string regexSource = "value $1"; + ReadOnlyMemory captureSource = "abc".AsMemory(); + + // act + string result = RegexSource.ReplaceCaptures(regexSource, captureSource, []); + + // assert + Assert.AreEqual("value $1", result); + } + + [Test] + public void ReplaceCaptures_ZeroLengthCaptureAtStart_ReplacesWithEmpty() + { + // arrange + const string regexSource = "value $1"; + ReadOnlyMemory captureSource = "abc".AsMemory(); + IOnigCaptureIndex[] captureIndices = + [ + null, + CreateCapture(0, 0) + ]; + + // act + string result = RegexSource.ReplaceCaptures(regexSource, captureSource, captureIndices); + + // assert + Assert.AreEqual("value ", result); + } + + [Test] + public void ReplaceCaptures_ZeroLengthCaptureAtEnd_ReplacesWithEmpty() + { + // arrange + const string regexSource = "value $1"; + ReadOnlyMemory captureSource = "abc".AsMemory(); + int end = captureSource.Length; + IOnigCaptureIndex[] captureIndices = + [ + null, + CreateCapture(end, end) + ]; + + // act + string result = RegexSource.ReplaceCaptures(regexSource, captureSource, captureIndices); + + // assert + Assert.AreEqual("value ", result); + } + + [Test] + public void ReplaceCaptures_MissingCaptureIndex_ReturnsOriginalMatch() + { + // arrange + const string regexSource = "start $2 end"; + ReadOnlyMemory captureSource = "alpha".AsMemory(); + IOnigCaptureIndex[] captureIndices = + [ + CreateCapture(0, 5), + CreateCapture(0, 5) + ]; + + // act + string result = RegexSource.ReplaceCaptures(regexSource, captureSource, captureIndices); + + // assert + Assert.AreEqual("start $2 end", result); + } + + [TestCase("value $a end")] + [TestCase("value $-1 end")] + [TestCase("value $ end")] + public void ReplaceCaptures_MalformedNumeric_ReturnsOriginal(string regexSource) + { + // arrange + ReadOnlyMemory captureSource = "alpha".AsMemory(); + + // act + string result = RegexSource.ReplaceCaptures(regexSource, captureSource, []); + + // assert + Assert.AreEqual(regexSource, result); + } + + [TestCase("value ${2:/invalid} end")] + [TestCase("value ${2:/} end")] + [TestCase("value ${2:/upcase end")] + [TestCase("value $2:/upcase} end")] + [TestCase("value ${2:/{downcase}} end")] + public void ReplaceCaptures_MalformedCommand_ReturnsOriginal(string regexSource) + { + // arrange + ReadOnlyMemory captureSource = "alpha".AsMemory(); + + // act + string result = RegexSource.ReplaceCaptures(regexSource, captureSource, []); + + // assert + Assert.AreEqual(regexSource, result); + } + + [Test] + public void ReplaceCaptures_NullCaptureEntry_ReturnsOriginalMatch() + { + // arrange + const string regexSource = "value $1"; + ReadOnlyMemory captureSource = "alpha".AsMemory(); + IOnigCaptureIndex[] captureIndices = + [ + CreateCapture(0, 5), + null + ]; + + // act + string result = RegexSource.ReplaceCaptures(regexSource, captureSource, captureIndices); + + // assert + Assert.AreEqual("value $1", result); + } + + [Test] + public void ReplaceCaptures_NullCaptureIndices_ReturnsOriginalMatch() + { + // arrange + const string regexSource = "value $1"; + ReadOnlyMemory captureSource = "alpha".AsMemory(); + + // act + string result = RegexSource.ReplaceCaptures(regexSource, captureSource, null); + + // assert + Assert.AreEqual("value $1", result); + } + + [Test] + public void ReplaceCaptures_InvalidCaptureStart_Throws() + { + // arrange + const string regexSource = "value $1"; + ReadOnlyMemory captureSource = "alpha".AsMemory(); + IOnigCaptureIndex[] captureIndices = + [ + CreateCapture(0, 5), + CreateCapture(-1, 2) + ]; + + // act + assert + Assert.Throws( + () => RegexSource.ReplaceCaptures(regexSource, captureSource, captureIndices)); + } + + [Test] + public void ReplaceCaptures_CaptureEndBeyondLength_Throws() + { + // arrange + const string regexSource = "value $1"; + ReadOnlyMemory captureSource = "alpha".AsMemory(); + IOnigCaptureIndex[] captureIndices = + [ + CreateCapture(0, 5), + CreateCapture(0, 6) + ]; + + // act + assert + Assert.Throws( + () => RegexSource.ReplaceCaptures(regexSource, captureSource, captureIndices)); + } + + [Test] + public void ReplaceCaptures_CaptureStartGreaterThanEnd_Throws() + { + // arrange + const string regexSource = "value $1"; + ReadOnlyMemory captureSource = "alpha".AsMemory(); + IOnigCaptureIndex[] captureIndices = + [ + CreateCapture(0, 5), + CreateCapture(4, 2) + ]; + + // act + assert + Assert.Throws( + () => RegexSource.ReplaceCaptures(regexSource, captureSource, captureIndices)); + } + + [Test] + public void ReplaceCaptures_NullRegexSource_Throws() + { + // arrange + ReadOnlyMemory captureSource = "alpha".AsMemory(); + + // act + assert + Assert.Throws( + () => RegexSource.ReplaceCaptures(null, captureSource, [])); + } + + [Test] + public void ReplaceCaptures_NullCaptureEntryAtIndexZero_ReturnsOriginalMatch() + { + // arrange + const string regexSource = "value $0"; + ReadOnlyMemory captureSource = "abc".AsMemory(); + IOnigCaptureIndex[] captureIndices = new IOnigCaptureIndex[1]; + + // act + string result = RegexSource.ReplaceCaptures(regexSource, captureSource, captureIndices); + + // assert + Assert.AreEqual("value $0", result); + } + + [Test] + public void ReplaceCaptures_EmptyRegexSource_ReturnsEmpty() + { + // arrange + const string regexSource = ""; + ReadOnlyMemory captureSource = "alpha".AsMemory(); + + // act + string result = RegexSource.ReplaceCaptures(regexSource, captureSource, []); + + // assert + Assert.AreEqual(string.Empty, result); + } + + [Test] + public void ReplaceCaptures_EmptyCaptureSource_ReplacesWithEmpty() + { + // arrange + const string regexSource = "value $1"; + ReadOnlyMemory captureSource = ReadOnlyMemory.Empty; + IOnigCaptureIndex[] captureIndices = + [ + null, + CreateCapture(0, 0) + ]; + + // act + string result = RegexSource.ReplaceCaptures(regexSource, captureSource, captureIndices); + + // assert + Assert.AreEqual("value ", result); + } + + [Test] + public void ReplaceCaptures_CaptureAtEndOfSource_Replaces() + { + // arrange + const string regexSource = "value $1"; + ReadOnlyMemory captureSource = "abc".AsMemory(); + int captureEndIndex = captureSource.Length; + IOnigCaptureIndex[] captureIndices = + [ + null, + CreateCapture(0, captureEndIndex) + ]; + + // act + string result = RegexSource.ReplaceCaptures(regexSource, captureSource, captureIndices); + + // assert + Assert.AreEqual("value abc", result); + } + + [Test] + public void ReplaceCaptures_CaptureZeroWithSingleElementArray_Replaces() + { + // arrange + const string regexSource = "value $0"; + ReadOnlyMemory captureSource = "abc".AsMemory(); + IOnigCaptureIndex[] captureIndices = + [ + CreateCapture(0, 3) + ]; + + // act + string result = RegexSource.ReplaceCaptures(regexSource, captureSource, captureIndices); + + // assert + Assert.AreEqual("value abc", result); + } + + [Test] + public void ReplaceCaptures_CaptureZeroWithEmptyArray_ReturnsOriginalMatch() + { + // arrange + const string regexSource = "value $0"; + ReadOnlyMemory captureSource = "abc".AsMemory(); + + // act + string result = RegexSource.ReplaceCaptures(regexSource, captureSource, []); + + // assert + Assert.AreEqual("value $0", result); + } + + [Test] + public void ReplaceCaptures_NullEntryAtLastIndex_ReturnsOriginalMatch() + { + // arrange + const string regexSource = "value $2"; + ReadOnlyMemory captureSource = "abc".AsMemory(); + IOnigCaptureIndex[] captureIndices = new IOnigCaptureIndex[3]; + + // act + string result = RegexSource.ReplaceCaptures(regexSource, captureSource, captureIndices); + + // assert + Assert.AreEqual("value $2", result); + } + + [Test] + public void ReplaceCaptures_MaxIntCaptureIndex_ReturnsOriginalMatch() + { + // arrange + string index = int.MaxValue.ToString(); + string regexSource = $"value ${index}"; + ReadOnlyMemory captureSource = "abc".AsMemory(); + IOnigCaptureIndex[] captureIndices = + [ + CreateCapture(0, 3) + ]; + + // act + string result = RegexSource.ReplaceCaptures(regexSource, captureSource, captureIndices); + + // assert + Assert.AreEqual(regexSource, result); + } + + #endregion ReplaceCaptures Tests + + private static IOnigCaptureIndex CreateCapture(int start, int end) + { + Mock capture = new Mock(); + capture.SetupGet(c => c.Start).Returns(start); + capture.SetupGet(c => c.End).Returns(end); + capture.SetupGet(c => c.Length).Returns(end - start); + return capture.Object; + } + } +} \ No newline at end of file From 34384934fbf2c40921a23659b1c222fc96ec4290 Mon Sep 17 00:00:00 2001 From: Dave Black <656118+udlose@users.noreply.github.com> Date: Wed, 18 Feb 2026 13:18:42 -0600 Subject: [PATCH 09/13] Add .runsettings to configure code coverage --- .runsettings | 29 +++++++++++++++++++++++++++++ TextMateSharp.sln | 1 + 2 files changed, 30 insertions(+) create mode 100644 .runsettings diff --git a/.runsettings b/.runsettings new file mode 100644 index 0000000..b74957b --- /dev/null +++ b/.runsettings @@ -0,0 +1,29 @@ + + + + + + + + + .*\.dll$ + + + .*Tests\.dll$ + .*Demo\.dll$ + + .*Microsoft\..* + .*System\..* + .*Moq\.dll$ + .*nunit.* + + + + + False + + + + + + diff --git a/TextMateSharp.sln b/TextMateSharp.sln index f6b02e7..5cceae5 100644 --- a/TextMateSharp.sln +++ b/TextMateSharp.sln @@ -13,6 +13,7 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TextMateSharp.Grammars", "s EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "SolutionItems", "SolutionItems", "{FB55729C-1952-4D20-BFE7-C3202B160A0B}" ProjectSection(SolutionItems) = preProject + .runsettings = .runsettings build\Directory.Build.props = build\Directory.Build.props build\SourceLink.props = build\SourceLink.props EndProjectSection From c242722f4f049a616b3892992f2fe9cbf62ad21d Mon Sep 17 00:00:00 2001 From: Dave Black <656118+udlose@users.noreply.github.com> Date: Wed, 18 Feb 2026 13:21:32 -0600 Subject: [PATCH 10/13] Refactor RegexSource for performance and safety Refactored RegexSource.cs to improve performance, safety, and code clarity. Key changes include using spans for efficient string parsing, adding null checks, making the regex field readonly, optimizing capture index parsing, and reducing string allocations. These updates modernize the code and enhance reliability. Benchmark Results: // Global total time: 00:11:40 (700.15 sec), executed benchmarks: 12 // * Summary * // // BenchmarkDotNet v0.15.8, Windows 11 (10.0.26200.7840/25H2/2025Update/HudsonValley2) // AMD Ryzen 9 7945HX with Radeon Graphics 2.50GHz, 1 CPU, 32 logical and 16 physical cores // // .NET SDK 10.0.103 // [Host] : .NET 10.0.3 (10.0.3, 10.0.326.7603), X64 RyuJIT x86-64-v4 // //| Method | Categories | Mean | Error | StdDev | Median | Ratio | RatioSD | Gen0 | Allocated | Alloc Ratio | //|------------------------------------------ |-------------------------------- |----------:|----------:|----------:|----------:|-------------:|--------:|-------:|----------:|------------:| //| EscapeRegExpCharacters_Plain | EscapeRegExpCharacters_Plain | 16.19 ns | 0.269 ns | 0.238 ns | 16.17 ns | baseline | | 0.0086 | 144 B | | //| EscapeRegExpCharacters_Plain_Optimized | EscapeRegExpCharacters_Plain | 16.47 ns | 0.364 ns | 0.357 ns | 16.46 ns | 1.02x slower | 0.03x | 0.0086 | 144 B | 1.00x more | //| | | | | | | | | | | | //| EscapeRegExpCharacters_Specials | EscapeRegExpCharacters_Specials | 85.03 ns | 2.223 ns | 6.379 ns | 84.67 ns | baseline | | 0.0248 | 416 B | | //| EscapeRegExpCharacters_Specials_Optimized | EscapeRegExpCharacters_Specials | 83.73 ns | 3.020 ns | 8.762 ns | 84.32 ns | 1.03x faster | 0.13x | 0.0248 | 416 B | 1.00x more | //| | | | | | | | | | | | //| HasCaptures_NoCapture | HasCaptures_NoCapture | 55.80 ns | 0.672 ns | 0.561 ns | 55.89 ns | baseline | | - | - | NA | //| HasCaptures_NoCapture_Optimized | HasCaptures_NoCapture | 55.79 ns | 0.725 ns | 0.678 ns | 55.67 ns | 1.00x faster | 0.02x | - | - | NA | //| | | | | | | | | | | | //| HasCaptures_WithCapture | HasCaptures_WithCapture | 99.10 ns | 2.801 ns | 8.169 ns | 99.42 ns | baseline | | 0.0162 | 272 B | | //| HasCaptures_WithCapture_Optimized | HasCaptures_WithCapture | 71.60 ns | 1.669 ns | 4.816 ns | 70.85 ns | 1.39x faster | 0.15x | - | - | NA | //| | | | | | | | | | | | //| ReplaceCaptures_Command | ReplaceCaptures_Command | 525.11 ns | 12.614 ns | 36.192 ns | 511.48 ns | baseline | | 0.0668 | 1128 B | | //| ReplaceCaptures_Command_Optimized | ReplaceCaptures_Command | 536.34 ns | 11.231 ns | 31.860 ns | 534.76 ns | 1.03x slower | 0.09x | 0.0591 | 1000 B | 1.13x less | //| | | | | | | | | | | | //| ReplaceCaptures_Numeric | ReplaceCaptures_Numeric | 420.10 ns | 9.319 ns | 25.666 ns | 414.95 ns | baseline | | 0.0525 | 880 B | | //| ReplaceCaptures_Numeric_Optimized | ReplaceCaptures_Numeric | 375.61 ns | 9.771 ns | 28.034 ns | 377.30 ns | 1.12x faster | 0.11x | 0.0496 | 832 B | 1.06x less | --- .../Internal/Utils/RegexSource.cs | 63 ++++++++++++++----- 1 file changed, 47 insertions(+), 16 deletions(-) diff --git a/src/TextMateSharp/Internal/Utils/RegexSource.cs b/src/TextMateSharp/Internal/Utils/RegexSource.cs index 762948b..bc530d7 100644 --- a/src/TextMateSharp/Internal/Utils/RegexSource.cs +++ b/src/TextMateSharp/Internal/Utils/RegexSource.cs @@ -1,19 +1,26 @@ +using Onigwrap; using System; using System.Text; using System.Text.RegularExpressions; -using Onigwrap; namespace TextMateSharp.Internal.Utils { public class RegexSource { - private static Regex CAPTURING_REGEX_SOURCE = new Regex( + private static readonly Regex CAPTURING_REGEX_SOURCE = new Regex( "\\$(\\d+)|\\$\\{(\\d+):\\/(downcase|upcase)}"); public static string EscapeRegExpCharacters(string value) { + if (value == null) throw new ArgumentNullException(nameof(value)); + int valueLen = value.Length; + if (valueLen == 0) + { + return string.Empty; + } + var sb = new StringBuilder(valueLen); for (int i = 0; i < valueLen; i++) { @@ -59,7 +66,7 @@ public static bool HasCaptures(string regexSource) { return false; } - return CAPTURING_REGEX_SOURCE.Match(regexSource).Success; + return CAPTURING_REGEX_SOURCE.IsMatch(regexSource); } public static string ReplaceCaptures(string regexSource, ReadOnlyMemory captureSource, IOnigCaptureIndex[] captureIndices) @@ -70,32 +77,42 @@ public static string ReplaceCaptures(string regexSource, ReadOnlyMemory ca private static string GetReplacement(string match, ReadOnlyMemory captureSource, IOnigCaptureIndex[] captureIndices) { - int index = -1; - string command = null; - int doublePointIndex = match.IndexOf(':'); + ReadOnlySpan matchSpan = match.AsSpan(); + int doublePointIndex = matchSpan.IndexOf(':'); + + int index = ParseCaptureIndex(matchSpan, doublePointIndex); + + ReadOnlySpan commandSpan = default; if (doublePointIndex != -1) { - index = int.Parse(match.SubstringAtIndexes(2, doublePointIndex)); - command = match.SubstringAtIndexes(doublePointIndex + 2, match.Length - 1); - } - else - { - index = int.Parse(match.SubstringAtIndexes(1, match.Length)); + int commandStart = doublePointIndex + 2; + int commandLength = matchSpan.Length - commandStart - 1; // exclude trailing '}' + if (commandLength > 0) + { + commandSpan = matchSpan.Slice(commandStart, commandLength); + } } + IOnigCaptureIndex capture = captureIndices != null && captureIndices.Length > index ? captureIndices[index] : null; if (capture != null) { string result = captureSource.SubstringAtIndexes(capture.Start, capture.End); + // Remove leading dots that would make the selector invalid - while (result.Length > 0 && result[0] == '.') + int start = 0; + while (start < result.Length && result[start] == '.') { - result = result.Substring(1); + start++; } - if ("downcase".Equals(command)) + if (start != 0) + { + result = result.Substring(start); + } + if (commandSpan.SequenceEqual("downcase")) { return result.ToLower(); } - else if ("upcase".Equals(command)) + else if (commandSpan.SequenceEqual("upcase")) { return result.ToUpper(); } @@ -109,5 +126,19 @@ private static string GetReplacement(string match, ReadOnlyMemory captureS return match; } } + + private static int ParseCaptureIndex(ReadOnlySpan matchSpan, int doublePointIndex) + { + int start = doublePointIndex != -1 ? 2 : 1; + int end = doublePointIndex != -1 ? doublePointIndex : matchSpan.Length; + + int value = 0; + for (int i = start; i < end; i++) + { + value = (value * 10) + (matchSpan[i] - '0'); + } + + return value; + } } } \ No newline at end of file From 7096e1c628f496944ddbc2c41351817ca442da8a Mon Sep 17 00:00:00 2001 From: Dave Black <656118+udlose@users.noreply.github.com> Date: Wed, 18 Feb 2026 15:39:08 -0600 Subject: [PATCH 11/13] Ensure thread-safe model binding in AbstractLineList Added atomic SetModel enforcement and safe model publication. Introduced GetModelIfAvailable to prevent premature TMModel callbacks. Improved documentation on lock ordering and initialization contracts. --- src/TextMateSharp/Model/AbstractLineList.cs | 112 +++++++++++++++++--- 1 file changed, 99 insertions(+), 13 deletions(-) diff --git a/src/TextMateSharp/Model/AbstractLineList.cs b/src/TextMateSharp/Model/AbstractLineList.cs index f7e9605..acb317a 100644 --- a/src/TextMateSharp/Model/AbstractLineList.cs +++ b/src/TextMateSharp/Model/AbstractLineList.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Threading; using TextMateSharp.Grammars; @@ -16,23 +17,52 @@ namespace TextMateSharp.Model /// Important: Do not call back into (e.g. via , /// , or ) while holding . /// may hold its own internal lock while calling - /// or , which can acquire ; + /// or , which can acquire ; /// reversing that order can deadlock. /// /// public abstract class AbstractLineList : IModelLines { - private IList _list = new List(); + // readonly only protects the reference to the list, not the contents; we still need mLock to synchronize access to the list contents + private readonly IList _list = new List(); + // Published by SetModel(), consumed by Invalidate* / ForceTokenization() + // Use Volatile.Read/Write for safe cross-thread publication without involving mLock. + // Lock ordering: TMModel has its own lock, and AbstractLineList uses mLock for its internal list. + // The correct lock acquisition order is: TMModel lock (if any) -> mLock. + // Never acquire TMModel's lock while holding mLock to avoid deadlocks. private TMModel _model; - public AbstractLineList() - { - } + // Guard to ensure SetModel is only called once per instance. We use Interlocked.CompareExchange for atomicity and fail-fast behavior + private int _setModelCalledFlag; // 0 = not called, 1 = called + /// + /// Sets the model for the current instance. This method can only be called once per instance to ensure + /// consistency. + /// + /// This method atomically assigns the model and invalidates all existing lines in the + /// list. It is intended to be invoked only once per instance; subsequent calls will result in an + /// exception. + /// The model to associate with this instance. Cannot be null. + /// Thrown if is null. + /// Thrown if + /// is called more than once on the same instance. public void SetModel(TMModel model) { - this._model = model; + // Initialization contract: + // 1. SetModel must be called exactly once; we fail-fast on repeats via CompareExchange + // 2. SetModel is expected to be called before any Invalidate* / ForceTokenization() calls: + // - Calls made before SetModel is ever invoked (flag == 0, _model == null) are treated as no-ops. + // - Calls made while SetModel is in progress (flag == 1, _model == null) will throw, + // so callers must ensure correct initialization order and not use this instance until SetModel has completed + // 3. Publication ordering: we publish _model only after invalidating existing lines; callers must still not use this instance until SetModel completes + // 4. Lock ordering is preserved: no mLock around _model access, and no TMModel callbacks while holding mLock + if (model == null) throw new ArgumentNullException(nameof(model)); + + // Fail-fast: only allow one successful caller + if (Interlocked.CompareExchange(ref this._setModelCalledFlag, 1, 0) != 0) + throw new InvalidOperationException($"{nameof(SetModel)} can only be called once per {nameof(AbstractLineList)} instance"); + lock (mLock) { for (int i = 0; i < _list.Count; i++) @@ -41,6 +71,9 @@ public void SetModel(TMModel model) line.IsInvalid = true; } } + + // Publish only after invalidation completes + Volatile.Write(ref this._model, model); } public void AddLine(int line) @@ -106,11 +139,15 @@ public void ForEach(Action action) /// /// /// Zero-based line index to invalidate. + /// + /// Thrown if has started but has not yet completed publishing the model. + /// protected void InvalidateLine(int lineIndex) { - if (_model != null) + TMModel model = GetModelIfAvailable(); + if (model != null) { - _model.InvalidateLine(lineIndex); + model.InvalidateLine(lineIndex); } } @@ -122,11 +159,15 @@ protected void InvalidateLine(int lineIndex) /// /// Zero-based start line index (inclusive). /// Zero-based end line index (inclusive). + /// + /// Thrown if has started but has not yet completed publishing the model. + /// protected void InvalidateLineRange(int iniLineIndex, int endLineIndex) { - if (_model != null) + TMModel model = GetModelIfAvailable(); + if (model != null) { - _model.InvalidateLineRange(iniLineIndex, endLineIndex); + model.InvalidateLineRange(iniLineIndex, endLineIndex); } } @@ -138,11 +179,15 @@ protected void InvalidateLineRange(int iniLineIndex, int endLineIndex) /// /// Zero-based start line index (inclusive). /// Zero-based end line index (inclusive). + /// + /// Thrown if has started but has not yet completed publishing the model. + /// protected void ForceTokenization(int startLineIndex, int endLineIndex) { - if (_model != null) + TMModel model = GetModelIfAvailable(); + if (model != null) { - _model.ForceTokenization(startLineIndex, endLineIndex); + model.ForceTokenization(startLineIndex, endLineIndex); } } @@ -180,5 +225,46 @@ public int GetSize() /// /// readonly object mLock = new object(); + + /// + /// Gets the current model instance if it is available. + /// + /// + /// + /// Returns the current model instance if it has been published via . + /// + /// + /// If indicates that has started but the + /// model has not yet been published to , an is thrown + /// because callbacks into this API during binding are considered a misuse. + /// + /// + /// If has not yet been set and is still zero, this method + /// returns null. + /// + /// + /// + /// The current instance of the model if it is available; otherwise, null if the model has not yet been set. + /// + /// + /// Thrown if has started but has not yet completed publishing the model. + /// + private TMModel GetModelIfAvailable() + { + TMModel model = Volatile.Read(ref this._model); + if (model != null) + { + return model; + } + + // if SetModel has never been called, treat as a no-op + if (Volatile.Read(ref this._setModelCalledFlag) == 0) + { + return null; + } + + // SetModel was called but has not yet completed publishing the model (as observed by this thread). + throw new InvalidOperationException($"{nameof(SetModel)} must complete before calling Invalidate*/ForceTokenization."); + } } -} \ No newline at end of file +} From 276e908ca28158ef619e20a6b2e81718f9f0a2f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Pen=CC=83alba?= Date: Sat, 28 Feb 2026 11:00:05 +0100 Subject: [PATCH 12/13] Fixed Rider build --- src/TextMateSharp/Internal/Utils/RegexSource.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/TextMateSharp/Internal/Utils/RegexSource.cs b/src/TextMateSharp/Internal/Utils/RegexSource.cs index bc530d7..68b1df1 100644 --- a/src/TextMateSharp/Internal/Utils/RegexSource.cs +++ b/src/TextMateSharp/Internal/Utils/RegexSource.cs @@ -108,11 +108,11 @@ private static string GetReplacement(string match, ReadOnlyMemory captureS { result = result.Substring(start); } - if (commandSpan.SequenceEqual("downcase")) + if (commandSpan.SequenceEqual("downcase".AsSpan())) { return result.ToLower(); } - else if (commandSpan.SequenceEqual("upcase")) + else if (commandSpan.SequenceEqual("upcase".AsSpan())) { return result.ToUpper(); } From 643fcd056dc748cb8448295c74060839aa493964 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Pen=CC=83alba?= Date: Sat, 28 Feb 2026 11:03:14 +0100 Subject: [PATCH 13/13] Actually build the libs in .netstandard 2.0 in the CI to early detect API incompatibilities --- .github/workflows/dotnet.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index a47db99..17da323 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -24,6 +24,10 @@ jobs: dotnet-version: 6.0.x - name: Restore dependencies run: dotnet restore + - name: Build netstandard2.0 libraries + run: | + dotnet build src/TextMateSharp/TextMateSharp.csproj --no-restore + dotnet build src/TextMateSharp.Grammars/TextMateSharp.Grammars.csproj --no-restore - name: Build run: dotnet build --no-restore - name: Test