Read capture targets from variables and forms on import !132

merged merged by cmc on 2026-10-07 05:53 UTC · krz/orgstar:import-capture-targets into main

2 files changed, +58 −7

Layout: unified · split

Sources/OrgApp/EmacsImport.swift +35 −7
@@ -166,6 +166,9 @@ public struct EmacsImport: Sendable {
166166 if let name = args[i].symbol { assign(name, args[i + 1], source, priority: priority, deferred: deferred) }
167167 i += 2
168168 }
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) }
169172 case "custom-set-variables":
170173 for arg in args {
171174 guard let entry = unquote(arg)?.elements, entry.count >= 2, let name = entry[0].symbol else { continue }
@@ -336,8 +339,8 @@ public struct EmacsImport: Sendable {
336339 switch head {
337340 case "quote", "function": return args.first
338341 case "\\`":
339 guard let inner = args.first, !inner.description.contains(",") else { return nil }
340 return inner
342 guard let inner = args.first else { return nil }
343 return backquote(inner, env)
341344 case "list":
342345 let values = args.compactMap { evaluate($0, env) }
343346 return values.count == args.count ? .list(values) : nil
@@ -362,6 +365,30 @@ public struct EmacsImport: Sendable {
362365 }
363366 }
364367
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
365392 // MARK: - Results
366393
367394 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 {
403430 continue
404431 }
405432 let before = unsupported.count
406 convert(name, value, source: assignment.source, id: id)
433 convert(name, value, source: assignment.source, env: env, id: id)
407434 if assignment.rank < 2, unsupported.count > before {
408435 doomDefaultsLeftOut += unsupported.count - before
409436 unsupported.removeLast(unsupported.count - before)
@@ -437,7 +464,7 @@ public struct EmacsImport: Sendable {
437464 }
438465 }
439466
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) {
441468 func add(_ key: String, _ toml: TOML.Value, _ summary: String? = nil) {
442469 items.append(Item(id: id(), name: name, summary: summary ?? "\(key) = \(UserConfig.format(toml))", source: source, change: .setting(key: key, value: toml)))
443470 }
@@ -507,7 +534,7 @@ public struct EmacsImport: Sendable {
507534 items.append(Item(id: id(), name: name, summary: "add folder \(folder)", source: source, change: .folder(folder)))
508535 }
509536 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) }
511538 case "doom-font":
512539 note("not a font-spec Orgstar can read")
513540 case "doom-variable-pitch-font":
@@ -519,7 +546,7 @@ public struct EmacsImport: Sendable {
519546 }
520547 }
521548
522 mutating func captureTemplate(_ entry: Sexp, source: String, id: () -> Int) {
549 mutating func captureTemplate(_ entry: Sexp, source: String, env: [String: Sexp] = [:], id: () -> Int) {
523550 guard let parts = entry.elements, parts.count >= 2, let key = parts[0].string else { return }
524551 let name = parts[1].string ?? key
525552 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 {
535562 }
536563 func quoted(_ s: String) -> String { UserConfig.format(.string(s)) }
537564 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 }
539567 // Property list after the template: `:prepend t :empty-lines 1 …`.
540568 var properties: [String: Sexp] = [:]
541569 let rest = Array(parts.dropFirst(5))
Tests/OrgAppTests/EmacsImportTests.swift +23
@@ -108,6 +108,29 @@ struct EmacsImportTests {
108108 #expect(result.unsupported.contains { $0.name == "C-c l" && $0.reason == "bound to code, not a command" })
109109 }
110110
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
111134 /// The user's own Doom configuration, when there is one.
112135 @Test(.enabled(if: FileManager.default.fileExists(atPath: NSHomeDirectory() + "/.config/doom/config.el")))
113136 func readsTheInstalledDoomConfig() {