scrive_core/transaction.rs
1//! The atomic multi-range transaction engine.
2//!
3//! Every change to a [`Buffer`] — typing, paste, undo, redo, programmatic —
4//! goes through `apply`: one mutation path, so an inverse can never drift
5//! from what was applied — the inverse is derived mechanically from the same
6//! normalized batch, never hand-written. The contract, in order:
7//!
8//! 1. clip each op's range to the pre-edit document (char boundaries) and
9//! normalize its text to LF;
10//! 2. stable-sort ops ascending by start, ties in caller order (so two inserts
11//! at one offset apply in the order the caller gave them);
12//! 3. overlap is a programmer error → `Err` (debug-panics);
13//! 4. capture each op's replaced text *before* mutating, and build both the
14//! forward [`Patch`] and the inverse ops (delta-chained on the sorted list);
15//! 5. apply descending so offsets stay valid; drop no-ops; an empty batch is no
16//! transaction at all (the revision does not move).
17//!
18//! Undo and redo are just `apply` fed the inverse ops — the
19//! `Committed::inverse_ops` of one call are the input of the next.
20
21use std::ops::Range;
22
23use crate::buffer::Buffer;
24use crate::coords::Bias;
25use crate::patch::{Edit, Patch};
26
27/// One replacement: put `text` where `range` currently is. `range` is in
28/// pre-edit byte coordinates; an empty `range` with empty `text` is a no-op and
29/// is dropped.
30///
31/// (A `cursor: Option<CursorPolicy>` field will join this once selections exist
32/// to position against; it has no consumer yet, so it is not built.)
33#[derive(Clone, PartialEq, Eq, Debug)]
34pub struct EditOp {
35 /// Pre-edit byte range to replace.
36 pub range: Range<u32>,
37 /// Replacement text (normalized to LF at the boundary).
38 pub text: String,
39}
40
41impl EditOp {
42 /// Replace `range` with `text`.
43 #[must_use]
44 pub fn new(range: Range<u32>, text: impl Into<String>) -> Self {
45 Self { range, text: text.into() }
46 }
47
48 /// Insert `text` at `at`.
49 #[must_use]
50 pub fn insert(at: u32, text: impl Into<String>) -> Self {
51 Self { range: at..at, text: text.into() }
52 }
53
54 /// Delete `range`.
55 #[must_use]
56 pub fn delete(range: Range<u32>) -> Self {
57 Self { range, text: String::new() }
58 }
59}
60
61/// The result of a committed transaction.
62#[derive(Clone, Debug, Default)]
63pub struct Committed {
64 patch: Patch,
65 inverse: Vec<EditOp>,
66 /// The applied (forward) ops — normalized (clipped, CRLF-folded, sorted,
67 /// no-ops dropped). Replaying them reproduces this edit, so history stores
68 /// them for redo. Present on the value `apply` returns; the `Committed`
69 /// handed back to callers keeps only the [`patch`](Self::patch) (the ops
70 /// move into history).
71 forward: Vec<EditOp>,
72}
73
74impl Committed {
75 /// The forward position-mapping currency for this transaction.
76 #[must_use]
77 pub fn patch(&self) -> &Patch {
78 &self.patch
79 }
80
81 /// A patch-only `Committed` — what [`Document::edit`](crate::Document::edit)
82 /// returns to callers after the op batches move into history (the verbs layer
83 /// reads only the patch, so carrying the ops here would just be a clone kept
84 /// alive for nobody).
85 pub(crate) fn from_patch(patch: Patch) -> Self {
86 Self { patch, inverse: Vec::new(), forward: Vec::new() }
87 }
88
89 /// Whether the transaction changed nothing (all ops were no-ops / the batch
90 /// was empty). Derived from the patch (not the inverse), so it stays correct
91 /// on the patch-only value returned after the ops move into history.
92 #[must_use]
93 pub fn is_empty(&self) -> bool {
94 self.patch.is_empty()
95 }
96
97 /// Borrow the ops that undo this transaction, in post-edit coordinates.
98 /// Feeding them back to `apply` restores the pre-edit buffer (and yields
99 /// the redo ops as *its* inverse).
100 #[must_use]
101 pub(crate) fn inverse_ops(&self) -> &[EditOp] {
102 &self.inverse
103 }
104
105 /// Borrow the applied (forward) ops — the normalized batch that produced this
106 /// edit. Used to classify the edit (e.g. did it touch a bracket char) without
107 /// re-cloning the caller's original `ops`.
108 #[must_use]
109 pub(crate) fn forward_ops(&self) -> &[EditOp] {
110 &self.forward
111 }
112
113 /// Consume this `Committed`, yielding `(patch, forward, inverse)` so the
114 /// history can *own* the two op batches with no clone while the caller keeps
115 /// the patch. The one place that needs owned ops (redo replay + undo).
116 pub(crate) fn into_ops(self) -> (Patch, Vec<EditOp>, Vec<EditOp>) {
117 (self.patch, self.forward, self.inverse)
118 }
119}
120
121/// A transaction could not be applied.
122#[non_exhaustive]
123#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
124pub enum TransactionError {
125 /// Two ops in the batch overlap in the pre-edit document — a programmer
126 /// error (the cursor layer must produce disjoint ops).
127 #[error("overlapping edit ranges: {first:?} and {second:?}")]
128 Overlap {
129 /// The earlier (by start) range.
130 first: Range<u32>,
131 /// The range that overlapped it.
132 second: Range<u32>,
133 },
134 /// The batch would grow the document past the u32 offset space — the
135 /// representational bound (offsets are `u32`), not a policy limit. There is
136 /// no separate document-size cap, so a large paste is what reaches this.
137 #[error("edit would grow the document to {len} bytes, past the u32 offset space")]
138 WouldOverflow {
139 /// The post-edit length the batch would have produced.
140 len: u64,
141 },
142}
143
144/// The post-edit document length for a normalized (disjoint, clipped) batch.
145/// Pure, so the overflow guard is unit-testable without allocating gigabytes
146/// (the same trick as the load guard in `buffer.rs`).
147fn post_len(current: u32, ops: &[EditOp]) -> u64 {
148 let delta: i64 =
149 ops.iter().map(|op| op.text.len() as i64 - (op.range.end - op.range.start) as i64).sum();
150 let len = current as i64 + delta;
151 debug_assert!(len >= 0, "deletes cannot exceed the document");
152 len as u64
153}
154
155/// Apply a batch of [`EditOp`]s to `buffer` atomically, returning the
156/// [`Committed`] result (forward patch + inverse ops + change records).
157///
158/// See the module docs for the six-step contract. The revision advances by
159/// exactly one iff at least one op actually changed text; an empty or all-no-op
160/// batch leaves the buffer and revision untouched.
161///
162/// `pub(crate)`: the public choke point is [`Document::edit`](crate::Document::edit),
163/// which is the only way to mutate text without bypassing history.
164pub(crate) fn apply(buffer: &mut Buffer, ops: Vec<EditOp>) -> Result<Committed, TransactionError> {
165 // (1) clip ranges to the pre-edit doc + normalize text; drop no-ops.
166 let mut norm: Vec<EditOp> = Vec::with_capacity(ops.len());
167 for op in ops {
168 let (a, b) = (op.range.start.min(op.range.end), op.range.start.max(op.range.end));
169 let start = buffer.clip_offset(a, Bias::Left);
170 let end = buffer.clip_offset(b, Bias::Right);
171 let text = if op.text.as_bytes().contains(&b'\r') {
172 op.text.replace("\r\n", "\n").replace('\r', "\n")
173 } else {
174 op.text
175 };
176 if start == end && text.is_empty() {
177 continue; // no-op
178 }
179 norm.push(EditOp { range: start..end, text });
180 }
181 if norm.is_empty() {
182 return Ok(Committed::default()); // empty batch → no transaction, no bump
183 }
184
185 // (2) stable-sort ascending by start; ties keep caller order.
186 norm.sort_by_key(|op| op.range.start);
187
188 // (3) reject overlap (touching is allowed).
189 for w in norm.windows(2) {
190 if w[0].range.end > w[1].range.start {
191 return Err(TransactionError::Overlap {
192 first: w[0].range.clone(),
193 second: w[1].range.clone(),
194 });
195 }
196 }
197
198 // (3b) the representational bound: refuse growth past the u32 offset
199 // space BEFORE any mutation, so a failed batch changes nothing. Offsets are
200 // u32, so this is the only size gate.
201 let len = post_len(buffer.len(), &norm);
202 if len >= u32::MAX as u64 {
203 return Err(TransactionError::WouldOverflow { len });
204 }
205
206 // (4) capture old text, build the forward patch and inverse ops.
207 let mut patch = Patch::new();
208 let mut inverse = Vec::with_capacity(norm.len());
209 let mut delta: i64 = 0;
210 for op in &norm {
211 let (s, e) = (op.range.start, op.range.end);
212 let old_text = buffer.slice(s..e).into_owned();
213 let ns = (s as i64 + delta) as u32; // post-edit start
214 let ne = ns + op.text.len() as u32; // post-edit end
215 patch.push(Edit { old: s..e, new: ns..ne });
216 // Inverse: put the old text back over the post-edit range (moves `old_text`).
217 inverse.push(EditOp { range: ns..ne, text: old_text });
218 delta += op.text.len() as i64 - (e - s) as i64;
219 }
220
221 // (5) apply all edits in ONE batched rope pass (the multi-caret path: one spine
222 // rebuild sharing every untouched subtree, not N sequential splices). `norm` is
223 // sorted ascending and disjoint — exactly `edit_many`'s contract. The `batch`
224 // borrows `norm`, so it lives in its own scope and drops before `norm` moves
225 // into the returned `Committed` as the forward ops.
226 {
227 let batch: Vec<(Range<u32>, &str)> =
228 norm.iter().map(|op| (op.range.clone(), op.text.as_str())).collect();
229 buffer.edit_many(&batch);
230 }
231 buffer.bump_revision();
232
233 Ok(Committed { patch, inverse, forward: norm })
234}
235
236#[cfg(test)]
237mod tests {
238 use super::*;
239
240 fn buf(s: &str) -> Buffer {
241 Buffer::new(s).unwrap()
242 }
243
244 #[test]
245 fn single_insert() {
246 let mut b = buf("ac");
247 let c = apply(&mut b, vec![EditOp::insert(1, "b")]).unwrap();
248 assert_eq!(b.text(), "abc");
249 assert_eq!(b.revision().0, 1);
250 assert!(!c.is_empty());
251 }
252
253 #[test]
254 fn single_delete_and_replace() {
255 let mut b = buf("hello");
256 apply(&mut b, vec![EditOp::delete(1..3)]).unwrap();
257 assert_eq!(b.text(), "hlo");
258 apply(&mut b, vec![EditOp::new(0..1, "H")]).unwrap();
259 assert_eq!(b.text(), "Hlo");
260 assert_eq!(b.revision().0, 2);
261 }
262
263 #[test]
264 fn multi_range_batch_is_atomic() {
265 // Replace both "a"s in "a b a" in one transaction, pre-edit coords.
266 let mut b = buf("a b a");
267 apply(&mut b, vec![EditOp::new(0..1, "X"), EditOp::new(4..5, "Y")]).unwrap();
268 assert_eq!(b.text(), "X b Y");
269 assert_eq!(b.revision().0, 1); // one transaction, one bump
270 }
271
272 #[test]
273 fn same_offset_insertions_apply_in_caller_order() {
274 let mut b = buf("");
275 apply(&mut b, vec![EditOp::insert(0, "A"), EditOp::insert(0, "B")]).unwrap();
276 assert_eq!(b.text(), "AB");
277 }
278
279 #[test]
280 fn overlap_is_rejected() {
281 let mut b = buf("abcdef");
282 let err = apply(&mut b, vec![EditOp::new(0..3, "x"), EditOp::new(2..5, "y")]);
283 assert!(matches!(err, Err(TransactionError::Overlap { .. })));
284 assert_eq!(b.text(), "abcdef"); // unchanged on error
285 assert_eq!(b.revision().0, 0);
286 }
287
288 #[test]
289 fn post_len_overflow_guard_is_pure() {
290 // The u32-offset guard is length arithmetic only — testable without
291 // allocating gigabytes. apply() refuses when this reaches u32::MAX.
292 let ops = vec![EditOp::new(0..3, "xxxxx"), EditOp::delete(5..9)];
293 assert_eq!(post_len(20, &ops), 20 + 2 - 4);
294 assert_eq!(post_len(0, &[]), 0);
295 let huge = vec![EditOp::insert(0, "x".repeat(16))];
296 assert!(post_len(u32::MAX - 8, &huge) >= u32::MAX as u64, "this batch would be refused");
297 }
298
299 #[test]
300 fn empty_and_noop_batches_do_not_move_the_revision() {
301 let mut b = buf("abc");
302 assert!(apply(&mut b, vec![]).unwrap().is_empty());
303 assert!(apply(&mut b, vec![EditOp::new(1..1, "")]).unwrap().is_empty());
304 assert_eq!(b.revision().0, 0);
305 assert_eq!(b.text(), "abc");
306 }
307
308 #[test]
309 fn text_is_normalized_to_lf() {
310 let mut b = buf("");
311 apply(&mut b, vec![EditOp::insert(0, "a\r\nb")]).unwrap();
312 assert_eq!(b.text(), "a\nb");
313 assert!(!b.text().contains('\r'));
314 assert_eq!(b.line_count(), 2);
315 }
316
317 #[test]
318 fn inverse_round_trips_text_and_revision_parity() {
319 // apply → apply(inverse) restores the exact pre-edit text.
320 let mut b = buf("the quick brown fox");
321 let before = b.text().into_owned();
322 let committed =
323 apply(&mut b, vec![EditOp::new(4..9, "SLOW"), EditOp::insert(0, ">> ")]).unwrap();
324 assert_ne!(b.text(), before);
325 let redo = apply(&mut b, committed.inverse_ops().to_vec()).unwrap();
326 assert_eq!(b.text(), before, "inverse must restore the pre-edit text");
327 assert_eq!(b.revision().0, 2); // forward + undo are both transactions
328 // And the inverse-of-the-inverse (redo) reapplies the change.
329 apply(&mut b, redo.inverse_ops().to_vec()).unwrap();
330 assert_ne!(b.text(), before);
331 }
332
333 #[test]
334 fn patch_maps_positions_across_the_transaction() {
335 // "hello world" → delete "hello " (0..6): a marker at 'w' (offset 6)
336 // maps to offset 0.
337 let mut b = buf("hello world");
338 let c = apply(&mut b, vec![EditOp::delete(0..6)]).unwrap();
339 assert_eq!(b.text(), "world");
340 assert_eq!(c.patch().map_offset(6, Bias::Left), 0);
341 assert_eq!(c.patch().map_offset(8, Bias::Left), 2); // 'r'
342 }
343}