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

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

\ 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 diff --git a/cs/Markdown/Interfaces/IMarkdownParser.cs b/cs/Markdown/Interfaces/IMarkdownParser.cs new file mode 100644 index 000000000..3fa469746 --- /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..5fc2c21e6 --- /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..1d4ce4595 --- /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(BaseToken baseToken, 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/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..b45ad260f --- /dev/null +++ b/cs/Markdown/Md.cs @@ -0,0 +1,21 @@ +using Markdown.Interfaces; + +namespace Markdown; + +public class Md +{ + private readonly IRenderer renderer; + private readonly IMarkdownParser parser; + + public Md(IRenderer renderer, IMarkdownParser parser) + { + this.renderer = renderer; + this.parser = parser; + } + + public string Render(string 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 new file mode 100644 index 000000000..16bfe1e5c --- /dev/null +++ b/cs/Markdown/Parsers/MarkdownParser.cs @@ -0,0 +1,63 @@ +using Markdown.Interfaces; +using Markdown.TokenHandlers; +using Markdown.Tokens; + +namespace Markdown.Parsers; + +public class MarkdownParser : IMarkdownParser +{ + private readonly List handlers = + [ + new StrongTokenHandler(), + new HeaderTokenHandler(), + new EmphasisTokenHandler(), + new NewLineHandler(), + new EscapeCharacterHandler(), + new LinkTokenHandler() + ]; + + public IEnumerable ParseTokens(string markdownText) + { + ArgumentNullException.ThrowIfNull(markdownText); + + var context = new MarkdownParseContext + { + 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 handler = handlers.FirstOrDefault(h => h.CanHandle(current, next, context)); + if (handler != null) + { + handler.Handle(context); + } + else + { + context.Buffer.Append(current); + context.CurrentIndex++; + } + } + + AddToken(context, TokenType.Text); + return context.Tokens; + } + + public static void AddToken(MarkdownParseContext context, TokenType type) + { + if (context.Buffer.Length == 0) return; + var token = new BaseToken(type, context.Buffer.ToString()); + context.Buffer.Clear(); + + if (context.Stack.Count > 0) + context.Stack.Peek().Children.Add(token); + else + context.Tokens.Add(token); + } +} \ 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..6b696352e --- /dev/null +++ b/cs/Markdown/Parsers/MarkdownParserContext.cs @@ -0,0 +1,17 @@ +using System.Text; +using Markdown.Interfaces; +using Markdown.Tokens; + +namespace Markdown.Parsers; + +public class MarkdownParseContext +{ + 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; init; } +} \ 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..133442a8f --- /dev/null +++ b/cs/Markdown/Renderers/HtmlRenderer.cs @@ -0,0 +1,22 @@ +using System.Text; +using Markdown.Interfaces; +using Markdown.TokenConverters; +using Markdown.Tokens; + +namespace Markdown.Renderers; + +public class HtmlRenderer : IRenderer +{ + public string Render(IEnumerable tokens) + { + var result = new StringBuilder(); + foreach (var token in tokens) + { + var converter = TokenConverterFactory.GetConverter(token.Type); + converter.Render(token, result); + } + 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 new file mode 100644 index 000000000..c50bbd139 --- /dev/null +++ b/cs/Markdown/TokenConverters/EmphasisConverter.cs @@ -0,0 +1,14 @@ +using System.Text; +using Markdown.Tokens; + +namespace Markdown.TokenConverters; + +public class EmphasisConverter : TokenConverterBase +{ + public override void Render(BaseToken baseToken, StringBuilder result) + { + result.Append(""); + 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 new file mode 100644 index 000000000..ee6e212de --- /dev/null +++ b/cs/Markdown/TokenConverters/HeaderConverter.cs @@ -0,0 +1,15 @@ +using System.Text; +using Markdown.Tokens; + +namespace Markdown.TokenConverters; + +public class HeaderConverter : TokenConverterBase +{ + public override void Render(BaseToken baseToken, StringBuilder result) + { + var level = baseToken.HeaderLevel; + result.Append($""); + RenderChildren(baseToken, result); + result.Append($""); + } +} \ No newline at end of file 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/TokenConverters/StrongConverter.cs b/cs/Markdown/TokenConverters/StrongConverter.cs new file mode 100644 index 000000000..cee93ecfc --- /dev/null +++ b/cs/Markdown/TokenConverters/StrongConverter.cs @@ -0,0 +1,14 @@ +using System.Text; +using Markdown.Tokens; + +namespace Markdown.TokenConverters; + +public class StrongConverter : TokenConverterBase +{ + public override void Render(BaseToken baseToken, StringBuilder result) + { + result.Append(""); + 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 new file mode 100644 index 000000000..e40fecd09 --- /dev/null +++ b/cs/Markdown/TokenConverters/TextConverter.cs @@ -0,0 +1,13 @@ +using System.Text; +using Markdown.Interfaces; +using Markdown.Tokens; + +namespace Markdown.TokenConverters; + +public class TextConverter : ITokenConverter +{ + public void Render(BaseToken baseToken, StringBuilder result) + { + result.Append(baseToken.Content); + } +} \ No newline at end of file diff --git a/cs/Markdown/TokenConverters/TokenConverterBase.cs b/cs/Markdown/TokenConverters/TokenConverterBase.cs new file mode 100644 index 000000000..f5a54faa3 --- /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(BaseToken baseToken, StringBuilder result); + + protected static void RenderChildren(BaseToken baseToken, StringBuilder result) + { + foreach (var child in baseToken.Children) + { + var converter = TokenConverterFactory.GetConverter(child.Type); + converter.Render(child, result); + } + } +} \ No newline at end of file diff --git a/cs/Markdown/TokenConverters/TokenConverterFactory.cs b/cs/Markdown/TokenConverters/TokenConverterFactory.cs new file mode 100644 index 000000000..9b635f984 --- /dev/null +++ b/cs/Markdown/TokenConverters/TokenConverterFactory.cs @@ -0,0 +1,38 @@ +using Markdown.Interfaces; +using Markdown.Tokens; + +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 + { + TokenType.Text => new TextConverter(), + TokenType.Emphasis => new EmphasisConverter(), + TokenType.Strong => new StrongConverter(), + TokenType.Header => new HeaderConverter(), + TokenType.Link => new LinkConverter(), + _ => 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 new file mode 100644 index 000000000..9c04ea602 --- /dev/null +++ b/cs/Markdown/TokenHandlers/BoundaryTokenHandler.cs @@ -0,0 +1,157 @@ +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; + var nextDelimiter = TokenType == TokenType.Strong ? "_" : "__"; + if (context.IntersectedIndexes.Contains(index)) + return false; + + if (context.Stack.Count > 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; + + var spaceIndex = text.IndexOf(' ', index + Delimiter.Length); + if (spaceIndex == -1) + return true; + + return !char.IsLetterOrDigit(text[index - 1]) || + !char.IsLetterOrDigit(text[index + 1]); + } + + 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 < paragraphEndIndex && + char.IsLetterOrDigit(text[closingIndex + Delimiter.Length])); + if (isInsideWord) + { + 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; + + 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 BaseToken(TokenType); + context.Stack.Push(newToken); + } + + 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 new file mode 100644 index 000000000..93668d4ba --- /dev/null +++ b/cs/Markdown/TokenHandlers/EmphasisTokenHandler.cs @@ -0,0 +1,13 @@ +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..1d2f2e213 --- /dev/null +++ b/cs/Markdown/TokenHandlers/EscapeCharacterHandler.cs @@ -0,0 +1,28 @@ +using Markdown.Interfaces; +using Markdown.Parsers; + +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 '\\' 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..6cd628abd --- /dev/null +++ b/cs/Markdown/TokenHandlers/HeaderTokenHandler.cs @@ -0,0 +1,53 @@ +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 BaseToken(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); + } + } +} \ No newline at end of file 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 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..4feab52bb --- /dev/null +++ b/cs/Markdown/TokenHandlers/StrongTokenHandler.cs @@ -0,0 +1,13 @@ +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/BaseToken.cs b/cs/Markdown/Tokens/BaseToken.cs new file mode 100644 index 000000000..7223dc66e --- /dev/null +++ b/cs/Markdown/Tokens/BaseToken.cs @@ -0,0 +1,21 @@ +namespace Markdown.Tokens; + +public class BaseToken +{ + public TokenType Type { get; } + public string Content { get; set; } + public List Children { get; init; } + public int HeaderLevel { get; init; } + + public BaseToken(TokenType type, string content, List? children = null) + { + Type = type; + Content = content; + Children = children ?? []; + HeaderLevel = 1; + } + + 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/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 diff --git a/cs/Markdown/Tokens/TokenType.cs b/cs/Markdown/Tokens/TokenType.cs new file mode 100644 index 000000000..537d7e0c6 --- /dev/null +++ b/cs/Markdown/Tokens/TokenType.cs @@ -0,0 +1,10 @@ +namespace Markdown.Tokens; + +public enum TokenType +{ + Text, + Emphasis, + Strong, + Header, + Link +} \ 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..c69f96917 --- /dev/null +++ b/cs/MarkdownTests/HtmlRenderer_Should.cs @@ -0,0 +1,173 @@ +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 (TokenType.Text, "Текст без тегов.") + }; + + var result = renderer.Render(tokens); + + result.Should().Be("Текст без тегов."); + } + + [Test] + public void Render_ShouldHandleEmphasisTags() + { + var tokens = new List + { + new (TokenType.Text, "Это "), + new (TokenType.Emphasis, "курсив") + { Children = new List + { new (TokenType.Text, "курсив") } + }, + new (TokenType.Text, " текст") + }; + + var result = renderer.Render(tokens); + + result.Should().Be("Это курсив текст"); + } + + [Test] + public void Render_ShoulHandleStrongTags() + { + var strongToken = new BaseToken(TokenType.Strong, string.Empty); + strongToken.Children.Add(new BaseToken(TokenType.Text, "полужирный")); + + var tokens = new List + { + new (TokenType.Text, "Это "), + strongToken, + new (TokenType.Text, " текст") + }; + + var result = renderer.Render(tokens); + + result.Should().Be("Это полужирный текст"); + } + + [Test] + public void Render_ShouldHandleHeaderTags() + { + 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); + + result.Should().Be("

Заголовок

"); + } + + [Test] + public void Render_ShouldHandleNestedTags() + { + 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 BaseToken(TokenType.Text, "курсивом")); + strongToken.Children.Add(new BaseToken(TokenType.Text, "полужирным текстом с ")); + strongToken.Children.Add(emphasisToken); + headToken.Children.Add(new BaseToken(TokenType.Text, "заголовок с ")); + headToken.Children.Add(strongToken); + var tokens = new List + { + new BaseToken(TokenType.Text, "Это "), + headToken + }; + + var result = renderer.Render(tokens); + + result.Should().Be("Это

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

"); + } + + [Test] + public void Render_ShouldHandleEmptyTags() + { + var tokens = new List + { + new (TokenType.Text, "Это "), + new (TokenType.Emphasis, string.Empty), + new (TokenType.Text, " текст") + }; + + var result = renderer.Render(tokens); + + result.Should().Be("Это текст"); + } + + [Test] + public void Render_ShouldHandleMultipleTags() + { + var tokens = new List + { + new (TokenType.Text, "Это "), + new (TokenType.Strong, "полужирный") { Children = { new BaseToken(TokenType.Text, "полужирный") } }, + new (TokenType.Text, " и "), + new (TokenType.Emphasis, string.Empty) { Children = { new BaseToken(TokenType.Text, "курсив") } }, + new (TokenType.Text, " текст.") + }; + + var result = renderer.Render(tokens); + + result.Should().Be("Это полужирный и курсив текст."); + } + + [Test] + public void Render_ShouldHandleNestedTagsWithMultipleLevels() + { + var innerStrongToken = new BaseToken(TokenType.Strong, string.Empty); + innerStrongToken.Children.Add(new BaseToken(TokenType.Text, "полужирный заголовок")); + + var innerEmphasisToken = new BaseToken(TokenType.Emphasis, string.Empty); + innerEmphasisToken.Children.Add(new BaseToken(TokenType.Text, "полужирный курсив")); + + var outerHeaderToken = new BaseToken(TokenType.Header, string.Empty); + outerHeaderToken.Children.Add(innerStrongToken); + + var outerStrongToken = new BaseToken(TokenType.Strong, string.Empty); + outerStrongToken.Children.Add(new BaseToken(TokenType.Text, "и ")); + outerStrongToken.Children.Add(innerEmphasisToken); + + var tokens = new List + { + outerHeaderToken, + outerStrongToken, + }; + + var result = renderer.Render(tokens); + + 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 diff --git a/cs/MarkdownTests/MarkdownParser_Should.cs b/cs/MarkdownTests/MarkdownParser_Should.cs new file mode 100644 index 000000000..cb772137a --- /dev/null +++ b/cs/MarkdownTests/MarkdownParser_Should.cs @@ -0,0 +1,367 @@ +using FluentAssertions; +using Markdown.Parsers; +using Markdown.Tokens; +using NUnit.Framework; + +namespace MarkdownTests; + +[TestFixture] +public class MarkdownParser_Should +{ + private readonly MarkdownParser parser = new (); + + public static IEnumerable TokenParsingTestCases() + { + yield return new TestCaseData( + "Это _курсив_ текст", + new List + { + new (TokenType.Text, "Это "), + new (TokenType.Emphasis, children: new List + { + new (TokenType.Text, "курсив") + }), + new (TokenType.Text, " текст") + }).SetName("ShouldParse_WhenItalicTag"); + + yield return new TestCaseData( + "Это __полужирный__ текст", + new List + { + new (TokenType.Text, "Это "), + new (TokenType.Strong, children: new List + { + new (TokenType.Text, "полужирный") + }), + new (TokenType.Text, " текст") + }).SetName("ShouldParse_WhenStrongTag"); + + yield return new TestCaseData( + "# Заголовок", + new List + { + new (TokenType.Header, children: new List + { + new (TokenType.Text, "Заголовок") + }) + }).SetName("ShouldParse_WhenHeaderTag"); + + 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"); + + 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"); + + 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"); + + yield return new TestCaseData( + @"Экранированный \_символ\_", + new List + { + new (TokenType.Text, "Экранированный _символ_") + }).SetName("ShouldParse_WhenEscapedTags"); + + 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"); + + 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"); + + 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"); + + 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 + { + new (TokenType.Text, "Если пустая _______ строка") + }).SetName("ShouldNotParse_WhenEmptyEmphasis"); + + yield return new TestCaseData( + "Текст с цифрами_12_3 не должен выделяться", + new List + { + new (TokenType.Text, "Текст с цифрами_12_3 не должен выделяться") + }).SetName("ShouldNotParse_WhenUnderscoresInNumbers"); + + yield return new TestCaseData( + @"Здесь сим\волы экранирования\ \должны остаться.\", + new List + { + new (TokenType.Text, @"Здесь сим\волы экранирования\ \должны остаться.\") + }).SetName("ShouldNotParse_WhenEscapingSymbols"); + + yield return new TestCaseData( + @"\\_вот это будет выделено тегом_", + new List + { + new (TokenType.Emphasis, children: new List + { + new (TokenType.Text, "вот это будет выделено тегом") + }) + }).SetName("ShouldNotParse_WhenEscapedYourself"); + + 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"); + + yield return new TestCaseData( + "Это пер_вый в_торой пример.", + new List + { + new (TokenType.Text, "Это пер_вый в_торой пример.") + }).SetName("ShouldNotParse_WhenTagInDifferentWords"); + + 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"); + + 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"); + + yield return new TestCaseData( + "Текст с [незавершённой ссылкой](http://link.com", + new List + { + new (TokenType.Text, "Текст с "), + new (TokenType.Text, "[незавершённой ссылкой](http://link.com") + }).SetName("ShouldNotParse_WhenUnfinishedLinkTag"); + } + + [TestCaseSource(nameof(TokenParsingTestCases))] + public void MarkdownParser_ShouldParseTokens(string input, List expectedTokens) + { + var actualTokens = parser.ParseTokens(input).ToList(); + CompareTokens(expectedTokens, actualTokens); + } + + private void CompareTokens(IReadOnlyList expected, IReadOnlyList actual) + { + 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} должно совпадать"); + + 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/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..bab7536ff --- /dev/null +++ b/cs/MarkdownTests/Md_Should.cs @@ -0,0 +1,170 @@ +using FluentAssertions; +using Markdown; +using NUnit.Framework; +using System.Diagnostics; +using Markdown.Parsers; +using Markdown.Renderers; + +namespace MarkdownTests; + +[TestFixture] +public class Md_Should +{ + 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() + { + var func = () => md.Render(null!); + func.Should().Throw(); + } + + [TestCase("", "", TestName = "InputIsEmpty")] + [TestCase("Это # не заголовок", + "Это # не заголовок", + TestName = "InvalidHeaderTags")] + [TestCase(@"Здесь сим\волы экранирования\ \должны остаться.\", + @"Здесь сим\волы экранирования\ \должны остаться.\", + TestName = "EscapingSymbols")] + [TestCase("В ра_зных сл_овах", + "В ра_зных сл_овах", + 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")] + [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); + result.Should().Be(expected); + } + + [TestCase("Это _курсив_ и __полужирный__ текст", + "Это курсив и полужирный текст", + TestName = "ItalicAndStrongTags")] + [TestCase("# Заголовок", + "

Заголовок

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

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

", + TestName = "HeaderWithNestedTags")] + [TestCase("Это __полужирный _текст_, _с курсивом_ внутри__", + "Это полужирный текст, с курсивом внутри", + TestName = "ItalicInStrong")] + [TestCase("Это _курсив с __полужирным__ внутри_", + "Это курсив с __полужирным__ внутри", + TestName = "StrongInItalic")] + [TestCase(@"Экранированный \_символ\_", + "Экранированный _символ_", + TestName = "EscapeTag")] + [TestCase("_подчерки _не считаются_", + "_подчерки не считаются", + TestName = "SpaceBeforeEndOfTag")] + [TestCase(@"\\_вот это будет выделено тегом_", + "вот это будет выделено тегом", + TestName = "EscapedYourselfOnStartOfTag")] + [TestCase(@"_e\\_", + "e", + TestName = "EscapedYourselfOnEndOfTag")] + [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 = "LotNestedTagsWithDoubleItalic")] + [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); + result.Should().Be(expected); + } + + [Test] + public void Md_ShouldRenderLargeInputQuickly() + { + var largeInput = string.Concat(Enumerable.Repeat("_Пример_ ", 1000)); + var expectedOutput = string.Concat(Enumerable.Repeat("Пример ", 1000)); + 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