krz/orgstar

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

Sources/OrgCore/Commands/EmacsBuffer.swift

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

392 lines · 14608 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        init(_ position: Int) { self.position = position }
 13    }
 14
 15    struct UserError: Error {
 16        let message: String
 17    }
 18
 19    private(set) var text: NSMutableString
 20    var point: Int
 21    private var markers: [Marker] = []
 22    let settings: OrgSettings
 23
 24    init(_ text: String, point: Int, settings: OrgSettings = .default) {
 25        self.text = NSMutableString(string: text)
 26        self.point = point
 27        self.settings = settings
 28    }
 29
 30    var string: String { text as String }
 31    var length: Int { text.length }
 32
 33    func character(at offset: Int) -> unichar? {
 34        offset >= 0 && offset < length ? text.character(at: offset) : nil
 35    }
 36
 37    static let newline = unichar(10)
 38
 39    // MARK: - Markers
 40
 41    func marker(_ position: Int) -> Marker {
 42        let marker = Marker(position)
 43        markers.append(marker)
 44        return marker
 45    }
 46
 47    func release(_ marker: Marker) {
 48        markers.removeAll { $0 === marker }
 49    }
 50
 51    /// Runs `body` and puts point back where it was, following edits as a marker does.
 52    @discardableResult
 53    func saveExcursion<T>(_ body: () throws -> T) rethrows -> T {
 54        let saved = marker(point)
 55        defer {
 56            point = saved.position
 57            release(saved)
 58        }
 59        return try body()
 60    }
 61
 62    // MARK: - Editing
 63
 64    /// `insert` at point.
 65    func insert(_ string: String) {
 66        let count = (string as NSString).length
 67        guard count > 0 else { return }
 68        text.insert(string, at: point)
 69        for marker in markers where marker.position > point { marker.position += count }
 70        point += count
 71    }
 72
 73    /// `insert-before-markers` at point: markers at point move past the text too.
 74    func insertBeforeMarkers(_ string: String) {
 75        let count = (string as NSString).length
 76        guard count > 0 else { return }
 77        text.insert(string, at: point)
 78        for marker in markers where marker.position >= point { marker.position += count }
 79        point += count
 80    }
 81
 82    func deleteRegion(_ start: Int, _ end: Int) {
 83        let (lower, upper) = (min(start, end), max(start, end))
 84        guard upper > lower else { return }
 85        text.deleteCharacters(in: NSRange(lower..<upper))
 86        func map(_ position: Int) -> Int {
 87            position <= lower ? position : position >= upper ? position - (upper - lower) : lower
 88        }
 89        point = map(point)
 90        for marker in markers { marker.position = map(marker.position) }
 91    }
 92
 93    func substring(_ start: Int, _ end: Int) -> String {
 94        text.substring(with: NSRange(min(start, end)..<max(start, end)))
 95    }
 96
 97    /// `replace-match` over `range`: positions strictly inside go to its start, positions at
 98    /// or after its end shift with the text.
 99    func replace(_ range: Range<Int>, with replacement: String) {
100        let delta = (replacement as NSString).length - range.count
101        text.replaceCharacters(in: NSRange(range), with: replacement)
102        func map(_ position: Int) -> Int {
103            if position >= range.upperBound, position > range.lowerBound { return position + delta }
104            return position > range.lowerBound ? range.lowerBound : position
105        }
106        point = map(point)
107        for marker in markers { marker.position = map(marker.position) }
108    }
109
110    // MARK: - Motion
111
112    var bolp: Bool { point == 0 || character(at: point - 1) == Self.newline }
113    var eolp: Bool { point == length || character(at: point) == Self.newline }
114    var bobp: Bool { point == 0 }
115    var eobp: Bool { point == length }
116
117    func lineStart(_ offset: Int) -> Int {
118        var at = min(offset, length)
119        while at > 0, character(at: at - 1) != Self.newline { at -= 1 }
120        return at
121    }
122
123    func lineEnd(_ offset: Int) -> Int {
124        var at = max(offset, 0)
125        while at < length, character(at: at) != Self.newline { at += 1 }
126        return at
127    }
128
129    func beginningOfLine() { point = lineStart(point) }
130    func endOfLine() { point = lineEnd(point) }
131
132    /// `forward-line`: to the start of the line `n` lines away, stopping at the buffer ends.
133    @discardableResult
134    func forwardLine(_ n: Int) -> Int {
135        point = lineStart(point)
136        var remaining = n
137        while remaining > 0 {
138            let end = lineEnd(point)
139            if end == length {
140                point = length
141                return remaining
142            }
143            point = end + 1
144            remaining -= 1
145        }
146        while remaining < 0 {
147            if point == 0 { return remaining }
148            point = lineStart(point - 1)
149            remaining += 1
150        }
151        return 0
152    }
153
154    func skipForward(_ set: Set<unichar>) {
155        while let c = character(at: point), set.contains(c) { point += 1 }
156    }
157
158    func skipBackward(_ set: Set<unichar>) {
159        while let c = character(at: point - 1), set.contains(c) { point -= 1 }
160    }
161
162    static let blanks: Set<unichar> = [32, 9]
163    static let whitespace: Set<unichar> = [32, 9, 10, 13]
164
165    var currentColumn: Int { column(of: substring(lineStart(point), point)) }
166
167    /// `move-to-column`: as far as `target` on this line, or the line's end.
168    func moveToColumn(_ target: Int) {
169        let start = lineStart(point)
170        let line = LineBuffer(substring(start, lineEnd(point)), caret: nil)
171        point = start + line.offset(ofColumn: target)
172    }
173
174    // MARK: - Outline
175
176    /// Stars of a heading line starting at `lineStart`, or nil.
177    func headingLevel(atLine lineStart: Int) -> Int? {
178        var at = lineStart
179        while character(at: at) == 42 { at += 1 }
180        return at > lineStart && character(at: at) == 32 ? at - lineStart : nil
181    }
182
183    var atHeading: Bool { headingLevel(atLine: lineStart(point)) != nil }
184
185    /// Start of the first heading line after `offset`'s line, or nil.
186    func nextHeadingStart(after offset: Int) -> Int? {
187        var line = lineEnd(offset)
188        while line < length {
189            line += 1
190            if headingLevel(atLine: line) != nil { return line }
191            line = lineEnd(line)
192        }
193        return nil
194    }
195
196    /// Start of the last heading line before `offset`'s line, or nil.
197    func previousHeadingStart(before offset: Int) -> Int? {
198        var line = lineStart(offset)
199        while line > 0 {
200            line = lineStart(line - 1)
201            if headingLevel(atLine: line) != nil { return line }
202        }
203        return nil
204    }
205
206    /// `org-outline-level`: the level of the heading at or before point's line, or 0.
207    var outlineLevel: Int {
208        let start = lineStart(point)
209        if let level = headingLevel(atLine: start) { return level }
210        return previousHeadingStart(before: start).flatMap { headingLevel(atLine: $0) } ?? 0
211    }
212
213    var currentLevel: Int? { outlineLevel > 0 ? outlineLevel : nil }
214
215    var beforeFirstHeading: Bool { outlineLevel == 0 }
216
217    /// `org-back-to-heading`.
218    @discardableResult
219    func backToHeading() throws -> Int {
220        let start = lineStart(point)
221        if headingLevel(atLine: start) != nil {
222            point = start
223        } else if let previous = previousHeadingStart(before: start) {
224            point = previous
225        } else {
226            throw UserError(message: "Before first headline at position \(point + 1) in buffer")
227        }
228        return point
229    }
230
231    /// `outline-next-heading`: the next heading line, or the buffer end. True when found.
232    @discardableResult
233    func outlineNextHeading() -> Bool {
234        if bolp, !eobp { point += 1 }
235        // A heading line starting at point counts, as `re-search-forward "^\\*+ "` would.
236        if bolp, headingLevel(atLine: point) != nil { return true }
237        guard let next = nextHeadingStart(after: point) else {
238            point = length
239            return false
240        }
241        point = next
242        return true
243    }
244
245    /// `outline-previous-heading`: the previous heading line, or the buffer start.
246    func outlinePreviousHeading() {
247        // `re-search-backward` matches must end by point: a heading on this line counts once
248        // point is past its stars and space.
249        let start = lineStart(point)
250        if let level = headingLevel(atLine: start), point >= start + level + 1 {
251            point = start
252            return
253        }
254        point = previousHeadingStart(before: point) ?? 0
255    }
256
257    /// `org-end-of-subtree`.
258    @discardableResult
259    func endOfSubtree(toHeading: Bool = false) -> Int {
260        if beforeFirstHeading {
261            point = length
262        } else {
263            let start = try! backToHeading()
264            let level = headingLevel(atLine: start)!
265            var next = nextHeadingStart(after: start)
266            while let candidate = next, headingLevel(atLine: candidate)! > level {
267                next = nextHeadingStart(after: candidate)
268            }
269            point = next ?? length
270        }
271        if !toHeading, let c = character(at: point - 1), c == 10 || c == 13 {
272            point -= 1
273            skipBackward([10, 13, 9, 32])
274        }
275        return point
276    }
277
278    /// `org-get-next-sibling`.
279    func nextSibling() -> Int? {
280        let level = outlineLevel
281        outlineNextHeading()
282        while !eobp, outlineLevel > level { outlineNextHeading() }
283        return eobp || outlineLevel < level ? nil : point
284    }
285
286    /// `org-get-previous-sibling`.
287    func previousSibling() -> Int? {
288        let start = point
289        let level = outlineLevel
290        outlinePreviousHeading()
291        guard point != start, atHeading else { return nil }
292        while outlineLevel > level, !bobp { outlinePreviousHeading() }
293        return outlineLevel < level ? nil : point
294    }
295
296    /// `org--line-empty-p -1`.
297    var previousLineEmpty: Bool {
298        guard !bobp else { return false }
299        return saveExcursion {
300            forwardLine(-1)
301            skipForward(Self.blanks)
302            return eolp
303        }
304    }
305
306    // MARK: - Regular expressions
307
308    /// `looking-at`: the groups of `pattern` matched at point (`^` and `$` match at lines),
309    /// or nil. Unmatched groups are nil.
310    func lookingAt(_ pattern: String) -> [Range<Int>?]? {
311        let regex = try! NSRegularExpression(pattern: pattern, options: [.anchorsMatchLines])
312        guard let match = regex.firstMatch(in: string, options: [.anchored], range: NSRange(point..<length)) else { return nil }
313        return (0..<match.numberOfRanges).map { i in
314            let range = match.range(at: i)
315            return range.location == NSNotFound ? nil : range.location..<NSMaxRange(range)
316        }
317    }
318
319    /// `org-complex-heading-regexp`: 1 stars, 2 keyword, 3 priority, 4 title, 5 tags.
320    var complexHeadingPattern: String {
321        "^(\\*+)(?: +(\(keywordAlternation)))?(?: +(\\[#(?:[A-Z]|[0-9]|[1-5][0-9]|6[0-4])\\]))?(?: +(.*?))??(?:[ \t]+(:[[:alnum:]_@#%:]+:))?[ \t]*$"
322    }
323
324    /// `org-todo-line-regexp`: 1 stars, 2 keyword, 3 title.
325    var todoLinePattern: String {
326        "^(\\*+)(?: +(\(keywordAlternation)))?(?: +(.*?))?[ \t]*$"
327    }
328
329    private var keywordAlternation: String {
330        keywordsInOrder.map { NSRegularExpression.escapedPattern(for: $0) }.joined(separator: "|")
331    }
332
333    /// `org-todo-keywords-1`.
334    var keywordsInOrder: [String] {
335        settings.todoSequences.flatMap { ($0.active + $0.done).map(\.name) }
336    }
337
338    // MARK: - Tags
339
340    /// `org-align-tags` on the line at `offset`, moving point and markers on the line as the
341    /// tag alignment rules say.
342    func alignTags(onLineAt offset: Int) {
343        let start = lineStart(offset)
344        let end = lineEnd(offset)
345        let line = substring(start, end)
346        var plain = LineBuffer(line, caret: nil)
347        OrgCore.alignTags(&plain)
348        guard plain.string != line else { return }
349        func map(_ position: Int) -> Int {
350            guard position >= start, position <= end else {
351                return position > end ? position + (plain.string as NSString).length - (line as NSString).length : position
352            }
353            var buffer = LineBuffer(line, caret: position - start)
354            OrgCore.alignTags(&buffer)
355            return start + buffer.caret!
356        }
357        let newPoint = map(point)
358        let newMarkers = markers.map { map($0.position) }
359        text.replaceCharacters(in: NSRange(start..<end), with: plain.string)
360        point = newPoint
361        for (marker, position) in zip(markers, newMarkers) { marker.position = position }
362    }
363}
364
365/// Runs a buffer-editing body against a context and turns the result into one minimal edit
366/// and the new caret. A user error becomes `.failed`.
367func commitBuffer(_ context: EditContext, _ body: (EmacsBuffer) throws -> Void) -> CommandStep {
368    let buffer = EmacsBuffer(context.text, point: context.caret, settings: context.tree.settings)
369    do {
370        try body(buffer)
371    } catch let error as EmacsBuffer.UserError {
372        return .failed(error.message)
373    } catch {
374        return .failed("\(error)")
375    }
376    let old = context.text as NSString
377    let new = buffer.text
378    var prefix = 0
379    let shorter = min(old.length, new.length)
380    while prefix < shorter, old.character(at: prefix) == new.character(at: prefix) { prefix += 1 }
381    var suffix = 0
382    while suffix < shorter - prefix, old.character(at: old.length - 1 - suffix) == new.character(at: new.length - 1 - suffix) { suffix += 1 }
383    // Keep surrogate pairs whole.
384    while prefix > 0, prefix < old.length, UTF16.isTrailSurrogate(old.character(at: prefix)) { prefix -= 1 }
385    while suffix > 0, UTF16.isTrailSurrogate(old.character(at: old.length - suffix)) { suffix -= 1 }
386    let caret = buffer.point
387    guard old.length != new.length || prefix != old.length else {
388        return .commit(EditResult(baseRevision: context.revision, edits: [], selection: [caret..<caret]))
389    }
390    let edit = TextEdit(range: prefix..<(old.length - suffix), replacement: new.substring(with: NSRange(prefix..<(new.length - suffix))))
391    return .commit(EditResult(baseRevision: context.revision, edits: [edit], selection: [caret..<caret]))
392}