A dependency-free Swift library that renders org-mode to sanitized HTML.

html library org-mode swift

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}