krz/orgstar

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

Sources/OrgDocument/DocumentState.swift

abca8dc581329472f7fb8ccd3af147e5fb882f29
orgstar/Sources/OrgDocument/DocumentState.swift history · blame · raw

175 lines · 7184 bytes

  1import Foundation
  2import OrgCore
  3
  4/// One open file: its text, tree, revision, undo history, and the bytes last read from or
  5/// written to disk, which are the base for merging external changes.
  6public struct DocumentState: Sendable {
  7    public enum EditError: Error, Equatable {
  8        case readOnly
  9        case staleRevision
 10        case overlappingEdits
 11    }
 12
 13    public enum ExternalChange: Sendable, Equatable {
 14        case unchanged
 15        /// The buffer had no edits and now holds the disk version. Edits are in the old text's
 16        /// coordinates, for mapping view state.
 17        case reloaded([TextEdit])
 18        /// The disk version was merged into the edited buffer.
 19        case merged([TextEdit])
 20        /// Nothing changed in the buffer.
 21        case conflict([MergeConflict])
 22    }
 23
 24    /// The bytes on disk as of the last read or write.
 25    public private(set) var source: SourceText
 26    public private(set) var text: String
 27    public private(set) var tree: OrgTree
 28    /// Increases on every change to `text`.
 29    public private(set) var revision = 0
 30    public let defaults: OrgSettings
 31    private var undoStack: [[TextEdit]] = []
 32    private var redoStack: [[TextEdit]] = []
 33
 34    public init(bytes: [UInt8], defaults: OrgSettings = .default) {
 35        source = SourceText(bytes: bytes)
 36        text = source.text
 37        tree = OrgParser.parse(text, defaults: defaults)
 38        self.defaults = defaults
 39    }
 40
 41    public var mergeBase: [UInt8] { source.originalBytes }
 42    public var isDirty: Bool { text != source.text }
 43    public var isEditable: Bool { source.isEditable }
 44    public var canUndo: Bool { !undoStack.isEmpty }
 45    public var canRedo: Bool { !redoStack.isEmpty }
 46
 47    /// Bytes to write for the current text, keeping the file's BOM.
 48    public func encodedText() throws -> [UInt8] {
 49        guard isEditable else { throw EditError.readOnly }
 50        return source.encode(text)
 51    }
 52
 53    // MARK: - Editing
 54
 55    /// Applies non-overlapping edits, computed against `baseRevision`, as one undo step.
 56    public mutating func apply(_ edits: [TextEdit], baseRevision: Int) throws {
 57        guard isEditable else { throw EditError.readOnly }
 58        guard baseRevision == revision else { throw EditError.staleRevision }
 59        let inverse = try applyGroup(edits)
 60        undoStack.append(inverse)
 61        redoStack = []
 62    }
 63
 64    /// Reverts the last edit group. Returns the edits applied, for mapping view state.
 65    public mutating func undo() -> [TextEdit]? {
 66        guard let group = undoStack.popLast() else { return nil }
 67        redoStack.append(try! applyGroup(group))
 68        return group
 69    }
 70
 71    public mutating func redo() -> [TextEdit]? {
 72        guard let group = redoStack.popLast() else { return nil }
 73        undoStack.append(try! applyGroup(group))
 74        return group
 75    }
 76
 77    /// Applies `edits` (old coordinates) and returns their inverse (new coordinates).
 78    private mutating func applyGroup(_ edits: [TextEdit]) throws -> [TextEdit] {
 79        let sorted = edits.sorted { $0.range.lowerBound < $1.range.lowerBound }
 80        for (first, second) in zip(sorted, sorted.dropFirst()) where first.range.upperBound > second.range.lowerBound {
 81            throw EditError.overlappingEdits
 82        }
 83        var inverse: [TextEdit] = []
 84        var shift = 0
 85        for edit in sorted {
 86            let start = edit.range.lowerBound + shift
 87            inverse.append(TextEdit(range: start..<(start + edit.replacement.utf16.count), replacement: utf16Slice(text, edit.range)))
 88            shift += edit.replacement.utf16.count - edit.range.count
 89        }
 90        // Back to front, so earlier offsets stay valid.
 91        for edit in sorted.reversed() {
 92            tree = OrgParser.reparse(tree, oldText: text, edit: edit, defaults: defaults)
 93            text = edit.apply(to: text)
 94        }
 95        revision += 1
 96        return inverse
 97    }
 98
 99    // MARK: - Commands
100
101    /// Runs `command` at `selection` and applies its edits as one undo step.
102    public mutating func run(
103        _ command: any OrgCommand, selection: [Range<Int>], now: Date = Date(),
104        calendar: Calendar = .current, answers: [String: String] = [:], options: EditingOptions = .org
105    ) throws -> CommandStep {
106        let context = EditContext(
107            revision: revision, text: text, tree: tree, selection: selection,
108            now: now, calendar: calendar, answers: answers, options: options
109        )
110        let step = command.run(in: context)
111        if case .commit(let result) = step, !result.edits.isEmpty {
112            try apply(result.edits, baseRevision: result.baseRevision)
113        }
114        return step
115    }
116
117    // MARK: - Disk
118
119    /// The file on disk now holds `bytes`. Reloads an unedited buffer, merges into an edited
120    /// one, and makes `bytes` the new merge base unless the merge conflicts.
121    public mutating func diskChanged(to bytes: [UInt8]) -> ExternalChange {
122        guard bytes != mergeBase else { return .unchanged }
123        let disk = SourceText(bytes: bytes)
124        if !isDirty {
125            let edits = lineEdits(from: text, to: disk.text)
126            replaceText(with: disk.text, source: disk)
127            return .reloaded(edits)
128        }
129        return mergeIn(disk, base: source.text, newSource: disk)
130    }
131
132    /// Our bytes are on disk, but they replaced `theirs`, which was based on `base`. Merges
133    /// their changes into the buffer and leaves the merge base at our bytes.
134    public mutating func mergeOverwritten(_ theirs: [UInt8], base: [UInt8]) -> ExternalChange {
135        mergeIn(SourceText(bytes: theirs), base: SourceText(bytes: base).text, newSource: source)
136    }
137
138    private mutating func mergeIn(_ theirs: SourceText, base: String, newSource: SourceText) -> ExternalChange {
139        guard theirs.isValidUTF8 else {
140            return .conflict([MergeConflict(base: base, ours: text, theirs: theirs.text)])
141        }
142        switch threeWayMerge(base: base, ours: text, theirs: theirs.text) {
143        case .merged(let merged):
144            let edits = lineEdits(from: text, to: merged)
145            replaceText(with: merged, source: newSource)
146            return .merged(edits)
147        case .conflict(let conflicts):
148            return .conflict(conflicts)
149        }
150    }
151
152    /// Records that `bytes` were written. The text stays as is; edits made since the write keep
153    /// the buffer dirty.
154    public mutating func didWrite(_ bytes: [UInt8]) {
155        source = SourceText(bytes: bytes)
156    }
157
158    /// Undo history can't be mapped through an external change, so it is cleared.
159    private mutating func replaceText(with newText: String, source newSource: SourceText) {
160        source = newSource
161        if newText != text {
162            text = newText
163            tree = OrgParser.parse(newText, defaults: defaults)
164            revision += 1
165        }
166        undoStack = []
167        redoStack = []
168    }
169}
170
171func utf16Slice(_ text: String, _ range: Range<Int>) -> String {
172    let start = String.Index(utf16Offset: range.lowerBound, in: text)
173    let end = String.Index(utf16Offset: range.upperBound, in: text)
174    return String(text.unicodeScalars[start..<end])
175}