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 | import Foundation | 2 | import 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 @@ | |||
| 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 | } | ||