krz/org-swift

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

Commit 49b729cc96

49b729cc966ddb8270e0184b0e4dd02a4e1956e8

parent: 4e5394edba

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-16 02:03 UTC

Org parsing in linear time on hostile input

\begin{name} lines find their \end{name} through a per-document index
rather than scanning to the end each; a missing \), \], $$, $ or ]]
closer is remembered for the rest of the run; footnotes, bare URLs and
email addresses are tested on a cheap prefix before the run is copied;
a timestamp body stops at 64 characters. An \end{name} line ends a
paragraph, $…$ borders count ASCII space only, and environment names
are ASCII word characters, as in go-org and the server. orgText draws
an expression once when a textTransform splits its run.

Ref krz/gitbay-ios#40

Layout: unified · split

Sources/OrgSwift/AST/OrgInlineParser.swift +32 −10
@@ -37,6 +37,12 @@ extension OrgParser {
3737 let chars = Array(text)
3838 var i = 0
3939 let lastEnd = lastEnvironmentEnd(text)
40 // A closer that is missing once is missing for every later opener of its kind.
41 var missing: Set<String> = []
42 func noteMissing<T>(_ closer: String) -> T? {
43 missing.insert(closer)
44 return nil
45 }
4046
4147 func flushPlain() {
4248 if !plain.isEmpty { objects.append(.text(plain)); plain = "" }
@@ -45,7 +51,7 @@ extension OrgParser {
4551 while i < chars.count {
4652 // Bracket links: [[target]] or [[target][description]]
4753 if chars[i] == "[", i + 1 < chars.count, chars[i + 1] == "[",
48 let link = scanLink(chars, from: i) {
54 !missing.contains("]]"), let link = scanLink(chars, from: i) ?? noteMissing("]]") {
4955 flushPlain()
5056 objects.append(link.object)
5157 i = link.next
@@ -53,7 +59,7 @@ extension OrgParser {
5359 }
5460
5561 // LaTeX fragments: $…$, $$…$$, \(…\), \[…\], \begin{…}…\end{…}
56 if chars[i] == "$" || chars[i] == "\\", let math = scanMath(chars, from: i, lastEnd: lastEnd) {
62 if chars[i] == "$" || chars[i] == "\\", let math = scanMath(chars, from: i, lastEnd: lastEnd, missing: &missing) {
5763 if case .text(let text) = math.object {
5864 plain += text
5965 } else {
@@ -260,6 +266,7 @@ extension OrgParser {
260266 }
261267
262268 private static func scanFootnote(_ chars: [Character], from start: Int) -> (object: OrgObject, next: Int)? {
269 guard chars[start...].starts(with: "[fn:") else { return nil }
263270 let rest = String(chars[start...])
264271 guard let match = rest.firstMatch(of: /^\[fn:([A-Za-z0-9_-]+)(?::([^\]]*))?\]/) else { return nil }
265272 let label = String(match.1)
@@ -275,7 +282,8 @@ extension OrgParser {
275282 var j = start + 1
276283 var body = ""
277284 while j < chars.count, chars[j] != closing {
278 if chars[j] == "\n" { return nil }
285 // No timestamp is this long; stopping here keeps a run of brackets linear.
286 if chars[j] == "\n" || body.count > 64 { return nil }
279287 body.append(chars[j]); j += 1
280288 }
281289 guard j < chars.count else { return nil }
@@ -309,7 +317,10 @@ extension OrgParser {
309317 }
310318
311319 private static func scanBareURL(_ chars: [Character], from start: Int) -> (object: OrgObject, next: Int)? {
312 let rest = String(chars[start...])
320 // Matched on the word alone, after a cheap prefix test: copying the rest of the run
321 // at every `h` made long text quadratic.
322 guard chars[start...].starts(with: "http") else { return nil }
323 let rest = String(chars[start..<(chars[start...].firstIndex(where: \.isWhitespace) ?? chars.count)])
313324 guard let match = rest.firstMatch(of: /^https?:\/\/[^\s<>()\[\]]+/) else { return nil }
314325 var url = String(rest[match.range])
315326 while let last = url.last, ".,;:!?".contains(last) { url.removeLast() }
@@ -323,7 +334,11 @@ extension OrgParser {
323334 }
324335
325336 private static func scanEmail(_ chars: [Character], from start: Int) -> (object: OrgObject, next: Int)? {
326 let rest = String(chars[start...])
337 // An address needs its @ after a run of local-part characters; anything else is
338 // refused before the rest of the run is copied.
339 let local = chars[start...].prefix(while: { $0.isASCII && ($0.isLetter || $0.isNumber || "._%+-".contains($0)) })
340 guard !local.isEmpty, start + local.count < chars.count, chars[start + local.count] == "@" else { return nil }
341 let rest = String(chars[start..<(chars[start...].firstIndex(where: \.isWhitespace) ?? chars.count)])
327342 guard let match = rest.firstMatch(of: /^[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}/) else {
328343 return nil
329344 }
@@ -338,7 +353,8 @@ extension OrgParser {
338353 /// anywhere later in the run. A `$…$` whose body starts or ends with a space is consumed
339354 /// but stays prose, org's rule for keeping "$5 and $10" a sentence.
340355 private static func scanMath(
341 _ chars: [Character], from start: Int, lastEnd: (offset: Int, name: String)?
356 _ chars: [Character], from start: Int, lastEnd: (offset: Int, name: String)?,
357 missing: inout Set<String>
342358 ) -> (object: OrgObject, next: Int)? {
343359 guard start + 2 < chars.count else { return nil }
344360 let opener: String
@@ -351,11 +367,17 @@ extension OrgParser {
351367 }
352368 let closer = ["$$": "$$", "$": "$", #"\("#: #"\)"#, #"\["#: #"\]"#][opener]!
353369 let body = start + opener.count
354 guard let close = firstIndex(of: Array(closer), in: chars, from: body) else { return nil }
370 guard !missing.contains(closer) else { return nil }
371 guard let close = firstIndex(of: Array(closer), in: chars, from: body) else {
372 missing.insert(closer)
373 return nil
374 }
355375 let tex = String(chars[body..<close])
356376 let source = opener + tex + closer
357377 let next = close + closer.count
358 if opener == "$", tex.first?.isWhitespace != false || tex.last?.isWhitespace != false {
378 // The server's border test, which is ASCII space only: a no-break space is TeX.
379 let space: Set<Character> = [" ", "\t", "\n", "\r"]
380 if opener == "$", tex.first.map(space.contains) != false || tex.last.map(space.contains) != false {
359381 return (.text(source), next)
360382 }
361383 let display = opener != "$" && opener != #"\("#
@@ -369,7 +391,7 @@ extension OrgParser {
369391 ) -> (object: OrgObject, next: Int)? {
370392 guard let lastEnd, lastEnd.offset > start,
371393 Array(chars[start...].prefix(7)) == Array(#"\begin{"#) else { return nil }
372 let name = chars[(start + 7)...].prefix(while: { $0.isLetter || $0.isNumber || $0 == "_" })
394 let name = chars[(start + 7)...].prefix(while: { $0.isASCII && ($0.isLetter || $0.isNumber) || $0 == "_" })
373395 guard !name.isEmpty, start + 7 + name.count < chars.count, chars[start + 7 + name.count] == "}",
374396 String(name) == lastEnd.name else { return nil }
375397 let closer = Array(#"\end{"# + String(name) + "}")
@@ -387,7 +409,7 @@ extension OrgParser {
387409 private static func firstIndex(of needle: [Character], in chars: [Character], from start: Int) -> Int? {
388410 guard !needle.isEmpty, chars.count >= needle.count, start <= chars.count - needle.count else { return nil }
389411 for i in start...(chars.count - needle.count) where chars[i] == needle[0] {
390 if Array(chars[i..<(i + needle.count)]) == needle { return i }
412 if (1..<needle.count).allSatisfy({ chars[i + $0] == needle[$0] }) { return i }
391413 }
392414 return nil
393415 }
Sources/OrgSwift/AST/OrgParser.swift +42 −18
@@ -14,6 +14,7 @@ public enum OrgParser {
1414 .replacingOccurrences(of: "\r", with: "\n")
1515 .components(separatedBy: "\n")
1616 var index = 0
17 let latexEnds = latexEndLines(lines)
1718 var pendingCaption: String?
1819 var pendingName: String?
1920 var pendingAttrs: [(key: String, value: String)] = []
@@ -134,7 +135,7 @@ public enum OrgParser {
134135 }
135136
136137 // A LaTeX block: \begin{name} alone on its line, through the matching \end{name}.
137 if let (raw, next) = parseLatexBlock(lines, from: index) {
138 if let (raw, next) = parseLatexBlock(lines, from: index, ends: latexEnds) {
138139 append(.latexBlock(raw))
139140 index = next
140141 flushPending()
@@ -149,7 +150,9 @@ public enum OrgParser {
149150 // The first line is always taken: nothing above claimed it, and
150151 // leaving it would parse the same line forever.
151152 if !paragraph.isEmpty,
152 candidateTrimmed.isEmpty || startsNewConstruct(candidateTrimmed, raw: candidate) { break }
153 candidateTrimmed.isEmpty || startsNewConstruct(candidateTrimmed, raw: candidate)
154 || latexEnd(candidateTrimmed) != nil
155 || latexBlockEnd(lines, from: index, ends: latexEnds) != nil { break }
153156 paragraph.append(candidateTrimmed)
154157 index += 1
155158 }
@@ -171,7 +174,6 @@ public enum OrgParser {
171174 if isTableLine(trimmed) { return true }
172175 if orgFootnoteDefinition(in: trimmed) != nil { return true }
173176 if isListMarkerLine(trimmed), !isIndentedContinuationLine(raw) { return true }
174 if latexBegin(trimmed) != nil { return true }
175177 return false
176178 }
177179
@@ -179,24 +181,46 @@ public enum OrgParser {
179181
180182 /// The environment a `\begin{name}` line opens, as go-org's lexer reads it (any case).
181183 private static func latexBegin(_ trimmed: String) -> String? {
182 trimmed.firstMatch(of: /^\\begin\{([^}]+)\}$/.ignoresCase()).map { String($0.1).lowercased() }
184 guard trimmed.hasPrefix("\\") else { return nil }
185 return trimmed.firstMatch(of: /^\\begin\{([^}]+)\}$/.ignoresCase()).map { String($0.1).lowercased() }
183186 }
184187
185 private static func parseLatexBlock(_ lines: [String], from start: Int) -> (String, Int)? {
186 let opener = lines[start]
187 guard let name = latexBegin(opener.trimmingCharacters(in: .whitespaces)) else { return nil }
188 let indent = leadingWidth(opener)
189 var raw = [dropLeading(opener, indent)]
190 for index in (start + 1)..<lines.count {
191 let line = lines[index]
192 raw.append(dropLeading(line, min(indent, leadingWidth(line))))
193 let trimmed = line.trimmingCharacters(in: .whitespaces)
194 if let end = trimmed.firstMatch(of: /^\\end\{([^}]+)\}$/.ignoresCase()),
195 String(end.1).lowercased() == name {
196 return (raw.joined(separator: "\n"), index + 1)
197 }
188 /// The environment a `\end{name}` line closes. Like go-org's, it ends a paragraph
189 /// whether or not a block is open.
190 private static func latexEnd(_ trimmed: String) -> String? {
191 guard trimmed.hasPrefix("\\") else { return nil }
192 return trimmed.firstMatch(of: /^\\end\{([^}]+)\}$/.ignoresCase()).map { String($0.1).lowercased() }
193 }
194
195 /// Each environment's `\end{name}` lines, in order, found in one pass so an opener
196 /// looks its closer up rather than scanning to the end of the document.
197 private static func latexEndLines(_ lines: [String]) -> [String: [Int]] {
198 var ends: [String: [Int]] = [:]
199 for (index, line) in lines.enumerated() {
200 if let name = latexEnd(line.trimmingCharacters(in: .whitespaces)) { ends[name, default: []].append(index) }
198201 }
199 return nil
202 return ends
203 }
204
205 /// The line closing the block a `\begin{name}` line at `start` opens, if one follows.
206 private static func latexBlockEnd(_ lines: [String], from start: Int, ends: [String: [Int]]) -> Int? {
207 guard let name = latexBegin(lines[start].trimmingCharacters(in: .whitespaces)),
208 let candidates = ends[name] else { return nil }
209 var low = 0, high = candidates.count
210 while low < high {
211 let mid = (low + high) / 2
212 if candidates[mid] <= start { low = mid + 1 } else { high = mid }
213 }
214 return low < candidates.count ? candidates[low] : nil
215 }
216
217 private static func parseLatexBlock(
218 _ lines: [String], from start: Int, ends: [String: [Int]]
219 ) -> (String, Int)? {
220 guard let end = latexBlockEnd(lines, from: start, ends: ends) else { return nil }
221 let indent = leadingWidth(lines[start])
222 let raw = lines[start...end].map { dropLeading($0, min(indent, leadingWidth($0))) }
223 return (raw.joined(separator: "\n"), end + 1)
200224 }
201225
202226 // MARK: - Heading
Sources/OrgSwiftUI/OrgMathText.swift +7 −5
@@ -16,17 +16,19 @@ extension EnvironmentValues {
1616 }
1717}
1818
19/// A styled run as `Text`, each math run drawn by `math` where it can be. Two identical
20/// expressions side by side share one run, so a run holding its source k times draws k.
19/// A styled run as `Text`, each expression drawn by `math` where it can be. Runs are taken
20/// by their math attribute alone, so an expression a `textTransform` split is still one;
21/// two identical expressions side by side share a range, so one holding its source k times
22/// draws k.
2123@MainActor
2224func orgText(_ string: AttributedString, math: OrgMathRenderer?) -> Text {
2325 guard let math, string.runs.contains(where: { $0[OrgMathAttribute.self] != nil }) else {
2426 return Text(string)
2527 }
2628 var result = Text(verbatim: "")
27 for run in string.runs {
28 let piece = AttributedString(string[run.range])
29 guard let expression = run[OrgMathAttribute.self], let drawn = math(expression) else {
29 for (expression, range) in string.runs[OrgMathAttribute.self] {
30 let piece = AttributedString(string[range])
31 guard let expression, let drawn = math(expression) else {
3032 result = Text("\(result)\(Text(piece))")
3133 continue
3234 }
Tests/OrgSwiftTests/MathTests.swift +28
@@ -66,6 +66,34 @@ struct MathParseTests {
6666 }
6767 }
6868
69 @Test(.timeLimit(.minutes(1)))
70 func unmatchedBeginLinesParseInLinearTime() {
71 let doc = OrgParser.parse(String(repeating: "\\begin{a}\n", count: 4000))
72 #expect(doc.elements.count == 1)
73 }
74
75 @Test(.timeLimit(.minutes(1)))
76 func unclosedOpenersScanInLinearTime() {
77 for opener in [#"\("#, #"\["#, "$$", "$x ", "[[", "[", "<"] {
78 let text = String(repeating: opener, count: 40_000)
79 let start = Date()
80 #expect(OrgParser.plain(objects(text)) == text.trimmingCharacters(in: .whitespaces), "\(opener)")
81 #expect(Date().timeIntervalSince(start) < 5, "\(opener) took \(Date().timeIntervalSince(start)) s")
82 }
83 }
84
85 @Test func onlyASCIISpaceKeepsDollarsProse() {
86 #expect(objects("$\u{00A0}x$") == [math("\u{00A0}x", false, "$\u{00A0}x$")])
87 }
88
89 @Test func environmentNamesAreASCIIWordCharacters() {
90 #expect(objects(#"\begin{aligné} x \end{aligné}"#) == [.text(#"\begin{aligné} x \end{aligné}"#)])
91 }
92
93 @Test func aStrayEndLineEndsAParagraph() {
94 #expect(OrgParser.parse("text\n\\end{equation}\nmore").elements.count == 2)
95 }
96
6997 @Test func plainTextAndHTMLShowTheSource() {
7098 let doc = OrgParser.parse("a $x$ b\n\n\\begin{align}\nx &< y\n\\end{align}")
7199 #expect(OrgParser.plain(objects("a $x$ b")) == "a $x$ b")
Tests/OrgSwiftUITests/OrgMathLayoutTests.swift +16
@@ -39,6 +39,22 @@ struct OrgMathLayoutTests {
3939 #expect(marked == [OrgMath(tex: "x", display: false, source: "$x$")])
4040 }
4141
42 @Test func anExpressionSplitByATransformDrawsOnce() {
43 let builder = OrgLayoutBuilder(options: .init(), styler: PlainOrgCodeStyler(), keywords: .init(),
44 textTransform: { text in
45 if let range = text.range(of: "#1") { text[range].link = URL(string: "https://x.test/1") }
46 })
47 guard case .paragraph(let text) = builder.layout(OrgParser.parse("a $x#1y$ b")).blocks.first else {
48 Issue.record("expected a paragraph"); return
49 }
50 var drawn = 0
51 _ = orgText(text) { _ in
52 drawn += 1
53 return Text(verbatim: "·")
54 }
55 #expect(drawn == 1)
56 }
57
4258 @Test func orgTextDrawsEachExpression() {
4359 guard case .paragraph(let text) = layout("$x$$x$ and $y$").blocks.first else {
4460 Issue.record("expected a paragraph"); return