Sources/OrgApp/EmacsRunner.swift
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}