Sources/OrgSwift/AST/OrgParser.swift
385 lines · 16246 bytes
1import Foundation
2
3/// Parses org source into an ``OrgDocument``.
4///
5/// This is the prototype of the AST split: parsing happens once, here, and renderers walk the
6/// result. It is deliberately a separate pipeline from the shipped ``OrgRenderer`` (which goes
7/// straight from source to an HTML string) so the two can be compared before anything migrates.
8public enum OrgParser {
9
10 public static func parse(_ source: String) -> OrgDocument {
11 var document = OrgDocument()
12 let lines = source
13 .replacingOccurrences(of: "\r\n", with: "\n")
14 .replacingOccurrences(of: "\r", with: "\n")
15 .components(separatedBy: "\n")
16 var index = 0
17 var pendingCaption: String?
18 var pendingName: String?
19 var pendingAttrs: [(key: String, value: String)] = []
20
21 func flushPending() {
22 pendingCaption = nil
23 pendingName = nil
24 pendingAttrs = []
25 }
26
27 /// Append a block, wrapping it in `.captioned` when an affiliated `#+CAPTION:` or
28 /// `#+NAME:` precedes it — org's exporter turns that pairing into a `<figure>`.
29 func append(_ element: OrgElement) {
30 if pendingCaption != nil || pendingName != nil {
31 document.elements.append(.captioned(
32 name: pendingName,
33 caption: pendingCaption.map(parseInline),
34 content: element
35 ))
36 } else {
37 document.elements.append(element)
38 }
39 }
40
41 while index < lines.count {
42 let line = lines[index]
43 let trimmed = line.trimmingCharacters(in: .whitespaces)
44
45 if trimmed.isEmpty { index += 1; continue }
46
47 // Comments.
48 if trimmed == "#" || trimmed.hasPrefix("# ") { index += 1; continue }
49
50 // Property drawers are heading metadata; org's exporter drops them.
51 if trimmed == ":PROPERTIES:" {
52 index += 1
53 while index < lines.count,
54 lines[index].trimmingCharacters(in: .whitespaces) != ":END:" {
55 index += 1
56 }
57 if index < lines.count { index += 1 }
58 continue
59 }
60
61 // Affiliated keywords and document metadata.
62 if let directive = orgKeywordDirective(in: trimmed) {
63 switch directive.keyword {
64 case "caption": pendingCaption = directive.value
65 case "name": pendingName = directive.value
66 case "attr_html": pendingAttrs = parseAttributes(directive.value)
67 default: document.keywords.append((directive.keyword, directive.value))
68 }
69 index += 1
70 continue
71 }
72
73 // Blocks: #+begin_… / #+end_…
74 if trimmed.lowercased().hasPrefix("#+begin_") {
75 let (element, next) = parseBlock(lines, from: index)
76 if let element { append(element) }
77 index = next
78 flushPending()
79 continue
80 }
81
82 // Heading.
83 if let match = trimmed.firstMatch(of: /^(\*{1,6})\s+(.+)$/) {
84 document.elements.append(.heading(parseHeading(stars: match.1.count, rest: String(match.2))))
85 index += 1
86 flushPending()
87 continue
88 }
89
90 // Horizontal rule.
91 if isOrgHorizontalRule(trimmed) {
92 document.elements.append(.horizontalRule)
93 index += 1
94 flushPending()
95 continue
96 }
97
98 // Footnote definition.
99 if let def = orgFootnoteDefinition(in: trimmed) {
100 document.elements.append(.footnoteDefinition(label: def.label, content: parseInline(def.text)))
101 index += 1
102 flushPending()
103 continue
104 }
105
106 // A standalone image link, promoted to a figure by an affiliated caption/attrs.
107 if let path = standaloneOrgImage(in: trimmed) {
108 document.elements.append(.figure(OrgFigure(
109 source: path,
110 caption: pendingCaption.map(parseInline),
111 attributes: pendingAttrs
112 )))
113 index += 1
114 flushPending()
115 continue
116 }
117
118 // Table.
119 if isTableLine(trimmed) {
120 let (table, next) = parseTable(lines, from: index)
121 append(.table(table))
122 index = next
123 flushPending()
124 continue
125 }
126
127 // List.
128 if isListMarkerLine(trimmed), !isIndentedContinuationLine(line) {
129 let (list, next) = parseList(lines, from: index)
130 document.elements.append(.list(list))
131 index = next
132 flushPending()
133 continue
134 }
135
136 // Paragraph: consume until a blank line or a line that starts another construct.
137 var paragraph: [String] = []
138 while index < lines.count {
139 let candidate = lines[index]
140 let candidateTrimmed = candidate.trimmingCharacters(in: .whitespaces)
141 // The first line is always taken: nothing above claimed it, and
142 // leaving it would parse the same line forever.
143 if !paragraph.isEmpty,
144 candidateTrimmed.isEmpty || startsNewConstruct(candidateTrimmed, raw: candidate) { break }
145 paragraph.append(candidateTrimmed)
146 index += 1
147 }
148 if !paragraph.isEmpty {
149 document.elements.append(.paragraph(parseInline(paragraph.joined(separator: " "))))
150 }
151 flushPending()
152 }
153
154 return document
155 }
156
157 /// Would this line begin a construct other than the paragraph currently being consumed?
158 private static func startsNewConstruct(_ trimmed: String, raw: String) -> Bool {
159 // A keyword or a comment; `#12 fixed this` is prose.
160 if trimmed.hasPrefix("#+") || trimmed == "#" || trimmed.hasPrefix("# ") { return true }
161 if trimmed.firstMatch(of: /^\*{1,6}\s+/) != nil { return true }
162 if isOrgHorizontalRule(trimmed) { return true }
163 if isTableLine(trimmed) { return true }
164 if orgFootnoteDefinition(in: trimmed) != nil { return true }
165 if isListMarkerLine(trimmed), !isIndentedContinuationLine(raw) { return true }
166 return false
167 }
168
169 // MARK: - Heading
170
171 private static func parseHeading(stars: Int, rest: String) -> OrgHeading {
172 var body = rest
173 var todo: String?
174 var priority: Character?
175
176 for keyword in ["TODO", "DONE"] where body == keyword || body.hasPrefix("\(keyword) ") {
177 todo = keyword
178 body = String(body.dropFirst(keyword.count)).trimmingCharacters(in: .whitespaces)
179 break
180 }
181 if let match = body.firstMatch(of: /^\[#([A-Z])\]\s*/) {
182 priority = Character(String(match.1))
183 body = String(body[match.range.upperBound...])
184 }
185 let (title, tags) = splitHeadingTags(body)
186 return OrgHeading(level: stars, todo: todo, priority: priority,
187 title: parseInline(title), tags: tags)
188 }
189
190 // MARK: - Blocks
191
192 private static func parseBlock(_ lines: [String], from start: Int) -> (OrgElement?, Int) {
193 let opener = lines[start].trimmingCharacters(in: .whitespaces)
194 let lower = opener.lowercased()
195 let name = String(lower.dropFirst("#+begin_".count)).split(separator: " ").first.map(String.init) ?? ""
196 let argument = opener
197 .split(separator: " ", maxSplits: 1, omittingEmptySubsequences: true)
198 .dropFirst().first.map { String($0).trimmingCharacters(in: .whitespaces) }
199
200 var body: [String] = []
201 var index = start + 1
202 while index < lines.count {
203 let trimmed = lines[index].trimmingCharacters(in: .whitespaces).lowercased()
204 if trimmed == "#+end_\(name)" { index += 1; break }
205 body.append(lines[index])
206 index += 1
207 }
208
209 switch name {
210 case "src":
211 return (.srcBlock(language: argument?.isEmpty == false ? argument : nil,
212 code: body.joined(separator: "\n")), index)
213 case "example":
214 return (.exampleBlock(body.joined(separator: "\n")), index)
215 case "quote":
216 return (.quoteBlock(parse(body.joined(separator: "\n")).elements), index)
217 case "center":
218 return (.centerBlock(parse(body.joined(separator: "\n")).elements), index)
219 case "verse":
220 return (.verseBlock(body.map(parseInline)), index)
221 case "export":
222 return (.exportBlock(backend: (argument ?? "").lowercased(),
223 raw: body.joined(separator: "\n")), index)
224 default:
225 return (.specialBlock(name: name, content: parse(body.joined(separator: "\n")).elements), index)
226 }
227 }
228
229 // MARK: - Table
230
231 private static func parseTable(_ lines: [String], from start: Int) -> (OrgTable, Int) {
232 var rows: [OrgTableRow] = []
233 var alignments: [OrgAlignment?] = []
234 var index = start
235
236 while index < lines.count {
237 let trimmed = lines[index].trimmingCharacters(in: .whitespaces)
238 guard isTableLine(trimmed) else { break }
239
240 // A separator row's columns are divided by `+`, not `|`, so it needs its own
241 // split — `|:---+---:|` is two columns, which splitting on `|` would miss.
242 let separatorCells = parseOrgTableSeparatorRow(trimmed)
243 let cells = separatorCells.allSatisfy(isTableSeparatorCell)
244 ? separatorCells : parseTableRow(trimmed)
245 if !cells.isEmpty, cells.allSatisfy(isTableSeparatorCell) {
246 rows.append(.rule)
247 let parsed = cells.map { cell -> OrgAlignment? in
248 switch tableAlignment(for: cell) {
249 case "left": return .left
250 case "center": return .center
251 case "right": return .right
252 default: return nil
253 }
254 }
255 if alignments.isEmpty || alignments.allSatisfy({ $0 == nil }) { alignments = parsed }
256 } else {
257 rows.append(.cells(cells.map(parseInline)))
258 }
259 index += 1
260 }
261 return (OrgTable(rows: rows, alignments: alignments), index)
262 }
263
264 // MARK: - List
265
266 private static func parseList(_ lines: [String], from start: Int) -> (OrgList, Int) {
267 var block: [String] = []
268 var index = start
269 var pendingBlanks: [String] = []
270 // A top-level marker of the other kind starts a *separate* list: an ordered list
271 // followed by a bullet list is two lists, not one with mixed items.
272 let startsOrdered = orderedListItem(in: lines[start].trimmingCharacters(in: .whitespaces)) != nil
273
274 while index < lines.count {
275 let line = lines[index]
276 let trimmed = line.trimmingCharacters(in: .whitespaces)
277 if trimmed.isEmpty {
278 pendingBlanks.append(line); index += 1; continue
279 }
280 if isListMarkerLine(trimmed), !isIndentedContinuationLine(line) {
281 guard (orderedListItem(in: trimmed) != nil) == startsOrdered else { break }
282 block.append(contentsOf: pendingBlanks); pendingBlanks = []
283 block.append(line); index += 1; continue
284 }
285 if isIndentedContinuationLine(line) {
286 block.append(contentsOf: pendingBlanks); pendingBlanks = []
287 block.append(line); index += 1; continue
288 }
289 break
290 }
291 return (buildList(block), index)
292 }
293
294 /// Group a list block's lines into items, recursing for nested lists.
295 private static func buildList(_ lines: [String]) -> OrgList {
296 let base = lines.filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty }
297 .map(leadingWidth).min() ?? 0
298 let normalized = lines.map { dropLeading($0, base) }
299
300 var groups: [[String]] = []
301 var current: [String] = []
302 for line in normalized {
303 if isListMarkerLine(line) {
304 if !current.isEmpty { groups.append(current) }
305 current = [line]
306 } else if !current.isEmpty {
307 current.append(line)
308 }
309 }
310 if !current.isEmpty { groups.append(current) }
311
312 let firstMarker = groups.first?.first ?? ""
313 var kind: OrgListKind = orderedListItem(in: firstMarker) != nil ? .ordered : .unordered
314 if stripMarker(firstMarker).contains(" :: ") { kind = .description }
315
316 let items = groups.map { buildItem($0, kind: kind) }
317 return OrgList(kind: kind, items: items)
318 }
319
320 private static func buildItem(_ lines: [String], kind: OrgListKind) -> OrgListItem {
321 var head = stripMarker(lines[0])
322 var checkbox: OrgCheckbox?
323 if head.hasPrefix("[ ] ") { checkbox = .off; head = String(head.dropFirst(4)) }
324 else if head.hasPrefix("[X] ") || head.hasPrefix("[x] ") { checkbox = .on; head = String(head.dropFirst(4)) }
325 else if head.hasPrefix("[-] ") { checkbox = .partial; head = String(head.dropFirst(4)) }
326
327 // `term :: definition` splits on the item's first line only, as org requires.
328 var term: [OrgObject]?
329 if kind == .description, let range = head.range(of: " :: ") {
330 term = parseInline(String(head[..<range.lowerBound]))
331 head = String(head[range.upperBound...])
332 }
333
334 // The item's body is outdented to column 0 and parsed as a document of its own, so an
335 // item holds whatever a document holds — several paragraphs, a nested list, a source
336 // block, a table — in source order.
337 let rest = Array(lines.dropFirst())
338 let childIndent = rest.filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty }
339 .map(leadingWidth).min() ?? 0
340 let body = ([head] + rest.map { dropLeading($0, childIndent) }).joined(separator: "\n")
341
342 return OrgListItem(checkbox: checkbox, term: term, content: parse(body).elements)
343 }
344
345 // MARK: - Helpers
346
347 private static func parseAttributes(_ value: String) -> [(key: String, value: String)] {
348 guard let regex = try? NSRegularExpression(pattern: #":([A-Za-z_][A-Za-z0-9_-]*)\s+("[^"]*"|\S+)"#) else {
349 return []
350 }
351 let ns = value as NSString
352 return regex.matches(in: value, range: NSRange(location: 0, length: ns.length)).map { m in
353 var raw = ns.substring(with: m.range(at: 2))
354 if raw.count >= 2, raw.hasPrefix("\""), raw.hasSuffix("\"") { raw = String(raw.dropFirst().dropLast()) }
355 return (ns.substring(with: m.range(at: 1)).lowercased(), raw)
356 }
357 }
358
359 private static func stripMarker(_ line: String) -> String {
360 let trimmed = line.trimmingCharacters(in: .whitespaces)
361 if trimmed.hasPrefix("- ") || trimmed.hasPrefix("+ ") { return String(trimmed.dropFirst(2)) }
362 if let match = trimmed.firstMatch(of: /^\d+[.)]\s+(.*)$/) { return String(match.1) }
363 return trimmed
364 }
365
366 private static func leadingWidth(_ line: String) -> Int {
367 var count = 0
368 for ch in line {
369 if ch == " " { count += 1 } else if ch == "\t" { count += 8 } else { break }
370 }
371 return count
372 }
373
374 private static func dropLeading(_ line: String, _ n: Int) -> String {
375 var dropped = 0
376 var index = line.startIndex
377 while index < line.endIndex, dropped < n {
378 if line[index] == " " { dropped += 1 }
379 else if line[index] == "\t" { dropped += 8 }
380 else { break }
381 index = line.index(after: index)
382 }
383 return String(line[index...])
384 }
385}