Skip to content
Merged
Show file tree
Hide file tree
Changes from 20 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
f43d988
Recognize supplementary characters
tats-u Nov 3, 2025
2a851a2
Internatize Rune
tats-u Dec 28, 2025
de5d3fe
Fix failing tests
tats-u Dec 31, 2025
095f0ed
Fix extra comment error
tats-u Dec 31, 2025
948bf66
Remove extra local variable c
tats-u Jan 2, 2026
e968e52
Reorganize classes around Rune
tats-u Jan 2, 2026
bbffa33
Prepare both Rune and char variants / make Rune variant public for .NET
tats-u Jan 2, 2026
3a65e4b
Make APIs in StringSlice.cs public only in modern .NET
tats-u Jan 3, 2026
0f928a2
Throw exception if cannot obtain first Rune
tats-u Jan 3, 2026
a4c9146
Add comments
tats-u Jan 3, 2026
9839b99
Add comment on PeekRuneExtra
tats-u Jan 4, 2026
3ba8a3c
Use `Rune.TryCreate`
tats-u Jan 4, 2026
8ab6542
Remove backtrack
tats-u Jan 4, 2026
f6d6916
Fix parameter name in XML comment
tats-u Jan 4, 2026
03822ac
Don't throw when error in `Rune.DecodeFromUtf16`
tats-u Jan 4, 2026
b9d9e09
Fix RuneAt
tats-u Jan 4, 2026
476fb63
Add tests of Rune-related methods of `StringSlice`
tats-u Jan 4, 2026
4cb6895
Make comment more tolerant of changes
tats-u Jan 4, 2026
e1e58cb
Tweak comment
tats-u Jan 4, 2026
b302cbc
Fix comment
tats-u Jan 5, 2026
a0d08bf
Add `readonly`
tats-u Jan 5, 2026
31f48ac
Move namespace of polyfilled Rune out of System.Text
tats-u Jan 5, 2026
dbcbcf9
Apply suggestions from code review
tats-u Jan 8, 2026
7d4a678
Fix regression by review suggestion
tats-u Jan 8, 2026
b23a002
Merge remote-tracking branch 'origin/master' into rune
tats-u Jan 8, 2026
c048018
Prepare constant for .NET Standard test
tats-u Jan 8, 2026
15baf1e
Don't call `IsPunctuationException` if unnecessary
tats-u Jan 10, 2026
3272f9a
PR feedback
MihaZupan Jan 11, 2026
0675757
Merge pull request #1 from MihaZupan/rune-feedback
tats-u Jan 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions src/Markdig.Tests/TestEmphasisPlus.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,19 @@ public void NormalStrongNormal()
TestParser.TestSpec("normal ***Strong emphasis*** normal", "<p>normal <em><strong>Strong emphasis</strong></em> normal</p>", "");
}

[Test]
public void SupplementaryPunctuation()
{
TestParser.TestSpec("a*a∇*a\n\na*∇a*a\n\na*a𝜵*a\n\na*𝜵a*a\n\na*𐬼a*a\n\na*a𐬼*a", "<p>a*a∇*a</p>\n<p>a*∇a*a</p>\n<p>a*a𝜵*a</p>\n<p>a*𝜵a*a</p>\n<p>a*𐬼a*a</p>\n<p>a*a𐬼*a</p>", "");
}

[Test]
public void RecognizeSupplementaryChars()
{
TestParser.TestSpec("🌶️**𰻞**🍜**𰻞**🌶️**麺**🍜", "<p>🌶️<strong>𰻞</strong>🍜<strong>𰻞</strong>🌶️<strong>麺</strong>🍜</p>", "");
}


[Test]
public void OpenEmphasisHasConvenientContentStringSlice()
{
Expand Down
10 changes: 10 additions & 0 deletions src/Markdig.Tests/TestSmartyPants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,14 @@ public void MappingCanBeReconfigured_HandlesRemovedMappings()

TestParser.TestSpec("<<test>>", "<p>&laquo;test&raquo;</p>", pipeline);
}

[Test]
public void RecognizesSupplementaryCharacters()
{
var pipeline = new MarkdownPipelineBuilder()
.UseSmartyPants()
.Build();

TestParser.TestSpec("\"𝜵\"𠮷\"𝜵\"𩸽\"", "<p>&quot;𝜵&ldquo;𠮷&rdquo;𝜵&ldquo;𩸽&rdquo;</p>", pipeline);
}
}
129 changes: 129 additions & 0 deletions src/Markdig.Tests/TestStringSlice.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
// Copyright (c) Alexandre Mutel. All rights reserved.
// This file is licensed under the BSD-Clause 2 license.
// See the license.txt file in the project root for more information.

using Markdig.Helpers;
using System;
using System.Collections.Generic;
using System.Drawing;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace Markdig.Tests;

[TestFixture]
internal class TestStringSlice
{
// TODO: Add more tests for StringSlice

// Old versions of modern .NET (that meets NETCOREAPP but not the following #if) try to load the polyfilled Rune in .NET Standard 2.x build of Markdig while they have built-in Rune too
// Adjust this condition to match the minimal modern (not .NET Standard or Framework) .NET target framework of Markdig (not Markdig.Tests)
#if NET8_0_OR_GREATER

[Test]
public void TestRuneBmp()
{
var slice = new StringSlice("01234");

Assert.AreEqual((Rune)'0', slice.CurrentRune);

Check failure on line 29 in src/Markdig.Tests/TestStringSlice.cs

View workflow job for this annotation

GitHub Actions / test-netstandard (netstandard2.0)

The type 'Rune' exists in both 'Markdig, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null' and 'System.Runtime, Version=8.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a'

Check failure on line 29 in src/Markdig.Tests/TestStringSlice.cs

View workflow job for this annotation

GitHub Actions / test-netstandard (netstandard2.1)

The type 'Rune' exists in both 'Markdig, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null' and 'System.Runtime, Version=8.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a'
Assert.AreEqual((Rune)'1', slice.NextRune());

Check failure on line 30 in src/Markdig.Tests/TestStringSlice.cs

View workflow job for this annotation

GitHub Actions / test-netstandard (netstandard2.0)

The type 'Rune' exists in both 'Markdig, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null' and 'System.Runtime, Version=8.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a'

Check failure on line 30 in src/Markdig.Tests/TestStringSlice.cs

View workflow job for this annotation

GitHub Actions / test-netstandard (netstandard2.1)

The type 'Rune' exists in both 'Markdig, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null' and 'System.Runtime, Version=8.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a'
Assert.AreEqual((Rune)'2', slice.NextRune());

Check failure on line 31 in src/Markdig.Tests/TestStringSlice.cs

View workflow job for this annotation

GitHub Actions / test-netstandard (netstandard2.0)

The type 'Rune' exists in both 'Markdig, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null' and 'System.Runtime, Version=8.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a'

Check failure on line 31 in src/Markdig.Tests/TestStringSlice.cs

View workflow job for this annotation

GitHub Actions / test-netstandard (netstandard2.1)

The type 'Rune' exists in both 'Markdig, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null' and 'System.Runtime, Version=8.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a'
Assert.AreEqual((Rune)'2', slice.CurrentRune);

Check failure on line 32 in src/Markdig.Tests/TestStringSlice.cs

View workflow job for this annotation

GitHub Actions / test-netstandard (netstandard2.0)

The type 'Rune' exists in both 'Markdig, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null' and 'System.Runtime, Version=8.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a'

Check failure on line 32 in src/Markdig.Tests/TestStringSlice.cs

View workflow job for this annotation

GitHub Actions / test-netstandard (netstandard2.1)

The type 'Rune' exists in both 'Markdig, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null' and 'System.Runtime, Version=8.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a'
Assert.AreEqual("234", slice.ToString());
Assert.AreEqual((Rune)'3', slice.PeekRuneExtra(1));

Check failure on line 34 in src/Markdig.Tests/TestStringSlice.cs

View workflow job for this annotation

GitHub Actions / test-netstandard (netstandard2.0)

The type 'Rune' exists in both 'Markdig, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null' and 'System.Runtime, Version=8.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a'

Check failure on line 34 in src/Markdig.Tests/TestStringSlice.cs

View workflow job for this annotation

GitHub Actions / test-netstandard (netstandard2.1)

The type 'Rune' exists in both 'Markdig, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null' and 'System.Runtime, Version=8.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a'
Assert.AreEqual((Rune)'4', slice.PeekRuneExtra(2));

Check failure on line 35 in src/Markdig.Tests/TestStringSlice.cs

View workflow job for this annotation

GitHub Actions / test-netstandard (netstandard2.0)

The type 'Rune' exists in both 'Markdig, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null' and 'System.Runtime, Version=8.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a'

Check failure on line 35 in src/Markdig.Tests/TestStringSlice.cs

View workflow job for this annotation

GitHub Actions / test-netstandard (netstandard2.1)

The type 'Rune' exists in both 'Markdig, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null' and 'System.Runtime, Version=8.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a'
Assert.AreEqual((Rune)'\0', slice.PeekRuneExtra(3));

Check failure on line 36 in src/Markdig.Tests/TestStringSlice.cs

View workflow job for this annotation

GitHub Actions / test-netstandard (netstandard2.0)

The type 'Rune' exists in both 'Markdig, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null' and 'System.Runtime, Version=8.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a'

Check failure on line 36 in src/Markdig.Tests/TestStringSlice.cs

View workflow job for this annotation

GitHub Actions / test-netstandard (netstandard2.1)

The type 'Rune' exists in both 'Markdig, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null' and 'System.Runtime, Version=8.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a'
Assert.AreEqual((Rune)'1', slice.PeekRuneExtra(-1));

Check failure on line 37 in src/Markdig.Tests/TestStringSlice.cs

View workflow job for this annotation

GitHub Actions / test-netstandard (netstandard2.0)

The type 'Rune' exists in both 'Markdig, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null' and 'System.Runtime, Version=8.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a'

Check failure on line 37 in src/Markdig.Tests/TestStringSlice.cs

View workflow job for this annotation

GitHub Actions / test-netstandard (netstandard2.1)

The type 'Rune' exists in both 'Markdig, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null' and 'System.Runtime, Version=8.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a'
Assert.AreEqual((Rune)'0', slice.PeekRuneExtra(-2));

Check failure on line 38 in src/Markdig.Tests/TestStringSlice.cs

View workflow job for this annotation

GitHub Actions / test-netstandard (netstandard2.0)

The type 'Rune' exists in both 'Markdig, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null' and 'System.Runtime, Version=8.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a'

Check failure on line 38 in src/Markdig.Tests/TestStringSlice.cs

View workflow job for this annotation

GitHub Actions / test-netstandard (netstandard2.1)

The type 'Rune' exists in both 'Markdig, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null' and 'System.Runtime, Version=8.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a'
Assert.AreEqual((Rune)'\0', slice.PeekRuneExtra(-3));

Check failure on line 39 in src/Markdig.Tests/TestStringSlice.cs

View workflow job for this annotation

GitHub Actions / test-netstandard (netstandard2.0)

The type 'Rune' exists in both 'Markdig, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null' and 'System.Runtime, Version=8.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a'

Check failure on line 39 in src/Markdig.Tests/TestStringSlice.cs

View workflow job for this annotation

GitHub Actions / test-netstandard (netstandard2.1)

The type 'Rune' exists in both 'Markdig, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null' and 'System.Runtime, Version=8.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a'
Assert.AreEqual((Rune)'0', slice.RuneAt(0));
Assert.AreEqual((Rune)'1', slice.RuneAt(1));
Assert.AreEqual((Rune)'2', slice.RuneAt(2));
Assert.AreEqual((Rune)'3', slice.RuneAt(3));
Assert.AreEqual((Rune)'4', slice.RuneAt(4));
}

[Test]
public void TestRuneSupplementaryOnly()
{
var slice = new StringSlice("𝟎𝟏𝟐𝟑𝟒");

// 𝟎 = U+1D7CE, 𝟐 = U+1D7D0
Assert.AreEqual((Rune)0x1D7CE, slice.CurrentRune); // 𝟎
Assert.AreEqual((Rune)0x1D7CF, slice.NextRune()); // 𝟏
Assert.AreEqual((Rune)0x1D7D0, slice.NextRune()); // 𝟐
Assert.AreEqual((Rune)0x1D7D0, slice.CurrentRune); // 𝟐
Assert.AreEqual("𝟐𝟑𝟒", slice.ToString());
// CurrentRune occupies 2 `char`s, so next Rune starts at index 2
Assert.AreEqual((Rune)0x1D7D1, slice.PeekRuneExtra(2)); // 𝟑
Assert.AreEqual((Rune)0x1D7D2, slice.PeekRuneExtra(4)); // 𝟒
Assert.AreEqual((Rune)'\0', slice.PeekRuneExtra(6));
Assert.AreEqual((Rune)0x1D7CF, slice.PeekRuneExtra(-1)); // 𝟏
Assert.AreEqual((Rune)0x1D7CE, slice.PeekRuneExtra(-3)); // 𝟎
Assert.AreEqual((Rune)'\0', slice.PeekRuneExtra(-5));
Assert.AreEqual((Rune)0x1D7CE, slice.RuneAt(0)); // 𝟎
Assert.AreEqual((Rune)0x1D7CF, slice.RuneAt(2)); // 𝟏
Assert.AreEqual((Rune)0x1D7D0, slice.RuneAt(4)); // 𝟐
Assert.AreEqual((Rune)0x1D7D1, slice.RuneAt(6)); // 𝟑
Assert.AreEqual((Rune)0x1D7D2, slice.RuneAt(8)); // 𝟒
// The following usages are not expected. You should take into consideration the `char`s that the Rune you just acquired occupies.
Assert.AreEqual((Rune)'\0', slice.PeekRuneExtra(-4));
Assert.AreEqual((Rune)'\0', slice.PeekRuneExtra(-2));
Assert.AreEqual((Rune)'\0', slice.PeekRuneExtra(1));
Assert.AreEqual((Rune)'\0', slice.PeekRuneExtra(3));
Assert.AreEqual((Rune)'\0', slice.PeekRuneExtra(5));
Assert.AreEqual((Rune)'\0', slice.RuneAt(1));
Assert.AreEqual((Rune)'\0', slice.RuneAt(3));
Assert.AreEqual((Rune)'\0', slice.RuneAt(5));
Assert.AreEqual((Rune)'\0', slice.RuneAt(7));
Assert.AreEqual((Rune)'\0', slice.RuneAt(9));
}

[Test]
public void TestRuneIsolatedHighSurrogate()
{
var slice = new StringSlice("\ud800\ud801\ud802\ud803\ud804");
Assert.AreEqual((Rune)'\0', slice.CurrentRune);
Assert.AreEqual((Rune)'\0', slice.NextRune());
Assert.AreEqual('\ud801', slice.CurrentChar);
Assert.AreEqual((Rune)'\0', slice.NextRune());
Assert.AreEqual('\ud802', slice.CurrentChar);
Assert.AreEqual((Rune)'\0', slice.CurrentRune);
Assert.AreEqual((Rune)'\0', slice.PeekRuneExtra(-3));
Assert.AreEqual((Rune)'\0', slice.PeekRuneExtra(-2));
Assert.AreEqual((Rune)'\0', slice.PeekRuneExtra(-1));
Assert.AreEqual((Rune)'\0', slice.PeekRuneExtra(1));
Assert.AreEqual((Rune)'\0', slice.PeekRuneExtra(2));
Assert.AreEqual((Rune)'\0', slice.PeekRuneExtra(3));
Assert.AreEqual((Rune)'\0', slice.RuneAt(0));
Assert.AreEqual((Rune)'\0', slice.RuneAt(1));
Assert.AreEqual((Rune)'\0', slice.RuneAt(2));
Assert.AreEqual((Rune)'\0', slice.RuneAt(3));
Assert.AreEqual((Rune)'\0', slice.RuneAt(4));
}

[Test]
public void TestRuneIsolatedLowSurrogate()
{
var slice = new StringSlice("\udc00\udc01\udc02\udc03\udc04");
Assert.AreEqual((Rune)'\0', slice.CurrentRune);
Assert.AreEqual((Rune)'\0', slice.NextRune());
Assert.AreEqual('\udc01', slice.CurrentChar);
Assert.AreEqual((Rune)'\0', slice.NextRune());
Assert.AreEqual('\udc02', slice.CurrentChar);
Assert.AreEqual((Rune)'\0', slice.CurrentRune);
Assert.AreEqual((Rune)'\0', slice.PeekRuneExtra(-3));
Assert.AreEqual((Rune)'\0', slice.PeekRuneExtra(-2));
Assert.AreEqual((Rune)'\0', slice.PeekRuneExtra(-1));
Assert.AreEqual((Rune)'\0', slice.PeekRuneExtra(1));
Assert.AreEqual((Rune)'\0', slice.PeekRuneExtra(2));
Assert.AreEqual((Rune)'\0', slice.PeekRuneExtra(3));
Assert.AreEqual((Rune)'\0', slice.RuneAt(0));
Assert.AreEqual((Rune)'\0', slice.RuneAt(1));
Assert.AreEqual((Rune)'\0', slice.RuneAt(2));
Assert.AreEqual((Rune)'\0', slice.RuneAt(3));
Assert.AreEqual((Rune)'\0', slice.RuneAt(4));
}
#endif
}
1 change: 0 additions & 1 deletion src/Markdig.Tests/TestStringSliceList.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ namespace Markdig.Tests;
[TestFixture]
public class TestStringSliceList
{
// TODO: Add tests for StringSlice
// TODO: Add more tests for StringLineGroup

[Test]
Expand Down
11 changes: 5 additions & 6 deletions src/Markdig/Extensions/SmartyPants/SmartyPantsInlineParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,16 +36,15 @@ public override bool Match(InlineProcessor processor, ref StringSlice slice)
// -- – &ndash; 'ndash'
// --- — &mdash; 'mdash'

var pc = slice.PeekCharExtra(-1);
var c = slice.CurrentChar;
var openingChar = c;
var pc = slice.PeekRuneExtra(-1);
var openingChar = slice.CurrentChar;

var startingPosition = slice.Start;

// undefined first
var type = (SmartyPantType) 0;

switch (c)
switch (openingChar)
{
case '\'':
type = SmartyPantType.Quote; // We will resolve them at the end of parsing all inlines
Expand Down Expand Up @@ -93,9 +92,9 @@ public override bool Match(InlineProcessor processor, ref StringSlice slice)
}

// Skip char
c = slice.NextChar();
var next = slice.NextRune();

CharHelper.CheckOpenCloseDelimiter(pc, c, false, out bool canOpen, out bool canClose);
CharHelper.CheckOpenCloseDelimiter(pc, next, false, out bool canOpen, out bool canClose);

bool postProcess = false;

Expand Down
Loading
Loading