krz/orgstar

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

Sources/OrgApp/BabelRunner.swift

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

107 lines · 5672 bytes

3 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    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