Babel :cache !151
2 files changed, +299 −27
Layout: unified · split
Sources/OrgCore/Compute/Babel.swift +201 −27
| @@ -1,3 +1,4 @@ | ||
| 1 | import CryptoKit | |
| 1 | 2 | import Foundation |
| 2 | 3 | |
| 3 | 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 | 66 | var fileDescription: String? |
| 66 | 67 | /// The program writes `file` itself, as graphics languages do. |
| 67 | 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 | 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 | 260 | let refs = refs ?? References(resolved: [:], options: options) |
| 258 | 261 | guard let language = block.language else { return .failure(.message("This block has no language")) } |
| 259 | 262 | let eval = params.single[":eval"]?.lowercased() |
| @@ -271,8 +274,6 @@ public enum Babel { | ||
| 271 | 274 | if session != nil, !shells.contains(language), language != "python" { |
| 272 | 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 | 277 | var body = normalizedBody(block) |
| 277 | 278 | if nowebEvaluates(params.single[":noweb"]) { |
| 278 | 279 | do { |
| @@ -288,9 +289,10 @@ public enum Babel { | ||
| 288 | 289 | params.single[":output-dir"].map { $0.hasSuffix("/") ? $0 + name : $0 + "/" + name } ?? name |
| 289 | 290 | } |
| 290 | 291 | let toFile = params.results.contains("file") |
| 292 | var hash: String? | |
| 291 | 293 | let finishBase = { (kind: BabelJob.Finish.Kind) in |
| 292 | 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 | 297 | let vars: [(String, VarValue)] |
| 296 | 298 | var stdin: VarValue? |
| @@ -314,6 +316,19 @@ public enum Babel { | ||
| 314 | 316 | } catch { |
| 315 | 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 | 332 | let blockText = ns.substring(with: NSRange(location)) |
| 318 | 333 | let dir = params.single[":dir"] |
| 319 | 334 | let alwaysAsk = eval == "query" |
| @@ -439,7 +454,7 @@ public enum Babel { | ||
| 439 | 454 | return .success(BabelJob( |
| 440 | 455 | language: language, runner: .process(executable: params.single[":cmd"] ?? executable, arguments: arguments), |
| 441 | 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 | 460 | return .failure(.message("No way to run \(language) blocks yet.")) |
| @@ -559,6 +574,125 @@ public enum Babel { | ||
| 559 | 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 | 696 | // MARK: - Header arguments |
| 563 | 697 | |
| 564 | 698 | struct Params { |
| @@ -568,6 +702,8 @@ public enum Babel { | ||
| 568 | 702 | var wrap: String? |
| 569 | 703 | /// Keys whose value is Lisp to evaluate (`org-babel-read`): unquoted, starting with `(`. |
| 570 | 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 | 708 | static let resultGroups: [Set<String>] = [ |
| 573 | 709 | ["file", "list", "vector", "table", "scalar", "verbatim"], |
| @@ -621,6 +757,7 @@ public enum Babel { | ||
| 621 | 757 | let value = quoted ? Params.unescape(String(raw.dropFirst().dropLast())) : raw |
| 622 | 758 | if key != ":var" { |
| 623 | 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 | 762 | switch key { |
| 626 | 763 | case ":results": |
| @@ -818,8 +955,20 @@ public enum Babel { | ||
| 818 | 955 | let extra = arguments.map { [splitArguments($0).map { (":var", $0) }] } ?? [] |
| 819 | 956 | var inner = refs |
| 820 | 957 | inner.visiting.insert(name) |
| 821 | switch plan(block, params: try Params(block: block, model: model, text: text, extra: extra), model: model, text: text, | |
| 822 | options: refs.options, location: block.range, name: name, refs: inner) { | |
| 958 | let params = try Params(block: block, model: model, text: text, extra: extra) | |
| 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 | 972 | case .failure(let failure): throw failure |
| 824 | 973 | case .success(let job): |
| 825 | 974 | throw Needed(need: job.needs.first ?? BabelJob.Need(reference: value, job: job)) |
| @@ -1302,30 +1451,55 @@ public enum Babel { | ||
| 1302 | 1451 | |
| 1303 | 1452 | /// `org-babel-where-is-src-block-result` with `insert`: the line of the block's results |
| 1304 | 1453 | /// keyword, added when there is none. |
| 1305 | static func resultsLine(_ finish: BabelJob.Finish, block: Range<Int>, buffer: EmacsBuffer) -> Int { | |
| 1306 | let ns = buffer.text | |
| 1307 | if let name = finish.name { | |
| 1454 | /// The `#+RESULTS` line of a block's results (`org-babel-where-is-src-block-result`): | |
| 1455 | /// the one naming `name`, or for an unnamed block an empty one right after it. | |
| 1456 | static func existingResults(name: String?, block: Range<Int>, text ns: NSString) -> Int? { | |
| 1457 | if let name { | |
| 1308 | 1458 | let pattern = "(?im)^[ \\t]*#\\+results(\\[[^\\]]*\\])?:[ \\t]*" + NSRegularExpression.escapedPattern(for: name) + "[ \\t]*$" |
| 1309 | 1459 | let regex = try! NSRegularExpression(pattern: pattern) |
| 1310 | for m in regex.matches(in: ns as String, range: NSRange(location: 0, length: ns.length)) { | |
| 1311 | // Not the name of another block. | |
| 1312 | return m.range.location | |
| 1460 | return regex.firstMatch(in: ns as String, range: NSRange(location: 0, length: ns.length))?.range.location | |
| 1461 | } | |
| 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 { | |
| 1315 | // The element after the block, past blank lines. | |
| 1316 | var location = block.upperBound | |
| 1317 | while location < ns.length { | |
| 1318 | let line = ns.lineRange(for: NSRange(location: location, length: 0)) | |
| 1319 | let content = ns.substring(with: line) | |
| 1320 | if content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { | |
| 1321 | location = NSMaxRange(line) | |
| 1322 | continue | |
| 1323 | } | |
| 1324 | if content.range(of: "(?i)^[ \\t]*#\\+results(\\[[^\\]]*\\])?:[ \\t]*\\n?$", options: .regularExpression) != nil { | |
| 1325 | return line.location | |
| 1471 | if content.range(of: "(?i)^[ \\t]*#\\+results(\\[[^\\]]*\\])?:[ \\t]*\\n?$", options: .regularExpression) != nil { | |
| 1472 | return line.location | |
| 1473 | } | |
| 1474 | break | |
| 1475 | } | |
| 1476 | return nil | |
| 1477 | } | |
| 1478 | ||
| 1479 | /// The hash in the `#+RESULTS[…]:` line at `line` (`org-babel-result-regexp`). | |
| 1480 | static func resultsHash(at line: Int, text ns: NSString) -> String? { | |
| 1481 | let content = ns.substring(with: ns.lineRange(for: NSRange(location: line, length: 0))) | |
| 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 | 1504 | // A new keyword after the block. |
| 1331 | 1505 | buffer.point = min(block.upperBound, buffer.length) |
| @@ -1339,7 +1513,7 @@ public enum Babel { | ||
| 1339 | 1513 | } |
| 1340 | 1514 | buffer.insert("\n") |
| 1341 | 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 | 1517 | if buffer.looking("^[ \\t]*$", caseInsensitive: false, setsMatch: false) == nil { |
| 1344 | 1518 | buffer.saveExcursion { buffer.insert("\n") } |
| 1345 | 1519 | } |
Tests/OrgCoreTests/BabelCacheTests.swift added +98
| @@ -0,0 +1,98 @@ | ||
| 1 | import Foundation | |
| 2 | import Testing | |
| 3 | @testable import OrgCore | |
| 4 | ||
| 5 | struct 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 | } | |