krz/orgstar

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

Sources/OrgCore/Capture/Capture.swift

b98a6509c5ae75e5172dd333d1b6105fd6ceee0e
orgstar/Sources/OrgCore/Capture/Capture.swift history · blame · raw

914 lines · 45044 bytes

39 symbols in this file
  1import Foundation
  2
  3// Capture (org-capture.el, Org 9.8.7): filling a template and placing the result in its
  4// target, as `org-capture` followed by `org-capture-finalize` leaves the target file.
  5
  6public struct CaptureTemplate: Sendable, Equatable, Identifiable {
  7    public enum Kind: String, Sendable, Equatable { case entry, item, checkitem, plain, tableLine = "table-line" }
  8
  9    /// `:tree-type`: what a date tree groups entries by.
 10    public enum TreeType: String, Sendable, Equatable { case day, week, month }
 11
 12    public enum Target: Sendable, Equatable {
 13        case file(String)
 14        case headline(file: String, headline: String)
 15        case outline(file: String, path: [String])
 16        /// `file+olp+datetree`: today's entry in a date tree, under `path` or at the top.
 17        case datetree(file: String, path: [String], tree: TreeType)
 18        /// `(id "…")`: the heading with that ID; the app finds its file.
 19        case id(String)
 20        /// `(clock)`: the clocked-in entry; the app turns it into `heading`.
 21        case clock
 22        /// The heading starting at `offset` in `file`.
 23        case heading(file: String, offset: Int)
 24
 25        /// The target file, or "" for `id` and `clock` until the app resolves them.
 26        public var file: String {
 27            switch self {
 28            case .file(let f), .headline(let f, _), .outline(let f, _), .datetree(let f, _, _), .heading(let f, _): f
 29            case .id, .clock: ""
 30            }
 31        }
 32    }
 33
 34    /// Template properties beyond the target (`org-capture-templates`).
 35    public struct Options: Sendable, Equatable {
 36        public var prepend = false
 37        /// `:immediate-finish`: file without showing the text.
 38        public var immediateFinish = false
 39        /// `:empty-lines`, `:empty-lines-before`, `:empty-lines-after`.
 40        public var emptyLines: Int?
 41        public var emptyLinesBefore: Int?
 42        public var emptyLinesAfter: Int?
 43        /// `:jump-to-captured`: show the entry after filing it.
 44        public var jumpToCaptured = false
 45        /// `:clock-in`, `:clock-keep`, `:clock-resume`.
 46        public var clockIn = false
 47        public var clockKeep = false
 48        public var clockResume = false
 49        /// `:table-line-pos`: `II-3` is the third line above the second rule.
 50        public var tableLinePos: String?
 51
 52        public init(
 53            prepend: Bool = false, immediateFinish: Bool = false, emptyLines: Int? = nil, emptyLinesBefore: Int? = nil,
 54            emptyLinesAfter: Int? = nil, jumpToCaptured: Bool = false, clockIn: Bool = false, clockKeep: Bool = false, clockResume: Bool = false,
 55            tableLinePos: String? = nil
 56        ) {
 57            self.tableLinePos = tableLinePos
 58            self.prepend = prepend
 59            self.immediateFinish = immediateFinish
 60            self.emptyLines = emptyLines
 61            self.emptyLinesBefore = emptyLinesBefore
 62            self.emptyLinesAfter = emptyLinesAfter
 63            self.jumpToCaptured = jumpToCaptured
 64            self.clockIn = clockIn
 65            self.clockKeep = clockKeep
 66            self.clockResume = clockResume
 67        }
 68
 69        var before: Int? { emptyLinesBefore ?? emptyLines }
 70        var after: Int { emptyLinesAfter ?? emptyLines ?? 0 }
 71    }
 72
 73    public let key: String
 74    public let name: String
 75    public let kind: Kind
 76    public let target: Target
 77    public let template: String
 78    public let options: Options
 79    public var prepend: Bool { options.prepend }
 80    public var id: String { key }
 81
 82    public init(key: String, name: String, kind: Kind = .entry, target: Target, template: String, prepend: Bool = false, options: Options = Options()) {
 83        self.key = key
 84        self.name = name
 85        self.kind = kind
 86        self.target = target
 87        self.template = template
 88        var options = options
 89        if prepend { options.prepend = true }
 90        self.options = options
 91    }
 92
 93    /// The same template aimed at `target`.
 94    public func retargeted(_ target: Target) -> CaptureTemplate {
 95        CaptureTemplate(key: key, name: name, kind: kind, target: target, template: template, options: options)
 96    }
 97}
 98
 99/// What the escapes in a template stand for.
100public struct CaptureContext: Sendable {
101    public var now: Date
102    public var calendar: Calendar
103    /// `%i`: the selected text.
104    public var initial: String
105    /// `%a`: a link to where capture started, `[[target][description]]`, or empty.
106    public var annotation: String
107    /// `%c` and `%x`: the clipboard.
108    public var clipboard: String
109    /// `%f` and `%F`: the file capture started from.
110    public var file: String
111    /// `%n`.
112    public var userName: String
113    /// `%k` and `%K`: the clocked-in entry's title and a link to it, or empty.
114    public var clockHeading: String
115    public var clockLink: String
116    /// `%^g` and `%^G` offer these: the target file's tags, and every file's.
117    public var fileTags: [String]
118    public var allTags: [String]
119    /// `org-tags-column`, for aligning tags `%^g` adds to a heading.
120    public var tagsColumn: Int
121    /// `org-store-link-plist` as org-protocol sets it, for `%:link`, `%:description` and others.
122    public var linkProperties: [String: String] = [:]
123
124    public init(
125        now: Date = Date(), calendar: Calendar = .current, initial: String = "", annotation: String = "", clipboard: String = "",
126        file: String = "", userName: String = "", clockHeading: String = "", clockLink: String = "",
127        fileTags: [String] = [], allTags: [String] = [], tagsColumn: Int = -77
128    ) {
129        self.now = now
130        self.calendar = calendar
131        self.initial = initial
132        self.annotation = annotation
133        self.clipboard = clipboard
134        self.file = file
135        self.userName = userName
136        self.clockHeading = clockHeading
137        self.clockLink = clockLink
138        self.fileTags = fileTags
139        self.allTags = allTags
140        self.tagsColumn = tagsColumn
141    }
142}
143
144public enum Capture {
145    public enum Failure: Error, Equatable {
146        case message(String)
147    }
148
149    /// A question the template asks before it can be filled: `%^{prompt|default|choice…}`,
150    /// or one of `%^g`, `%^G`, `%^t`, `%^T`, `%^u`, `%^U`, `%^C`, `%^L`, `%^{prop}p`.
151    public struct Question: Sendable, Equatable {
152        public enum Kind: Sendable, Equatable {
153            case text
154            /// Tags, separated by colons.
155            case tags
156            /// A date as `org-read-date` reads it (`+2d`, `fri 14:00`); empty is today.
157            case date(withTime: Bool, inactive: Bool)
158            /// `%^C` and `%^L`: text from the clipboard, inserted as a link with `link`.
159            case clipboard(link: Bool)
160            /// `%^{NAME}p`: the value of property `NAME`.
161            case property
162        }
163
164        public let prompt: String
165        public let defaultValue: String
166        public let choices: [String]
167        public let kind: Kind
168
169        public init(prompt: String, defaultValue: String, choices: [String], kind: Kind = .text) {
170            self.prompt = prompt
171            self.defaultValue = defaultValue
172            self.choices = choices
173            self.kind = kind
174        }
175    }
176
177    static let promptPattern = "%\\^(?:\\{([^}]*)\\})?([CgGLptTuU])?"
178
179    /// The questions `template` asks, in order.
180    public static func questions(_ template: String, context: CaptureContext = CaptureContext()) -> [Question] {
181        guard case .success(let expanded) = expand(template, context) else { return [] }
182        return prompts(in: expanded).map { question($0.items, $0.key, context) }
183    }
184
185    static func question(_ items: [String]?, _ key: String?, _ context: CaptureContext) -> Question {
186        let prompt = items?.first
187        let defaultValue = items.map { $0.count > 1 ? $0[1] : "" } ?? ""
188        switch key {
189        case "g", "G": return Question(prompt: prompt ?? "Tags", defaultValue: "", choices: key == "g" ? context.fileTags : context.allTags, kind: .tags)
190        case "t", "T", "u", "U":
191            let upper = key == key?.uppercased()
192            return Question(prompt: prompt ?? "Date", defaultValue: "", choices: [], kind: .date(withTime: upper, inactive: key == "u" || key == "U"))
193        case "C", "L":
194            let values = [context.initial, context.clipboard].filter { !$0.isEmpty }
195            return Question(prompt: "Clipboard/kill value", defaultValue: context.initial, choices: values, kind: .clipboard(link: key == "L"))
196        case "p": return Question(prompt: prompt ?? "", defaultValue: defaultValue, choices: [], kind: .property)
197        default: return Question(prompt: prompt ?? "Enter string", defaultValue: defaultValue, choices: items.map { Array($0.dropFirst(2)) } ?? [])
198        }
199    }
200
201    /// The unescaped `%^` placeholders in `text`.
202    static func prompts(in text: String) -> [(range: NSRange, items: [String]?, key: String?)] {
203        let regex = try! NSRegularExpression(pattern: promptPattern)
204        let ns = text as NSString
205        return regex.matches(in: text, range: NSRange(location: 0, length: ns.length)).compactMap { m in
206            guard !escaped(ns, at: m.range.location) else { return nil }
207            let items = m.range(at: 1).location == NSNotFound ? nil : ns.substring(with: m.range(at: 1)).components(separatedBy: "|")
208            let key = m.range(at: 2).location == NSNotFound ? nil : ns.substring(with: m.range(at: 2))
209            return (m.range, items, key)
210        }
211    }
212
213    /// `org-capture-escaped-%` without its unescaping: an odd run of backslashes before `%`.
214    static func escaped(_ ns: NSString, at index: Int) -> Bool {
215        var i = index
216        while i > 0, ns.character(at: i - 1) == 92 { i -= 1 }
217        return (index - i) % 2 == 1
218    }
219
220    /// Where `%?` puts the cursor while the template is filled.
221    static let cursorMark = "\u{E000}"
222
223    /// `org-capture-fill-template`: the text and where `%?` puts the cursor. `answers` holds
224    /// the replies to `questions(template)` in order.
225    public static func fill(_ template: String, context: CaptureContext, answers: [String] = []) -> Result<(text: String, cursor: Int), Failure> {
226        let expanded: String
227        switch expand(template, context) {
228        case .failure(let failure): return .failure(failure)
229        case .success(let text): expanded = text
230        }
231        // Prompts, in order, answered in the text as it stands then, as org does.
232        var options = EditingOptions.org
233        options.tagsColumn = context.tagsColumn
234        let buffer = EmacsBuffer(expanded, point: 0, settings: .default, options: options)
235        buffer.now = context.now
236        buffer.calendar = context.calendar
237        var strings: [String] = []
238        var all: [String] = []
239        var index = 0
240        while let match = buffer.searchForward(promptPattern, caseInsensitive: false) {
241            let whole = match[0]!
242            if unescape(buffer, at: whole.lowerBound) { continue }
243            let start = buffer.point - whole.count
244            let items = match[1].map { buffer.substring($0.lowerBound, $0.upperBound).components(separatedBy: "|") }
245            let key = match[2].map { buffer.substring($0.lowerBound, $0.upperBound) }
246            let question = question(items, key, context)
247            let answer = index < answers.count ? answers[index] : ""
248            index += 1
249            buffer.deleteRegion(start, buffer.point)
250            buffer.point = start
251            switch question.kind {
252            case .text:
253                let value = answer.isEmpty ? question.defaultValue : answer
254                strings.append(value)
255                all.append(value)
256                buffer.insert(value)
257            case .tags:
258                let tags = answer.split(separator: ":").map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty }
259                guard !tags.isEmpty else { continue }
260                all.append(":" + tags.joined(separator: ":") + ":")
261                if buffer.point == 0 || buffer.character(at: buffer.point - 1) != 58 { buffer.insert(":") }
262                buffer.insert(tags.joined(separator: ":"))
263                if buffer.point >= buffer.length || buffer.character(at: buffer.point) != 58 { buffer.insert(":") }
264                if buffer.lookingAtLine("^\\*+ ") { buffer.alignTags(onLineAt: buffer.point) }
265            case .date(let withTime, let inactive):
266                let result: ReadDate.Answer
267                if answer.trimmingCharacters(in: .whitespaces).isEmpty {
268                    let c = context.calendar.dateComponents([.year, .month, .day, .hour, .minute], from: context.now)
269                    result = ReadDate.Answer(year: c.year!, month: c.month!, day: c.day!, hour: c.hour!, minute: c.minute!, timeGiven: false)
270                } else {
271                    result = buffer.readDate(answer, default: context.now)
272                }
273                let stamp = buffer.insertTimestamp(result.components(context.calendar), withTime: result.timeGiven || withTime, inactive: inactive, endTime: result.endTime)
274                all.append(stamp)
275            case .clipboard(let link):
276                let value = answer.isEmpty ? question.defaultValue : answer
277                all.append(value)
278                buffer.insert(link ? Links.makeString(value, nil) : value)
279            case .property:
280                let value = answer.isEmpty ? question.defaultValue : answer
281                buffer.saveExcursion { buffer.entryPut(question.prompt, value) }
282                all.append(value)
283            }
284        }
285        // `%\N`: the Nth `%^{…}` answer; `%\*N`: the Nth answer of any prompt.
286        var text = buffer.string
287        for (pattern, values) in [("%\\\\\\*([1-9][0-9]*)", all), ("%\\\\([1-9][0-9]*)", strings)] {
288            let regex = try! NSRegularExpression(pattern: pattern)
289            let ns = text as NSString
290            var out = text
291            for m in regex.matches(in: text, range: NSRange(location: 0, length: ns.length)).reversed() where !escaped(ns, at: m.range.location) {
292                let n = Int(ns.substring(with: m.range(at: 1))) ?? Int.max
293                out = (out as NSString).replacingCharacters(in: m.range, with: n <= values.count ? values[n - 1] : "")
294            }
295            text = out
296        }
297        // No blank lines before the text; it ends with one newline, or is empty.
298        var lines = text.components(separatedBy: "\n")
299        while let first = lines.first, first.trimmingCharacters(in: .whitespaces).isEmpty, lines.count > 1 { lines.removeFirst() }
300        var marked = lines.joined(separator: "\n")
301        let trimmed = marked.replacingOccurrences(of: "[ \\t\\n]+$", with: "", options: .regularExpression)
302        marked = trimmed.isEmpty ? "" : trimmed + "\n"
303        marked = marked.replacingOccurrences(of: "\t", with: "        ")
304        guard let range = marked.range(of: cursorMark) else { return .success((marked, marked.utf16.count > 0 ? marked.utf16.count - 1 : 0)) }
305        let offset = marked[..<range.lowerBound].utf16.count
306        marked.removeSubrange(range)
307        return .success((marked, offset))
308    }
309
310    /// `org-capture-escaped-%` at the `%` at `index`: drops half the backslashes before it;
311    /// true when it was escaped. Point follows the deletion.
312    static func unescape(_ buffer: EmacsBuffer, at index: Int) -> Bool {
313        var i = index
314        while i > 0, buffer.character(at: i - 1) == 92 { i -= 1 }
315        let n = index - i
316        guard n > 0 else { return false }
317        buffer.deleteRegion(i, i + (n + 1) / 2)
318        return n % 2 == 1
319    }
320
321    /// The escapes that need no answer (`%a`, `%t`, `%<…>`, `%?`…), with `%^` prompts and `%\N`
322    /// left in place.
323    static func expand(_ template: String, _ context: CaptureContext) -> Result<String, Failure> {
324        func stamp(_ active: Bool, _ withTime: Bool) -> String {
325            let c = context.calendar.dateComponents([.year, .month, .day, .weekday, .hour, .minute], from: context.now)
326            let day = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"][(c.weekday ?? 1) - 1]
327            let body = String(format: "%04d-%02d-%02d %@", c.year!, c.month!, c.day!, day) + (withTime ? String(format: " %02d:%02d", c.hour!, c.minute!) : "")
328            return active ? "<\(body)>" : "[\(body)]"
329        }
330        let link = try! NSRegularExpression(pattern: "\\[\\[(.*?)\\](\\[.*?\\])?\\]")
331        func linkForm(_ template: String) -> String {
332            let ns = context.annotation as NSString
333            guard let m = link.firstMatch(in: context.annotation, range: NSRange(location: 0, length: ns.length)) else { return context.annotation }
334            return template.replacingOccurrences(of: "$1", with: ns.substring(with: m.range(at: 1)))
335        }
336        var text = ""
337        let chars = Array(template)
338        var i = 0
339        while i < chars.count {
340            let c = chars[i]
341            guard c == "%", i + 1 < chars.count else {
342                text.append(c)
343                i += 1
344                continue
345            }
346            // A `%` after an odd run of backslashes stays, with half of them.
347            var slashes = 0
348            while slashes < text.count, text.dropLast(slashes).last == "\\" { slashes += 1 }
349            let next = chars[i + 1]
350            if slashes > 0, "aAcfFikKlLntTuUx:<?".contains(next) {
351                text.removeLast((slashes + 1) / 2)
352                if slashes % 2 == 1 {
353                    text.append(c)
354                    i += 1
355                    continue
356                }
357            }
358            var consumed = 2
359            switch next {
360            case "?": text += cursorMark
361            case "i":
362                // Continuation lines keep the indentation of the line `%i` is on.
363                let lead = String(text.split(separator: "\n", omittingEmptySubsequences: false).last ?? "").prefix { $0 == " " || $0 == "\t" }
364                text += context.initial.replacingOccurrences(of: "\n", with: "\n" + lead)
365            case "a": text += context.annotation == "[[]]" ? "" : context.annotation
366            case "A":
367                // The link, with its description asked for.
368                let ns = context.annotation as NSString
369                if let m = link.firstMatch(in: context.annotation, range: NSRange(location: 0, length: ns.length)) {
370                    text += ns.replacingCharacters(in: m.range, with: "[[" + ns.substring(with: m.range(at: 1)) + "][%^{Link description}]]")
371                } else {
372                    text += context.annotation
373                }
374            case "l": text += linkForm("[[$1]]")
375            case "L": text += linkForm("$1")
376            case "c", "x": text += context.clipboard
377            case "f": text += (context.file as NSString).lastPathComponent
378            case "F": text += context.file
379            case "n": text += context.userName
380            case "k": text += context.clockHeading
381            case "K": text += context.clockLink
382            case "t": text += stamp(true, false)
383            case "T": text += stamp(true, true)
384            case "u": text += stamp(false, false)
385            case "U": text += stamp(false, true)
386            case "<":
387                guard let close = chars[(i + 2)...].firstIndex(of: ">"), !chars[(i + 2)..<close].contains("\n") else {
388                    text.append(c)
389                    consumed = 1
390                    break
391                }
392                text += strftime(String(chars[(i + 2)..<close]), context)
393                consumed = close - i + 1
394            case ":":
395                // A link property (`%:annotation`), from org-protocol or the capture's own.
396                var j = i + 2
397                while j < chars.count, chars[j].isLetter || chars[j] == "-" { j += 1 }
398                guard j > i + 2 else {
399                    text.append(c)
400                    consumed = 1
401                    break
402                }
403                let name = String(chars[(i + 2)..<j])
404                switch name {
405                case _ where context.linkProperties[name] != nil: text += context.linkProperties[name]!
406                case "annotation": text += context.annotation
407                case "initial": text += context.initial
408                default: break
409                }
410                consumed = j - i
411            case "(":
412                return .failure(.message("Template escapes like %( run Emacs Lisp, which Orgstar doesn't."))
413            case "[":
414                return .failure(.message("Template escapes like %[file] aren't supported yet."))
415            default:
416                text.append(c)
417                consumed = 1
418            }
419            i += consumed
420        }
421        return .success(text)
422    }
423
424    static func strftime(_ format: String, _ context: CaptureContext) -> String {
425        let c = context.calendar.dateComponents([.year, .month, .day, .weekday, .hour, .minute, .second], from: context.now)
426        let days = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]
427        let months = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]
428        var out = ""
429        var chars = format.makeIterator()
430        while let ch = chars.next() {
431            guard ch == "%", let f = chars.next() else {
432                out.append(ch)
433                continue
434            }
435            switch f {
436            case "Y": out += String(format: "%04d", c.year!)
437            case "m": out += String(format: "%02d", c.month!)
438            case "d": out += String(format: "%02d", c.day!)
439            case "e": out += String(format: "%2d", c.day!)
440            case "H": out += String(format: "%02d", c.hour!)
441            case "M": out += String(format: "%02d", c.minute!)
442            case "S": out += String(format: "%02d", c.second!)
443            case "a": out += String(days[c.weekday! - 1].prefix(3))
444            case "A": out += days[c.weekday! - 1]
445            case "b": out += String(months[c.month! - 1].prefix(3))
446            case "B": out += months[c.month! - 1]
447            case "F": out += String(format: "%04d-%02d-%02d", c.year!, c.month!, c.day!)
448            case "R": out += String(format: "%02d:%02d", c.hour!, c.minute!)
449            case "%": out += "%"
450            default: out += "%" + String(f)
451            }
452        }
453        return out
454    }
455
456    // MARK: - Placing
457
458    /// The target file's text after capturing `text`, as `org-capture-finalize` leaves it.
459    /// `now` and `calendar` pick a date tree's day.
460    public static func place(
461        _ text: String, template: CaptureTemplate, in fileText: String, settings: OrgSettings = .default,
462        now: Date = Date(), calendar: Calendar = .current
463    ) -> Result<String, Failure> {
464        placement(text, template: template, in: fileText, settings: settings, now: now, calendar: calendar).map(\.text)
465    }
466
467    /// The new text, and where the captured entry starts in it.
468    public static func placement(
469        _ text: String, template: CaptureTemplate, in fileText: String, settings: OrgSettings = .default,
470        now: Date = Date(), calendar: Calendar = .current
471    ) -> Result<(text: String, start: Int), Failure> {
472        let buffer = EmacsBuffer(fileText, point: 0, settings: settings)
473        let start: Int
474        do {
475            let entryHeading = try locate(template.target, buffer, date: calendar.dateComponents([.year, .month, .day], from: now))
476            let options = template.options
477            switch template.kind {
478            case .entry: start = try placeEntry(text, options: options, heading: entryHeading, buffer)
479            case .plain: start = placePlain(text, options: options, heading: entryHeading, buffer)
480            case .item, .checkitem: start = try placeItem(text, checkbox: template.kind == .checkitem, options: options, heading: entryHeading, buffer)
481            case .tableLine: start = try placeTableLine(text, options: options, heading: entryHeading, buffer)
482            }
483        } catch let error as Failure {
484            return .failure(error)
485        } catch let error as EmacsBuffer.UserError {
486            return .failure(.message(error.message))
487        } catch {
488            return .failure(.message("\(error)"))
489        }
490        // Saving the target adds a final newline, as `require-final-newline` does in Org.
491        var result = buffer.text as String
492        if !result.isEmpty, !result.hasSuffix("\n") { result += "\n" }
493        return .success((result, start))
494    }
495
496    /// `org-capture-set-target-location`: point at the target heading, or nil for a file.
497    static func locate(_ target: CaptureTemplate.Target, _ buffer: EmacsBuffer, date: DateComponents) throws -> Int? {
498        switch target {
499        case .file:
500            return nil
501        case .id(let id):
502            buffer.point = 0
503            guard let match = buffer.searchForward("^[ \\t]*:ID:[ \\t]+" + NSRegularExpression.escapedPattern(for: id) + "[ \\t]*$", caseInsensitive: true) else {
504                throw Failure.message("Cannot find target ID \"\(id)\"")
505            }
506            buffer.point = match[0]!.lowerBound
507            buffer.backToHeadingOrPointMin()
508            return buffer.beforeFirstHeading ? nil : buffer.point
509        case .clock:
510            throw Failure.message("No running clock")
511        case .heading(_, let offset):
512            guard offset <= buffer.length else { throw Failure.message("The clocked entry moved; try again") }
513            buffer.point = buffer.lineStart(offset)
514            guard atHeading(buffer) else { throw Failure.message("The clocked entry moved; try again") }
515            return buffer.point
516        case .datetree(_, let path, let tree):
517            let parent = path.isEmpty ? nil : try locate(.outline(file: "", path: path), buffer, date: date)
518            return Datetree.findCreate(buffer, under: parent, tree: tree, date: date)
519        case .headline(_, let headline):
520            buffer.point = 0
521            let pattern = "^\\*+[ \\t]+(?:[A-Z]+[ \\t]+)?(?:\\[#.\\][ \\t]*)?" + NSRegularExpression.escapedPattern(for: headline) + "(?:[ \\t]+:[\\w@#%:]+:)?[ \\t]*$"
522            if let match = buffer.searchForward(pattern, caseInsensitive: false) {
523                return buffer.lineStart(match[0]!.lowerBound)
524            }
525            buffer.point = buffer.length
526            if !buffer.bolp { buffer.insert("\n") }
527            let start = buffer.point
528            buffer.insert("* " + headline + "\n")
529            return start
530        case .outline(_, let path):
531            var start = 0
532            var end = buffer.length
533            var level = 0
534            var found: Int?
535            for title in path {
536                buffer.point = start
537                let pattern = "^(\\*+)[ \\t]+(?:[A-Z]+[ \\t]+)?(?:\\[#.\\][ \\t]*)?" + NSRegularExpression.escapedPattern(for: title) + "(?:[ \\t]+:[\\w@#%:]+:)?[ \\t]*$"
538                var hit: Int?
539                while let match = buffer.searchForward(pattern, bound: end, caseInsensitive: false) {
540                    let stars = buffer.substring(match[1]!.lowerBound, match[1]!.upperBound).count
541                    if stars > level, level == 0 || stars == level + 1 || found != nil {
542                        hit = buffer.lineStart(match[0]!.lowerBound)
543                        level = stars
544                        break
545                    }
546                }
547                guard let hit else { throw Failure.message("Heading not found on outline path \(path.joined(separator: "/"))") }
548                found = hit
549                buffer.point = hit
550                start = hit
551                end = buffer.saveExcursion { () -> Int in
552                    endOfSubtree(buffer, heading: hit)
553                    return buffer.point
554                }
555            }
556            return found
557        }
558    }
559
560    static func headingLevel(_ buffer: EmacsBuffer, at line: Int) -> Int {
561        buffer.substring(line, buffer.lineEnd(line)).prefix { $0 == "*" }.count
562    }
563
564    static func atHeading(_ buffer: EmacsBuffer) -> Bool {
565        buffer.lookingAtLine("^\\*+ ")
566    }
567
568    /// `outline-next-heading`.
569    static func nextHeading(_ buffer: EmacsBuffer) {
570        if let match = buffer.searchForward("\\n\\*+ ", caseInsensitive: false) {
571            buffer.point = match[0]!.lowerBound + 1
572        } else {
573            buffer.point = buffer.length
574        }
575    }
576
577    /// `org-end-of-subtree t t`: the start of the next heading at the same level or higher.
578    static func endOfSubtree(_ buffer: EmacsBuffer, heading: Int) {
579        let level = headingLevel(buffer, at: heading)
580        buffer.point = heading
581        while true {
582            guard let match = buffer.searchForward("\\n(\\*+) ", caseInsensitive: false) else {
583                buffer.point = buffer.length
584                return
585            }
586            let stars = match[1]!.count
587            if stars <= level {
588                buffer.point = match[0]!.lowerBound + 1
589                return
590            }
591            buffer.point = match[0]!.upperBound
592        }
593    }
594
595    static func previousLineEmpty(_ buffer: EmacsBuffer) -> Bool {
596        let start = buffer.lineStart(buffer.point)
597        guard start > 0 else { return false }
598        let previous = buffer.lineStart(start - 1)
599        return buffer.substring(previous, start - 1).trimmingCharacters(in: .whitespaces).isEmpty
600    }
601
602    /// `org--blank-before-heading-p` with `org-blank-before-new-entry` `(heading . auto)`.
603    static func blankBeforeHeading(_ buffer: EmacsBuffer) -> Bool {
604        buffer.saveExcursion {
605            let before = buffer.searchBackward("^\\*+ ", caseInsensitive: false) == nil && !atHeading(buffer)
606            if before {
607                nextHeading(buffer)
608                if buffer.point >= buffer.length, !atHeading(buffer) { return false }
609            }
610            // `org-back-to-heading`.
611            if !atHeading(buffer) {
612                buffer.point = buffer.lineStart(buffer.point)
613                if !atHeading(buffer), let match = buffer.searchBackward("^\\*+ ", caseInsensitive: false) {
614                    buffer.point = match[0]!.lowerBound
615                }
616            } else {
617                buffer.point = buffer.lineStart(buffer.point)
618            }
619            if buffer.point > 0 { return previousLineEmpty(buffer) }
620            let here = buffer.point
621            nextHeading(buffer)
622            if buffer.point < buffer.length || atHeading(buffer) { return previousLineEmpty(buffer) }
623            buffer.point = here
624            return false
625        }
626    }
627
628    /// `org-back-over-empty-lines`, deleting them.
629    static func deleteEmptyLinesBefore(_ buffer: EmacsBuffer) {
630        let pos = buffer.point
631        var start = pos
632        while start > 0 {
633            let lineStart = buffer.lineStart(start - 1)
634            guard buffer.substring(lineStart, start - 1).trimmingCharacters(in: .whitespaces).isEmpty, start - 1 >= lineStart else { break }
635            if lineStart == start - 1 || buffer.substring(lineStart, start).trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
636                // A blank line precedes; but keep the newline that ends the previous text line.
637                if lineStart == 0 { start = 0; break }
638                start = lineStart
639            } else {
640                break
641            }
642        }
643        if start < pos { buffer.deleteRegion(start, pos) }
644        buffer.point = start
645    }
646
647    /// `org-capture-empty-lines-after` with no lines: blank lines after point go.
648    static func deleteEmptyLinesAfter(_ buffer: EmacsBuffer) {
649        deleteEmptyLinesBefore(buffer)
650        while buffer.looking("[ \\t]*\\n", caseInsensitive: false, setsMatch: false) != nil {
651            let end = buffer.lineEnd(buffer.point) + 1
652            buffer.deleteRegion(buffer.point, end)
653        }
654    }
655
656    /// `org-capture-place-entry`.
657    static func placeEntry(_ entry: String, options: CaptureTemplate.Options, heading: Int?, _ buffer: EmacsBuffer) throws -> Int {
658        let prepend = options.prepend
659        var template = entry
660        if template.range(of: "(?m)^\\*+ ", options: .regularExpression) == nil { template = "* " + template }
661        // `org-capture-verify-tree`: the first heading has the lowest level.
662        let levels = template.components(separatedBy: "\n").compactMap { line -> Int? in
663            let stars = line.prefix { $0 == "*" }.count
664            return stars > 0 && line.dropFirst(stars).first == " " ? stars : nil
665        }
666        if let first = levels.first, levels.contains(where: { $0 < first }) || !template.hasPrefix("*") {
667            throw Failure.message("Template is not a valid Org entry or tree")
668        }
669        var level = 1
670        if let heading {
671            level = headingLevel(buffer, at: heading) + 1
672            buffer.point = heading
673            if prepend {
674                buffer.point = buffer.lineEnd(heading)
675                nextHeading(buffer)
676            } else {
677                endOfSubtree(buffer, heading: heading)
678            }
679        } else if prepend {
680            buffer.point = 0
681            if !atHeading(buffer) { nextHeading(buffer) }
682        } else {
683            buffer.point = buffer.length
684        }
685        if !buffer.bolp { buffer.insert("\n") }
686        let blank = blankBeforeHeading(buffer) && !previousLineEmpty(buffer)
687        deleteEmptyLinesBefore(buffer)
688        buffer.insert(String(repeating: "\n", count: options.before ?? (blank ? 1 : 0)))
689        let start = buffer.point
690        // `org-paste-subtree` at `level`.
691        let oldLevel = levels.first ?? 1
692        let shift = level - oldLevel
693        var shifted = template
694        if shift != 0 {
695            shifted = template.components(separatedBy: "\n").map { line in
696                let stars = line.prefix { $0 == "*" }.count
697                guard stars > 0, line.dropFirst(stars).first == " " else { return line }
698                return String(repeating: "*", count: max(1, stars + shift)) + line.dropFirst(stars)
699            }.joined(separator: "\n")
700        }
701        if !shifted.hasSuffix("\n") { shifted += "\n" }
702        buffer.insert(shifted)
703        emptyLinesAfter(buffer, options.after)
704        return start
705    }
706
707    /// `org-capture-empty-lines-after`: exactly `n` blank lines after point, point kept.
708    static func emptyLinesAfter(_ buffer: EmacsBuffer, _ n: Int) {
709        deleteEmptyLinesAfter(buffer)
710        buffer.saveExcursion { buffer.insert(String(repeating: "\n", count: n)) }
711    }
712
713    /// `org-capture-place-plain-text`.
714    static func placePlain(_ text: String, options: CaptureTemplate.Options, heading: Int?, _ buffer: EmacsBuffer) -> Int {
715        let prepend = options.prepend
716        if let heading {
717            buffer.point = heading
718            if prepend {
719                buffer.point = buffer.lineEnd(heading)
720                if buffer.point < buffer.length { buffer.point += 1 }
721                skipMetaData(buffer)
722            } else {
723                buffer.point = buffer.lineEnd(heading)
724                nextHeading(buffer)
725            }
726        } else {
727            buffer.point = prepend ? 0 : buffer.length
728        }
729        if !buffer.bolp { buffer.insert("\n") }
730        deleteEmptyLinesBefore(buffer)
731        buffer.insert(String(repeating: "\n", count: options.before ?? 0))
732        let start = buffer.point
733        buffer.insert(text.hasSuffix("\n") || text.isEmpty ? text : text + "\n")
734        emptyLinesAfter(buffer, options.after)
735        return start
736    }
737
738    /// `org-capture-place-table-line`, then what `org-capture-finalize` does to the table: the
739    /// first table in the entry, or the file, gets the line, at its end, after its first rule
740    /// with `prepend`, or where `table-line-pos` says; a table is made when there's none.
741    static func placeTableLine(_ filled: String, options: CaptureTemplate.Options, heading: Int?, _ buffer: EmacsBuffer) throws -> Int {
742        let template = filled.trimmingCharacters(in: .whitespacesAndNewlines)
743        // `org-table-border-regexp`: a template that isn't a table line becomes one.
744        let text = template.range(of: "^[ \\t]*[^| \\t]", options: .regularExpression) != nil ? "| " + template : template + "\n"
745        var begin = 0
746        var end = buffer.length
747        if let heading {
748            begin = min(buffer.length, buffer.lineEnd(heading) + 1)
749            buffer.point = buffer.lineEnd(heading)
750            nextHeading(buffer)
751            end = buffer.point
752        }
753        var table: Range<Int>?
754        buffer.point = begin
755        while table == nil, let match = buffer.searchForward("^[ \\t]*\\|[^-]", bound: end, caseInsensitive: false) {
756            buffer.point = match[0]!.lowerBound
757            if buffer.atTable { table = buffer.tableBegin()..<buffer.tableEnd() } else { buffer.point = match[0]!.upperBound }
758        }
759        if table == nil {
760            buffer.point = end
761            if !buffer.bolp { buffer.insert("\n") }
762            let origin = buffer.point
763            buffer.insert("|   |\n|---|\n")
764            table = origin..<buffer.point
765        }
766        let rows = buffer.substring(table!.lowerBound, table!.upperBound).components(separatedBy: "\n")
767        /// The start of the table's line `index`, within the table, as Org narrows to it.
768        func lineStart(_ index: Int) -> Int {
769            guard index > 0 else { return table!.lowerBound }
770            return buffer.saveExcursion {
771                buffer.point = table!.lowerBound
772                return buffer.forwardLine(index) == 0 ? min(buffer.point, table!.upperBound) : table!.upperBound
773            }
774        }
775        if let pos = options.tableLinePos {
776            guard let m = pos.firstMatch(of: /(I+)([-+][0-9]+)/) else { throw Failure.message("Invalid table line specification \"\(pos)\"") }
777            let hlines = rows.indices.filter { rows[$0].range(of: "^[ \\t]*\\|-", options: .regularExpression) != nil }
778            guard m.1.count <= hlines.count else { throw Failure.message("Invalid table line specification \"\(pos)\"") }
779            guard let delta = Int(m.2.replacingOccurrences(of: "+", with: "")), (-999_999...999_999).contains(delta) else {
780                throw Failure.message("Invalid table line specification \"\(pos)\"")
781            }
782            // `forward-line` stops at the table's start, then one more line down.
783            buffer.point = lineStart(max(0, hlines[m.1.count - 1] + delta + (delta < 0 ? 0 : -1)) + 1)
784        } else if options.prepend {
785            if let rule = rows.firstIndex(where: { $0.range(of: "^[ \\t]*\\|-", options: .regularExpression) != nil }) {
786                let data = rows.indices.first { $0 > rule && rows[$0].range(of: "^[ \\t]*\\|[^-]", options: .regularExpression) != nil }
787                buffer.point = data.map(lineStart) ?? table!.upperBound
788            } else {
789                buffer.point = table!.lowerBound
790            }
791        } else {
792            buffer.point = table!.upperBound
793        }
794        if !buffer.bolp { buffer.insert("\n") }
795        let start = buffer.point
796        let lines = text.filter { $0 == "\n" }.count
797        buffer.insert(text)
798        // `org-capture-finalize`: align, or fix the formulas' row references and recalculate.
799        buffer.point = start
800        let row = buffer.substring(0, start).filter { $0 == "\n" }.count
801        if buffer.atTable {
802            if buffer.firstTblfm(after: buffer.tableEnd()) == nil {
803                buffer.tableAlign()
804            } else {
805                buffer.fixFormulas("@", replace: [:], limit: buffer.tableCurrentDataLine() - 1, delta: lines)
806                do {
807                    try buffer.tableRecalculate(all: true)
808                } catch let unsupported as Calc.Unsupported {
809                    throw Failure.message("The table's formulas need Emacs: \(unsupported.reason)")
810                }
811            }
812        }
813        buffer.point = 0
814        buffer.forwardLine(row)
815        return buffer.point
816    }
817
818    /// `org-end-of-meta-data t`: past planning, drawers and the property drawer.
819    static func skipMetaData(_ buffer: EmacsBuffer) {
820        while buffer.point < buffer.length {
821            let line = buffer.substring(buffer.point, buffer.lineEnd(buffer.point))
822            let trimmed = line.trimmingCharacters(in: .whitespaces)
823            if trimmed.range(of: "^(SCHEDULED|DEADLINE|CLOSED):", options: .regularExpression) != nil {
824                buffer.forwardLine(1)
825            } else if trimmed.range(of: "^:[\\w-]+:$", options: .regularExpression) != nil {
826                while buffer.forwardLine(1) == 0 {
827                    if buffer.substring(buffer.point, buffer.lineEnd(buffer.point)).trimmingCharacters(in: .whitespaces).uppercased() == ":END:" {
828                        buffer.forwardLine(1)
829                        break
830                    }
831                }
832            } else {
833                break
834            }
835        }
836    }
837
838    /// `org-capture-place-item`: after the last item of the first list in the entry (the file
839    /// for a file target), or at the end of the entry's text.
840    static func placeItem(_ text: String, checkbox: Bool, options: CaptureTemplate.Options, heading: Int?, _ buffer: EmacsBuffer) throws -> Int {
841        let prepend = options.prepend
842        var item = text.trimmingCharacters(in: .newlines)
843        if item.range(of: "^[ \\t]*([-+*]|[0-9]+[.)])[ \\t]", options: .regularExpression) == nil {
844            let boxed = item.range(of: "^\\[[ X-]\\]", options: .regularExpression) != nil
845            item = "- " + (checkbox && !boxed ? "[ ] " : "") + item
846        } else if checkbox, item.range(of: "^[ \\t]*([-+*]|[0-9]+[.)])[ \\t]+\\[[ X-]\\]", options: .regularExpression) == nil {
847            item = item.replacingOccurrences(of: "^([ \\t]*([-+*]|[0-9]+[.)])[ \\t]+)", with: "$1[ ] ", options: .regularExpression)
848        }
849        let start: Int
850        let end: Int
851        if let heading {
852            start = min(buffer.length, buffer.lineEnd(heading) + 1)
853            buffer.point = buffer.lineEnd(heading)
854            nextHeading(buffer)
855            end = buffer.point
856        } else {
857            start = 0
858            end = buffer.length
859        }
860        // The first list's items in the range.
861        buffer.point = start
862        var items: [(start: Int, end: Int, indent: String)] = []
863        while buffer.point < end {
864            let line = buffer.substring(buffer.point, buffer.lineEnd(buffer.point))
865            // A `*` bullet needs indentation; at the margin it starts a heading.
866            if let m = line.firstMatch(of: /^([ \t]*)([-+*]|[0-9]+[.)])[ \t]/), m.2 != "*" || !m.1.isEmpty {
867                if let first = items.first, String(m.1) != first.indent {
868                    // A nested item belongs to the current one.
869                } else {
870                    items.append((buffer.point, buffer.point, String(m.1)))
871                }
872            } else if !items.isEmpty, !line.trimmingCharacters(in: .whitespaces).isEmpty,
873                      line.prefix(while: { $0 == " " || $0 == "\t" }).count <= (items.first?.indent.count ?? 0) {
874                break
875            }
876            if buffer.forwardLine(1) != 0 { break }
877            if !items.isEmpty { items[items.count - 1].end = buffer.point }
878        }
879        if let first = items.first {
880            let indent = first.indent
881            let indented = item.components(separatedBy: "\n").enumerated().map { $0.offset == 0 ? indent + $0.element : indent + $0.element }.joined(separator: "\n")
882            if prepend {
883                buffer.point = first.start
884            } else {
885                buffer.point = items.last!.end
886                // Before blank lines that end the list.
887                while buffer.point > first.start {
888                    let previous = buffer.lineStart(buffer.point - 1)
889                    guard buffer.substring(previous, buffer.point - 1).trimmingCharacters(in: .whitespaces).isEmpty else { break }
890                    buffer.point = previous
891                }
892            }
893            if !buffer.bolp { buffer.insert("\n") }
894            // In an existing list, at most one blank line between items.
895            if !prepend {
896                deleteEmptyLinesBefore(buffer)
897                buffer.insert(String(repeating: "\n", count: min(1, options.before ?? 0)))
898            }
899            let at = buffer.point
900            buffer.insert(indented + "\n")
901            if prepend { emptyLinesAfter(buffer, min(1, options.after)) }
902            return at
903        } else {
904            buffer.point = prepend ? start : end
905            if !buffer.bolp { buffer.insert("\n") }
906            deleteEmptyLinesBefore(buffer)
907            buffer.insert(String(repeating: "\n", count: options.before ?? 0))
908            let at = buffer.point
909            buffer.insert(item + "\n")
910            emptyLinesAfter(buffer, options.after)
911            return at
912        }
913    }
914}