Skip to main content

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}