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
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
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 @@
-
+
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
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
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
+ }
+}
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);
+ }
+ }
+}
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
diff --git a/src/TextMateSharp/Internal/Utils/RegexSource.cs b/src/TextMateSharp/Internal/Utils/RegexSource.cs
index 762948b..68b1df1 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".AsSpan()))
{
return result.ToLower();
}
- else if ("upcase".Equals(command))
+ else if (commandSpan.SequenceEqual("upcase".AsSpan()))
{
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
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
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
+}
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
+}