Run Emacs Lisp blocks on iOS !135

merged merged by cmc on 2026-10-07 07:29 UTC · krz/orgstar:ios-elisp into main

6 files changed, +207 −15

Layout: unified · split

Sources/OrgApp/DocumentSession.swift +17 −6
@@ -688,14 +688,26 @@ public final class DocumentSession {
688688 /// Runs a src block and writes its result under it, unless the block changed while it
689689 /// ran. Returns a message for the echo area.
690690 public func runBabel(_ job: BabelJob) async -> String {
691 #if os(macOS)
692691 guard let entry = current else { return "No file is open." }
693692 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 }
694706 do {
695707 // Blocks the `:var`s refer to run first, one at a time.
696708 var job = job
697709 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)
699711 guard entries.contains(where: { $0 === entry }) else { return "The file was closed before the block finished." }
700712 let document = entry.buffer.document
701713 switch Babel.resume(job, need: need, output: output, text: document.text, tree: document.tree) {
@@ -703,7 +715,7 @@ public final class DocumentSession {
703715 case .success(let next): job = next
704716 }
705717 }
706 let output = try await BabelRunner.run(job, fileDirectory: directory)
718 let output = try await run(job)
707719 guard entries.contains(where: { $0 === entry }) else { return "The file was closed before the block finished." }
708720 if let file = Babel.fileResult(job, output: output) {
709721 let runDirectory = job.directory.map { URL(fileURLWithPath: $0, relativeTo: directory).standardizedFileURL } ?? directory
@@ -729,12 +741,11 @@ public final class DocumentSession {
729741 }
730742 } catch is CancellationError {
731743 return "Code block canceled."
744 } catch Babel.Failure.message(let message) {
745 return message
732746 } catch {
733747 return "\(error)"
734748 }
735 #else
736 return "Source blocks don't run on iOS."
737 #endif
738749 }
739750
740751 /// The block running now, to cancel.
Sources/OrgCore/Compute/Babel.swift +46 −2
@@ -36,6 +36,8 @@ public struct BabelJob: Sendable, Equatable {
3636 var resolved: [String: Babel.VarValue] = [:]
3737 var caret = 0
3838 var options = Babel.Options()
39 /// An Emacs Lisp block's form, for `Babel.evaluateNatively`.
40 public internal(set) var lispForm: String?
3941
4042 public struct Need: Sendable, Equatable {
4143 /// The `:var` value, `name` or `name(args)`.
@@ -367,10 +369,12 @@ public enum Babel {
367369 let bindings = vars.map { "(\($0.0) '\(lispLiteral($0.1)))" }
368370 let expanded = bindings.isEmpty ? body + "\n" : "(let (\(bindings.joined(separator: "\n ")))\n\(body)\n)"
369371 let form = output ? "(with-output-to-string \(expanded))" : "(progn \(expanded)\n)"
370 return .success(BabelJob(
372 var job = BabelJob(
371373 language: language, runner: .emacs, input: emacsScript(form: form, params: params.results),
372374 directory: dir, block: location, blockText: blockText, alwaysAsk: alwaysAsk, finish: finishBase(.emacsLisp)
373 ))
375 )
376 job.lispForm = form
377 return .success(job)
374378 }
375379 if ["dot", "plantuml", "mermaid"].contains(language) {
376380 // ob-dot, ob-plantuml and ob-mermaid: the program draws into `:file`.
@@ -939,6 +943,46 @@ public enum Babel {
939943 """
940944 }
941945
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
942986 // MARK: - Finishing
943987
944988 /// 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 {
2020 private var functions: [String: Builtin] = [:]
2121 private var steps = 0
2222 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 = ""
2330
2431 public init() {
2532 Self.core(self)
@@ -135,7 +142,7 @@ public final class Elisp {
135142 /// `prin1-to-string` with `princ` for strings when `escape` is false.
136143 public static func printed(_ v: Sexp, escape: Bool = false) -> String {
137144 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
139146 case .float(let d): return floatText(d)
140147 case .character(let c): return String(c)
141148 case .list(let items): return "(" + items.map { printed($0, escape: true) }.joined(separator: " ") + ")"
@@ -290,6 +297,15 @@ public final class Elisp {
290297 return try eval(spec[2])
291298 }
292299 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())
293309 case "ignore-errors":
294310 do { return try progn(args) } catch is Signal { return .nil }
295311 case "condition-case":
@@ -623,6 +639,30 @@ public final class Elisp {
623639 throw Signal(message: args.isEmpty ? "" : try format(args))
624640 }
625641
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
626666 // Strings.
627667 lisp.define("concat") { _, args in .string(try args.map(printedString).joined()) }
628668 lisp.define("format") { _, args in .string(try format(args)) }
Sources/OrgstarMobile/EditorScreen.swift +34 −4
@@ -17,6 +17,9 @@ struct EditorScreen: View {
1717 @State private var prompt: PromptRequest?
1818 @State private var message: String?
1919 @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?
2023 @Environment(ClockModel.self) private var clock
2124 @Environment(MobileModel.self) private var model
2225 @Environment(\.openURL) private var openURL
@@ -86,9 +89,15 @@ struct EditorScreen: View {
8689 request.reply(answer)
8790 }
8891 }
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
9099 switch sheet {
91 case .palette: if let editor { CommandPalette(editor: editor) }
100 case .palette: if let editor { CommandPalette(editor: editor) { chosen = $0 } }
92101 case .conflict: ConflictSheet(session: session)
93102 case .syncCopies: SyncConflictSheet(session: session) { message = $0 }
94103 case .recovery: RecoverySheet(session: session) { message = $0 }
@@ -115,6 +124,25 @@ struct EditorScreen: View {
115124 }
116125 }
117126
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
118146 private func start() {
119147 if session.path != path { session.open(path) }
120148 if editor == nil, let document = session.document {
@@ -133,6 +161,7 @@ struct EditorScreen: View {
133161 }
134162 editor.onEditBlock = { edit in sheet = .block(edit) }
135163 editor.onTangle = { scope in message = session.tangle(scope) }
164 editor.onBabel = { job in run(job) }
136165 editor.onAgenda = { start, count in model.agendaRequest = .init(start: start, count: count) }
137166 editor.onAppCommand = { id in
138167 switch id {
@@ -362,6 +391,8 @@ struct BlockEditSheet: View {
362391/// Every command that applies at the caret, by name.
363392struct CommandPalette: View {
364393 let editor: MobileEditor
394 /// Called with the chosen command before the palette closes.
395 let choose: (String) -> Void
365396 @State private var query = ""
366397 @Environment(\.dismiss) private var dismiss
367398
@@ -379,9 +410,8 @@ struct CommandPalette: View {
379410 NavigationStack {
380411 List(shown, id: \.id) { command in
381412 Button {
413 choose(command.id)
382414 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) }
385415 } label: {
386416 HStack {
387417 Text(command.title).foregroundStyle(.primary)
Sources/OrgstarMobile/MobileEditor.swift +4
@@ -64,6 +64,8 @@ final class MobileEditor: NSObject, DocumentBuffer {
6464 private var applyingEdits = false
6565 /// Source blocks to write to their files (C-c C-v t).
6666 var onTangle: ((Tangle.Scope) -> Void)?
67 /// A source block to run (C-c C-c).
68 var onBabel: ((BabelJob) -> Void)?
6769 /// A block, a table's formulas or a field to edit apart (C-c ', C-c `).
6870 var onEditBlock: ((BlockEdit) -> Void)?
6971 /// A followed timestamp's agenda days: the first (a `Days` number) and how many.
@@ -168,6 +170,8 @@ final class MobileEditor: NSObject, DocumentBuffer {
168170 }
169171 case .external(.editBlock(let edit)) where onEditBlock != nil:
170172 onEditBlock?(edit)
173 case .external(.babel(let job)) where onBabel != nil:
174 onBabel?(job)
171175 case .external(.tangle(let scope)) where onTangle != nil:
172176 onTangle?(scope)
173177 case .external(.agenda(let start, let count)) where onAgenda != nil:
Tests/OrgCoreTests/BabelTests.swift +65 −2
@@ -3,6 +3,8 @@ import Testing
33@testable import OrgCore
44
55/// Runs a Babel job the way the app does, for tests.
6struct NativeUnsupported: Error {}
7
68enum TestBabelRunner {
79 static func run(_ job: BabelJob, directory: URL) throws -> BabelOutput {
810 let folder = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-babel-\(UUID().uuidString)")
@@ -37,7 +39,13 @@ enum TestBabelRunner {
3739 }
3840
3941 /// 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 }
4149 let context = EditContext(revision: 0, text: text, tree: OrgParser.parse(text), selection: [caret..<caret])
4250 switch Babel.plan(context) {
4351 case .failure(.message(let message)): return (text, message)
@@ -48,7 +56,13 @@ enum TestBabelRunner {
4856 case .success(let next): job = next
4957 }
5058 }
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 }
5266 if let file = Babel.fileResult(job, output: output) {
5367 try file.contents.write(to: directory.appendingPathComponent(file.path), atomically: true, encoding: .utf8)
5468 }
@@ -153,6 +167,55 @@ struct BabelTests {
153167 }
154168 }
155169
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
156219 @Test func asksOnceForEveryReferencedBlock() throws {
157220 let text = BabelTests.cases.first { $0.contains(":var a=outer") }!
158221 guard case .success(let job) = Babel.plan(EditContext(revision: 0, text: text, tree: OrgParser.parse(text), selection: [2..<2])) else {