From 2b6458d7d00a3846f8271f28024571164deebf62 Mon Sep 17 00:00:00 2001 From: Kostornoj-Dmitrij Date: Mon, 25 Nov 2024 12:02:12 +0500 Subject: [PATCH 01/16] =?UTF-8?q?homework=201=20=D1=87=D0=B0=D1=81=D1=82?= =?UTF-8?q?=D1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cs/Markdown/Markdown.csproj | 9 + cs/Markdown/Md.cs | 20 ++ cs/Markdown/Parsers/MarkdownParser.cs | 211 ++++++++++++++++++ cs/Markdown/Parsers/MarkdownParserContext.cs | 13 ++ cs/Markdown/Renderers/HtmlRenderer.cs | 59 +++++ cs/Markdown/Tokens/Token.cs | 23 ++ cs/Markdown/Tokens/TokenType.cs | 9 + cs/MarkdownTests/HtmlRenderer_Should.cs | 148 +++++++++++++ cs/MarkdownTests/MarkdownParser_Should.cs | 221 +++++++++++++++++++ cs/MarkdownTests/MarkdownTests.csproj | 19 ++ cs/MarkdownTests/Md_Should.cs | 98 ++++++++ cs/clean-code.sln | 12 + cs/clean-code.sln.DotSettings | 3 + 13 files changed, 845 insertions(+) create mode 100644 cs/Markdown/Markdown.csproj create mode 100644 cs/Markdown/Md.cs create mode 100644 cs/Markdown/Parsers/MarkdownParser.cs create mode 100644 cs/Markdown/Parsers/MarkdownParserContext.cs create mode 100644 cs/Markdown/Renderers/HtmlRenderer.cs create mode 100644 cs/Markdown/Tokens/Token.cs create mode 100644 cs/Markdown/Tokens/TokenType.cs create mode 100644 cs/MarkdownTests/HtmlRenderer_Should.cs create mode 100644 cs/MarkdownTests/MarkdownParser_Should.cs create mode 100644 cs/MarkdownTests/MarkdownTests.csproj create mode 100644 cs/MarkdownTests/Md_Should.cs diff --git a/cs/Markdown/Markdown.csproj b/cs/Markdown/Markdown.csproj new file mode 100644 index 000000000..3a6353295 --- /dev/null +++ b/cs/Markdown/Markdown.csproj @@ -0,0 +1,9 @@ + + + + net8.0 + enable + enable + + + diff --git a/cs/Markdown/Md.cs b/cs/Markdown/Md.cs new file mode 100644 index 000000000..ae8e87ca3 --- /dev/null +++ b/cs/Markdown/Md.cs @@ -0,0 +1,20 @@ +using Markdown.Parsers; +using Markdown.Renderers; + +namespace Markdown; + +public class Md +{ + private readonly HtmlRenderer renderer; + + public Md() + { + renderer = new HtmlRenderer(); + } + + public string Render(string markdownText) + { + var tokens = MarkdownParser.ParseTokens(markdownText); + return renderer.Render(tokens); + } +} \ No newline at end of file diff --git a/cs/Markdown/Parsers/MarkdownParser.cs b/cs/Markdown/Parsers/MarkdownParser.cs new file mode 100644 index 000000000..a4e281da2 --- /dev/null +++ b/cs/Markdown/Parsers/MarkdownParser.cs @@ -0,0 +1,211 @@ +using Markdown.Tokens; + +namespace Markdown.Parsers; + +public abstract class MarkdownParser +{ + public static IEnumerable ParseTokens(string markdownText) + { + if (markdownText == null) + throw new ArgumentNullException(nameof(markdownText)); + + var context = new MarkdownParseContext + { + MarkdownText = markdownText + }; + + while (context.CurrentIndex < context.MarkdownText.Length) + { + var current = context.MarkdownText[context.CurrentIndex]; + var next = context.CurrentIndex + 1 < context.MarkdownText.Length ? + context.MarkdownText[context.CurrentIndex + 1] : '\0'; + + switch (current) + { + case '\\': + HandleEscapeCharacter(next, context); + break; + case '_': + if (next == '_') + HandleStrongToken(context); + else + HandleEmphasisToken(context); + break; + case '#' when (context.CurrentIndex == 0 || + context.MarkdownText[context.CurrentIndex - 1] == '\n') && next == ' ': + context.HeaderLevel = HandleHeaderToken(context); + break; + case '\n' when context.Stack.Count > 0 && context.Stack.Peek().Type == TokenType.Header: + HandleNewLine(context); + break; + default: + context.Buffer.Append(current); + context.CurrentIndex++; + break; + } + + if (context.CurrentIndex != context.MarkdownText.Length || + context.Stack.Count <= 0 || context.Stack.Peek().Type != TokenType.Header) continue; + AddToken(context, TokenType.Text); + context.Tokens.Add(context.Stack.Pop()); + } + AddToken(context, TokenType.Text); + return context.Tokens; + } + + private static void HandleEscapeCharacter(char next, MarkdownParseContext context) + { + if (next is '_' or '#' or '\\') + { + if (next != '\\') + context.Buffer.Append(next); + context.CurrentIndex += 2; + } + else + { + context.Buffer.Append('\\'); + context.CurrentIndex++; + } + } + + private static void HandleStrongToken(MarkdownParseContext context) + { + if (IsValidBoundary(context,"__")) + { + HandleTokenBoundary(context, TokenType.Strong); + context.CurrentIndex += 2; + } + else + { + context.Buffer.Append("__"); + context.CurrentIndex += 2; + } + } + + private static void HandleEmphasisToken(MarkdownParseContext context) + { + if (IsValidBoundary(context, "_")) + { + HandleTokenBoundary(context, TokenType.Emphasis); + context.CurrentIndex++; + } + else + { + context.Buffer.Append('_'); + context.CurrentIndex++; + } + } + + private static int HandleHeaderToken(MarkdownParseContext context) + { + while (context.CurrentIndex < context.MarkdownText.Length && + context.MarkdownText[context.CurrentIndex] == '#') + { + context.HeaderLevel++; + context.CurrentIndex++; + } + + if (context.CurrentIndex < context.MarkdownText.Length && + context.MarkdownText[context.CurrentIndex] == ' ') + { + context.CurrentIndex++; + + AddToken(context, TokenType.Text); + var headerToken = new Token(TokenType.Header) + { + HeaderLevel = context.HeaderLevel + }; + + context.Tokens.Add(headerToken); + + var headerEnd = context.MarkdownText.IndexOf('\n', context.CurrentIndex); + if (headerEnd == -1) + headerEnd = context.MarkdownText.Length; + + var headerContent = ParseTokens(context.MarkdownText[context.CurrentIndex..headerEnd]); + + foreach (var childToken in headerContent) + { + headerToken.Children.Add(childToken); + } + context.CurrentIndex = headerEnd; + } + else + { + context.Buffer.Append('#', context.HeaderLevel); + } + + return context.HeaderLevel; + } + + private static void HandleNewLine(MarkdownParseContext context) + { + AddToken(context, TokenType.Text); + context.Tokens.Add(context.Stack.Pop()); + context.CurrentIndex++; + } + + private static void HandleTokenBoundary(MarkdownParseContext context, TokenType type) + { + AddToken(context, TokenType.Text); + + if (context.Stack.Count > 0 && context.Stack.Peek().Type == type) + { + var completedToken = context.Stack.Pop(); + + completedToken.Content = completedToken.Children.Count > 0 ? string.Empty : completedToken.Content; + context.Buffer.Clear(); + + if (context.Stack.Count > 0) + context.Stack.Peek().Children.Add(completedToken); + else + context.Tokens.Add(completedToken); + } + else + { + var newToken = new Token(type); + context.Stack.Push(newToken); + } + } + + private static void AddToken(MarkdownParseContext context, TokenType type) + { + if (context.Buffer.Length == 0) return; + var token = new Token(type, context.Buffer.ToString()); + context.Buffer.Clear(); + + if (context.Stack.Count > 0) + context.Stack.Peek().Children.Add(token); + else + context.Tokens.Add(token); + } + + private static bool IsValidBoundary(MarkdownParseContext context, string delimiter) + { + var index = context.CurrentIndex; + var text = context.MarkdownText; + if (context.Stack.Count > 0) + { + if (context.Buffer.Length == 0) + return false; + if (index == 0 || index == text.Length - 1) + return true; + return !char.IsLetterOrDigit(text[index - 1]) || + !char.IsLetterOrDigit(context.MarkdownText[index + 1]); + } + + var closingIndex = text.IndexOf(delimiter, index + delimiter.Length, StringComparison.Ordinal); + if (closingIndex == -1) + return false; + + var isInsideWord = (index > 0 && char.IsLetterOrDigit(text[index - 1])) || + (closingIndex + delimiter.Length < text.Length && + char.IsLetterOrDigit(text[closingIndex + delimiter.Length])); + if (isInsideWord) + return false; + + if (closingIndex - index <= delimiter.Length) + return false; + return index + 1 != closingIndex; + } +} \ No newline at end of file diff --git a/cs/Markdown/Parsers/MarkdownParserContext.cs b/cs/Markdown/Parsers/MarkdownParserContext.cs new file mode 100644 index 000000000..fd356e87c --- /dev/null +++ b/cs/Markdown/Parsers/MarkdownParserContext.cs @@ -0,0 +1,13 @@ +using Markdown.Tokens; +using System.Text; +namespace Markdown.Parsers; + +public class MarkdownParseContext +{ + public Stack Stack { get; set; } = new(); + public List Tokens { get; set; } = new(); + public StringBuilder Buffer { get; set; } = new(); + public string MarkdownText { get; set; } = ""; + public int CurrentIndex { get; set; } + public int HeaderLevel { get; set; } +} \ No newline at end of file diff --git a/cs/Markdown/Renderers/HtmlRenderer.cs b/cs/Markdown/Renderers/HtmlRenderer.cs new file mode 100644 index 000000000..66e0409bb --- /dev/null +++ b/cs/Markdown/Renderers/HtmlRenderer.cs @@ -0,0 +1,59 @@ +using System.Text; +using Markdown.Tokens; + +namespace Markdown.Renderers; + +public class HtmlRenderer +{ + public string Render(IEnumerable tokens) + { + var result = new StringBuilder(); + foreach (var token in tokens) + { + RenderToken(token, result); + } + return result.ToString(); + } + + private void RenderToken(Token token, StringBuilder result) + { + switch (token.Type) + { + case TokenType.Text: + result.Append(token.Content); + break; + case TokenType.Emphasis: + result.Append(""); + RenderChildren(token, result); + result.Append(""); + break; + case TokenType.Strong: + result.Append(""); + RenderChildren(token, result); + result.Append(""); + break; + case TokenType.Header: + var level = token.HeaderLevel; + result.Append($""); + RenderChildren(token, result); + result.Append($""); + break; + default: + result.Append(token.Content); + break; + } + } + + private void RenderChildren(Token token, StringBuilder result) + { + if (token.Children.Count > 0) + { + foreach (var child in token.Children) + { + RenderToken(child, result); + } + } + else + result.Append(token.Content); + } +} \ No newline at end of file diff --git a/cs/Markdown/Tokens/Token.cs b/cs/Markdown/Tokens/Token.cs new file mode 100644 index 000000000..8de9915b1 --- /dev/null +++ b/cs/Markdown/Tokens/Token.cs @@ -0,0 +1,23 @@ +namespace Markdown.Tokens; + +public class Token +{ + public TokenType Type { get; } + public string Content { get; set; } + public List Children { get; } + public int HeaderLevel { get; init; } + public Token(TokenType type, string content, List? children = null) + { + Type = type; + Content = content; + Children = children ?? []; + HeaderLevel = 1; + } + + public Token(TokenType type) + { + Type = type; + Content = string.Empty; + Children = []; + } +} \ No newline at end of file diff --git a/cs/Markdown/Tokens/TokenType.cs b/cs/Markdown/Tokens/TokenType.cs new file mode 100644 index 000000000..15f9d2469 --- /dev/null +++ b/cs/Markdown/Tokens/TokenType.cs @@ -0,0 +1,9 @@ +namespace Markdown.Tokens; + +public enum TokenType +{ + Text, + Emphasis, + Strong, + Header +} \ No newline at end of file diff --git a/cs/MarkdownTests/HtmlRenderer_Should.cs b/cs/MarkdownTests/HtmlRenderer_Should.cs new file mode 100644 index 000000000..59243439d --- /dev/null +++ b/cs/MarkdownTests/HtmlRenderer_Should.cs @@ -0,0 +1,148 @@ +using FluentAssertions; +using Markdown.Renderers; +using Markdown.Tokens; +using NUnit.Framework; + +namespace MarkdownTests; + +[TestFixture] +public class HtmlRenderer_Should +{ + private readonly HtmlRenderer renderer = new(); + + [Test] + public void Render_ShouldHandleTextWithoutTags() + { + var tokens = new List + { + new Token(TokenType.Text, "Текст без тегов.") + }; + + var result = renderer.Render(tokens); + + result.Should().Be("Текст без тегов."); + } + + [Test] + public void Render_ShouldHandleEmphasisTags() + { + var tokens = new List + { + new Token(TokenType.Text, "Это "), + new Token(TokenType.Emphasis, "курсив"), + new Token(TokenType.Text, " текст") + }; + + var result = renderer.Render(tokens); + + result.Should().Be("Это курсив текст"); + } + + [Test] + public void Render_ShoulHandleStrongTags() + { + var tokens = new List + { + new Token(TokenType.Text, "Это "), + new Token(TokenType.Strong, "полужирный"), + new Token(TokenType.Text, " текст") + }; + + var result = renderer.Render(tokens); + + result.Should().Be("Это полужирный текст"); + } + + [Test] + public void Render_ShouldHandleHeaderTags() + { + var tokens = new List + { + new Token(TokenType.Header, "Заголовок") + }; + + var result = renderer.Render(tokens); + + result.Should().Be("

Заголовок

"); + } + + [Test] + public void Render_ShouldHandleNestedTags() + { + var headToken = new Token(TokenType.Header, string.Empty); + var strongToken = new Token(TokenType.Strong, string.Empty); + var emphasisToken = new Token(TokenType.Emphasis, "курсивом" ); + + strongToken.Children.Add(new Token(TokenType.Text, "полужирным текстом с ")); + strongToken.Children.Add(emphasisToken); + headToken.Children.Add(new Token(TokenType.Text, "заголовок с ")); + headToken.Children.Add(strongToken); + var tokens = new List + { + new Token(TokenType.Text, "Это "), + headToken + }; + + var result = renderer.Render(tokens); + + result.Should().Be("Это

заголовок с полужирным " + + "текстом с курсивом

"); + } + + [Test] + public void Render_ShouldHandleEmptyTags() + { + var tokens = new List + { + new Token(TokenType.Text, "Это "), + new Token(TokenType.Emphasis, string.Empty), + new Token(TokenType.Text, " текст") + }; + + var result = renderer.Render(tokens); + + result.Should().Be("Это текст"); + } + + [Test] + public void Render_ShouldHandleMultipleTags() + { + var tokens = new List + { + new Token(TokenType.Text, "Это "), + new Token(TokenType.Strong, "полужирный"), + new Token(TokenType.Text, " и "), + new Token(TokenType.Emphasis, "курсив"), + new Token(TokenType.Text, " текст.") + }; + + var result = renderer.Render(tokens); + + result.Should().Be("Это полужирный и курсив текст."); + } + + [Test] + public void Render_ShouldHandleNestedTagsWithMultipleLevels() + { + var innerStrongToken = new Token(TokenType.Strong, "полужирный заголовок"); + var innerEmphasisToken = new Token(TokenType.Emphasis, " полужирный курсив"); + + var outerHeaderToken = new Token(TokenType.Header, string.Empty); + outerHeaderToken.Children.Add(innerStrongToken); + + var outerStrongToken = new Token(TokenType.Strong, string.Empty); + outerStrongToken.Children.Add(new Token(TokenType.Text, " и ")); + outerStrongToken.Children.Add(innerEmphasisToken); + + var tokens = new List + { + outerHeaderToken, + outerStrongToken, + }; + + var result = renderer.Render(tokens); + + result.Should().Be("

полужирный заголовок

" + + " и полужирный курсив"); + } +} \ No newline at end of file diff --git a/cs/MarkdownTests/MarkdownParser_Should.cs b/cs/MarkdownTests/MarkdownParser_Should.cs new file mode 100644 index 000000000..bea5a0f62 --- /dev/null +++ b/cs/MarkdownTests/MarkdownParser_Should.cs @@ -0,0 +1,221 @@ +using FluentAssertions; +using Markdown.Parsers; +using Markdown.Tokens; +using NUnit.Framework; + +namespace MarkdownTests; + +[TestFixture] +public class MarkdownParser_Should +{ + [Test] + public void MarkdownParser_ShouldParse_WhenItalicTag() + { + var tokens = MarkdownParser + .ParseTokens("Это _курсив_ текст").ToList(); + + tokens.Should().HaveCount(3); + tokens[0].Type.Should().Be(TokenType.Text); + tokens[0].Content.Should().Be("Это "); + tokens[1].Type.Should().Be(TokenType.Emphasis); + tokens[1].Children[0].Content.Should().Be("курсив"); + tokens[2].Type.Should().Be(TokenType.Text); + tokens[2].Content.Should().Be(" текст"); + } + + [Test] + public void MarkdownParser_ShouldParse_WhenStrongTag() + { + var tokens = MarkdownParser + .ParseTokens("Это __полужирный__ текст").ToList(); + + tokens.Should().HaveCount(3); + tokens[0].Type.Should().Be(TokenType.Text); + tokens[0].Content.Should().Be("Это "); + tokens[1].Type.Should().Be(TokenType.Strong); + tokens[1].Children[0].Type.Should().Be(TokenType.Text); + tokens[1].Children[0].Content.Should().Be("полужирный"); + tokens[2].Type.Should().Be(TokenType.Text); + tokens[2].Content.Should().Be(" текст"); + } + + [Test] + public void MarkdownParser_ShouldParse_WhenHeaderTag() + { + var tokens = MarkdownParser + .ParseTokens("# Заголовок").ToList(); + + tokens.Should().HaveCount(1); + tokens[0].Type.Should().Be(TokenType.Header); + tokens[0].Children[0].Content.Should().Be("Заголовок"); + } + + [Test] + public void MarkdownParser_ShouldParse_WhenEscaping() + { + var tokens = MarkdownParser + .ParseTokens(@"Экранированный \_символ\_").ToList(); + + tokens.Should().HaveCount(1); + tokens[0].Type.Should().Be(TokenType.Text); + tokens[0].Content.Should().Be("Экранированный _символ_"); + } + + [Test] + public void MarkdownParser_ShouldParse_WhenNestedItalicAndStrongTags() + { + var tokens = MarkdownParser + .ParseTokens("Это __жирный _и курсивный_ текст__").ToList(); + + tokens.Should().HaveCount(2); + tokens[0].Type.Should().Be(TokenType.Text); + tokens[0].Content.Should().Be("Это "); + tokens[1].Type.Should().Be(TokenType.Strong); + tokens[1].Children.Should().HaveCount(3); + tokens[1].Children[0].Type.Should().Be(TokenType.Text); + tokens[1].Children[0].Content.Should().Be("жирный "); + tokens[1].Children[1].Type.Should().Be(TokenType.Emphasis); + tokens[1].Children[1].Children[0].Type.Should().Be(TokenType.Text); + tokens[1].Children[1].Children[0].Content.Should().Be("и курсивный"); + tokens[1].Children[2].Type.Should().Be(TokenType.Text); + tokens[1].Children[2].Content.Should().Be(" текст"); + } + + [Test] + public void MarkdownParser_ShouldParse_WhenMultipleTokensInLine() + { + var tokens = MarkdownParser + .ParseTokens("Это _курсив_, а это __жирный__ текст.").ToList(); + + tokens.Should().HaveCount(5); + tokens[0].Type.Should().Be(TokenType.Text); + tokens[0].Content.Should().Be("Это "); + tokens[1].Type.Should().Be(TokenType.Emphasis); + tokens[1].Children[0].Content.Should().Be("курсив"); + tokens[2].Type.Should().Be(TokenType.Text); + tokens[2].Content.Should().Be(", а это "); + tokens[3].Type.Should().Be(TokenType.Strong); + tokens[3].Children[0].Content.Should().Be("жирный"); + tokens[4].Type.Should().Be(TokenType.Text); + tokens[4].Content.Should().Be(" текст."); + } + + [Test] + public void MarkdownParser_ShouldNotParse_WhenEscapingSymbols() + { + var tokens = MarkdownParser + .ParseTokens(@"Здесь сим\волы экранирования\ \должны остаться.\").ToList(); + + tokens.Should().HaveCount(1); + tokens[0].Type.Should().Be(TokenType.Text); + tokens[0].Content.Should().Be(@"Здесь сим\волы экранирования\ \должны остаться.\"); + } + + [Test] + public void MarkdownParser_ShouldParse_WhenEscapedTags() + { + var tokens = MarkdownParser + .ParseTokens(@"\\_вот это будет выделено тегом_").ToList(); + + tokens.Should().HaveCount(1); + tokens[0].Type.Should().Be(TokenType.Emphasis); + tokens[0].Children[0].Content.Should().Be("вот это будет выделено тегом"); + } + + [Test] + public void MarkdownParser_ShouldParse_WhenNestedItalicAndStrongCorrectly() + { + var tokens = MarkdownParser + .ParseTokens("Это __двойное _и одинарное_ выделение__").ToList(); + + tokens.Should().HaveCount(2); + tokens[0].Type.Should().Be(TokenType.Text); + tokens[0].Content.Should().Be("Это "); + tokens[1].Type.Should().Be(TokenType.Strong); + tokens[1].Children.Should().HaveCount(3); + tokens[1].Children[0].Type.Should().Be(TokenType.Text); + tokens[1].Children[0].Content.Should().Be("двойное "); + tokens[1].Children[1].Type.Should().Be(TokenType.Emphasis); + tokens[1].Children[1].Children[0].Type.Should().Be(TokenType.Text); + tokens[1].Children[1].Children[0].Content.Should().Be("и одинарное"); + tokens[1].Children[2].Type.Should().Be(TokenType.Text); + tokens[1].Children[2].Content.Should().Be(" выделение"); + } + + [Test] + public void MarkdownParser_ShouldParse_WhenHeaderWithTags() + { + var tokens = MarkdownParser + .ParseTokens("# Заголовок __с _разными_ символами__").ToList(); + + tokens.Should().HaveCount(1); + tokens[0].Type.Should().Be(TokenType.Header); + tokens[0].Children.Should().HaveCount(2); + tokens[0].Children[0].Type.Should().Be(TokenType.Text); + tokens[0].Children[0].Content.Should().Be("Заголовок "); + tokens[0].Children[1].Type.Should().Be(TokenType.Strong); + tokens[0].Children[1].Children[0].Type.Should().Be(TokenType.Text); + tokens[0].Children[1].Children[0].Content.Should().Be("с "); + tokens[0].Children[1].Children[1].Children[0].Type.Should().Be(TokenType.Text); + tokens[0].Children[1].Children[1].Children[0].Content.Should().Be("разными"); + tokens[0].Children[1].Children[2].Type.Should().Be(TokenType.Text); + tokens[0].Children[1].Children[2].Content.Should().Be(" символами"); + } + + [Test] + public void MarkdownParser_ShouldNotParse_WhenEmptyEmphasis() + { + var tokens = MarkdownParser + .ParseTokens("Если пустая _______ строка").ToList(); + + tokens.Should().HaveCount(1); + tokens[0].Type.Should().Be(TokenType.Text); + tokens[0].Content.Should().Be("Если пустая _______ строка"); + } + + [Test] + public void MarkdownParser_ShouldParse_WhenMultipleHeaders() + { + var tokens = MarkdownParser + .ParseTokens("# Заголовок 1\n# Заголовок 2").ToList(); + + tokens.Should().HaveCount(3); + tokens[0].Type.Should().Be(TokenType.Header); + tokens[0].Children[0].Content.Should().Be("Заголовок 1"); + tokens[2].Type.Should().Be(TokenType.Header); + tokens[2].Children[0].Content.Should().Be("Заголовок 2"); + } + + [Test] + public void MarkdownParser_ShouldNotParse_WhenUnderscoresInNumbers() + { + var tokens = MarkdownParser + .ParseTokens("Текст с цифрами_12_3 не должен выделяться").ToList(); + + tokens.Should().HaveCount(1); + tokens[0].Type.Should().Be(TokenType.Text); + tokens[0].Content.Should().Be("Текст с цифрами_12_3 не должен выделяться"); + } + + [Test] + public void MarkdownParser_ShouldNotParse_WhenTagsInWords() + { + var tokens = MarkdownParser + .ParseTokens("и в _нач_але, и в сер_еди_не").ToList(); + + tokens.Should().HaveCount(1); + tokens[0].Type.Should().Be(TokenType.Text); + tokens[0].Content.Should().Be("и в _нач_але, и в сер_еди_не"); + } + + [Test] + public void MarkdownParser_ShouldNotParse_WhenDifferentWords() + { + var tokens = MarkdownParser + .ParseTokens("Это пер_вый в_торой пример.").ToList(); + + tokens.Should().HaveCount(1); + tokens[0].Type.Should().Be(TokenType.Text); + tokens[0].Content.Should().Be("Это пер_вый в_торой пример."); + } +} \ No newline at end of file diff --git a/cs/MarkdownTests/MarkdownTests.csproj b/cs/MarkdownTests/MarkdownTests.csproj new file mode 100644 index 000000000..b3bdd436f --- /dev/null +++ b/cs/MarkdownTests/MarkdownTests.csproj @@ -0,0 +1,19 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + + diff --git a/cs/MarkdownTests/Md_Should.cs b/cs/MarkdownTests/Md_Should.cs new file mode 100644 index 000000000..ce9f01767 --- /dev/null +++ b/cs/MarkdownTests/Md_Should.cs @@ -0,0 +1,98 @@ +using FluentAssertions; +using Markdown; +using NUnit.Framework; +using System.Diagnostics; + +namespace MarkdownTests; + +[TestFixture] +public class Md_Should +{ + private readonly Md md = new(); + + [Test] + public void Md_ShouldThrowArgumentNullException_WhenInputIsNull() + { + var func = () => md.Render(null!); + func.Should().Throw(); + } + + [TestCase("", "", TestName = "InputIsEmpty")] + [TestCase("Это # не заголовок", + "Это # не заголовок", + TestName = "InvalidHeaderTags")] + [TestCase(@"Здесь сим\волы экранирования\ \должны остаться.\", + @"Здесь сим\волы экранирования\ \должны остаться.\", + TestName = "EscapingSymbols")] + [TestCase("Это н_е_ будет _ вы_деле_но", + "Это н_е_ будет _ вы_деле_но", + TestName = "InvalidItalicTags")] + [TestCase("Это н__е__ будет __ вы__деле__но", + "Это н__е__ будет __ вы__деле__но", + TestName = "InvalidStrongTags")] + [TestCase("В ра_зных сл_овах", + "В ра_зных сл_овах", + TestName = "TagsInDifferentWords")] + [TestCase("Это текст_с_подчеркиваниями_12_3", + "Это текст_с_подчеркиваниями_12_3", + TestName = "UnderscoresInsideWords")] + [TestCase("Это __непарные_ символы в одном абзаце.", + "Это __непарные_ символы в одном абзаце.", + TestName = "UnclosedTags")] + [TestCase("Если пустая _______ строка", + "Если пустая _______ строка", + TestName = "EmptyTags")] + public void Md_ShouldNotRender_When(string input, string expected) + { + var result = md.Render(input); + + result.Should().Be(expected); + } + + [TestCase("Это _курсив_ и __полужирный__ текст", + "Это курсив и полужирный текст", + TestName = "ItalicAndStrongTags")] + [TestCase("# Заголовок", + "

Заголовок

", + TestName = "HeaderTags")] + [TestCase("# Заголовок __с _разными_ символами__", + "

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

", + TestName = "HeaderWithNestedTags")] + [TestCase("Это __полужирный _текст_, _с курсивом_ внутри__", + "Это полужирный текст, с курсивом внутри", + TestName = "ItalicInStrong")] + [TestCase("Это _курсив с __полужирным__ внутри_", + "Это курсив с полужирным внутри", + TestName = "StrongInItalic")] + [TestCase(@"Экранированный \_символ\_", + "Экранированный _символ_", + TestName = "EscapeTag")] + [TestCase(@"\\_вот это будет выделено тегом_", + "вот это будет выделено тегом", + TestName = "EscapedYourself")] + [TestCase("# Заголовок 1\n# Заголовок 2", + "

Заголовок 1

\n

Заголовок 2

", + TestName = "MultipleHeaders")] + + public void Md_ShouldRender_When(string input, string expected) + { + var result = md.Render(input); + + result.Should().Be(expected); + } + + [Test] + public void Md_ShouldRenderLargeInputQuickly() + { + var largeInput = string.Concat(Enumerable.Repeat("_Пример_ ", 10000)); + var expectedOutput = string.Concat(Enumerable.Repeat("Пример ", 10000)); + var stopwatch = new Stopwatch(); + + stopwatch.Start(); + var result = md.Render(largeInput); + stopwatch.Stop(); + + stopwatch.ElapsedMilliseconds.Should().BeLessThan(1000); + expectedOutput.Should().BeEquivalentTo(result); + } +} \ No newline at end of file diff --git a/cs/clean-code.sln b/cs/clean-code.sln index 2206d54db..c0c32aab7 100644 --- a/cs/clean-code.sln +++ b/cs/clean-code.sln @@ -9,6 +9,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ControlDigit", "ControlDigi EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Samples", "Samples\Samples.csproj", "{C3EF41D7-50EF-4CE1-B30A-D1D81C93D7FA}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Markdown", "Markdown\Markdown.csproj", "{59318F61-936C-4DE7-BB97-3BE627267507}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MarkdownTests", "MarkdownTests\MarkdownTests.csproj", "{19E76A0C-0B32-456F-9556-1A88BF102982}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -27,5 +31,13 @@ Global {C3EF41D7-50EF-4CE1-B30A-D1D81C93D7FA}.Debug|Any CPU.Build.0 = Debug|Any CPU {C3EF41D7-50EF-4CE1-B30A-D1D81C93D7FA}.Release|Any CPU.ActiveCfg = Release|Any CPU {C3EF41D7-50EF-4CE1-B30A-D1D81C93D7FA}.Release|Any CPU.Build.0 = Release|Any CPU + {59318F61-936C-4DE7-BB97-3BE627267507}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {59318F61-936C-4DE7-BB97-3BE627267507}.Debug|Any CPU.Build.0 = Debug|Any CPU + {59318F61-936C-4DE7-BB97-3BE627267507}.Release|Any CPU.ActiveCfg = Release|Any CPU + {59318F61-936C-4DE7-BB97-3BE627267507}.Release|Any CPU.Build.0 = Release|Any CPU + {19E76A0C-0B32-456F-9556-1A88BF102982}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {19E76A0C-0B32-456F-9556-1A88BF102982}.Debug|Any CPU.Build.0 = Debug|Any CPU + {19E76A0C-0B32-456F-9556-1A88BF102982}.Release|Any CPU.ActiveCfg = Release|Any CPU + {19E76A0C-0B32-456F-9556-1A88BF102982}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal diff --git a/cs/clean-code.sln.DotSettings b/cs/clean-code.sln.DotSettings index 135b83ecb..53fe49b2f 100644 --- a/cs/clean-code.sln.DotSettings +++ b/cs/clean-code.sln.DotSettings @@ -1,6 +1,9 @@  <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb_AaBb" /> + <Policy><Descriptor Staticness="Instance" AccessRightKinds="Private" Description="Instance fields (private)"><ElementKinds><Kind Name="FIELD" /><Kind Name="READONLY_FIELD" /></ElementKinds></Descriptor><Policy Inspect="True" WarnAboutPrefixesAndSuffixes="False" Prefix="" Suffix="" Style="aaBb" /></Policy> + <Policy><Descriptor Staticness="Any" AccessRightKinds="Any" Description="Types and namespaces"><ElementKinds><Kind Name="NAMESPACE" /><Kind Name="CLASS" /><Kind Name="STRUCT" /><Kind Name="ENUM" /><Kind Name="DELEGATE" /></ElementKinds></Descriptor><Policy Inspect="True" WarnAboutPrefixesAndSuffixes="False" Prefix="" Suffix="" Style="AaBb_AaBb" /></Policy> + True True True Imported 10.10.2016 From 2532f5180c02c0a9f63bb62c419766e0954fe44c Mon Sep 17 00:00:00 2001 From: Kostornoj-Dmitrij Date: Sat, 30 Nov 2024 12:17:52 +0500 Subject: [PATCH 02/16] =?UTF-8?q?=D0=A0=D0=B0=D0=B7=D0=B4=D0=B5=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BD=D0=B0=20=D0=BC=D0=B5=D0=BD?= =?UTF-8?q?=D1=8C=D1=88=D0=B8=D0=B5=20=D0=BA=D0=BB=D0=B0=D1=81=D1=81=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cs/Markdown/Interfaces/IMarkdownParser.cs | 8 + cs/Markdown/Interfaces/IRenderer.cs | 8 + cs/Markdown/Interfaces/ITokenConverter.cs | 9 + cs/Markdown/Interfaces/ITokenHandler.cs | 9 + cs/Markdown/Md.cs | 15 +- cs/Markdown/Parsers/MarkdownParser.cs | 209 +++--------------- cs/Markdown/Parsers/MarkdownParserContext.cs | 3 + cs/Markdown/Renderers/HtmlRenderer.cs | 49 +--- .../TokenConverters/EmphasisConverter.cs | 18 ++ .../TokenConverters/HeaderConverter.cs | 17 ++ .../TokenConverters/StrongConverter.cs | 16 ++ cs/Markdown/TokenConverters/TextConverter.cs | 14 ++ .../TokenConverters/TokenConverterBase.cs | 19 ++ .../TokenConverters/TokenConverterFactory.cs | 19 ++ .../TokenHandlers/BoundaryTokenHandler.cs | 82 +++++++ .../TokenHandlers/EmphasisTokenHandler.cs | 14 ++ .../TokenHandlers/EscapeCharacterHandler.cs | 29 +++ .../TokenHandlers/HeaderTokenHandler.cs | 52 +++++ cs/Markdown/TokenHandlers/NewLineHandler.cs | 19 ++ .../TokenHandlers/StrongTokenHandler.cs | 14 ++ cs/Markdown/Tokens/Token.cs | 2 +- cs/MarkdownTests/HtmlRenderer_Should.cs | 30 ++- cs/MarkdownTests/MarkdownParser_Should.cs | 31 +-- cs/MarkdownTests/Md_Should.cs | 38 +++- 24 files changed, 467 insertions(+), 257 deletions(-) create mode 100644 cs/Markdown/Interfaces/IMarkdownParser.cs create mode 100644 cs/Markdown/Interfaces/IRenderer.cs create mode 100644 cs/Markdown/Interfaces/ITokenConverter.cs create mode 100644 cs/Markdown/Interfaces/ITokenHandler.cs create mode 100644 cs/Markdown/TokenConverters/EmphasisConverter.cs create mode 100644 cs/Markdown/TokenConverters/HeaderConverter.cs create mode 100644 cs/Markdown/TokenConverters/StrongConverter.cs create mode 100644 cs/Markdown/TokenConverters/TextConverter.cs create mode 100644 cs/Markdown/TokenConverters/TokenConverterBase.cs create mode 100644 cs/Markdown/TokenConverters/TokenConverterFactory.cs create mode 100644 cs/Markdown/TokenHandlers/BoundaryTokenHandler.cs create mode 100644 cs/Markdown/TokenHandlers/EmphasisTokenHandler.cs create mode 100644 cs/Markdown/TokenHandlers/EscapeCharacterHandler.cs create mode 100644 cs/Markdown/TokenHandlers/HeaderTokenHandler.cs create mode 100644 cs/Markdown/TokenHandlers/NewLineHandler.cs create mode 100644 cs/Markdown/TokenHandlers/StrongTokenHandler.cs diff --git a/cs/Markdown/Interfaces/IMarkdownParser.cs b/cs/Markdown/Interfaces/IMarkdownParser.cs new file mode 100644 index 000000000..cb73bbfaf --- /dev/null +++ b/cs/Markdown/Interfaces/IMarkdownParser.cs @@ -0,0 +1,8 @@ +using Markdown.Tokens; + +namespace Markdown.Interfaces; + +public interface IMarkdownParser +{ + IEnumerable ParseTokens(string markdownText); +} \ No newline at end of file diff --git a/cs/Markdown/Interfaces/IRenderer.cs b/cs/Markdown/Interfaces/IRenderer.cs new file mode 100644 index 000000000..20715e3df --- /dev/null +++ b/cs/Markdown/Interfaces/IRenderer.cs @@ -0,0 +1,8 @@ +using Markdown.Tokens; + +namespace Markdown.Interfaces; + +public interface IRenderer +{ + string Render(IEnumerable tokens); +} \ No newline at end of file diff --git a/cs/Markdown/Interfaces/ITokenConverter.cs b/cs/Markdown/Interfaces/ITokenConverter.cs new file mode 100644 index 000000000..ec09219ed --- /dev/null +++ b/cs/Markdown/Interfaces/ITokenConverter.cs @@ -0,0 +1,9 @@ +using System.Text; +using Markdown.Tokens; + +namespace Markdown.Interfaces; + +public interface ITokenConverter +{ + void Render(Token token, StringBuilder result); +} \ No newline at end of file diff --git a/cs/Markdown/Interfaces/ITokenHandler.cs b/cs/Markdown/Interfaces/ITokenHandler.cs new file mode 100644 index 000000000..4cf06f66f --- /dev/null +++ b/cs/Markdown/Interfaces/ITokenHandler.cs @@ -0,0 +1,9 @@ +using Markdown.Parsers; + +namespace Markdown.Interfaces; + +public interface ITokenHandler +{ + bool CanHandle(char current, char next, MarkdownParseContext context); + void Handle(MarkdownParseContext context); +} \ No newline at end of file diff --git a/cs/Markdown/Md.cs b/cs/Markdown/Md.cs index ae8e87ca3..b6176a1bd 100644 --- a/cs/Markdown/Md.cs +++ b/cs/Markdown/Md.cs @@ -1,20 +1,23 @@ -using Markdown.Parsers; +using Markdown.Interfaces; +using Markdown.Parsers; using Markdown.Renderers; namespace Markdown; public class Md { - private readonly HtmlRenderer renderer; - - public Md() + private readonly IRenderer renderer; + private readonly IMarkdownParser parser; + + public Md(IRenderer renderer, IMarkdownParser parser) { - renderer = new HtmlRenderer(); + this.renderer = renderer; + this.parser = parser; } public string Render(string markdownText) { - var tokens = MarkdownParser.ParseTokens(markdownText); + var tokens = parser.ParseTokens(markdownText); return renderer.Render(tokens); } } \ No newline at end of file diff --git a/cs/Markdown/Parsers/MarkdownParser.cs b/cs/Markdown/Parsers/MarkdownParser.cs index a4e281da2..5bb9358ff 100644 --- a/cs/Markdown/Parsers/MarkdownParser.cs +++ b/cs/Markdown/Parsers/MarkdownParser.cs @@ -1,174 +1,60 @@ +using Markdown.Interfaces; +using Markdown.TokenHandlers; using Markdown.Tokens; namespace Markdown.Parsers; -public abstract class MarkdownParser +public class MarkdownParser : IMarkdownParser { - public static IEnumerable ParseTokens(string markdownText) + private readonly List handlers; + + public MarkdownParser() + { + handlers = new List + { + new StrongTokenHandler(), + new HeaderTokenHandler(), + new EmphasisTokenHandler(), + new NewLineHandler(), + new EscapeCharacterHandler() + }; + } + + public IEnumerable ParseTokens(string markdownText) { - if (markdownText == null) + if (markdownText == null) throw new ArgumentNullException(nameof(markdownText)); var context = new MarkdownParseContext { - MarkdownText = markdownText + MarkdownText = markdownText, + Parser = this }; while (context.CurrentIndex < context.MarkdownText.Length) { var current = context.MarkdownText[context.CurrentIndex]; - var next = context.CurrentIndex + 1 < context.MarkdownText.Length ? - context.MarkdownText[context.CurrentIndex + 1] : '\0'; + var next = context.CurrentIndex + 1 < context.MarkdownText.Length + ? context.MarkdownText[context.CurrentIndex + 1] + : '\0'; - switch (current) + var handler = handlers.FirstOrDefault(h => h.CanHandle(current, next, context)); + if (handler != null) { - case '\\': - HandleEscapeCharacter(next, context); - break; - case '_': - if (next == '_') - HandleStrongToken(context); - else - HandleEmphasisToken(context); - break; - case '#' when (context.CurrentIndex == 0 || - context.MarkdownText[context.CurrentIndex - 1] == '\n') && next == ' ': - context.HeaderLevel = HandleHeaderToken(context); - break; - case '\n' when context.Stack.Count > 0 && context.Stack.Peek().Type == TokenType.Header: - HandleNewLine(context); - break; - default: - context.Buffer.Append(current); - context.CurrentIndex++; - break; + handler.Handle(context); } - - if (context.CurrentIndex != context.MarkdownText.Length || - context.Stack.Count <= 0 || context.Stack.Peek().Type != TokenType.Header) continue; - AddToken(context, TokenType.Text); - context.Tokens.Add(context.Stack.Pop()); - } - AddToken(context, TokenType.Text); - return context.Tokens; - } - - private static void HandleEscapeCharacter(char next, MarkdownParseContext context) - { - if (next is '_' or '#' or '\\') - { - if (next != '\\') - context.Buffer.Append(next); - context.CurrentIndex += 2; - } - else - { - context.Buffer.Append('\\'); - context.CurrentIndex++; - } - } - - private static void HandleStrongToken(MarkdownParseContext context) - { - if (IsValidBoundary(context,"__")) - { - HandleTokenBoundary(context, TokenType.Strong); - context.CurrentIndex += 2; - } - else - { - context.Buffer.Append("__"); - context.CurrentIndex += 2; - } - } - - private static void HandleEmphasisToken(MarkdownParseContext context) - { - if (IsValidBoundary(context, "_")) - { - HandleTokenBoundary(context, TokenType.Emphasis); - context.CurrentIndex++; - } - else - { - context.Buffer.Append('_'); - context.CurrentIndex++; - } - } - - private static int HandleHeaderToken(MarkdownParseContext context) - { - while (context.CurrentIndex < context.MarkdownText.Length && - context.MarkdownText[context.CurrentIndex] == '#') - { - context.HeaderLevel++; - context.CurrentIndex++; - } - - if (context.CurrentIndex < context.MarkdownText.Length && - context.MarkdownText[context.CurrentIndex] == ' ') - { - context.CurrentIndex++; - - AddToken(context, TokenType.Text); - var headerToken = new Token(TokenType.Header) - { - HeaderLevel = context.HeaderLevel - }; - - context.Tokens.Add(headerToken); - - var headerEnd = context.MarkdownText.IndexOf('\n', context.CurrentIndex); - if (headerEnd == -1) - headerEnd = context.MarkdownText.Length; - - var headerContent = ParseTokens(context.MarkdownText[context.CurrentIndex..headerEnd]); - - foreach (var childToken in headerContent) + else { - headerToken.Children.Add(childToken); + context.Buffer.Append(current); + context.CurrentIndex++; } - context.CurrentIndex = headerEnd; } - else - { - context.Buffer.Append('#', context.HeaderLevel); - } - - return context.HeaderLevel; - } - private static void HandleNewLine(MarkdownParseContext context) - { AddToken(context, TokenType.Text); - context.Tokens.Add(context.Stack.Pop()); - context.CurrentIndex++; - } - - private static void HandleTokenBoundary(MarkdownParseContext context, TokenType type) - { - AddToken(context, TokenType.Text); - - if (context.Stack.Count > 0 && context.Stack.Peek().Type == type) - { - var completedToken = context.Stack.Pop(); - - completedToken.Content = completedToken.Children.Count > 0 ? string.Empty : completedToken.Content; - context.Buffer.Clear(); - - if (context.Stack.Count > 0) - context.Stack.Peek().Children.Add(completedToken); - else - context.Tokens.Add(completedToken); - } - else - { - var newToken = new Token(type); - context.Stack.Push(newToken); - } + return context.Tokens; } - - private static void AddToken(MarkdownParseContext context, TokenType type) + + public static void AddToken(MarkdownParseContext context, TokenType type) { if (context.Buffer.Length == 0) return; var token = new Token(type, context.Buffer.ToString()); @@ -179,33 +65,4 @@ private static void AddToken(MarkdownParseContext context, TokenType type) else context.Tokens.Add(token); } - - private static bool IsValidBoundary(MarkdownParseContext context, string delimiter) - { - var index = context.CurrentIndex; - var text = context.MarkdownText; - if (context.Stack.Count > 0) - { - if (context.Buffer.Length == 0) - return false; - if (index == 0 || index == text.Length - 1) - return true; - return !char.IsLetterOrDigit(text[index - 1]) || - !char.IsLetterOrDigit(context.MarkdownText[index + 1]); - } - - var closingIndex = text.IndexOf(delimiter, index + delimiter.Length, StringComparison.Ordinal); - if (closingIndex == -1) - return false; - - var isInsideWord = (index > 0 && char.IsLetterOrDigit(text[index - 1])) || - (closingIndex + delimiter.Length < text.Length && - char.IsLetterOrDigit(text[closingIndex + delimiter.Length])); - if (isInsideWord) - return false; - - if (closingIndex - index <= delimiter.Length) - return false; - return index + 1 != closingIndex; - } } \ No newline at end of file diff --git a/cs/Markdown/Parsers/MarkdownParserContext.cs b/cs/Markdown/Parsers/MarkdownParserContext.cs index fd356e87c..320df619d 100644 --- a/cs/Markdown/Parsers/MarkdownParserContext.cs +++ b/cs/Markdown/Parsers/MarkdownParserContext.cs @@ -1,5 +1,7 @@ using Markdown.Tokens; using System.Text; +using Markdown.Interfaces; + namespace Markdown.Parsers; public class MarkdownParseContext @@ -10,4 +12,5 @@ public class MarkdownParseContext public string MarkdownText { get; set; } = ""; public int CurrentIndex { get; set; } public int HeaderLevel { get; set; } + public required IMarkdownParser Parser { get; set; } } \ No newline at end of file diff --git a/cs/Markdown/Renderers/HtmlRenderer.cs b/cs/Markdown/Renderers/HtmlRenderer.cs index 66e0409bb..d9f1b9c35 100644 --- a/cs/Markdown/Renderers/HtmlRenderer.cs +++ b/cs/Markdown/Renderers/HtmlRenderer.cs @@ -1,59 +1,20 @@ using System.Text; +using Markdown.Interfaces; +using Markdown.TokenConverters; using Markdown.Tokens; namespace Markdown.Renderers; -public class HtmlRenderer +public class HtmlRenderer : IRenderer { public string Render(IEnumerable tokens) { var result = new StringBuilder(); foreach (var token in tokens) { - RenderToken(token, result); + var converter = TokenConverterFactory.GetConverter(token.Type); + converter.Render(token, result); } return result.ToString(); } - - private void RenderToken(Token token, StringBuilder result) - { - switch (token.Type) - { - case TokenType.Text: - result.Append(token.Content); - break; - case TokenType.Emphasis: - result.Append(""); - RenderChildren(token, result); - result.Append(""); - break; - case TokenType.Strong: - result.Append(""); - RenderChildren(token, result); - result.Append(""); - break; - case TokenType.Header: - var level = token.HeaderLevel; - result.Append($""); - RenderChildren(token, result); - result.Append($""); - break; - default: - result.Append(token.Content); - break; - } - } - - private void RenderChildren(Token token, StringBuilder result) - { - if (token.Children.Count > 0) - { - foreach (var child in token.Children) - { - RenderToken(child, result); - } - } - else - result.Append(token.Content); - } } \ No newline at end of file diff --git a/cs/Markdown/TokenConverters/EmphasisConverter.cs b/cs/Markdown/TokenConverters/EmphasisConverter.cs new file mode 100644 index 000000000..6156f67b3 --- /dev/null +++ b/cs/Markdown/TokenConverters/EmphasisConverter.cs @@ -0,0 +1,18 @@ +using System.Text; +using Markdown.Interfaces; +using Markdown.Renderers; +using Markdown.Tokens; + +namespace Markdown.TokenConverters; + +public class EmphasisConverter : TokenConverterBase +{ + public override void Render(Token token, StringBuilder result) + { + result.Append(""); + RenderChildren(token, result); + result.Append(""); + } +} + + diff --git a/cs/Markdown/TokenConverters/HeaderConverter.cs b/cs/Markdown/TokenConverters/HeaderConverter.cs new file mode 100644 index 000000000..23d26ba25 --- /dev/null +++ b/cs/Markdown/TokenConverters/HeaderConverter.cs @@ -0,0 +1,17 @@ +using System.Text; +using Markdown.Interfaces; +using Markdown.Renderers; +using Markdown.Tokens; + +namespace Markdown.TokenConverters; + +public class HeaderConverter : TokenConverterBase +{ + public override void Render(Token token, StringBuilder result) + { + var level = token.HeaderLevel; + result.Append($""); + RenderChildren(token, result); + result.Append($""); + } +} diff --git a/cs/Markdown/TokenConverters/StrongConverter.cs b/cs/Markdown/TokenConverters/StrongConverter.cs new file mode 100644 index 000000000..38bb20f9d --- /dev/null +++ b/cs/Markdown/TokenConverters/StrongConverter.cs @@ -0,0 +1,16 @@ +using System.Text; +using Markdown.Interfaces; +using Markdown.Renderers; +using Markdown.Tokens; + +namespace Markdown.TokenConverters; + +public class StrongConverter : TokenConverterBase +{ + public override void Render(Token token, StringBuilder result) + { + result.Append(""); + RenderChildren(token, result); + result.Append(""); + } +} diff --git a/cs/Markdown/TokenConverters/TextConverter.cs b/cs/Markdown/TokenConverters/TextConverter.cs new file mode 100644 index 000000000..bea6b4002 --- /dev/null +++ b/cs/Markdown/TokenConverters/TextConverter.cs @@ -0,0 +1,14 @@ +using System.Text; +using Markdown.Interfaces; +using Markdown.Renderers; +using Markdown.Tokens; + +namespace Markdown.TokenConverters; + +public class TextConverter : ITokenConverter +{ + public void Render(Token token, StringBuilder result) + { + result.Append(token.Content); + } +} diff --git a/cs/Markdown/TokenConverters/TokenConverterBase.cs b/cs/Markdown/TokenConverters/TokenConverterBase.cs new file mode 100644 index 000000000..589f0e896 --- /dev/null +++ b/cs/Markdown/TokenConverters/TokenConverterBase.cs @@ -0,0 +1,19 @@ +using System.Text; +using Markdown.Interfaces; +using Markdown.Tokens; + +namespace Markdown.TokenConverters; + +public abstract class TokenConverterBase : ITokenConverter +{ + public abstract void Render(Token token, StringBuilder result); + + protected void RenderChildren(Token token, StringBuilder result) + { + foreach (var child in token.Children) + { + var converter = TokenConverterFactory.GetConverter(child.Type); + converter.Render(child, result); + } + } +} diff --git a/cs/Markdown/TokenConverters/TokenConverterFactory.cs b/cs/Markdown/TokenConverters/TokenConverterFactory.cs new file mode 100644 index 000000000..2d44f525b --- /dev/null +++ b/cs/Markdown/TokenConverters/TokenConverterFactory.cs @@ -0,0 +1,19 @@ +using Markdown.Interfaces; +using Markdown.Tokens; + +namespace Markdown.TokenConverters; + +public static class TokenConverterFactory +{ + public static ITokenConverter GetConverter(TokenType type) + { + return type switch + { + TokenType.Text => new TextConverter(), + TokenType.Emphasis => new EmphasisConverter(), + TokenType.Strong => new StrongConverter(), + TokenType.Header => new HeaderConverter(), + _ => throw new ArgumentOutOfRangeException() + }; + } +} \ No newline at end of file diff --git a/cs/Markdown/TokenHandlers/BoundaryTokenHandler.cs b/cs/Markdown/TokenHandlers/BoundaryTokenHandler.cs new file mode 100644 index 000000000..7b14b9607 --- /dev/null +++ b/cs/Markdown/TokenHandlers/BoundaryTokenHandler.cs @@ -0,0 +1,82 @@ +using Markdown.Interfaces; +using Markdown.Parsers; +using Markdown.Tokens; + +namespace Markdown.TokenHandlers; + +public abstract class BoundaryTokenHandler : ITokenHandler +{ + protected abstract string Delimiter { get; } + protected abstract TokenType TokenType { get; } + + public abstract bool CanHandle(char current, char next, MarkdownParseContext context); + + public void Handle(MarkdownParseContext context) + { + if (IsValidBoundary(context)) + { + HandleTokenBoundary(context); + } + else + { + context.Buffer.Append(Delimiter); + context.CurrentIndex += Delimiter.Length; + } + } + + private bool IsValidBoundary(MarkdownParseContext context) + { + var index = context.CurrentIndex; + var text = context.MarkdownText; + + if (context.Stack.Count > 0) + { + if (context.Buffer.Length == 0) + return false; + if (index == 0 || index == text.Length - 1) + return true; + return !char.IsLetterOrDigit(text[index - 1]) || + !char.IsLetterOrDigit(text[index + 1]); + } + + var closingIndex = text.IndexOf(Delimiter, index + Delimiter.Length, StringComparison.Ordinal); + if (closingIndex == -1) + return false; + + var isInsideWord = (index > 0 && char.IsLetterOrDigit(text[index - 1])) || + (closingIndex + Delimiter.Length < text.Length && + char.IsLetterOrDigit(text[closingIndex + Delimiter.Length])); + if (isInsideWord) + return false; + + if (closingIndex - index <= Delimiter.Length) + return false; + + return index + 1 != closingIndex; + } + + private void HandleTokenBoundary(MarkdownParseContext context) + { + MarkdownParser.AddToken(context, TokenType.Text); + + if (context.Stack.Count > 0 && context.Stack.Peek().Type == TokenType) + { + var completedToken = context.Stack.Pop(); + + completedToken.Content = completedToken.Children.Count > 0 ? string.Empty : completedToken.Content; + context.Buffer.Clear(); + + if (context.Stack.Count > 0) + context.Stack.Peek().Children.Add(completedToken); + else + context.Tokens.Add(completedToken); + } + else + { + var newToken = new Token(TokenType); + context.Stack.Push(newToken); + } + + context.CurrentIndex += Delimiter.Length; + } +} diff --git a/cs/Markdown/TokenHandlers/EmphasisTokenHandler.cs b/cs/Markdown/TokenHandlers/EmphasisTokenHandler.cs new file mode 100644 index 000000000..68239c5bf --- /dev/null +++ b/cs/Markdown/TokenHandlers/EmphasisTokenHandler.cs @@ -0,0 +1,14 @@ +using Markdown.Interfaces; +using Markdown.Parsers; +using Markdown.Tokens; + +namespace Markdown.TokenHandlers; + +public class EmphasisTokenHandler : BoundaryTokenHandler +{ + protected override string Delimiter => "_"; + protected override TokenType TokenType => TokenType.Emphasis; + + public override bool CanHandle(char current, char next, MarkdownParseContext context) + => current == '_' && next != '_'; +} \ No newline at end of file diff --git a/cs/Markdown/TokenHandlers/EscapeCharacterHandler.cs b/cs/Markdown/TokenHandlers/EscapeCharacterHandler.cs new file mode 100644 index 000000000..0dbe73e83 --- /dev/null +++ b/cs/Markdown/TokenHandlers/EscapeCharacterHandler.cs @@ -0,0 +1,29 @@ +using Markdown.Interfaces; +using Markdown.Parsers; +using Markdown.Tokens; + +namespace Markdown.TokenHandlers; + +public class EscapeCharacterHandler : ITokenHandler +{ + public bool CanHandle(char current, char next, MarkdownParseContext context) + => current == '\\'; + + public void Handle(MarkdownParseContext context) + { + + if (context.CurrentIndex + 1 < context.MarkdownText.Length) + { + var next = context.MarkdownText[context.CurrentIndex + 1]; + if (next is '_' or '#' or '\\') + { + if (next != '\\') + context.Buffer.Append(next); + context.CurrentIndex += 2; + return; + } + } + context.Buffer.Append('\\'); + context.CurrentIndex++; + } +} \ No newline at end of file diff --git a/cs/Markdown/TokenHandlers/HeaderTokenHandler.cs b/cs/Markdown/TokenHandlers/HeaderTokenHandler.cs new file mode 100644 index 000000000..191b36b64 --- /dev/null +++ b/cs/Markdown/TokenHandlers/HeaderTokenHandler.cs @@ -0,0 +1,52 @@ +using Markdown.Interfaces; +using Markdown.Parsers; +using Markdown.Tokens; + +namespace Markdown.TokenHandlers; + +public class HeaderTokenHandler : ITokenHandler +{ + public bool CanHandle(char current, char next, MarkdownParseContext context) + => current == '#' && (context.CurrentIndex == 0 || context.MarkdownText[context.CurrentIndex - 1] == '\n'); + + public void Handle(MarkdownParseContext context) + { + while (context.CurrentIndex < context.MarkdownText.Length && + context.MarkdownText[context.CurrentIndex] == '#') + { + context.HeaderLevel++; + context.CurrentIndex++; + } + + if (context.CurrentIndex < context.MarkdownText.Length && + context.MarkdownText[context.CurrentIndex] == ' ') + { + context.CurrentIndex++; + + MarkdownParser.AddToken(context, TokenType.Text); + var headerToken = new Token(TokenType.Header) + { + HeaderLevel = context.HeaderLevel + }; + + context.Tokens.Add(headerToken); + + var headerEnd = context.MarkdownText.IndexOf('\n', context.CurrentIndex); + if (headerEnd == -1) + headerEnd = context.MarkdownText.Length; + + var headerContent = context.Parser.ParseTokens(context.MarkdownText[context.CurrentIndex..headerEnd]); + + foreach (var childToken in headerContent) + { + headerToken.Children.Add(childToken); + } + context.CurrentIndex = headerEnd; + } + else + { + context.Buffer.Append('#', context.HeaderLevel); + } + + } +} diff --git a/cs/Markdown/TokenHandlers/NewLineHandler.cs b/cs/Markdown/TokenHandlers/NewLineHandler.cs new file mode 100644 index 000000000..7ba0ba737 --- /dev/null +++ b/cs/Markdown/TokenHandlers/NewLineHandler.cs @@ -0,0 +1,19 @@ +using Markdown.Interfaces; +using Markdown.Parsers; +using Markdown.Tokens; + +namespace Markdown.TokenHandlers; + +public class NewLineHandler : ITokenHandler +{ + public bool CanHandle(char current, char next, MarkdownParseContext context) + => current == '\n' && context.Stack.Count > 0 && context.Stack.Peek().Type == TokenType.Header; + + public void Handle(MarkdownParseContext context) + { + MarkdownParser.AddToken(context, TokenType.Text); + + context.Tokens.Add(context.Stack.Pop()); + context.CurrentIndex++; + } +} \ No newline at end of file diff --git a/cs/Markdown/TokenHandlers/StrongTokenHandler.cs b/cs/Markdown/TokenHandlers/StrongTokenHandler.cs new file mode 100644 index 000000000..d58f06afc --- /dev/null +++ b/cs/Markdown/TokenHandlers/StrongTokenHandler.cs @@ -0,0 +1,14 @@ +using Markdown.Interfaces; +using Markdown.Parsers; +using Markdown.Tokens; + +namespace Markdown.TokenHandlers; + +public class StrongTokenHandler : BoundaryTokenHandler +{ + protected override string Delimiter => "__"; + protected override TokenType TokenType => TokenType.Strong; + + public override bool CanHandle(char current, char next, MarkdownParseContext context) + => current == '_' && next == '_'; +} \ No newline at end of file diff --git a/cs/Markdown/Tokens/Token.cs b/cs/Markdown/Tokens/Token.cs index 8de9915b1..f653625cc 100644 --- a/cs/Markdown/Tokens/Token.cs +++ b/cs/Markdown/Tokens/Token.cs @@ -4,7 +4,7 @@ public class Token { public TokenType Type { get; } public string Content { get; set; } - public List Children { get; } + public List Children { get; set; } public int HeaderLevel { get; init; } public Token(TokenType type, string content, List? children = null) { diff --git a/cs/MarkdownTests/HtmlRenderer_Should.cs b/cs/MarkdownTests/HtmlRenderer_Should.cs index 59243439d..87092e681 100644 --- a/cs/MarkdownTests/HtmlRenderer_Should.cs +++ b/cs/MarkdownTests/HtmlRenderer_Should.cs @@ -29,7 +29,7 @@ public void Render_ShouldHandleEmphasisTags() var tokens = new List { new Token(TokenType.Text, "Это "), - new Token(TokenType.Emphasis, "курсив"), + new Token(TokenType.Emphasis, "курсив") { Children = new List { new Token(TokenType.Text, "курсив") } }, new Token(TokenType.Text, " текст") }; @@ -41,10 +41,13 @@ public void Render_ShouldHandleEmphasisTags() [Test] public void Render_ShoulHandleStrongTags() { + var strongToken = new Token(TokenType.Strong, string.Empty); + strongToken.Children.Add(new Token(TokenType.Text, "полужирный")); // Добавляем текст как дочерний токен + var tokens = new List { new Token(TokenType.Text, "Это "), - new Token(TokenType.Strong, "полужирный"), + strongToken, new Token(TokenType.Text, " текст") }; @@ -56,10 +59,10 @@ public void Render_ShoulHandleStrongTags() [Test] public void Render_ShouldHandleHeaderTags() { - var tokens = new List - { - new Token(TokenType.Header, "Заголовок") - }; + var headerToken = new Token(TokenType.Header, string.Empty); + headerToken.Children.Add(new Token(TokenType.Text, "Заголовок")); + + var tokens = new List { headerToken }; var result = renderer.Render(tokens); @@ -71,8 +74,9 @@ public void Render_ShouldHandleNestedTags() { var headToken = new Token(TokenType.Header, string.Empty); var strongToken = new Token(TokenType.Strong, string.Empty); - var emphasisToken = new Token(TokenType.Emphasis, "курсивом" ); + var emphasisToken = new Token(TokenType.Emphasis, string.Empty); + emphasisToken.Children.Add(new Token(TokenType.Text, "курсивом")); strongToken.Children.Add(new Token(TokenType.Text, "полужирным текстом с ")); strongToken.Children.Add(emphasisToken); headToken.Children.Add(new Token(TokenType.Text, "заголовок с ")); @@ -99,6 +103,7 @@ public void Render_ShouldHandleEmptyTags() new Token(TokenType.Text, " текст") }; + var result = renderer.Render(tokens); result.Should().Be("Это текст"); @@ -110,9 +115,9 @@ public void Render_ShouldHandleMultipleTags() var tokens = new List { new Token(TokenType.Text, "Это "), - new Token(TokenType.Strong, "полужирный"), + new Token(TokenType.Strong, "полужирный") { Children = { new Token(TokenType.Text, "полужирный") } }, new Token(TokenType.Text, " и "), - new Token(TokenType.Emphasis, "курсив"), + new Token(TokenType.Emphasis, string.Empty) { Children = { new Token(TokenType.Text, "курсив") } }, new Token(TokenType.Text, " текст.") }; @@ -124,8 +129,11 @@ public void Render_ShouldHandleMultipleTags() [Test] public void Render_ShouldHandleNestedTagsWithMultipleLevels() { - var innerStrongToken = new Token(TokenType.Strong, "полужирный заголовок"); - var innerEmphasisToken = new Token(TokenType.Emphasis, " полужирный курсив"); + var innerStrongToken = new Token(TokenType.Strong, string.Empty); + innerStrongToken.Children.Add(new Token(TokenType.Text, "полужирный заголовок")); + + var innerEmphasisToken = new Token(TokenType.Emphasis, string.Empty); + innerEmphasisToken.Children.Add(new Token(TokenType.Text, " полужирный курсив")); var outerHeaderToken = new Token(TokenType.Header, string.Empty); outerHeaderToken.Children.Add(innerStrongToken); diff --git a/cs/MarkdownTests/MarkdownParser_Should.cs b/cs/MarkdownTests/MarkdownParser_Should.cs index bea5a0f62..547d0355b 100644 --- a/cs/MarkdownTests/MarkdownParser_Should.cs +++ b/cs/MarkdownTests/MarkdownParser_Should.cs @@ -8,10 +8,11 @@ namespace MarkdownTests; [TestFixture] public class MarkdownParser_Should { + private MarkdownParser parser = new(); [Test] public void MarkdownParser_ShouldParse_WhenItalicTag() { - var tokens = MarkdownParser + var tokens = parser .ParseTokens("Это _курсив_ текст").ToList(); tokens.Should().HaveCount(3); @@ -26,7 +27,7 @@ public void MarkdownParser_ShouldParse_WhenItalicTag() [Test] public void MarkdownParser_ShouldParse_WhenStrongTag() { - var tokens = MarkdownParser + var tokens = parser .ParseTokens("Это __полужирный__ текст").ToList(); tokens.Should().HaveCount(3); @@ -42,7 +43,7 @@ public void MarkdownParser_ShouldParse_WhenStrongTag() [Test] public void MarkdownParser_ShouldParse_WhenHeaderTag() { - var tokens = MarkdownParser + var tokens = parser .ParseTokens("# Заголовок").ToList(); tokens.Should().HaveCount(1); @@ -53,7 +54,7 @@ public void MarkdownParser_ShouldParse_WhenHeaderTag() [Test] public void MarkdownParser_ShouldParse_WhenEscaping() { - var tokens = MarkdownParser + var tokens = parser .ParseTokens(@"Экранированный \_символ\_").ToList(); tokens.Should().HaveCount(1); @@ -64,7 +65,7 @@ public void MarkdownParser_ShouldParse_WhenEscaping() [Test] public void MarkdownParser_ShouldParse_WhenNestedItalicAndStrongTags() { - var tokens = MarkdownParser + var tokens = parser .ParseTokens("Это __жирный _и курсивный_ текст__").ToList(); tokens.Should().HaveCount(2); @@ -84,7 +85,7 @@ public void MarkdownParser_ShouldParse_WhenNestedItalicAndStrongTags() [Test] public void MarkdownParser_ShouldParse_WhenMultipleTokensInLine() { - var tokens = MarkdownParser + var tokens = parser .ParseTokens("Это _курсив_, а это __жирный__ текст.").ToList(); tokens.Should().HaveCount(5); @@ -103,7 +104,7 @@ public void MarkdownParser_ShouldParse_WhenMultipleTokensInLine() [Test] public void MarkdownParser_ShouldNotParse_WhenEscapingSymbols() { - var tokens = MarkdownParser + var tokens = parser .ParseTokens(@"Здесь сим\волы экранирования\ \должны остаться.\").ToList(); tokens.Should().HaveCount(1); @@ -114,7 +115,7 @@ public void MarkdownParser_ShouldNotParse_WhenEscapingSymbols() [Test] public void MarkdownParser_ShouldParse_WhenEscapedTags() { - var tokens = MarkdownParser + var tokens = parser .ParseTokens(@"\\_вот это будет выделено тегом_").ToList(); tokens.Should().HaveCount(1); @@ -125,7 +126,7 @@ public void MarkdownParser_ShouldParse_WhenEscapedTags() [Test] public void MarkdownParser_ShouldParse_WhenNestedItalicAndStrongCorrectly() { - var tokens = MarkdownParser + var tokens = parser .ParseTokens("Это __двойное _и одинарное_ выделение__").ToList(); tokens.Should().HaveCount(2); @@ -145,7 +146,7 @@ public void MarkdownParser_ShouldParse_WhenNestedItalicAndStrongCorrectly() [Test] public void MarkdownParser_ShouldParse_WhenHeaderWithTags() { - var tokens = MarkdownParser + var tokens = parser .ParseTokens("# Заголовок __с _разными_ символами__").ToList(); tokens.Should().HaveCount(1); @@ -165,7 +166,7 @@ public void MarkdownParser_ShouldParse_WhenHeaderWithTags() [Test] public void MarkdownParser_ShouldNotParse_WhenEmptyEmphasis() { - var tokens = MarkdownParser + var tokens = parser .ParseTokens("Если пустая _______ строка").ToList(); tokens.Should().HaveCount(1); @@ -176,7 +177,7 @@ public void MarkdownParser_ShouldNotParse_WhenEmptyEmphasis() [Test] public void MarkdownParser_ShouldParse_WhenMultipleHeaders() { - var tokens = MarkdownParser + var tokens = parser .ParseTokens("# Заголовок 1\n# Заголовок 2").ToList(); tokens.Should().HaveCount(3); @@ -189,7 +190,7 @@ public void MarkdownParser_ShouldParse_WhenMultipleHeaders() [Test] public void MarkdownParser_ShouldNotParse_WhenUnderscoresInNumbers() { - var tokens = MarkdownParser + var tokens = parser .ParseTokens("Текст с цифрами_12_3 не должен выделяться").ToList(); tokens.Should().HaveCount(1); @@ -200,7 +201,7 @@ public void MarkdownParser_ShouldNotParse_WhenUnderscoresInNumbers() [Test] public void MarkdownParser_ShouldNotParse_WhenTagsInWords() { - var tokens = MarkdownParser + var tokens = parser .ParseTokens("и в _нач_але, и в сер_еди_не").ToList(); tokens.Should().HaveCount(1); @@ -211,7 +212,7 @@ public void MarkdownParser_ShouldNotParse_WhenTagsInWords() [Test] public void MarkdownParser_ShouldNotParse_WhenDifferentWords() { - var tokens = MarkdownParser + var tokens = parser .ParseTokens("Это пер_вый в_торой пример.").ToList(); tokens.Should().HaveCount(1); diff --git a/cs/MarkdownTests/Md_Should.cs b/cs/MarkdownTests/Md_Should.cs index ce9f01767..fa74e103d 100644 --- a/cs/MarkdownTests/Md_Should.cs +++ b/cs/MarkdownTests/Md_Should.cs @@ -2,13 +2,26 @@ using Markdown; using NUnit.Framework; using System.Diagnostics; +using Markdown.Parsers; +using Markdown.Renderers; namespace MarkdownTests; [TestFixture] public class Md_Should { - private readonly Md md = new(); + private HtmlRenderer renderer; + private MarkdownParser parser; + private Md md; + + [SetUp] + public void Setup() + { + renderer = new HtmlRenderer(); + parser = new MarkdownParser(); + md = new Md(renderer, parser); + } + [Test] public void Md_ShouldThrowArgumentNullException_WhenInputIsNull() @@ -62,18 +75,35 @@ public void Md_ShouldNotRender_When(string input, string expected) "Это полужирный текст, с курсивом внутри", TestName = "ItalicInStrong")] [TestCase("Это _курсив с __полужирным__ внутри_", - "Это курсив с полужирным внутри", + "Это курсив с __полужирным__ внутри", TestName = "StrongInItalic")] [TestCase(@"Экранированный \_символ\_", "Экранированный _символ_", TestName = "EscapeTag")] [TestCase(@"\\_вот это будет выделено тегом_", "вот это будет выделено тегом", - TestName = "EscapedYourself")] + TestName = "EscapedYourselfOnStartOfTag")] + [TestCase(@"_e\\_", + "e", + TestName = "EscapedYourselfOnEndOfTag")] [TestCase("# Заголовок 1\n# Заголовок 2", "

Заголовок 1

\n

Заголовок 2

", TestName = "MultipleHeaders")] - + [TestCase("# h __s _E _e_ E_ s__ _e_", + "

h s E e E s e

", + TestName = "LotNestedTags")] + [TestCase("_e __s e_ s__", + "_e __s e_ s__", + TestName = "TagsIntersection")] + [TestCase("en_d._, mi__dd__le, _sta_rt", + "end., middle, start", + TestName = "BoundedTagsInOneWord")] + [TestCase("__s \n s__, _e \r\n e_", + "__s \n s__, _e \r\n e_", + TestName = "NewLines")] + [TestCase("_e __e", + "_e __e", + TestName = "UnPairedTags2")] public void Md_ShouldRender_When(string input, string expected) { var result = md.Render(input); From 1d98e9db4dd37f147444bcf4cd8d5bda04cbf68f Mon Sep 17 00:00:00 2001 From: Kostornoj-Dmitrij Date: Sat, 7 Dec 2024 16:18:29 +0500 Subject: [PATCH 03/16] homework fixes --- cs/Markdown/Md.cs | 2 - cs/Markdown/Parsers/MarkdownParser.cs | 26 +- cs/Markdown/Parsers/MarkdownParserContext.cs | 13 +- cs/Markdown/Renderers/HtmlRenderer.cs | 4 +- cs/Markdown/Renderers/SimilarTagsNester.cs | 15 + .../TokenConverters/EmphasisConverter.cs | 6 +- .../TokenConverters/HeaderConverter.cs | 4 +- .../TokenConverters/StrongConverter.cs | 4 +- cs/Markdown/TokenConverters/TextConverter.cs | 3 +- .../TokenConverters/TokenConverterBase.cs | 4 +- .../TokenConverters/TokenConverterFactory.cs | 18 + .../TokenHandlers/BoundaryTokenHandler.cs | 91 +++- .../TokenHandlers/EmphasisTokenHandler.cs | 1 - .../TokenHandlers/EscapeCharacterHandler.cs | 1 - .../TokenHandlers/HeaderTokenHandler.cs | 9 +- .../TokenHandlers/StrongTokenHandler.cs | 1 - cs/Markdown/Tokens/Token.cs | 12 +- cs/MarkdownTests/HtmlRenderer_Should.cs | 43 +- cs/MarkdownTests/MarkdownParser_Should.cs | 404 ++++++++++-------- cs/MarkdownTests/Md_Should.cs | 61 +-- 20 files changed, 426 insertions(+), 296 deletions(-) create mode 100644 cs/Markdown/Renderers/SimilarTagsNester.cs diff --git a/cs/Markdown/Md.cs b/cs/Markdown/Md.cs index b6176a1bd..b45ad260f 100644 --- a/cs/Markdown/Md.cs +++ b/cs/Markdown/Md.cs @@ -1,6 +1,4 @@ using Markdown.Interfaces; -using Markdown.Parsers; -using Markdown.Renderers; namespace Markdown; diff --git a/cs/Markdown/Parsers/MarkdownParser.cs b/cs/Markdown/Parsers/MarkdownParser.cs index 5bb9358ff..27da1760d 100644 --- a/cs/Markdown/Parsers/MarkdownParser.cs +++ b/cs/Markdown/Parsers/MarkdownParser.cs @@ -6,24 +6,18 @@ namespace Markdown.Parsers; public class MarkdownParser : IMarkdownParser { - private readonly List handlers; + private readonly List handlers = + [ + new StrongTokenHandler(), + new HeaderTokenHandler(), + new EmphasisTokenHandler(), + new NewLineHandler(), + new EscapeCharacterHandler() + ]; - public MarkdownParser() - { - handlers = new List - { - new StrongTokenHandler(), - new HeaderTokenHandler(), - new EmphasisTokenHandler(), - new NewLineHandler(), - new EscapeCharacterHandler() - }; - } - public IEnumerable ParseTokens(string markdownText) { - if (markdownText == null) - throw new ArgumentNullException(nameof(markdownText)); + ArgumentNullException.ThrowIfNull(markdownText); var context = new MarkdownParseContext { @@ -53,7 +47,7 @@ public IEnumerable ParseTokens(string markdownText) AddToken(context, TokenType.Text); return context.Tokens; } - + public static void AddToken(MarkdownParseContext context, TokenType type) { if (context.Buffer.Length == 0) return; diff --git a/cs/Markdown/Parsers/MarkdownParserContext.cs b/cs/Markdown/Parsers/MarkdownParserContext.cs index 320df619d..c62971d2f 100644 --- a/cs/Markdown/Parsers/MarkdownParserContext.cs +++ b/cs/Markdown/Parsers/MarkdownParserContext.cs @@ -1,16 +1,17 @@ -using Markdown.Tokens; using System.Text; using Markdown.Interfaces; +using Markdown.Tokens; namespace Markdown.Parsers; public class MarkdownParseContext { - public Stack Stack { get; set; } = new(); - public List Tokens { get; set; } = new(); - public StringBuilder Buffer { get; set; } = new(); - public string MarkdownText { get; set; } = ""; + public Stack Stack { get; } = new(); + public StringBuilder Buffer { get; } = new(); + public List Tokens { get; } = []; + public List IntersectedIndexes { get; } = []; + public string MarkdownText { get; init; } = ""; public int CurrentIndex { get; set; } public int HeaderLevel { get; set; } - public required IMarkdownParser Parser { get; set; } + public required IMarkdownParser Parser { get; init; } } \ No newline at end of file diff --git a/cs/Markdown/Renderers/HtmlRenderer.cs b/cs/Markdown/Renderers/HtmlRenderer.cs index d9f1b9c35..7bdae8efb 100644 --- a/cs/Markdown/Renderers/HtmlRenderer.cs +++ b/cs/Markdown/Renderers/HtmlRenderer.cs @@ -15,6 +15,8 @@ public string Render(IEnumerable tokens) var converter = TokenConverterFactory.GetConverter(token.Type); converter.Render(token, result); } - return result.ToString(); + var text = result.ToString(); + text = TagReplacer.SimilarTagsNester(text); + return text; } } \ No newline at end of file diff --git a/cs/Markdown/Renderers/SimilarTagsNester.cs b/cs/Markdown/Renderers/SimilarTagsNester.cs new file mode 100644 index 000000000..b9d5b485f --- /dev/null +++ b/cs/Markdown/Renderers/SimilarTagsNester.cs @@ -0,0 +1,15 @@ +using Markdown.TokenConverters; + +namespace Markdown.Renderers; + +public static class TagReplacer +{ + public static string SimilarTagsNester(string text) + { + foreach (var pair in TokenConverterFactory.GetTagPairs()) + { + text = text.Replace(pair.Key, pair.Value); + } + return text; + } +} \ No newline at end of file diff --git a/cs/Markdown/TokenConverters/EmphasisConverter.cs b/cs/Markdown/TokenConverters/EmphasisConverter.cs index 6156f67b3..76bbfc5c0 100644 --- a/cs/Markdown/TokenConverters/EmphasisConverter.cs +++ b/cs/Markdown/TokenConverters/EmphasisConverter.cs @@ -1,6 +1,4 @@ using System.Text; -using Markdown.Interfaces; -using Markdown.Renderers; using Markdown.Tokens; namespace Markdown.TokenConverters; @@ -13,6 +11,4 @@ public override void Render(Token token, StringBuilder result) RenderChildren(token, result); result.Append(""); } -} - - +} \ No newline at end of file diff --git a/cs/Markdown/TokenConverters/HeaderConverter.cs b/cs/Markdown/TokenConverters/HeaderConverter.cs index 23d26ba25..53a4a22f3 100644 --- a/cs/Markdown/TokenConverters/HeaderConverter.cs +++ b/cs/Markdown/TokenConverters/HeaderConverter.cs @@ -1,6 +1,4 @@ using System.Text; -using Markdown.Interfaces; -using Markdown.Renderers; using Markdown.Tokens; namespace Markdown.TokenConverters; @@ -14,4 +12,4 @@ public override void Render(Token token, StringBuilder result) RenderChildren(token, result); result.Append($""); } -} +} \ No newline at end of file diff --git a/cs/Markdown/TokenConverters/StrongConverter.cs b/cs/Markdown/TokenConverters/StrongConverter.cs index 38bb20f9d..4d9b13bda 100644 --- a/cs/Markdown/TokenConverters/StrongConverter.cs +++ b/cs/Markdown/TokenConverters/StrongConverter.cs @@ -1,6 +1,4 @@ using System.Text; -using Markdown.Interfaces; -using Markdown.Renderers; using Markdown.Tokens; namespace Markdown.TokenConverters; @@ -13,4 +11,4 @@ public override void Render(Token token, StringBuilder result) RenderChildren(token, result); result.Append(""); } -} +} \ No newline at end of file diff --git a/cs/Markdown/TokenConverters/TextConverter.cs b/cs/Markdown/TokenConverters/TextConverter.cs index bea6b4002..badbe6f8d 100644 --- a/cs/Markdown/TokenConverters/TextConverter.cs +++ b/cs/Markdown/TokenConverters/TextConverter.cs @@ -1,6 +1,5 @@ using System.Text; using Markdown.Interfaces; -using Markdown.Renderers; using Markdown.Tokens; namespace Markdown.TokenConverters; @@ -11,4 +10,4 @@ public void Render(Token token, StringBuilder result) { result.Append(token.Content); } -} +} \ No newline at end of file diff --git a/cs/Markdown/TokenConverters/TokenConverterBase.cs b/cs/Markdown/TokenConverters/TokenConverterBase.cs index 589f0e896..7ece35dd1 100644 --- a/cs/Markdown/TokenConverters/TokenConverterBase.cs +++ b/cs/Markdown/TokenConverters/TokenConverterBase.cs @@ -8,7 +8,7 @@ public abstract class TokenConverterBase : ITokenConverter { public abstract void Render(Token token, StringBuilder result); - protected void RenderChildren(Token token, StringBuilder result) + protected static void RenderChildren(Token token, StringBuilder result) { foreach (var child in token.Children) { @@ -16,4 +16,4 @@ protected void RenderChildren(Token token, StringBuilder result) converter.Render(child, result); } } -} +} \ No newline at end of file diff --git a/cs/Markdown/TokenConverters/TokenConverterFactory.cs b/cs/Markdown/TokenConverters/TokenConverterFactory.cs index 2d44f525b..363ed68db 100644 --- a/cs/Markdown/TokenConverters/TokenConverterFactory.cs +++ b/cs/Markdown/TokenConverters/TokenConverterFactory.cs @@ -5,6 +5,19 @@ namespace Markdown.TokenConverters; public static class TokenConverterFactory { + private static readonly Dictionary TagPairs; + + static TokenConverterFactory() + { + TagPairs = new Dictionary + { + { " ", " " }, + { " ", " " }, + { " ", " " }, + { " ", " " } + }; + } + public static ITokenConverter GetConverter(TokenType type) { return type switch @@ -16,4 +29,9 @@ public static ITokenConverter GetConverter(TokenType type) _ => throw new ArgumentOutOfRangeException() }; } + + public static Dictionary GetTagPairs() + { + return TagPairs; + } } \ No newline at end of file diff --git a/cs/Markdown/TokenHandlers/BoundaryTokenHandler.cs b/cs/Markdown/TokenHandlers/BoundaryTokenHandler.cs index 7b14b9607..857efd11d 100644 --- a/cs/Markdown/TokenHandlers/BoundaryTokenHandler.cs +++ b/cs/Markdown/TokenHandlers/BoundaryTokenHandler.cs @@ -28,26 +28,78 @@ private bool IsValidBoundary(MarkdownParseContext context) { var index = context.CurrentIndex; var text = context.MarkdownText; + var nextDelimiter = TokenType == TokenType.Strong ? "_" : "__"; + if (context.IntersectedIndexes.Contains(index)) + return false; if (context.Stack.Count > 0) { - if (context.Buffer.Length == 0) + if (context.Stack.Peek().Type == TokenType.Emphasis && TokenType == TokenType.Strong) + { + return false; + } + + if (context.Stack.Count > 2) + { + return false; + } + + if (context.Buffer.Length == 0) return false; + if (index == 0 || index == text.Length - 1) return true; - return !char.IsLetterOrDigit(text[index - 1]) || + + var spaceIndex = text.IndexOf(' ', index + Delimiter.Length); + if (spaceIndex == -1) + return true; + + return !char.IsLetterOrDigit(text[index - 1]) || !char.IsLetterOrDigit(text[index + 1]); } - var closingIndex = text.IndexOf(Delimiter, index + Delimiter.Length, StringComparison.Ordinal); + var paragraphEndIndex = text.IndexOfAny(['\n', '\r'], index); + if (paragraphEndIndex == -1) + { + paragraphEndIndex = text.Length; + } + + var closingIndex = FindSingleDelimiter(text, + index + Delimiter.Length, paragraphEndIndex, Delimiter); + var anotherOpenIndex = FindSingleDelimiter(text, + index + Delimiter.Length, paragraphEndIndex, nextDelimiter); + var anotherClosingIndex = FindSingleDelimiter(text, + anotherOpenIndex + nextDelimiter.Length, paragraphEndIndex, nextDelimiter); + + if (anotherOpenIndex < closingIndex && anotherClosingIndex > closingIndex) + { + context.IntersectedIndexes.Add(index); + context.IntersectedIndexes.Add(closingIndex); + context.IntersectedIndexes.Add(anotherOpenIndex); + context.IntersectedIndexes.Add(anotherClosingIndex); + return false; + } + if (closingIndex == -1) return false; - var isInsideWord = (index > 0 && char.IsLetterOrDigit(text[index - 1])) || - (closingIndex + Delimiter.Length < text.Length && + var isInsideWord = (index > 0 && char.IsLetterOrDigit(text[index - 1])) || + (closingIndex + Delimiter.Length < paragraphEndIndex && char.IsLetterOrDigit(text[closingIndex + Delimiter.Length])); if (isInsideWord) - return false; + { + if (index > 0 && + (char.IsDigit(text[index - 1]) || + char.IsDigit(text[index + 1])) && + closingIndex + Delimiter.Length < paragraphEndIndex && + (char.IsDigit(text[closingIndex - 1]) || + char.IsDigit(text[closingIndex + Delimiter.Length]))) + return false; + + var spaceIndex = text.IndexOf(' ', index + Delimiter.Length); + + return spaceIndex == -1 || closingIndex < spaceIndex; + } if (closingIndex - index <= Delimiter.Length) return false; @@ -63,7 +115,8 @@ private void HandleTokenBoundary(MarkdownParseContext context) { var completedToken = context.Stack.Pop(); - completedToken.Content = completedToken.Children.Count > 0 ? string.Empty : completedToken.Content; + completedToken.Content = completedToken.Children.Count > 0 ? + string.Empty : completedToken.Content; context.Buffer.Clear(); if (context.Stack.Count > 0) @@ -79,4 +132,26 @@ private void HandleTokenBoundary(MarkdownParseContext context) context.CurrentIndex += Delimiter.Length; } -} + + private static int FindSingleDelimiter(string text, int startIndex, int paragraphEndIndex, string delimiter) + { + var index = text.IndexOf(delimiter, startIndex, StringComparison.Ordinal); + + while (index != -1 && index < paragraphEndIndex) + { + if (index > 0 && text[index - 1] == '_') + { + index = text.IndexOf(delimiter, index + 1, StringComparison.Ordinal); + continue; + } + + if (index + delimiter.Length < text.Length && text[index + delimiter.Length] == '_') + { + index = text.IndexOf(delimiter, index + 2, StringComparison.Ordinal); + continue; + } + return index; + } + return -1; + } +} \ No newline at end of file diff --git a/cs/Markdown/TokenHandlers/EmphasisTokenHandler.cs b/cs/Markdown/TokenHandlers/EmphasisTokenHandler.cs index 68239c5bf..93668d4ba 100644 --- a/cs/Markdown/TokenHandlers/EmphasisTokenHandler.cs +++ b/cs/Markdown/TokenHandlers/EmphasisTokenHandler.cs @@ -1,4 +1,3 @@ -using Markdown.Interfaces; using Markdown.Parsers; using Markdown.Tokens; diff --git a/cs/Markdown/TokenHandlers/EscapeCharacterHandler.cs b/cs/Markdown/TokenHandlers/EscapeCharacterHandler.cs index 0dbe73e83..c403e81e8 100644 --- a/cs/Markdown/TokenHandlers/EscapeCharacterHandler.cs +++ b/cs/Markdown/TokenHandlers/EscapeCharacterHandler.cs @@ -1,6 +1,5 @@ using Markdown.Interfaces; using Markdown.Parsers; -using Markdown.Tokens; namespace Markdown.TokenHandlers; diff --git a/cs/Markdown/TokenHandlers/HeaderTokenHandler.cs b/cs/Markdown/TokenHandlers/HeaderTokenHandler.cs index 191b36b64..68191c904 100644 --- a/cs/Markdown/TokenHandlers/HeaderTokenHandler.cs +++ b/cs/Markdown/TokenHandlers/HeaderTokenHandler.cs @@ -7,7 +7,8 @@ namespace Markdown.TokenHandlers; public class HeaderTokenHandler : ITokenHandler { public bool CanHandle(char current, char next, MarkdownParseContext context) - => current == '#' && (context.CurrentIndex == 0 || context.MarkdownText[context.CurrentIndex - 1] == '\n'); + => current == '#' && (context.CurrentIndex == 0 || + context.MarkdownText[context.CurrentIndex - 1] == '\n'); public void Handle(MarkdownParseContext context) { @@ -35,7 +36,8 @@ public void Handle(MarkdownParseContext context) if (headerEnd == -1) headerEnd = context.MarkdownText.Length; - var headerContent = context.Parser.ParseTokens(context.MarkdownText[context.CurrentIndex..headerEnd]); + var headerContent = context.Parser + .ParseTokens(context.MarkdownText[context.CurrentIndex..headerEnd]); foreach (var childToken in headerContent) { @@ -47,6 +49,5 @@ public void Handle(MarkdownParseContext context) { context.Buffer.Append('#', context.HeaderLevel); } - } -} +} \ No newline at end of file diff --git a/cs/Markdown/TokenHandlers/StrongTokenHandler.cs b/cs/Markdown/TokenHandlers/StrongTokenHandler.cs index d58f06afc..4feab52bb 100644 --- a/cs/Markdown/TokenHandlers/StrongTokenHandler.cs +++ b/cs/Markdown/TokenHandlers/StrongTokenHandler.cs @@ -1,4 +1,3 @@ -using Markdown.Interfaces; using Markdown.Parsers; using Markdown.Tokens; diff --git a/cs/Markdown/Tokens/Token.cs b/cs/Markdown/Tokens/Token.cs index f653625cc..3132ce78c 100644 --- a/cs/Markdown/Tokens/Token.cs +++ b/cs/Markdown/Tokens/Token.cs @@ -4,8 +4,9 @@ public class Token { public TokenType Type { get; } public string Content { get; set; } - public List Children { get; set; } + public List Children { get; init; } public int HeaderLevel { get; init; } + public Token(TokenType type, string content, List? children = null) { Type = type; @@ -14,10 +15,7 @@ public Token(TokenType type, string content, List? children = null) HeaderLevel = 1; } - public Token(TokenType type) - { - Type = type; - Content = string.Empty; - Children = []; - } + public Token(TokenType type) : this(type, string.Empty) { } + public Token(TokenType type, List? children = null) + : this(type, string.Empty, children) { } } \ No newline at end of file diff --git a/cs/MarkdownTests/HtmlRenderer_Should.cs b/cs/MarkdownTests/HtmlRenderer_Should.cs index 87092e681..9c6c9bf70 100644 --- a/cs/MarkdownTests/HtmlRenderer_Should.cs +++ b/cs/MarkdownTests/HtmlRenderer_Should.cs @@ -8,14 +8,14 @@ namespace MarkdownTests; [TestFixture] public class HtmlRenderer_Should { - private readonly HtmlRenderer renderer = new(); + private readonly HtmlRenderer renderer = new (); [Test] public void Render_ShouldHandleTextWithoutTags() { var tokens = new List { - new Token(TokenType.Text, "Текст без тегов.") + new (TokenType.Text, "Текст без тегов.") }; var result = renderer.Render(tokens); @@ -28,9 +28,12 @@ public void Render_ShouldHandleEmphasisTags() { var tokens = new List { - new Token(TokenType.Text, "Это "), - new Token(TokenType.Emphasis, "курсив") { Children = new List { new Token(TokenType.Text, "курсив") } }, - new Token(TokenType.Text, " текст") + new (TokenType.Text, "Это "), + new (TokenType.Emphasis, "курсив") + { Children = new List + { new (TokenType.Text, "курсив") } + }, + new (TokenType.Text, " текст") }; var result = renderer.Render(tokens); @@ -42,13 +45,13 @@ public void Render_ShouldHandleEmphasisTags() public void Render_ShoulHandleStrongTags() { var strongToken = new Token(TokenType.Strong, string.Empty); - strongToken.Children.Add(new Token(TokenType.Text, "полужирный")); // Добавляем текст как дочерний токен + strongToken.Children.Add(new Token(TokenType.Text, "полужирный")); var tokens = new List { - new Token(TokenType.Text, "Это "), + new (TokenType.Text, "Это "), strongToken, - new Token(TokenType.Text, " текст") + new (TokenType.Text, " текст") }; var result = renderer.Render(tokens); @@ -61,7 +64,6 @@ public void Render_ShouldHandleHeaderTags() { var headerToken = new Token(TokenType.Header, string.Empty); headerToken.Children.Add(new Token(TokenType.Text, "Заголовок")); - var tokens = new List { headerToken }; var result = renderer.Render(tokens); @@ -98,12 +100,11 @@ public void Render_ShouldHandleEmptyTags() { var tokens = new List { - new Token(TokenType.Text, "Это "), - new Token(TokenType.Emphasis, string.Empty), - new Token(TokenType.Text, " текст") + new (TokenType.Text, "Это "), + new (TokenType.Emphasis, string.Empty), + new (TokenType.Text, " текст") }; - var result = renderer.Render(tokens); result.Should().Be("Это текст"); @@ -114,11 +115,11 @@ public void Render_ShouldHandleMultipleTags() { var tokens = new List { - new Token(TokenType.Text, "Это "), - new Token(TokenType.Strong, "полужирный") { Children = { new Token(TokenType.Text, "полужирный") } }, - new Token(TokenType.Text, " и "), - new Token(TokenType.Emphasis, string.Empty) { Children = { new Token(TokenType.Text, "курсив") } }, - new Token(TokenType.Text, " текст.") + new (TokenType.Text, "Это "), + new (TokenType.Strong, "полужирный") { Children = { new Token(TokenType.Text, "полужирный") } }, + new (TokenType.Text, " и "), + new (TokenType.Emphasis, string.Empty) { Children = { new Token(TokenType.Text, "курсив") } }, + new (TokenType.Text, " текст.") }; var result = renderer.Render(tokens); @@ -133,13 +134,13 @@ public void Render_ShouldHandleNestedTagsWithMultipleLevels() innerStrongToken.Children.Add(new Token(TokenType.Text, "полужирный заголовок")); var innerEmphasisToken = new Token(TokenType.Emphasis, string.Empty); - innerEmphasisToken.Children.Add(new Token(TokenType.Text, " полужирный курсив")); + innerEmphasisToken.Children.Add(new Token(TokenType.Text, "полужирный курсив")); var outerHeaderToken = new Token(TokenType.Header, string.Empty); outerHeaderToken.Children.Add(innerStrongToken); var outerStrongToken = new Token(TokenType.Strong, string.Empty); - outerStrongToken.Children.Add(new Token(TokenType.Text, " и ")); + outerStrongToken.Children.Add(new Token(TokenType.Text, "и ")); outerStrongToken.Children.Add(innerEmphasisToken); var tokens = new List @@ -151,6 +152,6 @@ public void Render_ShouldHandleNestedTagsWithMultipleLevels() var result = renderer.Render(tokens); result.Should().Be("

полужирный заголовок

" + - " и полужирный курсив"); + "и полужирный курсив"); } } \ No newline at end of file diff --git a/cs/MarkdownTests/MarkdownParser_Should.cs b/cs/MarkdownTests/MarkdownParser_Should.cs index 547d0355b..9dac2200e 100644 --- a/cs/MarkdownTests/MarkdownParser_Should.cs +++ b/cs/MarkdownTests/MarkdownParser_Should.cs @@ -8,215 +8,251 @@ namespace MarkdownTests; [TestFixture] public class MarkdownParser_Should { - private MarkdownParser parser = new(); - [Test] - public void MarkdownParser_ShouldParse_WhenItalicTag() - { - var tokens = parser - .ParseTokens("Это _курсив_ текст").ToList(); - - tokens.Should().HaveCount(3); - tokens[0].Type.Should().Be(TokenType.Text); - tokens[0].Content.Should().Be("Это "); - tokens[1].Type.Should().Be(TokenType.Emphasis); - tokens[1].Children[0].Content.Should().Be("курсив"); - tokens[2].Type.Should().Be(TokenType.Text); - tokens[2].Content.Should().Be(" текст"); - } + private readonly MarkdownParser parser = new (); - [Test] - public void MarkdownParser_ShouldParse_WhenStrongTag() + public static IEnumerable TokenParsingTestCases() { - var tokens = parser - .ParseTokens("Это __полужирный__ текст").ToList(); - - tokens.Should().HaveCount(3); - tokens[0].Type.Should().Be(TokenType.Text); - tokens[0].Content.Should().Be("Это "); - tokens[1].Type.Should().Be(TokenType.Strong); - tokens[1].Children[0].Type.Should().Be(TokenType.Text); - tokens[1].Children[0].Content.Should().Be("полужирный"); - tokens[2].Type.Should().Be(TokenType.Text); - tokens[2].Content.Should().Be(" текст"); - } + yield return new TestCaseData( + "Это _курсив_ текст", + new List + { + new (TokenType.Text, "Это "), + new (TokenType.Emphasis, children: new List + { + new (TokenType.Text, "курсив") + }), + new (TokenType.Text, " текст") + }).SetName("ShouldParse_WhenItalicTag"); - [Test] - public void MarkdownParser_ShouldParse_WhenHeaderTag() - { - var tokens = parser - .ParseTokens("# Заголовок").ToList(); + yield return new TestCaseData( + "Это __полужирный__ текст", + new List + { + new (TokenType.Text, "Это "), + new (TokenType.Strong, children: new List + { + new (TokenType.Text, "полужирный") + }), + new (TokenType.Text, " текст") + }).SetName("ShouldParse_WhenStrongTag"); - tokens.Should().HaveCount(1); - tokens[0].Type.Should().Be(TokenType.Header); - tokens[0].Children[0].Content.Should().Be("Заголовок"); - } + yield return new TestCaseData( + "# Заголовок", + new List + { + new (TokenType.Header, children: new List + { + new (TokenType.Text, "Заголовок") + }) + }).SetName("ShouldParse_WhenHeaderTag"); - [Test] - public void MarkdownParser_ShouldParse_WhenEscaping() - { - var tokens = parser - .ParseTokens(@"Экранированный \_символ\_").ToList(); + yield return new TestCaseData( + "Это __жирный _и курсивный_ текст__", + new List + { + new (TokenType.Text, "Это "), + new (TokenType.Strong, children: new List + { + new (TokenType.Text, "жирный "), + new (TokenType.Emphasis, children: new List + { + new (TokenType.Text, "и курсивный") + }), + new (TokenType.Text, " текст") + }) + }).SetName("ShouldParse_WhenNestedItalicAndStrongTags"); - tokens.Should().HaveCount(1); - tokens[0].Type.Should().Be(TokenType.Text); - tokens[0].Content.Should().Be("Экранированный _символ_"); - } + yield return new TestCaseData( + "Это _курсив_,а это __жирный__ текст.", + new List + { + new (TokenType.Text, "Это "), + new (TokenType.Emphasis, children: new List + { + new (TokenType.Text, "курсив") + }), + new (TokenType.Text, ",а это "), + new (TokenType.Strong, children: new List + { + new (TokenType.Text, "жирный") + }), + new (TokenType.Text, " текст.") + }).SetName("ShouldParse_WhenMultipleTokensInLine"); - [Test] - public void MarkdownParser_ShouldParse_WhenNestedItalicAndStrongTags() - { - var tokens = parser - .ParseTokens("Это __жирный _и курсивный_ текст__").ToList(); - - tokens.Should().HaveCount(2); - tokens[0].Type.Should().Be(TokenType.Text); - tokens[0].Content.Should().Be("Это "); - tokens[1].Type.Should().Be(TokenType.Strong); - tokens[1].Children.Should().HaveCount(3); - tokens[1].Children[0].Type.Should().Be(TokenType.Text); - tokens[1].Children[0].Content.Should().Be("жирный "); - tokens[1].Children[1].Type.Should().Be(TokenType.Emphasis); - tokens[1].Children[1].Children[0].Type.Should().Be(TokenType.Text); - tokens[1].Children[1].Children[0].Content.Should().Be("и курсивный"); - tokens[1].Children[2].Type.Should().Be(TokenType.Text); - tokens[1].Children[2].Content.Should().Be(" текст"); - } + yield return new TestCaseData( + "en_d._,mi__dd__le", + new List + { + new (TokenType.Text, "en"), + new (TokenType.Emphasis, children: new List + { + new (TokenType.Text, "d.") + }), + new (TokenType.Text, ",mi"), + new (TokenType.Strong, children: new List + { + new (TokenType.Text, "dd") + }), + new (TokenType.Text, "le") + }).SetName("ShouldParse_WhenBoundedTagsInOneWord"); - [Test] - public void MarkdownParser_ShouldParse_WhenMultipleTokensInLine() - { - var tokens = parser - .ParseTokens("Это _курсив_, а это __жирный__ текст.").ToList(); - - tokens.Should().HaveCount(5); - tokens[0].Type.Should().Be(TokenType.Text); - tokens[0].Content.Should().Be("Это "); - tokens[1].Type.Should().Be(TokenType.Emphasis); - tokens[1].Children[0].Content.Should().Be("курсив"); - tokens[2].Type.Should().Be(TokenType.Text); - tokens[2].Content.Should().Be(", а это "); - tokens[3].Type.Should().Be(TokenType.Strong); - tokens[3].Children[0].Content.Should().Be("жирный"); - tokens[4].Type.Should().Be(TokenType.Text); - tokens[4].Content.Should().Be(" текст."); - } + yield return new TestCaseData( + @"Экранированный \_символ\_", + new List + { + new (TokenType.Text, "Экранированный _символ_") + }).SetName("ShouldParse_WhenEscapedTags"); - [Test] - public void MarkdownParser_ShouldNotParse_WhenEscapingSymbols() - { - var tokens = parser - .ParseTokens(@"Здесь сим\волы экранирования\ \должны остаться.\").ToList(); + yield return new TestCaseData( + "Это __двойное _и одинарное_ выделение__", + new List + { + new (TokenType.Text, "Это "), + new (TokenType.Strong, children: new List + { + new (TokenType.Text, "двойное "), + new (TokenType.Emphasis, children: new List + { + new (TokenType.Text, "и одинарное") + }), + new (TokenType.Text, " выделение") + }) + }).SetName("ShouldParse_WhenItalicInStrong"); - tokens.Should().HaveCount(1); - tokens[0].Type.Should().Be(TokenType.Text); - tokens[0].Content.Should().Be(@"Здесь сим\волы экранирования\ \должны остаться.\"); - } + yield return new TestCaseData( + "# Заголовок __с _разными_ символами__", + new List + { + new (TokenType.Header, children: new List + { + new (TokenType.Text, "Заголовок "), + new (TokenType.Strong, children: new List + { + new (TokenType.Text, "с "), + new (TokenType.Emphasis, children: new List + { + new (TokenType.Text, "разными") + }), + new (TokenType.Text, " символами") + }) + }) + }).SetName("ShouldParse_WhenHeaderWithTags"); - [Test] - public void MarkdownParser_ShouldParse_WhenEscapedTags() - { - var tokens = parser - .ParseTokens(@"\\_вот это будет выделено тегом_").ToList(); + yield return new TestCaseData( + "# Заголовок 1\n# Заголовок 2", + new List + { + new (TokenType.Header, children: new List + { + new (TokenType.Text, "Заголовок 1") + }), + new (TokenType.Text, "\n"), + new (TokenType.Header, children: new List + { + new (TokenType.Text, "Заголовок 2") + }) + }).SetName("ShouldParse_WhenMultipleHeaders"); - tokens.Should().HaveCount(1); - tokens[0].Type.Should().Be(TokenType.Emphasis); - tokens[0].Children[0].Content.Should().Be("вот это будет выделено тегом"); - } + yield return new TestCaseData( + "Если пустая _______ строка", + new List + { + new (TokenType.Text, "Если пустая _______ строка") + }).SetName("ShouldNotParse_WhenEmptyEmphasis"); - [Test] - public void MarkdownParser_ShouldParse_WhenNestedItalicAndStrongCorrectly() - { - var tokens = parser - .ParseTokens("Это __двойное _и одинарное_ выделение__").ToList(); - - tokens.Should().HaveCount(2); - tokens[0].Type.Should().Be(TokenType.Text); - tokens[0].Content.Should().Be("Это "); - tokens[1].Type.Should().Be(TokenType.Strong); - tokens[1].Children.Should().HaveCount(3); - tokens[1].Children[0].Type.Should().Be(TokenType.Text); - tokens[1].Children[0].Content.Should().Be("двойное "); - tokens[1].Children[1].Type.Should().Be(TokenType.Emphasis); - tokens[1].Children[1].Children[0].Type.Should().Be(TokenType.Text); - tokens[1].Children[1].Children[0].Content.Should().Be("и одинарное"); - tokens[1].Children[2].Type.Should().Be(TokenType.Text); - tokens[1].Children[2].Content.Should().Be(" выделение"); - } + yield return new TestCaseData( + "Текст с цифрами_12_3 не должен выделяться", + new List + { + new (TokenType.Text, "Текст с цифрами_12_3 не должен выделяться") + }).SetName("ShouldNotParse_WhenUnderscoresInNumbers"); - [Test] - public void MarkdownParser_ShouldParse_WhenHeaderWithTags() - { - var tokens = parser - .ParseTokens("# Заголовок __с _разными_ символами__").ToList(); - - tokens.Should().HaveCount(1); - tokens[0].Type.Should().Be(TokenType.Header); - tokens[0].Children.Should().HaveCount(2); - tokens[0].Children[0].Type.Should().Be(TokenType.Text); - tokens[0].Children[0].Content.Should().Be("Заголовок "); - tokens[0].Children[1].Type.Should().Be(TokenType.Strong); - tokens[0].Children[1].Children[0].Type.Should().Be(TokenType.Text); - tokens[0].Children[1].Children[0].Content.Should().Be("с "); - tokens[0].Children[1].Children[1].Children[0].Type.Should().Be(TokenType.Text); - tokens[0].Children[1].Children[1].Children[0].Content.Should().Be("разными"); - tokens[0].Children[1].Children[2].Type.Should().Be(TokenType.Text); - tokens[0].Children[1].Children[2].Content.Should().Be(" символами"); - } + yield return new TestCaseData( + @"Здесь сим\волы экранирования\ \должны остаться.\", + new List + { + new (TokenType.Text, @"Здесь сим\волы экранирования\ \должны остаться.\") + }).SetName("ShouldNotParse_WhenEscapingSymbols"); - [Test] - public void MarkdownParser_ShouldNotParse_WhenEmptyEmphasis() - { - var tokens = parser - .ParseTokens("Если пустая _______ строка").ToList(); + yield return new TestCaseData( + @"\\_вот это будет выделено тегом_", + new List + { + new (TokenType.Emphasis, children: new List + { + new (TokenType.Text, "вот это будет выделено тегом") + }) + }).SetName("ShouldNotParse_WhenEscapedYourself"); - tokens.Should().HaveCount(1); - tokens[0].Type.Should().Be(TokenType.Text); - tokens[0].Content.Should().Be("Если пустая _______ строка"); - } + yield return new TestCaseData( + "и в нач_але_,и в сер__еди__не", + new List + { + new (TokenType.Text, "и в нач"), + new (TokenType.Emphasis, children: new List + { + new (TokenType.Text, "але") + }), + new (TokenType.Text, ",и в сер"), + new (TokenType.Strong, children: new List + { + new (TokenType.Text, "еди") + }), + new (TokenType.Text, "не") + }).SetName("ShouldParse_WhenTagsInSimilarWord"); - [Test] - public void MarkdownParser_ShouldParse_WhenMultipleHeaders() - { - var tokens = parser - .ParseTokens("# Заголовок 1\n# Заголовок 2").ToList(); - - tokens.Should().HaveCount(3); - tokens[0].Type.Should().Be(TokenType.Header); - tokens[0].Children[0].Content.Should().Be("Заголовок 1"); - tokens[2].Type.Should().Be(TokenType.Header); - tokens[2].Children[0].Content.Should().Be("Заголовок 2"); - } + yield return new TestCaseData( + "Это пер_вый в_торой пример.", + new List + { + new (TokenType.Text, "Это пер_вый в_торой пример.") + }).SetName("ShouldNotParse_WhenTagInDifferentWords"); - [Test] - public void MarkdownParser_ShouldNotParse_WhenUnderscoresInNumbers() - { - var tokens = parser - .ParseTokens("Текст с цифрами_12_3 не должен выделяться").ToList(); + yield return new TestCaseData( + "_e __e", + new List + { + new (TokenType.Text, "_e __e") + }).SetName("ShouldNotParse_WhenUnclosedTags"); + + yield return new TestCaseData( + "_e __s e_ s__", + new List + { + new (TokenType.Text, "_e __s e_ s__") + }).SetName("ShouldNotParse_WhenTagsIntersection"); - tokens.Should().HaveCount(1); - tokens[0].Type.Should().Be(TokenType.Text); - tokens[0].Content.Should().Be("Текст с цифрами_12_3 не должен выделяться"); + yield return new TestCaseData( + "__s \n s__,_e \r\n e_", + new List + { + new (TokenType.Text, "__s \n s__,_e \r\n e_") + }).SetName("ShouldNotParse_WhenTagsIntersectionNewLines"); } - [Test] - public void MarkdownParser_ShouldNotParse_WhenTagsInWords() + [TestCaseSource(nameof(TokenParsingTestCases))] + public void MarkdownParser_ShouldParseTokens(string input, List expectedTokens) { - var tokens = parser - .ParseTokens("и в _нач_але, и в сер_еди_не").ToList(); - - tokens.Should().HaveCount(1); - tokens[0].Type.Should().Be(TokenType.Text); - tokens[0].Content.Should().Be("и в _нач_але, и в сер_еди_не"); + var actualTokens = parser.ParseTokens(input).ToList(); + CompareTokens(expectedTokens, actualTokens); } - [Test] - public void MarkdownParser_ShouldNotParse_WhenDifferentWords() + private void CompareTokens(IReadOnlyList expected, IReadOnlyList actual) { - var tokens = parser - .ParseTokens("Это пер_вый в_торой пример.").ToList(); + actual.Should().HaveCount(expected.Count, "Количество токенов должно совпадать"); + for (int i = 0; i < expected.Count; i++) + { + actual[i].Type.Should().Be(expected[i].Type, $"Тип токена на позиции {i} должен совпадать"); + actual[i].Content.Should().Be(expected[i].Content, $"Содержимое токена на позиции {i} должно совпадать"); - tokens.Should().HaveCount(1); - tokens[0].Type.Should().Be(TokenType.Text); - tokens[0].Content.Should().Be("Это пер_вый в_торой пример."); + if (expected[i].Children.Any()) + { + CompareTokens(expected[i].Children, actual[i].Children); + } + else + { + actual[i].Children.Should().BeNullOrEmpty($"Токен на позиции {i} не должен иметь дочерних элементов"); + } + } } } \ No newline at end of file diff --git a/cs/MarkdownTests/Md_Should.cs b/cs/MarkdownTests/Md_Should.cs index fa74e103d..5c276a2cd 100644 --- a/cs/MarkdownTests/Md_Should.cs +++ b/cs/MarkdownTests/Md_Should.cs @@ -22,7 +22,6 @@ public void Setup() md = new Md(renderer, parser); } - [Test] public void Md_ShouldThrowArgumentNullException_WhenInputIsNull() { @@ -37,28 +36,36 @@ public void Md_ShouldThrowArgumentNullException_WhenInputIsNull() [TestCase(@"Здесь сим\волы экранирования\ \должны остаться.\", @"Здесь сим\волы экранирования\ \должны остаться.\", TestName = "EscapingSymbols")] - [TestCase("Это н_е_ будет _ вы_деле_но", - "Это н_е_ будет _ вы_деле_но", - TestName = "InvalidItalicTags")] - [TestCase("Это н__е__ будет __ вы__деле__но", - "Это н__е__ будет __ вы__деле__но", - TestName = "InvalidStrongTags")] [TestCase("В ра_зных сл_овах", "В ра_зных сл_овах", - TestName = "TagsInDifferentWords")] - [TestCase("Это текст_с_подчеркиваниями_12_3", - "Это текст_с_подчеркиваниями_12_3", - TestName = "UnderscoresInsideWords")] - [TestCase("Это __непарные_ символы в одном абзаце.", - "Это __непарные_ символы в одном абзаце.", - TestName = "UnclosedTags")] + TestName = "ItalicInDifferentWords")] + [TestCase("В ра__зных сл__овах", + "В ра__зных сл__овах", + TestName = "StrongInDifferentWords")] + [TestCase("Это __непарные _символы в одном абзаце.", + "Это __непарные _символы в одном абзаце.", + TestName = "UnclosedTagsInMiddle")] + [TestCase("_e __e", + "_e __e", + TestName = "UnclosedTagsInStart")] + [TestCase("e_ e__", + "e_ e__", + TestName = "UnclosedTagsInEnd")] [TestCase("Если пустая _______ строка", "Если пустая _______ строка", TestName = "EmptyTags")] + [TestCase("_e __s e_ s__", + "_e __s e_ s__", + TestName = "TagsIntersection")] + [TestCase("__s \n s__, _e \r\n e_", + "__s \n s__, _e \r\n e_", + TestName = "TagsIntersectionNewLines")] + [TestCase("Текст с цифрами_12_3 не должен выделяться", + "Текст с цифрами_12_3 не должен выделяться", + TestName = "UnderscoreInNumbers")] public void Md_ShouldNotRender_When(string input, string expected) { var result = md.Render(input); - result.Should().Be(expected); } @@ -77,9 +84,12 @@ public void Md_ShouldNotRender_When(string input, string expected) [TestCase("Это _курсив с __полужирным__ внутри_", "Это курсив с __полужирным__ внутри", TestName = "StrongInItalic")] - [TestCase(@"Экранированный \_символ\_", + [TestCase(@"Экранированный \_символ\_", "Экранированный _символ_", TestName = "EscapeTag")] + [TestCase("_подчерки _не считаются_", + "_подчерки не считаются", + TestName = "SpaceBeforeEndOfTag")] [TestCase(@"\\_вот это будет выделено тегом_", "вот это будет выделено тегом", TestName = "EscapedYourselfOnStartOfTag")] @@ -89,33 +99,26 @@ public void Md_ShouldNotRender_When(string input, string expected) [TestCase("# Заголовок 1\n# Заголовок 2", "

Заголовок 1

\n

Заголовок 2

", TestName = "MultipleHeaders")] + [TestCase("# h __E _e_ E__ _e_", + "

h E e E e

", + TestName = "LotNestedTags")] [TestCase("# h __s _E _e_ E_ s__ _e_", "

h s E e E s e

", - TestName = "LotNestedTags")] - [TestCase("_e __s e_ s__", - "_e __s e_ s__", - TestName = "TagsIntersection")] + TestName = "LotNestedTagsWithDoubleItalic")] [TestCase("en_d._, mi__dd__le, _sta_rt", "end., middle, start", TestName = "BoundedTagsInOneWord")] - [TestCase("__s \n s__, _e \r\n e_", - "__s \n s__, _e \r\n e_", - TestName = "NewLines")] - [TestCase("_e __e", - "_e __e", - TestName = "UnPairedTags2")] public void Md_ShouldRender_When(string input, string expected) { var result = md.Render(input); - result.Should().Be(expected); } [Test] public void Md_ShouldRenderLargeInputQuickly() { - var largeInput = string.Concat(Enumerable.Repeat("_Пример_ ", 10000)); - var expectedOutput = string.Concat(Enumerable.Repeat("Пример ", 10000)); + var largeInput = string.Concat(Enumerable.Repeat("_Пример_ ", 1000)); + var expectedOutput = string.Concat(Enumerable.Repeat("Пример ", 1000)); var stopwatch = new Stopwatch(); stopwatch.Start(); From 66180e87f8b974bdb9e0ecfd83735302b0be1064 Mon Sep 17 00:00:00 2001 From: Kostornoj-Dmitrij Date: Mon, 9 Dec 2024 19:45:25 +0500 Subject: [PATCH 04/16] Rename Token to BaseToken --- cs/Markdown/Interfaces/IMarkdownParser.cs | 2 +- cs/Markdown/Interfaces/IRenderer.cs | 2 +- cs/Markdown/Interfaces/ITokenConverter.cs | 2 +- cs/Markdown/Parsers/MarkdownParser.cs | 4 +- cs/Markdown/Parsers/MarkdownParserContext.cs | 4 +- cs/Markdown/Renderers/HtmlRenderer.cs | 2 +- .../TokenConverters/EmphasisConverter.cs | 4 +- .../TokenConverters/HeaderConverter.cs | 6 +- .../TokenConverters/StrongConverter.cs | 4 +- cs/Markdown/TokenConverters/TextConverter.cs | 4 +- .../TokenConverters/TokenConverterBase.cs | 6 +- .../TokenHandlers/BoundaryTokenHandler.cs | 2 +- .../TokenHandlers/HeaderTokenHandler.cs | 2 +- cs/Markdown/Tokens/{Token.cs => BaseToken.cs} | 10 +-- cs/Markdown/Tokens/TokenType.cs | 3 +- cs/MarkdownTests/HtmlRenderer_Should.cs | 58 +++++++------- cs/MarkdownTests/MarkdownParser_Should.cs | 80 +++++++++---------- 17 files changed, 98 insertions(+), 97 deletions(-) rename cs/Markdown/Tokens/{Token.cs => BaseToken.cs} (51%) diff --git a/cs/Markdown/Interfaces/IMarkdownParser.cs b/cs/Markdown/Interfaces/IMarkdownParser.cs index cb73bbfaf..3fa469746 100644 --- a/cs/Markdown/Interfaces/IMarkdownParser.cs +++ b/cs/Markdown/Interfaces/IMarkdownParser.cs @@ -4,5 +4,5 @@ namespace Markdown.Interfaces; public interface IMarkdownParser { - IEnumerable ParseTokens(string markdownText); + IEnumerable ParseTokens(string markdownText); } \ No newline at end of file diff --git a/cs/Markdown/Interfaces/IRenderer.cs b/cs/Markdown/Interfaces/IRenderer.cs index 20715e3df..5fc2c21e6 100644 --- a/cs/Markdown/Interfaces/IRenderer.cs +++ b/cs/Markdown/Interfaces/IRenderer.cs @@ -4,5 +4,5 @@ namespace Markdown.Interfaces; public interface IRenderer { - string Render(IEnumerable tokens); + string Render(IEnumerable tokens); } \ No newline at end of file diff --git a/cs/Markdown/Interfaces/ITokenConverter.cs b/cs/Markdown/Interfaces/ITokenConverter.cs index ec09219ed..1d4ce4595 100644 --- a/cs/Markdown/Interfaces/ITokenConverter.cs +++ b/cs/Markdown/Interfaces/ITokenConverter.cs @@ -5,5 +5,5 @@ namespace Markdown.Interfaces; public interface ITokenConverter { - void Render(Token token, StringBuilder result); + void Render(BaseToken baseToken, StringBuilder result); } \ No newline at end of file diff --git a/cs/Markdown/Parsers/MarkdownParser.cs b/cs/Markdown/Parsers/MarkdownParser.cs index 27da1760d..b18f6059c 100644 --- a/cs/Markdown/Parsers/MarkdownParser.cs +++ b/cs/Markdown/Parsers/MarkdownParser.cs @@ -15,7 +15,7 @@ public class MarkdownParser : IMarkdownParser new EscapeCharacterHandler() ]; - public IEnumerable ParseTokens(string markdownText) + public IEnumerable ParseTokens(string markdownText) { ArgumentNullException.ThrowIfNull(markdownText); @@ -51,7 +51,7 @@ public IEnumerable ParseTokens(string markdownText) public static void AddToken(MarkdownParseContext context, TokenType type) { if (context.Buffer.Length == 0) return; - var token = new Token(type, context.Buffer.ToString()); + var token = new BaseToken(type, context.Buffer.ToString()); context.Buffer.Clear(); if (context.Stack.Count > 0) diff --git a/cs/Markdown/Parsers/MarkdownParserContext.cs b/cs/Markdown/Parsers/MarkdownParserContext.cs index c62971d2f..6b696352e 100644 --- a/cs/Markdown/Parsers/MarkdownParserContext.cs +++ b/cs/Markdown/Parsers/MarkdownParserContext.cs @@ -6,9 +6,9 @@ namespace Markdown.Parsers; public class MarkdownParseContext { - public Stack Stack { get; } = new(); + public Stack Stack { get; } = new(); public StringBuilder Buffer { get; } = new(); - public List Tokens { get; } = []; + public List Tokens { get; } = []; public List IntersectedIndexes { get; } = []; public string MarkdownText { get; init; } = ""; public int CurrentIndex { get; set; } diff --git a/cs/Markdown/Renderers/HtmlRenderer.cs b/cs/Markdown/Renderers/HtmlRenderer.cs index 7bdae8efb..133442a8f 100644 --- a/cs/Markdown/Renderers/HtmlRenderer.cs +++ b/cs/Markdown/Renderers/HtmlRenderer.cs @@ -7,7 +7,7 @@ namespace Markdown.Renderers; public class HtmlRenderer : IRenderer { - public string Render(IEnumerable tokens) + public string Render(IEnumerable tokens) { var result = new StringBuilder(); foreach (var token in tokens) diff --git a/cs/Markdown/TokenConverters/EmphasisConverter.cs b/cs/Markdown/TokenConverters/EmphasisConverter.cs index 76bbfc5c0..c50bbd139 100644 --- a/cs/Markdown/TokenConverters/EmphasisConverter.cs +++ b/cs/Markdown/TokenConverters/EmphasisConverter.cs @@ -5,10 +5,10 @@ namespace Markdown.TokenConverters; public class EmphasisConverter : TokenConverterBase { - public override void Render(Token token, StringBuilder result) + public override void Render(BaseToken baseToken, StringBuilder result) { result.Append(""); - RenderChildren(token, result); + RenderChildren(baseToken, result); result.Append(""); } } \ No newline at end of file diff --git a/cs/Markdown/TokenConverters/HeaderConverter.cs b/cs/Markdown/TokenConverters/HeaderConverter.cs index 53a4a22f3..ee6e212de 100644 --- a/cs/Markdown/TokenConverters/HeaderConverter.cs +++ b/cs/Markdown/TokenConverters/HeaderConverter.cs @@ -5,11 +5,11 @@ namespace Markdown.TokenConverters; public class HeaderConverter : TokenConverterBase { - public override void Render(Token token, StringBuilder result) + public override void Render(BaseToken baseToken, StringBuilder result) { - var level = token.HeaderLevel; + var level = baseToken.HeaderLevel; result.Append($""); - RenderChildren(token, result); + RenderChildren(baseToken, result); result.Append($""); } } \ No newline at end of file diff --git a/cs/Markdown/TokenConverters/StrongConverter.cs b/cs/Markdown/TokenConverters/StrongConverter.cs index 4d9b13bda..cee93ecfc 100644 --- a/cs/Markdown/TokenConverters/StrongConverter.cs +++ b/cs/Markdown/TokenConverters/StrongConverter.cs @@ -5,10 +5,10 @@ namespace Markdown.TokenConverters; public class StrongConverter : TokenConverterBase { - public override void Render(Token token, StringBuilder result) + public override void Render(BaseToken baseToken, StringBuilder result) { result.Append(""); - RenderChildren(token, result); + RenderChildren(baseToken, result); result.Append(""); } } \ No newline at end of file diff --git a/cs/Markdown/TokenConverters/TextConverter.cs b/cs/Markdown/TokenConverters/TextConverter.cs index badbe6f8d..e40fecd09 100644 --- a/cs/Markdown/TokenConverters/TextConverter.cs +++ b/cs/Markdown/TokenConverters/TextConverter.cs @@ -6,8 +6,8 @@ namespace Markdown.TokenConverters; public class TextConverter : ITokenConverter { - public void Render(Token token, StringBuilder result) + public void Render(BaseToken baseToken, StringBuilder result) { - result.Append(token.Content); + result.Append(baseToken.Content); } } \ No newline at end of file diff --git a/cs/Markdown/TokenConverters/TokenConverterBase.cs b/cs/Markdown/TokenConverters/TokenConverterBase.cs index 7ece35dd1..f5a54faa3 100644 --- a/cs/Markdown/TokenConverters/TokenConverterBase.cs +++ b/cs/Markdown/TokenConverters/TokenConverterBase.cs @@ -6,11 +6,11 @@ namespace Markdown.TokenConverters; public abstract class TokenConverterBase : ITokenConverter { - public abstract void Render(Token token, StringBuilder result); + public abstract void Render(BaseToken baseToken, StringBuilder result); - protected static void RenderChildren(Token token, StringBuilder result) + protected static void RenderChildren(BaseToken baseToken, StringBuilder result) { - foreach (var child in token.Children) + foreach (var child in baseToken.Children) { var converter = TokenConverterFactory.GetConverter(child.Type); converter.Render(child, result); diff --git a/cs/Markdown/TokenHandlers/BoundaryTokenHandler.cs b/cs/Markdown/TokenHandlers/BoundaryTokenHandler.cs index 857efd11d..9c04ea602 100644 --- a/cs/Markdown/TokenHandlers/BoundaryTokenHandler.cs +++ b/cs/Markdown/TokenHandlers/BoundaryTokenHandler.cs @@ -126,7 +126,7 @@ private void HandleTokenBoundary(MarkdownParseContext context) } else { - var newToken = new Token(TokenType); + var newToken = new BaseToken(TokenType); context.Stack.Push(newToken); } diff --git a/cs/Markdown/TokenHandlers/HeaderTokenHandler.cs b/cs/Markdown/TokenHandlers/HeaderTokenHandler.cs index 68191c904..6cd628abd 100644 --- a/cs/Markdown/TokenHandlers/HeaderTokenHandler.cs +++ b/cs/Markdown/TokenHandlers/HeaderTokenHandler.cs @@ -25,7 +25,7 @@ public void Handle(MarkdownParseContext context) context.CurrentIndex++; MarkdownParser.AddToken(context, TokenType.Text); - var headerToken = new Token(TokenType.Header) + var headerToken = new BaseToken(TokenType.Header) { HeaderLevel = context.HeaderLevel }; diff --git a/cs/Markdown/Tokens/Token.cs b/cs/Markdown/Tokens/BaseToken.cs similarity index 51% rename from cs/Markdown/Tokens/Token.cs rename to cs/Markdown/Tokens/BaseToken.cs index 3132ce78c..7223dc66e 100644 --- a/cs/Markdown/Tokens/Token.cs +++ b/cs/Markdown/Tokens/BaseToken.cs @@ -1,13 +1,13 @@ namespace Markdown.Tokens; -public class Token +public class BaseToken { public TokenType Type { get; } public string Content { get; set; } - public List Children { get; init; } + public List Children { get; init; } public int HeaderLevel { get; init; } - public Token(TokenType type, string content, List? children = null) + public BaseToken(TokenType type, string content, List? children = null) { Type = type; Content = content; @@ -15,7 +15,7 @@ public Token(TokenType type, string content, List? children = null) HeaderLevel = 1; } - public Token(TokenType type) : this(type, string.Empty) { } - public Token(TokenType type, List? children = null) + public BaseToken(TokenType type) : this(type, string.Empty) { } + public BaseToken(TokenType type, List? children = null) : this(type, string.Empty, children) { } } \ No newline at end of file diff --git a/cs/Markdown/Tokens/TokenType.cs b/cs/Markdown/Tokens/TokenType.cs index 15f9d2469..537d7e0c6 100644 --- a/cs/Markdown/Tokens/TokenType.cs +++ b/cs/Markdown/Tokens/TokenType.cs @@ -5,5 +5,6 @@ public enum TokenType Text, Emphasis, Strong, - Header + Header, + Link } \ No newline at end of file diff --git a/cs/MarkdownTests/HtmlRenderer_Should.cs b/cs/MarkdownTests/HtmlRenderer_Should.cs index 9c6c9bf70..553753bcd 100644 --- a/cs/MarkdownTests/HtmlRenderer_Should.cs +++ b/cs/MarkdownTests/HtmlRenderer_Should.cs @@ -13,7 +13,7 @@ public class HtmlRenderer_Should [Test] public void Render_ShouldHandleTextWithoutTags() { - var tokens = new List + var tokens = new List { new (TokenType.Text, "Текст без тегов.") }; @@ -26,11 +26,11 @@ public void Render_ShouldHandleTextWithoutTags() [Test] public void Render_ShouldHandleEmphasisTags() { - var tokens = new List + var tokens = new List { new (TokenType.Text, "Это "), new (TokenType.Emphasis, "курсив") - { Children = new List + { Children = new List { new (TokenType.Text, "курсив") } }, new (TokenType.Text, " текст") @@ -44,10 +44,10 @@ public void Render_ShouldHandleEmphasisTags() [Test] public void Render_ShoulHandleStrongTags() { - var strongToken = new Token(TokenType.Strong, string.Empty); - strongToken.Children.Add(new Token(TokenType.Text, "полужирный")); + var strongToken = new BaseToken(TokenType.Strong, string.Empty); + strongToken.Children.Add(new BaseToken(TokenType.Text, "полужирный")); - var tokens = new List + var tokens = new List { new (TokenType.Text, "Это "), strongToken, @@ -62,9 +62,9 @@ public void Render_ShoulHandleStrongTags() [Test] public void Render_ShouldHandleHeaderTags() { - var headerToken = new Token(TokenType.Header, string.Empty); - headerToken.Children.Add(new Token(TokenType.Text, "Заголовок")); - var tokens = new List { headerToken }; + var headerToken = new BaseToken(TokenType.Header, string.Empty); + headerToken.Children.Add(new BaseToken(TokenType.Text, "Заголовок")); + var tokens = new List { headerToken }; var result = renderer.Render(tokens); @@ -74,18 +74,18 @@ public void Render_ShouldHandleHeaderTags() [Test] public void Render_ShouldHandleNestedTags() { - var headToken = new Token(TokenType.Header, string.Empty); - var strongToken = new Token(TokenType.Strong, string.Empty); - var emphasisToken = new Token(TokenType.Emphasis, string.Empty); + var headToken = new BaseToken(TokenType.Header, string.Empty); + var strongToken = new BaseToken(TokenType.Strong, string.Empty); + var emphasisToken = new BaseToken(TokenType.Emphasis, string.Empty); - emphasisToken.Children.Add(new Token(TokenType.Text, "курсивом")); - strongToken.Children.Add(new Token(TokenType.Text, "полужирным текстом с ")); + emphasisToken.Children.Add(new BaseToken(TokenType.Text, "курсивом")); + strongToken.Children.Add(new BaseToken(TokenType.Text, "полужирным текстом с ")); strongToken.Children.Add(emphasisToken); - headToken.Children.Add(new Token(TokenType.Text, "заголовок с ")); + headToken.Children.Add(new BaseToken(TokenType.Text, "заголовок с ")); headToken.Children.Add(strongToken); - var tokens = new List + var tokens = new List { - new Token(TokenType.Text, "Это "), + new BaseToken(TokenType.Text, "Это "), headToken }; @@ -98,7 +98,7 @@ public void Render_ShouldHandleNestedTags() [Test] public void Render_ShouldHandleEmptyTags() { - var tokens = new List + var tokens = new List { new (TokenType.Text, "Это "), new (TokenType.Emphasis, string.Empty), @@ -113,12 +113,12 @@ public void Render_ShouldHandleEmptyTags() [Test] public void Render_ShouldHandleMultipleTags() { - var tokens = new List + var tokens = new List { new (TokenType.Text, "Это "), - new (TokenType.Strong, "полужирный") { Children = { new Token(TokenType.Text, "полужирный") } }, + new (TokenType.Strong, "полужирный") { Children = { new BaseToken(TokenType.Text, "полужирный") } }, new (TokenType.Text, " и "), - new (TokenType.Emphasis, string.Empty) { Children = { new Token(TokenType.Text, "курсив") } }, + new (TokenType.Emphasis, string.Empty) { Children = { new BaseToken(TokenType.Text, "курсив") } }, new (TokenType.Text, " текст.") }; @@ -130,20 +130,20 @@ public void Render_ShouldHandleMultipleTags() [Test] public void Render_ShouldHandleNestedTagsWithMultipleLevels() { - var innerStrongToken = new Token(TokenType.Strong, string.Empty); - innerStrongToken.Children.Add(new Token(TokenType.Text, "полужирный заголовок")); + var innerStrongToken = new BaseToken(TokenType.Strong, string.Empty); + innerStrongToken.Children.Add(new BaseToken(TokenType.Text, "полужирный заголовок")); - var innerEmphasisToken = new Token(TokenType.Emphasis, string.Empty); - innerEmphasisToken.Children.Add(new Token(TokenType.Text, "полужирный курсив")); + var innerEmphasisToken = new BaseToken(TokenType.Emphasis, string.Empty); + innerEmphasisToken.Children.Add(new BaseToken(TokenType.Text, "полужирный курсив")); - var outerHeaderToken = new Token(TokenType.Header, string.Empty); + var outerHeaderToken = new BaseToken(TokenType.Header, string.Empty); outerHeaderToken.Children.Add(innerStrongToken); - var outerStrongToken = new Token(TokenType.Strong, string.Empty); - outerStrongToken.Children.Add(new Token(TokenType.Text, "и ")); + var outerStrongToken = new BaseToken(TokenType.Strong, string.Empty); + outerStrongToken.Children.Add(new BaseToken(TokenType.Text, "и ")); outerStrongToken.Children.Add(innerEmphasisToken); - var tokens = new List + var tokens = new List { outerHeaderToken, outerStrongToken, diff --git a/cs/MarkdownTests/MarkdownParser_Should.cs b/cs/MarkdownTests/MarkdownParser_Should.cs index 9dac2200e..92031ef7e 100644 --- a/cs/MarkdownTests/MarkdownParser_Should.cs +++ b/cs/MarkdownTests/MarkdownParser_Should.cs @@ -14,10 +14,10 @@ public static IEnumerable TokenParsingTestCases() { yield return new TestCaseData( "Это _курсив_ текст", - new List + new List { new (TokenType.Text, "Это "), - new (TokenType.Emphasis, children: new List + new (TokenType.Emphasis, children: new List { new (TokenType.Text, "курсив") }), @@ -26,10 +26,10 @@ public static IEnumerable TokenParsingTestCases() yield return new TestCaseData( "Это __полужирный__ текст", - new List + new List { new (TokenType.Text, "Это "), - new (TokenType.Strong, children: new List + new (TokenType.Strong, children: new List { new (TokenType.Text, "полужирный") }), @@ -38,9 +38,9 @@ public static IEnumerable TokenParsingTestCases() yield return new TestCaseData( "# Заголовок", - new List + new List { - new (TokenType.Header, children: new List + new (TokenType.Header, children: new List { new (TokenType.Text, "Заголовок") }) @@ -48,13 +48,13 @@ public static IEnumerable TokenParsingTestCases() yield return new TestCaseData( "Это __жирный _и курсивный_ текст__", - new List + new List { new (TokenType.Text, "Это "), - new (TokenType.Strong, children: new List + new (TokenType.Strong, children: new List { new (TokenType.Text, "жирный "), - new (TokenType.Emphasis, children: new List + new (TokenType.Emphasis, children: new List { new (TokenType.Text, "и курсивный") }), @@ -64,15 +64,15 @@ public static IEnumerable TokenParsingTestCases() yield return new TestCaseData( "Это _курсив_,а это __жирный__ текст.", - new List + new List { new (TokenType.Text, "Это "), - new (TokenType.Emphasis, children: new List + new (TokenType.Emphasis, children: new List { new (TokenType.Text, "курсив") }), new (TokenType.Text, ",а это "), - new (TokenType.Strong, children: new List + new (TokenType.Strong, children: new List { new (TokenType.Text, "жирный") }), @@ -81,15 +81,15 @@ public static IEnumerable TokenParsingTestCases() yield return new TestCaseData( "en_d._,mi__dd__le", - new List + new List { new (TokenType.Text, "en"), - new (TokenType.Emphasis, children: new List + new (TokenType.Emphasis, children: new List { new (TokenType.Text, "d.") }), new (TokenType.Text, ",mi"), - new (TokenType.Strong, children: new List + new (TokenType.Strong, children: new List { new (TokenType.Text, "dd") }), @@ -98,20 +98,20 @@ public static IEnumerable TokenParsingTestCases() yield return new TestCaseData( @"Экранированный \_символ\_", - new List + new List { new (TokenType.Text, "Экранированный _символ_") }).SetName("ShouldParse_WhenEscapedTags"); yield return new TestCaseData( "Это __двойное _и одинарное_ выделение__", - new List + new List { new (TokenType.Text, "Это "), - new (TokenType.Strong, children: new List + new (TokenType.Strong, children: new List { new (TokenType.Text, "двойное "), - new (TokenType.Emphasis, children: new List + new (TokenType.Emphasis, children: new List { new (TokenType.Text, "и одинарное") }), @@ -121,15 +121,15 @@ public static IEnumerable TokenParsingTestCases() yield return new TestCaseData( "# Заголовок __с _разными_ символами__", - new List + new List { - new (TokenType.Header, children: new List + new (TokenType.Header, children: new List { new (TokenType.Text, "Заголовок "), - new (TokenType.Strong, children: new List + new (TokenType.Strong, children: new List { new (TokenType.Text, "с "), - new (TokenType.Emphasis, children: new List + new (TokenType.Emphasis, children: new List { new (TokenType.Text, "разными") }), @@ -140,14 +140,14 @@ public static IEnumerable TokenParsingTestCases() yield return new TestCaseData( "# Заголовок 1\n# Заголовок 2", - new List + new List { - new (TokenType.Header, children: new List + new (TokenType.Header, children: new List { new (TokenType.Text, "Заголовок 1") }), new (TokenType.Text, "\n"), - new (TokenType.Header, children: new List + new (TokenType.Header, children: new List { new (TokenType.Text, "Заголовок 2") }) @@ -155,30 +155,30 @@ public static IEnumerable TokenParsingTestCases() yield return new TestCaseData( "Если пустая _______ строка", - new List + new List { new (TokenType.Text, "Если пустая _______ строка") }).SetName("ShouldNotParse_WhenEmptyEmphasis"); yield return new TestCaseData( "Текст с цифрами_12_3 не должен выделяться", - new List + new List { new (TokenType.Text, "Текст с цифрами_12_3 не должен выделяться") }).SetName("ShouldNotParse_WhenUnderscoresInNumbers"); yield return new TestCaseData( @"Здесь сим\волы экранирования\ \должны остаться.\", - new List + new List { new (TokenType.Text, @"Здесь сим\волы экранирования\ \должны остаться.\") }).SetName("ShouldNotParse_WhenEscapingSymbols"); yield return new TestCaseData( @"\\_вот это будет выделено тегом_", - new List + new List { - new (TokenType.Emphasis, children: new List + new (TokenType.Emphasis, children: new List { new (TokenType.Text, "вот это будет выделено тегом") }) @@ -186,15 +186,15 @@ public static IEnumerable TokenParsingTestCases() yield return new TestCaseData( "и в нач_але_,и в сер__еди__не", - new List + new List { new (TokenType.Text, "и в нач"), - new (TokenType.Emphasis, children: new List + new (TokenType.Emphasis, children: new List { new (TokenType.Text, "але") }), new (TokenType.Text, ",и в сер"), - new (TokenType.Strong, children: new List + new (TokenType.Strong, children: new List { new (TokenType.Text, "еди") }), @@ -203,41 +203,41 @@ public static IEnumerable TokenParsingTestCases() yield return new TestCaseData( "Это пер_вый в_торой пример.", - new List + new List { new (TokenType.Text, "Это пер_вый в_торой пример.") }).SetName("ShouldNotParse_WhenTagInDifferentWords"); yield return new TestCaseData( "_e __e", - new List + new List { new (TokenType.Text, "_e __e") }).SetName("ShouldNotParse_WhenUnclosedTags"); yield return new TestCaseData( "_e __s e_ s__", - new List + new List { new (TokenType.Text, "_e __s e_ s__") }).SetName("ShouldNotParse_WhenTagsIntersection"); yield return new TestCaseData( "__s \n s__,_e \r\n e_", - new List + new List { new (TokenType.Text, "__s \n s__,_e \r\n e_") }).SetName("ShouldNotParse_WhenTagsIntersectionNewLines"); } [TestCaseSource(nameof(TokenParsingTestCases))] - public void MarkdownParser_ShouldParseTokens(string input, List expectedTokens) + public void MarkdownParser_ShouldParseTokens(string input, List expectedTokens) { var actualTokens = parser.ParseTokens(input).ToList(); CompareTokens(expectedTokens, actualTokens); } - private void CompareTokens(IReadOnlyList expected, IReadOnlyList actual) + private void CompareTokens(IReadOnlyList expected, IReadOnlyList actual) { actual.Should().HaveCount(expected.Count, "Количество токенов должно совпадать"); for (int i = 0; i < expected.Count; i++) From 74e7dcc9179a23701f255f246782f01acf00085f Mon Sep 17 00:00:00 2001 From: Kostornoj-Dmitrij Date: Mon, 9 Dec 2024 23:02:28 +0500 Subject: [PATCH 05/16] Add tests for link tag --- cs/MarkdownTests/MarkdownParser_Should.cs | 109 ++++++++++++++++++++++ cs/MarkdownTests/Md_Should.cs | 39 ++++++++ 2 files changed, 148 insertions(+) diff --git a/cs/MarkdownTests/MarkdownParser_Should.cs b/cs/MarkdownTests/MarkdownParser_Should.cs index 92031ef7e..cb772137a 100644 --- a/cs/MarkdownTests/MarkdownParser_Should.cs +++ b/cs/MarkdownTests/MarkdownParser_Should.cs @@ -153,6 +153,107 @@ public static IEnumerable TokenParsingTestCases() }) }).SetName("ShouldParse_WhenMultipleHeaders"); + yield return new TestCaseData( + "Это текст с [ссылкой](http://link.com)", + new List + { + new (TokenType.Text, "Это текст с "), + new LinkToken(new List + { + new (TokenType.Text, "ссылкой") + }, "http://link.com") + }).SetName("ShouldParse_WhenSimpleLinkTag"); + + yield return new TestCaseData( + "Это текст с [двумя](http://link1.com) [ссылками](http://link2.com)", + new List + { + new (TokenType.Text, "Это текст с "), + new LinkToken(new List + { + new (TokenType.Text, "двумя") + }, "http://link1.com"), + new (TokenType.Text, " "), + new LinkToken(new List + { + new (TokenType.Text, "ссылками") + }, "http://link2.com") + }).SetName("ShouldParse_WhenSeveralLinkTags"); + + yield return new TestCaseData( + "_[Ссылка](http://link.com) внутри курсива_", + new List + { + new (TokenType.Emphasis, children: new List + { + new LinkToken(new List + { + new (TokenType.Text, "Ссылка") + }, "http://link.com"), + new (TokenType.Text, " внутри курсива") + }) + }).SetName("ShouldParse_WhenLinkInsideItalic"); + + yield return new TestCaseData( + "Это [ссылка с _тегом_](http://link.com)", + new List + { + new (TokenType.Text, "Это "), + new LinkToken(new List + { + new (TokenType.Text, "ссылка с "), + new (TokenType.Emphasis, children: new List + { + new (TokenType.Text, "тегом") + }) + }, "http://link.com") + }).SetName("ShouldParse_WhenLinkWithTagInside"); + + yield return new TestCaseData( + "Пустая ссылка [](http://link.com)", + new List + { + new (TokenType.Text, "Пустая ссылка "), + new LinkToken(new List(), "http://link.com") + }).SetName("ShouldParse_WhenLinkWithEmptyText"); + + yield return new TestCaseData( + "Пустая ссылка []()", + new List + { + new (TokenType.Text, "Пустая ссылка "), + new LinkToken(new List(), "") + }).SetName("ShouldParse_WhenLinkWithEmptyUrl"); + + yield return new TestCaseData( + @"[Ссылка с экранированными \] символами\]](http://link.com)", + new List + { + new LinkToken(new List + { + new (TokenType.Text, "Ссылка с экранированными ] символами]") + }, "http://link.com") + }).SetName("ShouldParse_WhenLinkWithEscapedSymbol"); + + yield return new TestCaseData( + "Это [ссылка с [ссылка с _тегом_](http://link.com)](http://link.com)", + new List + { + new (TokenType.Text, "Это "), + new LinkToken(new List + { + new (TokenType.Text, "ссылка с "), + new LinkToken(new List + { + new (TokenType.Text, "ссылка с "), + new (TokenType.Emphasis, children: new List + { + new (TokenType.Text, "тегом") + }) + }, "http://link.com") + }, "http://link.com") + }).SetName("ShouldParse_WhenLinkInsideLink"); + yield return new TestCaseData( "Если пустая _______ строка", new List @@ -228,6 +329,14 @@ public static IEnumerable TokenParsingTestCases() { new (TokenType.Text, "__s \n s__,_e \r\n e_") }).SetName("ShouldNotParse_WhenTagsIntersectionNewLines"); + + yield return new TestCaseData( + "Текст с [незавершённой ссылкой](http://link.com", + new List + { + new (TokenType.Text, "Текст с "), + new (TokenType.Text, "[незавершённой ссылкой](http://link.com") + }).SetName("ShouldNotParse_WhenUnfinishedLinkTag"); } [TestCaseSource(nameof(TokenParsingTestCases))] diff --git a/cs/MarkdownTests/Md_Should.cs b/cs/MarkdownTests/Md_Should.cs index 5c276a2cd..bab7536ff 100644 --- a/cs/MarkdownTests/Md_Should.cs +++ b/cs/MarkdownTests/Md_Should.cs @@ -63,6 +63,12 @@ public void Md_ShouldThrowArgumentNullException_WhenInputIsNull() [TestCase("Текст с цифрами_12_3 не должен выделяться", "Текст с цифрами_12_3 не должен выделяться", TestName = "UnderscoreInNumbers")] + [TestCase("Текст с [незавершённой ссылкой](http://link.com", + "Текст с [незавершённой ссылкой](http://link.com", + TestName = "UnfinishedUrlInLinkTag")] + [TestCase("Текст с [незавершённой ссылкой(http://link.com)", + "Текст с [незавершённой ссылкой(http://link.com)", + TestName = "UnfinishedTextInLinkTag")] public void Md_ShouldNotRender_When(string input, string expected) { var result = md.Render(input); @@ -108,6 +114,39 @@ public void Md_ShouldNotRender_When(string input, string expected) [TestCase("en_d._, mi__dd__le, _sta_rt", "end., middle, start", TestName = "BoundedTagsInOneWord")] + [TestCase("Это текст с [ссылкой](http://link.com)", + @"Это текст с ссылкой", + TestName = "SimpleLinkTag")] + [TestCase("Это текст с [двумя](http://link1.com) [ссылками](http://link2.com)", + @"Это текст с двумя ссылками", + TestName = "SeveralLinkTags")] + [TestCase("_[Ссылка](http://link.com) внутри курсива_", + @"Ссылка внутри курсива", + TestName = "LinkInsideItalic")] + [TestCase("__[Ссылка](http://link.com) внутри полужирного__", + @"Ссылка внутри полужирного", + TestName = "LinkInsideStrong")] + [TestCase("# [Ссылка](http://link.com)", + @"

Ссылка

", + TestName = "LinkInsideHeader")] + [TestCase("# h __E _e_ [ссылка](http://link.com) E__ _e_", + @"

h E e ссылка E e

", + TestName = "NestedTagsWithLink")] + [TestCase("Это [ссылка с _тегом_](http://link.com)", + @"Это ссылка с тегом", + TestName = "LinkWithTagInside")] + [TestCase("Пустая ссылка [](http://link.com)", + @"Пустая ссылка ", + TestName = "LinkWithEmptyText")] + [TestCase("Пустая ссылка []()", + @"Пустая ссылка ", + TestName = "LinkWithEmptyUrl")] + [TestCase(@"[Ссылка с экранированными \] символами\]](http://link.com)", + @"Ссылка с экранированными ] символами]", + TestName = "LinkWithEscapedSymbol")] + [TestCase("Это [ссылка с [ссылка с _тегом_](http://link.com)](http://link.com)", + @"Это ссылка с ссылка с тегом", + TestName = "LinkInsideLink")] public void Md_ShouldRender_When(string input, string expected) { var result = md.Render(input); From 1889dc556231cf10a20cd4f3090ae001feac45e5 Mon Sep 17 00:00:00 2001 From: Kostornoj-Dmitrij Date: Mon, 9 Dec 2024 23:03:08 +0500 Subject: [PATCH 06/16] Token and converter classes for link tag --- cs/Markdown/TokenConverters/LinkConverter.cs | 16 ++++++++++++++++ cs/Markdown/Tokens/LinkToken.cs | 13 +++++++++++++ 2 files changed, 29 insertions(+) create mode 100644 cs/Markdown/TokenConverters/LinkConverter.cs create mode 100644 cs/Markdown/Tokens/LinkToken.cs diff --git a/cs/Markdown/TokenConverters/LinkConverter.cs b/cs/Markdown/TokenConverters/LinkConverter.cs new file mode 100644 index 000000000..81130430c --- /dev/null +++ b/cs/Markdown/TokenConverters/LinkConverter.cs @@ -0,0 +1,16 @@ +using System.Text; +using Markdown.Tokens; + +namespace Markdown.TokenConverters; + +public class LinkConverter : TokenConverterBase +{ + public override void Render(BaseToken token, StringBuilder result) + { + var linkToken = (LinkToken)token; + + result.Append($""); + RenderChildren(linkToken, result); + result.Append(""); + } +} \ No newline at end of file diff --git a/cs/Markdown/Tokens/LinkToken.cs b/cs/Markdown/Tokens/LinkToken.cs new file mode 100644 index 000000000..1036d80f7 --- /dev/null +++ b/cs/Markdown/Tokens/LinkToken.cs @@ -0,0 +1,13 @@ +namespace Markdown.Tokens; + +public class LinkToken : BaseToken +{ + public string Url { get; } + + public LinkToken(IEnumerable labelTokens, string url) + : base(TokenType.Link) + { + Url = url; + Children.AddRange(labelTokens); + } +} \ No newline at end of file From 3ee51780669fb8e56f54f416d297945bfd1fdc99 Mon Sep 17 00:00:00 2001 From: Kostornoj-Dmitrij Date: Mon, 9 Dec 2024 23:05:02 +0500 Subject: [PATCH 07/16] Update tokenConverterFactory with LinkConverter --- cs/Markdown/TokenConverters/TokenConverterFactory.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/cs/Markdown/TokenConverters/TokenConverterFactory.cs b/cs/Markdown/TokenConverters/TokenConverterFactory.cs index 363ed68db..9b635f984 100644 --- a/cs/Markdown/TokenConverters/TokenConverterFactory.cs +++ b/cs/Markdown/TokenConverters/TokenConverterFactory.cs @@ -26,6 +26,7 @@ public static ITokenConverter GetConverter(TokenType type) TokenType.Emphasis => new EmphasisConverter(), TokenType.Strong => new StrongConverter(), TokenType.Header => new HeaderConverter(), + TokenType.Link => new LinkConverter(), _ => throw new ArgumentOutOfRangeException() }; } From 33092edc903f26ae2f9ca7be53c618a304297b31 Mon Sep 17 00:00:00 2001 From: Kostornoj-Dmitrij Date: Mon, 9 Dec 2024 23:05:25 +0500 Subject: [PATCH 08/16] Add handler for link token and update MarkdownParser with it --- cs/Markdown/Parsers/MarkdownParser.cs | 3 +- cs/Markdown/TokenHandlers/LinkTokenHandler.cs | 72 +++++++++++++++++++ 2 files changed, 74 insertions(+), 1 deletion(-) create mode 100644 cs/Markdown/TokenHandlers/LinkTokenHandler.cs diff --git a/cs/Markdown/Parsers/MarkdownParser.cs b/cs/Markdown/Parsers/MarkdownParser.cs index b18f6059c..16bfe1e5c 100644 --- a/cs/Markdown/Parsers/MarkdownParser.cs +++ b/cs/Markdown/Parsers/MarkdownParser.cs @@ -12,7 +12,8 @@ public class MarkdownParser : IMarkdownParser new HeaderTokenHandler(), new EmphasisTokenHandler(), new NewLineHandler(), - new EscapeCharacterHandler() + new EscapeCharacterHandler(), + new LinkTokenHandler() ]; public IEnumerable ParseTokens(string markdownText) diff --git a/cs/Markdown/TokenHandlers/LinkTokenHandler.cs b/cs/Markdown/TokenHandlers/LinkTokenHandler.cs new file mode 100644 index 000000000..6c8830648 --- /dev/null +++ b/cs/Markdown/TokenHandlers/LinkTokenHandler.cs @@ -0,0 +1,72 @@ +using Markdown.Interfaces; +using Markdown.Parsers; +using Markdown.Tokens; + +namespace Markdown.TokenHandlers; + +public class LinkTokenHandler : ITokenHandler +{ + public bool CanHandle(char current, char next, MarkdownParseContext context) + => current == '['; + + public void Handle(MarkdownParseContext context) + { + MarkdownParser.AddToken(context, TokenType.Text); + + var startIndex = context.CurrentIndex; + var endIndex = FindClosingBracket(context.MarkdownText, startIndex); + if (endIndex == -1) + { + context.Buffer.Append(context.MarkdownText[startIndex]); + context.CurrentIndex++; + return; + } + + var linkStartIndex = context.MarkdownText.IndexOf('(', endIndex); + var linkEndIndex = context.MarkdownText.IndexOf(')', linkStartIndex); + if (linkStartIndex == -1 || linkEndIndex == -1) + { + context.Buffer.Append(context.MarkdownText[startIndex]); + context.CurrentIndex++; + return; + } + + var labelText = context.MarkdownText.Substring(startIndex + 1, endIndex - startIndex - 1); + var url = context.MarkdownText.Substring(linkStartIndex + 1, linkEndIndex - linkStartIndex - 1); + + var labelTokens = context.Parser.ParseTokens(labelText); + + var linkToken = new LinkToken(labelTokens, url); + if (context.Stack.Count > 0) + { + context.Stack.Peek().Children.Add(linkToken); + } + else + { + context.Tokens.Add(linkToken); + } + + context.CurrentIndex = linkEndIndex + 1; + } + + private static int FindClosingBracket(string text, int startIndex) + { + var depth = 0; + for (var i = startIndex; i < text.Length; i++) + { + if (text[i] == '\\') + { + i++; + continue; + } + if (text[i] == '[') + depth++; + else if (text[i] == ']') + depth--; + + if (depth == 0) + return i; + } + return -1; + } +} \ No newline at end of file From 2c0de888e6e75b0232563ed2d4c4b0b63faf911f Mon Sep 17 00:00:00 2001 From: Kostornoj-Dmitrij Date: Mon, 9 Dec 2024 23:06:19 +0500 Subject: [PATCH 09/16] Update EscapeCharacterHandler with characters for link tag --- cs/Markdown/TokenHandlers/EscapeCharacterHandler.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cs/Markdown/TokenHandlers/EscapeCharacterHandler.cs b/cs/Markdown/TokenHandlers/EscapeCharacterHandler.cs index c403e81e8..1d2f2e213 100644 --- a/cs/Markdown/TokenHandlers/EscapeCharacterHandler.cs +++ b/cs/Markdown/TokenHandlers/EscapeCharacterHandler.cs @@ -14,7 +14,7 @@ public void Handle(MarkdownParseContext context) if (context.CurrentIndex + 1 < context.MarkdownText.Length) { var next = context.MarkdownText[context.CurrentIndex + 1]; - if (next is '_' or '#' or '\\') + if (next is '_' or '#' or '\\' or ']' or '(') { if (next != '\\') context.Buffer.Append(next); From 4f1cf357496293d98ae5d8ee74897c5d9e979fbc Mon Sep 17 00:00:00 2001 From: Kostornoj-Dmitrij Date: Mon, 9 Dec 2024 23:09:26 +0500 Subject: [PATCH 10/16] Add Html test for link tag --- cs/MarkdownTests/HtmlRenderer_Should.cs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/cs/MarkdownTests/HtmlRenderer_Should.cs b/cs/MarkdownTests/HtmlRenderer_Should.cs index 553753bcd..c69f96917 100644 --- a/cs/MarkdownTests/HtmlRenderer_Should.cs +++ b/cs/MarkdownTests/HtmlRenderer_Should.cs @@ -154,4 +154,20 @@ public void Render_ShouldHandleNestedTagsWithMultipleLevels() result.Should().Be("

полужирный заголовок

" + "и полужирный курсив"); } + + [Test] + public void Render_ShouldHandleLinkTags() + { + var tokens = new List + { + new LinkToken(new List + { + new (TokenType.Text, "Ссылка на Google") + }, "http://google.com") + }; + + var result = renderer.Render(tokens); + + result.Should().Be("Ссылка на Google"); + } } \ No newline at end of file From 36e24de23b6c0127a3354ed52d4b3ccd4b2c6f41 Mon Sep 17 00:00:00 2001 From: Kostornoj-Dmitrij Date: Mon, 9 Dec 2024 23:35:29 +0500 Subject: [PATCH 11/16] Specification update for link tag --- MarkdownSpec.md | 50 ++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 49 insertions(+), 1 deletion(-) diff --git a/MarkdownSpec.md b/MarkdownSpec.md index 886e99c95..b4601ff15 100644 --- a/MarkdownSpec.md +++ b/MarkdownSpec.md @@ -70,4 +70,52 @@ __Непарные_ символы в рамках одного абзаца н превратится в: -\

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

\ No newline at end of file +\

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

+ + + +# Ссылки + +Текст, заключенный в квадратные скобки [], с последующим URL в круглых скобках (), должен выделяться тегом \ со следующим синтаксисом: +\[Текст ссылки](URL) +Пример: +Это текст с \[ссылкой](http://link.com) + +Будет преобразовано в: + +Это текст с \ссылкой\ + + +Внутри текста ссылки допускается использование других элементов разметки: + +Это \[ссылка с \_курсивом\_](http://link.com) + +Будет преобразовано в: + +Это \ссылка с \курсивом\\ + + +Квадратные и круглые скобки, также как и другие специальные символы внутри текста ссылки можно экранировать: + +\[Ссылка с экранированными \] символами\]](http://link.com) + +Будет преобразовано в: + +\Ссылка с экранированными ] символами]\ + + +Если текст ссылки или URL не завершен, разметка остается неизменной. +Примеры: +\[незавершённая ссылка(http://link.com) + +\[незавершённая ссылка](http://link.com + +Ссылки могут быть вложены в другие элементы разметки, такие как заголовки, курсив или полужирный текст с теми же правилами, что и остальные теги + +# Пример сложной вложенности с ссылками: + +\# h \_\_E \_e_ \[ссылка](http://link.com) E__ \_e_ + +Будет преобразовано в: + +\

h \E \e\ \ссылка\ E\ \e\\

\ No newline at end of file From 5533dd78c7451b48320646c8639359d4df176f26 Mon Sep 17 00:00:00 2001 From: Kostornoj-Dmitrij Date: Mon, 9 Dec 2024 23:44:58 +0500 Subject: [PATCH 12/16] Specification update for link tag 2 --- MarkdownSpec.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/MarkdownSpec.md b/MarkdownSpec.md index b4601ff15..d81faeeb4 100644 --- a/MarkdownSpec.md +++ b/MarkdownSpec.md @@ -78,12 +78,14 @@ __Непарные_ символы в рамках одного абзаца н Текст, заключенный в квадратные скобки [], с последующим URL в круглых скобках (), должен выделяться тегом \ со следующим синтаксисом: \[Текст ссылки](URL) + Пример: + Это текст с \[ссылкой](http://link.com) Будет преобразовано в: -Это текст с \ссылкой\ +Это текст с \ссылкой Внутри текста ссылки допускается использование других элементов разметки: @@ -97,7 +99,7 @@ __Непарные_ символы в рамках одного абзаца н Квадратные и круглые скобки, также как и другие специальные символы внутри текста ссылки можно экранировать: -\[Ссылка с экранированными \] символами\]](http://link.com) +\[Ссылка с экранированными \\\] символами\\\]\](http://link.com) Будет преобразовано в: @@ -105,7 +107,9 @@ __Непарные_ символы в рамках одного абзаца н Если текст ссылки или URL не завершен, разметка остается неизменной. + Примеры: + \[незавершённая ссылка(http://link.com) \[незавершённая ссылка](http://link.com @@ -118,4 +122,4 @@ __Непарные_ символы в рамках одного абзаца н Будет преобразовано в: -\

h \E \e\ \ссылка\ E\ \e\\

\ No newline at end of file +\

h \E \e\ \ссылка E\ \e\\

\ No newline at end of file From 75acb6f0662d560c5244ddaa1ab1c51133c09587 Mon Sep 17 00:00:00 2001 From: Kostornoj-Dmitrij Date: Mon, 9 Dec 2024 23:48:47 +0500 Subject: [PATCH 13/16] Specification update for link tag 3 --- MarkdownSpec.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/MarkdownSpec.md b/MarkdownSpec.md index d81faeeb4..94784c679 100644 --- a/MarkdownSpec.md +++ b/MarkdownSpec.md @@ -77,6 +77,7 @@ __Непарные_ символы в рамках одного абзаца н # Ссылки Текст, заключенный в квадратные скобки [], с последующим URL в круглых скобках (), должен выделяться тегом \ со следующим синтаксисом: + \[Текст ссылки](URL) Пример: @@ -85,7 +86,7 @@ __Непарные_ символы в рамках одного абзаца н Будет преобразовано в: -Это текст с \ссылкой +Это текст с \ссылкой\ Внутри текста ссылки допускается использование других элементов разметки: @@ -122,4 +123,4 @@ __Непарные_ символы в рамках одного абзаца н Будет преобразовано в: -\

h \E \e\ \ссылка E\ \e\\

\ No newline at end of file +\

h \E \e\ \ссылка\ E\ \e\\

\ No newline at end of file From 8210ffe2af52ff4da55d625986151a492f2387c5 Mon Sep 17 00:00:00 2001 From: Kostornoj-Dmitrij Date: Mon, 9 Dec 2024 23:52:18 +0500 Subject: [PATCH 14/16] Specification update for link tag 4 --- MarkdownSpec.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/MarkdownSpec.md b/MarkdownSpec.md index 94784c679..7d122aa37 100644 --- a/MarkdownSpec.md +++ b/MarkdownSpec.md @@ -86,7 +86,7 @@ __Непарные_ символы в рамках одного абзаца н Будет преобразовано в: -Это текст с \ссылкой\ +Это текст с ссылкой Внутри текста ссылки допускается использование других элементов разметки: @@ -95,7 +95,7 @@ __Непарные_ символы в рамках одного абзаца н Будет преобразовано в: -Это \ссылка с \курсивом\\ +Это ссылка с курсивом Квадратные и круглые скобки, также как и другие специальные символы внутри текста ссылки можно экранировать: @@ -104,7 +104,7 @@ __Непарные_ символы в рамках одного абзаца н Будет преобразовано в: -\Ссылка с экранированными ] символами]\ +Ссылка с экранированными ] символами] Если текст ссылки или URL не завершен, разметка остается неизменной. @@ -123,4 +123,4 @@ __Непарные_ символы в рамках одного абзаца н Будет преобразовано в: -\

h \E \e\ \ссылка\ E\ \e\\

\ No newline at end of file +

h E e ссылка E e

\ No newline at end of file From 4c561f7916408cbb3d62eb7fef38361aa5a855ca Mon Sep 17 00:00:00 2001 From: Kostornoj-Dmitrij Date: Mon, 9 Dec 2024 23:54:11 +0500 Subject: [PATCH 15/16] Specification update for link tag 5 --- MarkdownSpec.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/MarkdownSpec.md b/MarkdownSpec.md index 7d122aa37..7b871e953 100644 --- a/MarkdownSpec.md +++ b/MarkdownSpec.md @@ -86,7 +86,7 @@ __Непарные_ символы в рамках одного абзаца н Будет преобразовано в: -Это текст с ссылкой +Это текст с \ссылкой\ Внутри текста ссылки допускается использование других элементов разметки: @@ -95,7 +95,7 @@ __Непарные_ символы в рамках одного абзаца н Будет преобразовано в: -Это ссылка с курсивом +Это \ссылка с \курсивом\\ Квадратные и круглые скобки, также как и другие специальные символы внутри текста ссылки можно экранировать: @@ -104,7 +104,7 @@ __Непарные_ символы в рамках одного абзаца н Будет преобразовано в: -Ссылка с экранированными ] символами] +\Ссылка с экранированными ] символами]\ Если текст ссылки или URL не завершен, разметка остается неизменной. @@ -123,4 +123,4 @@ __Непарные_ символы в рамках одного абзаца н Будет преобразовано в: -

h E e ссылка E e

\ No newline at end of file +\

h \E \e\ \ссылка \ E\ \e\\

\ No newline at end of file From 6af2bca4a2595f68d12c64da006ab178e3b6f8ec Mon Sep 17 00:00:00 2001 From: Kostornoj-Dmitrij Date: Mon, 9 Dec 2024 23:55:04 +0500 Subject: [PATCH 16/16] Specification update for link tag 6 --- MarkdownSpec.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/MarkdownSpec.md b/MarkdownSpec.md index 7b871e953..4fb374e67 100644 --- a/MarkdownSpec.md +++ b/MarkdownSpec.md @@ -86,7 +86,7 @@ __Непарные_ символы в рамках одного абзаца н Будет преобразовано в: -Это текст с \ссылкой\ +Это текст с \ссылкой \ Внутри текста ссылки допускается использование других элементов разметки: