krz/orgstar

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

Sources/OrgApp/EmacsRunner.swift

176fc78498d9ba962fa963bbeca0dd433d9e0bab
orgstar/Sources/OrgApp/EmacsRunner.swift history · blame · raw

133 lines · 7164 bytes

  1import Foundation
  2
  3/// Runs `emacs --batch` for what the native code hands over, such as tables outside the
  4/// native formula domain.
  5public enum EmacsRunner {
  6    public struct Failure: Error, CustomStringConvertible {
  7        public let description: String
  8    }
  9
 10    /// `ORGSTAR_EMACS`, then the usual install locations: an app launched from the Dock
 11    /// doesn't see the shell's PATH.
 12    public static func executable(environment: [String: String] = ProcessInfo.processInfo.environment) -> URL? {
 13        let candidates = [environment["ORGSTAR_EMACS"]].compactMap { $0 } + [
 14            "/opt/homebrew/bin/emacs", "/usr/local/bin/emacs", "/Applications/Emacs.app/Contents/MacOS/Emacs",
 15            "/run/current-system/sw/bin/emacs", "/usr/bin/emacs",
 16        ]
 17        return candidates.first { FileManager.default.isExecutableFile(atPath: $0) }.map(URL.init(fileURLWithPath:))
 18    }
 19
 20    /// Emacs point for a UTF-16 offset.
 21    static func point(_ offset: Int, in text: String) -> Int {
 22        let index = String.Index(utf16Offset: offset, in: text)
 23        return text.unicodeScalars[..<index].count + 1
 24    }
 25
 26    /// Runs `form` with point at `point` in a copy of `text` whose directory is `directory`,
 27    /// then returns the text of the table that starts at `table.lowerBound`.
 28    public static func recalculateTable(text: String, directory: URL, table: Range<Int>, point: Int, form: String, timeout: Duration = .seconds(60)) async throws -> String {
 29        guard let emacs = executable() else { throw Failure(description: "Emacs isn't installed, so this table can't be recalculated.") }
 30        let folder = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-emacs-\(UUID().uuidString)")
 31        try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
 32        defer { try? FileManager.default.removeItem(at: folder) }
 33        let input = folder.appendingPathComponent("input.org")
 34        let output = folder.appendingPathComponent("output.org")
 35        let script = folder.appendingPathComponent("recalc.el")
 36        try text.write(to: input, atomically: true, encoding: .utf8)
 37        func lisp(_ s: String) -> String { "\"" + s.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\"") + "\"" }
 38        try """
 39        ;;; -*- lexical-binding: t -*-
 40        (require 'org)
 41        (require 'org-table)
 42        (setq enable-local-variables :safe)
 43        (with-temp-buffer
 44          (let ((coding-system-for-read 'utf-8-unix))
 45            (insert-file-contents \(lisp(input.path))))
 46          (setq default-directory \(lisp(directory.path + "/")))
 47          (org-mode)
 48          (goto-char \(Self.point(point, in: text)))
 49          \(form)
 50          (goto-char \(Self.point(table.lowerBound, in: text)))
 51          (let ((coding-system-for-write 'utf-8-unix))
 52            (write-region (org-table-begin) (org-table-end) \(lisp(output.path)))))
 53        """.write(to: script, atomically: true, encoding: .utf8)
 54
 55        let process = Process()
 56        process.executableURL = emacs
 57        process.arguments = ["-Q", "--batch", "-l", script.path]
 58        process.currentDirectoryURL = directory
 59        let errors = Pipe()
 60        process.standardError = errors
 61        process.standardOutput = FileHandle.nullDevice
 62        try process.run()
 63        let deadline = ContinuousClock.now + timeout
 64        while process.isRunning {
 65            if ContinuousClock.now > deadline {
 66                process.terminate()
 67                throw Failure(description: "Emacs took too long to recalculate the table.")
 68            }
 69            try await Task.sleep(for: .milliseconds(50))
 70            if Task.isCancelled {
 71                process.terminate()
 72                throw CancellationError()
 73            }
 74        }
 75        guard process.terminationStatus == 0, let data = FileManager.default.contents(atPath: output.path) else {
 76            let log = String(decoding: errors.fileHandleForReading.readDataToEndOfFile(), as: UTF8.self)
 77            let last = log.split(separator: "\n").last.map(String.init) ?? "Emacs failed"
 78            throw Failure(description: last)
 79        }
 80        return String(decoding: data, as: UTF8.self)
 81    }
 82
 83    /// Exports `text`, the buffer of the file at `path`, with an ox function such as
 84    /// `org-latex-export-to-pdf`. The file is written beside `path`, as Emacs does; source
 85    /// blocks are not run. Returns the path Emacs wrote.
 86    public static func export(text: String, path: String, function: String, timeout: Duration = .seconds(120)) async throws -> String {
 87        guard let emacs = executable() else { throw Failure(description: "Emacs isn't installed, so this format can't be exported.") }
 88        let folder = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-export-\(UUID().uuidString)")
 89        try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
 90        defer { try? FileManager.default.removeItem(at: folder) }
 91        let snapshot = folder.appendingPathComponent("snapshot.org")
 92        let output = folder.appendingPathComponent("output")
 93        let script = folder.appendingPathComponent("export.el")
 94        try text.write(to: snapshot, atomically: true, encoding: .utf8)
 95        func lisp(_ s: String) -> String { "\"" + s.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\"") + "\"" }
 96        try """
 97        ;;; -*- lexical-binding: t -*-
 98        (require 'org)
 99        (require 'ox)
100        (setq enable-local-variables :safe
101              org-export-use-babel nil)
102        (with-current-buffer (find-file-noselect \(lisp(path)))
103          (let ((inhibit-read-only t)
104                (coding-system-for-read 'utf-8-unix))
105            (erase-buffer)
106            (insert-file-contents \(lisp(snapshot.path)))
107            (set-buffer-modified-p nil))
108          (let ((file (\(function))))
109            (with-temp-file \(lisp(output.path)) (insert (expand-file-name file)))))
110        """.write(to: script, atomically: true, encoding: .utf8)
111        let process = Process()
112        process.executableURL = emacs
113        process.arguments = ["-Q", "--batch", "-l", script.path]
114        process.currentDirectoryURL = URL(fileURLWithPath: path).deletingLastPathComponent()
115        let errors = Pipe()
116        process.standardError = errors
117        process.standardOutput = FileHandle.nullDevice
118        try process.run()
119        let deadline = ContinuousClock.now + timeout
120        while process.isRunning {
121            if ContinuousClock.now > deadline {
122                process.terminate()
123                throw Failure(description: "Emacs took too long to export.")
124            }
125            try await Task.sleep(for: .milliseconds(100))
126        }
127        guard let written = try? String(contentsOf: output, encoding: .utf8) else {
128            let log = String(decoding: errors.fileHandleForReading.readDataToEndOfFile(), as: UTF8.self)
129            throw Failure(description: log.split(separator: "\n").last.map(String.init) ?? "Emacs couldn't export the file.")
130        }
131        return written
132    }
133}