krz/orgstar

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

Sources/OrgDocument/DocumentState.swift

0271e942ff3d2f72a407b3d9a6463fab8e4b48cf
orgstar/Sources/OrgDocument/DocumentState.swift history · blame · raw

156 lines · 6400 bytes

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