import Foundation // Inline parsing into `[OrgObject]`. // // The shipped renderer does inline work by regex-substituting HTML into an escaped string, // using placeholder tokens to protect what must not be re-scanned. That works for one output // format but bakes HTML into the parse. Here the same constructs become a tree, so `*bold // /italic/*` nests properly and every renderer decides its own representation. extension OrgParser { /// Inline objects reduced to their plain text — for alt text, previews, or assertions. public static func plain(_ objects: [OrgObject]) -> String { objects.map { object in switch object { case .text(let text): return text case .verbatim(let text), .code(let text): return text case .bold(let c), .italic(let c), .underline(let c), .strikeThrough(let c), .superscript(let c): return plain(c) case .link(let link): if let description = link.description { return plain(description) } switch link.target { case .external(let value), .file(let value), .id(let value): return value } case .image(let figure): return figure.alt ?? "" case .timestamp(let stamp): return stamp.displayValue case .math(let math): return math.source case .footnoteRef, .lineBreak: return "" } }.joined() } /// Parse a run of inline org text into objects. public static func parseInline(_ text: String) -> [OrgObject] { var objects: [OrgObject] = [] var plain = "" let chars = Array(text) var i = 0 let lastEnd = lastEnvironmentEnd(text) // A closer that is missing once is missing for every later opener of its kind. var missing: Set = [] func noteMissing(_ closer: String) -> T? { missing.insert(closer) return nil } func flushPlain() { if !plain.isEmpty { objects.append(.text(plain)); plain = "" } } while i < chars.count { // Bracket links: [[target]] or [[target][description]] if chars[i] == "[", i + 1 < chars.count, chars[i + 1] == "[", !missing.contains("]]"), let link = scanLink(chars, from: i) ?? noteMissing("]]") { flushPlain() objects.append(link.object) i = link.next continue } // LaTeX fragments: $…$, $$…$$, \(…\), \[…\], \begin{…}…\end{…} if chars[i] == "$" || chars[i] == "\\", let math = scanMath(chars, from: i, lastEnd: lastEnd, missing: &missing) { if case .text(let text) = math.object { plain += text } else { flushPlain() objects.append(math.object) } i = math.next continue } // Footnote reference: [fn:label] or [fn:label:inline] if chars[i] == "[", let note = scanFootnote(chars, from: i) { flushPlain() objects.append(note.object) i = note.next continue } // Timestamps: <2024-01-15 Mon 10:30> or [2024-01-15 Mon] if chars[i] == "<" || chars[i] == "[", let stamp = scanTimestamp(chars, from: i) { flushPlain() objects.append(stamp.object) i = stamp.next continue } // Bare URL autolink. if chars[i] == "h", let url = scanBareURL(chars, from: i) { flushPlain() objects.append(url.object) i = url.next continue } // Bare email autolink, at a word boundary so `a.b@c.d` is not matched mid-token. if isWordCharacter(chars[i]), i == 0 || !isEmailBoundaryCharacter(chars[i - 1]), let email = scanEmail(chars, from: i) { flushPlain() objects.append(email.object) i = email.next continue } // Emphasis: *bold* /italic/ _underline_ +strike+ =verbatim= ~code~ if let marker = emphasisMarker(chars[i]), boundaryBefore(chars, i), let span = scanEmphasis(chars, from: i, marker: chars[i]) { flushPlain() switch marker { case .bold: objects.append(.bold(parseInline(span.body))) case .italic: objects.append(.italic(parseInline(span.body))) case .underline: objects.append(.underline(parseInline(span.body))) case .strike: objects.append(.strikeThrough(parseInline(span.body))) case .verbatim: objects.append(.verbatim(span.body)) case .code: objects.append(.code(span.body)) } i = span.next continue } // Superscript: x^2 or x^{group} if chars[i] == "^", i > 0, isWordCharacter(chars[i - 1]), let sup = scanSuperscript(chars, from: i) { flushPlain() objects.append(.superscript(parseInline(sup.body))) i = sup.next continue } plain.append(chars[i]) i += 1 } flushPlain() return objects } // MARK: - Scanners private enum Emphasis { case bold, italic, underline, strike, verbatim, code } private static func emphasisMarker(_ c: Character) -> Emphasis? { switch c { case "*": return .bold case "/": return .italic case "_": return .underline case "+": return .strike case "=": return .verbatim case "~": return .code default: return nil } } /// The character before an opening marker. org allows the start of the run, /// whitespace, or one of `-({'"` — the same set orgo uses, which is what the /// conformance corpus is generated from. private static func boundaryBefore(_ chars: [Character], _ i: Int) -> Bool { i == 0 || chars[i - 1].isWhitespace || preChars.contains(chars[i - 1]) } private static let preChars: Set = ["-", "(", "{", "'", "\""] /// The character after a closing marker, likewise from orgo. Note `[` is in the /// set and `]` is not, which looks backwards until you remember a link's /// description is parsed as its own run, so a `]` never follows a closer. private static let postChars: Set = ["-", ".", ",", ";", ":", "!", "?", ")", "}", "[", "\"", "'"] private static func isWordCharacter(_ c: Character) -> Bool { c.isLetter || c.isNumber } private static func scanEmphasis(_ chars: [Character], from start: Int, marker: Character) -> (body: String, next: Int)? { // org's border rule: the body may not begin with whitespace, so an opener followed by // a space is not an opener at all. guard start + 1 < chars.count, !chars[start + 1].isWhitespace else { return nil } var j = start + 1 var body = "" while j < chars.count { // The body may not end with whitespace either — which is what makes the closer of // `=ssh.mode = "system"=` the last `=` and not the middle one. if chars[j] == marker, let last = body.last, !last.isWhitespace { // The closer must end the run or be followed by space/punctuation. let after = j + 1 < chars.count ? chars[j + 1] : " " if after.isWhitespace || postChars.contains(after) { return (body, j + 1) } } if chars[j] == "\n" { return nil } body.append(chars[j]) j += 1 } return nil } private static func scanLink(_ chars: [Character], from start: Int) -> (object: OrgObject, next: Int)? { var j = start + 2 var target = "" while j < chars.count, !(chars[j] == "]" && j + 1 < chars.count && (chars[j + 1] == "]" || chars[j + 1] == "[")) { target.append(chars[j]); j += 1 } guard j < chars.count else { return nil } var description: String? if chars[j + 1] == "[" { j += 2 var text = "" var depth = 0 while j < chars.count { if chars[j] == "[" { depth += 1 } if chars[j] == "]" { if depth == 0 { break } depth -= 1 } text.append(chars[j]); j += 1 } description = text } // Consume the closing ]] while j < chars.count, chars[j] == "]" { j += 1 } let object = makeLinkObject(target: target, description: description) return (object, j) } private static func makeLinkObject(target rawTarget: String, description: String?) -> OrgObject { let target = rawTarget.hasPrefix("file:") ? String(rawTarget.dropFirst(5)) : rawTarget // A description that is itself an image makes the image the link's content — the // build-badge form. It arrives bracket-wrapped from `[[dest][[img]]]`, so unwrap any // balanced brackets before deciding. if let description { var inner = description while inner.hasPrefix("["), inner.hasSuffix("]"), inner.count > 2 { inner = String(inner.dropFirst().dropLast()) } let wasWrapped = inner != description let imageSource = inner.hasPrefix("file:") ? String(inner.dropFirst(5)) : inner if isImagePath(imageSource), wasWrapped || inner.hasPrefix("file:") || inner.hasPrefix("http://") || inner.hasPrefix("https://") { return .link(OrgLink(target: linkTarget(target), description: [.image(OrgFigure(source: imageSource))])) } return .link(OrgLink(target: linkTarget(target), description: parseInline(description))) } if isImagePath(target) { return .image(OrgFigure(source: target)) } return .link(OrgLink(target: linkTarget(target), description: nil)) } private static func linkTarget(_ target: String) -> OrgLinkTarget { if target.hasPrefix("id:") { return .id(String(target.dropFirst(3))) } if target.hasPrefix("http://") || target.hasPrefix("https://") || target.hasPrefix("mailto:") || target.hasPrefix("#") { return .external(target) } return .file(target) } private static func isImagePath(_ path: String) -> Bool { let lower = path.lowercased() return [".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".bmp", ".heic"] .contains { lower.hasSuffix($0) } } private static func scanFootnote(_ chars: [Character], from start: Int) -> (object: OrgObject, next: Int)? { guard chars[start...].starts(with: "[fn:") else { return nil } let rest = String(chars[start...]) guard let match = rest.firstMatch(of: /^\[fn:([A-Za-z0-9_-]+)(?::([^\]]*))?\]/) else { return nil } let label = String(match.1) // An inline footnote defines its note where it is used; parse that text as content. let inline = match.2.map { parseInline(String($0)) } let consumed = rest.distance(from: rest.startIndex, to: match.range.upperBound) return (.footnoteRef(label: label, inline: inline), start + consumed) } private static func scanTimestamp(_ chars: [Character], from start: Int) -> (object: OrgObject, next: Int)? { let active = chars[start] == "<" let closing: Character = active ? ">" : "]" var j = start + 1 var body = "" while j < chars.count, chars[j] != closing { // No timestamp is this long; stopping here keeps a run of brackets linear. if chars[j] == "\n" || body.count > 64 { return nil } body.append(chars[j]); j += 1 } guard j < chars.count else { return nil } guard let dateMatch = body.firstMatch(of: /(\d{4}-\d{2}-\d{2})/) else { return nil } let date = String(dateMatch.1) var time: String? var endTime: String? if let timeMatch = body.firstMatch(of: /(\d{2}:\d{2})(?:-(\d{2}:\d{2}))?/) { time = String(timeMatch.1) if let end = timeMatch.2 { endTime = String(end) } } var next = j + 1 // A multi-day range joins two stamps with `--`; org models that as one timestamp // carrying an end, so consume the second stamp here rather than leaving `--` as text. var endDate: String? let opening: Character = active ? "<" : "[" if next + 2 < chars.count, chars[next] == "-", chars[next + 1] == "-", chars[next + 2] == opening { var k = next + 3 var second = "" while k < chars.count, chars[k] != closing { second.append(chars[k]); k += 1 } if k < chars.count, let endMatch = second.firstMatch(of: /(\d{4}-\d{2}-\d{2})/) { endDate = String(endMatch.1) next = k + 1 } } return (.timestamp(OrgTimestamp(date: date, time: time, endTime: endTime, endDate: endDate, active: active)), next) } private static func scanBareURL(_ chars: [Character], from start: Int) -> (object: OrgObject, next: Int)? { // Matched on the word alone, after a cheap prefix test: copying the rest of the run // at every `h` made long text quadratic. guard chars[start...].starts(with: "http") else { return nil } let rest = String(chars[start..<(chars[start...].firstIndex(where: \.isWhitespace) ?? chars.count)]) guard let match = rest.firstMatch(of: /^https?:\/\/[^\s<>()\[\]]+/) else { return nil } var url = String(rest[match.range]) while let last = url.last, ".,;:!?".contains(last) { url.removeLast() } return (.link(OrgLink(target: .external(url), description: nil)), start + url.count) } /// Characters that keep an email from starting here, mirroring the shipped renderer's /// `(? Bool { c.isLetter || c.isNumber || c == "_" || c == "." || c == "%" || c == "+" || c == "-" } private static func scanEmail(_ chars: [Character], from start: Int) -> (object: OrgObject, next: Int)? { // An address needs its @ after a run of local-part characters; anything else is // refused before the rest of the run is copied. let local = chars[start...].prefix(while: { $0.isASCII && ($0.isLetter || $0.isNumber || "._%+-".contains($0)) }) guard !local.isEmpty, start + local.count < chars.count, chars[start + local.count] == "@" else { return nil } let rest = String(chars[start..<(chars[start...].firstIndex(where: \.isWhitespace) ?? chars.count)]) guard let match = rest.firstMatch(of: /^[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}/) else { return nil } let address = String(rest[match.range]) // A trailing character that cannot end an address belongs to the sentence, not the link. guard !address.hasSuffix("."), !address.hasSuffix("-") else { return nil } return (.link(OrgLink(target: .external("mailto:\(address)"), description: [.text(address)])), start + address.count) } /// A LaTeX fragment, found as go-org finds one: the first closing pair after the opener, /// anywhere later in the run. A `$…$` whose body starts or ends with a space is consumed /// but stays prose, org's rule for keeping "$5 and $10" a sentence. private static func scanMath( _ chars: [Character], from start: Int, lastEnd: (offset: Int, name: String)?, missing: inout Set ) -> (object: OrgObject, next: Int)? { guard start + 2 < chars.count else { return nil } let opener: String switch (chars[start], chars[start + 1]) { case ("$", "$"): opener = "$$" case ("$", _): opener = "$" case ("\\", "("): opener = #"\("# case ("\\", "["): opener = #"\["# default: return scanEnvironment(chars, from: start, lastEnd: lastEnd) } let closer = ["$$": "$$", "$": "$", #"\("#: #"\)"#, #"\["#: #"\]"#][opener]! let body = start + opener.count guard !missing.contains(closer) else { return nil } guard let close = firstIndex(of: Array(closer), in: chars, from: body) else { missing.insert(closer) return nil } let tex = String(chars[body.. = [" ", "\t", "\n", "\r"] if opener == "$", tex.first.map(space.contains) != false || tex.last.map(space.contains) != false { return (.text(source), next) } let display = opener != "$" && opener != #"\("# return (.math(OrgMath(tex: tex, display: display, source: source)), next) } /// `\begin{name}…\end{name}` inline. go-org matches the last `\end{…}` greedily and /// requires its name to be the opener's, then ends the fragment at the first `\end{name}`. private static func scanEnvironment( _ chars: [Character], from start: Int, lastEnd: (offset: Int, name: String)? ) -> (object: OrgObject, next: Int)? { guard let lastEnd, lastEnd.offset > start, Array(chars[start...].prefix(7)) == Array(#"\begin{"#) else { return nil } let name = chars[(start + 7)...].prefix(while: { $0.isASCII && ($0.isLetter || $0.isNumber) || $0 == "_" }) guard !name.isEmpty, start + 7 + name.count < chars.count, chars[start + 7 + name.count] == "}", String(name) == lastEnd.name else { return nil } let closer = Array(#"\end{"# + String(name) + "}") guard let close = firstIndex(of: closer, in: chars, from: start + 8 + name.count) else { return nil } let source = String(chars[start..<(close + closer.count)]) return (.math(OrgMath(tex: source, display: true, source: source)), close + closer.count) } /// Where the run's last `\end{name}` starts, in characters, and its name. private static func lastEnvironmentEnd(_ text: String) -> (offset: Int, name: String)? { guard text.contains(#"\end{"#), let last = text.matches(of: /\\end\{(\w+)\}/).last else { return nil } return (text.distance(from: text.startIndex, to: last.range.lowerBound), String(last.1)) } private static func firstIndex(of needle: [Character], in chars: [Character], from start: Int) -> Int? { guard !needle.isEmpty, chars.count >= needle.count, start <= chars.count - needle.count else { return nil } for i in start...(chars.count - needle.count) where chars[i] == needle[0] { if (1.. (body: String, next: Int)? { var j = start + 1 guard j < chars.count else { return nil } if chars[j] == "{" { j += 1 var body = "" while j < chars.count, chars[j] != "}" { body.append(chars[j]); j += 1 } guard j < chars.count else { return nil } return (body, j + 1) } var body = "" while j < chars.count, isWordCharacter(chars[j]) { body.append(chars[j]); j += 1 } return body.isEmpty ? nil : (body, j) } }