From d2e27302f99224fdb23c7d2c6a0dd9799b325177 Mon Sep 17 00:00:00 2001 From: kyrylokhlopko Date: Thu, 13 Jun 2024 20:48:35 +0200 Subject: [PATCH] Covering code block parsing with tests --- Markdown/HTMLRenderer.swift | 16 +- Markdown/Lexer.swift | 40 +++-- Markdown/Markdown.swift | 19 ++- Markdown/Parser.swift | 19 ++- Tests/MarkdownTests/MarkdownParserTests.swift | 160 +++++++++++++++++- 5 files changed, 230 insertions(+), 24 deletions(-) diff --git a/Markdown/HTMLRenderer.swift b/Markdown/HTMLRenderer.swift index 1a1f214..2ecbfe5 100644 --- a/Markdown/HTMLRenderer.swift +++ b/Markdown/HTMLRenderer.swift @@ -6,6 +6,7 @@ public struct HTMLRenderer { } public func render() -> String { + /* """ @@ -34,10 +35,14 @@ public struct HTMLRenderer { - \(markdown.blocks.map { render($0) }.joined(separator: "\n")) + */ + + "\(markdown.blocks.map { render($0) }.joined(separator: "\n"))" + /* """ + */ } private func render(_ block: Block) -> String { @@ -48,10 +53,15 @@ public struct HTMLRenderer { "\(value)" case let .list(blocks): "" - case let .code(value): - "
\(value)
" + case let .code(value, info): + render(code: value, lang: info.lang) case let .h(level, blocks): "\(blocks.map { render($0) }.joined())" } } + + private func render(code: String, lang: String?) -> String { + let className = lang.map { " class=\"language-\($0)\"" } ?? "" + return "
\(code)
" + } } diff --git a/Markdown/Lexer.swift b/Markdown/Lexer.swift index e460ef1..097a1b4 100644 --- a/Markdown/Lexer.swift +++ b/Markdown/Lexer.swift @@ -6,7 +6,7 @@ internal enum MarkdownToken { case line(String) case list case header(HeaderLevel) - case codeBlock(lang: String?) + case codeBlock(CodeBlockInfo) var rawValue: String { switch self { @@ -18,8 +18,8 @@ internal enum MarkdownToken { return "_" case let .header(level): return Array(repeating: "#", count: level.rawValue).joined() - case let .codeBlock(lang): - return "```\(lang ?? "")" + case let .codeBlock(info): + return "```\(info.lang ?? "") \(info.rest ?? "")" } } } @@ -49,6 +49,9 @@ internal struct Lexer { return header(start: start) case "-": lastPos += 1 + if lastPos < contents.count && contents[lastPos] == " " { + lastPos += 1 + } return .list case "`": // consume 3 backticks @@ -56,15 +59,32 @@ internal struct Lexer { while lastPos < contents.count && lastPos - start < 3 && contents[lastPos] == "`" { lastPos += 1 } - var lang: String? = nil - /* - while lastPos < contents.count && (contents[lastPos] != "\n" || contents[lastPos] != " ") { + if lastPos - start < 3 { + fallthrough + } + var lang: String? + var rest: String? + var restStart: Int? + while lastPos < contents.count && contents[lastPos] != "\n" { + if contents[lastPos] == " " { + if restStart == nil { + restStart = lastPos + } + lastPos += 1 + continue + } + if lang == nil { + lang = "" + } + lang! += String(contents[lastPos]) + restStart = nil lastPos += 1 } - lang = String(contents[start + 3..= value.startIndex && value[i] == "\n" { + value.removeLast() + i = value.index(before: value.endIndex) + } + blocks.append(.code(value, info)) return default: value += tok.rawValue } } + blocks.append(.code(value, info)) } private mutating func checkParagraph() { diff --git a/Tests/MarkdownTests/MarkdownParserTests.swift b/Tests/MarkdownTests/MarkdownParserTests.swift index f646815..730a135 100644 --- a/Tests/MarkdownTests/MarkdownParserTests.swift +++ b/Tests/MarkdownTests/MarkdownParserTests.swift @@ -4,9 +4,15 @@ import Testing @testable import DotMd +extension Block: CustomTestStringConvertible { + public var testDescription: String { + description + } +} + @Suite("Markdown > Parser Tests") struct MarkdownParserTests { - @Test + @Test("Empty input") func empty() { var parser = Parser(contents: "") @@ -15,7 +21,7 @@ struct MarkdownParserTests { #expect(result == []) } - @Test + @Test("Just one simple paragraph") func simpleParagraph() { var parser = Parser(contents: "This is simple paragraph of text.") @@ -27,7 +33,7 @@ struct MarkdownParserTests { #expect(result == expected) } - @Test + @Test("Multiple paragraphs and headers intermixed") func multipleParagraphsAndHeaderParagraphs() { var parser = Parser(contents: """ @@ -51,4 +57,152 @@ struct MarkdownParserTests { ] #expect(result == expected) } + + @Test("Parse a simple list") + func lists() { + var parser = Parser(contents: """ + Here is a list: + - First item + - Second item + - Third item + + And text after list. + """) + + let result = parser.parse() + + #expect(result == [ + .p([.text("Here is a list:", .regular)]), + .list([ + .text("First item", .regular), + .text("Second item", .regular), + .text("Third item", .regular), + ]), + .p([.text("And text after list.", .regular)]), + ]) + } + + /// Targets to cover examples from 119 to 147 + @Suite("4.5 Fenced code blocks (from CommonMark Spec)") + struct FencedCodeBlocks { + struct Argument: CustomTestStringConvertible { + let name: String + let input: String + let expectedResult: [Block] + + var testDescription: String { name } + } + + private static let arguments: [Argument] = [ + Argument( + name: "Example 119: Simple fence with backticks", + input: """ + ``` + < + > + ``` + """, + expectedResult: [ + .code("<\n >", .init(lang: nil, rest: nil)) + ] + ), + Argument( + name: "Example 121: Not enough backticks", + input: """ + `` + foo + `` + """, + expectedResult: [ + .p([.text("``\nfoo\n``", .regular)]) + ] + ), + Argument( + name: "Example 126: Unclosed code block at the end of the document", + input: """ + ``` + """, + expectedResult: [.code("", .init(lang: nil, rest: nil))] + ), + Argument( + name: "Example 140: Interrupt paragraphs without a blank line", + input: """ + foo + ``` + bar + ``` + baz + """, + expectedResult: [ + .p([.text("foo", .regular)]), + .code("bar", CodeBlockInfo(lang: nil, rest: nil)), + .p([.text("baz", .regular)]), + ] + ), + Argument( + name: "Example 142: Info string with language name", + input: """ + ```ruby + def foo(x) + return 3 + end + ``` + """, + expectedResult: [ + .code("def foo(x)\n return 3\nend", CodeBlockInfo(lang: "ruby", rest: nil)) + ] + ), + ] + + @Test(arguments: arguments) + func example(argument: Argument) throws { + var parser = Parser(contents: argument.input) + + let result = parser.parse() + + #expect(result == argument.expectedResult) + } + + @Test("Parse the most basic code block") + func simpleCodeBlock() { + var parser = Parser(contents: """ + Look at the following sample code: + ``` + let x = 5 + let y = 10 + let z = x + y + ``` + Isn't it cool? + """) + + let result = parser.parse() + + #expect(result == [ + .p([.text("Look at the following sample code:", .regular)]), + .code("let x = 5\nlet y = 10\nlet z = x + y", CodeBlockInfo(lang: nil, rest: nil)), + .p([.text("Isn't it cool?", .regular)]), + ]) + } + + @Test("Carry language information within the block") + func codeBlockWithLanguage() { + var parser = Parser(contents: """ + Look at the following sample code: + ``` swift + let x = 5 + let y = 10 + let z = x + y + ``` + Isn't it cool? + """) + + let result = parser.parse() + + #expect(result == [ + .p([.text("Look at the following sample code:", .regular)]), + .code("let x = 5\nlet y = 10\nlet z = x + y", CodeBlockInfo(lang: "swift", rest: nil)), + .p([.text("Isn't it cool?", .regular)]), + ]) + } + } }