docs/plans/2026-10-04-document-session.md
1081 lines · 43197 bytes
7 symbols in this file
1# Document Session and Save Path Implementation Plan
2
3> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
5**Goal:** The state of one open file (text, tree, revision, undo, merge base), view state that follows edits, a line-based three-way merge, and a save sequence that never loses a version another writer produced.
6
7**Architecture:** A new `OrgDocument` target on top of `OrgCore`. `DocumentState` is a value type: edits apply against a revision, go through `OrgParser.reparse`, and record their inverse for undo. External changes reload an unedited buffer or merge into an edited one. `Saver` runs the design's save sequence over a `FileSystem` protocol, so tests can inject another writer at every step; `CoordinatedFileSystem` is the real implementation (`NSFileCoordinator`, temp file and `replaceItemAt` with a kept backup), and `FileRecoveryStore` keeps displaced versions. Views map their own `ViewState` through the edits each change returns.
8
9**Tech Stack:** Swift 6.2 tools, Swift Testing, Foundation, CryptoKit (recovery folder names).
10
11**Spec:** `docs/design.md`, "Document session" and "Saving".
12
13## Global Constraints
14
15- Every version displaced by a save ends up on disk, in the buffer, or in recovery.
16- Read-only documents (not valid UTF-8) are never written.
17- Undo history is cleared by a reload or merge; it can't be mapped through an external change.
18- `OrgDocument` builds for iOS: `xcodebuild -scheme OrgDocument -destination 'generic/platform=iOS' build`.
19
20## Out of scope
21
22Per-window session objects and main-actor wiring (they belong to the app target), file watching (workspace plan), and conflict UI.
23
24## File structure
25
26| File | Responsibility |
27| --- | --- |
28| `Package.swift` | Adds the `OrgDocument` library and `OrgDocumentTests` |
29| `Sources/OrgDocument/Merge.swift` | Line diff (Myers), `threeWayMerge`, `lineEdits` |
30| `Sources/OrgDocument/DocumentState.swift` | Edits, undo/redo, external changes, write bookkeeping |
31| `Sources/OrgDocument/ViewState.swift` | Selection and folds mapped through edits |
32| `Sources/OrgDocument/Saving.swift` | `FileSystem`, `RecoveryStore`, `Saver`, outcomes |
33| `Sources/OrgDocument/FileStorage.swift` | `CoordinatedFileSystem`, `FileRecoveryStore` |
34
35---
36
37### Task 1: Three-way merge
38
39**Files:**
40- Modify: `Package.swift`
41- Create: `Sources/OrgDocument/Merge.swift`
42- Test: `Tests/OrgDocumentTests/MergeTests.swift`
43
44**Interfaces:**
45- Produces: `MergeConflict(base:ours:theirs:)`, `MergeResult` (`.merged`, `.conflict`), `threeWayMerge(base:ours:theirs:)`, `lineEdits(from:to:) -> [TextEdit]`; internal `textLines`, `matchingLines`.
46
47- [ ] **Step 1: Add the target**
48
49```swift
50// swift-tools-version: 6.2
51import PackageDescription
52
53let package = Package(
54 name: "Orgstar",
55 platforms: [.macOS(.v26), .iOS(.v26)],
56 products: [
57 .library(name: "OrgCore", targets: ["OrgCore"]),
58 .library(name: "OrgDocument", targets: ["OrgDocument"])
59 ],
60 targets: [
61 .target(name: "OrgCore"),
62 .target(name: "OrgDocument", dependencies: ["OrgCore"]),
63 .testTarget(name: "OrgCoreTests", dependencies: ["OrgCore"]),
64 .testTarget(name: "OrgDocumentTests", dependencies: ["OrgDocument"])
65 ]
66)
67```
68
69- [ ] **Step 2: Write the failing tests**
70
71```swift
72import OrgCore
73import Testing
74@testable import OrgDocument
75
76struct MergeTests {
77 @Test func separateChangesMerge() {
78 #expect(threeWayMerge(base: "a\nb\nc\n", ours: "A\nb\nc\n", theirs: "a\nb\nC\n") == .merged("A\nb\nC\n"))
79 }
80
81 @Test func sameChangeOnBothSidesMergesOnce() {
82 #expect(threeWayMerge(base: "a\nb\n", ours: "a\nX\n", theirs: "a\nX\n") == .merged("a\nX\n"))
83 }
84
85 @Test func differentChangesConflict() {
86 let result = threeWayMerge(base: "a\nb\nc\n", ours: "a\nX\nc\n", theirs: "a\nY\nc\n")
87 #expect(result == .conflict([MergeConflict(base: "b\n", ours: "X\n", theirs: "Y\n")]))
88 }
89
90 @Test func insertionsAndDeletions() {
91 #expect(threeWayMerge(base: "a\nb\n", ours: "a\nb\nc\n", theirs: "b\n") == .merged("b\nc\n"))
92 #expect(threeWayMerge(base: "", ours: "x\n", theirs: "") == .merged("x\n"))
93 }
94
95 @Test func lineEndingsSurvive() {
96 #expect(threeWayMerge(base: "a\r\nb\r\n", ours: "A\r\nb\r\n", theirs: "a\r\nb\r\nc") == .merged("A\r\nb\r\nc"))
97 }
98
99 @Test func lineEditsRebuildTheNewText() {
100 let pairs = [("a\nb\nc\n", "a\nx\nc\nd\n"), ("", "x"), ("x", ""), ("a\r\nb", "b\r\na"), ("same\n", "same\n")]
101 for (old, new) in pairs {
102 var text = old
103 for edit in lineEdits(from: old, to: new).reversed() { text = edit.apply(to: text) }
104 #expect(text == new)
105 }
106 }
107
108 @Test func largeFilesWithSmallChanges() {
109 let base = (0..<20_000).map { "line \($0)\n" }.joined()
110 let ours = base.replacingOccurrences(of: "line 100\n", with: "ours\n")
111 let theirs = base.replacingOccurrences(of: "line 19000\n", with: "theirs\n")
112 guard case .merged(let merged) = threeWayMerge(base: base, ours: ours, theirs: theirs) else {
113 Issue.record("expected a clean merge")
114 return
115 }
116 #expect(merged.contains("ours\n") && merged.contains("theirs\n"))
117 }
118}
119```
120
121- [ ] **Step 3: Run to verify failure**
122
123Run: `swift test --filter MergeTests`
124Expected: build failure, `cannot find 'threeWayMerge' in scope`.
125
126- [ ] **Step 4: Implement**
127
128```swift
129import OrgCore
130
131public struct MergeConflict: Sendable, Equatable {
132 public let base: String
133 public let ours: String
134 public let theirs: String
135}
136
137public enum MergeResult: Sendable, Equatable {
138 case merged(String)
139 case conflict([MergeConflict])
140}
141
142/// Line-based three-way merge. Lines keep their endings, so CRLF and a missing final newline
143/// survive. A region changed on one side takes that side; changed the same way on both, takes
144/// it once; changed differently, is a conflict.
145public func threeWayMerge(base: String, ours: String, theirs: String) -> MergeResult {
146 let baseLines = textLines(base)
147 let ourLines = textLines(ours)
148 let theirLines = textLines(theirs)
149 var ids = LineIDs()
150 let baseIDs = ids.encode(baseLines)
151 let ourIDs = ids.encode(ourLines)
152 let theirIDs = ids.encode(theirLines)
153
154 var toOurs = [Int?](repeating: nil, count: baseLines.count)
155 for (b, o) in matchingLines(baseIDs, ourIDs) { toOurs[b] = o }
156 var toTheirs = [Int?](repeating: nil, count: baseLines.count)
157 for (b, t) in matchingLines(baseIDs, theirIDs) { toTheirs[b] = t }
158
159 var merged: [String] = []
160 var conflicts: [MergeConflict] = []
161 var nextBase = 0, nextOurs = 0, nextTheirs = 0
162
163 func resolve(_ baseEnd: Int, _ oursEnd: Int, _ theirsEnd: Int) {
164 let b = baseLines[nextBase..<baseEnd]
165 let o = ourLines[nextOurs..<oursEnd]
166 let t = theirLines[nextTheirs..<theirsEnd]
167 if o.elementsEqual(b) {
168 merged += t
169 } else if t.elementsEqual(b) || o.elementsEqual(t) {
170 merged += o
171 } else {
172 conflicts.append(MergeConflict(base: b.joined(), ours: o.joined(), theirs: t.joined()))
173 }
174 }
175
176 // A base line kept by both sides is a fixed point; everything between fixed points is
177 // resolved as one region.
178 for k in baseLines.indices {
179 guard let o = toOurs[k], let t = toTheirs[k], o >= nextOurs, t >= nextTheirs else { continue }
180 resolve(k, o, t)
181 merged.append(baseLines[k])
182 nextBase = k + 1
183 nextOurs = o + 1
184 nextTheirs = t + 1
185 }
186 resolve(baseLines.count, ourLines.count, theirLines.count)
187 return conflicts.isEmpty ? .merged(merged.joined()) : .conflict(conflicts)
188}
189
190/// Edits, in `old` coordinates and ascending order, that turn `old` into `new` line by line.
191public func lineEdits(from old: String, to new: String) -> [TextEdit] {
192 let oldLines = textLines(old)
193 let newLines = textLines(new)
194 var ids = LineIDs()
195 let pairs = matchingLines(ids.encode(oldLines), ids.encode(newLines))
196 var offsets = [0]
197 for line in oldLines { offsets.append(offsets.last! + line.utf16.count) }
198 var edits: [TextEdit] = []
199 var i = 0, j = 0
200 for (pi, pj) in pairs + [(oldLines.count, newLines.count)] {
201 if i < pi || j < pj {
202 edits.append(TextEdit(range: offsets[i]..<offsets[pi], replacement: newLines[j..<pj].joined()))
203 }
204 i = pi + 1
205 j = pj + 1
206 }
207 return edits
208}
209
210/// Lines with their endings.
211func textLines(_ text: String) -> [String] {
212 var lines: [String] = []
213 var current = ""
214 for scalar in text.unicodeScalars {
215 current.unicodeScalars.append(scalar)
216 if scalar == "\n" {
217 lines.append(current)
218 current = ""
219 }
220 }
221 if !current.isEmpty { lines.append(current) }
222 return lines
223}
224
225struct LineIDs {
226 private var ids: [String: Int] = [:]
227
228 mutating func encode(_ lines: [String]) -> [Int] {
229 lines.map { line in
230 if let id = ids[line] { return id }
231 let id = ids.count
232 ids[line] = id
233 return id
234 }
235 }
236}
237
238/// Matched index pairs of a longest common subsequence, ascending. Common prefix and suffix
239/// are matched directly; Myers' algorithm handles the middle. When the middle differs by more
240/// than the memory budget allows, it is treated as having no matches.
241func matchingLines(_ a: [Int], _ b: [Int]) -> [(Int, Int)] {
242 var prefix = 0
243 while prefix < a.count, prefix < b.count, a[prefix] == b[prefix] { prefix += 1 }
244 var suffix = 0
245 while suffix < a.count - prefix, suffix < b.count - prefix, a[a.count - 1 - suffix] == b[b.count - 1 - suffix] {
246 suffix += 1
247 }
248 var pairs = (0..<prefix).map { ($0, $0) }
249 let middleA = Array(a[prefix..<(a.count - suffix)])
250 let middleB = Array(b[prefix..<(b.count - suffix)])
251 pairs += myers(middleA, middleB).map { ($0.0 + prefix, $0.1 + prefix) }
252 pairs += (0..<suffix).map { (a.count - suffix + $0, b.count - suffix + $0) }
253 return pairs
254}
255
256private func myers(_ a: [Int], _ b: [Int]) -> [(Int, Int)] {
257 let n = a.count, m = b.count
258 guard n > 0, m > 0 else { return [] }
259 let maxD = n + m
260 let offset = maxD + 1
261 // Each step keeps a copy of the frontier for backtracking; cap that at ~20M entries.
262 let budget = max(1, 20_000_000 / (2 * maxD + 3))
263 var v = [Int](repeating: 0, count: 2 * maxD + 3)
264 var trace: [[Int]] = []
265 var found = false
266 search: for d in 0...min(maxD, budget) {
267 trace.append(v)
268 for k in stride(from: -d, through: d, by: 2) {
269 var x = (k == -d || (k != d && v[offset + k - 1] < v[offset + k + 1])) ? v[offset + k + 1] : v[offset + k - 1] + 1
270 var y = x - k
271 while x < n, y < m, a[x] == b[y] {
272 x += 1
273 y += 1
274 }
275 v[offset + k] = x
276 if x >= n, y >= m {
277 found = true
278 break search
279 }
280 }
281 }
282 guard found else { return [] }
283
284 var pairs: [(Int, Int)] = []
285 var x = n, y = m
286 for d in stride(from: trace.count - 1, through: 0, by: -1) {
287 let v = trace[d]
288 let k = x - y
289 let previousK = (k == -d || (k != d && v[offset + k - 1] < v[offset + k + 1])) ? k + 1 : k - 1
290 let previousX = v[offset + previousK]
291 let previousY = previousX - previousK
292 while x > previousX, y > previousY {
293 pairs.append((x - 1, y - 1))
294 x -= 1
295 y -= 1
296 }
297 if d > 0 {
298 x = previousX
299 y = previousY
300 }
301 }
302 return pairs.reversed()
303}
304```
305
306- [ ] **Step 5: Run to verify pass, then commit**
307
308Run: `swift test --filter MergeTests`
309
310```bash
311git add Package.swift Sources/OrgDocument/Merge.swift Tests/OrgDocumentTests/MergeTests.swift
312git commit -m "Add OrgDocument target with three-way merge"
313```
314
315---
316
317### Task 2: Document state and view state
318
319**Files:**
320- Create: `Sources/OrgDocument/DocumentState.swift`, `Sources/OrgDocument/ViewState.swift`
321- Test: `Tests/OrgDocumentTests/DocumentStateTests.swift`
322
323**Interfaces:**
324- Consumes: `threeWayMerge`, `lineEdits` (Task 1); `OrgParser.reparse`, `SourceText`, `TextEdit`.
325- Produces: `DocumentState(bytes:defaults:)` with `source`, `text`, `tree`, `revision`, `mergeBase`, `isDirty`, `isEditable`, `canUndo`, `canRedo`, `encodedText()`, `apply(_:baseRevision:)`, `undo()`, `redo()`, `diskChanged(to:) -> ExternalChange`, `mergeOverwritten(_:base:)`, `didWrite(_:)`; `ViewState` with `mapped(through:)`, `pruned(to:)`; internal `mapOffset`, `utf16Slice`.
326
327- [ ] **Step 1: Write the failing tests**
328
329```swift
330import OrgCore
331import Testing
332@testable import OrgDocument
333
334func state(_ text: String) -> DocumentState {
335 DocumentState(bytes: Array(text.utf8))
336}
337
338struct DocumentStateTests {
339 @Test func applyUndoRedo() throws {
340 var doc = state("* a\nbody\n")
341 try doc.apply([TextEdit(range: 2..<3, replacement: "TODO b")], baseRevision: 0)
342 #expect(doc.text == "* TODO b\nbody\n")
343 #expect(doc.revision == 1)
344 #expect(doc.isDirty)
345 #expect(doc.tree.green == OrgParser.parse(doc.text).green)
346
347 #expect(doc.undo() != nil)
348 #expect(doc.text == "* a\nbody\n")
349 #expect(!doc.isDirty)
350 #expect(doc.tree.green == OrgParser.parse(doc.text).green)
351
352 #expect(doc.redo() != nil)
353 #expect(doc.text == "* TODO b\nbody\n")
354 }
355
356 @Test func groupedEditsAreOneUndoStep() throws {
357 var doc = state("ab cd ef\n")
358 try doc.apply([TextEdit(range: 0..<2, replacement: "X"), TextEdit(range: 6..<8, replacement: "YYY")], baseRevision: 0)
359 #expect(doc.text == "X cd YYY\n")
360 _ = doc.undo()
361 #expect(doc.text == "ab cd ef\n")
362 }
363
364 @Test func rejectsStaleOverlappingAndReadOnly() throws {
365 var doc = state("abc\n")
366 #expect(throws: DocumentState.EditError.staleRevision) { try doc.apply([], baseRevision: 5) }
367 #expect(throws: DocumentState.EditError.overlappingEdits) {
368 try doc.apply([TextEdit(range: 0..<2, replacement: ""), TextEdit(range: 1..<3, replacement: "")], baseRevision: 0)
369 }
370 var invalid = DocumentState(bytes: [0x61, 0xFF])
371 #expect(throws: DocumentState.EditError.readOnly) { try invalid.apply([], baseRevision: 0) }
372 #expect(throws: DocumentState.EditError.readOnly) { try invalid.encodedText() }
373 }
374
375 @Test func newEditClearsRedo() throws {
376 var doc = state("a\n")
377 try doc.apply([TextEdit(range: 0..<0, replacement: "x")], baseRevision: 0)
378 _ = doc.undo()
379 try doc.apply([TextEdit(range: 0..<0, replacement: "y")], baseRevision: doc.revision)
380 #expect(!doc.canRedo)
381 }
382
383 @Test func keepsBOMWhenEncoding() throws {
384 var doc = DocumentState(bytes: [0xEF, 0xBB, 0xBF] + Array("a\n".utf8))
385 try doc.apply([TextEdit(range: 0..<1, replacement: "b")], baseRevision: 0)
386 #expect(try doc.encodedText() == [0xEF, 0xBB, 0xBF] + Array("b\n".utf8))
387 }
388
389 @Test func externalChangeReloadsCleanBuffer() {
390 var doc = state("a\nb\n")
391 #expect(doc.diskChanged(to: Array("a\nb\n".utf8)) == .unchanged)
392 guard case .reloaded = doc.diskChanged(to: Array("a\nc\n".utf8)) else {
393 Issue.record("expected a reload")
394 return
395 }
396 #expect(doc.text == "a\nc\n")
397 #expect(!doc.isDirty)
398 }
399
400 @Test func externalChangeMergesIntoEditedBuffer() throws {
401 var doc = state("a\nb\nc\n")
402 try doc.apply([TextEdit(range: 0..<1, replacement: "A")], baseRevision: 0)
403 guard case .merged = doc.diskChanged(to: Array("a\nb\nC\n".utf8)) else {
404 Issue.record("expected a merge")
405 return
406 }
407 #expect(doc.text == "A\nb\nC\n")
408 #expect(doc.mergeBase == Array("a\nb\nC\n".utf8))
409 #expect(doc.isDirty)
410 #expect(!doc.canUndo)
411 }
412
413 @Test func conflictingExternalChangeLeavesBufferAlone() throws {
414 var doc = state("a\n")
415 try doc.apply([TextEdit(range: 0..<1, replacement: "X")], baseRevision: 0)
416 guard case .conflict = doc.diskChanged(to: Array("Y\n".utf8)) else {
417 Issue.record("expected a conflict")
418 return
419 }
420 #expect(doc.text == "X\n")
421 #expect(doc.mergeBase == Array("a\n".utf8))
422 }
423}
424
425struct ViewStateTests {
426 @Test func mapsSelectionAndFolds() {
427 let view = ViewState(selection: [2..<6], folds: [0, 10])
428 let mapped = view.mapped(through: [TextEdit(range: 1..<1, replacement: "xx"), TextEdit(range: 8..<9, replacement: "")])
429 #expect(mapped.selection == [4..<8])
430 #expect(mapped.folds == [0, 11])
431 }
432
433 @Test func offsetsInsideAReplacementMoveToItsEnd() {
434 let edits = [TextEdit(range: 2..<5, replacement: "ab")]
435 #expect(mapOffset(2, edits) == 2)
436 #expect(mapOffset(3, edits) == 4)
437 #expect(mapOffset(5, edits) == 4)
438 #expect(mapOffset(6, edits) == 5)
439 #expect(mapOffset(2, [TextEdit(range: 2..<2, replacement: "ab")]) == 4)
440 }
441
442 @Test func pruneDropsFoldsThatAreNoLongerHeadings() {
443 let tree = OrgParser.parse("* a\ntext\n* b\n")
444 #expect(ViewState(folds: [0, 4, 9]).pruned(to: tree).folds == [0, 9])
445 }
446}
447```
448
449- [ ] **Step 2: Run to verify failure**
450
451Run: `swift test --filter DocumentStateTests`
452Expected: build failure, `cannot find 'DocumentState' in scope`.
453
454- [ ] **Step 3: Implement `DocumentState.swift`**
455
456```swift
457import OrgCore
458
459/// One open file: its text, tree, revision, undo history, and the bytes last read from or
460/// written to disk, which are the base for merging external changes.
461public struct DocumentState: Sendable {
462 public enum EditError: Error, Equatable {
463 case readOnly
464 case staleRevision
465 case overlappingEdits
466 }
467
468 public enum ExternalChange: Sendable, Equatable {
469 case unchanged
470 /// The buffer had no edits and now holds the disk version. Edits are in the old text's
471 /// coordinates, for mapping view state.
472 case reloaded([TextEdit])
473 /// The disk version was merged into the edited buffer.
474 case merged([TextEdit])
475 /// Nothing changed in the buffer.
476 case conflict([MergeConflict])
477 }
478
479 /// The bytes on disk as of the last read or write.
480 public private(set) var source: SourceText
481 public private(set) var text: String
482 public private(set) var tree: OrgTree
483 /// Increases on every change to `text`.
484 public private(set) var revision = 0
485 public let defaults: OrgSettings
486 private var undoStack: [[TextEdit]] = []
487 private var redoStack: [[TextEdit]] = []
488
489 public init(bytes: [UInt8], defaults: OrgSettings = .default) {
490 source = SourceText(bytes: bytes)
491 text = source.text
492 tree = OrgParser.parse(text, defaults: defaults)
493 self.defaults = defaults
494 }
495
496 public var mergeBase: [UInt8] { source.originalBytes }
497 public var isDirty: Bool { text != source.text }
498 public var isEditable: Bool { source.isEditable }
499 public var canUndo: Bool { !undoStack.isEmpty }
500 public var canRedo: Bool { !redoStack.isEmpty }
501
502 /// Bytes to write for the current text, keeping the file's BOM.
503 public func encodedText() throws -> [UInt8] {
504 guard isEditable else { throw EditError.readOnly }
505 return source.encode(text)
506 }
507
508 // MARK: - Editing
509
510 /// Applies non-overlapping edits, computed against `baseRevision`, as one undo step.
511 public mutating func apply(_ edits: [TextEdit], baseRevision: Int) throws {
512 guard isEditable else { throw EditError.readOnly }
513 guard baseRevision == revision else { throw EditError.staleRevision }
514 let inverse = try applyGroup(edits)
515 undoStack.append(inverse)
516 redoStack = []
517 }
518
519 /// Reverts the last edit group. Returns the edits applied, for mapping view state.
520 public mutating func undo() -> [TextEdit]? {
521 guard let group = undoStack.popLast() else { return nil }
522 redoStack.append(try! applyGroup(group))
523 return group
524 }
525
526 public mutating func redo() -> [TextEdit]? {
527 guard let group = redoStack.popLast() else { return nil }
528 undoStack.append(try! applyGroup(group))
529 return group
530 }
531
532 /// Applies `edits` (old coordinates) and returns their inverse (new coordinates).
533 private mutating func applyGroup(_ edits: [TextEdit]) throws -> [TextEdit] {
534 let sorted = edits.sorted { $0.range.lowerBound < $1.range.lowerBound }
535 for (first, second) in zip(sorted, sorted.dropFirst()) where first.range.upperBound > second.range.lowerBound {
536 throw EditError.overlappingEdits
537 }
538 var inverse: [TextEdit] = []
539 var shift = 0
540 for edit in sorted {
541 let start = edit.range.lowerBound + shift
542 inverse.append(TextEdit(range: start..<(start + edit.replacement.utf16.count), replacement: utf16Slice(text, edit.range)))
543 shift += edit.replacement.utf16.count - edit.range.count
544 }
545 // Back to front, so earlier offsets stay valid.
546 for edit in sorted.reversed() {
547 tree = OrgParser.reparse(tree, oldText: text, edit: edit, defaults: defaults)
548 text = edit.apply(to: text)
549 }
550 revision += 1
551 return inverse
552 }
553
554 // MARK: - Disk
555
556 /// The file on disk now holds `bytes`. Reloads an unedited buffer, merges into an edited
557 /// one, and makes `bytes` the new merge base unless the merge conflicts.
558 public mutating func diskChanged(to bytes: [UInt8]) -> ExternalChange {
559 guard bytes != mergeBase else { return .unchanged }
560 let disk = SourceText(bytes: bytes)
561 if !isDirty {
562 let edits = lineEdits(from: text, to: disk.text)
563 replaceText(with: disk.text, source: disk)
564 return .reloaded(edits)
565 }
566 return mergeIn(disk, base: source.text, newSource: disk)
567 }
568
569 /// Our bytes are on disk, but they replaced `theirs`, which was based on `base`. Merges
570 /// their changes into the buffer and leaves the merge base at our bytes.
571 public mutating func mergeOverwritten(_ theirs: [UInt8], base: [UInt8]) -> ExternalChange {
572 mergeIn(SourceText(bytes: theirs), base: SourceText(bytes: base).text, newSource: source)
573 }
574
575 private mutating func mergeIn(_ theirs: SourceText, base: String, newSource: SourceText) -> ExternalChange {
576 guard theirs.isValidUTF8 else {
577 return .conflict([MergeConflict(base: base, ours: text, theirs: theirs.text)])
578 }
579 switch threeWayMerge(base: base, ours: text, theirs: theirs.text) {
580 case .merged(let merged):
581 let edits = lineEdits(from: text, to: merged)
582 replaceText(with: merged, source: newSource)
583 return .merged(edits)
584 case .conflict(let conflicts):
585 return .conflict(conflicts)
586 }
587 }
588
589 /// Records that `bytes` were written. The text stays as is; edits made since the write keep
590 /// the buffer dirty.
591 public mutating func didWrite(_ bytes: [UInt8]) {
592 source = SourceText(bytes: bytes)
593 }
594
595 /// Undo history can't be mapped through an external change, so it is cleared.
596 private mutating func replaceText(with newText: String, source newSource: SourceText) {
597 source = newSource
598 if newText != text {
599 text = newText
600 tree = OrgParser.parse(newText, defaults: defaults)
601 revision += 1
602 }
603 undoStack = []
604 redoStack = []
605 }
606}
607
608func utf16Slice(_ text: String, _ range: Range<Int>) -> String {
609 let start = String.Index(utf16Offset: range.lowerBound, in: text)
610 let end = String.Index(utf16Offset: range.upperBound, in: text)
611 return String(text.unicodeScalars[start..<end])
612}
613```
614
615- [ ] **Step 4: Implement `ViewState.swift`**
616
617```swift
618import OrgCore
619
620/// Per-window state for one document: selection and folds, in UTF-16 offsets.
621public struct ViewState: Sendable, Equatable {
622 public var selection: [Range<Int>]
623 /// Start offsets of folded headings.
624 public var folds: Set<Int>
625
626 public init(selection: [Range<Int>] = [0..<0], folds: Set<Int> = []) {
627 self.selection = selection
628 self.folds = folds
629 }
630
631 /// Maps through non-overlapping edits given in old coordinates.
632 public func mapped(through edits: [TextEdit]) -> ViewState {
633 let sorted = edits.sorted { $0.range.lowerBound < $1.range.lowerBound }
634 return ViewState(
635 selection: selection.map { mapOffset($0.lowerBound, sorted)..<mapOffset($0.upperBound, sorted) },
636 folds: Set(folds.map { mapOffset($0, sorted) })
637 )
638 }
639
640 /// Drops folds that no longer sit at the start of a heading.
641 public func pruned(to tree: OrgTree) -> ViewState {
642 let headings = Set(tree.root.descendants().filter { $0.kind == .heading }.map(\.range.lowerBound))
643 return ViewState(selection: selection, folds: folds.intersection(headings))
644 }
645}
646
647/// An offset before an edit stays put; one at the start of a replaced range stays at its start;
648/// one inside it, at its end, or at an insertion point moves past the replacement.
649func mapOffset(_ offset: Int, _ sortedEdits: [TextEdit]) -> Int {
650 var shift = 0
651 for edit in sortedEdits {
652 let lower = edit.range.lowerBound, upper = edit.range.upperBound
653 if offset < lower || (offset == lower && upper > lower) { return offset + shift }
654 if offset <= upper { return lower + shift + edit.replacement.utf16.count }
655 shift += edit.replacement.utf16.count - edit.range.count
656 }
657 return offset + shift
658}
659```
660
661- [ ] **Step 5: Run to verify pass, then commit**
662
663Run: `swift test --filter "DocumentStateTests|ViewStateTests"`
664
665```bash
666git add Sources/OrgDocument/DocumentState.swift Sources/OrgDocument/ViewState.swift Tests/OrgDocumentTests/DocumentStateTests.swift
667git commit -m "Add document state and view state"
668```
669
670---
671
672### Task 3: Save path
673
674**Files:**
675- Create: `Sources/OrgDocument/Saving.swift`, `Sources/OrgDocument/FileStorage.swift`
676- Test: `Tests/OrgDocumentTests/SaveTests.swift`
677
678**Interfaces:**
679- Consumes: `DocumentState` (Task 2).
680- Produces: `FileSystem`, `RecoveryStore`, `SaveOutcome`, `SaveError`, `Saver(fileSystem:recovery:)` with `save(_:to:)`, `CoordinatedFileSystem`, `FileRecoveryStore(directory:limit:)` with `versions(for:)`.
681
682- [ ] **Step 1: Write the failing tests**
683
684The fault-injection cases put another writer's change in place before each read and before the replace, and check that both versions survive.
685
686```swift
687import Foundation
688import OrgCore
689import Testing
690@testable import OrgDocument
691
692/// An in-memory file with hooks that let another writer change it at each step of a save.
693final class FaultyFileSystem: FileSystem, @unchecked Sendable {
694 var file: [UInt8]?
695 var reads = 0
696 /// Content another writer puts in place just before the n-th read (1-based).
697 var beforeRead: [Int: [UInt8]] = [:]
698 /// Content another writer puts in place just before our replace.
699 var beforeReplace: [UInt8]?
700
701 init(_ text: String?) {
702 file = text.map { Array($0.utf8) }
703 }
704
705 func read(_ url: URL) throws -> [UInt8]? {
706 reads += 1
707 if let injected = beforeRead[reads] { file = injected }
708 return file
709 }
710
711 func replace(_ url: URL, with bytes: [UInt8]) throws -> [UInt8]? {
712 if let injected = beforeReplace { file = injected }
713 let replaced = file
714 file = bytes
715 return replaced
716 }
717
718 var text: String? { file.map { String(decoding: $0, as: UTF8.self) } }
719}
720
721final class MemoryRecovery: RecoveryStore, @unchecked Sendable {
722 var kept: [(label: String, text: String)] = []
723
724 func keep(_ bytes: [UInt8], for url: URL, label: String) throws {
725 kept.append((label, String(decoding: bytes, as: UTF8.self)))
726 }
727
728 func contains(_ text: String) -> Bool { kept.contains { $0.text == text } }
729}
730
731let url = URL(fileURLWithPath: "/notes/a.org")
732
733/// A buffer loaded from "a\nb\nc\n" with its first line changed to "A".
734func editedState() throws -> DocumentState {
735 var doc = state("a\nb\nc\n")
736 try doc.apply([TextEdit(range: 0..<1, replacement: "A")], baseRevision: 0)
737 return doc
738}
739
740struct SaveTests {
741 @Test func plainSave() throws {
742 let files = FaultyFileSystem("a\nb\nc\n")
743 let recovery = MemoryRecovery()
744 var doc = try editedState()
745 #expect(try Saver(fileSystem: files, recovery: recovery).save(&doc, to: url) == .saved)
746 #expect(files.text == "A\nb\nc\n")
747 #expect(!doc.isDirty)
748 #expect(recovery.kept.isEmpty)
749 }
750
751 @Test func missingFileIsCreated() throws {
752 let files = FaultyFileSystem(nil)
753 var doc = try editedState()
754 #expect(try Saver(fileSystem: files, recovery: MemoryRecovery()).save(&doc, to: url) == .saved)
755 #expect(files.text == "A\nb\nc\n")
756 }
757
758 @Test func readOnlyDocumentsAreNotSaved() {
759 var doc = DocumentState(bytes: [0x61, 0xFF])
760 #expect(throws: DocumentState.EditError.readOnly) {
761 try Saver(fileSystem: FaultyFileSystem("x"), recovery: MemoryRecovery()).save(&doc, to: url)
762 }
763 }
764
765 // MARK: - Another writer at each step
766
767 @Test func changedBeforeSaveMerges() throws {
768 let files = FaultyFileSystem("a\nb\nc\n")
769 files.beforeRead[1] = Array("a\nb\nC\n".utf8)
770 let recovery = MemoryRecovery()
771 var doc = try editedState()
772 guard case .mergedAndSaved = try Saver(fileSystem: files, recovery: recovery).save(&doc, to: url) else {
773 Issue.record("expected a merge")
774 return
775 }
776 #expect(files.text == "A\nb\nC\n")
777 #expect(recovery.contains("a\nb\nC\n") && recovery.contains("A\nb\nc\n"))
778 }
779
780 @Test func conflictingChangeWritesNothing() throws {
781 let files = FaultyFileSystem("a\nb\nc\n")
782 files.beforeRead[1] = Array("Z\nb\nc\n".utf8)
783 var doc = try editedState()
784 guard case .conflict = try Saver(fileSystem: files, recovery: MemoryRecovery()).save(&doc, to: url) else {
785 Issue.record("expected a conflict")
786 return
787 }
788 #expect(files.text == "Z\nb\nc\n")
789 #expect(doc.text == "A\nb\nc\n")
790 }
791
792 @Test func changedBetweenReadAndCheckRetries() throws {
793 let files = FaultyFileSystem("a\nb\nc\n")
794 files.beforeRead[2] = Array("a\nb\nC\n".utf8)
795 var doc = try editedState()
796 guard case .mergedAndSaved = try Saver(fileSystem: files, recovery: MemoryRecovery()).save(&doc, to: url) else {
797 Issue.record("expected a merge on the second attempt")
798 return
799 }
800 #expect(files.text == "A\nb\nC\n")
801 }
802
803 @Test func changedJustBeforeReplaceIsRecovered() throws {
804 let files = FaultyFileSystem("a\nb\nc\n")
805 files.beforeReplace = Array("a\nb\nC\n".utf8)
806 let recovery = MemoryRecovery()
807 var doc = try editedState()
808 guard case .overwroteExternalChange(.merged) = try Saver(fileSystem: files, recovery: recovery).save(&doc, to: url) else {
809 Issue.record("expected their change merged into the buffer")
810 return
811 }
812 #expect(files.text == "A\nb\nc\n")
813 #expect(recovery.contains("a\nb\nC\n"))
814 #expect(doc.text == "A\nb\nC\n")
815 #expect(doc.isDirty)
816 }
817
818 @Test func changedRightAfterWriteKeepsOursInRecovery() throws {
819 let files = FaultyFileSystem("a\nb\nc\n")
820 files.beforeRead[3] = Array("A\nb\nc\nD\n".utf8)
821 let recovery = MemoryRecovery()
822 var doc = try editedState()
823 guard case .changedAfterWrite(.reloaded) = try Saver(fileSystem: files, recovery: recovery).save(&doc, to: url) else {
824 Issue.record("expected a reload of their version")
825 return
826 }
827 #expect(recovery.contains("A\nb\nc\n"))
828 #expect(doc.text == "A\nb\nc\nD\n")
829 }
830
831 @Test func keepsChangingGivesUp() throws {
832 let files = FaultyFileSystem("a\nb\nc\n")
833 for n in stride(from: 2, through: 6, by: 2) { files.beforeRead[n] = Array("a\nb\nc\n\(n)\n".utf8) }
834 var doc = try editedState()
835 #expect(throws: SaveError.fileKeepsChanging) {
836 try Saver(fileSystem: files, recovery: MemoryRecovery()).save(&doc, to: url)
837 }
838 }
839}
840
841struct FileStorageTests {
842 func temporaryFolder() throws -> URL {
843 let folder = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-\(UUID().uuidString)")
844 try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
845 return folder
846 }
847
848 @Test func readAndReplace() throws {
849 let folder = try temporaryFolder()
850 defer { try? FileManager.default.removeItem(at: folder) }
851 let file = folder.appendingPathComponent("a.org")
852 let files = CoordinatedFileSystem()
853 #expect(try files.read(file) == nil)
854 #expect(try files.replace(file, with: Array("one\n".utf8)) == nil)
855 #expect(try files.replace(file, with: Array("two\n".utf8)) == Array("one\n".utf8))
856 #expect(try files.read(file) == Array("two\n".utf8))
857 #expect(try FileManager.default.contentsOfDirectory(atPath: folder.path) == ["a.org"])
858 }
859
860 @Test func savesThroughTheRealFileSystem() throws {
861 let folder = try temporaryFolder()
862 defer { try? FileManager.default.removeItem(at: folder) }
863 let file = folder.appendingPathComponent("a.org")
864 try Data("a\nb\nc\n".utf8).write(to: file)
865 let recovery = FileRecoveryStore(directory: folder.appendingPathComponent("recovery"))
866 var doc = try editedState()
867 #expect(try Saver(fileSystem: CoordinatedFileSystem(), recovery: recovery).save(&doc, to: file) == .saved)
868 #expect(try String(contentsOf: file, encoding: .utf8) == "A\nb\nc\n")
869 }
870
871 @Test func recoveryKeepsTheNewestVersions() throws {
872 let folder = try temporaryFolder()
873 defer { try? FileManager.default.removeItem(at: folder) }
874 let store = FileRecoveryStore(directory: folder, limit: 3)
875 for n in 0..<5 { try store.keep(Array("v\(n)".utf8), for: url, label: "local") }
876 let versions = try store.versions(for: url)
877 #expect(versions.count == 3)
878 #expect(try versions.map { try String(contentsOf: $0, encoding: .utf8) } == ["v2", "v3", "v4"])
879 }
880}
881```
882
883- [ ] **Step 2: Run to verify failure**
884
885Run: `swift test --filter SaveTests`
886Expected: build failure, `cannot find type 'FileSystem' in scope`.
887
888- [ ] **Step 3: Implement `Saving.swift`**
889
890```swift
891import Foundation
892import OrgCore
893
894public protocol FileSystem: Sendable {
895 /// The file's bytes, or nil if it doesn't exist.
896 func read(_ url: URL) throws -> [UInt8]?
897 /// Replaces the file through a temporary file in the same folder. Returns the bytes that
898 /// were replaced, read from the replaced file itself, or nil if there was none.
899 func replace(_ url: URL, with bytes: [UInt8]) throws -> [UInt8]?
900}
901
902public protocol RecoveryStore: Sendable {
903 func keep(_ bytes: [UInt8], for url: URL, label: String) throws
904}
905
906public enum SaveOutcome: Sendable, Equatable {
907 case saved
908 /// The file had changed since it was read; the change merged cleanly into the buffer and
909 /// the merge was written.
910 case mergedAndSaved([TextEdit])
911 /// The file had changed and the change conflicts. Nothing was written; the buffer is
912 /// unchanged.
913 case conflict([MergeConflict])
914 /// Another writer replaced the file between our last check and our write. Ours is on disk;
915 /// theirs is in recovery and was merged into the buffer where possible.
916 case overwroteExternalChange(DocumentState.ExternalChange)
917 /// Another writer changed the file right after our write. Theirs is on disk; ours is in
918 /// recovery.
919 case changedAfterWrite(DocumentState.ExternalChange)
920}
921
922public enum SaveError: Error, Equatable {
923 case fileKeepsChanging
924}
925
926/// The save sequence from the design: read, merge if the file moved, check again, replace,
927/// read back. Emacs and Syncthing don't coordinate, so the sequence can't lock them out; it
928/// narrows the window and makes sure every version it displaces lands in recovery.
929public struct Saver: Sendable {
930 public let fileSystem: FileSystem
931 public let recovery: RecoveryStore
932 public var maxAttempts = 3
933
934 public init(fileSystem: FileSystem, recovery: RecoveryStore) {
935 self.fileSystem = fileSystem
936 self.recovery = recovery
937 }
938
939 public func save(_ state: inout DocumentState, to url: URL) throws -> SaveOutcome {
940 guard state.isEditable else { throw DocumentState.EditError.readOnly }
941 for _ in 0..<maxAttempts {
942 let disk = try fileSystem.read(url)
943 var merged: [TextEdit]?
944 if let disk, disk != state.mergeBase {
945 try recovery.keep(disk, for: url, label: "external")
946 try recovery.keep(state.encodedText(), for: url, label: "local")
947 switch state.diskChanged(to: disk) {
948 case .conflict(let conflicts): return .conflict(conflicts)
949 case .merged(let edits), .reloaded(let edits): merged = edits
950 case .unchanged: break
951 }
952 }
953 let bytes = try state.encodedText()
954 guard try fileSystem.read(url) == disk else { continue }
955
956 let replaced = try fileSystem.replace(url, with: bytes)
957 state.didWrite(bytes)
958 if replaced != disk, let replaced {
959 try recovery.keep(replaced, for: url, label: "external")
960 return .overwroteExternalChange(state.mergeOverwritten(replaced, base: disk ?? []))
961 }
962
963 if let after = try fileSystem.read(url), after != bytes {
964 try recovery.keep(bytes, for: url, label: "local")
965 return .changedAfterWrite(state.diskChanged(to: after))
966 }
967 return merged.map { .mergedAndSaved($0) } ?? .saved
968 }
969 throw SaveError.fileKeepsChanging
970 }
971}
972```
973
974- [ ] **Step 4: Implement `FileStorage.swift`**
975
976```swift
977import CryptoKit
978import Foundation
979
980/// Reads and replaces files under `NSFileCoordinator`, so iCloud and other coordinating
981/// writers see a consistent file.
982public struct CoordinatedFileSystem: FileSystem {
983 public init() {}
984
985 public func read(_ url: URL) throws -> [UInt8]? {
986 try coordinate(reading: url) { url in
987 FileManager.default.fileExists(atPath: url.path) ? [UInt8](try Data(contentsOf: url)) : nil
988 }
989 }
990
991 public func replace(_ url: URL, with bytes: [UInt8]) throws -> [UInt8]? {
992 try coordinate(writing: url) { url in
993 let manager = FileManager.default
994 let folder = url.deletingLastPathComponent()
995 let temporary = folder.appendingPathComponent(".\(url.lastPathComponent).orgstar-\(UUID().uuidString)")
996 try Data(bytes).write(to: temporary)
997 guard manager.fileExists(atPath: url.path) else {
998 try manager.moveItem(at: temporary, to: url)
999 return nil
1000 }
1001 // The backup is the file as it was at the moment of replacement, including any
1002 // write that landed after our last check.
1003 let backupName = ".\(url.lastPathComponent).orgstar-backup-\(UUID().uuidString)"
1004 _ = try manager.replaceItemAt(url, withItemAt: temporary, backupItemName: backupName, options: .withoutDeletingBackupItem)
1005 let backup = folder.appendingPathComponent(backupName)
1006 defer { try? manager.removeItem(at: backup) }
1007 return [UInt8](try Data(contentsOf: backup))
1008 }
1009 }
1010
1011 private func coordinate<T>(reading url: URL, _ body: (URL) throws -> T) throws -> T {
1012 var coordinationError: NSError?
1013 var result: Result<T, Error>?
1014 NSFileCoordinator(filePresenter: nil).coordinate(readingItemAt: url, options: [], error: &coordinationError) { url in
1015 result = Result { try body(url) }
1016 }
1017 if let coordinationError { throw coordinationError }
1018 return try result!.get()
1019 }
1020
1021 private func coordinate<T>(writing url: URL, _ body: (URL) throws -> T) throws -> T {
1022 var coordinationError: NSError?
1023 var result: Result<T, Error>?
1024 NSFileCoordinator(filePresenter: nil).coordinate(writingItemAt: url, options: .forReplacing, error: &coordinationError) { url in
1025 result = Result { try body(url) }
1026 }
1027 if let coordinationError { throw coordinationError }
1028 return try result!.get()
1029 }
1030}
1031
1032/// Keeps the last `limit` displaced versions per file in `directory/<hash of path>/`.
1033public struct FileRecoveryStore: RecoveryStore {
1034 public let directory: URL
1035 public let limit: Int
1036
1037 public init(directory: URL, limit: Int = 20) {
1038 self.directory = directory
1039 self.limit = limit
1040 }
1041
1042 public func folder(for url: URL) -> URL {
1043 let digest = SHA256.hash(data: Data(url.standardizedFileURL.path.utf8))
1044 let name = digest.prefix(8).map { String(format: "%02x", $0) }.joined()
1045 return directory.appendingPathComponent(name, isDirectory: true)
1046 }
1047
1048 public func keep(_ bytes: [UInt8], for url: URL, label: String) throws {
1049 let folder = folder(for: url)
1050 let manager = FileManager.default
1051 try manager.createDirectory(at: folder, withIntermediateDirectories: true)
1052 // Zero-padded wall-clock nanoseconds sort by time; the UUID keeps same-instant names apart.
1053 let stamp = String(format: "%020llu", UInt64(Date().timeIntervalSince1970 * 1_000_000_000))
1054 let name = "\(stamp)-\(label)-\(UUID().uuidString.prefix(8))-\(url.lastPathComponent)"
1055 try Data(bytes).write(to: folder.appendingPathComponent(name))
1056 let kept = try manager.contentsOfDirectory(atPath: folder.path).sorted()
1057 for old in kept.dropLast(limit) {
1058 try manager.removeItem(at: folder.appendingPathComponent(old))
1059 }
1060 }
1061
1062 /// Kept versions, oldest first.
1063 public func versions(for url: URL) throws -> [URL] {
1064 let folder = folder(for: url)
1065 guard FileManager.default.fileExists(atPath: folder.path) else { return [] }
1066 return try FileManager.default.contentsOfDirectory(atPath: folder.path).sorted().map { folder.appendingPathComponent($0) }
1067 }
1068}
1069```
1070
1071- [ ] **Step 5: Run everything, including the iOS build**
1072
1073Run: `swift test`, then `xcodebuild -scheme OrgDocument -destination 'generic/platform=iOS' build`
1074Expected: all pass; `BUILD SUCCEEDED`.
1075
1076- [ ] **Step 6: Commit**
1077
1078```bash
1079git add Sources/OrgDocument/Saving.swift Sources/OrgDocument/FileStorage.swift Tests/OrgDocumentTests/SaveTests.swift
1080git commit -m "Add save path with recovery and fault-injection tests"
1081```