Commit eab72a4e5c
Verified · cmc
Layout: unified · split
Sources/OrgDocument/DocumentState.swift added +156
| @@ -0,0 +1,156 @@ | ||
| 1 | import 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. | |
| 5 | public 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 | ||
| 152 | func 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 | } | |
Sources/OrgDocument/ViewState.swift added +41
| @@ -0,0 +1,41 @@ | ||
| 1 | import OrgCore | |
| 2 | ||
| 3 | /// Per-window state for one document: selection and folds, in UTF-16 offsets. | |
| 4 | public struct ViewState: Sendable, Equatable { | |
| 5 | public var selection: [Range<Int>] | |
| 6 | /// Start offsets of folded headings. | |
| 7 | public var folds: Set<Int> | |
| 8 | ||
| 9 | public init(selection: [Range<Int>] = [0..<0], folds: Set<Int> = []) { | |
| 10 | self.selection = selection | |
| 11 | self.folds = folds | |
| 12 | } | |
| 13 | ||
| 14 | /// Maps through non-overlapping edits given in old coordinates. | |
| 15 | public func mapped(through edits: [TextEdit]) -> ViewState { | |
| 16 | let sorted = edits.sorted { $0.range.lowerBound < $1.range.lowerBound } | |
| 17 | return ViewState( | |
| 18 | selection: selection.map { mapOffset($0.lowerBound, sorted)..<mapOffset($0.upperBound, sorted) }, | |
| 19 | folds: Set(folds.map { mapOffset($0, sorted) }) | |
| 20 | ) | |
| 21 | } | |
| 22 | ||
| 23 | /// Drops folds that no longer sit at the start of a heading. | |
| 24 | public func pruned(to tree: OrgTree) -> ViewState { | |
| 25 | let headings = Set(tree.root.descendants().filter { $0.kind == .heading }.map(\.range.lowerBound)) | |
| 26 | return ViewState(selection: selection, folds: folds.intersection(headings)) | |
| 27 | } | |
| 28 | } | |
| 29 | ||
| 30 | /// An offset before an edit stays put; one at the start of a replaced range stays at its start; | |
| 31 | /// one inside it, at its end, or at an insertion point moves past the replacement. | |
| 32 | func mapOffset(_ offset: Int, _ sortedEdits: [TextEdit]) -> Int { | |
| 33 | var shift = 0 | |
| 34 | for edit in sortedEdits { | |
| 35 | let lower = edit.range.lowerBound, upper = edit.range.upperBound | |
| 36 | if offset < lower || (offset == lower && upper > lower) { return offset + shift } | |
| 37 | if offset <= upper { return lower + shift + edit.replacement.utf16.count } | |
| 38 | shift += edit.replacement.utf16.count - edit.range.count | |
| 39 | } | |
| 40 | return offset + shift | |
| 41 | } | |
Tests/OrgDocumentTests/DocumentStateTests.swift added +117
| @@ -0,0 +1,117 @@ | ||
| 1 | import OrgCore | |
| 2 | import Testing | |
| 3 | @testable import OrgDocument | |
| 4 | ||
| 5 | func state(_ text: String) -> DocumentState { | |
| 6 | DocumentState(bytes: Array(text.utf8)) | |
| 7 | } | |
| 8 | ||
| 9 | struct DocumentStateTests { | |
| 10 | @Test func applyUndoRedo() throws { | |
| 11 | var doc = state("* a\nbody\n") | |
| 12 | try doc.apply([TextEdit(range: 2..<3, replacement: "TODO b")], baseRevision: 0) | |
| 13 | #expect(doc.text == "* TODO b\nbody\n") | |
| 14 | #expect(doc.revision == 1) | |
| 15 | #expect(doc.isDirty) | |
| 16 | #expect(doc.tree.green == OrgParser.parse(doc.text).green) | |
| 17 | ||
| 18 | #expect(doc.undo() != nil) | |
| 19 | #expect(doc.text == "* a\nbody\n") | |
| 20 | #expect(!doc.isDirty) | |
| 21 | #expect(doc.tree.green == OrgParser.parse(doc.text).green) | |
| 22 | ||
| 23 | #expect(doc.redo() != nil) | |
| 24 | #expect(doc.text == "* TODO b\nbody\n") | |
| 25 | } | |
| 26 | ||
| 27 | @Test func groupedEditsAreOneUndoStep() throws { | |
| 28 | var doc = state("ab cd ef\n") | |
| 29 | try doc.apply([TextEdit(range: 0..<2, replacement: "X"), TextEdit(range: 6..<8, replacement: "YYY")], baseRevision: 0) | |
| 30 | #expect(doc.text == "X cd YYY\n") | |
| 31 | _ = doc.undo() | |
| 32 | #expect(doc.text == "ab cd ef\n") | |
| 33 | } | |
| 34 | ||
| 35 | @Test func rejectsStaleOverlappingAndReadOnly() throws { | |
| 36 | var doc = state("abc\n") | |
| 37 | #expect(throws: DocumentState.EditError.staleRevision) { try doc.apply([], baseRevision: 5) } | |
| 38 | #expect(throws: DocumentState.EditError.overlappingEdits) { | |
| 39 | try doc.apply([TextEdit(range: 0..<2, replacement: ""), TextEdit(range: 1..<3, replacement: "")], baseRevision: 0) | |
| 40 | } | |
| 41 | var invalid = DocumentState(bytes: [0x61, 0xFF]) | |
| 42 | #expect(throws: DocumentState.EditError.readOnly) { try invalid.apply([], baseRevision: 0) } | |
| 43 | #expect(throws: DocumentState.EditError.readOnly) { try invalid.encodedText() } | |
| 44 | } | |
| 45 | ||
| 46 | @Test func newEditClearsRedo() throws { | |
| 47 | var doc = state("a\n") | |
| 48 | try doc.apply([TextEdit(range: 0..<0, replacement: "x")], baseRevision: 0) | |
| 49 | _ = doc.undo() | |
| 50 | try doc.apply([TextEdit(range: 0..<0, replacement: "y")], baseRevision: doc.revision) | |
| 51 | #expect(!doc.canRedo) | |
| 52 | } | |
| 53 | ||
| 54 | @Test func keepsBOMWhenEncoding() throws { | |
| 55 | var doc = DocumentState(bytes: [0xEF, 0xBB, 0xBF] + Array("a\n".utf8)) | |
| 56 | try doc.apply([TextEdit(range: 0..<1, replacement: "b")], baseRevision: 0) | |
| 57 | #expect(try doc.encodedText() == [0xEF, 0xBB, 0xBF] + Array("b\n".utf8)) | |
| 58 | } | |
| 59 | ||
| 60 | @Test func externalChangeReloadsCleanBuffer() { | |
| 61 | var doc = state("a\nb\n") | |
| 62 | #expect(doc.diskChanged(to: Array("a\nb\n".utf8)) == .unchanged) | |
| 63 | guard case .reloaded = doc.diskChanged(to: Array("a\nc\n".utf8)) else { | |
| 64 | Issue.record("expected a reload") | |
| 65 | return | |
| 66 | } | |
| 67 | #expect(doc.text == "a\nc\n") | |
| 68 | #expect(!doc.isDirty) | |
| 69 | } | |
| 70 | ||
| 71 | @Test func externalChangeMergesIntoEditedBuffer() throws { | |
| 72 | var doc = state("a\nb\nc\n") | |
| 73 | try doc.apply([TextEdit(range: 0..<1, replacement: "A")], baseRevision: 0) | |
| 74 | guard case .merged = doc.diskChanged(to: Array("a\nb\nC\n".utf8)) else { | |
| 75 | Issue.record("expected a merge") | |
| 76 | return | |
| 77 | } | |
| 78 | #expect(doc.text == "A\nb\nC\n") | |
| 79 | #expect(doc.mergeBase == Array("a\nb\nC\n".utf8)) | |
| 80 | #expect(doc.isDirty) | |
| 81 | #expect(!doc.canUndo) | |
| 82 | } | |
| 83 | ||
| 84 | @Test func conflictingExternalChangeLeavesBufferAlone() throws { | |
| 85 | var doc = state("a\n") | |
| 86 | try doc.apply([TextEdit(range: 0..<1, replacement: "X")], baseRevision: 0) | |
| 87 | guard case .conflict = doc.diskChanged(to: Array("Y\n".utf8)) else { | |
| 88 | Issue.record("expected a conflict") | |
| 89 | return | |
| 90 | } | |
| 91 | #expect(doc.text == "X\n") | |
| 92 | #expect(doc.mergeBase == Array("a\n".utf8)) | |
| 93 | } | |
| 94 | } | |
| 95 | ||
| 96 | struct ViewStateTests { | |
| 97 | @Test func mapsSelectionAndFolds() { | |
| 98 | let view = ViewState(selection: [2..<6], folds: [0, 10]) | |
| 99 | let mapped = view.mapped(through: [TextEdit(range: 1..<1, replacement: "xx"), TextEdit(range: 8..<9, replacement: "")]) | |
| 100 | #expect(mapped.selection == [4..<8]) | |
| 101 | #expect(mapped.folds == [0, 11]) | |
| 102 | } | |
| 103 | ||
| 104 | @Test func offsetsInsideAReplacementMoveToItsEnd() { | |
| 105 | let edits = [TextEdit(range: 2..<5, replacement: "ab")] | |
| 106 | #expect(mapOffset(2, edits) == 2) | |
| 107 | #expect(mapOffset(3, edits) == 4) | |
| 108 | #expect(mapOffset(5, edits) == 4) | |
| 109 | #expect(mapOffset(6, edits) == 5) | |
| 110 | #expect(mapOffset(2, [TextEdit(range: 2..<2, replacement: "ab")]) == 4) | |
| 111 | } | |
| 112 | ||
| 113 | @Test func pruneDropsFoldsThatAreNoLongerHeadings() { | |
| 114 | let tree = OrgParser.parse("* a\ntext\n* b\n") | |
| 115 | #expect(ViewState(folds: [0, 4, 9]).pruned(to: tree).folds == [0, 9]) | |
| 116 | } | |
| 117 | } | |