From 4b9e40a23e1d42b2cc5b9f87edb3e7348161baac Mon Sep 17 00:00:00 2001 From: Dongyu Zhao Date: Tue, 15 Jul 2025 01:07:04 +0800 Subject: [PATCH] Add support for Setext headings --- .../Languages/MarkdownLanguage.swift | 98 ++++++++++++++++++- Tests/SwiftParserTests/SwiftParserTests.swift | 8 ++ 2 files changed, 105 insertions(+), 1 deletion(-) diff --git a/Sources/SwiftParser/Languages/MarkdownLanguage.swift b/Sources/SwiftParser/Languages/MarkdownLanguage.swift index f259250..4d364fa 100644 --- a/Sources/SwiftParser/Languages/MarkdownLanguage.swift +++ b/Sources/SwiftParser/Languages/MarkdownLanguage.swift @@ -249,6 +249,102 @@ public struct MarkdownLanguage: CodeLanguage { } } + public class SetextHeadingBuilder: CodeElementBuilder { + public init() {} + public func accept(context: CodeContext, token: any CodeToken) -> Bool { + guard token is Token else { return false } + if context.index > 0 { + if let prev = context.tokens[context.index - 1] as? Token, case .newline = prev { + // ok + } else if context.index != 0 { + return false + } + } + + var idx = context.index + var sawText = false + while idx < context.tokens.count { + guard let t = context.tokens[idx] as? Token else { return false } + if case .newline = t { break } + if case .eof = t { return false } + sawText = true + idx += 1 + } + guard sawText else { return false } + guard idx < context.tokens.count, let nl = context.tokens[idx] as? Token, case .newline = nl else { return false } + idx += 1 + guard idx < context.tokens.count else { return false } + + var kind: Token? + var count = 0 + while idx < context.tokens.count { + guard let tok = context.tokens[idx] as? Token else { return false } + switch tok { + case .dash: + if kind == nil { kind = tok } + if case .dash = kind! { count += 1; idx += 1 } else { return false } + case .equal: + if kind == nil { kind = tok } + if case .equal = kind! { count += 1; idx += 1 } else { return false } + case .text(let s, _): + if s.trimmingCharacters(in: .whitespaces).isEmpty { idx += 1 } else { return false } + case .newline, .eof: + break + default: + return false + } + if idx < context.tokens.count, let next = context.tokens[idx] as? Token, case .newline = next { break } + } + if count == 0 { return false } + if idx < context.tokens.count, let endTok = context.tokens[idx] as? Token { + switch endTok { + case .newline, .eof: + return true + default: + return false + } + } + return false + } + public func build(context: inout CodeContext) { + var text = "" + while context.index < context.tokens.count { + if let tok = context.tokens[context.index] as? Token { + if case .newline = tok { + context.index += 1 + break + } else { + text += tok.text + context.index += 1 + } + } else { context.index += 1 } + } + while context.index < context.tokens.count { + if let tok = context.tokens[context.index] as? Token { + switch tok { + case .dash, .equal: + context.index += 1 + case .text(let s, _) where s.trimmingCharacters(in: .whitespaces).isEmpty: + context.index += 1 + case .newline: + context.index += 1 + let node = CodeNode(type: Element.heading, value: text.trimmingCharacters(in: .whitespaces)) + context.currentNode.addChild(node) + return + case .eof: + context.index += 1 + let node = CodeNode(type: Element.heading, value: text.trimmingCharacters(in: .whitespaces)) + context.currentNode.addChild(node) + return + default: + context.index += 1 + } + } else { context.index += 1 } + } + context.currentNode.addChild(CodeNode(type: Element.heading, value: text.trimmingCharacters(in: .whitespaces))) + } + } + public class ListItemBuilder: CodeElementBuilder { public init() {} public func accept(context: CodeContext, token: any CodeToken) -> Bool { @@ -855,7 +951,7 @@ public struct MarkdownLanguage: CodeLanguage { public var tokenizer: CodeTokenizer { Tokenizer() } public var builders: [CodeElementBuilder] { - [HeadingBuilder(), CodeBlockBuilder(), IndentedCodeBlockBuilder(), BlockQuoteBuilder(), ThematicBreakBuilder(), OrderedListItemBuilder(), ListItemBuilder(), ImageBuilder(), HTMLBuilder(), EntityBuilder(), StrikethroughBuilder(), AutoLinkBuilder(), TableBuilder(), FootnoteBuilder(), LinkBuilder(), StrongBuilder(), EmphasisBuilder(), InlineCodeBuilder(), ParagraphBuilder()] + [HeadingBuilder(), SetextHeadingBuilder(), CodeBlockBuilder(), IndentedCodeBlockBuilder(), BlockQuoteBuilder(), ThematicBreakBuilder(), OrderedListItemBuilder(), ListItemBuilder(), ImageBuilder(), HTMLBuilder(), EntityBuilder(), StrikethroughBuilder(), AutoLinkBuilder(), TableBuilder(), FootnoteBuilder(), LinkBuilder(), StrongBuilder(), EmphasisBuilder(), InlineCodeBuilder(), ParagraphBuilder()] } public var expressionBuilders: [CodeExpressionBuilder] { [] } public var rootElement: any CodeElement { Element.root } diff --git a/Tests/SwiftParserTests/SwiftParserTests.swift b/Tests/SwiftParserTests/SwiftParserTests.swift index 0640b74..7fc1f74 100644 --- a/Tests/SwiftParserTests/SwiftParserTests.swift +++ b/Tests/SwiftParserTests/SwiftParserTests.swift @@ -24,6 +24,14 @@ final class SwiftParserTests: XCTestCase { XCTAssertEqual(result.root.children.count, 2) } + func testMarkdownSetextHeading() { + let parser = SwiftParser() + let source = "Title\n----\n" + let result = parser.parse(source, language: MarkdownLanguage()) + XCTAssertEqual(result.errors.count, 0) + XCTAssertEqual(result.root.children.first?.type as? MarkdownLanguage.Element, .heading) + } + func testMarkdownListItem() { let parser = SwiftParser() let source = "- item1\n- item2"