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