krz/orgstar

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

Commit 944abf5b59

944abf5b590bc66902218a01a15a1efc518fea2c

parent: 2849ed6283

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-07 05:53 UTC

Read capture targets from variables and forms on import

Capture template targets that name a variable or a form, such as
org-default-notes-file or (concat org-directory "/x.org"), are
evaluated as org-capture-expand-file does. Backquoted template lists
have their , and ,@ parts evaluated, and defvar and defcustom give
values under any setq.

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() {