Sources/OrgSwift/AST/OrgDocument.swift
258 lines · 9101 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 own content: one paragraph normally, several for a multi-paragraph item.
97 public var content: [[OrgObject]]
98 /// A nested list, when the item has one.
99 public var sublist: OrgList?
100
101 public init(checkbox: OrgCheckbox? = nil, term: [OrgObject]? = nil,
102 content: [[OrgObject]], sublist: OrgList? = nil) {
103 self.checkbox = checkbox
104 self.term = term
105 self.content = content
106 self.sublist = sublist
107 }
108}
109
110public enum OrgCheckbox: Sendable, Equatable {
111 case off
112 case on
113 case partial
114}
115
116// MARK: - Tables
117
118public struct OrgTable: Sendable, Equatable {
119 public var rows: [OrgTableRow]
120 /// Per-column alignment from the separator row, when it carries `:` markers.
121 public var alignments: [OrgAlignment?]
122
123 public init(rows: [OrgTableRow], alignments: [OrgAlignment?] = []) {
124 self.rows = rows
125 self.alignments = alignments
126 }
127
128 /// A rule row separates the header band from the body, as in org.
129 public var headerRowCount: Int {
130 guard let ruleIndex = rows.firstIndex(where: { if case .rule = $0 { return true } else { return false } })
131 else { return 0 }
132 return ruleIndex
133 }
134}
135
136public enum OrgTableRow: Sendable, Equatable {
137 case cells([[OrgObject]])
138 case rule
139}
140
141public enum OrgAlignment: String, Sendable, Equatable {
142 case left, center, right
143}
144
145// MARK: - Figures
146
147public struct OrgFigure: Sendable, Equatable {
148 public var source: String
149 public var caption: [OrgObject]?
150 /// `#+ATTR_HTML:` pairs, kept as parsed so an HTML renderer can emit them and a native
151 /// renderer can read the ones it understands (`:alt`, `:width`).
152 public var attributes: [(key: String, value: String)]
153
154 public init(source: String, caption: [OrgObject]? = nil, attributes: [(key: String, value: String)] = []) {
155 self.source = source
156 self.caption = caption
157 self.attributes = attributes
158 }
159
160 public var alt: String? {
161 attributes.first { $0.key == "alt" }?.value
162 }
163
164 public static func == (lhs: OrgFigure, rhs: OrgFigure) -> Bool {
165 lhs.source == rhs.source && lhs.caption == rhs.caption
166 && lhs.attributes.count == rhs.attributes.count
167 && zip(lhs.attributes, rhs.attributes).allSatisfy { $0.key == $1.key && $0.value == $1.value }
168 }
169}
170
171// MARK: - Inline objects
172
173/// An inline object. Text-bearing cases carry their own children so a renderer can nest
174/// styling (`*bold /and italic/*`) rather than receiving pre-formatted markup.
175public indirect enum OrgObject: Sendable, Equatable {
176 case text(String)
177 case bold([OrgObject])
178 case italic([OrgObject])
179 case underline([OrgObject])
180 case strikeThrough([OrgObject])
181 /// Non-nesting by definition in org: `=verbatim=` and `~code~` hold literal text.
182 case verbatim(String)
183 case code(String)
184 case link(OrgLink)
185 case image(OrgFigure)
186 /// A footnote reference. `inline` carries the text of an inline footnote
187 /// (`[fn:label:text]`), which defines the note at the point of use; nil for a plain
188 /// reference whose definition appears elsewhere. Numbering is document-wide, so it is the
189 /// renderer's job, not the parser's.
190 case footnoteRef(label: String, inline: [OrgObject]?)
191 case timestamp(OrgTimestamp)
192 case superscript([OrgObject])
193 case lineBreak
194}
195
196public struct OrgLink: Sendable, Equatable {
197 public var target: OrgLinkTarget
198 /// Nil description means the link shows its target.
199 public var description: [OrgObject]?
200
201 public init(target: OrgLinkTarget, description: [OrgObject]? = nil) {
202 self.target = target
203 self.description = description
204 }
205}
206
207public enum OrgLinkTarget: Sendable, Equatable {
208 /// An absolute URL (`https:`, `mailto:`) or a bare autolinked URL.
209 case external(String)
210 /// A repository-relative path, from `[[file:…]]` or a bare relative target.
211 case file(String)
212 /// `[[id:…]]` — an in-page fragment.
213 case id(String)
214}
215
216public struct OrgTimestamp: Sendable, Equatable {
217 public var date: String
218 public var time: String?
219 /// End of a same-day time range (`<… 10:00-11:45>`).
220 public var endTime: String?
221 /// End of a multi-day range (`<a>--<b>`), which org models as one timestamp with an end.
222 public var endDate: String?
223 public var active: Bool
224
225 public init(date: String, time: String? = nil, endTime: String? = nil,
226 endDate: String? = nil, active: Bool) {
227 self.date = date
228 self.time = time
229 self.endTime = endTime
230 self.endDate = endDate
231 self.active = active
232 }
233
234 /// True when this stamp spans a range, in either form.
235 public var isRange: Bool { endTime != nil || endDate != nil }
236
237 /// The machine value and display text of the range's end, when there is one. A multi-day
238 /// end shows its whole date; a same-day end shows only the time, since the date is already
239 /// on the start.
240 public var end: (machineValue: String, displayValue: String)? {
241 if let endDate {
242 return (endDate, endDate)
243 }
244 if let endTime {
245 return ("\(date)T\(endTime)", endTime)
246 }
247 return nil
248 }
249
250 /// The `datetime` attribute value / sort key: `2024-01-15` or `2024-01-15T10:30`.
251 public var machineValue: String {
252 time.map { "\(date)T\($0)" } ?? date
253 }
254
255 public var displayValue: String {
256 time.map { "\(date) \($0)" } ?? date
257 }
258}