Sources/OrgApp/EmacsImport.swift
727 lines · 42217 bytes
1import Foundation
2import OrgCore
3
4/// Settings, folders, capture templates and key bindings read from an Emacs configuration
5/// without running it: `setq`-style forms with values Orgstar can work out, inside `after!`,
6/// `with-eval-after-load`, `use-package` and the like. For Doom, Doom's own org defaults are
7/// read first, as they apply under your config.
8public struct EmacsImport: Sendable {
9 public enum Change: Sendable, Equatable {
10 /// A `config.toml` setting.
11 case setting(key: String, value: TOML.Value)
12 /// A folder to add (`org-directory`, folders of `org-agenda-files`).
13 case folder(String)
14 /// A `[[template]]` table for capture.toml.
15 case capture(key: String, toml: String)
16 /// A `[[bind]]` table for keymap.toml.
17 case binding(keys: String, command: String, mode: String?)
18 }
19
20 public struct Item: Sendable, Identifiable {
21 public let id: Int
22 /// The Emacs variable or binding, as it reads in the file.
23 public let name: String
24 /// What it becomes in Orgstar, for the review list.
25 public let summary: String
26 /// `config.el:27`, or `Doom default` for Doom's own settings.
27 public let source: String
28 public let change: Change
29 }
30
31 public struct Note: Sendable, Identifiable {
32 public let id: Int
33 public let name: String
34 public let reason: String
35 public let source: String
36 }
37
38 public var items: [Item] = []
39 /// Org settings and bindings Orgstar has no equivalent for.
40 public var unsupported: [Note] = []
41 /// Values worked out at run time, which reading can't follow.
42 public var unreadable: [Note] = []
43 /// Files that couldn't be read, with why.
44 public var problems: [String] = []
45 /// Variables that aren't about org or the editor, left alone.
46 public var skipped = 0
47 /// Doom's own org defaults Orgstar has no use for, not listed one by one.
48 public var doomDefaultsLeftOut = 0
49 public var isDoom = false
50
51 // MARK: - Finding the configuration
52
53 /// The configuration files in `folder` (a Doom folder, an `.emacs.d`) or the file itself.
54 public static func files(in location: URL) -> [URL] {
55 var isDirectory: ObjCBool = false
56 guard FileManager.default.fileExists(atPath: location.path, isDirectory: &isDirectory) else { return [] }
57 guard isDirectory.boolValue else { return [location] }
58 return ["init.el", "config.el", "custom.el"].map { location.appendingPathComponent($0) }
59 .filter { FileManager.default.fileExists(atPath: $0.path) }
60 }
61
62 /// Where an Emacs configuration usually is, first found first.
63 public static func defaultLocations() -> [URL] {
64 let home = URL(fileURLWithPath: NSHomeDirectory())
65 let environment = ProcessInfo.processInfo.environment
66 var candidates: [URL] = []
67 if let doom = environment["DOOMDIR"] { candidates.append(URL(fileURLWithPath: doom)) }
68 candidates += [".config/doom", ".doom.d", ".config/emacs", ".emacs.d"].map { home.appendingPathComponent($0) }
69 candidates.append(home.appendingPathComponent(".emacs"))
70 return candidates.filter { !files(in: $0).isEmpty && !isDoomInstall($0) }
71 }
72
73 /// Doom's own source folder, which holds `lisp/doom.el`, isn't a user configuration.
74 static func isDoomInstall(_ url: URL) -> Bool {
75 FileManager.default.fileExists(atPath: url.appendingPathComponent("lisp/doom.el").path)
76 }
77
78 /// Doom's org module and core defaults, if Doom is installed.
79 static func doomDefaultFiles() -> [URL] {
80 let home = URL(fileURLWithPath: NSHomeDirectory())
81 let environment = ProcessInfo.processInfo.environment
82 let installs = ([environment["EMACSDIR"]].compactMap { $0 }.map { URL(fileURLWithPath: $0) })
83 + [".config/emacs", ".emacs.d"].map { home.appendingPathComponent($0) }
84 guard let install = installs.first(where: isDoomInstall) else { return [] }
85 var files = [install.appendingPathComponent("lisp/doom-emacs.el")]
86 var moduleRoots = [install.appendingPathComponent("modules")]
87 if let sources = try? FileManager.default.contentsOfDirectory(at: install.appendingPathComponent("sources"), includingPropertiesForKeys: nil) {
88 moduleRoots += sources.map { $0.appendingPathComponent("modules") }
89 }
90 if let org = moduleRoots.map({ $0.appendingPathComponent("lang/org/config.el") }).first(where: { FileManager.default.fileExists(atPath: $0.path) }) {
91 files.insert(org, at: 0)
92 }
93 return files.filter { FileManager.default.fileExists(atPath: $0.path) }
94 }
95
96 // MARK: - Reading
97
98 /// Reads `files`, and for a Doom configuration Doom's own defaults under them.
99 public static func scan(_ files: [URL], doomDefaults: [URL]? = nil) -> EmacsImport {
100 var sources: [(name: String, text: String, priority: Int)] = []
101 var result = EmacsImport()
102 for url in files {
103 guard let text = try? String(contentsOf: url, encoding: .utf8) else {
104 result.problems.append("Can't read \(url.path)")
105 continue
106 }
107 sources.append((url.lastPathComponent, text, 1))
108 }
109 result.isDoom = sources.contains { $0.text.contains("(doom!") || $0.text.contains("(map! ") || $0.text.contains("(after! ") }
110 if result.isDoom {
111 for url in doomDefaults ?? doomDefaultFiles() {
112 if let text = try? String(contentsOf: url, encoding: .utf8) { sources.insert(("Doom", text, 0), at: 0) }
113 }
114 }
115 var scanner = Scanner()
116 for source in sources {
117 do {
118 let forms = try LispReader.read(source.text)
119 for form in forms {
120 scanner.visit(form.sexp, source: source.priority == 0 ? "Doom default" : "\(source.name):\(form.line)",
121 priority: source.priority, deferred: false, insideDefun: false, doom: source.priority == 0)
122 }
123 } catch {
124 result.problems.append("\(source.name): \(error)")
125 }
126 }
127 result.build(from: scanner)
128 return result
129 }
130
131 struct Assignment {
132 let name: String
133 let value: Sexp
134 let source: String
135 /// Doom defaults, then the config, then what runs after a package loads.
136 let rank: Int
137 }
138
139 struct Binding {
140 let keys: String
141 let command: Sexp
142 let modes: [String?]
143 let source: String
144 }
145
146 struct Scanner {
147 var assignments: [Assignment] = []
148 var bindings: [Binding] = []
149 var usesEvil = false
150 var order = 0
151
152 mutating func assign(_ name: String, _ value: Sexp, _ source: String, priority: Int, deferred: Bool) {
153 assignments.append(Assignment(name: name, value: value, source: source, rank: priority * 2 + (deferred ? 1 : 0)))
154 }
155
156 mutating func visit(_ form: Sexp, source: String, priority: Int, deferred: Bool, insideDefun: Bool, doom: Bool) {
157 guard let items = form.elements, let head = form.head else { return }
158 let args = Array(items.dropFirst())
159 func body(_ forms: ArraySlice<Sexp>, deferred: Bool) {
160 for f in forms { visit(f, source: source, priority: priority, deferred: deferred, insideDefun: insideDefun, doom: doom) }
161 }
162 switch head {
163 case "setq", "setq-default", "setq!", "setopt", "csetq":
164 var i = 0
165 while i + 1 < args.count {
166 if let name = args[i].symbol { assign(name, args[i + 1], source, priority: priority, deferred: deferred) }
167 i += 2
168 }
169 case "defvar", "defcustom":
170 // Only sets a variable nothing set, so under every assignment.
171 if args.count >= 2, let name = args[0].symbol { assign(name, args[1], source, priority: -1, deferred: false) }
172 case "custom-set-variables":
173 for arg in args {
174 guard let entry = unquote(arg)?.elements, entry.count >= 2, let name = entry[0].symbol else { continue }
175 assign(name, .list([.symbol("quote"), entry[1]]), source, priority: priority, deferred: deferred)
176 }
177 case "after!", "with-eval-after-load", "eval-after-load", "use-package!", "use-package":
178 if head.hasPrefix("use-package") {
179 var i = 1
180 var section = ""
181 while i < args.count {
182 if let keyword = args[i].symbol, keyword.hasPrefix(":") {
183 section = keyword
184 } else if section == ":config" || section == ":init" {
185 visit(args[i], source: source, priority: priority, deferred: true, insideDefun: insideDefun, doom: doom)
186 } else if section == ":custom", let pair = args[i].elements, pair.count >= 2, let name = pair[0].symbol {
187 assign(name, pair[1], source, priority: priority, deferred: true)
188 }
189 i += 1
190 }
191 } else {
192 let forms = head == "eval-after-load" ? args.dropFirst().compactMap(unquote) : Array(args.dropFirst())
193 body(forms[...], deferred: true)
194 }
195 case "progn", "when", "unless", "if", "let", "let*", "with-no-warnings":
196 let skip = ["let", "let*", "when", "unless", "if"].contains(head) ? 1 : 0
197 body(args.dropFirst(skip), deferred: deferred)
198 case "defun", "defun!", "add-hook!":
199 // Doom sets org's defaults in the functions its module runs when org loads.
200 if doom, head == "defun" { body(args.dropFirst(2), deferred: true) }
201 case "doom!":
202 usesEvil = args.contains { $0.symbol == "evil" || $0.head == "evil" }
203 case "map!":
204 if !doom { bindings += mapBindings(args, prefix: "", modes: [nil], source: source) }
205 case "define-key", "keymap-set", "evil-define-key", "evil-define-key*", "global-set-key", "keymap-global-set":
206 guard !doom else { break }
207 var rest = args
208 var modes: [String?] = [nil]
209 if head.hasPrefix("evil-define-key"), let state = rest.first {
210 modes = evilStates(state)
211 rest = Array(rest.dropFirst())
212 }
213 if !head.hasPrefix("global"), head != "keymap-global-set" { rest = Array(rest.dropFirst()) }
214 var i = 0
215 while i + 1 < rest.count {
216 if let keys = keyString(rest[i]) { bindings.append(Binding(keys: keys, command: rest[i + 1], modes: modes, source: source)) }
217 i += 2
218 }
219 default:
220 break
221 }
222 }
223
224 func evilStates(_ sexp: Sexp) -> [String?] {
225 let names = (unquote(sexp)?.elements ?? [unquote(sexp) ?? sexp]).compactMap(\.symbol)
226 let modes = names.compactMap { name -> String? in
227 switch name {
228 case "normal", "motion": "normal"
229 case "insert": "insert"
230 case "visual": "visual"
231 default: nil
232 }
233 }
234 return modes.isEmpty ? [nil] : modes
235 }
236
237 /// `(kbd "C-c x")` or `"C-c x"` (keymap-set); key vectors such as `[f5]` are skipped.
238 func keyString(_ sexp: Sexp) -> String? {
239 if let s = sexp.string { return s }
240 if sexp.head == "kbd", let s = sexp.elements?.dropFirst().first?.string { return s }
241 return nil
242 }
243
244 /// Doom's `map!`: `:leader`, `:localleader`, `:prefix`, state keywords such as `:n`
245 /// and `:nv`, `:map`, `:desc`, and nested groups.
246 func mapBindings(_ args: [Sexp], prefix: String, modes: [String?], source: String) -> [Binding] {
247 var result: [Binding] = []
248 var prefix = prefix
249 var modes = modes
250 var i = 0
251 while i < args.count {
252 let arg = args[i]
253 if let keyword = arg.symbol, keyword.hasPrefix(":") {
254 switch keyword {
255 case ":leader":
256 prefix = "SPC"
257 modes = ["normal"]
258 case ":localleader":
259 prefix = "SPC m"
260 modes = ["normal"]
261 case ":prefix", ":prefix-map":
262 if i + 1 < args.count {
263 let p = args[i + 1].string ?? args[i + 1].elements?.first?.string ?? ""
264 prefix = [prefix, p].filter { !$0.isEmpty }.joined(separator: " ")
265 }
266 i += 1
267 case ":desc", ":map", ":mode", ":after", ":when", ":unless":
268 i += 1
269 default:
270 let states = keyword.dropFirst()
271 let mapped = states.compactMap { c -> String? in
272 switch c {
273 case "n", "m": "normal"
274 case "i": "insert"
275 case "v": "visual"
276 default: nil
277 }
278 }
279 if !mapped.isEmpty { modes = Array(Set(mapped)).sorted() }
280 }
281 } else if let group = arg.elements, arg.head?.hasPrefix(":") == true || group.first?.string != nil && group.count > 2 {
282 result += mapBindings(group, prefix: prefix, modes: modes, source: source)
283 } else if let key = arg.string, i + 1 < args.count {
284 let keys = [prefix, key].filter { !$0.isEmpty }.joined(separator: " ")
285 result.append(Binding(keys: keys, command: args[i + 1], modes: modes, source: source))
286 i += 1
287 }
288 i += 1
289 }
290 return result
291 }
292 }
293
294 // MARK: - Values
295
296 /// `(font-spec :family "JetBrains Mono" :size 14)`, or `"JetBrains Mono-14"`.
297 static func fontSpec(_ sexp: Sexp) -> (family: String?, size: Int?)? {
298 if let name = sexp.string {
299 let parts = name.split(separator: "-")
300 if parts.count > 1, let size = Int(parts.last!) { return (parts.dropLast().joined(separator: "-"), size) }
301 return (name, nil)
302 }
303 guard sexp.head == "font-spec", let args = sexp.elements?.dropFirst() else { return nil }
304 let list = Array(args)
305 var family: String?
306 var size: Int?
307 var i = 0
308 while i + 1 < list.count {
309 switch list[i].symbol {
310 case ":family": family = list[i + 1].string
311 case ":size":
312 if let n = list[i + 1].integer { size = n }
313 if case .float(let d) = list[i + 1] { size = Int(d.rounded()) }
314 default: break
315 }
316 i += 2
317 }
318 return family == nil && size == nil ? nil : (family, size)
319 }
320
321 static func unquote(_ sexp: Sexp) -> Sexp? {
322 if let items = sexp.elements, items.count == 2, items[0].symbol == "quote" || items[0].symbol == "function" { return items[1] }
323 if case .list(let items) = sexp, items.count == 2, items[0].symbol == "\\`" { return items[1] }
324 return nil
325 }
326
327 /// What a form evaluates to, for the few forms configurations build values with; nil
328 /// when reading can't tell.
329 static func evaluate(_ sexp: Sexp, _ env: [String: Sexp]) -> Sexp? {
330 switch sexp {
331 case .string, .integer, .float, .character, .vector: return sexp
332 case .symbol(let name):
333 if name == "t" || name == "nil" || name.hasPrefix(":") { return sexp }
334 return env[name]
335 case .dotted: return nil
336 case .list(let items):
337 guard let head = items.first?.symbol else { return nil }
338 let args = Array(items.dropFirst())
339 switch head {
340 case "quote", "function": return args.first
341 case "\\`":
342 guard let inner = args.first else { return nil }
343 return backquote(inner, env)
344 case "list":
345 let values = args.compactMap { evaluate($0, env) }
346 return values.count == args.count ? .list(values) : nil
347 case "concat":
348 let parts = args.compactMap { evaluate($0, env)?.string }
349 return parts.count == args.count ? .string(parts.joined()) : nil
350 case "expand-file-name", "file-name-concat":
351 let parts = args.compactMap { evaluate($0, env)?.string }
352 guard parts.count == args.count, !parts.isEmpty else { return nil }
353 if head == "expand-file-name" {
354 let name = parts[0]
355 if name.hasPrefix("/") || name.hasPrefix("~") || parts.count == 1 { return .string(name) }
356 return .string((parts[1] as NSString).appendingPathComponent(name))
357 }
358 return .string(parts.dropFirst().reduce(parts[0]) { ($0 as NSString).appendingPathComponent($1) })
359 case "file-name-as-directory":
360 guard let s = args.first.flatMap({ evaluate($0, env)?.string }) else { return nil }
361 return .string(s.hasSuffix("/") ? s : s + "/")
362 default:
363 return nil
364 }
365 }
366 }
367
368 /// A backquoted form with its `,` and `,@` parts evaluated.
369 static func backquote(_ sexp: Sexp, _ env: [String: Sexp]) -> Sexp? {
370 guard case .list(let items) = sexp else {
371 if case .dotted(let items, let last) = sexp {
372 let values = items.compactMap { backquote($0, env) }
373 guard values.count == items.count, let tail = backquote(last, env) else { return nil }
374 return .dotted(values, tail)
375 }
376 return sexp
377 }
378 if items.count == 2, items[0].symbol == "," { return evaluate(items[1], env) }
379 var out: [Sexp] = []
380 for item in items {
381 if case .list(let parts) = item, parts.count == 2, parts[0].symbol == ",@" {
382 guard let spliced = evaluate(parts[1], env)?.elements else { return nil }
383 out += spliced
384 } else {
385 guard let value = backquote(item, env) else { return nil }
386 out.append(value)
387 }
388 }
389 return .list(out)
390 }
391
392 // MARK: - Results
393
394 static let orgRelated = ["org-", "appt-", "display-line-numbers", "fill-column", "evil-", "doom-font", "doom-variable-pitch-font", "doom-theme", "calendar-week-start-day"]
395
396 mutating func build(from scanner: Scanner) {
397 var next = 0
398 func id() -> Int { next += 1; return next }
399 // The last assignment of each variable wins, those after a package loads over the rest.
400 var winners: [String: Assignment] = [:]
401 var env: [String: Sexp] = [:]
402 for assignment in scanner.assignments.enumerated().sorted(by: { ($0.element.rank, $0.offset) < ($1.element.rank, $1.offset) }).map(\.element) {
403 winners[assignment.name] = assignment
404 if let value = Self.evaluate(assignment.value, env) { env[assignment.name] = value }
405 }
406 if isDoom || scanner.usesEvil {
407 items.append(Item(id: id(), name: isDoom ? "Doom Emacs" : "evil-mode", summary: "keymap = \"doom\"", source: "", change: .setting(key: "keymap", value: .string("doom"))))
408 }
409 for name in winners.keys.sorted() {
410 let assignment = winners[name]!
411 guard Self.orgRelated.contains(where: { name.hasPrefix($0) }) else {
412 if assignment.rank >= 2 { skipped += 1 }
413 continue
414 }
415 if name == "doom-font", let spec = Self.fontSpec(assignment.value) {
416 if let family = spec.family {
417 items.append(Item(id: id(), name: name, summary: "font = \"\(family)\"", source: assignment.source, change: .setting(key: "font", value: .string(family))))
418 }
419 if let size = spec.size {
420 items.append(Item(id: id(), name: name, summary: "font-size = \(size)", source: assignment.source, change: .setting(key: "font-size", value: .integer(size))))
421 }
422 continue
423 }
424 guard let value = Self.evaluate(assignment.value, env) else {
425 if assignment.rank < 2 {
426 doomDefaultsLeftOut += 1
427 continue
428 }
429 unreadable.append(Note(id: id(), name: name, reason: "worked out when Emacs runs: \(assignment.value.description.prefix(80))", source: assignment.source))
430 continue
431 }
432 let before = unsupported.count
433 convert(name, value, source: assignment.source, env: env, id: id)
434 if assignment.rank < 2, unsupported.count > before {
435 doomDefaultsLeftOut += unsupported.count - before
436 unsupported.removeLast(unsupported.count - before)
437 }
438 }
439 // One item per folder.
440 var folders: Set<String> = []
441 items.removeAll { item in
442 guard case .folder(let path) = item.change else { return false }
443 let key = (path as NSString).expandingTildeInPath.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
444 return !folders.insert(key).inserted
445 }
446 for binding in scanner.bindings {
447 guard let commandName = Self.unquote(binding.command)?.symbol ?? binding.command.symbol else {
448 unsupported.append(Note(id: id(), name: binding.keys, reason: "bound to code, not a command", source: binding.source))
449 continue
450 }
451 guard let command = Self.commands[commandName] else {
452 unsupported.append(Note(id: id(), name: "\(binding.keys) → \(commandName)", reason: "Orgstar has no such command", source: binding.source))
453 continue
454 }
455 guard let keys = KeySequence.parse(binding.keys).map(KeySequence.format) else {
456 unsupported.append(Note(id: id(), name: binding.keys, reason: "keys Orgstar can't read", source: binding.source))
457 continue
458 }
459 let modes = isDoom ? binding.modes.map { $0 ?? "normal" } : binding.modes
460 for mode in Array(Set(modes.map { $0 ?? "" })).sorted() {
461 items.append(Item(id: id(), name: "\(keys) → \(commandName)", summary: mode.isEmpty ? command : "\(command) in \(mode) state",
462 source: binding.source, change: .binding(keys: keys, command: command, mode: mode.isEmpty ? nil : mode)))
463 }
464 }
465 }
466
467 mutating func convert(_ name: String, _ value: Sexp, source: String, env: [String: Sexp] = [:], id: () -> Int) {
468 func add(_ key: String, _ toml: TOML.Value, _ summary: String? = nil) {
469 items.append(Item(id: id(), name: name, summary: summary ?? "\(key) = \(UserConfig.format(toml))", source: source, change: .setting(key: key, value: toml)))
470 }
471 func note(_ reason: String) { unsupported.append(Note(id: id(), name: name, reason: reason, source: source)) }
472 let truthy = value.symbol != "nil" && value.elements?.isEmpty != true
473 switch name {
474 case "fill-column", "org-tags-column", "appt-message-warning-time":
475 guard let n = value.integer else { return note("not a number") }
476 add(name, .integer(n))
477 case "org-insert-heading-respect-content", "org-list-allow-alphabetical", "org-hide-emphasis-markers", "org-pretty-entities",
478 "org-cycle-hide-drawer-startup", "org-cycle-hide-block-startup", "org-use-speed-commands", "org-startup-with-inline-images",
479 "org-startup-indented", "org-hide-leading-stars", "org-startup-align-all-tables", "org-startup-truncated":
480 add(name, .bool(truthy))
481 case "org-hide-drawer-startup", "org-hide-block-startup":
482 add(name == "org-hide-drawer-startup" ? "org-cycle-hide-drawer-startup" : "org-cycle-hide-block-startup", .bool(truthy))
483 case "org-M-RET-may-split-line":
484 if let alist = value.elements, !alist.isEmpty {
485 // `((default . nil))`: the default entry, as most configurations write it.
486 let entry = alist.first { if case .dotted(let k, _) = $0 { return k.first?.symbol == "default" } else { return false } }
487 if case .dotted(_, let v)? = entry { add(name, .bool(v.symbol != "nil")) } else { note("per-context values aren't supported") }
488 } else {
489 add(name, .bool(truthy))
490 }
491 case "org-agenda-prefix-format":
492 if let s = value.string {
493 for view in ["agenda", "todo", "tags"] { add("org-agenda-prefix-format.\(view)", .string(s)) }
494 } else {
495 for entry in value.elements ?? [] {
496 guard case .dotted(let key, let format) = entry, let view = key.first?.symbol, let s = format.string else { continue }
497 if ["agenda", "todo", "tags"].contains(view) { add("org-agenda-prefix-format.\(view)", .string(s)) }
498 }
499 }
500 case "org-log-done", "org-log-reschedule", "org-log-redeadline":
501 switch value.symbol {
502 case "nil": add(name, .string("nil"))
503 case "time", "t": add(name, .string("time"))
504 case "note": add(name, .string("note"))
505 default: note("unexpected value \(value.description)")
506 }
507 case "org-log-into-drawer":
508 if let s = value.string { add(name, .string(s)) } else { add(name, .string(truthy ? "LOGBOOK" : "")) }
509 case "display-line-numbers-type":
510 add(name, .bool(truthy), truthy && value.symbol != "t" ? "display-line-numbers-type = true (Orgstar numbers lines absolutely)" : nil)
511 case "org-agenda-span":
512 let days: Int? = value.integer ?? ["day": 1, "week": 7, "fortnight": 14, "month": 30, "year": 365][value.symbol ?? ""]
513 guard let days else { return note("unknown span \(value.description)") }
514 add(name, .integer(days))
515 case "org-clock-idle-time":
516 // nil is never; a number is minutes.
517 if value.symbol == "nil" { add(name, .integer(0)) } else if let minutes = value.integer, minutes > 0 { add(name, .integer(minutes)) } else { note("not a number of minutes") }
518 case "org-clock-history-length":
519 guard let length = value.integer, length > 0 else { return note("not a positive number") }
520 add(name, .integer(length))
521 case "org-agenda-start-day":
522 if value.symbol == "nil" { add(name, .string("+0d")) } else if let s = value.string, UserConfig.dayOffset(s) != nil { add(name, .string(s)) } else { note("only day offsets such as \"-3d\" are supported") }
523 case "org-todo-keywords":
524 let sequences = (value.elements ?? []).compactMap { $0.elements }.filter { $0.count > 1 }.map { $0.dropFirst().compactMap(\.string) }
525 let lines = sequences.filter { !$0.contains { $0.contains(where: \.isWhitespace) } }.map { $0.joined(separator: " ") }
526 guard !lines.isEmpty else { return note("unexpected value") }
527 if lines.count < sequences.count {
528 note("\(sequences.count - lines.count) sequence(s) with spaces in a keyword left out; #+TODO syntax can't write them")
529 }
530 if (value.elements ?? []).contains(where: { $0.elements?.first?.symbol == "type" }) { note("type sequences are read as sequences") }
531 add(name, .string(lines.joined(separator: "\n")))
532 case "org-directory":
533 guard let path = value.string else { return note("not a folder") }
534 items.append(Item(id: id(), name: name, summary: "add folder \(path)", source: source, change: .folder(path)))
535 case "org-agenda-files":
536 let paths = (value.elements ?? [value]).compactMap(\.string)
537 guard !paths.isEmpty else { return note("not a list of files or folders") }
538 for path in paths {
539 let folder = path.hasSuffix(".org") ? (path as NSString).deletingLastPathComponent : path
540 items.append(Item(id: id(), name: name, summary: "add folder \(folder)", source: source, change: .folder(folder)))
541 }
542 case "org-capture-templates":
543 for entry in value.elements ?? [] { captureTemplate(entry, source: source, env: env, id: id) }
544 case "doom-font":
545 note("not a font-spec Orgstar can read")
546 case "doom-variable-pitch-font":
547 note("Orgstar uses one font; set it with doom-font or [theme] font")
548 case "doom-theme":
549 note("Emacs themes aren't read; set colors under [theme] in config.toml")
550 default:
551 note("Orgstar has no equivalent")
552 }
553 }
554
555 mutating func captureTemplate(_ entry: Sexp, source: String, env: [String: Sexp] = [:], id: () -> Int) {
556 guard let parts = entry.elements, parts.count >= 2, let key = parts[0].string else { return }
557 let name = parts[1].string ?? key
558 guard parts.count >= 5, let type = parts[2].symbol, let target = parts[3].elements, let kind = target.first?.symbol,
559 let template = parts[4].string else {
560 // A key and a name only: a group of templates.
561 if parts.count == 2 { return }
562 unsupported.append(Note(id: id(), name: "capture \"\(key)\"", reason: "a template that isn't a string", source: source))
563 return
564 }
565 guard ["entry", "item", "checkitem", "plain", "table-line"].contains(type) else {
566 unsupported.append(Note(id: id(), name: "capture \"\(key)\"", reason: "\(type) templates aren't supported", source: source))
567 return
568 }
569 func quoted(_ s: String) -> String { UserConfig.format(.string(s)) }
570 var toml = "[[template]]\nkey = \(quoted(key))\nname = \(quoted(name))\ntype = \(quoted(type))\n"
571 // `org-capture-expand-file`: a variable's value, or a form evaluated.
572 let strings = target.dropFirst().compactMap { $0.string ?? Self.evaluate($0, env)?.string }
573 // Property list after the template: `:prepend t :empty-lines 1 …`.
574 var properties: [String: Sexp] = [:]
575 let rest = Array(parts.dropFirst(5))
576 for i in stride(from: 0, to: rest.count - 1, by: 2) { if let name = rest[i].symbol { properties[name] = rest[i + 1] } }
577 let allStrings = strings.count == target.count - 1
578 var treeType = properties[":tree-type"]?.symbol
579 let place: String? = switch kind {
580 case "file" where strings.count == 1 && target.count == 2:
581 "file = \(quoted(strings[0]))\n"
582 case "file+headline" where strings.count == 2 && target.count == 3:
583 "file = \(quoted(strings[0]))\nheadline = \(quoted(strings[1]))\n"
584 case "file+olp" where strings.count >= 2 && allStrings:
585 "file = \(quoted(strings[0]))\nolp = \(quoted(strings.dropFirst().joined(separator: "/")))\n"
586 case "file+olp+datetree" where strings.count >= 1 && allStrings:
587 "file = \(quoted(strings[0]))\ndatetree = true\n" + (strings.count > 1 ? "olp = \(quoted(strings.dropFirst().joined(separator: "/")))\n" : "")
588 case let legacy where (legacy == "file+datetree" || legacy == "file+weektree") && strings.count == 1 && target.count == 2:
589 "file = \(quoted(strings[0]))\ndatetree = true\n"
590 case "id" where strings.count == 1 && target.count == 2:
591 "id = \(quoted(strings[0]))\n"
592 case "clock" where target.count == 1:
593 "clock = true\n"
594 default:
595 nil
596 }
597 guard let place else {
598 unsupported.append(Note(id: id(), name: "capture \"\(key)\"", reason: "target \(kind) isn't supported", source: source))
599 return
600 }
601 if kind == "file+weektree" { treeType = "week" }
602 toml += place
603 if let treeType {
604 guard ["day", "week", "month"].contains(treeType) else {
605 unsupported.append(Note(id: id(), name: "capture \"\(key)\"", reason: ":tree-type \(treeType) isn't supported", source: source))
606 return
607 }
608 if treeType != "day" { toml += "tree-type = \(quoted(treeType))\n" }
609 }
610 for flag in ["prepend", "immediate-finish", "jump-to-captured", "clock-in", "clock-keep", "clock-resume"] {
611 if let value = properties[":" + flag], value.symbol != "nil" { toml += "\(flag) = true\n" }
612 }
613 for count in ["empty-lines", "empty-lines-before", "empty-lines-after"] {
614 if let n = properties[":" + count]?.integer { toml += "\(count) = \(n)\n" }
615 }
616 if let pos = properties[":table-line-pos"]?.string { toml += "table-line-pos = \(quoted(pos))\n" }
617 let ignored = properties.keys.filter { ![":prepend", ":immediate-finish", ":jump-to-captured", ":clock-in", ":clock-keep", ":clock-resume", ":empty-lines", ":empty-lines-before", ":empty-lines-after", ":tree-type", ":table-line-pos"].contains($0) }
618 if !ignored.isEmpty {
619 unsupported.append(Note(id: id(), name: "capture \"\(key)\"", reason: "\(ignored.sorted().joined(separator: ", ")) left out", source: source))
620 }
621 toml += "template = \(quoted(template))\n"
622 items.append(Item(id: id(), name: "capture \"\(key)\" \(name)", summary: "capture template \"\(key)\"", source: source, change: .capture(key: key, toml: toml)))
623 }
624
625 /// Emacs commands and the Orgstar commands that do the same.
626 static let commands: [String: String] = [
627 "org-todo": "org.todo.cycle", "org-shiftright": "org.todo.next", "org-shiftleft": "org.todo.previous", "org-priority-up": "org.priority.up", "org-priority-down": "org.priority.down",
628 "org-metaleft": "org.heading.promote", "org-metaright": "org.heading.demote", "org-do-promote": "org.heading.promote", "org-do-demote": "org.heading.demote",
629 "org-promote-subtree": "org.subtree.promote", "org-demote-subtree": "org.subtree.demote",
630 "org-shiftmetaleft": "org.subtree.promote", "org-shiftmetaright": "org.subtree.demote",
631 "org-move-subtree-up": "org.subtree.up", "org-move-subtree-down": "org.subtree.down", "org-metaup": "org.subtree.up", "org-metadown": "org.subtree.down",
632 "org-insert-heading": "org.heading.insert", "org-meta-return": "org.heading.insert",
633 "org-insert-heading-respect-content": "org.heading.insert-after-subtree", "+org/insert-item-below": "org.heading.insert-after-subtree",
634 "org-insert-todo-heading": "org.heading.insert-todo",
635 "org-next-visible-heading": "org.heading.next", "org-previous-visible-heading": "org.heading.previous",
636 "org-forward-heading-same-level": "org.heading.forward-same-level", "org-backward-heading-same-level": "org.heading.backward-same-level",
637 "outline-up-heading": "org.heading.up", "org-up-element": "org.heading.up",
638 "org-set-tags-command": "org.tags.set", "org-set-property": "org.property.read-and-set",
639 "org-toggle-checkbox": "org.checkbox.toggle", "org-ctrl-c-ctrl-c": "org.ctrl-c-ctrl-c", "+org/dwim-at-point": "org.dwim",
640 "org-schedule": "org.schedule", "org-deadline": "org.deadline", "org-time-stamp": "org.timestamp.active", "org-timestamp": "org.timestamp.active",
641 "org-time-stamp-inactive": "org.timestamp.inactive", "org-timestamp-inactive": "org.timestamp.inactive",
642 "org-fill-paragraph": "org.fill-paragraph", "org-cycle": "org.cycle", "org-shifttab": "org.cycle-global", "org-global-cycle": "org.cycle-global",
643 "org-table-create": "org.table.create", "org-table-align": "org.table.align", "org-table-recalculate": "org.table.recalc",
644 "org-table-insert-row": "org.table.insert-row", "org-table-insert-column": "org.table.insert-column",
645 "org-table-delete-column": "org.table.delete-column", "org-table-kill-row": "org.table.kill-row", "org-table-insert-hline": "org.table.insert-hline",
646 "org-babel-execute-src-block": "org.babel.execute", "org-clock-in": "app.clock.in", "org-clock-out": "app.clock.out",
647 "org-clock-cancel": "app.clock.cancel", "org-clock-goto": "app.clock.goto", "org-clock-in-last": "app.clock.in-last",
648 "org-resolve-clocks": "app.clock.resolve", "org-clock-mark-default-task": "app.clock.default",
649 "org-refile": "app.refile", "org-archive-subtree": "app.archive", "org-archive-subtree-default": "app.archive",
650 "org-capture": "app.capture", "org-agenda": "app.agenda", "org-agenda-list": "app.agenda",
651 "save-buffer": "app.save", "find-file": "app.quick-open", "projectile-find-file": "app.quick-open", "+default/find-file-under-here": "app.quick-open",
652 "execute-extended-command": "app.palette", "counsel-M-x": "app.palette",
653 "isearch-forward": "edit.find", "isearch-backward": "edit.find", "consult-line": "edit.find", "+default/search-buffer": "edit.find", "swiper": "edit.find",
654 "query-replace": "edit.replace", "query-replace-regexp": "edit.replace",
655 "save-buffers-kill-terminal": "app.quit", "save-buffers-kill-emacs": "app.quit",
656 ]
657}
658
659extension EmacsImport {
660 /// Applies `items`: settings to `defaults` (and so config.toml), folders to the workspace,
661 /// capture templates and key bindings appended to capture.toml and keymap.toml in
662 /// `directory`, leaving out templates whose key is there already and bindings that are
663 /// there already. Returns a summary.
664 @MainActor
665 public static func apply(
666 _ items: [Item], defaults: UserDefaults = .standard, workspace: WorkspaceModel?, directory: URL = UserConfig.directory
667 ) async -> String {
668 var settings = 0, folders = 0, templates = 0, bindings = 0
669 var problems: [String] = []
670 for item in items {
671 guard case .setting(let key, let value) = item.change,
672 let setting = UserConfig.settings.first(where: { $0.path == key || $0.key == key }) else { continue }
673 if let problem = UserConfig.store(value, setting, in: defaults) { problems.append(problem) } else { settings += 1 }
674 }
675 for item in items {
676 guard case .folder(let path) = item.change else { continue }
677 let url = URL(fileURLWithPath: (path as NSString).expandingTildeInPath).standardizedFileURL
678 guard let workspace, FileManager.default.fileExists(atPath: url.path) else {
679 problems.append("\(path) doesn't exist")
680 continue
681 }
682 if workspace.roots.contains(where: { URL(fileURLWithPath: $0.path).standardizedFileURL.path == url.path }) { continue }
683 do {
684 try await workspace.addRoot(url)
685 folders += 1
686 } catch {
687 problems.append("Can't add \(path): \(error.localizedDescription)")
688 }
689 }
690 try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
691 let captureFile = directory.appendingPathComponent("capture.toml")
692 var capture = (try? String(contentsOf: captureFile, encoding: .utf8)) ?? ""
693 let existingKeys = Set((try? TOML.parse(capture))?.compactMap { $0.values["key"]?.string } ?? [])
694 var addedCapture = ""
695 for item in items {
696 guard case .capture(let key, let toml) = item.change, !existingKeys.contains(key) else { continue }
697 addedCapture += "\n" + toml
698 templates += 1
699 }
700 if !addedCapture.isEmpty {
701 capture += (capture.isEmpty ? "# Capture templates. Imported from Emacs.\n" : "\n# Imported from Emacs.\n") + addedCapture
702 try? capture.write(to: captureFile, atomically: true, encoding: .utf8)
703 }
704 let keymapFile = directory.appendingPathComponent("keymap.toml")
705 var keymap = (try? String(contentsOf: keymapFile, encoding: .utf8)) ?? ""
706 func binding(_ keys: String?, _ command: String?, _ mode: String?) -> String { [keys ?? "", command ?? "", mode ?? ""].joined(separator: "\u{0}") }
707 var existingBindings = Set((try? TOML.parse(keymap))?.filter { $0.name == "bind" && $0.values["when"] == nil }
708 .map { binding($0.values["keys"]?.string, $0.values["command"]?.string, $0.values["mode"]?.string) } ?? [])
709 var addedKeys = ""
710 for item in items {
711 guard case .binding(let keys, let command, let mode) = item.change,
712 existingBindings.insert(binding(keys, command, mode)).inserted else { continue }
713 addedKeys += "\n[[bind]]\nkeys = \(UserConfig.format(.string(keys)))\ncommand = \(UserConfig.format(.string(command)))\n"
714 if let mode { addedKeys += "mode = \(UserConfig.format(.string(mode)))\n" }
715 bindings += 1
716 }
717 if !addedKeys.isEmpty {
718 keymap += (keymap.isEmpty ? "" : "\n") + "# Imported from Emacs.\n" + addedKeys
719 try? keymap.write(to: keymapFile, atomically: true, encoding: .utf8)
720 }
721 func count(_ n: Int, _ word: String) -> String? { n == 0 ? nil : "\(n) \(word)\(n == 1 ? "" : "s")" }
722 let done = [count(settings, "setting"), count(folders, "folder"), count(templates, "capture template"), count(bindings, "key binding")].compactMap { $0 }
723 var summary = done.isEmpty ? "Nothing new to import." : "Imported " + done.joined(separator: ", ") + "."
724 if !problems.isEmpty { summary += " " + problems.joined(separator: "; ") + "." }
725 return summary
726 }
727}