Babel :file results, diagrams and :session !94

merged merged by cmc on 2026-10-06 05:19 UTC · krz/orgstar:babel-file-session into main

7 files changed, +337 −9

Layout: unified · split

Sources/OrgApp/BabelRunner.swift +7 −1
@@ -9,7 +9,7 @@ public enum BabelRunner {
9 } 9 }
10 10
11 /// PATH with the usual install locations, which an app launched from the Dock lacks. 11 /// PATH with the usual install locations, which an app launched from the Dock lacks.
12 static var environment: [String: String] { 12 public static var environment: [String: String] {
13 var environment = ProcessInfo.processInfo.environment 13 var environment = ProcessInfo.processInfo.environment
14 let extra = ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin"] 14 let extra = ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin"]
15 let path = (environment["PATH"] ?? "").split(separator: ":").map(String.init) 15 let path = (environment["PATH"] ?? "").split(separator: ":").map(String.init)
@@ -23,6 +23,10 @@ public enum BabelRunner {
23 defer { try? FileManager.default.removeItem(at: folder) } 23 defer { try? FileManager.default.removeItem(at: folder) }
24 let resultFile = folder.appendingPathComponent("result") 24 let resultFile = folder.appendingPathComponent("result")
25 let input = job.input.replacingOccurrences(of: Babel.resultFileToken, with: resultFile.path) 25 let input = job.input.replacingOccurrences(of: Babel.resultFileToken, with: resultFile.path)
26 if case .session(let name, let executable, let python) = job.runner {
27 let directory = job.directory.map { URL(fileURLWithPath: $0, relativeTo: fileDirectory).standardizedFileURL } ?? fileDirectory
28 return try await BabelSessions.shared.run(job, name: name, executable: executable, python: python, directory: directory, resultFile: resultFile)
29 }
26 30
27 let process = Process() 31 let process = Process()
28 process.executableURL = URL(fileURLWithPath: "/usr/bin/env") 32 process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
@@ -36,6 +40,8 @@ public enum BabelRunner {
36 let script = folder.appendingPathComponent("babel.el") 40 let script = folder.appendingPathComponent("babel.el")
37 try input.write(to: script, atomically: true, encoding: .utf8) 41 try input.write(to: script, atomically: true, encoding: .utf8)
38 process.arguments = [emacs.path, "-Q", "--batch", "-l", script.path] 42 process.arguments = [emacs.path, "-Q", "--batch", "-l", script.path]
43 case .session:
44 return BabelOutput(stdout: "")
39 } 45 }
40 let directory = job.directory.map { URL(fileURLWithPath: $0, relativeTo: fileDirectory).standardizedFileURL } ?? fileDirectory 46 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.") } 47 guard FileManager.default.fileExists(atPath: directory.path) else { throw Failure(description: "No folder \(directory.path) for :dir.") }
Sources/OrgApp/BabelSessions.swift added +124
@@ -0,0 +1,124 @@
1import Foundation
2import OrgCore
3
4/// Interpreters kept running for `:session`, one per file folder, interpreter and session name.
5/// Each run sends the code, then a line that prints a marker; the output is what came before it.
6public final class BabelSessions: @unchecked Sendable {
7 public static let shared = BabelSessions()
8
9 private let lock = NSLock()
10 private var sessions: [String: Session] = [:]
11
12 final class Session: @unchecked Sendable {
13 let process = Process()
14 private let input: FileHandle
15 private let lock = NSLock()
16 private var stdout = Data()
17 private var stderr = Data()
18 private var waiting: (marker: String, continuation: CheckedContinuation<(String, String, Int32), Error>)?
19
20 init(executable: String, arguments: [String], directory: URL) throws {
21 let inPipe = Pipe(), outPipe = Pipe(), errPipe = Pipe()
22 process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
23 process.arguments = [executable] + arguments
24 process.environment = BabelRunner.environment
25 process.currentDirectoryURL = directory
26 process.standardInput = inPipe
27 process.standardOutput = outPipe
28 process.standardError = errPipe
29 input = inPipe.fileHandleForWriting
30 outPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in self?.received(handle.availableData, error: false) }
31 errPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in self?.received(handle.availableData, error: true) }
32 process.terminationHandler = { [weak self] _ in self?.ended() }
33 try process.run()
34 }
35
36 /// Sends `code` and waits for `marker`, printed with the last status after it.
37 func run(_ code: String, marker: String) async throws -> (stdout: String, stderr: String, status: Int32) {
38 let result: (String, String, Int32) = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<(String, String, Int32), Error>) in
39 lock.withLock {
40 stdout = Data()
41 stderr = Data()
42 waiting = (marker, continuation)
43 }
44 do {
45 try input.write(contentsOf: Data(code.utf8))
46 } catch {
47 finish(.failure(error))
48 }
49 }
50 return (result.0, result.1, result.2)
51 }
52
53 private func received(_ data: Data, error: Bool) {
54 guard !data.isEmpty else { return }
55 var result: (String, String, Int32)?
56 lock.withLock {
57 if error { stderr.append(data) } else { stdout.append(data) }
58 guard let marker = waiting?.marker, !error else { return }
59 let text = String(decoding: stdout, as: UTF8.self)
60 guard let found = text.range(of: marker + " ", options: .backwards),
61 let lineEnd = text[found.upperBound...].firstIndex(of: "\n") else { return }
62 let status = Int32(text[found.upperBound..<lineEnd].trimmingCharacters(in: .whitespaces)) ?? 0
63 result = (String(text[..<found.lowerBound]), String(decoding: stderr, as: UTF8.self), status)
64 }
65 if let result { finish(.success(result)) }
66 }
67
68 private func ended() {
69 finish(.failure(BabelRunner.Failure(description: "The session ended.")))
70 }
71
72 private func finish(_ result: Result<(String, String, Int32), Error>) {
73 let continuation = lock.withLock { () -> CheckedContinuation<(String, String, Int32), Error>? in
74 defer { waiting = nil }
75 return waiting?.continuation
76 }
77 continuation?.resume(with: result)
78 }
79
80 func stop() { if process.isRunning { process.terminate() } }
81 }
82
83 /// Runs a session job: the session starts in `directory` the first time.
84 func run(_ job: BabelJob, name: String, executable: String, python: Bool, directory: URL, resultFile: URL) async throws -> BabelOutput {
85 let key = [directory.path, executable, name].joined(separator: "\u{1}")
86 let session: Session = try lock.withLock {
87 if let existing = sessions[key], existing.process.isRunning { return existing }
88 let created = try Session(executable: executable, arguments: python ? ["-u", "-i", "-q"] : [], directory: directory)
89 sessions[key] = created
90 return created
91 }
92 let marker = "__orgstar_done_\(UUID().uuidString.replacingOccurrences(of: "-", with: ""))__"
93 let program = job.input.replacingOccurrences(of: Babel.resultFileToken, with: resultFile.path)
94 let code: String
95 if python {
96 let file = resultFile.deletingLastPathComponent().appendingPathComponent("program.py")
97 try program.write(to: file, atomically: true, encoding: .utf8)
98 let path = file.path.replacingOccurrences(of: "'", with: "\\'")
99 code = "try:\n exec(compile(open('\(path)').read(), '<org-babel>', 'exec'), globals())\nfinally:\n print('\\n\(marker) 0', flush=True)\n\n"
100 } else {
101 code = program + (program.hasSuffix("\n") ? "" : "\n") + "printf '\\n%s %d\\n' \(marker) $?\n"
102 }
103 let (stdout, stderr, status) = try await withTaskCancellationHandler {
104 try await session.run(code, marker: marker)
105 } onCancel: {
106 session.stop()
107 }
108 // The marker starts on a line of its own; the newline before it isn't output.
109 var output = stdout
110 if output.hasSuffix("\n") { output.removeLast() }
111 // Interactive Python prompts go to standard error.
112 let problems = stderr.split(separator: "\n").filter { $0.range(of: "^(>>> |\\.\\.\\. )*$", options: .regularExpression) == nil }
113 return BabelOutput(stdout: output, resultFile: try? String(contentsOf: resultFile, encoding: .utf8),
114 stderr: problems.joined(separator: "\n"), status: status)
115 }
116
117 /// Stops every session, as when the app quits.
118 public func stopAll() {
119 lock.withLock {
120 sessions.values.forEach { $0.stop() }
121 sessions = [:]
122 }
123 }
124}
Sources/OrgApp/DocumentSession.swift +9
@@ -582,6 +582,15 @@ public final class DocumentSession {
582 do { 582 do {
583 let output = try await BabelRunner.run(job, fileDirectory: directory) 583 let output = try await BabelRunner.run(job, fileDirectory: directory)
584 guard entries.contains(where: { $0 === entry }) else { return "The file was closed before the block finished." } 584 guard entries.contains(where: { $0 === entry }) else { return "The file was closed before the block finished." }
585 if let file = Babel.fileResult(job, output: output) {
586 let runDirectory = job.directory.map { URL(fileURLWithPath: $0, relativeTo: directory).standardizedFileURL } ?? directory
587 let target = file.path.hasPrefix("/") ? URL(fileURLWithPath: file.path) : runDirectory.appendingPathComponent(file.path)
588 do {
589 try file.contents.write(to: target, atomically: true, encoding: .utf8)
590 } catch {
591 return "Can't write \(file.path): \(error.localizedDescription)"
592 }
593 }
585 let current = entry.buffer 594 let current = entry.buffer
586 let document = current.document 595 let document = current.document
587 let done = output.problem ?? "Code block evaluation complete." 596 let done = output.problem ?? "Code block evaluation complete."
Sources/OrgCore/Compute/Babel.swift +103 −6
@@ -12,6 +12,9 @@ public struct BabelJob: Sendable, Equatable {
12 case process(executable: String, arguments: [String]) 12 case process(executable: String, arguments: [String])
13 /// `emacs --batch` loading `input` as a script. 13 /// `emacs --batch` loading `input` as a script.
14 case emacs 14 case emacs
15 /// A long-running interpreter named by `:session`: a shell gets `input` as commands, Python
16 /// runs `input` as a program in its globals.
17 case session(name: String, executable: String, python: Bool)
15 } 18 }
16 19
17 public let language: String 20 public let language: String
@@ -32,12 +35,17 @@ public struct BabelJob: Sendable, Equatable {
32 enum Kind: Sendable, Equatable { case shell(valueIsExitStatus: Bool), python, generic, emacsLisp } 35 enum Kind: Sendable, Equatable { case shell(valueIsExitStatus: Bool), python, generic, emacsLisp }
33 let kind: Kind 36 let kind: Kind
34 var language = "" 37 var language = ""
35 public let params: [String] 38 public internal(set) var params: [String]
36 let output: Bool 39 let output: Bool
37 let wrap: String? 40 let wrap: String?
38 let name: String? 41 let name: String?
39 /// An inline block: the result goes after it, in `{{{results(…)}}}`. 42 /// An inline block: the result goes after it, in `{{{results(…)}}}`.
40 var inline = false 43 var inline = false
44 /// `:file` (under `:output-dir`) with `:results file`, and `:file-desc`.
45 var file: String?
46 var fileDescription: String?
47 /// The program writes `file` itself, as graphics languages do.
48 var writesFile = false
41 } 49 }
42} 50}
43 51
@@ -174,10 +182,16 @@ public enum Babel {
174 if eval == "never" || eval == "no" || eval == "never-export" || eval == "no-export" { 182 if eval == "never" || eval == "no" || eval == "never-export" || eval == "no-export" {
175 return .failure(.message("Evaluation of this \(language) code block\(block.name.map { " (\($0)) " } ?? " ")is disabled.")) 183 return .failure(.message("Evaluation of this \(language) code block\(block.name.map { " (\($0)) " } ?? " ")is disabled."))
176 } 184 }
177 for key in [":prologue", ":epilogue", ":file", ":post", ":stdin", ":cmdline", ":shebang"] where params.single[key] != nil { 185 if params.single[":cmdline"] != nil, !["dot", "plantuml", "mermaid"].contains(language) {
186 return .failure(.message(":cmdline isn't supported yet; nothing was run."))
187 }
188 for key in [":prologue", ":epilogue", ":post", ":stdin", ":shebang"] where params.single[key] != nil {
178 return .failure(.message("\(key) isn't supported yet; nothing was run.")) 189 return .failure(.message("\(key) isn't supported yet; nothing was run."))
179 } 190 }
180 if let session = params.single[":session"], session != "none" { return .failure(.message(":session isn't supported yet; nothing was run.")) } 191 let session = params.single[":session"].flatMap { $0 == "none" ? nil : ($0.isEmpty ? "*\(language)*" : $0) }
192 if session != nil, !shells.contains(language), language != "python" {
193 return .failure(.message(":session isn't supported for \(language) yet; nothing was run."))
194 }
181 if params.single[":cache"] == "yes" { return .failure(.message(":cache isn't supported yet; nothing was run.")) } 195 if params.single[":cache"] == "yes" { return .failure(.message(":cache isn't supported yet; nothing was run.")) }
182 196
183 var body = normalizedBody(block) 197 var body = normalizedBody(block)
@@ -191,8 +205,13 @@ public enum Babel {
191 } 205 }
192 } 206 }
193 let output = params.results.contains("output") 207 let output = params.results.contains("output")
208 let file = params.single[":file"].map { name in
209 params.single[":output-dir"].map { $0.hasSuffix("/") ? $0 + name : $0 + "/" + name } ?? name
210 }
211 let toFile = params.results.contains("file")
194 let finishBase = { (kind: BabelJob.Finish.Kind) in 212 let finishBase = { (kind: BabelJob.Finish.Kind) in
195 BabelJob.Finish(kind: kind, language: language, params: params.results, output: output, wrap: params.wrap, name: name, inline: inline) 213 BabelJob.Finish(kind: kind, language: language, params: params.results, output: output, wrap: params.wrap, name: name, inline: inline,
214 file: toFile ? file : nil, fileDescription: params.single[":file-desc"])
196 } 215 }
197 let vars: [(String, VarValue)] 216 let vars: [(String, VarValue)]
198 do { 217 do {
@@ -220,12 +239,37 @@ public enum Babel {
220 let full = (lines + [body]).joined(separator: "\n") + (valueIsExitStatus ? "\necho $?" : "") 239 let full = (lines + [body]).joined(separator: "\n") + (valueIsExitStatus ? "\necho $?" : "")
221 let executable = language == "shell" ? options.shell : language 240 let executable = language == "shell" ? options.shell : language
222 return .success(BabelJob( 241 return .success(BabelJob(
223 language: language, runner: .process(executable: executable, arguments: []), 242 language: language, runner: session.map { .session(name: $0, executable: executable, python: false) } ?? .process(executable: executable, arguments: []),
224 input: full.trimmingCharacters(in: .whitespacesAndNewlines) + "\n", directory: dir, 243 input: full.trimmingCharacters(in: .whitespacesAndNewlines) + "\n", directory: dir,
225 block: location, blockText: blockText, alwaysAsk: alwaysAsk, 244 block: location, blockText: blockText, alwaysAsk: alwaysAsk,
226 finish: finishBase(.shell(valueIsExitStatus: valueIsExitStatus)) 245 finish: finishBase(.shell(valueIsExitStatus: valueIsExitStatus))
227 )) 246 ))
228 } 247 }
248 if language == "python", let session {
249 // `org-babel-python--eval-ast`: the body at top level in the session, the value its
250 // last expression's.
251 let lines = vars.map { "\($0.0)=\(pythonLiteral($0.1))" }
252 let source = (lines + [body]).joined(separator: "\n")
253 let literal = String(data: try! JSONSerialization.data(withJSONObject: [source]), encoding: .utf8)!.dropFirst().dropLast()
254 let program: String
255 if output {
256 program = "exec(compile(\(literal), '<org-babel>', 'exec'), globals())\n"
257 } else {
258 program = pythonFormatter + """
259 import ast as __orgstar_ast
260 __orgstar_tree = __orgstar_ast.parse(\(literal))
261 __orgstar_last = None
262 if __orgstar_tree.body and isinstance(__orgstar_tree.body[-1], __orgstar_ast.Expr):
263 __orgstar_last = __orgstar_ast.Expression(__orgstar_tree.body.pop().value)
264 exec(compile(__orgstar_tree, '<org-babel>', 'exec'), globals())
265 __orgstar_format(eval(compile(__orgstar_last, '<org-babel>', 'eval'), globals()) if __orgstar_last else None, '\(resultFileToken)', [
266 """ + params.results.map { "\"\($0)\"" }.joined(separator: ", ") + "])\n"
267 }
268 return .success(BabelJob(
269 language: language, runner: .session(name: session, executable: params.single[":python"] ?? options.python, python: true),
270 input: program, directory: dir, block: location, blockText: blockText, alwaysAsk: alwaysAsk, finish: finishBase(.python)
271 ))
272 }
229 if language == "python" { 273 if language == "python" {
230 let lines = vars.map { "\($0.0)=\(pythonLiteral($0.1))" } 274 let lines = vars.map { "\($0.0)=\(pythonLiteral($0.1))" }
231 var full = (lines + [body]).joined(separator: "\n") 275 var full = (lines + [body]).joined(separator: "\n")
@@ -252,6 +296,32 @@ public enum Babel {
252 directory: dir, block: location, blockText: blockText, alwaysAsk: alwaysAsk, finish: finishBase(.emacsLisp) 296 directory: dir, block: location, blockText: blockText, alwaysAsk: alwaysAsk, finish: finishBase(.emacsLisp)
253 )) 297 ))
254 } 298 }
299 if ["dot", "plantuml", "mermaid"].contains(language) {
300 // ob-dot, ob-plantuml and ob-mermaid: the program draws into `:file`.
301 guard let file else { return .failure(.message("\(language) code blocks need a :file header argument")) }
302 let quoted = "'" + file.replacingOccurrences(of: "'", with: "'\"'\"'") + "'"
303 let ext = (file as NSString).pathExtension.isEmpty ? "png" : (file as NSString).pathExtension
304 let cmdline = params.single[":cmdline"].map { " " + $0 } ?? ""
305 var input = body + "\n"
306 let command: String
307 switch language {
308 case "dot":
309 command = "dot\(cmdline) -T\(ext) -o \(quoted)"
310 case "plantuml":
311 if body.range(of: "^[ \\t]*@start", options: .regularExpression) == nil { input = "@startuml\n" + body + "\n@enduml\n" }
312 command = "plantuml\(cmdline) -p -t\(ext) > \(quoted)"
313 default:
314 command = "f=$(mktemp).mmd; cat > \"$f\"; mmdc\(cmdline) -i \"$f\" -o \(quoted); s=$?; rm -f \"$f\"; exit $s"
315 }
316 var finish = finishBase(.generic)
317 finish.file = file
318 finish.writesFile = true
319 finish.params = params.results.filter { !["table", "scalar", "verbatim", "list", "vector"].contains($0) } + (params.results.contains("file") ? [] : ["file"])
320 return .success(BabelJob(
321 language: language, runner: .process(executable: "/bin/sh", arguments: ["-c", command]),
322 input: input, directory: dir, block: location, blockText: blockText, alwaysAsk: alwaysAsk, finish: finish
323 ))
324 }
255 if let (executable, arguments) = generic[language] { 325 if let (executable, arguments) = generic[language] {
256 guard vars.isEmpty else { return .failure(.message(":var isn't supported for \(language) yet.")) } 326 guard vars.isEmpty else { return .failure(.message(":var isn't supported for \(language) yet.")) }
257 return .success(BabelJob( 327 return .success(BabelJob(
@@ -713,6 +783,13 @@ public enum Babel {
713 /// The language's result handling: `org-babel-result-cond` and the table import. 783 /// The language's result handling: `org-babel-result-cond` and the table import.
714 static func value(_ finish: BabelJob.Finish, _ output: BabelOutput) -> Value { 784 static func value(_ finish: BabelJob.Finish, _ output: BabelOutput) -> Value {
715 let params = finish.params 785 let params = finish.params
786 if params.contains("file") {
787 // `org-babel-result-to-file`: a link to `:file`, or to the result as a path.
788 let path = finish.file ?? rawResult(finish, output)
789 var link = "[[file:" + path + "]"
790 if let description = finish.fileDescription { link += "[" + (description.isEmpty ? path : description) + "]" }
791 return .string(link + "]")
792 }
716 let scalar = params.contains("scalar") || params.contains("verbatim") || params.contains("html") || params.contains("code") 793 let scalar = params.contains("scalar") || params.contains("verbatim") || params.contains("html") || params.contains("code")
717 || params.contains("pp") || params.contains("file") 794 || params.contains("pp") || params.contains("file")
718 || ((params.contains("output") || params.contains("raw") || params.contains("org")) && !params.contains("table")) 795 || ((params.contains("output") || params.contains("raw") || params.contains("org")) && !params.contains("table"))
@@ -745,6 +822,26 @@ public enum Babel {
745 } 822 }
746 } 823 }
747 824
825 /// The result as the program left it, before tables are read.
826 static func rawResult(_ finish: BabelJob.Finish, _ output: BabelOutput) -> String {
827 switch finish.kind {
828 case .python: return finish.output ? output.stdout : (output.resultFile ?? "")
829 case .emacsLisp:
830 guard let data = output.stdout.data(using: .utf8), let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return output.stdout }
831 return object["s"] as? String ?? ""
832 case .shell(let valueIsExitStatus):
833 return valueIsExitStatus ? output.stdout.split(separator: "\n").last.map(String.init) ?? "" : output.stdout
834 case .generic: return output.stdout
835 }
836 }
837
838 /// With `:results file` and `:file`, what to write there, relative to the run's folder;
839 /// nil when there's nothing to write or the program wrote it.
840 public static func fileResult(_ job: BabelJob, output: BabelOutput) -> (path: String, contents: String)? {
841 guard job.finish.params.contains("file"), let file = job.finish.file, !job.finish.writesFile else { return nil }
842 return (file, rawResult(job.finish, output))
843 }
844
748 /// `org-babel-import-elisp-from-file`. 845 /// `org-babel-import-elisp-from-file`.
749 static func importTable(_ results: String) -> Value { 846 static func importTable(_ results: String) -> Value {
750 guard !results.isEmpty else { return .none } 847 guard !results.isEmpty else { return .none }
@@ -1064,7 +1161,7 @@ public enum Babel {
1064 wrap("#+begin_src org", "#+end_src", escape: true) 1161 wrap("#+begin_src org", "#+end_src", escape: true)
1065 } else if params.contains("code") { 1162 } else if params.contains("code") {
1066 wrap("#+begin_src \(finish.language.isEmpty ? "none" : finish.language)", "#+end_src", escape: true) 1163 wrap("#+begin_src \(finish.language.isEmpty ? "none" : finish.language)", "#+end_src", escape: true)
1067 } else if params.contains("raw") { 1164 } else if params.contains("raw") || params.contains("file") {
1068 // As inserted. 1165 // As inserted.
1069 } else if params.contains("drawer") { 1166 } else if params.contains("drawer") {
1070 wrap(":results:", ":end:", escape: false) 1167 wrap(":results:", ":end:", escape: false)
Sources/Orgstar/OrgstarApp.swift +5
@@ -108,6 +108,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
108 if SystemReminderCenter.isAvailable { UNUserNotificationCenter.current().delegate = self } 108 if SystemReminderCenter.isAvailable { UNUserNotificationCenter.current().delegate = self }
109 } 109 }
110 110
111 /// `:session` interpreters end with the app.
112 func applicationWillTerminate(_ notification: Notification) {
113 BabelSessions.shared.stopAll()
114 }
115
111 /// Reminders show while Orgstar is in front too. 116 /// Reminders show while Orgstar is in front too.
112 nonisolated func userNotificationCenter( 117 nonisolated func userNotificationCenter(
113 _ center: UNUserNotificationCenter, willPresent notification: UNNotification 118 _ center: UNUserNotificationCenter, willPresent notification: UNNotification
Tests/OrgAppTests/BabelSessionTests.swift added +32
@@ -0,0 +1,32 @@
1import Foundation
2import Testing
3import OrgCore
4@testable import OrgApp
5
6struct BabelSessionStateTests {
7 func run(_ text: String, in folder: URL) async throws -> String {
8 let context = EditContext(revision: 0, text: text, tree: OrgParser.parse(text), selection: [2..<2])
9 guard case .success(let job) = Babel.plan(context) else { Issue.record("not planned: \(text)"); return "" }
10 let output = try await BabelRunner.run(job, fileDirectory: folder)
11 guard case .success(let edits) = Babel.finish(job, output: output, text: text) else { return "" }
12 let new = edits.sorted { $0.range.lowerBound > $1.range.lowerBound }.reduce(text) { $1.apply(to: $0) }
13 return String(new.dropFirst(text.count))
14 }
15
16 @Test func sessionsKeepState() async throws {
17 let folder = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-session-\(UUID().uuidString)")
18 try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
19 defer {
20 BabelSessions.shared.stopAll()
21 try? FileManager.default.removeItem(at: folder)
22 }
23 _ = try await run("#+begin_src sh :session s1\nx=5; cd /tmp\n#+end_src\n", in: folder)
24 #expect(try await run("#+begin_src sh :session s1 :results output\necho $x\n#+end_src\n", in: folder) == "\n#+RESULTS:\n: 5\n")
25 #expect(try await run("#+begin_src sh :session other :results output\necho \"[$x]\"\n#+end_src\n", in: folder) == "\n#+RESULTS:\n: []\n")
26 _ = try await run("#+begin_src python :session py :results output\na = 2\nprint('set')\n#+end_src\n", in: folder)
27 #expect(try await run("#+begin_src python :session py\na * 3\n#+end_src\n", in: folder) == "\n#+RESULTS:\n: 6\n")
28 #expect(try await run("#+begin_src python :session py :results output\nprint(a + 1)\n#+end_src\n", in: folder) == "\n#+RESULTS:\n: 3\n")
29 _ = try await run("#+begin_src python :session py :results output\nraise ValueError('x')\n#+end_src\n", in: folder)
30 #expect(try await run("#+begin_src python :session py\n[a, a]\n#+end_src\n", in: folder) == "\n#+RESULTS:\n| 2 | 2 |\n")
31 }
32}
Tests/OrgCoreTests/BabelTests.swift +57 −2
@@ -19,6 +19,8 @@ enum TestBabelRunner {
19 let script = folder.appendingPathComponent("babel.el") 19 let script = folder.appendingPathComponent("babel.el")
20 try input.write(to: script, atomically: true, encoding: .utf8) 20 try input.write(to: script, atomically: true, encoding: .utf8)
21 process.arguments = [EmacsOracle.executable, "-Q", "--batch", "-l", script.path] 21 process.arguments = [EmacsOracle.executable, "-Q", "--batch", "-l", script.path]
22 case .session:
23 return BabelOutput(stdout: "")
22 } 24 }
23 process.currentDirectoryURL = job.directory.map { URL(fileURLWithPath: $0, relativeTo: directory) } ?? directory 25 process.currentDirectoryURL = job.directory.map { URL(fileURLWithPath: $0, relativeTo: directory) } ?? directory
24 let stdin = Pipe() 26 let stdin = Pipe()
@@ -41,6 +43,9 @@ enum TestBabelRunner {
41 case .failure(.message(let message)): return (text, message) 43 case .failure(.message(let message)): return (text, message)
42 case .success(let job): 44 case .success(let job):
43 let output = try run(job, directory: directory) 45 let output = try run(job, directory: directory)
46 if let file = Babel.fileResult(job, output: output) {
47 try file.contents.write(to: directory.appendingPathComponent(file.path), atomically: true, encoding: .utf8)
48 }
44 switch Babel.finish(job, output: output, text: text) { 49 switch Babel.finish(job, output: output, text: text) {
45 case .failure(.message(let message)): return (text, message) 50 case .failure(.message(let message)): return (text, message)
46 case .success(let edits): 51 case .success(let edits):
@@ -137,9 +142,9 @@ struct BabelTests {
137 @Test func refusesWhatWouldRunDifferently() { 142 @Test func refusesWhatWouldRunDifferently() {
138 for (text, message) in [ 143 for (text, message) in [
139 ("#+begin_src sh :eval never\necho\n#+end_src\n", "Evaluation of this sh code block is disabled."), 144 ("#+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."), 145 ("#+begin_src ruby :session s\nputs 1\n#+end_src\n", ":session isn't supported for ruby yet; nothing was run."),
141 ("#+begin_src sh :noweb yes\n<<x()>>\n#+end_src\n", "Noweb references that run a block (x()) aren't supported yet; nothing was run."), 146 ("#+begin_src sh :noweb yes\n<<x()>>\n#+end_src\n", "Noweb references that run a block (x()) aren'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."), 147 ("#+begin_src sh :cmdline -x\necho\n#+end_src\n", ":cmdline isn't supported yet; nothing was run."),
143 ("#+begin_src cobol\nDISPLAY\n#+end_src\n", "No way to run cobol blocks yet."), 148 ("#+begin_src cobol\nDISPLAY\n#+end_src\n", "No way to run cobol blocks yet."),
144 ] { 149 ] {
145 let context = EditContext(revision: 0, text: text, tree: OrgParser.parse(text), selection: [2..<2]) 150 let context = EditContext(revision: 0, text: text, tree: OrgParser.parse(text), selection: [2..<2])
@@ -193,3 +198,53 @@ struct BabelCallTests {
193 } 198 }
194 } 199 }
195} 200}
201
202struct BabelFileTests {
203 static let cases = [
204 "#+begin_src sh :results file :file out.txt\necho hello\necho there\n#+end_src\n",
205 "#+begin_src sh :results output file :file x.txt :file-desc Desc\necho out\n#+end_src\n",
206 "#+begin_src sh :results file :file y.txt :file-desc\necho y\n#+end_src\n",
207 "#+begin_src python :results file :file p.txt\nreturn [[1,2],[3,4]]\n#+end_src\n",
208 "#+begin_src sh :results file\necho no-file.txt\n#+end_src\n",
209 "#+begin_src sh :results file :file w.txt :output-dir outd\necho w\n#+end_src\n",
210 "#+begin_src sh :file plain.txt\necho not to a file\n#+end_src\n",
211 ]
212
213 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
214 func matchesEmacs() throws {
215 for (i, text) in Self.cases.enumerated() {
216 let ours = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-file-ours-\(UUID().uuidString)")
217 let theirs = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-file-emacs-\(UUID().uuidString)")
218 for folder in [ours, theirs] { try FileManager.default.createDirectory(at: folder.appendingPathComponent("outd"), withIntermediateDirectories: true) }
219 defer { for folder in [ours, theirs] { try? FileManager.default.removeItem(at: folder) } }
220 let mine = try TestBabelRunner.execute(text, caret: 2, directory: ours)
221 let emacs = try EmacsOracle.run([EmacsOracle.Case(text: text, point: 3, form: """
222 (progn (require 'ob-shell) (require 'ob-python)
223 (setq org-confirm-babel-evaluate nil org-babel-python-command "python3" default-directory \(lispString(theirs.path + "/")))
224 (org-babel-execute-src-block))
225 """)])[0]
226 #expect(mine.text == emacs.text, "case \(i)\nours: \(mine.text) \(mine.failure ?? "")\nemacs: \(emacs.text) \(emacs.error)")
227 for name in ["out.txt", "x.txt", "y.txt", "p.txt", "outd/w.txt", "plain.txt"] {
228 let a = try? String(contentsOf: ours.appendingPathComponent(name), encoding: .utf8)
229 let b = try? String(contentsOf: theirs.appendingPathComponent(name), encoding: .utf8)
230 #expect(a == b, "case \(i) \(name): \(a.debugDescription) vs \(b.debugDescription)")
231 }
232 }
233 }
234
235 @Test func plansDiagrams() throws {
236 let text = "#+begin_src dot :file g.svg\ndigraph { a -> b }\n#+end_src\n"
237 guard case .success(let job) = Babel.plan(EditContext(revision: 0, text: text, tree: OrgParser.parse(text), selection: [2..<2])) else {
238 Issue.record("not planned")
239 return
240 }
241 #expect(job.runner == .process(executable: "/bin/sh", arguments: ["-c", "dot -Tsvg -o 'g.svg'"]))
242 guard case .success(let edits) = Babel.finish(job, output: BabelOutput(stdout: ""), text: text) else { return }
243 let new = edits.sorted { $0.range.lowerBound > $1.range.lowerBound }.reduce(text) { $1.apply(to: $0) }
244 #expect(new.hasSuffix("#+RESULTS:\n[[file:g.svg]]\n"))
245 #expect(Babel.fileResult(job, output: BabelOutput(stdout: "")) == nil)
246 let uml = "#+begin_src plantuml :file u.png\nA -> B\n#+end_src\n"
247 guard case .success(let umlJob) = Babel.plan(EditContext(revision: 0, text: uml, tree: OrgParser.parse(uml), selection: [2..<2])) else { return }
248 #expect(umlJob.input == "@startuml\nA -> B\n@enduml\n")
249 }
250}