krz/orgstar

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

Sources/OrgApp/BabelRunner.swift

16086b4cf2caff5328774b2cd5ae3ffe1ab65ca4
orgstar/Sources/OrgApp/BabelRunner.swift history · blame · raw

117 lines · 6412 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    /// 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