diff --git a/cs/Markdown/Converters/HtmlConverter.cs b/cs/Markdown/Converters/HtmlConverter.cs new file mode 100644 index 000000000..379976f3c --- /dev/null +++ b/cs/Markdown/Converters/HtmlConverter.cs @@ -0,0 +1,33 @@ +using System.Text; +using Markdown.Tokenizers; + +namespace Markdown.Converters; + +public class HtmlConverter : IConverter +{ + private static readonly Dictionary HtmlTag = new() + { + { TokenType.Italic, "em" }, + { TokenType.Strong, "strong" }, + }; + + public string Convert(IEnumerable tokens) + { + var html = new StringBuilder(); + var isClosed = Enum.GetValues().ToDictionary(type => type, type => true); + + foreach (var token in tokens) + { + html.Append(token.Type switch + { + TokenType.Italic or TokenType.Strong => + isClosed[token.Type] ? Tag.Open(HtmlTag[token.Type]) : Tag.Close(HtmlTag[token.Type]), + _ => token.Content + }); + + isClosed[token.Type] = !isClosed[token.Type]; + } + + return html.ToString(); + } +} \ No newline at end of file diff --git a/cs/Markdown/Converters/IConverter.cs b/cs/Markdown/Converters/IConverter.cs new file mode 100644 index 000000000..f5607d7f8 --- /dev/null +++ b/cs/Markdown/Converters/IConverter.cs @@ -0,0 +1,8 @@ +using Markdown.Tokenizers; + +namespace Markdown.Converters; + +public interface IConverter +{ + public string Convert(IEnumerable tokens); +} \ No newline at end of file diff --git a/cs/Markdown/Markdown.csproj b/cs/Markdown/Markdown.csproj new file mode 100644 index 000000000..85b49591f --- /dev/null +++ b/cs/Markdown/Markdown.csproj @@ -0,0 +1,10 @@ + + + + Exe + net9.0 + enable + enable + + + diff --git a/cs/Markdown/Md.cs b/cs/Markdown/Md.cs new file mode 100644 index 000000000..0386c2fa4 --- /dev/null +++ b/cs/Markdown/Md.cs @@ -0,0 +1,51 @@ +using System.Text; +using Markdown.Converters; +using Markdown.Tokenizers; + +namespace Markdown; + +public class Md(ITokenizer tokenizer, IConverter converter) +{ + public string Render(string markdown) + { + if (string.IsNullOrEmpty(markdown)) + return string.Empty; + + var result = new StringBuilder(); + var paragraphs = markdown.Split(Environment.NewLine); + + foreach (var paragraph in paragraphs) + { + var htmlLine = ProcessParagraph(paragraph); + result.AppendLine(htmlLine); + } + + return result + .ToString() + .TrimEnd(Environment.NewLine.ToCharArray()); + } + + private string ProcessParagraph(string line) + { + var trimmedLine = line.TrimEnd(Environment.NewLine.ToCharArray()); + + if (trimmedLine.StartsWith("# ")) + { + var headerContent = trimmedLine[2..]; + var htmlContent = ParseMarkdown(headerContent); + return Tag.Wrap("h1", htmlContent); + } + else + { + var htmlContent = ParseMarkdown(trimmedLine); + return htmlContent; + } + } + + private string ParseMarkdown(string markdown) + { + var tokens = tokenizer.Tokenize(markdown); + var html = converter.Convert(tokens); + return html; + } +} \ No newline at end of file diff --git a/cs/Markdown/Program.cs b/cs/Markdown/Program.cs new file mode 100644 index 000000000..723b19aa0 --- /dev/null +++ b/cs/Markdown/Program.cs @@ -0,0 +1,16 @@ +using Markdown; +using Markdown.Converters; +using Markdown.Tokenizers; + +var tokenizer = new MdTokenizer(); +var converter = new HtmlConverter(); +var markdown = new Md(tokenizer, converter); + +const string firstExample = "This _is_ a __sample__ markdown _file_."; +const string secondExample = "#This is another __sample__ markdown _file_"; + +const string input = firstExample; + +var result = markdown.Render(input); + +Console.WriteLine(result); \ No newline at end of file diff --git a/cs/Markdown/Tag.cs b/cs/Markdown/Tag.cs new file mode 100644 index 000000000..c1282e304 --- /dev/null +++ b/cs/Markdown/Tag.cs @@ -0,0 +1,10 @@ +namespace Markdown; + +public static class Tag +{ + public static string Open(string tagName) => $"<{tagName}>"; + + public static string Close(string tagName) => $""; + + public static string Wrap(string tagName, string content) => $"{Open(tagName)}{content}{Close(tagName)}"; +} \ No newline at end of file diff --git a/cs/Markdown/Token.cs b/cs/Markdown/Token.cs new file mode 100644 index 000000000..7a433c3c0 --- /dev/null +++ b/cs/Markdown/Token.cs @@ -0,0 +1,8 @@ +namespace Markdown; + +public record Token +{ + public TokenType Type { get; init; } + public required string Content { get; init; } + public Token? Pair { get; set; } +} \ No newline at end of file diff --git a/cs/Markdown/TokenExtensions.cs b/cs/Markdown/TokenExtensions.cs new file mode 100644 index 000000000..a29b5a6bb --- /dev/null +++ b/cs/Markdown/TokenExtensions.cs @@ -0,0 +1,12 @@ +namespace Markdown; + +public static class TokenExtensions +{ + public static void RemovePair(this Token? token) + { + if (token?.Pair == null) return; + + token.Pair.Pair = null; + token.Pair = null; + } +} \ No newline at end of file diff --git a/cs/Markdown/TokenType.cs b/cs/Markdown/TokenType.cs new file mode 100644 index 000000000..d4f01a473 --- /dev/null +++ b/cs/Markdown/TokenType.cs @@ -0,0 +1,11 @@ +namespace Markdown; + +public enum TokenType +{ + Punctuation, + Text, + Italic, + Strong, + Escape, + WhiteSpace +} \ No newline at end of file diff --git a/cs/Markdown/Tokenizers/ITokenizer.cs b/cs/Markdown/Tokenizers/ITokenizer.cs new file mode 100644 index 000000000..512299f26 --- /dev/null +++ b/cs/Markdown/Tokenizers/ITokenizer.cs @@ -0,0 +1,6 @@ +namespace Markdown.Tokenizers; + +public interface ITokenizer +{ + public List Tokenize(string markdown); +} \ No newline at end of file diff --git a/cs/Markdown/Tokenizers/MdTokenizer.cs b/cs/Markdown/Tokenizers/MdTokenizer.cs new file mode 100644 index 000000000..7c20b7bc6 --- /dev/null +++ b/cs/Markdown/Tokenizers/MdTokenizer.cs @@ -0,0 +1,123 @@ +namespace Markdown.Tokenizers; + +public class MdTokenizer : ITokenizer +{ + private readonly Dictionary tokensSymbols = new() + { + { TokenType.Escape, "\\" }, + { TokenType.Italic, "_" }, + { TokenType.Strong, "__" }, + { TokenType.WhiteSpace, " " }, + }; + + private readonly HashSet pairableTokens = + [ + TokenType.Italic, + TokenType.Strong + ]; + + public List Tokenize(string markdown) + { + return GetTokens(markdown) + .CreateTokenPairs(TokenType.Strong) + .SplitFreeStrongTokens(tokensSymbols) + .CreateTokenPairs(TokenType.Italic) + .FilterStrongInsideItalic() + .ReplacePairless(pairableTokens, tokensSymbols); + } + + private List GetTokens(string markdown) + { + var tokens = new List(); + + for (var index = 0; index < markdown.Length;) + { + tokens.Add(markdown[index] switch + { + '\\' => HandleEscape(ref index, markdown), + '_' => HandleUnderscore(ref index, markdown), + ' ' => HandleWhitespace(ref index), + var c when char.IsPunctuation(c) => HandlePunctuation(ref index, markdown[index]), + _ => HandleText(ref index, markdown[index]) + }); + } + + return tokens; + } + + private Token HandleEscape(ref int index, string markdown) + { + var currentChar = markdown[index].ToString(); + var nextChar = markdown[index + 1].ToString(); + + if (index + 1 < markdown.Length && tokensSymbols.ContainsValue(nextChar)) + { + index += 2; + return new Token + { + Type = TokenType.Escape, + Content = nextChar, + }; + } + + index++; + return new Token + { + Type = TokenType.Escape, + Content = currentChar, + }; + } + + private Token HandlePunctuation(ref int index, char markdown) + { + index++; + return new Token + { + Type = TokenType.Punctuation, + Content = markdown.ToString(), + }; + } + + private Token HandleText(ref int index, char markdown) + { + index++; + return new Token + { + Type = TokenType.Text, + Content = markdown.ToString(), + }; + } + + private Token HandleWhitespace(ref int index) + { + index++; + return new Token + { + Type = TokenType.WhiteSpace, + Content = tokensSymbols[TokenType.WhiteSpace], + }; + } + + private Token HandleUnderscore(ref int index, string markdown) + { + var tokenType = GetTokenType(markdown, index); + var tokenLenght = tokenType == TokenType.Strong ? 2 : 1; + index += tokenLenght; + + return new Token + { + Type = tokenType, + Content = tokensSymbols[tokenType], + }; + } + + private TokenType GetTokenType(string text, int index) + { + if (index + 1 < text.Length && text[index + 1] == tokensSymbols[TokenType.Italic][0]) + { + return TokenType.Strong; + } + + return TokenType.Italic; + } +} \ No newline at end of file diff --git a/cs/Markdown/Tokenizers/TokenListExtension.cs b/cs/Markdown/Tokenizers/TokenListExtension.cs new file mode 100644 index 000000000..524f662ee --- /dev/null +++ b/cs/Markdown/Tokenizers/TokenListExtension.cs @@ -0,0 +1,117 @@ +namespace Markdown.Tokenizers; + +public static class TokenListExtensions +{ + public static List ReplacePairless(this List tokens, HashSet pairableTokens, Dictionary tokensSymbols) + { + var result = new List(); + + foreach (var token in tokens) + { + if (token.Pair == null && pairableTokens.Contains(token.Type)) + { + result.Add(new Token + { + Type = TokenType.Text, + Content = tokensSymbols[token.Type], + }); + } + else result.Add(token); + } + + return result; + } + + public static List CreateTokenPairs(this List tokens, TokenType type) + { + var openStack = new Stack(); + + for (var index = 0; index < tokens.Count; index++) + { + var token = tokens[index]; + if (token.Type != type) continue; + + if (tokens.IsOpening(index)) + { + openStack.Push(token); + } + else if (openStack.Count > 0 && tokens.IsClosing(index)) + { + var opening = openStack.Pop(); + token.Pair = opening; + opening.Pair = token; + } + } + + return tokens; + } + + public static List SplitFreeStrongTokens(this List tokens, Dictionary tokenSymbols) + { + var result = new List(); + + foreach (var token in tokens) + { + if (token is { Type: TokenType.Strong, Pair: null }) + { + result.Add(new Token + { + Type = TokenType.Italic, + Content = tokenSymbols[TokenType.Italic], + }); + result.Add(new Token + { + Type = TokenType.Italic, + Content = tokenSymbols[TokenType.Italic], + }); + } + else + { + result.Add(token); + } + } + + return result; + } + + public static List FilterStrongInsideItalic(this List tokens) + { + var isItalicOpen = false; + Token? italicOpening = null; + + for (var index = 0; index < tokens.Count; index++) + { + var token = tokens[index]; + if (token.Pair == null) continue; + + if (token.Type == TokenType.Italic) + { + isItalicOpen = !isItalicOpen; + italicOpening = isItalicOpen ? token : null; + } + else if (token.Type == TokenType.Strong && isItalicOpen && !tokens.IsNearItalic(index)) + { + italicOpening?.RemovePair(); + token.RemovePair(); + isItalicOpen = false; + } + } + + return tokens; + } + + private static bool IsOpening(this List tokens, int index) + { + return index != tokens.Count - 1 && (index == 0 || (index - 1 >= 0 && tokens[index - 1].Type != TokenType.Text)); + } + + private static bool IsClosing(this List tokens, int index) + { + return index == tokens.Count - 1 || (index + 1 <= tokens.Count - 1 && tokens[index + 1].Type != TokenType.Text); + } + + private static bool IsNearItalic(this List tokens, int index) + { + return index + 1 < tokens.Count && tokens[index + 1].Type == TokenType.Italic; + } +} \ No newline at end of file diff --git a/cs/MarkdownTests/MarkdownTests.csproj b/cs/MarkdownTests/MarkdownTests.csproj new file mode 100644 index 000000000..36e7d5ec2 --- /dev/null +++ b/cs/MarkdownTests/MarkdownTests.csproj @@ -0,0 +1,28 @@ + + + + net9.0 + latest + enable + enable + false + + + + + + + + + + + + + + + + + + + + diff --git a/cs/MarkdownTests/MdTests.cs b/cs/MarkdownTests/MdTests.cs new file mode 100644 index 000000000..874b01c93 --- /dev/null +++ b/cs/MarkdownTests/MdTests.cs @@ -0,0 +1,238 @@ +using System.Diagnostics; +using System.Text; +using FluentAssertions; +using Markdown; +using Markdown.Converters; +using Markdown.Tokenizers; + + +namespace MarkdownTests; + +[TestFixture] +public class MdTests +{ + private Md renderer; + + [SetUp] + public void Setup() + { + var tokenizer = new MdTokenizer(); + var converter = new HtmlConverter(); + renderer = new Md(tokenizer, converter); + } + + [Test] + public void Render_EmptyString_ReturnsEmpty() + { + var result = renderer.Render(""); + result.Should().Be(string.Empty); + } + + [Test] + public void Render_NestedItalicNearStrong() + { + var result = renderer.Render("___some text___"); + result.Should().Be("some text"); + } + + [Test] + public void Render_BeginWithPairlessStrong() + { + var result = renderer.Render("__Some_"); + result.Should().Be("_Some"); + } + + [Test] + public void Render_EndWithPairlessStrong() + { + var result = renderer.Render("_Some__"); + result.Should().Be("Some_"); + } + + [Test] + public void Render_Italic() + { + var result = renderer.Render("Текст, _окруженный с двух сторон_ одинарными символами подчерка"); + result.Should().Be("Текст, окруженный с двух сторон одинарными символами подчерка"); + } + + [Test] + public void Render_Same_WhenUnderscoresInsideWords() + { + var result = renderer.Render("Some_Another_Text"); + result.Should().Be("Some_Another_Text"); + } + + [Test] + public void Render_Strong() + { + var result = renderer.Render("__Выделенный двумя символами текст__"); + result.Should().Be("Выделенный двумя символами текст"); + } + + [Test] + public void Render_Escaped() + { + var result = renderer.Render(@"\_Вот это\_, не должно выделиться"); + result.Should().Be("_Вот это_, не должно выделиться"); + } + + [Test] + public void Render_FakeEscape() + { + var result = renderer.Render(@"123\456"); + result.Should().Be(@"123\456"); + } + + [Test] + public void Render_EscapeEscape() + { + var result = renderer.Render(@"\\_some text_"); + result.Should().Be(@"\some text"); + } + + [Test] + public void Render_ItalicInsideStrong() + { + var result = renderer.Render("Внутри __двойного выделения _одинарное_ тоже__ работает."); + result.Should().Be("Внутри двойного выделения одинарное тоже работает."); + } + + [Test] + public void Render_DoubleNested() + { + var result = renderer.Render("Внутри __двойного _выделения_ _одинарное_ тоже__ работает."); + result.Should().Be("Внутри двойного выделения одинарное тоже работает."); + } + + [Test] + public void Render_Same_StrongInsideItalic() + { + var result = renderer.Render("Но не наоборот — внутри _одинарного __двойное__ не_ работает."); + result.Should().Be("Но не наоборот — внутри _одинарного __двойное__ не_ работает."); + } + + [Test] + public void Render_Same_WhenInsideDigits() + { + var result = renderer.Render("цифрами_12_3"); + result.Should().Be("цифрами_12_3"); + } + + [Test] + public void Render_UnderscoresInsideWords() + { + var result = renderer.Render("Однако выделять часть слова они могут: и в _нач_але, и в сер_еди_не, и в кон_це._"); + result.Should().Be("Однако выделять часть слова они могут: и в нач_але, и в сер_еди_не, и в кон_це."); + } + + [Test] + public void Render_ItalicDifferentWords() + { + var result = renderer.Render(@"В то же время выделение в ра_зных сл_овах не работает."); + result.Should().Be("В то же время выделение в ра_зных сл_овах не работает."); + } + + [Test] + public void Render_Same_NonPaired() + { + var result = renderer.Render(@"__Непарные_ символы в рамках одного абзаца не считаются выделением."); + result.Should().Be("_Непарные символы в рамках одного абзаца не считаются выделением."); + } + + [Test] + public void Render_ItalicMustHaveNonWhitespaceAfterOpen() + { + var result = renderer.Render(@"За подчерками, начинающими выделение, должен следовать непробельный символ. Иначе эти_ подчерки_ не считаются выделением и остаются просто символами подчерка."); + result.Should().Be("За подчерками, начинающими выделение, должен следовать непробельный символ. Иначе эти_ подчерки_ не считаются выделением и остаются просто символами подчерка."); + } + + [Test] + public void Render_ItalicMustHaveNonWhitespaceBeforeClose() + { + var result = renderer.Render(@"Иначе эти _подчерки _не считаются_ окончанием"); + result.Should().Be("Иначе эти _подчерки не считаются окончанием"); + } + + [Test] + public void Render_Same_OverlappingPairs() + { + var result = renderer.Render(@"В случае __пересечения _двойных__ и одинарных_ подчерков ни один из них не считается выделением."); + result.Should().Be("В случае __пересечения _двойных__ и одинарных_ подчерков ни один из них не считается выделением."); + } + + [Test] + public void Render_EmptyUnderscores() + { + var result = renderer.Render(@"Если внутри подчерков пустая строка ____, то они остаются символами подчерка."); + result.Should().Be("Если внутри подчерков пустая строка ____, то они остаются символами подчерка."); + } + + [Test] + public void Render_Header() + { + var result = renderer.Render("# Header text"); + result.Should().Be("

Header text

"); + } + + [Test] + public void Render_HeaderWithTokens() + { + var result = renderer.Render(@"# Заголовок __с _разными_ символами__"); + result.Should().Be("

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

"); + } + + [Test] + public void Render_NewLineHeader() + { + var result = renderer.Render(""" + # Заголовок __с _разными_ символами__ + # Заголовок __с _разными_ символами__ + """); + result.Should().Be(""" +

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

+

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

+ """); + } + + [Test] + public void Render_IsLinear() + { + const int tolerance = 2; + const int firstRunCount = 100_000; + const int secondRunCount = 1_000_000; + const double expectedCoefficient = secondRunCount / firstRunCount + tolerance; + + var markdown = new StringBuilder(); + for (var i = 0; i < firstRunCount; i++) + { + markdown.Append("_text_"); + markdown.Append("__text__"); + } + + var firstRun = GetRunTime(() => renderer.Render(markdown.ToString()));; + markdown.Clear(); + + for (var i = 0; i < secondRunCount; i++) + { + markdown.Append("_text_"); + markdown.Append("__text__"); + } + + + var secondRun = GetRunTime(() => renderer.Render(markdown.ToString())); + + var coefficient = secondRun / firstRun; + + coefficient.Should().BeLessThan(expectedCoefficient); + } + + private static double GetRunTime(Action run) + { + var sw = Stopwatch.StartNew(); + run.Invoke(); + sw.Stop(); + + return sw.ElapsedMilliseconds; + } +} \ No newline at end of file diff --git a/cs/clean-code.sln b/cs/clean-code.sln index 2206d54db..cc56d3b30 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", "{2AEFA03E-ABDD-44A0-98ED-109EE8BDAA6A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MarkdownTests", "MarkdownTests\MarkdownTests.csproj", "{25FE0800-F08D-437D-87B1-3DA0AF9B15B5}" +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 + {2AEFA03E-ABDD-44A0-98ED-109EE8BDAA6A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2AEFA03E-ABDD-44A0-98ED-109EE8BDAA6A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2AEFA03E-ABDD-44A0-98ED-109EE8BDAA6A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2AEFA03E-ABDD-44A0-98ED-109EE8BDAA6A}.Release|Any CPU.Build.0 = Release|Any CPU + {25FE0800-F08D-437D-87B1-3DA0AF9B15B5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {25FE0800-F08D-437D-87B1-3DA0AF9B15B5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {25FE0800-F08D-437D-87B1-3DA0AF9B15B5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {25FE0800-F08D-437D-87B1-3DA0AF9B15B5}.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..229f449d2 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" 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" Prefix="" Suffix="" Style="AaBb_AaBb" /></Policy> + True True True Imported 10.10.2016