diff --git a/MarkdownSpec.md b/MarkdownSpec.md
index 886e99c95..4fb374e67 100644
--- a/MarkdownSpec.md
+++ b/MarkdownSpec.md
@@ -70,4 +70,57 @@ __Непарные_ символы в рамках одного абзаца н
превратится в:
-\
Заголовок \с \разными\ символами\\
\ No newline at end of file
+\Заголовок \с \разными\ символами\\
+
+
+
+# Ссылки
+
+Текст, заключенный в квадратные скобки [], с последующим URL в круглых скобках (), должен выделяться тегом \ со следующим синтаксисом:
+
+\[Текст ссылки](URL)
+
+Пример:
+
+Это текст с \[ссылкой](http://link.com)
+
+Будет преобразовано в:
+
+Это текст с \ссылкой \
+
+
+Внутри текста ссылки допускается использование других элементов разметки:
+
+Это \[ссылка с \_курсивом\_](http://link.com)
+
+Будет преобразовано в:
+
+Это \ссылка с \курсивом\\
+
+
+Квадратные и круглые скобки, также как и другие специальные символы внутри текста ссылки можно экранировать:
+
+\[Ссылка с экранированными \\\] символами\\\]\](http://link.com)
+
+Будет преобразовано в:
+
+\Ссылка с экранированными ] символами]\
+
+
+Если текст ссылки или URL не завершен, разметка остается неизменной.
+
+Примеры:
+
+\[незавершённая ссылка(http://link.com)
+
+\[незавершённая ссылка](http://link.com
+
+Ссылки могут быть вложены в другие элементы разметки, такие как заголовки, курсив или полужирный текст с теми же правилами, что и остальные теги
+
+# Пример сложной вложенности с ссылками:
+
+\# h \_\_E \_e_ \[ссылка](http://link.com) E__ \_e_
+
+Будет преобразовано в:
+
+\h \E \e\ \ссылка \ E\ \e\\
\ No newline at end of file
diff --git a/cs/Markdown/Interfaces/IMarkdownParser.cs b/cs/Markdown/Interfaces/IMarkdownParser.cs
new file mode 100644
index 000000000..3fa469746
--- /dev/null
+++ b/cs/Markdown/Interfaces/IMarkdownParser.cs
@@ -0,0 +1,8 @@
+using Markdown.Tokens;
+
+namespace Markdown.Interfaces;
+
+public interface IMarkdownParser
+{
+ IEnumerable ParseTokens(string markdownText);
+}
\ No newline at end of file
diff --git a/cs/Markdown/Interfaces/IRenderer.cs b/cs/Markdown/Interfaces/IRenderer.cs
new file mode 100644
index 000000000..5fc2c21e6
--- /dev/null
+++ b/cs/Markdown/Interfaces/IRenderer.cs
@@ -0,0 +1,8 @@
+using Markdown.Tokens;
+
+namespace Markdown.Interfaces;
+
+public interface IRenderer
+{
+ string Render(IEnumerable tokens);
+}
\ No newline at end of file
diff --git a/cs/Markdown/Interfaces/ITokenConverter.cs b/cs/Markdown/Interfaces/ITokenConverter.cs
new file mode 100644
index 000000000..1d4ce4595
--- /dev/null
+++ b/cs/Markdown/Interfaces/ITokenConverter.cs
@@ -0,0 +1,9 @@
+using System.Text;
+using Markdown.Tokens;
+
+namespace Markdown.Interfaces;
+
+public interface ITokenConverter
+{
+ void Render(BaseToken baseToken, StringBuilder result);
+}
\ No newline at end of file
diff --git a/cs/Markdown/Interfaces/ITokenHandler.cs b/cs/Markdown/Interfaces/ITokenHandler.cs
new file mode 100644
index 000000000..4cf06f66f
--- /dev/null
+++ b/cs/Markdown/Interfaces/ITokenHandler.cs
@@ -0,0 +1,9 @@
+using Markdown.Parsers;
+
+namespace Markdown.Interfaces;
+
+public interface ITokenHandler
+{
+ bool CanHandle(char current, char next, MarkdownParseContext context);
+ void Handle(MarkdownParseContext context);
+}
\ No newline at end of file
diff --git a/cs/Markdown/Markdown.csproj b/cs/Markdown/Markdown.csproj
new file mode 100644
index 000000000..3a6353295
--- /dev/null
+++ b/cs/Markdown/Markdown.csproj
@@ -0,0 +1,9 @@
+
+
+
+ net8.0
+ enable
+ enable
+
+
+
diff --git a/cs/Markdown/Md.cs b/cs/Markdown/Md.cs
new file mode 100644
index 000000000..b45ad260f
--- /dev/null
+++ b/cs/Markdown/Md.cs
@@ -0,0 +1,21 @@
+using Markdown.Interfaces;
+
+namespace Markdown;
+
+public class Md
+{
+ private readonly IRenderer renderer;
+ private readonly IMarkdownParser parser;
+
+ public Md(IRenderer renderer, IMarkdownParser parser)
+ {
+ this.renderer = renderer;
+ this.parser = parser;
+ }
+
+ public string Render(string markdownText)
+ {
+ var tokens = parser.ParseTokens(markdownText);
+ return renderer.Render(tokens);
+ }
+}
\ No newline at end of file
diff --git a/cs/Markdown/Parsers/MarkdownParser.cs b/cs/Markdown/Parsers/MarkdownParser.cs
new file mode 100644
index 000000000..16bfe1e5c
--- /dev/null
+++ b/cs/Markdown/Parsers/MarkdownParser.cs
@@ -0,0 +1,63 @@
+using Markdown.Interfaces;
+using Markdown.TokenHandlers;
+using Markdown.Tokens;
+
+namespace Markdown.Parsers;
+
+public class MarkdownParser : IMarkdownParser
+{
+ private readonly List handlers =
+ [
+ new StrongTokenHandler(),
+ new HeaderTokenHandler(),
+ new EmphasisTokenHandler(),
+ new NewLineHandler(),
+ new EscapeCharacterHandler(),
+ new LinkTokenHandler()
+ ];
+
+ public IEnumerable ParseTokens(string markdownText)
+ {
+ ArgumentNullException.ThrowIfNull(markdownText);
+
+ var context = new MarkdownParseContext
+ {
+ MarkdownText = markdownText,
+ Parser = this
+ };
+
+ while (context.CurrentIndex < context.MarkdownText.Length)
+ {
+ var current = context.MarkdownText[context.CurrentIndex];
+ var next = context.CurrentIndex + 1 < context.MarkdownText.Length
+ ? context.MarkdownText[context.CurrentIndex + 1]
+ : '\0';
+
+ var handler = handlers.FirstOrDefault(h => h.CanHandle(current, next, context));
+ if (handler != null)
+ {
+ handler.Handle(context);
+ }
+ else
+ {
+ context.Buffer.Append(current);
+ context.CurrentIndex++;
+ }
+ }
+
+ AddToken(context, TokenType.Text);
+ return context.Tokens;
+ }
+
+ public static void AddToken(MarkdownParseContext context, TokenType type)
+ {
+ if (context.Buffer.Length == 0) return;
+ var token = new BaseToken(type, context.Buffer.ToString());
+ context.Buffer.Clear();
+
+ if (context.Stack.Count > 0)
+ context.Stack.Peek().Children.Add(token);
+ else
+ context.Tokens.Add(token);
+ }
+}
\ No newline at end of file
diff --git a/cs/Markdown/Parsers/MarkdownParserContext.cs b/cs/Markdown/Parsers/MarkdownParserContext.cs
new file mode 100644
index 000000000..6b696352e
--- /dev/null
+++ b/cs/Markdown/Parsers/MarkdownParserContext.cs
@@ -0,0 +1,17 @@
+using System.Text;
+using Markdown.Interfaces;
+using Markdown.Tokens;
+
+namespace Markdown.Parsers;
+
+public class MarkdownParseContext
+{
+ public Stack Stack { get; } = new();
+ public StringBuilder Buffer { get; } = new();
+ public List Tokens { get; } = [];
+ public List IntersectedIndexes { get; } = [];
+ public string MarkdownText { get; init; } = "";
+ public int CurrentIndex { get; set; }
+ public int HeaderLevel { get; set; }
+ public required IMarkdownParser Parser { get; init; }
+}
\ No newline at end of file
diff --git a/cs/Markdown/Renderers/HtmlRenderer.cs b/cs/Markdown/Renderers/HtmlRenderer.cs
new file mode 100644
index 000000000..133442a8f
--- /dev/null
+++ b/cs/Markdown/Renderers/HtmlRenderer.cs
@@ -0,0 +1,22 @@
+using System.Text;
+using Markdown.Interfaces;
+using Markdown.TokenConverters;
+using Markdown.Tokens;
+
+namespace Markdown.Renderers;
+
+public class HtmlRenderer : IRenderer
+{
+ public string Render(IEnumerable tokens)
+ {
+ var result = new StringBuilder();
+ foreach (var token in tokens)
+ {
+ var converter = TokenConverterFactory.GetConverter(token.Type);
+ converter.Render(token, result);
+ }
+ var text = result.ToString();
+ text = TagReplacer.SimilarTagsNester(text);
+ return text;
+ }
+}
\ No newline at end of file
diff --git a/cs/Markdown/Renderers/SimilarTagsNester.cs b/cs/Markdown/Renderers/SimilarTagsNester.cs
new file mode 100644
index 000000000..b9d5b485f
--- /dev/null
+++ b/cs/Markdown/Renderers/SimilarTagsNester.cs
@@ -0,0 +1,15 @@
+using Markdown.TokenConverters;
+
+namespace Markdown.Renderers;
+
+public static class TagReplacer
+{
+ public static string SimilarTagsNester(string text)
+ {
+ foreach (var pair in TokenConverterFactory.GetTagPairs())
+ {
+ text = text.Replace(pair.Key, pair.Value);
+ }
+ return text;
+ }
+}
\ No newline at end of file
diff --git a/cs/Markdown/TokenConverters/EmphasisConverter.cs b/cs/Markdown/TokenConverters/EmphasisConverter.cs
new file mode 100644
index 000000000..c50bbd139
--- /dev/null
+++ b/cs/Markdown/TokenConverters/EmphasisConverter.cs
@@ -0,0 +1,14 @@
+using System.Text;
+using Markdown.Tokens;
+
+namespace Markdown.TokenConverters;
+
+public class EmphasisConverter : TokenConverterBase
+{
+ public override void Render(BaseToken baseToken, StringBuilder result)
+ {
+ result.Append("");
+ RenderChildren(baseToken, result);
+ result.Append("");
+ }
+}
\ No newline at end of file
diff --git a/cs/Markdown/TokenConverters/HeaderConverter.cs b/cs/Markdown/TokenConverters/HeaderConverter.cs
new file mode 100644
index 000000000..ee6e212de
--- /dev/null
+++ b/cs/Markdown/TokenConverters/HeaderConverter.cs
@@ -0,0 +1,15 @@
+using System.Text;
+using Markdown.Tokens;
+
+namespace Markdown.TokenConverters;
+
+public class HeaderConverter : TokenConverterBase
+{
+ public override void Render(BaseToken baseToken, StringBuilder result)
+ {
+ var level = baseToken.HeaderLevel;
+ result.Append($"");
+ RenderChildren(baseToken, result);
+ result.Append($"");
+ }
+}
\ No newline at end of file
diff --git a/cs/Markdown/TokenConverters/LinkConverter.cs b/cs/Markdown/TokenConverters/LinkConverter.cs
new file mode 100644
index 000000000..81130430c
--- /dev/null
+++ b/cs/Markdown/TokenConverters/LinkConverter.cs
@@ -0,0 +1,16 @@
+using System.Text;
+using Markdown.Tokens;
+
+namespace Markdown.TokenConverters;
+
+public class LinkConverter : TokenConverterBase
+{
+ public override void Render(BaseToken token, StringBuilder result)
+ {
+ var linkToken = (LinkToken)token;
+
+ result.Append($"");
+ RenderChildren(linkToken, result);
+ result.Append("");
+ }
+}
\ No newline at end of file
diff --git a/cs/Markdown/TokenConverters/StrongConverter.cs b/cs/Markdown/TokenConverters/StrongConverter.cs
new file mode 100644
index 000000000..cee93ecfc
--- /dev/null
+++ b/cs/Markdown/TokenConverters/StrongConverter.cs
@@ -0,0 +1,14 @@
+using System.Text;
+using Markdown.Tokens;
+
+namespace Markdown.TokenConverters;
+
+public class StrongConverter : TokenConverterBase
+{
+ public override void Render(BaseToken baseToken, StringBuilder result)
+ {
+ result.Append("");
+ RenderChildren(baseToken, result);
+ result.Append("");
+ }
+}
\ No newline at end of file
diff --git a/cs/Markdown/TokenConverters/TextConverter.cs b/cs/Markdown/TokenConverters/TextConverter.cs
new file mode 100644
index 000000000..e40fecd09
--- /dev/null
+++ b/cs/Markdown/TokenConverters/TextConverter.cs
@@ -0,0 +1,13 @@
+using System.Text;
+using Markdown.Interfaces;
+using Markdown.Tokens;
+
+namespace Markdown.TokenConverters;
+
+public class TextConverter : ITokenConverter
+{
+ public void Render(BaseToken baseToken, StringBuilder result)
+ {
+ result.Append(baseToken.Content);
+ }
+}
\ No newline at end of file
diff --git a/cs/Markdown/TokenConverters/TokenConverterBase.cs b/cs/Markdown/TokenConverters/TokenConverterBase.cs
new file mode 100644
index 000000000..f5a54faa3
--- /dev/null
+++ b/cs/Markdown/TokenConverters/TokenConverterBase.cs
@@ -0,0 +1,19 @@
+using System.Text;
+using Markdown.Interfaces;
+using Markdown.Tokens;
+
+namespace Markdown.TokenConverters;
+
+public abstract class TokenConverterBase : ITokenConverter
+{
+ public abstract void Render(BaseToken baseToken, StringBuilder result);
+
+ protected static void RenderChildren(BaseToken baseToken, StringBuilder result)
+ {
+ foreach (var child in baseToken.Children)
+ {
+ var converter = TokenConverterFactory.GetConverter(child.Type);
+ converter.Render(child, result);
+ }
+ }
+}
\ No newline at end of file
diff --git a/cs/Markdown/TokenConverters/TokenConverterFactory.cs b/cs/Markdown/TokenConverters/TokenConverterFactory.cs
new file mode 100644
index 000000000..9b635f984
--- /dev/null
+++ b/cs/Markdown/TokenConverters/TokenConverterFactory.cs
@@ -0,0 +1,38 @@
+using Markdown.Interfaces;
+using Markdown.Tokens;
+
+namespace Markdown.TokenConverters;
+
+public static class TokenConverterFactory
+{
+ private static readonly Dictionary TagPairs;
+
+ static TokenConverterFactory()
+ {
+ TagPairs = new Dictionary
+ {
+ { " ", " " },
+ { " ", " " },
+ { " ", " " },
+ { " ", " " }
+ };
+ }
+
+ public static ITokenConverter GetConverter(TokenType type)
+ {
+ return type switch
+ {
+ TokenType.Text => new TextConverter(),
+ TokenType.Emphasis => new EmphasisConverter(),
+ TokenType.Strong => new StrongConverter(),
+ TokenType.Header => new HeaderConverter(),
+ TokenType.Link => new LinkConverter(),
+ _ => throw new ArgumentOutOfRangeException()
+ };
+ }
+
+ public static Dictionary GetTagPairs()
+ {
+ return TagPairs;
+ }
+}
\ No newline at end of file
diff --git a/cs/Markdown/TokenHandlers/BoundaryTokenHandler.cs b/cs/Markdown/TokenHandlers/BoundaryTokenHandler.cs
new file mode 100644
index 000000000..9c04ea602
--- /dev/null
+++ b/cs/Markdown/TokenHandlers/BoundaryTokenHandler.cs
@@ -0,0 +1,157 @@
+using Markdown.Interfaces;
+using Markdown.Parsers;
+using Markdown.Tokens;
+
+namespace Markdown.TokenHandlers;
+
+public abstract class BoundaryTokenHandler : ITokenHandler
+{
+ protected abstract string Delimiter { get; }
+ protected abstract TokenType TokenType { get; }
+
+ public abstract bool CanHandle(char current, char next, MarkdownParseContext context);
+
+ public void Handle(MarkdownParseContext context)
+ {
+ if (IsValidBoundary(context))
+ {
+ HandleTokenBoundary(context);
+ }
+ else
+ {
+ context.Buffer.Append(Delimiter);
+ context.CurrentIndex += Delimiter.Length;
+ }
+ }
+
+ private bool IsValidBoundary(MarkdownParseContext context)
+ {
+ var index = context.CurrentIndex;
+ var text = context.MarkdownText;
+ var nextDelimiter = TokenType == TokenType.Strong ? "_" : "__";
+ if (context.IntersectedIndexes.Contains(index))
+ return false;
+
+ if (context.Stack.Count > 0)
+ {
+ if (context.Stack.Peek().Type == TokenType.Emphasis && TokenType == TokenType.Strong)
+ {
+ return false;
+ }
+
+ if (context.Stack.Count > 2)
+ {
+ return false;
+ }
+
+ if (context.Buffer.Length == 0)
+ return false;
+
+ if (index == 0 || index == text.Length - 1)
+ return true;
+
+ var spaceIndex = text.IndexOf(' ', index + Delimiter.Length);
+ if (spaceIndex == -1)
+ return true;
+
+ return !char.IsLetterOrDigit(text[index - 1]) ||
+ !char.IsLetterOrDigit(text[index + 1]);
+ }
+
+ var paragraphEndIndex = text.IndexOfAny(['\n', '\r'], index);
+ if (paragraphEndIndex == -1)
+ {
+ paragraphEndIndex = text.Length;
+ }
+
+ var closingIndex = FindSingleDelimiter(text,
+ index + Delimiter.Length, paragraphEndIndex, Delimiter);
+ var anotherOpenIndex = FindSingleDelimiter(text,
+ index + Delimiter.Length, paragraphEndIndex, nextDelimiter);
+ var anotherClosingIndex = FindSingleDelimiter(text,
+ anotherOpenIndex + nextDelimiter.Length, paragraphEndIndex, nextDelimiter);
+
+ if (anotherOpenIndex < closingIndex && anotherClosingIndex > closingIndex)
+ {
+ context.IntersectedIndexes.Add(index);
+ context.IntersectedIndexes.Add(closingIndex);
+ context.IntersectedIndexes.Add(anotherOpenIndex);
+ context.IntersectedIndexes.Add(anotherClosingIndex);
+ return false;
+ }
+
+ if (closingIndex == -1)
+ return false;
+
+ var isInsideWord = (index > 0 && char.IsLetterOrDigit(text[index - 1])) ||
+ (closingIndex + Delimiter.Length < paragraphEndIndex &&
+ char.IsLetterOrDigit(text[closingIndex + Delimiter.Length]));
+ if (isInsideWord)
+ {
+ if (index > 0 &&
+ (char.IsDigit(text[index - 1]) ||
+ char.IsDigit(text[index + 1])) &&
+ closingIndex + Delimiter.Length < paragraphEndIndex &&
+ (char.IsDigit(text[closingIndex - 1]) ||
+ char.IsDigit(text[closingIndex + Delimiter.Length])))
+ return false;
+
+ var spaceIndex = text.IndexOf(' ', index + Delimiter.Length);
+
+ return spaceIndex == -1 || closingIndex < spaceIndex;
+ }
+
+ if (closingIndex - index <= Delimiter.Length)
+ return false;
+
+ return index + 1 != closingIndex;
+ }
+
+ private void HandleTokenBoundary(MarkdownParseContext context)
+ {
+ MarkdownParser.AddToken(context, TokenType.Text);
+
+ if (context.Stack.Count > 0 && context.Stack.Peek().Type == TokenType)
+ {
+ var completedToken = context.Stack.Pop();
+
+ completedToken.Content = completedToken.Children.Count > 0 ?
+ string.Empty : completedToken.Content;
+ context.Buffer.Clear();
+
+ if (context.Stack.Count > 0)
+ context.Stack.Peek().Children.Add(completedToken);
+ else
+ context.Tokens.Add(completedToken);
+ }
+ else
+ {
+ var newToken = new BaseToken(TokenType);
+ context.Stack.Push(newToken);
+ }
+
+ context.CurrentIndex += Delimiter.Length;
+ }
+
+ private static int FindSingleDelimiter(string text, int startIndex, int paragraphEndIndex, string delimiter)
+ {
+ var index = text.IndexOf(delimiter, startIndex, StringComparison.Ordinal);
+
+ while (index != -1 && index < paragraphEndIndex)
+ {
+ if (index > 0 && text[index - 1] == '_')
+ {
+ index = text.IndexOf(delimiter, index + 1, StringComparison.Ordinal);
+ continue;
+ }
+
+ if (index + delimiter.Length < text.Length && text[index + delimiter.Length] == '_')
+ {
+ index = text.IndexOf(delimiter, index + 2, StringComparison.Ordinal);
+ continue;
+ }
+ return index;
+ }
+ return -1;
+ }
+}
\ No newline at end of file
diff --git a/cs/Markdown/TokenHandlers/EmphasisTokenHandler.cs b/cs/Markdown/TokenHandlers/EmphasisTokenHandler.cs
new file mode 100644
index 000000000..93668d4ba
--- /dev/null
+++ b/cs/Markdown/TokenHandlers/EmphasisTokenHandler.cs
@@ -0,0 +1,13 @@
+using Markdown.Parsers;
+using Markdown.Tokens;
+
+namespace Markdown.TokenHandlers;
+
+public class EmphasisTokenHandler : BoundaryTokenHandler
+{
+ protected override string Delimiter => "_";
+ protected override TokenType TokenType => TokenType.Emphasis;
+
+ public override bool CanHandle(char current, char next, MarkdownParseContext context)
+ => current == '_' && next != '_';
+}
\ No newline at end of file
diff --git a/cs/Markdown/TokenHandlers/EscapeCharacterHandler.cs b/cs/Markdown/TokenHandlers/EscapeCharacterHandler.cs
new file mode 100644
index 000000000..1d2f2e213
--- /dev/null
+++ b/cs/Markdown/TokenHandlers/EscapeCharacterHandler.cs
@@ -0,0 +1,28 @@
+using Markdown.Interfaces;
+using Markdown.Parsers;
+
+namespace Markdown.TokenHandlers;
+
+public class EscapeCharacterHandler : ITokenHandler
+{
+ public bool CanHandle(char current, char next, MarkdownParseContext context)
+ => current == '\\';
+
+ public void Handle(MarkdownParseContext context)
+ {
+
+ if (context.CurrentIndex + 1 < context.MarkdownText.Length)
+ {
+ var next = context.MarkdownText[context.CurrentIndex + 1];
+ if (next is '_' or '#' or '\\' or ']' or '(')
+ {
+ if (next != '\\')
+ context.Buffer.Append(next);
+ context.CurrentIndex += 2;
+ return;
+ }
+ }
+ context.Buffer.Append('\\');
+ context.CurrentIndex++;
+ }
+}
\ No newline at end of file
diff --git a/cs/Markdown/TokenHandlers/HeaderTokenHandler.cs b/cs/Markdown/TokenHandlers/HeaderTokenHandler.cs
new file mode 100644
index 000000000..6cd628abd
--- /dev/null
+++ b/cs/Markdown/TokenHandlers/HeaderTokenHandler.cs
@@ -0,0 +1,53 @@
+using Markdown.Interfaces;
+using Markdown.Parsers;
+using Markdown.Tokens;
+
+namespace Markdown.TokenHandlers;
+
+public class HeaderTokenHandler : ITokenHandler
+{
+ public bool CanHandle(char current, char next, MarkdownParseContext context)
+ => current == '#' && (context.CurrentIndex == 0 ||
+ context.MarkdownText[context.CurrentIndex - 1] == '\n');
+
+ public void Handle(MarkdownParseContext context)
+ {
+ while (context.CurrentIndex < context.MarkdownText.Length &&
+ context.MarkdownText[context.CurrentIndex] == '#')
+ {
+ context.HeaderLevel++;
+ context.CurrentIndex++;
+ }
+
+ if (context.CurrentIndex < context.MarkdownText.Length &&
+ context.MarkdownText[context.CurrentIndex] == ' ')
+ {
+ context.CurrentIndex++;
+
+ MarkdownParser.AddToken(context, TokenType.Text);
+ var headerToken = new BaseToken(TokenType.Header)
+ {
+ HeaderLevel = context.HeaderLevel
+ };
+
+ context.Tokens.Add(headerToken);
+
+ var headerEnd = context.MarkdownText.IndexOf('\n', context.CurrentIndex);
+ if (headerEnd == -1)
+ headerEnd = context.MarkdownText.Length;
+
+ var headerContent = context.Parser
+ .ParseTokens(context.MarkdownText[context.CurrentIndex..headerEnd]);
+
+ foreach (var childToken in headerContent)
+ {
+ headerToken.Children.Add(childToken);
+ }
+ context.CurrentIndex = headerEnd;
+ }
+ else
+ {
+ context.Buffer.Append('#', context.HeaderLevel);
+ }
+ }
+}
\ No newline at end of file
diff --git a/cs/Markdown/TokenHandlers/LinkTokenHandler.cs b/cs/Markdown/TokenHandlers/LinkTokenHandler.cs
new file mode 100644
index 000000000..6c8830648
--- /dev/null
+++ b/cs/Markdown/TokenHandlers/LinkTokenHandler.cs
@@ -0,0 +1,72 @@
+using Markdown.Interfaces;
+using Markdown.Parsers;
+using Markdown.Tokens;
+
+namespace Markdown.TokenHandlers;
+
+public class LinkTokenHandler : ITokenHandler
+{
+ public bool CanHandle(char current, char next, MarkdownParseContext context)
+ => current == '[';
+
+ public void Handle(MarkdownParseContext context)
+ {
+ MarkdownParser.AddToken(context, TokenType.Text);
+
+ var startIndex = context.CurrentIndex;
+ var endIndex = FindClosingBracket(context.MarkdownText, startIndex);
+ if (endIndex == -1)
+ {
+ context.Buffer.Append(context.MarkdownText[startIndex]);
+ context.CurrentIndex++;
+ return;
+ }
+
+ var linkStartIndex = context.MarkdownText.IndexOf('(', endIndex);
+ var linkEndIndex = context.MarkdownText.IndexOf(')', linkStartIndex);
+ if (linkStartIndex == -1 || linkEndIndex == -1)
+ {
+ context.Buffer.Append(context.MarkdownText[startIndex]);
+ context.CurrentIndex++;
+ return;
+ }
+
+ var labelText = context.MarkdownText.Substring(startIndex + 1, endIndex - startIndex - 1);
+ var url = context.MarkdownText.Substring(linkStartIndex + 1, linkEndIndex - linkStartIndex - 1);
+
+ var labelTokens = context.Parser.ParseTokens(labelText);
+
+ var linkToken = new LinkToken(labelTokens, url);
+ if (context.Stack.Count > 0)
+ {
+ context.Stack.Peek().Children.Add(linkToken);
+ }
+ else
+ {
+ context.Tokens.Add(linkToken);
+ }
+
+ context.CurrentIndex = linkEndIndex + 1;
+ }
+
+ private static int FindClosingBracket(string text, int startIndex)
+ {
+ var depth = 0;
+ for (var i = startIndex; i < text.Length; i++)
+ {
+ if (text[i] == '\\')
+ {
+ i++;
+ continue;
+ }
+ if (text[i] == '[')
+ depth++;
+ else if (text[i] == ']')
+ depth--;
+
+ if (depth == 0)
+ return i;
+ }
+ return -1;
+ }
+}
\ No newline at end of file
diff --git a/cs/Markdown/TokenHandlers/NewLineHandler.cs b/cs/Markdown/TokenHandlers/NewLineHandler.cs
new file mode 100644
index 000000000..7ba0ba737
--- /dev/null
+++ b/cs/Markdown/TokenHandlers/NewLineHandler.cs
@@ -0,0 +1,19 @@
+using Markdown.Interfaces;
+using Markdown.Parsers;
+using Markdown.Tokens;
+
+namespace Markdown.TokenHandlers;
+
+public class NewLineHandler : ITokenHandler
+{
+ public bool CanHandle(char current, char next, MarkdownParseContext context)
+ => current == '\n' && context.Stack.Count > 0 && context.Stack.Peek().Type == TokenType.Header;
+
+ public void Handle(MarkdownParseContext context)
+ {
+ MarkdownParser.AddToken(context, TokenType.Text);
+
+ context.Tokens.Add(context.Stack.Pop());
+ context.CurrentIndex++;
+ }
+}
\ No newline at end of file
diff --git a/cs/Markdown/TokenHandlers/StrongTokenHandler.cs b/cs/Markdown/TokenHandlers/StrongTokenHandler.cs
new file mode 100644
index 000000000..4feab52bb
--- /dev/null
+++ b/cs/Markdown/TokenHandlers/StrongTokenHandler.cs
@@ -0,0 +1,13 @@
+using Markdown.Parsers;
+using Markdown.Tokens;
+
+namespace Markdown.TokenHandlers;
+
+public class StrongTokenHandler : BoundaryTokenHandler
+{
+ protected override string Delimiter => "__";
+ protected override TokenType TokenType => TokenType.Strong;
+
+ public override bool CanHandle(char current, char next, MarkdownParseContext context)
+ => current == '_' && next == '_';
+}
\ No newline at end of file
diff --git a/cs/Markdown/Tokens/BaseToken.cs b/cs/Markdown/Tokens/BaseToken.cs
new file mode 100644
index 000000000..7223dc66e
--- /dev/null
+++ b/cs/Markdown/Tokens/BaseToken.cs
@@ -0,0 +1,21 @@
+namespace Markdown.Tokens;
+
+public class BaseToken
+{
+ public TokenType Type { get; }
+ public string Content { get; set; }
+ public List Children { get; init; }
+ public int HeaderLevel { get; init; }
+
+ public BaseToken(TokenType type, string content, List? children = null)
+ {
+ Type = type;
+ Content = content;
+ Children = children ?? [];
+ HeaderLevel = 1;
+ }
+
+ public BaseToken(TokenType type) : this(type, string.Empty) { }
+ public BaseToken(TokenType type, List? children = null)
+ : this(type, string.Empty, children) { }
+}
\ No newline at end of file
diff --git a/cs/Markdown/Tokens/LinkToken.cs b/cs/Markdown/Tokens/LinkToken.cs
new file mode 100644
index 000000000..1036d80f7
--- /dev/null
+++ b/cs/Markdown/Tokens/LinkToken.cs
@@ -0,0 +1,13 @@
+namespace Markdown.Tokens;
+
+public class LinkToken : BaseToken
+{
+ public string Url { get; }
+
+ public LinkToken(IEnumerable labelTokens, string url)
+ : base(TokenType.Link)
+ {
+ Url = url;
+ Children.AddRange(labelTokens);
+ }
+}
\ No newline at end of file
diff --git a/cs/Markdown/Tokens/TokenType.cs b/cs/Markdown/Tokens/TokenType.cs
new file mode 100644
index 000000000..537d7e0c6
--- /dev/null
+++ b/cs/Markdown/Tokens/TokenType.cs
@@ -0,0 +1,10 @@
+namespace Markdown.Tokens;
+
+public enum TokenType
+{
+ Text,
+ Emphasis,
+ Strong,
+ Header,
+ Link
+}
\ No newline at end of file
diff --git a/cs/MarkdownTests/HtmlRenderer_Should.cs b/cs/MarkdownTests/HtmlRenderer_Should.cs
new file mode 100644
index 000000000..c69f96917
--- /dev/null
+++ b/cs/MarkdownTests/HtmlRenderer_Should.cs
@@ -0,0 +1,173 @@
+using FluentAssertions;
+using Markdown.Renderers;
+using Markdown.Tokens;
+using NUnit.Framework;
+
+namespace MarkdownTests;
+
+[TestFixture]
+public class HtmlRenderer_Should
+{
+ private readonly HtmlRenderer renderer = new ();
+
+ [Test]
+ public void Render_ShouldHandleTextWithoutTags()
+ {
+ var tokens = new List
+ {
+ new (TokenType.Text, "Текст без тегов.")
+ };
+
+ var result = renderer.Render(tokens);
+
+ result.Should().Be("Текст без тегов.");
+ }
+
+ [Test]
+ public void Render_ShouldHandleEmphasisTags()
+ {
+ var tokens = new List
+ {
+ new (TokenType.Text, "Это "),
+ new (TokenType.Emphasis, "курсив")
+ { Children = new List
+ { new (TokenType.Text, "курсив") }
+ },
+ new (TokenType.Text, " текст")
+ };
+
+ var result = renderer.Render(tokens);
+
+ result.Should().Be("Это курсив текст");
+ }
+
+ [Test]
+ public void Render_ShoulHandleStrongTags()
+ {
+ var strongToken = new BaseToken(TokenType.Strong, string.Empty);
+ strongToken.Children.Add(new BaseToken(TokenType.Text, "полужирный"));
+
+ var tokens = new List
+ {
+ new (TokenType.Text, "Это "),
+ strongToken,
+ new (TokenType.Text, " текст")
+ };
+
+ var result = renderer.Render(tokens);
+
+ result.Should().Be("Это полужирный текст");
+ }
+
+ [Test]
+ public void Render_ShouldHandleHeaderTags()
+ {
+ var headerToken = new BaseToken(TokenType.Header, string.Empty);
+ headerToken.Children.Add(new BaseToken(TokenType.Text, "Заголовок"));
+ var tokens = new List { headerToken };
+
+ var result = renderer.Render(tokens);
+
+ result.Should().Be("Заголовок
");
+ }
+
+ [Test]
+ public void Render_ShouldHandleNestedTags()
+ {
+ var headToken = new BaseToken(TokenType.Header, string.Empty);
+ var strongToken = new BaseToken(TokenType.Strong, string.Empty);
+ var emphasisToken = new BaseToken(TokenType.Emphasis, string.Empty);
+
+ emphasisToken.Children.Add(new BaseToken(TokenType.Text, "курсивом"));
+ strongToken.Children.Add(new BaseToken(TokenType.Text, "полужирным текстом с "));
+ strongToken.Children.Add(emphasisToken);
+ headToken.Children.Add(new BaseToken(TokenType.Text, "заголовок с "));
+ headToken.Children.Add(strongToken);
+ var tokens = new List
+ {
+ new BaseToken(TokenType.Text, "Это "),
+ headToken
+ };
+
+ var result = renderer.Render(tokens);
+
+ result.Should().Be("Это заголовок с полужирным " +
+ "текстом с курсивом
");
+ }
+
+ [Test]
+ public void Render_ShouldHandleEmptyTags()
+ {
+ var tokens = new List
+ {
+ new (TokenType.Text, "Это "),
+ new (TokenType.Emphasis, string.Empty),
+ new (TokenType.Text, " текст")
+ };
+
+ var result = renderer.Render(tokens);
+
+ result.Should().Be("Это текст");
+ }
+
+ [Test]
+ public void Render_ShouldHandleMultipleTags()
+ {
+ var tokens = new List
+ {
+ new (TokenType.Text, "Это "),
+ new (TokenType.Strong, "полужирный") { Children = { new BaseToken(TokenType.Text, "полужирный") } },
+ new (TokenType.Text, " и "),
+ new (TokenType.Emphasis, string.Empty) { Children = { new BaseToken(TokenType.Text, "курсив") } },
+ new (TokenType.Text, " текст.")
+ };
+
+ var result = renderer.Render(tokens);
+
+ result.Should().Be("Это полужирный и курсив текст.");
+ }
+
+ [Test]
+ public void Render_ShouldHandleNestedTagsWithMultipleLevels()
+ {
+ var innerStrongToken = new BaseToken(TokenType.Strong, string.Empty);
+ innerStrongToken.Children.Add(new BaseToken(TokenType.Text, "полужирный заголовок"));
+
+ var innerEmphasisToken = new BaseToken(TokenType.Emphasis, string.Empty);
+ innerEmphasisToken.Children.Add(new BaseToken(TokenType.Text, "полужирный курсив"));
+
+ var outerHeaderToken = new BaseToken(TokenType.Header, string.Empty);
+ outerHeaderToken.Children.Add(innerStrongToken);
+
+ var outerStrongToken = new BaseToken(TokenType.Strong, string.Empty);
+ outerStrongToken.Children.Add(new BaseToken(TokenType.Text, "и "));
+ outerStrongToken.Children.Add(innerEmphasisToken);
+
+ var tokens = new List
+ {
+ outerHeaderToken,
+ outerStrongToken,
+ };
+
+ var result = renderer.Render(tokens);
+
+ result.Should().Be("полужирный заголовок
" +
+ "и полужирный курсив");
+ }
+
+ [Test]
+ public void Render_ShouldHandleLinkTags()
+ {
+ var tokens = new List
+ {
+ new LinkToken(new List
+ {
+ new (TokenType.Text, "Ссылка на Google")
+ }, "http://google.com")
+ };
+
+ var result = renderer.Render(tokens);
+
+ result.Should().Be("Ссылка на Google");
+ }
+}
\ No newline at end of file
diff --git a/cs/MarkdownTests/MarkdownParser_Should.cs b/cs/MarkdownTests/MarkdownParser_Should.cs
new file mode 100644
index 000000000..cb772137a
--- /dev/null
+++ b/cs/MarkdownTests/MarkdownParser_Should.cs
@@ -0,0 +1,367 @@
+using FluentAssertions;
+using Markdown.Parsers;
+using Markdown.Tokens;
+using NUnit.Framework;
+
+namespace MarkdownTests;
+
+[TestFixture]
+public class MarkdownParser_Should
+{
+ private readonly MarkdownParser parser = new ();
+
+ public static IEnumerable TokenParsingTestCases()
+ {
+ yield return new TestCaseData(
+ "Это _курсив_ текст",
+ new List
+ {
+ new (TokenType.Text, "Это "),
+ new (TokenType.Emphasis, children: new List
+ {
+ new (TokenType.Text, "курсив")
+ }),
+ new (TokenType.Text, " текст")
+ }).SetName("ShouldParse_WhenItalicTag");
+
+ yield return new TestCaseData(
+ "Это __полужирный__ текст",
+ new List
+ {
+ new (TokenType.Text, "Это "),
+ new (TokenType.Strong, children: new List
+ {
+ new (TokenType.Text, "полужирный")
+ }),
+ new (TokenType.Text, " текст")
+ }).SetName("ShouldParse_WhenStrongTag");
+
+ yield return new TestCaseData(
+ "# Заголовок",
+ new List
+ {
+ new (TokenType.Header, children: new List
+ {
+ new (TokenType.Text, "Заголовок")
+ })
+ }).SetName("ShouldParse_WhenHeaderTag");
+
+ yield return new TestCaseData(
+ "Это __жирный _и курсивный_ текст__",
+ new List
+ {
+ new (TokenType.Text, "Это "),
+ new (TokenType.Strong, children: new List
+ {
+ new (TokenType.Text, "жирный "),
+ new (TokenType.Emphasis, children: new List
+ {
+ new (TokenType.Text, "и курсивный")
+ }),
+ new (TokenType.Text, " текст")
+ })
+ }).SetName("ShouldParse_WhenNestedItalicAndStrongTags");
+
+ yield return new TestCaseData(
+ "Это _курсив_,а это __жирный__ текст.",
+ new List
+ {
+ new (TokenType.Text, "Это "),
+ new (TokenType.Emphasis, children: new List
+ {
+ new (TokenType.Text, "курсив")
+ }),
+ new (TokenType.Text, ",а это "),
+ new (TokenType.Strong, children: new List
+ {
+ new (TokenType.Text, "жирный")
+ }),
+ new (TokenType.Text, " текст.")
+ }).SetName("ShouldParse_WhenMultipleTokensInLine");
+
+ yield return new TestCaseData(
+ "en_d._,mi__dd__le",
+ new List
+ {
+ new (TokenType.Text, "en"),
+ new (TokenType.Emphasis, children: new List
+ {
+ new (TokenType.Text, "d.")
+ }),
+ new (TokenType.Text, ",mi"),
+ new (TokenType.Strong, children: new List
+ {
+ new (TokenType.Text, "dd")
+ }),
+ new (TokenType.Text, "le")
+ }).SetName("ShouldParse_WhenBoundedTagsInOneWord");
+
+ yield return new TestCaseData(
+ @"Экранированный \_символ\_",
+ new List
+ {
+ new (TokenType.Text, "Экранированный _символ_")
+ }).SetName("ShouldParse_WhenEscapedTags");
+
+ yield return new TestCaseData(
+ "Это __двойное _и одинарное_ выделение__",
+ new List
+ {
+ new (TokenType.Text, "Это "),
+ new (TokenType.Strong, children: new List
+ {
+ new (TokenType.Text, "двойное "),
+ new (TokenType.Emphasis, children: new List
+ {
+ new (TokenType.Text, "и одинарное")
+ }),
+ new (TokenType.Text, " выделение")
+ })
+ }).SetName("ShouldParse_WhenItalicInStrong");
+
+ yield return new TestCaseData(
+ "# Заголовок __с _разными_ символами__",
+ new List
+ {
+ new (TokenType.Header, children: new List
+ {
+ new (TokenType.Text, "Заголовок "),
+ new (TokenType.Strong, children: new List
+ {
+ new (TokenType.Text, "с "),
+ new (TokenType.Emphasis, children: new List
+ {
+ new (TokenType.Text, "разными")
+ }),
+ new (TokenType.Text, " символами")
+ })
+ })
+ }).SetName("ShouldParse_WhenHeaderWithTags");
+
+ yield return new TestCaseData(
+ "# Заголовок 1\n# Заголовок 2",
+ new List
+ {
+ new (TokenType.Header, children: new List
+ {
+ new (TokenType.Text, "Заголовок 1")
+ }),
+ new (TokenType.Text, "\n"),
+ new (TokenType.Header, children: new List
+ {
+ new (TokenType.Text, "Заголовок 2")
+ })
+ }).SetName("ShouldParse_WhenMultipleHeaders");
+
+ yield return new TestCaseData(
+ "Это текст с [ссылкой](http://link.com)",
+ new List
+ {
+ new (TokenType.Text, "Это текст с "),
+ new LinkToken(new List
+ {
+ new (TokenType.Text, "ссылкой")
+ }, "http://link.com")
+ }).SetName("ShouldParse_WhenSimpleLinkTag");
+
+ yield return new TestCaseData(
+ "Это текст с [двумя](http://link1.com) [ссылками](http://link2.com)",
+ new List
+ {
+ new (TokenType.Text, "Это текст с "),
+ new LinkToken(new List
+ {
+ new (TokenType.Text, "двумя")
+ }, "http://link1.com"),
+ new (TokenType.Text, " "),
+ new LinkToken(new List
+ {
+ new (TokenType.Text, "ссылками")
+ }, "http://link2.com")
+ }).SetName("ShouldParse_WhenSeveralLinkTags");
+
+ yield return new TestCaseData(
+ "_[Ссылка](http://link.com) внутри курсива_",
+ new List
+ {
+ new (TokenType.Emphasis, children: new List
+ {
+ new LinkToken(new List
+ {
+ new (TokenType.Text, "Ссылка")
+ }, "http://link.com"),
+ new (TokenType.Text, " внутри курсива")
+ })
+ }).SetName("ShouldParse_WhenLinkInsideItalic");
+
+ yield return new TestCaseData(
+ "Это [ссылка с _тегом_](http://link.com)",
+ new List
+ {
+ new (TokenType.Text, "Это "),
+ new LinkToken(new List
+ {
+ new (TokenType.Text, "ссылка с "),
+ new (TokenType.Emphasis, children: new List
+ {
+ new (TokenType.Text, "тегом")
+ })
+ }, "http://link.com")
+ }).SetName("ShouldParse_WhenLinkWithTagInside");
+
+ yield return new TestCaseData(
+ "Пустая ссылка [](http://link.com)",
+ new List
+ {
+ new (TokenType.Text, "Пустая ссылка "),
+ new LinkToken(new List(), "http://link.com")
+ }).SetName("ShouldParse_WhenLinkWithEmptyText");
+
+ yield return new TestCaseData(
+ "Пустая ссылка []()",
+ new List
+ {
+ new (TokenType.Text, "Пустая ссылка "),
+ new LinkToken(new List(), "")
+ }).SetName("ShouldParse_WhenLinkWithEmptyUrl");
+
+ yield return new TestCaseData(
+ @"[Ссылка с экранированными \] символами\]](http://link.com)",
+ new List
+ {
+ new LinkToken(new List
+ {
+ new (TokenType.Text, "Ссылка с экранированными ] символами]")
+ }, "http://link.com")
+ }).SetName("ShouldParse_WhenLinkWithEscapedSymbol");
+
+ yield return new TestCaseData(
+ "Это [ссылка с [ссылка с _тегом_](http://link.com)](http://link.com)",
+ new List
+ {
+ new (TokenType.Text, "Это "),
+ new LinkToken(new List
+ {
+ new (TokenType.Text, "ссылка с "),
+ new LinkToken(new List
+ {
+ new (TokenType.Text, "ссылка с "),
+ new (TokenType.Emphasis, children: new List
+ {
+ new (TokenType.Text, "тегом")
+ })
+ }, "http://link.com")
+ }, "http://link.com")
+ }).SetName("ShouldParse_WhenLinkInsideLink");
+
+ yield return new TestCaseData(
+ "Если пустая _______ строка",
+ new List
+ {
+ new (TokenType.Text, "Если пустая _______ строка")
+ }).SetName("ShouldNotParse_WhenEmptyEmphasis");
+
+ yield return new TestCaseData(
+ "Текст с цифрами_12_3 не должен выделяться",
+ new List
+ {
+ new (TokenType.Text, "Текст с цифрами_12_3 не должен выделяться")
+ }).SetName("ShouldNotParse_WhenUnderscoresInNumbers");
+
+ yield return new TestCaseData(
+ @"Здесь сим\волы экранирования\ \должны остаться.\",
+ new List
+ {
+ new (TokenType.Text, @"Здесь сим\волы экранирования\ \должны остаться.\")
+ }).SetName("ShouldNotParse_WhenEscapingSymbols");
+
+ yield return new TestCaseData(
+ @"\\_вот это будет выделено тегом_",
+ new List
+ {
+ new (TokenType.Emphasis, children: new List
+ {
+ new (TokenType.Text, "вот это будет выделено тегом")
+ })
+ }).SetName("ShouldNotParse_WhenEscapedYourself");
+
+ yield return new TestCaseData(
+ "и в нач_але_,и в сер__еди__не",
+ new List
+ {
+ new (TokenType.Text, "и в нач"),
+ new (TokenType.Emphasis, children: new List
+ {
+ new (TokenType.Text, "але")
+ }),
+ new (TokenType.Text, ",и в сер"),
+ new (TokenType.Strong, children: new List
+ {
+ new (TokenType.Text, "еди")
+ }),
+ new (TokenType.Text, "не")
+ }).SetName("ShouldParse_WhenTagsInSimilarWord");
+
+ yield return new TestCaseData(
+ "Это пер_вый в_торой пример.",
+ new List
+ {
+ new (TokenType.Text, "Это пер_вый в_торой пример.")
+ }).SetName("ShouldNotParse_WhenTagInDifferentWords");
+
+ yield return new TestCaseData(
+ "_e __e",
+ new List
+ {
+ new (TokenType.Text, "_e __e")
+ }).SetName("ShouldNotParse_WhenUnclosedTags");
+
+ yield return new TestCaseData(
+ "_e __s e_ s__",
+ new List
+ {
+ new (TokenType.Text, "_e __s e_ s__")
+ }).SetName("ShouldNotParse_WhenTagsIntersection");
+
+ yield return new TestCaseData(
+ "__s \n s__,_e \r\n e_",
+ new List
+ {
+ new (TokenType.Text, "__s \n s__,_e \r\n e_")
+ }).SetName("ShouldNotParse_WhenTagsIntersectionNewLines");
+
+ yield return new TestCaseData(
+ "Текст с [незавершённой ссылкой](http://link.com",
+ new List
+ {
+ new (TokenType.Text, "Текст с "),
+ new (TokenType.Text, "[незавершённой ссылкой](http://link.com")
+ }).SetName("ShouldNotParse_WhenUnfinishedLinkTag");
+ }
+
+ [TestCaseSource(nameof(TokenParsingTestCases))]
+ public void MarkdownParser_ShouldParseTokens(string input, List expectedTokens)
+ {
+ var actualTokens = parser.ParseTokens(input).ToList();
+ CompareTokens(expectedTokens, actualTokens);
+ }
+
+ private void CompareTokens(IReadOnlyList expected, IReadOnlyList actual)
+ {
+ actual.Should().HaveCount(expected.Count, "Количество токенов должно совпадать");
+ for (int i = 0; i < expected.Count; i++)
+ {
+ actual[i].Type.Should().Be(expected[i].Type, $"Тип токена на позиции {i} должен совпадать");
+ actual[i].Content.Should().Be(expected[i].Content, $"Содержимое токена на позиции {i} должно совпадать");
+
+ if (expected[i].Children.Any())
+ {
+ CompareTokens(expected[i].Children, actual[i].Children);
+ }
+ else
+ {
+ actual[i].Children.Should().BeNullOrEmpty($"Токен на позиции {i} не должен иметь дочерних элементов");
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/cs/MarkdownTests/MarkdownTests.csproj b/cs/MarkdownTests/MarkdownTests.csproj
new file mode 100644
index 000000000..b3bdd436f
--- /dev/null
+++ b/cs/MarkdownTests/MarkdownTests.csproj
@@ -0,0 +1,19 @@
+
+
+
+ net8.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/cs/MarkdownTests/Md_Should.cs b/cs/MarkdownTests/Md_Should.cs
new file mode 100644
index 000000000..bab7536ff
--- /dev/null
+++ b/cs/MarkdownTests/Md_Should.cs
@@ -0,0 +1,170 @@
+using FluentAssertions;
+using Markdown;
+using NUnit.Framework;
+using System.Diagnostics;
+using Markdown.Parsers;
+using Markdown.Renderers;
+
+namespace MarkdownTests;
+
+[TestFixture]
+public class Md_Should
+{
+ private HtmlRenderer renderer;
+ private MarkdownParser parser;
+ private Md md;
+
+ [SetUp]
+ public void Setup()
+ {
+ renderer = new HtmlRenderer();
+ parser = new MarkdownParser();
+ md = new Md(renderer, parser);
+ }
+
+ [Test]
+ public void Md_ShouldThrowArgumentNullException_WhenInputIsNull()
+ {
+ var func = () => md.Render(null!);
+ func.Should().Throw();
+ }
+
+ [TestCase("", "", TestName = "InputIsEmpty")]
+ [TestCase("Это # не заголовок",
+ "Это # не заголовок",
+ TestName = "InvalidHeaderTags")]
+ [TestCase(@"Здесь сим\волы экранирования\ \должны остаться.\",
+ @"Здесь сим\волы экранирования\ \должны остаться.\",
+ TestName = "EscapingSymbols")]
+ [TestCase("В ра_зных сл_овах",
+ "В ра_зных сл_овах",
+ TestName = "ItalicInDifferentWords")]
+ [TestCase("В ра__зных сл__овах",
+ "В ра__зных сл__овах",
+ TestName = "StrongInDifferentWords")]
+ [TestCase("Это __непарные _символы в одном абзаце.",
+ "Это __непарные _символы в одном абзаце.",
+ TestName = "UnclosedTagsInMiddle")]
+ [TestCase("_e __e",
+ "_e __e",
+ TestName = "UnclosedTagsInStart")]
+ [TestCase("e_ e__",
+ "e_ e__",
+ TestName = "UnclosedTagsInEnd")]
+ [TestCase("Если пустая _______ строка",
+ "Если пустая _______ строка",
+ TestName = "EmptyTags")]
+ [TestCase("_e __s e_ s__",
+ "_e __s e_ s__",
+ TestName = "TagsIntersection")]
+ [TestCase("__s \n s__, _e \r\n e_",
+ "__s \n s__, _e \r\n e_",
+ TestName = "TagsIntersectionNewLines")]
+ [TestCase("Текст с цифрами_12_3 не должен выделяться",
+ "Текст с цифрами_12_3 не должен выделяться",
+ TestName = "UnderscoreInNumbers")]
+ [TestCase("Текст с [незавершённой ссылкой](http://link.com",
+ "Текст с [незавершённой ссылкой](http://link.com",
+ TestName = "UnfinishedUrlInLinkTag")]
+ [TestCase("Текст с [незавершённой ссылкой(http://link.com)",
+ "Текст с [незавершённой ссылкой(http://link.com)",
+ TestName = "UnfinishedTextInLinkTag")]
+ public void Md_ShouldNotRender_When(string input, string expected)
+ {
+ var result = md.Render(input);
+ result.Should().Be(expected);
+ }
+
+ [TestCase("Это _курсив_ и __полужирный__ текст",
+ "Это курсив и полужирный текст",
+ TestName = "ItalicAndStrongTags")]
+ [TestCase("# Заголовок",
+ "Заголовок
",
+ TestName = "HeaderTags")]
+ [TestCase("# Заголовок __с _разными_ символами__",
+ "Заголовок с разными символами
",
+ TestName = "HeaderWithNestedTags")]
+ [TestCase("Это __полужирный _текст_, _с курсивом_ внутри__",
+ "Это полужирный текст, с курсивом внутри",
+ TestName = "ItalicInStrong")]
+ [TestCase("Это _курсив с __полужирным__ внутри_",
+ "Это курсив с __полужирным__ внутри",
+ TestName = "StrongInItalic")]
+ [TestCase(@"Экранированный \_символ\_",
+ "Экранированный _символ_",
+ TestName = "EscapeTag")]
+ [TestCase("_подчерки _не считаются_",
+ "_подчерки не считаются",
+ TestName = "SpaceBeforeEndOfTag")]
+ [TestCase(@"\\_вот это будет выделено тегом_",
+ "вот это будет выделено тегом",
+ TestName = "EscapedYourselfOnStartOfTag")]
+ [TestCase(@"_e\\_",
+ "e",
+ TestName = "EscapedYourselfOnEndOfTag")]
+ [TestCase("# Заголовок 1\n# Заголовок 2",
+ "Заголовок 1
\nЗаголовок 2
",
+ TestName = "MultipleHeaders")]
+ [TestCase("# h __E _e_ E__ _e_",
+ "h E e E e
",
+ TestName = "LotNestedTags")]
+ [TestCase("# h __s _E _e_ E_ s__ _e_",
+ "h s E e E s e
",
+ TestName = "LotNestedTagsWithDoubleItalic")]
+ [TestCase("en_d._, mi__dd__le, _sta_rt",
+ "end., middle, start",
+ TestName = "BoundedTagsInOneWord")]
+ [TestCase("Это текст с [ссылкой](http://link.com)",
+ @"Это текст с ссылкой",
+ TestName = "SimpleLinkTag")]
+ [TestCase("Это текст с [двумя](http://link1.com) [ссылками](http://link2.com)",
+ @"Это текст с двумя ссылками",
+ TestName = "SeveralLinkTags")]
+ [TestCase("_[Ссылка](http://link.com) внутри курсива_",
+ @"Ссылка внутри курсива",
+ TestName = "LinkInsideItalic")]
+ [TestCase("__[Ссылка](http://link.com) внутри полужирного__",
+ @"Ссылка внутри полужирного",
+ TestName = "LinkInsideStrong")]
+ [TestCase("# [Ссылка](http://link.com)",
+ @"",
+ TestName = "LinkInsideHeader")]
+ [TestCase("# h __E _e_ [ссылка](http://link.com) E__ _e_",
+ @"",
+ TestName = "NestedTagsWithLink")]
+ [TestCase("Это [ссылка с _тегом_](http://link.com)",
+ @"Это ссылка с тегом",
+ TestName = "LinkWithTagInside")]
+ [TestCase("Пустая ссылка [](http://link.com)",
+ @"Пустая ссылка ",
+ TestName = "LinkWithEmptyText")]
+ [TestCase("Пустая ссылка []()",
+ @"Пустая ссылка ",
+ TestName = "LinkWithEmptyUrl")]
+ [TestCase(@"[Ссылка с экранированными \] символами\]](http://link.com)",
+ @"Ссылка с экранированными ] символами]",
+ TestName = "LinkWithEscapedSymbol")]
+ [TestCase("Это [ссылка с [ссылка с _тегом_](http://link.com)](http://link.com)",
+ @"Это ссылка с ссылка с тегом",
+ TestName = "LinkInsideLink")]
+ public void Md_ShouldRender_When(string input, string expected)
+ {
+ var result = md.Render(input);
+ result.Should().Be(expected);
+ }
+
+ [Test]
+ public void Md_ShouldRenderLargeInputQuickly()
+ {
+ var largeInput = string.Concat(Enumerable.Repeat("_Пример_ ", 1000));
+ var expectedOutput = string.Concat(Enumerable.Repeat("Пример ", 1000));
+ var stopwatch = new Stopwatch();
+
+ stopwatch.Start();
+ var result = md.Render(largeInput);
+ stopwatch.Stop();
+
+ stopwatch.ElapsedMilliseconds.Should().BeLessThan(1000);
+ expectedOutput.Should().BeEquivalentTo(result);
+ }
+}
\ No newline at end of file
diff --git a/cs/clean-code.sln b/cs/clean-code.sln
index 2206d54db..c0c32aab7 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", "{59318F61-936C-4DE7-BB97-3BE627267507}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MarkdownTests", "MarkdownTests\MarkdownTests.csproj", "{19E76A0C-0B32-456F-9556-1A88BF102982}"
+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
+ {59318F61-936C-4DE7-BB97-3BE627267507}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {59318F61-936C-4DE7-BB97-3BE627267507}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {59318F61-936C-4DE7-BB97-3BE627267507}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {59318F61-936C-4DE7-BB97-3BE627267507}.Release|Any CPU.Build.0 = Release|Any CPU
+ {19E76A0C-0B32-456F-9556-1A88BF102982}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {19E76A0C-0B32-456F-9556-1A88BF102982}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {19E76A0C-0B32-456F-9556-1A88BF102982}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {19E76A0C-0B32-456F-9556-1A88BF102982}.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