Sources/OrgSwift/AST/OrgParser.swift
408 lines · 16645 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 if candidateTrimmed.isEmpty || startsNewConstruct(candidateTrimmed, raw: candidate) { break }
142 paragraph.append(candidateTrimmed)
143 index += 1
144 }
145 if !paragraph.isEmpty {
146 document.elements.append(.paragraph(parseInline(paragraph.joined(separator: " "))))
147 }
148 flushPending()
149 }
150
151 return document
152 }
153
154 /// Would this line begin a construct other than the paragraph currently being consumed?
155 private static func startsNewConstruct(_ trimmed: String, raw: String) -> Bool {
156 if trimmed.hasPrefix("#+") || trimmed.hasPrefix("#") { return true }
157 if trimmed.firstMatch(of: /^\*{1,6}\s+/) != nil { return true }
158 if isOrgHorizontalRule(trimmed) { return true }
159 if isTableLine(trimmed) { return true }
160 if orgFootnoteDefinition(in: trimmed) != nil { return true }
161 if isListMarkerLine(trimmed), !isIndentedContinuationLine(raw) { return true }
162 return false
163 }
164
165 // MARK: - Heading
166
167 private static func parseHeading(stars: Int, rest: String) -> OrgHeading {
168 var body = rest
169 var todo: String?
170 var priority: Character?
171
172 for keyword in ["TODO", "DONE"] where body == keyword || body.hasPrefix("\(keyword) ") {
173 todo = keyword
174 body = String(body.dropFirst(keyword.count)).trimmingCharacters(in: .whitespaces)
175 break
176 }
177 if let match = body.firstMatch(of: /^\[#([A-Z])\]\s*/) {
178 priority = Character(String(match.1))
179 body = String(body[match.range.upperBound...])
180 }
181 let (title, tags) = splitHeadingTags(body)
182 return OrgHeading(level: stars, todo: todo, priority: priority,
183 title: parseInline(title), tags: tags)
184 }
185
186 // MARK: - Blocks
187
188 private static func parseBlock(_ lines: [String], from start: Int) -> (OrgElement?, Int) {
189 let opener = lines[start].trimmingCharacters(in: .whitespaces)
190 let lower = opener.lowercased()
191 let name = String(lower.dropFirst("#+begin_".count)).split(separator: " ").first.map(String.init) ?? ""
192 let argument = opener
193 .split(separator: " ", maxSplits: 1, omittingEmptySubsequences: true)
194 .dropFirst().first.map { String($0).trimmingCharacters(in: .whitespaces) }
195
196 var body: [String] = []
197 var index = start + 1
198 while index < lines.count {
199 let trimmed = lines[index].trimmingCharacters(in: .whitespaces).lowercased()
200 if trimmed == "#+end_\(name)" { index += 1; break }
201 body.append(lines[index])
202 index += 1
203 }
204
205 switch name {
206 case "src":
207 return (.srcBlock(language: argument?.isEmpty == false ? argument : nil,
208 code: body.joined(separator: "\n")), index)
209 case "example":
210 return (.exampleBlock(body.joined(separator: "\n")), index)
211 case "quote":
212 return (.quoteBlock(parse(body.joined(separator: "\n")).elements), index)
213 case "center":
214 return (.centerBlock(parse(body.joined(separator: "\n")).elements), index)
215 case "verse":
216 return (.verseBlock(body.map(parseInline)), index)
217 case "export":
218 return (.exportBlock(backend: (argument ?? "").lowercased(),
219 raw: body.joined(separator: "\n")), index)
220 default:
221 return (.specialBlock(name: name, content: parse(body.joined(separator: "\n")).elements), index)
222 }
223 }
224
225 // MARK: - Table
226
227 private static func parseTable(_ lines: [String], from start: Int) -> (OrgTable, Int) {
228 var rows: [OrgTableRow] = []
229 var alignments: [OrgAlignment?] = []
230 var index = start
231
232 while index < lines.count {
233 let trimmed = lines[index].trimmingCharacters(in: .whitespaces)
234 guard isTableLine(trimmed) else { break }
235
236 // A separator row's columns are divided by `+`, not `|`, so it needs its own
237 // split — `|:---+---:|` is two columns, which splitting on `|` would miss.
238 let separatorCells = parseOrgTableSeparatorRow(trimmed)
239 let cells = separatorCells.allSatisfy(isTableSeparatorCell)
240 ? separatorCells : parseTableRow(trimmed)
241 if !cells.isEmpty, cells.allSatisfy(isTableSeparatorCell) {
242 rows.append(.rule)
243 let parsed = cells.map { cell -> OrgAlignment? in
244 switch tableAlignment(for: cell) {
245 case "left": return .left
246 case "center": return .center
247 case "right": return .right
248 default: return nil
249 }
250 }
251 if alignments.isEmpty || alignments.allSatisfy({ $0 == nil }) { alignments = parsed }
252 } else {
253 rows.append(.cells(cells.map(parseInline)))
254 }
255 index += 1
256 }
257 return (OrgTable(rows: rows, alignments: alignments), index)
258 }
259
260 // MARK: - List
261
262 private static func parseList(_ lines: [String], from start: Int) -> (OrgList, Int) {
263 var block: [String] = []
264 var index = start
265 var pendingBlanks: [String] = []
266 // A top-level marker of the other kind starts a *separate* list: an ordered list
267 // followed by a bullet list is two lists, not one with mixed items.
268 let startsOrdered = orderedListItem(in: lines[start].trimmingCharacters(in: .whitespaces)) != nil
269
270 while index < lines.count {
271 let line = lines[index]
272 let trimmed = line.trimmingCharacters(in: .whitespaces)
273 if trimmed.isEmpty {
274 pendingBlanks.append(line); index += 1; continue
275 }
276 if isListMarkerLine(trimmed), !isIndentedContinuationLine(line) {
277 guard (orderedListItem(in: trimmed) != nil) == startsOrdered else { break }
278 block.append(contentsOf: pendingBlanks); pendingBlanks = []
279 block.append(line); index += 1; continue
280 }
281 if isIndentedContinuationLine(line) {
282 block.append(contentsOf: pendingBlanks); pendingBlanks = []
283 block.append(line); index += 1; continue
284 }
285 break
286 }
287 return (buildList(block), index)
288 }
289
290 /// Group a list block's lines into items, recursing for nested lists.
291 private static func buildList(_ lines: [String]) -> OrgList {
292 let base = lines.filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty }
293 .map(leadingWidth).min() ?? 0
294 let normalized = lines.map { dropLeading($0, base) }
295
296 var groups: [[String]] = []
297 var current: [String] = []
298 for line in normalized {
299 if isListMarkerLine(line) {
300 if !current.isEmpty { groups.append(current) }
301 current = [line]
302 } else if !current.isEmpty {
303 current.append(line)
304 }
305 }
306 if !current.isEmpty { groups.append(current) }
307
308 let firstMarker = groups.first?.first ?? ""
309 var kind: OrgListKind = orderedListItem(in: firstMarker) != nil ? .ordered : .unordered
310 if stripMarker(firstMarker).contains(" :: ") { kind = .description }
311
312 let items = groups.map { buildItem($0, kind: kind) }
313 return OrgList(kind: kind, items: items)
314 }
315
316 private static func buildItem(_ lines: [String], kind: OrgListKind) -> OrgListItem {
317 var head = stripMarker(lines[0])
318 var checkbox: OrgCheckbox?
319 if head.hasPrefix("[ ] ") { checkbox = .off; head = String(head.dropFirst(4)) }
320 else if head.hasPrefix("[X] ") || head.hasPrefix("[x] ") { checkbox = .on; head = String(head.dropFirst(4)) }
321 else if head.hasPrefix("[-] ") { checkbox = .partial; head = String(head.dropFirst(4)) }
322
323 let rest = Array(lines.dropFirst())
324 let childIndent = rest.filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty }
325 .map(leadingWidth).min() ?? 0
326 let outdented = rest.map { dropLeading($0, childIndent) }
327
328 var paragraphs: [String] = []
329 var currentParagraph = [head]
330 var sublistLines: [String] = []
331 var inSublist = false
332
333 func flush() {
334 let joined = currentParagraph.joined(separator: " ").trimmingCharacters(in: .whitespaces)
335 if !joined.isEmpty { paragraphs.append(joined) }
336 currentParagraph = []
337 }
338
339 for line in outdented {
340 let trimmed = line.trimmingCharacters(in: .whitespaces)
341 if isListMarkerLine(line) || inSublist {
342 if !inSublist { flush() }
343 inSublist = true
344 sublistLines.append(line)
345 } else if trimmed.isEmpty {
346 flush()
347 } else {
348 currentParagraph.append(trimmed)
349 }
350 }
351 flush()
352
353 var term: [OrgObject]?
354 var content = paragraphs
355 if kind == .description, let first = paragraphs.first, let range = first.range(of: " :: ") {
356 term = parseInline(String(first[..<range.lowerBound]))
357 content[0] = String(first[range.upperBound...])
358 }
359
360 return OrgListItem(
361 checkbox: checkbox,
362 term: term,
363 content: content.map(parseInline),
364 sublist: sublistLines.isEmpty ? nil : buildList(sublistLines)
365 )
366 }
367
368 // MARK: - Helpers
369
370 private static func parseAttributes(_ value: String) -> [(key: String, value: String)] {
371 guard let regex = try? NSRegularExpression(pattern: #":([A-Za-z_][A-Za-z0-9_-]*)\s+("[^"]*"|\S+)"#) else {
372 return []
373 }
374 let ns = value as NSString
375 return regex.matches(in: value, range: NSRange(location: 0, length: ns.length)).map { m in
376 var raw = ns.substring(with: m.range(at: 2))
377 if raw.count >= 2, raw.hasPrefix("\""), raw.hasSuffix("\"") { raw = String(raw.dropFirst().dropLast()) }
378 return (ns.substring(with: m.range(at: 1)).lowercased(), raw)
379 }
380 }
381
382 private static func stripMarker(_ line: String) -> String {
383 let trimmed = line.trimmingCharacters(in: .whitespaces)
384 if trimmed.hasPrefix("- ") || trimmed.hasPrefix("+ ") { return String(trimmed.dropFirst(2)) }
385 if let match = trimmed.firstMatch(of: /^\d+[.)]\s+(.*)$/) { return String(match.1) }
386 return trimmed
387 }
388
389 private static func leadingWidth(_ line: String) -> Int {
390 var count = 0
391 for ch in line {
392 if ch == " " { count += 1 } else if ch == "\t" { count += 8 } else { break }
393 }
394 return count
395 }
396
397 private static func dropLeading(_ line: String, _ n: Int) -> String {
398 var dropped = 0
399 var index = line.startIndex
400 while index < line.endIndex, dropped < n {
401 if line[index] == " " { dropped += 1 }
402 else if line[index] == "\t" { dropped += 8 }
403 else { break }
404 index = line.index(after: index)
405 }
406 return String(line[index...])
407 }
408}