Sources/OrgDocument/DocumentState.swift
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}