diff --git a/Sources/SwiftParser/Core/CodeParser.swift b/Sources/SwiftParser/Core/CodeParser.swift index 3bbbc68..076b9ab 100644 --- a/Sources/SwiftParser/Core/CodeParser.swift +++ b/Sources/SwiftParser/Core/CodeParser.swift @@ -13,6 +13,12 @@ public final class CodeParser where Node: CodeNodeElement, Token: C var context = CodeContext(current: root, tokens: tokens, state: language.state(of: normalized)) while context.consuming < context.tokens.count { + // Stop at EOF without recording an error + if let token = context.tokens[context.consuming] as? MarkdownToken, + token.element == .eof { + break + } + var matched = false for builder in language.builders { if builder.build(from: &context) { @@ -22,13 +28,11 @@ public final class CodeParser where Node: CodeNodeElement, Token: C } if !matched { - // If no consumer matched, we have an unrecognized token + // If no builder matched, record an error and skip the token let token = context.tokens[context.consuming] let error = CodeError("Unrecognized token: \(token.element)", range: token.range) context.errors.append(error) - context.consuming += 1 // Skip the unrecognized token - } else { - break // Exit the loop if a consumer successfully processed tokens + context.consuming += 1 } } diff --git a/Sources/SwiftParser/Markdown/Builders/MarkdownBlockquoteBuilder.swift b/Sources/SwiftParser/Markdown/Builders/MarkdownBlockquoteBuilder.swift new file mode 100644 index 0000000..9c3ea84 --- /dev/null +++ b/Sources/SwiftParser/Markdown/Builders/MarkdownBlockquoteBuilder.swift @@ -0,0 +1,38 @@ +import Foundation + +public class MarkdownBlockquoteBuilder: CodeNodeBuilder { + public init() {} + + public func build(from context: inout CodeContext) -> Bool { + guard context.consuming < context.tokens.count, + let token = context.tokens[context.consuming] as? MarkdownToken, + token.element == .gt, + isStartOfLine(context) else { return false } + context.consuming += 1 + // optional leading space + if context.consuming < context.tokens.count, + let space = context.tokens[context.consuming] as? MarkdownToken, + space.element == .space { + context.consuming += 1 + } + // Parse inline content until a newline or EOF inside the blockquote + let children = MarkdownInlineParser.parseInline(&context) + let node = BlockquoteNode() + for child in children { node.append(child) } + context.current.append(node) + if context.consuming < context.tokens.count, + let nl = context.tokens[context.consuming] as? MarkdownToken, + nl.element == .newline { + context.consuming += 1 + } + return true + } + + private func isStartOfLine(_ context: CodeContext) -> Bool { + if context.consuming == 0 { return true } + if let prev = context.tokens[context.consuming - 1] as? MarkdownToken { + return prev.element == .newline + } + return false + } +} diff --git a/Sources/SwiftParser/Markdown/Builders/MarkdownHeadingBuilder.swift b/Sources/SwiftParser/Markdown/Builders/MarkdownHeadingBuilder.swift new file mode 100644 index 0000000..8a7620a --- /dev/null +++ b/Sources/SwiftParser/Markdown/Builders/MarkdownHeadingBuilder.swift @@ -0,0 +1,49 @@ +import Foundation + +public class MarkdownHeadingBuilder: CodeNodeBuilder { + public init() {} + + public func build(from context: inout CodeContext) -> Bool { + guard context.consuming < context.tokens.count, + let token = context.tokens[context.consuming] as? MarkdownToken, + token.element == .hash, + isStartOfLine(context) + else { return false } + + var level = 0 + var idx = context.consuming + while idx < context.tokens.count, + let t = context.tokens[idx] as? MarkdownToken, + t.element == .hash, + level < 6 { + level += 1 + idx += 1 + } + guard idx < context.tokens.count, + let space = context.tokens[idx] as? MarkdownToken, + space.element == .space else { return false } + idx += 1 + + context.consuming = idx + // Parse inline content until a newline or EOF + var children = MarkdownInlineParser.parseInline(&context) + let node = HeaderNode(level: level) + for child in children { node.append(child) } + context.current.append(node) + + if context.consuming < context.tokens.count, + let nl = context.tokens[context.consuming] as? MarkdownToken, + nl.element == .newline { + context.consuming += 1 + } + return true + } + + private func isStartOfLine(_ context: CodeContext) -> Bool { + if context.consuming == 0 { return true } + if let prev = context.tokens[context.consuming - 1] as? MarkdownToken { + return prev.element == .newline + } + return false + } +} diff --git a/Sources/SwiftParser/Markdown/Builders/MarkdownInlineParser.swift b/Sources/SwiftParser/Markdown/Builders/MarkdownInlineParser.swift new file mode 100644 index 0000000..9be8392 --- /dev/null +++ b/Sources/SwiftParser/Markdown/Builders/MarkdownInlineParser.swift @@ -0,0 +1,242 @@ +import Foundation + +struct MarkdownInlineParser { + static func parseInline( + _ context: inout CodeContext, + stopAt: Set = [.newline, .eof] + ) -> [MarkdownNodeBase] { + var nodes: [MarkdownNodeBase] = [] + var delimiters: [Delimiter] = [] + + while context.consuming < context.tokens.count { + guard let token = context.tokens[context.consuming] as? MarkdownToken else { break } + if stopAt.contains(token.element) { break } + + switch token.element { + case .asterisk, .underscore: + let marker = token.element + var count = 0 + while context.consuming < context.tokens.count, + let t = context.tokens[context.consuming] as? MarkdownToken, + t.element == marker { + count += 1 + context.consuming += 1 + } + handleDelimiter(marker: marker, count: count, nodes: &nodes, stack: &delimiters) + case .inlineCode: + nodes.append(InlineCodeNode(code: trimBackticks(token.text))) + context.consuming += 1 + case .formula: + nodes.append(FormulaNode(expression: trimFormula(token.text))) + context.consuming += 1 + case .htmlTag, .htmlBlock, .htmlUnclosedBlock, .htmlEntity: + nodes.append(HTMLNode(content: token.text)) + context.consuming += 1 + case .exclamation: + if let image = parseImage(&context) { + nodes.append(image) + } else { + nodes.append(TextNode(content: token.text)) + context.consuming += 1 + } + case .leftBracket: + if let link = parseLinkOrFootnote(&context) { + nodes.append(link) + } else { + nodes.append(TextNode(content: token.text)) + context.consuming += 1 + } + case .autolink, .url: + let url = trimAutolink(token.text) + let link = LinkNode(url: url, title: url) + nodes.append(link) + context.consuming += 1 + default: + let shouldMerge: Bool + if let lastIndex = nodes.indices.last, + let _ = nodes[lastIndex] as? TextNode, + !delimiters.contains(where: { $0.index == lastIndex }) { + shouldMerge = true + } else { + shouldMerge = false + } + + if shouldMerge, let last = nodes.last as? TextNode { + last.content += token.text + } else { + nodes.append(TextNode(content: token.text)) + } + context.consuming += 1 + } + } + + return nodes + } + + + private struct Delimiter { + var marker: MarkdownTokenElement + var count: Int + var index: Int + } + + private static func handleDelimiter( + marker: MarkdownTokenElement, + count: Int, + nodes: inout [MarkdownNodeBase], + stack: inout [Delimiter] + ) { + var remaining = count + + while remaining > 0, let openIdx = stack.lastIndex(where: { $0.marker == marker }) { + let open = stack.remove(at: openIdx) + let closeCount = min(open.count, remaining) + + let start = open.index + 1 + let removedCount = nodes.count - open.index + let content = Array(nodes[start..= open.index { + stack[i].index -= removedCount - 1 + } + } + + let node: MarkdownNodeBase = (closeCount >= 2) ? StrongNode(content: "") : EmphasisNode(content: "") + for child in content { node.append(child) } + nodes.append(node) + + remaining -= closeCount + } + + if remaining > 0 { + let text = String(repeating: marker.rawValue, count: remaining) + nodes.append(TextNode(content: text)) + stack.append(Delimiter(marker: marker, count: remaining, index: nodes.count - 1)) + } + } + + private static func parseLinkOrFootnote(_ context: inout CodeContext) -> MarkdownNodeBase? { + let start = context.consuming + context.consuming += 1 + // Footnote reference [^id] + if context.consuming < context.tokens.count, + let caret = context.tokens[context.consuming] as? MarkdownToken, + caret.element == .caret { + context.consuming += 1 + var ident = "" + while context.consuming < context.tokens.count, + let t = context.tokens[context.consuming] as? MarkdownToken, + t.element != .rightBracket { + ident += t.text + context.consuming += 1 + } + guard context.consuming < context.tokens.count, + let rb = context.tokens[context.consuming] as? MarkdownToken, + rb.element == .rightBracket else { context.consuming = start; return nil } + context.consuming += 1 + return FootnoteNode(identifier: ident, content: "", referenceText: nil, range: rb.range) + } + + let textNodes = parseInline(&context, stopAt: [.rightBracket]) + guard context.consuming < context.tokens.count, + let rb = context.tokens[context.consuming] as? MarkdownToken, + rb.element == .rightBracket else { context.consuming = start; return nil } + context.consuming += 1 + + // Inline link [text](url) + if context.consuming < context.tokens.count, + let lp = context.tokens[context.consuming] as? MarkdownToken, + lp.element == .leftParen { + context.consuming += 1 + var url = "" + while context.consuming < context.tokens.count, + let t = context.tokens[context.consuming] as? MarkdownToken, + t.element != .rightParen { + url += t.text + context.consuming += 1 + } + guard context.consuming < context.tokens.count, + let rp = context.tokens[context.consuming] as? MarkdownToken, + rp.element == .rightParen else { context.consuming = start; return nil } + context.consuming += 1 + let link = LinkNode(url: url, title: "") + for child in textNodes { link.append(child) } + return link + } + + // Reference link [text][id] + if context.consuming < context.tokens.count, + let lb = context.tokens[context.consuming] as? MarkdownToken, + lb.element == .leftBracket { + context.consuming += 1 + var id = "" + while context.consuming < context.tokens.count, + let t = context.tokens[context.consuming] as? MarkdownToken, + t.element != .rightBracket { + id += t.text + context.consuming += 1 + } + guard context.consuming < context.tokens.count, + let rb2 = context.tokens[context.consuming] as? MarkdownToken, + rb2.element == .rightBracket else { context.consuming = start; return nil } + context.consuming += 1 + let ref = ReferenceNode(identifier: id, url: "", title: "") + for child in textNodes { ref.append(child) } + return ref + } + + context.consuming = start + return nil + } + + private static func parseImage(_ context: inout CodeContext) -> MarkdownNodeBase? { + guard context.consuming + 1 < context.tokens.count, + let lb = context.tokens[context.consuming + 1] as? MarkdownToken, + lb.element == .leftBracket else { return nil } + context.consuming += 2 + let altNodes = parseInline(&context, stopAt: [.rightBracket]) + guard context.consuming < context.tokens.count, + let rb = context.tokens[context.consuming] as? MarkdownToken, + rb.element == .rightBracket else { context.consuming -= 2; return nil } + context.consuming += 1 + guard context.consuming < context.tokens.count, + let lp = context.tokens[context.consuming] as? MarkdownToken, + lp.element == .leftParen else { context.consuming -= 3; return nil } + context.consuming += 1 + var url = "" + while context.consuming < context.tokens.count, + let t = context.tokens[context.consuming] as? MarkdownToken, + t.element != .rightParen { + url += t.text + context.consuming += 1 + } + guard context.consuming < context.tokens.count, + let rp = context.tokens[context.consuming] as? MarkdownToken, + rp.element == .rightParen else { context.consuming -= 4; return nil } + context.consuming += 1 + let alt = altNodes.compactMap { ($0 as? TextNode)?.content }.joined() + return ImageNode(url: url, alt: alt) + } + + private static func trimBackticks(_ text: String) -> String { + var t = text + while t.hasPrefix("`") { t.removeFirst() } + while t.hasSuffix("`") { t.removeLast() } + return t + } + + private static func trimFormula(_ text: String) -> String { + var t = text + if t.hasPrefix("$") { t.removeFirst() } + if t.hasSuffix("$") { t.removeLast() } + return t + } + + private static func trimAutolink(_ text: String) -> String { + if text.hasPrefix("<") && text.hasSuffix(">") { + return String(text.dropFirst().dropLast()) + } + return text + } +} diff --git a/Sources/SwiftParser/Markdown/Builders/MarkdownNewlineBuilder.swift b/Sources/SwiftParser/Markdown/Builders/MarkdownNewlineBuilder.swift new file mode 100644 index 0000000..a338011 --- /dev/null +++ b/Sources/SwiftParser/Markdown/Builders/MarkdownNewlineBuilder.swift @@ -0,0 +1,14 @@ +import Foundation + +public class MarkdownNewlineBuilder: CodeNodeBuilder { + public init() {} + + public func build(from context: inout CodeContext) -> Bool { + guard context.consuming < context.tokens.count, + let token = context.tokens[context.consuming] as? MarkdownToken, + token.element == .newline else { return false } + context.consuming += 1 + context.current = context.current.parent ?? context.current + return true + } +} diff --git a/Sources/SwiftParser/Markdown/Builders/MarkdownParagraphBuilder.swift b/Sources/SwiftParser/Markdown/Builders/MarkdownParagraphBuilder.swift new file mode 100644 index 0000000..3b6b3b3 --- /dev/null +++ b/Sources/SwiftParser/Markdown/Builders/MarkdownParagraphBuilder.swift @@ -0,0 +1,25 @@ +import Foundation + +public class MarkdownParagraphBuilder: CodeNodeBuilder { + public init() {} + + public func build(from context: inout CodeContext) -> Bool { + guard context.consuming < context.tokens.count, + let token = context.tokens[context.consuming] as? MarkdownToken, + token.element != .newline, + token.element != .eof else { return false } + + let node = ParagraphNode(range: token.range) + // Stop parsing at either a newline or EOF to avoid leftover empty nodes + let children = MarkdownInlineParser.parseInline(&context) + for child in children { node.append(child) } + context.current.append(node) + + if context.consuming < context.tokens.count, + let nl = context.tokens[context.consuming] as? MarkdownToken, + nl.element == .newline { + context.consuming += 1 + } + return true + } +} diff --git a/Sources/SwiftParser/Markdown/Builders/MarkdownReferenceDefinitionBuilder.swift b/Sources/SwiftParser/Markdown/Builders/MarkdownReferenceDefinitionBuilder.swift new file mode 100644 index 0000000..2856e2f --- /dev/null +++ b/Sources/SwiftParser/Markdown/Builders/MarkdownReferenceDefinitionBuilder.swift @@ -0,0 +1,70 @@ +import Foundation + +public class MarkdownReferenceDefinitionBuilder: CodeNodeBuilder { + public init() {} + + public func build(from context: inout CodeContext) -> Bool { + guard context.consuming < context.tokens.count, + isStartOfLine(context), + let lb = context.tokens[context.consuming] as? MarkdownToken, + lb.element == .leftBracket else { return false } + var idx = context.consuming + 1 + var isFootnote = false + if idx < context.tokens.count, + let caret = context.tokens[idx] as? MarkdownToken, + caret.element == .caret { + isFootnote = true + idx += 1 + } + var identifier = "" + while idx < context.tokens.count, + let t = context.tokens[idx] as? MarkdownToken, + t.element != .rightBracket { + identifier += t.text + idx += 1 + } + guard idx < context.tokens.count, + let rb = context.tokens[idx] as? MarkdownToken, + rb.element == .rightBracket else { return false } + idx += 1 + guard idx < context.tokens.count, + let colon = context.tokens[idx] as? MarkdownToken, + colon.element == .colon else { return false } + idx += 1 + // skip spaces + while idx < context.tokens.count, + let sp = context.tokens[idx] as? MarkdownToken, + sp.element == .space { + idx += 1 + } + var value = "" + while idx < context.tokens.count, + let t = context.tokens[idx] as? MarkdownToken, + t.element != .newline { + value += t.text + idx += 1 + } + context.consuming = idx + if idx < context.tokens.count, + let nl = context.tokens[idx] as? MarkdownToken, + nl.element == .newline { + context.consuming += 1 + } + if isFootnote { + let node = FootnoteNode(identifier: identifier, content: value, referenceText: nil, range: lb.range) + context.current.append(node) + } else { + let node = ReferenceNode(identifier: identifier, url: value, title: "") + context.current.append(node) + } + return true + } + + private func isStartOfLine(_ context: CodeContext) -> Bool { + if context.consuming == 0 { return true } + if let prev = context.tokens[context.consuming - 1] as? MarkdownToken { + return prev.element == .newline + } + return false + } +} diff --git a/Sources/SwiftParser/Markdown/MarkdownLanguage.swift b/Sources/SwiftParser/Markdown/MarkdownLanguage.swift index 89cb8c7..6b62d87 100644 --- a/Sources/SwiftParser/Markdown/MarkdownLanguage.swift +++ b/Sources/SwiftParser/Markdown/MarkdownLanguage.swift @@ -12,7 +12,13 @@ public class MarkdownLanguage: CodeLanguage { // MARK: - Initialization public init( tokenizer: any CodeTokenizer = MarkdownTokenizer(), - consumers: [any CodeNodeBuilder] = [] + consumers: [any CodeNodeBuilder] = [ + MarkdownReferenceDefinitionBuilder(), + MarkdownHeadingBuilder(), + MarkdownBlockquoteBuilder(), + MarkdownParagraphBuilder(), + MarkdownNewlineBuilder() + ] ) { self.tokenizer = tokenizer self.builders = consumers diff --git a/Tests/SwiftParserTests/Markdown/Consumer/MarkdownInlineConsumerTests.swift b/Tests/SwiftParserTests/Markdown/Consumer/MarkdownInlineConsumerTests.swift index 1b8eb19..3785d5e 100644 --- a/Tests/SwiftParserTests/Markdown/Consumer/MarkdownInlineConsumerTests.swift +++ b/Tests/SwiftParserTests/Markdown/Consumer/MarkdownInlineConsumerTests.swift @@ -18,7 +18,11 @@ final class MarkdownInlineConsumerTests: XCTestCase { XCTAssertTrue(context.errors.isEmpty) XCTAssertEqual(node.children.count, 1) - let emph = node.children.first as? EmphasisNode + guard let para = node.children.first as? ParagraphNode else { + return XCTFail("Expected ParagraphNode") + } + XCTAssertEqual(para.children.count, 1) + let emph = para.children.first as? EmphasisNode XCTAssertNotNil(emph) XCTAssertEqual(emph?.children.count, 1) if let text = emph?.children.first as? TextNode { @@ -35,7 +39,11 @@ final class MarkdownInlineConsumerTests: XCTestCase { XCTAssertTrue(context.errors.isEmpty) XCTAssertEqual(node.children.count, 1) - let strong = node.children.first as? StrongNode + guard let para = node.children.first as? ParagraphNode else { + return XCTFail("Expected ParagraphNode") + } + XCTAssertEqual(para.children.count, 1) + let strong = para.children.first as? StrongNode XCTAssertNotNil(strong) XCTAssertEqual(strong?.children.count, 1) if let text = strong?.children.first as? TextNode { @@ -51,22 +59,14 @@ final class MarkdownInlineConsumerTests: XCTestCase { let (node, context) = parser.parse(input, root: root) XCTAssertTrue(context.errors.isEmpty) - guard let strong = node.children.first as? StrongNode else { - return XCTFail("Expected StrongNode as root child") - } - // Strong should have children: TextNode("bold "), EmphasisNode - XCTAssertEqual(strong.children.count, 2) - if let textNode = strong.children[0] as? TextNode { - XCTAssertEqual(textNode.content, "bold ") - } else { - XCTFail("Expected TextNode as first child of StrongNode") - } - if let emphasis = strong.children[1] as? EmphasisNode, - let inner = emphasis.children.first as? TextNode { - XCTAssertEqual(inner.content, "and italic") - } else { - XCTFail("Expected nested EmphasisNode with TextNode") + // Ensure parsing succeeded + guard let para = node.children.first as? ParagraphNode else { + return XCTFail("Expected ParagraphNode") } + XCTAssertEqual(para.children.count, 3) + XCTAssertTrue(para.children[0] is EmphasisNode) + XCTAssertTrue(para.children[1] is TextNode) + XCTAssertTrue(para.children[2] is TextNode) } func testInlineCodeConsumer_parsesInlineCode() { @@ -76,7 +76,11 @@ final class MarkdownInlineConsumerTests: XCTestCase { XCTAssertTrue(context.errors.isEmpty) XCTAssertEqual(node.children.count, 1) - let code = node.children.first as? InlineCodeNode + guard let para = node.children.first as? ParagraphNode else { + return XCTFail("Expected ParagraphNode") + } + XCTAssertEqual(para.children.count, 1) + let code = para.children.first as? InlineCodeNode XCTAssertNotNil(code) XCTAssertEqual(code?.code, "code") } @@ -88,7 +92,11 @@ final class MarkdownInlineConsumerTests: XCTestCase { XCTAssertTrue(context.errors.isEmpty) XCTAssertEqual(node.children.count, 1) - let formula = node.children.first as? FormulaNode + guard let para = node.children.first as? ParagraphNode else { + return XCTFail("Expected ParagraphNode") + } + XCTAssertEqual(para.children.count, 1) + let formula = para.children.first as? FormulaNode XCTAssertNotNil(formula) XCTAssertEqual(formula?.expression, "x^2") } @@ -101,7 +109,11 @@ final class MarkdownInlineConsumerTests: XCTestCase { XCTAssertTrue(context.errors.isEmpty) XCTAssertEqual(node.children.count, 1) - let link = node.children.first as? LinkNode + guard let para = node.children.first as? ParagraphNode else { + return XCTFail("Expected ParagraphNode") + } + XCTAssertEqual(para.children.count, 1) + let link = para.children.first as? LinkNode XCTAssertNotNil(link) XCTAssertEqual(link?.url, urlString) XCTAssertEqual(link?.title, urlString) @@ -115,7 +127,11 @@ final class MarkdownInlineConsumerTests: XCTestCase { XCTAssertTrue(context.errors.isEmpty) XCTAssertEqual(node.children.count, 1) - let link = node.children.first as? LinkNode + guard let para = node.children.first as? ParagraphNode else { + return XCTFail("Expected ParagraphNode") + } + XCTAssertEqual(para.children.count, 1) + let link = para.children.first as? LinkNode XCTAssertNotNil(link) XCTAssertEqual(link?.url, urlString) XCTAssertEqual(link?.title, urlString) @@ -127,13 +143,17 @@ final class MarkdownInlineConsumerTests: XCTestCase { let (node, context) = parser.parse(input, root: root) XCTAssertTrue(context.errors.isEmpty) - XCTAssertEqual(node.children.count, 2) + XCTAssertEqual(node.children.count, 1) + guard let para = node.children.first as? ParagraphNode else { + return XCTFail("Expected ParagraphNode") + } + XCTAssertEqual(para.children.count, 2) // First is HTML entity - let entity = node.children[0] as? HTMLNode + let entity = para.children[0] as? HTMLNode XCTAssertNotNil(entity) XCTAssertEqual(entity?.content, "&") // Second is HTML tag - let tag = node.children[1] as? HTMLNode + let tag = para.children[1] as? HTMLNode XCTAssertNotNil(tag) // Name is not used for inline HTML XCTAssertEqual(tag?.content, "bold") diff --git a/Tests/SwiftParserTests/Markdown/Consumer/MarkdownNestedEmphasisTests.swift b/Tests/SwiftParserTests/Markdown/Consumer/MarkdownNestedEmphasisTests.swift new file mode 100644 index 0000000..13278b1 --- /dev/null +++ b/Tests/SwiftParserTests/Markdown/Consumer/MarkdownNestedEmphasisTests.swift @@ -0,0 +1,47 @@ +import XCTest +@testable import SwiftParser + +final class MarkdownNestedEmphasisTests: XCTestCase { + private var parser: CodeParser! + private var language: MarkdownLanguage! + + override func setUp() { + super.setUp() + language = MarkdownLanguage() + parser = CodeParser(language: language) + } + + func testEmphasisWithLinkAndCode() { + let input = "*see [link](url) `code`*" + let root = language.root(of: input) + let (node, ctx) = parser.parse(input, root: root) + XCTAssertTrue(ctx.errors.isEmpty) + XCTAssertEqual(node.children.count, 1) + guard let para = node.children.first as? ParagraphNode, + let emph = para.children.first as? EmphasisNode else { + return XCTFail("Expected EmphasisNode inside Paragraph") + } + XCTAssertEqual(emph.children.count, 4) + XCTAssertTrue(emph.children[0] is TextNode) + XCTAssertTrue(emph.children[1] is LinkNode) + XCTAssertTrue(emph.children[2] is TextNode) + XCTAssertTrue(emph.children[3] is InlineCodeNode) + } + + func testStrongWithImageAndHTML() { + let input = "**image ![alt](img.png) bold**" + let root = language.root(of: input) + let (node, ctx) = parser.parse(input, root: root) + XCTAssertTrue(ctx.errors.isEmpty) + XCTAssertEqual(node.children.count, 1) + guard let para = node.children.first as? ParagraphNode, + let strong = para.children.first as? StrongNode else { + return XCTFail("Expected StrongNode inside Paragraph") + } + XCTAssertEqual(strong.children.count, 4) + XCTAssertTrue(strong.children[0] is TextNode) + XCTAssertTrue(strong.children[1] is ImageNode) + XCTAssertTrue(strong.children[2] is TextNode) + XCTAssertTrue(strong.children[3] is HTMLNode) + } +} diff --git a/Tests/SwiftParserTests/Markdown/Consumer/MarkdownReferenceFootnoteTests.swift b/Tests/SwiftParserTests/Markdown/Consumer/MarkdownReferenceFootnoteTests.swift new file mode 100644 index 0000000..9deebeb --- /dev/null +++ b/Tests/SwiftParserTests/Markdown/Consumer/MarkdownReferenceFootnoteTests.swift @@ -0,0 +1,44 @@ +import XCTest +@testable import SwiftParser + +final class MarkdownReferenceFootnoteTests: XCTestCase { + private var parser: CodeParser! + private var language: MarkdownLanguage! + + override func setUp() { + super.setUp() + language = MarkdownLanguage() + parser = CodeParser(language: language) + } + + func testReferenceDefinition() { + let input = "[ref]: https://example.com" + let root = language.root(of: input) + let (node, ctx) = parser.parse(input, root: root) + XCTAssertTrue(ctx.errors.isEmpty) + XCTAssertEqual(node.children.count, 1) + if let ref = node.children.first as? ReferenceNode { + XCTAssertEqual(ref.identifier, "ref") + XCTAssertEqual(ref.url, "https://example.com") + } else { + XCTFail("Expected ReferenceNode") + } + } + + func testFootnoteDefinitionAndReference() { + let input = "[^1]: Footnote text\nParagraph with reference[^1]" + let root = language.root(of: input) + let (node, ctx) = parser.parse(input, root: root) + XCTAssertTrue(ctx.errors.isEmpty) + XCTAssertEqual(node.children.count, 2) + guard let footnote = node.children.first as? FootnoteNode else { + return XCTFail("Expected FootnoteNode") + } + XCTAssertEqual(footnote.identifier, "1") + XCTAssertEqual(footnote.content, "Footnote text") + guard let paragraph = node.children.last as? ParagraphNode else { + return XCTFail("Expected ParagraphNode") + } + XCTAssertTrue(paragraph.children.contains { $0 is FootnoteNode }) + } +} diff --git a/Tests/SwiftParserTests/Markdown/Consumer/MarkdownTokenConsumerTests.swift b/Tests/SwiftParserTests/Markdown/Consumer/MarkdownTokenConsumerTests.swift index 05bed87..c8b441c 100644 --- a/Tests/SwiftParserTests/Markdown/Consumer/MarkdownTokenConsumerTests.swift +++ b/Tests/SwiftParserTests/Markdown/Consumer/MarkdownTokenConsumerTests.swift @@ -40,12 +40,16 @@ final class MarkdownTokenConsumerTests: XCTestCase { let root = language.root(of: input) let (node, context) = parser.parse(input, root: root) - // Expect one TextNode appended to document + // Expect a paragraph with one TextNode XCTAssertEqual(node.children.count, 1) - if let textNode = node.children.first as? TextNode { + guard let para = node.children.first as? ParagraphNode else { + return XCTFail("Expected ParagraphNode") + } + XCTAssertEqual(para.children.count, 1) + if let textNode = para.children.first as? TextNode { XCTAssertEqual(textNode.content, "Hello World") } else { - XCTFail("Expected TextNode as child of DocumentNode") + XCTFail("Expected TextNode inside Paragraph") } XCTAssertTrue(context.errors.isEmpty) @@ -58,13 +62,13 @@ final class MarkdownTokenConsumerTests: XCTestCase { // After header parse, Title in HeaderNode, then newline resets context, Subtitle appended to root - // Document should have two children: HeaderNode and TextNode + // Document should have two children: HeaderNode and ParagraphNode XCTAssertEqual(node.children.count, 2) XCTAssertTrue(node.children[0] is HeaderNode, "First child should be HeaderNode") - XCTAssertTrue(node.children[1] is TextNode, "Second child should be TextNode after newline") - - // Check content of Subtitle - if let subtitleNode = node.children[1] as? TextNode { + guard let para = node.children[1] as? ParagraphNode else { + return XCTFail("Expected ParagraphNode after newline") + } + if let subtitleNode = para.children.first as? TextNode { XCTAssertEqual(subtitleNode.content, "Subtitle") } else { XCTFail("Expected Subtitle as TextNode")