Run Emacs Lisp blocks on iOS !135
6 files changed, +207 −15
Layout: unified · split
Sources/OrgApp/DocumentSession.swift +17 −6
| @@ -688,14 +688,26 @@ public final class DocumentSession { | |||
| 688 | /// Runs a src block and writes its result under it, unless the block changed while it | 688 | /// Runs a src block and writes its result under it, unless the block changed while it |
| 689 | /// ran. Returns a message for the echo area. | 689 | /// ran. Returns a message for the echo area. |
| 690 | public func runBabel(_ job: BabelJob) async -> String { | 690 | public func runBabel(_ job: BabelJob) async -> String { |
| 691 | #if os(macOS) | ||
| 692 | guard let entry = current else { return "No file is open." } | 691 | guard let entry = current else { return "No file is open." } |
| 693 | let directory = URL(fileURLWithPath: entry.path).deletingLastPathComponent() | 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 | do { | 706 | do { |
| 695 | // Blocks the `:var`s refer to run first, one at a time. | 707 | // Blocks the `:var`s refer to run first, one at a time. |
| 696 | var job = job | 708 | var job = job |
| 697 | while let need = job.needs.first { | 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 | guard entries.contains(where: { $0 === entry }) else { return "The file was closed before the block finished." } | 711 | guard entries.contains(where: { $0 === entry }) else { return "The file was closed before the block finished." } |
| 700 | let document = entry.buffer.document | 712 | let document = entry.buffer.document |
| 701 | switch Babel.resume(job, need: need, output: output, text: document.text, tree: document.tree) { | 713 | switch Babel.resume(job, need: need, output: output, text: document.text, tree: document.tree) { |
| @@ -703,7 +715,7 @@ public final class DocumentSession { | |||
| 703 | case .success(let next): job = next | 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 | guard entries.contains(where: { $0 === entry }) else { return "The file was closed before the block finished." } | 719 | guard entries.contains(where: { $0 === entry }) else { return "The file was closed before the block finished." } |
| 708 | if let file = Babel.fileResult(job, output: output) { | 720 | if let file = Babel.fileResult(job, output: output) { |
| 709 | let runDirectory = job.directory.map { URL(fileURLWithPath: $0, relativeTo: directory).standardizedFileURL } ?? directory | 721 | let runDirectory = job.directory.map { URL(fileURLWithPath: $0, relativeTo: directory).standardizedFileURL } ?? directory |
| @@ -729,12 +741,11 @@ public final class DocumentSession { | |||
| 729 | } | 741 | } |
| 730 | } catch is CancellationError { | 742 | } catch is CancellationError { |
| 731 | return "Code block canceled." | 743 | return "Code block canceled." |
| 744 | } catch Babel.Failure.message(let message) { | ||
| 745 | return message | ||
| 732 | } catch { | 746 | } catch { |
| 733 | return "\(error)" | 747 | return "\(error)" |
| 734 | } | 748 | } |
| 735 | #else | ||
| 736 | return "Source blocks don't run on iOS." | ||
| 737 | #endif | ||
| 738 | } | 749 | } |
| 739 | 750 | ||
| 740 | /// The block running now, to cancel. | 751 | /// The block running now, to cancel. |
Sources/OrgCore/Compute/Babel.swift +46 −2
| @@ -36,6 +36,8 @@ public struct BabelJob: Sendable, Equatable { | |||
| 36 | var resolved: [String: Babel.VarValue] = [:] | 36 | var resolved: [String: Babel.VarValue] = [:] |
| 37 | var caret = 0 | 37 | var caret = 0 |
| 38 | var options = Babel.Options() | 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 | public struct Need: Sendable, Equatable { | 42 | public struct Need: Sendable, Equatable { |
| 41 | /// The `:var` value, `name` or `name(args)`. | 43 | /// The `:var` value, `name` or `name(args)`. |
| @@ -367,10 +369,12 @@ public enum Babel { | |||
| 367 | let bindings = vars.map { "(\($0.0) '\(lispLiteral($0.1)))" } | 369 | let bindings = vars.map { "(\($0.0) '\(lispLiteral($0.1)))" } |
| 368 | let expanded = bindings.isEmpty ? body + "\n" : "(let (\(bindings.joined(separator: "\n ")))\n\(body)\n)" | 370 | let expanded = bindings.isEmpty ? body + "\n" : "(let (\(bindings.joined(separator: "\n ")))\n\(body)\n)" |
| 369 | let form = output ? "(with-output-to-string \(expanded))" : "(progn \(expanded)\n)" | 371 | let form = output ? "(with-output-to-string \(expanded))" : "(progn \(expanded)\n)" |
| 370 | return .success(BabelJob( | 372 | var job = BabelJob( |
| 371 | language: language, runner: .emacs, input: emacsScript(form: form, params: params.results), | 373 | language: language, runner: .emacs, input: emacsScript(form: form, params: params.results), |
| 372 | directory: dir, block: location, blockText: blockText, alwaysAsk: alwaysAsk, finish: finishBase(.emacsLisp) | 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 | if ["dot", "plantuml", "mermaid"].contains(language) { | 379 | if ["dot", "plantuml", "mermaid"].contains(language) { |
| 376 | // ob-dot, ob-plantuml and ob-mermaid: the program draws into `:file`. | 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 | // MARK: - Finishing | 986 | // MARK: - Finishing |
| 943 | 987 | ||
| 944 | /// What the run produced, as Babel holds it before inserting. | 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 | private var functions: [String: Builtin] = [:] | 20 | private var functions: [String: Builtin] = [:] |
| 21 | private var steps = 0 | 21 | private var steps = 0 |
| 22 | static let stepLimit = 1_000_000 | 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 | public init() { | 31 | public init() { |
| 25 | Self.core(self) | 32 | Self.core(self) |
| @@ -135,7 +142,7 @@ public final class Elisp { | |||
| 135 | /// `prin1-to-string` with `princ` for strings when `escape` is false. | 142 | /// `prin1-to-string` with `princ` for strings when `escape` is false. |
| 136 | public static func printed(_ v: Sexp, escape: Bool = false) -> String { | 143 | public static func printed(_ v: Sexp, escape: Bool = false) -> String { |
| 137 | switch v { | 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 | case .float(let d): return floatText(d) | 146 | case .float(let d): return floatText(d) |
| 140 | case .character(let c): return String(c) | 147 | case .character(let c): return String(c) |
| 141 | case .list(let items): return "(" + items.map { printed($0, escape: true) }.joined(separator: " ") + ")" | 148 | case .list(let items): return "(" + items.map { printed($0, escape: true) }.joined(separator: " ") + ")" |
| @@ -290,6 +297,15 @@ public final class Elisp { | |||
| 290 | return try eval(spec[2]) | 297 | return try eval(spec[2]) |
| 291 | } | 298 | } |
| 292 | return .nil | 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 | case "ignore-errors": | 309 | case "ignore-errors": |
| 294 | do { return try progn(args) } catch is Signal { return .nil } | 310 | do { return try progn(args) } catch is Signal { return .nil } |
| 295 | case "condition-case": | 311 | case "condition-case": |
| @@ -623,6 +639,30 @@ public final class Elisp { | |||
| 623 | throw Signal(message: args.isEmpty ? "" : try format(args)) | 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 | // Strings. | 666 | // Strings. |
| 627 | lisp.define("concat") { _, args in .string(try args.map(printedString).joined()) } | 667 | lisp.define("concat") { _, args in .string(try args.map(printedString).joined()) } |
| 628 | lisp.define("format") { _, args in .string(try format(args)) } | 668 | lisp.define("format") { _, args in .string(try format(args)) } |
Sources/OrgstarMobile/EditorScreen.swift +34 −4
| @@ -17,6 +17,9 @@ struct EditorScreen: View { | |||
| 17 | @State private var prompt: PromptRequest? | 17 | @State private var prompt: PromptRequest? |
| 18 | @State private var message: String? | 18 | @State private var message: String? |
| 19 | @State private var sheet: Sheet? | 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 | @Environment(ClockModel.self) private var clock | 23 | @Environment(ClockModel.self) private var clock |
| 21 | @Environment(MobileModel.self) private var model | 24 | @Environment(MobileModel.self) private var model |
| 22 | @Environment(\.openURL) private var openURL | 25 | @Environment(\.openURL) private var openURL |
| @@ -86,9 +89,15 @@ struct EditorScreen: View { | |||
| 86 | request.reply(answer) | 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 | switch sheet { | 99 | switch sheet { |
| 91 | case .palette: if let editor { CommandPalette(editor: editor) } | 100 | case .palette: if let editor { CommandPalette(editor: editor) { chosen = $0 } } |
| 92 | case .conflict: ConflictSheet(session: session) | 101 | case .conflict: ConflictSheet(session: session) |
| 93 | case .syncCopies: SyncConflictSheet(session: session) { message = $0 } | 102 | case .syncCopies: SyncConflictSheet(session: session) { message = $0 } |
| 94 | case .recovery: RecoverySheet(session: session) { message = $0 } | 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 | private func start() { | 146 | private func start() { |
| 119 | if session.path != path { session.open(path) } | 147 | if session.path != path { session.open(path) } |
| 120 | if editor == nil, let document = session.document { | 148 | if editor == nil, let document = session.document { |
| @@ -133,6 +161,7 @@ struct EditorScreen: View { | |||
| 133 | } | 161 | } |
| 134 | editor.onEditBlock = { edit in sheet = .block(edit) } | 162 | editor.onEditBlock = { edit in sheet = .block(edit) } |
| 135 | editor.onTangle = { scope in message = session.tangle(scope) } | 163 | editor.onTangle = { scope in message = session.tangle(scope) } |
| 164 | editor.onBabel = { job in run(job) } | ||
| 136 | editor.onAgenda = { start, count in model.agendaRequest = .init(start: start, count: count) } | 165 | editor.onAgenda = { start, count in model.agendaRequest = .init(start: start, count: count) } |
| 137 | editor.onAppCommand = { id in | 166 | editor.onAppCommand = { id in |
| 138 | switch id { | 167 | switch id { |
| @@ -362,6 +391,8 @@ struct BlockEditSheet: View { | |||
| 362 | /// Every command that applies at the caret, by name. | 391 | /// Every command that applies at the caret, by name. |
| 363 | struct CommandPalette: View { | 392 | struct CommandPalette: View { |
| 364 | let editor: MobileEditor | 393 | let editor: MobileEditor |
| 394 | /// Called with the chosen command before the palette closes. | ||
| 395 | let choose: (String) -> Void | ||
| 365 | @State private var query = "" | 396 | @State private var query = "" |
| 366 | @Environment(\.dismiss) private var dismiss | 397 | @Environment(\.dismiss) private var dismiss |
| 367 | 398 | ||
| @@ -379,9 +410,8 @@ struct CommandPalette: View { | |||
| 379 | NavigationStack { | 410 | NavigationStack { |
| 380 | List(shown, id: \.id) { command in | 411 | List(shown, id: \.id) { command in |
| 381 | Button { | 412 | Button { |
| 413 | choose(command.id) | ||
| 382 | dismiss() | 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 | } label: { | 415 | } label: { |
| 386 | HStack { | 416 | HStack { |
| 387 | Text(command.title).foregroundStyle(.primary) | 417 | Text(command.title).foregroundStyle(.primary) |
Sources/OrgstarMobile/MobileEditor.swift +4
| @@ -64,6 +64,8 @@ final class MobileEditor: NSObject, DocumentBuffer { | |||
| 64 | private var applyingEdits = false | 64 | private var applyingEdits = false |
| 65 | /// Source blocks to write to their files (C-c C-v t). | 65 | /// Source blocks to write to their files (C-c C-v t). |
| 66 | var onTangle: ((Tangle.Scope) -> Void)? | 66 | var onTangle: ((Tangle.Scope) -> Void)? |
| 67 | /// A source block to run (C-c C-c). | ||
| 68 | var onBabel: ((BabelJob) -> Void)? | ||
| 67 | /// A block, a table's formulas or a field to edit apart (C-c ', C-c `). | 69 | /// A block, a table's formulas or a field to edit apart (C-c ', C-c `). |
| 68 | var onEditBlock: ((BlockEdit) -> Void)? | 70 | var onEditBlock: ((BlockEdit) -> Void)? |
| 69 | /// A followed timestamp's agenda days: the first (a `Days` number) and how many. | 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 | case .external(.editBlock(let edit)) where onEditBlock != nil: | 171 | case .external(.editBlock(let edit)) where onEditBlock != nil: |
| 170 | onEditBlock?(edit) | 172 | onEditBlock?(edit) |
| 173 | case .external(.babel(let job)) where onBabel != nil: | ||
| 174 | onBabel?(job) | ||
| 171 | case .external(.tangle(let scope)) where onTangle != nil: | 175 | case .external(.tangle(let scope)) where onTangle != nil: |
| 172 | onTangle?(scope) | 176 | onTangle?(scope) |
| 173 | case .external(.agenda(let start, let count)) where onAgenda != nil: | 177 | case .external(.agenda(let start, let count)) where onAgenda != nil: |
Tests/OrgCoreTests/BabelTests.swift +65 −2
| @@ -3,6 +3,8 @@ import Testing | |||
| 3 | @testable import OrgCore | 3 | @testable import OrgCore |
| 4 | 4 | ||
| 5 | /// Runs a Babel job the way the app does, for tests. | 5 | /// Runs a Babel job the way the app does, for tests. |
| 6 | struct NativeUnsupported: Error {} | ||
| 7 | |||
| 6 | enum TestBabelRunner { | 8 | enum TestBabelRunner { |
| 7 | static func run(_ job: BabelJob, directory: URL) throws -> BabelOutput { | 9 | static func run(_ job: BabelJob, directory: URL) throws -> BabelOutput { |
| 8 | let folder = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-babel-\(UUID().uuidString)") | 10 | let folder = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-babel-\(UUID().uuidString)") |
| @@ -37,7 +39,13 @@ enum TestBabelRunner { | |||
| 37 | } | 39 | } |
| 38 | 40 | ||
| 39 | /// Plans, runs and finishes the block at `caret`: the new text, or the failure. | 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 | let context = EditContext(revision: 0, text: text, tree: OrgParser.parse(text), selection: [caret..<caret]) | 49 | let context = EditContext(revision: 0, text: text, tree: OrgParser.parse(text), selection: [caret..<caret]) |
| 42 | switch Babel.plan(context) { | 50 | switch Babel.plan(context) { |
| 43 | case .failure(.message(let message)): return (text, message) | 51 | case .failure(.message(let message)): return (text, message) |
| @@ -48,7 +56,13 @@ enum TestBabelRunner { | |||
| 48 | case .success(let next): job = next | 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 | if let file = Babel.fileResult(job, output: output) { | 66 | if let file = Babel.fileResult(job, output: output) { |
| 53 | try file.contents.write(to: directory.appendingPathComponent(file.path), atomically: true, encoding: .utf8) | 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 | @Test func asksOnceForEveryReferencedBlock() throws { | 219 | @Test func asksOnceForEveryReferencedBlock() throws { |
| 157 | let text = BabelTests.cases.first { $0.contains(":var a=outer") }! | 220 | let text = BabelTests.cases.first { $0.contains(":var a=outer") }! |
| 158 | guard case .success(let job) = Babel.plan(EditContext(revision: 0, text: text, tree: OrgParser.parse(text), selection: [2..<2])) else { | 221 | guard case .success(let job) = Babel.plan(EditContext(revision: 0, text: text, tree: OrgParser.parse(text), selection: [2..<2])) else { |