From d37d48c8ec272b86a0d7d6aee9304c7bdd7ccc9a Mon Sep 17 00:00:00 2001 From: younggogy Date: Mon, 3 Nov 2025 18:46:57 +0500 Subject: [PATCH 01/32] =?UTF-8?q?=D0=9F=D1=80=D0=BE=D0=B2=D0=B5=D0=BB=20?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D0=B5=D0=BA=D1=82=D0=B8=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D0=B5,=20=D0=B2=D1=8B=D0=B4=D0=B5=D0=BB?= =?UTF-8?q?=D0=B8=D0=BB=20=D0=B8=D0=BD=D1=82=D0=B5=D1=80=D1=84=D0=B5=D0=B9?= =?UTF-8?q?=D1=81=D1=8B=20=D1=81=20=D0=BA=D0=BB=D0=B0=D1=81=D1=81=D0=B0?= =?UTF-8?q?=D0=BC=D0=B8=20=D0=B8=20extension=20=D0=BC=D0=B5=D1=82=D0=BE?= =?UTF-8?q?=D0=B4=D0=BE=D0=BC=20=D0=B4=D0=BB=D1=8F=20string?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cs/Markdown/Extensions/StringExtensions.cs | 10 ++++++++++ cs/Markdown/Markdown.csproj | 9 +++++++++ .../RenderServices/Abstractions/IRender.cs | 6 ++++++ .../Abstractions/IRenderFragment.cs | 6 ++++++ .../RenderServices/Implementations/Render.cs | 16 ++++++++++++++++ .../Implementations/RenderFragment.cs | 10 ++++++++++ .../TokensUtils/Abstractions/ITokenizer.cs | 6 ++++++ .../TokensUtils/Implementations/Tokenizer.cs | 12 ++++++++++++ cs/Markdown/TokensUtils/TagRenderer.cs | 7 +++++++ cs/Markdown/TokensUtils/Token.cs | 13 +++++++++++++ cs/Markdown/TokensUtils/TokenType.cs | 6 ++++++ 11 files changed, 101 insertions(+) create mode 100644 cs/Markdown/Extensions/StringExtensions.cs create mode 100644 cs/Markdown/Markdown.csproj create mode 100644 cs/Markdown/RenderServices/Abstractions/IRender.cs create mode 100644 cs/Markdown/RenderServices/Abstractions/IRenderFragment.cs create mode 100644 cs/Markdown/RenderServices/Implementations/Render.cs create mode 100644 cs/Markdown/RenderServices/Implementations/RenderFragment.cs create mode 100644 cs/Markdown/TokensUtils/Abstractions/ITokenizer.cs create mode 100644 cs/Markdown/TokensUtils/Implementations/Tokenizer.cs create mode 100644 cs/Markdown/TokensUtils/TagRenderer.cs create mode 100644 cs/Markdown/TokensUtils/Token.cs create mode 100644 cs/Markdown/TokensUtils/TokenType.cs diff --git a/cs/Markdown/Extensions/StringExtensions.cs b/cs/Markdown/Extensions/StringExtensions.cs new file mode 100644 index 000000000..3a5a1f888 --- /dev/null +++ b/cs/Markdown/Extensions/StringExtensions.cs @@ -0,0 +1,10 @@ +namespace Markdown.Extensions; + +public static class StringExtensions +{ + public static IEnumerable EnumerateLines(this string markdown) + { + // Разделяет текст по \n, но сохраняет порядок и пустые строки + throw new NotImplementedException(); + } +} \ No newline at end of file diff --git a/cs/Markdown/Markdown.csproj b/cs/Markdown/Markdown.csproj new file mode 100644 index 000000000..17b910f6d --- /dev/null +++ b/cs/Markdown/Markdown.csproj @@ -0,0 +1,9 @@ + + + + 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/Abstractions/IRenderFragment.cs b/cs/Markdown/RenderServices/Abstractions/IRenderFragment.cs new file mode 100644 index 000000000..5c800e1fd --- /dev/null +++ b/cs/Markdown/RenderServices/Abstractions/IRenderFragment.cs @@ -0,0 +1,6 @@ +namespace Markdown; + +public interface IRenderFragment +{ + public string RenderLine(string line); +} \ No newline at end of file diff --git a/cs/Markdown/RenderServices/Implementations/Render.cs b/cs/Markdown/RenderServices/Implementations/Render.cs new file mode 100644 index 000000000..d9ca13c2f --- /dev/null +++ b/cs/Markdown/RenderServices/Implementations/Render.cs @@ -0,0 +1,16 @@ +namespace Markdown; + +public class Render : IRender +{ + private readonly IRenderFragment renderFragment = new RenderFragment(); + + public string RenderText(string markdown) + { + //сделаем коллекцию строк из большого текста + //будем бежать по каждой строке и применять рендер к ней + //после либо создадим новую коллекцию, в которой будут измененные строки либо будем изменять текущую(я за + //то, чтобы изменять текущую) + //создадим строку через стрингбилдер из коллекции и вернем ее. + throw new NotImplementedException(); + } +} \ No newline at end of file diff --git a/cs/Markdown/RenderServices/Implementations/RenderFragment.cs b/cs/Markdown/RenderServices/Implementations/RenderFragment.cs new file mode 100644 index 000000000..074a7473e --- /dev/null +++ b/cs/Markdown/RenderServices/Implementations/RenderFragment.cs @@ -0,0 +1,10 @@ +namespace Markdown; + +public class RenderFragment : IRenderFragment +{ + public string RenderLine(string line) + { + //рендерит по определенным правилам строку, будут добавлены методы для рендеринга по определенным символам + throw new NotImplementedException(); + } +} \ 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..a299d52e7 --- /dev/null +++ b/cs/Markdown/TokensUtils/Abstractions/ITokenizer.cs @@ -0,0 +1,6 @@ +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..4b90769ff --- /dev/null +++ b/cs/Markdown/TokensUtils/Implementations/Tokenizer.cs @@ -0,0 +1,12 @@ +using Markdown.TokensUtils.Abstractions; + +namespace Markdown.TokensUtils; + +public class Tokenizer : ITokenizer +{ + public IEnumerable Tokenize(string line) + { + // Проходит по строке посимвольно, обрабатывает экранирование и спецсимволы + throw new NotImplementedException(); + } +} \ No newline at end of file diff --git a/cs/Markdown/TokensUtils/TagRenderer.cs b/cs/Markdown/TokensUtils/TagRenderer.cs new file mode 100644 index 000000000..6401083ca --- /dev/null +++ b/cs/Markdown/TokensUtils/TagRenderer.cs @@ -0,0 +1,7 @@ +namespace Markdown.TokensUtils; + +public static class TagRenderer +{ + public static string WrapEm(string content) => $"{content}"; + //остальные методы по аналогии для каждого тега +} \ No newline at end of file diff --git a/cs/Markdown/TokensUtils/Token.cs b/cs/Markdown/TokensUtils/Token.cs new file mode 100644 index 000000000..fd56c2b7f --- /dev/null +++ b/cs/Markdown/TokensUtils/Token.cs @@ -0,0 +1,13 @@ +namespace Markdown.TokensUtils; + +public class Token +{ + public string Value { get; set; } + public string TokenType { get; set; } + + public Token(string value, string tokenType) + { + Value = value; + TokenType = tokenType; + } +} \ No newline at end of file diff --git a/cs/Markdown/TokensUtils/TokenType.cs b/cs/Markdown/TokensUtils/TokenType.cs new file mode 100644 index 000000000..49e8538ee --- /dev/null +++ b/cs/Markdown/TokensUtils/TokenType.cs @@ -0,0 +1,6 @@ +namespace Markdown.TokensUtils; + +public enum TokenType +{ + //будет хранить тип для токенов Text, italic и тд +} \ No newline at end of file From 0739f53c0760e7979ad497e57cd91da8633e9029 Mon Sep 17 00:00:00 2001 From: younggogy Date: Mon, 3 Nov 2025 19:38:47 +0500 Subject: [PATCH 02/32] =?UTF-8?q?=D0=B8=D0=B7=D0=BC=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D1=82=D1=8C=20=D0=BC=D0=B5=D1=82=D0=BE=D0=B4=20Wrap=20=D0=B2?= =?UTF-8?q?=20=D0=B7=D0=B0=D0=B2=D0=B8=D1=81=D0=B8=D0=BC=D0=BE=D1=81=D1=82?= =?UTF-8?q?=D0=B8=20=D0=BE=D1=82=20TokenType,=20=D0=BF=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=D0=B5=D0=BB=20=D0=BD=D0=B5=D0=B1=D0=BE=D0=BB=D1=8C=D1=88=D0=BE?= =?UTF-8?q?=D0=B9=20=D1=80=D0=B5=D1=84=D0=B0=D0=BA=D1=82=D0=BE=D1=80=D0=B8?= =?UTF-8?q?=D0=BD=D0=B3=20=D0=B2=20=D0=BA=D0=BB=D0=B0=D1=81=D1=81=D0=B5=20?= =?UTF-8?q?Token.cs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cs/Markdown/TokensUtils/TagRenderer.cs | 15 ++++++++++----- cs/Markdown/TokensUtils/Token.cs | 6 +++--- cs/Markdown/TokensUtils/TokenType.cs | 2 +- 3 files changed, 14 insertions(+), 9 deletions(-) diff --git a/cs/Markdown/TokensUtils/TagRenderer.cs b/cs/Markdown/TokensUtils/TagRenderer.cs index 6401083ca..b30c6f959 100644 --- a/cs/Markdown/TokensUtils/TagRenderer.cs +++ b/cs/Markdown/TokensUtils/TagRenderer.cs @@ -1,7 +1,12 @@ -namespace Markdown.TokensUtils; - -public static class TagRenderer +namespace Markdown.TokensUtils { - public static string WrapEm(string content) => $"{content}"; - //остальные методы по аналогии для каждого тега + public static class TagRenderer + { + public static string Wrap(TokenType type, string content) + => type switch + { + TokenType.Italic => $"{content}", + _ => content + }; + } } \ No newline at end of file diff --git a/cs/Markdown/TokensUtils/Token.cs b/cs/Markdown/TokensUtils/Token.cs index fd56c2b7f..40b5aa239 100644 --- a/cs/Markdown/TokensUtils/Token.cs +++ b/cs/Markdown/TokensUtils/Token.cs @@ -3,11 +3,11 @@ namespace Markdown.TokensUtils; public class Token { public string Value { get; set; } - public string TokenType { get; set; } + public TokenType Type { get; set; } - public Token(string value, string tokenType) + public Token(string value, TokenType type) { Value = value; - TokenType = tokenType; + Type = type; } } \ No newline at end of file diff --git a/cs/Markdown/TokensUtils/TokenType.cs b/cs/Markdown/TokensUtils/TokenType.cs index 49e8538ee..426677737 100644 --- a/cs/Markdown/TokensUtils/TokenType.cs +++ b/cs/Markdown/TokensUtils/TokenType.cs @@ -2,5 +2,5 @@ namespace Markdown.TokensUtils; public enum TokenType { - //будет хранить тип для токенов Text, italic и тд + Italic } \ No newline at end of file From 3fb0ae822b29235ad7232a24bd69e275ed415d63 Mon Sep 17 00:00:00 2001 From: younggogy Date: Wed, 5 Nov 2025 03:53:29 +0500 Subject: [PATCH 03/32] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D0=BB=20=D0=BD=D0=BE=D0=B2=D1=8B=D0=B5=20=D1=82=D0=BE=D0=BA?= =?UTF-8?q?=D0=B5=D0=BD=D1=8B,=20=D1=80=D0=B5=D0=B0=D0=BB=D0=B8=D0=B7?= =?UTF-8?q?=D0=BE=D0=B2=D0=B0=D0=BB=20=D0=BB=D0=BE=D0=B3=D0=B8=D0=BA=D1=83?= =?UTF-8?q?=20Tokenizer.cs=20=D0=B8=20TagRender.cs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../TokensUtils/Implementations/Tokenizer.cs | 57 ++++++++++++++++++- .../{TagRenderer.cs => TagRender.cs} | 5 +- cs/Markdown/TokensUtils/TokenType.cs | 7 ++- 3 files changed, 64 insertions(+), 5 deletions(-) rename cs/Markdown/TokensUtils/{TagRenderer.cs => TagRender.cs} (53%) diff --git a/cs/Markdown/TokensUtils/Implementations/Tokenizer.cs b/cs/Markdown/TokensUtils/Implementations/Tokenizer.cs index 4b90769ff..08e1cb22c 100644 --- a/cs/Markdown/TokensUtils/Implementations/Tokenizer.cs +++ b/cs/Markdown/TokensUtils/Implementations/Tokenizer.cs @@ -1,12 +1,63 @@ +using System.Text; using Markdown.TokensUtils.Abstractions; namespace Markdown.TokensUtils; public class Tokenizer : ITokenizer { - public IEnumerable Tokenize(string line) + public IEnumerable Tokenize(string? line) { - // Проходит по строке посимвольно, обрабатывает экранирование и спецсимволы - throw new NotImplementedException(); + ArgumentNullException.ThrowIfNull(line); + + var tokenizeLineStringBuilder = new StringBuilder(); + var i = 0; + while (i < line.Length) + { + var currentChar = line[i]; + + if (IsSpecialSymbol(currentChar) && tokenizeLineStringBuilder.Length > 0) + { + yield return new Token(tokenizeLineStringBuilder.ToString(), TokenType.Text); + tokenizeLineStringBuilder.Clear(); + } + + switch (currentChar) + { + case '\\': + yield return new Token("\\", TokenType.Escape); + i++; + break; + case '_' when i + 1 < line.Length && line[i + 1] == '_': + yield return new Token("__", TokenType.Strong); + i += 2; + break; + case '_': + yield return new Token("_", TokenType.Italic); + i++; + break; + case '#' when i + 1 < line.Length && line[i + 1] == ' ': + yield return new Token("#", TokenType.Header); + i+=2; + break; + default: + tokenizeLineStringBuilder.Append(currentChar); + i++; + break; + } + } + + if (tokenizeLineStringBuilder.Length > 0) + yield return new Token(tokenizeLineStringBuilder.ToString(), TokenType.Text); + + yield return new Token(string.Empty, TokenType.End); + } + + private bool IsSpecialSymbol(char c) + { + return c switch + { + '\\' or '_' or '#' => true, + _ => false + }; } } \ No newline at end of file diff --git a/cs/Markdown/TokensUtils/TagRenderer.cs b/cs/Markdown/TokensUtils/TagRender.cs similarity index 53% rename from cs/Markdown/TokensUtils/TagRenderer.cs rename to cs/Markdown/TokensUtils/TagRender.cs index b30c6f959..a9dd2973f 100644 --- a/cs/Markdown/TokensUtils/TagRenderer.cs +++ b/cs/Markdown/TokensUtils/TagRender.cs @@ -1,11 +1,14 @@ namespace Markdown.TokensUtils { - public static class TagRenderer + public static class TagRender { public static string Wrap(TokenType type, string content) => type switch { TokenType.Italic => $"{content}", + TokenType.Strong => $"{content}", + TokenType.Escape => $"{content}", + TokenType.Header => $"

{content}

", _ => content }; } diff --git a/cs/Markdown/TokensUtils/TokenType.cs b/cs/Markdown/TokensUtils/TokenType.cs index 426677737..d9f2d67bb 100644 --- a/cs/Markdown/TokensUtils/TokenType.cs +++ b/cs/Markdown/TokensUtils/TokenType.cs @@ -2,5 +2,10 @@ namespace Markdown.TokensUtils; public enum TokenType { - Italic + Italic, + Strong, + Text, + Escape, + Header, + End } \ No newline at end of file From c4a3f9057fa8f6f2602210d5b0e0f7216d28f532 Mon Sep 17 00:00:00 2001 From: younggogy Date: Wed, 5 Nov 2025 03:54:28 +0500 Subject: [PATCH 04/32] =?UTF-8?q?=D0=9F=D0=B5=D1=80=D0=B5=D0=BD=D0=B5?= =?UTF-8?q?=D1=81=20=D0=BC=D0=B5=D1=82=D0=BE=D0=B4=20RenderLine=20=D0=B2?= =?UTF-8?q?=20=D0=BA=D0=BB=D0=B0=D1=81=D1=81=20Render.cs=20=D0=B8=20=D0=B4?= =?UTF-8?q?=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D0=BB=20=D0=BD=D0=B5=D0=BA=D0=BE?= =?UTF-8?q?=D1=82=D0=BE=D1=80=D1=83=D1=8E=20=D0=BB=D0=BE=D0=B3=D0=B8=D0=BA?= =?UTF-8?q?=D1=83=20=D1=81=20=D0=BE=D0=B1=D1=80=D0=B0=D0=B1=D0=BE=D1=82?= =?UTF-8?q?=D0=BA=D0=BE=D0=B9=20=5F=20=D0=B8=20=5F=5F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cs/Markdown/Extensions/StringExtensions.cs | 25 +++- .../Abstractions/IRenderFragment.cs | 6 - .../RenderServices/Implementations/Render.cs | 140 +++++++++++++++++- .../Implementations/RenderFragment.cs | 10 -- .../TokensUtils/Abstractions/ITokenizer.cs | 2 +- 5 files changed, 153 insertions(+), 30 deletions(-) delete mode 100644 cs/Markdown/RenderServices/Abstractions/IRenderFragment.cs delete mode 100644 cs/Markdown/RenderServices/Implementations/RenderFragment.cs diff --git a/cs/Markdown/Extensions/StringExtensions.cs b/cs/Markdown/Extensions/StringExtensions.cs index 3a5a1f888..9475b21fa 100644 --- a/cs/Markdown/Extensions/StringExtensions.cs +++ b/cs/Markdown/Extensions/StringExtensions.cs @@ -1,10 +1,23 @@ -namespace Markdown.Extensions; - -public static class StringExtensions +namespace Markdown.Extensions { - public static IEnumerable EnumerateLines(this string markdown) + public static class StringExtensions { - // Разделяет текст по \n, но сохраняет порядок и пустые строки - throw new NotImplementedException(); + 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/RenderServices/Abstractions/IRenderFragment.cs b/cs/Markdown/RenderServices/Abstractions/IRenderFragment.cs deleted file mode 100644 index 5c800e1fd..000000000 --- a/cs/Markdown/RenderServices/Abstractions/IRenderFragment.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Markdown; - -public interface IRenderFragment -{ - public string RenderLine(string line); -} \ No newline at end of file diff --git a/cs/Markdown/RenderServices/Implementations/Render.cs b/cs/Markdown/RenderServices/Implementations/Render.cs index d9ca13c2f..2e3f98e95 100644 --- a/cs/Markdown/RenderServices/Implementations/Render.cs +++ b/cs/Markdown/RenderServices/Implementations/Render.cs @@ -1,16 +1,142 @@ -namespace Markdown; +using System.Text; +using Markdown.Extensions; +using Markdown.TokensUtils; +using Markdown.TokensUtils.Abstractions; + +namespace Markdown; public class Render : IRender { - private readonly IRenderFragment renderFragment = new RenderFragment(); + private readonly ITokenizer tokenizer = new Tokenizer(); 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(); + var result = new StringBuilder(); + var tagStack = new Stack<(TokenType Type, StringBuilder Content)>(); + + var skipNextAsMarkup = false; + + foreach (var token in tokens) + { + if (skipNextAsMarkup) + { + AppendToParentOrResult(tagStack, result, token.Value); + skipNextAsMarkup = false; + continue; + } + + switch (token.Type) + { + case TokenType.Text: + AppendToParentOrResult(tagStack, result, token.Value); + break; + + case TokenType.Escape: + AppendToParentOrResult(tagStack, result, token.Value); + skipNextAsMarkup = true; + break; + + case TokenType.Italic: + ProcessItalic(token, tagStack, result); + break; + + case TokenType.Strong: + ProcessStrong(token, tagStack, result); + break; + + case TokenType.Header: + result.Append(ProcessHeader(tokens)); + return result.ToString(); + + case TokenType.End: + ProcessEnd(tagStack, result); + break; + + default: + throw new ArgumentOutOfRangeException(); + } + } + + + return result.ToString(); + } + + private void ProcessStrong(Token token, Stack<(TokenType Type, StringBuilder Content)> tagStack, + StringBuilder result) + { + if (tagStack.Count > 0) + { + var parent = tagStack.Peek(); + var parentType = parent.Type; + + if (parentType == TokenType.Italic) + AppendToParentOrResult(tagStack, result, token.Value); + else + CloseTopTag(tagStack, result); + } + else + { + tagStack.Push((token.Type, new StringBuilder())); + } + } + + private void ProcessItalic(Token token, Stack<(TokenType Type, StringBuilder Content)> tagStack, + StringBuilder result) + { + if (tagStack.Count > 0 && tagStack.Peek().Type == token.Type) + { + CloseTopTag(tagStack, result); + } + else + { + tagStack.Push((token.Type, new StringBuilder())); + } + } + + private string ProcessHeader(List tokens) + { throw new NotImplementedException(); } + + private void ProcessEnd(Stack<(TokenType Type, StringBuilder Content)> tagStack, StringBuilder result) + { + while (tagStack.Count > 0) + { + CloseTopTag(tagStack, result); + } + } + + private void AppendToParentOrResult(Stack<(TokenType Type, StringBuilder Content)> tagStack, StringBuilder result, string content) + { + if (tagStack.Count > 0) + tagStack.Peek().Content.Append(content); + else + result.Append(content); + } + + private void CloseTopTag(Stack<(TokenType Type, StringBuilder Content)> tagStack, StringBuilder result) + { + var top = tagStack.Pop(); + var wrapped = TagRender.Wrap(top.Type, top.Content.ToString()); + + AppendToParentOrResult(tagStack, result, wrapped); + } } \ No newline at end of file diff --git a/cs/Markdown/RenderServices/Implementations/RenderFragment.cs b/cs/Markdown/RenderServices/Implementations/RenderFragment.cs deleted file mode 100644 index 074a7473e..000000000 --- a/cs/Markdown/RenderServices/Implementations/RenderFragment.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace Markdown; - -public class RenderFragment : IRenderFragment -{ - public string RenderLine(string line) - { - //рендерит по определенным правилам строку, будут добавлены методы для рендеринга по определенным символам - throw new NotImplementedException(); - } -} \ No newline at end of file diff --git a/cs/Markdown/TokensUtils/Abstractions/ITokenizer.cs b/cs/Markdown/TokensUtils/Abstractions/ITokenizer.cs index a299d52e7..14d255306 100644 --- a/cs/Markdown/TokensUtils/Abstractions/ITokenizer.cs +++ b/cs/Markdown/TokensUtils/Abstractions/ITokenizer.cs @@ -2,5 +2,5 @@ namespace Markdown.TokensUtils.Abstractions; public interface ITokenizer { - public IEnumerable Tokenize(string line); + public IEnumerable Tokenize(string? line); } \ No newline at end of file From 87a105f8ed745939e93d36eecf36d855131ef7c4 Mon Sep 17 00:00:00 2001 From: younggogy Date: Wed, 5 Nov 2025 03:55:08 +0500 Subject: [PATCH 05/32] =?UTF-8?q?=D0=9D=D0=B0=D0=BF=D0=B8=D1=81=D0=B0?= =?UTF-8?q?=D0=BB=20=D1=82=D0=B5=D1=81=D1=82=D1=8B,=20=D0=BA=D0=BE=D1=82?= =?UTF-8?q?=D0=BE=D1=80=D1=8B=D0=B9=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D1=80?= =?UTF-8?q?=D1=8F=D1=8E=D1=82=20=D0=BB=D0=BE=D0=B3=D0=B8=D0=BA=D1=83=20?= =?UTF-8?q?=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D1=8B=20=D1=80=D0=B5=D0=BD=D0=B4?= =?UTF-8?q?=D0=B5=D1=80=D0=B0=20=D1=81=20=5F=20=D0=B8=20=5F=5F.=20=D0=9F?= =?UTF-8?q?=D0=BE=D0=BB=D0=BE=D0=B2=D0=B8=D0=BD=D0=B0=20=D1=82=D0=B5=D1=81?= =?UTF-8?q?=D1=82=D0=BE=D0=B2=20=D0=BF=D0=B0=D0=B4=D0=B0=D1=8E=D1=82,=20?= =?UTF-8?q?=D1=82.=D0=BA.=20=D0=BB=D0=BE=D0=B3=D0=B8=D0=BA=D1=83=20=D0=BD?= =?UTF-8?q?=D0=B5=20=D0=B4=D0=BE=20=D0=BA=D0=BE=D0=BD=D1=86=D0=B0=20=D1=80?= =?UTF-8?q?=D0=B5=D0=B0=D0=BB=D0=B8=D0=B7=D0=BE=D0=B2=D0=B0=D0=BD=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cs/Markdown/Markdown.csproj | 7 ++ cs/Markdown/Tests/MarkdownTests.cs | 158 +++++++++++++++++++++++++++++ 2 files changed, 165 insertions(+) create mode 100644 cs/Markdown/Tests/MarkdownTests.cs diff --git a/cs/Markdown/Markdown.csproj b/cs/Markdown/Markdown.csproj index 17b910f6d..6452b2ab0 100644 --- a/cs/Markdown/Markdown.csproj +++ b/cs/Markdown/Markdown.csproj @@ -6,4 +6,11 @@ enable + + + + + + + diff --git a/cs/Markdown/Tests/MarkdownTests.cs b/cs/Markdown/Tests/MarkdownTests.cs new file mode 100644 index 000000000..fac4083d2 --- /dev/null +++ b/cs/Markdown/Tests/MarkdownTests.cs @@ -0,0 +1,158 @@ +using FluentAssertions; +using NUnit.Framework; + +namespace Markdown.Tests; + +public class MarkdownTests +{ + private IRender render = new Render(); + + [Test] + public void Markdown_ShouldBeTextWrappedWithEmTag_WhenSurroundedBySingleUnderscores_Test() + { + var line = + "Текст, _окруженный с двух сторон_ одинарными символами подчерка"; + + var result = render.RenderText(line); + var expected = "Текст, окруженный с двух сторон одинарными символами подчерка"; + result + .Should() + .Be(expected); + } + + [Test] + public void Markdown_ShouldBeTextWrappedWithStrongTag_WhenSurroundedByDoubleUnderscores_Test() + { + var line = "__Выделенный двумя символами текст__ должен становиться полужирным"; + + var result = render.RenderText(line); + var expected = "Выделенный двумя символами текст должен становиться полужирным"; + result + .Should() + .Be(expected); + } + + [Test] + public void Markdown_ShouldAddEscapeCharacter_WhenTextContainsMarkdownTags_Test() + { + var line = "Любой тег можно экранировать \\ и даже \\"; + var result = render.RenderText(line); + var expected = "Любой тег можно экранировать \\ и даже \\"; + result + .Should() + .Be(expected); + } + + [Test] + public void Markdown_ShouldBeTextNotWrappedWithEmTag_WhenUnderscoresAreEscaped_Test() + { + var line = + "Любой символ можно экранировать, чтобы он не считался частью разметки.\n\\_Вот это\\_, не должно выделиться тегом \\. \n Также \\__Это не выделяется\\__ тегом \\."; + var result = render.RenderText(line); + var expected = + "Любой символ можно экранировать, чтобы он не считался частью разметки.\n\\_Вот это\\_, не должно выделиться тегом \\. \n Также \\__Это не выделяется\\__ тегом \\."; + result + .Should() + .Be(expected); + } + + [Test] + public void Markdown_ShouldRenderNestedTagsCorrectly_WhenDoubleAndSingleUnderscoresAreMixed_Test() + { + var line = "Внутри __двойного выделения _одинарное_ тоже__ работает."; + var result = render.RenderText(line); + var expected = "Внутри двойного выделения одинарное тоже работает."; + + result + .Should() + .Be(expected); + } + + [Test] + public void Markdown_ShouldNotRenderStrongInsideEm_WhenDoubleUnderscoresInsideSingleUnderscore_Test() + { + var line = "Но не наоборот — внутри _одинарного __двойное__ не_ работает."; + var result = render.RenderText(line); + var expected = "Но не наоборот — внутри одинарного __двойное__ не работает."; + result.Should().Be(expected); + } + + [Test] + public void Markdown_ShouldNotRenderEmOrStrong_WhenUnderscoresSurroundNumbers_Test() + { + var line = "Подчерки внутри текста c цифрами_12__3 не считаются выделением и должны оставаться символами подчерка."; + var result = render.RenderText(line); + var expected = + "Подчерки внутри текста c цифрами_12__3 не считаются выделением и должны оставаться символами подчерка."; + + result.Should().Be(expected); + } + + [Test] + public void Markdown_ShouldRenderEmOrStrong_WhenUnderscoresInsideWord_Test() + { + var line = "Однако выделять часть слова они могут: и в _нач_але, и в сер_еди_не, и в кон__це.__"; + var result = render.RenderText(line); + var expected = "Однако выделять часть слова они могут: и в начале, и в середине, и в конце."; + result.Should().Be(expected); + } + + [Test] + public void Markdown_ShouldNotRenderEmOrStrong_WhenUnderscoresAcrossMultipleWords_Test() + { + var line = "В то же время выделение в ра_зных сл_овах не работает."; + var result = render.RenderText(line); + var expected = "В то же время выделение в ра_зных сл_овах не работает."; + result.Should().Be(expected); + } + + [Test] + public void Markdown_ShouldNotRenderEmOrStrong_WhenUnmatchedUnderscoresInParagraph_Test() + { + var line = "__Непарные_ символы в рамках одного абзаца не считаются выделением."; + var result = render.RenderText(line); + var expected = "__Непарные_ символы в рамках одного абзаца не считаются выделением."; + result.Should().Be(expected); + } + + [Test] + public void Markdown_ShouldNotRenderEmOrStrong_WhenUnderscoreFollowedByWhitespace_Test() + { + var line = + "За подчерками, начинающими выделение, должен следовать непробельный символ. Иначе эти_ подчерки_ не считаются выделением \nи остаются просто символами подчерка."; + var result = render.RenderText(line); + var expected = + "За подчерками, начинающими выделение, должен следовать непробельный символ. Иначе эти_ подчерки_ не считаются выделением \nи остаются просто символами подчерка."; + result.Should().Be(expected); + } + + [Test] + public void Markdown_ShouldNotRenderEmOrStrong_WhenEndingUnderscorePrecededByWhitespace_Test() + { + var line = + "Подчерки, заканчивающие выделение, должны следовать за непробельным символом. Иначе эти _подчерки _не считаются_ окончанием выделения \nи остаются просто символами подчерка."; + var result = render.RenderText(line); + var expected = + "Подчерки, заканчивающие выделение, должны следовать за непробельным символом. Иначе эти _подчерки _не считаются_ окончанием выделения \nи остаются просто символами подчерка."; + result.Should().Be(expected); + } + + [Test] + public void Markdown_ShouldNotRenderEmOrStrong_WhenSingleAndDoubleUnderscoresIntersect_Test() + { + var line = "В случае __пересечения _двойных__ и одинарных_ подчерков ни один из них не считается выделением.\n"; + var result = render.RenderText(line); + var expected = + "В случае __пересечения _двойных__ и одинарных_ подчерков ни один из них не считается выделением.\n"; + result.Should().Be(expected); + } + + [Test] + public void Markdown_ShouldNotRenderStrongOrEm_WhenDoubleUnderscoresAreEmpty_Test() + { + var line = "Если внутри подчерков пустая строка _____, то они остаются символами подчерка.\n"; + var result = render.RenderText(line); + var expected = "Если внутри подчерков пустая строка _____, то они остаются символами подчерка.\n"; + result.Should().Be(expected); + } +} \ No newline at end of file From 6c74bd6e97efd5dac583ddb978a6b001cf4621e2 Mon Sep 17 00:00:00 2001 From: younggogy Date: Wed, 5 Nov 2025 03:58:24 +0500 Subject: [PATCH 06/32] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D0=BB=20=D1=82=D0=B5=D1=81=D1=82=20=D0=BD=D0=B0=20=D0=BF=D1=80?= =?UTF-8?q?=D0=BE=D0=B8=D0=B7=D0=B2=D0=BE=D0=B4=D0=B8=D1=82=D0=B5=D0=BB?= =?UTF-8?q?=D1=8C=D0=BD=D0=BE=D1=81=D1=82=D1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cs/Markdown/Tests/MarkdownTests.cs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/cs/Markdown/Tests/MarkdownTests.cs b/cs/Markdown/Tests/MarkdownTests.cs index fac4083d2..bbbfa1344 100644 --- a/cs/Markdown/Tests/MarkdownTests.cs +++ b/cs/Markdown/Tests/MarkdownTests.cs @@ -155,4 +155,21 @@ public void Markdown_ShouldNotRenderStrongOrEm_WhenDoubleUnderscoresAreEmpty_Tes var expected = "Если внутри подчерков пустая строка _____, то они остаются символами подчерка.\n"; result.Should().Be(expected); } + + [Test] + public void Markdown_ShouldProcessLongInputWithoutCrashing_Test() + { + var longLine = new string('_', 10_000) + "тест" + new string('_', 10_000); + + var result = render.RenderText(longLine); + + result.Length.Should().BeGreaterThanOrEqualTo(longLine.Length); + + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + render.RenderText(longLine); + stopwatch.Stop(); + stopwatch.ElapsedMilliseconds.Should().BeLessThan(1000); + } + + } \ No newline at end of file From 8f77b1f01ba4e81c6840aeeb25a66e6c3ca75ceb Mon Sep 17 00:00:00 2001 From: younggogy Date: Wed, 5 Nov 2025 04:00:45 +0500 Subject: [PATCH 07/32] =?UTF-8?q?=D0=BC=D0=B0=D0=BB=D0=B5=D0=BD=D1=8C?= =?UTF-8?q?=D0=BA=D0=B8=D0=B9=20=D1=80=D0=B5=D1=84=D0=B0=D0=BA=D1=82=D0=BE?= =?UTF-8?q?=D1=80=D0=B8=D0=BD=D0=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cs/Markdown/Tests/MarkdownTests.cs | 5 ++--- cs/Markdown/TokensUtils/Implementations/Tokenizer.cs | 8 +++----- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/cs/Markdown/Tests/MarkdownTests.cs b/cs/Markdown/Tests/MarkdownTests.cs index bbbfa1344..5749bcf5a 100644 --- a/cs/Markdown/Tests/MarkdownTests.cs +++ b/cs/Markdown/Tests/MarkdownTests.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using FluentAssertions; using NUnit.Framework; @@ -165,11 +166,9 @@ public void Markdown_ShouldProcessLongInputWithoutCrashing_Test() result.Length.Should().BeGreaterThanOrEqualTo(longLine.Length); - var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + var stopwatch = Stopwatch.StartNew(); render.RenderText(longLine); stopwatch.Stop(); stopwatch.ElapsedMilliseconds.Should().BeLessThan(1000); } - - } \ No newline at end of file diff --git a/cs/Markdown/TokensUtils/Implementations/Tokenizer.cs b/cs/Markdown/TokensUtils/Implementations/Tokenizer.cs index 08e1cb22c..409b56a30 100644 --- a/cs/Markdown/TokensUtils/Implementations/Tokenizer.cs +++ b/cs/Markdown/TokensUtils/Implementations/Tokenizer.cs @@ -25,25 +25,23 @@ public IEnumerable Tokenize(string? line) { case '\\': yield return new Token("\\", TokenType.Escape); - i++; break; case '_' when i + 1 < line.Length && line[i + 1] == '_': yield return new Token("__", TokenType.Strong); - i += 2; + i++; break; case '_': yield return new Token("_", TokenType.Italic); - i++; break; case '#' when i + 1 < line.Length && line[i + 1] == ' ': yield return new Token("#", TokenType.Header); - i+=2; + i++; break; default: tokenizeLineStringBuilder.Append(currentChar); - i++; break; } + i++; } if (tokenizeLineStringBuilder.Length > 0) From 4bece38ee9cf6fb55ee2f75ff99ca163e936447e Mon Sep 17 00:00:00 2001 From: younggogy Date: Wed, 5 Nov 2025 04:55:36 +0500 Subject: [PATCH 08/32] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D0=BB=20=D0=BB=D0=BE=D0=B3=D0=B8=D0=BA=D1=83=20=D1=80=D0=B5?= =?UTF-8?q?=D0=BD=D0=B4=D0=B5=D1=80=D0=B0=20=D0=B4=D0=BB=D1=8F=20header.?= =?UTF-8?q?=20=D0=9D=D0=B0=D0=BF=D0=B8=D1=81=D0=B0=D0=BB=20=D1=82=D0=B5?= =?UTF-8?q?=D1=81=D1=82=20=D0=B4=D0=BB=D1=8F=20=D0=BF=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=D0=B5=D1=80=D0=BA=D0=B8=20=D0=BD=D0=BE=D0=B2=D0=BE=D0=B9=20?= =?UTF-8?q?=D0=BB=D0=BE=D0=B3=D0=B8=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../RenderServices/Implementations/Render.cs | 216 +++++++++--------- cs/Markdown/Tests/MarkdownTests.cs | 13 +- 2 files changed, 122 insertions(+), 107 deletions(-) diff --git a/cs/Markdown/RenderServices/Implementations/Render.cs b/cs/Markdown/RenderServices/Implementations/Render.cs index 2e3f98e95..04d65a630 100644 --- a/cs/Markdown/RenderServices/Implementations/Render.cs +++ b/cs/Markdown/RenderServices/Implementations/Render.cs @@ -3,140 +3,146 @@ using Markdown.TokensUtils; using Markdown.TokensUtils.Abstractions; -namespace Markdown; - -public class Render : IRender +namespace Markdown { - private readonly ITokenizer tokenizer = new Tokenizer(); - - public string RenderText(string markdown) + public class Render : IRender { - var markDownLines = markdown.EnumerateLines(); - var result = new StringBuilder(); + private readonly ITokenizer tokenizer = new Tokenizer(); - foreach (var line in markDownLines) + public string RenderText(string markdown) { - if (result.Length > 0) - result.Append('\n'); + var markDownLines = markdown.EnumerateLines(); + var result = new StringBuilder(); + foreach (var line in markDownLines) + { + if (result.Length > 0) result.Append('\n'); + result.Append(RenderLine(line)); + } - result.Append(RenderLine(line)); + return result.ToString(); } - return result.ToString(); - } - - - private string RenderLine(string line) - { - var tokens = tokenizer.Tokenize(line).ToList(); - var result = new StringBuilder(); - var tagStack = new Stack<(TokenType Type, StringBuilder Content)>(); + private string RenderLine(string line) + { + var tokens = tokenizer.Tokenize(line).ToList(); + var result = new StringBuilder(); - var skipNextAsMarkup = false; + var tagStack = new Stack<(TokenType Type, string Marker, StringBuilder Content)>(); + var skipNextAsMarkup = false; - foreach (var token in tokens) - { - if (skipNextAsMarkup) + foreach (var token in tokens) { - AppendToParentOrResult(tagStack, result, token.Value); - skipNextAsMarkup = false; - continue; + if (skipNextAsMarkup) + { + AppendToParentOrResult(tagStack, result, token.Value); + skipNextAsMarkup = false; + continue; + } + + switch (token.Type) + { + case TokenType.Text: + AppendToParentOrResult(tagStack, result, token.Value); + break; + case TokenType.Escape: + AppendToParentOrResult(tagStack, result, token.Value); + skipNextAsMarkup = true; + break; + case TokenType.Italic: + ProcessItalic(token, tagStack, result); + break; + case TokenType.Strong: + ProcessStrong(token, tagStack, result); + break; + case TokenType.Header: + ProcessHeader(tagStack); + break; + case TokenType.End: + ProcessEnd(tagStack, result); + break; + default: + throw new ArgumentOutOfRangeException(); + } } - switch (token.Type) + return result.ToString(); + } + + private void ProcessStrong(Token token, Stack<(TokenType Type, string Marker, StringBuilder Content)> tagStack, + StringBuilder result) + { + if (tagStack.Count > 0) { - case TokenType.Text: - AppendToParentOrResult(tagStack, result, token.Value); - break; + var parent = tagStack.Peek(); + var parentType = parent.Type; - case TokenType.Escape: + if (parentType == TokenType.Italic) + { AppendToParentOrResult(tagStack, result, token.Value); - skipNextAsMarkup = true; - break; - - case TokenType.Italic: - ProcessItalic(token, tagStack, result); - break; - - case TokenType.Strong: - ProcessStrong(token, tagStack, result); - break; - - case TokenType.Header: - result.Append(ProcessHeader(tokens)); - return result.ToString(); - - case TokenType.End: - ProcessEnd(tagStack, result); - break; - - default: - throw new ArgumentOutOfRangeException(); + } + else if (parentType == token.Type) + { + CloseTopTag(tagStack, result); + } + else + { + tagStack.Push((token.Type, token.Value, new StringBuilder())); + } + } + else + { + tagStack.Push((token.Type, token.Value, new StringBuilder())); } } - - return result.ToString(); - } - - private void ProcessStrong(Token token, Stack<(TokenType Type, StringBuilder Content)> tagStack, - StringBuilder result) - { - if (tagStack.Count > 0) + private void ProcessItalic(Token token, Stack<(TokenType Type, string Marker, StringBuilder Content)> tagStack, + StringBuilder result) { - var parent = tagStack.Peek(); - var parentType = parent.Type; - - if (parentType == TokenType.Italic) - AppendToParentOrResult(tagStack, result, token.Value); - else + if (tagStack.Count > 0 && tagStack.Peek().Type == token.Type) + { CloseTopTag(tagStack, result); + } + else + { + tagStack.Push((token.Type, token.Value, new StringBuilder())); + } } - else + + private void ProcessHeader(Stack<(TokenType Type, string Marker, StringBuilder Content)> tagStack) { - tagStack.Push((token.Type, new StringBuilder())); + tagStack.Push((TokenType.Header, "#", new StringBuilder())); } - } - - private void ProcessItalic(Token token, Stack<(TokenType Type, StringBuilder Content)> tagStack, - StringBuilder result) - { - if (tagStack.Count > 0 && tagStack.Peek().Type == token.Type) + + private void ProcessEnd(Stack<(TokenType Type, string Marker, StringBuilder Content)> tagStack, + StringBuilder result) { - CloseTopTag(tagStack, result); + while (tagStack.Count > 0) + { + var top = tagStack.Pop(); + string rendered; + + var raw = top.Type is TokenType.Header + ? TagRender.Wrap(top.Type, top.Content.ToString()) + : top.Marker + + top.Content; + + AppendToParentOrResult(tagStack, result, raw); + } } - else + + private void AppendToParentOrResult(Stack<(TokenType Type, string Marker, StringBuilder Content)> tagStack, + StringBuilder result, string content) { - tagStack.Push((token.Type, new StringBuilder())); + if (tagStack.Count > 0) tagStack.Peek().Content.Append(content); + else result.Append(content); } - } - - private string ProcessHeader(List tokens) - { - throw new NotImplementedException(); - } - private void ProcessEnd(Stack<(TokenType Type, StringBuilder Content)> tagStack, StringBuilder result) - { - while (tagStack.Count > 0) + private void CloseTopTag(Stack<(TokenType Type, string Marker, StringBuilder Content)> tagStack, + StringBuilder result) { - CloseTopTag(tagStack, result); + var top = tagStack.Pop(); + var wrapped = TagRender.Wrap(top.Type, top.Content.ToString()); + AppendToParentOrResult(tagStack, result, wrapped); } } - - private void AppendToParentOrResult(Stack<(TokenType Type, StringBuilder Content)> tagStack, StringBuilder result, string content) - { - if (tagStack.Count > 0) - tagStack.Peek().Content.Append(content); - else - result.Append(content); - } - - private void CloseTopTag(Stack<(TokenType Type, StringBuilder Content)> tagStack, StringBuilder result) - { - var top = tagStack.Pop(); - var wrapped = TagRender.Wrap(top.Type, top.Content.ToString()); - - AppendToParentOrResult(tagStack, result, wrapped); - } } \ No newline at end of file diff --git a/cs/Markdown/Tests/MarkdownTests.cs b/cs/Markdown/Tests/MarkdownTests.cs index 5749bcf5a..a52ca7c97 100644 --- a/cs/Markdown/Tests/MarkdownTests.cs +++ b/cs/Markdown/Tests/MarkdownTests.cs @@ -81,10 +81,10 @@ public void Markdown_ShouldNotRenderStrongInsideEm_WhenDoubleUnderscoresInsideSi [Test] public void Markdown_ShouldNotRenderEmOrStrong_WhenUnderscoresSurroundNumbers_Test() { - var line = "Подчерки внутри текста c цифрами_12__3 не считаются выделением и должны оставаться символами подчерка."; + var line = "Подчерки внутри текста c цифрами_12_3 или 1__123 не считаются выделением и должны оставаться символами подчерка."; var result = render.RenderText(line); var expected = - "Подчерки внутри текста c цифрами_12__3 не считаются выделением и должны оставаться символами подчерка."; + "Подчерки внутри текста c цифрами_12_3 или 1__123 не считаются выделением и должны оставаться символами подчерка."; result.Should().Be(expected); } @@ -171,4 +171,13 @@ public void Markdown_ShouldProcessLongInputWithoutCrashing_Test() stopwatch.Stop(); stopwatch.ElapsedMilliseconds.Should().BeLessThan(1000); } + + [Test] + public void Markdown_ShouldRenderH1Tag_WhenParagraphStartsWithHashAndSpace_Test() + { + var line = "# Заголовок __с _разными_ символами__"; + var result = render.RenderText(line); + var expected = "

Заголовок с разными символами

"; + result.Should().Be(expected); + } } \ No newline at end of file From 7c4967d97d48ce4c3f72b318bc3c89259cfba5fd Mon Sep 17 00:00:00 2001 From: younggogy Date: Wed, 5 Nov 2025 05:06:52 +0500 Subject: [PATCH 09/32] =?UTF-8?q?=D0=A1=D0=B4=D0=B5=D0=BB=D0=B0=D0=BB=20?= =?UTF-8?q?=D0=B4=D0=BE=D0=BF.=20=D0=BA=D0=BB=D0=B0=D1=81=D1=81=20=D0=B4?= =?UTF-8?q?=D0=BB=D1=8F=20=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D1=8B=20=D1=81=20?= =?UTF-8?q?=D1=82=D0=B5=D0=B3=D0=B0=D0=BC=D0=B8=20=D1=87=D0=B5=D1=80=D0=B5?= =?UTF-8?q?=D0=B7=20=D1=81=D1=82=D0=B5=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cs/Markdown/Dto/Tag.cs | 17 +++++++++++++ cs/Markdown/{TokensUtils => Dto}/Token.cs | 2 +- cs/Markdown/{TokensUtils => Dto}/TokenType.cs | 2 +- .../RenderServices/Implementations/Render.cs | 24 ++++++++++--------- .../TokensUtils/Abstractions/ITokenizer.cs | 2 ++ .../TokensUtils/Implementations/Tokenizer.cs | 3 ++- cs/Markdown/TokensUtils/TagRender.cs | 2 ++ 7 files changed, 38 insertions(+), 14 deletions(-) create mode 100644 cs/Markdown/Dto/Tag.cs rename cs/Markdown/{TokensUtils => Dto}/Token.cs (86%) rename cs/Markdown/{TokensUtils => Dto}/TokenType.cs (74%) diff --git a/cs/Markdown/Dto/Tag.cs b/cs/Markdown/Dto/Tag.cs new file mode 100644 index 000000000..2778d4558 --- /dev/null +++ b/cs/Markdown/Dto/Tag.cs @@ -0,0 +1,17 @@ +using System.Text; + +namespace Markdown.Dto; + +public class Tag +{ + public TokenType Type { get; set; } + public string Marker { get; set; } + public StringBuilder Content { get; set; } + + public Tag(TokenType type, string marker, StringBuilder content) + { + Type = type; + Marker = marker; + Content = content; + } +} \ No newline at end of file diff --git a/cs/Markdown/TokensUtils/Token.cs b/cs/Markdown/Dto/Token.cs similarity index 86% rename from cs/Markdown/TokensUtils/Token.cs rename to cs/Markdown/Dto/Token.cs index 40b5aa239..9afc4e115 100644 --- a/cs/Markdown/TokensUtils/Token.cs +++ b/cs/Markdown/Dto/Token.cs @@ -1,4 +1,4 @@ -namespace Markdown.TokensUtils; +namespace Markdown.Dto; public class Token { diff --git a/cs/Markdown/TokensUtils/TokenType.cs b/cs/Markdown/Dto/TokenType.cs similarity index 74% rename from cs/Markdown/TokensUtils/TokenType.cs rename to cs/Markdown/Dto/TokenType.cs index d9f2d67bb..201a41e3c 100644 --- a/cs/Markdown/TokensUtils/TokenType.cs +++ b/cs/Markdown/Dto/TokenType.cs @@ -1,4 +1,4 @@ -namespace Markdown.TokensUtils; +namespace Markdown.Dto; public enum TokenType { diff --git a/cs/Markdown/RenderServices/Implementations/Render.cs b/cs/Markdown/RenderServices/Implementations/Render.cs index 04d65a630..e063fb0fa 100644 --- a/cs/Markdown/RenderServices/Implementations/Render.cs +++ b/cs/Markdown/RenderServices/Implementations/Render.cs @@ -1,7 +1,9 @@ using System.Text; +using Markdown.Dto; using Markdown.Extensions; using Markdown.TokensUtils; using Markdown.TokensUtils.Abstractions; +using Markdown.TokensUtils.Implementations; namespace Markdown { @@ -27,7 +29,7 @@ private string RenderLine(string line) var tokens = tokenizer.Tokenize(line).ToList(); var result = new StringBuilder(); - var tagStack = new Stack<(TokenType Type, string Marker, StringBuilder Content)>(); + var tagStack = new Stack(); var skipNextAsMarkup = false; foreach (var token in tokens) @@ -68,7 +70,7 @@ private string RenderLine(string line) return result.ToString(); } - private void ProcessStrong(Token token, Stack<(TokenType Type, string Marker, StringBuilder Content)> tagStack, + private void ProcessStrong(Token token, Stack tagStack, StringBuilder result) { if (tagStack.Count > 0) @@ -86,16 +88,16 @@ private void ProcessStrong(Token token, Stack<(TokenType Type, string Marker, St } else { - tagStack.Push((token.Type, token.Value, new StringBuilder())); + tagStack.Push(new Tag(token.Type, token.Value, new StringBuilder())); } } else { - tagStack.Push((token.Type, token.Value, new StringBuilder())); + tagStack.Push(new Tag(token.Type, token.Value, new StringBuilder())); } } - private void ProcessItalic(Token token, Stack<(TokenType Type, string Marker, StringBuilder Content)> tagStack, + private void ProcessItalic(Token token, Stack tagStack, StringBuilder result) { if (tagStack.Count > 0 && tagStack.Peek().Type == token.Type) @@ -104,16 +106,16 @@ private void ProcessItalic(Token token, Stack<(TokenType Type, string Marker, St } else { - tagStack.Push((token.Type, token.Value, new StringBuilder())); + tagStack.Push(new Tag(token.Type, token.Value, new StringBuilder())); } } - private void ProcessHeader(Stack<(TokenType Type, string Marker, StringBuilder Content)> tagStack) + private void ProcessHeader(Stack tagStack) { - tagStack.Push((TokenType.Header, "#", new StringBuilder())); + tagStack.Push(new Tag(TokenType.Header, "#", new StringBuilder())); } - private void ProcessEnd(Stack<(TokenType Type, string Marker, StringBuilder Content)> tagStack, + private void ProcessEnd(Stack tagStack, StringBuilder result) { while (tagStack.Count > 0) @@ -130,14 +132,14 @@ private void ProcessEnd(Stack<(TokenType Type, string Marker, StringBuilder Cont } } - private void AppendToParentOrResult(Stack<(TokenType Type, string Marker, StringBuilder Content)> tagStack, + private void AppendToParentOrResult(Stack tagStack, StringBuilder result, string content) { if (tagStack.Count > 0) tagStack.Peek().Content.Append(content); else result.Append(content); } - private void CloseTopTag(Stack<(TokenType Type, string Marker, StringBuilder Content)> tagStack, + private void CloseTopTag(Stack tagStack, StringBuilder result) { var top = tagStack.Pop(); diff --git a/cs/Markdown/TokensUtils/Abstractions/ITokenizer.cs b/cs/Markdown/TokensUtils/Abstractions/ITokenizer.cs index 14d255306..be54372d6 100644 --- a/cs/Markdown/TokensUtils/Abstractions/ITokenizer.cs +++ b/cs/Markdown/TokensUtils/Abstractions/ITokenizer.cs @@ -1,3 +1,5 @@ +using Markdown.Dto; + namespace Markdown.TokensUtils.Abstractions; public interface ITokenizer diff --git a/cs/Markdown/TokensUtils/Implementations/Tokenizer.cs b/cs/Markdown/TokensUtils/Implementations/Tokenizer.cs index 409b56a30..c273ad22c 100644 --- a/cs/Markdown/TokensUtils/Implementations/Tokenizer.cs +++ b/cs/Markdown/TokensUtils/Implementations/Tokenizer.cs @@ -1,7 +1,8 @@ using System.Text; +using Markdown.Dto; using Markdown.TokensUtils.Abstractions; -namespace Markdown.TokensUtils; +namespace Markdown.TokensUtils.Implementations; public class Tokenizer : ITokenizer { diff --git a/cs/Markdown/TokensUtils/TagRender.cs b/cs/Markdown/TokensUtils/TagRender.cs index a9dd2973f..99eb67f75 100644 --- a/cs/Markdown/TokensUtils/TagRender.cs +++ b/cs/Markdown/TokensUtils/TagRender.cs @@ -1,3 +1,5 @@ +using Markdown.Dto; + namespace Markdown.TokensUtils { public static class TagRender From 818e646ab7d976252282bfcbc57ab96bc09cc4cd Mon Sep 17 00:00:00 2001 From: younggogy Date: Thu, 6 Nov 2025 00:27:27 +0500 Subject: [PATCH 10/32] =?UTF-8?q?=D0=A1=D0=B4=D0=B5=D0=BB=D0=B0=D0=BB=20?= =?UTF-8?q?=D0=BA=D0=BB=D0=B0=D1=81=D1=81=D1=8B,=20=D0=BA=D0=BE=D1=82?= =?UTF-8?q?=D0=BE=D1=80=D1=8B=D0=B5=20=D1=81=D0=BB=D1=83=D0=B6=D0=B0=D1=82?= =?UTF-8?q?,=20=D1=82=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20=D0=BA=D0=B0=D0=BA?= =?UTF-8?q?=20=D0=B4=D0=B0=D0=BD=D0=BD=D1=8B=D0=B5=20=D1=80=D0=B5=D0=BA?= =?UTF-8?q?=D0=BE=D1=80=D0=B4=D0=B0=D0=BC=D0=B8.=20=D0=94=D0=BE=D0=B1?= =?UTF-8?q?=D0=B0=D0=B2=D0=B8=D0=BB=20=D0=BF=D0=B0=D1=80=D1=83=20=D0=BD?= =?UTF-8?q?=D0=BE=D0=B2=D1=8B=D1=85=20=D0=BF=D0=BE=D0=BB=D0=B5=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cs/Markdown/Dto/Tag.cs | 14 +------------- cs/Markdown/Dto/Token.cs | 12 +----------- cs/Markdown/Dto/TokenType.cs | 2 +- 3 files changed, 3 insertions(+), 25 deletions(-) diff --git a/cs/Markdown/Dto/Tag.cs b/cs/Markdown/Dto/Tag.cs index 2778d4558..e86f75548 100644 --- a/cs/Markdown/Dto/Tag.cs +++ b/cs/Markdown/Dto/Tag.cs @@ -2,16 +2,4 @@ namespace Markdown.Dto; -public class Tag -{ - public TokenType Type { get; set; } - public string Marker { get; set; } - public StringBuilder Content { get; set; } - - public Tag(TokenType type, string marker, StringBuilder content) - { - Type = type; - Marker = marker; - Content = content; - } -} \ No newline at end of file +public record Tag(Token Token, StringBuilder Content, bool isOpen); \ No newline at end of file diff --git a/cs/Markdown/Dto/Token.cs b/cs/Markdown/Dto/Token.cs index 9afc4e115..70241c1d2 100644 --- a/cs/Markdown/Dto/Token.cs +++ b/cs/Markdown/Dto/Token.cs @@ -1,13 +1,3 @@ namespace Markdown.Dto; -public class Token -{ - public string Value { get; set; } - public TokenType Type { get; set; } - - public Token(string value, TokenType type) - { - Value = value; - Type = type; - } -} \ No newline at end of file +public record Token(string Value, TokenType Type, bool CanOpen, bool CanClose, bool InsideWord); \ No newline at end of file diff --git a/cs/Markdown/Dto/TokenType.cs b/cs/Markdown/Dto/TokenType.cs index 201a41e3c..406e1500c 100644 --- a/cs/Markdown/Dto/TokenType.cs +++ b/cs/Markdown/Dto/TokenType.cs @@ -2,9 +2,9 @@ namespace Markdown.Dto; public enum TokenType { + Text = 0, Italic, Strong, - Text, Escape, Header, End From d2ef3857d0607fd02b1792a4eac006f6d823bf85 Mon Sep 17 00:00:00 2001 From: younggogy Date: Thu, 6 Nov 2025 00:28:27 +0500 Subject: [PATCH 11/32] =?UTF-8?q?=D0=A3=D0=B1=D1=80=D0=B0=D0=BB=20=D0=BB?= =?UTF-8?q?=D0=B8=D1=88=D0=BD=D0=B8=D0=B9=20=D1=82=D0=B0=D0=B1=20=D0=B2=20?= =?UTF-8?q?TagRender.cs.=20=D0=92=D1=8B=D0=BD=D0=B5=D1=81=20=D0=B2=20?= =?UTF-8?q?=D0=BE=D1=82=D0=B4=D0=B5=D0=BB=D1=8C=D0=BD=D1=8B=D0=B9=20=D1=81?= =?UTF-8?q?=D1=82=D0=B0=D1=82=D0=B8=D1=87=D0=B5=D1=81=D0=BA=D0=B8=D0=B9=20?= =?UTF-8?q?=D0=BA=D0=BB=D0=B0=D1=81=D1=81=20=D0=BC=D0=B0=D0=BF=D0=B8=D0=BD?= =?UTF-8?q?=D0=B3=20=D0=B4=D0=BB=D1=8F=20=D1=81=D0=BF=D0=B5=D1=86=D0=B8?= =?UTF-8?q?=D0=B0=D0=BB=D1=8C=D0=BD=D1=8B=D1=85=20=D1=81=D0=B8=D0=BC=D0=B2?= =?UTF-8?q?=D0=BE=D0=BB=D0=BE=D0=B2=20=D0=B8=20=D0=BD=D0=B5=D0=BC=D0=BD?= =?UTF-8?q?=D0=BE=D0=B3=D0=BE=20=D0=B8=D0=B7=D0=BC=D0=B5=D0=BD=D0=B8=D0=BB?= =?UTF-8?q?=20Tokenizer.cs,=20=D0=B2=20=D1=81=D0=B2=D1=8F=D0=B7=D0=B8=20?= =?UTF-8?q?=D1=81=20=D0=B8=D0=B7=D0=BC=D0=B5=D0=BD=D0=B5=D0=BD=D0=B8=D1=8F?= =?UTF-8?q?=D0=BC=D0=B8=20=D1=81=20Token.cs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../TokensUtils/Implementations/Tokenizer.cs | 63 ++++++++----------- cs/Markdown/TokensUtils/MapSpecialSymbol.cs | 42 +++++++++++++ cs/Markdown/TokensUtils/TagRender.cs | 2 +- 3 files changed, 70 insertions(+), 37 deletions(-) create mode 100644 cs/Markdown/TokensUtils/MapSpecialSymbol.cs diff --git a/cs/Markdown/TokensUtils/Implementations/Tokenizer.cs b/cs/Markdown/TokensUtils/Implementations/Tokenizer.cs index c273ad22c..f9c0807f9 100644 --- a/cs/Markdown/TokensUtils/Implementations/Tokenizer.cs +++ b/cs/Markdown/TokensUtils/Implementations/Tokenizer.cs @@ -6,57 +6,48 @@ namespace Markdown.TokensUtils.Implementations; public class Tokenizer : ITokenizer { + private static readonly HashSet SpecialSymbols = new() { '\\', '_', '#' }; + public IEnumerable Tokenize(string? line) { ArgumentNullException.ThrowIfNull(line); - var tokenizeLineStringBuilder = new StringBuilder(); + var sb = new StringBuilder(); var i = 0; + while (i < line.Length) { - var currentChar = line[i]; - - if (IsSpecialSymbol(currentChar) && tokenizeLineStringBuilder.Length > 0) + var c = line[i]; + + if (SpecialSymbols.Contains(c) && sb.Length > 0) { - yield return new Token(tokenizeLineStringBuilder.ToString(), TokenType.Text); - tokenizeLineStringBuilder.Clear(); + yield return new Token(sb.ToString(), TokenType.Text, false, false, false); + sb.Clear(); } - - switch (currentChar) + + var token = MapSpecialSymbol.Specialize(c, line, i); + if (token != null) { - case '\\': - yield return new Token("\\", TokenType.Escape); - break; - case '_' when i + 1 < line.Length && line[i + 1] == '_': - yield return new Token("__", TokenType.Strong); - i++; - break; - case '_': - yield return new Token("_", TokenType.Italic); - break; - case '#' when i + 1 < line.Length && line[i + 1] == ' ': - yield return new Token("#", TokenType.Header); + yield return token; + + if (token.Type is TokenType.Header or TokenType.Strong) + { i++; - break; - default: - tokenizeLineStringBuilder.Append(currentChar); - break; + } + } + else + { + sb.Append(c); } + i++; } - if (tokenizeLineStringBuilder.Length > 0) - yield return new Token(tokenizeLineStringBuilder.ToString(), TokenType.Text); - - yield return new Token(string.Empty, TokenType.End); - } - - private bool IsSpecialSymbol(char c) - { - return c switch + if (sb.Length > 0) { - '\\' or '_' or '#' => true, - _ => false - }; + yield return new Token(sb.ToString(), TokenType.Text, false, false, false); + } + + yield return new Token(string.Empty, TokenType.End, false, 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..15def81ea --- /dev/null +++ b/cs/Markdown/TokensUtils/MapSpecialSymbol.cs @@ -0,0 +1,42 @@ +namespace Markdown.TokensUtils; + +using Markdown.Dto; + +public static class MapSpecialSymbol +{ + public static Token? Specialize(char c, string line, int index) + { + return c switch + { + '_' 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, false, false, false), + _ => null + }; + } + + private static Token CreateUnderscoreToken(string line, int index, bool isStrong) + { + var value = isStrong ? "__" : "_"; + var type = isStrong ? TokenType.Strong : TokenType.Italic; + var prevChar = index > 0 ? line[index - 1] : (char?)null; + var nextChar = index + value.Length < line.Length ? line[index + value.Length] : (char?)null; + var canOpen = nextChar != null && !char.IsWhiteSpace(nextChar.Value); + var canClose = prevChar != null && !char.IsWhiteSpace(prevChar.Value); + var isInsideWord = canOpen && canClose; + return new Token(value, type, canOpen, canClose, isInsideWord); + } + + private static Token CreateHeaderToken(string line, int index) + { + var value = "#"; + var nextChar = index + value.Length < line.Length ? line[index + value.Length] : (char?)null; + var canOpenAndClose = nextChar != null && char.IsWhiteSpace(nextChar.Value); + return new Token(value, TokenType.Header, canOpenAndClose, canOpenAndClose, !canOpenAndClose); + } +} \ No newline at end of file diff --git a/cs/Markdown/TokensUtils/TagRender.cs b/cs/Markdown/TokensUtils/TagRender.cs index 99eb67f75..92a8066be 100644 --- a/cs/Markdown/TokensUtils/TagRender.cs +++ b/cs/Markdown/TokensUtils/TagRender.cs @@ -7,7 +7,7 @@ public static class TagRender public static string Wrap(TokenType type, string content) => type switch { - TokenType.Italic => $"{content}", + TokenType.Italic => $"{content}", TokenType.Strong => $"{content}", TokenType.Escape => $"{content}", TokenType.Header => $"

{content}

", From 3dd137995288b3335677a22ac0b6dae8055d5c3f Mon Sep 17 00:00:00 2001 From: younggogy Date: Thu, 6 Nov 2025 00:30:30 +0500 Subject: [PATCH 12/32] =?UTF-8?q?=D0=9F=D0=BE=D1=80=D0=B5=D1=84=D0=B0?= =?UTF-8?q?=D0=BA=D1=82=D0=BE=D1=80=D0=B8=D0=BB=20if,=20=D1=82=D0=B5=D0=BF?= =?UTF-8?q?=D0=B5=D1=80=D1=8C=20=D0=B2=D1=81=D0=B5=20if=20=D0=BD=D0=B0?= =?UTF-8?q?=D1=87=D0=B8=D0=BD=D0=B0=D1=8E=D1=82=D1=81=D1=8F=20=D1=81=20?= =?UTF-8?q?=D0=BD=D0=BE=D0=B2=D0=BE=D0=B9=20=D1=81=D1=82=D1=80=D0=BE=D0=BA?= =?UTF-8?q?=D0=B8=20=D0=B8=20=D1=81=D0=BA=D0=BE=D0=B1=D0=BE=D0=BA.=20?= =?UTF-8?q?=D0=9F=D0=B5=D1=80=D0=B5=D0=BF=D0=B8=D1=81=D0=B0=D0=BB=20=D1=82?= =?UTF-8?q?=D0=B5=D1=81=D1=82=D1=8B=20=D0=BD=D0=B0=20testsource=20=D0=B8?= =?UTF-8?q?=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D0=BB=20=D0=BD=D0=BE?= =?UTF-8?q?=D0=B2=D1=8B=D0=B9=20=D1=82=D0=B5=D1=81=D1=82.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cs/Markdown/Extensions/StringExtensions.cs | 35 +-- cs/Markdown/Tests/MarkdownTests.cs | 259 ++++++++------------- 2 files changed, 120 insertions(+), 174 deletions(-) diff --git a/cs/Markdown/Extensions/StringExtensions.cs b/cs/Markdown/Extensions/StringExtensions.cs index 9475b21fa..85ad57d12 100644 --- a/cs/Markdown/Extensions/StringExtensions.cs +++ b/cs/Markdown/Extensions/StringExtensions.cs @@ -1,23 +1,30 @@ -namespace Markdown.Extensions +namespace Markdown.Extensions; + +public static class StringExtensions { - public static class StringExtensions + public static IEnumerable EnumerateLines(this string? markdown) { - 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 is null) yield break; - - var start = 0; - for (var i = 0; i < markdown.Length; i++) + if (markdown[i] != '\n') { - if (markdown[i] != '\n') - continue; - - yield return markdown[start..i]; - start = i + 1; + continue; } - if (start <= markdown.Length) - yield return markdown[start..]; + 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/Tests/MarkdownTests.cs b/cs/Markdown/Tests/MarkdownTests.cs index a52ca7c97..856737629 100644 --- a/cs/Markdown/Tests/MarkdownTests.cs +++ b/cs/Markdown/Tests/MarkdownTests.cs @@ -1,5 +1,6 @@ using System.Diagnostics; using FluentAssertions; +using FluentAssertions.Execution; using NUnit.Framework; namespace Markdown.Tests; @@ -8,176 +9,114 @@ public class MarkdownTests { private IRender render = new Render(); - [Test] - public void Markdown_ShouldBeTextWrappedWithEmTag_WhenSurroundedBySingleUnderscores_Test() - { - var line = - "Текст, _окруженный с двух сторон_ одинарными символами подчерка"; - - var result = render.RenderText(line); - var expected = "Текст, окруженный с двух сторон одинарными символами подчерка"; - result - .Should() - .Be(expected); - } - - [Test] - public void Markdown_ShouldBeTextWrappedWithStrongTag_WhenSurroundedByDoubleUnderscores_Test() - { - var line = "__Выделенный двумя символами текст__ должен становиться полужирным"; - - var result = render.RenderText(line); - var expected = "Выделенный двумя символами текст должен становиться полужирным"; - result - .Should() - .Be(expected); - } - - [Test] - public void Markdown_ShouldAddEscapeCharacter_WhenTextContainsMarkdownTags_Test() - { - var line = "Любой тег можно экранировать \\ и даже \\"; - var result = render.RenderText(line); - var expected = "Любой тег можно экранировать \\ и даже \\"; - result - .Should() - .Be(expected); - } - - [Test] - public void Markdown_ShouldBeTextNotWrappedWithEmTag_WhenUnderscoresAreEscaped_Test() - { - var line = - "Любой символ можно экранировать, чтобы он не считался частью разметки.\n\\_Вот это\\_, не должно выделиться тегом \\. \n Также \\__Это не выделяется\\__ тегом \\."; - var result = render.RenderText(line); - var expected = - "Любой символ можно экранировать, чтобы он не считался частью разметки.\n\\_Вот это\\_, не должно выделиться тегом \\. \n Также \\__Это не выделяется\\__ тегом \\."; - result - .Should() - .Be(expected); - } - - [Test] - public void Markdown_ShouldRenderNestedTagsCorrectly_WhenDoubleAndSingleUnderscoresAreMixed_Test() - { - var line = "Внутри __двойного выделения _одинарное_ тоже__ работает."; - var result = render.RenderText(line); - var expected = "Внутри двойного выделения одинарное тоже работает."; - - result - .Should() - .Be(expected); - } - - [Test] - public void Markdown_ShouldNotRenderStrongInsideEm_WhenDoubleUnderscoresInsideSingleUnderscore_Test() + [TestCaseSource(nameof(MarkdownCases))] + public void Markdown_RenderText_ShouldMatchExpected(string line, string expectedAndErrorMessage) { - var line = "Но не наоборот — внутри _одинарного __двойное__ не_ работает."; var result = render.RenderText(line); - var expected = "Но не наоборот — внутри одинарного __двойное__ не работает."; - result.Should().Be(expected); + result.Should().Be(expectedAndErrorMessage, expectedAndErrorMessage); } - [Test] - public void Markdown_ShouldNotRenderEmOrStrong_WhenUnderscoresSurroundNumbers_Test() - { - var line = "Подчерки внутри текста c цифрами_12_3 или 1__123 не считаются выделением и должны оставаться символами подчерка."; - var result = render.RenderText(line); - var expected = - "Подчерки внутри текста c цифрами_12_3 или 1__123 не считаются выделением и должны оставаться символами подчерка."; - - result.Should().Be(expected); - } - - [Test] - public void Markdown_ShouldRenderEmOrStrong_WhenUnderscoresInsideWord_Test() - { - var line = "Однако выделять часть слова они могут: и в _нач_але, и в сер_еди_не, и в кон__це.__"; - var result = render.RenderText(line); - var expected = "Однако выделять часть слова они могут: и в начале, и в середине, и в конце."; - result.Should().Be(expected); - } - - [Test] - public void Markdown_ShouldNotRenderEmOrStrong_WhenUnderscoresAcrossMultipleWords_Test() - { - var line = "В то же время выделение в ра_зных сл_овах не работает."; - var result = render.RenderText(line); - var expected = "В то же время выделение в ра_зных сл_овах не работает."; - result.Should().Be(expected); - } - - [Test] - public void Markdown_ShouldNotRenderEmOrStrong_WhenUnmatchedUnderscoresInParagraph_Test() - { - var line = "__Непарные_ символы в рамках одного абзаца не считаются выделением."; - var result = render.RenderText(line); - var expected = "__Непарные_ символы в рамках одного абзаца не считаются выделением."; - result.Should().Be(expected); - } - - [Test] - public void Markdown_ShouldNotRenderEmOrStrong_WhenUnderscoreFollowedByWhitespace_Test() - { - var line = - "За подчерками, начинающими выделение, должен следовать непробельный символ. Иначе эти_ подчерки_ не считаются выделением \nи остаются просто символами подчерка."; - var result = render.RenderText(line); - var expected = - "За подчерками, начинающими выделение, должен следовать непробельный символ. Иначе эти_ подчерки_ не считаются выделением \nи остаются просто символами подчерка."; - result.Should().Be(expected); - } - - [Test] - public void Markdown_ShouldNotRenderEmOrStrong_WhenEndingUnderscorePrecededByWhitespace_Test() - { - var line = - "Подчерки, заканчивающие выделение, должны следовать за непробельным символом. Иначе эти _подчерки _не считаются_ окончанием выделения \nи остаются просто символами подчерка."; - var result = render.RenderText(line); - var expected = - "Подчерки, заканчивающие выделение, должны следовать за непробельным символом. Иначе эти _подчерки _не считаются_ окончанием выделения \nи остаются просто символами подчерка."; - result.Should().Be(expected); - } - - [Test] - public void Markdown_ShouldNotRenderEmOrStrong_WhenSingleAndDoubleUnderscoresIntersect_Test() - { - var line = "В случае __пересечения _двойных__ и одинарных_ подчерков ни один из них не считается выделением.\n"; - var result = render.RenderText(line); - var expected = - "В случае __пересечения _двойных__ и одинарных_ подчерков ни один из них не считается выделением.\n"; - result.Should().Be(expected); - } - - [Test] - public void Markdown_ShouldNotRenderStrongOrEm_WhenDoubleUnderscoresAreEmpty_Test() - { - var line = "Если внутри подчерков пустая строка _____, то они остаются символами подчерка.\n"; - var result = render.RenderText(line); - var expected = "Если внутри подчерков пустая строка _____, то они остаются символами подчерка.\n"; - result.Should().Be(expected); - } - [Test] public void Markdown_ShouldProcessLongInputWithoutCrashing_Test() { var longLine = new string('_', 10_000) + "тест" + new string('_', 10_000); - var result = render.RenderText(longLine); - result.Length.Should().BeGreaterThanOrEqualTo(longLine.Length); + using (new AssertionScope()) + { + result.Length.Should().BeGreaterThanOrEqualTo(longLine.Length, "рендер не должен обрезать длинный ввод"); - var stopwatch = Stopwatch.StartNew(); - render.RenderText(longLine); - stopwatch.Stop(); - stopwatch.ElapsedMilliseconds.Should().BeLessThan(1000); - } + var stopwatch = Stopwatch.StartNew(); + render.RenderText(longLine); + stopwatch.Stop(); - [Test] - public void Markdown_ShouldRenderH1Tag_WhenParagraphStartsWithHashAndSpace_Test() - { - var line = "# Заголовок __с _разными_ символами__"; - var result = render.RenderText(line); - var expected = "

Заголовок с разными символами

"; - result.Should().Be(expected); + stopwatch.ElapsedMilliseconds.Should().BeLessThan(1000, "рендер должен завершаться за приемлемое время"); + } } -} \ No newline at end of file + + 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__123 не считаются выделением и должны оставаться символами подчерка.", + "Подчерки внутри текста c цифрами_12_3 или 1__123 не считаются выделением и должны оставаться символами подчерка." + ).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"); + } + } +} From 2ca6b3f8272d11f5a7aba7a5c28e2e17bd3d4cee Mon Sep 17 00:00:00 2001 From: younggogy Date: Thu, 6 Nov 2025 00:31:02 +0500 Subject: [PATCH 13/32] =?UTF-8?q?=D0=A0=D0=B5=D0=B0=D0=BB=D0=B8=D0=B7?= =?UTF-8?q?=D0=BE=D0=B2=D0=B0=D0=BB=20=D0=B2=D1=81=D1=8E=20=D0=BB=D0=BE?= =?UTF-8?q?=D0=B3=D0=B8=D0=BA=D1=83=20=D0=B2=20Render.cs=20=D0=BA=D0=BE?= =?UTF-8?q?=D1=82=D0=BE=D1=80=D0=B0=D1=8F=20=D0=B1=D1=8B=D0=BB=D0=B0=20?= =?UTF-8?q?=D0=BE=D0=BF=D0=B8=D1=81=D0=B0=D0=BD=D0=B0=20=D0=BD=D0=B0=20?= =?UTF-8?q?=D0=BC=D0=B8=D0=BD=D0=B8=D0=BC=D0=B0=D0=BB=D1=8C=D0=BD=D1=8B?= =?UTF-8?q?=D0=B9=20=D0=B1=D0=B0=D0=BB=D0=BB.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../RenderServices/Implementations/Render.cs | 128 +++++++++++------- 1 file changed, 79 insertions(+), 49 deletions(-) diff --git a/cs/Markdown/RenderServices/Implementations/Render.cs b/cs/Markdown/RenderServices/Implementations/Render.cs index e063fb0fa..61556014f 100644 --- a/cs/Markdown/RenderServices/Implementations/Render.cs +++ b/cs/Markdown/RenderServices/Implementations/Render.cs @@ -29,14 +29,14 @@ private string RenderLine(string line) var tokens = tokenizer.Tokenize(line).ToList(); var result = new StringBuilder(); - var tagStack = new Stack(); + var tagsStack = new Stack(); var skipNextAsMarkup = false; foreach (var token in tokens) { if (skipNextAsMarkup) { - AppendToParentOrResult(tagStack, result, token.Value); + AppendToParentOrResult(tagsStack, result, token.Value); skipNextAsMarkup = false; continue; } @@ -44,24 +44,30 @@ private string RenderLine(string line) switch (token.Type) { case TokenType.Text: - AppendToParentOrResult(tagStack, result, token.Value); + AppendToParentOrResult(tagsStack, result, token.Value); break; + case TokenType.Escape: - AppendToParentOrResult(tagStack, result, token.Value); + AppendToParentOrResult(tagsStack, result, token.Value); skipNextAsMarkup = true; break; + case TokenType.Italic: - ProcessItalic(token, tagStack, result); + ProcessItalic(token, tagsStack, result); break; + case TokenType.Strong: - ProcessStrong(token, tagStack, result); + ProcessStrong(token, tagsStack, result); break; + case TokenType.Header: - ProcessHeader(tagStack); + ProcessHeader(token, tagsStack); break; + case TokenType.End: - ProcessEnd(tagStack, result); + ProcessEnd(tagsStack, result); break; + default: throw new ArgumentOutOfRangeException(); } @@ -70,81 +76,105 @@ private string RenderLine(string line) return result.ToString(); } - private void ProcessStrong(Token token, Stack tagStack, - StringBuilder result) + private void ProcessStrong(Token token, Stack tagsStack, StringBuilder result) { - if (tagStack.Count > 0) + var canOpen = token.CanOpen; + var canClose = token.CanClose; + + var parent = tagsStack.Count > 0 ? tagsStack.Peek() : null; + if (canClose && tagsStack.Count > 0 && parent.Token.Type == token.Type && + !(parent.Token.InsideWord && parent.Content.ToString().Contains(' ')) + && !decimal.TryParse(parent.Content.ToString().AsSpan(2), out _) && + parent.Content.ToString().AsSpan(2).Length > 0) { - var parent = tagStack.Peek(); - var parentType = parent.Type; + 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 (parentType == TokenType.Italic) - { - AppendToParentOrResult(tagStack, result, token.Value); - } - else if (parentType == token.Type) + if (shouldNotClose) { - CloseTopTag(tagStack, result); + var newContent = popParent.Content.Append(popParent.Token.Value); + AppendToParentOrResult(tagsStack, result, newContent.ToString()); } else { - tagStack.Push(new Tag(token.Type, token.Value, new StringBuilder())); + tagsStack.Push(popParent); + CloseTopTag(tagsStack, result); } } + else if (canOpen || (canClose && tagsStack.Count > 0 && tagsStack.Peek().isOpen)) + { + tagsStack.Push(new Tag(token, new StringBuilder(token.Value), true)); + } else { - tagStack.Push(new Tag(token.Type, token.Value, new StringBuilder())); + AppendToParentOrResult(tagsStack, result, token.Value); } } - private void ProcessItalic(Token token, Stack tagStack, - StringBuilder result) + private void ProcessItalic(Token token, Stack tagsStack, StringBuilder result) { - if (tagStack.Count > 0 && tagStack.Peek().Type == token.Type) + var canOpen = token.CanOpen; + var canClose = token.CanClose; + var parent = tagsStack.Count > 0 ? tagsStack.Peek() : null; + if (canClose && tagsStack.Count > 0 && parent.Token.Type == token.Type && + !(parent.Token.InsideWord && parent.Content.ToString().Contains(' ')) + && !decimal.TryParse(parent.Content.ToString().AsSpan(1), out _) + && parent.Content.ToString().AsSpan(2).Length > 0) + { + CloseTopTag(tagsStack, result); + } + else if (canOpen) { - CloseTopTag(tagStack, result); + tagsStack.Push(new Tag(token, new StringBuilder(token.Value), true)); } else { - tagStack.Push(new Tag(token.Type, token.Value, new StringBuilder())); + AppendToParentOrResult(tagsStack, result, token.Value); } } - private void ProcessHeader(Stack tagStack) + private void ProcessHeader(Token token, Stack tagsStack) { - tagStack.Push(new Tag(TokenType.Header, "#", new StringBuilder())); + tagsStack.Push(new Tag(token, new StringBuilder(token.Value), true)); } - private void ProcessEnd(Stack tagStack, - StringBuilder result) + private void ProcessEnd(Stack tagsStack, StringBuilder result) { - while (tagStack.Count > 0) + while (tagsStack.Count > 0) { - var top = tagStack.Pop(); - string rendered; - - var raw = top.Type is TokenType.Header - ? TagRender.Wrap(top.Type, top.Content.ToString()) - : top.Marker + - top.Content; - - AppendToParentOrResult(tagStack, result, raw); + var top = tagsStack.Pop(); + var content = top.Content.ToString(); + + var raw = top.Token.Type == TokenType.Header + ? TagRender.Wrap(top.Token.Type, content[1..]) + : content; + + AppendToParentOrResult(tagsStack, result, raw); } } - private void AppendToParentOrResult(Stack tagStack, - StringBuilder result, string content) + private void AppendToParentOrResult(Stack tagsStack, StringBuilder result, string content) { - if (tagStack.Count > 0) tagStack.Peek().Content.Append(content); - else result.Append(content); + if (tagsStack.Count > 0) + tagsStack.Peek().Content.Append(content); + else + result.Append(content); } - private void CloseTopTag(Stack tagStack, - StringBuilder result) + private void CloseTopTag(Stack tagsStack, StringBuilder result) { - var top = tagStack.Pop(); - var wrapped = TagRender.Wrap(top.Type, top.Content.ToString()); - AppendToParentOrResult(tagStack, result, wrapped); + var top = tagsStack.Pop(); + var content = top.Content.ToString(); + + var markerLength = top.Token.Value.Length; + var innerContent = content.Length > markerLength + ? content[markerLength..] + : string.Empty; + + var wrapped = TagRender.Wrap(top.Token.Type, innerContent); + AppendToParentOrResult(tagsStack, result, wrapped); } } } \ No newline at end of file From d2b239c249ba407913dac84238bade65468eafbc Mon Sep 17 00:00:00 2001 From: younggogy Date: Thu, 6 Nov 2025 00:40:16 +0500 Subject: [PATCH 14/32] =?UTF-8?q?=D0=92=D1=8B=D0=BD=D0=B5=D1=81=20=D0=BB?= =?UTF-8?q?=D0=BE=D0=B3=D0=B8=D0=BA=D1=83=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=D0=BA=D0=B8=20=D0=BD=D0=B0=20=D0=B7=D0=B0=D0=BA=D1=80?= =?UTF-8?q?=D1=8B=D1=82=D0=B8=D0=B5=20=D1=82=D0=B5=D0=B3=D0=B0=20=D0=B2=20?= =?UTF-8?q?=D0=BE=D1=82=D0=B4=D0=B5=D0=BB=D1=8C=D0=BD=D1=8B=D0=B9=20=D0=BC?= =?UTF-8?q?=D0=B5=D1=82=D0=BE=D0=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../RenderServices/Implementations/Render.cs | 34 +++++++++++-------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/cs/Markdown/RenderServices/Implementations/Render.cs b/cs/Markdown/RenderServices/Implementations/Render.cs index 61556014f..91fefe815 100644 --- a/cs/Markdown/RenderServices/Implementations/Render.cs +++ b/cs/Markdown/RenderServices/Implementations/Render.cs @@ -78,19 +78,13 @@ private string RenderLine(string line) private void ProcessStrong(Token token, Stack tagsStack, StringBuilder result) { - var canOpen = token.CanOpen; - var canClose = token.CanClose; - var parent = tagsStack.Count > 0 ? tagsStack.Peek() : null; - if (canClose && tagsStack.Count > 0 && parent.Token.Type == token.Type && - !(parent.Token.InsideWord && parent.Content.ToString().Contains(' ')) - && !decimal.TryParse(parent.Content.ToString().AsSpan(2), out _) && - parent.Content.ToString().AsSpan(2).Length > 0) + + if (token.CanClose && tagsStack.Count > 0 && !IsInvalidCloseContext(parent, token, 2)) { 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; + var shouldNotClose = grandParent is not null && grandParent.Token.Type == TokenType.Italic && grandParent.isOpen; if (shouldNotClose) { @@ -103,7 +97,7 @@ private void ProcessStrong(Token token, Stack tagsStack, StringBuilder resu CloseTopTag(tagsStack, result); } } - else if (canOpen || (canClose && tagsStack.Count > 0 && tagsStack.Peek().isOpen)) + else if (token.CanOpen || (token.CanClose && tagsStack.Count > 0 && tagsStack.Peek().isOpen)) { tagsStack.Push(new Tag(token, new StringBuilder(token.Value), true)); } @@ -116,12 +110,8 @@ private void ProcessStrong(Token token, Stack tagsStack, StringBuilder resu private void ProcessItalic(Token token, Stack tagsStack, StringBuilder result) { var canOpen = token.CanOpen; - var canClose = token.CanClose; var parent = tagsStack.Count > 0 ? tagsStack.Peek() : null; - if (canClose && tagsStack.Count > 0 && parent.Token.Type == token.Type && - !(parent.Token.InsideWord && parent.Content.ToString().Contains(' ')) - && !decimal.TryParse(parent.Content.ToString().AsSpan(1), out _) - && parent.Content.ToString().AsSpan(2).Length > 0) + if (token.CanClose && tagsStack.Count > 0 && !IsInvalidCloseContext(parent, token, 2)) { CloseTopTag(tagsStack, result); } @@ -158,9 +148,13 @@ private void ProcessEnd(Stack tagsStack, StringBuilder result) private void AppendToParentOrResult(Stack tagsStack, StringBuilder result, string content) { if (tagsStack.Count > 0) + { tagsStack.Peek().Content.Append(content); + } else + { result.Append(content); + } } private void CloseTopTag(Stack tagsStack, StringBuilder result) @@ -176,5 +170,15 @@ private void CloseTopTag(Stack tagsStack, StringBuilder result) var wrapped = TagRender.Wrap(top.Token.Type, innerContent); AppendToParentOrResult(tagsStack, result, wrapped); } + + private bool IsInvalidCloseContext(Tag parent, Token token, int markerLength) + { + if (parent == null) return true; + if (parent.Token.Type != token.Type) return true; + if (parent.Token.InsideWord && parent.Content.ToString().Contains(' ')) return true; + + var contentSpan = parent.Content.ToString().AsSpan(markerLength); + return contentSpan.Length == 0 || decimal.TryParse(contentSpan, out _); + } } } \ No newline at end of file From db6590bc4fb9791e6c1beea44aa919bf526a7f81 Mon Sep 17 00:00:00 2001 From: gogy4 Date: Thu, 6 Nov 2025 14:04:09 +0300 Subject: [PATCH 15/32] Update MarkdownTests.cs --- cs/Markdown/Tests/MarkdownTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cs/Markdown/Tests/MarkdownTests.cs b/cs/Markdown/Tests/MarkdownTests.cs index 856737629..6f69945df 100644 --- a/cs/Markdown/Tests/MarkdownTests.cs +++ b/cs/Markdown/Tests/MarkdownTests.cs @@ -17,7 +17,7 @@ public void Markdown_RenderText_ShouldMatchExpected(string line, string expected } [Test] - public void Markdown_ShouldProcessLongInputWithoutCrashing_Test() + public void Markdown_ShouldProcessLongInputWithoutCrashing() { var longLine = new string('_', 10_000) + "тест" + new string('_', 10_000); var result = render.RenderText(longLine); From cd9e0f4b50c01cec30205c827e5398885aa670e1 Mon Sep 17 00:00:00 2001 From: younggogy Date: Fri, 7 Nov 2025 22:11:11 +0500 Subject: [PATCH 16/32] =?UTF-8?q?=D0=92=D1=8B=D0=BD=D0=B5=D1=81=20=D0=BD?= =?UTF-8?q?=D0=BE=D0=B2=D1=8B=D0=B5=20enum=20=D0=B4=D0=BB=D1=8F=20=D1=83?= =?UTF-8?q?=D0=B4=D0=BE=D0=B1=D0=BD=D0=BE=D0=B9=20=D1=80=D0=B0=D0=B1=D0=BE?= =?UTF-8?q?=D1=82=D1=8B=20=D1=81=20=D1=82=D0=BE=D0=BA=D0=B5=D0=BD=D0=B0?= =?UTF-8?q?=D0=BC=D0=B8.=20=D0=A1=D0=B4=D0=B5=D0=BB=D0=B0=D0=BB=20Token.cs?= =?UTF-8?q?=20=D0=B8=20Tag.cs=20=D0=BD=D0=B5=20record=20=D0=BA=D0=BB=D0=B0?= =?UTF-8?q?=D1=81=D1=81=D0=B0=D0=BC=D0=B8=20=D1=81=20=D0=BB=D0=BE=D0=B3?= =?UTF-8?q?=D0=B8=D0=BA=D0=BE=D0=B9.=20=D0=91=D0=BB=D0=B0=D0=B3=D0=BE?= =?UTF-8?q?=D0=B4=D0=B0=D1=80=D1=8F=20=D0=BB=D0=BE=D0=B3=D0=B8=D0=BA=D0=B5?= =?UTF-8?q?=20=D0=B2=20Tag.cs=20=D1=80=D0=B5=D0=BD=D0=B4=D0=B5=D1=80=20?= =?UTF-8?q?=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D0=B0=D0=B5=D1=82=20=D0=B1=D1=8B?= =?UTF-8?q?=D1=8B=D1=81=D1=82=D1=80=D0=B5=D0=B5,=20=D1=82.=D0=BA.=20=D0=BD?= =?UTF-8?q?=D0=B5=20=D0=BD=D1=83=D0=B6=D0=BD=D0=BE=20=D0=B4=D0=B5=D0=BB?= =?UTF-8?q?=D0=B0=D1=82=D1=8C=20=D0=B4=D0=BE=D0=BB=D0=B3=D0=B8=D0=B5=20?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D0=BA=D0=B8=20=D0=BD=D0=B0?= =?UTF-8?q?=20decimal.tryparse=20=D0=B8=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BA=D0=BE=D0=BD=D1=82=D0=B5?= =?UTF-8?q?=D0=BD=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cs/Markdown/Dto/Tag.cs | 45 ++++++++++++++++++++++++++++-- cs/Markdown/Dto/Token.cs | 48 +++++++++++++++++++++++++++++++- cs/Markdown/Dto/TokenPosition.cs | 9 ++++++ cs/Markdown/Dto/TokenRole.cs | 9 ++++++ 4 files changed, 108 insertions(+), 3 deletions(-) create mode 100644 cs/Markdown/Dto/TokenPosition.cs create mode 100644 cs/Markdown/Dto/TokenRole.cs diff --git a/cs/Markdown/Dto/Tag.cs b/cs/Markdown/Dto/Tag.cs index e86f75548..651dbe7a7 100644 --- a/cs/Markdown/Dto/Tag.cs +++ b/cs/Markdown/Dto/Tag.cs @@ -1,5 +1,46 @@ using System.Text; -namespace Markdown.Dto; +namespace Markdown.Dto +{ + public class Tag + { + public Token Token { get; } + public StringBuilder Content { get; } + public bool IsOpen { get; } -public record Tag(Token Token, StringBuilder Content, bool isOpen); \ No newline at end of file + 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 index 70241c1d2..c3989c923 100644 --- a/cs/Markdown/Dto/Token.cs +++ b/cs/Markdown/Dto/Token.cs @@ -1,3 +1,49 @@ namespace Markdown.Dto; -public record Token(string Value, TokenType Type, bool CanOpen, bool CanClose, bool InsideWord); \ No newline at end of file +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) + { + Role = (canOpen, canClose) switch + { + (true, true) => TokenRole.Both, + (true, false) => TokenRole.Open, + (false, true) => TokenRole.Close, + (false, false) => TokenRole.None + }; + } + + private void DeterminePosition(bool canOpen, bool canClose) + { + Position = (canOpen, canClose) switch + { + (true, true) => TokenPosition.Inside, + (true, false) => TokenPosition.Begin, + (false, true) => TokenPosition.End, + (false, false) => TokenPosition.None + }; + } + +} \ 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 From 794afe9594bc71ca0ca7e6d429438d8cd0b68f4b Mon Sep 17 00:00:00 2001 From: younggogy Date: Fri, 7 Nov 2025 22:12:23 +0500 Subject: [PATCH 17/32] =?UTF-8?q?=D0=9F=D0=BE=D0=BC=D0=B5=D0=BD=D1=8F?= =?UTF-8?q?=D0=BB=20=D0=BB=D0=BE=D0=B3=D0=B8=D0=BA=D1=83=20=D0=B2=20=D1=81?= =?UTF-8?q?=D0=B2=D1=8F=D0=B7=D0=B8=20=D0=B8=D0=B7=D0=BC=D0=B5=D0=BD=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B9=20=D0=B4=D1=82=D0=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cs/Markdown/TokensUtils/Implementations/Tokenizer.cs | 8 ++++---- cs/Markdown/TokensUtils/MapSpecialSymbol.cs | 10 +++++----- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/cs/Markdown/TokensUtils/Implementations/Tokenizer.cs b/cs/Markdown/TokensUtils/Implementations/Tokenizer.cs index f9c0807f9..32691095d 100644 --- a/cs/Markdown/TokensUtils/Implementations/Tokenizer.cs +++ b/cs/Markdown/TokensUtils/Implementations/Tokenizer.cs @@ -6,7 +6,7 @@ namespace Markdown.TokensUtils.Implementations; public class Tokenizer : ITokenizer { - private static readonly HashSet SpecialSymbols = new() { '\\', '_', '#' }; + private static readonly HashSet SpecialSymbols = ['\\', '_', '#']; public IEnumerable Tokenize(string? line) { @@ -21,7 +21,7 @@ public IEnumerable Tokenize(string? line) if (SpecialSymbols.Contains(c) && sb.Length > 0) { - yield return new Token(sb.ToString(), TokenType.Text, false, false, false); + yield return new Token(sb.ToString(), TokenType.Text, false, false); sb.Clear(); } @@ -45,9 +45,9 @@ public IEnumerable Tokenize(string? line) if (sb.Length > 0) { - yield return new Token(sb.ToString(), TokenType.Text, false, false, false); + yield return new Token(sb.ToString(), TokenType.Text, false, false); } - yield return new Token(string.Empty, TokenType.End, false, 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 index 15def81ea..ea35b7df3 100644 --- a/cs/Markdown/TokensUtils/MapSpecialSymbol.cs +++ b/cs/Markdown/TokensUtils/MapSpecialSymbol.cs @@ -15,7 +15,7 @@ public static class MapSpecialSymbol '#' when index + 1 < line.Length && line[index + 1] == ' ' => CreateHeaderToken(line, index), '\\' when index + 1 < line.Length => - new Token(line[index].ToString(), TokenType.Escape, false, false, false), + new Token(line[index].ToString(), TokenType.Escape, TokenRole.None, TokenPosition.None), _ => null }; } @@ -28,15 +28,15 @@ private static Token CreateUnderscoreToken(string line, int index, bool isStrong var nextChar = index + value.Length < line.Length ? line[index + value.Length] : (char?)null; var canOpen = nextChar != null && !char.IsWhiteSpace(nextChar.Value); var canClose = prevChar != null && !char.IsWhiteSpace(prevChar.Value); - var isInsideWord = canOpen && canClose; - return new Token(value, type, canOpen, canClose, isInsideWord); + + return new Token(value, type, canOpen, canClose); } private static Token CreateHeaderToken(string line, int index) { - var value = "#"; + const string value = "#"; var nextChar = index + value.Length < line.Length ? line[index + value.Length] : (char?)null; var canOpenAndClose = nextChar != null && char.IsWhiteSpace(nextChar.Value); - return new Token(value, TokenType.Header, canOpenAndClose, canOpenAndClose, !canOpenAndClose); + return new Token(value, TokenType.Header, canOpenAndClose, canOpenAndClose); } } \ No newline at end of file From b2d5fadfa3b704b23fbb79417f9270c13fa2309d Mon Sep 17 00:00:00 2001 From: younggogy Date: Fri, 7 Nov 2025 22:13:00 +0500 Subject: [PATCH 18/32] =?UTF-8?q?=D0=9F=D0=B5=D1=80=D0=B5=D0=B8=D0=BC?= =?UTF-8?q?=D0=B5=D0=BD=D0=BE=D0=B2=D0=B0=D0=BB=20=D0=BA=D0=BB=D0=B0=D1=81?= =?UTF-8?q?=D1=81=20=D1=80=D0=B5=D0=BD=D0=B4=D0=B5=D1=80=D0=B0,=20=D0=B2?= =?UTF-8?q?=D1=8B=D0=BD=D0=B5=D1=81=20=D0=B2=20=D0=BE=D1=82=D0=B4=D0=B5?= =?UTF-8?q?=D0=BB=D1=8C=D0=BD=D1=83=D1=8E=20=D0=BB=D0=BE=D0=B3=D0=B8=D0=BA?= =?UTF-8?q?=D1=83=20=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D1=83=20=D1=81=20=D1=82?= =?UTF-8?q?=D0=B5=D0=B3=D0=B0=D0=BC=D0=B8,=20=D1=87=D1=82=D0=BE=D0=B1?= =?UTF-8?q?=D1=8B=20=D1=81=D0=BB=D0=B5=D0=B4=D0=BE=D0=B2=D0=B0=D1=82=D1=8C?= =?UTF-8?q?=20SRP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Implementations/MarkdownRender.cs | 33 ++++ .../RenderServices/Implementations/Render.cs | 184 ------------------ .../Abstractions/ITagContentManager.cs | 10 + .../TagUtils/Abstractions/ITagManager.cs | 13 ++ .../TagUtils/Abstractions/ITagProcessor.cs | 9 + cs/Markdown/TagUtils/CloseContext.cs | 21 ++ .../Implementations/TagContentManager.cs | 35 ++++ .../TagUtils/Implementations/TagManager.cs | 81 ++++++++ .../TagUtils/Implementations/TagProcessor.cs | 60 ++++++ 9 files changed, 262 insertions(+), 184 deletions(-) create mode 100644 cs/Markdown/RenderServices/Implementations/MarkdownRender.cs delete mode 100644 cs/Markdown/RenderServices/Implementations/Render.cs create mode 100644 cs/Markdown/TagUtils/Abstractions/ITagContentManager.cs create mode 100644 cs/Markdown/TagUtils/Abstractions/ITagManager.cs create mode 100644 cs/Markdown/TagUtils/Abstractions/ITagProcessor.cs create mode 100644 cs/Markdown/TagUtils/CloseContext.cs create mode 100644 cs/Markdown/TagUtils/Implementations/TagContentManager.cs create mode 100644 cs/Markdown/TagUtils/Implementations/TagManager.cs create mode 100644 cs/Markdown/TagUtils/Implementations/TagProcessor.cs 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/RenderServices/Implementations/Render.cs b/cs/Markdown/RenderServices/Implementations/Render.cs deleted file mode 100644 index 91fefe815..000000000 --- a/cs/Markdown/RenderServices/Implementations/Render.cs +++ /dev/null @@ -1,184 +0,0 @@ -using System.Text; -using Markdown.Dto; -using Markdown.Extensions; -using Markdown.TokensUtils; -using Markdown.TokensUtils.Abstractions; -using Markdown.TokensUtils.Implementations; - -namespace Markdown -{ - public class Render : IRender - { - private readonly ITokenizer tokenizer = new Tokenizer(); - - 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(); - var result = new StringBuilder(); - - var tagsStack = new Stack(); - var skipNextAsMarkup = false; - - foreach (var token in tokens) - { - if (skipNextAsMarkup) - { - AppendToParentOrResult(tagsStack, result, token.Value); - skipNextAsMarkup = false; - continue; - } - - switch (token.Type) - { - case TokenType.Text: - AppendToParentOrResult(tagsStack, result, token.Value); - break; - - case TokenType.Escape: - AppendToParentOrResult(tagsStack, result, token.Value); - skipNextAsMarkup = true; - break; - - case TokenType.Italic: - ProcessItalic(token, tagsStack, result); - break; - - case TokenType.Strong: - ProcessStrong(token, tagsStack, result); - break; - - case TokenType.Header: - ProcessHeader(token, tagsStack); - break; - - case TokenType.End: - ProcessEnd(tagsStack, result); - break; - - default: - throw new ArgumentOutOfRangeException(); - } - } - - return result.ToString(); - } - - private void ProcessStrong(Token token, Stack tagsStack, StringBuilder result) - { - var parent = tagsStack.Count > 0 ? tagsStack.Peek() : null; - - if (token.CanClose && tagsStack.Count > 0 && !IsInvalidCloseContext(parent, token, 2)) - { - 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); - AppendToParentOrResult(tagsStack, result, newContent.ToString()); - } - else - { - tagsStack.Push(popParent); - CloseTopTag(tagsStack, result); - } - } - else if (token.CanOpen || (token.CanClose && tagsStack.Count > 0 && tagsStack.Peek().isOpen)) - { - tagsStack.Push(new Tag(token, new StringBuilder(token.Value), true)); - } - else - { - AppendToParentOrResult(tagsStack, result, token.Value); - } - } - - private void ProcessItalic(Token token, Stack tagsStack, StringBuilder result) - { - var canOpen = token.CanOpen; - var parent = tagsStack.Count > 0 ? tagsStack.Peek() : null; - if (token.CanClose && tagsStack.Count > 0 && !IsInvalidCloseContext(parent, token, 2)) - { - CloseTopTag(tagsStack, result); - } - else if (canOpen) - { - tagsStack.Push(new Tag(token, new StringBuilder(token.Value), true)); - } - else - { - AppendToParentOrResult(tagsStack, result, token.Value); - } - } - - private void ProcessHeader(Token token, Stack tagsStack) - { - tagsStack.Push(new Tag(token, new StringBuilder(token.Value), true)); - } - - private void ProcessEnd(Stack tagsStack, StringBuilder result) - { - while (tagsStack.Count > 0) - { - var top = tagsStack.Pop(); - var content = top.Content.ToString(); - - var raw = top.Token.Type == TokenType.Header - ? TagRender.Wrap(top.Token.Type, content[1..]) - : content; - - AppendToParentOrResult(tagsStack, result, raw); - } - } - - private void AppendToParentOrResult(Stack tagsStack, StringBuilder result, string content) - { - if (tagsStack.Count > 0) - { - tagsStack.Peek().Content.Append(content); - } - else - { - result.Append(content); - } - } - - private void CloseTopTag(Stack tagsStack, StringBuilder result) - { - var top = tagsStack.Pop(); - var content = top.Content.ToString(); - - var markerLength = top.Token.Value.Length; - var innerContent = content.Length > markerLength - ? content[markerLength..] - : string.Empty; - - var wrapped = TagRender.Wrap(top.Token.Type, innerContent); - AppendToParentOrResult(tagsStack, result, wrapped); - } - - private bool IsInvalidCloseContext(Tag parent, Token token, int markerLength) - { - if (parent == null) return true; - if (parent.Token.Type != token.Type) return true; - if (parent.Token.InsideWord && parent.Content.ToString().Contains(' ')) return true; - - var contentSpan = parent.Content.ToString().AsSpan(markerLength); - return contentSpan.Length == 0 || decimal.TryParse(contentSpan, out _); - } - } -} \ No newline at end of file diff --git a/cs/Markdown/TagUtils/Abstractions/ITagContentManager.cs b/cs/Markdown/TagUtils/Abstractions/ITagContentManager.cs new file mode 100644 index 000000000..38804055a --- /dev/null +++ b/cs/Markdown/TagUtils/Abstractions/ITagContentManager.cs @@ -0,0 +1,10 @@ +using System.Text; +using Markdown.Dto; + +namespace Markdown.TagUtils.Abstractions; + +public interface ITagContentManager +{ + public void AppendToParentOrResult(Stack tagsStack, StringBuilder result, string content); + public void CloseTopTag(Stack tagsStack, StringBuilder result); +} \ No newline at end of file diff --git a/cs/Markdown/TagUtils/Abstractions/ITagManager.cs b/cs/Markdown/TagUtils/Abstractions/ITagManager.cs new file mode 100644 index 000000000..10da8c351 --- /dev/null +++ b/cs/Markdown/TagUtils/Abstractions/ITagManager.cs @@ -0,0 +1,13 @@ +using System.Text; +using Markdown.Dto; + +namespace Markdown.TagUtils.Abstractions; + +public interface ITagManager +{ + public void EndProcess(Stack tagsStack, StringBuilder result); + public void HeaderProcess(Token token, Stack tagsStack); + public void ItalicProcess(Token token, Stack tagsStack, StringBuilder result); + public void StrongProcess(Token token, Stack tagsStack, StringBuilder result); + +} \ 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..14dbb448e --- /dev/null +++ b/cs/Markdown/TagUtils/CloseContext.cs @@ -0,0 +1,21 @@ +using Markdown.Dto; + +namespace Markdown.TagUtils; + +public static class CloseContext +{ + public 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 || parent.HasOnlyDigits; + } +} \ No newline at end of file diff --git a/cs/Markdown/TagUtils/Implementations/TagContentManager.cs b/cs/Markdown/TagUtils/Implementations/TagContentManager.cs new file mode 100644 index 000000000..f3128d600 --- /dev/null +++ b/cs/Markdown/TagUtils/Implementations/TagContentManager.cs @@ -0,0 +1,35 @@ +using System.Text; +using Markdown.Dto; +using Markdown.TagUtils.Abstractions; +using Markdown.TokensUtils; + +namespace Markdown.TagUtils.Implementations; + +public class TagContentManager : ITagContentManager +{ + public void AppendToParentOrResult(Stack tagsStack, StringBuilder result, string content) + { + if (tagsStack.Count > 0) + { + tagsStack.Peek().Append(content); + } + else + { + result.Append(content); + } + } + + public void CloseTopTag(Stack tagsStack, StringBuilder result) + { + var top = tagsStack.Pop(); + var content = top.Content.ToString(); + + var markerLength = top.Token.Value.Length; + var innerContent = content.Length > markerLength + ? content[markerLength..] + : string.Empty; + + var wrapped = TagRender.Wrap(top.Token.Type, innerContent); + AppendToParentOrResult(tagsStack, result, wrapped); + } +} \ No newline at end of file diff --git a/cs/Markdown/TagUtils/Implementations/TagManager.cs b/cs/Markdown/TagUtils/Implementations/TagManager.cs new file mode 100644 index 000000000..d4b86126b --- /dev/null +++ b/cs/Markdown/TagUtils/Implementations/TagManager.cs @@ -0,0 +1,81 @@ +using System.Text; +using Markdown.Dto; +using Markdown.TagUtils.Abstractions; +using Markdown.TokensUtils; + +namespace Markdown.TagUtils.Implementations; + +public class TagManager(ITagContentManager contentManager) : ITagManager +{ + public void EndProcess(Stack tagsStack, StringBuilder result) + { + while (tagsStack.Count > 0) + { + var top = tagsStack.Pop(); + var content = top.Content.ToString(); + + var raw = top.Token.Type == TokenType.Header + ? TagRender.Wrap(top.Token.Type, content[1..]) + : content; + + contentManager.AppendToParentOrResult(tagsStack, result, raw); + } + } + + public void HeaderProcess(Token token, Stack tagsStack) + { + tagsStack.Push(new Tag(token, new StringBuilder(token.Value), true)); + } + + public void ItalicProcess(Token token, Stack tagsStack, StringBuilder result) + { + var canOpen = token.Role is TokenRole.Open or TokenRole.Both; + var parent = tagsStack.Count > 0 ? tagsStack.Peek() : null; + if (token.Role is TokenRole.Close or TokenRole.Both && tagsStack.Count > 0 && + !CloseContext.IsInvalidCloseContext(parent, token, 2)) + { + contentManager.CloseTopTag(tagsStack, result); + } + else if (canOpen) + { + tagsStack.Push(new Tag(token, new StringBuilder(token.Value), true)); + } + else + { + contentManager.AppendToParentOrResult(tagsStack, result, token.Value); + } + } + + public void StrongProcess(Token token, Stack tagsStack, StringBuilder result) + { + var parent = tagsStack.Count > 0 ? tagsStack.Peek() : null; + + if (token.Role == TokenRole.Close && tagsStack.Count > 0 && !CloseContext.IsInvalidCloseContext(parent, token, 2)) + { + 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); + contentManager.AppendToParentOrResult(tagsStack, result, newContent.ToString()); + } + else + { + tagsStack.Push(popParent); + contentManager.CloseTopTag(tagsStack, result); + } + } + else if (token.Role is TokenRole.Open or TokenRole.Both || + (token.Role == TokenRole.Close && tagsStack.Count > 0 && tagsStack.Peek().IsOpen)) + { + tagsStack.Push(new Tag(token, new StringBuilder(token.Value), true)); + } + else + { + contentManager.AppendToParentOrResult(tagsStack, result, token.Value); + } + } +} \ 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..bff8a28e0 --- /dev/null +++ b/cs/Markdown/TagUtils/Implementations/TagProcessor.cs @@ -0,0 +1,60 @@ +using System.Text; +using Markdown.Dto; +using Markdown.TagUtils.Abstractions; +using Markdown.TokensUtils; + +namespace Markdown.TagUtils.Implementations +{ + public class TagProcessor(ITagContentManager contentManager, ITagManager tagManager) : ITagProcessor + { + public string Process(IEnumerable tokens) + { + var result = new StringBuilder(); + var tagsStack = new Stack(); + var skipNextAsMarkup = false; + + foreach (var token in tokens) + { + if (skipNextAsMarkup) + { + contentManager.AppendToParentOrResult(tagsStack, result, token.Value); + skipNextAsMarkup = false; + continue; + } + + switch (token.Type) + { + case TokenType.Text: + contentManager.AppendToParentOrResult(tagsStack, result, token.Value); + break; + + case TokenType.Escape: + contentManager.AppendToParentOrResult(tagsStack, result, token.Value); + skipNextAsMarkup = true; + break; + + case TokenType.Italic: + tagManager.ItalicProcess(token, tagsStack, result); + break; + + case TokenType.Strong: + tagManager.StrongProcess(token, tagsStack, result); + break; + + case TokenType.Header: + tagManager.HeaderProcess(token, tagsStack); + break; + + case TokenType.End: + tagManager.EndProcess(tagsStack, result); + break; + + default: + throw new ArgumentOutOfRangeException(); + } + } + + return result.ToString(); + } + } +} \ No newline at end of file From 78ccd277565eae6f51c1daaa6d540cce173b2afa Mon Sep 17 00:00:00 2001 From: younggogy Date: Fri, 7 Nov 2025 22:13:46 +0500 Subject: [PATCH 19/32] =?UTF-8?q?=D0=9F=D0=BE=D0=BC=D0=B5=D0=BD=D1=8F?= =?UTF-8?q?=D0=BB=20=D1=82=D0=B5=D1=81=D1=82,=20=D0=BA=D0=BE=D1=82=D0=BE?= =?UTF-8?q?=D1=80=D1=8B=D0=B9=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D1=8F?= =?UTF-8?q?=D0=B5=D1=82=20=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D1=83=20=D0=B7?= =?UTF-8?q?=D0=B0=20O(n),=20=D1=82=D0=B5=D0=BF=D0=B5=D1=80=D1=8C=20=D0=B2?= =?UTF-8?q?=D1=8B=D0=BF=D0=BE=D0=BB=D0=BD=D1=8F=D0=B5=D1=82=D1=81=D1=8F=20?= =?UTF-8?q?=D0=BD=D0=B5=D1=81=D0=BA=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20=D0=B7?= =?UTF-8?q?=D0=B0=D0=BC=D0=B5=D1=80=D0=BE=D0=B2=20=D0=B2=D1=80=D0=B5=D0=BC?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=20=D0=BD=D0=B0=20=D1=80=D0=B0=D0=B7=D0=BD?= =?UTF-8?q?=D0=BE=D0=B5=20=D0=BA=D0=BE=D0=BB=D0=B8=D1=87=D0=B5=D1=81=D1=82?= =?UTF-8?q?=D0=B2=D0=BE=20=D1=81=D1=82=D1=80=D0=BE=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cs/Markdown/Tests/MarkdownTests.cs | 80 +++++++++++++++++++++++------- 1 file changed, 63 insertions(+), 17 deletions(-) diff --git a/cs/Markdown/Tests/MarkdownTests.cs b/cs/Markdown/Tests/MarkdownTests.cs index 6f69945df..26a9b1c3a 100644 --- a/cs/Markdown/Tests/MarkdownTests.cs +++ b/cs/Markdown/Tests/MarkdownTests.cs @@ -1,13 +1,27 @@ using System.Diagnostics; using FluentAssertions; -using FluentAssertions.Execution; +using Markdown.TagUtils.Implementations; +using Markdown.TokensUtils.Implementations; using NUnit.Framework; namespace Markdown.Tests; public class MarkdownTests { - private IRender render = new Render(); + private TagProcessor tagProcessor; + private Tokenizer tokenizer; + private IRender render; + private TagContentManager tagContentManager; + private TagManager tagManager; + + public MarkdownTests() + { + tagContentManager = new TagContentManager(); + tagManager = new TagManager(tagContentManager); + tagProcessor = new TagProcessor(tagContentManager, tagManager); + tokenizer = new Tokenizer(); + render = new MarkdownRender(tokenizer, tagProcessor); + } [TestCaseSource(nameof(MarkdownCases))] public void Markdown_RenderText_ShouldMatchExpected(string line, string expectedAndErrorMessage) @@ -17,23 +31,55 @@ public void Markdown_RenderText_ShouldMatchExpected(string line, string expected } [Test] - public void Markdown_ShouldProcessLongInputWithoutCrashing() + public void Markdown_RenderTime_ShouldScaleLinearly_Test() { - var longLine = new string('_', 10_000) + "тест" + new string('_', 10_000); - var result = render.RenderText(longLine); + //Arrange + var sizes = new[] { 100, 1000, 3000, 100000 }; + int? previousSize = null; + double? previousTime = null; + const int runsPerSize = 5; - using (new AssertionScope()) + foreach (var size in sizes) { - result.Length.Should().BeGreaterThanOrEqualTo(longLine.Length, "рендер не должен обрезать длинный ввод"); - - var stopwatch = Stopwatch.StartNew(); - render.RenderText(longLine); - stopwatch.Stop(); - - stopwatch.ElapsedMilliseconds.Should().BeLessThan(1000, "рендер должен завершаться за приемлемое время"); + //Arrange + 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 totalTime = 0d; + + //Act + for (var run = 0; run < runsPerSize; run++) + { + var sw = Stopwatch.StartNew(); + render.RenderText(input); + sw.Stop(); + totalTime += sw.Elapsed.TotalMilliseconds; + } + + var avgTime = totalTime / runsPerSize; + + //Assert + if (previousTime.HasValue && previousSize.HasValue) + { + var timeRatio = avgTime / previousTime.Value; + var sizeRatio = (double)size / previousSize.Value; + + timeRatio.Should().BeLessThanOrEqualTo(sizeRatio * 1.2); + } + + previousTime = avgTime; + previousSize = size; } } - + + public static IEnumerable MarkdownCases { get @@ -69,8 +115,8 @@ public static IEnumerable MarkdownCases ).SetName("StrongInsideEm_NoRender"); yield return new TestCaseData( - "Подчерки внутри текста c цифрами_12_3 или 1__123 не считаются выделением и должны оставаться символами подчерка.", - "Подчерки внутри текста c цифрами_12_3 или 1__123 не считаются выделением и должны оставаться символами подчерка." + "Подчерки внутри текста c цифрами_12_3 или 1__1__23 не считаются выделением и должны оставаться символами подчерка.", + "Подчерки внутри текста c цифрами_12_3 или 1__1__23 не считаются выделением и должны оставаться символами подчерка." ).SetName("UnderscoresAroundNumbers_NoRender"); yield return new TestCaseData( @@ -119,4 +165,4 @@ public static IEnumerable MarkdownCases ).SetName("TripleUnderscore_NoRender"); } } -} +} \ No newline at end of file From 5d37e7c5e65cac6201b88a98a769a6698a5d612d Mon Sep 17 00:00:00 2001 From: younggogy Date: Sat, 8 Nov 2025 20:59:42 +0500 Subject: [PATCH 20/32] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D0=BB=20=D0=BE=D0=B1=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D0=BA=D1=83?= =?UTF-8?q?=20=D0=BD=D0=BE=D0=B2=D0=BE=D0=B3=D0=BE=20=D1=82=D0=B5=D0=B3?= =?UTF-8?q?=D0=B0=20-=20=D1=81=D1=81=D1=8B=D0=BB=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cs/Markdown/Dto/TokenType.cs | 1 + .../TagUtils/Abstractions/ITagManager.cs | 2 +- cs/Markdown/TagUtils/CloseContext.cs | 44 ++++++++++++++++++- .../TagUtils/Implementations/TagProcessor.cs | 4 ++ .../TokensUtils/Implementations/Tokenizer.cs | 2 +- cs/Markdown/TokensUtils/MapSpecialSymbol.cs | 14 ++++++ cs/Markdown/TokensUtils/TagRender.cs | 11 ++++- 7 files changed, 73 insertions(+), 5 deletions(-) diff --git a/cs/Markdown/Dto/TokenType.cs b/cs/Markdown/Dto/TokenType.cs index 406e1500c..c52614c70 100644 --- a/cs/Markdown/Dto/TokenType.cs +++ b/cs/Markdown/Dto/TokenType.cs @@ -7,5 +7,6 @@ public enum TokenType Strong, Escape, Header, + Link, End } \ No newline at end of file diff --git a/cs/Markdown/TagUtils/Abstractions/ITagManager.cs b/cs/Markdown/TagUtils/Abstractions/ITagManager.cs index 10da8c351..aa7811710 100644 --- a/cs/Markdown/TagUtils/Abstractions/ITagManager.cs +++ b/cs/Markdown/TagUtils/Abstractions/ITagManager.cs @@ -9,5 +9,5 @@ public interface ITagManager public void HeaderProcess(Token token, Stack tagsStack); public void ItalicProcess(Token token, Stack tagsStack, StringBuilder result); public void StrongProcess(Token token, Stack tagsStack, StringBuilder result); - + public void LinkProcess(Token token, Stack tagsStack, StringBuilder result); } \ No newline at end of file diff --git a/cs/Markdown/TagUtils/CloseContext.cs b/cs/Markdown/TagUtils/CloseContext.cs index 14dbb448e..bee19c78b 100644 --- a/cs/Markdown/TagUtils/CloseContext.cs +++ b/cs/Markdown/TagUtils/CloseContext.cs @@ -4,18 +4,58 @@ namespace Markdown.TagUtils; public static class CloseContext { - public static bool IsInvalidCloseContext(Tag parent, Token token, int markerLength) + 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 || parent.HasOnlyDigits; + return contentLength <= 0; } } \ No newline at end of file diff --git a/cs/Markdown/TagUtils/Implementations/TagProcessor.cs b/cs/Markdown/TagUtils/Implementations/TagProcessor.cs index bff8a28e0..14df25501 100644 --- a/cs/Markdown/TagUtils/Implementations/TagProcessor.cs +++ b/cs/Markdown/TagUtils/Implementations/TagProcessor.cs @@ -40,6 +40,10 @@ public string Process(IEnumerable tokens) case TokenType.Strong: tagManager.StrongProcess(token, tagsStack, result); break; + + case TokenType.Link: + tagManager.LinkProcess(token, tagsStack, result); + break; case TokenType.Header: tagManager.HeaderProcess(token, tagsStack); diff --git a/cs/Markdown/TokensUtils/Implementations/Tokenizer.cs b/cs/Markdown/TokensUtils/Implementations/Tokenizer.cs index 32691095d..ca139bd20 100644 --- a/cs/Markdown/TokensUtils/Implementations/Tokenizer.cs +++ b/cs/Markdown/TokensUtils/Implementations/Tokenizer.cs @@ -6,7 +6,7 @@ namespace Markdown.TokensUtils.Implementations; public class Tokenizer : ITokenizer { - private static readonly HashSet SpecialSymbols = ['\\', '_', '#']; + private static readonly HashSet SpecialSymbols = ['\\', '_', '#', ')', '[']; public IEnumerable Tokenize(string? line) { diff --git a/cs/Markdown/TokensUtils/MapSpecialSymbol.cs b/cs/Markdown/TokensUtils/MapSpecialSymbol.cs index ea35b7df3..161608b51 100644 --- a/cs/Markdown/TokensUtils/MapSpecialSymbol.cs +++ b/cs/Markdown/TokensUtils/MapSpecialSymbol.cs @@ -8,6 +8,10 @@ public static class MapSpecialSymbol { return c switch { + '[' => + CreateLinkToken(line, index, true), + ')' => + CreateLinkToken(line, index, false), '_' when index + 1 < line.Length && line[index + 1] == '_' => CreateUnderscoreToken(line, index, true), '_' => @@ -32,6 +36,16 @@ private static Token CreateUnderscoreToken(string line, int index, bool isStrong return new Token(value, type, canOpen, canClose); } + private static Token CreateLinkToken(string line, int index, bool isOpen) + { + var value = isOpen ? "[" : ")"; + var prevChar = index > 0 ? line[index - 1] : (char?)null; + var nextChar = index + value.Length < line.Length ? line[index + value.Length] : (char?)null; + var canOpen = nextChar != null && !char.IsWhiteSpace(nextChar.Value); + var canClose = prevChar != null && !char.IsWhiteSpace(prevChar.Value); + return new Token(value, TokenType.Link, isOpen && canOpen, !isOpen && canClose); + } + private static Token CreateHeaderToken(string line, int index) { const string value = "#"; diff --git a/cs/Markdown/TokensUtils/TagRender.cs b/cs/Markdown/TokensUtils/TagRender.cs index 92a8066be..2f09fc004 100644 --- a/cs/Markdown/TokensUtils/TagRender.cs +++ b/cs/Markdown/TokensUtils/TagRender.cs @@ -4,14 +4,23 @@ namespace Markdown.TokensUtils { public static class TagRender { - public static string Wrap(TokenType type, string content) + 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 From eb9d6b7c45d3254b4f2a9e068ad1c398cc512946 Mon Sep 17 00:00:00 2001 From: younggogy Date: Sat, 8 Nov 2025 21:00:15 +0500 Subject: [PATCH 21/32] =?UTF-8?q?=D0=BF=D0=BE=D1=80=D0=B5=D1=84=D0=B0?= =?UTF-8?q?=D0=BA=D1=82=D0=BE=D1=80=D0=B8=D0=BB=20=D0=BA=D0=BE=D0=B4,=20?= =?UTF-8?q?=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D0=BB=20=D0=BE=D0=B1=D1=80?= =?UTF-8?q?=D0=B0=D0=B1=D0=BE=D1=82=D0=BA=D1=83=20=D1=81=D1=81=D1=8B=D0=BB?= =?UTF-8?q?=D0=BA=D0=B8,=20=D0=B2=D1=8B=D0=BD=D0=B5=D1=81=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=B2=D1=82=D0=BE=D1=80=D1=8F=D1=8E=D1=89=D0=B8=D0=B9=D1=81?= =?UTF-8?q?=D1=8F=20=D0=BA=D0=BE=D0=B4=20=D0=B2=20=D0=BE=D1=82=D0=B4=D0=B5?= =?UTF-8?q?=D0=BB=D1=8C=D0=BD=D1=8B=D0=B9=20=D0=BC=D0=B5=D1=82=D0=BE=D0=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Abstractions/ITagContentManager.cs | 2 +- .../Implementations/TagContentManager.cs | 26 ++- .../TagUtils/Implementations/TagManager.cs | 76 ++++----- cs/Markdown/Tests/MarkdownTests.cs | 153 ++++++++++++------ 4 files changed, 165 insertions(+), 92 deletions(-) diff --git a/cs/Markdown/TagUtils/Abstractions/ITagContentManager.cs b/cs/Markdown/TagUtils/Abstractions/ITagContentManager.cs index 38804055a..30f80c3ae 100644 --- a/cs/Markdown/TagUtils/Abstractions/ITagContentManager.cs +++ b/cs/Markdown/TagUtils/Abstractions/ITagContentManager.cs @@ -6,5 +6,5 @@ namespace Markdown.TagUtils.Abstractions; public interface ITagContentManager { public void AppendToParentOrResult(Stack tagsStack, StringBuilder result, string content); - public void CloseTopTag(Stack tagsStack, StringBuilder result); + public void CloseTopTag(Stack tagsStack, StringBuilder result, bool isFinal = false); } \ No newline at end of file diff --git a/cs/Markdown/TagUtils/Implementations/TagContentManager.cs b/cs/Markdown/TagUtils/Implementations/TagContentManager.cs index f3128d600..5026b365c 100644 --- a/cs/Markdown/TagUtils/Implementations/TagContentManager.cs +++ b/cs/Markdown/TagUtils/Implementations/TagContentManager.cs @@ -19,17 +19,29 @@ public void AppendToParentOrResult(Stack tagsStack, StringBuilder result, s } } - public void CloseTopTag(Stack tagsStack, StringBuilder result) + public void CloseTopTag(Stack tagsStack, StringBuilder result, bool isFinal = false) { var top = tagsStack.Pop(); var content = top.Content.ToString(); - var markerLength = top.Token.Value.Length; - var innerContent = content.Length > markerLength - ? content[markerLength..] - : string.Empty; + 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; + - var wrapped = TagRender.Wrap(top.Token.Type, innerContent); - AppendToParentOrResult(tagsStack, result, wrapped); + wrapped.Append(TagRender.Wrap(top.Token.Type, innerContent)); + } + + AppendToParentOrResult(tagsStack, result, wrapped.ToString()); } } \ No newline at end of file diff --git a/cs/Markdown/TagUtils/Implementations/TagManager.cs b/cs/Markdown/TagUtils/Implementations/TagManager.cs index d4b86126b..a80dc9761 100644 --- a/cs/Markdown/TagUtils/Implementations/TagManager.cs +++ b/cs/Markdown/TagUtils/Implementations/TagManager.cs @@ -11,14 +11,7 @@ public void EndProcess(Stack tagsStack, StringBuilder result) { while (tagsStack.Count > 0) { - var top = tagsStack.Pop(); - var content = top.Content.ToString(); - - var raw = top.Token.Type == TokenType.Header - ? TagRender.Wrap(top.Token.Type, content[1..]) - : content; - - contentManager.AppendToParentOrResult(tagsStack, result, raw); + contentManager.CloseTopTag(tagsStack, result, true); } } @@ -29,47 +22,58 @@ public void HeaderProcess(Token token, Stack tagsStack) public void ItalicProcess(Token token, Stack tagsStack, StringBuilder result) { - var canOpen = token.Role is TokenRole.Open or TokenRole.Both; - var parent = tagsStack.Count > 0 ? tagsStack.Peek() : null; - if (token.Role is TokenRole.Close or TokenRole.Both && tagsStack.Count > 0 && - !CloseContext.IsInvalidCloseContext(parent, token, 2)) - { - contentManager.CloseTopTag(tagsStack, result); - } - else if (canOpen) - { - tagsStack.Push(new Tag(token, new StringBuilder(token.Value), true)); - } - else - { - contentManager.AppendToParentOrResult(tagsStack, result, token.Value); - } + ProcessTag(token, tagsStack, result, false, (parent, t) => + CloseContext.IsInvalidUnderScoreCloseContext(parent, t, token.Value.Length)); } public void StrongProcess(Token token, Stack tagsStack, StringBuilder result) { - var parent = tagsStack.Count > 0 ? tagsStack.Peek() : null; + ProcessTag(token, tagsStack, result, true, (parent, t) => + CloseContext.IsInvalidUnderScoreCloseContext(parent, t, token.Value.Length)); + } - if (token.Role == TokenRole.Close && tagsStack.Count > 0 && !CloseContext.IsInvalidCloseContext(parent, token, 2)) - { - 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; + public void LinkProcess(Token token, Stack tagsStack, StringBuilder result) + { + ProcessTag(token, tagsStack, result, false, (parent, t) => + CloseContext.IsInvalidLinkCloseContext(parent, t, token.Value.Length)); + } - if (shouldNotClose) + private void ProcessTag(Token token, Stack tagsStack, StringBuilder result, bool isStrong, + Func isInvalidCloseContext) + { + 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 newContent = popParent.Content.Append(popParent.Token.Value); - contentManager.AppendToParentOrResult(tagsStack, result, newContent.ToString()); + 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); + contentManager.AppendToParentOrResult(tagsStack, result, newContent.ToString()); + } + else + { + tagsStack.Push(popParent); + contentManager.CloseTopTag(tagsStack, result); + } } else { - tagsStack.Push(popParent); contentManager.CloseTopTag(tagsStack, result); } } - else if (token.Role is TokenRole.Open or TokenRole.Both || - (token.Role == TokenRole.Close && tagsStack.Count > 0 && tagsStack.Peek().IsOpen)) + else if (canOpen) { tagsStack.Push(new Tag(token, new StringBuilder(token.Value), true)); } diff --git a/cs/Markdown/Tests/MarkdownTests.cs b/cs/Markdown/Tests/MarkdownTests.cs index 26a9b1c3a..438278000 100644 --- a/cs/Markdown/Tests/MarkdownTests.cs +++ b/cs/Markdown/Tests/MarkdownTests.cs @@ -85,84 +85,141 @@ public static IEnumerable MarkdownCases get { yield return new TestCaseData( - "Текст, _окруженный с двух сторон_ одинарными символами подчерка", - "Текст, окруженный с двух сторон одинарными символами подчерка" - ).SetName("SingleUnderscores_Em"); + "Текст, _окруженный с двух сторон_ одинарными символами подчерка", + "Текст, окруженный с двух сторон одинарными символами подчерка") + .SetName("SingleUnderscores_Em"); yield return new TestCaseData( - "__Выделенный двумя символами текст__ должен становиться полужирным", - "Выделенный двумя символами текст должен становиться полужирным" - ).SetName("DoubleUnderscores_Strong"); + "__Выделенный двумя символами текст__ должен становиться полужирным", + "Выделенный двумя символами текст должен становиться полужирным") + .SetName("DoubleUnderscores_Strong"); yield return new TestCaseData( - "Любой тег можно экранировать \\ и даже \\", - "Любой тег можно экранировать \\ и даже \\" - ).SetName("EscapeMarkdownTags"); + "Любой тег можно экранировать \\ и даже \\", + "Любой тег можно экранировать \\ и даже \\") + .SetName("EscapeMarkdownTags"); yield return new TestCaseData( - "Любой символ можно экранировать, чтобы он не считался частью разметки.\n\\_Вот это\\_, не должно выделиться тегом \\. \n Также \\__Это не выделяется\\__ тегом \\.", - "Любой символ можно экранировать, чтобы он не считался частью разметки.\n\\_Вот это\\_, не должно выделиться тегом \\. \n Также \\__Это не выделяется\\__ тегом \\." - ).SetName("EscapedUnderscores_NoRender"); + "Любой символ можно экранировать, чтобы он не считался частью разметки.\n\\_Вот это\\_, " + + "не должно выделиться тегом \\. \n Также \\__Это не выделяется\\__ тегом \\.", + "Любой символ можно экранировать, чтобы он не считался частью разметки.\n\\_Вот это\\_, " + + "не должно выделиться тегом \\. \n Также \\__Это не выделяется\\__ тегом \\.") + .SetName("EscapedUnderscores_NoRender"); yield return new TestCaseData( - "Внутри __двойного выделения _одинарное_ тоже__ работает.", - "Внутри двойного выделения одинарное тоже работает." - ).SetName("Nested_StrongAndEm"); + "Внутри __двойного выделения _одинарное_ тоже__ работает.", + "Внутри двойного выделения одинарное тоже работает.") + .SetName("Nested_StrongAndEm"); yield return new TestCaseData( - "Но не наоборот — внутри _одинарного __двойное__ не_ работает.", - "Но не наоборот — внутри одинарного __двойное__ не работает." - ).SetName("StrongInsideEm_NoRender"); + "Но не наоборот — внутри _одинарного __двойное__ не_ работает.", + "Но не наоборот — внутри одинарного __двойное__ не работает.") + .SetName("StrongInsideEm_NoRender"); yield return new TestCaseData( - "Подчерки внутри текста c цифрами_12_3 или 1__1__23 не считаются выделением и должны оставаться символами подчерка.", - "Подчерки внутри текста c цифрами_12_3 или 1__1__23 не считаются выделением и должны оставаться символами подчерка." - ).SetName("UnderscoresAroundNumbers_NoRender"); + "Подчерки внутри текста c цифрами_12_3 или 1__1__23 не считаются выделением и должны оставаться символами подчерка.", + "Подчерки внутри текста c цифрами_12_3 или 1__1__23 не считаются выделением и должны оставаться символами подчерка.") + .SetName("UnderscoresAroundNumbers_NoRender"); yield return new TestCaseData( - "Однако выделять часть слова они могут: и в _нач_але, и в сер_еди_не, и в кон__це.__", - "Однако выделять часть слова они могут: и в начале, и в середине, и в конце." - ).SetName("UnderscoresInsideWord_Render"); + "Однако выделять часть слова они могут: и в _нач_але, и в сер_еди_не, и в кон__це.__", + "Однако выделять часть слова они могут: и в начале, и в середине, и в конце.") + .SetName("UnderscoresInsideWord_Render"); yield return new TestCaseData( - "В то же время выделение в ра_зных сл_овах не работает.", - "В то же время выделение в ра_зных сл_овах не работает." - ).SetName("UnderscoresAcrossWords_NoRender"); + "В то же время выделение в ра_зных сл_овах не работает.", + "В то же время выделение в ра_зных сл_овах не работает.") + .SetName("UnderscoresAcrossWords_NoRender"); yield return new TestCaseData( - "__Непарные_ символы в рамках одного абзаца не считаются выделением.", - "__Непарные_ символы в рамках одного абзаца не считаются выделением." - ).SetName("UnmatchedUnderscores_NoRender"); + "__Непарные_ символы в рамках одного абзаца не считаются выделением.", + "__Непарные_ символы в рамках одного абзаца не считаются выделением.") + .SetName("UnmatchedUnderscores_NoRender"); yield return new TestCaseData( - "За подчерками, начинающими выделение, должен следовать непробельный символ. Иначе эти_ подчерки_ не считаются выделением \nи остаются просто символами подчерка.", - "За подчерками, начинающими выделение, должен следовать непробельный символ. Иначе эти_ подчерки_ не считаются выделением \nи остаются просто символами подчерка." - ).SetName("StartingUnderscoreFollowedByWhitespace_NoRender"); + "За подчерками, начинающими выделение, должен следовать непробельный символ. " + + "Иначе эти_ подчерки_ не считаются выделением \nи остаются просто символами подчерка.", + "За подчерками, начинающими выделение, должен следовать непробельный символ. " + + "Иначе эти_ подчерки_ не считаются выделением \nи остаются просто символами подчерка.") + .SetName("StartingUnderscoreFollowedByWhitespace_NoRender"); yield return new TestCaseData( - "Подчерки, заканчивающие выделение, должны следовать за непробельным символом. Иначе эти _подчерки _не считаются_ окончанием выделения \nи остаются просто символами подчерка.", - "Подчерки, заканчивающие выделение, должны следовать за непробельным символом. Иначе эти _подчерки не считаются окончанием выделения \nи остаются просто символами подчерка." - ).SetName("EndingUnderscorePrecededByWhitespace_Render"); + "Подчерки, заканчивающие выделение, должны следовать за непробельным символом. " + + "Иначе эти _подчерки _не считаются_ окончанием выделения \nи остаются просто символами подчерка.", + "Подчерки, заканчивающие выделение, должны следовать за непробельным символом. " + + "Иначе эти _подчерки не считаются окончанием выделения \nи остаются просто символами подчерка.") + .SetName("EndingUnderscorePrecededByWhitespace_Render"); yield return new TestCaseData( - "В случае __пересечения _двойных__ и одинарных_ подчерков ни один из них не считается выделением.\n", - "В случае __пересечения _двойных__ и одинарных_ подчерков ни один из них не считается выделением.\n" - ).SetName("IntersectingSingleAndDoubleUnderscores_NoRender"); + "В случае __пересечения _двойных__ и одинарных_ подчерков ни один из них не считается выделением.\n", + "В случае __пересечения _двойных__ и одинарных_ подчерков ни один из них не считается выделением.\n") + .SetName("IntersectingSingleAndDoubleUnderscores_NoRender"); yield return new TestCaseData( - "Если внутри подчерков пустая строка _____, то они остаются символами подчерка.\n", - "Если внутри подчерков пустая строка _____, то они остаются символами подчерка.\n" - ).SetName("EmptyDoubleUnderscores_NoRender"); + "Если внутри подчерков пустая строка _____, то они остаются символами подчерка.\n", + "Если внутри подчерков пустая строка _____, то они остаются символами подчерка.\n") + .SetName("EmptyDoubleUnderscores_NoRender"); yield return new TestCaseData( - "# Заголовок __с _разными_ символами__", - "

Заголовок с разными символами

" - ).SetName("H1Tag_Render"); + "# Заголовок __с _разными_ символами__", + "

Заголовок с разными символами

") + .SetName("H1Tag_Render"); yield return new TestCaseData( - "___Слово___", - "___Слово___" - ).SetName("TripleUnderscore_NoRender"); + "___Слово___", + "___Слово___") + .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( + "[asd[asd(asd])", + "[asd[asd(asd])") + .SetName("IncorrectLink_NestedBrackets"); + + yield return new TestCaseData( + "[asd](https://asd.com", + "[asd](https://asd.com") + .SetName("IncorrectLink_MissingClosingParenthesis"); + + yield return new TestCaseData( + "asd](https://asd.com)", + "asd](https://asd.com)") + .SetName("IncorrectLink_MissingOpeningBracket"); + + yield return new TestCaseData( + "[asd(https://asd.com)", + "[asd(https://asd.com)") + .SetName("IncorrectLink_MissingClosingBracket"); + + yield return new TestCaseData( + "(https://asd.com)[asd]", + "(https://asd.com)[asd]") + .SetName("IncorrectLink_ReversedOrder"); + + yield return new TestCaseData( + "[](https://asd.com)", + "[](https://asd.com)") + .SetName("IncorrectLink_EmptyText"); + + + yield return new TestCaseData( + "[Текст]()", + "[Текст]()") + .SetName("IncorrectLink_EmptyUrl"); } } } \ No newline at end of file From e62c0f0e465d5f2be08d7059a17d2a07f1c49948 Mon Sep 17 00:00:00 2001 From: younggogy Date: Sat, 8 Nov 2025 21:04:51 +0500 Subject: [PATCH 22/32] =?UTF-8?q?=D0=98=D0=B7=D0=BC=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=BB=20=D1=82=D0=B5=D0=BA=D1=81=D1=82=20=D0=B2=20=D1=82=D0=B5?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D1=85,=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D0=BB=20=D0=BD=D0=BE=D0=B2=D1=8B=D0=B9=20=D1=82=D0=B5?= =?UTF-8?q?=D1=81=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cs/Markdown/Tests/MarkdownTests.cs | 34 +++++++++++++++++------------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/cs/Markdown/Tests/MarkdownTests.cs b/cs/Markdown/Tests/MarkdownTests.cs index 438278000..46ab898da 100644 --- a/cs/Markdown/Tests/MarkdownTests.cs +++ b/cs/Markdown/Tests/MarkdownTests.cs @@ -186,39 +186,43 @@ public static IEnumerable MarkdownCases .SetName("MultipleLinks_RenderAll"); yield return new TestCaseData( - "[asd[asd(asd])", - "[asd[asd(asd])") + "[Текст с ошибкой[внутри]()", + "[Текст с ошибкой[внутри]()") .SetName("IncorrectLink_NestedBrackets"); yield return new TestCaseData( - "[asd](https://asd.com", - "[asd](https://asd.com") + "[Ссылка без закрытия](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( - "asd](https://asd.com)", - "asd](https://asd.com)") + "Текст без открывающей скобки](https://example.com)", + "Текст без открывающей скобки](https://example.com)") .SetName("IncorrectLink_MissingOpeningBracket"); yield return new TestCaseData( - "[asd(https://asd.com)", - "[asd(https://asd.com)") + "[Текст без закрывающей скобки(https://example.com)", + "[Текст без закрывающей скобки(https://example.com)") .SetName("IncorrectLink_MissingClosingBracket"); yield return new TestCaseData( - "(https://asd.com)[asd]", - "(https://asd.com)[asd]") + "(https://example.com)[Неправильный порядок]", + "(https://example.com)[Неправильный порядок]") .SetName("IncorrectLink_ReversedOrder"); yield return new TestCaseData( - "[](https://asd.com)", - "[](https://asd.com)") + "[](Ссылка без текста https://example.com)", + "[](Ссылка без текста https://example.com)") .SetName("IncorrectLink_EmptyText"); - yield return new TestCaseData( - "[Текст]()", - "[Текст]()") + "[Текст с пустой ссылкой]()", + "[Текст с пустой ссылкой]()") .SetName("IncorrectLink_EmptyUrl"); } } From f3157cf28e100eb25130c6ca3f998a8c4cc2b589 Mon Sep 17 00:00:00 2001 From: younggogy Date: Mon, 10 Nov 2025 23:35:55 +0500 Subject: [PATCH 23/32] =?UTF-8?q?=D0=92=D1=8B=D0=BD=D0=B5=D1=81=20=D1=80?= =?UTF-8?q?=D0=B0=D0=B1=D0=BE=D1=82=D1=83=20=D1=81=20=D0=BA=D0=BE=D0=BD?= =?UTF-8?q?=D1=82=D0=B5=D0=BD=D1=82=D0=BE=D0=BC=20=D0=B2=20TagContext.cs,?= =?UTF-8?q?=20=D0=BA=D0=BE=D1=82=D0=BE=D1=80=D1=8B=D0=B9=20=D1=80=D0=B5?= =?UTF-8?q?=D0=B0=D0=BB=D0=B8=D0=B7=D1=83=D0=B5=D1=82=20IDisposable,=20?= =?UTF-8?q?=D0=B8=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=B8=D0=BB=20=D0=B7=D0=B0?= =?UTF-8?q?=D0=BC=D0=B5=D1=87=D0=B0=D0=BD=D0=B8=D1=8F=20=D0=BF=D0=BE=20?= =?UTF-8?q?=D1=82=D0=B5=D1=81=D1=82=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Abstractions/ITagContentManager.cs | 10 -- .../TagUtils/Abstractions/ITagContext.cs | 13 ++ .../TagUtils/Abstractions/ITagManager.cs | 10 +- .../Implementations/TagContentManager.cs | 47 -------- .../TagUtils/Implementations/TagContext.cs | 64 ++++++++++ .../TagUtils/Implementations/TagManager.cs | 37 +++--- .../TagUtils/Implementations/TagProcessor.cs | 79 +++++++------ cs/Markdown/Tests/MarkdownTests.cs | 22 ++-- cs/Markdown/TokensUtils/MapSpecialSymbol.cs | 111 ++++++++++-------- 9 files changed, 215 insertions(+), 178 deletions(-) delete mode 100644 cs/Markdown/TagUtils/Abstractions/ITagContentManager.cs create mode 100644 cs/Markdown/TagUtils/Abstractions/ITagContext.cs delete mode 100644 cs/Markdown/TagUtils/Implementations/TagContentManager.cs create mode 100644 cs/Markdown/TagUtils/Implementations/TagContext.cs diff --git a/cs/Markdown/TagUtils/Abstractions/ITagContentManager.cs b/cs/Markdown/TagUtils/Abstractions/ITagContentManager.cs deleted file mode 100644 index 30f80c3ae..000000000 --- a/cs/Markdown/TagUtils/Abstractions/ITagContentManager.cs +++ /dev/null @@ -1,10 +0,0 @@ -using System.Text; -using Markdown.Dto; - -namespace Markdown.TagUtils.Abstractions; - -public interface ITagContentManager -{ - public void AppendToParentOrResult(Stack tagsStack, StringBuilder result, string content); - public void CloseTopTag(Stack tagsStack, StringBuilder result, bool isFinal = false); -} \ 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..c1ec37057 --- /dev/null +++ b/cs/Markdown/TagUtils/Abstractions/ITagContext.cs @@ -0,0 +1,13 @@ +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; } +} \ No newline at end of file diff --git a/cs/Markdown/TagUtils/Abstractions/ITagManager.cs b/cs/Markdown/TagUtils/Abstractions/ITagManager.cs index aa7811710..cf48edcdb 100644 --- a/cs/Markdown/TagUtils/Abstractions/ITagManager.cs +++ b/cs/Markdown/TagUtils/Abstractions/ITagManager.cs @@ -5,9 +5,9 @@ namespace Markdown.TagUtils.Abstractions; public interface ITagManager { - public void EndProcess(Stack tagsStack, StringBuilder result); - public void HeaderProcess(Token token, Stack tagsStack); - public void ItalicProcess(Token token, Stack tagsStack, StringBuilder result); - public void StrongProcess(Token token, Stack tagsStack, StringBuilder result); - public void LinkProcess(Token token, Stack tagsStack, StringBuilder result); + public void EndProcess(); + public void HeaderProcess(Token token); + public void ItalicProcess(Token token); + public void StrongProcess(Token token); + public void LinkProcess(Token token); } \ No newline at end of file diff --git a/cs/Markdown/TagUtils/Implementations/TagContentManager.cs b/cs/Markdown/TagUtils/Implementations/TagContentManager.cs deleted file mode 100644 index 5026b365c..000000000 --- a/cs/Markdown/TagUtils/Implementations/TagContentManager.cs +++ /dev/null @@ -1,47 +0,0 @@ -using System.Text; -using Markdown.Dto; -using Markdown.TagUtils.Abstractions; -using Markdown.TokensUtils; - -namespace Markdown.TagUtils.Implementations; - -public class TagContentManager : ITagContentManager -{ - public void AppendToParentOrResult(Stack tagsStack, StringBuilder result, string content) - { - if (tagsStack.Count > 0) - { - tagsStack.Peek().Append(content); - } - else - { - result.Append(content); - } - } - - public void CloseTopTag(Stack tagsStack, StringBuilder result, bool isFinal = false) - { - var top = tagsStack.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)); - } - - AppendToParentOrResult(tagsStack, result, wrapped.ToString()); - } -} \ 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..f978c9b75 --- /dev/null +++ b/cs/Markdown/TagUtils/Implementations/TagContext.cs @@ -0,0 +1,64 @@ +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; } + public StringBuilder Content { get; } + + public TagContext() + { + Tags = new Stack(); + Content = new StringBuilder(); + } + + 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/TagManager.cs b/cs/Markdown/TagUtils/Implementations/TagManager.cs index a80dc9761..9c9054063 100644 --- a/cs/Markdown/TagUtils/Implementations/TagManager.cs +++ b/cs/Markdown/TagUtils/Implementations/TagManager.cs @@ -5,42 +5,43 @@ namespace Markdown.TagUtils.Implementations; -public class TagManager(ITagContentManager contentManager) : ITagManager +public class TagManager(ITagContext context) : ITagManager { - public void EndProcess(Stack tagsStack, StringBuilder result) + public void EndProcess() { - while (tagsStack.Count > 0) + while (context.Tags.Count > 0) { - contentManager.CloseTopTag(tagsStack, result, true); + context.CloseTop(true); } } - public void HeaderProcess(Token token, Stack tagsStack) + public void HeaderProcess(Token token) { - tagsStack.Push(new Tag(token, new StringBuilder(token.Value), true)); + context.Open(new Tag(token, new StringBuilder(token.Value), true)); } - public void ItalicProcess(Token token, Stack tagsStack, StringBuilder result) + public void ItalicProcess(Token token) { - ProcessTag(token, tagsStack, result, false, (parent, t) => + ProcessTag(token, false, (parent, t) => CloseContext.IsInvalidUnderScoreCloseContext(parent, t, token.Value.Length)); } - public void StrongProcess(Token token, Stack tagsStack, StringBuilder result) + public void StrongProcess(Token token) { - ProcessTag(token, tagsStack, result, true, (parent, t) => + ProcessTag(token, true, (parent, t) => CloseContext.IsInvalidUnderScoreCloseContext(parent, t, token.Value.Length)); } - public void LinkProcess(Token token, Stack tagsStack, StringBuilder result) + public void LinkProcess(Token token) { - ProcessTag(token, tagsStack, result, false, (parent, t) => + ProcessTag(token, false, (parent, t) => CloseContext.IsInvalidLinkCloseContext(parent, t, token.Value.Length)); } - private void ProcessTag(Token token, Stack tagsStack, StringBuilder result, bool isStrong, + private 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 @@ -60,26 +61,26 @@ private void ProcessTag(Token token, Stack tagsStack, StringBuilder result, if (shouldNotClose) { var newContent = popParent.Content.Append(popParent.Token.Value); - contentManager.AppendToParentOrResult(tagsStack, result, newContent.ToString()); + context.Append(newContent.ToString()); } else { tagsStack.Push(popParent); - contentManager.CloseTopTag(tagsStack, result); + context.CloseTop(); } } else { - contentManager.CloseTopTag(tagsStack, result); + context.CloseTop(); } } else if (canOpen) { - tagsStack.Push(new Tag(token, new StringBuilder(token.Value), true)); + context.Open(new Tag(token, new StringBuilder(token.Value), true)); } else { - contentManager.AppendToParentOrResult(tagsStack, result, token.Value); + context.Append(token.Value); } } } \ No newline at end of file diff --git a/cs/Markdown/TagUtils/Implementations/TagProcessor.cs b/cs/Markdown/TagUtils/Implementations/TagProcessor.cs index 14df25501..ea0f23e5e 100644 --- a/cs/Markdown/TagUtils/Implementations/TagProcessor.cs +++ b/cs/Markdown/TagUtils/Implementations/TagProcessor.cs @@ -5,60 +5,61 @@ namespace Markdown.TagUtils.Implementations { - public class TagProcessor(ITagContentManager contentManager, ITagManager tagManager) : ITagProcessor + public class TagProcessor(ITagContext context, ITagManager tagManager) : ITagProcessor { public string Process(IEnumerable tokens) { - var result = new StringBuilder(); - var tagsStack = new Stack(); - var skipNextAsMarkup = false; - - foreach (var token in tokens) + using (context) { - if (skipNextAsMarkup) - { - contentManager.AppendToParentOrResult(tagsStack, result, token.Value); - skipNextAsMarkup = false; - continue; - } + var skipNextAsMarkup = false; - switch (token.Type) + foreach (var token in tokens) { - case TokenType.Text: - contentManager.AppendToParentOrResult(tagsStack, result, token.Value); - break; + if (skipNextAsMarkup) + { + context.Append(token.Value); + skipNextAsMarkup = false; + continue; + } - case TokenType.Escape: - contentManager.AppendToParentOrResult(tagsStack, result, token.Value); - skipNextAsMarkup = true; - break; + switch (token.Type) + { + case TokenType.Text: + context.Append(token.Value); + break; - case TokenType.Italic: - tagManager.ItalicProcess(token, tagsStack, result); - break; + case TokenType.Escape: + context.Append(token.Value); + skipNextAsMarkup = true; + break; - case TokenType.Strong: - tagManager.StrongProcess(token, tagsStack, result); - break; + case TokenType.Italic: + tagManager.ItalicProcess(token); + break; + + case TokenType.Strong: + tagManager.StrongProcess(token); + break; - case TokenType.Link: - tagManager.LinkProcess(token, tagsStack, result); - break; + case TokenType.Link: + tagManager.LinkProcess(token); + break; - case TokenType.Header: - tagManager.HeaderProcess(token, tagsStack); - break; + case TokenType.Header: + tagManager.HeaderProcess(token); + break; - case TokenType.End: - tagManager.EndProcess(tagsStack, result); - break; + case TokenType.End: + tagManager.EndProcess(); + break; - default: - throw new ArgumentOutOfRangeException(); + default: + throw new ArgumentOutOfRangeException(); + } } - } - return result.ToString(); + return context.Content.ToString(); + } } } } \ No newline at end of file diff --git a/cs/Markdown/Tests/MarkdownTests.cs b/cs/Markdown/Tests/MarkdownTests.cs index 46ab898da..8dc049b7a 100644 --- a/cs/Markdown/Tests/MarkdownTests.cs +++ b/cs/Markdown/Tests/MarkdownTests.cs @@ -11,14 +11,14 @@ public class MarkdownTests private TagProcessor tagProcessor; private Tokenizer tokenizer; private IRender render; - private TagContentManager tagContentManager; + private TagContext tagContext; private TagManager tagManager; public MarkdownTests() { - tagContentManager = new TagContentManager(); - tagManager = new TagManager(tagContentManager); - tagProcessor = new TagProcessor(tagContentManager, tagManager); + tagContext = new TagContext(); + tagManager = new TagManager(tagContext); + tagProcessor = new TagProcessor(tagContext, tagManager); tokenizer = new Tokenizer(); render = new MarkdownRender(tokenizer, tagProcessor); } @@ -51,30 +51,28 @@ public void Markdown_RenderTime_ShouldScaleLinearly_Test() return i <= part * 2 ? $"__line {i}__" : $"#line {i}"; }); var input = string.Join(Environment.NewLine, lines); + var times = new List(runsPerSize); - var totalTime = 0d; - - //Act for (var run = 0; run < runsPerSize; run++) { var sw = Stopwatch.StartNew(); render.RenderText(input); sw.Stop(); - totalTime += sw.Elapsed.TotalMilliseconds; + times.Add(sw.Elapsed.TotalMilliseconds); } - var avgTime = totalTime / runsPerSize; + var medianTime = times.OrderBy(t => t).ElementAt(runsPerSize / 2); //Assert if (previousTime.HasValue && previousSize.HasValue) { - var timeRatio = avgTime / previousTime.Value; + var timeRatio = medianTime / previousTime.Value; var sizeRatio = (double)size / previousSize.Value; - timeRatio.Should().BeLessThanOrEqualTo(sizeRatio * 1.2); + timeRatio.Should().BeLessThanOrEqualTo(sizeRatio * 1.5); } - previousTime = avgTime; + previousTime = medianTime; previousSize = size; } } diff --git a/cs/Markdown/TokensUtils/MapSpecialSymbol.cs b/cs/Markdown/TokensUtils/MapSpecialSymbol.cs index 161608b51..317910a82 100644 --- a/cs/Markdown/TokensUtils/MapSpecialSymbol.cs +++ b/cs/Markdown/TokensUtils/MapSpecialSymbol.cs @@ -1,56 +1,73 @@ -namespace Markdown.TokensUtils; - -using Markdown.Dto; - -public static class MapSpecialSymbol +namespace Markdown.TokensUtils { - public static Token? Specialize(char c, string line, int index) + using Markdown.Dto; + + public static class MapSpecialSymbol { - return c switch + public static Token? Specialize(char c, string line, int index) { - '[' => - 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 - }; - } + 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 Token CreateUnderscoreToken(string line, int index, bool isStrong) - { - var value = isStrong ? "__" : "_"; - var type = isStrong ? TokenType.Strong : TokenType.Italic; - var prevChar = index > 0 ? line[index - 1] : (char?)null; - var nextChar = index + value.Length < line.Length ? line[index + value.Length] : (char?)null; - var canOpen = nextChar != null && !char.IsWhiteSpace(nextChar.Value); - var canClose = prevChar != null && !char.IsWhiteSpace(prevChar.Value); + 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)); + } - return new Token(value, type, canOpen, canClose); - } - private static Token CreateLinkToken(string line, int index, bool isOpen) - { - var value = isOpen ? "[" : ")"; - var prevChar = index > 0 ? line[index - 1] : (char?)null; - var nextChar = index + value.Length < line.Length ? line[index + value.Length] : (char?)null; - var canOpen = nextChar != null && !char.IsWhiteSpace(nextChar.Value); - var canClose = prevChar != null && !char.IsWhiteSpace(prevChar.Value); - return new Token(value, TokenType.Link, isOpen && canOpen, !isOpen && 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) - { - const string value = "#"; - var nextChar = index + value.Length < line.Length ? line[index + value.Length] : (char?)null; - var canOpenAndClose = nextChar != null && char.IsWhiteSpace(nextChar.Value); - return new Token(value, TokenType.Header, canOpenAndClose, canOpenAndClose); + 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 From efc74a3a1e378397438cdf71d3bbf43fc054d35f Mon Sep 17 00:00:00 2001 From: younggogy Date: Mon, 10 Nov 2025 23:37:33 +0500 Subject: [PATCH 24/32] =?UTF-8?q?=D0=94=D0=BE=D0=BF=D1=83=D1=88=D0=B8?= =?UTF-8?q?=D0=BB=20=D0=BA=D0=BB=D0=B0=D1=81=D1=81=20CharContext.cs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cs/Markdown/Dto/CharContext.cs | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 cs/Markdown/Dto/CharContext.cs 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 From 0042b1f3dce69877d61a21c6b58eeccf1f2c69eb Mon Sep 17 00:00:00 2001 From: younggogy Date: Tue, 11 Nov 2025 15:19:57 +0500 Subject: [PATCH 25/32] =?UTF-8?q?=D0=A1=D0=B4=D0=B5=D0=BB=D0=B0=D0=BB=20?= =?UTF-8?q?=D0=BE=D0=B1=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D0=BA=D1=83=20=D1=82?= =?UTF-8?q?=D0=BE=D0=BA=D0=B5=D0=BD=D0=BE=D0=B2=20=D1=87=D0=B5=D1=80=D0=B5?= =?UTF-8?q?=D0=B7=20=D0=BF=D0=B0=D1=82=D1=82=D0=B5=D1=80=D0=BD=20"=D0=A1?= =?UTF-8?q?=D1=82=D1=80=D0=B0=D1=82=D0=B5=D0=B3=D0=B8=D1=8F".=20=D0=A1?= =?UTF-8?q?=D0=BE=D0=B7=D0=B4=D0=B0=D0=BB=20=D0=B0=D0=B1=D1=81=D1=82=D1=80?= =?UTF-8?q?=D0=B0=D0=BA=D1=86=D0=B8=D1=8E=20=D0=B8=20=D0=BA=D0=BB=D0=B0?= =?UTF-8?q?=D1=81=D1=81=D1=8B=20=D0=BD=D0=B0=D1=81=D0=BB=D0=B5=D0=B4=D0=BD?= =?UTF-8?q?=D0=B8=D0=BA=D0=B8.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../BaseTagStrategy.cs} | 43 +++---------------- .../TagUtils/Abstractions/ITagContext.cs | 1 + .../TagUtils/Abstractions/ITagManager.cs | 13 ------ .../Implementations/Strategies/EndStrategy.cs | 15 +++++++ .../Strategies/EscapeStrategy.cs | 13 ++++++ .../Strategies/HeaderStrategy.cs | 13 ++++++ .../Strategies/ItalicStrategy.cs | 14 ++++++ .../Strategies/LinkStrategy.cs | 13 ++++++ .../Strategies/StrongStrategy.cs | 13 ++++++ .../Strategies/TextStrategy.cs | 12 ++++++ .../TagUtils/Implementations/TagContext.cs | 11 ++--- cs/Markdown/TagUtils/TagStrategyFactory.cs | 20 +++++++++ 12 files changed, 122 insertions(+), 59 deletions(-) rename cs/Markdown/TagUtils/{Implementations/TagManager.cs => Abstractions/BaseTagStrategy.cs} (60%) delete mode 100644 cs/Markdown/TagUtils/Abstractions/ITagManager.cs create mode 100644 cs/Markdown/TagUtils/Implementations/Strategies/EndStrategy.cs create mode 100644 cs/Markdown/TagUtils/Implementations/Strategies/EscapeStrategy.cs create mode 100644 cs/Markdown/TagUtils/Implementations/Strategies/HeaderStrategy.cs create mode 100644 cs/Markdown/TagUtils/Implementations/Strategies/ItalicStrategy.cs create mode 100644 cs/Markdown/TagUtils/Implementations/Strategies/LinkStrategy.cs create mode 100644 cs/Markdown/TagUtils/Implementations/Strategies/StrongStrategy.cs create mode 100644 cs/Markdown/TagUtils/Implementations/Strategies/TextStrategy.cs create mode 100644 cs/Markdown/TagUtils/TagStrategyFactory.cs diff --git a/cs/Markdown/TagUtils/Implementations/TagManager.cs b/cs/Markdown/TagUtils/Abstractions/BaseTagStrategy.cs similarity index 60% rename from cs/Markdown/TagUtils/Implementations/TagManager.cs rename to cs/Markdown/TagUtils/Abstractions/BaseTagStrategy.cs index 9c9054063..0bb6a3469 100644 --- a/cs/Markdown/TagUtils/Implementations/TagManager.cs +++ b/cs/Markdown/TagUtils/Abstractions/BaseTagStrategy.cs @@ -1,45 +1,10 @@ using System.Text; using Markdown.Dto; using Markdown.TagUtils.Abstractions; -using Markdown.TokensUtils; -namespace Markdown.TagUtils.Implementations; - -public class TagManager(ITagContext context) : ITagManager +public abstract class BaseTagStrategy(ITagContext context) { - public void EndProcess() - { - while (context.Tags.Count > 0) - { - context.CloseTop(true); - } - } - - public void HeaderProcess(Token token) - { - context.Open(new Tag(token, new StringBuilder(token.Value), true)); - } - - public void ItalicProcess(Token token) - { - ProcessTag(token, false, (parent, t) => - CloseContext.IsInvalidUnderScoreCloseContext(parent, t, token.Value.Length)); - } - - public void StrongProcess(Token token) - { - ProcessTag(token, true, (parent, t) => - CloseContext.IsInvalidUnderScoreCloseContext(parent, t, token.Value.Length)); - } - - public void LinkProcess(Token token) - { - ProcessTag(token, false, (parent, t) => - CloseContext.IsInvalidLinkCloseContext(parent, t, token.Value.Length)); - } - - private void ProcessTag(Token token, bool isStrong, - Func isInvalidCloseContext) + protected void ProcessTag(Token token, bool isStrong, Func isInvalidCloseContext) { var tagsStack = context.Tags; var parent = tagsStack.Count > 0 ? tagsStack.Peek() : null; @@ -48,7 +13,7 @@ private void ProcessTag(Token token, bool 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) @@ -83,4 +48,6 @@ private void ProcessTag(Token token, bool isStrong, 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 index c1ec37057..8b649da13 100644 --- a/cs/Markdown/TagUtils/Abstractions/ITagContext.cs +++ b/cs/Markdown/TagUtils/Abstractions/ITagContext.cs @@ -10,4 +10,5 @@ public interface ITagContext : IDisposable 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/ITagManager.cs b/cs/Markdown/TagUtils/Abstractions/ITagManager.cs deleted file mode 100644 index cf48edcdb..000000000 --- a/cs/Markdown/TagUtils/Abstractions/ITagManager.cs +++ /dev/null @@ -1,13 +0,0 @@ -using System.Text; -using Markdown.Dto; - -namespace Markdown.TagUtils.Abstractions; - -public interface ITagManager -{ - public void EndProcess(); - public void HeaderProcess(Token token); - public void ItalicProcess(Token token); - public void StrongProcess(Token token); - public void LinkProcess(Token token); -} \ 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 index f978c9b75..a55a9a838 100644 --- a/cs/Markdown/TagUtils/Implementations/TagContext.cs +++ b/cs/Markdown/TagUtils/Implementations/TagContext.cs @@ -7,14 +7,9 @@ namespace Markdown.TagUtils.Implementations; public class TagContext : ITagContext { - public Stack Tags { get; } - public StringBuilder Content { get; } - - public TagContext() - { - Tags = new Stack(); - Content = new StringBuilder(); - } + public Stack Tags { get; } = new(); + public StringBuilder Content { get; } = new(); + public bool SkipNextAsMarkup { get; set; } public void Append(string content) { 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 From 33ec3deeeed4788df9263d69213ba91749432e67 Mon Sep 17 00:00:00 2001 From: younggogy Date: Tue, 11 Nov 2025 15:20:34 +0500 Subject: [PATCH 26/32] =?UTF-8?q?=D0=9F=D1=80=D0=B8=D0=BC=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=BB=20=D0=BF=D0=B0=D1=82=D1=82=D0=B5=D1=80=D0=BD=20"?= =?UTF-8?q?=D0=A1=D1=82=D1=80=D0=B0=D1=82=D0=B5=D0=B3=D0=B8=D1=8F"=20?= =?UTF-8?q?=D0=B2=20TagProcessor.cs.=20=D0=9F=D0=BE=D1=80=D0=B5=D1=84?= =?UTF-8?q?=D0=B0=D0=BA=D1=82=D0=BE=D1=80=D0=B8=D0=BB=20=D0=BA=D0=BB=D0=B0?= =?UTF-8?q?=D1=81=D1=81=D1=8B.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cs/Markdown/Dto/Token.cs | 87 ++++++++++--------- cs/Markdown/TagUtils/CloseContext.cs | 2 +- .../TagUtils/Implementations/TagProcessor.cs | 63 +++----------- cs/Markdown/Tests/MarkdownTests.cs | 6 +- 4 files changed, 64 insertions(+), 94 deletions(-) diff --git a/cs/Markdown/Dto/Token.cs b/cs/Markdown/Dto/Token.cs index c3989c923..7307c62fc 100644 --- a/cs/Markdown/Dto/Token.cs +++ b/cs/Markdown/Dto/Token.cs @@ -1,49 +1,54 @@ -namespace Markdown.Dto; - -public class Token +namespace Markdown.Dto { - 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) + public class Token { - Value = value; - Type = type; - Role = role; - Position = position; - } + 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, bool canOpen, bool canClose) - { - Value = value; - Type = type; - DetermineRole(canOpen, canClose); - DeterminePosition(canOpen, canClose); - } - - - private void DetermineRole(bool canOpen, bool canClose) - { - Role = (canOpen, canClose) switch + public Token(string value, TokenType type, TokenRole role, TokenPosition position) { - (true, true) => TokenRole.Both, - (true, false) => TokenRole.Open, - (false, true) => TokenRole.Close, - (false, false) => TokenRole.None - }; - } + Value = value; + Type = type; + Role = role; + Position = position; + } - private void DeterminePosition(bool canOpen, bool canClose) - { - Position = (canOpen, canClose) switch + 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) { - (true, true) => TokenPosition.Inside, - (true, false) => TokenPosition.Begin, - (false, true) => TokenPosition.End, - (false, false) => TokenPosition.None - }; + if (canOpen == canClose) + { + setter(both); + } + else if (canOpen) + { + setter(open); + } + else + { + setter(close); + } + } } - } \ No newline at end of file diff --git a/cs/Markdown/TagUtils/CloseContext.cs b/cs/Markdown/TagUtils/CloseContext.cs index bee19c78b..aebd4956f 100644 --- a/cs/Markdown/TagUtils/CloseContext.cs +++ b/cs/Markdown/TagUtils/CloseContext.cs @@ -38,7 +38,7 @@ public static bool IsInvalidLinkCloseContext(Tag parent, Token token, int marker return openParenIndex - closeBracketIndex != 1; } - private static bool IsInvalidCloseContext(Tag parent, Token token, int markerLength) + private static bool IsInvalidCloseContext(Tag? parent, Token token, int markerLength) { if (parent == null) { diff --git a/cs/Markdown/TagUtils/Implementations/TagProcessor.cs b/cs/Markdown/TagUtils/Implementations/TagProcessor.cs index ea0f23e5e..8aef3efc2 100644 --- a/cs/Markdown/TagUtils/Implementations/TagProcessor.cs +++ b/cs/Markdown/TagUtils/Implementations/TagProcessor.cs @@ -3,63 +3,28 @@ using Markdown.TagUtils.Abstractions; using Markdown.TokensUtils; -namespace Markdown.TagUtils.Implementations +namespace Markdown.TagUtils.Implementations; + +public class TagProcessor(ITagContext context, TagStrategyFactory factory) : ITagProcessor { - public class TagProcessor(ITagContext context, ITagManager tagManager) : ITagProcessor + public string Process(IEnumerable tokens) { - public string Process(IEnumerable tokens) + using (context) { - using (context) + foreach (var token in tokens) { - var skipNextAsMarkup = false; - - foreach (var token in tokens) + if (context.SkipNextAsMarkup) { - if (skipNextAsMarkup) - { - context.Append(token.Value); - skipNextAsMarkup = false; - continue; - } - - switch (token.Type) - { - case TokenType.Text: - context.Append(token.Value); - break; - - case TokenType.Escape: - context.Append(token.Value); - skipNextAsMarkup = true; - break; - - case TokenType.Italic: - tagManager.ItalicProcess(token); - break; - - case TokenType.Strong: - tagManager.StrongProcess(token); - break; - - case TokenType.Link: - tagManager.LinkProcess(token); - break; - - case TokenType.Header: - tagManager.HeaderProcess(token); - break; - - case TokenType.End: - tagManager.EndProcess(); - break; - - default: - throw new ArgumentOutOfRangeException(); - } + context.Append(token.Value); + context.SkipNextAsMarkup = false; + continue; } - return context.Content.ToString(); + var strategy = factory.Get(token.Type); + strategy?.Process(token); } + + return context.Content.ToString(); } } } \ No newline at end of file diff --git a/cs/Markdown/Tests/MarkdownTests.cs b/cs/Markdown/Tests/MarkdownTests.cs index 8dc049b7a..f0c9e92b5 100644 --- a/cs/Markdown/Tests/MarkdownTests.cs +++ b/cs/Markdown/Tests/MarkdownTests.cs @@ -12,13 +12,13 @@ public class MarkdownTests private Tokenizer tokenizer; private IRender render; private TagContext tagContext; - private TagManager tagManager; + private TagStrategyFactory factory; public MarkdownTests() { tagContext = new TagContext(); - tagManager = new TagManager(tagContext); - tagProcessor = new TagProcessor(tagContext, tagManager); + factory = new TagStrategyFactory(tagContext); + tagProcessor = new TagProcessor(tagContext, factory); tokenizer = new Tokenizer(); render = new MarkdownRender(tokenizer, tagProcessor); } From a95fcea819705224481f284bffe6204a59d256ee Mon Sep 17 00:00:00 2001 From: younggogy Date: Tue, 11 Nov 2025 15:24:05 +0500 Subject: [PATCH 27/32] =?UTF-8?q?=D0=9D=D0=B5=D0=B1=D0=BE=D0=BB=D1=8C?= =?UTF-8?q?=D1=88=D0=BE=D0=B9=20=D1=80=D0=B5=D1=84=D0=B0=D0=BA=D1=82=D0=BE?= =?UTF-8?q?=D1=80=20=D1=82=D0=B5=D1=81=D1=82=D0=B0=20Markdown=5FRenderTime?= =?UTF-8?q?=5FShouldScaleLinearly?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cs/Markdown/Tests/MarkdownTests.cs | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/cs/Markdown/Tests/MarkdownTests.cs b/cs/Markdown/Tests/MarkdownTests.cs index f0c9e92b5..28ffa20a4 100644 --- a/cs/Markdown/Tests/MarkdownTests.cs +++ b/cs/Markdown/Tests/MarkdownTests.cs @@ -31,9 +31,8 @@ public void Markdown_RenderText_ShouldMatchExpected(string line, string expected } [Test] - public void Markdown_RenderTime_ShouldScaleLinearly_Test() + public void Markdown_RenderTime_ShouldScaleLinearly() { - //Arrange var sizes = new[] { 100, 1000, 3000, 100000 }; int? previousSize = null; double? previousTime = null; @@ -41,13 +40,14 @@ public void Markdown_RenderTime_ShouldScaleLinearly_Test() foreach (var size in sizes) { - //Arrange 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); @@ -62,8 +62,7 @@ public void Markdown_RenderTime_ShouldScaleLinearly_Test() } var medianTime = times.OrderBy(t => t).ElementAt(runsPerSize / 2); - - //Assert + if (previousTime.HasValue && previousSize.HasValue) { var timeRatio = medianTime / previousTime.Value; From fd494f6fc7a7f8444ec6ea1f46280c4e58ac6dd9 Mon Sep 17 00:00:00 2001 From: younggogy Date: Wed, 12 Nov 2025 00:55:27 +0500 Subject: [PATCH 28/32] =?UTF-8?q?=D0=98=D0=B7=D0=BC=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=BB=20MarkdownSpec.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- MarkdownSpec.md | 40 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/MarkdownSpec.md b/MarkdownSpec.md index 886e99c95..3cd8b3b59 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) From 4dbd8aca6af66468b6742dd8c9c69bd026f15b28 Mon Sep 17 00:00:00 2001 From: younggogy Date: Wed, 12 Nov 2025 00:56:42 +0500 Subject: [PATCH 29/32] =?UTF-8?q?=D0=98=D1=81=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D0=BB=20MarkdownSpec.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- MarkdownSpec.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/MarkdownSpec.md b/MarkdownSpec.md index 3cd8b3b59..9e6c69887 100644 --- a/MarkdownSpec.md +++ b/MarkdownSpec.md @@ -89,10 +89,10 @@ __Непарные_ символы в рамках одного абзаца н В тексте ссылки могут присутствовать и другие символы разметки с правилами: Нельзя выделять весь текст ссылки в другие символы, например: -[__Полужирная ссылка__](google.com) так и останется +\[__Полужирная ссылка__](google.com) так и останется Аналогично и с курсивом: -[__Курсивная ссылка__](google.com) так и останется +\[__Курсивная ссылка__](google.com) так и останется Другие символы разметки можно использовать только на учатски, а не на весь текст, например ссылка: [Ссылка с _курсивным_ текстом](google.com) From bae770aebd3e968f20b8336d7297611c0cffdc70 Mon Sep 17 00:00:00 2001 From: younggogy Date: Wed, 12 Nov 2025 00:58:08 +0500 Subject: [PATCH 30/32] =?UTF-8?q?=D0=98=D1=81=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D0=BB=20=D1=8D=D0=BA=D1=80=D0=B0=D0=BD=D0=B8=D1=80=D0=BE?= =?UTF-8?q?=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=20=D0=B2=20MarkdownSpec.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- MarkdownSpec.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/MarkdownSpec.md b/MarkdownSpec.md index 9e6c69887..ecd142705 100644 --- a/MarkdownSpec.md +++ b/MarkdownSpec.md @@ -76,12 +76,12 @@ __Непарные_ символы в рамках одного абзаца н # Ссылки -Ссылка представлена строго в виде: [текст ссылки](URL). Выделяется тегом text +Ссылка представлена строго в виде: \[текст ссылки](URL). Выделяется тегом \text\ Текст ссылки не должен начинаться с пробела и в скбоках с URL не должно быть пробелов, иначе это не считается выделением Таким образом -Ссылка с текстом: [репозиторий gogy](https://github.com/gogy4?tab=repositories) +Ссылка с текстом: \[репозиторий gogy](https://github.com/gogy4?tab=repositories) превратится в: \репозиторий gogy\ @@ -95,13 +95,13 @@ __Непарные_ символы в рамках одного абзаца н \[__Курсивная ссылка__](google.com) так и останется Другие символы разметки можно использовать только на учатски, а не на весь текст, например ссылка: -[Ссылка с _курсивным_ текстом](google.com) +\[Ссылка с _курсивным_ текстом](google.com) первратится в: \Ссылка с \полужирным\ текстом\ From f032539bebd755e28a450194321141a143f05a87 Mon Sep 17 00:00:00 2001 From: younggogy Date: Wed, 12 Nov 2025 01:00:11 +0500 Subject: [PATCH 31/32] =?UTF-8?q?=D0=95=D1=89=D0=B5=20=D0=BD=D0=B5=D0=BC?= =?UTF-8?q?=D0=BD=D0=BE=D0=B3=D0=BE=20=D1=84=D0=B8=D0=BA=D1=81=D0=BE=D0=B2?= =?UTF-8?q?=20=D1=81=20=D1=8D=D0=BA=D1=80=D0=B0=D0=BD=D0=B8=D1=80=D0=BE?= =?UTF-8?q?=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- MarkdownSpec.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/MarkdownSpec.md b/MarkdownSpec.md index ecd142705..a42655cb2 100644 --- a/MarkdownSpec.md +++ b/MarkdownSpec.md @@ -89,23 +89,23 @@ __Непарные_ символы в рамках одного абзаца н В тексте ссылки могут присутствовать и другие символы разметки с правилами: Нельзя выделять весь текст ссылки в другие символы, например: -\[__Полужирная ссылка__](google.com) так и останется +\[\__Полужирная ссылка\__](google.com) так и останется Аналогично и с курсивом: -\[__Курсивная ссылка__](google.com) так и останется +\[\_Курсивная ссылка\_](google.com) так и останется Другие символы разметки можно использовать только на учатски, а не на весь текст, например ссылка: -\[Ссылка с _курсивным_ текстом](google.com) +\[Ссылка с \_курсивным\_ текстом](google.com) первратится в: \Ссылка с \полужирным\ текстом\ Чтобы экранировать текст с ссылкой, достаточно поставить знак экранирования перед [ -\[Ссылка с текстом](google.com) +\\\[Ссылка с текстом](google.com) From 49faf438cbf5907dff2d954349a5dc9f02fc3b9e Mon Sep 17 00:00:00 2001 From: younggogy Date: Wed, 12 Nov 2025 01:01:32 +0500 Subject: [PATCH 32/32] =?UTF-8?q?=D0=A4=D0=B8=D0=BA=D1=81=20=D1=8D=D0=BA?= =?UTF-8?q?=D1=80=D0=B0=D0=BD=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D1=8F=20=D0=BF=D0=BE=D0=BB=D1=83=D0=B6=D0=B8=D1=80=D0=BD=D0=BE?= =?UTF-8?q?=D0=B3=D0=BE=20=D1=81=D0=B8=D0=BC=D0=B2=D0=BE=D0=BB=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- MarkdownSpec.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/MarkdownSpec.md b/MarkdownSpec.md index a42655cb2..04082e9b1 100644 --- a/MarkdownSpec.md +++ b/MarkdownSpec.md @@ -89,7 +89,7 @@ __Непарные_ символы в рамках одного абзаца н В тексте ссылки могут присутствовать и другие символы разметки с правилами: Нельзя выделять весь текст ссылки в другие символы, например: -\[\__Полужирная ссылка\__](google.com) так и останется +\[\_\_Полужирная ссылка\_\_](google.com) так и останется Аналогично и с курсивом: \[\_Курсивная ссылка\_](google.com) так и останется @@ -101,7 +101,7 @@ __Непарные_ символы в рамках одного абзаца н \Ссылка с \полужирным\ текстом\