Babel :cache !151

merged merged by cmc on 2026-10-08 00:21 UTC · krz/orgstar:babel-cache into main

2 files changed, +299 −27

Layout: unified · split

Sources/OrgCore/Compute/Babel.swift +201 −27
@@ -1,3 +1,4 @@
1import CryptoKit
1import Foundation 2import Foundation
2 3
3// Babel (ob-core.el, ob-shell.el, ob-python.el, ob-emacs-lisp.el, Org 9.8.7): executing a src 4// Babel (ob-core.el, ob-shell.el, ob-python.el, ob-emacs-lisp.el, Org 9.8.7): executing a src
@@ -65,6 +66,8 @@ public struct BabelJob: Sendable, Equatable {
65 var fileDescription: String? 66 var fileDescription: String?
66 /// The program writes `file` itself, as graphics languages do. 67 /// The program writes `file` itself, as graphics languages do.
67 var writesFile = false 68 var writesFile = false
69 /// `:cache yes`: the hash for the `#+RESULTS[…]:` line.
70 var hash: String?
68 } 71 }
69} 72}
70 73
@@ -253,7 +256,7 @@ public enum Babel {
253 } 256 }
254 257
255 static func plan(_ block: SrcBlockInfo, params: Params, model: DocumentModel, text ns: NSString, options: Options, 258 static func plan(_ block: SrcBlockInfo, params: Params, model: DocumentModel, text ns: NSString, options: Options,
256 location: Range<Int>, name: String?, inline: Bool = false, refs: References? = nil) -> Result<BabelJob, Failure> { 259 location: Range<Int>, name: String?, inline: Bool = false, refs: References? = nil, useCache: Bool = true) -> Result<BabelJob, Failure> {
257 let refs = refs ?? References(resolved: [:], options: options) 260 let refs = refs ?? References(resolved: [:], options: options)
258 guard let language = block.language else { return .failure(.message("This block has no language")) } 261 guard let language = block.language else { return .failure(.message("This block has no language")) }
259 let eval = params.single[":eval"]?.lowercased() 262 let eval = params.single[":eval"]?.lowercased()
@@ -271,8 +274,6 @@ public enum Babel {
271 if session != nil, !shells.contains(language), language != "python" { 274 if session != nil, !shells.contains(language), language != "python" {
272 return .failure(.message(":session isn't supported for \(language) yet; nothing was run.")) 275 return .failure(.message(":session isn't supported for \(language) yet; nothing was run."))
273 } 276 }
274 if params.single[":cache"] == "yes" { return .failure(.message(":cache isn't supported yet; nothing was run.")) }
275
276 var body = normalizedBody(block) 277 var body = normalizedBody(block)
277 if nowebEvaluates(params.single[":noweb"]) { 278 if nowebEvaluates(params.single[":noweb"]) {
278 do { 279 do {
@@ -288,9 +289,10 @@ public enum Babel {
288 params.single[":output-dir"].map { $0.hasSuffix("/") ? $0 + name : $0 + "/" + name } ?? name 289 params.single[":output-dir"].map { $0.hasSuffix("/") ? $0 + name : $0 + "/" + name } ?? name
289 } 290 }
290 let toFile = params.results.contains("file") 291 let toFile = params.results.contains("file")
292 var hash: String?
291 let finishBase = { (kind: BabelJob.Finish.Kind) in 293 let finishBase = { (kind: BabelJob.Finish.Kind) in
292 BabelJob.Finish(kind: kind, language: language, params: params.results, output: output, wrap: params.wrap, name: name, inline: inline, 294 BabelJob.Finish(kind: kind, language: language, params: params.results, output: output, wrap: params.wrap, name: name, inline: inline,
293 file: toFile ? file : nil, fileDescription: params.single[":file-desc"]) 295 file: toFile ? file : nil, fileDescription: params.single[":file-desc"], hash: hash)
294 } 296 }
295 let vars: [(String, VarValue)] 297 let vars: [(String, VarValue)]
296 var stdin: VarValue? 298 var stdin: VarValue?
@@ -314,6 +316,19 @@ public enum Babel {
314 } catch { 316 } catch {
315 return .failure(.message("\(error)")) 317 return .failure(.message("\(error)"))
316 } 318 }
319 if useCache, !inline, params.single[":cache"] == "yes" {
320 do {
321 let state = try cacheState(language: language, params: params, vars: vars, body: body, location: location, name: name,
322 call: location != block.range, text: ns)
323 if state.current { return .failure(.message(cachedMessage(state.value))) }
324 // Appended or prepended results accumulate, so they carry no hash.
325 if params.results.contains("replace") { hash = state.hash }
326 } catch let error as Failure {
327 return .failure(error)
328 } catch {
329 return .failure(.message("\(error)"))
330 }
331 }
317 let blockText = ns.substring(with: NSRange(location)) 332 let blockText = ns.substring(with: NSRange(location))
318 let dir = params.single[":dir"] 333 let dir = params.single[":dir"]
319 let alwaysAsk = eval == "query" 334 let alwaysAsk = eval == "query"
@@ -439,7 +454,7 @@ public enum Babel {
439 return .success(BabelJob( 454 return .success(BabelJob(
440 language: language, runner: .process(executable: params.single[":cmd"] ?? executable, arguments: arguments), 455 language: language, runner: .process(executable: params.single[":cmd"] ?? executable, arguments: arguments),
441 input: body + "\n", directory: dir, block: location, blockText: blockText, alwaysAsk: alwaysAsk, 456 input: body + "\n", directory: dir, block: location, blockText: blockText, alwaysAsk: alwaysAsk,
442 finish: BabelJob.Finish(kind: .generic, language: language, params: params.results + (output ? [] : ["output"]), output: true, wrap: params.wrap, name: name, inline: inline) 457 finish: BabelJob.Finish(kind: .generic, language: language, params: params.results + (output ? [] : ["output"]), output: true, wrap: params.wrap, name: name, inline: inline, hash: hash)
443 )) 458 ))
444 } 459 }
445 return .failure(.message("No way to run \(language) blocks yet.")) 460 return .failure(.message("No way to run \(language) blocks yet."))
@@ -559,6 +574,125 @@ public enum Babel {
559 return lines.joined(separator: "\n") 574 return lines.joined(separator: "\n")
560 } 575 }
561 576
577 // MARK: - Cache
578
579 /// `:cache yes` (`org-babel-execute-src-block`): the block's hash, and whether its results
580 /// already carry it, with their value when it can be read.
581 static func cacheState(language: String, params: Params, vars: [(String, VarValue)], body: String, location: Range<Int>, name: String?,
582 call: Bool, text: NSString) throws -> (hash: String, current: Bool, value: VarValue?) {
583 let input = try cacheInput(language: language, params: params, vars: vars, expanded: expandedBody(language: language, vars: vars, body: body), call: call)
584 let hash = Insecure.SHA1.hash(data: Data(input.utf8)).map { String(format: "%02x", $0) }.joined()
585 guard let line = existingResults(name: name, block: location, text: text), resultsHash(at: line, text: text) == hash else {
586 return (hash, false, nil)
587 }
588 return (hash, true, readResult(after: line, text: text))
589 }
590
591 static func cachedMessage(_ value: VarValue?) -> String {
592 value.map { "Cached: " + lispLiteral($0) } ?? "Cached; nothing was run."
593 }
594
595 /// The body with its variables, as the language's `org-babel-expand-body:` writes it.
596 static func expandedBody(language: String, vars: [(String, VarValue)], body: String) -> String {
597 if shells.contains(language) {
598 let lines = vars.map { "\($0.0)='" + shellString($0.1).replacingOccurrences(of: "'", with: "'\"'\"'") + "'" }
599 return (lines + [body]).joined(separator: "\n")
600 }
601 if language == "python" {
602 return (vars.map { "\($0.0)=\(pythonLiteral($0.1))" } + [body]).joined(separator: "\n")
603 }
604 if language == "emacs-lisp" || language == "elisp" {
605 let bindings = vars.map { "(\($0.0) '\(lispLiteral($0.1)))" }
606 return bindings.isEmpty ? body + "\n" : "(let (\(bindings.joined(separator: "\n ")))\n\(body)\n)"
607 }
608 return body
609 }
610
611 /// What `org-babel-sha1-hash` hashes: the header arguments after `org-babel-process-params`,
612 /// sorted by key and printed with `%S`, then the expanded body.
613 static func cacheInput(language: String, params: Params, vars: [(String, VarValue)], expanded: String, call: Bool) throws -> String {
614 if !params.lisp.isEmpty || params.single[":colnames"] != nil || params.single[":rownames"] != nil {
615 throw Failure.message(":cache with Lisp header values, :colnames or :rownames isn't supported yet; nothing was run.")
616 }
617 let ignored: Set<String> = ["replace", "silent", "none", "discard", "append", "prepend"]
618 let drawsFile = language == "dot" || language == "plantuml"
619 // `org-babel-default-header-args`, the language's and `org-babel-default-lob-header-args`.
620 var defaults = [":session": "none", ":exports": call || drawsFile ? "results" : "code", ":cache": "no", ":noweb": "no", ":hlines": "no", ":tangle": "no"]
621 if language == "emacs-lisp" || language == "elisp" { defaults[":lexical"] = "no" }
622 var entries: [(key: String, printed: String)] = []
623 for (key, value) in defaults.merging(params.single, uniquingKeysWith: { $1 }) {
624 let printed: String
625 if value.isEmpty {
626 continue
627 } else if key == ":exports" {
628 printed = lispString(value.split(whereSeparator: \.isWhitespace).map(String.init).filter { !ignored.contains($0) }.sorted().joined(separator: " "))
629 } else if params.single[key] != nil, !params.quoted.contains(key), let n = number(value) {
630 printed = n
631 } else {
632 printed = lispString(value)
633 }
634 entries.append((key, printed))
635 }
636 var words = params.results
637 if drawsFile, !words.contains(where: { Params.resultGroups[0].contains($0) }) { words.append("file") }
638 let kept = words.filter { !ignored.contains($0) }.sorted()
639 if !kept.isEmpty { entries.append((":result-params", "(" + kept.map { lispString($0) }.joined(separator: " ") + ")")) }
640 entries.append((":result-type", words.contains("output") ? "output" : "value"))
641 entries.append((":results", lispString(kept.joined(separator: " "))))
642 if let wrap = params.wrap, !wrap.isEmpty { entries.append((":wrap", lispString(wrap))) }
643 for (name, value) in vars {
644 switch value {
645 case .scalar, .number: entries.append((":var", "(\(name) . \(lispLiteral(value)))"))
646 case .list, .table: entries.append((":var", "(\(name) " + lispLiteral(value).dropFirst()))
647 }
648 }
649 let sorted = entries.enumerated().sorted { ($0.element.key, $0.offset) < ($1.element.key, $1.offset) }.map(\.element.printed)
650 return sorted.joined(separator: ":") + "-" + expanded
651 }
652
653 /// `org-babel-read-result` for fixed-width lines, a table without rules or a plain list after
654 /// the `#+RESULTS` line at `line`; nil for anything else.
655 static func readResult(after line: Int, text ns: NSString) -> VarValue? {
656 var location = NSMaxRange(ns.lineRange(for: NSRange(location: line, length: 0)))
657 var lines: [String] = []
658 while location < ns.length {
659 let range = ns.lineRange(for: NSRange(location: location, length: 0))
660 let content = ns.substring(with: range).trimmingCharacters(in: .newlines)
661 if content.trimmingCharacters(in: .whitespaces).isEmpty { break }
662 lines.append(content.trimmingCharacters(in: .whitespaces))
663 location = NSMaxRange(range)
664 }
665 guard let first = lines.first else { return nil }
666 if first == ":" || first.hasPrefix(": ") {
667 var fixed: [String] = []
668 for l in lines {
669 guard l == ":" || l.hasPrefix(": ") else { break }
670 fixed.append(String(l.dropFirst(2)))
671 }
672 let value = fixed.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines)
673 return number(value).map(VarValue.number) ?? .scalar(value)
674 }
675 if first.hasPrefix("|") {
676 var rows: [[Cell]] = []
677 for l in lines {
678 guard l.hasPrefix("|") else { break }
679 if l.hasPrefix("|-") { return nil }
680 let cells = l.dropFirst().split(separator: "|", omittingEmptySubsequences: false).map { $0.trimmingCharacters(in: .whitespaces) }
681 rows.append((l.hasSuffix("|") ? Array(cells.dropLast()) : cells).map(cell))
682 }
683 return .table(rows)
684 }
685 if first.firstMatch(of: #/^[-+*] /#) != nil {
686 var items: [Cell] = []
687 for l in lines {
688 guard let m = l.firstMatch(of: /^[-+*] (.*)$/) else { return nil }
689 items.append(cell(String(m.1)))
690 }
691 return .list(items)
692 }
693 return nil
694 }
695
562 // MARK: - Header arguments 696 // MARK: - Header arguments
563 697
564 struct Params { 698 struct Params {
@@ -568,6 +702,8 @@ public enum Babel {
568 var wrap: String? 702 var wrap: String?
569 /// Keys whose value is Lisp to evaluate (`org-babel-read`): unquoted, starting with `(`. 703 /// Keys whose value is Lisp to evaluate (`org-babel-read`): unquoted, starting with `(`.
570 var lisp: Set<String> = [] 704 var lisp: Set<String> = []
705 /// Keys whose value was a quoted string, which `org-babel-read` doesn't take for a number.
706 var quoted: Set<String> = []
571 707
572 static let resultGroups: [Set<String>] = [ 708 static let resultGroups: [Set<String>] = [
573 ["file", "list", "vector", "table", "scalar", "verbatim"], 709 ["file", "list", "vector", "table", "scalar", "verbatim"],
@@ -621,6 +757,7 @@ public enum Babel {
621 let value = quoted ? Params.unescape(String(raw.dropFirst().dropLast())) : raw 757 let value = quoted ? Params.unescape(String(raw.dropFirst().dropLast())) : raw
622 if key != ":var" { 758 if key != ":var" {
623 if !quoted, value.hasPrefix("(") { lisp.insert(key) } else { lisp.remove(key) } 759 if !quoted, value.hasPrefix("(") { lisp.insert(key) } else { lisp.remove(key) }
760 if quoted { self.quoted.insert(key) } else { self.quoted.remove(key) }
624 } 761 }
625 switch key { 762 switch key {
626 case ":results": 763 case ":results":
@@ -818,8 +955,20 @@ public enum Babel {
818 let extra = arguments.map { [splitArguments($0).map { (":var", $0) }] } ?? [] 955 let extra = arguments.map { [splitArguments($0).map { (":var", $0) }] } ?? []
819 var inner = refs 956 var inner = refs
820 inner.visiting.insert(name) 957 inner.visiting.insert(name)
821 switch plan(block, params: try Params(block: block, model: model, text: text, extra: extra), model: model, text: text, 958 let params = try Params(block: block, model: model, text: text, extra: extra)
822 options: refs.options, location: block.range, name: name, refs: inner) { 959 // A cached block gives its result without running, as `org-babel-execute-src-block` does.
960 var useCache = true
961 if params.single[":cache"] == "yes", let language = block.language {
962 let vars = try params.vars.map { ($0.name, try resolve($0.value, model: model, text: text, refs: inner)) }
963 let body = nowebEvaluates(params.single[":noweb"]) ? try expandNoweb(block, params: params, model: model, text: text) : normalizedBody(block)
964 let state = try cacheState(language: language, params: params, vars: vars, body: body, location: block.range, name: name, call: false, text: text)
965 if state.current {
966 if let value = state.value { return value }
967 useCache = false
968 }
969 }
970 switch plan(block, params: params, model: model, text: text,
971 options: refs.options, location: block.range, name: name, refs: inner, useCache: useCache) {
823 case .failure(let failure): throw failure 972 case .failure(let failure): throw failure
824 case .success(let job): 973 case .success(let job):
825 throw Needed(need: job.needs.first ?? BabelJob.Need(reference: value, job: job)) 974 throw Needed(need: job.needs.first ?? BabelJob.Need(reference: value, job: job))
@@ -1302,30 +1451,55 @@ public enum Babel {
1302 1451
1303 /// `org-babel-where-is-src-block-result` with `insert`: the line of the block's results 1452 /// `org-babel-where-is-src-block-result` with `insert`: the line of the block's results
1304 /// keyword, added when there is none. 1453 /// keyword, added when there is none.
1305 static func resultsLine(_ finish: BabelJob.Finish, block: Range<Int>, buffer: EmacsBuffer) -> Int { 1454 /// The `#+RESULTS` line of a block's results (`org-babel-where-is-src-block-result`):
1306 let ns = buffer.text 1455 /// the one naming `name`, or for an unnamed block an empty one right after it.
1307 if let name = finish.name { 1456 static func existingResults(name: String?, block: Range<Int>, text ns: NSString) -> Int? {
1457 if let name {
1308 let pattern = "(?im)^[ \\t]*#\\+results(\\[[^\\]]*\\])?:[ \\t]*" + NSRegularExpression.escapedPattern(for: name) + "[ \\t]*$" 1458 let pattern = "(?im)^[ \\t]*#\\+results(\\[[^\\]]*\\])?:[ \\t]*" + NSRegularExpression.escapedPattern(for: name) + "[ \\t]*$"
1309 let regex = try! NSRegularExpression(pattern: pattern) 1459 let regex = try! NSRegularExpression(pattern: pattern)
1310 for m in regex.matches(in: ns as String, range: NSRange(location: 0, length: ns.length)) { 1460 return regex.firstMatch(in: ns as String, range: NSRange(location: 0, length: ns.length))?.range.location
1311 // Not the name of another block. 1461 }
1312 return m.range.location 1462 // The element after the block, past blank lines.
1463 var location = block.upperBound
1464 while location < ns.length {
1465 let line = ns.lineRange(for: NSRange(location: location, length: 0))
1466 let content = ns.substring(with: line)
1467 if content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
1468 location = NSMaxRange(line)
1469 continue
1313 } 1470 }
1314 } else { 1471 if content.range(of: "(?i)^[ \\t]*#\\+results(\\[[^\\]]*\\])?:[ \\t]*\\n?$", options: .regularExpression) != nil {
1315 // The element after the block, past blank lines. 1472 return line.location
1316 var location = block.upperBound 1473 }
1317 while location < ns.length { 1474 break
1318 let line = ns.lineRange(for: NSRange(location: location, length: 0)) 1475 }
1319 let content = ns.substring(with: line) 1476 return nil
1320 if content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { 1477 }
1321 location = NSMaxRange(line) 1478
1322 continue 1479 /// The hash in the `#+RESULTS[…]:` line at `line` (`org-babel-result-regexp`).
1323 } 1480 static func resultsHash(at line: Int, text ns: NSString) -> String? {
1324 if content.range(of: "(?i)^[ \\t]*#\\+results(\\[[^\\]]*\\])?:[ \\t]*\\n?$", options: .regularExpression) != nil { 1481 let content = ns.substring(with: ns.lineRange(for: NSRange(location: line, length: 0)))
1325 return line.location 1482 return content.firstMatch(of: #/(?i)^[ \t]*#\+results\[(?:\(\d{4}-\d\d-\d\d \d\d:\d\d:\d\d\) )?([0-9a-f]+)\]:/#).map { String($0.1) }
1483 }
1484
1485 static func resultsLine(_ finish: BabelJob.Finish, block: Range<Int>, buffer: EmacsBuffer) -> Int {
1486 if let line = existingResults(name: finish.name, block: block, text: buffer.text) {
1487 // `org-babel--clear-results-maybe`: a different hash clears the results and
1488 // rewrites the keyword.
1489 if let hash = finish.hash, resultsHash(at: line, text: buffer.text) != hash {
1490 buffer.point = line
1491 let indent = String(buffer.substring(line, buffer.lineEnd(line)).prefix { $0 == " " || $0 == "\t" })
1492 buffer.forwardLine(1)
1493 let contents = buffer.point
1494 buffer.deleteRegion(contents, max(contents, resultEnd(buffer)))
1495 buffer.deleteRegion(line, contents)
1496 buffer.point = line
1497 buffer.insert(indent + "#+RESULTS[\(hash)]:" + (finish.name.map { " " + $0 } ?? "") + "\n")
1498 if buffer.looking("^[ \\t]*$", caseInsensitive: false, setsMatch: false) == nil {
1499 buffer.saveExcursion { buffer.insert("\n") }
1326 } 1500 }
1327 break
1328 } 1501 }
1502 return line
1329 } 1503 }
1330 // A new keyword after the block. 1504 // A new keyword after the block.
1331 buffer.point = min(block.upperBound, buffer.length) 1505 buffer.point = min(block.upperBound, buffer.length)
@@ -1339,7 +1513,7 @@ public enum Babel {
1339 } 1513 }
1340 buffer.insert("\n") 1514 buffer.insert("\n")
1341 let keywordLine = buffer.point 1515 let keywordLine = buffer.point
1342 buffer.insert(indent + "#+RESULTS:" + (finish.name.map { " " + $0 } ?? "") + "\n") 1516 buffer.insert(indent + "#+RESULTS" + (finish.hash.map { "[\($0)]" } ?? "") + ":" + (finish.name.map { " " + $0 } ?? "") + "\n")
1343 if buffer.looking("^[ \\t]*$", caseInsensitive: false, setsMatch: false) == nil { 1517 if buffer.looking("^[ \\t]*$", caseInsensitive: false, setsMatch: false) == nil {
1344 buffer.saveExcursion { buffer.insert("\n") } 1518 buffer.saveExcursion { buffer.insert("\n") }
1345 } 1519 }
Tests/OrgCoreTests/BabelCacheTests.swift added +98
@@ -0,0 +1,98 @@
1import Foundation
2import Testing
3@testable import OrgCore
4
5struct BabelCacheTests {
6 /// A file, and the text before which each run in turn puts the caret.
7 struct Case {
8 var text: String
9 var runs: [String]
10 /// Replacements made after the runs before the last, as the user editing.
11 var edits: [(String, String)] = []
12 }
13
14 static let cases: [Case] = [
15 Case(text: "#+begin_src sh :cache yes\necho hi\n#+end_src\n", runs: ["#+begin_src", "#+begin_src"]),
16 Case(text: "#+begin_src sh :cache yes\necho there\n#+end_src\n\n#+RESULTS[0000]:\n: old\n\nAfter.\n", runs: ["#+begin_src"]),
17 Case(text: "#+begin_src sh :cache yes :results append\necho more\n#+end_src\n\n#+RESULTS[0000]:\n: first\n", runs: ["#+begin_src"]),
18 Case(text: "#+begin_src sh :cache yes\necho new\n#+end_src\n\n#+RESULTS:\n: no hash\n", runs: ["#+begin_src"]),
19 Case(text: "#+NAME: a\n#+begin_src sh :cache yes\necho named\n#+end_src\n\nText.\n\n#+RESULTS[abc]: a\n: stale\n", runs: ["#+begin_src", "#+begin_src"]),
20 Case(text: "#+NAME: a\n#+begin_src sh :cache yes\necho named\n#+end_src\n", runs: ["#+begin_src", "#+begin_src"]),
21 Case(text: "#+begin_src sh :cache yes :var x=5 :var y=\"two words\" :height 400 :width \"400\" :exports both\necho $x $y\n#+end_src\n",
22 runs: ["#+begin_src", "#+begin_src"]),
23 Case(text: "#+begin_src sh :cache yes :results output :exports none :wrap example\nprintf 'a\\nb\\n'\n#+end_src\n", runs: ["#+begin_src", "#+begin_src"]),
24 Case(text: "#+NAME: tbl\n| 1 | x |\n| 2 | y |\n\n#+NAME: items\n- a\n- b\n\n#+begin_src python :cache yes :var t=tbl :var n=1.5 :var l=items\nreturn [len(t), n, l]\n#+end_src\n",
25 runs: ["#+begin_src", "#+begin_src"]),
26 Case(text: "#+begin_src emacs-lisp :cache yes :var x=4 :var s=\"q\\\"z\"\n(list x s)\n#+end_src\n", runs: ["#+begin_src", "#+begin_src"]),
27 Case(text: "#+begin_src emacs-lisp :cache yes :lexical yes :results output\n(princ 7)\n#+end_src\n", runs: ["#+begin_src", "#+begin_src"]),
28 Case(text: "#+NAME: a\n#+begin_src sh\necho hi\n#+end_src\n\n#+CALL: a() :cache yes\n", runs: ["#+CALL", "#+CALL"]),
29 Case(text: "#+PROPERTY: header-args :cache yes\n#+begin_src sh :session none\necho prop\n#+end_src\n", runs: ["#+begin_src", "#+begin_src"]),
30 // Edited after the first run, so it runs again.
31 Case(text: "#+begin_src sh :cache yes\necho one\n#+end_src\n", runs: ["#+begin_src", "#+begin_src"], edits: [("echo one", "echo two")]),
32 // A reference to a cached block takes its result without running it.
33 Case(text: "#+begin_src sh :var x=five\necho $((x+1))\n#+end_src\n\n#+NAME: five\n#+begin_src sh :cache yes\necho 5\n#+end_src\n",
34 runs: ["#+begin_src sh :cache", "#+begin_src sh :var"], edits: [(": 5\n", ": 50\n")]),
35 Case(text: "#+begin_src sh :var x=five\necho $((x+1))\n#+end_src\n\n#+NAME: five\n#+begin_src sh :cache yes\necho 5\n#+end_src\n\n#+RESULTS[0000]: five\n: 50\n",
36 runs: ["#+begin_src sh :var"]),
37 ]
38
39 static func caret(_ marker: String, in text: String) -> Int {
40 (text as NSString).range(of: marker).location + 2
41 }
42
43 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
44 func matchesEmacs() throws {
45 let folder = FileManager.default.temporaryDirectory
46 var emacsCases: [EmacsOracle.Case] = []
47 var ours: [(text: String, failure: String?)] = []
48 for c in Self.cases {
49 var text = c.text
50 var failure: String?
51 for (i, marker) in c.runs.enumerated() {
52 if i == c.runs.count - 1, c.runs.count > 1 { for (old, new) in c.edits { text = text.replacingOccurrences(of: old, with: new) } }
53 (text, failure) = try TestBabelRunner.execute(text, caret: Self.caret(marker, in: text), directory: folder)
54 }
55 ours.append((text, failure))
56 let steps = c.runs.enumerated().map { i, marker in
57 let edits = i == c.runs.count - 1 && c.runs.count > 1
58 ? c.edits.map { "(goto-char (point-min)) (while (search-forward \(Babel.lispString($0.0)) nil t) (replace-match \(Babel.lispString($0.1)) t t))" }.joined(separator: " ")
59 : ""
60 return "\(edits) (goto-char (point-min)) (search-forward \(Babel.lispString(marker))) (goto-char (+ (match-beginning 0) 2)) (org-babel-execute-maybe)"
61 }
62 emacsCases.append(EmacsOracle.Case(text: c.text, point: 1, form: """
63 (progn
64 (require 'ob-shell)
65 (require 'ob-python)
66 (require 'ob-emacs-lisp)
67 (setq org-confirm-babel-evaluate nil
68 org-babel-python-command "python3"
69 default-directory temporary-file-directory)
70 \(steps.joined(separator: "\n ")))
71 """))
72 }
73 let emacs = try EmacsOracle.run(emacsCases)
74 for (i, (mine, theirs)) in zip(ours, emacs).enumerated() {
75 #expect(mine.text == theirs.text, "case \(i):\n\(Self.cases[i].text)\nours (\(mine.failure ?? "")):\n\(mine.text)\nemacs (\(theirs.error)):\n\(theirs.text)")
76 }
77 }
78
79 @Test func cachedRunSaysSo() throws {
80 let text = "#+begin_src sh :cache yes\necho hi\n#+end_src\n"
81 let folder = FileManager.default.temporaryDirectory
82 let first = try TestBabelRunner.execute(text, caret: 2, directory: folder)
83 #expect(first.failure == nil)
84 let second = try TestBabelRunner.execute(first.text, caret: 2, directory: folder)
85 #expect(second.text == first.text)
86 #expect(second.failure == "Cached: \"hi\"")
87 }
88
89 @Test func lispHeadersAreRefused() {
90 let text = "#+begin_src sh :cache yes :dir (concat \"/\" \"tmp\")\necho hi\n#+end_src\n"
91 let context = EditContext(revision: 0, text: text, tree: OrgParser.parse(text), selection: [2..<2])
92 guard case .failure(.message(let message)) = Babel.plan(context) else {
93 Issue.record("planned")
94 return
95 }
96 #expect(message.hasPrefix(":cache with Lisp header values"))
97 }
98}