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.3.0
org-swift/Sources/OrgSwift/AST/OrgInlineParser.swift history · blame · raw

327 lines · 14081 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    /// org requires the opening marker to follow whitespace or start the run.
135    private static func boundaryBefore(_ chars: [Character], _ i: Int) -> Bool {
136        i == 0 || chars[i - 1].isWhitespace || "([{'\"".contains(chars[i - 1])
137    }
138
139    private static func isWordCharacter(_ c: Character) -> Bool {
140        c.isLetter || c.isNumber
141    }
142
143    private static func scanEmphasis(_ chars: [Character], from start: Int, marker: Character)
144        -> (body: String, next: Int)? {
145        // org's border rule: the body may not begin with whitespace, so an opener followed by
146        // a space is not an opener at all.
147        guard start + 1 < chars.count, !chars[start + 1].isWhitespace else { return nil }
148
149        var j = start + 1
150        var body = ""
151        while j < chars.count {
152            // The body may not end with whitespace either — which is what makes the closer of
153            // `=ssh.mode = "system"=` the last `=` and not the middle one.
154            if chars[j] == marker, let last = body.last, !last.isWhitespace {
155                // The closer must end the run or be followed by space/punctuation.
156                let after = j + 1 < chars.count ? chars[j + 1] : " "
157                if after.isWhitespace || ".,;:!?)]}'\"".contains(after) || j + 1 == chars.count {
158                    return (body, j + 1)
159                }
160            }
161            if chars[j] == "\n" { return nil }
162            body.append(chars[j])
163            j += 1
164        }
165        return nil
166    }
167
168    private static func scanLink(_ chars: [Character], from start: Int) -> (object: OrgObject, next: Int)? {
169        var j = start + 2
170        var target = ""
171        while j < chars.count, !(chars[j] == "]" && j + 1 < chars.count && (chars[j + 1] == "]" || chars[j + 1] == "[")) {
172            target.append(chars[j]); j += 1
173        }
174        guard j < chars.count else { return nil }
175
176        var description: String?
177        if chars[j + 1] == "[" {
178            j += 2
179            var text = ""
180            var depth = 0
181            while j < chars.count {
182                if chars[j] == "[" { depth += 1 }
183                if chars[j] == "]" {
184                    if depth == 0 { break }
185                    depth -= 1
186                }
187                text.append(chars[j]); j += 1
188            }
189            description = text
190        }
191        // Consume the closing ]]
192        while j < chars.count, chars[j] == "]" { j += 1 }
193
194        let object = makeLinkObject(target: target, description: description)
195        return (object, j)
196    }
197
198    private static func makeLinkObject(target rawTarget: String, description: String?) -> OrgObject {
199        let target = rawTarget.hasPrefix("file:") ? String(rawTarget.dropFirst(5)) : rawTarget
200
201        // A description that is itself an image makes the image the link's content — the
202        // build-badge form. It arrives bracket-wrapped from `[[dest][[img]]]`, so unwrap any
203        // balanced brackets before deciding.
204        if let description {
205            var inner = description
206            while inner.hasPrefix("["), inner.hasSuffix("]"), inner.count > 2 {
207                inner = String(inner.dropFirst().dropLast())
208            }
209            let wasWrapped = inner != description
210            let imageSource = inner.hasPrefix("file:") ? String(inner.dropFirst(5)) : inner
211            if isImagePath(imageSource),
212               wasWrapped || inner.hasPrefix("file:") || inner.hasPrefix("http://") || inner.hasPrefix("https://") {
213                return .link(OrgLink(target: linkTarget(target),
214                                     description: [.image(OrgFigure(source: imageSource))]))
215            }
216            return .link(OrgLink(target: linkTarget(target), description: parseInline(description)))
217        }
218
219        if isImagePath(target) { return .image(OrgFigure(source: target)) }
220        return .link(OrgLink(target: linkTarget(target), description: nil))
221    }
222
223    private static func linkTarget(_ target: String) -> OrgLinkTarget {
224        if target.hasPrefix("id:") { return .id(String(target.dropFirst(3))) }
225        if target.hasPrefix("http://") || target.hasPrefix("https://")
226            || target.hasPrefix("mailto:") || target.hasPrefix("#") {
227            return .external(target)
228        }
229        return .file(target)
230    }
231
232    private static func isImagePath(_ path: String) -> Bool {
233        let lower = path.lowercased()
234        return [".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".bmp", ".heic"]
235            .contains { lower.hasSuffix($0) }
236    }
237
238    private static func scanFootnote(_ chars: [Character], from start: Int) -> (object: OrgObject, next: Int)? {
239        let rest = String(chars[start...])
240        guard let match = rest.firstMatch(of: /^\[fn:([A-Za-z0-9_-]+)(?::([^\]]*))?\]/) else { return nil }
241        let label = String(match.1)
242        // An inline footnote defines its note where it is used; parse that text as content.
243        let inline = match.2.map { parseInline(String($0)) }
244        let consumed = rest.distance(from: rest.startIndex, to: match.range.upperBound)
245        return (.footnoteRef(label: label, inline: inline), start + consumed)
246    }
247
248    private static func scanTimestamp(_ chars: [Character], from start: Int) -> (object: OrgObject, next: Int)? {
249        let active = chars[start] == "<"
250        let closing: Character = active ? ">" : "]"
251        var j = start + 1
252        var body = ""
253        while j < chars.count, chars[j] != closing {
254            if chars[j] == "\n" { return nil }
255            body.append(chars[j]); j += 1
256        }
257        guard j < chars.count else { return nil }
258        guard let dateMatch = body.firstMatch(of: /(\d{4}-\d{2}-\d{2})/) else { return nil }
259
260        let date = String(dateMatch.1)
261        var time: String?
262        var endTime: String?
263        if let timeMatch = body.firstMatch(of: /(\d{2}:\d{2})(?:-(\d{2}:\d{2}))?/) {
264            time = String(timeMatch.1)
265            if let end = timeMatch.2 { endTime = String(end) }
266        }
267        var next = j + 1
268
269        // A multi-day range joins two stamps with `--`; org models that as one timestamp
270        // carrying an end, so consume the second stamp here rather than leaving `--` as text.
271        var endDate: String?
272        let opening: Character = active ? "<" : "["
273        if next + 2 < chars.count, chars[next] == "-", chars[next + 1] == "-", chars[next + 2] == opening {
274            var k = next + 3
275            var second = ""
276            while k < chars.count, chars[k] != closing { second.append(chars[k]); k += 1 }
277            if k < chars.count, let endMatch = second.firstMatch(of: /(\d{4}-\d{2}-\d{2})/) {
278                endDate = String(endMatch.1)
279                next = k + 1
280            }
281        }
282
283        return (.timestamp(OrgTimestamp(date: date, time: time, endTime: endTime,
284                                        endDate: endDate, active: active)), next)
285    }
286
287    private static func scanBareURL(_ chars: [Character], from start: Int) -> (object: OrgObject, next: Int)? {
288        let rest = String(chars[start...])
289        guard let match = rest.firstMatch(of: /^https?:\/\/[^\s<>()\[\]]+/) else { return nil }
290        var url = String(rest[match.range])
291        while let last = url.last, ".,;:!?".contains(last) { url.removeLast() }
292        return (.link(OrgLink(target: .external(url), description: nil)), start + url.count)
293    }
294
295    /// Characters that keep an email from starting here, mirroring the shipped renderer's
296    /// `(?<![\w.%+\-])` guard.
297    private static func isEmailBoundaryCharacter(_ c: Character) -> Bool {
298        c.isLetter || c.isNumber || c == "_" || c == "." || c == "%" || c == "+" || c == "-"
299    }
300
301    private static func scanEmail(_ chars: [Character], from start: Int) -> (object: OrgObject, next: Int)? {
302        let rest = String(chars[start...])
303        guard let match = rest.firstMatch(of: /^[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}/) else {
304            return nil
305        }
306        let address = String(rest[match.range])
307        // A trailing character that cannot end an address belongs to the sentence, not the link.
308        guard !address.hasSuffix("."), !address.hasSuffix("-") else { return nil }
309        return (.link(OrgLink(target: .external("mailto:\(address)"),
310                              description: [.text(address)])), start + address.count)
311    }
312
313    private static func scanSuperscript(_ chars: [Character], from start: Int) -> (body: String, next: Int)? {
314        var j = start + 1
315        guard j < chars.count else { return nil }
316        if chars[j] == "{" {
317            j += 1
318            var body = ""
319            while j < chars.count, chars[j] != "}" { body.append(chars[j]); j += 1 }
320            guard j < chars.count else { return nil }
321            return (body, j + 1)
322        }
323        var body = ""
324        while j < chars.count, isWordCharacter(chars[j]) { body.append(chars[j]); j += 1 }
325        return body.isEmpty ? nil : (body, j)
326    }
327}