Sources/OrgSwift/AST/OrgDocument.swift
282 lines · 10180 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 /// `\begin{…}` and its `\end{…}` on lines of their own, with everything between: display
55 /// math, as org's exporter reads it.
56 case latexBlock(String)
57}
58
59public struct OrgHeading: Sendable, Equatable {
60 /// Star count, before any render-time level offset.
61 public var level: Int
62 public var todo: String?
63 public var priority: Character?
64 public var title: [OrgObject]
65 public var tags: [String]
66
67 public init(level: Int, todo: String? = nil, priority: Character? = nil,
68 title: [OrgObject], tags: [String] = []) {
69 self.level = level
70 self.todo = todo
71 self.priority = priority
72 self.title = title
73 self.tags = tags
74 }
75}
76
77// MARK: - Lists
78
79public struct OrgList: Sendable, Equatable {
80 public var kind: OrgListKind
81 public var items: [OrgListItem]
82
83 public init(kind: OrgListKind, items: [OrgListItem]) {
84 self.kind = kind
85 self.items = items
86 }
87}
88
89public enum OrgListKind: Sendable, Equatable {
90 case unordered
91 case ordered
92 case description
93}
94
95public struct OrgListItem: Sendable, Equatable {
96 public var checkbox: OrgCheckbox?
97 /// The term of a description-list item (`term :: definition`).
98 public var term: [OrgObject]?
99 /// The item's body, in document order. Usually one paragraph, but an item may hold any
100 /// block element — a nested list, a source block, a table — so this is a block sequence
101 /// rather than a list of paragraphs.
102 public var content: [OrgElement]
103
104 public init(checkbox: OrgCheckbox? = nil, term: [OrgObject]? = nil,
105 content: [OrgElement]) {
106 self.checkbox = checkbox
107 self.term = term
108 self.content = content
109 }
110
111 /// The item's paragraphs, ignoring any other block it carries. Used where only prose is
112 /// meaningful — the `<dd>` of a description list, an item's plain-text reduction.
113 public var paragraphs: [[OrgObject]] {
114 content.compactMap { if case .paragraph(let objects) = $0 { objects } else { nil } }
115 }
116}
117
118public enum OrgCheckbox: Sendable, Equatable {
119 case off
120 case on
121 case partial
122}
123
124// MARK: - Tables
125
126public struct OrgTable: Sendable, Equatable {
127 public var rows: [OrgTableRow]
128 /// Per-column alignment from the separator row, when it carries `:` markers.
129 public var alignments: [OrgAlignment?]
130
131 public init(rows: [OrgTableRow], alignments: [OrgAlignment?] = []) {
132 self.rows = rows
133 self.alignments = alignments
134 }
135
136 /// A rule row separates the header band from the body, as in org.
137 public var headerRowCount: Int {
138 guard let ruleIndex = rows.firstIndex(where: { if case .rule = $0 { return true } else { return false } })
139 else { return 0 }
140 return ruleIndex
141 }
142}
143
144public enum OrgTableRow: Sendable, Equatable {
145 case cells([[OrgObject]])
146 case rule
147}
148
149public enum OrgAlignment: String, Sendable, Equatable {
150 case left, center, right
151}
152
153// MARK: - Figures
154
155public struct OrgFigure: Sendable, Equatable {
156 public var source: String
157 public var caption: [OrgObject]?
158 /// `#+ATTR_HTML:` pairs, kept as parsed so an HTML renderer can emit them and a native
159 /// renderer can read the ones it understands (`:alt`, `:width`).
160 public var attributes: [(key: String, value: String)]
161
162 public init(source: String, caption: [OrgObject]? = nil, attributes: [(key: String, value: String)] = []) {
163 self.source = source
164 self.caption = caption
165 self.attributes = attributes
166 }
167
168 public var alt: String? {
169 attributes.first { $0.key == "alt" }?.value
170 }
171
172 public static func == (lhs: OrgFigure, rhs: OrgFigure) -> Bool {
173 lhs.source == rhs.source && lhs.caption == rhs.caption
174 && lhs.attributes.count == rhs.attributes.count
175 && zip(lhs.attributes, rhs.attributes).allSatisfy { $0.key == $1.key && $0.value == $1.value }
176 }
177}
178
179// MARK: - Inline objects
180
181/// An inline object. Text-bearing cases carry their own children so a renderer can nest
182/// styling (`*bold /and italic/*`) rather than receiving pre-formatted markup.
183public indirect enum OrgObject: Sendable, Equatable {
184 case text(String)
185 case bold([OrgObject])
186 case italic([OrgObject])
187 case underline([OrgObject])
188 case strikeThrough([OrgObject])
189 /// Non-nesting by definition in org: `=verbatim=` and `~code~` hold literal text.
190 case verbatim(String)
191 case code(String)
192 case link(OrgLink)
193 case image(OrgFigure)
194 /// A footnote reference. `inline` carries the text of an inline footnote
195 /// (`[fn:label:text]`), which defines the note at the point of use; nil for a plain
196 /// reference whose definition appears elsewhere. Numbering is document-wide, so it is the
197 /// renderer's job, not the parser's.
198 case footnoteRef(label: String, inline: [OrgObject]?)
199 case timestamp(OrgTimestamp)
200 case superscript([OrgObject])
201 case lineBreak
202 /// A LaTeX fragment: `$…$`, `$$…$$`, `\(…\)`, `\[…\]` or an inline `\begin{…}…\end{…}`.
203 case math(OrgMath)
204}
205
206/// TeX to typeset. `source` is the fragment as written, delimiters included, for a renderer
207/// with no typesetter to show; an environment's `tex` is its whole source.
208public struct OrgMath: Sendable, Hashable {
209 public var tex: String
210 public var display: Bool
211 public var source: String
212
213 public init(tex: String, display: Bool, source: String) {
214 self.tex = tex
215 self.display = display
216 self.source = source
217 }
218}
219
220public struct OrgLink: Sendable, Equatable {
221 public var target: OrgLinkTarget
222 /// Nil description means the link shows its target.
223 public var description: [OrgObject]?
224
225 public init(target: OrgLinkTarget, description: [OrgObject]? = nil) {
226 self.target = target
227 self.description = description
228 }
229}
230
231public enum OrgLinkTarget: Sendable, Equatable {
232 /// An absolute URL (`https:`, `mailto:`) or a bare autolinked URL.
233 case external(String)
234 /// A repository-relative path, from `[[file:…]]` or a bare relative target.
235 case file(String)
236 /// `[[id:…]]` — an in-page fragment.
237 case id(String)
238}
239
240public struct OrgTimestamp: Sendable, Equatable {
241 public var date: String
242 public var time: String?
243 /// End of a same-day time range (`<… 10:00-11:45>`).
244 public var endTime: String?
245 /// End of a multi-day range (`<a>--<b>`), which org models as one timestamp with an end.
246 public var endDate: String?
247 public var active: Bool
248
249 public init(date: String, time: String? = nil, endTime: String? = nil,
250 endDate: String? = nil, active: Bool) {
251 self.date = date
252 self.time = time
253 self.endTime = endTime
254 self.endDate = endDate
255 self.active = active
256 }
257
258 /// True when this stamp spans a range, in either form.
259 public var isRange: Bool { endTime != nil || endDate != nil }
260
261 /// The machine value and display text of the range's end, when there is one. A multi-day
262 /// end shows its whole date; a same-day end shows only the time, since the date is already
263 /// on the start.
264 public var end: (machineValue: String, displayValue: String)? {
265 if let endDate {
266 return (endDate, endDate)
267 }
268 if let endTime {
269 return ("\(date)T\(endTime)", endTime)
270 }
271 return nil
272 }
273
274 /// The `datetime` attribute value / sort key: `2024-01-15` or `2024-01-15T10:30`.
275 public var machineValue: String {
276 time.map { "\(date)T\($0)" } ?? date
277 }
278
279 public var displayValue: String {
280 time.map { "\(date) \($0)" } ?? date
281 }
282}