import Foundation /// Parses org source into an ``OrgDocument``. /// /// This is the prototype of the AST split: parsing happens once, here, and renderers walk the /// result. It is deliberately a separate pipeline from the shipped ``OrgRenderer`` (which goes /// straight from source to an HTML string) so the two can be compared before anything migrates. public enum OrgParser { public static func parse(_ source: String) -> OrgDocument { var document = OrgDocument() let lines = source .replacingOccurrences(of: "\r\n", with: "\n") .replacingOccurrences(of: "\r", with: "\n") .components(separatedBy: "\n") var index = 0 var pendingCaption: String? var pendingName: String? var pendingAttrs: [(key: String, value: String)] = [] func flushPending() { pendingCaption = nil pendingName = nil pendingAttrs = [] } /// Append a block, wrapping it in `.captioned` when an affiliated `#+CAPTION:` or /// `#+NAME:` precedes it — org's exporter turns that pairing into a `
`. func append(_ element: OrgElement) { if pendingCaption != nil || pendingName != nil { document.elements.append(.captioned( name: pendingName, caption: pendingCaption.map(parseInline), content: element )) } else { document.elements.append(element) } } while index < lines.count { let line = lines[index] let trimmed = line.trimmingCharacters(in: .whitespaces) if trimmed.isEmpty { index += 1; continue } // Comments. if trimmed == "#" || trimmed.hasPrefix("# ") { index += 1; continue } // Property drawers are heading metadata; org's exporter drops them. if trimmed == ":PROPERTIES:" { index += 1 while index < lines.count, lines[index].trimmingCharacters(in: .whitespaces) != ":END:" { index += 1 } if index < lines.count { index += 1 } continue } // Affiliated keywords and document metadata. if let directive = orgKeywordDirective(in: trimmed) { switch directive.keyword { case "caption": pendingCaption = directive.value case "name": pendingName = directive.value case "attr_html": pendingAttrs = parseAttributes(directive.value) default: document.keywords.append((directive.keyword, directive.value)) } index += 1 continue } // Blocks: #+begin_… / #+end_… if trimmed.lowercased().hasPrefix("#+begin_") { let (element, next) = parseBlock(lines, from: index) if let element { append(element) } index = next flushPending() continue } // Heading. if let match = trimmed.firstMatch(of: /^(\*{1,6})\s+(.+)$/) { document.elements.append(.heading(parseHeading(stars: match.1.count, rest: String(match.2)))) index += 1 flushPending() continue } // Horizontal rule. if isOrgHorizontalRule(trimmed) { document.elements.append(.horizontalRule) index += 1 flushPending() continue } // Footnote definition. if let def = orgFootnoteDefinition(in: trimmed) { document.elements.append(.footnoteDefinition(label: def.label, content: parseInline(def.text))) index += 1 flushPending() continue } // A standalone image link, promoted to a figure by an affiliated caption/attrs. if let path = standaloneOrgImage(in: trimmed) { document.elements.append(.figure(OrgFigure( source: path, caption: pendingCaption.map(parseInline), attributes: pendingAttrs ))) index += 1 flushPending() continue } // Table. if isTableLine(trimmed) { let (table, next) = parseTable(lines, from: index) append(.table(table)) index = next flushPending() continue } // List. if isListMarkerLine(trimmed), !isIndentedContinuationLine(line) { let (list, next) = parseList(lines, from: index) document.elements.append(.list(list)) index = next flushPending() continue } // Paragraph: consume until a blank line or a line that starts another construct. var paragraph: [String] = [] while index < lines.count { let candidate = lines[index] let candidateTrimmed = candidate.trimmingCharacters(in: .whitespaces) if candidateTrimmed.isEmpty || startsNewConstruct(candidateTrimmed, raw: candidate) { break } paragraph.append(candidateTrimmed) index += 1 } if !paragraph.isEmpty { document.elements.append(.paragraph(parseInline(paragraph.joined(separator: " ")))) } flushPending() } return document } /// Would this line begin a construct other than the paragraph currently being consumed? private static func startsNewConstruct(_ trimmed: String, raw: String) -> Bool { if trimmed.hasPrefix("#+") || trimmed.hasPrefix("#") { return true } if trimmed.firstMatch(of: /^\*{1,6}\s+/) != nil { return true } if isOrgHorizontalRule(trimmed) { return true } if isTableLine(trimmed) { return true } if orgFootnoteDefinition(in: trimmed) != nil { return true } if isListMarkerLine(trimmed), !isIndentedContinuationLine(raw) { return true } return false } // MARK: - Heading private static func parseHeading(stars: Int, rest: String) -> OrgHeading { var body = rest var todo: String? var priority: Character? for keyword in ["TODO", "DONE"] where body == keyword || body.hasPrefix("\(keyword) ") { todo = keyword body = String(body.dropFirst(keyword.count)).trimmingCharacters(in: .whitespaces) break } if let match = body.firstMatch(of: /^\[#([A-Z])\]\s*/) { priority = Character(String(match.1)) body = String(body[match.range.upperBound...]) } let (title, tags) = splitHeadingTags(body) return OrgHeading(level: stars, todo: todo, priority: priority, title: parseInline(title), tags: tags) } // MARK: - Blocks private static func parseBlock(_ lines: [String], from start: Int) -> (OrgElement?, Int) { let opener = lines[start].trimmingCharacters(in: .whitespaces) let lower = opener.lowercased() let name = String(lower.dropFirst("#+begin_".count)).split(separator: " ").first.map(String.init) ?? "" let argument = opener .split(separator: " ", maxSplits: 1, omittingEmptySubsequences: true) .dropFirst().first.map { String($0).trimmingCharacters(in: .whitespaces) } var body: [String] = [] var index = start + 1 while index < lines.count { let trimmed = lines[index].trimmingCharacters(in: .whitespaces).lowercased() if trimmed == "#+end_\(name)" { index += 1; break } body.append(lines[index]) index += 1 } switch name { case "src": return (.srcBlock(language: argument?.isEmpty == false ? argument : nil, code: body.joined(separator: "\n")), index) case "example": return (.exampleBlock(body.joined(separator: "\n")), index) case "quote": return (.quoteBlock(parse(body.joined(separator: "\n")).elements), index) case "center": return (.centerBlock(parse(body.joined(separator: "\n")).elements), index) case "verse": return (.verseBlock(body.map(parseInline)), index) case "export": return (.exportBlock(backend: (argument ?? "").lowercased(), raw: body.joined(separator: "\n")), index) default: return (.specialBlock(name: name, content: parse(body.joined(separator: "\n")).elements), index) } } // MARK: - Table private static func parseTable(_ lines: [String], from start: Int) -> (OrgTable, Int) { var rows: [OrgTableRow] = [] var alignments: [OrgAlignment?] = [] var index = start while index < lines.count { let trimmed = lines[index].trimmingCharacters(in: .whitespaces) guard isTableLine(trimmed) else { break } // A separator row's columns are divided by `+`, not `|`, so it needs its own // split — `|:---+---:|` is two columns, which splitting on `|` would miss. let separatorCells = parseOrgTableSeparatorRow(trimmed) let cells = separatorCells.allSatisfy(isTableSeparatorCell) ? separatorCells : parseTableRow(trimmed) if !cells.isEmpty, cells.allSatisfy(isTableSeparatorCell) { rows.append(.rule) let parsed = cells.map { cell -> OrgAlignment? in switch tableAlignment(for: cell) { case "left": return .left case "center": return .center case "right": return .right default: return nil } } if alignments.isEmpty || alignments.allSatisfy({ $0 == nil }) { alignments = parsed } } else { rows.append(.cells(cells.map(parseInline))) } index += 1 } return (OrgTable(rows: rows, alignments: alignments), index) } // MARK: - List private static func parseList(_ lines: [String], from start: Int) -> (OrgList, Int) { var block: [String] = [] var index = start var pendingBlanks: [String] = [] // A top-level marker of the other kind starts a *separate* list: an ordered list // followed by a bullet list is two lists, not one with mixed items. let startsOrdered = orderedListItem(in: lines[start].trimmingCharacters(in: .whitespaces)) != nil while index < lines.count { let line = lines[index] let trimmed = line.trimmingCharacters(in: .whitespaces) if trimmed.isEmpty { pendingBlanks.append(line); index += 1; continue } if isListMarkerLine(trimmed), !isIndentedContinuationLine(line) { guard (orderedListItem(in: trimmed) != nil) == startsOrdered else { break } block.append(contentsOf: pendingBlanks); pendingBlanks = [] block.append(line); index += 1; continue } if isIndentedContinuationLine(line) { block.append(contentsOf: pendingBlanks); pendingBlanks = [] block.append(line); index += 1; continue } break } return (buildList(block), index) } /// Group a list block's lines into items, recursing for nested lists. private static func buildList(_ lines: [String]) -> OrgList { let base = lines.filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty } .map(leadingWidth).min() ?? 0 let normalized = lines.map { dropLeading($0, base) } var groups: [[String]] = [] var current: [String] = [] for line in normalized { if isListMarkerLine(line) { if !current.isEmpty { groups.append(current) } current = [line] } else if !current.isEmpty { current.append(line) } } if !current.isEmpty { groups.append(current) } let firstMarker = groups.first?.first ?? "" var kind: OrgListKind = orderedListItem(in: firstMarker) != nil ? .ordered : .unordered if stripMarker(firstMarker).contains(" :: ") { kind = .description } let items = groups.map { buildItem($0, kind: kind) } return OrgList(kind: kind, items: items) } private static func buildItem(_ lines: [String], kind: OrgListKind) -> OrgListItem { var head = stripMarker(lines[0]) var checkbox: OrgCheckbox? if head.hasPrefix("[ ] ") { checkbox = .off; head = String(head.dropFirst(4)) } else if head.hasPrefix("[X] ") || head.hasPrefix("[x] ") { checkbox = .on; head = String(head.dropFirst(4)) } else if head.hasPrefix("[-] ") { checkbox = .partial; head = String(head.dropFirst(4)) } let rest = Array(lines.dropFirst()) let childIndent = rest.filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty } .map(leadingWidth).min() ?? 0 let outdented = rest.map { dropLeading($0, childIndent) } var paragraphs: [String] = [] var currentParagraph = [head] var sublistLines: [String] = [] var inSublist = false func flush() { let joined = currentParagraph.joined(separator: " ").trimmingCharacters(in: .whitespaces) if !joined.isEmpty { paragraphs.append(joined) } currentParagraph = [] } for line in outdented { let trimmed = line.trimmingCharacters(in: .whitespaces) if isListMarkerLine(line) || inSublist { if !inSublist { flush() } inSublist = true sublistLines.append(line) } else if trimmed.isEmpty { flush() } else { currentParagraph.append(trimmed) } } flush() var term: [OrgObject]? var content = paragraphs if kind == .description, let first = paragraphs.first, let range = first.range(of: " :: ") { term = parseInline(String(first[.. [(key: String, value: String)] { guard let regex = try? NSRegularExpression(pattern: #":([A-Za-z_][A-Za-z0-9_-]*)\s+("[^"]*"|\S+)"#) else { return [] } let ns = value as NSString return regex.matches(in: value, range: NSRange(location: 0, length: ns.length)).map { m in var raw = ns.substring(with: m.range(at: 2)) if raw.count >= 2, raw.hasPrefix("\""), raw.hasSuffix("\"") { raw = String(raw.dropFirst().dropLast()) } return (ns.substring(with: m.range(at: 1)).lowercased(), raw) } } private static func stripMarker(_ line: String) -> String { let trimmed = line.trimmingCharacters(in: .whitespaces) if trimmed.hasPrefix("- ") || trimmed.hasPrefix("+ ") { return String(trimmed.dropFirst(2)) } if let match = trimmed.firstMatch(of: /^\d+[.)]\s+(.*)$/) { return String(match.1) } return trimmed } private static func leadingWidth(_ line: String) -> Int { var count = 0 for ch in line { if ch == " " { count += 1 } else if ch == "\t" { count += 8 } else { break } } return count } private static func dropLeading(_ line: String, _ n: Int) -> String { var dropped = 0 var index = line.startIndex while index < line.endIndex, dropped < n { if line[index] == " " { dropped += 1 } else if line[index] == "\t" { dropped += 8 } else { break } index = line.index(after: index) } return String(line[index...]) } }