Sources/OrgApp/BabelRunner.swift
117 lines · 6412 bytes
4 symbols in this file
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 /// Whether `program` is an executable file on `environment`'s PATH, or the path of one.
22 public static func installed(_ program: String) -> Bool {
23 let paths = program.contains("/") ? [program] : (environment["PATH"] ?? "").split(separator: ":").map { "\($0)/\(program)" }
24 return paths.contains { FileManager.default.isExecutableFile(atPath: $0) }
25 }
26
27 public static func run(_ job: BabelJob, fileDirectory: URL, timeout: Duration = .seconds(300)) async throws -> BabelOutput {
28 for programs in job.programs where !programs.contains(where: installed) {
29 let names = programs.count == 1 ? programs[0] : "Neither " + programs.joined(separator: " nor ")
30 throw Failure(description: "\(names) \(programs.count == 1 ? "isn't" : "is") installed, so \(job.language) blocks can't run.")
31 }
32 let folder = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-babel-\(UUID().uuidString)")
33 try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
34 defer { try? FileManager.default.removeItem(at: folder) }
35 let resultFile = folder.appendingPathComponent("result")
36 let input = job.input.replacingOccurrences(of: Babel.resultFileToken, with: resultFile.path)
37 if case .session(let name, let executable, let python) = job.runner {
38 let directory = job.directory.map { URL(fileURLWithPath: $0, relativeTo: fileDirectory).standardizedFileURL } ?? fileDirectory
39 return try await BabelSessions.shared.run(job, name: name, executable: executable, python: python, directory: directory, resultFile: resultFile)
40 }
41
42 let process = Process()
43 process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
44 process.environment = environment
45 let stdin = Pipe()
46 switch job.runner {
47 case .process(let executable, var arguments):
48 if let script = job.script {
49 let file = folder.appendingPathComponent("script")
50 try script.write(to: file, atomically: true, encoding: .utf8)
51 try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: file.path)
52 arguments = arguments.map { $0.replacingOccurrences(of: Babel.scriptFileToken, with: file.path) }
53 }
54 process.arguments = [executable] + arguments
55 case .emacs:
56 guard let emacs = EmacsRunner.executable() else { throw Failure(description: "Emacs isn't installed, so Emacs Lisp blocks can't run.") }
57 let script = folder.appendingPathComponent("babel.el")
58 try input.write(to: script, atomically: true, encoding: .utf8)
59 process.arguments = [emacs.path, "-Q", "--batch", "-l", script.path]
60 case .session:
61 return BabelOutput(stdout: "")
62 }
63 let directory = job.directory.map { URL(fileURLWithPath: $0, relativeTo: fileDirectory).standardizedFileURL } ?? fileDirectory
64 guard FileManager.default.fileExists(atPath: directory.path) else { throw Failure(description: "No folder \(directory.path) for :dir.") }
65 process.currentDirectoryURL = directory
66 // As Emacs's `call-process` does, so `pwd` prints the folder as written, not resolved.
67 process.environment?["PWD"] = directory.path
68 let stdout = folder.appendingPathComponent("stdout")
69 FileManager.default.createFile(atPath: stdout.path, contents: nil)
70 let out = try FileHandle(forWritingTo: stdout)
71 let stderr = folder.appendingPathComponent("stderr")
72 FileManager.default.createFile(atPath: stderr.path, contents: nil)
73 let err = try FileHandle(forWritingTo: stderr)
74 process.standardInput = stdin
75 process.standardOutput = out
76 process.standardError = err
77 do {
78 try process.run()
79 } catch {
80 throw Failure(description: "Can't start \(process.arguments?.first ?? "the interpreter"): \(error.localizedDescription)")
81 }
82 if case .process = job.runner {
83 let handle = stdin.fileHandleForWriting
84 let data = Data(input.utf8)
85 // Large programs would fill the pipe; write off the main thread.
86 Task.detached {
87 try? handle.write(contentsOf: data)
88 try? handle.close()
89 }
90 } else {
91 try stdin.fileHandleForWriting.close()
92 }
93 let deadline = ContinuousClock.now + timeout
94 while process.isRunning {
95 if ContinuousClock.now > deadline {
96 process.terminate()
97 throw Failure(description: "The block took longer than \(timeout) and was stopped.")
98 }
99 do {
100 try await Task.sleep(for: .milliseconds(30))
101 } catch {
102 process.terminate()
103 throw CancellationError()
104 }
105 }
106 try out.close()
107 try err.close()
108 let output = (try? String(contentsOf: stdout, encoding: .utf8)) ?? ""
109 let errors = (try? String(contentsOf: stderr, encoding: .utf8)) ?? ""
110 let result = try? String(contentsOf: resultFile, encoding: .utf8)
111 if case .process(let executable, _) = job.runner, job.programs.isEmpty, process.terminationStatus == 127, output.isEmpty {
112 throw Failure(description: "\(executable) isn't installed.")
113 }
114 return BabelOutput(stdout: output, resultFile: result, stderr: errors, status: process.terminationStatus)
115 }
116}
117#endif