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 +}