Skip to content

Commit

Permalink
Covering code block parsing with tests
Browse files Browse the repository at this point in the history
  • Loading branch information
khlopko committed Jun 13, 2024
1 parent 2f5d95d commit d2e2730
Show file tree
Hide file tree
Showing 5 changed files with 230 additions and 24 deletions.
16 changes: 13 additions & 3 deletions Markdown/HTMLRenderer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ public struct HTMLRenderer {
}

public func render() -> String {
/*
"""
<!DOCTYPE html>
<html>
Expand Down Expand Up @@ -34,10 +35,14 @@ public struct HTMLRenderer {
</style>
</head>
<body>
\(markdown.blocks.map { render($0) }.joined(separator: "\n"))
*/

"\(markdown.blocks.map { render($0) }.joined(separator: "\n"))"
/*
</body>
</html>
"""
*/
}

private func render(_ block: Block) -> String {
Expand All @@ -48,10 +53,15 @@ public struct HTMLRenderer {
"<span class=\"\(style)\">\(value)</span>"
case let .list(blocks):
"<ul>\(blocks.map { "<li>\(render($0))</li>" }.joined())</ul>"
case let .code(value):
"<pre><code>\(value)</code></pre>"
case let .code(value, info):
render(code: value, lang: info.lang)
case let .h(level, blocks):
"<h\(level.rawValue)>\(blocks.map { render($0) }.joined())</h\(level.rawValue)>"
}
}

private func render(code: String, lang: String?) -> String {
let className = lang.map { " class=\"language-\($0)\"" } ?? ""
return "<pre><code\(className)>\(code)</code></pre>"
}
}
40 changes: 30 additions & 10 deletions Markdown/Lexer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 ?? "")"
}
}
}
Expand Down Expand Up @@ -49,22 +49,42 @@ internal struct Lexer {
return header(start: start)
case "-":
lastPos += 1
if lastPos < contents.count && contents[lastPos] == " " {
lastPos += 1
}
return .list
case "`":
// consume 3 backticks
let start = lastPos
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..<lastPos])
print(lastPos, lang)
*/
return .codeBlock(lang: lang)
if let restStart {
rest = String(contents[restStart..<lastPos])
}
let info = CodeBlockInfo(lang: lang, rest: rest)
return .codeBlock(info)
default:
return line(start: start)
}
Expand Down
19 changes: 15 additions & 4 deletions Markdown/Markdown.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ public enum Block: Equatable {
case p([Block])
case text(String, TextStyle)
case list([Block])
case code(String)
case code(String, CodeBlockInfo)
indirect case h(HeaderLevel, [Block])
}

Expand All @@ -45,7 +45,7 @@ extension Block: CustomStringConvertible {
return blocks.map { block in
block.description
}.joined(separator: "") + "\n"
case let .code(value):
case let .code(value, _):
return value
case let .h(level, block):
return "\(String(Array(repeating: "#", count: level.rawValue))) \(block.description)"
Expand All @@ -62,8 +62,14 @@ extension Block: CustomDebugStringConvertible {
return "text(\(value), \(style))"
case let .list(blocks):
return "list(\(blocks.map(\.debugDescription).joined(separator: ", ")))"
case let .code(value):
return "cade(\(value))"
case let .code(value, info):
var prefix: String = [info.lang, info.rest].compactMap {
$0?.description
}.joined(separator: "")
if !prefix.isEmpty {
prefix = "[\(prefix)]"
}
return "code\(prefix)(\(value))"
case let .h(level, block):
return "h(\(level), \(block.debugDescription))"
}
Expand All @@ -83,3 +89,8 @@ public enum TextStyle: Equatable {
case regular
}

public struct CodeBlockInfo: Equatable {
public let lang: String?
public let rest: String?
}

19 changes: 15 additions & 4 deletions Markdown/Parser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,8 @@ internal struct Parser {
parseHeader(level: level)
case .list:
parseList()
case .codeBlock:
parseCodeBlock()
case let .codeBlock(info):
parseCodeBlock(info: info)
}
}
checkParagraph()
Expand Down Expand Up @@ -84,17 +84,28 @@ internal struct Parser {
blocks.append(.list(items))
}

private mutating func parseCodeBlock() {
private mutating func parseCodeBlock(info: CodeBlockInfo) {
var value = ""
while let tok = lexer.nextTok() {
switch tok {
case .codeBlock:
blocks.append(.code(value))
var i = value.startIndex
while i < value.endIndex && value[i] == "\n" {
value.removeFirst()
i = value.startIndex
}
i = value.index(before: value.endIndex)
while i >= 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() {
Expand Down
160 changes: 157 additions & 3 deletions Tests/MarkdownTests/MarkdownParserTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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: "")

Expand All @@ -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.")

Expand All @@ -27,7 +33,7 @@ struct MarkdownParserTests {
#expect(result == expected)
}

@Test
@Test("Multiple paragraphs and headers intermixed")
func multipleParagraphsAndHeaderParagraphs() {
var parser = Parser(contents:
"""
Expand All @@ -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)]),
])
}
}
}

0 comments on commit d2e2730

Please sign in to comment.