diff --git a/cs/Markdown/BlockSegmenter.cs b/cs/Markdown/BlockSegmenter.cs new file mode 100644 index 000000000..6d0052ac9 --- /dev/null +++ b/cs/Markdown/BlockSegmenter.cs @@ -0,0 +1,31 @@ +using Markdown.Entities; +using static Markdown.Inlines.InlineSyntax; + +namespace Markdown; + +public class BlockSegmenter +{ + private const string CarriageReturnParagraphSeparator = "\r\n\r\n"; + private const string ParagraphSeparator = "\n\n"; + + public IReadOnlyList Segment(string text) + { + var paragraphs = SplitToParagraphs(text); + var blocks = new List(); + + foreach (var paragraph in paragraphs) + { + var blockType = paragraph.StartsWith(Sharp) ? BlockType.Heading : BlockType.Paragraph; + var rawText = paragraph.TrimStart('#', ' '); + var block = new Block(rawText, blockType); + blocks.Add(block); + } + + return blocks; + } + + private static string[] SplitToParagraphs(string text) + { + return text.Split([CarriageReturnParagraphSeparator, ParagraphSeparator], StringSplitOptions.RemoveEmptyEntries); + } +} diff --git a/cs/Markdown/Entities/Block.cs b/cs/Markdown/Entities/Block.cs new file mode 100644 index 000000000..018828704 --- /dev/null +++ b/cs/Markdown/Entities/Block.cs @@ -0,0 +1,16 @@ +namespace Markdown.Entities; + +public class Block(string rawText, BlockType type) +{ + public string RawText { get; } = rawText; + + public BlockType Type { get; } = type; + + public IReadOnlyList Inlines { get; init; } = []; +} + +public enum BlockType +{ + Heading, + Paragraph +} \ No newline at end of file diff --git a/cs/Markdown/Entities/Node.cs b/cs/Markdown/Entities/Node.cs new file mode 100644 index 000000000..5cf0af287 --- /dev/null +++ b/cs/Markdown/Entities/Node.cs @@ -0,0 +1,15 @@ +namespace Markdown.Entities; + +public class Node(string? text, NodeType type) +{ + public string? Text { get; } = text; + + public NodeType Type { get; } = type; +} + +public enum NodeType +{ + Text, + Strong, + Em +} \ No newline at end of file diff --git a/cs/Markdown/HtmlRenderer.cs b/cs/Markdown/HtmlRenderer.cs new file mode 100644 index 000000000..68666bdc4 --- /dev/null +++ b/cs/Markdown/HtmlRenderer.cs @@ -0,0 +1,122 @@ +namespace Markdown; + +using Markdown.Entities; +using System.Text; + +public class HtmlRenderer +{ + private const string EmOpen = ""; + private const string EmClose = ""; + private const string StrongOpen = ""; + private const string StrongClose = ""; + private const string HeadingOpen = "

"; + private const string HeadingClose = "

"; + private const string ParagraphOpen = "

"; + private const string ParagraphClose = "

"; + + public string Render(IReadOnlyList blocks) + { + var sb = new StringBuilder(); + + for (int i = 0; i < blocks.Count; i++) + { + var block = blocks[i]; + var inner = RenderInlines(block.Inlines, block.Type); + + switch (block.Type) + { + case BlockType.Heading: + sb.Append(HeadingOpen).Append(inner).Append(HeadingClose); + break; + + case BlockType.Paragraph: + sb.Append(ParagraphOpen).Append(inner).Append(ParagraphClose); + break; + } + + if (i < blocks.Count - 1) + sb.AppendLine(); + } + + return sb.ToString(); + } + + private static string RenderInlines(IReadOnlyList inlines, BlockType blockType) + { + var sb = new StringBuilder(); + + for (int i = 0; i < inlines.Count; i++) + { + var node = inlines[i]; + + switch (node.Type) + { + case NodeType.Text: + if (!string.IsNullOrEmpty(node.Text)) + sb.Append(node.Text); + break; + + case NodeType.Em: + RenderEm(sb, node); + break; + + case NodeType.Strong: + if (blockType == BlockType.Heading) + RenderHeadingStrong(sb, inlines, ref i); + else + RenderStrong(sb, node); + break; + } + } + + return sb.ToString(); + } + + private static void RenderEm(StringBuilder sb, Node node) + { + if (!string.IsNullOrEmpty(node.Text)) + sb.Append(EmOpen).Append(node.Text).Append(EmClose); + } + + private static void RenderStrong(StringBuilder sb, Node node) + { + if (!string.IsNullOrEmpty(node.Text)) + sb.Append(StrongOpen).Append(node.Text).Append(StrongClose); + } + + private static void RenderHeadingStrong(StringBuilder sb, IReadOnlyList inlines, ref int currentIndex) + { + sb.Append(StrongOpen); + + var currentNode = inlines[currentIndex]; + if (!string.IsNullOrEmpty(currentNode.Text)) + sb.Append(currentNode.Text); + + var nextIndex = currentIndex + 1; + while (nextIndex < inlines.Count) + { + var nextNode = inlines[nextIndex]; + + if (nextNode.Type == NodeType.Em) + { + if (!string.IsNullOrEmpty(nextNode.Text)) + sb.Append(EmOpen).Append(nextNode.Text).Append(EmClose); + + nextIndex++; + continue; + } + + if (nextNode.Type == NodeType.Strong) + { + if (!string.IsNullOrEmpty(nextNode.Text)) + sb.Append(nextNode.Text); + + nextIndex++; + continue; + } + } + + sb.Append(StrongClose); + currentIndex = nextIndex - 1; + } +} diff --git a/cs/Markdown/Inlines/InlineEscapesNormalizer.cs b/cs/Markdown/Inlines/InlineEscapesNormalizer.cs new file mode 100644 index 000000000..b7c0cfe5d --- /dev/null +++ b/cs/Markdown/Inlines/InlineEscapesNormalizer.cs @@ -0,0 +1,91 @@ +using Markdown.Entities; +using System.Text; +using static Markdown.Inlines.InlineSyntax; + +namespace Markdown.Inlines; + +public static class InlineEscapesNormalizer +{ + public static string Normalize(string text) + { + if (string.IsNullOrEmpty(text)) + return text; + + var result = new StringBuilder(text.Length); + + for (var index = 0; index < text.Length;) + { + var current = text[index]; + + if (current == Escape) + { + index = HandleEscape(text, index, result); + continue; + } + + result.Append(current); + index++; + } + + return result.ToString(); + } + + private static int HandleEscape(string text, int index, StringBuilder result) + { + if (index + 1 >= text.Length) + { + result.Append(Escape); + return index + 1; + } + + var nextSymbol = text[index + 1]; + + if (nextSymbol == Underscore) + { + result.Append(PlaceholderUnderscore); + return index + 2; + } + + if (nextSymbol == Sharp) + { + result.Append(PlaceholderHash); + return index + 2; + } + + if (nextSymbol == Escape) + return HandleEscapedEscape(text, index, result); + + result.Append(Escape).Append(nextSymbol); + return index + 2; + } + + private static int HandleEscapedEscape(string text, int index, StringBuilder result) + { + var afterSymbol = index + 2 < text.Length ? text[index + 2] : EndOfText; + + if (afterSymbol == Underscore || afterSymbol == Sharp) + result.Append(PlaceholderBackslash); + else + result.Append(Escape).Append(Escape); + + return index + 2; + } + + public static void RestorePlaceholders(List nodes) + { + for (var i = 0; i < nodes.Count; i++) + { + var text = nodes[i].Text; + if (text is null) + continue; + + var restored = text + .Replace(PlaceholderUnderscore, Underscore) + .Replace(PlaceholderBackslash, Escape) + .Replace(PlaceholderHash, Sharp); + + if (!ReferenceEquals(restored, text)) + nodes[i] = new Node(restored, nodes[i].Type); + } + } +} diff --git a/cs/Markdown/Inlines/InlineExtensions.cs b/cs/Markdown/Inlines/InlineExtensions.cs new file mode 100644 index 000000000..58e42acc9 --- /dev/null +++ b/cs/Markdown/Inlines/InlineExtensions.cs @@ -0,0 +1,40 @@ +using Markdown.Entities; +using System.Text; + +namespace Markdown.Inlines; + +public static class InlineExtensions +{ + public static void CommitText(this StringBuilder builder, List nodes) + { + if (builder.Length == 0) return; + + nodes.Add(new Node(builder.ToString(), NodeType.Text)); + builder.Clear(); + } + + public static void MergeTextNodes(this List nodes) + { + for (int i = 0; i < nodes.Count - 1;) + { + if (nodes[i].Type == NodeType.Text && nodes[i + 1].Type == NodeType.Text) + { + nodes[i] = new Node(nodes[i].Text + nodes[i + 1].Text, NodeType.Text); + nodes.RemoveAt(i + 1); + } + else + { + i++; + } + } + } + + public static void InsertFromEnd(this StringBuilder target, List<(int index, string text)> inserts) + { + if (inserts.Count == 0) return; + + inserts.Sort((a, b) => b.index.CompareTo(a.index)); + foreach (var (index, text) in inserts) + target.Insert(index, text); + } +} diff --git a/cs/Markdown/Inlines/InlineParser.cs b/cs/Markdown/Inlines/InlineParser.cs new file mode 100644 index 000000000..b9ca936ac --- /dev/null +++ b/cs/Markdown/Inlines/InlineParser.cs @@ -0,0 +1,255 @@ +using Markdown.Entities; +using System.Text; +using static Markdown.Inlines.InlineSyntax; + +namespace Markdown.Inlines; + +public class InlineParser +{ + private const int StrongLength = 2; + private const int EmLength = 1; + + private bool _isStrongOpen; + private bool _isEmOpen; + + private int _strongStartIndex; + private int _emStartIndex; + + private bool _strongOpenedInsideWord; + private bool _emOpenedInsideWord; + + private bool _strongSawWhitespace; + private bool _emSawWhitespace; + + public IReadOnlyList Parse(string text) + { + ResetInlineMarkers(); + + var input = InlineEscapesNormalizer.Normalize(text); + + var nodes = new List(); + var textBuilder = new StringBuilder(input.Length); + + for (int index = 0; index < input.Length;) + { + var current = input[index]; + + if (current == Underscore) + { + index = HandleUnderscore(input, index, textBuilder, nodes); + continue; + } + + if (char.IsWhiteSpace(current)) + MarkWhitespace(); + + textBuilder.Append(current); + index++; + } + + FinalizeUnclosedMarkers(textBuilder); + textBuilder.CommitText(nodes); + + InlineEscapesNormalizer.RestorePlaceholders(nodes); + nodes.MergeTextNodes(); + + return nodes; + } + + private int HandleUnderscore(string input, int index, StringBuilder textBuilder, List nodes) + { + var slice = input.AsSpan(index); + + if (slice.StartsWith(StrongMarker)) + return HandleStrong(input, index, textBuilder, nodes); + + return HandleEm(input, index, textBuilder, nodes); + } + + private int HandleStrong(string input, int index, StringBuilder textBuilder, List nodes) + { + if (_isEmOpen && _isStrongOpen && _strongStartIndex < _emStartIndex) + { + textBuilder.InsertFromEnd( + [ + (_strongStartIndex, StrongMarker), + (_emStartIndex, EmMarker) + ]); + + ResetInlineMarkers(); + + textBuilder.Append(StrongMarker); + return index + StrongLength; + } + + if (_isEmOpen) + { + textBuilder.Append(StrongMarker); + return index + StrongLength; + } + + if (ShouldOpenStrong(input, index)) + { + OpenStrong(input, index, textBuilder); + return index + StrongLength; + } + + if (ShouldCloseStrong(input, index, textBuilder)) + { + CloseStrong(textBuilder, nodes); + return index + StrongLength; + } + + textBuilder.Append(StrongMarker); + return index + StrongLength; + } + + private int HandleEm(string input, int index, StringBuilder textBuilder, List nodes) + { + if (_isEmOpen && _emOpenedInsideWord && _emSawWhitespace) + { + textBuilder.Insert(_emStartIndex, EmMarker); + _isEmOpen = false; + _emSawWhitespace = false; + + textBuilder.Append(Underscore); + return index + EmLength; + } + + if (ShouldOpenEm(input, index)) + { + OpenEm(input, index, textBuilder); + return index + EmLength; + } + + if (ShouldCloseEm(input, index, textBuilder)) + { + CloseEm(textBuilder, nodes); + return index + EmLength; + } + + textBuilder.Append(Underscore); + return index + EmLength; + } + + private bool ShouldOpenStrong(string input, int index) + { + return !_isStrongOpen && CanOpenOrCloseMarker(text: input, position: index, length: 2, open: true); + } + + private bool ShouldCloseStrong(string input, int index, StringBuilder textBuilder) + { + if (_isStrongOpen && _strongOpenedInsideWord && _strongSawWhitespace) + return false; + + var nextChar = GetNextChar(text: input, position: index, length: 2); + + return _isStrongOpen && + CanOpenOrCloseMarker(text: input, position: index, length: 2, open: false) && + !IsCrossingWords(_strongOpenedInsideWord, char.IsLetterOrDigit(nextChar), _strongSawWhitespace) && + textBuilder.Length > _strongStartIndex; + } + + private void OpenStrong(string input, int index, StringBuilder textBuilder) + { + _isStrongOpen = true; + _strongStartIndex = textBuilder.Length; + _strongOpenedInsideWord = char.IsLetterOrDigit(GetPrevChar(input, index)); + _strongSawWhitespace = false; + } + + private void CloseStrong(StringBuilder textBuilder, List nodes) + { + var content = textBuilder.ToString(_strongStartIndex, textBuilder.Length - _strongStartIndex); + textBuilder.Length = _strongStartIndex; + + textBuilder.CommitText(nodes); + nodes.Add(new Node(content, NodeType.Strong)); + + _isStrongOpen = false; + _strongSawWhitespace = false; + } + + private bool ShouldOpenEm(string input, int index) + { + return !_isEmOpen && CanOpenOrCloseMarker(input, index, 1, open: true); + } + + private bool ShouldCloseEm(string input, int index, StringBuilder buffer) + { + var nextChar = GetNextChar(text: input, position: index, length: 1); + + return _isEmOpen && + CanOpenOrCloseMarker(input, index, 1, open: false) && + !IsCrossingWords(_emOpenedInsideWord, char.IsLetterOrDigit(nextChar), _emSawWhitespace) && + buffer.Length > _emStartIndex; + } + + private void OpenEm(string input, int index, StringBuilder textBuilder) + { + _isEmOpen = true; + _emStartIndex = textBuilder.Length; + _emOpenedInsideWord = char.IsLetterOrDigit(GetPrevChar(input, index)); + _emSawWhitespace = false; + } + + private void CloseEm(StringBuilder buffer, List nodes) + { + var emContent = buffer.ToString(_emStartIndex, buffer.Length - _emStartIndex); + + if (_isStrongOpen && _strongStartIndex < _emStartIndex) + { + var strongBeforeEm = buffer.ToString(_strongStartIndex, _emStartIndex - _strongStartIndex); + buffer.Length = _strongStartIndex; + buffer.CommitText(nodes); + nodes.Add(new Node(strongBeforeEm, NodeType.Strong)); + _strongStartIndex = buffer.Length; + } + else + { + buffer.Length = _emStartIndex; + buffer.CommitText(nodes); + } + + nodes.Add(new Node(emContent, NodeType.Em)); + + _isEmOpen = false; + _emSawWhitespace = false; + } + + private void ResetInlineMarkers() + { + _isStrongOpen = false; + _isEmOpen = false; + _strongOpenedInsideWord = false; + _emOpenedInsideWord = false; + _strongSawWhitespace = false; + _emSawWhitespace = false; + _strongStartIndex = 0; + _emStartIndex = 0; + } + + private void MarkWhitespace() + { + if (_isStrongOpen) _strongSawWhitespace = true; + if (_isEmOpen) _emSawWhitespace = true; + } + + private void FinalizeUnclosedMarkers(StringBuilder textBuilder) + { + if (!_isStrongOpen && !_isEmOpen) + return; + + var inserts = new List<(int index, string text)>(); + + if (_isStrongOpen) + inserts.Add((_strongStartIndex, StrongMarker)); + + if (_isEmOpen) + inserts.Add((_emStartIndex, EmMarker)); + + textBuilder.InsertFromEnd(inserts); + + ResetInlineMarkers(); + } +} \ No newline at end of file diff --git a/cs/Markdown/Inlines/InlineSyntax.cs b/cs/Markdown/Inlines/InlineSyntax.cs new file mode 100644 index 000000000..d87b5dd8c --- /dev/null +++ b/cs/Markdown/Inlines/InlineSyntax.cs @@ -0,0 +1,51 @@ +namespace Markdown.Inlines; + +public static class InlineSyntax +{ + public const char Escape = '\\'; + public const char Underscore = '_'; + public const char Space = ' '; + public const char Sharp = '#'; + public const char EndOfText = '\0'; + + public const char PlaceholderUnderscore = '\uE000'; + public const char PlaceholderBackslash = '\uE001'; + public const char PlaceholderHash = '\uE002'; + + public const string StrongMarker = "__"; + public const string EmMarker = "_"; + + public static bool CanOpenOrCloseMarker(string text, int position, int length, bool open) + { + var prevChar = GetPrevChar(text, position); + var nextChar = GetNextChar(text, position, length); + + if (open) + { + if (char.IsWhiteSpace(nextChar)) return false; + } + else + { + if (char.IsWhiteSpace(prevChar)) return false; + } + + if (char.IsDigit(prevChar) && char.IsDigit(nextChar)) return false; + + return true; + } + + public static char GetPrevChar(string text, int position) + { + return position - 1 >= 0 ? text[position - 1] : Space; + } + + public static char GetNextChar(string text, int position, int length) + { + return position + length < text.Length ? text[position + length] : Space; + } + + public static bool IsCrossingWords(bool openedInsideWord, bool closingInsideWord, bool sawWhitespace) + { + return openedInsideWord && closingInsideWord && sawWhitespace; + } +} diff --git a/cs/Markdown/Markdown.csproj b/cs/Markdown/Markdown.csproj new file mode 100644 index 000000000..125f4c93b --- /dev/null +++ b/cs/Markdown/Markdown.csproj @@ -0,0 +1,9 @@ + + + + net9.0 + enable + enable + + + diff --git a/cs/Markdown/Md.cs b/cs/Markdown/Md.cs new file mode 100644 index 000000000..004b39b5f --- /dev/null +++ b/cs/Markdown/Md.cs @@ -0,0 +1,27 @@ +using Markdown.Entities; +using Markdown.Inlines; + +namespace Markdown; + +public class Md(BlockSegmenter segmenter, InlineParser parser, HtmlRenderer renderer) +{ + public string Render(string text) + { + var segmentedBlocks = segmenter.Segment(text); + var blocks = new List(segmentedBlocks.Count); + + foreach (var block in segmentedBlocks) + { + var inlines = parser.Parse(block.RawText); + + blocks.Add(new Block(block.RawText, block.Type) + { + Inlines = inlines + }); + } + + var html = renderer.Render(blocks); + + return html; + } +} diff --git a/cs/MarkdownTests/MarkdownTests.csproj b/cs/MarkdownTests/MarkdownTests.csproj new file mode 100644 index 000000000..b703cb9ee --- /dev/null +++ b/cs/MarkdownTests/MarkdownTests.csproj @@ -0,0 +1,34 @@ + + + + net9.0 + latest + enable + enable + false + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + diff --git a/cs/MarkdownTests/MarkupLanguageProcessorTests.cs b/cs/MarkdownTests/MarkupLanguageProcessorTests.cs new file mode 100644 index 000000000..8ab225b59 --- /dev/null +++ b/cs/MarkdownTests/MarkupLanguageProcessorTests.cs @@ -0,0 +1,429 @@ +using FluentAssertions; +using Markdown; +using Markdown.Inlines; + +namespace MarkdownTests; + +[TestFixture] +public class MarkupLanguageProcessorTests +{ + private Md _md = null!; + + [SetUp] + public void SetUp() + { + _md = new Md(new BlockSegmenter(), new InlineParser(), new HtmlRenderer()); + } + + [Test] + public void Render_EmSimpleText_AddsEmTag() + { + var input = "Текст, _окруженный с двух сторон_ одинарными символами подчерка"; + + var html = _md.Render(input); + + html.Should().Be("

Текст, окруженный с двух сторон одинарными символами подчерка

"); + } + + // Проверяем пример из спецификацииЖ текст с какой-то текст должен обернуться в

, но сами теги остаются как есть + [Test] + public void Render_TextWithHtmlTags_KeepsHtmlTags() + { + var input = "Текст, окруженный с двух сторон одинарными символами подчерка, должен помещаться в HTML-тег ."; + + var html = _md.Render(input); + + html.Should().Be("

Текст, окруженный с двух сторон одинарными символами подчерка, должен помещаться в HTML-тег .

"); + } + + [Test] + public void Render_StrongSimpleText_AddsStrongTag() + { + var input = "__Выделенный двумя символами текст__ должен становиться полужирным с помощью тега ."; + + var html = _md.Render(input); + + html.Should().Be("

Выделенный двумя символами текст должен становиться полужирным с помощью тега .

"); + } + + [Test] + public void Render_EscapedUnderscore_ShowsPlainUnderscore() + { + var input = @"\_Вот это\_, не должно выделиться тегом ."; + + var html = _md.Render(input); + + html.Should().Be("

_Вот это_, не должно выделиться тегом .

"); + } + + [Test] + public void Render_BackslashBeforeNormalChar_StaysInText() + { + var input = @"Здесь сим\волы экранирования\ \должны остаться.\"; + + var html = _md.Render(input); + + html.Should().Be("

Здесь сим\\волы экранирования\\ \\должны остаться.\\

"); + } + + // Последовательность \\_ дает один символ '\' и выделение _какой-то текст_ + [Test] + public void Render_EscapedBackslashBeforeEmTag_ShowsBackslashAndEmTag() + { + var input = @"Символ экранирования тоже можно экранировать: \\_вот это будет выделено тегом_ "; + + var html = _md.Render(input); + + html.Should().Be("

Символ экранирования тоже можно экранировать: \\вот это будет выделено тегом

"); + } + + [Test] + public void Render_EscapedStrongMarkers_ShowsPlainText() + { + var input = @"\__стронг__"; + + var html = _md.Render(input); + + html.Should().Be("

__стронг__

"); + } + + [Test] + public void Render_EscapedHash_DoesNotMakeHeading() + { + var input = @"\# Не заголовок"; + + var html = _md.Render(input); + + html.Should().Be("

# Не заголовок

"); + } + + [Test] + public void Render_BackslashAtEnd_StaysInText() + { + var input = @"abc\"; + + var html = _md.Render(input); + + html.Should().Be("

abc\\

"); + } + + // В заголовке экранирования и inline-выделение работают по таким же правилам, что и в обычном тексте + [Test] + public void Render_H1WithEscapes_ShowsCorrectHeading() + { + var input = @"# Тест \_эм\_ и \\__стронг__"; + + var html = _md.Render(input).ReplaceLineEndings(string.Empty); + + html.Should().Be("

Тест _эм_ и \\стронг

"); + } + + [Test] + public void Render_EscapedBackslashBeforeStrong_AddsStrongTag() + { + var input = @"\\__a__"; + + var html = _md.Render(input); + + html.Should().Be("

\\a

"); + } + + [Test] + public void Render_EmInsideStrong_AddsEmAndStrongTags() + { + var input = "Внутри __двойного выделения _одинарное_ тоже__ работает."; + + var html = _md.Render(input); + + html.Should().Be("

Внутри двойного выделения одинарное тоже работает.

"); + } + + // Непарные подчеркивания в абзаце не дают ни , ни + [Test] + public void Render_StrongInsideEm_DoesNotAddStrongTag() + { + var input = "Но не наоборот — внутри _одинарного __двойное__ не_ работает."; + + var html = _md.Render(input); + + html.Should().Be("

Но не наоборот — внутри одинарного __двойное__ не работает.

"); + } + + [Test] + public void Render_UnpairedUnderscoresInParagraph_DoesNotAddAnyTags() + { + var input = "__Непарные_ символы в рамках одного абзаца не считаются выделением."; + + var html = _md.Render(input); + + html.Should().Be("

__Непарные_ символы в рамках одного абзаца не считаются выделением.

"); + } + + // При пустоте между подчеркиваниями ____ все подчеркивания остаются в тексте + [Test] + public void Render_CrossingStrongAndEm_DoesNotAddTags() + { + var input = "В случае __пересечения _двойных__ и одинарных_ подчерков ни один из них не считается выделением."; + + var html = _md.Render(input); + + html.Should().Be("

В случае __пересечения _двойных__ и одинарных_ подчерков ни один из них не считается выделением.

"); + } + + [Test] + public void Render_UnderscoreBetweenDigits_DoesNotStartEmTag() + { + var input = "Подчерки внутри текста c цифрами_12_3 не считаются выделением и должны оставаться символами подчерка."; + + var html = _md.Render(input); + + html.Should().Be("

Подчерки внутри текста c цифрами_12_3 не считаются выделением и должны оставаться символами подчерка.

"); + } + + [Test] + public void Render_StrongMarkersBetweenDigits_DoesNotAddStrongTag() + { + var input = "цифры__12__3"; + + var html = _md.Render(input); + + html.Should().Be("

цифры__12__3

"); + } + + [Test] + public void Render_OpeningUnderscoreBeforeSpace_DoesNotStartEmTag() + { + var input = "За подчерками, начинающими выделение, должен следовать непробельный символ. Иначе эти_ подчерки_ не считаются выделением и остаются просто символами подчерка."; + + var html = _md.Render(input); + + html.Should().Be("

За подчерками, начинающими выделение, должен следовать непробельный символ. Иначе эти_ подчерки_ не считаются выделением и остаются просто символами подчерка.

"); + } + + [Test] + public void Render_ClosingUnderscoreAfterSpace_DoesNotEndEmTag() + { + var input = "эти _подчерки _ не считаются окончанием выделения"; + + var html = _md.Render(input); + + html.Should().Be("

эти _подчерки _ не считаются окончанием выделения

"); + } + + [Test] + public void Render_EmptyBetweenUnderscores_KeepsAllUnderscores() + { + var input = "Если внутри подчерков пустая строка ____, то они остаются символами подчерка."; + + var html = _md.Render(input); + + html.Should().Be("

Если внутри подчерков пустая строка ____, то они остаются символами подчерка.

"); + } + + [Test] + public void Render_ClosingUnderscoreAfterWordSpace_DoesNotEndEmTag() + { + var input = "a _b _ c"; + + var html = _md.Render(input); + + html.Should().Be("

a _b _ c

"); + } + + [Test] + public void Render_OnlyDoubleUnderscore_KeepsDoubleUnderscore() + { + var input = "__"; + + var html = _md.Render(input); + + html.Should().Be("

__

"); + } + + [Test] + public void Render_EmInsideWordAtStart_AddsEmTag() + { + var input = "в _нач_але"; + + var html = _md.Render(input); + + html.Should().Be("

в начале

"); + } + + [Test] + public void Render_EmInsideWordInMiddle_AddsEmTag() + { + var input = "сер_еди_не"; + + var html = _md.Render(input); + + html.Should().Be("

середине

"); + } + + [Test] + public void Render_EmInsideWordAtEnd_AddsEmTag() + { + var input = "в кон_це._"; + + var html = _md.Render(input); + + html.Should().Be("

в конце.

"); + } + + [Test] + public void Render_EmAcrossTwoWords_DoesNotAddEmTag() + { + var input = "В то же время выделение в ра_зных сл_овах не работает."; + + var html = _md.Render(input); + + html.Should().Be("

В то же время выделение в ра_зных сл_овах не работает.

"); + } + + [TestCase("и в __нач__але", "и в начале", + TestName = "Render_StrongInsideWordAtStart_AddsStrongTag")] + [TestCase("сер__еди__не", "середине", + TestName = "Render_StrongInsideWordInMiddle_AddsStrongTag")] + [TestCase("и в кон__це__.", "и в конце.", + TestName = "Render_StrongInsideWordAtEnd_AddsStrongTag")] + public void Render_StrongInsideWord_AddsStrongTag(string input, string expectedInnerHtml) + { + var html = _md.Render(input); + + html.Should().Be($"

{expectedInnerHtml}

"); + } + + [TestCase("_a_,", "a,", + TestName = "Render_EmWithCommaAfter_ShowsEmBeforeComma")] + [TestCase("._b_", ".b", + TestName = "Render_EmWithDotBefore_ShowsEmAfterDot")] + [TestCase("_abc_ в начале", "abc в начале", + TestName = "Render_EmAtLineStart_ShowsEmAtStart")] + public void Render_EmWithPunctuation_ShowsCorrectHtml(string input, string expectedInnerHtml) + { + var html = _md.Render(input); + + html.Should().Be($"

{expectedInnerHtml}

"); + } + + [Test] + public void Render_StrongAtLineEnd_AddsStrongTag() + { + var input = "в конце __abc__"; + + var html = _md.Render(input); + + html.Should().Be("

в конце abc

"); + } + + [Test] + public void Render_PunctuationBeforeOpeningUnderscore_AllowsEmTag() + { + var input = "!_a_"; + + var html = _md.Render(input); + + html.Should().Be("

!a

"); + } + + [Test] + public void Render_H1Simple_AddsH1Tag() + { + var input = "# Заголовок"; + + var html = _md.Render(input); + + html.Should().Be("

Заголовок

"); + } + + [Test] + public void Render_TwoParagraphsSimple_ShowsTwoParagraphTags() + { + var input = "первый абзац\n\nвторой абзац"; + + var html = _md.Render(input).ReplaceLineEndings(string.Empty); + + html.Should().Be("

первый абзац

второй абзац

"); + } + + [Test] + public void Render_H1ThenParagraphWithTags_ShowsCorrectHtml() + { + var input = + "# Заголовок __с _разными_ символами__\n\n" + + "Текст про _эм_ и __стронг__."; + + var html = _md.Render(input).ReplaceLineEndings(string.Empty); + + html.Should().Be("

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

Текст про эм и стронг.

"); + } + + // Подчеркивания не могут открыть выделение в одном абзаце и закрыть в другом + [Test] + public void Render_EmAcrossParagraphs_DoesNotJoinParagraphs() + { + var input = "Начало _абзаца\n\nпродолжение_ абзаца"; + + var html = _md.Render(input).ReplaceLineEndings(string.Empty); + + html.Should().Be("

Начало _абзаца

продолжение_ абзаца

"); + } + + [Test] + public void Render_SpacesBetweenParagraphs_DoNotAddExtraParagraph() + { + var input = "a\n \n\nb"; + + var html = _md.Render(input).ReplaceLineEndings(string.Empty); + + html.Should().Be("

a

b

"); + } + + [Test] + public void Render_MultipleEmptyLinesBetweenParagraphs_ProducesTwoParagraphs() + { + var input = "a\n\n\n\nb"; + + var html = _md.Render(input).ReplaceLineEndings(string.Empty); + + html.Should().Be("

a

b

"); + } + + [Test] + public void Render_ThreeParagraphsWithHeading_ShowsCorrectHtml() + { + var input = "_эм_ абзац\n\n# Заголовок\n\ntext __bold__"; + + var html = _md.Render(input).ReplaceLineEndings(string.Empty); + + html.Should().Be("

эм абзац

Заголовок

text bold

"); + } + + [Test] + public void Render_EmptyInput_ReturnsEmptyString() + { + var html = _md.Render(string.Empty); + + html.Should().Be(string.Empty); + } + + [Test] + public void Render_SpacesOnlyParagraph_ShowsEmptyParagraph() + { + var input = " "; + + var html = _md.Render(input); + + html.Should().Be("

"); + } + + [Test] + public void Render_EmWithUnicodeChar_ShowsCorrectHtml() + { + var input = "текст с _символом unicode ▭_ внутри"; + + var html = _md.Render(input); + + html.Should().Be("

текст с символом unicode ▭ внутри

"); + } +} diff --git a/cs/MarkdownTests/PerformanceTests.cs b/cs/MarkdownTests/PerformanceTests.cs new file mode 100644 index 000000000..2eb7e7130 --- /dev/null +++ b/cs/MarkdownTests/PerformanceTests.cs @@ -0,0 +1,103 @@ +using FluentAssertions; +using Markdown; +using Markdown.Inlines; +using System.Diagnostics; +using System.Text; + +namespace MarkdownTests; + +[TestFixture] +public class PerformanceTests +{ + private static readonly string[] Patterns = + [ + "# Заголовок __с _разными_ символами__\n\n", + "Текст, _окруженный_ и __сильный__ и \\_экранированный\\_ 12_3 ", + "слово ра_зных сл_овах не работает. __Непарные_ и _непарные__ . ", + "Внутри __двойного _одинарное_ тоже__ работает. ", + "Но _внутри __одинарного__ не_ работает. Конец\\\\\n\n" + ]; + + private const double AllowedGrowth = 2.0; + private const int NestingLevels = 1000; + + private Md _md = null!; + + [SetUp] + public void SetUp() + { + _md = new Md(new BlockSegmenter(), new InlineParser(), new HtmlRenderer()); + } + + private static string BuildText(int size) + { + var sb = new StringBuilder(size); + var patternIndex = 0; + + while (sb.Length < size) + { + var pattern = Patterns[patternIndex % Patterns.Length]; + sb.Append(pattern); + patternIndex++; + } + + return sb.ToString(); + } + + [Test] + public void Render_TextSizeIncreases_ExecutionTimeLinearlyOrBetter() + { + var sizes = new[] { 2_000, 16_000, 128_000, 1_000_000 }; + var timesMs = new double[sizes.Length]; + + const int iterationsPerSize = 5; + + for (int i = 0; i < sizes.Length; i++) + { + var size = sizes[i]; + var totalMs = 0.0; + + for (int _ = 0; _ < iterationsPerSize; _++) + { + var text = BuildText(size); + + var sw = Stopwatch.StartNew(); + var html = _md.Render(text); + sw.Stop(); + + html.Should().NotBeNullOrEmpty(); + + totalMs += sw.Elapsed.TotalMilliseconds; + } + + timesMs[i] = totalMs / iterationsPerSize; + } + + var costPerChar = new double[sizes.Length]; + for (int i = 0; i < sizes.Length; i++) + costPerChar[i] = timesMs[i] / sizes[i]; + + for (int i = 1; i < sizes.Length; i++) + (costPerChar[i] / costPerChar[i - 1]).Should().BeLessThanOrEqualTo(AllowedGrowth); + } + + [Test] + public void Render_DeeplyNestedMarkup_DoesNotThrow() + { + var builder = new StringBuilder(); + + for (int i = 0; i < NestingLevels; i++) + builder.Append("__вложенный _текст "); + + builder.Append("конец"); + + for (int i = 0; i < NestingLevels; i++) + builder.Append("_ конец__"); + + var input = builder.ToString(); + + Action act = () => _md.Render(input); + + act.Should().NotThrow(); + } +} diff --git a/cs/clean-code.sln b/cs/clean-code.sln index 2206d54db..d69702eba 100644 --- a/cs/clean-code.sln +++ b/cs/clean-code.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio 14 -VisualStudioVersion = 14.0.25420.1 +# Visual Studio Version 18 +VisualStudioVersion = 18.0.11123.170 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Chess", "Chess\Chess.csproj", "{DBFBE40E-EE0C-48F4-8763-EBD11C960081}" EndProject @@ -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", "{1668C1D9-9323-474A-AF55-D641CE2E2EE0}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MarkdownTests", "MarkdownTests\MarkdownTests.csproj", "{8DEBBB12-F9BD-C3F5-021F-7E159CAB423D}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -27,5 +31,19 @@ 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 + {1668C1D9-9323-474A-AF55-D641CE2E2EE0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1668C1D9-9323-474A-AF55-D641CE2E2EE0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1668C1D9-9323-474A-AF55-D641CE2E2EE0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1668C1D9-9323-474A-AF55-D641CE2E2EE0}.Release|Any CPU.Build.0 = Release|Any CPU + {8DEBBB12-F9BD-C3F5-021F-7E159CAB423D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8DEBBB12-F9BD-C3F5-021F-7E159CAB423D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8DEBBB12-F9BD-C3F5-021F-7E159CAB423D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8DEBBB12-F9BD-C3F5-021F-7E159CAB423D}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {0E5D959D-1409-4C9F-999C-5C3F249E68AD} EndGlobalSection EndGlobal