Import from Emacs !48

merged merged by cmc on 2026-10-05 19:36 UTC · krz/orgstar:emacs-import into main

10 files changed, +1144 −4

Layout: unified · split

Sources/OrgApp/EmacsImport.swift added +592
@@ -0,0 +1,592 @@
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 = FileManager.default.homeDirectoryForCurrentUser
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 = FileManager.default.homeDirectoryForCurrentUser
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 "custom-set-variables":
170 for arg in args {
171 guard let entry = unquote(arg)?.elements, entry.count >= 2, let name = entry[0].symbol else { continue }
172 assign(name, .list([.symbol("quote"), entry[1]]), source, priority: priority, deferred: deferred)
173 }
174 case "after!", "with-eval-after-load", "eval-after-load", "use-package!", "use-package":
175 if head.hasPrefix("use-package") {
176 var i = 1
177 var section = ""
178 while i < args.count {
179 if let keyword = args[i].symbol, keyword.hasPrefix(":") {
180 section = keyword
181 } else if section == ":config" || section == ":init" {
182 visit(args[i], source: source, priority: priority, deferred: true, insideDefun: insideDefun, doom: doom)
183 } else if section == ":custom", let pair = args[i].elements, pair.count >= 2, let name = pair[0].symbol {
184 assign(name, pair[1], source, priority: priority, deferred: true)
185 }
186 i += 1
187 }
188 } else {
189 let forms = head == "eval-after-load" ? args.dropFirst().compactMap(unquote) : Array(args.dropFirst())
190 body(forms[...], deferred: true)
191 }
192 case "progn", "when", "unless", "if", "let", "let*", "with-no-warnings":
193 let skip = ["let", "let*", "when", "unless", "if"].contains(head) ? 1 : 0
194 body(args.dropFirst(skip), deferred: deferred)
195 case "defun", "defun!", "add-hook!":
196 // Doom sets org's defaults in the functions its module runs when org loads.
197 if doom, head == "defun" { body(args.dropFirst(2), deferred: true) }
198 case "doom!":
199 usesEvil = args.contains { $0.symbol == "evil" || $0.head == "evil" }
200 case "map!":
201 if !doom { bindings += mapBindings(args, prefix: "", modes: [nil], source: source) }
202 case "define-key", "keymap-set", "evil-define-key", "evil-define-key*", "global-set-key", "keymap-global-set":
203 guard !doom else { break }
204 var rest = args
205 var modes: [String?] = [nil]
206 if head.hasPrefix("evil-define-key"), let state = rest.first {
207 modes = evilStates(state)
208 rest = Array(rest.dropFirst())
209 }
210 if !head.hasPrefix("global"), head != "keymap-global-set" { rest = Array(rest.dropFirst()) }
211 var i = 0
212 while i + 1 < rest.count {
213 if let keys = keyString(rest[i]) { bindings.append(Binding(keys: keys, command: rest[i + 1], modes: modes, source: source)) }
214 i += 2
215 }
216 default:
217 break
218 }
219 }
220
221 func evilStates(_ sexp: Sexp) -> [String?] {
222 let names = (unquote(sexp)?.elements ?? [unquote(sexp) ?? sexp]).compactMap(\.symbol)
223 let modes = names.compactMap { name -> String? in
224 switch name {
225 case "normal", "motion": "normal"
226 case "insert": "insert"
227 case "visual": "visual"
228 default: nil
229 }
230 }
231 return modes.isEmpty ? [nil] : modes
232 }
233
234 /// `(kbd "C-c x")`, `"C-c x"` (keymap-set), or a vector of one key.
235 func keyString(_ sexp: Sexp) -> String? {
236 if let s = sexp.string { return s }
237 if sexp.head == "kbd", let s = sexp.elements?.dropFirst().first?.string { return s }
238 return nil
239 }
240
241 /// Doom's `map!`: `:leader`, `:localleader`, `:prefix`, state keywords such as `:n`
242 /// and `:nv`, `:map`, `:desc`, and nested groups.
243 func mapBindings(_ args: [Sexp], prefix: String, modes: [String?], source: String) -> [Binding] {
244 var result: [Binding] = []
245 var prefix = prefix
246 var modes = modes
247 var i = 0
248 while i < args.count {
249 let arg = args[i]
250 if let keyword = arg.symbol, keyword.hasPrefix(":") {
251 switch keyword {
252 case ":leader":
253 prefix = "SPC"
254 modes = ["normal"]
255 case ":localleader":
256 prefix = "SPC m"
257 modes = ["normal"]
258 case ":prefix", ":prefix-map":
259 if i + 1 < args.count {
260 let p = args[i + 1].string ?? args[i + 1].elements?.first?.string ?? ""
261 prefix = [prefix, p].filter { !$0.isEmpty }.joined(separator: " ")
262 }
263 i += 1
264 case ":desc", ":map", ":mode", ":after", ":when", ":unless":
265 i += 1
266 default:
267 let states = keyword.dropFirst()
268 let mapped = states.compactMap { c -> String? in
269 switch c {
270 case "n", "m": "normal"
271 case "i": "insert"
272 case "v": "visual"
273 default: nil
274 }
275 }
276 if !mapped.isEmpty { modes = Array(Set(mapped)).sorted() }
277 }
278 } else if let group = arg.elements, arg.head?.hasPrefix(":") == true || group.first?.string != nil && group.count > 2 {
279 result += mapBindings(group, prefix: prefix, modes: modes, source: source)
280 } else if let key = arg.string, i + 1 < args.count {
281 let keys = [prefix, key].filter { !$0.isEmpty }.joined(separator: " ")
282 result.append(Binding(keys: keys, command: args[i + 1], modes: modes, source: source))
283 i += 1
284 }
285 i += 1
286 }
287 return result
288 }
289 }
290
291 // MARK: - Values
292
293 static func unquote(_ sexp: Sexp) -> Sexp? {
294 if let items = sexp.elements, items.count == 2, items[0].symbol == "quote" || items[0].symbol == "function" { return items[1] }
295 if case .list(let items) = sexp, items.count == 2, items[0].symbol == "\\`" { return items[1] }
296 return nil
297 }
298
299 /// What a form evaluates to, for the few forms configurations build values with; nil
300 /// when reading can't tell.
301 static func evaluate(_ sexp: Sexp, _ env: [String: Sexp]) -> Sexp? {
302 switch sexp {
303 case .string, .integer, .float, .character, .vector: return sexp
304 case .symbol(let name):
305 if name == "t" || name == "nil" || name.hasPrefix(":") { return sexp }
306 return env[name]
307 case .dotted: return nil
308 case .list(let items):
309 guard let head = items.first?.symbol else { return nil }
310 let args = Array(items.dropFirst())
311 switch head {
312 case "quote", "function": return args.first
313 case "\\`":
314 guard let inner = args.first, !inner.description.contains(",") else { return nil }
315 return inner
316 case "list":
317 let values = args.compactMap { evaluate($0, env) }
318 return values.count == args.count ? .list(values) : nil
319 case "concat":
320 let parts = args.compactMap { evaluate($0, env)?.string }
321 return parts.count == args.count ? .string(parts.joined()) : nil
322 case "expand-file-name", "file-name-concat":
323 let parts = args.compactMap { evaluate($0, env)?.string }
324 guard parts.count == args.count, !parts.isEmpty else { return nil }
325 if head == "expand-file-name" {
326 let name = parts[0]
327 if name.hasPrefix("/") || name.hasPrefix("~") || parts.count == 1 { return .string(name) }
328 return .string((parts[1] as NSString).appendingPathComponent(name))
329 }
330 return .string(parts.dropFirst().reduce(parts[0]) { ($0 as NSString).appendingPathComponent($1) })
331 case "file-name-as-directory":
332 guard let s = args.first.flatMap({ evaluate($0, env)?.string }) else { return nil }
333 return .string(s.hasSuffix("/") ? s : s + "/")
334 default:
335 return nil
336 }
337 }
338 }
339
340 // MARK: - Results
341
342 static let orgRelated = ["org-", "appt-", "display-line-numbers", "fill-column", "evil-", "doom-font", "doom-variable-pitch-font", "doom-theme", "calendar-week-start-day"]
343
344 mutating func build(from scanner: Scanner) {
345 var next = 0
346 func id() -> Int { next += 1; return next }
347 // The last assignment of each variable wins, those after a package loads over the rest.
348 var winners: [String: Assignment] = [:]
349 var env: [String: Sexp] = [:]
350 for assignment in scanner.assignments.enumerated().sorted(by: { ($0.element.rank, $0.offset) < ($1.element.rank, $1.offset) }).map(\.element) {
351 winners[assignment.name] = assignment
352 if let value = Self.evaluate(assignment.value, env) { env[assignment.name] = value }
353 }
354 if isDoom || scanner.usesEvil {
355 items.append(Item(id: id(), name: isDoom ? "Doom Emacs" : "evil-mode", summary: "keymap = \"doom\"", source: "", change: .setting(key: "keymap", value: .string("doom"))))
356 }
357 for name in winners.keys.sorted() {
358 let assignment = winners[name]!
359 guard Self.orgRelated.contains(where: { name.hasPrefix($0) }) else {
360 if assignment.rank >= 2 { skipped += 1 }
361 continue
362 }
363 guard let value = Self.evaluate(assignment.value, env) else {
364 if assignment.rank < 2 {
365 doomDefaultsLeftOut += 1
366 continue
367 }
368 unreadable.append(Note(id: id(), name: name, reason: "worked out when Emacs runs: \(assignment.value.description.prefix(80))", source: assignment.source))
369 continue
370 }
371 let before = unsupported.count
372 convert(name, value, source: assignment.source, id: id)
373 if assignment.rank < 2, unsupported.count > before {
374 doomDefaultsLeftOut += unsupported.count - before
375 unsupported.removeLast(unsupported.count - before)
376 }
377 }
378 // One item per folder.
379 var folders: Set<String> = []
380 items.removeAll { item in
381 guard case .folder(let path) = item.change else { return false }
382 let key = (path as NSString).expandingTildeInPath.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
383 return !folders.insert(key).inserted
384 }
385 for binding in scanner.bindings {
386 guard let commandName = Self.unquote(binding.command)?.symbol ?? binding.command.symbol else {
387 unsupported.append(Note(id: id(), name: binding.keys, reason: "bound to code, not a command", source: binding.source))
388 continue
389 }
390 guard let command = Self.commands[commandName] else {
391 unsupported.append(Note(id: id(), name: "\(binding.keys) → \(commandName)", reason: "Orgstar has no such command", source: binding.source))
392 continue
393 }
394 guard let keys = KeySequence.parse(binding.keys).map(KeySequence.format) else {
395 unsupported.append(Note(id: id(), name: binding.keys, reason: "keys Orgstar can't read", source: binding.source))
396 continue
397 }
398 let modes = isDoom ? binding.modes.map { $0 ?? "normal" } : binding.modes
399 for mode in Array(Set(modes.map { $0 ?? "" })).sorted() {
400 items.append(Item(id: id(), name: "\(keys) → \(commandName)", summary: mode.isEmpty ? command : "\(command) in \(mode) state",
401 source: binding.source, change: .binding(keys: keys, command: command, mode: mode.isEmpty ? nil : mode)))
402 }
403 }
404 }
405
406 mutating func convert(_ name: String, _ value: Sexp, source: String, id: () -> Int) {
407 func add(_ key: String, _ toml: TOML.Value, _ summary: String? = nil) {
408 items.append(Item(id: id(), name: name, summary: summary ?? "\(key) = \(UserConfig.format(toml))", source: source, change: .setting(key: key, value: toml)))
409 }
410 func note(_ reason: String) { unsupported.append(Note(id: id(), name: name, reason: reason, source: source)) }
411 let truthy = value.symbol != "nil" && value.elements?.isEmpty != true
412 switch name {
413 case "fill-column", "org-tags-column", "appt-message-warning-time":
414 guard let n = value.integer else { return note("not a number") }
415 add(name, .integer(n))
416 case "org-insert-heading-respect-content", "org-list-allow-alphabetical", "org-hide-emphasis-markers", "org-pretty-entities":
417 add(name, .bool(truthy))
418 case "org-M-RET-may-split-line":
419 if let alist = value.elements, !alist.isEmpty {
420 // `((default . nil))`: the default entry, as most configurations write it.
421 let entry = alist.first { if case .dotted(let k, _) = $0 { return k.first?.symbol == "default" } else { return false } }
422 if case .dotted(_, let v)? = entry { add(name, .bool(v.symbol != "nil")) } else { note("per-context values aren't supported") }
423 } else {
424 add(name, .bool(truthy))
425 }
426 case "display-line-numbers-type":
427 add(name, .bool(truthy), truthy && value.symbol != "t" ? "display-line-numbers-type = true (Orgstar numbers lines absolutely)" : nil)
428 case "org-agenda-span":
429 let days: Int? = value.integer ?? ["day": 1, "week": 7, "fortnight": 14, "month": 30, "year": 365][value.symbol ?? ""]
430 guard let days else { return note("unknown span \(value.description)") }
431 add(name, .integer(days))
432 case "org-agenda-start-day":
433 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") }
434 case "org-todo-keywords":
435 guard let sequences = value.elements, let first = sequences.first?.elements, first.count > 1 else { return note("unexpected value") }
436 let words = first.dropFirst().compactMap(\.string)
437 add(name, .string(words.joined(separator: " ")))
438 if sequences.count > 1 { note("only the first sequence is used; the other \(sequences.count - 1) are left out") }
439 case "org-directory":
440 guard let path = value.string else { return note("not a folder") }
441 items.append(Item(id: id(), name: name, summary: "add folder \(path)", source: source, change: .folder(path)))
442 case "org-agenda-files":
443 let paths = (value.elements ?? [value]).compactMap(\.string)
444 guard !paths.isEmpty else { return note("not a list of files or folders") }
445 for path in paths {
446 let folder = path.hasSuffix(".org") ? (path as NSString).deletingLastPathComponent : path
447 items.append(Item(id: id(), name: name, summary: "add folder \(folder)", source: source, change: .folder(folder)))
448 }
449 case "org-capture-templates":
450 for entry in value.elements ?? [] { captureTemplate(entry, source: source, id: id) }
451 case "doom-font", "doom-variable-pitch-font", "doom-theme":
452 note("Orgstar has no fonts or themes to set yet")
453 default:
454 note("Orgstar has no equivalent")
455 }
456 }
457
458 mutating func captureTemplate(_ entry: Sexp, source: String, id: () -> Int) {
459 guard let parts = entry.elements, parts.count >= 2, let key = parts[0].string else { return }
460 let name = parts[1].string ?? key
461 guard parts.count >= 5, let type = parts[2].symbol, let target = parts[3].elements, let kind = target.first?.symbol,
462 let template = parts[4].string else {
463 // A key and a name only: a group of templates.
464 if parts.count == 2 { return }
465 unsupported.append(Note(id: id(), name: "capture \"\(key)\"", reason: "a template that isn't a string", source: source))
466 return
467 }
468 guard ["entry", "item", "checkitem", "plain"].contains(type) else {
469 unsupported.append(Note(id: id(), name: "capture \"\(key)\"", reason: "\(type) templates aren't supported", source: source))
470 return
471 }
472 func quoted(_ s: String) -> String { UserConfig.format(.string(s)) }
473 var toml = "[[template]]\nkey = \(quoted(key))\nname = \(quoted(name))\ntype = \(quoted(type))\n"
474 let strings = target.dropFirst().compactMap(\.string)
475 let place: String? = switch kind {
476 case "file" where strings.count == 1 && target.count == 2:
477 "file = \(quoted(strings[0]))\n"
478 case "file+headline" where strings.count == 2 && target.count == 3:
479 "file = \(quoted(strings[0]))\nheadline = \(quoted(strings[1]))\n"
480 case "file+olp" where strings.count >= 2 && strings.count == target.count - 1:
481 "file = \(quoted(strings[0]))\nolp = \(quoted(strings.dropFirst().joined(separator: "/")))\n"
482 default:
483 nil
484 }
485 guard let place else {
486 unsupported.append(Note(id: id(), name: "capture \"\(key)\"", reason: "target \(kind) isn't supported", source: source))
487 return
488 }
489 toml += place
490 let prepend = parts.dropFirst(5).enumerated().contains { $0.element.symbol == ":prepend" && parts.dropFirst(5).dropFirst($0.offset + 1).first?.symbol != "nil" }
491 if prepend { toml += "prepend = true\n" }
492 toml += "template = \(quoted(template))\n"
493 items.append(Item(id: id(), name: "capture \"\(key)\" \(name)", summary: "capture template \"\(key)\"", source: source, change: .capture(key: key, toml: toml)))
494 }
495
496 /// Emacs commands and the Orgstar commands that do the same.
497 static let commands: [String: String] = [
498 "org-todo": "org.todo.cycle", "org-shiftright": "org.todo.cycle", "org-priority-up": "org.priority.up", "org-priority-down": "org.priority.down",
499 "org-metaleft": "org.heading.promote", "org-metaright": "org.heading.demote", "org-do-promote": "org.heading.promote", "org-do-demote": "org.heading.demote",
500 "org-promote-subtree": "org.subtree.promote", "org-demote-subtree": "org.subtree.demote",
501 "org-shiftmetaleft": "org.subtree.promote", "org-shiftmetaright": "org.subtree.demote",
502 "org-move-subtree-up": "org.subtree.up", "org-move-subtree-down": "org.subtree.down", "org-metaup": "org.subtree.up", "org-metadown": "org.subtree.down",
503 "org-insert-heading": "org.heading.insert", "org-meta-return": "org.heading.insert",
504 "org-insert-heading-respect-content": "org.heading.insert-after-subtree", "+org/insert-item-below": "org.heading.insert-after-subtree",
505 "org-insert-todo-heading": "org.heading.insert-todo",
506 "org-next-visible-heading": "org.heading.next", "org-previous-visible-heading": "org.heading.previous",
507 "org-forward-heading-same-level": "org.heading.forward-same-level", "org-backward-heading-same-level": "org.heading.backward-same-level",
508 "outline-up-heading": "org.heading.up", "org-up-element": "org.heading.up",
509 "org-set-tags-command": "org.tags.set", "org-set-property": "org.property.set",
510 "org-toggle-checkbox": "org.checkbox.toggle", "org-ctrl-c-ctrl-c": "org.dwim", "+org/dwim-at-point": "org.dwim",
511 "org-schedule": "org.schedule", "org-deadline": "org.deadline", "org-time-stamp": "org.timestamp.active", "org-timestamp": "org.timestamp.active",
512 "org-time-stamp-inactive": "org.timestamp.inactive", "org-timestamp-inactive": "org.timestamp.inactive",
513 "org-fill-paragraph": "org.fill-paragraph", "org-cycle": "org.cycle", "org-shifttab": "org.cycle-global", "org-global-cycle": "org.cycle-global",
514 "org-table-create": "org.table.create", "org-table-align": "org.table.align", "org-table-recalculate": "org.table.recalc",
515 "org-table-insert-row": "org.table.insert-row", "org-table-insert-column": "org.table.insert-column",
516 "org-table-delete-column": "org.table.delete-column", "org-table-kill-row": "org.table.kill-row", "org-table-insert-hline": "org.table.insert-hline",
517 "org-babel-execute-src-block": "org.babel.execute", "org-clock-in": "app.clock.in", "org-clock-out": "app.clock.out",
518 "org-clock-cancel": "app.clock.cancel", "org-clock-goto": "app.clock.goto",
519 "org-refile": "app.refile", "org-archive-subtree": "app.archive", "org-archive-subtree-default": "app.archive",
520 "org-capture": "app.capture", "org-agenda": "app.agenda", "org-agenda-list": "app.agenda",
521 "save-buffer": "app.save", "find-file": "app.quick-open", "projectile-find-file": "app.quick-open", "+default/find-file-under-here": "app.quick-open",
522 "execute-extended-command": "app.palette", "counsel-M-x": "app.palette",
523 "isearch-forward": "edit.find", "isearch-backward": "edit.find", "consult-line": "edit.find", "+default/search-buffer": "edit.find", "swiper": "edit.find",
524 "query-replace": "edit.replace", "query-replace-regexp": "edit.replace",
525 "save-buffers-kill-terminal": "app.quit", "save-buffers-kill-emacs": "app.quit",
526 ]
527}
528
529extension EmacsImport {
530 /// Applies `items`: settings to `defaults` (and so config.toml), folders to the workspace,
531 /// capture templates and key bindings appended to capture.toml and keymap.toml in
532 /// `directory`, leaving out templates whose key is there already. Returns a summary.
533 @MainActor
534 public static func apply(
535 _ items: [Item], defaults: UserDefaults = .standard, workspace: WorkspaceModel?, directory: URL = UserConfig.directory
536 ) async -> String {
537 var settings = 0, folders = 0, templates = 0, bindings = 0
538 var problems: [String] = []
539 for item in items {
540 guard case .setting(let key, let value) = item.change,
541 let setting = UserConfig.settings.first(where: { $0.key == key }) else { continue }
542 if let problem = UserConfig.store(value, setting, in: defaults) { problems.append(problem) } else { settings += 1 }
543 }
544 for item in items {
545 guard case .folder(let path) = item.change else { continue }
546 let url = URL(fileURLWithPath: (path as NSString).expandingTildeInPath).standardizedFileURL
547 guard let workspace, FileManager.default.fileExists(atPath: url.path) else {
548 problems.append("\(path) doesn't exist")
549 continue
550 }
551 if workspace.roots.contains(where: { URL(fileURLWithPath: $0.path).standardizedFileURL.path == url.path }) { continue }
552 do {
553 try await workspace.addRoot(url)
554 folders += 1
555 } catch {
556 problems.append("Can't add \(path): \(error.localizedDescription)")
557 }
558 }
559 try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
560 let captureFile = directory.appendingPathComponent("capture.toml")
561 var capture = (try? String(contentsOf: captureFile, encoding: .utf8)) ?? ""
562 let existingKeys = Set((try? TOML.parse(capture))?.compactMap { $0.values["key"]?.string } ?? [])
563 var addedCapture = ""
564 for item in items {
565 guard case .capture(let key, let toml) = item.change, !existingKeys.contains(key) else { continue }
566 addedCapture += "\n" + toml
567 templates += 1
568 }
569 if !addedCapture.isEmpty {
570 capture += (capture.isEmpty ? "# Capture templates. Imported from Emacs.\n" : "\n# Imported from Emacs.\n") + addedCapture
571 try? capture.write(to: captureFile, atomically: true, encoding: .utf8)
572 }
573 let keymapFile = directory.appendingPathComponent("keymap.toml")
574 var keymap = (try? String(contentsOf: keymapFile, encoding: .utf8)) ?? ""
575 var addedKeys = ""
576 for item in items {
577 guard case .binding(let keys, let command, let mode) = item.change else { continue }
578 addedKeys += "\n[[bind]]\nkeys = \(UserConfig.format(.string(keys)))\ncommand = \(UserConfig.format(.string(command)))\n"
579 if let mode { addedKeys += "mode = \(UserConfig.format(.string(mode)))\n" }
580 bindings += 1
581 }
582 if !addedKeys.isEmpty {
583 keymap += (keymap.isEmpty ? "" : "\n") + "# Imported from Emacs.\n" + addedKeys
584 try? keymap.write(to: keymapFile, atomically: true, encoding: .utf8)
585 }
586 func count(_ n: Int, _ word: String) -> String? { n == 0 ? nil : "\(n) \(word)\(n == 1 ? "" : "s")" }
587 let done = [count(settings, "setting"), count(folders, "folder"), count(templates, "capture template"), count(bindings, "key binding")].compactMap { $0 }
588 var summary = done.isEmpty ? "Nothing new to import." : "Imported " + done.joined(separator: ", ") + "."
589 if !problems.isEmpty { summary += " " + problems.joined(separator: "; ") + "." }
590 return summary
591 }
592}
Sources/OrgApp/UserConfig.swift +3 −1
@@ -201,7 +201,9 @@ public enum UserConfig {
201201 switch value {
202202 case .bool(let b): b ? "true" : "false"
203203 case .integer(let i): String(i)
204 case .string(let s): "\"" + s.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\"") + "\""
204 case .string(let s):
205 "\"" + s.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\"")
206 .replacingOccurrences(of: "\n", with: "\\n").replacingOccurrences(of: "\t", with: "\\t") + "\""
205207 }
206208 }
207209
Sources/OrgCore/Config/Lisp.swift added +224
@@ -0,0 +1,224 @@
1import Foundation
2
3/// Emacs Lisp read without evaluating: for importing settings from an Emacs configuration.
4public indirect enum Sexp: Equatable, Sendable {
5 case symbol(String)
6 case string(String)
7 case integer(Int)
8 case float(Double)
9 /// `?a`, as its code point.
10 case character(Int)
11 case list([Sexp])
12 /// `(a b . c)`: the elements, then the final cdr.
13 case dotted([Sexp], Sexp)
14 case vector([Sexp])
15
16 public static let `nil` = Sexp.symbol("nil")
17
18 public var symbol: String? { if case .symbol(let s) = self { return s } else { return nil } }
19 public var string: String? { if case .string(let s) = self { return s } else { return nil } }
20 public var integer: Int? { if case .integer(let i) = self { return i } else { return nil } }
21
22 /// A list's elements; `nil` is the empty list.
23 public var elements: [Sexp]? {
24 switch self {
25 case .list(let items): items
26 case .symbol("nil"): []
27 default: nil
28 }
29 }
30
31 /// The symbol a list starts with: `setq` for `(setq ...)`.
32 public var head: String? { elements?.first?.symbol }
33
34 /// Written back as Lisp, for reports.
35 public var description: String {
36 switch self {
37 case .symbol(let s): return s
38 case .string(let s): return "\"" + s.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\"").replacingOccurrences(of: "\n", with: "\\n") + "\""
39 case .integer(let i): return String(i)
40 case .float(let d): return String(d)
41 case .character(let c): return "?" + (Unicode.Scalar(c).map { String(Character($0)) } ?? "")
42 case .list(let items):
43 if items.count == 2, let head = items[0].symbol {
44 switch head {
45 case "quote": return "'" + items[1].description
46 case "function": return "#'" + items[1].description
47 case "\\`": return "`" + items[1].description
48 case ",": return "," + items[1].description
49 case ",@": return ",@" + items[1].description
50 default: break
51 }
52 }
53 return "(" + items.map(\.description).joined(separator: " ") + ")"
54 case .dotted(let items, let last): return "(" + items.map(\.description).joined(separator: " ") + " . " + last.description + ")"
55 case .vector(let items): return "[" + items.map(\.description).joined(separator: " ") + "]"
56 }
57 }
58}
59
60public enum LispReader {
61 public struct Form: Sendable {
62 public let sexp: Sexp
63 /// 1-based line where the form starts.
64 public let line: Int
65 }
66
67 public struct ReadError: Error, Equatable, CustomStringConvertible {
68 public let line: Int
69 public let message: String
70 public var description: String { "line \(line): \(message)" }
71 }
72
73 /// The top-level forms of `text`.
74 public static func read(_ text: String) throws -> [Form] {
75 var reader = Reader(Array(text.unicodeScalars))
76 var forms: [Form] = []
77 while true {
78 reader.skipSpace()
79 guard !reader.atEnd else { break }
80 let line = reader.line
81 forms.append(Form(sexp: try reader.form(), line: line))
82 }
83 return forms
84 }
85
86 struct Reader {
87 let chars: [Unicode.Scalar]
88 var index = 0
89 var line = 1
90
91 init(_ chars: [Unicode.Scalar]) { self.chars = chars }
92
93 var atEnd: Bool { index >= chars.count }
94 var peek: Unicode.Scalar? { index < chars.count ? chars[index] : nil }
95
96 mutating func next() -> Unicode.Scalar? {
97 guard index < chars.count else { return nil }
98 let c = chars[index]
99 index += 1
100 if c == "\n" { line += 1 }
101 return c
102 }
103
104 mutating func skipSpace() {
105 while let c = peek {
106 if c == ";" {
107 while let d = peek, d != "\n" { _ = next() }
108 } else if c.properties.isWhitespace {
109 _ = next()
110 } else {
111 return
112 }
113 }
114 }
115
116 func error(_ message: String) -> ReadError { ReadError(line: line, message: message) }
117
118 static let delimiters: Set<Unicode.Scalar> = ["(", ")", "[", "]", "\"", "'", "`", ",", ";"]
119
120 mutating func form() throws -> Sexp {
121 skipSpace()
122 guard let c = next() else { throw error("unexpected end of file") }
123 switch c {
124 case "(": return try list(close: ")")
125 case "[":
126 guard case .list(let items) = try list(close: "]") else { throw error("bad vector") }
127 return .vector(items)
128 case ")", "]": throw error("unexpected \(c)")
129 case "\"": return .string(try string())
130 case "'": return .list([.symbol("quote"), try form()])
131 case "`": return .list([.symbol("\\`"), try form()])
132 case ",":
133 if peek == "@" {
134 _ = next()
135 return .list([.symbol(",@"), try form()])
136 }
137 return .list([.symbol(","), try form()])
138 case "?": return .character(try character())
139 case "#":
140 if peek == "'" {
141 _ = next()
142 return .list([.symbol("function"), try form()])
143 }
144 return .symbol("#" + atom())
145 default:
146 index -= 1
147 if c == "\n" { line -= 1 }
148 return number(atom())
149 }
150 }
151
152 mutating func list(close: Unicode.Scalar) throws -> Sexp {
153 var items: [Sexp] = []
154 while true {
155 skipSpace()
156 guard let c = peek else { throw error("unclosed list") }
157 if c == close {
158 _ = next()
159 return .list(items)
160 }
161 if c == ".", index + 1 < chars.count, chars[index + 1].properties.isWhitespace || Self.delimiters.contains(chars[index + 1]) {
162 _ = next()
163 let last = try form()
164 skipSpace()
165 guard next() == close else { throw error("bad dotted list") }
166 return .dotted(items, last)
167 }
168 items.append(try form())
169 }
170 }
171
172 mutating func string() throws -> String {
173 var out = ""
174 while let c = next() {
175 switch c {
176 case "\"": return out
177 case "\\":
178 guard let e = next() else { break }
179 switch e {
180 case "n": out += "\n"
181 case "t": out += "\t"
182 case "\n": break
183 default: out.unicodeScalars.append(e)
184 }
185 default:
186 out.unicodeScalars.append(c)
187 }
188 }
189 throw error("unclosed string")
190 }
191
192 mutating func character() throws -> Int {
193 guard let c = next() else { throw error("bad character") }
194 guard c == "\\" else { return Int(c.value) }
195 guard let e = next() else { throw error("bad character") }
196 switch e {
197 case "n": return 10
198 case "t": return 9
199 case "s": return 32
200 case "e": return 27
201 case "C", "M", "S", "s", "H", "A":
202 if peek == "-" { _ = next(); return try character() }
203 return Int(e.value)
204 default: return Int(e.value)
205 }
206 }
207
208 mutating func atom() -> String {
209 var out = ""
210 while let c = peek, !c.properties.isWhitespace, !Self.delimiters.contains(c) {
211 _ = next()
212 if c == "\\", let escaped = next() { out.unicodeScalars.append(escaped) } else { out.unicodeScalars.append(c) }
213 }
214 return out
215 }
216
217 func number(_ text: String) -> Sexp {
218 if let i = Int(text.hasPrefix("+") ? String(text.dropFirst()) : text) { return .integer(i) }
219 if text.contains(where: { $0 == "." || $0 == "e" }), text.first.map({ $0.isNumber || $0 == "-" || $0 == "+" || $0 == "." }) == true,
220 let d = Double(text) { return .float(d) }
221 return .symbol(text)
222 }
223 }
224}
Sources/OrgEditorAppKit/EditorCommands.swift +1
@@ -43,6 +43,7 @@ public enum EditorCommands {
4343 EditorCommand(id: "app.toggle-outline", title: "Show or Hide Outline", action: .app),
4444 EditorCommand(id: "app.toggle-markup", title: "Show or Hide Markup", action: .app),
4545 EditorCommand(id: "app.edit-config", title: "Edit Config File", action: .app),
46 EditorCommand(id: "app.import-emacs", title: "Import from Emacs…", action: .app),
4647 EditorCommand(id: "app.toggle-line-numbers", title: "Show or Hide Line Numbers", action: .app),
4748 EditorCommand(id: "app.quick-open", title: "Quick Open…", action: .app),
4849 EditorCommand(id: "app.palette", title: "Command Palette…", action: .app),
Sources/Orgstar/ContentView.swift +4
@@ -47,6 +47,7 @@ struct ContentView: View {
4747 @AppStorage("showLineNumbers") private var showLineNumbers = true
4848 /// The outline for a file that isn't org, off until asked for.
4949 @State private var plainOutlineShown = false
50 @State private var showingImport = false
5051 @State private var query = ""
5152 @FocusState private var searchFocused: Bool
5253 @Environment(\.openWindow) private var openWindow
@@ -162,6 +163,7 @@ struct ContentView: View {
162163 .onChange(of: saveMode, initial: true) { _, mode in session.saveMode = mode }
163164 .onChange(of: options, initial: true) { _, options in session.options = options }
164165 .onChange(of: preset, initial: true) { loadKeymap() }
166 .sheet(isPresented: $showingImport) { EmacsImportView(workspace: workspace) }
165167 .sheet(isPresented: $commands.showingPalette) {
166168 PaletteView(commands: commands) { id in
167169 commands.showingPalette = false
@@ -256,6 +258,7 @@ struct ContentView: View {
256258 DevelopmentHooks.scheduleSnapshot()
257259 if let path = DevelopmentHooks.fileToOpen { selectedFile = path }
258260 if let root = DevelopmentHooks.rootToAdd { try? await workspace.addRoot(URL(fileURLWithPath: root)) }
261 if let command = DevelopmentHooks.commandToRun { perform(command) }
259262 if let id = DevelopmentHooks.windowToOpen {
260263 if id == "settings" { openSettings() } else { openWindow(id: id) }
261264 }
@@ -394,6 +397,7 @@ struct ContentView: View {
394397 case "app.toggle-outline": toggleOutline()
395398 case "app.toggle-markup": showMarkup.toggle()
396399 case "app.toggle-line-numbers": showLineNumbers.toggle()
400 case "app.import-emacs": showingImport = true
397401 case "app.edit-config": commands.jump(path: UserConfig.configFile.path, offset: 0)
398402 case "app.quick-open": commands.showingQuickOpen = true
399403 case "app.palette": commands.showingPalette = true
Sources/Orgstar/DevelopmentHooks.swift +6 −1
@@ -5,6 +5,8 @@ import AppKit
55/// - `ORGSTAR_OPEN`: a file to select at launch.
66/// - `ORGSTAR_ROOT`: a folder to add at launch.
77/// - `ORGSTAR_WINDOW`: a window to open at launch (`agenda`, `settings`); the snapshot renders it.
8/// - `ORGSTAR_COMMAND`: a command to run at launch (`app.import-emacs`); a sheet it opens is
9/// what the snapshot renders.
810/// - `ORGSTAR_SNAPSHOT`: after `ORGSTAR_SNAPSHOT_DELAY` seconds (default 3), render the main
911/// window to this PNG path and quit. Rendering from inside the app needs no screen-recording
1012/// permission.
@@ -12,6 +14,7 @@ enum DevelopmentHooks {
1214 static var fileToOpen: String? { ProcessInfo.processInfo.environment["ORGSTAR_OPEN"] }
1315 static var rootToAdd: String? { ProcessInfo.processInfo.environment["ORGSTAR_ROOT"] }
1416 static var windowToOpen: String? { ProcessInfo.processInfo.environment["ORGSTAR_WINDOW"] }
17 static var commandToRun: String? { ProcessInfo.processInfo.environment["ORGSTAR_COMMAND"] }
1518
1619 @MainActor
1720 static func scheduleSnapshot() {
@@ -20,12 +23,14 @@ enum DevelopmentHooks {
2023 let delay = Double(environment["ORGSTAR_SNAPSHOT_DELAY"] ?? "") ?? 3
2124 DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
2225 let visible = NSApp.windows.filter(\.isVisible)
23 let window = windowToOpen.flatMap { id in visible.first { $0.identifier?.rawValue.lowercased().contains(id) == true } } ?? visible.first
26 let chosen = windowToOpen.flatMap { id in visible.first { $0.identifier?.rawValue.lowercased().contains(id) == true } } ?? visible.first
27 let window = chosen?.attachedSheet ?? chosen
2428 if let view = window?.contentView?.superview,
2529 let bitmap = view.bitmapImageRepForCachingDisplay(in: view.bounds) {
2630 view.cacheDisplay(in: view.bounds, to: bitmap)
2731 try? bitmap.representation(using: .png, properties: [:])?.write(to: URL(fileURLWithPath: path))
2832 }
33 if let chosen, let sheet = chosen.attachedSheet { chosen.endSheet(sheet) }
2934 NSApp.terminate(nil)
3035 }
3136 }
Sources/Orgstar/EmacsImportView.swift added +130
@@ -0,0 +1,130 @@
1import OrgApp
2import OrgCore
3import SwiftUI
4
5/// Import from Emacs: what was read from a configuration, to pick from before anything changes.
6struct EmacsImportView: View {
7 let workspace: WorkspaceModel?
8 @Environment(\.dismiss) private var dismiss
9 @State private var location: URL?
10 @State private var result: EmacsImport?
11 @State private var selected: Set<Int> = []
12 @State private var choosing = false
13 @State private var summary: String?
14 @State private var importing = false
15
16 var body: some View {
17 VStack(alignment: .leading, spacing: 0) {
18 header
19 Divider()
20 if let summary {
21 Text(summary).padding(20).frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
22 } else if let result {
23 list(result)
24 } else {
25 ContentUnavailableView("No Emacs configuration found", systemImage: "doc.questionmark",
26 description: Text("Choose your init.el, or the folder holding it (~/.emacs.d, ~/.config/doom)."))
27 }
28 Divider()
29 HStack {
30 if let result, summary == nil {
31 Text(footnote(result)).font(.caption).foregroundStyle(.secondary)
32 }
33 Spacer()
34 if summary == nil {
35 Button("Cancel") { dismiss() }.keyboardShortcut(.cancelAction)
36 Button("Import") { runImport() }
37 .keyboardShortcut(.defaultAction)
38 .disabled(selected.isEmpty || importing)
39 } else {
40 Button("Done") { dismiss() }.keyboardShortcut(.defaultAction)
41 }
42 }
43 .padding(12)
44 }
45 .frame(width: 640, height: 560)
46 .onAppear {
47 if location == nil, let first = EmacsImport.defaultLocations().first { read(first) }
48 }
49 .fileImporter(isPresented: $choosing, allowedContentTypes: [.folder, .item]) { chosen in
50 if case .success(let url) = chosen { read(url) }
51 }
52 }
53
54 private var header: some View {
55 HStack {
56 VStack(alignment: .leading, spacing: 2) {
57 Text("Import from Emacs").font(.headline)
58 Text(location.map { "Reading \($0.path)" + ((result?.isDoom ?? false) ? ", with Doom's own org defaults" : "") } ?? "")
59 .font(.caption).foregroundStyle(.secondary).lineLimit(2)
60 }
61 Spacer()
62 Button("Choose…") { choosing = true }
63 }
64 .padding(12)
65 }
66
67 private func list(_ result: EmacsImport) -> some View {
68 let groups: [(String, [EmacsImport.Item])] = [
69 ("Settings", result.items.filter { if case .setting = $0.change { true } else { false } }),
70 ("Folders", result.items.filter { if case .folder = $0.change { true } else { false } }),
71 ("Capture templates", result.items.filter { if case .capture = $0.change { true } else { false } }),
72 ("Key bindings", result.items.filter { if case .binding = $0.change { true } else { false } }),
73 ]
74 return List {
75 ForEach(groups.filter { !$0.1.isEmpty }, id: \.0) { title, items in
76 Section(title) {
77 ForEach(items) { item in
78 Toggle(isOn: Binding { selected.contains(item.id) } set: { on in
79 if on { selected.insert(item.id) } else { selected.remove(item.id) }
80 }) {
81 VStack(alignment: .leading, spacing: 1) {
82 Text(item.summary).font(.system(.body, design: .monospaced))
83 Text([item.name == item.summary ? nil : item.name, item.source.isEmpty ? nil : item.source].compactMap { $0 }.joined(separator: " · "))
84 .font(.caption).foregroundStyle(.secondary)
85 }
86 }
87 }
88 }
89 }
90 let notes = result.unsupported.map { ($0.id, $0.name, $0.reason, $0.source) } + result.unreadable.map { ($0.id, $0.name, $0.reason, $0.source) }
91 if !notes.isEmpty || !result.problems.isEmpty {
92 Section("Not imported") {
93 ForEach(result.problems, id: \.self) { Text($0).foregroundStyle(.red) }
94 ForEach(notes, id: \.0) { note in
95 VStack(alignment: .leading, spacing: 1) {
96 Text(note.1).font(.system(.body, design: .monospaced))
97 Text("\(note.2) · \(note.3)").font(.caption).foregroundStyle(.secondary)
98 }
99 }
100 }
101 }
102 }
103 }
104
105 private func footnote(_ result: EmacsImport) -> String {
106 var parts = ["\(result.skipped) variables not about org left alone"]
107 if result.doomDefaultsLeftOut > 0 { parts.append("\(result.doomDefaultsLeftOut) Doom defaults Orgstar has no use for") }
108 return parts.joined(separator: "; ")
109 }
110
111 private func read(_ url: URL) {
112 let accessing = url.startAccessingSecurityScopedResource()
113 defer { if accessing { url.stopAccessingSecurityScopedResource() } }
114 location = url
115 let scanned = EmacsImport.scan(EmacsImport.files(in: url))
116 result = scanned
117 selected = Set(scanned.items.map(\.id))
118 summary = nil
119 }
120
121 private func runImport() {
122 guard let result else { return }
123 importing = true
124 let chosen = result.items.filter { selected.contains($0.id) }
125 Task {
126 summary = await EmacsImport.apply(chosen, workspace: workspace)
127 importing = false
128 }
129 }
130}
Sources/Orgstar/OrgstarApp.swift +8 −2
@@ -252,7 +252,7 @@ struct OrgstarApp: App {
252252 }
253253 }
254254 Settings {
255 SettingsView(commands: commands)
255 SettingsView(commands: commands, workspace: try? workspace.get())
256256 }
257257 }
258258}
@@ -322,6 +322,8 @@ struct AgendaMenuItem: View {
322322
323323struct SettingsView: View {
324324 let commands: AppCommands
325 let workspace: WorkspaceModel?
326 @State private var importing = false
325327 @Environment(\.openWindow) private var openWindow
326328 @AppStorage("saveMode") private var saveMode = SaveMode.automatic
327329 @AppStorage("keymap") private var preset = KeymapPreset.emacs
@@ -368,6 +370,9 @@ struct SettingsView: View {
368370 Button("Show in Finder") { NSWorkspace.shared.activateFileViewerSelecting([UserConfig.configFile]) }
369371 }
370372 }
373 LabeledContent("Emacs") {
374 Button("Import from Emacs…") { importing = true }
375 }
371376 } footer: {
372377 Text("Every setting here is also in \(UserConfig.configFile.path), which you can edit, share or keep with your dotfiles. Changes in either place show up in the other.")
373378 .settingsFooter()
@@ -395,7 +400,8 @@ struct SettingsView: View {
395400 }
396401 }
397402 .formStyle(.grouped)
398 .frame(height: 250)
403 .frame(height: 330)
404 .sheet(isPresented: $importing) { EmacsImportView(workspace: workspace) }
399405 }
400406
401407 private var editing: some View {
Tests/OrgAppTests/EmacsImportTests.swift added +136
@@ -0,0 +1,136 @@
1import Foundation
2import OrgCore
3import Testing
4@testable import OrgApp
5
6struct EmacsImportTests {
7 static let config = #"""
8 (setq user-full-name "Someone")
9 (setq doom-theme 'doom-homage-black)
10 (setq display-line-numbers-type t)
11 (setq org-support-shift-select t)
12 (setq org-directory "~/notes/")
13 (setq org-roam-directory (file-name-concat org-directory "roam"))
14 (after! org
15 (setq org-hide-emphasis-markers t
16 org-pretty-entities t
17 org-agenda-files '("~/notes/" "~/work/plan.org")
18 org-tags-column -80))
19 (setq org-tags-column 4)
20 (setq fill-column (+ 70 2))
21 (setq org-capture-templates
22 '(("t" "Todo" entry (file+headline "~/notes/todo.org" "Inbox") "* TODO %?\n%a")
23 ("j" "Journal" entry (file+olp+datetree "~/notes/journal.org") "* %?")
24 ("w" "Work")
25 ("wn" "Work note" plain (file "~/work/notes.org") "%?" :prepend t)))
26 (map! :leader :desc "Report" "z t" #'tr/report
27 :localleader "x" #'org-toggle-checkbox)
28 (map! :n "g s" #'save-buffer)
29 (global-set-key (kbd "C-c a") #'org-agenda)
30 (with-eval-after-load 'org (define-key org-mode-map (kbd "C-c l") (lambda () (interactive))))
31 """#
32
33 static let doomOrg = #"""
34 (defun +org-init-appearance-h ()
35 (setq org-tags-column 0
36 org-agenda-span 10
37 org-agenda-start-day "-3d"
38 org-todo-keywords '((sequence "TODO(t)" "WAIT(w)" "|" "DONE(d)") (sequence "[ ](T)" "|" "[X](D)"))))
39 (after! org (setq org-M-RET-may-split-line nil org-list-allow-alphabetical t))
40 (map! :map org-mode-map "C-c q" #'org-set-tags-command)
41 """#
42
43 func scan() throws -> EmacsImport {
44 let folder = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-import-\(UUID().uuidString)")
45 try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
46 defer { try? FileManager.default.removeItem(at: folder) }
47 try "(doom! :editor (evil +everywhere) :lang (org +pretty))".write(to: folder.appendingPathComponent("init.el"), atomically: true, encoding: .utf8)
48 try Self.config.write(to: folder.appendingPathComponent("config.el"), atomically: true, encoding: .utf8)
49 let doom = folder.appendingPathComponent("doom-org.el")
50 try Self.doomOrg.write(to: doom, atomically: true, encoding: .utf8)
51 return EmacsImport.scan(EmacsImport.files(in: folder), doomDefaults: [doom])
52 }
53
54 func settings(_ result: EmacsImport) -> [String: TOML.Value] {
55 var out: [String: TOML.Value] = [:]
56 for item in result.items { if case .setting(let key, let value) = item.change { out[key] = value } }
57 return out
58 }
59
60 @Test func readsSettingsWithDoomDefaultsUnderneath() throws {
61 let result = try scan()
62 #expect(result.isDoom)
63 let s = settings(result)
64 #expect(s["keymap"] == .string("doom"))
65 #expect(s["display-line-numbers-type"] == .bool(true))
66 #expect(s["org-hide-emphasis-markers"] == .bool(true))
67 #expect(s["org-pretty-entities"] == .bool(true))
68 // after! org runs after the top-level setq.
69 #expect(s["org-tags-column"] == .integer(-80))
70 #expect(s["org-agenda-span"] == .integer(10))
71 #expect(s["org-agenda-start-day"] == .string("-3d"))
72 #expect(s["org-M-RET-may-split-line"] == .bool(false))
73 #expect(s["org-todo-keywords"] == .string("TODO(t) WAIT(w) | DONE(d)"))
74 #expect(s["fill-column"] == nil)
75 let folders = result.items.compactMap { if case .folder(let f) = $0.change { f } else { nil } }
76 #expect(folders == ["~/notes/", "~/work"])
77 #expect(result.unreadable.map(\.name) == ["fill-column"])
78 #expect(Set(result.unsupported.map(\.name)) == ["org-support-shift-select", "doom-theme", "org-roam-directory",
79 "capture \"j\"", "SPC z t → tr/report", "C-c l"])
80 #expect(result.skipped == 1)
81 #expect(result.doomDefaultsLeftOut == 1)
82 }
83
84 @Test func readsCaptureTemplatesAndBindings() throws {
85 let result = try scan()
86 let captures = result.items.compactMap { if case .capture(let key, let toml) = $0.change { (key, toml) } else { nil } }
87 #expect(captures.map(\.0) == ["t", "wn"])
88 #expect(captures[0].1 == "[[template]]\nkey = \"t\"\nname = \"Todo\"\ntype = \"entry\"\nfile = \"~/notes/todo.org\"\nheadline = \"Inbox\"\ntemplate = \"* TODO %?\\n%a\"\n")
89 #expect(captures[1].1.contains("prepend = true\n") && captures[1].1.contains("type = \"plain\""))
90 #expect(result.unsupported.contains { $0.name == "capture \"j\"" })
91
92 let bindings = result.items.compactMap { if case .binding(let keys, let command, let mode) = $0.change { "\(keys) \(command) \(mode ?? "-")" } else { nil } }
93 #expect(bindings == ["SPC m x org.checkbox.toggle normal", "g s app.save normal", "C-c a app.agenda normal"])
94 #expect(result.unsupported.contains { $0.name == "SPC z t → tr/report" })
95 #expect(result.unsupported.contains { $0.name == "C-c l" && $0.reason == "bound to code, not a command" })
96 }
97
98 /// The user's own Doom configuration, when there is one.
99 @Test(.enabled(if: FileManager.default.fileExists(atPath: NSHomeDirectory() + "/.config/doom/config.el")))
100 func readsTheInstalledDoomConfig() {
101 let result = EmacsImport.scan(EmacsImport.files(in: URL(fileURLWithPath: NSHomeDirectory() + "/.config/doom")))
102 #expect(result.problems.isEmpty, "\(result.problems)")
103 let s = settings(result)
104 #expect(s["keymap"] == .string("doom"))
105 #expect(s["org-agenda-start-day"] == .string("-3d"))
106 #expect(s["org-tags-column"] == .integer(0))
107 let report = result.items.map { "\($0.name) | \($0.summary) | \($0.source)" }
108 + result.unsupported.map { "NO \($0.name): \($0.reason) | \($0.source)" }
109 + result.unreadable.map { "?? \($0.name): \($0.reason) | \($0.source)" }
110 print(report.joined(separator: "\n"))
111 }
112
113 @MainActor
114 @Test func appliesToDefaultsAndFiles() async throws {
115 let result = try scan()
116 let folder = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-import-out-\(UUID().uuidString)")
117 defer { try? FileManager.default.removeItem(at: folder) }
118 try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
119 try "[[template]]\nkey = \"t\"\nfile = \"x.org\"\ntemplate = \"* %?\"\n".write(to: folder.appendingPathComponent("capture.toml"), atomically: true, encoding: .utf8)
120 let name = "orgstar-import-\(UUID().uuidString)"
121 let defaults = UserDefaults(suiteName: name)!
122 defer { defaults.removePersistentDomain(forName: name) }
123 let summary = await EmacsImport.apply(result.items, defaults: defaults, workspace: nil, directory: folder)
124 #expect(defaults.string(forKey: "keymap") == "doom")
125 #expect(defaults.integer(forKey: "tagsColumn") == -80)
126 #expect(defaults.integer(forKey: "agendaStartOffset") == -3)
127 #expect(defaults.string(forKey: "todoKeywords") == "TODO(t) WAIT(w) | DONE(d)")
128 let capture = try String(contentsOf: folder.appendingPathComponent("capture.toml"), encoding: .utf8)
129 #expect(capture.components(separatedBy: "[[template]]").count == 3)
130 #expect(CaptureTemplates.load(userFile: folder.appendingPathComponent("capture.toml")).problems.isEmpty)
131 let keymap = folder.appendingPathComponent("keymap.toml")
132 #expect(KeymapLoader.load(.doom, userFile: keymap).problems.isEmpty)
133 #expect(KeymapLoader.load(.doom, userFile: keymap).keymap.keyLabels(for: "app.agenda", modes: ["normal"]).contains("C-c a"))
134 #expect(summary.hasPrefix("Imported 10 settings, 1 capture template, 3 key bindings."), "\(summary)")
135 }
136}
Tests/OrgCoreTests/LispReaderTests.swift added +40
@@ -0,0 +1,40 @@
1import Foundation
2import Testing
3@testable import OrgCore
4
5struct LispReaderTests {
6 @Test func readsForms() throws {
7 let text = #"""
8 ;; comment
9 (setq org-tags-column -77 ; inline
10 fill-column 80)
11 (after! org (setq org-agenda-start-day "-3d\n" x 1.5 c ?a d ?\C-x))
12 (map! :leader :desc "Save" "f s" #'save-buffer)
13 '(a . b) `(x ,y ,@z) [1 2] nil
14 """#
15 let forms = try LispReader.read(text)
16 #expect(forms.map(\.line) == [2, 4, 5, 6, 6, 6, 6])
17 #expect(forms[0].sexp == .list([.symbol("setq"), .symbol("org-tags-column"), .integer(-77), .symbol("fill-column"), .integer(80)]))
18 #expect(forms[1].sexp.description == "(after! org (setq org-agenda-start-day \"-3d\\n\" x 1.5 c ?a d ?x))")
19 #expect(forms[2].sexp.elements?.last == .list([.symbol("function"), .symbol("save-buffer")]))
20 #expect(forms[3].sexp == .list([.symbol("quote"), .dotted([.symbol("a")], .symbol("b"))]))
21 #expect(forms[4].sexp.description == "`(x ,y ,@z)")
22 #expect(forms[5].sexp == .vector([.integer(1), .integer(2)]))
23 #expect(throws: LispReader.ReadError.self) { try LispReader.read("(setq a") }
24 }
25
26 /// Every Lisp file of the installed Doom Emacs and the user's Doom config reads.
27 @Test(.enabled(if: FileManager.default.fileExists(atPath: NSHomeDirectory() + "/.config/emacs/lisp")))
28 func readsDoom() throws {
29 var failures: [String] = []
30 for folder in ["/.config/emacs/lisp", "/.config/emacs/modules/lang/org", "/.config/doom"] {
31 guard let files = FileManager.default.enumerator(atPath: NSHomeDirectory() + folder) else { continue }
32 for case let file as String in files where file.hasSuffix(".el") {
33 let path = NSHomeDirectory() + folder + "/" + file
34 guard let text = try? String(contentsOfFile: path, encoding: .utf8) else { continue }
35 do { _ = try LispReader.read(text) } catch { failures.append("\(path): \(error)") }
36 }
37 }
38 #expect(failures.isEmpty, "\(failures)")
39 }
40}