Sources/OrgApp/BabelRunner.swift
107 lines · 5672 bytes
1#if os(macOS)
2import Foundation
3import OrgCore
4
5/// Runs Babel jobs as processes: the interpreter reads the program on standard input, in the
6/// file's folder or `:dir`.
7public enum BabelRunner {
8 public struct Failure: Error, CustomStringConvertible {
9 public let description: String
10 }
11
12 /// PATH with the usual install locations, which an app launched from the Dock lacks.
13 public static var environment: [String: String] {
14 var environment = ProcessInfo.processInfo.environment
15 let extra = ["/opt/homebrew/bin", "/usr/local/bin", "/Library/TeX/texbin", "/usr/bin", "/bin"]
16 let path = (environment["PATH"] ?? "").split(separator: ":").map(String.init)
17 environment["PATH"] = (path + extra.filter { !path.contains($0) }).joined(separator: ":")
18 return environment
19 }
20
21 public static func run(_ job: BabelJob, fileDirectory: URL, timeout: Duration = .seconds(300)) async throws -> BabelOutput {
22 let folder = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-babel-\(UUID().uuidString)")
23 try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
24 defer { try? FileManager.default.removeItem(at: folder) }
25 let resultFile = folder.appendingPathComponent("result")
26 let input = job.input.replacingOccurrences(of: Babel.resultFileToken, with: resultFile.path)
27 if case .session(let name, let executable, let python) = job.runner {
28 let directory = job.directory.map { URL(fileURLWithPath: $0, relativeTo: fileDirectory).standardizedFileURL } ?? fileDirectory
29 return try await BabelSessions.shared.run(job, name: name, executable: executable, python: python, directory: directory, resultFile: resultFile)
30 }
31
32 let process = Process()
33 process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
34 process.environment = environment
35 let stdin = Pipe()
36 switch job.runner {
37 case .process(let executable, var arguments):
38 if let script = job.script {
39 let file = folder.appendingPathComponent("script")
40 try script.write(to: file, atomically: true, encoding: .utf8)
41 try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: file.path)
42 arguments = arguments.map { $0.replacingOccurrences(of: Babel.scriptFileToken, with: file.path) }
43 }
44 process.arguments = [executable] + arguments
45 case .emacs:
46 guard let emacs = EmacsRunner.executable() else { throw Failure(description: "Emacs isn't installed, so Emacs Lisp blocks can't run.") }
47 let script = folder.appendingPathComponent("babel.el")
48 try input.write(to: script, atomically: true, encoding: .utf8)
49 process.arguments = [emacs.path, "-Q", "--batch", "-l", script.path]
50 case .session:
51 return BabelOutput(stdout: "")
52 }
53 let directory = job.directory.map { URL(fileURLWithPath: $0, relativeTo: fileDirectory).standardizedFileURL } ?? fileDirectory
54 guard FileManager.default.fileExists(atPath: directory.path) else { throw Failure(description: "No folder \(directory.path) for :dir.") }
55 process.currentDirectoryURL = directory
56 // As Emacs's `call-process` does, so `pwd` prints the folder as written, not resolved.
57 process.environment?["PWD"] = directory.path
58 let stdout = folder.appendingPathComponent("stdout")
59 FileManager.default.createFile(atPath: stdout.path, contents: nil)
60 let out = try FileHandle(forWritingTo: stdout)
61 let stderr = folder.appendingPathComponent("stderr")
62 FileManager.default.createFile(atPath: stderr.path, contents: nil)
63 let err = try FileHandle(forWritingTo: stderr)
64 process.standardInput = stdin
65 process.standardOutput = out
66 process.standardError = err
67 do {
68 try process.run()
69 } catch {
70 throw Failure(description: "Can't start \(process.arguments?.first ?? "the interpreter"): \(error.localizedDescription)")
71 }
72 if case .process = job.runner {
73 let handle = stdin.fileHandleForWriting
74 let data = Data(input.utf8)
75 // Large programs would fill the pipe; write off the main thread.
76 Task.detached {
77 try? handle.write(contentsOf: data)
78 try? handle.close()
79 }
80 } else {
81 try stdin.fileHandleForWriting.close()
82 }
83 let deadline = ContinuousClock.now + timeout
84 while process.isRunning {
85 if ContinuousClock.now > deadline {
86 process.terminate()
87 throw Failure(description: "The block took longer than \(timeout) and was stopped.")
88 }
89 do {
90 try await Task.sleep(for: .milliseconds(30))
91 } catch {
92 process.terminate()
93 throw CancellationError()
94 }
95 }
96 try out.close()
97 try err.close()
98 let output = (try? String(contentsOf: stdout, encoding: .utf8)) ?? ""
99 let errors = (try? String(contentsOf: stderr, encoding: .utf8)) ?? ""
100 let result = try? String(contentsOf: resultFile, encoding: .utf8)
101 if case .process(let executable, _) = job.runner, process.terminationStatus == 127, output.isEmpty {
102 throw Failure(description: "\(executable) isn't installed.")
103 }
104 return BabelOutput(stdout: output, resultFile: result, stderr: errors, status: process.terminationStatus)
105 }
106}
107#endif