diff --git a/cs/MarkupConverter/AST/Blocks/Block.cs b/cs/MarkupConverter/AST/Blocks/Block.cs new file mode 100644 index 000000000..6ff8a231b --- /dev/null +++ b/cs/MarkupConverter/AST/Blocks/Block.cs @@ -0,0 +1,13 @@ +using MarkupConverter.AST.Inlines; + +namespace MarkupConverter.AST.Blocks; + +public abstract class Block : Node +{ + public List Inlines { get; } + + protected Block(List inlines) + { + Inlines = inlines; + } +} \ No newline at end of file diff --git a/cs/MarkupConverter/AST/Blocks/BlockContainerNode.cs b/cs/MarkupConverter/AST/Blocks/BlockContainerNode.cs new file mode 100644 index 000000000..65a003c22 --- /dev/null +++ b/cs/MarkupConverter/AST/Blocks/BlockContainerNode.cs @@ -0,0 +1,11 @@ +namespace MarkupConverter.AST.Blocks; + +public abstract class BlockContainerNode +{ + public List Blocks { get; } + + protected BlockContainerNode(List blocks) + { + Blocks = blocks; + } +} \ No newline at end of file diff --git a/cs/MarkupConverter/AST/Blocks/Document.cs b/cs/MarkupConverter/AST/Blocks/Document.cs new file mode 100644 index 000000000..0df0036fc --- /dev/null +++ b/cs/MarkupConverter/AST/Blocks/Document.cs @@ -0,0 +1,8 @@ +namespace MarkupConverter.AST.Blocks; + +public class Document : BlockContainerNode +{ + public Document(List blocks) : base(blocks) + { + } +} \ No newline at end of file diff --git a/cs/MarkupConverter/AST/Blocks/Header.cs b/cs/MarkupConverter/AST/Blocks/Header.cs new file mode 100644 index 000000000..00225a5c8 --- /dev/null +++ b/cs/MarkupConverter/AST/Blocks/Header.cs @@ -0,0 +1,13 @@ +using MarkupConverter.AST.Inlines; + +namespace MarkupConverter.AST.Blocks; + +public class Header : Block +{ + public int Level { get; } + + public Header(List inlines, int level) : base(inlines) + { + Level = level; + } +} \ No newline at end of file diff --git a/cs/MarkupConverter/AST/Blocks/Paragraph.cs b/cs/MarkupConverter/AST/Blocks/Paragraph.cs new file mode 100644 index 000000000..fec6d263e --- /dev/null +++ b/cs/MarkupConverter/AST/Blocks/Paragraph.cs @@ -0,0 +1,10 @@ +using MarkupConverter.AST.Inlines; + +namespace MarkupConverter.AST.Blocks; + +public class Paragraph : Block +{ + public Paragraph(List inlines) : base(inlines) + { + } +} \ No newline at end of file diff --git a/cs/MarkupConverter/AST/Inlines/Bold.cs b/cs/MarkupConverter/AST/Inlines/Bold.cs new file mode 100644 index 000000000..9eeeaaddf --- /dev/null +++ b/cs/MarkupConverter/AST/Inlines/Bold.cs @@ -0,0 +1,11 @@ +namespace MarkupConverter.AST.Inlines; + +public class Bold : Inline +{ + public List Inlines { get; } + + public Bold(List inlines) + { + Inlines = inlines; + } +} \ No newline at end of file diff --git a/cs/MarkupConverter/AST/Inlines/Inline.cs b/cs/MarkupConverter/AST/Inlines/Inline.cs new file mode 100644 index 000000000..cf3d83631 --- /dev/null +++ b/cs/MarkupConverter/AST/Inlines/Inline.cs @@ -0,0 +1,3 @@ +namespace MarkupConverter.AST.Inlines; + +public abstract class Inline : Node; \ No newline at end of file diff --git a/cs/MarkupConverter/AST/Inlines/InlineLeaf.cs b/cs/MarkupConverter/AST/Inlines/InlineLeaf.cs new file mode 100644 index 000000000..d67ce7a0d --- /dev/null +++ b/cs/MarkupConverter/AST/Inlines/InlineLeaf.cs @@ -0,0 +1,3 @@ +namespace MarkupConverter.AST.Inlines; + +public abstract class InlineLeaf : Inline; \ No newline at end of file diff --git a/cs/MarkupConverter/AST/Inlines/Italic.cs b/cs/MarkupConverter/AST/Inlines/Italic.cs new file mode 100644 index 000000000..17029c4da --- /dev/null +++ b/cs/MarkupConverter/AST/Inlines/Italic.cs @@ -0,0 +1,11 @@ +namespace MarkupConverter.AST.Inlines; + +public class Italic : InlineLeaf +{ + public List InlineLeaves { get; } + + public Italic(List inlineLeaves) + { + InlineLeaves = inlineLeaves; + } +} \ No newline at end of file diff --git a/cs/MarkupConverter/AST/Inlines/Text.cs b/cs/MarkupConverter/AST/Inlines/Text.cs new file mode 100644 index 000000000..a79d08444 --- /dev/null +++ b/cs/MarkupConverter/AST/Inlines/Text.cs @@ -0,0 +1,11 @@ +namespace MarkupConverter.AST.Inlines; + +public class Text : InlineLeaf +{ + public string Content { get; } + + public Text(string content) + { + Content = content; + } +} \ No newline at end of file diff --git a/cs/MarkupConverter/AST/Node.cs b/cs/MarkupConverter/AST/Node.cs new file mode 100644 index 000000000..575f84293 --- /dev/null +++ b/cs/MarkupConverter/AST/Node.cs @@ -0,0 +1,3 @@ +namespace MarkupConverter.AST; + +public class Node; \ No newline at end of file diff --git a/cs/MarkupConverter/MarkupConverter.cs b/cs/MarkupConverter/MarkupConverter.cs new file mode 100644 index 000000000..a8926fa9d --- /dev/null +++ b/cs/MarkupConverter/MarkupConverter.cs @@ -0,0 +1,24 @@ +using MarkupConverter.Parsers; +using MarkupConverter.Renderers; + +namespace MarkupConverter; + +public class MarkupConverter +{ + private readonly IParser parser; + private readonly IRenderer renderer; + + public MarkupConverter(IParser parser, IRenderer renderer) + { + this.parser = parser; + this.renderer = renderer; + } + + public string Convert(string text) + { + var ast = parser.Parse(text); + var markup = renderer.Render(ast); + + return markup; + } +} \ No newline at end of file diff --git a/cs/MarkupConverter/MarkupConverter.csproj b/cs/MarkupConverter/MarkupConverter.csproj new file mode 100644 index 000000000..3a6353295 --- /dev/null +++ b/cs/MarkupConverter/MarkupConverter.csproj @@ -0,0 +1,9 @@ + + + + net8.0 + enable + enable + + + diff --git a/cs/MarkupConverter/Parsers/BlockParsers/HeaderParser.cs b/cs/MarkupConverter/Parsers/BlockParsers/HeaderParser.cs new file mode 100644 index 000000000..d1e7bfb04 --- /dev/null +++ b/cs/MarkupConverter/Parsers/BlockParsers/HeaderParser.cs @@ -0,0 +1,34 @@ +using MarkupConverter.Parsers.BlockParsers.OpenBlocks; + +namespace MarkupConverter.Parsers.BlockParsers; + +public class HeaderParser : IBlockParser +{ + public bool CanParse(string line) + { + var trimmed = line.TrimStart(); + var headingLevel = CountHeadingLevel(trimmed); + + return headingLevel is >= 1 and <= 6 + && (headingLevel == trimmed.Length || char.IsWhiteSpace(trimmed[headingLevel])); + } + + public IOpenBlock Parse(string line) + { + var trimmed = line.TrimStart(); + var headingLevel = CountHeadingLevel(trimmed); + var content = trimmed.Substring(headingLevel + 1).Trim(); + + return new HeaderOpenBlock(content, headingLevel); + } + + private static int CountHeadingLevel(string line) + { + var level = 0; + foreach (var c in line) + if (c == '#') level++; + else break; + + return level; + } +} \ No newline at end of file diff --git a/cs/MarkupConverter/Parsers/BlockParsers/IBlockParser.cs b/cs/MarkupConverter/Parsers/BlockParsers/IBlockParser.cs new file mode 100644 index 000000000..9cd89adc1 --- /dev/null +++ b/cs/MarkupConverter/Parsers/BlockParsers/IBlockParser.cs @@ -0,0 +1,9 @@ +using MarkupConverter.Parsers.BlockParsers.OpenBlocks; + +namespace MarkupConverter.Parsers.BlockParsers; + +public interface IBlockParser +{ + bool CanParse(string line); + IOpenBlock Parse(string line); +} \ No newline at end of file diff --git a/cs/MarkupConverter/Parsers/BlockParsers/OpenBlocks/HeaderOpenBlock.cs b/cs/MarkupConverter/Parsers/BlockParsers/OpenBlocks/HeaderOpenBlock.cs new file mode 100644 index 000000000..ba47fd83b --- /dev/null +++ b/cs/MarkupConverter/Parsers/BlockParsers/OpenBlocks/HeaderOpenBlock.cs @@ -0,0 +1,31 @@ +using MarkupConverter.AST.Blocks; +using MarkupConverter.Parsers.InlineParsers; + +namespace MarkupConverter.Parsers.BlockParsers.OpenBlocks; + +public class HeaderOpenBlock : IOpenBlock +{ + private readonly int level; + public string Content { get; } + public Type BlockType => typeof(Header); + + public HeaderOpenBlock(string content, int level) + { + Content = content; + this.level = level; + } + + public bool CanAccept(IOpenBlock block) + { + return false; + } + + public void Accept(IOpenBlock block) + { + } + + public Block Close(IInlineParser parser) + { + return new Header(parser.Parse(Content), level); + } +} \ No newline at end of file diff --git a/cs/MarkupConverter/Parsers/BlockParsers/OpenBlocks/IOpenBlock.cs b/cs/MarkupConverter/Parsers/BlockParsers/OpenBlocks/IOpenBlock.cs new file mode 100644 index 000000000..680a47bda --- /dev/null +++ b/cs/MarkupConverter/Parsers/BlockParsers/OpenBlocks/IOpenBlock.cs @@ -0,0 +1,13 @@ +using MarkupConverter.AST.Blocks; +using MarkupConverter.Parsers.InlineParsers; + +namespace MarkupConverter.Parsers.BlockParsers.OpenBlocks; + +public interface IOpenBlock +{ + string Content { get; } + Type BlockType { get; } + bool CanAccept(IOpenBlock block); + void Accept(IOpenBlock block); + Block Close(IInlineParser parser); +} \ No newline at end of file diff --git a/cs/MarkupConverter/Parsers/BlockParsers/OpenBlocks/ParagraphOpenBlock.cs b/cs/MarkupConverter/Parsers/BlockParsers/OpenBlocks/ParagraphOpenBlock.cs new file mode 100644 index 000000000..a1129ede4 --- /dev/null +++ b/cs/MarkupConverter/Parsers/BlockParsers/OpenBlocks/ParagraphOpenBlock.cs @@ -0,0 +1,30 @@ +using MarkupConverter.AST.Blocks; +using MarkupConverter.Parsers.InlineParsers; + +namespace MarkupConverter.Parsers.BlockParsers.OpenBlocks; + +public class ParagraphOpenBlock : IOpenBlock +{ + public string Content { get; private set; } + public Type BlockType => typeof(Paragraph); + + public ParagraphOpenBlock(string content) + { + Content = content; + } + + public bool CanAccept(IOpenBlock block) + { + return block.BlockType == BlockType; + } + + public void Accept(IOpenBlock block) + { + Content += ' ' + block.Content; + } + + public Block Close(IInlineParser parser) + { + return new Paragraph(parser.Parse(Content)); + } +} \ No newline at end of file diff --git a/cs/MarkupConverter/Parsers/BlockParsers/ParagraphParser.cs b/cs/MarkupConverter/Parsers/BlockParsers/ParagraphParser.cs new file mode 100644 index 000000000..e1b0b1538 --- /dev/null +++ b/cs/MarkupConverter/Parsers/BlockParsers/ParagraphParser.cs @@ -0,0 +1,18 @@ +using MarkupConverter.Parsers.BlockParsers.OpenBlocks; + +namespace MarkupConverter.Parsers.BlockParsers; + +public class ParagraphParser : IBlockParser +{ + public bool CanParse(string line) + { + return !string.IsNullOrWhiteSpace(line); + } + + public IOpenBlock Parse(string line) + { + var content = line.Trim(); + + return new ParagraphOpenBlock(content); + } +} \ No newline at end of file diff --git a/cs/MarkupConverter/Parsers/IParser.cs b/cs/MarkupConverter/Parsers/IParser.cs new file mode 100644 index 000000000..11414b423 --- /dev/null +++ b/cs/MarkupConverter/Parsers/IParser.cs @@ -0,0 +1,8 @@ +using MarkupConverter.AST.Blocks; + +namespace MarkupConverter.Parsers; + +public interface IParser +{ + public Document Parse(string text); +} \ No newline at end of file diff --git a/cs/MarkupConverter/Parsers/InlineParsers/Delimiter.cs b/cs/MarkupConverter/Parsers/InlineParsers/Delimiter.cs new file mode 100644 index 000000000..675b9f44b --- /dev/null +++ b/cs/MarkupConverter/Parsers/InlineParsers/Delimiter.cs @@ -0,0 +1,11 @@ +namespace MarkupConverter.Parsers.InlineParsers; + +public class Delimiter +{ + public int NodeIndex; + public int Length; + public bool CanOpen; + public bool CanClose; + public bool IsInsideWord; + public bool Matched; +} \ No newline at end of file diff --git a/cs/MarkupConverter/Parsers/InlineParsers/IInlineParser.cs b/cs/MarkupConverter/Parsers/InlineParsers/IInlineParser.cs new file mode 100644 index 000000000..20d1e10d6 --- /dev/null +++ b/cs/MarkupConverter/Parsers/InlineParsers/IInlineParser.cs @@ -0,0 +1,8 @@ +using MarkupConverter.AST.Inlines; + +namespace MarkupConverter.Parsers.InlineParsers; + +public interface IInlineParser +{ + public List Parse(string text); +} \ No newline at end of file diff --git a/cs/MarkupConverter/Parsers/InlineParsers/InlineParser.cs b/cs/MarkupConverter/Parsers/InlineParsers/InlineParser.cs new file mode 100644 index 000000000..33fe72c76 --- /dev/null +++ b/cs/MarkupConverter/Parsers/InlineParsers/InlineParser.cs @@ -0,0 +1,231 @@ +using System.Text; +using MarkupConverter.AST.Inlines; + +namespace MarkupConverter.Parsers.InlineParsers; + +public class InlineParser : IInlineParser +{ + public List Parse(string text) + { + var output = new List(); + var delimiters = new List(); + var sb = new StringBuilder(); + + var i = 0; + var n = text.Length; + + void FlushText() + { + if (sb.Length == 0) return; + output.Add(new Text(sb.ToString())); + sb.Clear(); + } + + while (i < n) + { + var c = text[i]; + + if (c == '\\') + { + if (i + 1 < n) + { + var nx = text[i + 1]; + if (nx == '_' || nx == '\\') + { + sb.Append(nx); + i += 2; + } + else + { + sb.Append('\\'); + i++; + } + } + else + { + sb.Append('\\'); + i++; + } + + continue; + } + + if (c == '_') + { + var start = i; + var run = 0; + while (i < n && text[i] == '_') + { + run++; + i++; + } + + var processed = 0; + while (processed < run) + { + var take = run - processed >= 2 ? 2 : 1; + var runStart = start + processed; + + var leftPos = runStart - 1; + var rightPos = runStart + take; + + var leftIsSpace = IsWhiteSpace(leftPos, text); + var rightIsSpace = IsWhiteSpace(rightPos, text); + + var leftIsDigit = IsDigit(leftPos, text); + var rightIsDigit = IsDigit(rightPos, text); + var isInsideWord = IsInsideWord(leftPos, text) && IsInsideWord(rightPos, text); + + var canOpen = !rightIsSpace && !rightIsDigit; + var canClose = !leftIsSpace && !leftIsDigit; + + if (!canOpen && !canClose) + { + sb.Append(new string('_', take)); + } + else + { + FlushText(); + delimiters.Add(new Delimiter + { + NodeIndex = output.Count, + Length = take, + CanOpen = canOpen, + CanClose = canClose, + IsInsideWord = isInsideWord, + Matched = false + }); + } + + processed += take; + } + + continue; + } + + sb.Append(c); + i++; + } + + FlushText(); + + for (var d = 0; d < delimiters.Count; d++) + { + var closer = delimiters[d]; + if (!closer.CanClose || closer.Matched) continue; + + var j = d - 1; + while (j >= 0) + { + var opener = delimiters[j]; + if (opener.Matched) + { + j--; + continue; + } + + if (!opener.CanOpen) + { + j--; + continue; + } + + if (opener.Length != closer.Length) + { + j--; + continue; + } + + var startIdx = opener.NodeIndex; + var endIdx = closer.NodeIndex; + + if (endIdx <= startIdx) + { + j--; + continue; + } + + var contentBuilder = new StringBuilder(); + for (var k = startIdx; k < endIdx; k++) + if (output[k] is Text t) contentBuilder.Append(t.Content); + else contentBuilder.Append(output[k]); + + var contentStr = contentBuilder.ToString(); + + if (opener.IsInsideWord) + if (string.IsNullOrEmpty(contentStr) || contentStr.IndexOfAny([' ', '\t', '\r', '\n']) >= 0) + { + j--; + continue; + } + + var crossing = false; + for (var m = j + 1; m < d; m++) + if (!delimiters[m].Matched && delimiters[m].Length != opener.Length) + { + crossing = true; + break; + } + + if (crossing) + { + j--; + continue; + } + + var children = new List(); + for (var k = startIdx; k < endIdx; k++) + children.Add(output[k]); + + if (opener.Length == 1) + { + output.RemoveRange(startIdx, endIdx - startIdx); + output.Insert(startIdx, new Italic(children)); + } + else + { + output.RemoveRange(startIdx, endIdx - startIdx); + output.Insert(startIdx, new Bold(children)); + } + + var removedCount = endIdx - startIdx - 1; + if (removedCount != 0) + foreach (var delimiter in delimiters) + if (delimiter.NodeIndex > startIdx) + delimiter.NodeIndex -= removedCount; + + opener.Matched = true; + closer.Matched = true; + + break; + } + } + + for (var idx = delimiters.Count - 1; idx >= 0; idx--) + { + var delim = delimiters[idx]; + if (!delim.Matched) + { + var literal = delim.Length == 1 ? "_" : "__"; + output.Insert(delim.NodeIndex, new Text(literal)); + } + } + + return output; + } + + private static bool IsWhiteSpace(int pos, string text) + { + return pos < 0 || pos >= text.Length || char.IsWhiteSpace(text[pos]); + } + + private static bool IsDigit(int pos, string text) + { + return pos >= 0 && pos < text.Length && char.IsDigit(text[pos]); + } + + private static bool IsInsideWord(int pos, string text) + { + return pos >= 0 && pos < text.Length && char.IsLetter(text[pos]); + } +} \ No newline at end of file diff --git a/cs/MarkupConverter/Parsers/MarkdownParser.cs b/cs/MarkupConverter/Parsers/MarkdownParser.cs new file mode 100644 index 000000000..a5d195ef2 --- /dev/null +++ b/cs/MarkupConverter/Parsers/MarkdownParser.cs @@ -0,0 +1,79 @@ +using MarkupConverter.AST.Blocks; +using MarkupConverter.Parsers.BlockParsers; +using MarkupConverter.Parsers.InlineParsers; + +namespace MarkupConverter.Parsers; + +public class MarkdownParser : IParser +{ + private readonly List blockParsers; + private readonly IInlineParser inlineParser; + + public MarkdownParser(List blockParsers, IInlineParser inlineParser) + { + this.blockParsers = blockParsers; + this.inlineParser = inlineParser; + } + + public Document Parse(string text) + { + var doc = new Document([]); + var parsingContext = new ParsingContext(); + + var lines = text.Split(new[] { "\r\n", "\r", "\n" }, StringSplitOptions.None); + + foreach (var line in lines) + { + ProcessLine(line, parsingContext, doc); + } + + while (parsingContext.HasOpenBlocks) + { + var block = parsingContext.Pop(); + doc.Blocks.Add(block.Close(inlineParser)); + } + + return doc; + } + + private void ProcessLine(string line, ParsingContext parsingContext, Document doc) + { + if (IsBlank(line)) + { + if (parsingContext.HasOpenBlocks) + { + var block = parsingContext.Pop(); + doc.Blocks.Add(block.Close(inlineParser)); + } + + return; + } + + var selectedParser = blockParsers.FirstOrDefault(blockParser => blockParser.CanParse(line)); + + if (selectedParser is null) + return; + + var newBlock = selectedParser.Parse(line); + + if (!parsingContext.HasOpenBlocks) + { + parsingContext.Push(newBlock); + } + else if (parsingContext.CurrentBlock.CanAccept(newBlock)) + { + parsingContext.CurrentBlock.Accept(newBlock); + } + else + { + var oldBlock = parsingContext.Pop(); + doc.Blocks.Add(oldBlock.Close(inlineParser)); + parsingContext.Push(newBlock); + } + } + + private static bool IsBlank(string line) + { + return string.IsNullOrWhiteSpace(line); + } +} \ No newline at end of file diff --git a/cs/MarkupConverter/Parsers/ParsingContext.cs b/cs/MarkupConverter/Parsers/ParsingContext.cs new file mode 100644 index 000000000..8be020d22 --- /dev/null +++ b/cs/MarkupConverter/Parsers/ParsingContext.cs @@ -0,0 +1,28 @@ +using MarkupConverter.Parsers.BlockParsers.OpenBlocks; + +namespace MarkupConverter.Parsers; + +public class ParsingContext +{ + private readonly Stack stack = new(); + + public IOpenBlock CurrentBlock => + stack.TryPeek(out var block) + ? block + : throw new InvalidOperationException("No open blocks in the parsing context."); + + public bool HasOpenBlocks => stack.Count > 0; + + public void Push(IOpenBlock block) + { + stack.Push(block); + } + + public IOpenBlock Pop() + { + if (!HasOpenBlocks) + throw new InvalidOperationException("Cannot pop from an empty parsing context."); + + return stack.Pop(); + } +} \ No newline at end of file diff --git a/cs/MarkupConverter/Renderers/HtmlRenderer.cs b/cs/MarkupConverter/Renderers/HtmlRenderer.cs new file mode 100644 index 000000000..bf0c6dd47 --- /dev/null +++ b/cs/MarkupConverter/Renderers/HtmlRenderer.cs @@ -0,0 +1,74 @@ +using MarkupConverter.AST.Blocks; +using MarkupConverter.AST.Inlines; +using System.Text; + +namespace MarkupConverter.Renderers; + +public class HtmlRenderer : IRenderer +{ + public string Render(Document document) + { + var htmlBlocks = document.Blocks.Select(Render); + return string.Join(Environment.NewLine, htmlBlocks); + } + + private string Render(Block block) + { + return block switch + { + Header header => RenderHeader(header), + Paragraph paragraph => RenderParagraph(paragraph), + _ => throw new NotSupportedException($"Block type {block.GetType().Name} not supported") + }; + } + + private string RenderHeader(Header header) + { + var tag = $"h{header.Level}"; + var content = RenderInlineList(header.Inlines); + return $"<{tag}>{content}"; + } + + private string RenderParagraph(Paragraph paragraph) + { + var content = RenderInlineList(paragraph.Inlines); + return $"

{content}

"; + } + + private string RenderInlineList(List inlines) + { + var builder = new StringBuilder(); + + foreach (var inline in inlines) builder.Append(RenderInline(inline)); + + return builder.ToString(); + } + + private string RenderInline(Inline inline) + { + return inline switch + { + Text text => RenderText(text), + Bold bold => RenderBold(bold), + Italic italic => RenderItalic(italic), + _ => throw new NotSupportedException($"Inline type {inline.GetType().Name} not supported") + }; + } + + private string RenderText(Text text) + { + return System.Net.WebUtility.HtmlEncode(text.Content); + } + + private string RenderBold(Bold bold) + { + var content = RenderInlineList(bold.Inlines); + return $"{content}"; + } + + private string RenderItalic(Italic italic) + { + var content = RenderInlineList(italic.InlineLeaves); + return $"{content}"; + } +} \ No newline at end of file diff --git a/cs/MarkupConverter/Renderers/IRenderer.cs b/cs/MarkupConverter/Renderers/IRenderer.cs new file mode 100644 index 000000000..e2f2eee69 --- /dev/null +++ b/cs/MarkupConverter/Renderers/IRenderer.cs @@ -0,0 +1,8 @@ +using MarkupConverter.AST.Blocks; + +namespace MarkupConverter.Renderers; + +public interface IRenderer +{ + public string Render(Document doc); +} \ No newline at end of file diff --git a/cs/Tests/BlockConvertingTests/HeaderConvertingTests.cs b/cs/Tests/BlockConvertingTests/HeaderConvertingTests.cs new file mode 100644 index 000000000..b1290d3ea --- /dev/null +++ b/cs/Tests/BlockConvertingTests/HeaderConvertingTests.cs @@ -0,0 +1,86 @@ +using FluentAssertions; + +namespace Tests.BlockConvertingTests; + +public class HeaderConvertingTests : MarkupConverterTestBase +{ + [TestCase("# header", "

header

", TestName = "One hash")] + [TestCase("## header", "

header

", TestName = "Two hashes")] + [TestCase("### header", "

header

", TestName = "Three hashes")] + [TestCase("#### header", "

header

", TestName = "Four hashes")] + [TestCase("##### header", "
header
", TestName = "Five hashes")] + [TestCase("###### header", "
header
", TestName = "Six hashes")] + public void Convert_WhenOneToSixHashes_ShouldCreateHtmlHeading(string markdown, string expectedHtml) + { + var actualHtml = Converter.Convert(markdown); + + actualHtml.Should().Be(expectedHtml); + } + + [Test] + public void Convert_WhenMoreThanSixHashes_ShouldCreateParagraph() + { + var markdown = "####### header"; + var expectedHtml = "

####### header

"; + + var actualHtml = Converter.Convert(markdown); + + actualHtml.Should().Be(expectedHtml); + } + + [Test] + public void Convert_WhenHasIsNotFollowedBySpace_ShouldCreateParagraph() + { + var markdown = "#header"; + var expectedHtml = "

#header

"; + + var actualHtml = Converter.Convert(markdown); + + actualHtml.Should().Be(expectedHtml); + } + + [TestCase("# header ", TestName = "Leading and trailing spaces after hash")] + [TestCase(" # header", TestName = "Leading spaces before hash")] + [TestCase(" # header ", TestName = "Leading and trailing spaces before and after hash")] + public void Convert_WhenHeadingHasLeadingAndTrailingSpaces_ShouldTrimSpaces(string markdown) + { + var expectedHtml = "

header

"; + + var actualHtml = Converter.Convert(markdown); + + actualHtml.Should().Be(expectedHtml); + } + + [Test] + public void Convert_WhenEmptyHeading_ShouldCreateEmptyHtmlHeading() + { + var markdown = "# "; + var expectedHtml = "

"; + + var actualHtml = Converter.Convert(markdown); + + actualHtml.Should().Be(expectedHtml); + } + + [Test] + public void Convert_WhenHashesAfterOpenHash_ShouldNotCreateAnotherHtmlHeading() + { + var markdown = "# header # another header"; + var expectedHtml = "

header # another header

"; + + var actualHtml = Converter.Convert(markdown); + + actualHtml.Should().Be(expectedHtml); + } + + [Test] + public void Convert_ManyHeadings_ShouldCreateManyHtmlHeading() + { + var markdown = $"# header{Environment.NewLine}## another header"; + var expectedHtml = $"

header

{Environment.NewLine}

another header

"; + + var actualHtml = Converter.Convert(markdown); + + actualHtml.Should().Be(expectedHtml); + } +} \ No newline at end of file diff --git a/cs/Tests/BlockConvertingTests/ParagraphConvertingTests.cs b/cs/Tests/BlockConvertingTests/ParagraphConvertingTests.cs new file mode 100644 index 000000000..8c5374244 --- /dev/null +++ b/cs/Tests/BlockConvertingTests/ParagraphConvertingTests.cs @@ -0,0 +1,86 @@ +using FluentAssertions; + +namespace Tests.BlockConvertingTests; + +public class ParagraphConvertingTests : MarkupConverterTestBase +{ + [Test] + public void Convert_WhenSingleLineParagraph_ShouldCreateSingleParagraph() + { + var markdown = "aaa"; + var expectedHtml = "

aaa

"; + + var actualHtml = Converter.Convert(markdown); + + actualHtml.Should().Be(expectedHtml); + } + + [Test] + public void Convert_WhenTwoParagraphsSeparatedByBlankLine_ShouldCreateTwoParagraphs() + { + var markdown = $"aaa{Environment.NewLine}{Environment.NewLine}bbb"; + var expectedHtml = $"

aaa

{Environment.NewLine}

bbb

"; + + var actualHtml = Converter.Convert(markdown); + + actualHtml.Should().Be(expectedHtml); + } + + [Test] + public void Convert_WhenMultiLineParagraphWithoutBlankLines_ShouldCreateSingleParagraph() + { + var markdown = $"aaa{Environment.NewLine}bbb{Environment.NewLine}ccc{Environment.NewLine}ddd"; + var expectedHtml = $"

aaa bbb ccc ddd

"; + + var actualHtml = Converter.Convert(markdown); + + actualHtml.Should().Be(expectedHtml); + } + + [Test] + public void Convert_WhenMultipleBlankLinesBetweenParagraphs_ShouldCreateTwoParagraphs() + { + var markdown = $"aaa{Environment.NewLine}{Environment.NewLine}{Environment.NewLine}bbb"; + var expectedHtml = $"

aaa

{Environment.NewLine}

bbb

"; + + var actualHtml = Converter.Convert(markdown); + + actualHtml.Should().Be(expectedHtml); + } + + [Test] + public void Convert_WhenBlankLinesAtStartAndEnd_ShouldIgnoreBlankLines() + { + var markdown = $" {Environment.NewLine}{Environment.NewLine}aaa" + + $"{Environment.NewLine}{Environment.NewLine}" + + $"bbb{Environment.NewLine}{Environment.NewLine}"; + + var expectedHtml = $"

aaa

{Environment.NewLine}

bbb

"; + + var actualHtml = Converter.Convert(markdown); + + actualHtml.Should().Be(expectedHtml); + } + + [Test] + public void Convert_WhenParagraphHasTrailingSpacesAndLeadingSpaces_ShouldTrimSpaces() + { + var markdown = " aaa "; + var expectedHtml = "

aaa

"; + + var actualHtml = Converter.Convert(markdown); + + actualHtml.Should().Be(expectedHtml); + } + + [Test] + public void Convert_WhenHeaderAfterParagraph_ShouldCloseParagraph() + { + var markdown = $"aaa{Environment.NewLine}# bbb"; + var expectedHtml = $"

aaa

{Environment.NewLine}

bbb

"; + + var actualHtml = Converter.Convert(markdown); + + actualHtml.Should().Be(expectedHtml); + } +} \ No newline at end of file diff --git a/cs/Tests/GlobalUsings.cs b/cs/Tests/GlobalUsings.cs new file mode 100644 index 000000000..cefced496 --- /dev/null +++ b/cs/Tests/GlobalUsings.cs @@ -0,0 +1 @@ +global using NUnit.Framework; \ No newline at end of file diff --git a/cs/Tests/InlineConvertingTests/EmphasisConvertingTests.cs b/cs/Tests/InlineConvertingTests/EmphasisConvertingTests.cs new file mode 100644 index 000000000..1fed19329 --- /dev/null +++ b/cs/Tests/InlineConvertingTests/EmphasisConvertingTests.cs @@ -0,0 +1,297 @@ +using FluentAssertions; + +namespace Tests.InlineConvertingTests; + +public class MarkupConverterEmphasisTests : MarkupConverterTestBase +{ + [Test] + public void Convert_WhenSingleUnderscoreEmphasis_ShouldCreateEmTag() + { + var markdown = "Текст, _окруженный с двух сторон_ одинарными символами подчерка"; + var expectedHtml = "

Текст, окруженный с двух сторон одинарными символами подчерка

"; + + var actualHtml = Converter.Convert(markdown); + + actualHtml.Should().Be(expectedHtml); + } + + [Test] + public void Convert_WhenDoubleUnderscoreEmphasis_ShouldCreateStrongTag() + { + var markdown = "__Выделенный двумя символами текст__ должен становиться полужирным"; + var expectedHtml = "

Выделенный двумя символами текст должен становиться полужирным

"; + + var actualHtml = Converter.Convert(markdown); + + actualHtml.Should().Be(expectedHtml); + } + + [Test] + public void Convert_WhenEscapedUnderscores_ShouldNotCreateEmphasis() + { + var markdown = @"\_Вот это\_, не должно выделиться тегом"; + var expectedHtml = "

_Вот это_, не должно выделиться тегом

"; + + var actualHtml = Converter.Convert(markdown); + + actualHtml.Should().Be(expectedHtml); + } + + [Test] + public void Convert_WhenBackSlashNotEscapingAnything_ShouldRemain() + { + var markdown = @"Здесь сим\волы экранирования\ \должны остаться.\"; + var expectedHtml = "

Здесь сим\\волы экранирования\\ \\должны остаться.\\

"; + + var actualHtml = Converter.Convert(markdown); + + actualHtml.Should().Be(expectedHtml); + } + + [Test] + public void Convert_WhenEscapedBackslashBeforeUnderscore_ShouldCreateEmphasis() + { + var markdown = @"\\_вот это будет выделено тегом_"; + var expectedHtml = "

\\вот это будет выделено тегом

"; + + var actualHtml = Converter.Convert(markdown); + + actualHtml.Should().Be(expectedHtml); + } + + [Test] + public void Convert_WhenBoldContainsItalic_ShouldWorkCorrectly() + { + var markdown = "Внутри __двойного выделения _одинарное_ тоже__ работает."; + var expectedHtml = "

Внутри двойного выделения одинарное тоже работает.

"; + + var actualHtml = Converter.Convert(markdown); + + actualHtml.Should().Be(expectedHtml); + } + + [Test] + public void Convert_WhenItalicContainsBold_ShouldNotWork() + { + var markdown = "Но не наоборот — внутри _одинарного __двойное__ не_ работает."; + var expectedHtml = "

Но не наоборот — внутри одинарного __двойное__ не работает.

"; + + var actualHtml = Converter.Convert(markdown); + + actualHtml.Should().Be(expectedHtml); + } + + [Test] + public void Convert_WhenUnderscoresInTextWithNumbers_ShouldNotCreateEmphasis() + { + var markdown = "Подчерки внутри текста c цифрами_12_3 не считаются выделением"; + var expectedHtml = "

Подчерки внутри текста c цифрами_12_3 не считаются выделением

"; + + var actualHtml = Converter.Convert(markdown); + + actualHtml.Should().Be(expectedHtml); + } + + [Test] + public void Convert_WhenEmphasisAtBeginningOfWord_ShouldWork() + { + var markdown = "и в _нач_але слова"; + var expectedHtml = "

и в начале слова

"; + + var actualHtml = Converter.Convert(markdown); + + actualHtml.Should().Be(expectedHtml); + } + + [Test] + public void Convert_WhenEmphasisInMiddleOfWord_ShouldWork() + { + var markdown = "и в сер_еди_не слова"; + var expectedHtml = "

и в середине слова

"; + + var actualHtml = Converter.Convert(markdown); + + actualHtml.Should().Be(expectedHtml); + } + + [Test] + public void Convert_WhenEmphasisAtEndOfWord_ShouldWork() + { + var markdown = "и в кон_це._ слова"; + var expectedHtml = "

и в конце. слова

"; + + var actualHtml = Converter.Convert(markdown); + + actualHtml.Should().Be(expectedHtml); + } + + [Test] + public void Convert_WhenEmphasisAcrossDifferentWords_ShouldNotWork() + { + var markdown = "В то же время выделение в ра_зных сл_овах не работает."; + var expectedHtml = "

В то же время выделение в ра_зных сл_овах не работает.

"; + + var actualHtml = Converter.Convert(markdown); + + actualHtml.Should().Be(expectedHtml); + } + + [Test] + public void Convert_WhenUnpairedUnderscoresInParagraph_ShouldNotCreateEmphasis() + { + var markdown = "__Непарные_ символы в рамках одного абзаца не считаются выделением."; + var expectedHtml = "

__Непарные_ символы в рамках одного абзаца не считаются выделением.

"; + + var actualHtml = Converter.Convert(markdown); + + actualHtml.Should().Be(expectedHtml); + } + + [Test] + public void Convert_WhenUnderscoreFollowedBySpaceAfterOpening_ShouldNotStartEmphasis() + { + var markdown = "Иначе эти_ подчерки_ не считаются выделением"; + var expectedHtml = "

Иначе эти_ подчерки_ не считаются выделением

"; + + var actualHtml = Converter.Convert(markdown); + + actualHtml.Should().Be(expectedHtml); + } + + [Test] + public void Convert_WhenUnderscoreFollowedBySpaceBeforeOpening_ShouldStartEmphasis() + { + var markdown = "Иначе эти _подчерки_ не считаются выделением"; + var expectedHtml = "

Иначе эти подчерки не считаются выделением

"; + + var actualHtml = Converter.Convert(markdown); + + actualHtml.Should().Be(expectedHtml); + } + + [Test] + public void Convert_WhenUnderscorePrecededBySpaceBeforeClosing_ShouldNotEndEmphasis() + { + var markdown = "Иначе эти _подчерки _не считаются окончанием выделения"; + var expectedHtml = "

Иначе эти _подчерки _не считаются окончанием выделения

"; + + var actualHtml = Converter.Convert(markdown); + + actualHtml.Should().Be(expectedHtml); + } + + [Test] + public void Convert_WhenUnderscoreNotPrecededBySpaceBeforeClosing_ShouldEndEmphasis() + { + var markdown = "Иначе эти _подчерки_ не считаются окончанием выделения"; + var expectedHtml = "

Иначе эти подчерки не считаются окончанием выделения

"; + + var actualHtml = Converter.Convert(markdown); + + actualHtml.Should().Be(expectedHtml); + } + + [Test] + public void Convert_WhenIntersectingDoubleAndSingleUnderscores_ShouldNotCreateEmphasis() + { + var markdown = "В случае __пересечения _двойных__ и одинарных_ подчерков"; + var expectedHtml = "

В случае __пересечения _двойных__ и одинарных_ подчерков

"; + + var actualHtml = Converter.Convert(markdown); + + actualHtml.Should().Be(expectedHtml); + } + + [Test] + public void Convert_WhenEmptyEmphasisBetweenUnderscores_ShouldNotCreateEmphasis() + { + var markdown = "Если внутри подчерков пустая строка ____, то они остаются символами подчерка."; + var expectedHtml = "

Если внутри подчерков пустая строка ____, то они остаются символами подчерка.

"; + + var actualHtml = Converter.Convert(markdown); + + actualHtml.Should().Be(expectedHtml); + } + + [Test] + public void Convert_WhenSingleUnderscoresWithSpacesBetween_ShouldNotCreateEmphasis() + { + var markdown = "Если _ здесь есть пробелы _, то теги не создаются"; + var expectedHtml = "

Если _ здесь есть пробелы _, то теги не создаются

"; + + var actualHtml = Converter.Convert(markdown); + + actualHtml.Should().Be(expectedHtml); + } + + [Test] + public void Convert_WhenDoubleUnderscoresWithSpacesBetween_ShouldNotCreateEmphasis() + { + var markdown = "Если __ здесь есть пробелы __, то теги не создаются"; + var expectedHtml = "

Если __ здесь есть пробелы __, то теги не создаются

"; + + var actualHtml = Converter.Convert(markdown); + + actualHtml.Should().Be(expectedHtml); + } + + [Test] + public void Convert_WhenMixedEmphasisWithEscaping_ShouldHandleCorrectly() + { + var markdown = "Текст с _курсивом_, __жирным__ и \\_экранированным\\_"; + var expectedHtml = "

Текст с курсивом, жирным и _экранированным_

"; + + var actualHtml = Converter.Convert(markdown); + + actualHtml.Should().Be(expectedHtml); + } + + [Test] + public void Convert_WhenMultipleParagraphsWithEmphasis_ShouldHandleCorrectly() + { + var markdown = $"Первый абзац с _курсивом_{Environment.NewLine}{Environment.NewLine}" + + $"Второй абзац с __жирным__"; + var expectedHtml = $"

Первый абзац с курсивом

{Environment.NewLine}" + + $"

Второй абзац с жирным

"; + + var actualHtml = Converter.Convert(markdown); + + actualHtml.Should().Be(expectedHtml); + } + + [Test] + public void Convert_WhenComplexNestedEmphasis_ShouldHandleCorrectly() + { + var markdown = "Вот __пример _вложенного_ выделения__ и обычного _курсива_"; + var expectedHtml = + "

Вот пример вложенного выделения и обычного курсива

"; + + var actualHtml = Converter.Convert(markdown); + + actualHtml.Should().Be(expectedHtml); + } + + [Test] + public void Convert_WhenOnlyUnderscoresInText_ShouldNotCreateEmphasis() + { + var markdown = "Текст с только подчерками: ___ и ___"; + var expectedHtml = "

Текст с только подчерками: ___ и ___

"; + + var actualHtml = Converter.Convert(markdown); + + actualHtml.Should().Be(expectedHtml); + } + + [Test] + public void Convert_WhenExampleFromSpecification_ShouldMatchExpected() + { + var markdown = + "За подчерками, начинающими выделение, должен следовать непробельный символ. Иначе эти_ подчерки_ не считаются выделением"; + var expectedHtml = + "

За подчерками, начинающими выделение, должен следовать непробельный символ. Иначе эти_ подчерки_ не считаются выделением

"; + + var actualHtml = Converter.Convert(markdown); + + actualHtml.Should().Be(expectedHtml); + } +} \ No newline at end of file diff --git a/cs/Tests/MarkupConverterTestBase.cs b/cs/Tests/MarkupConverterTestBase.cs new file mode 100644 index 000000000..c24fec356 --- /dev/null +++ b/cs/Tests/MarkupConverterTestBase.cs @@ -0,0 +1,23 @@ +using MarkupConverter.Parsers; +using MarkupConverter.Parsers.BlockParsers; +using MarkupConverter.Parsers.InlineParsers; +using MarkupConverter.Renderers; + +namespace Tests; + +public class MarkupConverterTestBase +{ + protected MarkupConverter.MarkupConverter Converter; + + [SetUp] + public void Setup() + { + var parser = new MarkdownParser( + [new HeaderParser(), new ParagraphParser()], + new InlineParser() + ); + var renderer = new HtmlRenderer(); + + Converter = new MarkupConverter.MarkupConverter(parser ,renderer); + } +} \ No newline at end of file diff --git a/cs/Tests/Tests.csproj b/cs/Tests/Tests.csproj new file mode 100644 index 000000000..53c629d74 --- /dev/null +++ b/cs/Tests/Tests.csproj @@ -0,0 +1,25 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + + + + + + + + + diff --git a/cs/clean-code.sln b/cs/clean-code.sln index 2206d54db..d12ed4bf3 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}") = "MarkupConverter", "MarkupConverter\MarkupConverter.csproj", "{1C395AAA-D756-40B3-9FE9-9DB8D79CAE48}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tests", "Tests\Tests.csproj", "{4CE52D84-D1A1-4183-908E-75A54ED2F926}" +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 + {1C395AAA-D756-40B3-9FE9-9DB8D79CAE48}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1C395AAA-D756-40B3-9FE9-9DB8D79CAE48}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1C395AAA-D756-40B3-9FE9-9DB8D79CAE48}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1C395AAA-D756-40B3-9FE9-9DB8D79CAE48}.Release|Any CPU.Build.0 = Release|Any CPU + {4CE52D84-D1A1-4183-908E-75A54ED2F926}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4CE52D84-D1A1-4183-908E-75A54ED2F926}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4CE52D84-D1A1-4183-908E-75A54ED2F926}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4CE52D84-D1A1-4183-908E-75A54ED2F926}.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