diff --git a/MarkdownSpec.md b/MarkdownSpec.md
index 886e99c95..04082e9b1 100644
--- a/MarkdownSpec.md
+++ b/MarkdownSpec.md
@@ -70,4 +70,42 @@ __Непарные_ символы в рамках одного абзаца н
превратится в:
-\
Заголовок \с \разными\ символами\\
\ No newline at end of file
+\Заголовок \с \разными\ символами\\
+
+
+
+# Ссылки
+
+Ссылка представлена строго в виде: \[текст ссылки](URL). Выделяется тегом \text\
+Текст ссылки не должен начинаться с пробела и в скбоках с URL не должно быть пробелов, иначе это не считается выделением
+
+Таким образом
+
+Ссылка с текстом: \[репозиторий gogy](https://github.com/gogy4?tab=repositories)
+
+превратится в:
+\репозиторий gogy\
+
+В тексте ссылки могут присутствовать и другие символы разметки с правилами:
+
+Нельзя выделять весь текст ссылки в другие символы, например:
+\[\_\_Полужирная ссылка\_\_](google.com) так и останется
+
+Аналогично и с курсивом:
+\[\_Курсивная ссылка\_](google.com) так и останется
+
+Другие символы разметки можно использовать только на учатски, а не на весь текст, например ссылка:
+\[Ссылка с \_курсивным\_ текстом](google.com)
+
+первратится в:
+\Ссылка с \полужирным\ текстом\
+
+Чтобы экранировать текст с ссылкой, достаточно поставить знак экранирования перед [
+
+\\\[Ссылка с текстом](google.com)
diff --git a/cs/Markdown/Dto/CharContext.cs b/cs/Markdown/Dto/CharContext.cs
new file mode 100644
index 000000000..066ce2f1f
--- /dev/null
+++ b/cs/Markdown/Dto/CharContext.cs
@@ -0,0 +1,3 @@
+namespace Markdown.Dto;
+
+public record CharContext(char? Next, bool CanOpen, bool CanClose);
\ No newline at end of file
diff --git a/cs/Markdown/Dto/Tag.cs b/cs/Markdown/Dto/Tag.cs
new file mode 100644
index 000000000..651dbe7a7
--- /dev/null
+++ b/cs/Markdown/Dto/Tag.cs
@@ -0,0 +1,46 @@
+using System.Text;
+
+namespace Markdown.Dto
+{
+ public class Tag
+ {
+ public Token Token { get; }
+ public StringBuilder Content { get; }
+ public bool IsOpen { get; }
+
+ public bool ContainsSpace { get; private set; }
+ public bool HasOnlyDigits { get; private set; } = true;
+
+ public Tag(Token token, StringBuilder content, bool isOpen)
+ {
+ Token = token;
+ Content = content;
+ IsOpen = isOpen;
+ UpdateFlags(content.ToString());
+ }
+
+ public void Append(string text)
+ {
+ Content.Append(text);
+ UpdateFlags(text);
+ }
+
+ private void UpdateFlags(string text)
+ {
+ if (!ContainsSpace && text.Contains(' '))
+ {
+ ContainsSpace = true;
+ }
+
+ if (!HasOnlyDigits)
+ {
+ return;
+ }
+ foreach (var ch in text.Skip(Token.Value.Length).Where(ch => !char.IsDigit(ch)))
+ {
+ HasOnlyDigits = false;
+ break;
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/cs/Markdown/Dto/Token.cs b/cs/Markdown/Dto/Token.cs
new file mode 100644
index 000000000..7307c62fc
--- /dev/null
+++ b/cs/Markdown/Dto/Token.cs
@@ -0,0 +1,54 @@
+namespace Markdown.Dto
+{
+ public class Token
+ {
+ public string Value { get; private set; }
+ public TokenType Type { get; private set; }
+ public TokenRole Role { get; private set; }
+ public TokenPosition Position { get; private set; }
+
+ public Token(string value, TokenType type, TokenRole role, TokenPosition position)
+ {
+ Value = value;
+ Type = type;
+ Role = role;
+ Position = position;
+ }
+
+ public Token(string value, TokenType type, bool canOpen, bool canClose)
+ {
+ Value = value;
+ Type = type;
+ DetermineRole(canOpen, canClose);
+ DeterminePosition(canOpen, canClose);
+ }
+
+ private void DetermineRole(bool canOpen, bool canClose)
+ {
+ Determine(canOpen, canClose, v => Role = v,
+ TokenRole.Both, TokenRole.Open, TokenRole.Close);
+ }
+
+ private void DeterminePosition(bool canOpen, bool canClose)
+ {
+ Determine(canOpen, canClose, v => Position = v,
+ TokenPosition.Inside, TokenPosition.Begin, TokenPosition.End);
+ }
+
+ private void Determine(bool canOpen, bool canClose, Action setter, T both, T open, T close)
+ {
+ if (canOpen == canClose)
+ {
+ setter(both);
+ }
+ else if (canOpen)
+ {
+ setter(open);
+ }
+ else
+ {
+ setter(close);
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/cs/Markdown/Dto/TokenPosition.cs b/cs/Markdown/Dto/TokenPosition.cs
new file mode 100644
index 000000000..fe8518151
--- /dev/null
+++ b/cs/Markdown/Dto/TokenPosition.cs
@@ -0,0 +1,9 @@
+namespace Markdown.Dto;
+
+public enum TokenPosition
+{
+ None = 0,
+ Begin,
+ Inside,
+ End,
+}
\ No newline at end of file
diff --git a/cs/Markdown/Dto/TokenRole.cs b/cs/Markdown/Dto/TokenRole.cs
new file mode 100644
index 000000000..c7cd95028
--- /dev/null
+++ b/cs/Markdown/Dto/TokenRole.cs
@@ -0,0 +1,9 @@
+namespace Markdown.Dto;
+
+public enum TokenRole
+{
+ None = 0,
+ Open,
+ Close,
+ Both,
+}
\ No newline at end of file
diff --git a/cs/Markdown/Dto/TokenType.cs b/cs/Markdown/Dto/TokenType.cs
new file mode 100644
index 000000000..c52614c70
--- /dev/null
+++ b/cs/Markdown/Dto/TokenType.cs
@@ -0,0 +1,12 @@
+namespace Markdown.Dto;
+
+public enum TokenType
+{
+ Text = 0,
+ Italic,
+ Strong,
+ Escape,
+ Header,
+ Link,
+ End
+}
\ No newline at end of file
diff --git a/cs/Markdown/Extensions/StringExtensions.cs b/cs/Markdown/Extensions/StringExtensions.cs
new file mode 100644
index 000000000..85ad57d12
--- /dev/null
+++ b/cs/Markdown/Extensions/StringExtensions.cs
@@ -0,0 +1,30 @@
+namespace Markdown.Extensions;
+
+public static class StringExtensions
+{
+ public static IEnumerable EnumerateLines(this string? markdown)
+ {
+ if (markdown is null)
+ {
+ yield break;
+ }
+
+ var start = 0;
+
+ for (var i = 0; i < markdown.Length; i++)
+ {
+ if (markdown[i] != '\n')
+ {
+ continue;
+ }
+
+ yield return markdown[start..i];
+ start = i + 1;
+ }
+
+ if (start <= markdown.Length)
+ {
+ yield return markdown[start..];
+ }
+ }
+}
\ No newline at end of file
diff --git a/cs/Markdown/Markdown.csproj b/cs/Markdown/Markdown.csproj
new file mode 100644
index 000000000..6452b2ab0
--- /dev/null
+++ b/cs/Markdown/Markdown.csproj
@@ -0,0 +1,16 @@
+
+
+
+ net9.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
diff --git a/cs/Markdown/RenderServices/Abstractions/IRender.cs b/cs/Markdown/RenderServices/Abstractions/IRender.cs
new file mode 100644
index 000000000..57fac78c1
--- /dev/null
+++ b/cs/Markdown/RenderServices/Abstractions/IRender.cs
@@ -0,0 +1,6 @@
+namespace Markdown;
+
+public interface IRender
+{
+ public string RenderText(string markdown);
+}
\ No newline at end of file
diff --git a/cs/Markdown/RenderServices/Implementations/MarkdownRender.cs b/cs/Markdown/RenderServices/Implementations/MarkdownRender.cs
new file mode 100644
index 000000000..d9588f260
--- /dev/null
+++ b/cs/Markdown/RenderServices/Implementations/MarkdownRender.cs
@@ -0,0 +1,33 @@
+using System.Text;
+using Markdown.Dto;
+using Markdown.Extensions;
+using Markdown.TagUtils.Abstractions;
+using Markdown.TagUtils.Implementations;
+using Markdown.TokensUtils;
+using Markdown.TokensUtils.Abstractions;
+using Markdown.TokensUtils.Implementations;
+
+namespace Markdown
+{
+ public class MarkdownRender(ITokenizer tokenizer, ITagProcessor tagProcessor) : IRender
+ {
+ public string RenderText(string markdown)
+ {
+ var markDownLines = markdown.EnumerateLines();
+ var result = new StringBuilder();
+ foreach (var line in markDownLines)
+ {
+ if (result.Length > 0) result.Append('\n');
+ result.Append(RenderLine(line));
+ }
+
+ return result.ToString();
+ }
+
+ private string RenderLine(string line)
+ {
+ var tokens = tokenizer.Tokenize(line).ToList();
+ return tagProcessor.Process(tokens);
+ }
+ }
+}
\ No newline at end of file
diff --git a/cs/Markdown/TagUtils/Abstractions/BaseTagStrategy.cs b/cs/Markdown/TagUtils/Abstractions/BaseTagStrategy.cs
new file mode 100644
index 000000000..0bb6a3469
--- /dev/null
+++ b/cs/Markdown/TagUtils/Abstractions/BaseTagStrategy.cs
@@ -0,0 +1,53 @@
+using System.Text;
+using Markdown.Dto;
+using Markdown.TagUtils.Abstractions;
+
+public abstract class BaseTagStrategy(ITagContext context)
+{
+ protected void ProcessTag(Token token, bool isStrong, Func isInvalidCloseContext)
+ {
+ var tagsStack = context.Tags;
+ var parent = tagsStack.Count > 0 ? tagsStack.Peek() : null;
+ var canClose = isStrong ? token.Role is TokenRole.Close : token.Role is TokenRole.Close or TokenRole.Both;
+ var canOpen = isStrong
+ ? token.Role is TokenRole.Open or TokenRole.Both ||
+ (token.Role == TokenRole.Close && tagsStack.Count > 0 && tagsStack.Peek().IsOpen)
+ : token.Role is TokenRole.Open or TokenRole.Both;
+
+ if (canClose && tagsStack.Count > 0 && !isInvalidCloseContext(parent, token))
+ {
+ if (isStrong)
+ {
+ var popParent = tagsStack.Pop();
+ var grandParent = tagsStack.Count > 0 ? tagsStack.Peek() : null;
+ var shouldNotClose = grandParent is not null && grandParent.Token.Type == TokenType.Italic &&
+ grandParent.IsOpen;
+
+ if (shouldNotClose)
+ {
+ var newContent = popParent.Content.Append(popParent.Token.Value);
+ context.Append(newContent.ToString());
+ }
+ else
+ {
+ tagsStack.Push(popParent);
+ context.CloseTop();
+ }
+ }
+ else
+ {
+ context.CloseTop();
+ }
+ }
+ else if (canOpen)
+ {
+ context.Open(new Tag(token, new StringBuilder(token.Value), true));
+ }
+ else
+ {
+ context.Append(token.Value);
+ }
+ }
+
+ public abstract void Process(Token token);
+}
\ No newline at end of file
diff --git a/cs/Markdown/TagUtils/Abstractions/ITagContext.cs b/cs/Markdown/TagUtils/Abstractions/ITagContext.cs
new file mode 100644
index 000000000..8b649da13
--- /dev/null
+++ b/cs/Markdown/TagUtils/Abstractions/ITagContext.cs
@@ -0,0 +1,14 @@
+using System.Text;
+using Markdown.Dto;
+
+namespace Markdown.TagUtils.Abstractions;
+
+public interface ITagContext : IDisposable
+{
+ public void Append(string content);
+ public void CloseTop(bool isFinal = false);
+ public void Open(Tag tag);
+ public Stack Tags { get; }
+ public StringBuilder Content { get; }
+ public bool SkipNextAsMarkup { get; set; }
+}
\ No newline at end of file
diff --git a/cs/Markdown/TagUtils/Abstractions/ITagProcessor.cs b/cs/Markdown/TagUtils/Abstractions/ITagProcessor.cs
new file mode 100644
index 000000000..546270e14
--- /dev/null
+++ b/cs/Markdown/TagUtils/Abstractions/ITagProcessor.cs
@@ -0,0 +1,9 @@
+using System.Text;
+using Markdown.Dto;
+
+namespace Markdown.TagUtils.Abstractions;
+
+public interface ITagProcessor
+{
+ public string Process(IEnumerable tokens);
+}
\ No newline at end of file
diff --git a/cs/Markdown/TagUtils/CloseContext.cs b/cs/Markdown/TagUtils/CloseContext.cs
new file mode 100644
index 000000000..aebd4956f
--- /dev/null
+++ b/cs/Markdown/TagUtils/CloseContext.cs
@@ -0,0 +1,61 @@
+using Markdown.Dto;
+
+namespace Markdown.TagUtils;
+
+public static class CloseContext
+{
+ public static bool IsInvalidUnderScoreCloseContext(Tag parent, Token token, int markerLength)
+ {
+ return IsInvalidCloseContext(parent, token, markerLength) || parent.HasOnlyDigits;
+ }
+
+ public static bool IsInvalidLinkCloseContext(Tag parent, Token token, int markerLength)
+ {
+ if (IsInvalidCloseContext(parent, token, markerLength)) return true;
+
+ var content = parent.Content.ToString();
+ var closeBracketIndex = content.IndexOf(']');
+ var openParenIndex = content.IndexOf('(');
+
+ const int startCloseBracketIndex = 0;
+ var endCloseParenIndex = content.Length;
+
+ if (closeBracketIndex == -1 || openParenIndex == -1)
+ {
+ return true;
+ }
+
+ if (closeBracketIndex - startCloseBracketIndex == 1)
+ {
+ return true;
+ }
+
+ if (endCloseParenIndex - openParenIndex == 1)
+ {
+ return true;
+ }
+
+ return openParenIndex - closeBracketIndex != 1;
+ }
+
+ private static bool IsInvalidCloseContext(Tag? parent, Token token, int markerLength)
+ {
+ if (parent == null)
+ {
+ return true;
+ }
+
+ if (parent.Token.Type != token.Type)
+ {
+ return true;
+ }
+
+ if (parent.Token.Position == TokenPosition.Inside && parent.ContainsSpace)
+ {
+ return true;
+ }
+
+ var contentLength = parent.Content.Length - markerLength;
+ return contentLength <= 0;
+ }
+}
\ No newline at end of file
diff --git a/cs/Markdown/TagUtils/Implementations/Strategies/EndStrategy.cs b/cs/Markdown/TagUtils/Implementations/Strategies/EndStrategy.cs
new file mode 100644
index 000000000..5ffb4ac7d
--- /dev/null
+++ b/cs/Markdown/TagUtils/Implementations/Strategies/EndStrategy.cs
@@ -0,0 +1,15 @@
+using Markdown.Dto;
+using Markdown.TagUtils.Abstractions;
+
+namespace Markdown.TagUtils.Implementations;
+
+public class EndStrategy(ITagContext context) : BaseTagStrategy(context)
+{
+ public override void Process(Token token)
+ {
+ while (context.Tags.Count > 0)
+ {
+ context.CloseTop(true);
+ }
+ }
+}
\ No newline at end of file
diff --git a/cs/Markdown/TagUtils/Implementations/Strategies/EscapeStrategy.cs b/cs/Markdown/TagUtils/Implementations/Strategies/EscapeStrategy.cs
new file mode 100644
index 000000000..1bcc9b485
--- /dev/null
+++ b/cs/Markdown/TagUtils/Implementations/Strategies/EscapeStrategy.cs
@@ -0,0 +1,13 @@
+using Markdown.Dto;
+using Markdown.TagUtils.Abstractions;
+
+namespace Markdown.TagUtils.Implementations;
+
+public class EscapeStrategy(ITagContext context) : BaseTagStrategy(context)
+{
+ public override void Process(Token token)
+ {
+ context.Append(token.Value);
+ context.SkipNextAsMarkup = true;
+ }
+}
\ No newline at end of file
diff --git a/cs/Markdown/TagUtils/Implementations/Strategies/HeaderStrategy.cs b/cs/Markdown/TagUtils/Implementations/Strategies/HeaderStrategy.cs
new file mode 100644
index 000000000..39f439366
--- /dev/null
+++ b/cs/Markdown/TagUtils/Implementations/Strategies/HeaderStrategy.cs
@@ -0,0 +1,13 @@
+using System.Text;
+using Markdown.Dto;
+using Markdown.TagUtils.Abstractions;
+
+namespace Markdown.TagUtils.Implementations;
+
+public class HeaderStrategy(ITagContext context) : BaseTagStrategy(context)
+{
+ public override void Process(Token token)
+ {
+ context.Open(new Tag(token, new StringBuilder(token.Value), true));
+ }
+}
\ No newline at end of file
diff --git a/cs/Markdown/TagUtils/Implementations/Strategies/ItalicStrategy.cs b/cs/Markdown/TagUtils/Implementations/Strategies/ItalicStrategy.cs
new file mode 100644
index 000000000..b0ea44d07
--- /dev/null
+++ b/cs/Markdown/TagUtils/Implementations/Strategies/ItalicStrategy.cs
@@ -0,0 +1,14 @@
+using System.Text;
+using Markdown.Dto;
+using Markdown.TagUtils.Abstractions;
+
+namespace Markdown.TagUtils.Implementations;
+
+public class ItalicStrategy(ITagContext context) : BaseTagStrategy(context)
+{
+ public override void Process(Token token)
+ {
+ ProcessTag(token, false, (parent, t) =>
+ CloseContext.IsInvalidUnderScoreCloseContext(parent, t, token.Value.Length));
+ }
+}
\ No newline at end of file
diff --git a/cs/Markdown/TagUtils/Implementations/Strategies/LinkStrategy.cs b/cs/Markdown/TagUtils/Implementations/Strategies/LinkStrategy.cs
new file mode 100644
index 000000000..793a29419
--- /dev/null
+++ b/cs/Markdown/TagUtils/Implementations/Strategies/LinkStrategy.cs
@@ -0,0 +1,13 @@
+using Markdown.Dto;
+using Markdown.TagUtils.Abstractions;
+
+namespace Markdown.TagUtils.Implementations;
+
+public class LinkStrategy(ITagContext context) : BaseTagStrategy(context)
+{
+ public override void Process(Token token)
+ {
+ ProcessTag(token, false, (parent, t) =>
+ CloseContext.IsInvalidLinkCloseContext(parent, t, token.Value.Length));
+ }
+}
\ No newline at end of file
diff --git a/cs/Markdown/TagUtils/Implementations/Strategies/StrongStrategy.cs b/cs/Markdown/TagUtils/Implementations/Strategies/StrongStrategy.cs
new file mode 100644
index 000000000..eaa6dff45
--- /dev/null
+++ b/cs/Markdown/TagUtils/Implementations/Strategies/StrongStrategy.cs
@@ -0,0 +1,13 @@
+using Markdown.Dto;
+using Markdown.TagUtils.Abstractions;
+
+namespace Markdown.TagUtils.Implementations;
+
+public class StrongStrategy(ITagContext context) : BaseTagStrategy(context)
+{
+ public override void Process(Token token)
+ {
+ ProcessTag(token, true, (parent, t) =>
+ CloseContext.IsInvalidUnderScoreCloseContext(parent, t, token.Value.Length));
+ }
+}
\ No newline at end of file
diff --git a/cs/Markdown/TagUtils/Implementations/Strategies/TextStrategy.cs b/cs/Markdown/TagUtils/Implementations/Strategies/TextStrategy.cs
new file mode 100644
index 000000000..729a47589
--- /dev/null
+++ b/cs/Markdown/TagUtils/Implementations/Strategies/TextStrategy.cs
@@ -0,0 +1,12 @@
+using Markdown.Dto;
+using Markdown.TagUtils.Abstractions;
+
+namespace Markdown.TagUtils.Implementations;
+
+public class TextStrategy(ITagContext context) : BaseTagStrategy(context)
+{
+ public override void Process(Token token)
+ {
+ context.Append(token.Value);
+ }
+}
\ No newline at end of file
diff --git a/cs/Markdown/TagUtils/Implementations/TagContext.cs b/cs/Markdown/TagUtils/Implementations/TagContext.cs
new file mode 100644
index 000000000..a55a9a838
--- /dev/null
+++ b/cs/Markdown/TagUtils/Implementations/TagContext.cs
@@ -0,0 +1,59 @@
+using System.Text;
+using Markdown.Dto;
+using Markdown.TagUtils.Abstractions;
+using Markdown.TokensUtils;
+
+namespace Markdown.TagUtils.Implementations;
+
+public class TagContext : ITagContext
+{
+ public Stack Tags { get; } = new();
+ public StringBuilder Content { get; } = new();
+ public bool SkipNextAsMarkup { get; set; }
+
+ public void Append(string content)
+ {
+ if (Tags.Count > 0)
+ {
+ Tags.Peek().Append(content);
+ }
+ else
+ {
+ Content.Append(content);
+ }
+ }
+
+ public void Open(Tag tag)
+ {
+ Tags.Push(tag);
+ }
+
+
+ public void CloseTop(bool isFinal = false)
+ {
+ var top = Tags.Pop();
+ var content = top.Content.ToString();
+ var wrapped = new StringBuilder();
+
+ if (isFinal)
+ {
+ wrapped.Append(top.Token.Type == TokenType.Header
+ ? TagRender.Wrap(top.Token.Type, content[1..])
+ : content);
+ }
+ else
+ {
+ var markerLength = top.Token.Value.Length;
+ var innerContent = content.Length > markerLength ? content[markerLength..] : string.Empty;
+ wrapped.Append(TagRender.Wrap(top.Token.Type, innerContent));
+ }
+
+ Append(wrapped.ToString());
+ }
+
+ public void Dispose()
+ {
+ Tags.Clear();
+ Content.Clear();
+ }
+}
\ No newline at end of file
diff --git a/cs/Markdown/TagUtils/Implementations/TagProcessor.cs b/cs/Markdown/TagUtils/Implementations/TagProcessor.cs
new file mode 100644
index 000000000..8aef3efc2
--- /dev/null
+++ b/cs/Markdown/TagUtils/Implementations/TagProcessor.cs
@@ -0,0 +1,30 @@
+using System.Text;
+using Markdown.Dto;
+using Markdown.TagUtils.Abstractions;
+using Markdown.TokensUtils;
+
+namespace Markdown.TagUtils.Implementations;
+
+public class TagProcessor(ITagContext context, TagStrategyFactory factory) : ITagProcessor
+{
+ public string Process(IEnumerable tokens)
+ {
+ using (context)
+ {
+ foreach (var token in tokens)
+ {
+ if (context.SkipNextAsMarkup)
+ {
+ context.Append(token.Value);
+ context.SkipNextAsMarkup = false;
+ continue;
+ }
+
+ var strategy = factory.Get(token.Type);
+ strategy?.Process(token);
+ }
+
+ return context.Content.ToString();
+ }
+ }
+}
\ No newline at end of file
diff --git a/cs/Markdown/TagUtils/TagStrategyFactory.cs b/cs/Markdown/TagUtils/TagStrategyFactory.cs
new file mode 100644
index 000000000..be0f2b9b4
--- /dev/null
+++ b/cs/Markdown/TagUtils/TagStrategyFactory.cs
@@ -0,0 +1,20 @@
+using Markdown.Dto;
+using Markdown.TagUtils.Abstractions;
+using Markdown.TagUtils.Implementations;
+
+public class TagStrategyFactory(ITagContext context)
+{
+ private readonly Dictionary strategies = new()
+ {
+ [TokenType.Italic] = new ItalicStrategy(context),
+ [TokenType.Strong] = new StrongStrategy(context),
+ [TokenType.Link] = new LinkStrategy(context),
+ [TokenType.Header] = new HeaderStrategy(context),
+ [TokenType.End] = new EndStrategy(context),
+ [TokenType.Text] = new TextStrategy(context),
+ [TokenType.Escape] = new EscapeStrategy(context)
+ };
+
+ public BaseTagStrategy? Get(TokenType type)
+ => strategies.GetValueOrDefault(type);
+}
\ No newline at end of file
diff --git a/cs/Markdown/Tests/MarkdownTests.cs b/cs/Markdown/Tests/MarkdownTests.cs
new file mode 100644
index 000000000..28ffa20a4
--- /dev/null
+++ b/cs/Markdown/Tests/MarkdownTests.cs
@@ -0,0 +1,226 @@
+using System.Diagnostics;
+using FluentAssertions;
+using Markdown.TagUtils.Implementations;
+using Markdown.TokensUtils.Implementations;
+using NUnit.Framework;
+
+namespace Markdown.Tests;
+
+public class MarkdownTests
+{
+ private TagProcessor tagProcessor;
+ private Tokenizer tokenizer;
+ private IRender render;
+ private TagContext tagContext;
+ private TagStrategyFactory factory;
+
+ public MarkdownTests()
+ {
+ tagContext = new TagContext();
+ factory = new TagStrategyFactory(tagContext);
+ tagProcessor = new TagProcessor(tagContext, factory);
+ tokenizer = new Tokenizer();
+ render = new MarkdownRender(tokenizer, tagProcessor);
+ }
+
+ [TestCaseSource(nameof(MarkdownCases))]
+ public void Markdown_RenderText_ShouldMatchExpected(string line, string expectedAndErrorMessage)
+ {
+ var result = render.RenderText(line);
+ result.Should().Be(expectedAndErrorMessage, expectedAndErrorMessage);
+ }
+
+ [Test]
+ public void Markdown_RenderTime_ShouldScaleLinearly()
+ {
+ var sizes = new[] { 100, 1000, 3000, 100000 };
+ int? previousSize = null;
+ double? previousTime = null;
+ const int runsPerSize = 5;
+
+ foreach (var size in sizes)
+ {
+ var part = size / 3;
+ var lines = Enumerable.Range(1, size)
+ .Select(i =>
+ {
+ if (i <= part)
+ {
+ return $"_line {i}_";
+ }
+ return i <= part * 2 ? $"__line {i}__" : $"#line {i}";
+ });
+ var input = string.Join(Environment.NewLine, lines);
+ var times = new List(runsPerSize);
+
+ for (var run = 0; run < runsPerSize; run++)
+ {
+ var sw = Stopwatch.StartNew();
+ render.RenderText(input);
+ sw.Stop();
+ times.Add(sw.Elapsed.TotalMilliseconds);
+ }
+
+ var medianTime = times.OrderBy(t => t).ElementAt(runsPerSize / 2);
+
+ if (previousTime.HasValue && previousSize.HasValue)
+ {
+ var timeRatio = medianTime / previousTime.Value;
+ var sizeRatio = (double)size / previousSize.Value;
+
+ timeRatio.Should().BeLessThanOrEqualTo(sizeRatio * 1.5);
+ }
+
+ previousTime = medianTime;
+ previousSize = size;
+ }
+ }
+
+
+ public static IEnumerable MarkdownCases
+ {
+ get
+ {
+ yield return new TestCaseData(
+ "Текст, _окруженный с двух сторон_ одинарными символами подчерка",
+ "Текст, окруженный с двух сторон одинарными символами подчерка")
+ .SetName("SingleUnderscores_Em");
+
+ yield return new TestCaseData(
+ "__Выделенный двумя символами текст__ должен становиться полужирным",
+ "Выделенный двумя символами текст должен становиться полужирным")
+ .SetName("DoubleUnderscores_Strong");
+
+ yield return new TestCaseData(
+ "Любой тег можно экранировать \\ и даже \\",
+ "Любой тег можно экранировать \\ и даже \\")
+ .SetName("EscapeMarkdownTags");
+
+ yield return new TestCaseData(
+ "Любой символ можно экранировать, чтобы он не считался частью разметки.\n\\_Вот это\\_, " +
+ "не должно выделиться тегом \\. \n Также \\__Это не выделяется\\__ тегом \\.",
+ "Любой символ можно экранировать, чтобы он не считался частью разметки.\n\\_Вот это\\_, " +
+ "не должно выделиться тегом \\. \n Также \\__Это не выделяется\\__ тегом \\.")
+ .SetName("EscapedUnderscores_NoRender");
+
+ yield return new TestCaseData(
+ "Внутри __двойного выделения _одинарное_ тоже__ работает.",
+ "Внутри двойного выделения одинарное тоже работает.")
+ .SetName("Nested_StrongAndEm");
+
+ yield return new TestCaseData(
+ "Но не наоборот — внутри _одинарного __двойное__ не_ работает.",
+ "Но не наоборот — внутри одинарного __двойное__ не работает.")
+ .SetName("StrongInsideEm_NoRender");
+
+ yield return new TestCaseData(
+ "Подчерки внутри текста c цифрами_12_3 или 1__1__23 не считаются выделением и должны оставаться символами подчерка.",
+ "Подчерки внутри текста c цифрами_12_3 или 1__1__23 не считаются выделением и должны оставаться символами подчерка.")
+ .SetName("UnderscoresAroundNumbers_NoRender");
+
+ yield return new TestCaseData(
+ "Однако выделять часть слова они могут: и в _нач_але, и в сер_еди_не, и в кон__це.__",
+ "Однако выделять часть слова они могут: и в начале, и в середине, и в конце.")
+ .SetName("UnderscoresInsideWord_Render");
+
+ yield return new TestCaseData(
+ "В то же время выделение в ра_зных сл_овах не работает.",
+ "В то же время выделение в ра_зных сл_овах не работает.")
+ .SetName("UnderscoresAcrossWords_NoRender");
+
+ yield return new TestCaseData(
+ "__Непарные_ символы в рамках одного абзаца не считаются выделением.",
+ "__Непарные_ символы в рамках одного абзаца не считаются выделением.")
+ .SetName("UnmatchedUnderscores_NoRender");
+
+ yield return new TestCaseData(
+ "За подчерками, начинающими выделение, должен следовать непробельный символ. " +
+ "Иначе эти_ подчерки_ не считаются выделением \nи остаются просто символами подчерка.",
+ "За подчерками, начинающими выделение, должен следовать непробельный символ. " +
+ "Иначе эти_ подчерки_ не считаются выделением \nи остаются просто символами подчерка.")
+ .SetName("StartingUnderscoreFollowedByWhitespace_NoRender");
+
+ yield return new TestCaseData(
+ "Подчерки, заканчивающие выделение, должны следовать за непробельным символом. " +
+ "Иначе эти _подчерки _не считаются_ окончанием выделения \nи остаются просто символами подчерка.",
+ "Подчерки, заканчивающие выделение, должны следовать за непробельным символом. " +
+ "Иначе эти _подчерки не считаются окончанием выделения \nи остаются просто символами подчерка.")
+ .SetName("EndingUnderscorePrecededByWhitespace_Render");
+
+ yield return new TestCaseData(
+ "В случае __пересечения _двойных__ и одинарных_ подчерков ни один из них не считается выделением.\n",
+ "В случае __пересечения _двойных__ и одинарных_ подчерков ни один из них не считается выделением.\n")
+ .SetName("IntersectingSingleAndDoubleUnderscores_NoRender");
+
+ yield return new TestCaseData(
+ "Если внутри подчерков пустая строка _____, то они остаются символами подчерка.\n",
+ "Если внутри подчерков пустая строка _____, то они остаются символами подчерка.\n")
+ .SetName("EmptyDoubleUnderscores_NoRender");
+
+ yield return new TestCaseData(
+ "# Заголовок __с _разными_ символами__",
+ "Заголовок с разными символами
")
+ .SetName("H1Tag_Render");
+
+ yield return new TestCaseData(
+ "___Слово___",
+ "___Слово___")
+ .SetName("TripleUnderscore_NoRender");
+
+ yield return new TestCaseData(
+ "[репозиторий gogy](https://github.com/gogy4?tab=repositories)",
+ "репозиторий gogy")
+ .SetName("CorrectLink_Render");
+
+ yield return new TestCaseData(
+ "[Неполная ссылка(ссылка)",
+ "[Неполная ссылка(ссылка)")
+ .SetName("IncorrectLink_NoRender");
+
+ yield return new TestCaseData(
+ "[Google](https://google.com) и [YouTube](https://youtube.com)",
+ "Google и YouTube")
+ .SetName("MultipleLinks_RenderAll");
+
+ yield return new TestCaseData(
+ "[Текст с ошибкой[внутри]()",
+ "[Текст с ошибкой[внутри]()")
+ .SetName("IncorrectLink_NestedBrackets");
+
+ yield return new TestCaseData(
+ "[Ссылка без закрытия](https://example.com",
+ "[Ссылка без закрытия](https://example.com")
+ .SetName("IncorrectLink_MissingClosingParenthesis");
+
+ yield return new TestCaseData(
+ "[Ссылка без открытия]https://example.com",
+ "[Ссылка без открытия]https://example.com")
+ .SetName("IncorrectLink_MissingOpeningParenthesis");
+
+ yield return new TestCaseData(
+ "Текст без открывающей скобки](https://example.com)",
+ "Текст без открывающей скобки](https://example.com)")
+ .SetName("IncorrectLink_MissingOpeningBracket");
+
+ yield return new TestCaseData(
+ "[Текст без закрывающей скобки(https://example.com)",
+ "[Текст без закрывающей скобки(https://example.com)")
+ .SetName("IncorrectLink_MissingClosingBracket");
+
+ yield return new TestCaseData(
+ "(https://example.com)[Неправильный порядок]",
+ "(https://example.com)[Неправильный порядок]")
+ .SetName("IncorrectLink_ReversedOrder");
+
+ yield return new TestCaseData(
+ "[](Ссылка без текста https://example.com)",
+ "[](Ссылка без текста https://example.com)")
+ .SetName("IncorrectLink_EmptyText");
+
+ yield return new TestCaseData(
+ "[Текст с пустой ссылкой]()",
+ "[Текст с пустой ссылкой]()")
+ .SetName("IncorrectLink_EmptyUrl");
+ }
+ }
+}
\ No newline at end of file
diff --git a/cs/Markdown/TokensUtils/Abstractions/ITokenizer.cs b/cs/Markdown/TokensUtils/Abstractions/ITokenizer.cs
new file mode 100644
index 000000000..be54372d6
--- /dev/null
+++ b/cs/Markdown/TokensUtils/Abstractions/ITokenizer.cs
@@ -0,0 +1,8 @@
+using Markdown.Dto;
+
+namespace Markdown.TokensUtils.Abstractions;
+
+public interface ITokenizer
+{
+ public IEnumerable Tokenize(string? line);
+}
\ No newline at end of file
diff --git a/cs/Markdown/TokensUtils/Implementations/Tokenizer.cs b/cs/Markdown/TokensUtils/Implementations/Tokenizer.cs
new file mode 100644
index 000000000..ca139bd20
--- /dev/null
+++ b/cs/Markdown/TokensUtils/Implementations/Tokenizer.cs
@@ -0,0 +1,53 @@
+using System.Text;
+using Markdown.Dto;
+using Markdown.TokensUtils.Abstractions;
+
+namespace Markdown.TokensUtils.Implementations;
+
+public class Tokenizer : ITokenizer
+{
+ private static readonly HashSet SpecialSymbols = ['\\', '_', '#', ')', '['];
+
+ public IEnumerable Tokenize(string? line)
+ {
+ ArgumentNullException.ThrowIfNull(line);
+
+ var sb = new StringBuilder();
+ var i = 0;
+
+ while (i < line.Length)
+ {
+ var c = line[i];
+
+ if (SpecialSymbols.Contains(c) && sb.Length > 0)
+ {
+ yield return new Token(sb.ToString(), TokenType.Text, false, false);
+ sb.Clear();
+ }
+
+ var token = MapSpecialSymbol.Specialize(c, line, i);
+ if (token != null)
+ {
+ yield return token;
+
+ if (token.Type is TokenType.Header or TokenType.Strong)
+ {
+ i++;
+ }
+ }
+ else
+ {
+ sb.Append(c);
+ }
+
+ i++;
+ }
+
+ if (sb.Length > 0)
+ {
+ yield return new Token(sb.ToString(), TokenType.Text, false, false);
+ }
+
+ yield return new Token(string.Empty, TokenType.End, false, false);
+ }
+}
\ No newline at end of file
diff --git a/cs/Markdown/TokensUtils/MapSpecialSymbol.cs b/cs/Markdown/TokensUtils/MapSpecialSymbol.cs
new file mode 100644
index 000000000..317910a82
--- /dev/null
+++ b/cs/Markdown/TokensUtils/MapSpecialSymbol.cs
@@ -0,0 +1,73 @@
+namespace Markdown.TokensUtils
+{
+ using Markdown.Dto;
+
+ public static class MapSpecialSymbol
+ {
+ public static Token? Specialize(char c, string line, int index)
+ {
+ return c switch
+ {
+ '[' => CreateLinkToken(line, index, true),
+ ')' => CreateLinkToken(line, index, false),
+ '_' when index + 1 < line.Length && line[index + 1] == '_' =>
+ CreateUnderscoreToken(line, index, true),
+ '_' => CreateUnderscoreToken(line, index, false),
+ '#' when index + 1 < line.Length && line[index + 1] == ' ' =>
+ CreateHeaderToken(line, index),
+ '\\' when index + 1 < line.Length =>
+ new Token(line[index].ToString(), TokenType.Escape, TokenRole.None, TokenPosition.None),
+ _ => null
+ };
+ }
+
+ private static CharContext GetCharContext(string line, int index, int tokenLength)
+ {
+ var prevChar = index > 0 ? line[index - 1] : (char?)null;
+ var nextChar = index + tokenLength < line.Length ? line[index + tokenLength] : (char?)null;
+ var canOpen = nextChar != null && !char.IsWhiteSpace(nextChar.Value);
+ var canClose = prevChar != null && !char.IsWhiteSpace(prevChar.Value);
+ return new CharContext(nextChar, canOpen, canClose);
+ }
+
+ private static Token CreateUnderscoreToken(string line, int index, bool isStrong)
+ {
+ return CreateToken(line, index, isStrong ? "__" : "_",
+ isStrong ? TokenType.Strong : TokenType.Italic,
+ charContext => (charContext.CanOpen, charContext.CanClose));
+ }
+
+
+ private static Token CreateLinkToken(string line, int index, bool isOpen)
+ {
+ return CreateToken(line, index, isOpen ? "[" : ")",
+ TokenType.Link,
+ charContext => (isOpen && charContext.CanOpen, !isOpen && charContext.CanClose));
+ }
+
+
+ private static Token CreateHeaderToken(string line, int index)
+ {
+ return CreateToken(line, index, "#",
+ TokenType.Header,
+ ctx =>
+ {
+ var canOpenClose = ctx.Next != null && char.IsWhiteSpace(ctx.Next.Value);
+ return (canOpenClose, canOpenClose);
+ });
+ }
+
+
+ private static Token CreateToken(
+ string line,
+ int index,
+ string value,
+ TokenType type,
+ Func roleSelector)
+ {
+ var ctx = GetCharContext(line, index, value.Length);
+ var (canOpen, canClose) = roleSelector(ctx);
+ return new Token(value, type, canOpen, canClose);
+ }
+ }
+}
\ No newline at end of file
diff --git a/cs/Markdown/TokensUtils/TagRender.cs b/cs/Markdown/TokensUtils/TagRender.cs
new file mode 100644
index 000000000..2f09fc004
--- /dev/null
+++ b/cs/Markdown/TokensUtils/TagRender.cs
@@ -0,0 +1,26 @@
+using Markdown.Dto;
+
+namespace Markdown.TokensUtils
+{
+ public static class TagRender
+ {
+ public static string Wrap(TokenType type, string content, string url = null)
+ => type switch
+ {
+ TokenType.Italic => $"{content}",
+ TokenType.Strong => $"{content}",
+ TokenType.Escape => $"{content}",
+ TokenType.Header => $"{content}
",
+ TokenType.Link => WrapLink(content),
+ _ => content
+ };
+
+ private static string WrapLink(string content)
+ {
+ var parts = content.Split(["]("], StringSplitOptions.None);
+ return parts.Length != 2
+ ? throw new ArgumentException("Invalid link format")
+ : $"{parts[0]}";
+ }
+ }
+}
\ No newline at end of file