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