Sources/OrgSwift/AST/OrgInlineParser.swift
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}