krz/orgstar

A native macOS editor for org-mode files. editor org-mode swift

Commit eab72a4e5c

eab72a4e5ceeed518fb5b620fc0a76fd094f69fc

parent: 31d36e6de2

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-05 00:02 UTC

Add document state and view state

Layout: unified · split

Sources/OrgDocument/DocumentState.swift added +156
@@ -0,0 +1,156 @@
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}
Sources/OrgDocument/ViewState.swift added +41
@@ -0,0 +1,41 @@
1import OrgCore
2
3/// Per-window state for one document: selection and folds, in UTF-16 offsets.
4public 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.
32func 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 @@
1import OrgCore
2import Testing
3@testable import OrgDocument
4
5func state(_ text: String) -> DocumentState {
6 DocumentState(bytes: Array(text.utf8))
7}
8
9struct 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
96struct 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}