Commit 46d6a046f5
Verified · cmc
Layout: unified · split
Sources/OrgApp/DocumentSession.swift +17 −6
| @@ -688,14 +688,26 @@ public final class DocumentSession { | ||
| 688 | 688 | /// Runs a src block and writes its result under it, unless the block changed while it |
| 689 | 689 | /// ran. Returns a message for the echo area. |
| 690 | 690 | public func runBabel(_ job: BabelJob) async -> String { |
| 691 | #if os(macOS) | |
| 692 | 691 | guard let entry = current else { return "No file is open." } |
| 693 | 692 | let directory = URL(fileURLWithPath: entry.path).deletingLastPathComponent() |
| 693 | /// A program on the Mac; on iOS, Emacs Lisp in `Elisp`, and nothing else. | |
| 694 | func run(_ job: BabelJob) async throws -> BabelOutput { | |
| 695 | #if os(macOS) | |
| 696 | return try await BabelRunner.run(job, fileDirectory: directory) | |
| 697 | #else | |
| 698 | guard let output = try Babel.evaluateNatively(job) else { | |
| 699 | throw Babel.Failure.message(job.lispForm == nil | |
| 700 | ? "\(job.language) blocks need the Mac to run." | |
| 701 | : "This block uses Emacs Lisp that runs only in Emacs, on the Mac.") | |
| 702 | } | |
| 703 | return output | |
| 704 | #endif | |
| 705 | } | |
| 694 | 706 | do { |
| 695 | 707 | // Blocks the `:var`s refer to run first, one at a time. |
| 696 | 708 | var job = job |
| 697 | 709 | while let need = job.needs.first { |
| 698 | let output = try await BabelRunner.run(need.job, fileDirectory: directory) | |
| 710 | let output = try await run(need.job) | |
| 699 | 711 | guard entries.contains(where: { $0 === entry }) else { return "The file was closed before the block finished." } |
| 700 | 712 | let document = entry.buffer.document |
| 701 | 713 | switch Babel.resume(job, need: need, output: output, text: document.text, tree: document.tree) { |
| @@ -703,7 +715,7 @@ public final class DocumentSession { | ||
| 703 | 715 | case .success(let next): job = next |
| 704 | 716 | } |
| 705 | 717 | } |
| 706 | let output = try await BabelRunner.run(job, fileDirectory: directory) | |
| 718 | let output = try await run(job) | |
| 707 | 719 | guard entries.contains(where: { $0 === entry }) else { return "The file was closed before the block finished." } |
| 708 | 720 | if let file = Babel.fileResult(job, output: output) { |
| 709 | 721 | let runDirectory = job.directory.map { URL(fileURLWithPath: $0, relativeTo: directory).standardizedFileURL } ?? directory |
| @@ -729,12 +741,11 @@ public final class DocumentSession { | ||
| 729 | 741 | } |
| 730 | 742 | } catch is CancellationError { |
| 731 | 743 | return "Code block canceled." |
| 744 | } catch Babel.Failure.message(let message) { | |
| 745 | return message | |
| 732 | 746 | } catch { |
| 733 | 747 | return "\(error)" |
| 734 | 748 | } |
| 735 | #else | |
| 736 | return "Source blocks don't run on iOS." | |
| 737 | #endif | |
| 738 | 749 | } |
| 739 | 750 | |
| 740 | 751 | /// The block running now, to cancel. |
Sources/OrgCore/Compute/Babel.swift +46 −2
| @@ -36,6 +36,8 @@ public struct BabelJob: Sendable, Equatable { | ||
| 36 | 36 | var resolved: [String: Babel.VarValue] = [:] |
| 37 | 37 | var caret = 0 |
| 38 | 38 | var options = Babel.Options() |
| 39 | /// An Emacs Lisp block's form, for `Babel.evaluateNatively`. | |
| 40 | public internal(set) var lispForm: String? | |
| 39 | 41 | |
| 40 | 42 | public struct Need: Sendable, Equatable { |
| 41 | 43 | /// The `:var` value, `name` or `name(args)`. |
| @@ -367,10 +369,12 @@ public enum Babel { | ||
| 367 | 369 | let bindings = vars.map { "(\($0.0) '\(lispLiteral($0.1)))" } |
| 368 | 370 | let expanded = bindings.isEmpty ? body + "\n" : "(let (\(bindings.joined(separator: "\n ")))\n\(body)\n)" |
| 369 | 371 | let form = output ? "(with-output-to-string \(expanded))" : "(progn \(expanded)\n)" |
| 370 | return .success(BabelJob( | |
| 372 | var job = BabelJob( | |
| 371 | 373 | language: language, runner: .emacs, input: emacsScript(form: form, params: params.results), |
| 372 | 374 | directory: dir, block: location, blockText: blockText, alwaysAsk: alwaysAsk, finish: finishBase(.emacsLisp) |
| 373 | )) | |
| 375 | ) | |
| 376 | job.lispForm = form | |
| 377 | return .success(job) | |
| 374 | 378 | } |
| 375 | 379 | if ["dot", "plantuml", "mermaid"].contains(language) { |
| 376 | 380 | // ob-dot, ob-plantuml and ob-mermaid: the program draws into `:file`. |
| @@ -939,6 +943,46 @@ public enum Babel { | ||
| 939 | 943 | """ |
| 940 | 944 | } |
| 941 | 945 | |
| 946 | /// An Emacs Lisp job run by `Elisp` instead of Emacs: the output `emacsScript` would print. | |
| 947 | /// A Lisp error fails the run, as it does in Emacs; nil when the block uses what `Elisp` | |
| 948 | /// doesn't have. | |
| 949 | public static func evaluateNatively(_ job: BabelJob) throws(Failure) -> BabelOutput? { | |
| 950 | guard let form = job.lispForm, let sexp = try? LispReader.readFirst(form).sexp else { return nil } | |
| 951 | let lisp = Elisp() | |
| 952 | let result: Sexp | |
| 953 | do { | |
| 954 | result = try lisp.eval(sexp) | |
| 955 | } catch is Elisp.Unsupported { | |
| 956 | return nil | |
| 957 | } catch { | |
| 958 | throw .message("\(error)") | |
| 959 | } | |
| 960 | func cell(_ c: Sexp) -> String { Elisp.printed(c) } | |
| 961 | func isProper(_ v: Sexp) -> Bool { | |
| 962 | if case .list = v { return true } | |
| 963 | return Elisp.isNil(v) | |
| 964 | } | |
| 965 | func isAtom(_ v: Sexp) -> Bool { | |
| 966 | switch v { | |
| 967 | case .list(let items): items.isEmpty | |
| 968 | case .dotted: false | |
| 969 | default: true | |
| 970 | } | |
| 971 | } | |
| 972 | var rows: [Any] = [] | |
| 973 | if case .list(let items) = result, items.allSatisfy({ isAtom($0) || isProper($0) }) { | |
| 974 | if items.allSatisfy({ $0 == .symbol("hline") || isProper($0) }) { | |
| 975 | rows = items.map { $0 == .symbol("hline") ? "hline" as Any : ((try? Elisp.elements($0)) ?? []).map(cell) as Any } | |
| 976 | } else { | |
| 977 | rows = [items.map(cell)] | |
| 978 | } | |
| 979 | } | |
| 980 | var object: [String: Any] = ["s": Elisp.printed(result), "S": Elisp.printed(result, escape: true), "string": result.string != nil] | |
| 981 | object["rows"] = rows.isEmpty ? NSNull() : rows | |
| 982 | guard let data = try? JSONSerialization.data(withJSONObject: object), let json = String(data: data, encoding: .utf8) else { return nil } | |
| 983 | return BabelOutput(stdout: lisp.standardOutput + json, stderr: lisp.messages) | |
| 984 | } | |
| 985 | ||
| 942 | 986 | // MARK: - Finishing |
| 943 | 987 | |
| 944 | 988 | /// What the run produced, as Babel holds it before inserting. |
Sources/OrgCore/Config/Elisp.swift +41 −1
| @@ -20,6 +20,13 @@ public final class Elisp { | ||
| 20 | 20 | private var functions: [String: Builtin] = [:] |
| 21 | 21 | private var steps = 0 |
| 22 | 22 | static let stepLimit = 1_000_000 |
| 23 | /// What `princ` and the other printers wrote: the innermost `with-output-to-string`, or | |
| 24 | /// standard output. | |
| 25 | private var outputs: [String] = [""] | |
| 26 | /// What went to standard output. | |
| 27 | public var standardOutput: String { outputs[0] } | |
| 28 | /// What `message` wrote. | |
| 29 | public private(set) var messages = "" | |
| 23 | 30 | |
| 24 | 31 | public init() { |
| 25 | 32 | Self.core(self) |
| @@ -135,7 +142,7 @@ public final class Elisp { | ||
| 135 | 142 | /// `prin1-to-string` with `princ` for strings when `escape` is false. |
| 136 | 143 | public static func printed(_ v: Sexp, escape: Bool = false) -> String { |
| 137 | 144 | switch v { |
| 138 | case .string(let s): return escape ? v.description : s | |
| 145 | case .string(let s): return escape ? "\"" + s.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\"") + "\"" : s | |
| 139 | 146 | case .float(let d): return floatText(d) |
| 140 | 147 | case .character(let c): return String(c) |
| 141 | 148 | case .list(let items): return "(" + items.map { printed($0, escape: true) }.joined(separator: " ") + ")" |
| @@ -290,6 +297,15 @@ public final class Elisp { | ||
| 290 | 297 | return try eval(spec[2]) |
| 291 | 298 | } |
| 292 | 299 | return .nil |
| 300 | case "with-output-to-string": | |
| 301 | outputs.append("") | |
| 302 | do { | |
| 303 | _ = try progn(args) | |
| 304 | } catch { | |
| 305 | outputs.removeLast() | |
| 306 | throw error | |
| 307 | } | |
| 308 | return .string(outputs.removeLast()) | |
| 293 | 309 | case "ignore-errors": |
| 294 | 310 | do { return try progn(args) } catch is Signal { return .nil } |
| 295 | 311 | case "condition-case": |
| @@ -623,6 +639,30 @@ public final class Elisp { | ||
| 623 | 639 | throw Signal(message: args.isEmpty ? "" : try format(args)) |
| 624 | 640 | } |
| 625 | 641 | |
| 642 | // Printing. | |
| 643 | for (name, escape) in [("princ", false), ("prin1", true), ("print", true)] { | |
| 644 | lisp.define(name) { lisp, args in | |
| 645 | try arity(args, 1...2, name) | |
| 646 | let text = printed(args[0], escape: escape) | |
| 647 | lisp.outputs[lisp.outputs.count - 1] += name == "print" ? "\n" + text + "\n" : text | |
| 648 | return args[0] | |
| 649 | } | |
| 650 | } | |
| 651 | lisp.define("terpri") { lisp, _ in | |
| 652 | lisp.outputs[lisp.outputs.count - 1] += "\n" | |
| 653 | return t | |
| 654 | } | |
| 655 | lisp.define("message") { lisp, args in | |
| 656 | guard !args.isEmpty, !isNil(args[0]) else { return .nil } | |
| 657 | let text = try format(args) | |
| 658 | lisp.messages += text + "\n" | |
| 659 | return .string(text) | |
| 660 | } | |
| 661 | lisp.define("prin1-to-string") { _, args in | |
| 662 | try arity(args, 1...2, "prin1-to-string") | |
| 663 | return .string(printed(args[0], escape: args.count < 2 || isNil(args[1]))) | |
| 664 | } | |
| 665 | ||
| 626 | 666 | // Strings. |
| 627 | 667 | lisp.define("concat") { _, args in .string(try args.map(printedString).joined()) } |
| 628 | 668 | lisp.define("format") { _, args in .string(try format(args)) } |
Sources/OrgstarMobile/EditorScreen.swift +34 −4
| @@ -17,6 +17,9 @@ struct EditorScreen: View { | ||
| 17 | 17 | @State private var prompt: PromptRequest? |
| 18 | 18 | @State private var message: String? |
| 19 | 19 | @State private var sheet: Sheet? |
| 20 | @State private var trust = TrustStore() | |
| 21 | /// The command chosen in the palette, run once the palette is gone. | |
| 22 | @State private var chosen: String? | |
| 20 | 23 | @Environment(ClockModel.self) private var clock |
| 21 | 24 | @Environment(MobileModel.self) private var model |
| 22 | 25 | @Environment(\.openURL) private var openURL |
| @@ -86,9 +89,15 @@ struct EditorScreen: View { | ||
| 86 | 89 | request.reply(answer) |
| 87 | 90 | } |
| 88 | 91 | } |
| 89 | .sheet(item: $sheet) { sheet in | |
| 92 | .sheet(item: $sheet, onDismiss: { | |
| 93 | // After the sheet has gone, so a command's own prompt can show. | |
| 94 | if let id = chosen { | |
| 95 | chosen = nil | |
| 96 | editor?.run(id) | |
| 97 | } | |
| 98 | }) { sheet in | |
| 90 | 99 | switch sheet { |
| 91 | case .palette: if let editor { CommandPalette(editor: editor) } | |
| 100 | case .palette: if let editor { CommandPalette(editor: editor) { chosen = $0 } } | |
| 92 | 101 | case .conflict: ConflictSheet(session: session) |
| 93 | 102 | case .syncCopies: SyncConflictSheet(session: session) { message = $0 } |
| 94 | 103 | case .recovery: RecoverySheet(session: session) { message = $0 } |
| @@ -115,6 +124,25 @@ struct EditorScreen: View { | ||
| 115 | 124 | } |
| 116 | 125 | } |
| 117 | 126 | |
| 127 | /// Runs a block once it's trusted, asking as `org-confirm-babel-evaluate` does. | |
| 128 | private func run(_ job: BabelJob) { | |
| 129 | let go = { | |
| 130 | message = "Running \(job.language) block…" | |
| 131 | session.startBabel(job) { message = $0 } | |
| 132 | } | |
| 133 | if !job.alwaysAsk, trust.isTrusted(path: path, content: job.blockText) { return go() } | |
| 134 | let choices = job.alwaysAsk ? ["yes", "no"] : ["yes", "no", "always"] | |
| 135 | prompt = PromptRequest(prompt: Prompt(key: "confirm", message: "Run this \(job.language) block?", choices: choices)) { answer in | |
| 136 | switch answer { | |
| 137 | case "always": | |
| 138 | trust.trust(path: path, content: job.blockText) | |
| 139 | go() | |
| 140 | case "yes": go() | |
| 141 | default: message = "Not run" | |
| 142 | } | |
| 143 | } | |
| 144 | } | |
| 145 | ||
| 118 | 146 | private func start() { |
| 119 | 147 | if session.path != path { session.open(path) } |
| 120 | 148 | if editor == nil, let document = session.document { |
| @@ -133,6 +161,7 @@ struct EditorScreen: View { | ||
| 133 | 161 | } |
| 134 | 162 | editor.onEditBlock = { edit in sheet = .block(edit) } |
| 135 | 163 | editor.onTangle = { scope in message = session.tangle(scope) } |
| 164 | editor.onBabel = { job in run(job) } | |
| 136 | 165 | editor.onAgenda = { start, count in model.agendaRequest = .init(start: start, count: count) } |
| 137 | 166 | editor.onAppCommand = { id in |
| 138 | 167 | switch id { |
| @@ -362,6 +391,8 @@ struct BlockEditSheet: View { | ||
| 362 | 391 | /// Every command that applies at the caret, by name. |
| 363 | 392 | struct CommandPalette: View { |
| 364 | 393 | let editor: MobileEditor |
| 394 | /// Called with the chosen command before the palette closes. | |
| 395 | let choose: (String) -> Void | |
| 365 | 396 | @State private var query = "" |
| 366 | 397 | @Environment(\.dismiss) private var dismiss |
| 367 | 398 | |
| @@ -379,9 +410,8 @@ struct CommandPalette: View { | ||
| 379 | 410 | NavigationStack { |
| 380 | 411 | List(shown, id: \.id) { command in |
| 381 | 412 | Button { |
| 413 | choose(command.id) | |
| 382 | 414 | dismiss() |
| 383 | // After the sheet goes, so a command's own prompt can show. | |
| 384 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { editor.run(command.id) } | |
| 385 | 415 | } label: { |
| 386 | 416 | HStack { |
| 387 | 417 | Text(command.title).foregroundStyle(.primary) |
Sources/OrgstarMobile/MobileEditor.swift +4
| @@ -64,6 +64,8 @@ final class MobileEditor: NSObject, DocumentBuffer { | ||
| 64 | 64 | private var applyingEdits = false |
| 65 | 65 | /// Source blocks to write to their files (C-c C-v t). |
| 66 | 66 | var onTangle: ((Tangle.Scope) -> Void)? |
| 67 | /// A source block to run (C-c C-c). | |
| 68 | var onBabel: ((BabelJob) -> Void)? | |
| 67 | 69 | /// A block, a table's formulas or a field to edit apart (C-c ', C-c `). |
| 68 | 70 | var onEditBlock: ((BlockEdit) -> Void)? |
| 69 | 71 | /// A followed timestamp's agenda days: the first (a `Days` number) and how many. |
| @@ -168,6 +170,8 @@ final class MobileEditor: NSObject, DocumentBuffer { | ||
| 168 | 170 | } |
| 169 | 171 | case .external(.editBlock(let edit)) where onEditBlock != nil: |
| 170 | 172 | onEditBlock?(edit) |
| 173 | case .external(.babel(let job)) where onBabel != nil: | |
| 174 | onBabel?(job) | |
| 171 | 175 | case .external(.tangle(let scope)) where onTangle != nil: |
| 172 | 176 | onTangle?(scope) |
| 173 | 177 | case .external(.agenda(let start, let count)) where onAgenda != nil: |
Tests/OrgCoreTests/BabelTests.swift +65 −2
| @@ -3,6 +3,8 @@ import Testing | ||
| 3 | 3 | @testable import OrgCore |
| 4 | 4 | |
| 5 | 5 | /// Runs a Babel job the way the app does, for tests. |
| 6 | struct NativeUnsupported: Error {} | |
| 7 | ||
| 6 | 8 | enum TestBabelRunner { |
| 7 | 9 | static func run(_ job: BabelJob, directory: URL) throws -> BabelOutput { |
| 8 | 10 | let folder = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-babel-\(UUID().uuidString)") |
| @@ -37,7 +39,13 @@ enum TestBabelRunner { | ||
| 37 | 39 | } |
| 38 | 40 | |
| 39 | 41 | /// Plans, runs and finishes the block at `caret`: the new text, or the failure. |
| 40 | static func execute(_ text: String, caret: Int, directory: URL) throws -> (text: String, failure: String?) { | |
| 42 | /// `native` runs Emacs Lisp blocks with `Babel.evaluateNatively` instead of Emacs. | |
| 43 | static func execute(_ text: String, caret: Int, directory: URL, native: Bool = false) throws -> (text: String, failure: String?) { | |
| 44 | func run(_ job: BabelJob, directory: URL) throws -> BabelOutput { | |
| 45 | guard native else { return try TestBabelRunner.run(job, directory: directory) } | |
| 46 | guard let output = try Babel.evaluateNatively(job) else { throw NativeUnsupported() } | |
| 47 | return output | |
| 48 | } | |
| 41 | 49 | let context = EditContext(revision: 0, text: text, tree: OrgParser.parse(text), selection: [caret..<caret]) |
| 42 | 50 | switch Babel.plan(context) { |
| 43 | 51 | case .failure(.message(let message)): return (text, message) |
| @@ -48,7 +56,13 @@ enum TestBabelRunner { | ||
| 48 | 56 | case .success(let next): job = next |
| 49 | 57 | } |
| 50 | 58 | } |
| 51 | let output = try run(job, directory: directory) | |
| 59 | let output: BabelOutput | |
| 60 | do { | |
| 61 | output = try run(job, directory: directory) | |
| 62 | } catch let failure as Babel.Failure { | |
| 63 | guard case .message(let message) = failure else { return (text, nil) } | |
| 64 | return (text, message) | |
| 65 | } | |
| 52 | 66 | if let file = Babel.fileResult(job, output: output) { |
| 53 | 67 | try file.contents.write(to: directory.appendingPathComponent(file.path), atomically: true, encoding: .utf8) |
| 54 | 68 | } |
| @@ -153,6 +167,55 @@ struct BabelTests { | ||
| 153 | 167 | } |
| 154 | 168 | } |
| 155 | 169 | |
| 170 | static let lispCases = [ | |
| 171 | "#+begin_src emacs-lisp\n(list 1 2.5 \"three\" 'four)\n#+end_src\n", | |
| 172 | "#+begin_src emacs-lisp\n(list '(a 1) 'hline '(b 2))\n#+end_src\n", | |
| 173 | "#+begin_src emacs-lisp\n(format \"%05.2f|%s\" 3.14159 'sym)\n#+end_src\n", | |
| 174 | "#+begin_src emacs-lisp\n\"line one\\nline \\\"two\\\"\"\n#+end_src\n", | |
| 175 | "#+begin_src emacs-lisp :results verbatim\n\"with \\\"quotes\\\" and \\\\ slash\"\n#+end_src\n", | |
| 176 | "#+begin_src emacs-lisp\nnil\n#+end_src\n", | |
| 177 | "#+begin_src emacs-lisp\nt\n#+end_src\n", | |
| 178 | "#+begin_src emacs-lisp\n[1 2 3]\n#+end_src\n", | |
| 179 | "#+begin_src emacs-lisp\n(cons 1 2)\n#+end_src\n", | |
| 180 | "#+begin_src emacs-lisp\n(/ 10 4.0)\n#+end_src\n", | |
| 181 | "#+begin_src emacs-lisp\n(let ((s 0)) (dotimes (i 5) (setq s (+ s i))) s)\n#+end_src\n", | |
| 182 | "#+begin_src emacs-lisp\n(mapcar (lambda (x) (* x x)) '(1 2 3))\n#+end_src\n", | |
| 183 | "#+begin_src emacs-lisp :results output\n(princ \"a\")\n(terpri)\n(prin1 \"b\")\n(print 'c)\n#+end_src\n", | |
| 184 | "#+begin_src emacs-lisp :results value scalar\n'(1 2)\n#+end_src\n", | |
| 185 | "#+begin_src emacs-lisp :results list\n'(\"x\" \"y\")\n#+end_src\n", | |
| 186 | "#+NAME: tbl\n| 1 | 2 |\n| 3 | 4 |\n\n#+begin_src emacs-lisp :var rows=tbl\n(mapcar (lambda (r) (apply #'+ r)) rows)\n#+end_src\n", | |
| 187 | "#+begin_src emacs-lisp :var n=6 :var s=\"hi\"\n(concat s (number-to-string (* n 7)))\n#+end_src\n", | |
| 188 | "#+begin_src emacs-lisp\n(car 1)\n#+end_src\n", | |
| 189 | "#+begin_src emacs-lisp :results raw\n\"*bold*\"\n#+end_src\n", | |
| 190 | "#+begin_src emacs-lisp :wrap example\n(upcase \"wrapped\")\n#+end_src\n", | |
| 191 | ] | |
| 192 | ||
| 193 | @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil)) | |
| 194 | func lispRunsNativelyLikeEmacs() throws { | |
| 195 | let folder = FileManager.default.temporaryDirectory | |
| 196 | let texts = Self.lispCases + Self.cases.filter { ($0.contains("emacs-lisp") || $0.contains("begin_src elisp")) && !$0.contains("begin_src sh") } | |
| 197 | var emacsCases: [EmacsOracle.Case] = [] | |
| 198 | var ours: [(text: String, failure: String?)] = [] | |
| 199 | for text in texts { | |
| 200 | let caret = (text as NSString).range(of: "#+begin_src").location + 2 | |
| 201 | do { | |
| 202 | ours.append(try TestBabelRunner.execute(text, caret: caret, directory: folder, native: true)) | |
| 203 | } catch is NativeUnsupported { | |
| 204 | Issue.record("not run natively:\n\(text)") | |
| 205 | ours.append((text, "unsupported")) | |
| 206 | } | |
| 207 | emacsCases.append(EmacsOracle.Case(text: text, point: EmacsOracle.point(caret, in: text), form: """ | |
| 208 | (progn | |
| 209 | (setq org-confirm-babel-evaluate nil) | |
| 210 | (org-babel-execute-src-block)) | |
| 211 | """)) | |
| 212 | } | |
| 213 | let emacs = try EmacsOracle.run(emacsCases) | |
| 214 | for (i, (mine, theirs)) in zip(ours, emacs).enumerated() { | |
| 215 | #expect(mine.text == theirs.text, "case \(i):\n\(texts[i])\nours (\(mine.failure ?? "")):\n\(mine.text)\nemacs (\(theirs.error)):\n\(theirs.text)") | |
| 216 | } | |
| 217 | } | |
| 218 | ||
| 156 | 219 | @Test func asksOnceForEveryReferencedBlock() throws { |
| 157 | 220 | let text = BabelTests.cases.first { $0.contains(":var a=outer") }! |
| 158 | 221 | guard case .success(let job) = Babel.plan(EditContext(revision: 0, text: text, tree: OrgParser.parse(text), selection: [2..<2])) else { |