diff --git a/Markdown/Block.cs b/Markdown/Block.cs new file mode 100644 index 000000000..9d5ae09ab --- /dev/null +++ b/Markdown/Block.cs @@ -0,0 +1,13 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Markdown +{ + internal class Block(List tokens) + { + public List tokens = tokens; + } +} diff --git a/Markdown/EmphasisProcessor.cs b/Markdown/EmphasisProcessor.cs new file mode 100644 index 000000000..759ca06ce --- /dev/null +++ b/Markdown/EmphasisProcessor.cs @@ -0,0 +1,215 @@ +using System.Collections.Generic; + +namespace Markdown +{ + internal sealed class EmphasisProcessor : ITokenProcessor + { + public List Process(List tokens) + { + + int n = tokens.Count; + var pair = new int[n]; + for (int i = 0; i < n; i++) pair[i] = -1; + + int lastOpenEm = -1; + int lastOpenStrong = -1; + + bool isEmOpenInsideWord = false; + bool isStrongOpenInsideWord = false; + bool isInsideWord = false; + + int lastDigit = -1; + + for(int i = 0; i < n; i++) // первый проход по массиву токенов, делаем первоначальный матчинг пар + { + if (tokens[i].Type == TokenType.Text || + tokens[i].Type == TokenType.Digit) + { + isInsideWord = true; + } + if (tokens[i].Type == TokenType.Digit) + { + lastDigit = i; + lastOpenEm = -1; + lastOpenStrong = -1; + isEmOpenInsideWord = false; + isStrongOpenInsideWord = false; + } + if (tokens[i].Type == TokenType.Whitespace) + { + isInsideWord = false; + if (isEmOpenInsideWord) + { + isEmOpenInsideWord = false; + lastOpenEm = -1; + } + if (isStrongOpenInsideWord) + { + isStrongOpenInsideWord = false; + lastOpenStrong = -1; + } + continue; + } + if (tokens[i].Type == TokenType.Digit) + { + lastOpenEm = -1; + lastOpenStrong = -1; + } + if (tokens[i].Type == TokenType.Underscore) + { + if (lastOpenEm != -1 && tokens[i].CanClose) + { + pair[lastOpenEm] = i; + pair[i] = lastOpenEm; + lastOpenEm = -1; + continue; + } + if(tokens[i].CanOpen) + { + lastOpenEm = i; + isEmOpenInsideWord = isInsideWord; + continue; + } + } + if (tokens[i].Type == TokenType.DoubleUnderscore) + { + if (lastOpenStrong != -1 && tokens[i].CanClose) + { + pair[lastOpenStrong] = i; + pair[i] = lastOpenStrong; + lastOpenStrong = -1; + continue; + } + if (tokens[i].CanOpen) + { + lastOpenStrong = i; + isStrongOpenInsideWord = isInsideWord; + continue; + } + } + } + // обрабатывает различные неучтённые при первом проходе ситуации + bool isEmOpen = false; + bool isEmInInterval = false; + bool isStrongInInterval = false; + bool isStrongOpen = false; + int lastEmPair = -1; + int lastStrongPair = -1; + for (int i = 0; i < n; i++) + { + if (pair[i] == -1) + { + isEmInInterval |= tokens[i].Type == TokenType.Underscore && + isStrongOpen; + isStrongInInterval |= tokens[i].Type == TokenType.DoubleUnderscore && + isEmOpen; + continue; + } + if (tokens[i].Type == TokenType.Underscore) + { + bool isOpening = pair[i] > i; + isEmOpen = isOpening; + if (isOpening) + { + lastEmPair = i; + isStrongInInterval = false; + } + else if (isStrongOpen && lastEmPair < lastStrongPair ) + { + + pair[pair[lastStrongPair]] = -1; + pair[lastStrongPair] = -1; + pair[pair[i]] = -1; + pair[i] = -1; + isEmOpen = false; + isStrongOpen = false; + } + else if (isStrongInInterval || Math.Abs(i - pair[i]) == 1) + { + pair[pair[i]] = -1; + pair[i] = -1; + isEmOpen = false; + } + } + else + { + bool isOpening = pair[i] > i; + isStrongOpen = isOpening; + if (isOpening) + { + lastStrongPair = i; + isEmInInterval = false; + } + else if (isEmOpen && lastEmPair > lastStrongPair) + { + pair[pair[lastEmPair]] = -1; + pair[lastEmPair] = -1; + pair[pair[i]] = -1; + pair[i] = -1; + isEmOpen = false; + isStrongOpen = false; + } + else if (isEmInInterval || Math.Abs(i - pair[i]) == 1) + { + pair[pair[i]] = -1; + pair[i] = -1; + isStrongOpen = false; + } + } + } + + // обрабатывает ситуацию "_пример __теста__ пример_" -> "пример __теста__ пример" + isEmOpen = false; + for(int i = 0; i < n; i++) + { + if (pair[i] == -1) + { + continue; + } + if (tokens[i].Type == TokenType.Underscore) + { + isEmOpen = pair[i] > i; + } + else + { + if (isEmOpen) + { + pair[pair[i]] = -1; + pair[i] = -1; + } + } + } + + + var result = new List(n); + for (int i = 0; i < n; i++) + { + var t = tokens[i]; + + if (t.Type == TokenType.Underscore || t.Type == TokenType.DoubleUnderscore) + { + + if (pair[i] != -1) + { + bool isOpening = pair[i] > i; + if (t.Type == TokenType.DoubleUnderscore) + result.Add(new Token(TokenType.Tag, false, false, isOpening ? "" : "")); + else + result.Add(new Token(TokenType.Tag, false, false, isOpening ? "" : "")); + continue; + } + else + { + result.Add(new Token(TokenType.Text, false, false, t.Text)); + continue; + } + + } + + result.Add(t); + } + + return result; + } + } +} diff --git a/Markdown/EscapeProcessor.cs b/Markdown/EscapeProcessor.cs new file mode 100644 index 000000000..e40833e97 --- /dev/null +++ b/Markdown/EscapeProcessor.cs @@ -0,0 +1,37 @@ +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace Markdown +{ + internal sealed class EscapeProcessor : ITokenProcessor + { + public List Process(List tokens) + { + var result = new List(); + for (int i = 0; i < tokens.Count; i++) + { + var t = tokens[i]; + if (t.Type != TokenType.Backslash) + { + result.Add(t); + continue; + } + if(i == tokens.Count - 1 || + (tokens[i + 1].Type != TokenType.DoubleUnderscore && + tokens[i + 1].Type != TokenType.Underscore && + tokens[i + 1].Type != TokenType.Backslash && + tokens[i+1].Type != TokenType.Grid)) + { + result.Add(new Token(TokenType.Text, false, false, "\\")); + continue; + } + + result.Add(new Token(TokenType.Text, false, false, tokens[i+1].Text)); + i += 1; + } + + return result; + } + } +} \ No newline at end of file diff --git a/Markdown/HeaderProcessor.cs b/Markdown/HeaderProcessor.cs new file mode 100644 index 000000000..3813385e9 --- /dev/null +++ b/Markdown/HeaderProcessor.cs @@ -0,0 +1,41 @@ +namespace Markdown +{ + internal class HeaderProcessor : IBlockProcessor + { + public List Process(List blocks) + { + var result = new List(); + foreach(var block in blocks) + { + result.Add(new Block(new List())); + for (int i = 0; i < block.tokens.Count; i++) { + if (block.tokens[i].Type == TokenType.NewLine) + { + result.Add(new Block(new List())); + } + else if(block.tokens[i].Type == TokenType.Grid && + i != block.tokens.Count -1 && + block.tokens[i+1].Type == TokenType.Whitespace && + result[result.Count - 1].tokens.Count == 0) + { + result[result.Count - 1].tokens.Add(new Token(TokenType.Tag, false, false, "

")); + i++; // пропуск пробела + } + else + { + result[result.Count - 1].tokens.Add(block.tokens[i]); + } + } + } + for(int block = 0; block < result.Count; block++) + { + if (result[block].tokens[0].Type == TokenType.Tag && + result[block].tokens[0].Text == "

") + { + result[block].tokens.Add(new Token(TokenType.Tag, false, false, "

")); + } + } + return result; + } + } +} \ No newline at end of file diff --git a/Markdown/IBlockProcessor.cs b/Markdown/IBlockProcessor.cs new file mode 100644 index 000000000..a195027ac --- /dev/null +++ b/Markdown/IBlockProcessor.cs @@ -0,0 +1,9 @@ +using System.Collections.Generic; + +namespace Markdown +{ + internal interface IBlockProcessor + { + List Process(List blocks); + } +} diff --git a/Markdown/ITokenProcessor.cs b/Markdown/ITokenProcessor.cs new file mode 100644 index 000000000..5c57af144 --- /dev/null +++ b/Markdown/ITokenProcessor.cs @@ -0,0 +1,9 @@ +using System.Collections.Generic; + +namespace Markdown +{ + internal interface ITokenProcessor + { + List Process(List tokens); + } +} diff --git a/Markdown/Md.cs b/Markdown/Md.cs new file mode 100644 index 000000000..31740bf7e --- /dev/null +++ b/Markdown/Md.cs @@ -0,0 +1,40 @@ +using System; + +namespace Markdown +{ + public class Md + { + private Tokenizer tokenizer = new Tokenizer(); + private List blockProcessors = new List + { + new HeaderProcessor() + }; + private List tokenProcessors = new List + { + new EscapeProcessor(), + new EmphasisProcessor() + }; + private RenderProcess renderProcess = new RenderProcess(); + + public string Render(string input) + { + if (input == null) return null; + var tokens = tokenizer.Tokenize(input); + var blocks = new List(); + blocks.Add(new Block(tokens)); + foreach (var blockProcesor in blockProcessors) + { + blocks = blockProcesor.Process(blocks); + } + foreach (var tokenProcesor in tokenProcessors) + { + for (int i = 0; i < blocks.Count; i++) + { + blocks[i] = new Block(tokenProcesor.Process(blocks[i].tokens)); + } + } + + return renderProcess.Render(blocks); + } + } +} diff --git a/Markdown/RenderProcess.cs b/Markdown/RenderProcess.cs new file mode 100644 index 000000000..a7301c31f --- /dev/null +++ b/Markdown/RenderProcess.cs @@ -0,0 +1,24 @@ +using System.Text; + +namespace Markdown +{ + internal class RenderProcess + { + internal string Render(List blocks) + { + var sb = new StringBuilder(); + for (int i = 0; i < blocks.Count; i++) + { + foreach (var token in blocks[i].tokens) + { + sb.Append(token.Text); + } + if (i != blocks.Count - 1) + { + sb.Append('\n'); + } + } + return sb.ToString(); + } + } +} \ No newline at end of file diff --git a/Markdown/Token.cs b/Markdown/Token.cs new file mode 100644 index 000000000..cb4e60742 --- /dev/null +++ b/Markdown/Token.cs @@ -0,0 +1,36 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Markdown +{ + internal enum TokenType { + Text, + Underscore, + DoubleUnderscore, + Digit, + NewLine, + Grid, + Whitespace, + Tag, + Backslash, + + } + internal class Token + { + public TokenType Type; + public bool CanOpen; + public bool CanClose; + public string Text; + + public Token(TokenType kind, bool canOpen, bool canClose, string text) + { + Type = kind; + CanOpen = canOpen; + CanClose = canClose; + Text = text; + } + } +} diff --git a/Markdown/Tokenizer.cs b/Markdown/Tokenizer.cs new file mode 100644 index 000000000..5731a9011 --- /dev/null +++ b/Markdown/Tokenizer.cs @@ -0,0 +1,105 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace Markdown +{ + internal sealed class Tokenizer + { + public List Tokenize(string s) + { + var result = new List(); + + int i = 0; + int len = s.Length; + + while (i < len) + { + char c = s[i]; + + switch (c) + { + case '_': + if (i + 1 < len && s[i + 1] == '_') + { + result.Add(new Token(TokenType.DoubleUnderscore, false, false, "__")); + i += 2; + } + else + { + result.Add(new Token(TokenType.Underscore, false, false, "_")); + i += 1; + } + break; + + case '\\': + result.Add(new Token(TokenType.Backslash, false, false, "\\")); + i++; + break; + + case '\n': + result.Add(new Token(TokenType.NewLine, false, false, "\n")); + i++; + break; + + case '#': + result.Add(new Token(TokenType.Grid, false, false, "#")); + i++; + break; + + default: + if (char.IsDigit(c)) + { + result.Add(new Token(TokenType.Digit, false, false, c.ToString())); + i++; + } + else if (char.IsWhiteSpace(c)) + { + result.Add(new Token(TokenType.Whitespace, false, false, " ")); + i++; + } + else + { + var sb = new StringBuilder(); + while (i < len) + { + char ch = s[i]; + if (ch == '_' || ch == '\\' || ch == '\n' || ch == '\r' || ch == '#' || + char.IsWhiteSpace(ch) || char.IsDigit(ch)) + break; + sb.Append(ch); + i++; + } + if (sb.Length > 0) + result.Add(new Token(TokenType.Text, false, false, sb.ToString())); + } + break; + } + } + + ComputeOpenCloseFlags(result); + + return result; + } + + private void ComputeOpenCloseFlags(List tokens) + { + for (int i = 0; i < tokens.Count; i++) + { + var t = tokens[i]; + if (t.Type != TokenType.Underscore && t.Type != TokenType.DoubleUnderscore) + continue; + + bool nextIsWhitespace = (i + 1 < tokens.Count) && tokens[i + 1].Type == TokenType.Whitespace; + bool prevIsWhitespace = (i - 1 >= 0) && tokens[i - 1].Type == TokenType.Whitespace; + + bool nextIsDigit = (i + 1 < tokens.Count) && tokens[i + 1].Type == TokenType.Digit; + bool prevIsDigit = (i - 1 >= 0) && tokens[i - 1].Type == TokenType.Digit; + + t.CanOpen = (i + 1 < tokens.Count) && !nextIsWhitespace && !nextIsDigit; + + t.CanClose = (i - 1 >= 0) && !prevIsWhitespace && !prevIsDigit; + } + } + } +} diff --git a/Markdown/tests/MarkdownExtraTests.cs b/Markdown/tests/MarkdownExtraTests.cs new file mode 100644 index 000000000..b3e02723d --- /dev/null +++ b/Markdown/tests/MarkdownExtraTests.cs @@ -0,0 +1,83 @@ +// MarkdownExtraTests.cs +using NUnit.Framework; +using FluentAssertions; +using Markdown; + +namespace Markdown.tests +{ + [TestFixture] + public class MarkdownExtraTests + { + private Md md; + + [SetUp] + public void Setup() + { + md = new Md(); + } + + [Test] + public void MarkdownSpec_ShouldReturnNull_WhenInputIsNull() + { + md.Render(null).Should().Be(null); + } + + [TestCase("#НеЗаголовок", "#НеЗаголовок", TestName = "Без пробела # не заголовок")] + [TestCase("_пример число12 текста_", "_пример число12 текста_", TestName = "Числа не выделяются em")] + [TestCase("__пример число12 текста__", "__пример число12 текста__", TestName = "Числа не выделяются strong")] + [TestCase("текст_нет выделения_конец", "текст_нет выделения_конец", TestName = "Раздельные части не выделяются")] + [TestCase("____", "____", TestName = "Пустая строка не выделяется strong")] + [TestCase("__", "__", TestName = "Пустая строка не выделяется em")] + public void MarkdownSpec_ShouldReturnExpectedValue_WhenNoChangesExpected(string input, string expected) + { + md.Render(input).Should().Be(expected); + } + [TestCase("_выделение_конец", "выделениеконец", TestName = "Выделение в начале em")] + [TestCase("текст_выделение_конец", "текствыделениеконец", TestName = "Выделение в середине em")] + [TestCase("текст_выделение_", "текствыделение", TestName = "Выделение в конце em")] + [TestCase("_а__б__с_", "а__б__с", TestName = " не работает в em")] + [TestCase("_а_ _б_", "а б", TestName = "Два тега em")] + [TestCase("_пример _текста_ пример_", "_пример текста пример_", TestName = "Нет вложенных em")] + [TestCase("a_b c_d_", "a_b cd", TestName = "Пробел блокирует выделение em")] + public void MarkdownSpec_ShouldReturnExpectedValue_WhenEmExpected(string input, string expected) + { + md.Render(input).Should().Be(expected); + } + [TestCase("__выделение__конец", "выделениеконец", TestName = "Выделение в начале strong")] + [TestCase("текст__выделение__конец", "текствыделениеконец", TestName = "Выделение в середине strong")] + [TestCase("текст__выделение__", "текствыделение", TestName = "Выделение в конце strong")] + [TestCase("__а__ __б__", "а б", TestName = "Два тега strong")] + [TestCase("__пример __текста__ пример__", "__пример текста пример__", TestName = "Нет вложенных strong")] + [TestCase("a__b c__d__", "a__b cd", TestName = "Пробел блокирует выделение strong")] + public void MarkdownSpec_ShouldReturnExpectedValue_WhenStrongExpected(string input, string expected) + { + md.Render(input).Should().Be(expected); + } + [TestCase("__а_b_в__", "аbв",TestName = "em разрешено в strong")] + [TestCase("__a__ _b_", "a b")] + public void MarkdownSpec_ShouldReturnExpectedValue_WhenStrongAndEmExpected(string input, string expected) + { + md.Render(input).Should().Be(expected); + } + + [TestCase(@"\\_а_", @"\а", TestName = @"Экранироание \")] + [TestCase(@"\# Заголовок", "# Заголовок", TestName = "Экранирование #")] + [TestCase(@"\_пример 1 текста_", "_пример 1 текста_", TestName = "Экранирование без пары")] + [TestCase(@"\_x_", "_x_", TestName = "Экранирование em")] + [TestCase(@"\__x__", "__x__", TestName = "Экранирование strong")] + public void MarkdownSpec_ShouldReturnExpectedValue_WhenEscapingExpected(string input, string expected) + { + md.Render(input).Should().Be(expected); + } + + [TestCase("# Заголовок", "

Заголовок

", TestName = "Базовый заголовок")] + [TestCase("# Заголовок __bold__", "

Заголовок bold

", TestName = "Заголовок strong")] + [TestCase("# Заголовок _cursive_", "

Заголовок cursive

", TestName = "Заголовок em")] + + public void MarkdownSpec_ShouldReturnExpectedValue_WhenHeaderExpected(string input, string expected) + { + md.Render(input).Should().Be(expected); + } + + } +} diff --git a/Markdown/tests/MarkdownPerformanceTests.cs b/Markdown/tests/MarkdownPerformanceTests.cs new file mode 100644 index 000000000..02ea44bb6 --- /dev/null +++ b/Markdown/tests/MarkdownPerformanceTests.cs @@ -0,0 +1,70 @@ +using System; +using System.Diagnostics; +using System.Text; +using FluentAssertions; +using NUnit.Framework; +using Markdown; + +namespace Markdown.tests +{ + [TestFixture] + public class MarkdownPerformanceTests + { + private Md md; + + [SetUp] + public void Setup() + { + md = new Md(); + } + + private string MakeInput(int minLength) + { + var sb = new StringBuilder(minLength + 100); + int i = 0; + while (sb.Length < minLength) + { + sb.Append("слово"); + sb.Append(i % 10); + sb.Append("_"); + sb.Append("внутри"); + sb.Append(i % 100); + sb.Append("__bold__ "); + if (i % 7 == 0) sb.Append(@"\_esc\_ "); + i++; + } + return sb.ToString(); + } + + [Test] + public void Render_ShouldScaleApproximatelyLinearly() + { + + int n1 = 50_000; + int n2 = 2_000_000; + var s1 = MakeInput(n1); + var s2 = MakeInput(n2); + + var sw = Stopwatch.StartNew(); + md.Render(s1); + sw.Stop(); + double t1 = sw.Elapsed.TotalMilliseconds; + + sw.Restart(); + md.Render(s2); + sw.Stop(); + double t2 = sw.Elapsed.TotalMilliseconds; + + double perChar1 = t1 / s1.Length; + double perChar2 = t2 / s2.Length; + + TestContext.WriteLine($"Размер1={s1.Length}, время1={t1} ms, на символ={perChar1} ms"); + TestContext.WriteLine($"Размер2={s2.Length}, время2={t2} ms, на символ={perChar2} ms"); + + // Допустимый коэффициент роста времени на символ. + double allowedFactor = 3.0; + + perChar2.Should().BeLessThan(perChar1 * allowedFactor); + } + } +} diff --git a/Markdown/tests/MarkdownSpecTests.cs b/Markdown/tests/MarkdownSpecTests.cs new file mode 100644 index 000000000..b4edf8799 --- /dev/null +++ b/Markdown/tests/MarkdownSpecTests.cs @@ -0,0 +1,55 @@ +// MdTests.cs +using NUnit.Framework; +using FluentAssertions; +using Markdown; + +namespace Markdown.tests +{ + [TestFixture] + public class MarkdownSpecTests + { + private Md md; + + [SetUp] + public void Setup() + { + md = new Md(); + } + + [TestCase("Текст, _окруженный с двух сторон_ одинарными символами", + "Текст, окруженный с двух сторон одинарными символами")] + [TestCase("__Выделенный двумя символами текст__", + "Выделенный двумя символами текст")] + [TestCase(@"\_Вот это\_, не должно выделиться тегом", + "_Вот это_, не должно выделиться тегом")] + [TestCase(@"Здесь сим\волы экранирования\ \должны остаться.\", + @"Здесь сим\волы экранирования\ \должны остаться.\")] + [TestCase(@"Символ экранирования тоже можно экранировать: \\_вот_ это будет выделено тегом", + @"Символ экранирования тоже можно экранировать: \вот это будет выделено тегом")] + [TestCase("Внутри __двойного выделения _одинарное_ тоже__ работает.", + "Внутри двойного выделения одинарное тоже работает.")] + [TestCase("Но не наоборот — внутри _одинарного __двойное__ не_ работает.", + "Но не наоборот — внутри одинарного __двойное__ не работает.")] + [TestCase("Подчерки внутри текста c цифрами_12_3 не считаются выделением и должны оставаться символами подчерка.", + "Подчерки внутри текста c цифрами_12_3 не считаются выделением и должны оставаться символами подчерка.")] + [TestCase("Однако выделять часть слова они могут: и в _нач_але, и в сер_еди_не, и в кон_це._", + "Однако выделять часть слова они могут: и в начале, и в середине, и в конце.")] + [TestCase("В то же время выделение в ра_зных сл_овах не работает.", + "В то же время выделение в ра_зных сл_овах не работает.")] + [TestCase("__Непарные_ символы в рамках одного абзаца не считаются выделением.", + "__Непарные_ символы в рамках одного абзаца не считаются выделением.")] + [TestCase("эти_ подчерки_ не считаются выделением и остаются просто символами подчерка.", + "эти_ подчерки_ не считаются выделением и остаются просто символами подчерка.")] + [TestCase("Иначе эти _подчерки _не считаются_ окончанием выделения", + "Иначе эти _подчерки не считаются окончанием выделения")] + [TestCase("В случае __пересечения _двойных__ и одинарных_ подчерков ни один из них не считается выделением.", + "В случае __пересечения _двойных__ и одинарных_ подчерков ни один из них не считается выделением.")] + [TestCase("Если внутри подчерков пустая строка ____, то они остаются символами подчерка.", + "Если внутри подчерков пустая строка ____, то они остаются символами подчерка.")] + [TestCase("# Заголовок __с _разными_ символами__", + "

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

")] + public void MarkdownSpec_ShouldReturnExpectedValue(string input, string expected) { + md.Render(input).Should().Be(expected); + } + } +}