Capture template escapes !72

merged merged by cmc on 2026-10-06 02:37 UTC · krz/orgstar:capture-escapes into main

3 files changed, +329 −66

Layout: unified · split

Sources/OrgCore/Capture/Capture.swift +226 −63
@@ -100,14 +100,26 @@ public struct CaptureContext: Sendable {
100100 public var initial: String
101101 /// `%a`: a link to where capture started, `[[target][description]]`, or empty.
102102 public var annotation: String
103 /// `%c`: the clipboard.
103 /// `%c` and `%x`: the clipboard.
104104 public var clipboard: String
105105 /// `%f` and `%F`: the file capture started from.
106106 public var file: String
107107 /// `%n`.
108108 public var userName: String
109
110 public init(now: Date = Date(), calendar: Calendar = .current, initial: String = "", annotation: String = "", clipboard: String = "", file: String = "", userName: String = "") {
109 /// `%k` and `%K`: the clocked-in entry's title and a link to it, or empty.
110 public var clockHeading: String
111 public var clockLink: String
112 /// `%^g` and `%^G` offer these: the target file's tags, and every file's.
113 public var fileTags: [String]
114 public var allTags: [String]
115 /// `org-tags-column`, for aligning tags `%^g` adds to a heading.
116 public var tagsColumn: Int
117
118 public init(
119 now: Date = Date(), calendar: Calendar = .current, initial: String = "", annotation: String = "", clipboard: String = "",
120 file: String = "", userName: String = "", clockHeading: String = "", clockLink: String = "",
121 fileTags: [String] = [], allTags: [String] = [], tagsColumn: Int = -77
122 ) {
111123 self.now = now
112124 self.calendar = calendar
113125 self.initial = initial
@@ -115,6 +127,11 @@ public struct CaptureContext: Sendable {
115127 self.clipboard = clipboard
116128 self.file = file
117129 self.userName = userName
130 self.clockHeading = clockHeading
131 self.clockLink = clockLink
132 self.fileTags = fileTags
133 self.allTags = allTags
134 self.tagsColumn = tagsColumn
118135 }
119136}
120137
@@ -123,26 +140,181 @@ public enum Capture {
123140 case message(String)
124141 }
125142
126 /// A `%^{prompt|default|choice…}` the template asks before it can be filled.
143 /// A question the template asks before it can be filled: `%^{prompt|default|choice…}`,
144 /// or one of `%^g`, `%^G`, `%^t`, `%^T`, `%^u`, `%^U`, `%^C`, `%^L`, `%^{prop}p`.
127145 public struct Question: Sendable, Equatable {
146 public enum Kind: Sendable, Equatable {
147 case text
148 /// Tags, separated by colons.
149 case tags
150 /// A date as `org-read-date` reads it (`+2d`, `fri 14:00`); empty is today.
151 case date(withTime: Bool, inactive: Bool)
152 /// `%^C` and `%^L`: text from the clipboard, inserted as a link with `link`.
153 case clipboard(link: Bool)
154 /// `%^{NAME}p`: the value of property `NAME`.
155 case property
156 }
157
128158 public let prompt: String
129159 public let defaultValue: String
130160 public let choices: [String]
161 public let kind: Kind
162
163 public init(prompt: String, defaultValue: String, choices: [String], kind: Kind = .text) {
164 self.prompt = prompt
165 self.defaultValue = defaultValue
166 self.choices = choices
167 self.kind = kind
168 }
169 }
170
171 static let promptPattern = "%\\^(?:\\{([^}]*)\\})?([CgGLptTuU])?"
172
173 /// The questions `template` asks, in order.
174 public static func questions(_ template: String, context: CaptureContext = CaptureContext()) -> [Question] {
175 guard case .success(let expanded) = expand(template, context) else { return [] }
176 return prompts(in: expanded).map { question($0.items, $0.key, context) }
177 }
178
179 static func question(_ items: [String]?, _ key: String?, _ context: CaptureContext) -> Question {
180 let prompt = items?.first
181 let defaultValue = items.map { $0.count > 1 ? $0[1] : "" } ?? ""
182 switch key {
183 case "g", "G": return Question(prompt: prompt ?? "Tags", defaultValue: "", choices: key == "g" ? context.fileTags : context.allTags, kind: .tags)
184 case "t", "T", "u", "U":
185 let upper = key == key?.uppercased()
186 return Question(prompt: prompt ?? "Date", defaultValue: "", choices: [], kind: .date(withTime: upper, inactive: key == "u" || key == "U"))
187 case "C", "L":
188 let values = [context.initial, context.clipboard].filter { !$0.isEmpty }
189 return Question(prompt: "Clipboard/kill value", defaultValue: context.initial, choices: values, kind: .clipboard(link: key == "L"))
190 case "p": return Question(prompt: prompt ?? "", defaultValue: defaultValue, choices: [], kind: .property)
191 default: return Question(prompt: prompt ?? "Enter string", defaultValue: defaultValue, choices: items.map { Array($0.dropFirst(2)) } ?? [])
192 }
131193 }
132194
133 /// The prompts in `template`, in order.
134 public static func questions(_ template: String) -> [Question] {
135 let regex = try! NSRegularExpression(pattern: "(?<!%)%\\^\\{([^}]*)\\}")
136 let ns = template as NSString
137 return regex.matches(in: template, range: NSRange(location: 0, length: ns.length)).map { m in
138 let items = ns.substring(with: m.range(at: 1)).components(separatedBy: "|")
139 return Question(prompt: items[0], defaultValue: items.count > 1 ? items[1] : "", choices: Array(items.dropFirst(2)))
195 /// The unescaped `%^` placeholders in `text`.
196 static func prompts(in text: String) -> [(range: NSRange, items: [String]?, key: String?)] {
197 let regex = try! NSRegularExpression(pattern: promptPattern)
198 let ns = text as NSString
199 return regex.matches(in: text, range: NSRange(location: 0, length: ns.length)).compactMap { m in
200 guard !escaped(ns, at: m.range.location) else { return nil }
201 let items = m.range(at: 1).location == NSNotFound ? nil : ns.substring(with: m.range(at: 1)).components(separatedBy: "|")
202 let key = m.range(at: 2).location == NSNotFound ? nil : ns.substring(with: m.range(at: 2))
203 return (m.range, items, key)
140204 }
141205 }
142206
207 /// `org-capture-escaped-%` without its unescaping: an odd run of backslashes before `%`.
208 static func escaped(_ ns: NSString, at index: Int) -> Bool {
209 var i = index
210 while i > 0, ns.character(at: i - 1) == 92 { i -= 1 }
211 return (index - i) % 2 == 1
212 }
213
214 /// Where `%?` puts the cursor while the template is filled.
215 static let cursorMark = "\u{E000}"
216
143217 /// `org-capture-fill-template`: the text and where `%?` puts the cursor. `answers` holds
144218 /// the replies to `questions(template)` in order.
145219 public static func fill(_ template: String, context: CaptureContext, answers: [String] = []) -> Result<(text: String, cursor: Int), Failure> {
220 let expanded: String
221 switch expand(template, context) {
222 case .failure(let failure): return .failure(failure)
223 case .success(let text): expanded = text
224 }
225 // Prompts, in order, answered in the text as it stands then, as org does.
226 var options = EditingOptions.org
227 options.tagsColumn = context.tagsColumn
228 let buffer = EmacsBuffer(expanded, point: 0, settings: .default, options: options)
229 buffer.now = context.now
230 buffer.calendar = context.calendar
231 var strings: [String] = []
232 var all: [String] = []
233 var index = 0
234 while let match = buffer.searchForward(promptPattern, caseInsensitive: false) {
235 let whole = match[0]!
236 if unescape(buffer, at: whole.lowerBound) { continue }
237 let start = buffer.point - whole.count
238 let items = match[1].map { buffer.substring($0.lowerBound, $0.upperBound).components(separatedBy: "|") }
239 let key = match[2].map { buffer.substring($0.lowerBound, $0.upperBound) }
240 let question = question(items, key, context)
241 let answer = index < answers.count ? answers[index] : ""
242 index += 1
243 buffer.deleteRegion(start, buffer.point)
244 buffer.point = start
245 switch question.kind {
246 case .text:
247 let value = answer.isEmpty ? question.defaultValue : answer
248 strings.append(value)
249 all.append(value)
250 buffer.insert(value)
251 case .tags:
252 let tags = answer.split(separator: ":").map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty }
253 guard !tags.isEmpty else { continue }
254 all.append(":" + tags.joined(separator: ":") + ":")
255 if buffer.point == 0 || buffer.character(at: buffer.point - 1) != 58 { buffer.insert(":") }
256 buffer.insert(tags.joined(separator: ":"))
257 if buffer.point >= buffer.length || buffer.character(at: buffer.point) != 58 { buffer.insert(":") }
258 if buffer.lookingAtLine("^\\*+ ") { buffer.alignTags(onLineAt: buffer.point) }
259 case .date(let withTime, let inactive):
260 let result: ReadDate.Answer
261 if answer.trimmingCharacters(in: .whitespaces).isEmpty {
262 let c = context.calendar.dateComponents([.year, .month, .day, .hour, .minute], from: context.now)
263 result = ReadDate.Answer(year: c.year!, month: c.month!, day: c.day!, hour: c.hour!, minute: c.minute!, timeGiven: false)
264 } else {
265 result = buffer.readDate(answer, default: context.now)
266 }
267 let stamp = buffer.insertTimestamp(result.components(context.calendar), withTime: result.timeGiven || withTime, inactive: inactive, endTime: result.endTime)
268 all.append(stamp)
269 case .clipboard(let link):
270 let value = answer.isEmpty ? question.defaultValue : answer
271 all.append(value)
272 buffer.insert(link ? Links.makeString(value, nil) : value)
273 case .property:
274 let value = answer.isEmpty ? question.defaultValue : answer
275 buffer.saveExcursion { buffer.entryPut(question.prompt, value) }
276 all.append(value)
277 }
278 }
279 // `%\N`: the Nth `%^{…}` answer; `%\*N`: the Nth answer of any prompt.
280 var text = buffer.string
281 for (pattern, values) in [("%\\\\\\*([1-9][0-9]*)", all), ("%\\\\([1-9][0-9]*)", strings)] {
282 let regex = try! NSRegularExpression(pattern: pattern)
283 let ns = text as NSString
284 var out = text
285 for m in regex.matches(in: text, range: NSRange(location: 0, length: ns.length)).reversed() where !escaped(ns, at: m.range.location) {
286 let n = Int(ns.substring(with: m.range(at: 1)))!
287 out = (out as NSString).replacingCharacters(in: m.range, with: n <= values.count ? values[n - 1] : "")
288 }
289 text = out
290 }
291 // No blank lines before the text; it ends with one newline, or is empty.
292 var lines = text.components(separatedBy: "\n")
293 while let first = lines.first, first.trimmingCharacters(in: .whitespaces).isEmpty, lines.count > 1 { lines.removeFirst() }
294 var marked = lines.joined(separator: "\n")
295 let trimmed = marked.replacingOccurrences(of: "[ \\t\\n]+$", with: "", options: .regularExpression)
296 marked = trimmed.isEmpty ? "" : trimmed + "\n"
297 marked = marked.replacingOccurrences(of: "\t", with: " ")
298 guard let range = marked.range(of: cursorMark) else { return .success((marked, marked.utf16.count > 0 ? marked.utf16.count - 1 : 0)) }
299 let offset = marked[..<range.lowerBound].utf16.count
300 marked.removeSubrange(range)
301 return .success((marked, offset))
302 }
303
304 /// `org-capture-escaped-%` at the `%` at `index`: drops half the backslashes before it;
305 /// true when it was escaped. Point follows the deletion.
306 static func unescape(_ buffer: EmacsBuffer, at index: Int) -> Bool {
307 var i = index
308 while i > 0, buffer.character(at: i - 1) == 92 { i -= 1 }
309 let n = index - i
310 guard n > 0 else { return false }
311 buffer.deleteRegion(i, i + (n + 1) / 2)
312 return n % 2 == 1
313 }
314
315 /// The escapes that need no answer (`%a`, `%t`, `%<…>`, `%?`…), with `%^` prompts and `%\N`
316 /// left in place.
317 static func expand(_ template: String, _ context: CaptureContext) -> Result<String, Failure> {
146318 func stamp(_ active: Bool, _ withTime: Bool) -> String {
147319 let c = context.calendar.dateComponents([.year, .month, .day, .weekday, .hour, .minute], from: context.now)
148320 let day = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"][(c.weekday ?? 1) - 1]
@@ -156,9 +328,6 @@ public enum Capture {
156328 return template.replacingOccurrences(of: "$1", with: ns.substring(with: m.range(at: 1)))
157329 }
158330 var text = ""
159 var answerIndex = 0
160 var answered: [String] = []
161 var cursor: Int?
162331 let chars = Array(template)
163332 var i = 0
164333 while i < chars.count {
@@ -168,86 +337,80 @@ public enum Capture {
168337 i += 1
169338 continue
170339 }
340 // A `%` after an odd run of backslashes stays, with half of them.
341 var slashes = 0
342 while slashes < text.count, text.dropLast(slashes).last == "\\" { slashes += 1 }
171343 let next = chars[i + 1]
172 if next == "%" {
173 text.append("%")
174 i += 2
175 continue
344 if slashes > 0, "aAcfFikKlLntTuUx:<?".contains(next) {
345 text.removeLast((slashes + 1) / 2)
346 if slashes % 2 == 1 {
347 text.append(c)
348 i += 1
349 continue
350 }
176351 }
177352 var consumed = 2
178353 switch next {
179 case "?": cursor = text.utf16.count
354 case "?": text += cursorMark
180355 case "i":
181356 // Continuation lines keep the indentation of the line `%i` is on.
182357 let lead = String(text.split(separator: "\n", omittingEmptySubsequences: false).last ?? "").prefix { $0 == " " || $0 == "\t" }
183358 text += context.initial.replacingOccurrences(of: "\n", with: "\n" + lead)
184359 case "a": text += context.annotation == "[[]]" ? "" : context.annotation
360 case "A":
361 // The link, with its description asked for.
362 let ns = context.annotation as NSString
363 if let m = link.firstMatch(in: context.annotation, range: NSRange(location: 0, length: ns.length)) {
364 text += ns.replacingCharacters(in: m.range, with: "[[" + ns.substring(with: m.range(at: 1)) + "][%^{Link description}]]")
365 } else {
366 text += context.annotation
367 }
185368 case "l": text += linkForm("[[$1]]")
186369 case "L": text += linkForm("$1")
187 case "c": text += context.clipboard
370 case "c", "x": text += context.clipboard
188371 case "f": text += (context.file as NSString).lastPathComponent
189372 case "F": text += context.file
190373 case "n": text += context.userName
374 case "k": text += context.clockHeading
375 case "K": text += context.clockLink
191376 case "t": text += stamp(true, false)
192377 case "T": text += stamp(true, true)
193378 case "u": text += stamp(false, false)
194379 case "U": text += stamp(false, true)
195380 case "<":
196 guard let close = chars[(i + 2)...].firstIndex(of: ">") else { return .failure(.message("Unclosed %< in the template")) }
197 text += strftime(String(chars[(i + 2)..<close]), context)
198 consumed = close - i + 1
199 case "^":
200 guard i + 2 < chars.count, chars[i + 2] == "{", let close = chars[(i + 3)...].firstIndex(of: "}") else {
201 return .failure(.message("Template escapes like %^\(i + 2 < chars.count ? String(chars[i + 2]) : "") aren't supported yet."))
381 guard let close = chars[(i + 2)...].firstIndex(of: ">"), !chars[(i + 2)..<close].contains("\n") else {
382 text.append(c)
383 consumed = 1
384 break
202385 }
203 let after = close + 1 < chars.count ? chars[close + 1] : " "
204 if "CgGLptTuU".contains(after) { return .failure(.message("Template escapes like %^{…}\(after) aren't supported yet.")) }
205 let items = String(chars[(i + 3)..<close]).components(separatedBy: "|")
206 var answer = answerIndex < answers.count ? answers[answerIndex] : ""
207 if answer.isEmpty, items.count > 1 { answer = items[1] }
208 answerIndex += 1
209 answered.append(answer)
210 text += answer
386 text += strftime(String(chars[(i + 2)..<close]), context)
211387 consumed = close - i + 1
212 case "\\":
388 case ":":
389 // A link property (`%:annotation`); only the capture's own are known.
213390 var j = i + 2
214 var digits = ""
215 while j < chars.count, chars[j].isNumber {
216 digits.append(chars[j])
217 j += 1
218 }
219 if let n = Int(digits), n >= 1, n <= answered.count {
220 text += answered[n - 1]
221 consumed = j - i
222 } else {
391 while j < chars.count, chars[j].isLetter || chars[j] == "-" { j += 1 }
392 guard j > i + 2 else {
223393 text.append(c)
224394 consumed = 1
395 break
396 }
397 switch String(chars[(i + 2)..<j]) {
398 case "annotation": text += context.annotation
399 case "initial": text += context.initial
400 default: break
225401 }
226 case "(", "[", "k", "K", "x", "A", ":":
227 return .failure(.message("Template escapes like %\(next) aren't supported yet."))
402 consumed = j - i
403 case "(":
404 return .failure(.message("Template escapes like %( run Emacs Lisp, which Orgstar doesn't."))
405 case "[":
406 return .failure(.message("Template escapes like %[file] aren't supported yet."))
228407 default:
229408 text.append(c)
230409 consumed = 1
231410 }
232411 i += consumed
233412 }
234 // No blank lines before the text; it ends with one newline, or is empty.
235 var marked = text
236 let marker = "\u{0}CURSOR\u{0}"
237 if let cursor {
238 let index = String.Index(utf16Offset: cursor, in: marked)
239 marked.insert(contentsOf: marker, at: index)
240 }
241 var lines = marked.components(separatedBy: "\n")
242 while let first = lines.first, first.trimmingCharacters(in: .whitespaces).isEmpty, lines.count > 1 { lines.removeFirst() }
243 marked = lines.joined(separator: "\n")
244 let trimmed = marked.replacingOccurrences(of: "[ \\t\\n]+$", with: "", options: .regularExpression)
245 marked = trimmed.isEmpty ? "" : trimmed + "\n"
246 marked = marked.replacingOccurrences(of: "\t", with: " ")
247 guard let range = marked.range(of: marker) else { return .success((marked, marked.utf16.count > 0 ? marked.utf16.count - 1 : 0)) }
248 let offset = marked[..<range.lowerBound].utf16.count
249 marked.removeSubrange(range)
250 return .success((marked, offset))
413 return .success(text)
251414 }
252415
253416 static func strftime(_ format: String, _ context: CaptureContext) -> String {
Sources/Orgstar/CaptureView.swift +29 −3
@@ -42,7 +42,11 @@ struct CaptureView: View {
4242 Text(template.name).font(.headline)
4343 Form {
4444 ForEach(questions.indices, id: \.self) { index in
45 TextField(questions[index].prompt, text: $answers[index], prompt: Text(questions[index].defaultValue))
45 let question = questions[index]
46 TextField(question.prompt, text: $answers[index], prompt: Text(placeholder(question)))
47 if !question.choices.isEmpty {
48 Text(question.choices.prefix(12).joined(separator: " ")).font(.caption).foregroundStyle(.secondary)
49 }
4650 }
4751 }
4852 HStack {
@@ -80,6 +84,15 @@ struct CaptureView: View {
8084 .onAppear(perform: reset)
8185 }
8286
87 /// What an empty answer means.
88 private func placeholder(_ question: Capture.Question) -> String {
89 switch question.kind {
90 case .text, .property, .clipboard: question.defaultValue
91 case .tags: "tag:tag"
92 case .date(let withTime, _): withTime ? "now; or +2d, fri 14:00" : "today; or +2d, fri"
93 }
94 }
95
8396 private func target(_ template: CaptureTemplate) -> String {
8497 let file = (template.target.file as NSString).lastPathComponent
8598 switch template.target {
@@ -98,18 +111,31 @@ struct CaptureView: View {
98111 problem = loaded.problems.first
99112 template = nil
100113 editing = false
114 let clocked = clock.running
115 let clockFile = clocked.map { Links.abbreviate($0.path, home: NSHomeDirectory()) }
101116 context = CaptureContext(
102117 initial: commands.editor?.selectedText ?? "",
103118 annotation: session.captureAnnotation(),
104119 clipboard: NSPasteboard.general.string(forType: .string) ?? "",
105120 file: session.path ?? "",
106 userName: NSFullUserName()
121 userName: NSFullUserName(),
122 clockHeading: clocked?.heading ?? "",
123 clockLink: clocked.map { "[[file:\(clockFile!)::*\($0.heading)][\($0.heading)]]" } ?? "",
124 allTags: (try? workspace.index.tags()) ?? [],
125 tagsColumn: UserDefaults.standard.object(forKey: "tagsColumn") as? Int ?? -77
107126 )
108127 }
109128
110129 private func choose(_ chosen: CaptureTemplate) {
111130 template = chosen
112 questions = Capture.questions(chosen.template)
131 // `%^g` offers the target file's tags.
132 let path = DocumentSession.resolve(chosen.target.file, root: workspace.rootPaths.first)
133 if !chosen.target.file.isEmpty, let text = session.openDocuments.first(where: { $0.path == path })?.document.text ?? (try? String(contentsOfFile: path, encoding: .utf8)) {
134 var tags: [String] = []
135 for heading in DocumentModel(tree: OrgParser.parse(text)).headings { for tag in heading.localTags where !tags.contains(tag) { tags.append(tag) } }
136 context.fileTags = tags
137 }
138 questions = Capture.questions(chosen.template, context: context)
113139 answers = questions.map { _ in "" }
114140 if questions.isEmpty { expand() }
115141 }
Tests/OrgCoreTests/CaptureTests.swift +74
@@ -212,3 +212,77 @@ struct CaptureTests {
212212 #expect(filled.text == "* untitled for me\nuntitled again\n")
213213 }
214214}
215
216struct CaptureEscapeTests {
217 /// Templates and the answers to their prompts, in order.
218 static let cases: [(template: String, answers: [String])] = [
219 ("* TODO %^{Task} %^g\n%^{Effort}p%?", ["Call bank", "work:phone", "1:00"]),
220 ("* Meeting %^T\n%^{Who|Ann|Bob} said %\\1, tags %\\*2", ["fri 14:00", ""]),
221 ("* %^{Title} :work:\nSCHEDULED: %^t\n%^u %^U", ["Plan", "+2d", "", "2026-10-01 9:30"]),
222 ("* Link %^L and %^C", ["https://orgmode.org", ""]),
223 ("* %A\n%:annotation %:initial %k%?", ["the page"]),
224 ("\\%a literal and \\\\%a doubled %c", []),
225 ("* Heading %^{Due}t and %^{Seen}U", ["tue", ""]),
226 ("%^{Name}\n- item %^g", ["Bob", "a:b"]),
227 ("* A rather long heading title that goes on %^G", ["x:y"]),
228 ("* Empty tags %^g done", [""]),
229 ("* Prop %^{CATEGORY|home}p\nbody", [""]),
230 ]
231
232 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
233 func fillsLikeOrgCaptureFillTemplate() throws {
234 let seconds = 1_789_000_000
235 let folder = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-fill-\(UUID().uuidString)")
236 try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
237 defer { try? FileManager.default.removeItem(at: folder) }
238 let forms = Self.cases.map { c in
239 """
240 (let ((answers (list \(c.answers.map(CaptureTests.lisp).joined(separator: " ")))))
241 (cl-letf* ((next (lambda (default) (let ((a (pop answers))) (if (or (null a) (equal a "")) (or default "") a))))
242 (ord (symbol-function 'org-read-date))
243 ((symbol-function 'org-completing-read) (lambda (_p _c &optional _a _b _i _h def &rest _) (funcall next def)))
244 ((symbol-function 'completing-read-multiple) (lambda (&rest _) (split-string (funcall next "") ":" t)))
245 ((symbol-function 'read-string) (lambda (_p &optional _i _h def &rest _) (funcall next def)))
246 ((symbol-function 'org-read-property-value) (lambda (_p &optional _e def) (funcall next def)))
247 ((symbol-function 'org-read-date)
248 (lambda (&optional with-time to-time _from prompt &rest _)
249 (let ((a (pop answers))) (funcall ord with-time to-time (if (equal a "") "." a) prompt)))))
250 (with-temp-buffer
251 (org-mode)
252 (setq org-capture-plist (list :buffer (current-buffer) :pos 1))
253 (push (org-capture-fill-template \(CaptureTests.lisp(c.template)) "Selected" "[[file:~/n.org::*Task][Task]]") results))))
254 """
255 }
256 let output = folder.appendingPathComponent("out.json")
257 let script = folder.appendingPathComponent("fill.el")
258 try """
259 ;;; -*- lexical-binding: t -*-
260 (require 'org)
261 (require 'org-capture)
262 (require 'json)
263 (require 'cl-lib)
264 (setq org-adapt-indentation nil)
265 (kill-new "Clip")
266 (defvar results nil)
267 \(frozenClock(seconds, "(progn " + forms.joined(separator: "\n") + ")"))
268 (with-temp-file \(CaptureTests.lisp(output.path)) (insert (json-encode (vconcat (nreverse results)))))
269 """.write(to: script, atomically: true, encoding: .utf8)
270 let log = try EmacsOracle.run(["-Q", "--batch", "-l", script.path], environment: ["TZ": "UTC"])
271 guard FileManager.default.fileExists(atPath: output.path) else { throw EmacsOracle.EvaluateError(log: log) }
272 let emacs = try JSONDecoder().decode([String].self, from: Data(contentsOf: output))
273 var utc = Calendar(identifier: .gregorian)
274 utc.timeZone = TimeZone(identifier: "UTC")!
275 let context = CaptureContext(now: Date(timeIntervalSince1970: Double(seconds)), calendar: utc, initial: "Selected",
276 annotation: "[[file:~/n.org::*Task][Task]]", clipboard: "Clip")
277 for (c, expected) in zip(Self.cases, emacs) {
278 #expect(Capture.questions(c.template, context: context).count == c.answers.count, "\(c.template.debugDescription)")
279 guard case .success(let filled) = Capture.fill(c.template, context: context, answers: c.answers) else {
280 Issue.record("fill failed for \(c.template.debugDescription)")
281 continue
282 }
283 var mine = filled.text
284 if c.template.contains("%?") { mine.insert(contentsOf: "%?", at: String.Index(utf16Offset: filled.cursor, in: mine)) }
285 #expect(mine == expected, "\(c.template.debugDescription)\nours: \(mine.debugDescription)\nemacs: \(expected.debugDescription)")
286 }
287 }
288}