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 | } | ||