diff --git a/MarkdownSpec.md b/MarkdownSpec.md index 886e99c95..04082e9b1 100644 --- a/MarkdownSpec.md +++ b/MarkdownSpec.md @@ -70,4 +70,42 @@ __Непарные_ символы в рамках одного абзаца н превратится в: -\

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

\ No newline at end of file +\

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

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

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

") + .SetName("H1Tag_Render"); + + yield return new TestCaseData( + "___Слово___", + "___Слово___") + .SetName("TripleUnderscore_NoRender"); + + yield return new TestCaseData( + "[репозиторий gogy](https://github.com/gogy4?tab=repositories)", + "репозиторий gogy") + .SetName("CorrectLink_Render"); + + yield return new TestCaseData( + "[Неполная ссылка(ссылка)", + "[Неполная ссылка(ссылка)") + .SetName("IncorrectLink_NoRender"); + + yield return new TestCaseData( + "[Google](https://google.com) и [YouTube](https://youtube.com)", + "Google и YouTube") + .SetName("MultipleLinks_RenderAll"); + + yield return new TestCaseData( + "[Текст с ошибкой[внутри]()", + "[Текст с ошибкой[внутри]()") + .SetName("IncorrectLink_NestedBrackets"); + + yield return new TestCaseData( + "[Ссылка без закрытия](https://example.com", + "[Ссылка без закрытия](https://example.com") + .SetName("IncorrectLink_MissingClosingParenthesis"); + + yield return new TestCaseData( + "[Ссылка без открытия]https://example.com", + "[Ссылка без открытия]https://example.com") + .SetName("IncorrectLink_MissingOpeningParenthesis"); + + yield return new TestCaseData( + "Текст без открывающей скобки](https://example.com)", + "Текст без открывающей скобки](https://example.com)") + .SetName("IncorrectLink_MissingOpeningBracket"); + + yield return new TestCaseData( + "[Текст без закрывающей скобки(https://example.com)", + "[Текст без закрывающей скобки(https://example.com)") + .SetName("IncorrectLink_MissingClosingBracket"); + + yield return new TestCaseData( + "(https://example.com)[Неправильный порядок]", + "(https://example.com)[Неправильный порядок]") + .SetName("IncorrectLink_ReversedOrder"); + + yield return new TestCaseData( + "[](Ссылка без текста https://example.com)", + "[](Ссылка без текста https://example.com)") + .SetName("IncorrectLink_EmptyText"); + + yield return new TestCaseData( + "[Текст с пустой ссылкой]()", + "[Текст с пустой ссылкой]()") + .SetName("IncorrectLink_EmptyUrl"); + } + } +} \ No newline at end of file diff --git a/cs/Markdown/TokensUtils/Abstractions/ITokenizer.cs b/cs/Markdown/TokensUtils/Abstractions/ITokenizer.cs new file mode 100644 index 000000000..be54372d6 --- /dev/null +++ b/cs/Markdown/TokensUtils/Abstractions/ITokenizer.cs @@ -0,0 +1,8 @@ +using Markdown.Dto; + +namespace Markdown.TokensUtils.Abstractions; + +public interface ITokenizer +{ + public IEnumerable Tokenize(string? line); +} \ No newline at end of file diff --git a/cs/Markdown/TokensUtils/Implementations/Tokenizer.cs b/cs/Markdown/TokensUtils/Implementations/Tokenizer.cs new file mode 100644 index 000000000..ca139bd20 --- /dev/null +++ b/cs/Markdown/TokensUtils/Implementations/Tokenizer.cs @@ -0,0 +1,53 @@ +using System.Text; +using Markdown.Dto; +using Markdown.TokensUtils.Abstractions; + +namespace Markdown.TokensUtils.Implementations; + +public class Tokenizer : ITokenizer +{ + private static readonly HashSet SpecialSymbols = ['\\', '_', '#', ')', '[']; + + public IEnumerable Tokenize(string? line) + { + ArgumentNullException.ThrowIfNull(line); + + var sb = new StringBuilder(); + var i = 0; + + while (i < line.Length) + { + var c = line[i]; + + if (SpecialSymbols.Contains(c) && sb.Length > 0) + { + yield return new Token(sb.ToString(), TokenType.Text, false, false); + sb.Clear(); + } + + var token = MapSpecialSymbol.Specialize(c, line, i); + if (token != null) + { + yield return token; + + if (token.Type is TokenType.Header or TokenType.Strong) + { + i++; + } + } + else + { + sb.Append(c); + } + + i++; + } + + if (sb.Length > 0) + { + yield return new Token(sb.ToString(), TokenType.Text, false, false); + } + + yield return new Token(string.Empty, TokenType.End, false, false); + } +} \ No newline at end of file diff --git a/cs/Markdown/TokensUtils/MapSpecialSymbol.cs b/cs/Markdown/TokensUtils/MapSpecialSymbol.cs new file mode 100644 index 000000000..317910a82 --- /dev/null +++ b/cs/Markdown/TokensUtils/MapSpecialSymbol.cs @@ -0,0 +1,73 @@ +namespace Markdown.TokensUtils +{ + using Markdown.Dto; + + public static class MapSpecialSymbol + { + public static Token? Specialize(char c, string line, int index) + { + return c switch + { + '[' => CreateLinkToken(line, index, true), + ')' => CreateLinkToken(line, index, false), + '_' when index + 1 < line.Length && line[index + 1] == '_' => + CreateUnderscoreToken(line, index, true), + '_' => CreateUnderscoreToken(line, index, false), + '#' when index + 1 < line.Length && line[index + 1] == ' ' => + CreateHeaderToken(line, index), + '\\' when index + 1 < line.Length => + new Token(line[index].ToString(), TokenType.Escape, TokenRole.None, TokenPosition.None), + _ => null + }; + } + + private static CharContext GetCharContext(string line, int index, int tokenLength) + { + var prevChar = index > 0 ? line[index - 1] : (char?)null; + var nextChar = index + tokenLength < line.Length ? line[index + tokenLength] : (char?)null; + var canOpen = nextChar != null && !char.IsWhiteSpace(nextChar.Value); + var canClose = prevChar != null && !char.IsWhiteSpace(prevChar.Value); + return new CharContext(nextChar, canOpen, canClose); + } + + private static Token CreateUnderscoreToken(string line, int index, bool isStrong) + { + return CreateToken(line, index, isStrong ? "__" : "_", + isStrong ? TokenType.Strong : TokenType.Italic, + charContext => (charContext.CanOpen, charContext.CanClose)); + } + + + private static Token CreateLinkToken(string line, int index, bool isOpen) + { + return CreateToken(line, index, isOpen ? "[" : ")", + TokenType.Link, + charContext => (isOpen && charContext.CanOpen, !isOpen && charContext.CanClose)); + } + + + private static Token CreateHeaderToken(string line, int index) + { + return CreateToken(line, index, "#", + TokenType.Header, + ctx => + { + var canOpenClose = ctx.Next != null && char.IsWhiteSpace(ctx.Next.Value); + return (canOpenClose, canOpenClose); + }); + } + + + private static Token CreateToken( + string line, + int index, + string value, + TokenType type, + Func roleSelector) + { + var ctx = GetCharContext(line, index, value.Length); + var (canOpen, canClose) = roleSelector(ctx); + return new Token(value, type, canOpen, canClose); + } + } +} \ No newline at end of file diff --git a/cs/Markdown/TokensUtils/TagRender.cs b/cs/Markdown/TokensUtils/TagRender.cs new file mode 100644 index 000000000..2f09fc004 --- /dev/null +++ b/cs/Markdown/TokensUtils/TagRender.cs @@ -0,0 +1,26 @@ +using Markdown.Dto; + +namespace Markdown.TokensUtils +{ + public static class TagRender + { + public static string Wrap(TokenType type, string content, string url = null) + => type switch + { + TokenType.Italic => $"{content}", + TokenType.Strong => $"{content}", + TokenType.Escape => $"{content}", + TokenType.Header => $"

{content}

", + TokenType.Link => WrapLink(content), + _ => content + }; + + private static string WrapLink(string content) + { + var parts = content.Split(["]("], StringSplitOptions.None); + return parts.Length != 2 + ? throw new ArgumentException("Invalid link format") + : $"{parts[0]}"; + } + } +} \ No newline at end of file