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