Document session and save path !5

merged merged by cmc on 2026-10-05 00:06 UTC · krz/orgstar:phase1-document-session into main

10 files changed, +1989 −2

Layout: unified · split

Package.swift +5 −2
@@ -5,10 +5,13 @@ let package = Package(
5 name: "Orgstar", 5 name: "Orgstar",
6 platforms: [.macOS(.v26), .iOS(.v26)], 6 platforms: [.macOS(.v26), .iOS(.v26)],
7 products: [ 7 products: [
8 .library(name: "OrgCore", targets: ["OrgCore"]) 8 .library(name: "OrgCore", targets: ["OrgCore"]),
9 .library(name: "OrgDocument", targets: ["OrgDocument"])
9 ], 10 ],
10 targets: [ 11 targets: [
11 .target(name: "OrgCore"), 12 .target(name: "OrgCore"),
12 .testTarget(name: "OrgCoreTests", dependencies: ["OrgCore"]) 13 .target(name: "OrgDocument", dependencies: ["OrgCore"]),
14 .testTarget(name: "OrgCoreTests", dependencies: ["OrgCore"]),
15 .testTarget(name: "OrgDocumentTests", dependencies: ["OrgDocument"])
13 ] 16 ]
14) 17)
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/FileStorage.swift added +92
@@ -0,0 +1,92 @@
1import CryptoKit
2import Foundation
3
4/// Reads and replaces files under `NSFileCoordinator`, so iCloud and other coordinating
5/// writers see a consistent file.
6public struct CoordinatedFileSystem: FileSystem {
7 public init() {}
8
9 public func read(_ url: URL) throws -> [UInt8]? {
10 try coordinate(reading: url) { url in
11 FileManager.default.fileExists(atPath: url.path) ? [UInt8](try Data(contentsOf: url)) : nil
12 }
13 }
14
15 public func replace(_ url: URL, with bytes: [UInt8]) throws -> [UInt8]? {
16 try coordinate(writing: url) { url in
17 let manager = FileManager.default
18 let folder = url.deletingLastPathComponent()
19 let temporary = folder.appendingPathComponent(".\(url.lastPathComponent).orgstar-\(UUID().uuidString)")
20 try Data(bytes).write(to: temporary)
21 guard manager.fileExists(atPath: url.path) else {
22 try manager.moveItem(at: temporary, to: url)
23 return nil
24 }
25 // The backup is the file as it was at the moment of replacement, including any
26 // write that landed after our last check.
27 let backupName = ".\(url.lastPathComponent).orgstar-backup-\(UUID().uuidString)"
28 _ = try manager.replaceItemAt(url, withItemAt: temporary, backupItemName: backupName, options: .withoutDeletingBackupItem)
29 let backup = folder.appendingPathComponent(backupName)
30 defer { try? manager.removeItem(at: backup) }
31 return [UInt8](try Data(contentsOf: backup))
32 }
33 }
34
35 private func coordinate<T>(reading url: URL, _ body: (URL) throws -> T) throws -> T {
36 var coordinationError: NSError?
37 var result: Result<T, Error>?
38 NSFileCoordinator(filePresenter: nil).coordinate(readingItemAt: url, options: [], error: &coordinationError) { url in
39 result = Result { try body(url) }
40 }
41 if let coordinationError { throw coordinationError }
42 return try result!.get()
43 }
44
45 private func coordinate<T>(writing url: URL, _ body: (URL) throws -> T) throws -> T {
46 var coordinationError: NSError?
47 var result: Result<T, Error>?
48 NSFileCoordinator(filePresenter: nil).coordinate(writingItemAt: url, options: .forReplacing, error: &coordinationError) { url in
49 result = Result { try body(url) }
50 }
51 if let coordinationError { throw coordinationError }
52 return try result!.get()
53 }
54}
55
56/// Keeps the last `limit` displaced versions per file in `directory/<hash of path>/`.
57public struct FileRecoveryStore: RecoveryStore {
58 public let directory: URL
59 public let limit: Int
60
61 public init(directory: URL, limit: Int = 20) {
62 self.directory = directory
63 self.limit = limit
64 }
65
66 public func folder(for url: URL) -> URL {
67 let digest = SHA256.hash(data: Data(url.standardizedFileURL.path.utf8))
68 let name = digest.prefix(8).map { String(format: "%02x", $0) }.joined()
69 return directory.appendingPathComponent(name, isDirectory: true)
70 }
71
72 public func keep(_ bytes: [UInt8], for url: URL, label: String) throws {
73 let folder = folder(for: url)
74 let manager = FileManager.default
75 try manager.createDirectory(at: folder, withIntermediateDirectories: true)
76 // Zero-padded wall-clock nanoseconds sort by time; the UUID keeps same-instant names apart.
77 let stamp = String(format: "%020llu", UInt64(Date().timeIntervalSince1970 * 1_000_000_000))
78 let name = "\(stamp)-\(label)-\(UUID().uuidString.prefix(8))-\(url.lastPathComponent)"
79 try Data(bytes).write(to: folder.appendingPathComponent(name))
80 let kept = try manager.contentsOfDirectory(atPath: folder.path).sorted()
81 for old in kept.dropLast(limit) {
82 try manager.removeItem(at: folder.appendingPathComponent(old))
83 }
84 }
85
86 /// Kept versions, oldest first.
87 public func versions(for url: URL) throws -> [URL] {
88 let folder = folder(for: url)
89 guard FileManager.default.fileExists(atPath: folder.path) else { return [] }
90 return try FileManager.default.contentsOfDirectory(atPath: folder.path).sorted().map { folder.appendingPathComponent($0) }
91 }
92}
Sources/OrgDocument/Merge.swift added +175
@@ -0,0 +1,175 @@
1import OrgCore
2
3public struct MergeConflict: Sendable, Equatable {
4 public let base: String
5 public let ours: String
6 public let theirs: String
7}
8
9public enum MergeResult: Sendable, Equatable {
10 case merged(String)
11 case conflict([MergeConflict])
12}
13
14/// Line-based three-way merge. Lines keep their endings, so CRLF and a missing final newline
15/// survive. A region changed on one side takes that side; changed the same way on both, takes
16/// it once; changed differently, is a conflict.
17public func threeWayMerge(base: String, ours: String, theirs: String) -> MergeResult {
18 let baseLines = textLines(base)
19 let ourLines = textLines(ours)
20 let theirLines = textLines(theirs)
21 var ids = LineIDs()
22 let baseIDs = ids.encode(baseLines)
23 let ourIDs = ids.encode(ourLines)
24 let theirIDs = ids.encode(theirLines)
25
26 var toOurs = [Int?](repeating: nil, count: baseLines.count)
27 for (b, o) in matchingLines(baseIDs, ourIDs) { toOurs[b] = o }
28 var toTheirs = [Int?](repeating: nil, count: baseLines.count)
29 for (b, t) in matchingLines(baseIDs, theirIDs) { toTheirs[b] = t }
30
31 var merged: [String] = []
32 var conflicts: [MergeConflict] = []
33 var nextBase = 0, nextOurs = 0, nextTheirs = 0
34
35 func resolve(_ baseEnd: Int, _ oursEnd: Int, _ theirsEnd: Int) {
36 let b = baseLines[nextBase..<baseEnd]
37 let o = ourLines[nextOurs..<oursEnd]
38 let t = theirLines[nextTheirs..<theirsEnd]
39 if o.elementsEqual(b) {
40 merged += t
41 } else if t.elementsEqual(b) || o.elementsEqual(t) {
42 merged += o
43 } else {
44 conflicts.append(MergeConflict(base: b.joined(), ours: o.joined(), theirs: t.joined()))
45 }
46 }
47
48 // A base line kept by both sides is a fixed point; everything between fixed points is
49 // resolved as one region.
50 for k in baseLines.indices {
51 guard let o = toOurs[k], let t = toTheirs[k], o >= nextOurs, t >= nextTheirs else { continue }
52 resolve(k, o, t)
53 merged.append(baseLines[k])
54 nextBase = k + 1
55 nextOurs = o + 1
56 nextTheirs = t + 1
57 }
58 resolve(baseLines.count, ourLines.count, theirLines.count)
59 return conflicts.isEmpty ? .merged(merged.joined()) : .conflict(conflicts)
60}
61
62/// Edits, in `old` coordinates and ascending order, that turn `old` into `new` line by line.
63public func lineEdits(from old: String, to new: String) -> [TextEdit] {
64 let oldLines = textLines(old)
65 let newLines = textLines(new)
66 var ids = LineIDs()
67 let pairs = matchingLines(ids.encode(oldLines), ids.encode(newLines))
68 var offsets = [0]
69 for line in oldLines { offsets.append(offsets.last! + line.utf16.count) }
70 var edits: [TextEdit] = []
71 var i = 0, j = 0
72 for (pi, pj) in pairs + [(oldLines.count, newLines.count)] {
73 if i < pi || j < pj {
74 edits.append(TextEdit(range: offsets[i]..<offsets[pi], replacement: newLines[j..<pj].joined()))
75 }
76 i = pi + 1
77 j = pj + 1
78 }
79 return edits
80}
81
82/// Lines with their endings.
83func textLines(_ text: String) -> [String] {
84 var lines: [String] = []
85 var current = ""
86 for scalar in text.unicodeScalars {
87 current.unicodeScalars.append(scalar)
88 if scalar == "\n" {
89 lines.append(current)
90 current = ""
91 }
92 }
93 if !current.isEmpty { lines.append(current) }
94 return lines
95}
96
97struct LineIDs {
98 private var ids: [String: Int] = [:]
99
100 mutating func encode(_ lines: [String]) -> [Int] {
101 lines.map { line in
102 if let id = ids[line] { return id }
103 let id = ids.count
104 ids[line] = id
105 return id
106 }
107 }
108}
109
110/// Matched index pairs of a longest common subsequence, ascending. Common prefix and suffix
111/// are matched directly; Myers' algorithm handles the middle. When the middle differs by more
112/// than the memory budget allows, it is treated as having no matches.
113func matchingLines(_ a: [Int], _ b: [Int]) -> [(Int, Int)] {
114 var prefix = 0
115 while prefix < a.count, prefix < b.count, a[prefix] == b[prefix] { prefix += 1 }
116 var suffix = 0
117 while suffix < a.count - prefix, suffix < b.count - prefix, a[a.count - 1 - suffix] == b[b.count - 1 - suffix] {
118 suffix += 1
119 }
120 var pairs = (0..<prefix).map { ($0, $0) }
121 let middleA = Array(a[prefix..<(a.count - suffix)])
122 let middleB = Array(b[prefix..<(b.count - suffix)])
123 pairs += myers(middleA, middleB).map { ($0.0 + prefix, $0.1 + prefix) }
124 pairs += (0..<suffix).map { (a.count - suffix + $0, b.count - suffix + $0) }
125 return pairs
126}
127
128private func myers(_ a: [Int], _ b: [Int]) -> [(Int, Int)] {
129 let n = a.count, m = b.count
130 guard n > 0, m > 0 else { return [] }
131 let maxD = n + m
132 let offset = maxD + 1
133 // Each step keeps a copy of the frontier for backtracking; cap that at ~20M entries.
134 let budget = max(1, 20_000_000 / (2 * maxD + 3))
135 var v = [Int](repeating: 0, count: 2 * maxD + 3)
136 var trace: [[Int]] = []
137 var found = false
138 search: for d in 0...min(maxD, budget) {
139 trace.append(v)
140 for k in stride(from: -d, through: d, by: 2) {
141 var x = (k == -d || (k != d && v[offset + k - 1] < v[offset + k + 1])) ? v[offset + k + 1] : v[offset + k - 1] + 1
142 var y = x - k
143 while x < n, y < m, a[x] == b[y] {
144 x += 1
145 y += 1
146 }
147 v[offset + k] = x
148 if x >= n, y >= m {
149 found = true
150 break search
151 }
152 }
153 }
154 guard found else { return [] }
155
156 var pairs: [(Int, Int)] = []
157 var x = n, y = m
158 for d in stride(from: trace.count - 1, through: 0, by: -1) {
159 let v = trace[d]
160 let k = x - y
161 let previousK = (k == -d || (k != d && v[offset + k - 1] < v[offset + k + 1])) ? k + 1 : k - 1
162 let previousX = v[offset + previousK]
163 let previousY = previousX - previousK
164 while x > previousX, y > previousY {
165 pairs.append((x - 1, y - 1))
166 x -= 1
167 y -= 1
168 }
169 if d > 0 {
170 x = previousX
171 y = previousY
172 }
173 }
174 return pairs.reversed()
175}
Sources/OrgDocument/Saving.swift added +81
@@ -0,0 +1,81 @@
1import Foundation
2import OrgCore
3
4public protocol FileSystem: Sendable {
5 /// The file's bytes, or nil if it doesn't exist.
6 func read(_ url: URL) throws -> [UInt8]?
7 /// Replaces the file through a temporary file in the same folder. Returns the bytes that
8 /// were replaced, read from the replaced file itself, or nil if there was none.
9 func replace(_ url: URL, with bytes: [UInt8]) throws -> [UInt8]?
10}
11
12public protocol RecoveryStore: Sendable {
13 func keep(_ bytes: [UInt8], for url: URL, label: String) throws
14}
15
16public enum SaveOutcome: Sendable, Equatable {
17 case saved
18 /// The file had changed since it was read; the change merged cleanly into the buffer and
19 /// the merge was written.
20 case mergedAndSaved([TextEdit])
21 /// The file had changed and the change conflicts. Nothing was written; the buffer is
22 /// unchanged.
23 case conflict([MergeConflict])
24 /// Another writer replaced the file between our last check and our write. Ours is on disk;
25 /// theirs is in recovery and was merged into the buffer where possible.
26 case overwroteExternalChange(DocumentState.ExternalChange)
27 /// Another writer changed the file right after our write. Theirs is on disk; ours is in
28 /// recovery.
29 case changedAfterWrite(DocumentState.ExternalChange)
30}
31
32public enum SaveError: Error, Equatable {
33 case fileKeepsChanging
34}
35
36/// The save sequence from the design: read, merge if the file moved, check again, replace,
37/// read back. Emacs and Syncthing don't coordinate, so the sequence can't lock them out; it
38/// narrows the window and makes sure every version it displaces lands in recovery.
39public struct Saver: Sendable {
40 public let fileSystem: FileSystem
41 public let recovery: RecoveryStore
42 public var maxAttempts = 3
43
44 public init(fileSystem: FileSystem, recovery: RecoveryStore) {
45 self.fileSystem = fileSystem
46 self.recovery = recovery
47 }
48
49 public func save(_ state: inout DocumentState, to url: URL) throws -> SaveOutcome {
50 guard state.isEditable else { throw DocumentState.EditError.readOnly }
51 for _ in 0..<maxAttempts {
52 let disk = try fileSystem.read(url)
53 var merged: [TextEdit]?
54 if let disk, disk != state.mergeBase {
55 try recovery.keep(disk, for: url, label: "external")
56 try recovery.keep(state.encodedText(), for: url, label: "local")
57 switch state.diskChanged(to: disk) {
58 case .conflict(let conflicts): return .conflict(conflicts)
59 case .merged(let edits), .reloaded(let edits): merged = edits
60 case .unchanged: break
61 }
62 }
63 let bytes = try state.encodedText()
64 guard try fileSystem.read(url) == disk else { continue }
65
66 let replaced = try fileSystem.replace(url, with: bytes)
67 state.didWrite(bytes)
68 if replaced != disk, let replaced {
69 try recovery.keep(replaced, for: url, label: "external")
70 return .overwroteExternalChange(state.mergeOverwritten(replaced, base: disk ?? []))
71 }
72
73 if let after = try fileSystem.read(url), after != bytes {
74 try recovery.keep(bytes, for: url, label: "local")
75 return .changedAfterWrite(state.diskChanged(to: after))
76 }
77 return merged.map { .mergedAndSaved($0) } ?? .saved
78 }
79 throw SaveError.fileKeepsChanging
80 }
81}
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}
Tests/OrgDocumentTests/MergeTests.swift added +47
@@ -0,0 +1,47 @@
1import OrgCore
2import Testing
3@testable import OrgDocument
4
5struct MergeTests {
6 @Test func separateChangesMerge() {
7 #expect(threeWayMerge(base: "a\nb\nc\n", ours: "A\nb\nc\n", theirs: "a\nb\nC\n") == .merged("A\nb\nC\n"))
8 }
9
10 @Test func sameChangeOnBothSidesMergesOnce() {
11 #expect(threeWayMerge(base: "a\nb\n", ours: "a\nX\n", theirs: "a\nX\n") == .merged("a\nX\n"))
12 }
13
14 @Test func differentChangesConflict() {
15 let result = threeWayMerge(base: "a\nb\nc\n", ours: "a\nX\nc\n", theirs: "a\nY\nc\n")
16 #expect(result == .conflict([MergeConflict(base: "b\n", ours: "X\n", theirs: "Y\n")]))
17 }
18
19 @Test func insertionsAndDeletions() {
20 #expect(threeWayMerge(base: "a\nb\n", ours: "a\nb\nc\n", theirs: "b\n") == .merged("b\nc\n"))
21 #expect(threeWayMerge(base: "", ours: "x\n", theirs: "") == .merged("x\n"))
22 }
23
24 @Test func lineEndingsSurvive() {
25 #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"))
26 }
27
28 @Test func lineEditsRebuildTheNewText() {
29 let pairs = [("a\nb\nc\n", "a\nx\nc\nd\n"), ("", "x"), ("x", ""), ("a\r\nb", "b\r\na"), ("same\n", "same\n")]
30 for (old, new) in pairs {
31 var text = old
32 for edit in lineEdits(from: old, to: new).reversed() { text = edit.apply(to: text) }
33 #expect(text == new)
34 }
35 }
36
37 @Test func largeFilesWithSmallChanges() {
38 let base = (0..<20_000).map { "line \($0)\n" }.joined()
39 let ours = base.replacingOccurrences(of: "line 100\n", with: "ours\n")
40 let theirs = base.replacingOccurrences(of: "line 19000\n", with: "theirs\n")
41 guard case .merged(let merged) = threeWayMerge(base: base, ours: ours, theirs: theirs) else {
42 Issue.record("expected a clean merge")
43 return
44 }
45 #expect(merged.contains("ours\n") && merged.contains("theirs\n"))
46 }
47}
Tests/OrgDocumentTests/SaveTests.swift added +194
@@ -0,0 +1,194 @@
1import Foundation
2import OrgCore
3import Testing
4@testable import OrgDocument
5
6/// An in-memory file with hooks that let another writer change it at each step of a save.
7final class FaultyFileSystem: FileSystem, @unchecked Sendable {
8 var file: [UInt8]?
9 var reads = 0
10 /// Content another writer puts in place just before the n-th read (1-based).
11 var beforeRead: [Int: [UInt8]] = [:]
12 /// Content another writer puts in place just before our replace.
13 var beforeReplace: [UInt8]?
14
15 init(_ text: String?) {
16 file = text.map { Array($0.utf8) }
17 }
18
19 func read(_ url: URL) throws -> [UInt8]? {
20 reads += 1
21 if let injected = beforeRead[reads] { file = injected }
22 return file
23 }
24
25 func replace(_ url: URL, with bytes: [UInt8]) throws -> [UInt8]? {
26 if let injected = beforeReplace { file = injected }
27 let replaced = file
28 file = bytes
29 return replaced
30 }
31
32 var text: String? { file.map { String(decoding: $0, as: UTF8.self) } }
33}
34
35final class MemoryRecovery: RecoveryStore, @unchecked Sendable {
36 var kept: [(label: String, text: String)] = []
37
38 func keep(_ bytes: [UInt8], for url: URL, label: String) throws {
39 kept.append((label, String(decoding: bytes, as: UTF8.self)))
40 }
41
42 func contains(_ text: String) -> Bool { kept.contains { $0.text == text } }
43}
44
45let url = URL(fileURLWithPath: "/notes/a.org")
46
47/// A buffer loaded from "a\nb\nc\n" with its first line changed to "A".
48func editedState() throws -> DocumentState {
49 var doc = state("a\nb\nc\n")
50 try doc.apply([TextEdit(range: 0..<1, replacement: "A")], baseRevision: 0)
51 return doc
52}
53
54struct SaveTests {
55 @Test func plainSave() throws {
56 let files = FaultyFileSystem("a\nb\nc\n")
57 let recovery = MemoryRecovery()
58 var doc = try editedState()
59 #expect(try Saver(fileSystem: files, recovery: recovery).save(&doc, to: url) == .saved)
60 #expect(files.text == "A\nb\nc\n")
61 #expect(!doc.isDirty)
62 #expect(recovery.kept.isEmpty)
63 }
64
65 @Test func missingFileIsCreated() throws {
66 let files = FaultyFileSystem(nil)
67 var doc = try editedState()
68 #expect(try Saver(fileSystem: files, recovery: MemoryRecovery()).save(&doc, to: url) == .saved)
69 #expect(files.text == "A\nb\nc\n")
70 }
71
72 @Test func readOnlyDocumentsAreNotSaved() {
73 var doc = DocumentState(bytes: [0x61, 0xFF])
74 #expect(throws: DocumentState.EditError.readOnly) {
75 try Saver(fileSystem: FaultyFileSystem("x"), recovery: MemoryRecovery()).save(&doc, to: url)
76 }
77 }
78
79 // MARK: - Another writer at each step
80
81 @Test func changedBeforeSaveMerges() throws {
82 let files = FaultyFileSystem("a\nb\nc\n")
83 files.beforeRead[1] = Array("a\nb\nC\n".utf8)
84 let recovery = MemoryRecovery()
85 var doc = try editedState()
86 guard case .mergedAndSaved = try Saver(fileSystem: files, recovery: recovery).save(&doc, to: url) else {
87 Issue.record("expected a merge")
88 return
89 }
90 #expect(files.text == "A\nb\nC\n")
91 #expect(recovery.contains("a\nb\nC\n") && recovery.contains("A\nb\nc\n"))
92 }
93
94 @Test func conflictingChangeWritesNothing() throws {
95 let files = FaultyFileSystem("a\nb\nc\n")
96 files.beforeRead[1] = Array("Z\nb\nc\n".utf8)
97 var doc = try editedState()
98 guard case .conflict = try Saver(fileSystem: files, recovery: MemoryRecovery()).save(&doc, to: url) else {
99 Issue.record("expected a conflict")
100 return
101 }
102 #expect(files.text == "Z\nb\nc\n")
103 #expect(doc.text == "A\nb\nc\n")
104 }
105
106 @Test func changedBetweenReadAndCheckRetries() throws {
107 let files = FaultyFileSystem("a\nb\nc\n")
108 files.beforeRead[2] = Array("a\nb\nC\n".utf8)
109 var doc = try editedState()
110 guard case .mergedAndSaved = try Saver(fileSystem: files, recovery: MemoryRecovery()).save(&doc, to: url) else {
111 Issue.record("expected a merge on the second attempt")
112 return
113 }
114 #expect(files.text == "A\nb\nC\n")
115 }
116
117 @Test func changedJustBeforeReplaceIsRecovered() throws {
118 let files = FaultyFileSystem("a\nb\nc\n")
119 files.beforeReplace = Array("a\nb\nC\n".utf8)
120 let recovery = MemoryRecovery()
121 var doc = try editedState()
122 guard case .overwroteExternalChange(.merged) = try Saver(fileSystem: files, recovery: recovery).save(&doc, to: url) else {
123 Issue.record("expected their change merged into the buffer")
124 return
125 }
126 #expect(files.text == "A\nb\nc\n")
127 #expect(recovery.contains("a\nb\nC\n"))
128 #expect(doc.text == "A\nb\nC\n")
129 #expect(doc.isDirty)
130 }
131
132 @Test func changedRightAfterWriteKeepsOursInRecovery() throws {
133 let files = FaultyFileSystem("a\nb\nc\n")
134 files.beforeRead[3] = Array("A\nb\nc\nD\n".utf8)
135 let recovery = MemoryRecovery()
136 var doc = try editedState()
137 guard case .changedAfterWrite(.reloaded) = try Saver(fileSystem: files, recovery: recovery).save(&doc, to: url) else {
138 Issue.record("expected a reload of their version")
139 return
140 }
141 #expect(recovery.contains("A\nb\nc\n"))
142 #expect(doc.text == "A\nb\nc\nD\n")
143 }
144
145 @Test func keepsChangingGivesUp() throws {
146 let files = FaultyFileSystem("a\nb\nc\n")
147 for n in stride(from: 2, through: 6, by: 2) { files.beforeRead[n] = Array("a\nb\nc\n\(n)\n".utf8) }
148 var doc = try editedState()
149 #expect(throws: SaveError.fileKeepsChanging) {
150 try Saver(fileSystem: files, recovery: MemoryRecovery()).save(&doc, to: url)
151 }
152 }
153}
154
155struct FileStorageTests {
156 func temporaryFolder() throws -> URL {
157 let folder = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-\(UUID().uuidString)")
158 try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
159 return folder
160 }
161
162 @Test func readAndReplace() throws {
163 let folder = try temporaryFolder()
164 defer { try? FileManager.default.removeItem(at: folder) }
165 let file = folder.appendingPathComponent("a.org")
166 let files = CoordinatedFileSystem()
167 #expect(try files.read(file) == nil)
168 #expect(try files.replace(file, with: Array("one\n".utf8)) == nil)
169 #expect(try files.replace(file, with: Array("two\n".utf8)) == Array("one\n".utf8))
170 #expect(try files.read(file) == Array("two\n".utf8))
171 #expect(try FileManager.default.contentsOfDirectory(atPath: folder.path) == ["a.org"])
172 }
173
174 @Test func savesThroughTheRealFileSystem() throws {
175 let folder = try temporaryFolder()
176 defer { try? FileManager.default.removeItem(at: folder) }
177 let file = folder.appendingPathComponent("a.org")
178 try Data("a\nb\nc\n".utf8).write(to: file)
179 let recovery = FileRecoveryStore(directory: folder.appendingPathComponent("recovery"))
180 var doc = try editedState()
181 #expect(try Saver(fileSystem: CoordinatedFileSystem(), recovery: recovery).save(&doc, to: file) == .saved)
182 #expect(try String(contentsOf: file, encoding: .utf8) == "A\nb\nc\n")
183 }
184
185 @Test func recoveryKeepsTheNewestVersions() throws {
186 let folder = try temporaryFolder()
187 defer { try? FileManager.default.removeItem(at: folder) }
188 let store = FileRecoveryStore(directory: folder, limit: 3)
189 for n in 0..<5 { try store.keep(Array("v\(n)".utf8), for: url, label: "local") }
190 let versions = try store.versions(for: url)
191 #expect(versions.count == 3)
192 #expect(try versions.map { try String(contentsOf: $0, encoding: .utf8) } == ["v2", "v3", "v4"])
193 }
194}
docs/plans/2026-10-04-document-session.md added +1081
@@ -0,0 +1,1081 @@
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```