krz/orgstar

A native macOS editor for org-mode files. editor org-mode swift

Commit 46d6a046f5

46d6a046f519e5ab5a9338ff643341bd53e7788f

parent: 5bb13b8565

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-07 07:29 UTC

Run Emacs Lisp blocks on iOS

Emacs Lisp blocks run in the Lisp interpreter on iOS, which writes
what the Emacs script would print, so results are inserted the same
way; a Lisp error fails the run as in Emacs. The interpreter gains
princ, prin1, print, terpri, message, prin1-to-string and
with-output-to-string. Other languages, and Lisp the interpreter
doesn't have, say they need the Mac. Runs are confirmed as on the
Mac.

The command palette now runs the chosen command once it has closed,
not after a fixed delay, which crashed when the command's prompt
arrived while the palette and its keyboard were still going.

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 {