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 .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 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] == "[", let link = scanLink(chars, from: i) { flushPlain() objects.append(link.object) i = link.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 } } /// org requires the opening marker to follow whitespace or start the run. private static func boundaryBefore(_ chars: [Character], _ i: Int) -> Bool { i == 0 || chars[i - 1].isWhitespace || "([{'\"".contains(chars[i - 1]) } 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)? { var j = start + 1 var body = "" while j < chars.count { if chars[j] == marker { // The closer must end the run or be followed by space/punctuation. let after = j + 1 < chars.count ? chars[j + 1] : " " if !body.isEmpty, after.isWhitespace || ".,;:!?)]}'\"".contains(after) || j + 1 == chars.count { 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)? { 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 { if chars[j] == "\n" { 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)? { let rest = String(chars[start...]) 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)? { let rest = String(chars[start...]) 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) } private static func scanSuperscript(_ chars: [Character], from start: Int) -> (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) } }