Commit 49b729cc96
Verified · cmc
Layout: unified · split
Sources/OrgSwift/AST/OrgInlineParser.swift +32 −10
| @@ -37,6 +37,12 @@ extension OrgParser { | ||
| 37 | 37 | let chars = Array(text) |
| 38 | 38 | var i = 0 |
| 39 | 39 | 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 | } | |
| 40 | 46 | |
| 41 | 47 | func flushPlain() { |
| 42 | 48 | if !plain.isEmpty { objects.append(.text(plain)); plain = "" } |
| @@ -45,7 +51,7 @@ extension OrgParser { | ||
| 45 | 51 | while i < chars.count { |
| 46 | 52 | // Bracket links: [[target]] or [[target][description]] |
| 47 | 53 | 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("]]") { | |
| 49 | 55 | flushPlain() |
| 50 | 56 | objects.append(link.object) |
| 51 | 57 | i = link.next |
| @@ -53,7 +59,7 @@ extension OrgParser { | ||
| 53 | 59 | } |
| 54 | 60 | |
| 55 | 61 | // 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) { | |
| 57 | 63 | if case .text(let text) = math.object { |
| 58 | 64 | plain += text |
| 59 | 65 | } else { |
| @@ -260,6 +266,7 @@ extension OrgParser { | ||
| 260 | 266 | } |
| 261 | 267 | |
| 262 | 268 | private static func scanFootnote(_ chars: [Character], from start: Int) -> (object: OrgObject, next: Int)? { |
| 269 | guard chars[start...].starts(with: "[fn:") else { return nil } | |
| 263 | 270 | let rest = String(chars[start...]) |
| 264 | 271 | guard let match = rest.firstMatch(of: /^\[fn:([A-Za-z0-9_-]+)(?::([^\]]*))?\]/) else { return nil } |
| 265 | 272 | let label = String(match.1) |
| @@ -275,7 +282,8 @@ extension OrgParser { | ||
| 275 | 282 | var j = start + 1 |
| 276 | 283 | var body = "" |
| 277 | 284 | 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 } | |
| 279 | 287 | body.append(chars[j]); j += 1 |
| 280 | 288 | } |
| 281 | 289 | guard j < chars.count else { return nil } |
| @@ -309,7 +317,10 @@ extension OrgParser { | ||
| 309 | 317 | } |
| 310 | 318 | |
| 311 | 319 | 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)]) | |
| 313 | 324 | guard let match = rest.firstMatch(of: /^https?:\/\/[^\s<>()\[\]]+/) else { return nil } |
| 314 | 325 | var url = String(rest[match.range]) |
| 315 | 326 | while let last = url.last, ".,;:!?".contains(last) { url.removeLast() } |
| @@ -323,7 +334,11 @@ extension OrgParser { | ||
| 323 | 334 | } |
| 324 | 335 | |
| 325 | 336 | 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)]) | |
| 327 | 342 | guard let match = rest.firstMatch(of: /^[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}/) else { |
| 328 | 343 | return nil |
| 329 | 344 | } |
| @@ -338,7 +353,8 @@ extension OrgParser { | ||
| 338 | 353 | /// anywhere later in the run. A `$…$` whose body starts or ends with a space is consumed |
| 339 | 354 | /// but stays prose, org's rule for keeping "$5 and $10" a sentence. |
| 340 | 355 | 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> | |
| 342 | 358 | ) -> (object: OrgObject, next: Int)? { |
| 343 | 359 | guard start + 2 < chars.count else { return nil } |
| 344 | 360 | let opener: String |
| @@ -351,11 +367,17 @@ extension OrgParser { | ||
| 351 | 367 | } |
| 352 | 368 | let closer = ["$$": "$$", "$": "$", #"\("#: #"\)"#, #"\["#: #"\]"#][opener]! |
| 353 | 369 | 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 | } | |
| 355 | 375 | let tex = String(chars[body..<close]) |
| 356 | 376 | let source = opener + tex + closer |
| 357 | 377 | 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 { | |
| 359 | 381 | return (.text(source), next) |
| 360 | 382 | } |
| 361 | 383 | let display = opener != "$" && opener != #"\("# |
| @@ -369,7 +391,7 @@ extension OrgParser { | ||
| 369 | 391 | ) -> (object: OrgObject, next: Int)? { |
| 370 | 392 | guard let lastEnd, lastEnd.offset > start, |
| 371 | 393 | 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 == "_" }) | |
| 373 | 395 | guard !name.isEmpty, start + 7 + name.count < chars.count, chars[start + 7 + name.count] == "}", |
| 374 | 396 | String(name) == lastEnd.name else { return nil } |
| 375 | 397 | let closer = Array(#"\end{"# + String(name) + "}") |
| @@ -387,7 +409,7 @@ extension OrgParser { | ||
| 387 | 409 | private static func firstIndex(of needle: [Character], in chars: [Character], from start: Int) -> Int? { |
| 388 | 410 | guard !needle.isEmpty, chars.count >= needle.count, start <= chars.count - needle.count else { return nil } |
| 389 | 411 | 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 } | |
| 391 | 413 | } |
| 392 | 414 | return nil |
| 393 | 415 | } |
Sources/OrgSwift/AST/OrgParser.swift +42 −18
| @@ -14,6 +14,7 @@ public enum OrgParser { | ||
| 14 | 14 | .replacingOccurrences(of: "\r", with: "\n") |
| 15 | 15 | .components(separatedBy: "\n") |
| 16 | 16 | var index = 0 |
| 17 | let latexEnds = latexEndLines(lines) | |
| 17 | 18 | var pendingCaption: String? |
| 18 | 19 | var pendingName: String? |
| 19 | 20 | var pendingAttrs: [(key: String, value: String)] = [] |
| @@ -134,7 +135,7 @@ public enum OrgParser { | ||
| 134 | 135 | } |
| 135 | 136 | |
| 136 | 137 | // 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) { | |
| 138 | 139 | append(.latexBlock(raw)) |
| 139 | 140 | index = next |
| 140 | 141 | flushPending() |
| @@ -149,7 +150,9 @@ public enum OrgParser { | ||
| 149 | 150 | // The first line is always taken: nothing above claimed it, and |
| 150 | 151 | // leaving it would parse the same line forever. |
| 151 | 152 | 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 } | |
| 153 | 156 | paragraph.append(candidateTrimmed) |
| 154 | 157 | index += 1 |
| 155 | 158 | } |
| @@ -171,7 +174,6 @@ public enum OrgParser { | ||
| 171 | 174 | if isTableLine(trimmed) { return true } |
| 172 | 175 | if orgFootnoteDefinition(in: trimmed) != nil { return true } |
| 173 | 176 | if isListMarkerLine(trimmed), !isIndentedContinuationLine(raw) { return true } |
| 174 | if latexBegin(trimmed) != nil { return true } | |
| 175 | 177 | return false |
| 176 | 178 | } |
| 177 | 179 | |
| @@ -179,24 +181,46 @@ public enum OrgParser { | ||
| 179 | 181 | |
| 180 | 182 | /// The environment a `\begin{name}` line opens, as go-org's lexer reads it (any case). |
| 181 | 183 | 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() } | |
| 183 | 186 | } |
| 184 | 187 | |
| 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) } | |
| 198 | 201 | } |
| 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) | |
| 200 | 224 | } |
| 201 | 225 | |
| 202 | 226 | // MARK: - Heading |
Sources/OrgSwiftUI/OrgMathText.swift +7 −5
| @@ -16,17 +16,19 @@ extension EnvironmentValues { | ||
| 16 | 16 | } |
| 17 | 17 | } |
| 18 | 18 | |
| 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. | |
| 21 | 23 | @MainActor |
| 22 | 24 | func orgText(_ string: AttributedString, math: OrgMathRenderer?) -> Text { |
| 23 | 25 | guard let math, string.runs.contains(where: { $0[OrgMathAttribute.self] != nil }) else { |
| 24 | 26 | return Text(string) |
| 25 | 27 | } |
| 26 | 28 | 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 { | |
| 30 | 32 | result = Text("\(result)\(Text(piece))") |
| 31 | 33 | continue |
| 32 | 34 | } |
Tests/OrgSwiftTests/MathTests.swift +28
| @@ -66,6 +66,34 @@ struct MathParseTests { | ||
| 66 | 66 | } |
| 67 | 67 | } |
| 68 | 68 | |
| 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 | ||
| 69 | 97 | @Test func plainTextAndHTMLShowTheSource() { |
| 70 | 98 | let doc = OrgParser.parse("a $x$ b\n\n\\begin{align}\nx &< y\n\\end{align}") |
| 71 | 99 | #expect(OrgParser.plain(objects("a $x$ b")) == "a $x$ b") |
Tests/OrgSwiftUITests/OrgMathLayoutTests.swift +16
| @@ -39,6 +39,22 @@ struct OrgMathLayoutTests { | ||
| 39 | 39 | #expect(marked == [OrgMath(tex: "x", display: false, source: "$x$")]) |
| 40 | 40 | } |
| 41 | 41 | |
| 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 | ||
| 42 | 58 | @Test func orgTextDrawsEachExpression() { |
| 43 | 59 | guard case .paragraph(let text) = layout("$x$$x$ and $y$").blocks.first else { |
| 44 | 60 | Issue.record("expected a paragraph"); return |