krz/orgstar

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

Commit b5449dec59

b5449dec59a00fac6867954166c6c612ffd6aee4

parent: f00fcfad5a

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-05 19:35 UTC

Import from Emacs

Reads an Emacs configuration without running it (LispReader): setq,
setq-default, setopt and custom-set-variables, inside after!,
with-eval-after-load, use-package and conditionals, with literal values
or ones built with quote, list, concat and file-name helpers. For Doom,
Doom's org module and core defaults are read first. Known variables
become config.toml settings, org-directory and org-agenda-files
folders, org-capture-templates capture.toml templates, and map!,
define-key and global-set-key bindings of known commands keymap.toml
bindings. A review sheet (Settings ▸ General, or the palette) lists
each change to keep or drop, and what wasn't imported and why.
ORGSTAR_COMMAND runs a command at launch for snapshots.

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 {
201 switch value { 201 switch value {
202 case .bool(let b): b ? "true" : "false" 202 case .bool(let b): b ? "true" : "false"
203 case .integer(let i): String(i) 203 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") + "\""
205 } 207 }
206 } 208 }
207 209
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 {
43 EditorCommand(id: "app.toggle-outline", title: "Show or Hide Outline", action: .app), 43 EditorCommand(id: "app.toggle-outline", title: "Show or Hide Outline", action: .app),
44 EditorCommand(id: "app.toggle-markup", title: "Show or Hide Markup", action: .app), 44 EditorCommand(id: "app.toggle-markup", title: "Show or Hide Markup", action: .app),
45 EditorCommand(id: "app.edit-config", title: "Edit Config File", action: .app), 45 EditorCommand(id: "app.edit-config", title: "Edit Config File", action: .app),
46 EditorCommand(id: "app.import-emacs", title: "Import from Emacs…", action: .app),
46 EditorCommand(id: "app.toggle-line-numbers", title: "Show or Hide Line Numbers", action: .app), 47 EditorCommand(id: "app.toggle-line-numbers", title: "Show or Hide Line Numbers", action: .app),
47 EditorCommand(id: "app.quick-open", title: "Quick Open…", action: .app), 48 EditorCommand(id: "app.quick-open", title: "Quick Open…", action: .app),
48 EditorCommand(id: "app.palette", title: "Command Palette…", action: .app), 49 EditorCommand(id: "app.palette", title: "Command Palette…", action: .app),
Sources/Orgstar/ContentView.swift +4
@@ -47,6 +47,7 @@ struct ContentView: View {
47 @AppStorage("showLineNumbers") private var showLineNumbers = true 47 @AppStorage("showLineNumbers") private var showLineNumbers = true
48 /// The outline for a file that isn't org, off until asked for. 48 /// The outline for a file that isn't org, off until asked for.
49 @State private var plainOutlineShown = false 49 @State private var plainOutlineShown = false
50 @State private var showingImport = false
50 @State private var query = "" 51 @State private var query = ""
51 @FocusState private var searchFocused: Bool 52 @FocusState private var searchFocused: Bool
52 @Environment(\.openWindow) private var openWindow 53 @Environment(\.openWindow) private var openWindow
@@ -162,6 +163,7 @@ struct ContentView: View {
162 .onChange(of: saveMode, initial: true) { _, mode in session.saveMode = mode } 163 .onChange(of: saveMode, initial: true) { _, mode in session.saveMode = mode }
163 .onChange(of: options, initial: true) { _, options in session.options = options } 164 .onChange(of: options, initial: true) { _, options in session.options = options }
164 .onChange(of: preset, initial: true) { loadKeymap() } 165 .onChange(of: preset, initial: true) { loadKeymap() }
166 .sheet(isPresented: $showingImport) { EmacsImportView(workspace: workspace) }
165 .sheet(isPresented: $commands.showingPalette) { 167 .sheet(isPresented: $commands.showingPalette) {
166 PaletteView(commands: commands) { id in 168 PaletteView(commands: commands) { id in
167 commands.showingPalette = false 169 commands.showingPalette = false
@@ -256,6 +258,7 @@ struct ContentView: View {
256 DevelopmentHooks.scheduleSnapshot() 258 DevelopmentHooks.scheduleSnapshot()
257 if let path = DevelopmentHooks.fileToOpen { selectedFile = path } 259 if let path = DevelopmentHooks.fileToOpen { selectedFile = path }
258 if let root = DevelopmentHooks.rootToAdd { try? await workspace.addRoot(URL(fileURLWithPath: root)) } 260 if let root = DevelopmentHooks.rootToAdd { try? await workspace.addRoot(URL(fileURLWithPath: root)) }
261 if let command = DevelopmentHooks.commandToRun { perform(command) }
259 if let id = DevelopmentHooks.windowToOpen { 262 if let id = DevelopmentHooks.windowToOpen {
260 if id == "settings" { openSettings() } else { openWindow(id: id) } 263 if id == "settings" { openSettings() } else { openWindow(id: id) }
261 } 264 }
@@ -394,6 +397,7 @@ struct ContentView: View {
394 case "app.toggle-outline": toggleOutline() 397 case "app.toggle-outline": toggleOutline()
395 case "app.toggle-markup": showMarkup.toggle() 398 case "app.toggle-markup": showMarkup.toggle()
396 case "app.toggle-line-numbers": showLineNumbers.toggle() 399 case "app.toggle-line-numbers": showLineNumbers.toggle()
400 case "app.import-emacs": showingImport = true
397 case "app.edit-config": commands.jump(path: UserConfig.configFile.path, offset: 0) 401 case "app.edit-config": commands.jump(path: UserConfig.configFile.path, offset: 0)
398 case "app.quick-open": commands.showingQuickOpen = true 402 case "app.quick-open": commands.showingQuickOpen = true
399 case "app.palette": commands.showingPalette = true 403 case "app.palette": commands.showingPalette = true
Sources/Orgstar/DevelopmentHooks.swift +6 −1
@@ -5,6 +5,8 @@ import AppKit
5/// - `ORGSTAR_OPEN`: a file to select at launch. 5/// - `ORGSTAR_OPEN`: a file to select at launch.
6/// - `ORGSTAR_ROOT`: a folder to add at launch. 6/// - `ORGSTAR_ROOT`: a folder to add at launch.
7/// - `ORGSTAR_WINDOW`: a window to open at launch (`agenda`, `settings`); the snapshot renders it. 7/// - `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.
8/// - `ORGSTAR_SNAPSHOT`: after `ORGSTAR_SNAPSHOT_DELAY` seconds (default 3), render the main 10/// - `ORGSTAR_SNAPSHOT`: after `ORGSTAR_SNAPSHOT_DELAY` seconds (default 3), render the main
9/// window to this PNG path and quit. Rendering from inside the app needs no screen-recording 11/// window to this PNG path and quit. Rendering from inside the app needs no screen-recording
10/// permission. 12/// permission.
@@ -12,6 +14,7 @@ enum DevelopmentHooks {
12 static var fileToOpen: String? { ProcessInfo.processInfo.environment["ORGSTAR_OPEN"] } 14 static var fileToOpen: String? { ProcessInfo.processInfo.environment["ORGSTAR_OPEN"] }
13 static var rootToAdd: String? { ProcessInfo.processInfo.environment["ORGSTAR_ROOT"] } 15 static var rootToAdd: String? { ProcessInfo.processInfo.environment["ORGSTAR_ROOT"] }
14 static var windowToOpen: String? { ProcessInfo.processInfo.environment["ORGSTAR_WINDOW"] } 16 static var windowToOpen: String? { ProcessInfo.processInfo.environment["ORGSTAR_WINDOW"] }
17 static var commandToRun: String? { ProcessInfo.processInfo.environment["ORGSTAR_COMMAND"] }
15 18
16 @MainActor 19 @MainActor
17 static func scheduleSnapshot() { 20 static func scheduleSnapshot() {
@@ -20,12 +23,14 @@ enum DevelopmentHooks {
20 let delay = Double(environment["ORGSTAR_SNAPSHOT_DELAY"] ?? "") ?? 3 23 let delay = Double(environment["ORGSTAR_SNAPSHOT_DELAY"] ?? "") ?? 3
21 DispatchQueue.main.asyncAfter(deadline: .now() + delay) { 24 DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
22 let visible = NSApp.windows.filter(\.isVisible) 25 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
24 if let view = window?.contentView?.superview, 28 if let view = window?.contentView?.superview,
25 let bitmap = view.bitmapImageRepForCachingDisplay(in: view.bounds) { 29 let bitmap = view.bitmapImageRepForCachingDisplay(in: view.bounds) {
26 view.cacheDisplay(in: view.bounds, to: bitmap) 30 view.cacheDisplay(in: view.bounds, to: bitmap)
27 try? bitmap.representation(using: .png, properties: [:])?.write(to: URL(fileURLWithPath: path)) 31 try? bitmap.representation(using: .png, properties: [:])?.write(to: URL(fileURLWithPath: path))
28 } 32 }
33 if let chosen, let sheet = chosen.attachedSheet { chosen.endSheet(sheet) }
29 NSApp.terminate(nil) 34 NSApp.terminate(nil)
30 } 35 }
31 } 36 }
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 {
252 } 252 }
253 } 253 }
254 Settings { 254 Settings {
255 SettingsView(commands: commands) 255 SettingsView(commands: commands, workspace: try? workspace.get())
256 } 256 }
257 } 257 }
258} 258}
@@ -322,6 +322,8 @@ struct AgendaMenuItem: View {
322 322
323struct SettingsView: View { 323struct SettingsView: View {
324 let commands: AppCommands 324 let commands: AppCommands
325 let workspace: WorkspaceModel?
326 @State private var importing = false
325 @Environment(\.openWindow) private var openWindow 327 @Environment(\.openWindow) private var openWindow
326 @AppStorage("saveMode") private var saveMode = SaveMode.automatic 328 @AppStorage("saveMode") private var saveMode = SaveMode.automatic
327 @AppStorage("keymap") private var preset = KeymapPreset.emacs 329 @AppStorage("keymap") private var preset = KeymapPreset.emacs
@@ -368,6 +370,9 @@ struct SettingsView: View {
368 Button("Show in Finder") { NSWorkspace.shared.activateFileViewerSelecting([UserConfig.configFile]) } 370 Button("Show in Finder") { NSWorkspace.shared.activateFileViewerSelecting([UserConfig.configFile]) }
369 } 371 }
370 } 372 }
373 LabeledContent("Emacs") {
374 Button("Import from Emacs…") { importing = true }
375 }
371 } footer: { 376 } footer: {
372 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.") 377 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.")
373 .settingsFooter() 378 .settingsFooter()
@@ -395,7 +400,8 @@ struct SettingsView: View {
395 } 400 }
396 } 401 }
397 .formStyle(.grouped) 402 .formStyle(.grouped)
398 .frame(height: 250) 403 .frame(height: 330)
404 .sheet(isPresented: $importing) { EmacsImportView(workspace: workspace) }
399 } 405 }
400 406
401 private var editing: some View { 407 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}