Sources/OrgSwift/AST/OrgDocument.swift
263 lines · 9428 bytes
1import Foundation
2
3// The org element tree — the document model, deliberately mirroring orgo's `model.rs`
4// (`Element`/`Object`, `TableRow::{Cells, Rule}`, `ListKind`, `Checkbox`) so the two can be
5// compared tree-to-tree later, instead of only through rendered HTML.
6//
7// Why a tree at all: the shipped renderer is a single pass from source straight to an HTML
8// string, so every new output format would mean re-deriving the parse. With a tree, parsing
9// happens once and each renderer is a walk — the HTML renderer stays the one the conformance
10// corpus measures, and a native renderer rides on the same proven parse.
11
12/// A parsed org document: metadata keywords plus the block elements of the body.
13public struct OrgDocument: Sendable, Equatable {
14 /// `#+TITLE:`, `#+AUTHOR:`, `#+DATE:` and any other `#+KEY: value`, in source order.
15 public var keywords: [(key: String, value: String)]
16 public var elements: [OrgElement]
17
18 public init(keywords: [(key: String, value: String)] = [], elements: [OrgElement] = []) {
19 self.keywords = keywords
20 self.elements = elements
21 }
22
23 public func keyword(_ name: String) -> String? {
24 keywords.first { $0.key.caseInsensitiveCompare(name) == .orderedSame }?.value
25 }
26
27 public static func == (lhs: OrgDocument, rhs: OrgDocument) -> Bool {
28 lhs.elements == rhs.elements
29 && lhs.keywords.count == rhs.keywords.count
30 && zip(lhs.keywords, rhs.keywords).allSatisfy { $0.key == $1.key && $0.value == $1.value }
31 }
32}
33
34/// A block-level element.
35public indirect enum OrgElement: Sendable, Equatable {
36 case heading(OrgHeading)
37 case paragraph([OrgObject])
38 case list(OrgList)
39 case table(OrgTable)
40 case srcBlock(language: String?, code: String)
41 case exampleBlock(String)
42 case quoteBlock([OrgElement])
43 case centerBlock([OrgElement])
44 case verseBlock([[OrgObject]])
45 case specialBlock(name: String, content: [OrgElement])
46 case exportBlock(backend: String, raw: String)
47 case figure(OrgFigure)
48 /// A block carrying an affiliated `#+CAPTION:` / `#+NAME:`, which org's exporter wraps in
49 /// a `<figure>`. Images take the `.figure` case instead; this is for everything else
50 /// (a captioned source block, example, or table).
51 case captioned(name: String?, caption: [OrgObject]?, content: OrgElement)
52 case horizontalRule
53 case footnoteDefinition(label: String, content: [OrgObject])
54}
55
56public struct OrgHeading: Sendable, Equatable {
57 /// Star count, before any render-time level offset.
58 public var level: Int
59 public var todo: String?
60 public var priority: Character?
61 public var title: [OrgObject]
62 public var tags: [String]
63
64 public init(level: Int, todo: String? = nil, priority: Character? = nil,
65 title: [OrgObject], tags: [String] = []) {
66 self.level = level
67 self.todo = todo
68 self.priority = priority
69 self.title = title
70 self.tags = tags
71 }
72}
73
74// MARK: - Lists
75
76public struct OrgList: Sendable, Equatable {
77 public var kind: OrgListKind
78 public var items: [OrgListItem]
79
80 public init(kind: OrgListKind, items: [OrgListItem]) {
81 self.kind = kind
82 self.items = items
83 }
84}
85
86public enum OrgListKind: Sendable, Equatable {
87 case unordered
88 case ordered
89 case description
90}
91
92public struct OrgListItem: Sendable, Equatable {
93 public var checkbox: OrgCheckbox?
94 /// The term of a description-list item (`term :: definition`).
95 public var term: [OrgObject]?
96 /// The item's body, in document order. Usually one paragraph, but an item may hold any
97 /// block element — a nested list, a source block, a table — so this is a block sequence
98 /// rather than a list of paragraphs.
99 public var content: [OrgElement]
100
101 public init(checkbox: OrgCheckbox? = nil, term: [OrgObject]? = nil,
102 content: [OrgElement]) {
103 self.checkbox = checkbox
104 self.term = term
105 self.content = content
106 }
107
108 /// The item's paragraphs, ignoring any other block it carries. Used where only prose is
109 /// meaningful — the `<dd>` of a description list, an item's plain-text reduction.
110 public var paragraphs: [[OrgObject]] {
111 content.compactMap { if case .paragraph(let objects) = $0 { objects } else { nil } }
112 }
113}
114
115public enum OrgCheckbox: Sendable, Equatable {
116 case off
117 case on
118 case partial
119}
120
121// MARK: - Tables
122
123public struct OrgTable: Sendable, Equatable {
124 public var rows: [OrgTableRow]
125 /// Per-column alignment from the separator row, when it carries `:` markers.
126 public var alignments: [OrgAlignment?]
127
128 public init(rows: [OrgTableRow], alignments: [OrgAlignment?] = []) {
129 self.rows = rows
130 self.alignments = alignments
131 }
132
133 /// A rule row separates the header band from the body, as in org.
134 public var headerRowCount: Int {
135 guard let ruleIndex = rows.firstIndex(where: { if case .rule = $0 { return true } else { return false } })
136 else { return 0 }
137 return ruleIndex
138 }
139}
140
141public enum OrgTableRow: Sendable, Equatable {
142 case cells([[OrgObject]])
143 case rule
144}
145
146public enum OrgAlignment: String, Sendable, Equatable {
147 case left, center, right
148}
149
150// MARK: - Figures
151
152public struct OrgFigure: Sendable, Equatable {
153 public var source: String
154 public var caption: [OrgObject]?
155 /// `#+ATTR_HTML:` pairs, kept as parsed so an HTML renderer can emit them and a native
156 /// renderer can read the ones it understands (`:alt`, `:width`).
157 public var attributes: [(key: String, value: String)]
158
159 public init(source: String, caption: [OrgObject]? = nil, attributes: [(key: String, value: String)] = []) {
160 self.source = source
161 self.caption = caption
162 self.attributes = attributes
163 }
164
165 public var alt: String? {
166 attributes.first { $0.key == "alt" }?.value
167 }
168
169 public static func == (lhs: OrgFigure, rhs: OrgFigure) -> Bool {
170 lhs.source == rhs.source && lhs.caption == rhs.caption
171 && lhs.attributes.count == rhs.attributes.count
172 && zip(lhs.attributes, rhs.attributes).allSatisfy { $0.key == $1.key && $0.value == $1.value }
173 }
174}
175
176// MARK: - Inline objects
177
178/// An inline object. Text-bearing cases carry their own children so a renderer can nest
179/// styling (`*bold /and italic/*`) rather than receiving pre-formatted markup.
180public indirect enum OrgObject: Sendable, Equatable {
181 case text(String)
182 case bold([OrgObject])
183 case italic([OrgObject])
184 case underline([OrgObject])
185 case strikeThrough([OrgObject])
186 /// Non-nesting by definition in org: `=verbatim=` and `~code~` hold literal text.
187 case verbatim(String)
188 case code(String)
189 case link(OrgLink)
190 case image(OrgFigure)
191 /// A footnote reference. `inline` carries the text of an inline footnote
192 /// (`[fn:label:text]`), which defines the note at the point of use; nil for a plain
193 /// reference whose definition appears elsewhere. Numbering is document-wide, so it is the
194 /// renderer's job, not the parser's.
195 case footnoteRef(label: String, inline: [OrgObject]?)
196 case timestamp(OrgTimestamp)
197 case superscript([OrgObject])
198 case lineBreak
199}
200
201public struct OrgLink: Sendable, Equatable {
202 public var target: OrgLinkTarget
203 /// Nil description means the link shows its target.
204 public var description: [OrgObject]?
205
206 public init(target: OrgLinkTarget, description: [OrgObject]? = nil) {
207 self.target = target
208 self.description = description
209 }
210}
211
212public enum OrgLinkTarget: Sendable, Equatable {
213 /// An absolute URL (`https:`, `mailto:`) or a bare autolinked URL.
214 case external(String)
215 /// A repository-relative path, from `[[file:…]]` or a bare relative target.
216 case file(String)
217 /// `[[id:…]]` — an in-page fragment.
218 case id(String)
219}
220
221public struct OrgTimestamp: Sendable, Equatable {
222 public var date: String
223 public var time: String?
224 /// End of a same-day time range (`<… 10:00-11:45>`).
225 public var endTime: String?
226 /// End of a multi-day range (`<a>--<b>`), which org models as one timestamp with an end.
227 public var endDate: String?
228 public var active: Bool
229
230 public init(date: String, time: String? = nil, endTime: String? = nil,
231 endDate: String? = nil, active: Bool) {
232 self.date = date
233 self.time = time
234 self.endTime = endTime
235 self.endDate = endDate
236 self.active = active
237 }
238
239 /// True when this stamp spans a range, in either form.
240 public var isRange: Bool { endTime != nil || endDate != nil }
241
242 /// The machine value and display text of the range's end, when there is one. A multi-day
243 /// end shows its whole date; a same-day end shows only the time, since the date is already
244 /// on the start.
245 public var end: (machineValue: String, displayValue: String)? {
246 if let endDate {
247 return (endDate, endDate)
248 }
249 if let endTime {
250 return ("\(date)T\(endTime)", endTime)
251 }
252 return nil
253 }
254
255 /// The `datetime` attribute value / sort key: `2024-01-15` or `2024-01-15T10:30`.
256 public var machineValue: String {
257 time.map { "\(date)T\($0)" } ?? date
258 }
259
260 public var displayValue: String {
261 time.map { "\(date) \($0)" } ?? date
262 }
263}