krz/orgstar

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

Commit 6c2b0142ae

6c2b0142aedbcc4495fceadee8750ecb9a790e34

parent: 24ce0772ea

Verified · cmc

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

Babel

C-c C-c in a src block runs sh and other shells, python, emacs-lisp,
and ruby/js/R/awk for output, and writes #+RESULTS: as
org-babel-insert-result does; checked on 53 blocks against
org-babel-execute-src-block. Header arguments that would change what
runs are refused with a message. Running asks first, with trust by
block content.

Ref plan docs/plans/2026-10-05-babel.md

Layout: unified · split

Sources/OrgApp/BabelRunner.swift added +86
@@ -0,0 +1,86 @@
1import Foundation
2import OrgCore
3
4/// Runs Babel jobs as processes: the interpreter reads the program on standard input, in the
5/// file's folder or `:dir`.
6public enum BabelRunner {
7 public struct Failure: Error, CustomStringConvertible {
8 public let description: String
9 }
10
11 /// PATH with the usual install locations, which an app launched from the Dock lacks.
12 static var environment: [String: String] {
13 var environment = ProcessInfo.processInfo.environment
14 let extra = ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin"]
15 let path = (environment["PATH"] ?? "").split(separator: ":").map(String.init)
16 environment["PATH"] = (path + extra.filter { !path.contains($0) }).joined(separator: ":")
17 return environment
18 }
19
20 public static func run(_ job: BabelJob, fileDirectory: URL, timeout: Duration = .seconds(300)) async throws -> BabelOutput {
21 let folder = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-babel-\(UUID().uuidString)")
22 try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
23 defer { try? FileManager.default.removeItem(at: folder) }
24 let resultFile = folder.appendingPathComponent("result")
25 let input = job.input.replacingOccurrences(of: Babel.resultFileToken, with: resultFile.path)
26
27 let process = Process()
28 process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
29 process.environment = environment
30 let stdin = Pipe()
31 switch job.runner {
32 case .process(let executable, let arguments):
33 process.arguments = [executable] + arguments
34 case .emacs:
35 guard let emacs = EmacsRunner.executable() else { throw Failure(description: "Emacs isn't installed, so Emacs Lisp blocks can't run.") }
36 let script = folder.appendingPathComponent("babel.el")
37 try input.write(to: script, atomically: true, encoding: .utf8)
38 process.arguments = [emacs.path, "-Q", "--batch", "-l", script.path]
39 }
40 let directory = job.directory.map { URL(fileURLWithPath: $0, relativeTo: fileDirectory).standardizedFileURL } ?? fileDirectory
41 guard FileManager.default.fileExists(atPath: directory.path) else { throw Failure(description: "No folder \(directory.path) for :dir.") }
42 process.currentDirectoryURL = directory
43 let stdout = folder.appendingPathComponent("stdout")
44 FileManager.default.createFile(atPath: stdout.path, contents: nil)
45 let out = try FileHandle(forWritingTo: stdout)
46 process.standardInput = stdin
47 process.standardOutput = out
48 process.standardError = FileHandle.nullDevice
49 do {
50 try process.run()
51 } catch {
52 throw Failure(description: "Can't start \(process.arguments?.first ?? "the interpreter"): \(error.localizedDescription)")
53 }
54 if case .process = job.runner {
55 let handle = stdin.fileHandleForWriting
56 let data = Data(input.utf8)
57 // Large programs would fill the pipe; write off the main thread.
58 Task.detached {
59 try? handle.write(contentsOf: data)
60 try? handle.close()
61 }
62 } else {
63 try stdin.fileHandleForWriting.close()
64 }
65 let deadline = ContinuousClock.now + timeout
66 while process.isRunning {
67 if ContinuousClock.now > deadline {
68 process.terminate()
69 throw Failure(description: "The block took longer than \(timeout) and was stopped.")
70 }
71 do {
72 try await Task.sleep(for: .milliseconds(30))
73 } catch {
74 process.terminate()
75 throw CancellationError()
76 }
77 }
78 try out.close()
79 let output = (try? String(contentsOf: stdout, encoding: .utf8)) ?? ""
80 let result = try? String(contentsOf: resultFile, encoding: .utf8)
81 if case .process(let executable, _) = job.runner, process.terminationStatus == 127, output.isEmpty {
82 throw Failure(description: "\(executable) isn't installed.")
83 }
84 return BabelOutput(stdout: output, resultFile: result)
85 }
86}
Sources/OrgApp/DocumentSession.swift +24
@@ -343,6 +343,30 @@ public final class DocumentSession {
343 } 343 }
344 } 344 }
345 345
346 /// Runs a src block and writes its result under it, unless the block changed while it
347 /// ran. Returns a message for the echo area.
348 public func runBabel(_ job: BabelJob) async -> String {
349 guard let path, buffer != nil else { return "No file is open." }
350 let directory = URL(fileURLWithPath: path).deletingLastPathComponent()
351 do {
352 let output = try await BabelRunner.run(job, fileDirectory: directory)
353 guard let current = self.buffer, self.path == path else { return "The file was closed before the block finished." }
354 let document = current.document
355 switch Babel.finish(job, output: output, text: document.text, settings: document.tree.settings) {
356 case .failure(.message(let message)):
357 return message
358 case .success(let edits):
359 guard !edits.isEmpty else { return job.finish.params.contains("silent") ? output.stdout : "Code block evaluation complete." }
360 let result = EditResult(baseRevision: document.revision, edits: edits)
361 if case .failed(let message) = current.perform(PrecomputedEdit(result), now: Date(), answers: [:]) { return message }
362 bufferChanged()
363 return "Code block evaluation complete."
364 }
365 } catch {
366 return "\(error)"
367 }
368 }
369
346 // MARK: - Locations 370 // MARK: - Locations
347 371
348 /// Opens the file of `location` if needed and asks the editor to show the heading. Offsets 372 /// Opens the file of `location` if needed and asks the editor to show the heading. Offsets
Sources/OrgCore/Commands/Command.swift +3 −1
@@ -146,6 +146,8 @@ public enum ExternalRequest: Sendable, Equatable {
146 /// Recalculate the table whose text is at `table` in Emacs, running `form` with point at 146 /// Recalculate the table whose text is at `table` in Emacs, running `form` with point at
147 /// `point`, and put Emacs's table text in its place. `lisp` when its formulas run Lisp. 147 /// `point`, and put Emacs's table text in its place. `lisp` when its formulas run Lisp.
148 case emacsTable(table: Range<Int>, point: Int, form: String, reason: String, lisp: Bool) 148 case emacsTable(table: Range<Int>, point: Int, form: String, reason: String, lisp: Bool)
149 /// Run a src block; `Babel.finish` turns its output into the result edit.
150 case babel(BabelJob)
149} 151}
150 152
151/// A named operation. Keys, menus, the palette and touch controls all run commands. 153/// A named operation. Keys, menus, the palette and touch controls all run commands.
@@ -171,7 +173,7 @@ public enum Commands {
171 MoveItemUp(), MoveItemDown(), ToggleCheckbox(), 173 MoveItemUp(), MoveItemDown(), ToggleCheckbox(),
172 InsertTimestamp(), InsertTimestamp(inactive: true), SetPlanning(deadline: false), SetPlanning(deadline: true), 174 InsertTimestamp(), InsertTimestamp(inactive: true), SetPlanning(deadline: false), SetPlanning(deadline: true),
173 RemovePlanning(deadline: false), RemovePlanning(deadline: true), FillParagraph(), CreateTable(), 175 RemovePlanning(deadline: false), RemovePlanning(deadline: true), FillParagraph(), CreateTable(),
174 TableRecalculate(all: false), TableRecalculate(all: true), 176 TableRecalculate(all: false), TableRecalculate(all: true), ExecuteSrcBlock(),
175 ShiftTimestamp(up: true), ShiftTimestamp(up: false), ShiftTimestampDay(later: true), ShiftTimestampDay(later: false), 177 ShiftTimestamp(up: true), ShiftTimestamp(up: false), ShiftTimestampDay(later: true), ShiftTimestampDay(later: false),
176 ] 178 ]
177 179
Sources/OrgCore/Compute/Babel.swift added +951
@@ -0,0 +1,951 @@
1import Foundation
2
3// Babel (ob-core.el, ob-shell.el, ob-python.el, ob-emacs-lisp.el, Org 9.8.7): executing a src
4// block is planned here, run by the platform, and finished here. The plan resolves header
5// arguments and refuses anything that would make the run differ from Emacs's; the finish turns
6// the output into a result the way `org-babel-insert-result` writes it.
7
8/// A src block run for the platform to carry out.
9public struct BabelJob: Sendable, Equatable {
10 public enum Runner: Sendable, Equatable {
11 /// An interpreter reading the program on standard input.
12 case process(executable: String, arguments: [String])
13 /// `emacs --batch` loading `input` as a script.
14 case emacs
15 }
16
17 public let language: String
18 public let runner: Runner
19 /// Standard input, or the Emacs script. `Babel.resultFileToken` stands for a file the runner
20 /// creates; its contents come back as `BabelOutput.resultFile`.
21 public let input: String
22 /// `:dir`, relative to the file's folder; nil for the folder itself.
23 public let directory: String?
24 /// The block as it was planned, to find it again when the run ends.
25 public let block: Range<Int>
26 public let blockText: String
27 /// Asks every time (`:eval query`) rather than trusting by content.
28 public let alwaysAsk: Bool
29 public let finish: Finish
30
31 public struct Finish: Sendable, Equatable {
32 enum Kind: Sendable, Equatable { case shell(valueIsExitStatus: Bool), python, generic, emacsLisp }
33 let kind: Kind
34 var language = ""
35 public let params: [String]
36 let output: Bool
37 let wrap: String?
38 let name: String?
39 }
40}
41
42public struct BabelOutput: Sendable, Equatable {
43 public var stdout: String
44 public var resultFile: String?
45 public init(stdout: String, resultFile: String? = nil) {
46 self.stdout = stdout
47 self.resultFile = resultFile
48 }
49}
50
51public enum Babel {
52 public static let resultFileToken = "__ORGSTAR_RESULT_FILE__"
53 static let shells: Set<String> = ["sh", "bash", "zsh", "shell", "fish", "ksh", "dash", "ash", "csh", "mksh", "posh"]
54 static let generic: [String: (String, [String])] = [
55 "ruby": ("ruby", []), "js": ("node", []), "javascript": ("node", []), "R": ("Rscript", ["-"]),
56 "awk": ("awk", ["-f", "/dev/stdin"]),
57 ]
58
59 public struct Options: Sendable, Equatable {
60 public var python = "python3"
61 /// `shell-file-name`, for blocks in the `shell` language.
62 public var shell = "/bin/sh"
63 public init() {}
64 }
65
66 // MARK: - Planning
67
68 /// The run for the src block at the caret, or why it can't run.
69 public static func plan(_ context: EditContext, options: Options = Options()) -> Result<BabelJob, Failure> {
70 let text = context.text
71 let ns = text as NSString
72 let model = DocumentModel(tree: context.tree)
73 guard let block = model.srcBlocks.first(where: { $0.range.contains(context.caret) || $0.range.upperBound == context.caret }) else {
74 return .failure(.message("Not in a source block"))
75 }
76 guard let language = block.language else { return .failure(.message("This block has no language")) }
77 let params: Params
78 do {
79 params = try Params(block: block, model: model, text: ns)
80 } catch let error as Failure {
81 return .failure(error)
82 } catch {
83 return .failure(.message("\(error)"))
84 }
85 let eval = params.single[":eval"]?.lowercased()
86 if eval == "never" || eval == "no" || eval == "never-export" || eval == "no-export" {
87 return .failure(.message("Evaluation of this \(language) code block\(block.name.map { " (\($0)) " } ?? " ")is disabled."))
88 }
89 for key in [":prologue", ":epilogue", ":file", ":post", ":stdin", ":cmdline", ":shebang"] where params.single[key] != nil {
90 return .failure(.message("\(key) isn't supported yet; nothing was run."))
91 }
92 if let session = params.single[":session"], session != "none" { return .failure(.message(":session isn't supported yet; nothing was run.")) }
93 if let noweb = params.single[":noweb"], !["no", "strip-export", "tangle"].contains(noweb) {
94 return .failure(.message(":noweb isn't supported yet; nothing was run."))
95 }
96 if params.single[":cache"] == "yes" { return .failure(.message(":cache isn't supported yet; nothing was run.")) }
97
98 let body = normalizedBody(block)
99 let output = params.results.contains("output")
100 let finishBase = { (kind: BabelJob.Finish.Kind) in
101 BabelJob.Finish(kind: kind, language: language, params: params.results, output: output, wrap: params.wrap, name: block.name)
102 }
103 let vars: [(String, VarValue)]
104 do {
105 vars = try params.vars.map { ($0.name, try resolve($0.value, model: model, text: ns)) }
106 } catch let error as Failure {
107 return .failure(error)
108 } catch {
109 return .failure(.message("\(error)"))
110 }
111 let blockText = ns.substring(with: NSRange(block.range))
112 let dir = params.single[":dir"]
113 let alwaysAsk = eval == "query"
114
115 if shells.contains(language) {
116 let valueIsExitStatus = params.results.contains("value")
117 var lines: [String] = []
118 for (name, value) in vars {
119 let s: String
120 switch value {
121 case .scalar(let v), .number(let v): s = v
122 default: return .failure(.message("Tables and lists in :var aren't supported for \(language) yet."))
123 }
124 lines.append("\(name)='" + s.replacingOccurrences(of: "'", with: "'\"'\"'") + "'")
125 }
126 let full = (lines + [body]).joined(separator: "\n") + (valueIsExitStatus ? "\necho $?" : "")
127 let executable = language == "shell" ? options.shell : language
128 return .success(BabelJob(
129 language: language, runner: .process(executable: executable, arguments: []),
130 input: full.trimmingCharacters(in: .whitespacesAndNewlines) + "\n", directory: dir,
131 block: block.range, blockText: blockText, alwaysAsk: alwaysAsk,
132 finish: finishBase(.shell(valueIsExitStatus: valueIsExitStatus))
133 ))
134 }
135 if language == "python" {
136 let lines = vars.map { "\($0.0)=\(pythonLiteral($0.1))" }
137 var full = (lines + [body]).joined(separator: "\n")
138 if let returnValue = params.single[":return"], !output { full += "\nreturn \(returnValue)" }
139 let input: String
140 if output {
141 input = full + "\n"
142 } else {
143 input = pythonFormatter + "def main():\n" + shiftRight(full) + "\n\n__orgstar_format(main(), '\(resultFileToken)', ["
144 + params.results.map { "\"\($0)\"" }.joined(separator: ", ") + "])\n"
145 }
146 return .success(BabelJob(
147 language: language, runner: .process(executable: params.single[":python"] ?? options.python, arguments: []),
148 input: input, directory: dir, block: block.range, blockText: blockText, alwaysAsk: alwaysAsk,
149 finish: finishBase(.python)
150 ))
151 }
152 if language == "emacs-lisp" || language == "elisp" {
153 let bindings = vars.map { "(\($0.0) '\(lispLiteral($0.1)))" }
154 let expanded = bindings.isEmpty ? body + "\n" : "(let (\(bindings.joined(separator: "\n ")))\n\(body)\n)"
155 let form = output ? "(with-output-to-string \(expanded))" : "(progn \(expanded)\n)"
156 return .success(BabelJob(
157 language: language, runner: .emacs, input: emacsScript(form: form, params: params.results),
158 directory: dir, block: block.range, blockText: blockText, alwaysAsk: alwaysAsk, finish: finishBase(.emacsLisp)
159 ))
160 }
161 if let (executable, arguments) = generic[language] {
162 guard vars.isEmpty else { return .failure(.message(":var isn't supported for \(language) yet.")) }
163 return .success(BabelJob(
164 language: language, runner: .process(executable: params.single[":cmd"] ?? executable, arguments: arguments),
165 input: body + "\n", directory: dir, block: block.range, blockText: blockText, alwaysAsk: alwaysAsk,
166 finish: BabelJob.Finish(kind: .generic, language: language, params: params.results + (output ? [] : ["output"]), output: true, wrap: params.wrap, name: block.name)
167 ))
168 }
169 return .failure(.message("No way to run \(language) blocks yet."))
170 }
171
172 public enum Failure: Error, Equatable {
173 case message(String)
174 }
175
176 /// `org-babel--normalize-body`: without the final newline, and without the indentation
177 /// common to its lines unless `-i` keeps it.
178 static func normalizedBody(_ block: SrcBlockInfo) -> String {
179 var lines = block.body.components(separatedBy: "\n")
180 if lines.last == "" { lines.removeLast() }
181 // Org escapes lines starting with `*` or `#+` inside blocks with a comma.
182 lines = lines.map { $0.replacingOccurrences(of: "^([ \\t]*),([,*]|#\\+)", with: "$1$2", options: .regularExpression) }
183 if !block.switches.contains("-i") {
184 let indents = lines.filter { $0.contains(where: { !$0.isWhitespace }) }.map { $0.prefix { $0 == " " || $0 == "\t" }.count }
185 let common = indents.min() ?? 0
186 lines = lines.map { String($0.dropFirst(min(common, $0.prefix { $0 == " " || $0 == "\t" }.count))) }
187 }
188 return lines.joined(separator: "\n")
189 }
190
191 // MARK: - Header arguments
192
193 struct Params {
194 var results: [String] = ["replace"]
195 var single: [String: String] = [:]
196 var vars: [(name: String, value: String)] = []
197 var wrap: String?
198
199 static let resultGroups: [Set<String>] = [
200 ["file", "list", "vector", "table", "scalar", "verbatim"],
201 ["raw", "html", "latex", "org", "code", "pp", "drawer", "link", "graphics"],
202 ["replace", "silent", "none", "discard", "append", "prepend"],
203 ["output", "value"],
204 ]
205
206 init(block: SrcBlockInfo, model: DocumentModel, text: NSString) throws {
207 var layers: [[(String, String)]] = []
208 let language = block.language ?? ""
209 func properties(_ entries: [Property]) {
210 for key in ["header-args", "header-args:\(language)"] {
211 for entry in entries where entry.key.lowercased() == key {
212 let pairs = Params.parse(entry.value)
213 layers.append(pairs)
214 }
215 }
216 }
217 properties(model.fileProperties)
218 if let heading = block.heading {
219 for index in model.ancestors(of: heading) + [heading] { properties(model.headings[index].properties) }
220 }
221 let firstLine = text.substring(with: text.lineRange(for: NSRange(location: block.range.lowerBound, length: 0)))
222 if let r = firstLine.range(of: "(?i)#\\+begin_src[ \\t]+\\S+", options: .regularExpression) {
223 var rest = String(firstLine[r.upperBound...]).trimmingCharacters(in: .whitespacesAndNewlines)
224 while let first = rest.split(separator: " ").first, first.hasPrefix("-") || first.hasPrefix("+") {
225 rest = String(rest.dropFirst(first.count)).trimmingCharacters(in: .whitespaces)
226 }
227 layers.append(Params.parse(rest))
228 }
229 // `#+HEADER:` lines above the block.
230 var headers: [String] = []
231 var location = block.range.lowerBound
232 while location > 0 {
233 let previous = text.lineRange(for: NSRange(location: location - 1, length: 0))
234 let line = text.substring(with: previous).trimmingCharacters(in: .whitespacesAndNewlines)
235 guard line.range(of: "(?i)^#\\+(header|name|caption|attr_[a-z]+|results|plot|label):", options: .regularExpression) != nil else { break }
236 if line.lowercased().hasPrefix("#+header:") { headers.insert(String(line.dropFirst("#+header:".count)), at: 0) }
237 location = previous.location
238 }
239 for header in headers { layers.append(Params.parse(header)) }
240
241 for layer in layers {
242 for (key, value) in layer {
243 switch key {
244 case ":results":
245 for word in value.split(whereSeparator: \.isWhitespace).map(String.init) {
246 if let group = Params.resultGroups.first(where: { $0.contains(word) }) { results.removeAll { group.contains($0) } }
247 results.removeAll { $0 == word }
248 results.insert(word, at: 0)
249 }
250 case ":var":
251 guard let eq = value.firstIndex(of: "=") else { throw Failure.message(":var \(value) needs a name and a value") }
252 let name = value[..<eq].trimmingCharacters(in: .whitespaces)
253 let assigned = value[value.index(after: eq)...].trimmingCharacters(in: .whitespaces)
254 vars.removeAll { $0.name == name }
255 vars.append((name, assigned))
256 case ":wrap":
257 wrap = value
258 default:
259 single[key] = value
260 }
261 }
262 }
263 }
264
265 /// `org-babel-parse-header-arguments`: split before each `:key` outside quotes and
266 /// parentheses.
267 static func parse(_ text: String) -> [(String, String)] {
268 var parts: [String] = []
269 var current = ""
270 var depth = 0
271 var quoted = false
272 var previous: Character = " "
273 for c in text {
274 if c == "\"" { quoted.toggle() }
275 if !quoted {
276 if c == "(" { depth += 1 }
277 if c == ")" { depth = max(0, depth - 1) }
278 }
279 if c == ":", !quoted, depth == 0, previous == " " || previous == "\t" {
280 parts.append(current)
281 current = ""
282 }
283 current.append(c)
284 previous = c
285 }
286 parts.append(current)
287 var pairs: [(String, String)] = []
288 for part in parts {
289 let trimmed = part.trimmingCharacters(in: .whitespaces)
290 guard trimmed.hasPrefix(":") else { continue }
291 let key = String(trimmed.prefix { !$0.isWhitespace }).lowercased()
292 var value = trimmed.dropFirst(key.count).trimmingCharacters(in: .whitespaces)
293 if key != ":var", value.count >= 2, value.hasPrefix("\""), value.hasSuffix("\"") {
294 value = String(value.dropFirst().dropLast())
295 }
296 pairs.append((key, value))
297 }
298 return pairs
299 }
300 }
301
302 // MARK: - Variables
303
304 enum VarValue: Equatable {
305 case scalar(String)
306 case number(String)
307 case table([[Cell]])
308 case list([Cell])
309 }
310
311 enum Cell: Equatable {
312 case text(String)
313 case number(String)
314 }
315
316 /// `org-babel--string-to-number`, keeping Emacs's printed form.
317 static func number(_ s: String) -> String? {
318 let t = s.trimmingCharacters(in: .whitespaces)
319 guard !t.isEmpty, !t.contains(where: \.isWhitespace), t.range(of: "^[0-9e.+ -]+$", options: .regularExpression) != nil else { return nil }
320 if t.range(of: "^[-+]?[0-9]+\\.?$", options: .regularExpression) != nil {
321 var digits = t.hasSuffix(".") ? String(t.dropLast()) : t
322 let negative = digits.hasPrefix("-")
323 if digits.hasPrefix("-") || digits.hasPrefix("+") { digits.removeFirst() }
324 digits = String(digits.drop { $0 == "0" })
325 if digits.isEmpty { return "0" }
326 return (negative ? "-" : "") + digits
327 }
328 guard t.range(of: "^[-+]?([0-9]+\\.[0-9]+|[0-9]*\\.[0-9]+|[0-9]+(\\.[0-9]*)?e[-+]?[0-9]+|[0-9]*\\.[0-9]+e[-+]?[0-9]+)$", options: .regularExpression) != nil,
329 let d = Double(t) else { return nil }
330 return emacsFloat(d)
331 }
332
333 /// How Emacs prints a float.
334 static func emacsFloat(_ d: Double) -> String {
335 if d == d.rounded(), abs(d) < 1e16 { return String(format: "%.1f", d) }
336 return "\(d)"
337 }
338
339 static func cell(_ s: String) -> Cell {
340 var t = s
341 if let m = s.firstMatch(of: /^\s*"(.+)"\s*$/) { t = String(m.1) }
342 return number(t).map(Cell.number) ?? .text(t)
343 }
344
345 /// `org-babel-ref-resolve` for literals and named tables and lists in the file.
346 static func resolve(_ value: String, model: DocumentModel, text: NSString) throws -> VarValue {
347 if value.hasPrefix("\""), value.hasSuffix("\""), value.count >= 2 { return .scalar(String(value.dropFirst().dropLast())) }
348 if let n = number(value) { return .number(n) }
349 if value.contains("(") || value.contains("[") { throw Failure.message("References like \(value) aren't supported yet.") }
350 // A named element: `#+NAME: value` above a table or list.
351 let pattern = "(?im)^[ \\t]*#\\+name:[ \\t]*" + NSRegularExpression.escapedPattern(for: value) + "[ \\t]*$"
352 guard let r = (text as String).range(of: pattern, options: .regularExpression) else {
353 throw Failure.message("Can't find \(value) for :var.")
354 }
355 let start = NSMaxRange(text.lineRange(for: NSRange(r, in: text as String)))
356 var location = start
357 var rows: [[Cell]] = []
358 var items: [Cell] = []
359 while location < text.length {
360 let lineRange = text.lineRange(for: NSRange(location: location, length: 0))
361 let line = text.substring(with: lineRange).trimmingCharacters(in: .newlines)
362 let trimmed = line.trimmingCharacters(in: .whitespaces)
363 if trimmed.hasPrefix("|") {
364 if trimmed.hasPrefix("|-") { throw Failure.message("Tables with rules in :var aren't supported yet.") }
365 let cells = trimmed.dropFirst().split(separator: "|", omittingEmptySubsequences: false).map { $0.trimmingCharacters(in: .whitespaces) }
366 rows.append((trimmed.hasSuffix("|") ? Array(cells.dropLast()) : cells).map(cell))
367 } else if let m = trimmed.firstMatch(of: /^[-+*] (.*)$/), rows.isEmpty {
368 items.append(cell(String(m.1)))
369 } else if trimmed.hasPrefix("#+") || (!rows.isEmpty || !items.isEmpty) {
370 if rows.isEmpty, items.isEmpty, trimmed.lowercased().hasPrefix("#+begin_src") {
371 throw Failure.message("Using a src block's result in :var isn't supported yet.")
372 }
373 break
374 } else {
375 break
376 }
377 location = NSMaxRange(lineRange)
378 }
379 if !rows.isEmpty { return .table(rows) }
380 if !items.isEmpty { return .list(items) }
381 throw Failure.message("\(value) isn't a table or list.")
382 }
383
384 /// `org-babel-python-var-to-python`.
385 static func pythonLiteral(_ value: VarValue) -> String {
386 func cell(_ c: Cell) -> String {
387 switch c {
388 case .number(let n): return n
389 case .text(let s): return lispString(s, triple: s.contains("\n") || s.contains("\r"))
390 }
391 }
392 switch value {
393 case .scalar(let s): return lispString(s, triple: s.contains("\n") || s.contains("\r"))
394 case .number(let n): return n
395 case .list(let items): return "[" + items.map(cell).joined(separator: ", ") + "]"
396 case .table(let rows): return "[" + rows.map { "[" + $0.map(cell).joined(separator: ", ") + "]" }.joined(separator: ", ") + "]"
397 }
398 }
399
400 /// `prin1` of a string.
401 static func lispString(_ s: String, triple: Bool = false) -> String {
402 let quoted = "\"" + s.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\"") + "\""
403 return triple ? "\"\"" + quoted + "\"\"" : quoted
404 }
405
406 static func lispLiteral(_ value: VarValue) -> String {
407 func cell(_ c: Cell) -> String {
408 switch c {
409 case .number(let n): return n
410 case .text(let s): return lispString(s)
411 }
412 }
413 switch value {
414 case .scalar(let s): return lispString(s)
415 case .number(let n): return n
416 case .list(let items): return "(" + items.map(cell).joined(separator: " ") + ")"
417 case .table(let rows): return "(" + rows.map { "(" + $0.map(cell).joined(separator: " ") + ")" }.joined(separator: " ") + ")"
418 }
419 }
420
421 // MARK: - Python
422
423 /// Writes the value of `main()` to the result file as org's Python wrapper does: `pprint`
424 /// for `pp`, `key :: value` items for a dict as a list, dicts as rows for tables,
425 /// otherwise `str`.
426 static let pythonFormatter = """
427 def __orgstar_format(value, path, params):
428 def rows(v):
429 if isinstance(v, dict):
430 return [(k, rows(x)) for k, x in v.items()]
431 if isinstance(v, (list, tuple)):
432 return [rows(x) for x in v]
433 return v
434 with open(path, 'w') as out:
435 if 'pp' in params:
436 import pprint
437 out.write(pprint.pformat(value))
438 elif 'list' in params and isinstance(value, dict):
439 out.write(str(['{} :: {}'.format(k, v) for k, v in value.items()]))
440 else:
441 if not set(params) & {'scalar', 'verbatim', 'raw'} and 'table' in params:
442 value = rows(value)
443 out.write(str(value))
444
445 """
446
447 /// `org-babel-python--shift-right`: four spaces more on every line outside strings.
448 static func shiftRight(_ body: String) -> String {
449 var inString: String?
450 var lines: [String] = []
451 for line in body.components(separatedBy: "\n") {
452 lines.append(inString == nil ? " " + line : line)
453 var i = line.startIndex
454 while i < line.endIndex {
455 if let delimiter = inString {
456 if line[i...].hasPrefix(delimiter) {
457 inString = nil
458 i = line.index(i, offsetBy: 3)
459 continue
460 }
461 } else if line[i] == "#" {
462 break
463 } else if line[i...].hasPrefix("\"\"\"") || line[i...].hasPrefix("'''") {
464 inString = String(line[i...].prefix(3))
465 i = line.index(i, offsetBy: 3)
466 continue
467 } else if line[i] == "\"" || line[i] == "'" {
468 let quote = line[i]
469 i = line.index(after: i)
470 while i < line.endIndex, line[i] != quote {
471 if line[i] == "\\" { i = line.index(after: i) }
472 if i < line.endIndex { i = line.index(after: i) }
473 }
474 }
475 if i < line.endIndex { i = line.index(after: i) }
476 }
477 }
478 return lines.joined(separator: "\n")
479 }
480
481 // MARK: - Emacs Lisp
482
483 /// Evaluates `form` and prints the result as JSON: its `%s` and `%S` forms, and its rows
484 /// when it is a table.
485 static func emacsScript(form: String, params: [String]) -> String {
486 """
487 ;;; -*- lexical-binding: t -*-
488 (require 'cl-lib)
489 (require 'json)
490 (let* ((result \(form))
491 (print-level nil)
492 (print-length nil)
493 (cell (lambda (c) (if (stringp c) c (format "%s" c))))
494 (tabulable (and (proper-list-p result)
495 (cl-every (lambda (e) (or (atom e) (proper-list-p e))) result)))
496 (rows (and tabulable result
497 (if (cl-every (lambda (e) (or (eq e 'hline) (listp e))) result)
498 (mapcar (lambda (r) (if (eq r 'hline) "hline" (vconcat (mapcar cell r)))) result)
499 (list (vconcat (mapcar cell result)))))))
500 (princ (json-encode `((s . ,(format "%s" result))
501 (S . ,(format "%S" result))
502 (string . ,(if (stringp result) t :json-false))
503 (rows . ,(if rows (vconcat rows) :json-null))))))
504 """
505 }
506
507 // MARK: - Finishing
508
509 /// What the run produced, as Babel holds it before inserting.
510 enum Value: Equatable {
511 case none
512 case string(String)
513 /// Rows of cells; nil rows are rules.
514 case table([[String]?])
515 }
516
517 /// The edit that writes the run's result under the block, or why it can't.
518 public static func finish(_ job: BabelJob, output: BabelOutput, text: String, settings: OrgSettings = .default) -> Result<[TextEdit], Failure> {
519 let ns = text as NSString
520 var blockRange = job.block
521 if blockRange.upperBound > ns.length || ns.substring(with: NSRange(blockRange)) != job.blockText {
522 let found = ns.range(of: job.blockText)
523 guard found.location != NSNotFound,
524 ns.range(of: job.blockText, range: NSRange(location: NSMaxRange(found), length: ns.length - NSMaxRange(found))).location == NSNotFound else {
525 return .failure(.message("The block changed while it ran; the result wasn't written."))
526 }
527 blockRange = found.location..<NSMaxRange(found)
528 }
529 let value = Self.value(job.finish, output)
530 if job.finish.params.contains("silent") || job.finish.params.contains("none") || job.finish.params.contains("discard") { return .success([]) }
531 let buffer = EmacsBuffer(text, point: 0, settings: settings)
532 insertResult(value, job.finish, block: blockRange, buffer: buffer)
533 return .success(singleEdit(from: text, to: buffer.text as String))
534 }
535
536 /// The one edit between two texts: everything between their common prefix and suffix.
537 static func singleEdit(from old: String, to new: String) -> [TextEdit] {
538 let a = Array(old.utf16)
539 let b = Array(new.utf16)
540 var prefix = 0
541 while prefix < a.count, prefix < b.count, a[prefix] == b[prefix] { prefix += 1 }
542 var suffix = 0
543 while suffix < a.count - prefix, suffix < b.count - prefix, a[a.count - 1 - suffix] == b[b.count - 1 - suffix] { suffix += 1 }
544 // Keep the edit on scalar boundaries.
545 while prefix > 0, (prefix < a.count && UTF16.isTrailSurrogate(a[prefix])) || (prefix < b.count && UTF16.isTrailSurrogate(b[prefix])) { prefix -= 1 }
546 while suffix > 0, suffix < a.count, UTF16.isLeadSurrogate(a[a.count - suffix - 1]) { suffix -= 1 }
547 if prefix == a.count, prefix == b.count { return [] }
548 let replacement = String(utf16CodeUnits: Array(b[prefix..<(b.count - suffix)]), count: b.count - suffix - prefix)
549 return [TextEdit(range: prefix..<(a.count - suffix), replacement: replacement)]
550 }
551
552 /// The language's result handling: `org-babel-result-cond` and the table import.
553 static func value(_ finish: BabelJob.Finish, _ output: BabelOutput) -> Value {
554 let params = finish.params
555 let scalar = params.contains("scalar") || params.contains("verbatim") || params.contains("html") || params.contains("code")
556 || params.contains("pp") || params.contains("file")
557 || ((params.contains("output") || params.contains("raw") || params.contains("org")) && !params.contains("table"))
558 switch finish.kind {
559 case .shell(let valueIsExitStatus):
560 var results = output.stdout
561 if valueIsExitStatus {
562 results = results.split(separator: "\n", omittingEmptySubsequences: true).last.map(String.init) ?? ""
563 }
564 if scalar { return .string(results) }
565 return importTable(results)
566 case .generic:
567 return .string(output.stdout)
568 case .python:
569 let raw = finish.output ? output.stdout : (output.resultFile ?? "")
570 if scalar { return .string(raw) }
571 return pythonValue(raw)
572 case .emacsLisp:
573 guard let data = output.stdout.data(using: .utf8),
574 let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return .string(output.stdout) }
575 if scalar {
576 return .string((params.contains("scalar") || params.contains("verbatim") ? object["S"] : object["s"]) as? String ?? "")
577 }
578 if object["string"] as? Bool == true { return .string(object["s"] as? String ?? "") }
579 if let rows = object["rows"] as? [Any] {
580 return .table(rows.map { $0 as? String == "hline" ? nil : ($0 as? [String] ?? []) })
581 }
582 let printed = object["S"] as? String ?? ""
583 return printed == "nil" ? .none : .string(printed)
584 }
585 }
586
587 /// `org-babel-import-elisp-from-file`.
588 static func importTable(_ results: String) -> Value {
589 guard !results.isEmpty else { return .none }
590 var lines = results.components(separatedBy: "\n")
591 if lines.last == "" { lines.removeLast() }
592 let nonEmpty = lines.filter { !$0.isEmpty }
593 let rows: [[String]]
594 if !nonEmpty.isEmpty, nonEmpty.allSatisfy({ $0.contains("\t") }) {
595 rows = lines.map { $0.components(separatedBy: "\t") }
596 } else if !nonEmpty.isEmpty, nonEmpty.allSatisfy({ $0.contains(",") }) {
597 rows = lines.map(csvFields)
598 } else if lines.count == 1 {
599 rows = [[lines[0]]]
600 } else {
601 rows = lines.map { line in
602 let trimmed = line.drop { $0 == " " }
603 return trimmed.split(separator: " ", omittingEmptySubsequences: true).map(String.init).isEmpty
604 ? [""] : trimmed.split(separator: " ", omittingEmptySubsequences: true).map(String.init)
605 }
606 }
607 let cells = rows.map { row in row.map { field -> String in
608 let trimmed = field.trimmingCharacters(in: .whitespaces)
609 switch cell(trimmed) {
610 case .number(let n): return n
611 case .text(let t): return t
612 }
613 } }
614 if cells.count == 1, cells[0].count == 1 { return .string(cells[0][0]) }
615 return .table(cells)
616 }
617
618 static func csvFields(_ line: String) -> [String] {
619 var fields: [String] = []
620 var current = ""
621 var quoted = false
622 for c in line {
623 if c == "\"" {
624 quoted.toggle()
625 } else if c == ",", !quoted {
626 fields.append(current.trimmingCharacters(in: .whitespaces))
627 current = ""
628 } else {
629 current.append(c)
630 }
631 }
632 fields.append(current.trimmingCharacters(in: .whitespaces))
633 return fields
634 }
635
636 /// `org-babel-python-table-or-string`.
637 static func pythonValue(_ raw: String) -> Value {
638 guard let first = raw.first, let last = raw.last, raw.count >= 2,
639 (first == "[" && last == "]") || (first == "(" && last == ")") else {
640 if raw.isEmpty { return .string("") }
641 if raw.count > 2, (raw.hasPrefix("'") && raw.hasSuffix("'")) || (raw.hasPrefix("\"") && raw.hasSuffix("\"")) {
642 return .string(String(raw.dropFirst().dropLast()))
643 }
644 if let n = number(raw) { return .string(n) }
645 return .string(raw)
646 }
647 var parser = PythonLiteral(Array(raw))
648 guard let parsed = parser.parse(), parser.atEnd, case .list(let items) = parsed else { return .string(raw) }
649 func text(_ v: PythonLiteral.Node) -> String {
650 switch v {
651 case .atom(let s): return s
652 case .string(let s): return s
653 case .list(let xs): return "(" + xs.map(text).joined(separator: " ") + ")"
654 }
655 }
656 if items.allSatisfy({ if case .list = $0 { return true }; if case .atom("None") = $0 { return true }; return false }) {
657 return .table(items.map { item -> [String]? in
658 if case .list(let cells) = item { return cells.map(text) }
659 return nil
660 })
661 }
662 return .table([items.map { if case .atom("None") = $0 { return "hline" }; return text($0) }])
663 }
664
665 struct PythonLiteral {
666 indirect enum Node: Equatable {
667 case atom(String)
668 case string(String)
669 case list([Node])
670 }
671
672 let chars: [Character]
673 var i = 0
674 init(_ chars: [Character]) { self.chars = chars }
675 var atEnd: Bool { mutating get { skip(); return i == chars.count } }
676
677 mutating func skip() { while i < chars.count, chars[i] == " " || chars[i] == "\n" { i += 1 } }
678
679 mutating func parse() -> Node? {
680 skip()
681 guard i < chars.count else { return nil }
682 let c = chars[i]
683 if c == "[" || c == "(" {
684 let close: Character = c == "[" ? "]" : ")"
685 i += 1
686 var items: [Node] = []
687 skip()
688 if i < chars.count, chars[i] == close {
689 i += 1
690 return .list(items)
691 }
692 while true {
693 guard let item = parse() else { return nil }
694 items.append(item)
695 skip()
696 guard i < chars.count else { return nil }
697 if chars[i] == "," {
698 i += 1
699 skip()
700 if i < chars.count, chars[i] == close {
701 i += 1
702 return .list(items)
703 }
704 continue
705 }
706 if chars[i] == close {
707 i += 1
708 return .list(items)
709 }
710 return nil
711 }
712 }
713 if c == "'" || c == "\"" {
714 i += 1
715 var s = ""
716 while i < chars.count, chars[i] != c {
717 if chars[i] == "\\", i + 1 < chars.count {
718 i += 1
719 switch chars[i] {
720 case "n": s.append("\n")
721 case "t": s.append("\t")
722 default: s.append(chars[i])
723 }
724 } else {
725 s.append(chars[i])
726 }
727 i += 1
728 }
729 guard i < chars.count else { return nil }
730 i += 1
731 return .string(s)
732 }
733 var s = ""
734 while i < chars.count, !",)] \n".contains(chars[i]) {
735 s.append(chars[i])
736 i += 1
737 }
738 guard !s.isEmpty else { return nil }
739 return .atom(number(s) ?? s)
740 }
741 }
742
743 // MARK: - Inserting
744
745 static let resultKeyword = "(?i)^[ \\t]*#\\+results(\\[[^\\]]*\\])?:"
746
747 /// `org-babel-where-is-src-block-result` with `insert`: the line of the block's results
748 /// keyword, added when there is none.
749 static func resultsLine(_ finish: BabelJob.Finish, block: Range<Int>, buffer: EmacsBuffer) -> Int {
750 let ns = buffer.text
751 if let name = finish.name {
752 let pattern = "(?im)^[ \\t]*#\\+results(\\[[^\\]]*\\])?:[ \\t]*" + NSRegularExpression.escapedPattern(for: name) + "[ \\t]*$"
753 let regex = try! NSRegularExpression(pattern: pattern)
754 for m in regex.matches(in: ns as String, range: NSRange(location: 0, length: ns.length)) {
755 // Not the name of another block.
756 return m.range.location
757 }
758 } else {
759 // The element after the block, past blank lines.
760 var location = block.upperBound
761 while location < ns.length {
762 let line = ns.lineRange(for: NSRange(location: location, length: 0))
763 let content = ns.substring(with: line)
764 if content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
765 location = NSMaxRange(line)
766 continue
767 }
768 if content.range(of: "(?i)^[ \\t]*#\\+results(\\[[^\\]]*\\])?:[ \\t]*\\n?$", options: .regularExpression) != nil {
769 return line.location
770 }
771 break
772 }
773 }
774 // A new keyword after the block.
775 buffer.point = min(block.upperBound, buffer.length)
776 buffer.skipBackward([32, 9, 10, 13])
777 buffer.forwardLine(1)
778 if !buffer.bolp { buffer.insert("\n") }
779 let indent = buffer.saveExcursion { () -> String in
780 buffer.point = block.lowerBound
781 let line = buffer.substring(buffer.lineStart(buffer.point), buffer.lineEnd(buffer.point))
782 return String(line.prefix { $0 == " " || $0 == "\t" })
783 }
784 buffer.insert("\n")
785 let keywordLine = buffer.point
786 buffer.insert(indent + "#+RESULTS:" + (finish.name.map { " " + $0 } ?? "") + "\n")
787 if buffer.looking("^[ \\t]*$", caseInsensitive: false, setsMatch: false) == nil {
788 buffer.saveExcursion { buffer.insert("\n") }
789 }
790 return keywordLine
791 }
792
793 /// `org-babel-result-end` with point at the start of the result.
794 static func resultEnd(_ buffer: EmacsBuffer) -> Int {
795 let start = buffer.point
796 let line = buffer.substring(buffer.lineStart(start), buffer.lineEnd(start))
797 if line.trimmingCharacters(in: .whitespaces).isEmpty { return start }
798 if line.range(of: "^[ \\t]*\\[\\[[^\\]]+\\](\\[[^\\]]*\\])?\\][ \\t]*$", options: .regularExpression) != nil {
799 return min(buffer.length, buffer.lineEnd(start) + 1)
800 }
801 let tree = OrgParser.parse(buffer.text as String)
802 var node = tree.root
803 var found: SyntaxNode?
804 while let child = node.child(containing: start) {
805 if child.range.lowerBound == start || (buffer.lineStart(child.range.lowerBound) == start) {
806 if [.fixedWidth, .table, .plainList, .drawer, .block, .dynamicBlock, .latexFragment].contains(child.kind) {
807 found = child
808 break
809 }
810 }
811 node = child
812 }
813 guard let element = found else { return start }
814 var end = element.range.upperBound
815 while end > start, let c = buffer.character(at: end - 1), c == 32 || c == 9 || c == 10 || c == 13 { end -= 1 }
816 return min(buffer.length, buffer.lineEnd(end) + 1)
817 }
818
819 /// `org-babel-insert-result` for a src block.
820 static func insertResult(_ value: Value, _ finish: BabelJob.Finish, block: Range<Int>, buffer: EmacsBuffer) {
821 let params = finish.params
822 let keyword = resultsLine(finish, block: block, buffer: buffer)
823 buffer.point = keyword
824 let indent = buffer.substring(buffer.lineStart(keyword), buffer.lineEnd(keyword)).prefix { $0 == " " || $0 == "\t" }.count
825 buffer.forwardLine(1)
826 if buffer.point == buffer.lineEnd(keyword) { buffer.insert("\n") }
827 var beg = buffer.point
828 if params.contains("replace") {
829 buffer.deleteRegion(beg, resultEnd(buffer))
830 buffer.point = beg
831 } else if params.contains("append") {
832 buffer.point = resultEnd(buffer)
833 beg = buffer.point
834 }
835 var tabulated = false
836 switch value {
837 case .none:
838 break
839 case .string(var s) where params.contains("list"):
840 if s.last != "\n" { s += "\n" }
841 let items = s.split(separator: "\n", omittingEmptySubsequences: true)
842 buffer.insert(items.map { "- " + $0 }.joined(separator: "\n") + "\n")
843 case .table(let rows) where params.contains("list"):
844 let items = rows.count == 1 ? (rows[0] ?? []) : rows.compactMap { $0?.joined(separator: " ") }
845 buffer.insert(items.map { "- " + $0 }.joined(separator: "\n") + "\n")
846 case .table(let rows):
847 tabulated = true
848 let lines = rows.map { row in row.map { "| " + $0.map { " " + $0 + " " }.joined(separator: "|").dropFirst() + " |" } ?? "|-" }
849 let start = buffer.point
850 buffer.insert(lines.joined(separator: "\n") + "\n")
851 buffer.point = start
852 buffer.tableAlign()
853 buffer.point = buffer.tableEnd()
854 case .string(var s):
855 if !s.isEmpty, s.last != "\n" { s += "\n" }
856 buffer.insert(s)
857 }
858 let end = buffer.marker(buffer.point, advances: true)
859 defer { buffer.release(end) }
860 func wrap(_ open: String, _ close: String, escape: Bool) {
861 buffer.point = end.position
862 buffer.insert(close + "\n")
863 buffer.point = beg
864 buffer.insert(open + "\n")
865 if escape { escapeCode(buffer, from: buffer.point, to: end.position - close.utf16.count - 1) }
866 }
867 if let wrapValue = finish.wrap {
868 let full = wrapValue.isEmpty ? "results" : wrapValue
869 let type = String(full.split(separator: " ").first ?? "results")
870 if type.lowercased() != "nil", type.lowercased() != "no" {
871 wrap("#+begin_" + full, "#+end_" + type, escape: !["example", "src", "export"].contains(type.lowercased()) ? false : true)
872 }
873 } else if params.contains("html") {
874 wrap("#+begin_export html", "#+end_export", escape: true)
875 } else if params.contains("latex") {
876 wrap("#+begin_export latex", "#+end_export", escape: true)
877 } else if params.contains("org") {
878 wrap("#+begin_src org", "#+end_src", escape: true)
879 } else if params.contains("code") {
880 wrap("#+begin_src \(finish.language.isEmpty ? "none" : finish.language)", "#+end_src", escape: true)
881 } else if params.contains("raw") {
882 // As inserted.
883 } else if params.contains("drawer") {
884 wrap(":results:", ":end:", escape: false)
885 } else if !tabulated, !params.contains("list") {
886 examplify(buffer, beg, end.position)
887 }
888 if indent > 0, !(tabulated && params.contains("append")) {
889 buffer.point = beg
890 while buffer.point < end.position {
891 if !buffer.eolp { buffer.insert(String(repeating: " ", count: indent)) }
892 if buffer.forwardLine(1) != 0 { break }
893 }
894 }
895 }
896
897 /// `org-escape-code-in-region`.
898 static func escapeCode(_ buffer: EmacsBuffer, from start: Int, to end: Int) {
899 buffer.point = start
900 let stop = buffer.marker(end)
901 defer { buffer.release(stop) }
902 while buffer.point < stop.position {
903 let line = buffer.substring(buffer.point, buffer.lineEnd(buffer.point))
904 if let m = line.firstMatch(of: /^[ \t]*(,*(\*|#\+))/) {
905 buffer.point += line[..<m.1.startIndex].utf16.count
906 buffer.insert(",")
907 }
908 if buffer.forwardLine(1) != 0 { break }
909 }
910 }
911
912 /// `org-babel-examplify-region`: fewer than ten lines get `: `, more an example block.
913 static func examplify(_ buffer: EmacsBuffer, _ beg: Int, _ end: Int) {
914 let text = buffer.substring(beg, end)
915 var size = text.components(separatedBy: "\n").count - (text.hasSuffix("\n") ? 1 : 0)
916 if text.isEmpty { size = 0 }
917 guard size > 0 else { return }
918 if size < 10 {
919 buffer.point = beg
920 for _ in 0..<size {
921 buffer.point = buffer.lineStart(buffer.point)
922 buffer.insert(": ")
923 buffer.forwardLine(1)
924 }
925 return
926 }
927 let endMarker = buffer.marker(end)
928 defer { buffer.release(endMarker) }
929 buffer.point = beg
930 buffer.insert("#+begin_example\n")
931 escapeCode(buffer, from: buffer.point, to: endMarker.position)
932 buffer.point = endMarker.position
933 if !buffer.bolp { buffer.insert("\n") }
934 buffer.insert("#+end_example")
935 if !buffer.eolp { buffer.insert("\n") }
936 }
937}
938
939/// C-c C-c in a src block: `org-babel-execute-src-block`.
940public struct ExecuteSrcBlock: OrgCommand {
941 public init() {}
942 public var id: String { "org.babel.execute" }
943 public var title: String { "Run Source Block" }
944 public func applies(in context: EditContext) -> Bool { KeyContext.holds("src", in: context) }
945 public func run(in context: EditContext) -> CommandStep {
946 switch Babel.plan(context) {
947 case .success(let job): return .external(.babel(job))
948 case .failure(.message(let message)): return .failed(message)
949 }
950 }
951}
Sources/OrgCore/Keymap/Keymap.swift +2 −1
@@ -103,7 +103,7 @@ public struct Keymap: Sendable, Equatable {
103 103
104/// Context names a binding's `when` can use. 104/// Context names a binding's `when` can use.
105public enum KeyContext { 105public enum KeyContext {
106 public static let names: Set<String> = ["heading", "table", "item", "region", "timestamp", "tblfm"] 106 public static let names: Set<String> = ["heading", "table", "item", "region", "timestamp", "tblfm", "src"]
107 107
108 public static func holds(_ name: String, in context: EditContext) -> Bool { 108 public static func holds(_ name: String, in context: EditContext) -> Bool {
109 switch name { 109 switch name {
@@ -113,6 +113,7 @@ public enum KeyContext {
113 case "region": return context.selection.contains { !$0.isEmpty } 113 case "region": return context.selection.contains { !$0.isEmpty }
114 case "timestamp": return EmacsBuffer(context.text, point: context.caret).atTimestamp() != nil 114 case "timestamp": return EmacsBuffer(context.text, point: context.caret).atTimestamp() != nil
115 case "tblfm": return TableRecalculate.tblfmLine(in: context) != nil 115 case "tblfm": return TableRecalculate.tblfmLine(in: context) != nil
116 case "src": return enclosing(.block, context).map(DocumentModel.isSrcBlock) ?? false
116 default: return false 117 default: return false
117 } 118 }
118 } 119 }
Sources/OrgCore/Keymap/Presets.swift +4
@@ -297,6 +297,10 @@ public enum KeymapPreset: String, Sendable, CaseIterable {
297 command = "org.table.recalc" 297 command = "org.table.recalc"
298 when = "table" 298 when = "table"
299 [[bind]] 299 [[bind]]
300 keys = "C-c C-c"
301 command = "org.babel.execute"
302 when = "src"
303 [[bind]]
300 keys = "M-<up>" 304 keys = "M-<up>"
301 command = "org.table.row-up" 305 command = "org.table.row-up"
302 when = "table" 306 when = "table"
Sources/Orgstar/ContentView.swift +17
@@ -186,6 +186,23 @@ struct ContentView: View {
186 default: commands.show("Not recalculated") 186 default: commands.show("Not recalculated")
187 } 187 }
188 } 188 }
189 case .babel(let job):
190 let run = {
191 commands.show("Running \(job.language) block…")
192 Task { commands.show(await session.runBabel(job)) }
193 }
194 guard let path = session.path else { return }
195 if !job.alwaysAsk, trust.isTrusted(path: path, content: job.blockText) { return run() }
196 let choices = job.alwaysAsk ? ["yes", "no"] : ["yes", "no", "always"]
197 commands.ask(Prompt(key: "confirm", message: "Run this \(job.language) block? (\(choices.joined(separator: ", ")))", choices: choices)) { answer in
198 switch answer {
199 case "always":
200 trust.trust(path: path, content: job.blockText)
201 run()
202 case "yes": run()
203 default: commands.show("Not run")
204 }
205 }
189 } 206 }
190 } 207 }
191 208
Tests/OrgAppTests/AppTests.swift +18
@@ -441,3 +441,21 @@ struct EmacsFallbackTests {
441 #expect(session.document?.text == "* Prices\n| item | n | twice |\n|------+---+-------|\n| x | a | 2 a |\n| y | 2 | 4 |\n#+TBLFM: $3=$2*2\n") 441 #expect(session.document?.text == "* Prices\n| item | n | twice |\n|------+---+-------|\n| x | a | 2 a |\n| y | 2 | 4 |\n#+TBLFM: $3=$2*2\n")
442 } 442 }
443} 443}
444
445@MainActor
446struct BabelSessionTests {
447 @Test func runsBlocksAndWritesResults() async throws {
448 let notes = try Folder()
449 let file = try notes.write("b.org", "#+begin_src sh\necho answer\necho \"$((6 * 7))\"\n#+end_src\n")
450 let session = DocumentSession(recovery: FileRecoveryStore(directory: notes.url.appendingPathComponent(".r")))
451 session.open(file.path)
452 let step = session.perform(ExecuteSrcBlock(), path: file.path, at: 3, expect: nil)
453 guard case .external(.babel(let job)) = step else {
454 Issue.record("\(step)")
455 return
456 }
457 #expect(await session.runBabel(job) == "Code block evaluation complete.")
458 // A column that is half numbers is right-aligned, as org-table-align does.
459 #expect(session.document?.text == "#+begin_src sh\necho answer\necho \"$((6 * 7))\"\n#+end_src\n\n#+RESULTS:\n| answer |\n| 42 |\n")
460 }
461}
Tests/OrgCoreTests/BabelTests.swift added +153
@@ -0,0 +1,153 @@
1import Foundation
2import Testing
3@testable import OrgCore
4
5/// Runs a Babel job the way the app does, for tests.
6enum TestBabelRunner {
7 static func run(_ job: BabelJob, directory: URL) throws -> BabelOutput {
8 let folder = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-babel-\(UUID().uuidString)")
9 try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
10 defer { try? FileManager.default.removeItem(at: folder) }
11 let resultFile = folder.appendingPathComponent("result")
12 let input = job.input.replacingOccurrences(of: Babel.resultFileToken, with: resultFile.path)
13 let process = Process()
14 process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
15 switch job.runner {
16 case .process(let executable, let arguments):
17 process.arguments = [executable] + arguments
18 case .emacs:
19 let script = folder.appendingPathComponent("babel.el")
20 try input.write(to: script, atomically: true, encoding: .utf8)
21 process.arguments = [EmacsOracle.executable, "-Q", "--batch", "-l", script.path]
22 }
23 process.currentDirectoryURL = job.directory.map { URL(fileURLWithPath: $0, relativeTo: directory) } ?? directory
24 let stdin = Pipe()
25 let stdout = Pipe()
26 process.standardInput = stdin
27 process.standardOutput = stdout
28 process.standardError = FileHandle.nullDevice
29 try process.run()
30 if case .process = job.runner { stdin.fileHandleForWriting.write(Data(input.utf8)) }
31 try stdin.fileHandleForWriting.close()
32 let data = stdout.fileHandleForReading.readDataToEndOfFile()
33 process.waitUntilExit()
34 return BabelOutput(stdout: String(decoding: data, as: UTF8.self), resultFile: try? String(contentsOf: resultFile, encoding: .utf8))
35 }
36
37 /// Plans, runs and finishes the block at `caret`: the new text, or the failure.
38 static func execute(_ text: String, caret: Int, directory: URL) throws -> (text: String, failure: String?) {
39 let context = EditContext(revision: 0, text: text, tree: OrgParser.parse(text), selection: [caret..<caret])
40 switch Babel.plan(context) {
41 case .failure(.message(let message)): return (text, message)
42 case .success(let job):
43 let output = try run(job, directory: directory)
44 switch Babel.finish(job, output: output, text: text) {
45 case .failure(.message(let message)): return (text, message)
46 case .success(let edits):
47 var new = text
48 for edit in edits.sorted(by: { $0.range.lowerBound > $1.range.lowerBound }) { new = edit.apply(to: new) }
49 return (new, nil)
50 }
51 }
52 }
53}
54
55struct BabelTests {
56 static let cases: [String] = [
57 "#+begin_src sh\necho hello\n#+end_src\n",
58 "#+begin_src sh\necho hello\n#+end_src\nNext paragraph.\n",
59 "#+begin_src sh\necho hello\n#+end_src\n\nNext paragraph.\n",
60 "#+begin_src sh\necho hello\n#+end_src",
61 "#+begin_src sh\necho one; echo two\n#+end_src\n",
62 "#+begin_src sh\nprintf 'a b c\\nd e f\\n'\n#+end_src\n",
63 "#+begin_src sh\nprintf 'a\\tb\\nc\\td\\n'\n#+end_src\n",
64 "#+begin_src sh\nprintf '1,2\\n3,4\\n'\n#+end_src\n",
65 "#+begin_src sh :results output\nprintf 'a b c\\nd e f\\n'\n#+end_src\n",
66 "#+begin_src sh :results output\nseq 1 12\n#+end_src\n",
67 "#+begin_src sh :results output\nprintf '* not a heading\\n#+not a keyword\\n'\nseq 1 9\n#+end_src\n",
68 "#+begin_src sh :results value\necho hi\n#+end_src\n",
69 "#+begin_src sh :results verbatim\necho 007\n#+end_src\n",
70 "#+begin_src sh\necho 007\n#+end_src\n",
71 "#+begin_src sh :results raw\necho '*bold*'\n#+end_src\n",
72 "#+begin_src sh :results drawer\necho in a drawer\n#+end_src\n",
73 "#+begin_src sh :results list\nprintf 'a\\nb\\n'\n#+end_src\n",
74 "#+begin_src sh :results silent\necho quiet\n#+end_src\n",
75 "#+begin_src sh :results output code\necho 'x=1'\n#+end_src\n",
76 "#+begin_src sh :wrap example\necho wrapped\n#+end_src\n",
77 "#+begin_src sh\necho new\n#+end_src\n\n#+RESULTS:\n: old\n\nAfter.\n",
78 "#+begin_src sh\necho new\n#+end_src\n#+RESULTS:\n| old | table |\n| a | b |\nAfter.\n",
79 "#+begin_src sh :results append\necho more\n#+end_src\n\n#+RESULTS:\n: first\n",
80 "#+NAME: greet\n#+begin_src sh\necho named\n#+end_src\n\nText.\n\n#+RESULTS: greet\n: stale\n",
81 "#+NAME: fresh\n#+begin_src sh\necho named\n#+end_src\n",
82 "- item\n #+begin_src sh\n echo indented\n #+end_src\n",
83 "#+begin_src sh :var x=5 :var y=\"two words\"\necho $x $y\n#+end_src\n",
84 "#+begin_src bash\necho ${BASH_VERSION:+bash}\n#+end_src\n",
85 "#+begin_src sh :results output\n#+end_src\n",
86 "#+begin_src sh\nprintf ''\n#+end_src\n",
87 "#+begin_src python\nreturn 42\n#+end_src\n",
88 "#+begin_src python\nreturn 'hello'\n#+end_src\n",
89 "#+begin_src python\nreturn [[1, 2], [3, 4]]\n#+end_src\n",
90 "#+begin_src python\nreturn [1, 'a', 2.5]\n#+end_src\n",
91 "#+begin_src python\nreturn 1/3\n#+end_src\n",
92 "#+begin_src python\nx = 2\nif x:\n return x * 21\n#+end_src\n",
93 "#+begin_src python :results output\nprint('one')\nprint('two')\n#+end_src\n",
94 "#+begin_src python :results output\nfor i in range(12): print(i)\n#+end_src\n",
95 "#+begin_src python :var n=3 :var s=\"hi\"\nreturn s * n\n#+end_src\n",
96 "#+NAME: nums\n| 1 | 2 |\n| 3 | 4 |\n\n#+begin_src python :var t=nums\nreturn [[c * 10 for c in r] for r in t]\n#+end_src\n",
97 "#+begin_src python :results list\nreturn ['a', 'b']\n#+end_src\n",
98 "#+begin_src python :results verbatim\nreturn [1, 2]\n#+end_src\n",
99 "#+begin_src python\ns = '''multi\n line'''\nreturn s\n#+end_src\n",
100 "#+begin_src python\nreturn None\n#+end_src\n",
101 "#+begin_src emacs-lisp\n(+ 1 2)\n#+end_src\n",
102 "#+begin_src emacs-lisp\n'((1 2) (3 4))\n#+end_src\n",
103 "#+begin_src elisp\n(concat \"a\" \"b\")\n#+end_src\n",
104 "#+begin_src emacs-lisp :results verbatim\n\"quoted\"\n#+end_src\n",
105 "#+begin_src emacs-lisp :results output\n(princ \"printed\")\n#+end_src\n",
106 "#+begin_src emacs-lisp :var x=4\n(* x x)\n#+end_src\n",
107 "#+PROPERTY: header-args :results output\n#+begin_src sh\nprintf 'a b\\nc d\\n'\n#+end_src\n",
108 "* Heading\n:PROPERTIES:\n:header-args:sh: :results verbatim\n:END:\n#+begin_src sh\necho 1 2\n#+end_src\n",
109 "#+HEADER: :results output\n#+begin_src sh\nprintf 'x y\\nz w\\n'\n#+end_src\n",
110 ]
111
112 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
113 func matchesEmacs() throws {
114 let folder = FileManager.default.temporaryDirectory
115 var emacsCases: [EmacsOracle.Case] = []
116 var ours: [(text: String, failure: String?)] = []
117 for text in Self.cases {
118 let caret = (text as NSString).range(of: "#+begin_src").location + 2
119 ours.append(try TestBabelRunner.execute(text, caret: caret, directory: folder))
120 emacsCases.append(EmacsOracle.Case(text: text, point: EmacsOracle.point(caret, in: text), form: """
121 (progn
122 (require 'ob-shell)
123 (require 'ob-python)
124 (require 'ob-emacs-lisp)
125 (setq org-confirm-babel-evaluate nil
126 org-babel-python-command "python3"
127 default-directory temporary-file-directory)
128 (org-babel-execute-src-block))
129 """))
130 }
131 let emacs = try EmacsOracle.run(emacsCases)
132 for (i, (mine, theirs)) in zip(ours, emacs).enumerated() {
133 #expect(mine.text == theirs.text, "case \(i):\n\(Self.cases[i])\nours (\(mine.failure ?? "")):\n\(mine.text)\nemacs (\(theirs.error)):\n\(theirs.text)")
134 }
135 }
136
137 @Test func refusesWhatWouldRunDifferently() {
138 for (text, message) in [
139 ("#+begin_src sh :eval never\necho\n#+end_src\n", "Evaluation of this sh code block is disabled."),
140 ("#+begin_src sh :session s\necho\n#+end_src\n", ":session isn't supported yet; nothing was run."),
141 ("#+begin_src sh :noweb yes\necho\n#+end_src\n", ":noweb isn't supported yet; nothing was run."),
142 ("#+begin_src sh :file out.txt\necho\n#+end_src\n", ":file isn't supported yet; nothing was run."),
143 ("#+begin_src cobol\nDISPLAY\n#+end_src\n", "No way to run cobol blocks yet."),
144 ] {
145 let context = EditContext(revision: 0, text: text, tree: OrgParser.parse(text), selection: [2..<2])
146 guard case .failure(.message(let got)) = Babel.plan(context) else {
147 Issue.record("ran: \(text)")
148 continue
149 }
150 #expect(got == message)
151 }
152 }
153}
docs/plans/2026-10-05-babel.md added +28
@@ -0,0 +1,28 @@
1# Babel Implementation Plan
2
3> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
5**Goal:** C-c C-c in a src block runs it (sh and the other shells, python, emacs-lisp, and ruby/js/R/awk for output) and writes `#+RESULTS:` as Emacs does.
6
7**Architecture:** `OrgCore/Compute/Babel.swift` plans and finishes; the platform runs. `Babel.plan` resolves header arguments as `org-babel-merge-params` does (defaults, `header-args` properties from the file and headings, the block line, `#+HEADER:` lines; `:results` by exclusive groups; `:var` by name), refuses `:eval never`, `:session`, `:noweb`, `:file`, `:prologue`, `:epilogue`, `:post`, `:stdin`, `:cmdline`, `:shebang`, `:cache yes` and src-block references with a message, and builds a `BabelJob`: the program on standard input for the interpreter (shell variables as `name='value'`, python wrapped in `def main()` with a result file as ob-python does, emacs-lisp evaluated in `emacs --batch` printing its value as JSON). `Babel.finish` applies `org-babel-result-cond`, the table import (`org-table-convert-region` with `babel-auto`, `org-babel--string-to-number`), python's `table-or-string`, and `org-babel-insert-result`: where the results go (`#+RESULTS:` after the block or `#+RESULTS: name`), `replace`/`append`/`silent`, tables aligned, lists, `raw`, `drawer`, `code`, `org`, `html`, `latex`, `:wrap`, `: ` lines under ten lines and example blocks from ten, code escaping, indentation. The app asks before running (yes/no/always, trusted by block content in `trusted.json`; `:eval query` always asks), runs the job with `BabelRunner`, and writes the result if the block is still there.
8
9**Tech Stack:** Swift 6.2 tools, Swift Testing, Emacs 31.1 / Org 9.8.7 oracle with python3.
10
11**Spec:** `docs/design.md`, "Babel execution (macOS)".
12
13## Global Constraints
14
15- Oracle: 53 blocks run natively and by `org-babel-execute-src-block`; whole buffers compared.
16- Shell blocks run `sh`, `bash`, … by name with the program on standard input; `shell` runs `/bin/sh`.
17- The python wrapper is written for Orgstar and behaves as ob-python's (no pandas or numpy conversion).
18
19## Defaults chosen (user may change)
20
21- `python3` for python blocks (`:python` overrides); five-minute timeout.
22- `:var` tables with rules, `name[...]` indexing, calls and src-block references aren't supported yet; tables and lists only for python and emacs-lisp.
23
24---
25
26- [x] `Babel.plan`, `Babel.finish`, `ExecuteSrcBlock`, `src` key context, `C-c C-c`; oracle.
27- [x] `BabelRunner`, `DocumentSession.runBabel`, confirmation and trust; session test.
28- [x] Commit "Babel".