From 06a9dc0c5a03b6a7f43d3891dfaabb540db0c0b8 Mon Sep 17 00:00:00 2001 From: Jesse Beder Date: Wed, 7 May 2025 18:24:48 -0400 Subject: [PATCH] Add footnotes. This relies on the footnote extension in cmark, which does not expose footnote type names, but does expose enough information via the type enum. --- Container | 0 Sources/Markdown/Base/Markup.swift | 4 ++ Sources/Markdown/Base/RawMarkup.swift | 11 +++ Sources/Markdown/Block | 0 .../FootnoteDefinition.swift | 56 +++++++++++++++ .../Inline Containers/FootnoteReference.swift | 57 +++++++++++++++ .../Markdown/Parser/CommonMarkConverter.swift | 40 +++++++++++ .../Markdown/Rewriter/MarkupRewriter.swift | 7 ++ Sources/Markdown/Visitor/MarkupVisitor.swift | 22 ++++++ .../Walker/Walkers/MarkupTreeDumper.swift | 8 +++ .../Block Nodes/FootnoteTests.swift | 70 +++++++++++++++++++ 11 files changed, 275 insertions(+) create mode 100644 Container create mode 100644 Sources/Markdown/Block create mode 100644 Sources/Markdown/Block Nodes/Block Container Blocks/FootnoteDefinition.swift create mode 100644 Sources/Markdown/Inline Nodes/Inline Containers/FootnoteReference.swift create mode 100644 Tests/MarkdownTests/Block Nodes/FootnoteTests.swift diff --git a/Container b/Container new file mode 100644 index 00000000..e69de29b diff --git a/Sources/Markdown/Base/Markup.swift b/Sources/Markdown/Base/Markup.swift index e26ff1ef..000d4a84 100644 --- a/Sources/Markdown/Base/Markup.swift +++ b/Sources/Markdown/Base/Markup.swift @@ -71,6 +71,10 @@ func makeMarkup(_ data: _MarkupData) -> Markup { return SymbolLink(data) case .inlineAttributes: return InlineAttributes(data) + case .footnoteReference: + return FootnoteReference(data) + case .footnoteDefinition: + return FootnoteDefinition(data) case .doxygenDiscussion: return DoxygenDiscussion(data) case .doxygenNote: diff --git a/Sources/Markdown/Base/RawMarkup.swift b/Sources/Markdown/Base/RawMarkup.swift index b6b231c6..0b1c2b5a 100644 --- a/Sources/Markdown/Base/RawMarkup.swift +++ b/Sources/Markdown/Base/RawMarkup.swift @@ -52,6 +52,9 @@ enum RawMarkupData: Equatable { case tableRow case tableCell(colspan: UInt, rowspan: UInt) + case footnoteReference(footnoteID: String) + case footnoteDefinition(footnoteID: String) + case doxygenDiscussion case doxygenNote case doxygenParam(name: String) @@ -344,6 +347,14 @@ final class RawMarkup: ManagedBuffer { return .create(data: .tableCell(colspan: colspan, rowspan: rowspan), parsedRange: parsedRange, children: children) } + static func footnoteDefinition(footnoteID: String, parsedRange: SourceRange?, _ children: [RawMarkup]) -> RawMarkup { + return .create(data: .footnoteDefinition(footnoteID: footnoteID), parsedRange: parsedRange, children: children) + } + + static func footnoteReference(footnoteID: String, parsedRange: SourceRange?, _ children: [RawMarkup]) -> RawMarkup { + return .create(data: .footnoteReference(footnoteID: footnoteID), parsedRange: parsedRange, children: children) + } + static func doxygenDiscussion(parsedRange: SourceRange?, _ children: [RawMarkup]) -> RawMarkup { return .create(data: .doxygenDiscussion, parsedRange: parsedRange, children: children) } diff --git a/Sources/Markdown/Block b/Sources/Markdown/Block new file mode 100644 index 00000000..e69de29b diff --git a/Sources/Markdown/Block Nodes/Block Container Blocks/FootnoteDefinition.swift b/Sources/Markdown/Block Nodes/Block Container Blocks/FootnoteDefinition.swift new file mode 100644 index 00000000..85c47cbf --- /dev/null +++ b/Sources/Markdown/Block Nodes/Block Container Blocks/FootnoteDefinition.swift @@ -0,0 +1,56 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021 Apple Inc. and the Swift project authors + Licensed under Apache License v2.0 with Runtime Library Exception + + See https://swift.org/LICENSE.txt for license information + See https://swift.org/CONTRIBUTORS.txt for Swift project authors +*/ + +public struct FootnoteDefinition: BlockContainer { + public var _data: _MarkupData + init(_ raw: RawMarkup) throws { + guard case .footnoteDefinition = raw.data else { + throw RawMarkup.Error.concreteConversionError(from: raw, to: FootnoteDefinition.self) + } + let absoluteRaw = AbsoluteRawMarkup(markup: raw, metadata: MarkupMetadata(id: .newRoot(), indexInParent: 0)) + self.init(_MarkupData(absoluteRaw)) + } + + init(_ data: _MarkupData) { + self._data = data + } +} + +// MARK: - Public API + +public extension FootnoteDefinition { + // MARK: BasicBlockContainer + + init(footnoteID: String, _ children: Children) where Children.Element == BlockMarkup { + try! self.init(.footnoteDefinition(footnoteID: footnoteID, parsedRange: nil, children.map { $0.raw.markup })) + } + + init(footnoteID: String, _ children: BlockMarkup...) { + self.init(footnoteID: footnoteID, children) + } + + var footnoteID: String { + get { + guard case let .footnoteDefinition(footnoteID: footnoteID) = _data.raw.markup.data else { + fatalError("\(self) markup wrapped unexpected \(_data.raw)") + } + return footnoteID + } + set { + _data = _data.replacingSelf(.footnoteDefinition(footnoteID: newValue, parsedRange: nil, _data.raw.markup.copyChildren())) + } + } + + // MARK: Visitation + + func accept(_ visitor: inout V) -> V.Result { + return visitor.visitFootnoteDefinition(self) + } +} diff --git a/Sources/Markdown/Inline Nodes/Inline Containers/FootnoteReference.swift b/Sources/Markdown/Inline Nodes/Inline Containers/FootnoteReference.swift new file mode 100644 index 00000000..0b08affe --- /dev/null +++ b/Sources/Markdown/Inline Nodes/Inline Containers/FootnoteReference.swift @@ -0,0 +1,57 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021 Apple Inc. and the Swift project authors + Licensed under Apache License v2.0 with Runtime Library Exception + + See https://swift.org/LICENSE.txt for license information + See https://swift.org/CONTRIBUTORS.txt for Swift project authors +*/ + +/// A reference to a footnote +public struct FootnoteReference: InlineMarkup, InlineContainer { + public var _data: _MarkupData + + init(_ raw: RawMarkup) throws { + guard case .footnoteReference = raw.data else { + throw RawMarkup.Error.concreteConversionError(from: raw, to: FootnoteReference.self) + } + let absoluteRaw = AbsoluteRawMarkup(markup: raw, metadata: MarkupMetadata(id: .newRoot(), indexInParent: 0)) + self.init(_MarkupData(absoluteRaw)) + } + + init(_ data: _MarkupData) { + self._data = data + } +} + +// MARK: - Public API + +public extension FootnoteReference { + init(footnoteID: String, _ children: Children) where Children.Element == RecurringInlineMarkup { + try! self.init(.footnoteReference(footnoteID: footnoteID, parsedRange: nil, children.map { $0.raw.markup })) + } + + init(footnoteID: String, _ children: RecurringInlineMarkup...) { + self.init(footnoteID: footnoteID, children) + } + + /// The specified attributes in JSON5 format. + var footnoteID: String { + get { + guard case let .footnoteReference(footnoteID: footnoteID) = _data.raw.markup.data else { + fatalError("\(self) markup wrapped unexpected \(_data.raw)") + } + return footnoteID + } + set { + _data = _data.replacingSelf(.footnoteReference(footnoteID: newValue, parsedRange: nil, _data.raw.markup.copyChildren())) + } + } + + // MARK: Visitation + + func accept(_ visitor: inout V) -> V.Result { + return visitor.visitFootnoteReference(self) + } +} diff --git a/Sources/Markdown/Parser/CommonMarkConverter.swift b/Sources/Markdown/Parser/CommonMarkConverter.swift index 510bbfda..0af78451 100644 --- a/Sources/Markdown/Parser/CommonMarkConverter.swift +++ b/Sources/Markdown/Parser/CommonMarkConverter.swift @@ -68,6 +68,9 @@ fileprivate enum CommonMarkNodeType: String { case tableCell = "table_cell" case taskListItem = "tasklist" + + case footnoteReference = "footnote_reference" + case footnoteDefinition = "footnote_definition" } /// Represents the result of a cmark conversion: the current `MarkupConverterState` and the resulting converted node. @@ -139,6 +142,16 @@ fileprivate struct MarkupConverterState { guard let type = CommonMarkNodeType(rawValue: typeString) else { fatalError("Unknown cmark node type '\(typeString)' encountered during conversion") } + if type == .unknown { + // NOTE: cmark does not expose strings for the footnote types, but + // does correctly identify their type. + switch cmark_node_get_type(node) { + case CMARK_NODE_FOOTNOTE_DEFINITION: return .footnoteDefinition + case CMARK_NODE_FOOTNOTE_REFERENCE: return .footnoteReference + default: + fatalError("Unknown cmark node type '\(typeString)' encountered during conversion") + } + } return type } @@ -232,6 +245,10 @@ struct MarkupParser { return convertTableCell(state) case .inlineAttributes: return convertInlineAttributes(state) + case .footnoteReference: + return convertFootnoteReference(state) + case .footnoteDefinition: + return convertFootnoteDefinition(state) default: fatalError("Unknown cmark node type '\(state.nodeType.rawValue)' encountered during conversion") } @@ -608,6 +625,28 @@ struct MarkupParser { return MarkupConversion(state: childConversion.state.next(), result: .inlineAttributes(attributes: attributes, parsedRange: parsedRange, childConversion.result)) } + private static func convertFootnoteReference(_ state: MarkupConverterState) -> MarkupConversion { + precondition(state.event == CMARK_EVENT_ENTER) + precondition(state.nodeType == .footnoteReference) + let parsedRange = state.range(state.node) + let childConversion = convertChildren(state) + let footnoteID = String(cString: cmark_node_get_literal(state.node)) + precondition(childConversion.state.node == state.node) + precondition(childConversion.state.event == CMARK_EVENT_EXIT) + return MarkupConversion(state: childConversion.state.next(), result: .footnoteReference(footnoteID: footnoteID, parsedRange: parsedRange, childConversion.result)) + } + + private static func convertFootnoteDefinition(_ state: MarkupConverterState) -> MarkupConversion { + precondition(state.event == CMARK_EVENT_ENTER) + precondition(state.nodeType == .footnoteDefinition) + let parsedRange = state.range(state.node) + let childConversion = convertChildren(state) + let footnoteID = String(cString: cmark_node_get_literal(state.node)) + precondition(childConversion.state.node == state.node) + precondition(childConversion.state.event == CMARK_EVENT_EXIT) + return MarkupConversion(state: childConversion.state.next(), result: .footnoteDefinition(footnoteID: footnoteID, parsedRange: parsedRange, childConversion.result)) + } + static func parseString(_ string: String, source: URL?, options: ParseOptions) -> Document { cmark_gfm_core_extensions_ensure_registered() @@ -618,6 +657,7 @@ struct MarkupParser { if !options.contains(.disableSourcePosOpts) { cmarkOptions |= CMARK_OPT_SOURCEPOS } + cmarkOptions |= CMARK_OPT_FOOTNOTES let parser = cmark_parser_new(cmarkOptions) diff --git a/Sources/Markdown/Rewriter/MarkupRewriter.swift b/Sources/Markdown/Rewriter/MarkupRewriter.swift index 931224b9..8349eded 100644 --- a/Sources/Markdown/Rewriter/MarkupRewriter.swift +++ b/Sources/Markdown/Rewriter/MarkupRewriter.swift @@ -87,4 +87,11 @@ extension MarkupRewriter { public mutating func visitText(_ text: Text) -> Result { return defaultVisit(text) } + public mutating func visitFootnoteReference(_ footnoteReference: FootnoteReference) -> Result { + return defaultVisit(footnoteReference) + } + + public mutating func visitFootnoteDefinition(_ footnoteDefinition: FootnoteDefinition) -> Result { + return defaultVisit(footnoteDefinition) + } } diff --git a/Sources/Markdown/Visitor/MarkupVisitor.swift b/Sources/Markdown/Visitor/MarkupVisitor.swift index 417cf974..3833422f 100644 --- a/Sources/Markdown/Visitor/MarkupVisitor.swift +++ b/Sources/Markdown/Visitor/MarkupVisitor.swift @@ -275,6 +275,22 @@ public protocol MarkupVisitor { */ mutating func visitInlineAttributes(_ attributes: InlineAttributes) -> Result + /** + Visit an `FootnoteReference` element and return the result. + + - parameter attributes: An `FootnoteReference` element. + - returns: The result of the visit. + */ + mutating func visitFootnoteReference(_ footnoteReference: FootnoteReference) -> Result + + /** + Visit an `FootnoteDefinition` element and return the result. + + - parameter attributes: An `FootnoteDefinition` element. + - returns: The result of the visit. + */ + mutating func visitFootnoteDefinition(_ footnoteDefinition: FootnoteDefinition) -> Result + /** Visit a `DoxygenDiscussion` element and return the result. @@ -405,6 +421,12 @@ extension MarkupVisitor { public mutating func visitInlineAttributes(_ attributes: InlineAttributes) -> Result { return defaultVisit(attributes) } + public mutating func visitFootnoteReference(_ footnoteReference: FootnoteReference) -> Result { + return defaultVisit(footnoteReference) + } + public mutating func visitFootnoteDefinition(_ footnoteDefinition: FootnoteDefinition) -> Result { + return defaultVisit(footnoteDefinition) + } public mutating func visitDoxygenDiscussion(_ doxygenDiscussion: DoxygenDiscussion) -> Result { return defaultVisit(doxygenDiscussion) } diff --git a/Sources/Markdown/Walker/Walkers/MarkupTreeDumper.swift b/Sources/Markdown/Walker/Walkers/MarkupTreeDumper.swift index 0b588741..d01b3d73 100644 --- a/Sources/Markdown/Walker/Walkers/MarkupTreeDumper.swift +++ b/Sources/Markdown/Walker/Walkers/MarkupTreeDumper.swift @@ -287,6 +287,14 @@ struct MarkupTreeDumper: MarkupWalker { dump(attributes, customDescription: "attributes: `\(attributes.attributes)`") } + mutating func visitFootnoteReference(_ footnoteReference: FootnoteReference) -> () { + dump(footnoteReference, customDescription: "footnoteID: `\(footnoteReference.footnoteID)`") + } + + mutating func visitFootnoteDefinition(_ footnoteDefinition: FootnoteDefinition) -> () { + dump(footnoteDefinition, customDescription: "footnoteID: `\(footnoteDefinition.footnoteID)`") + } + mutating func visitDoxygenParameter(_ doxygenParam: DoxygenParameter) -> () { dump(doxygenParam, customDescription: "parameter: \(doxygenParam.name)") } diff --git a/Tests/MarkdownTests/Block Nodes/FootnoteTests.swift b/Tests/MarkdownTests/Block Nodes/FootnoteTests.swift new file mode 100644 index 00000000..deaa0176 --- /dev/null +++ b/Tests/MarkdownTests/Block Nodes/FootnoteTests.swift @@ -0,0 +1,70 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021 Apple Inc. and the Swift project authors + Licensed under Apache License v2.0 with Runtime Library Exception + + See https://swift.org/LICENSE.txt for license information + See https://swift.org/CONTRIBUTORS.txt for Swift project authors +*/ + +@testable import Markdown +import XCTest + +class FootnoteTests: XCTestCase { + func testSimpleFootnote() { + let source = """ + text with a footnote [^1]. + [^1]: footnote definition. + """ + + let document = Document(parsing: source) + + let expectedDump = """ + Document @1:1-2:27 + ├─ Paragraph @1:1-1:27 + │ ├─ Text @1:1-1:22 "text with a footnote " + │ ├─ FootnoteReference @1:22-1:26 footnoteID: `1` + │ └─ Text @1:26-1:27 "." + └─ FootnoteDefinition @2:7-2:27 footnoteID: `1` + └─ Paragraph @2:7-2:27 + └─ Text @2:7-2:27 "footnote definition." + """ + + XCTAssertEqual(expectedDump, document.debugDescription(options: .printSourceLocations)) + } + + func testBlockFootnote() { + let source = """ + text with a block footnote [^1]. + [^1]: This is a long footnote, including a quote: + + > This is a multi-line quote, spanning + > multiple lines. + + And then some more text. + """ + + let document = Document(parsing: source) + + let expectedDump = """ + Document @1:1-7:29 + ├─ Paragraph @1:1-1:33 + │ ├─ Text @1:1-1:28 "text with a block footnote " + │ ├─ FootnoteReference @1:28-1:32 footnoteID: `1` + │ └─ Text @1:32-1:33 "." + └─ FootnoteDefinition @2:7-7:29 footnoteID: `1` + ├─ Paragraph @2:7-2:50 + │ └─ Text @2:7-2:50 "This is a long footnote, including a quote:" + ├─ BlockQuote @4:5-5:22 + │ └─ Paragraph @4:7-5:22 + │ ├─ Text @4:7-4:43 "This is a multi-line quote, spanning" + │ ├─ SoftBreak + │ └─ Text @5:7-5:22 "multiple lines." + └─ Paragraph @7:5-7:29 + └─ Text @7:5-7:29 "And then some more text." + """ + + XCTAssertEqual(expectedDump, document.debugDescription(options: .printSourceLocations)) + } + }