krz/org-swift

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

Sources/OrgSwift/AST/OrgInlineParser.swift

v0.6.0
org-swift/Sources/OrgSwift/AST/OrgInlineParser.swift history · blame · raw

337 lines · 14626 bytes

  1import Foundation
  2
  3// Inline parsing into `[OrgObject]`.
  4//
  5// The shipped renderer does inline work by regex-substituting HTML into an escaped string,
  6// using placeholder tokens to protect what must not be re-scanned. That works for one output
  7// format but bakes HTML into the parse. Here the same constructs become a tree, so `*bold
  8// /italic/*` nests properly and every renderer decides its own representation.
  9
 10extension OrgParser {
 11
 12    /// Inline objects reduced to their plain text — for alt text, previews, or assertions.
 13    public static func plain(_ objects: [OrgObject]) -> String {
 14        objects.map { object in
 15            switch object {
 16            case .text(let text): return text
 17            case .verbatim(let text), .code(let text): return text
 18            case .bold(let c), .italic(let c), .underline(let c), .strikeThrough(let c), .superscript(let c):
 19                return plain(c)
 20            case .link(let link):
 21                if let description = link.description { return plain(description) }
 22                switch link.target {
 23                case .external(let value), .file(let value), .id(let value): return value
 24                }
 25            case .image(let figure): return figure.alt ?? ""
 26            case .timestamp(let stamp): return stamp.displayValue
 27            case .footnoteRef, .lineBreak: return ""
 28            }
 29        }.joined()
 30    }
 31
 32    /// Parse a run of inline org text into objects.
 33    public static func parseInline(_ text: String) -> [OrgObject] {
 34        var objects: [OrgObject] = []
 35        var plain = ""
 36        let chars = Array(text)
 37        var i = 0
 38
 39        func flushPlain() {
 40            if !plain.isEmpty { objects.append(.text(plain)); plain = "" }
 41        }
 42
 43        while i < chars.count {
 44            // Bracket links: [[target]] or [[target][description]]
 45            if chars[i] == "[", i + 1 < chars.count, chars[i + 1] == "[",
 46               let link = scanLink(chars, from: i) {
 47                flushPlain()
 48                objects.append(link.object)
 49                i = link.next
 50                continue
 51            }
 52
 53            // Footnote reference: [fn:label] or [fn:label:inline]
 54            if chars[i] == "[", let note = scanFootnote(chars, from: i) {
 55                flushPlain()
 56                objects.append(note.object)
 57                i = note.next
 58                continue
 59            }
 60
 61            // Timestamps: <2024-01-15 Mon 10:30> or [2024-01-15 Mon]
 62            if chars[i] == "<" || chars[i] == "[", let stamp = scanTimestamp(chars, from: i) {
 63                flushPlain()
 64                objects.append(stamp.object)
 65                i = stamp.next
 66                continue
 67            }
 68
 69            // Bare URL autolink.
 70            if chars[i] == "h", let url = scanBareURL(chars, from: i) {
 71                flushPlain()
 72                objects.append(url.object)
 73                i = url.next
 74                continue
 75            }
 76
 77            // Bare email autolink, at a word boundary so `a.b@c.d` is not matched mid-token.
 78            if isWordCharacter(chars[i]), i == 0 || !isEmailBoundaryCharacter(chars[i - 1]),
 79               let email = scanEmail(chars, from: i) {
 80                flushPlain()
 81                objects.append(email.object)
 82                i = email.next
 83                continue
 84            }
 85
 86            // Emphasis: *bold* /italic/ _underline_ +strike+ =verbatim= ~code~
 87            if let marker = emphasisMarker(chars[i]), boundaryBefore(chars, i),
 88               let span = scanEmphasis(chars, from: i, marker: chars[i]) {
 89                flushPlain()
 90                switch marker {
 91                case .bold: objects.append(.bold(parseInline(span.body)))
 92                case .italic: objects.append(.italic(parseInline(span.body)))
 93                case .underline: objects.append(.underline(parseInline(span.body)))
 94                case .strike: objects.append(.strikeThrough(parseInline(span.body)))
 95                case .verbatim: objects.append(.verbatim(span.body))
 96                case .code: objects.append(.code(span.body))
 97                }
 98                i = span.next
 99                continue
100            }
101
102            // Superscript: x^2 or x^{group}
103            if chars[i] == "^", i > 0, isWordCharacter(chars[i - 1]),
104               let sup = scanSuperscript(chars, from: i) {
105                flushPlain()
106                objects.append(.superscript(parseInline(sup.body)))
107                i = sup.next
108                continue
109            }
110
111            plain.append(chars[i])
112            i += 1
113        }
114        flushPlain()
115        return objects
116    }
117
118    // MARK: - Scanners
119
120    private enum Emphasis { case bold, italic, underline, strike, verbatim, code }
121
122    private static func emphasisMarker(_ c: Character) -> Emphasis? {
123        switch c {
124        case "*": return .bold
125        case "/": return .italic
126        case "_": return .underline
127        case "+": return .strike
128        case "=": return .verbatim
129        case "~": return .code
130        default: return nil
131        }
132    }
133
134    /// The character before an opening marker. org allows the start of the run,
135    /// whitespace, or one of `-({'"` — the same set orgo uses, which is what the
136    /// conformance corpus is generated from.
137    private static func boundaryBefore(_ chars: [Character], _ i: Int) -> Bool {
138        i == 0 || chars[i - 1].isWhitespace || preChars.contains(chars[i - 1])
139    }
140
141    private static let preChars: Set<Character> = ["-", "(", "{", "'", "\""]
142
143    /// The character after a closing marker, likewise from orgo. Note `[` is in the
144    /// set and `]` is not, which looks backwards until you remember a link's
145    /// description is parsed as its own run, so a `]` never follows a closer.
146    private static let postChars: Set<Character> =
147        ["-", ".", ",", ";", ":", "!", "?", ")", "}", "[", "\"", "'"]
148
149    private static func isWordCharacter(_ c: Character) -> Bool {
150        c.isLetter || c.isNumber
151    }
152
153    private static func scanEmphasis(_ chars: [Character], from start: Int, marker: Character)
154        -> (body: String, next: Int)? {
155        // org's border rule: the body may not begin with whitespace, so an opener followed by
156        // a space is not an opener at all.
157        guard start + 1 < chars.count, !chars[start + 1].isWhitespace else { return nil }
158
159        var j = start + 1
160        var body = ""
161        while j < chars.count {
162            // The body may not end with whitespace either — which is what makes the closer of
163            // `=ssh.mode = "system"=` the last `=` and not the middle one.
164            if chars[j] == marker, let last = body.last, !last.isWhitespace {
165                // The closer must end the run or be followed by space/punctuation.
166                let after = j + 1 < chars.count ? chars[j + 1] : " "
167                if after.isWhitespace || postChars.contains(after) {
168                    return (body, j + 1)
169                }
170            }
171            if chars[j] == "\n" { return nil }
172            body.append(chars[j])
173            j += 1
174        }
175        return nil
176    }
177
178    private static func scanLink(_ chars: [Character], from start: Int) -> (object: OrgObject, next: Int)? {
179        var j = start + 2
180        var target = ""
181        while j < chars.count, !(chars[j] == "]" && j + 1 < chars.count && (chars[j + 1] == "]" || chars[j + 1] == "[")) {
182            target.append(chars[j]); j += 1
183        }
184        guard j < chars.count else { return nil }
185
186        var description: String?
187        if chars[j + 1] == "[" {
188            j += 2
189            var text = ""
190            var depth = 0
191            while j < chars.count {
192                if chars[j] == "[" { depth += 1 }
193                if chars[j] == "]" {
194                    if depth == 0 { break }
195                    depth -= 1
196                }
197                text.append(chars[j]); j += 1
198            }
199            description = text
200        }
201        // Consume the closing ]]
202        while j < chars.count, chars[j] == "]" { j += 1 }
203
204        let object = makeLinkObject(target: target, description: description)
205        return (object, j)
206    }
207
208    private static func makeLinkObject(target rawTarget: String, description: String?) -> OrgObject {
209        let target = rawTarget.hasPrefix("file:") ? String(rawTarget.dropFirst(5)) : rawTarget
210
211        // A description that is itself an image makes the image the link's content — the
212        // build-badge form. It arrives bracket-wrapped from `[[dest][[img]]]`, so unwrap any
213        // balanced brackets before deciding.
214        if let description {
215            var inner = description
216            while inner.hasPrefix("["), inner.hasSuffix("]"), inner.count > 2 {
217                inner = String(inner.dropFirst().dropLast())
218            }
219            let wasWrapped = inner != description
220            let imageSource = inner.hasPrefix("file:") ? String(inner.dropFirst(5)) : inner
221            if isImagePath(imageSource),
222               wasWrapped || inner.hasPrefix("file:") || inner.hasPrefix("http://") || inner.hasPrefix("https://") {
223                return .link(OrgLink(target: linkTarget(target),
224                                     description: [.image(OrgFigure(source: imageSource))]))
225            }
226            return .link(OrgLink(target: linkTarget(target), description: parseInline(description)))
227        }
228
229        if isImagePath(target) { return .image(OrgFigure(source: target)) }
230        return .link(OrgLink(target: linkTarget(target), description: nil))
231    }
232
233    private static func linkTarget(_ target: String) -> OrgLinkTarget {
234        if target.hasPrefix("id:") { return .id(String(target.dropFirst(3))) }
235        if target.hasPrefix("http://") || target.hasPrefix("https://")
236            || target.hasPrefix("mailto:") || target.hasPrefix("#") {
237            return .external(target)
238        }
239        return .file(target)
240    }
241
242    private static func isImagePath(_ path: String) -> Bool {
243        let lower = path.lowercased()
244        return [".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".bmp", ".heic"]
245            .contains { lower.hasSuffix($0) }
246    }
247
248    private static func scanFootnote(_ chars: [Character], from start: Int) -> (object: OrgObject, next: Int)? {
249        let rest = String(chars[start...])
250        guard let match = rest.firstMatch(of: /^\[fn:([A-Za-z0-9_-]+)(?::([^\]]*))?\]/) else { return nil }
251        let label = String(match.1)
252        // An inline footnote defines its note where it is used; parse that text as content.
253        let inline = match.2.map { parseInline(String($0)) }
254        let consumed = rest.distance(from: rest.startIndex, to: match.range.upperBound)
255        return (.footnoteRef(label: label, inline: inline), start + consumed)
256    }
257
258    private static func scanTimestamp(_ chars: [Character], from start: Int) -> (object: OrgObject, next: Int)? {
259        let active = chars[start] == "<"
260        let closing: Character = active ? ">" : "]"
261        var j = start + 1
262        var body = ""
263        while j < chars.count, chars[j] != closing {
264            if chars[j] == "\n" { return nil }
265            body.append(chars[j]); j += 1
266        }
267        guard j < chars.count else { return nil }
268        guard let dateMatch = body.firstMatch(of: /(\d{4}-\d{2}-\d{2})/) else { return nil }
269
270        let date = String(dateMatch.1)
271        var time: String?
272        var endTime: String?
273        if let timeMatch = body.firstMatch(of: /(\d{2}:\d{2})(?:-(\d{2}:\d{2}))?/) {
274            time = String(timeMatch.1)
275            if let end = timeMatch.2 { endTime = String(end) }
276        }
277        var next = j + 1
278
279        // A multi-day range joins two stamps with `--`; org models that as one timestamp
280        // carrying an end, so consume the second stamp here rather than leaving `--` as text.
281        var endDate: String?
282        let opening: Character = active ? "<" : "["
283        if next + 2 < chars.count, chars[next] == "-", chars[next + 1] == "-", chars[next + 2] == opening {
284            var k = next + 3
285            var second = ""
286            while k < chars.count, chars[k] != closing { second.append(chars[k]); k += 1 }
287            if k < chars.count, let endMatch = second.firstMatch(of: /(\d{4}-\d{2}-\d{2})/) {
288                endDate = String(endMatch.1)
289                next = k + 1
290            }
291        }
292
293        return (.timestamp(OrgTimestamp(date: date, time: time, endTime: endTime,
294                                        endDate: endDate, active: active)), next)
295    }
296
297    private static func scanBareURL(_ chars: [Character], from start: Int) -> (object: OrgObject, next: Int)? {
298        let rest = String(chars[start...])
299        guard let match = rest.firstMatch(of: /^https?:\/\/[^\s<>()\[\]]+/) else { return nil }
300        var url = String(rest[match.range])
301        while let last = url.last, ".,;:!?".contains(last) { url.removeLast() }
302        return (.link(OrgLink(target: .external(url), description: nil)), start + url.count)
303    }
304
305    /// Characters that keep an email from starting here, mirroring the shipped renderer's
306    /// `(?<![\w.%+\-])` guard.
307    private static func isEmailBoundaryCharacter(_ c: Character) -> Bool {
308        c.isLetter || c.isNumber || c == "_" || c == "." || c == "%" || c == "+" || c == "-"
309    }
310
311    private static func scanEmail(_ chars: [Character], from start: Int) -> (object: OrgObject, next: Int)? {
312        let rest = String(chars[start...])
313        guard let match = rest.firstMatch(of: /^[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}/) else {
314            return nil
315        }
316        let address = String(rest[match.range])
317        // A trailing character that cannot end an address belongs to the sentence, not the link.
318        guard !address.hasSuffix("."), !address.hasSuffix("-") else { return nil }
319        return (.link(OrgLink(target: .external("mailto:\(address)"),
320                              description: [.text(address)])), start + address.count)
321    }
322
323    private static func scanSuperscript(_ chars: [Character], from start: Int) -> (body: String, next: Int)? {
324        var j = start + 1
325        guard j < chars.count else { return nil }
326        if chars[j] == "{" {
327            j += 1
328            var body = ""
329            while j < chars.count, chars[j] != "}" { body.append(chars[j]); j += 1 }
330            guard j < chars.count else { return nil }
331            return (body, j + 1)
332        }
333        var body = ""
334        while j < chars.count, isWordCharacter(chars[j]) { body.append(chars[j]); j += 1 }
335        return body.isEmpty ? nil : (body, j)
336    }
337}