krz/orgstar

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

Sources/OrgCore/Commands/EmacsBuffer.swift

ab6ccec56eca03d0dadf2c9332aab10c4490eb62
orgstar/Sources/OrgCore/Commands/EmacsBuffer.swift history · blame · raw

510 lines · 20083 bytes

  1import Foundation
  2
  3/// A whole-text buffer with Emacs's point and marker rules, for porting org commands that
  4/// edit several lines. Offsets are UTF-16, as everywhere in `OrgCore`.
  5///
  6/// Inserting at point moves point past the text; a marker at the insertion point stays before
  7/// it unless the insertion is `insertBeforeMarkers`. Deleting a range moves positions inside
  8/// it to its start. `saveExcursion` restores point from a marker, as Emacs does.
  9final class EmacsBuffer {
 10    final class Marker {
 11        var position: Int
 12        /// Inserting at the marker moves it past the text, as `set-marker-insertion-type` t.
 13        let advances: Bool
 14        init(_ position: Int, advances: Bool = false) {
 15            self.position = position
 16            self.advances = advances
 17        }
 18    }
 19
 20    struct UserError: Error {
 21        let message: String
 22    }
 23
 24    /// The command needs an answer first.
 25    struct Ask: Error {
 26        let prompt: Prompt
 27    }
 28
 29    private(set) var text: NSMutableString
 30    var point: Int
 31    private var markers: [Marker] = []
 32    let settings: OrgSettings
 33    private var visibleCache: [String: VisibleLine] = [:]
 34    /// Groups of the last successful match, as Emacs's match data.
 35    var lastMatch: [Range<Int>?] = []
 36    /// The clock and calendar commands see.
 37    var now = Date()
 38    var calendar = Calendar.current
 39    /// `org-last-inserted-timestamp`.
 40    var lastInsertedTimestamp: String?
 41    /// Shown after the command commits, as `message`.
 42    var message: String?
 43
 44    let options: EditingOptions
 45
 46    init(_ text: String, point: Int, settings: OrgSettings = .default, options: EditingOptions = .org) {
 47        self.text = NSMutableString(string: text)
 48        self.point = point
 49        self.settings = settings
 50        self.options = options
 51        unchangedPrefix = self.text.length
 52        unchangedSuffix = self.text.length
 53    }
 54
 55    /// Lengths of the start and end of the text no edit has touched, so the result's diff can
 56    /// look only between them.
 57    private(set) var unchangedPrefix: Int
 58    private(set) var unchangedSuffix: Int
 59
 60    private func touched(_ range: Range<Int>) {
 61        unchangedPrefix = min(unchangedPrefix, range.lowerBound)
 62        unchangedSuffix = min(unchangedSuffix, length - range.upperBound)
 63    }
 64
 65    /// The text as a `String` backed by an immutable `NSString`, copied once per change rather
 66    /// than per regexp search; regexps read it without converting.
 67    var string: String {
 68        if let cachedString { return cachedString as String }
 69        let copy = text.copy() as! NSString
 70        cachedString = copy
 71        return copy as String
 72    }
 73
 74    private var cachedString: NSString?
 75    var length: Int { text.length }
 76
 77    func character(at offset: Int) -> unichar? {
 78        offset >= 0 && offset < length ? text.character(at: offset) : nil
 79    }
 80
 81    static let newline = unichar(10)
 82
 83    // MARK: - Markers
 84
 85    func marker(_ position: Int, advances: Bool = false) -> Marker {
 86        let marker = Marker(position, advances: advances)
 87        markers.append(marker)
 88        return marker
 89    }
 90
 91    func release(_ marker: Marker) {
 92        markers.removeAll { $0 === marker }
 93    }
 94
 95    /// Runs `body` and puts point back where it was, following edits as a marker does.
 96    @discardableResult
 97    func saveExcursion<T>(_ body: () throws -> T) rethrows -> T {
 98        let saved = marker(point)
 99        defer {
100            point = saved.position
101            release(saved)
102        }
103        return try body()
104    }
105
106    // MARK: - Editing
107
108    /// `insert` at point.
109    func insert(_ string: String) {
110        let count = (string as NSString).length
111        guard count > 0 else { return }
112        touched(point..<point)
113        text.insert(string, at: point)
114        cachedString = nil
115        for marker in markers where marker.position > point || (marker.advances && marker.position == point) { marker.position += count }
116        point += count
117    }
118
119    /// `insert-before-markers` at point: markers at point move past the text too.
120    func insertBeforeMarkers(_ string: String) {
121        let count = (string as NSString).length
122        guard count > 0 else { return }
123        touched(point..<point)
124        text.insert(string, at: point)
125        cachedString = nil
126        for marker in markers where marker.position >= point { marker.position += count }
127        point += count
128    }
129
130    func deleteRegion(_ start: Int, _ end: Int) {
131        let (lower, upper) = (min(start, end), max(start, end))
132        guard upper > lower else { return }
133        touched(lower..<upper)
134        text.deleteCharacters(in: NSRange(lower..<upper))
135        cachedString = nil
136        func map(_ position: Int) -> Int {
137            position <= lower ? position : position >= upper ? position - (upper - lower) : lower
138        }
139        point = map(point)
140        for marker in markers { marker.position = map(marker.position) }
141    }
142
143    func substring(_ start: Int, _ end: Int) -> String {
144        text.substring(with: NSRange(min(start, end)..<max(start, end)))
145    }
146
147    /// `replace-match` over `range`: positions strictly inside go to its start, positions at
148    /// or after its end shift with the text.
149    func replace(_ range: Range<Int>, with replacement: String) {
150        let delta = (replacement as NSString).length - range.count
151        touched(range)
152        text.replaceCharacters(in: NSRange(range), with: replacement)
153        cachedString = nil
154        func map(_ position: Int) -> Int {
155            if position >= range.upperBound, position > range.lowerBound { return position + delta }
156            return position > range.lowerBound ? range.lowerBound : position
157        }
158        point = map(point)
159        for marker in markers { marker.position = map(marker.position) }
160    }
161
162    // MARK: - Motion
163
164    var bolp: Bool { point == 0 || character(at: point - 1) == Self.newline }
165    var eolp: Bool { point == length || character(at: point) == Self.newline }
166    var bobp: Bool { point == 0 }
167    var eobp: Bool { point == length }
168
169    func lineStart(_ offset: Int) -> Int {
170        var at = min(offset, length)
171        while at > 0, character(at: at - 1) != Self.newline { at -= 1 }
172        return at
173    }
174
175    func lineEnd(_ offset: Int) -> Int {
176        var at = max(offset, 0)
177        while at < length, character(at: at) != Self.newline { at += 1 }
178        return at
179    }
180
181    func beginningOfLine() { point = lineStart(point) }
182    func endOfLine() { point = lineEnd(point) }
183
184    /// `forward-line`: to the start of the line `n` lines away, stopping at the buffer ends.
185    @discardableResult
186    func forwardLine(_ n: Int) -> Int {
187        point = lineStart(point)
188        var remaining = n
189        while remaining > 0 {
190            let end = lineEnd(point)
191            if end == length {
192                point = length
193                return remaining
194            }
195            point = end + 1
196            remaining -= 1
197        }
198        while remaining < 0 {
199            if point == 0 { return remaining }
200            point = lineStart(point - 1)
201            remaining += 1
202        }
203        return 0
204    }
205
206    func skipForward(_ set: Set<unichar>) {
207        while let c = character(at: point), set.contains(c) { point += 1 }
208    }
209
210    func skipBackward(_ set: Set<unichar>) {
211        while let c = character(at: point - 1), set.contains(c) { point -= 1 }
212    }
213
214    static let blanks: Set<unichar> = [32, 9]
215    static let whitespace: Set<unichar> = [32, 9, 10, 13]
216
217    /// Display column of point, counting only what Emacs shows.
218    var currentColumn: Int {
219        let start = lineStart(point)
220        return visibleLine(start).column(at: point - start)
221    }
222
223    /// The display widths of the line starting at `start`, seen with three lines on either
224    /// side so that links spanning lines are whole. Cached by that text.
225    func visibleLine(_ start: Int) -> VisibleLine {
226        let (from, to) = saveExcursion { () -> (Int, Int) in
227            point = start
228            forwardLine(-3)
229            let from = point
230            point = start
231            forwardLine(3)
232            return (from, lineEnd(point))
233        }
234        let context = substring(from, to)
235        let range = (start - from)..<(lineEnd(start) - from)
236        let key = context + "\u{0}\(range.lowerBound)"
237        if let cached = visibleCache[key] { return cached }
238        let line = VisibleLine(context, line: range, options: options, settings: settings)
239        if visibleCache.count > 256 { visibleCache.removeAll() }
240        visibleCache[key] = line
241        return line
242    }
243
244    /// `move-to-column`: as far as `target` on this line, or the line's end. With `force`, a
245    /// tab spanning `target` becomes spaces (`indent-tabs-mode` nil) so point lands on it.
246    func moveToColumn(_ target: Int, force: Bool = false) {
247        let start = lineStart(point)
248        point = start + visibleLine(start).offset(ofColumn: target)
249        guard force, currentColumn > target, character(at: point - 1) == 9 else { return }
250        let tabEnd = currentColumn
251        point -= 1
252        let tabStart = currentColumn
253        deleteRegion(point, point + 1)
254        insert(String(repeating: " ", count: tabEnd - tabStart))
255        point -= tabEnd - target
256    }
257
258    // MARK: - Outline
259
260    /// Stars of a heading line starting at `lineStart`, or nil.
261    func headingLevel(atLine lineStart: Int) -> Int? {
262        var at = lineStart
263        while character(at: at) == 42 { at += 1 }
264        return at > lineStart && character(at: at) == 32 ? at - lineStart : nil
265    }
266
267    var atHeading: Bool { headingLevel(atLine: lineStart(point)) != nil }
268
269    /// Start of the first heading line after `offset`'s line, or nil.
270    func nextHeadingStart(after offset: Int) -> Int? {
271        var line = lineEnd(offset)
272        while line < length {
273            line += 1
274            if headingLevel(atLine: line) != nil { return line }
275            line = lineEnd(line)
276        }
277        return nil
278    }
279
280    /// Start of the last heading line before `offset`'s line, or nil.
281    func previousHeadingStart(before offset: Int) -> Int? {
282        var line = lineStart(offset)
283        while line > 0 {
284            line = lineStart(line - 1)
285            if headingLevel(atLine: line) != nil { return line }
286        }
287        return nil
288    }
289
290    /// `org-outline-level`: the level of the heading at or before point's line, or 0.
291    var outlineLevel: Int {
292        let start = lineStart(point)
293        if let level = headingLevel(atLine: start) { return level }
294        return previousHeadingStart(before: start).flatMap { headingLevel(atLine: $0) } ?? 0
295    }
296
297    var currentLevel: Int? { outlineLevel > 0 ? outlineLevel : nil }
298
299    var beforeFirstHeading: Bool { outlineLevel == 0 }
300
301    /// `org-back-to-heading`.
302    @discardableResult
303    func backToHeading() throws -> Int {
304        let start = lineStart(point)
305        if headingLevel(atLine: start) != nil {
306            point = start
307        } else if let previous = previousHeadingStart(before: start) {
308            point = previous
309        } else {
310            throw UserError(message: "Before first headline at position \(point + 1) in buffer")
311        }
312        return point
313    }
314
315    /// `outline-next-heading`: the next heading line, or the buffer end. True when found.
316    @discardableResult
317    func outlineNextHeading() -> Bool {
318        if bolp, !eobp { point += 1 }
319        // A heading line starting at point counts, as `re-search-forward "^\\*+ "` would.
320        if bolp, headingLevel(atLine: point) != nil { return true }
321        guard let next = nextHeadingStart(after: point) else {
322            point = length
323            return false
324        }
325        point = next
326        return true
327    }
328
329    /// `outline-previous-heading`: the previous heading line, or the buffer start.
330    func outlinePreviousHeading() {
331        // `re-search-backward` matches must end by point: a heading on this line counts once
332        // point is past its stars and space.
333        let start = lineStart(point)
334        if let level = headingLevel(atLine: start), point >= start + level + 1 {
335            point = start
336            return
337        }
338        point = previousHeadingStart(before: point) ?? 0
339    }
340
341    /// `org-end-of-subtree`.
342    @discardableResult
343    func endOfSubtree(toHeading: Bool = false) -> Int {
344        if beforeFirstHeading {
345            point = length
346        } else {
347            let start = try! backToHeading()
348            let level = headingLevel(atLine: start)!
349            var next = nextHeadingStart(after: start)
350            while let candidate = next, headingLevel(atLine: candidate)! > level {
351                next = nextHeadingStart(after: candidate)
352            }
353            point = next ?? length
354        }
355        if !toHeading, let c = character(at: point - 1), c == 10 || c == 13 {
356            point -= 1
357            skipBackward([10, 13, 9, 32])
358        }
359        return point
360    }
361
362    /// `org-get-next-sibling`.
363    func nextSibling() -> Int? {
364        let level = outlineLevel
365        outlineNextHeading()
366        while !eobp, outlineLevel > level { outlineNextHeading() }
367        return eobp || outlineLevel < level ? nil : point
368    }
369
370    /// `org-get-previous-sibling`.
371    func previousSibling() -> Int? {
372        let start = point
373        let level = outlineLevel
374        outlinePreviousHeading()
375        guard point != start, atHeading else { return nil }
376        while outlineLevel > level, !bobp { outlinePreviousHeading() }
377        return outlineLevel < level ? nil : point
378    }
379
380    /// `org--line-empty-p -1`.
381    var previousLineEmpty: Bool {
382        guard !bobp else { return false }
383        return saveExcursion {
384            forwardLine(-1)
385            skipForward(Self.blanks)
386            return eolp
387        }
388    }
389
390    // MARK: - Regular expressions
391
392    static let lookingOptions: NSRegularExpression.MatchingOptions = [.anchored, .withoutAnchoringBounds, .withTransparentBounds]
393    static let searchOptions: NSRegularExpression.MatchingOptions = [.withoutAnchoringBounds, .withTransparentBounds]
394    nonisolated(unsafe) private static var cache: [String: NSRegularExpression] = [:]
395    private static let cacheLock = NSLock()
396
397    /// A compiled Emacs-style pattern: `^` and `$` match at line boundaries, and a leading `^`
398    /// also matches at the end of a buffer that ends with a newline, as in Emacs.
399    static func regex(_ pattern: String, caseInsensitive: Bool) -> NSRegularExpression {
400        let key = (caseInsensitive ? "i" : "c") + pattern
401        return cacheLock.withLock {
402            if let cached = cache[key] { return cached }
403            let source = pattern.hasPrefix("^") ? "(?:(?<=\\n)|(?<![\\s\\S]))" + pattern.dropFirst() : pattern
404            let compiled = try! NSRegularExpression(pattern: source, options: caseInsensitive ? [.anchorsMatchLines, .caseInsensitive] : [.anchorsMatchLines])
405            cache[key] = compiled
406            return compiled
407        }
408    }
409
410    /// `looking-at`: the groups of `pattern` matched at point (`^` and `$` match at lines),
411    /// or nil. Unmatched groups are nil.
412    func lookingAt(_ pattern: String) -> [Range<Int>?]? {
413        let regex = Self.regex(pattern, caseInsensitive: false)
414        guard let match = regex.firstMatch(in: string, options: Self.lookingOptions, range: NSRange(point..<length)) else { return nil }
415        lastMatch = (0..<match.numberOfRanges).map { i in
416            let range = match.range(at: i)
417            return range.location == NSNotFound ? nil : range.location..<NSMaxRange(range)
418        }
419        return lastMatch
420    }
421
422    /// `org-complex-heading-regexp`: 1 stars, 2 keyword, 3 priority, 4 title, 5 tags.
423    var complexHeadingPattern: String {
424        "^(\\*+)(?: +(\(keywordAlternation)))?(?: +(\\[#(?:[A-Z]|[0-9]|[1-5][0-9]|6[0-4])\\]))?(?: +(.*?))??(?:[ \t]+(:[[:alnum:]_@#%:]+:))?[ \t]*$"
425    }
426
427    /// `org-todo-line-regexp`: 1 stars, 2 keyword, 3 title.
428    var todoLinePattern: String {
429        "^(\\*+)(?: +(\(keywordAlternation)))?(?: +(.*?))?[ \t]*$"
430    }
431
432    private var keywordAlternation: String {
433        keywordsInOrder.map { NSRegularExpression.escapedPattern(for: $0) }.joined(separator: "|")
434    }
435
436    /// `org-todo-keywords-1`.
437    var keywordsInOrder: [String] {
438        settings.todoSequences.flatMap { ($0.active + $0.done).map(\.name) }
439    }
440
441    // MARK: - Tags
442
443    /// `org-align-tags` on the line at `offset`, moving point and markers on the line as the
444    /// tag alignment rules say.
445    func alignTags(onLineAt offset: Int) {
446        let start = lineStart(offset)
447        let end = lineEnd(offset)
448        let line = substring(start, end)
449        var plain = LineBuffer(line, caret: nil)
450        OrgCore.alignTags(&plain, options: options)
451        guard plain.string != line else { return }
452        func map(_ position: Int) -> Int {
453            guard position >= start, position <= end else {
454                return position > end ? position + (plain.string as NSString).length - (line as NSString).length : position
455            }
456            var buffer = LineBuffer(line, caret: position - start)
457            OrgCore.alignTags(&buffer, options: options)
458            return start + buffer.caret!
459        }
460        let newPoint = map(point)
461        let newMarkers = markers.map { map($0.position) }
462        touched(start..<end)
463        text.replaceCharacters(in: NSRange(start..<end), with: plain.string)
464        cachedString = nil
465        point = newPoint
466        for (marker, position) in zip(markers, newMarkers) { marker.position = position }
467    }
468}
469
470/// Runs a buffer-editing body against a context and turns the result into one minimal edit
471/// and the new caret, with the buffer's message. A user error becomes `.failed`.
472func commitBuffer(_ context: EditContext, _ body: (EmacsBuffer) throws -> Void) -> CommandStep {
473    let buffer = EmacsBuffer(context.text, point: context.caret, settings: context.tree.settings, options: context.options)
474    buffer.now = context.now
475    buffer.calendar = context.calendar
476    do {
477        try body(buffer)
478    } catch let error as EmacsBuffer.UserError {
479        return .failed(error.message)
480    } catch let ask as EmacsBuffer.Ask {
481        return .prompt(ask.prompt)
482    } catch {
483        return .failed("\(error)")
484    }
485    // Only the span between the untouched start and end can differ.
486    let oldText = context.text.utf16
487    let oldCount = oldText.count
488    let newCount = buffer.length
489    let lower = min(buffer.unchangedPrefix, oldCount, newCount)
490    let window = max(0, min(buffer.unchangedSuffix, oldCount - lower, newCount - lower))
491    let old = Array(oldText[oldText.index(oldText.startIndex, offsetBy: lower)..<oldText.index(oldText.startIndex, offsetBy: oldCount - window)])
492    var newBuffer = [unichar](repeating: 0, count: newCount - window - lower)
493    buffer.text.getCharacters(&newBuffer, range: NSRange(lower..<(newCount - window)))
494    let new = newBuffer
495    var prefix = 0
496    let shorter = min(old.count, new.count)
497    while prefix < shorter, old[prefix] == new[prefix] { prefix += 1 }
498    var suffix = 0
499    while suffix < shorter - prefix, old[old.count - 1 - suffix] == new[new.count - 1 - suffix] { suffix += 1 }
500    // Keep surrogate pairs whole.
501    while prefix > 0, prefix < old.count, UTF16.isTrailSurrogate(old[prefix]) { prefix -= 1 }
502    while suffix > 0, UTF16.isTrailSurrogate(old[old.count - suffix]) { suffix -= 1 }
503    let caret = buffer.point
504    let effects: [Effect] = buffer.message.map { [.message($0)] } ?? []
505    guard old.count != new.count || prefix != old.count else {
506        return .commit(EditResult(baseRevision: context.revision, edits: [], selection: [caret..<caret], effects: effects))
507    }
508    let edit = TextEdit(range: (lower + prefix)..<(lower + old.count - suffix), replacement: String(decoding: new[prefix..<(new.count - suffix)], as: UTF16.self))
509    return .commit(EditResult(baseRevision: context.revision, edits: [edit], selection: [caret..<caret], effects: effects))
510}