| @@ -0,0 +1,592 @@ |
| 1 | import Foundation |
| 2 | import 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. |
| 8 | public 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 | |
| 529 | extension 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 | } |