Commit 944abf5b59
Verified · cmc
Layout: unified · split
Sources/OrgApp/EmacsImport.swift +35 −7
| @@ -166,6 +166,9 @@ public struct EmacsImport: Sendable { | |||
| 166 | if let name = args[i].symbol { assign(name, args[i + 1], source, priority: priority, deferred: deferred) } | 166 | if let name = args[i].symbol { assign(name, args[i + 1], source, priority: priority, deferred: deferred) } |
| 167 | i += 2 | 167 | i += 2 |
| 168 | } | 168 | } |
| 169 | case "defvar", "defcustom": | ||
| 170 | // Only sets a variable nothing set, so under every assignment. | ||
| 171 | if args.count >= 2, let name = args[0].symbol { assign(name, args[1], source, priority: -1, deferred: false) } | ||
| 169 | case "custom-set-variables": | 172 | case "custom-set-variables": |
| 170 | for arg in args { | 173 | for arg in args { |
| 171 | guard let entry = unquote(arg)?.elements, entry.count >= 2, let name = entry[0].symbol else { continue } | 174 | guard let entry = unquote(arg)?.elements, entry.count >= 2, let name = entry[0].symbol else { continue } |
| @@ -336,8 +339,8 @@ public struct EmacsImport: Sendable { | |||
| 336 | switch head { | 339 | switch head { |
| 337 | case "quote", "function": return args.first | 340 | case "quote", "function": return args.first |
| 338 | case "\\`": | 341 | case "\\`": |
| 339 | guard let inner = args.first, !inner.description.contains(",") else { return nil } | 342 | guard let inner = args.first else { return nil } |
| 340 | return inner | 343 | return backquote(inner, env) |
| 341 | case "list": | 344 | case "list": |
| 342 | let values = args.compactMap { evaluate($0, env) } | 345 | let values = args.compactMap { evaluate($0, env) } |
| 343 | return values.count == args.count ? .list(values) : nil | 346 | return values.count == args.count ? .list(values) : nil |
| @@ -362,6 +365,30 @@ public struct EmacsImport: Sendable { | |||
| 362 | } | 365 | } |
| 363 | } | 366 | } |
| 364 | 367 | ||
| 368 | /// A backquoted form with its `,` and `,@` parts evaluated. | ||
| 369 | static func backquote(_ sexp: Sexp, _ env: [String: Sexp]) -> Sexp? { | ||
| 370 | guard case .list(let items) = sexp else { | ||
| 371 | if case .dotted(let items, let last) = sexp { | ||
| 372 | let values = items.compactMap { backquote($0, env) } | ||
| 373 | guard values.count == items.count, let tail = backquote(last, env) else { return nil } | ||
| 374 | return .dotted(values, tail) | ||
| 375 | } | ||
| 376 | return sexp | ||
| 377 | } | ||
| 378 | if items.count == 2, items[0].symbol == "," { return evaluate(items[1], env) } | ||
| 379 | var out: [Sexp] = [] | ||
| 380 | for item in items { | ||
| 381 | if case .list(let parts) = item, parts.count == 2, parts[0].symbol == ",@" { | ||
| 382 | guard let spliced = evaluate(parts[1], env)?.elements else { return nil } | ||
| 383 | out += spliced | ||
| 384 | } else { | ||
| 385 | guard let value = backquote(item, env) else { return nil } | ||
| 386 | out.append(value) | ||
| 387 | } | ||
| 388 | } | ||
| 389 | return .list(out) | ||
| 390 | } | ||
| 391 | |||
| 365 | // MARK: - Results | 392 | // MARK: - Results |
| 366 | 393 | ||
| 367 | static let orgRelated = ["org-", "appt-", "display-line-numbers", "fill-column", "evil-", "doom-font", "doom-variable-pitch-font", "doom-theme", "calendar-week-start-day"] | 394 | static let orgRelated = ["org-", "appt-", "display-line-numbers", "fill-column", "evil-", "doom-font", "doom-variable-pitch-font", "doom-theme", "calendar-week-start-day"] |
| @@ -403,7 +430,7 @@ public struct EmacsImport: Sendable { | |||
| 403 | continue | 430 | continue |
| 404 | } | 431 | } |
| 405 | let before = unsupported.count | 432 | let before = unsupported.count |
| 406 | convert(name, value, source: assignment.source, id: id) | 433 | convert(name, value, source: assignment.source, env: env, id: id) |
| 407 | if assignment.rank < 2, unsupported.count > before { | 434 | if assignment.rank < 2, unsupported.count > before { |
| 408 | doomDefaultsLeftOut += unsupported.count - before | 435 | doomDefaultsLeftOut += unsupported.count - before |
| 409 | unsupported.removeLast(unsupported.count - before) | 436 | unsupported.removeLast(unsupported.count - before) |
| @@ -437,7 +464,7 @@ public struct EmacsImport: Sendable { | |||
| 437 | } | 464 | } |
| 438 | } | 465 | } |
| 439 | 466 | ||
| 440 | mutating func convert(_ name: String, _ value: Sexp, source: String, id: () -> Int) { | 467 | mutating func convert(_ name: String, _ value: Sexp, source: String, env: [String: Sexp] = [:], id: () -> Int) { |
| 441 | func add(_ key: String, _ toml: TOML.Value, _ summary: String? = nil) { | 468 | func add(_ key: String, _ toml: TOML.Value, _ summary: String? = nil) { |
| 442 | items.append(Item(id: id(), name: name, summary: summary ?? "\(key) = \(UserConfig.format(toml))", source: source, change: .setting(key: key, value: toml))) | 469 | items.append(Item(id: id(), name: name, summary: summary ?? "\(key) = \(UserConfig.format(toml))", source: source, change: .setting(key: key, value: toml))) |
| 443 | } | 470 | } |
| @@ -507,7 +534,7 @@ public struct EmacsImport: Sendable { | |||
| 507 | items.append(Item(id: id(), name: name, summary: "add folder \(folder)", source: source, change: .folder(folder))) | 534 | items.append(Item(id: id(), name: name, summary: "add folder \(folder)", source: source, change: .folder(folder))) |
| 508 | } | 535 | } |
| 509 | case "org-capture-templates": | 536 | case "org-capture-templates": |
| 510 | for entry in value.elements ?? [] { captureTemplate(entry, source: source, id: id) } | 537 | for entry in value.elements ?? [] { captureTemplate(entry, source: source, env: env, id: id) } |
| 511 | case "doom-font": | 538 | case "doom-font": |
| 512 | note("not a font-spec Orgstar can read") | 539 | note("not a font-spec Orgstar can read") |
| 513 | case "doom-variable-pitch-font": | 540 | case "doom-variable-pitch-font": |
| @@ -519,7 +546,7 @@ public struct EmacsImport: Sendable { | |||
| 519 | } | 546 | } |
| 520 | } | 547 | } |
| 521 | 548 | ||
| 522 | mutating func captureTemplate(_ entry: Sexp, source: String, id: () -> Int) { | 549 | mutating func captureTemplate(_ entry: Sexp, source: String, env: [String: Sexp] = [:], id: () -> Int) { |
| 523 | guard let parts = entry.elements, parts.count >= 2, let key = parts[0].string else { return } | 550 | guard let parts = entry.elements, parts.count >= 2, let key = parts[0].string else { return } |
| 524 | let name = parts[1].string ?? key | 551 | let name = parts[1].string ?? key |
| 525 | guard parts.count >= 5, let type = parts[2].symbol, let target = parts[3].elements, let kind = target.first?.symbol, | 552 | guard parts.count >= 5, let type = parts[2].symbol, let target = parts[3].elements, let kind = target.first?.symbol, |
| @@ -535,7 +562,8 @@ public struct EmacsImport: Sendable { | |||
| 535 | } | 562 | } |
| 536 | func quoted(_ s: String) -> String { UserConfig.format(.string(s)) } | 563 | func quoted(_ s: String) -> String { UserConfig.format(.string(s)) } |
| 537 | var toml = "[[template]]\nkey = \(quoted(key))\nname = \(quoted(name))\ntype = \(quoted(type))\n" | 564 | var toml = "[[template]]\nkey = \(quoted(key))\nname = \(quoted(name))\ntype = \(quoted(type))\n" |
| 538 | let strings = target.dropFirst().compactMap(\.string) | 565 | // `org-capture-expand-file`: a variable's value, or a form evaluated. |
| 566 | let strings = target.dropFirst().compactMap { $0.string ?? Self.evaluate($0, env)?.string } | ||
| 539 | // Property list after the template: `:prepend t :empty-lines 1 …`. | 567 | // Property list after the template: `:prepend t :empty-lines 1 …`. |
| 540 | var properties: [String: Sexp] = [:] | 568 | var properties: [String: Sexp] = [:] |
| 541 | let rest = Array(parts.dropFirst(5)) | 569 | let rest = Array(parts.dropFirst(5)) |
Tests/OrgAppTests/EmacsImportTests.swift +23
| @@ -108,6 +108,29 @@ struct EmacsImportTests { | |||
| 108 | #expect(result.unsupported.contains { $0.name == "C-c l" && $0.reason == "bound to code, not a command" }) | 108 | #expect(result.unsupported.contains { $0.name == "C-c l" && $0.reason == "bound to code, not a command" }) |
| 109 | } | 109 | } |
| 110 | 110 | ||
| 111 | @Test func readsCaptureTargetsFromVariablesAndForms() throws { | ||
| 112 | let folder = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-import-\(UUID().uuidString)") | ||
| 113 | try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) | ||
| 114 | defer { try? FileManager.default.removeItem(at: folder) } | ||
| 115 | try #""" | ||
| 116 | (defvar my-inbox "~/old.org") | ||
| 117 | (setq org-directory "~/org" | ||
| 118 | org-default-notes-file (expand-file-name "notes.org" org-directory) | ||
| 119 | my-inbox (concat org-directory "/inbox.org")) | ||
| 120 | (setq org-capture-templates | ||
| 121 | `(("n" "Note" entry (file+headline org-default-notes-file "Notes") "* %?") | ||
| 122 | ("i" "Inbox" entry (file my-inbox) "* TODO %?") | ||
| 123 | ("w" "Work" entry (file ,(concat org-directory "/work.org")) "* %?") | ||
| 124 | ("j" "Journal" entry (file+olp+datetree (concat org-directory "/journal.org")) "* %?"))) | ||
| 125 | """#.write(to: folder.appendingPathComponent("init.el"), atomically: true, encoding: .utf8) | ||
| 126 | let result = EmacsImport.scan(EmacsImport.files(in: folder)) | ||
| 127 | let files = result.items.compactMap { item -> String? in | ||
| 128 | guard case .capture(let key, let toml) = item.change else { return nil } | ||
| 129 | return key + " " + (toml.firstMatch(of: /file = "([^"]*)"/).map { String($0.1) } ?? "-") | ||
| 130 | } | ||
| 131 | #expect(files == ["n ~/org/notes.org", "i ~/org/inbox.org", "w ~/org/work.org", "j ~/org/journal.org"]) | ||
| 132 | } | ||
| 133 | |||
| 111 | /// The user's own Doom configuration, when there is one. | 134 | /// The user's own Doom configuration, when there is one. |
| 112 | @Test(.enabled(if: FileManager.default.fileExists(atPath: NSHomeDirectory() + "/.config/doom/config.el"))) | 135 | @Test(.enabled(if: FileManager.default.fileExists(atPath: NSHomeDirectory() + "/.config/doom/config.el"))) |
| 113 | func readsTheInstalledDoomConfig() { | 136 | func readsTheInstalledDoomConfig() { |