Commit fce1110f7b
Verified · cmc
Layout: unified · split
Sources/OrgApp/BabelRunner.swift +7 −1
| @@ -9,7 +9,7 @@ public enum BabelRunner { | ||
| 9 | 9 | } |
| 10 | 10 | |
| 11 | 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 | 13 | var environment = ProcessInfo.processInfo.environment |
| 14 | 14 | let extra = ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin"] |
| 15 | 15 | let path = (environment["PATH"] ?? "").split(separator: ":").map(String.init) |
| @@ -23,6 +23,10 @@ public enum BabelRunner { | ||
| 23 | 23 | defer { try? FileManager.default.removeItem(at: folder) } |
| 24 | 24 | let resultFile = folder.appendingPathComponent("result") |
| 25 | 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 | 31 | let process = Process() |
| 28 | 32 | process.executableURL = URL(fileURLWithPath: "/usr/bin/env") |
| @@ -36,6 +40,8 @@ public enum BabelRunner { | ||
| 36 | 40 | let script = folder.appendingPathComponent("babel.el") |
| 37 | 41 | try input.write(to: script, atomically: true, encoding: .utf8) |
| 38 | 42 | process.arguments = [emacs.path, "-Q", "--batch", "-l", script.path] |
| 43 | case .session: | |
| 44 | return BabelOutput(stdout: "") | |
| 39 | 45 | } |
| 40 | 46 | let directory = job.directory.map { URL(fileURLWithPath: $0, relativeTo: fileDirectory).standardizedFileURL } ?? fileDirectory |
| 41 | 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 @@ | ||
| 1 | import Foundation | |
| 2 | import 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. | |
| 6 | public 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 | 582 | do { |
| 583 | 583 | let output = try await BabelRunner.run(job, fileDirectory: directory) |
| 584 | 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 | 594 | let current = entry.buffer |
| 586 | 595 | let document = current.document |
| 587 | 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 | 12 | case process(executable: String, arguments: [String]) |
| 13 | 13 | /// `emacs --batch` loading `input` as a script. |
| 14 | 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 | 20 | public let language: String |
| @@ -32,12 +35,17 @@ public struct BabelJob: Sendable, Equatable { | ||
| 32 | 35 | enum Kind: Sendable, Equatable { case shell(valueIsExitStatus: Bool), python, generic, emacsLisp } |
| 33 | 36 | let kind: Kind |
| 34 | 37 | var language = "" |
| 35 | public let params: [String] | |
| 38 | public internal(set) var params: [String] | |
| 36 | 39 | let output: Bool |
| 37 | 40 | let wrap: String? |
| 38 | 41 | let name: String? |
| 39 | 42 | /// An inline block: the result goes after it, in `{{{results(…)}}}`. |
| 40 | 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 | 182 | if eval == "never" || eval == "no" || eval == "never-export" || eval == "no-export" { |
| 175 | 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 | 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 | 195 | if params.single[":cache"] == "yes" { return .failure(.message(":cache isn't supported yet; nothing was run.")) } |
| 182 | 196 | |
| 183 | 197 | var body = normalizedBody(block) |
| @@ -191,8 +205,13 @@ public enum Babel { | ||
| 191 | 205 | } |
| 192 | 206 | } |
| 193 | 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 | 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 | 216 | let vars: [(String, VarValue)] |
| 198 | 217 | do { |
| @@ -220,12 +239,37 @@ public enum Babel { | ||
| 220 | 239 | let full = (lines + [body]).joined(separator: "\n") + (valueIsExitStatus ? "\necho $?" : "") |
| 221 | 240 | let executable = language == "shell" ? options.shell : language |
| 222 | 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 | 243 | input: full.trimmingCharacters(in: .whitespacesAndNewlines) + "\n", directory: dir, |
| 225 | 244 | block: location, blockText: blockText, alwaysAsk: alwaysAsk, |
| 226 | 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 | 273 | if language == "python" { |
| 230 | 274 | let lines = vars.map { "\($0.0)=\(pythonLiteral($0.1))" } |
| 231 | 275 | var full = (lines + [body]).joined(separator: "\n") |
| @@ -252,6 +296,32 @@ public enum Babel { | ||
| 252 | 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 | 325 | if let (executable, arguments) = generic[language] { |
| 256 | 326 | guard vars.isEmpty else { return .failure(.message(":var isn't supported for \(language) yet.")) } |
| 257 | 327 | return .success(BabelJob( |
| @@ -713,6 +783,13 @@ public enum Babel { | ||
| 713 | 783 | /// The language's result handling: `org-babel-result-cond` and the table import. |
| 714 | 784 | static func value(_ finish: BabelJob.Finish, _ output: BabelOutput) -> Value { |
| 715 | 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 | 793 | let scalar = params.contains("scalar") || params.contains("verbatim") || params.contains("html") || params.contains("code") |
| 717 | 794 | || params.contains("pp") || params.contains("file") |
| 718 | 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 | 845 | /// `org-babel-import-elisp-from-file`. |
| 749 | 846 | static func importTable(_ results: String) -> Value { |
| 750 | 847 | guard !results.isEmpty else { return .none } |
| @@ -1064,7 +1161,7 @@ public enum Babel { | ||
| 1064 | 1161 | wrap("#+begin_src org", "#+end_src", escape: true) |
| 1065 | 1162 | } else if params.contains("code") { |
| 1066 | 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 | 1165 | // As inserted. |
| 1069 | 1166 | } else if params.contains("drawer") { |
| 1070 | 1167 | wrap(":results:", ":end:", escape: false) |
Sources/Orgstar/OrgstarApp.swift +5
| @@ -108,6 +108,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent | ||
| 108 | 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 | 116 | /// Reminders show while Orgstar is in front too. |
| 112 | 117 | nonisolated func userNotificationCenter( |
| 113 | 118 | _ center: UNUserNotificationCenter, willPresent notification: UNNotification |
Tests/OrgAppTests/BabelSessionTests.swift added +32
| @@ -0,0 +1,32 @@ | ||
| 1 | import Foundation | |
| 2 | import Testing | |
| 3 | import OrgCore | |
| 4 | @testable import OrgApp | |
| 5 | ||
| 6 | struct 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 | 19 | let script = folder.appendingPathComponent("babel.el") |
| 20 | 20 | try input.write(to: script, atomically: true, encoding: .utf8) |
| 21 | 21 | process.arguments = [EmacsOracle.executable, "-Q", "--batch", "-l", script.path] |
| 22 | case .session: | |
| 23 | return BabelOutput(stdout: "") | |
| 22 | 24 | } |
| 23 | 25 | process.currentDirectoryURL = job.directory.map { URL(fileURLWithPath: $0, relativeTo: directory) } ?? directory |
| 24 | 26 | let stdin = Pipe() |
| @@ -41,6 +43,9 @@ enum TestBabelRunner { | ||
| 41 | 43 | case .failure(.message(let message)): return (text, message) |
| 42 | 44 | case .success(let job): |
| 43 | 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 | 49 | switch Babel.finish(job, output: output, text: text) { |
| 45 | 50 | case .failure(.message(let message)): return (text, message) |
| 46 | 51 | case .success(let edits): |
| @@ -137,9 +142,9 @@ struct BabelTests { | ||
| 137 | 142 | @Test func refusesWhatWouldRunDifferently() { |
| 138 | 143 | for (text, message) in [ |
| 139 | 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 | 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 | 148 | ("#+begin_src cobol\nDISPLAY\n#+end_src\n", "No way to run cobol blocks yet."), |
| 144 | 149 | ] { |
| 145 | 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 | ||
| 202 | struct 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 | } | |