krz/orgstar

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

Sources/OrgApp/EmacsRunner.swift

15f6b0709d88971fb62ed432c3e5b8032643670a
orgstar/Sources/OrgApp/EmacsRunner.swift history · blame · raw

142 lines · 7505 bytes

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