krz/orgstar

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

Sources/OrgApp/BabelRunner.swift

abca8dc581329472f7fb8ccd3af147e5fb882f29
orgstar/Sources/OrgApp/BabelRunner.swift history · blame · raw

86 lines · 4275 bytes

 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}