diff --git a/cs/BenchmarkMarkdown/BenchmarkDotNet.Artifacts/results/BenchmarkMarkdown.MarkdownParserBenchmark-report-github.md b/cs/BenchmarkMarkdown/BenchmarkDotNet.Artifacts/results/BenchmarkMarkdown.MarkdownParserBenchmark-report-github.md
new file mode 100644
index 000000000..1060d0b7f
--- /dev/null
+++ b/cs/BenchmarkMarkdown/BenchmarkDotNet.Artifacts/results/BenchmarkMarkdown.MarkdownParserBenchmark-report-github.md
@@ -0,0 +1,16 @@
+```
+
+BenchmarkDotNet v0.15.6, Windows 10 (10.0.19045.6216/22H2/2022Update)
+AMD Ryzen 9 5900HX with Radeon Graphics 3.30GHz, 1 CPU, 16 logical and 8 physical cores
+.NET SDK 9.0.306
+ [Host] : .NET 9.0.10 (9.0.10, 9.0.1025.47515), X64 RyuJIT x86-64-v3
+ DefaultJob : .NET 9.0.10 (9.0.10, 9.0.1025.47515), X64 RyuJIT x86-64-v3
+
+
+```
+| Method | TextSizeMultiplier | Mean | Error | StdDev | Gen0 | Gen1 | Gen2 | Allocated |
+|------- |------------------- |-------------:|------------:|------------:|------------:|-----------:|----------:|--------------:|
+| **Parse** | **1** | **294.4 μs** | **5.65 μs** | **5.55 μs** | **124.5117** | **2.4414** | **-** | **1017.81 KB** |
+| **Parse** | **10** | **3,172.7 μs** | **10.81 μs** | **9.59 μs** | **1363.2813** | **187.5000** | **11.7188** | **11146.04 KB** |
+| **Parse** | **100** | **36,115.6 μs** | **120.37 μs** | **100.52 μs** | **12333.3333** | **333.3333** | **133.3333** | **101203 KB** |
+| **Parse** | **1000** | **335,972.8 μs** | **6,497.19 μs** | **7,482.17 μs** | **127000.0000** | **33000.0000** | **4000.0000** | **1011847.79 KB** |
diff --git a/cs/BenchmarkMarkdown/BenchmarkDotNet.Artifacts/results/BenchmarkMarkdown.MarkdownParserBenchmark-report.csv b/cs/BenchmarkMarkdown/BenchmarkDotNet.Artifacts/results/BenchmarkMarkdown.MarkdownParserBenchmark-report.csv
new file mode 100644
index 000000000..291e26d98
--- /dev/null
+++ b/cs/BenchmarkMarkdown/BenchmarkDotNet.Artifacts/results/BenchmarkMarkdown.MarkdownParserBenchmark-report.csv
@@ -0,0 +1,5 @@
+Method;Job;AnalyzeLaunchVariance;EvaluateOverhead;MaxAbsoluteError;MaxRelativeError;MinInvokeCount;MinIterationTime;OutlierMode;Affinity;EnvironmentVariables;Jit;LargeAddressAware;Platform;PowerPlanMode;Runtime;AllowVeryLargeObjects;Concurrent;CpuGroups;Force;HeapAffinitizeMask;HeapCount;NoAffinitize;RetainVm;Server;Arguments;BuildConfiguration;Clock;EngineFactory;NuGetReferences;Toolchain;IsMutator;InvocationCount;IterationCount;IterationTime;LaunchCount;MaxIterationCount;MaxWarmupIterationCount;MemoryRandomization;MinIterationCount;MinWarmupIterationCount;RunStrategy;UnrollFactor;WarmupCount;TextSizeMultiplier;Mean;Error;StdDev;Gen0;Gen1;Gen2;Allocated
+Parse;DefaultJob;False;Default;Default;Default;Default;Default;Default;1111111111111111;Empty;RyuJit;Default;X64;8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c;.NET 9.0;False;True;False;True;Default;Default;False;False;False;Default;Default;Default;Default;Default;Default;Default;Default;Default;Default;Default;Default;Default;Default;Default;Default;Default;16;Default;1;294.4 μs;5.65 μs;5.55 μs;124.5117;2.4414;0.0000;1017.81 KB
+Parse;DefaultJob;False;Default;Default;Default;Default;Default;Default;1111111111111111;Empty;RyuJit;Default;X64;8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c;.NET 9.0;False;True;False;True;Default;Default;False;False;False;Default;Default;Default;Default;Default;Default;Default;Default;Default;Default;Default;Default;Default;Default;Default;Default;Default;16;Default;10;"3,172.7 μs";10.81 μs;9.59 μs;1363.2813;187.5000;11.7188;11146.04 KB
+Parse;DefaultJob;False;Default;Default;Default;Default;Default;Default;1111111111111111;Empty;RyuJit;Default;X64;8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c;.NET 9.0;False;True;False;True;Default;Default;False;False;False;Default;Default;Default;Default;Default;Default;Default;Default;Default;Default;Default;Default;Default;Default;Default;Default;Default;16;Default;100;"36,115.6 μs";120.37 μs;100.52 μs;12333.3333;333.3333;133.3333;101203 KB
+Parse;DefaultJob;False;Default;Default;Default;Default;Default;Default;1111111111111111;Empty;RyuJit;Default;X64;8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c;.NET 9.0;False;True;False;True;Default;Default;False;False;False;Default;Default;Default;Default;Default;Default;Default;Default;Default;Default;Default;Default;Default;Default;Default;Default;Default;16;Default;1000;"335,972.8 μs";"6,497.19 μs";"7,482.17 μs";127000.0000;33000.0000;4000.0000;1011847.79 KB
diff --git a/cs/BenchmarkMarkdown/BenchmarkDotNet.Artifacts/results/BenchmarkMarkdown.MarkdownParserBenchmark-report.html b/cs/BenchmarkMarkdown/BenchmarkDotNet.Artifacts/results/BenchmarkMarkdown.MarkdownParserBenchmark-report.html
new file mode 100644
index 000000000..53e5791ab
--- /dev/null
+++ b/cs/BenchmarkMarkdown/BenchmarkDotNet.Artifacts/results/BenchmarkMarkdown.MarkdownParserBenchmark-report.html
@@ -0,0 +1,33 @@
+
+
+
+
+BenchmarkMarkdown.MarkdownParserBenchmark-20251115-183056
+
+
+
+
+
+BenchmarkDotNet v0.15.6, Windows 10 (10.0.19045.6216/22H2/2022Update)
+AMD Ryzen 9 5900HX with Radeon Graphics 3.30GHz, 1 CPU, 16 logical and 8 physical cores
+.NET SDK 9.0.306
+ [Host] : .NET 9.0.10 (9.0.10, 9.0.1025.47515), X64 RyuJIT x86-64-v3
+ DefaultJob : .NET 9.0.10 (9.0.10, 9.0.1025.47515), X64 RyuJIT x86-64-v3
+
+
+
+
+| Method | TextSizeMultiplier | Mean | Error | StdDev | Gen0 | Gen1 | Gen2 | Allocated |
+
+| Parse | 1 | 294.4 μs | 5.65 μs | 5.55 μs | 124.5117 | 2.4414 | - | 1017.81 KB |
+
| Parse | 10 | 3,172.7 μs | 10.81 μs | 9.59 μs | 1363.2813 | 187.5000 | 11.7188 | 11146.04 KB |
+
| Parse | 100 | 36,115.6 μs | 120.37 μs | 100.52 μs | 12333.3333 | 333.3333 | 133.3333 | 101203 KB |
+
| Parse | 1000 | 335,972.8 μs | 6,497.19 μs | 7,482.17 μs | 127000.0000 | 33000.0000 | 4000.0000 | 1011847.79 KB |
+
+
+
diff --git a/cs/BenchmarkMarkdown/BenchmarkDotNet.Artifacts/results/BenchmarkMarkdown.MdBenchmark-report-github.md b/cs/BenchmarkMarkdown/BenchmarkDotNet.Artifacts/results/BenchmarkMarkdown.MdBenchmark-report-github.md
new file mode 100644
index 000000000..f186405f0
--- /dev/null
+++ b/cs/BenchmarkMarkdown/BenchmarkDotNet.Artifacts/results/BenchmarkMarkdown.MdBenchmark-report-github.md
@@ -0,0 +1,16 @@
+```
+
+BenchmarkDotNet v0.15.6, Windows 10 (10.0.19045.6216/22H2/2022Update)
+AMD Ryzen 9 5900HX with Radeon Graphics 3.30GHz, 1 CPU, 16 logical and 8 physical cores
+.NET SDK 9.0.306
+ [Host] : .NET 9.0.10 (9.0.10, 9.0.1025.47515), X64 RyuJIT x86-64-v3
+ DefaultJob : .NET 9.0.10 (9.0.10, 9.0.1025.47515), X64 RyuJIT x86-64-v3
+
+
+```
+| Method | TextSizeMultiplier | Mean | Error | StdDev | Gen0 | Gen1 | Gen2 | Allocated |
+|------- |------------------- |-------------:|-------------:|-------------:|------------:|-----------:|----------:|-----------:|
+| **Render** | **1** | **500.1 μs** | **9.93 μs** | **17.13 μs** | **150.3906** | **2.9297** | **-** | **1.2 MB** |
+| **Render** | **10** | **5,322.4 μs** | **105.85 μs** | **273.24 μs** | **1492.1875** | **195.3125** | **-** | **11.95 MB** |
+| **Render** | **100** | **56,038.2 μs** | **1,117.32 μs** | **1,566.33 μs** | **14900.0000** | **500.0000** | **200.0000** | **119.28 MB** |
+| **Render** | **1000** | **576,250.8 μs** | **11,258.63 μs** | **11,561.78 μs** | **149000.0000** | **31000.0000** | **4000.0000** | **1192.54 MB** |
diff --git a/cs/BenchmarkMarkdown/BenchmarkDotNet.Artifacts/results/BenchmarkMarkdown.MdBenchmark-report.csv b/cs/BenchmarkMarkdown/BenchmarkDotNet.Artifacts/results/BenchmarkMarkdown.MdBenchmark-report.csv
new file mode 100644
index 000000000..956d9f60f
--- /dev/null
+++ b/cs/BenchmarkMarkdown/BenchmarkDotNet.Artifacts/results/BenchmarkMarkdown.MdBenchmark-report.csv
@@ -0,0 +1,5 @@
+Method;Job;AnalyzeLaunchVariance;EvaluateOverhead;MaxAbsoluteError;MaxRelativeError;MinInvokeCount;MinIterationTime;OutlierMode;Affinity;EnvironmentVariables;Jit;LargeAddressAware;Platform;PowerPlanMode;Runtime;AllowVeryLargeObjects;Concurrent;CpuGroups;Force;HeapAffinitizeMask;HeapCount;NoAffinitize;RetainVm;Server;Arguments;BuildConfiguration;Clock;EngineFactory;NuGetReferences;Toolchain;IsMutator;InvocationCount;IterationCount;IterationTime;LaunchCount;MaxIterationCount;MaxWarmupIterationCount;MemoryRandomization;MinIterationCount;MinWarmupIterationCount;RunStrategy;UnrollFactor;WarmupCount;TextSizeMultiplier;Mean;Error;StdDev;Gen0;Gen1;Gen2;Allocated
+Render;DefaultJob;False;Default;Default;Default;Default;Default;Default;1111111111111111;Empty;RyuJit;Default;X64;8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c;.NET 9.0;False;True;False;True;Default;Default;False;False;False;Default;Default;Default;Default;Default;Default;Default;Default;Default;Default;Default;Default;Default;Default;Default;Default;Default;16;Default;1;500.1 μs;9.93 μs;17.13 μs;150.3906;2.9297;0.0000;1.2 MB
+Render;DefaultJob;False;Default;Default;Default;Default;Default;Default;1111111111111111;Empty;RyuJit;Default;X64;8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c;.NET 9.0;False;True;False;True;Default;Default;False;False;False;Default;Default;Default;Default;Default;Default;Default;Default;Default;Default;Default;Default;Default;Default;Default;Default;Default;16;Default;10;"5,322.4 μs";105.85 μs;273.24 μs;1492.1875;195.3125;0.0000;11.95 MB
+Render;DefaultJob;False;Default;Default;Default;Default;Default;Default;1111111111111111;Empty;RyuJit;Default;X64;8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c;.NET 9.0;False;True;False;True;Default;Default;False;False;False;Default;Default;Default;Default;Default;Default;Default;Default;Default;Default;Default;Default;Default;Default;Default;Default;Default;16;Default;100;"56,038.2 μs";"1,117.32 μs";"1,566.33 μs";14900.0000;500.0000;200.0000;119.28 MB
+Render;DefaultJob;False;Default;Default;Default;Default;Default;Default;1111111111111111;Empty;RyuJit;Default;X64;8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c;.NET 9.0;False;True;False;True;Default;Default;False;False;False;Default;Default;Default;Default;Default;Default;Default;Default;Default;Default;Default;Default;Default;Default;Default;Default;Default;16;Default;1000;"576,250.8 μs";"11,258.63 μs";"11,561.78 μs";149000.0000;31000.0000;4000.0000;1192.54 MB
diff --git a/cs/BenchmarkMarkdown/BenchmarkDotNet.Artifacts/results/BenchmarkMarkdown.MdBenchmark-report.html b/cs/BenchmarkMarkdown/BenchmarkDotNet.Artifacts/results/BenchmarkMarkdown.MdBenchmark-report.html
new file mode 100644
index 000000000..17cd9cb08
--- /dev/null
+++ b/cs/BenchmarkMarkdown/BenchmarkDotNet.Artifacts/results/BenchmarkMarkdown.MdBenchmark-report.html
@@ -0,0 +1,33 @@
+
+
+
+
+BenchmarkMarkdown.MdBenchmark-20251130-183500
+
+
+
+
+
+BenchmarkDotNet v0.15.6, Windows 10 (10.0.19045.6216/22H2/2022Update)
+AMD Ryzen 9 5900HX with Radeon Graphics 3.30GHz, 1 CPU, 16 logical and 8 physical cores
+.NET SDK 9.0.306
+ [Host] : .NET 9.0.10 (9.0.10, 9.0.1025.47515), X64 RyuJIT x86-64-v3
+ DefaultJob : .NET 9.0.10 (9.0.10, 9.0.1025.47515), X64 RyuJIT x86-64-v3
+
+
+
+
+| Method | TextSizeMultiplier | Mean | Error | StdDev | Gen0 | Gen1 | Gen2 | Allocated |
+
+| Render | 1 | 500.1 μs | 9.93 μs | 17.13 μs | 150.3906 | 2.9297 | - | 1.2 MB |
+
| Render | 10 | 5,322.4 μs | 105.85 μs | 273.24 μs | 1492.1875 | 195.3125 | - | 11.95 MB |
+
| Render | 100 | 56,038.2 μs | 1,117.32 μs | 1,566.33 μs | 14900.0000 | 500.0000 | 200.0000 | 119.28 MB |
+
| Render | 1000 | 576,250.8 μs | 11,258.63 μs | 11,561.78 μs | 149000.0000 | 31000.0000 | 4000.0000 | 1192.54 MB |
+
+
+
diff --git a/cs/BenchmarkMarkdown/BenchmarkMarkdown.csproj b/cs/BenchmarkMarkdown/BenchmarkMarkdown.csproj
new file mode 100644
index 000000000..2fda6a3da
--- /dev/null
+++ b/cs/BenchmarkMarkdown/BenchmarkMarkdown.csproj
@@ -0,0 +1,25 @@
+
+
+
+ Exe
+ net9.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
+ PreserveNewest
+
+
+
+
diff --git a/cs/BenchmarkMarkdown/InputData b/cs/BenchmarkMarkdown/InputData
new file mode 100644
index 000000000..ea848ff40
--- /dev/null
+++ b/cs/BenchmarkMarkdown/InputData
@@ -0,0 +1,56 @@
+# Introduction to Markdown
+
+Markdown is a lightweight markup language __created__ by John Gruber, with contributions from Aaron Swartz. It’s designed to be easy to read and write, using plain text conventions that translate to valid HTML.
+
+Markdown allows you to write __bold__ and _italic_ text, create # headings, lists, links, and more — all while keeping the text legible in a raw form.
+
+---
+
+## Basic Formatting
+
+Here is an example of __bold text__ and _italic text_ in Markdown. Double underscores surround bold text, while single underscores can be used for italics. Like this:
+
+__This is bold__, _this is italic_, and __this is __bold with some inner _italic_ text__ too__.
+
+---
+
+## Combining Tags
+
+You can combine __bold__ and _italic_ like this: ___bold italic___ text.
+
+# Headers
+
+Markdown supports headers starting with `#`:
+
+# Heading level 1
+
+## Heading level 2
+
+### Heading level 3
+
+#### Heading level 4
+
+---
+
+## Paragraphs and line breaks
+
+Markdown treats one or more empty lines as paragraph breaks.
+
+Use two spaces at the end of a line for a line break.
+Example here with a break.
+
+___
+
+## Lists
+
+- __Bold item__
+- _Italic item_
+- Combined __bold and _italic_ item__
+
+1. First numbered item
+2. Second item with _italic_
+3. Third item with __bold__
+
+
+[Name Link](https://www.example.com \"Tooltip\")
+---
diff --git a/cs/BenchmarkMarkdown/MarkdownParserBenchmark.cs b/cs/BenchmarkMarkdown/MarkdownParserBenchmark.cs
new file mode 100644
index 000000000..782f6493b
--- /dev/null
+++ b/cs/BenchmarkMarkdown/MarkdownParserBenchmark.cs
@@ -0,0 +1,31 @@
+using System.Text;
+using BenchmarkDotNet.Attributes;
+using Markdown;
+
+namespace BenchmarkMarkdown;
+
+[MemoryDiagnoser]
+public class MarkdownParserBenchmark
+{
+ private IParser parser;
+ private string fileContent;
+
+ [Params(1, 10, 100, 1000)] public int TextSizeMultiplier { get; set; }
+
+ [GlobalSetup]
+ public void Setup()
+ {
+ parser = new MarkdownParser();
+
+ var baseDir = AppContext.BaseDirectory;
+ var filePath = Path.Combine(baseDir, "InputData");
+ var baseFileContent = File.ReadAllText(filePath, Encoding.UTF8);
+ fileContent = string.Concat(Enumerable.Repeat(baseFileContent, TextSizeMultiplier));
+ }
+
+ [Benchmark]
+ public void Parse()
+ {
+ parser.Parse(fileContent);
+ }
+}
\ No newline at end of file
diff --git a/cs/BenchmarkMarkdown/MdBenchmark.cs b/cs/BenchmarkMarkdown/MdBenchmark.cs
new file mode 100644
index 000000000..2e7af6445
--- /dev/null
+++ b/cs/BenchmarkMarkdown/MdBenchmark.cs
@@ -0,0 +1,31 @@
+using System.Text;
+using BenchmarkDotNet.Attributes;
+using Markdown;
+
+namespace BenchmarkMarkdown;
+
+[MemoryDiagnoser]
+public class MdBenchmark
+{
+ private Md md;
+ private string fileContent;
+
+ [Params(1, 10, 100, 1000)] public int TextSizeMultiplier { get; set; }
+
+ [GlobalSetup]
+ public void Setup()
+ {
+ md = new Md();
+
+ var baseDir = AppContext.BaseDirectory;
+ var filePath = Path.Combine(baseDir, "InputData");
+ var baseFileContent = File.ReadAllText(filePath, Encoding.UTF8);
+ fileContent = string.Concat(Enumerable.Repeat(baseFileContent, TextSizeMultiplier));
+ }
+
+ [Benchmark]
+ public string Render()
+ {
+ return md.Render(fileContent);
+ }
+}
\ No newline at end of file
diff --git a/cs/BenchmarkMarkdown/Program.cs b/cs/BenchmarkMarkdown/Program.cs
new file mode 100644
index 000000000..ee1bca1dd
--- /dev/null
+++ b/cs/BenchmarkMarkdown/Program.cs
@@ -0,0 +1,5 @@
+using BenchmarkDotNet.Running;
+using BenchmarkMarkdown;
+
+BenchmarkRunner.Run();
+BenchmarkRunner.Run();
\ No newline at end of file
diff --git a/cs/Markdown/Markdown.csproj b/cs/Markdown/Markdown.csproj
new file mode 100644
index 000000000..360133917
--- /dev/null
+++ b/cs/Markdown/Markdown.csproj
@@ -0,0 +1,15 @@
+
+
+
+ net9.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
diff --git a/cs/Markdown/Md.cs b/cs/Markdown/Md.cs
new file mode 100644
index 000000000..44691ea66
--- /dev/null
+++ b/cs/Markdown/Md.cs
@@ -0,0 +1,14 @@
+namespace Markdown;
+
+public class Md
+{
+ private readonly IParser parser = new MarkdownParser();
+ private readonly IRenderer renderer = new HtmlRenderer();
+
+ public string Render(string markdownText)
+ {
+ var tokens = parser.Parse(markdownText);
+ var result = renderer.Render(tokens, markdownText);
+ return result;
+ }
+}
\ No newline at end of file
diff --git a/cs/Markdown/OpenToken.cs b/cs/Markdown/OpenToken.cs
new file mode 100644
index 000000000..ae569c1ea
--- /dev/null
+++ b/cs/Markdown/OpenToken.cs
@@ -0,0 +1,8 @@
+namespace Markdown;
+
+public class OpenToken(MarkdownTag openTag, int textStartPosition)
+{
+ public MarkdownTag OpenTag = openTag;
+ public int TextStartPosition = textStartPosition;
+ public readonly List NestedTokens = [];
+}
\ No newline at end of file
diff --git a/cs/Markdown/Parser/IParser.cs b/cs/Markdown/Parser/IParser.cs
new file mode 100644
index 000000000..1e5c9afdf
--- /dev/null
+++ b/cs/Markdown/Parser/IParser.cs
@@ -0,0 +1,6 @@
+namespace Markdown;
+
+public interface IParser
+{
+ public IEnumerable Parse(string text);
+}
\ No newline at end of file
diff --git a/cs/Markdown/Parser/MarkdownParser.cs b/cs/Markdown/Parser/MarkdownParser.cs
new file mode 100644
index 000000000..6230e6033
--- /dev/null
+++ b/cs/Markdown/Parser/MarkdownParser.cs
@@ -0,0 +1,223 @@
+namespace Markdown;
+
+public class MarkdownParser : IParser
+{
+ private static readonly Dictionary markdownTagsByType = new()
+ {
+ { TagType.Header, new HeaderMarkdownTag() },
+ { TagType.Italic, new ItalicMarkdownTag() },
+ { TagType.Bold, new BoldMarkdownTag() },
+ { TagType.Escaping, new EscapingMarkdownTag() },
+ { TagType.EndOfLine, new EndOfLineMarkdownTag() },
+ { TagType.None, new NoneMarkdownTag() },
+ { TagType.Link, new LinkMarkdownTag() }
+ };
+
+ private readonly List possibleTags = [];
+
+ public IEnumerable Parse(string text)
+ {
+ var result = new List();
+ var tokensWithOpenTag = new Stack();
+
+ OpenToken? openTokenWithEmptyTag = null;
+ int currentTagLength;
+
+ for (var i = 0; i < text.Length; i += currentTagLength)
+ {
+ var currentTag = GetTag(text, i, tokensWithOpenTag);
+
+ switch (currentTag.TagType)
+ {
+ case TagType.Link when LinkMarkdownTag.TryProcessTag(text, i, out var resultLinkToken):
+ currentTagLength = resultLinkToken.lengthTag;
+ result.Add(resultLinkToken.token);
+ continue;
+ case TagType.Link or TagType.None:
+ currentTagLength = 1;
+ openTokenWithEmptyTag ??= new OpenToken(markdownTagsByType[TagType.None], i);
+ continue;
+ case TagType.EndOfLine when tokensWithOpenTag.Any(a => a.OpenTag.TagType == TagType.Header):
+ openTokenWithEmptyTag = new OpenToken(markdownTagsByType[TagType.None], i);
+ break;
+ }
+
+ if (openTokenWithEmptyTag is not null && currentTag.TagType != TagType.EndOfLine)
+ {
+ var token = CreateToken(text, i, openTokenWithEmptyTag);
+ AddToken(token, tokensWithOpenTag, result);
+ openTokenWithEmptyTag = null;
+ }
+
+ if (currentTag.IsPairedTag)
+ ProcessPairedTag(text, currentTag, tokensWithOpenTag, i, result);
+ else
+ ProcessUnpairedTag(text, currentTag, tokensWithOpenTag, i, result);
+
+ currentTagLength = currentTag.TotalTagLength;
+ }
+
+ result = AddUnfinishedTags(text, openTokenWithEmptyTag, tokensWithOpenTag, result);
+
+ return result;
+ }
+
+ #region Processing Tags
+
+ private static void ProcessUnpairedTag(string text, MarkdownTag currentTag, Stack tokensWithOpenTag,
+ int position, List result)
+ {
+ if (currentTag.IsPairedTag)
+ throw new ArgumentException("Unpaired tag was expected, but paired tag was received");
+
+ var currentTagLength = currentTag.TagText.Length;
+
+ switch (currentTag.TagType)
+ {
+ case TagType.Header:
+ {
+ var openToken = new OpenToken(currentTag, position + currentTagLength);
+ tokensWithOpenTag.Push(openToken);
+ break;
+ }
+ case TagType.EndOfLine:
+ HeaderMarkdownTag.ProcessEndTag(text, tokensWithOpenTag, position, result);
+ break;
+ case TagType.Escaping:
+ {
+ var startPosition = position + currentTagLength;
+ var openToken = new OpenToken(currentTag, startPosition);
+ var token = CreateToken(text, startPosition + 1, openToken);
+ AddToken(token, tokensWithOpenTag, result);
+ break;
+ }
+ }
+ }
+
+ private static void ProcessPairedTag(string text, MarkdownTag currentTag, Stack tokensWithOpenTag,
+ int position, List result)
+ {
+ if (!currentTag.IsPairedTag)
+ throw new ArgumentException("Paired tag was expected, but unpaired tag was received");
+
+ if (tokensWithOpenTag.IsPeekEqual(currentTag.TagType))
+ {
+ var openToken = tokensWithOpenTag.Pop();
+ AddToken(CreateToken(text, position, openToken), tokensWithOpenTag, result);
+ }
+ else if (tokensWithOpenTag.All(t => t.OpenTag.TagType != currentTag.TagType))
+ {
+ tokensWithOpenTag.Push(new OpenToken(currentTag, position + currentTag.TagText.Length));
+ }
+ }
+
+ #endregion
+
+ #region BorderlineCases
+
+ private static OpenToken ConvertTagBoldInsideTagItalic(OpenToken openToken)
+ {
+ if (openToken.OpenTag.TagType == TagType.Italic && openToken.NestedTokens.Count > 0)
+ for (var i = 0; i < openToken.NestedTokens.Count; i++)
+ openToken.NestedTokens[i] = ConvertTagBoldToTagNone(openToken.NestedTokens[i]);
+ return openToken;
+ }
+
+ private static Token ConvertTagBoldToTagNone(Token token)
+ {
+ if (token.TagType == TagType.Bold)
+ {
+ var tagText = markdownTagsByType[TagType.Bold].TagText;
+ var newText = $"{tagText}{token.Content}{tagText}";
+ var children = token.Children?.Select(ConvertTagBoldToTagNone).ToList();
+ return new Token(TagType.None, newText, children);
+ }
+
+ if (token.Children != null)
+ {
+ var children = token.Children.Select(ConvertTagBoldToTagNone).ToList();
+ return new Token(token.TagType, token.Content, children);
+ }
+
+ return token;
+ }
+
+ #endregion
+
+ private static List AddUnfinishedTags(string text, OpenToken? openTokenWithEmptyTag,
+ Stack tokensWithOpenTag, List listTokens)
+ {
+ if (openTokenWithEmptyTag is not null && tokensWithOpenTag.Count == 0)
+ {
+ var token = CreateToken(text, text.Length, openTokenWithEmptyTag);
+ AddToken(token, tokensWithOpenTag, listTokens);
+ }
+
+ while (tokensWithOpenTag.Count > 0)
+ {
+ var openToken = tokensWithOpenTag.Pop();
+ if (openToken.OpenTag.TagType != TagType.Header)
+ {
+ var token = CreateTokenForPairedTagWithoutPair(text, text.Length, openToken);
+ AddToken(token, tokensWithOpenTag, listTokens);
+ }
+ else
+ {
+ var token = CreateToken(text, text.Length, openToken);
+ AddToken(token, tokensWithOpenTag, listTokens);
+ }
+ }
+
+ return listTokens;
+ }
+
+ public static void AddToken(Token token, Stack tokensWithOpenTag, List result)
+ {
+ if (tokensWithOpenTag.Count == 0)
+ result.Add(token);
+ else
+ tokensWithOpenTag.Peek().NestedTokens.Add(token);
+ }
+
+ public static Token CreateToken(string text, int endPosition, OpenToken openToken)
+ {
+ var length = endPosition - openToken.TextStartPosition;
+
+ if (openToken.OpenTag.TagType == TagType.Italic) openToken = ConvertTagBoldInsideTagItalic(openToken);
+
+ if (!markdownTagsByType[openToken.OpenTag.TagType].IsTagCorrect(text, endPosition, openToken))
+ {
+ var tagText = openToken.OpenTag.TagText;
+ var content = $"{tagText}{text.Substring(openToken.TextStartPosition, length)}{tagText}";
+ return new Token(TagType.None, content);
+ }
+
+ if (openToken.NestedTokens is [{ TagType: TagType.None }] || openToken.NestedTokens.Count == 0)
+ return new Token(openToken.OpenTag.TagType, text.Substring(openToken.TextStartPosition, length));
+
+ return new Token(openToken.OpenTag.TagType, text.Substring(openToken.TextStartPosition, length),
+ openToken.NestedTokens);
+ }
+
+ public static TokenTagLink CreateLinkToken(string content, string linkText, string? tooltipText)
+ {
+ return new TokenTagLink(content, linkText, tooltipText);
+ }
+
+ private static Token CreateTokenForPairedTagWithoutPair(string text, int endPosition, OpenToken openToken)
+ {
+ var startPosition = openToken.TextStartPosition - openToken.OpenTag.TagText.Length;
+ return new Token(TagType.None, text.Substring(startPosition, endPosition - startPosition));
+ }
+
+ private MarkdownTag GetTag(string text, int position, Stack tokensWithOpenTag)
+ {
+ possibleTags.Clear();
+
+ foreach (var tag in markdownTagsByType.Values)
+ if (tag.IsStartOfTag(text, position, tokensWithOpenTag.Any(t => t.OpenTag.TagType == tag.TagType)))
+ possibleTags.Add(tag);
+
+ return possibleTags.OrderByDescending(x => x.TagText.Length).First();
+ }
+}
\ No newline at end of file
diff --git a/cs/Markdown/Renderer/HtmlRenderer.cs b/cs/Markdown/Renderer/HtmlRenderer.cs
new file mode 100644
index 000000000..e3eb178f9
--- /dev/null
+++ b/cs/Markdown/Renderer/HtmlRenderer.cs
@@ -0,0 +1,63 @@
+using System.Text;
+
+namespace Markdown;
+
+public class HtmlRenderer : IRenderer
+{
+ private readonly Dictionary tags = new()
+ {
+ { TagType.None, new HtmlTag(false, "") },
+ { TagType.Header, new HtmlTag(true, "", "
") },
+ { TagType.Italic, new HtmlTag(true, "", "") },
+ { TagType.Bold, new HtmlTag(true, "", "") },
+ { TagType.Escaping, new HtmlTag(true, "\\") },
+ { TagType.EndOfLine, new HtmlTag(false, "\n") }
+ };
+
+ public string Render(IEnumerable tokens, string text)
+ {
+ var stringBuilder = new StringBuilder(text.Length);
+
+ foreach (var token in tokens)
+ {
+ RenderToken(token, stringBuilder);
+ }
+
+ return stringBuilder.ToString();
+ }
+
+ private void RenderToken(Token token, StringBuilder builder)
+ {
+ if (token.TagType == TagType.Link)
+ {
+ RenderLinkToken((TokenTagLink)token, builder);
+ return;
+ }
+
+ var tag = tags[token.TagType];
+
+ if (token.Children is null)
+ {
+ var content = tag.IsPairedTag
+ ? $"{tag.StartTag}{token.Content}{tag.EndTag}"
+ : $"{tag.StartTag}{token.Content}";
+ builder.Append(content);
+ return;
+ }
+
+ builder.Append(tag.StartTag);
+ foreach (var child in token.Children)
+ {
+ RenderToken(child, builder);
+ }
+ builder.Append(tag.EndTag);
+ }
+
+ private void RenderLinkToken(TokenTagLink token, StringBuilder builder)
+ {
+ var content = token.TooltipText is not null
+ ? $"{LinkHtmlTag.StartLinkTag}{token.LinkText}{LinkHtmlTag.EndLinkTag} {LinkHtmlTag.StartTitleTag}{token.TooltipText}{LinkHtmlTag.EndTitleTag}>{token.Content}{LinkHtmlTag.EndTag}"
+ : $"{LinkHtmlTag.StartLinkTag}{token.LinkText}{LinkHtmlTag.EndLinkTag}>{token.Content}{LinkHtmlTag.EndTag}";
+ builder.Append(content);
+ }
+}
\ No newline at end of file
diff --git a/cs/Markdown/Renderer/IRenderer.cs b/cs/Markdown/Renderer/IRenderer.cs
new file mode 100644
index 000000000..863f65d04
--- /dev/null
+++ b/cs/Markdown/Renderer/IRenderer.cs
@@ -0,0 +1,6 @@
+namespace Markdown;
+
+public interface IRenderer
+{
+ public string Render(IEnumerable tokens, string text);
+}
\ No newline at end of file
diff --git a/cs/Markdown/StackExtensions.cs b/cs/Markdown/StackExtensions.cs
new file mode 100644
index 000000000..d11e0e679
--- /dev/null
+++ b/cs/Markdown/StackExtensions.cs
@@ -0,0 +1,9 @@
+namespace Markdown;
+
+public static class StackExtensions
+{
+ public static bool IsPeekEqual(this Stack tokensWithOpenTag, TagType tagType)
+ {
+ return tokensWithOpenTag.Count > 0 && tokensWithOpenTag.Peek().OpenTag.TagType == tagType;
+ }
+}
diff --git a/cs/Markdown/Tag/HtmlTag/HtmlTag.cs b/cs/Markdown/Tag/HtmlTag/HtmlTag.cs
new file mode 100644
index 000000000..5aa7af6ed
--- /dev/null
+++ b/cs/Markdown/Tag/HtmlTag/HtmlTag.cs
@@ -0,0 +1,8 @@
+namespace Markdown;
+
+public class HtmlTag(bool isPairedTag, string startTag, string? endTag = null)
+{
+ public string StartTag { get; } = startTag;
+ public string? EndTag { get; } = endTag;
+ public bool IsPairedTag { get; } = isPairedTag;
+}
\ No newline at end of file
diff --git a/cs/Markdown/Tag/HtmlTag/LinkHtmlTag.cs b/cs/Markdown/Tag/HtmlTag/LinkHtmlTag.cs
new file mode 100644
index 000000000..1502444fb
--- /dev/null
+++ b/cs/Markdown/Tag/HtmlTag/LinkHtmlTag.cs
@@ -0,0 +1,11 @@
+namespace Markdown;
+
+public class LinkHtmlTag() : HtmlTag(true, "")
+{
+ public static string StartLinkTag = " text.Length) return false;
+
+ var isSatisfiesConditions = false;
+ if (isSameTagAlreadyOpen)
+ isSatisfiesConditions = !char.IsWhiteSpace(text[position - 1]);
+ else if (text.Length > position + TagText.Length)
+ isSatisfiesConditions = !char.IsWhiteSpace(text[position + TagText.Length]);
+ else
+ isSatisfiesConditions = true;
+
+ return text.AsSpan(position, TagText.Length).Equals(TagText, StringComparison.Ordinal) && isSatisfiesConditions;
+ }
+
+ public override bool IsTagCorrect(string text, int endPosition, OpenToken openToken)
+ {
+ if (openToken.OpenTag.TagType != TagType.Bold)
+ throw new ArgumentException($"{openToken.OpenTag.TagType.ToString()} should be {nameof(TagType.Bold)}");
+
+ var isTokenEmpty = openToken.TextStartPosition == endPosition && openToken.NestedTokens.Count == 0;
+
+ return !IsTokenLocatedInsideWords(text, endPosition, openToken) &&
+ !IsTokenHighlightsPartOfWordWithDigits(text, endPosition, openToken) && !isTokenEmpty;
+ }
+
+ private bool IsTokenLocatedInsideWords(string text, int endPosition, OpenToken openToken)
+ {
+ var tagLength = TagText.Length;
+ var symbolBeforeStartTag = openToken.TextStartPosition - tagLength - 1;
+
+ var isStartTagInMiddleWord = symbolBeforeStartTag >= 0 &&
+ !char.IsWhiteSpace(text[openToken.TextStartPosition]) &&
+ !char.IsWhiteSpace(text[symbolBeforeStartTag]);
+ var isEndTagInMiddleWord = endPosition + tagLength < text.Length &&
+ !char.IsWhiteSpace(text[endPosition + tagLength]) &&
+ !char.IsWhiteSpace(text[endPosition - 1]);
+ var textContainsSeveralWords = false;
+
+ var part = text.AsSpan(openToken.TextStartPosition, endPosition - openToken.TextStartPosition);
+
+ foreach (var symbol in part)
+ if (char.IsWhiteSpace(symbol))
+ {
+ textContainsSeveralWords = true;
+ break;
+ }
+
+ return (isStartTagInMiddleWord || isEndTagInMiddleWord) && textContainsSeveralWords;
+ }
+
+ private bool IsTokenHighlightsPartOfWordWithDigits(string text, int endPosition, OpenToken openToken)
+ {
+ var tagLength = TagText.Length;
+ var symbolBeforeStartTag = openToken.TextStartPosition - tagLength - 1;
+ var isStartTagInMiddleWord = symbolBeforeStartTag >= 0 &&
+ !char.IsWhiteSpace(text[openToken.TextStartPosition]) &&
+ !char.IsWhiteSpace(text[symbolBeforeStartTag]);
+ var isEndTagInMiddleWord = endPosition + tagLength < text.Length &&
+ !char.IsWhiteSpace(text[endPosition + tagLength]) &&
+ !char.IsWhiteSpace(text[endPosition - 1]);
+
+ var isOnlyPartOfWordHighlighted = isStartTagInMiddleWord || isEndTagInMiddleWord;
+ var partText = text.Substring(openToken.TextStartPosition, endPosition - openToken.TextStartPosition);
+
+ return isOnlyPartOfWordHighlighted && partText.All(char.IsLetterOrDigit) && partText.Any(char.IsDigit);
+ }
+}
\ No newline at end of file
diff --git a/cs/Markdown/Tag/MarkdownTag/EndOfLineMarkdownTag.cs b/cs/Markdown/Tag/MarkdownTag/EndOfLineMarkdownTag.cs
new file mode 100644
index 000000000..141b209d9
--- /dev/null
+++ b/cs/Markdown/Tag/MarkdownTag/EndOfLineMarkdownTag.cs
@@ -0,0 +1,12 @@
+namespace Markdown;
+
+public class EndOfLineMarkdownTag() : MarkdownTag(TagType.EndOfLine, Environment.NewLine, false)
+{
+ public override bool IsStartOfTag(string text, int position, bool isSameTagAlreadyOpen)
+ {
+ if (TagText.Length + position > text.Length) return false;
+
+ if (text.AsSpan(position, TagText.Length).Equals(TagText, StringComparison.Ordinal)) return true;
+ return false;
+ }
+}
\ No newline at end of file
diff --git a/cs/Markdown/Tag/MarkdownTag/EscapingMarkdownTag.cs b/cs/Markdown/Tag/MarkdownTag/EscapingMarkdownTag.cs
new file mode 100644
index 000000000..5f4f1cddb
--- /dev/null
+++ b/cs/Markdown/Tag/MarkdownTag/EscapingMarkdownTag.cs
@@ -0,0 +1,15 @@
+namespace Markdown;
+
+public class EscapingMarkdownTag() : MarkdownTag(TagType.Escaping, "\\", false, 2)
+{
+ private static readonly List escapeSymbols = ['\\', '#', '_'];
+
+ public override bool IsStartOfTag(string text, int position, bool isSameTagAlreadyOpen)
+ {
+ if (TotalTagLength + position > text.Length) return false;
+
+ var isCanEscaping = escapeSymbols.Contains(text[position + 1]);
+
+ return text.AsSpan(position, TagText.Length).Equals(TagText, StringComparison.Ordinal) && isCanEscaping;
+ }
+}
diff --git a/cs/Markdown/Tag/MarkdownTag/HeaderMarkdownTag.cs b/cs/Markdown/Tag/MarkdownTag/HeaderMarkdownTag.cs
new file mode 100644
index 000000000..928f11fc7
--- /dev/null
+++ b/cs/Markdown/Tag/MarkdownTag/HeaderMarkdownTag.cs
@@ -0,0 +1,34 @@
+namespace Markdown;
+
+public class HeaderMarkdownTag() : MarkdownTag(TagType.Header, "# ", false)
+{
+ public override bool IsStartOfTag(string text, int position, bool isSameTagAlreadyOpen)
+ {
+ if (TagText.Length + position > text.Length) return false;
+
+ var doubleNewLine = string.Concat(Enumerable.Repeat(Environment.NewLine, 2));
+ var length = doubleNewLine.Length;
+
+ var isNewParagraph = position == 0 || (position >= length && text.AsSpan(position - length, length)
+ .Equals(doubleNewLine, StringComparison.Ordinal));
+
+ return text.AsSpan(position, TagText.Length).Equals(TagText, StringComparison.Ordinal) && isNewParagraph &&
+ !isSameTagAlreadyOpen;
+ }
+
+ public static void ProcessEndTag(string text, Stack tokensWithOpenTag, int position,
+ List result)
+ {
+ if (tokensWithOpenTag.All(t => t.OpenTag.TagType != TagType.Header))
+ return;
+
+ while (!tokensWithOpenTag.IsPeekEqual(TagType.Header))
+ tokensWithOpenTag.Pop();
+
+ if (tokensWithOpenTag.Count > 0)
+ {
+ var headerToken = tokensWithOpenTag.Pop();
+ MarkdownParser.AddToken(MarkdownParser.CreateToken(text, position, headerToken), tokensWithOpenTag, result);
+ }
+ }
+}
\ No newline at end of file
diff --git a/cs/Markdown/Tag/MarkdownTag/ItalicMarkdownTag.cs b/cs/Markdown/Tag/MarkdownTag/ItalicMarkdownTag.cs
new file mode 100644
index 000000000..debc70902
--- /dev/null
+++ b/cs/Markdown/Tag/MarkdownTag/ItalicMarkdownTag.cs
@@ -0,0 +1,77 @@
+namespace Markdown;
+
+public class ItalicMarkdownTag() : MarkdownTag(TagType.Italic, "_", true)
+{
+ public override bool IsStartOfTag(string text, int position, bool isSameTagAlreadyOpen)
+ {
+ if (TagText.Length + position > text.Length) return false;
+
+ var isSatisfiesConditions = false;
+ if (isSameTagAlreadyOpen)
+ isSatisfiesConditions = !char.IsWhiteSpace(text[position - 1]);
+ else if (text.Length > position + TagText.Length)
+ isSatisfiesConditions = !char.IsWhiteSpace(text[position + TagText.Length]);
+ else
+ isSatisfiesConditions = true;
+
+ var isContainsTagItalic = text.AsSpan(position, TagText.Length).Equals(TagText, StringComparison.Ordinal);
+ var tagBoldLength = BoldMarkdownTag.TagText.Length;
+ var tagBoldText = BoldMarkdownTag.TagText;
+ var isContainsTagBold = tagBoldLength + position <= text.Length && text.AsSpan(position, tagBoldLength)
+ .Equals(tagBoldText, StringComparison.Ordinal);
+ return isContainsTagItalic && !isContainsTagBold && isSatisfiesConditions;
+ }
+
+ public override bool IsTagCorrect(string text, int endPosition, OpenToken openToken)
+ {
+ if (openToken.OpenTag.TagType != TagType.Italic)
+ throw new ArgumentException($"{openToken.OpenTag.TagType.ToString()} should be {nameof(TagType.Italic)}");
+
+ var isTokenEmpty = openToken.TextStartPosition == endPosition && openToken.NestedTokens.Count == 0;
+
+ return !IsTokenLocatedInsideWords(text, endPosition, openToken) &&
+ !IsTokenHighlightsPartOfWordWithDigits(text, endPosition, openToken) && !isTokenEmpty;
+ }
+
+ private bool IsTokenLocatedInsideWords(string text, int endPosition, OpenToken openToken)
+ {
+ var tagLength = TagText.Length;
+ var symbolBeforeStartTag = openToken.TextStartPosition - tagLength - 1;
+
+ var isStartTagInMiddleWord = symbolBeforeStartTag >= 0 &&
+ !char.IsWhiteSpace(text[openToken.TextStartPosition]) &&
+ !char.IsWhiteSpace(text[symbolBeforeStartTag]);
+ var isEndTagInMiddleWord = endPosition + tagLength < text.Length &&
+ !char.IsWhiteSpace(text[endPosition + tagLength]) &&
+ !char.IsWhiteSpace(text[endPosition - 1]);
+ var textContainsSeveralWords = false;
+
+ var part = text.AsSpan(openToken.TextStartPosition, endPosition - openToken.TextStartPosition);
+
+ foreach (var symbol in part)
+ if (char.IsWhiteSpace(symbol))
+ {
+ textContainsSeveralWords = true;
+ break;
+ }
+
+ return (isStartTagInMiddleWord || isEndTagInMiddleWord) && textContainsSeveralWords;
+ }
+
+ private bool IsTokenHighlightsPartOfWordWithDigits(string text, int endPosition, OpenToken openToken)
+ {
+ var tagLength = TagText.Length;
+ var symbolBeforeStartTag = openToken.TextStartPosition - tagLength - 1;
+ var isStartTagInMiddleWord = symbolBeforeStartTag >= 0 &&
+ !char.IsWhiteSpace(text[openToken.TextStartPosition]) &&
+ !char.IsWhiteSpace(text[symbolBeforeStartTag]);
+ var isEndTagInMiddleWord = endPosition + tagLength < text.Length &&
+ !char.IsWhiteSpace(text[endPosition + tagLength]) &&
+ !char.IsWhiteSpace(text[endPosition - 1]);
+
+ var isOnlyPartOfWordHighlighted = isStartTagInMiddleWord || isEndTagInMiddleWord;
+ var partText = text.Substring(openToken.TextStartPosition, endPosition - openToken.TextStartPosition);
+
+ return isOnlyPartOfWordHighlighted && partText.All(char.IsLetterOrDigit) && partText.Any(char.IsDigit);
+ }
+}
\ No newline at end of file
diff --git a/cs/Markdown/Tag/MarkdownTag/LinkMarkdownTag.cs b/cs/Markdown/Tag/MarkdownTag/LinkMarkdownTag.cs
new file mode 100644
index 000000000..5dcf62db2
--- /dev/null
+++ b/cs/Markdown/Tag/MarkdownTag/LinkMarkdownTag.cs
@@ -0,0 +1,88 @@
+namespace Markdown;
+
+public class LinkMarkdownTag() : MarkdownTag(TagType.Link, "[", true)
+{
+ public static readonly string StartTagTextForName = "[";
+ public static readonly string EndTagTextForName = "]";
+ public static readonly string StartTagTextForLink = "(";
+ public static readonly string EndTagTextForLink = ")";
+ public static readonly string StartTagTextForTooltip = " \"";
+ public static readonly string EndTagTextForTooltip = "\"";
+
+ public override bool IsStartOfTag(string text, int position, bool isSameTagAlreadyOpen)
+ {
+ if (TagText.Length + position > text.Length) return false;
+
+ var isContainsStartTagTextForName = text.AsSpan(position, StartTagTextForName.Length).Equals(StartTagTextForName, StringComparison.Ordinal);
+
+ return isContainsStartTagTextForName;
+ }
+
+ public static bool TryProcessTag(string text, int startPosition, out (TokenTagLink? token, int lengthTag) result)
+ {
+ var currentPosition = startPosition;
+
+ if (!text.StartsWith(StartTagTextForName, StringComparison.Ordinal))
+ {
+ result = (null, 0);
+ return false;
+ }
+
+ var indexEndTagTextForName = text.IndexOf(EndTagTextForName, startPosition + StartTagTextForName.Length, StringComparison.Ordinal);
+ var indexStartTagTextForLink = text.IndexOf(StartTagTextForLink, indexEndTagTextForName + EndTagTextForName.Length, StringComparison.Ordinal);
+
+ var indexStartTagTextForTooltip = -1;
+ var indexEndTagTextForTooltip = -1;
+
+ if (TryFindTooltip(text, currentPosition, out var tooltip))
+ {
+ indexStartTagTextForTooltip = tooltip.start;
+ indexEndTagTextForTooltip = tooltip.end;
+ }
+
+ var indexEndTagTextForLink = text.IndexOf(EndTagTextForLink, indexStartTagTextForLink + StartTagTextForLink.Length, StringComparison.Ordinal);
+
+ if (indexEndTagTextForName == -1 || indexStartTagTextForLink == -1 || indexEndTagTextForLink == -1)
+ {
+ result = (null, 0);
+ return false;
+ }
+
+ string linkText;
+ string? tooltipText = null;
+
+ if (indexStartTagTextForTooltip == -1)
+ {
+ linkText = text.Substring(indexStartTagTextForLink + StartTagTextForLink.Length,
+ indexEndTagTextForLink - indexStartTagTextForLink - StartTagTextForLink.Length);
+ }
+ else
+ {
+ linkText = text.Substring(indexStartTagTextForLink + StartTagTextForLink.Length,
+ indexStartTagTextForTooltip - indexStartTagTextForLink - StartTagTextForLink.Length);
+ tooltipText = text.Substring(indexStartTagTextForTooltip + StartTagTextForTooltip.Length,
+ indexEndTagTextForTooltip - indexStartTagTextForTooltip - StartTagTextForTooltip.Length);
+ }
+
+
+ var content = text.Substring(startPosition + StartTagTextForName.Length, indexEndTagTextForName - startPosition - EndTagTextForName.Length);
+
+ result = (MarkdownParser.CreateLinkToken(content, linkText, tooltipText), indexEndTagTextForLink - startPosition + 1);
+ return true;
+ }
+
+ private static bool TryFindTooltip(string text, int startPosition, out (int start, int end) positions)
+ {
+ var indexStartTagTextForTooltip = text.IndexOf(StartTagTextForTooltip, startPosition, StringComparison.Ordinal);
+ var indexEndTagTextForTooltip = text.IndexOf(EndTagTextForTooltip, indexStartTagTextForTooltip + StartTagTextForTooltip.Length, StringComparison.Ordinal);
+
+ if (indexEndTagTextForTooltip == -1 || indexStartTagTextForTooltip == -1)
+ {
+ positions = (0, 0);
+ return false;
+ }
+
+ positions = (indexStartTagTextForTooltip, indexEndTagTextForTooltip);
+ return true;
+ }
+}
\ No newline at end of file
diff --git a/cs/Markdown/Tag/MarkdownTag/MarkdownTag.cs b/cs/Markdown/Tag/MarkdownTag/MarkdownTag.cs
new file mode 100644
index 000000000..261d82f48
--- /dev/null
+++ b/cs/Markdown/Tag/MarkdownTag/MarkdownTag.cs
@@ -0,0 +1,16 @@
+namespace Markdown;
+
+public abstract class MarkdownTag(TagType tagType, string tagText, bool isPairedTag, int? tagLength = null)
+{
+ public TagType TagType { get; } = tagType;
+ public string TagText { get; } = tagText;
+ public bool IsPairedTag { get; } = isPairedTag;
+ public int TotalTagLength { get; } = tagLength ?? tagText.Length;
+
+ public abstract bool IsStartOfTag(string text, int position, bool isSameTagAlreadyOpen);
+
+ public virtual bool IsTagCorrect(string text, int endPosition, OpenToken openToken)
+ {
+ return true;
+ }
+}
\ No newline at end of file
diff --git a/cs/Markdown/Tag/MarkdownTag/NoneMarkdownTag.cs b/cs/Markdown/Tag/MarkdownTag/NoneMarkdownTag.cs
new file mode 100644
index 000000000..65bbbe930
--- /dev/null
+++ b/cs/Markdown/Tag/MarkdownTag/NoneMarkdownTag.cs
@@ -0,0 +1,9 @@
+namespace Markdown;
+
+public class NoneMarkdownTag() : MarkdownTag(TagType.None, "", false)
+{
+ public override bool IsStartOfTag(string text, int position, bool isSameTagAlreadyOpen)
+ {
+ return true;
+ }
+}
\ No newline at end of file
diff --git a/cs/Markdown/Tag/TagType.cs b/cs/Markdown/Tag/TagType.cs
new file mode 100644
index 000000000..90309fefa
--- /dev/null
+++ b/cs/Markdown/Tag/TagType.cs
@@ -0,0 +1,12 @@
+namespace Markdown;
+
+public enum TagType
+{
+ None,
+ Header,
+ Italic,
+ Bold,
+ Escaping,
+ Link,
+ EndOfLine
+}
\ No newline at end of file
diff --git a/cs/Markdown/Tests/HtmlRendererTests.cs b/cs/Markdown/Tests/HtmlRendererTests.cs
new file mode 100644
index 000000000..d86cca1c4
--- /dev/null
+++ b/cs/Markdown/Tests/HtmlRendererTests.cs
@@ -0,0 +1,58 @@
+using FluentAssertions;
+using NUnit.Framework;
+
+namespace Markdown.Tests;
+
+[TestFixture]
+public class HtmlRendererTests
+{
+ private IRenderer renderer;
+
+ [SetUp]
+ public void SetUp()
+ {
+ renderer = new HtmlRenderer();
+ }
+
+ [TestCaseSource(nameof(CasesWhenListWithOneToken))]
+ [Description("Checks each tag")]
+ public void Parse_ReturnsString_WhenListWithOneToken(IEnumerable inputTokens, string inputText, string expectedResult)
+ {
+ var result = renderer.Render(inputTokens, inputText);
+
+ result.Should().BeEquivalentTo(expectedResult);
+ }
+
+ [TestCaseSource(nameof(CasesWhenListWithMultipleNestedTokens))]
+ public void Parse_ReturnsString_WhenListWithMultipleNestedTokens(IEnumerable inputTokens, string inputText, string expectedResult)
+ {
+ var result = renderer.Render(inputTokens, inputText);
+
+ result.Should().BeEquivalentTo(expectedResult);
+ }
+
+ public static IEnumerable CasesWhenListWithOneToken()
+ {
+ yield return new TestCaseData(new List { new (TagType.None, "Human") }, "Human", "Human");
+ yield return new TestCaseData(new List { new (TagType.Italic, "Human") }, "Human", "Human");
+ yield return new TestCaseData(new List { new (TagType.Bold, "Human") }, "Human", "Human");
+ yield return new TestCaseData(new List { new (TagType.Escaping, "\\") }, "Human", @"\\");
+ yield return new TestCaseData(new List { new (TagType.Header, "Human") }, "Human", "Human
");
+ }
+
+ public static IEnumerable CasesWhenListWithMultipleNestedTokens()
+ {
+ yield return new TestCaseData(new List { new (TagType.Header, "Human1 Human2", [
+ new Token(TagType.Bold, "Human1"),
+ new Token(TagType.None, " "),
+ new Token(TagType.Italic, "Human2")
+ ]) }, "Human1 Human2", "Human1 Human2
");
+
+ yield return new TestCaseData(new List { new (TagType.Header, "Human1 Human2", new List()
+ {
+ new (TagType.Bold, "Human1"),
+ new (TagType.None, " "),
+ new (TagType.Italic, "Human2"),
+ }) }, "Human1 Human2", "Human1 Human2
");
+ }
+}
\ No newline at end of file
diff --git a/cs/Markdown/Tests/MarkdownParserTests.cs b/cs/Markdown/Tests/MarkdownParserTests.cs
new file mode 100644
index 000000000..c1a9a6590
--- /dev/null
+++ b/cs/Markdown/Tests/MarkdownParserTests.cs
@@ -0,0 +1,314 @@
+using FluentAssertions;
+using NUnit.Framework;
+
+namespace Markdown.Tests;
+
+[TestFixture]
+public class MarkdownParserTests
+{
+ private static readonly Dictionary pairedTags = new()
+ {
+ { TagType.Italic, "_" },
+ { TagType.Bold, "__" }
+ };
+
+ private IParser parser;
+
+ [SetUp]
+ public void SetUp()
+ {
+ parser = new MarkdownParser();
+ }
+
+ [TestCase("wordA wordB")]
+ public void Parse_ReturnsIEnumerableTokens_WhenTextWithoutTags(string input)
+ {
+ var result = parser.Parse(input);
+
+ result.Should().BeEquivalentTo([new Token(TagType.None, "wordA wordB")]);
+ }
+
+ [TestCaseSource(nameof(CasesWhenTextWithOnePairedTag))]
+ [Description("Checks each paired tag")]
+ public void Parse_ReturnsIEnumerableTokens_WhenTextWithOnePairedTag(string input, IEnumerable expectedResult)
+ {
+ var result = parser.Parse(input);
+
+ result.Should().BeEquivalentTo(expectedResult, options => options.WithStrictOrdering());
+ }
+
+ [TestCaseSource(nameof(CasesWhenTextWithMultipleNonNestedPairedTags))]
+ public void Parse_ReturnsIEnumerableTokens_WhenTextWithMultipleNonNestedPairedTags(string input,
+ IEnumerable expectedResult)
+ {
+ var result = parser.Parse(input);
+
+ result.Should().BeEquivalentTo(expectedResult, options => options.WithStrictOrdering());
+ }
+
+ [TestCaseSource(nameof(CasesWhenTextWithMultipleNestedPairedTags))]
+ public void Parse_ReturnsIEnumerableTokens_WhenTextWithMultipleNestedPairedTags(string input,
+ IEnumerable expectedResult)
+ {
+ var result = parser.Parse(input);
+
+ result.Should().BeEquivalentTo(expectedResult, options => options.WithStrictOrdering());
+ }
+
+ [TestCaseSource(nameof(CasesWhenTextWithPairedTagWithoutPair))]
+ [Description("Checks each paired tag")]
+ public void Parse_ReturnsIEnumerableTokens_WhenTextWithPairedTagWithoutPair(string input,
+ IEnumerable expectedResult)
+ {
+ var result = parser.Parse(input);
+
+ result.Should().BeEquivalentTo(expectedResult, options => options.WithStrictOrdering());
+ }
+
+ [TestCaseSource(nameof(CasesWhenEmptyTextInsideTags))]
+ public void Parse_ReturnsIEnumerableTokens_WhenEmptyTextInsideTags(string input, IEnumerable expectedResult)
+ {
+ var result = parser.Parse(input);
+
+ result.Should().BeEquivalentTo(expectedResult, options => options.WithStrictOrdering());
+ }
+
+ [TestCaseSource(nameof(CasesTextContainsHeaderTag))]
+ public void Parse_ReturnsIEnumerableTokens_WhenTextContainsHeaderTag(string input,
+ IEnumerable expectedResult)
+ {
+ var result = parser.Parse(input);
+
+ result.Should().BeEquivalentTo(expectedResult, options => options.WithStrictOrdering());
+ }
+
+ [TestCaseSource(nameof(CasesWhenTextContainsEscapingTag))]
+ public void Parse_ReturnsIEnumerableTokens_WhenTextContainsEscapingTag(string input,
+ IEnumerable expectedResult)
+ {
+ var result = parser.Parse(input);
+
+ result.Should().BeEquivalentTo(expectedResult, options => options.WithStrictOrdering());
+ }
+
+ [TestCaseSource(nameof(CasesWhenTextContainsOverlappingTags))]
+ public void Parse_ReturnsIEnumerableTokens_WhenTextContainsOverlappingTags(string input,
+ IEnumerable expectedResult)
+ {
+ var result = parser.Parse(input);
+
+ result.Should().BeEquivalentTo(expectedResult, options => options.WithStrictOrdering());
+ }
+
+ [TestCaseSource(nameof(CasesWhenBoldTagInsideItalicTag))]
+ public void Parse_ReturnsIEnumerableTokens_WhenBoldTagInsideItalicTag(string input,
+ IEnumerable expectedResult)
+ {
+ var result = parser.Parse(input);
+
+ result.Should().BeEquivalentTo(expectedResult, options => options.WithStrictOrdering());
+ }
+
+ [TestCaseSource(nameof(CasesWhenItalicTagInsideBoldTag))]
+ public void Parse_ReturnsIEnumerableTokens_WhenItalicTagInsideBoldTag(string input,
+ IEnumerable expectedResult)
+ {
+ var result = parser.Parse(input);
+
+ result.Should().BeEquivalentTo(expectedResult, options => options.WithStrictOrdering());
+ }
+
+ [TestCaseSource(nameof(CasesWhenTextWithNumbersAndContainsBoldItalicTags))]
+ public void Parse_ReturnsIEnumerableTokens_WhenTextWithNumbersAndContainsBoldItalicTags(string input,
+ IEnumerable expectedResult)
+ {
+ var result = parser.Parse(input);
+
+ result.Should().BeEquivalentTo(expectedResult, options => options.WithStrictOrdering());
+ }
+
+ [TestCaseSource(nameof(CasesWhenTextWithWhiteSpaceAndContainsBoldItalicTags))]
+ public void Parse_ReturnsIEnumerableTokens_WhenTextWithWhiteSpaceAndContainsBoldItalicTags(string input,
+ IEnumerable expectedResult)
+ {
+ var result = parser.Parse(input);
+
+ result.Should().BeEquivalentTo(expectedResult, options => options.WithStrictOrdering());
+ }
+
+ [TestCaseSource(nameof(CasesWhenTextContainsBoldItalicTagsInMiddleWords))]
+ public void Parse_ReturnsIEnumerableTokens_WhenTextContainsBoldItalicTagsInMiddleWords(string input,
+ IEnumerable expectedResult)
+ {
+ var result = parser.Parse(input);
+
+ result.Should().BeEquivalentTo(expectedResult, options => options.WithStrictOrdering());
+ }
+
+ [TestCaseSource(nameof(CasesWhenTextContainsLinkTag))]
+ public void Parse_ReturnsIEnumerableTokens_WhenTextContainsLinkTag(string input,
+ IEnumerable expectedResult)
+ {
+ var result = parser.Parse(input);
+
+ result.Should().BeEquivalentTo(expectedResult, options => options.WithStrictOrdering());
+ }
+
+ public static IEnumerable CasesWhenTextWithOnePairedTag()
+ {
+ foreach (var (tagType, tagContent) in pairedTags)
+ {
+ yield return new TestCaseData($"{tagContent}wordA wordB{tagContent}",
+ new[] { new Token(tagType, "wordA wordB") });
+ yield return new TestCaseData($"wordA {tagContent}wordB wordC{tagContent} wordD",
+ new[]
+ {
+ new Token(TagType.None, "wordA "), new Token(tagType, "wordB wordC"),
+ new Token(TagType.None, " wordD")
+ });
+ }
+ }
+
+ public static IEnumerable CasesWhenTextWithMultipleNonNestedPairedTags()
+ {
+ yield return new TestCaseData("_wordA_ __wordB__ wordC",
+ new[]
+ {
+ new Token(TagType.Italic, "wordA"), new Token(TagType.None, " "), new Token(TagType.Bold, "wordB"),
+ new Token(TagType.None, " wordC")
+ });
+ }
+
+ public static IEnumerable CasesWhenTextWithMultipleNestedPairedTags()
+ {
+ yield return new TestCaseData("__wordA _wordB_ wordC__",
+ new[]
+ {
+ new Token(TagType.Bold, "wordA _wordB_ wordC",
+ [
+ new Token(TagType.None, "wordA "), new Token(TagType.Italic, "wordB"),
+ new Token(TagType.None, " wordC")
+ ])
+ });
+ }
+
+ public static IEnumerable CasesWhenTextWithPairedTagWithoutPair()
+ {
+ foreach (var (tagType, tagContent) in pairedTags)
+ yield return new TestCaseData($"{tagContent}wordA",
+ new[] { new Token(TagType.None, $"{tagContent}wordA") });
+ }
+
+ public static IEnumerable CasesWhenEmptyTextInsideTags()
+ {
+ yield return new TestCaseData("__", new[] { new Token(TagType.None, "__") });
+ yield return new TestCaseData("____", new[] { new Token(TagType.None, "____") });
+ }
+
+ public static IEnumerable CasesTextContainsHeaderTag()
+ {
+ yield return new TestCaseData("# wordA", new[] { new Token(TagType.Header, "wordA") });
+ yield return new TestCaseData("# wordA # ", new[] { new Token(TagType.Header, "wordA # ") });
+ yield return new TestCaseData(" # wordA", new[] { new Token(TagType.None, " # wordA") });
+
+ yield return new TestCaseData($"# wordA{Environment.NewLine} # ",
+ new[]
+ {
+ new Token(TagType.Header, "wordA"),
+ new Token(TagType.None, $"{Environment.NewLine} # ")
+ });
+
+ yield return new TestCaseData($" wordA{Environment.NewLine}{Environment.NewLine}# wordB",
+ new[]
+ {
+ new Token(TagType.None, $" wordA{Environment.NewLine}{Environment.NewLine}"), new Token(TagType.Header, "wordB")
+ });
+ }
+
+ public static IEnumerable CasesWhenTextContainsEscapingTag()
+ {
+ yield return new TestCaseData(@"\\", new[] { new Token(TagType.Escaping, @"\") });
+ yield return new TestCaseData(@"\_wordA_",
+ new[] { new Token(TagType.Escaping, "_"), new Token(TagType.None, "wordA"), new Token(TagType.None, "_") });
+ }
+
+ public static IEnumerable CasesWhenTextContainsOverlappingTags()
+ {
+ yield return new TestCaseData("__wordA_wordB__wordC_",
+ new[] { new Token(TagType.None, "__wordA_wordB__wordC_") });
+ yield return new TestCaseData("_wordA__wordB_wordC__ wordD",
+ new[] { new Token(TagType.None, "_wordA__wordB_wordC__ wordD") });
+ }
+
+ public static IEnumerable CasesWhenBoldTagInsideItalicTag()
+ {
+ yield return new TestCaseData("_wordA__wordB__wordC_",
+ new[]
+ {
+ new Token(TagType.Italic, "wordA__wordB__wordC",
+ [
+ new Token(TagType.None, "wordA"), new Token(TagType.None, "__wordB__"),
+ new Token(TagType.None, "wordC")
+ ])
+ });
+ }
+
+ public static IEnumerable CasesWhenItalicTagInsideBoldTag()
+ {
+ yield return new TestCaseData("__wordA_wordB_wordC__",
+ new[]
+ {
+ new Token(TagType.Bold, "wordA_wordB_wordC",
+ [
+ new Token(TagType.None, "wordA"), new Token(TagType.Italic, "wordB"),
+ new Token(TagType.None, "wordC")
+ ])
+ });
+ }
+
+ public static IEnumerable CasesWhenTextWithNumbersAndContainsBoldItalicTags()
+ {
+ yield return new TestCaseData("wo__rd1__",
+ new[] { new Token(TagType.None, "wo"), new Token(TagType.None, "__rd1__") });
+ yield return new TestCaseData("wo_rd1_",
+ new[] { new Token(TagType.None, "wo"), new Token(TagType.None, "_rd1_") });
+ }
+
+ public static IEnumerable CasesWhenTextWithWhiteSpaceAndContainsBoldItalicTags()
+ {
+ yield return new TestCaseData("_wordA _", new[] { new Token(TagType.None, "_wordA _") });
+ yield return new TestCaseData("__wordA __", new[] { new Token(TagType.None, "__wordA __") });
+ yield return new TestCaseData("_ wordA_",
+ new[] { new Token(TagType.None, "_ wordA"), new Token(TagType.None, "_") });
+ yield return new TestCaseData("__ wordA__",
+ new[] { new Token(TagType.None, "__ wordA"), new Token(TagType.None, "__") });
+ }
+
+ public static IEnumerable CasesWhenTextContainsBoldItalicTagsInMiddleWords()
+ {
+ yield return new TestCaseData("_wor_dA",
+ new[] { new Token(TagType.Italic, "wor"), new Token(TagType.None, "dA") });
+ yield return new TestCaseData("__wor__dA",
+ new[] { new Token(TagType.Bold, "wor"), new Token(TagType.None, "dA") });
+ yield return new TestCaseData("_wordA wor_dB",
+ new[] { new Token(TagType.None, "_wordA wor_"), new Token(TagType.None, "dB") });
+ yield return new TestCaseData("__wordA wor__dB",
+ new[] { new Token(TagType.None, "__wordA wor__"), new Token(TagType.None, "dB") });
+ }
+
+ public static IEnumerable CasesWhenTextContainsLinkTag()
+ {
+ yield return new TestCaseData("[Name Link](https://www.example.com \"Tooltip\")",
+ new[] { new TokenTagLink("Name Link", "https://www.example.com", "Tooltip") });
+ yield return new TestCaseData("[Name Link](https://www.example.com Tooltip\")",
+ new[] { new TokenTagLink("Name Link", "https://www.example.com Tooltip\"") });
+ yield return new TestCaseData("Name Link](https://www.example.com Tooltip\")",
+ new[] { new Token(TagType.None, "Name Link](https://www.example.com Tooltip\")") });
+ yield return new TestCaseData("[Name Link(https://www.example.com Tooltip\")",
+ new[] { new Token(TagType.None, "[Name Link(https://www.example.com Tooltip\")") });
+ yield return new TestCaseData("[Name Link]https://www.example.com Tooltip\")",
+ new[] { new Token(TagType.None, "[Name Link]https://www.example.com Tooltip\")") });
+ yield return new TestCaseData("[Name Link](https://www.example.com Tooltip\"",
+ new[] { new Token(TagType.None, "[Name Link](https://www.example.com Tooltip\"") });
+ }
+}
\ No newline at end of file
diff --git a/cs/Markdown/Tests/MdTests.cs b/cs/Markdown/Tests/MdTests.cs
new file mode 100644
index 000000000..a05aa5f4d
--- /dev/null
+++ b/cs/Markdown/Tests/MdTests.cs
@@ -0,0 +1,237 @@
+using FluentAssertions;
+using NUnit.Framework;
+
+namespace Markdown.Tests;
+
+[TestFixture]
+public class MdTests
+{
+ private Md markdown;
+
+ [SetUp]
+ public void SetUp()
+ {
+ markdown = new Md();
+ }
+
+ [TestCase("wordA wordB")]
+ public void Render_ReturnsString_WhenTextWithoutTags(string input)
+ {
+ var result = markdown.Render(input);
+
+ result.Should().Be("wordA wordB");
+ }
+
+ [TestCaseSource(nameof(CasesWhenTextWithOnePairedTag))]
+ [Description("Checks each paired tag")]
+ public void Render_ReturnsString_WhenTextWithOnePairedTag(string input, string expectedResult)
+ {
+ var result = markdown.Render(input);
+
+ result.Should().Be(expectedResult);
+ }
+
+ [TestCaseSource(nameof(CasesWhenTextWithMultipleNonNestedPairedTags))]
+ public void Render_ReturnsString_WhenTextWithMultipleNonNestedPairedTags(string input, string expectedResult)
+ {
+ var result = markdown.Render(input);
+
+ result.Should().Be(expectedResult);
+ }
+
+ [TestCaseSource(nameof(CasesWhenTextWithMultipleNestedPairedTags))]
+ public void Render_ReturnsString_WhenTextWithMultipleNestedPairedTags(string input, string expectedResult)
+ {
+ var result = markdown.Render(input);
+
+ result.Should().Be(expectedResult);
+ }
+
+ [TestCaseSource(nameof(CasesWhenTextWithPairedTagWithoutPair))]
+ [Description("Checks each paired tag")]
+ public void Render_ReturnsString_WhenTextWithPairedTagWithoutPair(string input, string expectedResult)
+ {
+ var result = markdown.Render(input);
+
+ result.Should().Be(expectedResult);
+ }
+
+ [TestCaseSource(nameof(CasesWhenEmptyTextInsideTags))]
+ public void Render_ReturnsString_WhenEmptyTextInsideTags(string input, string expectedResult)
+ {
+ var result = markdown.Render(input);
+
+ result.Should().Be(expectedResult);
+ }
+
+ [TestCaseSource(nameof(CasesTextContainsHeaderTag))]
+ public void Render_ReturnsString_WhenTextContainsHeaderTag(string input, string expectedResult)
+ {
+ var result = markdown.Render(input);
+
+ result.Should().Be(expectedResult);
+ }
+
+ [TestCaseSource(nameof(CasesWhenTextContainsEscapingTag))]
+ public void Render_ReturnsString_WhenTextContainsEscapingTag(string input, string expectedResult)
+ {
+ var result = markdown.Render(input);
+
+ result.Should().Be(expectedResult);
+ }
+
+ [TestCaseSource(nameof(CasesWhenTextContainsOverlappingTags))]
+ public void Render_ReturnsString_WhenTextContainsOverlappingTags(string input, string expectedResult)
+ {
+ var result = markdown.Render(input);
+
+ result.Should().Be(expectedResult);
+ }
+
+ [TestCaseSource(nameof(CasesWhenBoldTagInsideItalicTag))]
+ public void Render_ReturnsString_WhenBoldTagInsideItalicTag(string input, string expectedResult)
+ {
+ var result = markdown.Render(input);
+
+ result.Should().Be(expectedResult);
+ }
+
+ [TestCaseSource(nameof(CasesWhenItalicTagInsideBoldTag))]
+ public void Render_ReturnsString_WhenItalicTagInsideBoldTag(string input, string expectedResult)
+ {
+ var result = markdown.Render(input);
+
+ result.Should().Be(expectedResult);
+ }
+
+ [TestCaseSource(nameof(CasesWhenTextWithNumbersAndContainsBoldItalicTags))]
+ public void Render_ReturnsString_WhenTextWithNumbersAndContainsBoldItalicTags(string input, string expectedResult)
+ {
+ var result = markdown.Render(input);
+
+ result.Should().Be(expectedResult);
+ }
+
+ [TestCaseSource(nameof(CasesWhenTextWithWhiteSpaceAndContainsBoldItalicTags))]
+ public void Render_ReturnsString_WhenTextWithWhiteSpaceAndContainsBoldItalicTags(string input, string expectedResult)
+ {
+ var result = markdown.Render(input);
+
+ result.Should().Be(expectedResult);
+ }
+
+ [TestCaseSource(nameof(CasesWhenTextContainsBoldItalicTagsInMiddleWords))]
+ public void Render_ReturnsString_WhenTextContainsBoldItalicTagsInMiddleWords(string input, string expectedResult)
+ {
+ var result = markdown.Render(input);
+
+ result.Should().Be(expectedResult);
+ }
+
+ [TestCaseSource(nameof(CasesWhenTextContainsLinkTag))]
+ public void Parse_ReturnsIEnumerableTokens_WhenTextContainsLinkTag(string input, string expectedResult)
+ {
+ var result = markdown.Render(input);
+
+ result.Should().Be(expectedResult);
+ }
+
+ public static IEnumerable CasesWhenTextWithOnePairedTag()
+ {
+ yield return new TestCaseData("_wordA wordB_", "wordA wordB");
+ yield return new TestCaseData("__wordA wordB__", "wordA wordB");
+ yield return new TestCaseData("wordA _wordB wordC_ wordD", "wordA wordB wordC wordD");
+ yield return new TestCaseData("wordA __wordB wordC__ wordD", "wordA wordB wordC wordD");
+ }
+
+ public static IEnumerable CasesWhenTextWithMultipleNonNestedPairedTags()
+ {
+ yield return new TestCaseData("_wordA_ __wordB__ wordC", "wordA wordB wordC");
+ }
+
+ public static IEnumerable CasesWhenTextWithMultipleNestedPairedTags()
+ {
+ yield return new TestCaseData("__wordA _wordB_ wordC__", "wordA wordB wordC");
+ }
+
+ public static IEnumerable CasesWhenTextWithPairedTagWithoutPair()
+ {
+ yield return new TestCaseData("_wordA", "_wordA");
+ yield return new TestCaseData("__wordA", "__wordA");
+ }
+
+ public static IEnumerable CasesWhenEmptyTextInsideTags()
+ {
+ yield return new TestCaseData("__", "__");
+ yield return new TestCaseData("____", "____");
+ }
+
+ public static IEnumerable CasesTextContainsHeaderTag()
+ {
+ yield return new TestCaseData("# wordA", "wordA
");
+ yield return new TestCaseData("# wordA # ", "wordA #
");
+ yield return new TestCaseData(" # wordA", " # wordA");
+ yield return new TestCaseData($"# wordA{Environment.NewLine} # ", $"wordA
{Environment.NewLine} # ");
+ yield return new TestCaseData($" wordA{Environment.NewLine}{Environment.NewLine}# wordB", $" wordA{Environment.NewLine}{Environment.NewLine}wordB
");
+ }
+
+ public static IEnumerable CasesWhenTextContainsEscapingTag()
+ {
+ yield return new TestCaseData(@"\\", @"\\");
+ yield return new TestCaseData(@"\_wordA_", @"\_wordA_");
+ }
+
+ public static IEnumerable CasesWhenTextContainsOverlappingTags()
+ {
+ yield return new TestCaseData("__wordA_wordB__wordC_", "__wordA_wordB__wordC_");
+ yield return new TestCaseData("_wordA__wordB_wordC__ wordD", "_wordA__wordB_wordC__ wordD");
+ }
+
+ public static IEnumerable CasesWhenBoldTagInsideItalicTag()
+ {
+ yield return new TestCaseData("_wordA__wordB__wordC_", "wordA__wordB__wordC");
+ }
+
+ public static IEnumerable CasesWhenItalicTagInsideBoldTag()
+ {
+ yield return new TestCaseData("__wordA_wordB_wordC__", "wordAwordBwordC");
+ }
+
+ public static IEnumerable CasesWhenTextWithNumbersAndContainsBoldItalicTags()
+ {
+ yield return new TestCaseData("wo__rd1__", "wo__rd1__");
+ yield return new TestCaseData("wo_rd1_", "wo_rd1_");
+ }
+
+ public static IEnumerable CasesWhenTextWithWhiteSpaceAndContainsBoldItalicTags()
+ {
+ yield return new TestCaseData("_wordA _", "_wordA _");
+ yield return new TestCaseData("__wordA __", "__wordA __");
+ yield return new TestCaseData("_ wordA_", "_ wordA_");
+ yield return new TestCaseData("__ wordA__", "__ wordA__");
+ }
+
+ public static IEnumerable CasesWhenTextContainsBoldItalicTagsInMiddleWords()
+ {
+ yield return new TestCaseData("_wor_dA", "wordA");
+ yield return new TestCaseData("__wor__dA", "wordA");
+ yield return new TestCaseData("_wordA wor_dB", "_wordA wor_dB");
+ yield return new TestCaseData("__wordA wor__dB", "__wordA wor__dB");
+ }
+ public static IEnumerable CasesWhenTextContainsLinkTag()
+ {
+ yield return new TestCaseData("[Name Link](https://www.example.com \"Tooltip\")",
+ "Name Link");
+ yield return new TestCaseData("[Name Link](https://www.example.com Tooltip\")",
+ "Name Link");
+ yield return new TestCaseData("Name Link](https://www.example.com Tooltip\")",
+ "Name Link](https://www.example.com Tooltip\")");
+ yield return new TestCaseData("[Name Link(https://www.example.com Tooltip\")",
+ "[Name Link(https://www.example.com Tooltip\")");
+ yield return new TestCaseData("[Name Link]https://www.example.com Tooltip\")",
+ "[Name Link]https://www.example.com Tooltip\")");
+ yield return new TestCaseData("[Name Link](https://www.example.com Tooltip\"",
+ "[Name Link](https://www.example.com Tooltip\"");
+ }
+
+}
\ No newline at end of file
diff --git a/cs/Markdown/Token/Token.cs b/cs/Markdown/Token/Token.cs
new file mode 100644
index 000000000..2920fbb49
--- /dev/null
+++ b/cs/Markdown/Token/Token.cs
@@ -0,0 +1,10 @@
+namespace Markdown;
+
+public class Token(TagType tagType, string content, List? children = null)
+{
+ public TagType TagType { get; } = tagType;
+
+ public string Content { get; } = content;
+
+ public List? Children { get; } = children;
+}
\ No newline at end of file
diff --git a/cs/Markdown/Token/TokenTagLink.cs b/cs/Markdown/Token/TokenTagLink.cs
new file mode 100644
index 000000000..aef7c48a5
--- /dev/null
+++ b/cs/Markdown/Token/TokenTagLink.cs
@@ -0,0 +1,7 @@
+namespace Markdown;
+
+public class TokenTagLink(string content, string linkText, string? tooltipText = null, List? children = null) : Token(TagType.Link, content, children)
+{
+ public string? TooltipText { get; } = tooltipText;
+ public string LinkText { get; } = linkText;
+}
\ No newline at end of file
diff --git a/cs/clean-code.sln b/cs/clean-code.sln
index 2206d54db..9e2352620 100644
--- a/cs/clean-code.sln
+++ b/cs/clean-code.sln
@@ -9,6 +9,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ControlDigit", "ControlDigi
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Samples", "Samples\Samples.csproj", "{C3EF41D7-50EF-4CE1-B30A-D1D81C93D7FA}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Markdown", "Markdown\Markdown.csproj", "{8794FD77-5E36-4990-A3CC-AB363934F2CC}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BenchmarkMarkdown", "BenchmarkMarkdown\BenchmarkMarkdown.csproj", "{A85DA5D4-13A8-439F-A81A-CB37962627DC}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -27,5 +31,13 @@ Global
{C3EF41D7-50EF-4CE1-B30A-D1D81C93D7FA}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C3EF41D7-50EF-4CE1-B30A-D1D81C93D7FA}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C3EF41D7-50EF-4CE1-B30A-D1D81C93D7FA}.Release|Any CPU.Build.0 = Release|Any CPU
+ {8794FD77-5E36-4990-A3CC-AB363934F2CC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {8794FD77-5E36-4990-A3CC-AB363934F2CC}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {8794FD77-5E36-4990-A3CC-AB363934F2CC}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {8794FD77-5E36-4990-A3CC-AB363934F2CC}.Release|Any CPU.Build.0 = Release|Any CPU
+ {A85DA5D4-13A8-439F-A81A-CB37962627DC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {A85DA5D4-13A8-439F-A81A-CB37962627DC}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {A85DA5D4-13A8-439F-A81A-CB37962627DC}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {A85DA5D4-13A8-439F-A81A-CB37962627DC}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal
diff --git a/cs/clean-code.sln.DotSettings b/cs/clean-code.sln.DotSettings
index 135b83ecb..53fe49b2f 100644
--- a/cs/clean-code.sln.DotSettings
+++ b/cs/clean-code.sln.DotSettings
@@ -1,6 +1,9 @@
<Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" />
<Policy Inspect="True" Prefix="" Suffix="" Style="AaBb_AaBb" />
+ <Policy><Descriptor Staticness="Instance" AccessRightKinds="Private" Description="Instance fields (private)"><ElementKinds><Kind Name="FIELD" /><Kind Name="READONLY_FIELD" /></ElementKinds></Descriptor><Policy Inspect="True" WarnAboutPrefixesAndSuffixes="False" Prefix="" Suffix="" Style="aaBb" /></Policy>
+ <Policy><Descriptor Staticness="Any" AccessRightKinds="Any" Description="Types and namespaces"><ElementKinds><Kind Name="NAMESPACE" /><Kind Name="CLASS" /><Kind Name="STRUCT" /><Kind Name="ENUM" /><Kind Name="DELEGATE" /></ElementKinds></Descriptor><Policy Inspect="True" WarnAboutPrefixesAndSuffixes="False" Prefix="" Suffix="" Style="AaBb_AaBb" /></Policy>
+ True
True
True
Imported 10.10.2016