Sources/OrgApp/EmacsRunner.swift
142 lines · 7505 bytes
8 symbols in this file
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