Commit 49b729cc96
Verified · cmc
Layout: unified · split
Sources/OrgSwift/AST/OrgInlineParser.swift +32 −10
| @@ -37,6 +37,12 @@ extension OrgParser { | |||
| 37 | let chars = Array(text) | 37 | let chars = Array(text) |
| 38 | var i = 0 | 38 | var i = 0 |
| 39 | let lastEnd = lastEnvironmentEnd(text) | 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 | func flushPlain() { | 47 | func flushPlain() { |
| 42 | if !plain.isEmpty { objects.append(.text(plain)); plain = "" } | 48 | if !plain.isEmpty { objects.append(.text(plain)); plain = "" } |
| @@ -45,7 +51,7 @@ extension OrgParser { | |||
| 45 | while i < chars.count { | 51 | while i < chars.count { |
| 46 | // Bracket links: [[target]] or [[target][description]] | 52 | // Bracket links: [[target]] or [[target][description]] |
| 47 | if chars[i] == "[", i + 1 < chars.count, chars[i + 1] == "[", | 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 | flushPlain() | 55 | flushPlain() |
| 50 | objects.append(link.object) | 56 | objects.append(link.object) |
| 51 | i = link.next | 57 | i = link.next |
| @@ -53,7 +59,7 @@ extension OrgParser { | |||
| 53 | } | 59 | } |
| 54 | 60 | ||
| 55 | // LaTeX fragments: $…$, $$…$$, \(…\), \[…\], \begin{…}…\end{…} | 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 | if case .text(let text) = math.object { | 63 | if case .text(let text) = math.object { |
| 58 | plain += text | 64 | plain += text |
| 59 | } else { | 65 | } else { |
| @@ -260,6 +266,7 @@ extension OrgParser { | |||
| 260 | } | 266 | } |
| 261 | 267 | ||
| 262 | private static func scanFootnote(_ chars: [Character], from start: Int) -> (object: OrgObject, next: Int)? { | 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 | let rest = String(chars[start...]) | 270 | let rest = String(chars[start...]) |
| 264 | guard let match = rest.firstMatch(of: /^\[fn:([A-Za-z0-9_-]+)(?::([^\]]*))?\]/) else { return nil } | 271 | guard let match = rest.firstMatch(of: /^\[fn:([A-Za-z0-9_-]+)(?::([^\]]*))?\]/) else { return nil } |
| 265 | let label = String(match.1) | 272 | let label = String(match.1) |
| @@ -275,7 +282,8 @@ extension OrgParser { | |||
| 275 | var j = start + 1 | 282 | var j = start + 1 |
| 276 | var body = "" | 283 | var body = "" |
| 277 | while j < chars.count, chars[j] != closing { | 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 | body.append(chars[j]); j += 1 | 287 | body.append(chars[j]); j += 1 |
| 280 | } | 288 | } |
| 281 | guard j < chars.count else { return nil } | 289 | guard j < chars.count else { return nil } |
| @@ -309,7 +317,10 @@ extension OrgParser { | |||
| 309 | } | 317 | } |
| 310 | 318 | ||
| 311 | private static func scanBareURL(_ chars: [Character], from start: Int) -> (object: OrgObject, next: Int)? { | 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 | guard let match = rest.firstMatch(of: /^https?:\/\/[^\s<>()\[\]]+/) else { return nil } | 324 | guard let match = rest.firstMatch(of: /^https?:\/\/[^\s<>()\[\]]+/) else { return nil } |
| 314 | var url = String(rest[match.range]) | 325 | var url = String(rest[match.range]) |
| 315 | while let last = url.last, ".,;:!?".contains(last) { url.removeLast() } | 326 | while let last = url.last, ".,;:!?".contains(last) { url.removeLast() } |
| @@ -323,7 +334,11 @@ extension OrgParser { | |||
| 323 | } | 334 | } |
| 324 | 335 | ||
| 325 | private static func scanEmail(_ chars: [Character], from start: Int) -> (object: OrgObject, next: Int)? { | 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 | guard let match = rest.firstMatch(of: /^[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}/) else { | 342 | guard let match = rest.firstMatch(of: /^[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}/) else { |
| 328 | return nil | 343 | return nil |
| 329 | } | 344 | } |
| @@ -338,7 +353,8 @@ extension OrgParser { | |||
| 338 | /// anywhere later in the run. A `$…$` whose body starts or ends with a space is consumed | 353 | /// anywhere later in the run. A `$…$` whose body starts or ends with a space is consumed |
| 339 | /// but stays prose, org's rule for keeping "$5 and $10" a sentence. | 354 | /// but stays prose, org's rule for keeping "$5 and $10" a sentence. |
| 340 | private static func scanMath( | 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 | ) -> (object: OrgObject, next: Int)? { | 358 | ) -> (object: OrgObject, next: Int)? { |
| 343 | guard start + 2 < chars.count else { return nil } | 359 | guard start + 2 < chars.count else { return nil } |
| 344 | let opener: String | 360 | let opener: String |
| @@ -351,11 +367,17 @@ extension OrgParser { | |||
| 351 | } | 367 | } |
| 352 | let closer = ["$$": "$$", "$": "$", #"\("#: #"\)"#, #"\["#: #"\]"#][opener]! | 368 | let closer = ["$$": "$$", "$": "$", #"\("#: #"\)"#, #"\["#: #"\]"#][opener]! |
| 353 | let body = start + opener.count | 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 | let tex = String(chars[body..<close]) | 375 | let tex = String(chars[body..<close]) |
| 356 | let source = opener + tex + closer | 376 | let source = opener + tex + closer |
| 357 | let next = close + closer.count | 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 | return (.text(source), next) | 381 | return (.text(source), next) |
| 360 | } | 382 | } |
| 361 | let display = opener != "$" && opener != #"\("# | 383 | let display = opener != "$" && opener != #"\("# |
| @@ -369,7 +391,7 @@ extension OrgParser { | |||
| 369 | ) -> (object: OrgObject, next: Int)? { | 391 | ) -> (object: OrgObject, next: Int)? { |
| 370 | guard let lastEnd, lastEnd.offset > start, | 392 | guard let lastEnd, lastEnd.offset > start, |
| 371 | Array(chars[start...].prefix(7)) == Array(#"\begin{"#) else { return nil } | 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 | guard !name.isEmpty, start + 7 + name.count < chars.count, chars[start + 7 + name.count] == "}", | 395 | guard !name.isEmpty, start + 7 + name.count < chars.count, chars[start + 7 + name.count] == "}", |
| 374 | String(name) == lastEnd.name else { return nil } | 396 | String(name) == lastEnd.name else { return nil } |
| 375 | let closer = Array(#"\end{"# + String(name) + "}") | 397 | let closer = Array(#"\end{"# + String(name) + "}") |
| @@ -387,7 +409,7 @@ extension OrgParser { | |||
| 387 | private static func firstIndex(of needle: [Character], in chars: [Character], from start: Int) -> Int? { | 409 | private static func firstIndex(of needle: [Character], in chars: [Character], from start: Int) -> Int? { |
| 388 | guard !needle.isEmpty, chars.count >= needle.count, start <= chars.count - needle.count else { return nil } | 410 | guard !needle.isEmpty, chars.count >= needle.count, start <= chars.count - needle.count else { return nil } |
| 389 | for i in start...(chars.count - needle.count) where chars[i] == needle[0] { | 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 | return nil | 414 | return nil |
| 393 | } | 415 | } |
Sources/OrgSwift/AST/OrgParser.swift +42 −18
| @@ -14,6 +14,7 @@ public enum OrgParser { | |||
| 14 | .replacingOccurrences(of: "\r", with: "\n") | 14 | .replacingOccurrences(of: "\r", with: "\n") |
| 15 | .components(separatedBy: "\n") | 15 | .components(separatedBy: "\n") |
| 16 | var index = 0 | 16 | var index = 0 |
| 17 | let latexEnds = latexEndLines(lines) | ||
| 17 | var pendingCaption: String? | 18 | var pendingCaption: String? |
| 18 | var pendingName: String? | 19 | var pendingName: String? |
| 19 | var pendingAttrs: [(key: String, value: String)] = [] | 20 | var pendingAttrs: [(key: String, value: String)] = [] |
| @@ -134,7 +135,7 @@ public enum OrgParser { | |||
| 134 | } | 135 | } |
| 135 | 136 | ||
| 136 | // A LaTeX block: \begin{name} alone on its line, through the matching \end{name}. | 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 | append(.latexBlock(raw)) | 139 | append(.latexBlock(raw)) |
| 139 | index = next | 140 | index = next |
| 140 | flushPending() | 141 | flushPending() |
| @@ -149,7 +150,9 @@ public enum OrgParser { | |||
| 149 | // The first line is always taken: nothing above claimed it, and | 150 | // The first line is always taken: nothing above claimed it, and |
| 150 | // leaving it would parse the same line forever. | 151 | // leaving it would parse the same line forever. |
| 151 | if !paragraph.isEmpty, | 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 | paragraph.append(candidateTrimmed) | 156 | paragraph.append(candidateTrimmed) |
| 154 | index += 1 | 157 | index += 1 |
| 155 | } | 158 | } |
| @@ -171,7 +174,6 @@ public enum OrgParser { | |||
| 171 | if isTableLine(trimmed) { return true } | 174 | if isTableLine(trimmed) { return true } |
| 172 | if orgFootnoteDefinition(in: trimmed) != nil { return true } | 175 | if orgFootnoteDefinition(in: trimmed) != nil { return true } |
| 173 | if isListMarkerLine(trimmed), !isIndentedContinuationLine(raw) { return true } | 176 | if isListMarkerLine(trimmed), !isIndentedContinuationLine(raw) { return true } |
| 174 | if latexBegin(trimmed) != nil { return true } | ||
| 175 | return false | 177 | return false |
| 176 | } | 178 | } |
| 177 | 179 | ||
| @@ -179,24 +181,46 @@ public enum OrgParser { | |||
| 179 | 181 | ||
| 180 | /// The environment a `\begin{name}` line opens, as go-org's lexer reads it (any case). | 182 | /// The environment a `\begin{name}` line opens, as go-org's lexer reads it (any case). |
| 181 | private static func latexBegin(_ trimmed: String) -> String? { | 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)? { | 188 | /// The environment a `\end{name}` line closes. Like go-org's, it ends a paragraph |
| 186 | let opener = lines[start] | 189 | /// whether or not a block is open. |
| 187 | guard let name = latexBegin(opener.trimmingCharacters(in: .whitespaces)) else { return nil } | 190 | private static func latexEnd(_ trimmed: String) -> String? { |
| 188 | let indent = leadingWidth(opener) | 191 | guard trimmed.hasPrefix("\\") else { return nil } |
| 189 | var raw = [dropLeading(opener, indent)] | 192 | return trimmed.firstMatch(of: /^\\end\{([^}]+)\}$/.ignoresCase()).map { String($0.1).lowercased() } |
| 190 | for index in (start + 1)..<lines.count { | 193 | } |
| 191 | let line = lines[index] | 194 | |
| 192 | raw.append(dropLeading(line, min(indent, leadingWidth(line)))) | 195 | /// Each environment's `\end{name}` lines, in order, found in one pass so an opener |
| 193 | let trimmed = line.trimmingCharacters(in: .whitespaces) | 196 | /// looks its closer up rather than scanning to the end of the document. |
| 194 | if let end = trimmed.firstMatch(of: /^\\end\{([^}]+)\}$/.ignoresCase()), | 197 | private static func latexEndLines(_ lines: [String]) -> [String: [Int]] { |
| 195 | String(end.1).lowercased() == name { | 198 | var ends: [String: [Int]] = [:] |
| 196 | return (raw.joined(separator: "\n"), index + 1) | 199 | for (index, line) in lines.enumerated() { |
| 197 | } | 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 | // MARK: - Heading | 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 | 19 | /// A styled run as `Text`, each expression drawn by `math` where it can be. Runs are taken |
| 20 | /// expressions side by side share one run, so a run holding its source k times draws k. | 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 | @MainActor | 23 | @MainActor |
| 22 | func orgText(_ string: AttributedString, math: OrgMathRenderer?) -> Text { | 24 | func orgText(_ string: AttributedString, math: OrgMathRenderer?) -> Text { |
| 23 | guard let math, string.runs.contains(where: { $0[OrgMathAttribute.self] != nil }) else { | 25 | guard let math, string.runs.contains(where: { $0[OrgMathAttribute.self] != nil }) else { |
| 24 | return Text(string) | 26 | return Text(string) |
| 25 | } | 27 | } |
| 26 | var result = Text(verbatim: "") | 28 | var result = Text(verbatim: "") |
| 27 | for run in string.runs { | 29 | for (expression, range) in string.runs[OrgMathAttribute.self] { |
| 28 | let piece = AttributedString(string[run.range]) | 30 | let piece = AttributedString(string[range]) |
| 29 | guard let expression = run[OrgMathAttribute.self], let drawn = math(expression) else { | 31 | guard let expression, let drawn = math(expression) else { |
| 30 | result = Text("\(result)\(Text(piece))") | 32 | result = Text("\(result)\(Text(piece))") |
| 31 | continue | 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 | @Test func plainTextAndHTMLShowTheSource() { | 97 | @Test func plainTextAndHTMLShowTheSource() { |
| 70 | let doc = OrgParser.parse("a $x$ b\n\n\\begin{align}\nx &< y\n\\end{align}") | 98 | let doc = OrgParser.parse("a $x$ b\n\n\\begin{align}\nx &< y\n\\end{align}") |
| 71 | #expect(OrgParser.plain(objects("a $x$ b")) == "a $x$ b") | 99 | #expect(OrgParser.plain(objects("a $x$ b")) == "a $x$ b") |
Tests/OrgSwiftUITests/OrgMathLayoutTests.swift +16
| @@ -39,6 +39,22 @@ struct OrgMathLayoutTests { | |||
| 39 | #expect(marked == [OrgMath(tex: "x", display: false, source: "$x$")]) | 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 | @Test func orgTextDrawsEachExpression() { | 58 | @Test func orgTextDrawsEachExpression() { |
| 43 | guard case .paragraph(let text) = layout("$x$$x$ and $y$").blocks.first else { | 59 | guard case .paragraph(let text) = layout("$x$$x$ and $y$").blocks.first else { |
| 44 | Issue.record("expected a paragraph"); return | 60 | Issue.record("expected a paragraph"); return |