diff --git a/cs/Markdown.Tests/Markdown.Tests.csproj b/cs/Markdown.Tests/Markdown.Tests.csproj new file mode 100644 index 000000000..ba72b41b0 --- /dev/null +++ b/cs/Markdown.Tests/Markdown.Tests.csproj @@ -0,0 +1,36 @@ + + + + net8.0 + enable + enable + true + false + true + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + diff --git a/cs/Markdown.Tests/MdTests.cs b/cs/Markdown.Tests/MdTests.cs new file mode 100644 index 000000000..d18a89ccf --- /dev/null +++ b/cs/Markdown.Tests/MdTests.cs @@ -0,0 +1,208 @@ +using FluentAssertions; +using Markdown.Parsers.MdParsers; +using Markdown.Renderers; +using System.Diagnostics; + +namespace Markdown.Tests +{ + internal class MdTests + { + [Test] + public void Md_ThrowsException_ReceivingNullAsIParser() + { + Assert.Throws(() => new Md(null!, new RendererHTML())); + } + + [Test] + public void Md_ThrowsException_ReceivingNullAsIRenderer() + { + Assert.Throws(() => new Md(new ParserMd(), null!)); + } + + [TestCase("__Bold token__", "Bold token")] + [TestCase("_Italic token_", "Italic token")] + [TestCase("# Header token", "

Header token

")] + [TestCase("Text token", "Text token")] + [TestCase("# _Set_ __of__ tokens", "

Set of tokens

")] + public void Md_RendersCorrectly_SimpleTokens(string input, string expected) + { + var md = new Md(new ParserMd(), new RendererHTML()); + var actual = md.Render(input); + actual.Should().Be(expected); + } + + [TestCase(@"\_\_Text token\_\_", "__Text token__")] + [TestCase(@"\_Text token\_", "_Text token_")] + [TestCase(@"\# Text token", "# Text token")] + [TestCase(@"#\ Text token", "# Text token")] + public void Md_RendersCorrectly_SimpleEscapedTokens(string input, string expected) + { + var md = new Md(new ParserMd(), new RendererHTML()); + var actual = md.Render(input); + actual.Should().Be(expected); + } + + [TestCase(@"Ste\gosaur\us", @"Ste\gosaur\us")] + [TestCase(@"\", @"\")] + [TestCase(@"_Italic \token_", @"Italic \token")] + public void Md_RendersCorrectly_WhenEscapedNothing(string input, string expected) + { + var md = new Md(new ParserMd(), new RendererHTML()); + var actual = md.Render(input); + actual.Should().Be(expected); + } + + [TestCase(@"\\a", @"\a")] + [TestCase(@"\\_\\_Text token\\_\\_", @"\\Text token\\")] + [TestCase(@"\\_Italic token\\_", @"\Italic token\")] + public void Md_RendersCorrectly_WhenEscapeEscaped(string input, string expected) + { + var md = new Md(new ParserMd(), new RendererHTML()); + var actual = md.Render(input); + actual.Should().Be(expected); + } + + [TestCase("__Outer bold _Inner italic part_ outer bold__", "Outer bold Inner italic part outer bold")] + public void Md_RendersCorrectly_ItalicInsideBold(string input, string expected) + { + var md = new Md(new ParserMd(), new RendererHTML()); + var actual = md.Render(input); + actual.Should().Be(expected); + } + + [TestCase("_Outer italic __Inner Bold part__ outer Italic_", "Outer italic __Inner Bold part__ outer Italic")] + public void Md_RendersCorrectly_BoldInsideItalic(string input, string expected) + { + var md = new Md(new ParserMd(), new RendererHTML()); + var actual = md.Render(input); + actual.Should().Be(expected); + } + + [TestCase("_Digits 12 3_", "Digits 12 3")] + [TestCase("Digits_12_3", "Digits_12_3")] + [TestCase("Digits__12__3", "Digits__12__3")] + public void Md_RendersCorrectly_DigitsWithUnderscores(string input, string expected) + { + var md = new Md(new ParserMd(), new RendererHTML()); + var actual = md.Render(input); + actual.Should().Be(expected); + } + + + [TestCase("__Sta__rt", "Start")] + [TestCase("_Sta_rt", "Start")] + [TestCase("S__tar__t", "Start")] + [TestCase("S_tar_t", "Start")] + [TestCase("St__art__", "Start")] + [TestCase("St_art_", "Start")] + public void Md_RendersCorrectly_WordsWithUnderscores(string input, string expected) + { + var md = new Md(new ParserMd(), new RendererHTML()); + var actual = md.Render(input); + actual.Should().Be(expected); + } + + [TestCase("Hel_lo, Wor_ld", "Hel_lo, Wor_ld")] + [TestCase("Hel__lo, Wor__ld", "Hel__lo, Wor__ld")] + public void Md_RendersCorrectly_UnderscoresInsideDifferentWords(string input, string expected) + { + var md = new Md(new ParserMd(), new RendererHTML()); + var actual = md.Render(input); + actual.Should().Be(expected); + } + + [TestCase("_Hello world__", "_Hello world__")] + [TestCase("__Hello world_", "__Hello world_")] + public void Md_RendersCorrectly_UnpairedTags(string input, string expected) + { + var md = new Md(new ParserMd(), new RendererHTML()); + var actual = md.Render(input); + actual.Should().Be(expected); + } + + [TestCase("_ Hello world_", "_ Hello world_")] + [TestCase("_ Hello world _", "_ Hello world _")] + [TestCase("_Hello world _", "_Hello world _")] + [TestCase("__ Hello world__", "__ Hello world__")] + [TestCase("__ Hello world __", "__ Hello world __")] + [TestCase("__Hello world __", "__Hello world __")] + public void Md_RendersCorrectly_WithSpaceAfterOrBeforeTag(string input, string expected) + { + var md = new Md(new ParserMd(), new RendererHTML()); + var actual = md.Render(input); + actual.Should().Be(expected); + } + + [TestCase("_Hello__ _world__", "_Hello__ _world__")] + [TestCase("__Hello_ __world_", "__Hello_ __world_")] + public void Md_RendersCorrectly_WithUnderscoreIntersections(string input, string expected) + { + var md = new Md(new ParserMd(), new RendererHTML()); + var actual = md.Render(input); + actual.Should().Be(expected); + } + + [TestCase("__", "__")] + [TestCase("____", "____")] + public void Md_RendersCorrectly_UnderscoresWithEmptyValue(string input, string expected) + { + var md = new Md(new ParserMd(), new RendererHTML()); + var actual = md.Render(input); + actual.Should().Be(expected); + } + + [TestCase("# Header token 1\n# Header token 2", "

Header token 1

Header token 2

")] + [TestCase("this is regular sentence # 123", "this is regular sentence # 123")] + public void Md_RendersCorrectly_Heading(string input, string expected) + { + var md = new Md(new ParserMd(), new RendererHTML()); + var actual = md.Render(input); + actual.Should().Be(expected); + } + + [TestCase(5, 10, 0.5)] + public void Md_ShouldWorkInLinearTime(int iterations, int baseIterationSize, double measurementError) + { + var testSizes = new int[iterations]; + testSizes[0] = baseIterationSize; + for (var i = 1; i < iterations; i++) + { + testSizes[i] = 2 * testSizes[i - 1]; + } + + var executionTimes = GetExecutionTimes(testSizes); + + for (var i = 1; i < iterations; i++) + { + var growthFactor = executionTimes[i] / executionTimes[i - 1]; + Assert.That(growthFactor, Is.LessThanOrEqualTo(2.0 + measurementError)); + } + } + + private double[] GetExecutionTimes(int[] sizes) + { + var results = new double[sizes.Length]; + var md = new Md(new ParserMd(), new RendererHTML()); + md.Render(GenerateText(sizes[^1])); + + for (var i = 0; i < sizes.Length; i++) + { + md = new Md(new ParserMd(), new RendererHTML()); + var text = GenerateText(sizes[i]); + + var stopwatch = Stopwatch.StartNew(); + md.Render(text); + stopwatch.Stop(); + + results[i] = stopwatch.Elapsed.TotalMilliseconds; + } + + return results; + } + + private string GenerateText(int numberOfRepetitions) + => string.Concat( + Enumerable.Repeat(@"__This _is_ a__ simple text \_for\_ crea\ting complex _test_ __text__.", + numberOfRepetitions)); + } +} diff --git a/cs/Markdown.Tests/ParsersTests/ParserMdTest.cs b/cs/Markdown.Tests/ParsersTests/ParserMdTest.cs new file mode 100644 index 000000000..b158bdb53 --- /dev/null +++ b/cs/Markdown.Tests/ParsersTests/ParserMdTest.cs @@ -0,0 +1,77 @@ +using FluentAssertions; +using Markdown.Parsers.MdParsers; +using Markdown.Tokens.HtmlTokens; + +namespace Markdown.Tests.ParsersTests +{ + [TestFixture] + public class ParserMdTests + { + private ParserMd _parser; + private readonly List _expected = new List(); + + [SetUp] + public void SetUp() + { + _parser = new ParserMd(); + _expected.Clear(); + } + + [TestCase("__Bold token__")] + public void Parse_SimpleBoldText_Correctly(string text) + { + _expected.Add( + new BoldToken( + new TextToken("Bold token"))); + CheckCorrectness(text); + } + + [TestCase("# Header token")] + public void Parse_SimpleHeaderText_Correctly(string text) + { + _expected.Add( + new HeaderToken( + new TextToken("Header token"))); + CheckCorrectness(text); + } + + [TestCase("_Italic token_")] + public void Parse_SimpleItalicText_Correctly(string text) + { + _expected.Add( + new ItalicToken( + new TextToken("Italic token"))); + CheckCorrectness(text); + } + + [TestCase("Text Token")] + public void Parse_SimpleText_Correctly(string text) + { + _expected.Add( + new TextToken(text)); + CheckCorrectness(text); + } + + [TestCase("# _Set_ __of__ tokens")] + public void Parse_ComplexText_Correctly(string text) + { + _expected.Add( + new HeaderToken( + new SetToken( + new ItalicToken( + new TextToken("Set")), + new TextToken(" "), + new BoldToken( + new TextToken("of")), + new TextToken(" tokens")))); + CheckCorrectness(text); + } + + private void CheckCorrectness(string text) + { + var actual = _parser.Parse(text); + actual.Should().BeEquivalentTo(_expected, + options => options.RespectingRuntimeTypes()); + } + } +} diff --git a/cs/Markdown.Tests/RenderesTests/RendererHtmlTests.cs b/cs/Markdown.Tests/RenderesTests/RendererHtmlTests.cs new file mode 100644 index 000000000..9305b096a --- /dev/null +++ b/cs/Markdown.Tests/RenderesTests/RendererHtmlTests.cs @@ -0,0 +1,115 @@ +using FluentAssertions; +using Markdown.Renderers; +using Markdown.Tokens.HtmlTokens; + +namespace Markdown.Tests.RenderesTests +{ + [TestFixture] + public class RendererHtmlTests + { + private RendererHTML _renderer; + private readonly string _dummyText = "Dummy"; + + [SetUp] + public void ClearRenderer() + { + _renderer = new RendererHTML(); + } + + [Test] + public void RenderBold_ThrowsException_ReceivingNullAsToken() + { + Assert.Throws(() => _renderer.RenderBold(null!)); + } + + [Test] + public void RenderBold_RendersCorrectly() + { + _renderer.RenderBold( + new BoldToken( + new TextToken(_dummyText))); + _renderer.ToString().Should().Be($"{_dummyText}"); + } + + [Test] + public void RenderHeader_ThrowsException_ReceivingNullAsToken() + { + Assert.Throws(() => _renderer.RenderHeader(null!)); + } + + [Test] + public void RenderHeader_RendersCorrectly() + { + _renderer.RenderHeader( + new HeaderToken( + new TextToken(_dummyText))); + _renderer.ToString().Should().Be($"

{_dummyText}

"); + } + + [Test] + public void RenderItalic_ThrowsException_ReceivingNullAsToken() + { + Assert.Throws(() => _renderer.RenderItalic(null!)); + } + + [Test] + public void RenderItalic_RendersCorrectly() + { + _renderer.RenderItalic( + new ItalicToken( + new TextToken(_dummyText))); + _renderer.ToString().Should().Be($"{_dummyText}"); + } + + [Test] + public void RenderSet_ThrowsException_ReceivingNullAsToken() + { + Assert.Throws(() => _renderer.RenderSet(null!)); + } + + [Test] + public void RenderSet_RendersCorrectly() + { + _renderer.RenderSet( + new SetToken( + new ItalicToken( + new TextToken("Set")), + new TextToken(" "), + new BoldToken( + new TextToken("of")), + new TextToken(" "), + new TextToken("tokens"))); + _renderer.ToString().Should().Be($"Set of tokens"); + } + + [Test] + public void RenderText_ThrowsException_ReceivingNullAsToken() + { + Assert.Throws(() => _renderer.RenderText(null!)); + } + + [Test] + public void RenderText_RendersCorrectly() + { + _renderer.RenderText( + new TextToken(_dummyText)); + _renderer.ToString().Should().Be(_dummyText); + } + + [Test] + public void Render_ComplexTextCorrectly() + { + _renderer.RenderHeader( + new HeaderToken( + new SetToken( + new ItalicToken( + new TextToken("Set")), + new TextToken(" "), + new BoldToken( + new TextToken("of")), + new TextToken(" "), + new TextToken("tokens")))); + _renderer.ToString().Should().Be("

Set of tokens

"); + } + } +} diff --git a/cs/Markdown.Tests/TokensTests/TokensTests.cs b/cs/Markdown.Tests/TokensTests/TokensTests.cs new file mode 100644 index 000000000..6d0242772 --- /dev/null +++ b/cs/Markdown.Tests/TokensTests/TokensTests.cs @@ -0,0 +1,76 @@ +using Markdown.Tokens.HtmlTokens; + +namespace Markdown.Tests.TokensTests +{ + [TestFixture] + public class TokensTests + { + private readonly string _dummyText = "Hello, World!"; + private readonly TextToken _dummyTextToken = new TextToken("Hello, World!"); + + [Test] + public void BoldToken_ThrowsException_ReceivingNullAsIRenderable() + { + Assert.Throws(() => new BoldToken(null!)); + } + + [Test] + public void BoldToken_ThrowsException_ReceivingNullAsIRenderer_OnRender() + { + Assert.Throws(() + => new BoldToken(_dummyTextToken).Render(null!)); + } + + [Test] + public void HeaderToken_ThrowsException_ReceivingNullAsIRenderable() + { + Assert.Throws(() => new HeaderToken(null!)); + } + + [Test] + public void HeaderToken_ThrowsException_ReceivingNullAsIRenderer_OnRender() + { + Assert.Throws(() + => new HeaderToken(_dummyTextToken).Render(null!)); + } + + [Test] + public void ItalicToken_ThrowsException_ReceivingNullAsIRenderable() + { + Assert.Throws(() => new ItalicToken(null!)); + } + + [Test] + public void ItalicToken_ThrowsException_ReceivingNullAsIRenderer_OnRender() + { + Assert.Throws(() + => new ItalicToken(_dummyTextToken).Render(null!)); + } + + [Test] + public void SetToken_ThrowsException_ReceivingNullAsIRenderable() + { + Assert.Throws(() => new SetToken(null!)); + } + + [Test] + public void SetToken_ThrowsException_ReceivingNullAsIRenderer_OnRender() + { + Assert.Throws(() + => new SetToken(_dummyTextToken).Render(null!)); + } + + [Test] + public void TextToken_ThrowsException_ReceivingNullAsText() + { + Assert.Throws(() => new TextToken(null!)); + } + + [Test] + public void TextToken_ThrowsException_ReceivingNullAsIRenderer_OnRender() + { + Assert.Throws(() + => new TextToken(_dummyText).Render(null!)); + } + } +} diff --git a/cs/Markdown/Extensions/StringExtensions/DeterminingStringStartExtensions.cs b/cs/Markdown/Extensions/StringExtensions/DeterminingStringStartExtensions.cs new file mode 100644 index 000000000..b5ae6bce7 --- /dev/null +++ b/cs/Markdown/Extensions/StringExtensions/DeterminingStringStartExtensions.cs @@ -0,0 +1,27 @@ +namespace Markdown.Extensions.StringExtensions +{ + internal static class DeterminingStringStartExtensions + { + internal static bool IsEscapeStart(this string text, int index, HashSet markers) + => text[index] == '\\' + && index + 1 < text.Length + && (markers.Contains(text[index + 1]) + || text[index + 1] == '\\' + || index - 1 >= 0 + && index + 1 < text.Length + && text[index - 1] == '#' + && text[index + 1] == ' '); + + internal static bool IsHeaderStart(this string text, int index, string headerMarkerStart) + => text[index] == headerMarkerStart[0] + && index + 1 < text.Length + && text[index + 1] == headerMarkerStart[1] && (index == 0 || text[index - 1] == '\n'); + + internal static bool IsBoldStart(this string text, int index, string italicMarkerStart) + => text.IsItalicStart(index, italicMarkerStart) + && index + 1 < text.Length && text.IsItalicStart(index + 1, italicMarkerStart); + + internal static bool IsItalicStart(this string text, int index, string italicMarkerStart) + => text[index] == italicMarkerStart[0]; + } +} diff --git a/cs/Markdown/Extensions/StringExtensions/StringExtensions.cs b/cs/Markdown/Extensions/StringExtensions/StringExtensions.cs new file mode 100644 index 000000000..3c7330fe1 --- /dev/null +++ b/cs/Markdown/Extensions/StringExtensions/StringExtensions.cs @@ -0,0 +1,73 @@ +namespace Markdown.Extensions.HandlerExtensions +{ + internal static class StringExtensions + { + internal static int FindEndOfLine(this string text, int startIndex) + { + var result = text.IndexOf('\n', startIndex); + if (result == -1) + { + return text.Length; + } + return result; + } + + internal static bool IsSurroundedByDigits(this string text, int index, int markerLength) + { + var isLeftDigit = index - 1 >= 0 && char.IsDigit(text[index - 1]); + var isRightDigit = index + markerLength < text.Length + && char.IsDigit(text[index + markerLength]); + return isLeftDigit || isRightDigit; + } + + internal static int CountConsecutiveCharacters(this string text, int index, char character) + { + var result = 0; + while (index + result < text.Length && text[index + result] == character) + { + result += 1; + } + return result; + } + + internal static bool IsInsideOfWord(this string text, int index, int markerLength) + => index - markerLength >= 0 + && char.IsLetter(text[index - markerLength]) + && index + markerLength < text.Length + && char.IsLetter(text[index + markerLength]); + + internal static int FindClosingMarker(this string text, int startIndex, string marker) + { + for (var i = startIndex; i < text.Length; i++) + { + if (char.IsWhiteSpace(text[i])) + { + var nextIndex = text.FindClosingMarker(i + 1, marker); + if (text.IsInsideOfWord(nextIndex, marker.Length)) + { + return -1; + }; + } + + if (i + marker.Length > text.Length) + { + return -1; + } + + var consecutiveCharactersCount = text.CountConsecutiveCharacters(i, marker[0]); + var sub = text.Substring(i, marker.Length); + if (sub == marker && consecutiveCharactersCount == marker.Length) + { + var preMarkerIndex = i - 1; + if (preMarkerIndex >= startIndex && !char.IsWhiteSpace(text[preMarkerIndex])) + { + return i; + } + } + i += consecutiveCharactersCount; + } + + return -1; + } + } +} diff --git a/cs/Markdown/Markdown.csproj b/cs/Markdown/Markdown.csproj new file mode 100644 index 000000000..f992d3aa8 --- /dev/null +++ b/cs/Markdown/Markdown.csproj @@ -0,0 +1,17 @@ + + + + net8.0 + enable + enable + true + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + diff --git a/cs/Markdown/Md.cs b/cs/Markdown/Md.cs new file mode 100644 index 000000000..63e74fbd4 --- /dev/null +++ b/cs/Markdown/Md.cs @@ -0,0 +1,26 @@ +using Markdown.Parsers.MdParsers; +using Markdown.Renderers; +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Markdown.Tests")] +namespace Markdown +{ + internal class Md(ParserMd parser, IRenderer renderer) + { + public readonly ParserMd Parser = parser ?? throw new ArgumentNullException( + "Передаваемый ParserMd не может быть null."); + + public readonly IRenderer Renderer = renderer ?? throw new ArgumentNullException( + "Передаваемый IRenderer не может быть null."); + + public string Render(string text) + { + var mdTokens = Parser.Parse(text); + foreach (var token in mdTokens) + { + token.Render(Renderer); + } + return Renderer.ToString()!; + } + } +} diff --git a/cs/Markdown/Parsers/IParser.cs b/cs/Markdown/Parsers/IParser.cs new file mode 100644 index 000000000..951e199ba --- /dev/null +++ b/cs/Markdown/Parsers/IParser.cs @@ -0,0 +1,9 @@ +using Markdown.Tokens.HtmlTokens; + +namespace Markdown.Parsers +{ + internal interface IParser + { + IList Parse(string text); + } +} diff --git a/cs/Markdown/Parsers/MdParsers/ParserMD.cs b/cs/Markdown/Parsers/MdParsers/ParserMD.cs new file mode 100644 index 000000000..01803e9d4 --- /dev/null +++ b/cs/Markdown/Parsers/MdParsers/ParserMD.cs @@ -0,0 +1,169 @@ +using Markdown.Extensions.HandlerExtensions; +using Markdown.Extensions.StringExtensions; +using Markdown.Tokens.HtmlTokens; + +namespace Markdown.Parsers.MdParsers +{ + internal class ParserMd : IParser + { + private const string BoldMarkerStart = "__"; + private const string ItalicMarkerStart = "_"; + private const string HeaderMarkerStart = "# "; + + private static readonly HashSet _markers = new HashSet() + { + '_', + '\n', + '#' + }; + + public IList Parse(string text) + { + return ParseTextPart(text); + } + + private static List ParseTextPart(string text) + { + var tokens = new List(); + var index = 0; + while (index < text.Length) + { + if (text.IsEscapeStart(index, _markers)) + { + index = ParseEscape(text, index, tokens); + } + else if (text.IsHeaderStart(index, HeaderMarkerStart)) + { + index = ParseHeader(text, index, tokens); + } + else if (text.IsBoldStart(index, ItalicMarkerStart)) + { + index = ParseBold(text, index, tokens); + } + else if (text.IsItalicStart(index, ItalicMarkerStart)) + { + index = ParseItalic(text, index, tokens); + } + else + { + index = ParseText(text, index, tokens); + } + } + return tokens; + } + + private static int ParseEscape(string text, int index, List tokens) + { + tokens.Add(new TextToken(text[index + 1].ToString())); + return index + 2; + } + + private static int ParseHeader(string text, int index, List tokens) + { + var startIndex = index + HeaderMarkerStart.Length; + var endIndex = text.FindEndOfLine(startIndex); + var innerTokens = ParseTextPart(text.Substring(startIndex, endIndex - startIndex)); + endIndex += 1; + tokens.Add(new HeaderToken(WrapInnerTokens(innerTokens))); + return endIndex; + } + + private static int ParseItalic(string text, int index, List tokens) + { + var startIndex = index + 1; + + if (startIndex >= text.Length + || text.IsSurroundedByDigits(index, 1) + || char.IsWhiteSpace(text[startIndex])) + { + tokens.Add(new TextToken(ItalicMarkerStart)); + return startIndex; + } + + var endIndex = text.FindClosingMarker(startIndex, + ItalicMarkerStart); + if (endIndex == -1) + { + tokens.Add(new TextToken(ItalicMarkerStart)); + return startIndex; + } + + var innerTokens = ParseTextPart(text.Substring(startIndex, endIndex - startIndex)); + CorrectBoldTokensInsideItalicToken(innerTokens); + tokens.Add(new ItalicToken(WrapInnerTokens(innerTokens))); + return endIndex + ItalicMarkerStart.Length; + } + + private static void CorrectBoldTokensInsideItalicToken(IList innerTokens) + { + for (var i = 0; i < innerTokens.Count; i++) + { + if (innerTokens[i] is BoldToken boldToken) + { + var innerTextToken = (TextToken)boldToken.InnerItem; + innerTokens[i] = new TextToken( + $"{BoldMarkerStart}{innerTextToken.Text}{BoldMarkerStart}"); + } + } + } + + private static int ParseBold(string text, int index, List tokens) + { + var startIndex = index + BoldMarkerStart.Length; + + if (startIndex >= text.Length + || text.IsSurroundedByDigits(index, BoldMarkerStart.Length) + || char.IsWhiteSpace(text[startIndex])) + { + tokens.Add(new TextToken(BoldMarkerStart)); + return startIndex; + } + + var endIndex = text.FindClosingMarker(startIndex, BoldMarkerStart); + if (endIndex == -1) + { + tokens.Add(new TextToken(BoldMarkerStart)); + return startIndex; + } + + var innerTokens = ParseTextPart(text.Substring(startIndex, endIndex - startIndex)); + tokens.Add(new BoldToken(WrapInnerTokens(innerTokens))); + return endIndex + BoldMarkerStart.Length; + } + + private static int ParseText(string text, int index, List tokens) + { + var startIndex = index; + + while (index < text.Length && !_markers.Contains(text[index])) + { + if (text.IsEscapeStart(index, _markers)) + { + if (index + 1 < text.Length && char.IsLetterOrDigit(text[index + 1])) + { + index += 1; + continue; + } + break; + } + index += 1; + } + + if (index == startIndex) + { + index += 1; + } + tokens.Add(new TextToken(text.Substring(startIndex, index - startIndex))); + return index; + } + + private static IRenderable WrapInnerTokens(IList innerTokens) + { + if (innerTokens.Count == 1) + { + return innerTokens[0]; + } + return new SetToken(innerTokens.ToArray()); + } + } +} \ No newline at end of file diff --git a/cs/Markdown/Renderers/IRenderer.cs b/cs/Markdown/Renderers/IRenderer.cs new file mode 100644 index 000000000..558ab878e --- /dev/null +++ b/cs/Markdown/Renderers/IRenderer.cs @@ -0,0 +1,13 @@ +using Markdown.Tokens.HtmlTokens; + +namespace Markdown.Renderers +{ + internal interface IRenderer + { + void RenderText(TextToken textToken); + void RenderItalic(ItalicToken italicToken); + void RenderBold(BoldToken boldToken); + void RenderHeader(HeaderToken headerToken); + void RenderSet(SetToken setToken); + } +} diff --git a/cs/Markdown/Renderers/RendererHtml.cs b/cs/Markdown/Renderers/RendererHtml.cs new file mode 100644 index 000000000..0a8cb3f40 --- /dev/null +++ b/cs/Markdown/Renderers/RendererHtml.cs @@ -0,0 +1,56 @@ +using Markdown.Tokens.HtmlTokens; +using System.Text; + +namespace Markdown.Renderers +{ + internal class RendererHTML : IRenderer + { + private readonly StringBuilder _stringBuilder = new StringBuilder(); + + public void RenderBold(BoldToken boldToken) + { + ArgumentNullException.ThrowIfNull(boldToken); + + _stringBuilder.Append(""); + boldToken.InnerItem.Render(this); + _stringBuilder.Append(""); + } + + public void RenderHeader(HeaderToken headerToken) + { + ArgumentNullException.ThrowIfNull(headerToken); + + _stringBuilder.Append("

"); + headerToken.InnerItem.Render(this); + _stringBuilder.Append("

"); + } + + public void RenderItalic(ItalicToken italicToken) + { + ArgumentNullException.ThrowIfNull(italicToken); + + _stringBuilder.Append(""); + italicToken.InnerItem.Render(this); + _stringBuilder.Append(""); + } + + public void RenderSet(SetToken setToken) + { + ArgumentNullException.ThrowIfNull(setToken); + + foreach (var item in setToken.InnerItems) + { + item.Render(this); + } + } + + public void RenderText(TextToken text) + { + ArgumentNullException.ThrowIfNull(text); + + _stringBuilder.Append(text.Text); + } + + public override string ToString() => _stringBuilder.ToString(); + } +} diff --git a/cs/Markdown/Tokens/HtmlTokens/BasicMarkdownToken.cs b/cs/Markdown/Tokens/HtmlTokens/BasicMarkdownToken.cs new file mode 100644 index 000000000..7b7fc68dd --- /dev/null +++ b/cs/Markdown/Tokens/HtmlTokens/BasicMarkdownToken.cs @@ -0,0 +1,9 @@ +namespace Markdown.Tokens.HtmlTokens +{ + internal abstract class BasicMarkdownToken(IRenderable innerItem) + { + public IRenderable InnerItem { get; init; } + = innerItem ?? throw new ArgumentNullException(nameof(innerItem), + "Значение не может быть null."); + } +} diff --git a/cs/Markdown/Tokens/HtmlTokens/BoldToken.cs b/cs/Markdown/Tokens/HtmlTokens/BoldToken.cs new file mode 100644 index 000000000..bb8127e98 --- /dev/null +++ b/cs/Markdown/Tokens/HtmlTokens/BoldToken.cs @@ -0,0 +1,15 @@ +using Markdown.Renderers; + +namespace Markdown.Tokens.HtmlTokens +{ + internal class BoldToken(IRenderable innerItem) : BasicMarkdownToken(innerItem), IRenderable + { + public readonly TokenTypes Type = TokenTypes.Bold; + public void Render(IRenderer renderer) + { + ArgumentNullException.ThrowIfNull(renderer); + + renderer.RenderBold(this); + } + } +} diff --git a/cs/Markdown/Tokens/HtmlTokens/HeaderToken.cs b/cs/Markdown/Tokens/HtmlTokens/HeaderToken.cs new file mode 100644 index 000000000..ef6a66cd0 --- /dev/null +++ b/cs/Markdown/Tokens/HtmlTokens/HeaderToken.cs @@ -0,0 +1,15 @@ +using Markdown.Renderers; + +namespace Markdown.Tokens.HtmlTokens +{ + internal class HeaderToken(IRenderable innerItem) : BasicMarkdownToken(innerItem), IRenderable + { + public readonly TokenTypes Type = TokenTypes.Header; + public void Render(IRenderer renderer) + { + ArgumentNullException.ThrowIfNull(renderer); + + renderer.RenderHeader(this); + } + } +} \ No newline at end of file diff --git a/cs/Markdown/Tokens/HtmlTokens/IRenderable.cs b/cs/Markdown/Tokens/HtmlTokens/IRenderable.cs new file mode 100644 index 000000000..555cf422d --- /dev/null +++ b/cs/Markdown/Tokens/HtmlTokens/IRenderable.cs @@ -0,0 +1,9 @@ +using Markdown.Renderers; + +namespace Markdown.Tokens.HtmlTokens +{ + internal interface IRenderable + { + void Render(IRenderer renderer); + } +} diff --git a/cs/Markdown/Tokens/HtmlTokens/ItalicToken.cs b/cs/Markdown/Tokens/HtmlTokens/ItalicToken.cs new file mode 100644 index 000000000..f6f3d2b87 --- /dev/null +++ b/cs/Markdown/Tokens/HtmlTokens/ItalicToken.cs @@ -0,0 +1,15 @@ +using Markdown.Renderers; + +namespace Markdown.Tokens.HtmlTokens +{ + internal class ItalicToken(IRenderable innerItem) : BasicMarkdownToken(innerItem), IRenderable + { + public readonly TokenTypes Type = TokenTypes.Italic; + public void Render(IRenderer renderer) + { + ArgumentNullException.ThrowIfNull(renderer); + + renderer.RenderItalic(this); + } + } +} diff --git a/cs/Markdown/Tokens/HtmlTokens/SetToken.cs b/cs/Markdown/Tokens/HtmlTokens/SetToken.cs new file mode 100644 index 000000000..5f058ef15 --- /dev/null +++ b/cs/Markdown/Tokens/HtmlTokens/SetToken.cs @@ -0,0 +1,20 @@ +using Markdown.Renderers; + +namespace Markdown.Tokens.HtmlTokens +{ + internal class SetToken(params IRenderable[] innerItems) : IRenderable + { + private readonly IRenderable[] _innerItems = innerItems + ?? throw new ArgumentNullException(nameof(innerItems), + "Список внутренних токенов не может быть null."); + public readonly TokenTypes Type = TokenTypes.Set; + public IRenderable[] InnerItems => _innerItems; + + public void Render(IRenderer renderer) + { + ArgumentNullException.ThrowIfNull(renderer); + + renderer.RenderSet(this); + } + } +} diff --git a/cs/Markdown/Tokens/HtmlTokens/TextToken.cs b/cs/Markdown/Tokens/HtmlTokens/TextToken.cs new file mode 100644 index 000000000..70a17c89a --- /dev/null +++ b/cs/Markdown/Tokens/HtmlTokens/TextToken.cs @@ -0,0 +1,19 @@ +using Markdown.Renderers; + +namespace Markdown.Tokens.HtmlTokens +{ + internal class TextToken(string text) : IRenderable + { + public readonly string Text = text + ?? throw new ArgumentNullException(nameof(text), + "Список внутренних токенов не может быть null."); + public readonly TokenTypes Type = TokenTypes.Text; + + public void Render(IRenderer renderer) + { + ArgumentNullException.ThrowIfNull(renderer); + + renderer.RenderText(this); + } + } +} diff --git a/cs/Markdown/Tokens/TokenTypes.cs b/cs/Markdown/Tokens/TokenTypes.cs new file mode 100644 index 000000000..521caec05 --- /dev/null +++ b/cs/Markdown/Tokens/TokenTypes.cs @@ -0,0 +1,11 @@ +namespace Markdown.Tokens +{ + enum TokenTypes + { + Bold, + Header, + Italic, + Set, + Text + } +} diff --git a/cs/clean-code.sln b/cs/clean-code.sln index 2206d54db..58c1637e4 100644 --- a/cs/clean-code.sln +++ b/cs/clean-code.sln @@ -1,13 +1,15 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio 14 -VisualStudioVersion = 14.0.25420.1 +# Visual Studio Version 17 +VisualStudioVersion = 17.11.35327.3 MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Chess", "Chess\Chess.csproj", "{DBFBE40E-EE0C-48F4-8763-EBD11C960081}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Chess", "Chess\Chess.csproj", "{DBFBE40E-EE0C-48F4-8763-EBD11C960081}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ControlDigit", "ControlDigit\ControlDigit.csproj", "{B06A4B35-9D61-4A63-9167-0673F20CA989}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ControlDigit", "ControlDigit\ControlDigit.csproj", "{B06A4B35-9D61-4A63-9167-0673F20CA989}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Samples", "Samples\Samples.csproj", "{C3EF41D7-50EF-4CE1-B30A-D1D81C93D7FA}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Markdown", "Markdown\Markdown.csproj", "{FAB1F67E-C77E-4A5A-9214-A4B7C8D86B61}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Markdown.Tests", "Markdown.Tests\Markdown.Tests.csproj", "{6767A9C8-9BBE-49B0-9B6B-9D0A271D7B4D}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -23,9 +25,19 @@ Global {B06A4B35-9D61-4A63-9167-0673F20CA989}.Debug|Any CPU.Build.0 = Debug|Any CPU {B06A4B35-9D61-4A63-9167-0673F20CA989}.Release|Any CPU.ActiveCfg = Release|Any CPU {B06A4B35-9D61-4A63-9167-0673F20CA989}.Release|Any CPU.Build.0 = Release|Any CPU - {C3EF41D7-50EF-4CE1-B30A-D1D81C93D7FA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {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 + {FAB1F67E-C77E-4A5A-9214-A4B7C8D86B61}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FAB1F67E-C77E-4A5A-9214-A4B7C8D86B61}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FAB1F67E-C77E-4A5A-9214-A4B7C8D86B61}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FAB1F67E-C77E-4A5A-9214-A4B7C8D86B61}.Release|Any CPU.Build.0 = Release|Any CPU + {6767A9C8-9BBE-49B0-9B6B-9D0A271D7B4D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6767A9C8-9BBE-49B0-9B6B-9D0A271D7B4D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6767A9C8-9BBE-49B0-9B6B-9D0A271D7B4D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6767A9C8-9BBE-49B0-9B6B-9D0A271D7B4D}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {94673EF3-52C0-4F5C-9907-268A715D8A8D} EndGlobalSection EndGlobal