krz/orgstar

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

Sources/OrgDocument/DocumentState.swift

3a3dedba062790e1580c66d38606eda4b02cb501
orgstar/Sources/OrgDocument/DocumentState.swift history · blame · raw

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