Skip to content

Commit 06a9dc0

Browse files
committed
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.
1 parent 5d4f6b8 commit 06a9dc0

File tree

11 files changed

+275
-0
lines changed

11 files changed

+275
-0
lines changed

Container

Whitespace-only changes.

Sources/Markdown/Base/Markup.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,10 @@ func makeMarkup(_ data: _MarkupData) -> Markup {
7171
return SymbolLink(data)
7272
case .inlineAttributes:
7373
return InlineAttributes(data)
74+
case .footnoteReference:
75+
return FootnoteReference(data)
76+
case .footnoteDefinition:
77+
return FootnoteDefinition(data)
7478
case .doxygenDiscussion:
7579
return DoxygenDiscussion(data)
7680
case .doxygenNote:

Sources/Markdown/Base/RawMarkup.swift

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,9 @@ enum RawMarkupData: Equatable {
5252
case tableRow
5353
case tableCell(colspan: UInt, rowspan: UInt)
5454

55+
case footnoteReference(footnoteID: String)
56+
case footnoteDefinition(footnoteID: String)
57+
5558
case doxygenDiscussion
5659
case doxygenNote
5760
case doxygenParam(name: String)
@@ -344,6 +347,14 @@ final class RawMarkup: ManagedBuffer<RawMarkupHeader, RawMarkup> {
344347
return .create(data: .tableCell(colspan: colspan, rowspan: rowspan), parsedRange: parsedRange, children: children)
345348
}
346349

350+
static func footnoteDefinition(footnoteID: String, parsedRange: SourceRange?, _ children: [RawMarkup]) -> RawMarkup {
351+
return .create(data: .footnoteDefinition(footnoteID: footnoteID), parsedRange: parsedRange, children: children)
352+
}
353+
354+
static func footnoteReference(footnoteID: String, parsedRange: SourceRange?, _ children: [RawMarkup]) -> RawMarkup {
355+
return .create(data: .footnoteReference(footnoteID: footnoteID), parsedRange: parsedRange, children: children)
356+
}
357+
347358
static func doxygenDiscussion(parsedRange: SourceRange?, _ children: [RawMarkup]) -> RawMarkup {
348359
return .create(data: .doxygenDiscussion, parsedRange: parsedRange, children: children)
349360
}

Sources/Markdown/Block

Whitespace-only changes.
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
/*
2+
This source file is part of the Swift.org open source project
3+
4+
Copyright (c) 2021 Apple Inc. and the Swift project authors
5+
Licensed under Apache License v2.0 with Runtime Library Exception
6+
7+
See https://swift.org/LICENSE.txt for license information
8+
See https://swift.org/CONTRIBUTORS.txt for Swift project authors
9+
*/
10+
11+
public struct FootnoteDefinition: BlockContainer {
12+
public var _data: _MarkupData
13+
init(_ raw: RawMarkup) throws {
14+
guard case .footnoteDefinition = raw.data else {
15+
throw RawMarkup.Error.concreteConversionError(from: raw, to: FootnoteDefinition.self)
16+
}
17+
let absoluteRaw = AbsoluteRawMarkup(markup: raw, metadata: MarkupMetadata(id: .newRoot(), indexInParent: 0))
18+
self.init(_MarkupData(absoluteRaw))
19+
}
20+
21+
init(_ data: _MarkupData) {
22+
self._data = data
23+
}
24+
}
25+
26+
// MARK: - Public API
27+
28+
public extension FootnoteDefinition {
29+
// MARK: BasicBlockContainer
30+
31+
init<Children: Sequence>(footnoteID: String, _ children: Children) where Children.Element == BlockMarkup {
32+
try! self.init(.footnoteDefinition(footnoteID: footnoteID, parsedRange: nil, children.map { $0.raw.markup }))
33+
}
34+
35+
init(footnoteID: String, _ children: BlockMarkup...) {
36+
self.init(footnoteID: footnoteID, children)
37+
}
38+
39+
var footnoteID: String {
40+
get {
41+
guard case let .footnoteDefinition(footnoteID: footnoteID) = _data.raw.markup.data else {
42+
fatalError("\(self) markup wrapped unexpected \(_data.raw)")
43+
}
44+
return footnoteID
45+
}
46+
set {
47+
_data = _data.replacingSelf(.footnoteDefinition(footnoteID: newValue, parsedRange: nil, _data.raw.markup.copyChildren()))
48+
}
49+
}
50+
51+
// MARK: Visitation
52+
53+
func accept<V: MarkupVisitor>(_ visitor: inout V) -> V.Result {
54+
return visitor.visitFootnoteDefinition(self)
55+
}
56+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
/*
2+
This source file is part of the Swift.org open source project
3+
4+
Copyright (c) 2021 Apple Inc. and the Swift project authors
5+
Licensed under Apache License v2.0 with Runtime Library Exception
6+
7+
See https://swift.org/LICENSE.txt for license information
8+
See https://swift.org/CONTRIBUTORS.txt for Swift project authors
9+
*/
10+
11+
/// A reference to a footnote
12+
public struct FootnoteReference: InlineMarkup, InlineContainer {
13+
public var _data: _MarkupData
14+
15+
init(_ raw: RawMarkup) throws {
16+
guard case .footnoteReference = raw.data else {
17+
throw RawMarkup.Error.concreteConversionError(from: raw, to: FootnoteReference.self)
18+
}
19+
let absoluteRaw = AbsoluteRawMarkup(markup: raw, metadata: MarkupMetadata(id: .newRoot(), indexInParent: 0))
20+
self.init(_MarkupData(absoluteRaw))
21+
}
22+
23+
init(_ data: _MarkupData) {
24+
self._data = data
25+
}
26+
}
27+
28+
// MARK: - Public API
29+
30+
public extension FootnoteReference {
31+
init<Children: Sequence>(footnoteID: String, _ children: Children) where Children.Element == RecurringInlineMarkup {
32+
try! self.init(.footnoteReference(footnoteID: footnoteID, parsedRange: nil, children.map { $0.raw.markup }))
33+
}
34+
35+
init(footnoteID: String, _ children: RecurringInlineMarkup...) {
36+
self.init(footnoteID: footnoteID, children)
37+
}
38+
39+
/// The specified attributes in JSON5 format.
40+
var footnoteID: String {
41+
get {
42+
guard case let .footnoteReference(footnoteID: footnoteID) = _data.raw.markup.data else {
43+
fatalError("\(self) markup wrapped unexpected \(_data.raw)")
44+
}
45+
return footnoteID
46+
}
47+
set {
48+
_data = _data.replacingSelf(.footnoteReference(footnoteID: newValue, parsedRange: nil, _data.raw.markup.copyChildren()))
49+
}
50+
}
51+
52+
// MARK: Visitation
53+
54+
func accept<V: MarkupVisitor>(_ visitor: inout V) -> V.Result {
55+
return visitor.visitFootnoteReference(self)
56+
}
57+
}

Sources/Markdown/Parser/CommonMarkConverter.swift

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,9 @@ fileprivate enum CommonMarkNodeType: String {
6868
case tableCell = "table_cell"
6969

7070
case taskListItem = "tasklist"
71+
72+
case footnoteReference = "footnote_reference"
73+
case footnoteDefinition = "footnote_definition"
7174
}
7275

7376
/// Represents the result of a cmark conversion: the current `MarkupConverterState` and the resulting converted node.
@@ -139,6 +142,16 @@ fileprivate struct MarkupConverterState {
139142
guard let type = CommonMarkNodeType(rawValue: typeString) else {
140143
fatalError("Unknown cmark node type '\(typeString)' encountered during conversion")
141144
}
145+
if type == .unknown {
146+
// NOTE: cmark does not expose strings for the footnote types, but
147+
// does correctly identify their type.
148+
switch cmark_node_get_type(node) {
149+
case CMARK_NODE_FOOTNOTE_DEFINITION: return .footnoteDefinition
150+
case CMARK_NODE_FOOTNOTE_REFERENCE: return .footnoteReference
151+
default:
152+
fatalError("Unknown cmark node type '\(typeString)' encountered during conversion")
153+
}
154+
}
142155
return type
143156
}
144157

@@ -232,6 +245,10 @@ struct MarkupParser {
232245
return convertTableCell(state)
233246
case .inlineAttributes:
234247
return convertInlineAttributes(state)
248+
case .footnoteReference:
249+
return convertFootnoteReference(state)
250+
case .footnoteDefinition:
251+
return convertFootnoteDefinition(state)
235252
default:
236253
fatalError("Unknown cmark node type '\(state.nodeType.rawValue)' encountered during conversion")
237254
}
@@ -608,6 +625,28 @@ struct MarkupParser {
608625
return MarkupConversion(state: childConversion.state.next(), result: .inlineAttributes(attributes: attributes, parsedRange: parsedRange, childConversion.result))
609626
}
610627

628+
private static func convertFootnoteReference(_ state: MarkupConverterState) -> MarkupConversion<RawMarkup> {
629+
precondition(state.event == CMARK_EVENT_ENTER)
630+
precondition(state.nodeType == .footnoteReference)
631+
let parsedRange = state.range(state.node)
632+
let childConversion = convertChildren(state)
633+
let footnoteID = String(cString: cmark_node_get_literal(state.node))
634+
precondition(childConversion.state.node == state.node)
635+
precondition(childConversion.state.event == CMARK_EVENT_EXIT)
636+
return MarkupConversion(state: childConversion.state.next(), result: .footnoteReference(footnoteID: footnoteID, parsedRange: parsedRange, childConversion.result))
637+
}
638+
639+
private static func convertFootnoteDefinition(_ state: MarkupConverterState) -> MarkupConversion<RawMarkup> {
640+
precondition(state.event == CMARK_EVENT_ENTER)
641+
precondition(state.nodeType == .footnoteDefinition)
642+
let parsedRange = state.range(state.node)
643+
let childConversion = convertChildren(state)
644+
let footnoteID = String(cString: cmark_node_get_literal(state.node))
645+
precondition(childConversion.state.node == state.node)
646+
precondition(childConversion.state.event == CMARK_EVENT_EXIT)
647+
return MarkupConversion(state: childConversion.state.next(), result: .footnoteDefinition(footnoteID: footnoteID, parsedRange: parsedRange, childConversion.result))
648+
}
649+
611650
static func parseString(_ string: String, source: URL?, options: ParseOptions) -> Document {
612651
cmark_gfm_core_extensions_ensure_registered()
613652

@@ -618,6 +657,7 @@ struct MarkupParser {
618657
if !options.contains(.disableSourcePosOpts) {
619658
cmarkOptions |= CMARK_OPT_SOURCEPOS
620659
}
660+
cmarkOptions |= CMARK_OPT_FOOTNOTES
621661

622662
let parser = cmark_parser_new(cmarkOptions)
623663

Sources/Markdown/Rewriter/MarkupRewriter.swift

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,4 +87,11 @@ extension MarkupRewriter {
8787
public mutating func visitText(_ text: Text) -> Result {
8888
return defaultVisit(text)
8989
}
90+
public mutating func visitFootnoteReference(_ footnoteReference: FootnoteReference) -> Result {
91+
return defaultVisit(footnoteReference)
92+
}
93+
94+
public mutating func visitFootnoteDefinition(_ footnoteDefinition: FootnoteDefinition) -> Result {
95+
return defaultVisit(footnoteDefinition)
96+
}
9097
}

Sources/Markdown/Visitor/MarkupVisitor.swift

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,22 @@ public protocol MarkupVisitor<Result> {
275275
*/
276276
mutating func visitInlineAttributes(_ attributes: InlineAttributes) -> Result
277277

278+
/**
279+
Visit an `FootnoteReference` element and return the result.
280+
281+
- parameter attributes: An `FootnoteReference` element.
282+
- returns: The result of the visit.
283+
*/
284+
mutating func visitFootnoteReference(_ footnoteReference: FootnoteReference) -> Result
285+
286+
/**
287+
Visit an `FootnoteDefinition` element and return the result.
288+
289+
- parameter attributes: An `FootnoteDefinition` element.
290+
- returns: The result of the visit.
291+
*/
292+
mutating func visitFootnoteDefinition(_ footnoteDefinition: FootnoteDefinition) -> Result
293+
278294
/**
279295
Visit a `DoxygenDiscussion` element and return the result.
280296

@@ -405,6 +421,12 @@ extension MarkupVisitor {
405421
public mutating func visitInlineAttributes(_ attributes: InlineAttributes) -> Result {
406422
return defaultVisit(attributes)
407423
}
424+
public mutating func visitFootnoteReference(_ footnoteReference: FootnoteReference) -> Result {
425+
return defaultVisit(footnoteReference)
426+
}
427+
public mutating func visitFootnoteDefinition(_ footnoteDefinition: FootnoteDefinition) -> Result {
428+
return defaultVisit(footnoteDefinition)
429+
}
408430
public mutating func visitDoxygenDiscussion(_ doxygenDiscussion: DoxygenDiscussion) -> Result {
409431
return defaultVisit(doxygenDiscussion)
410432
}

Sources/Markdown/Walker/Walkers/MarkupTreeDumper.swift

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,14 @@ struct MarkupTreeDumper: MarkupWalker {
287287
dump(attributes, customDescription: "attributes: `\(attributes.attributes)`")
288288
}
289289

290+
mutating func visitFootnoteReference(_ footnoteReference: FootnoteReference) -> () {
291+
dump(footnoteReference, customDescription: "footnoteID: `\(footnoteReference.footnoteID)`")
292+
}
293+
294+
mutating func visitFootnoteDefinition(_ footnoteDefinition: FootnoteDefinition) -> () {
295+
dump(footnoteDefinition, customDescription: "footnoteID: `\(footnoteDefinition.footnoteID)`")
296+
}
297+
290298
mutating func visitDoxygenParameter(_ doxygenParam: DoxygenParameter) -> () {
291299
dump(doxygenParam, customDescription: "parameter: \(doxygenParam.name)")
292300
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
/*
2+
This source file is part of the Swift.org open source project
3+
4+
Copyright (c) 2021 Apple Inc. and the Swift project authors
5+
Licensed under Apache License v2.0 with Runtime Library Exception
6+
7+
See https://swift.org/LICENSE.txt for license information
8+
See https://swift.org/CONTRIBUTORS.txt for Swift project authors
9+
*/
10+
11+
@testable import Markdown
12+
import XCTest
13+
14+
class FootnoteTests: XCTestCase {
15+
func testSimpleFootnote() {
16+
let source = """
17+
text with a footnote [^1].
18+
[^1]: footnote definition.
19+
"""
20+
21+
let document = Document(parsing: source)
22+
23+
let expectedDump = """
24+
Document @1:1-2:27
25+
├─ Paragraph @1:1-1:27
26+
│ ├─ Text @1:1-1:22 "text with a footnote "
27+
│ ├─ FootnoteReference @1:22-1:26 footnoteID: `1`
28+
│ └─ Text @1:26-1:27 "."
29+
└─ FootnoteDefinition @2:7-2:27 footnoteID: `1`
30+
└─ Paragraph @2:7-2:27
31+
└─ Text @2:7-2:27 "footnote definition."
32+
"""
33+
34+
XCTAssertEqual(expectedDump, document.debugDescription(options: .printSourceLocations))
35+
}
36+
37+
func testBlockFootnote() {
38+
let source = """
39+
text with a block footnote [^1].
40+
[^1]: This is a long footnote, including a quote:
41+
42+
> This is a multi-line quote, spanning
43+
> multiple lines.
44+
45+
And then some more text.
46+
"""
47+
48+
let document = Document(parsing: source)
49+
50+
let expectedDump = """
51+
Document @1:1-7:29
52+
├─ Paragraph @1:1-1:33
53+
│ ├─ Text @1:1-1:28 "text with a block footnote "
54+
│ ├─ FootnoteReference @1:28-1:32 footnoteID: `1`
55+
│ └─ Text @1:32-1:33 "."
56+
└─ FootnoteDefinition @2:7-7:29 footnoteID: `1`
57+
├─ Paragraph @2:7-2:50
58+
│ └─ Text @2:7-2:50 "This is a long footnote, including a quote:"
59+
├─ BlockQuote @4:5-5:22
60+
│ └─ Paragraph @4:7-5:22
61+
│ ├─ Text @4:7-4:43 "This is a multi-line quote, spanning"
62+
│ ├─ SoftBreak
63+
│ └─ Text @5:7-5:22 "multiple lines."
64+
└─ Paragraph @7:5-7:29
65+
└─ Text @7:5-7:29 "And then some more text."
66+
"""
67+
68+
XCTAssertEqual(expectedDump, document.debugDescription(options: .printSourceLocations))
69+
}
70+
}

0 commit comments

Comments
 (0)