Import from Emacs !48
10 files changed, +1144 −4
Layout: unified · split
Sources/OrgApp/EmacsImport.swift added +592
| @@ -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 | } | ||
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 @@ | |||
| 1 | import Foundation | ||
| 2 | |||
| 3 | /// Emacs Lisp read without evaluating: for importing settings from an Emacs configuration. | ||
| 4 | public 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 | |||
| 60 | public 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 @@ | |||
| 1 | import OrgApp | ||
| 2 | import OrgCore | ||
| 3 | import SwiftUI | ||
| 4 | |||
| 5 | /// Import from Emacs: what was read from a configuration, to pick from before anything changes. | ||
| 6 | struct 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 | ||
| 323 | struct SettingsView: View { | 323 | struct 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 @@ | |||
| 1 | import Foundation | ||
| 2 | import OrgCore | ||
| 3 | import Testing | ||
| 4 | @testable import OrgApp | ||
| 5 | |||
| 6 | struct 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 @@ | |||
| 1 | import Foundation | ||
| 2 | import Testing | ||
| 3 | @testable import OrgCore | ||
| 4 | |||
| 5 | struct 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 | } | ||