krz/orgstar

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

Sources/OrgApp/EmacsImport.swift

191ddb506f3a91b93c755ba6e8123a4e8ac4f90d
orgstar/Sources/OrgApp/EmacsImport.swift history · blame · raw

730 lines · 42447 bytes

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