hjkl_buffer/undo.rs
1//! Undo/redo entry type for per-buffer undo history.
2//!
3//! Lives in `hjkl-buffer` so that [`crate::Buffer`] can own the undo stack
4//! directly, keeping per-buffer state co-located with the rope.
5
6use std::collections::BTreeMap;
7use std::sync::Arc;
8use std::time::{Duration, SystemTime, UNIX_EPOCH};
9
10use serde::{Deserialize, Serialize};
11
12/// A single entry in the undo or redo stack.
13///
14/// The `timestamp` records the wall-clock time at which the snapshot was
15/// taken (i.e. when `push_undo` was called), enabling the `:earlier` /
16/// `:later` time-travel ex commands to walk the stack by duration rather
17/// than by step count.
18///
19/// Stored as a `ropey::Rope` (O(1) Arc-clone) rather than a `String` so
20/// snapshot cost is negligible even on multi-MB buffers.
21#[derive(Debug, Clone)]
22pub struct UndoEntry {
23 pub rope: ropey::Rope,
24 pub cursor: (usize, usize),
25 pub timestamp: SystemTime,
26 /// Local marks / jumplist / changelist / this-buffer's-global-marks
27 /// snapshot, so undo/redo restore mark-ish positions alongside the
28 /// text instead of leaving them shifted by the edit being undone
29 /// (audit-r2 fix 2). `Default::default()` (all empty) for callers
30 /// that don't populate it — restoring an all-empty snapshot is a
31 /// no-op against a freshly-constructed buffer's own empty state, so
32 /// existing fixtures that only care about text/cursor stay valid.
33 pub marks: MarkSnapshot,
34}
35
36/// Buffer-scoped "edit coherence" state snapshotted alongside a
37/// [`UndoEntry`]'s rope so undo/redo can restore marks, not just text.
38///
39/// Positions are plain `(row, col)` (or `(row, col)` values keyed by
40/// mark char) — no buffer-id tagging needed here even for
41/// `global_marks`, because a `MarkSnapshot` always belongs to exactly
42/// one buffer's undo stack; the engine is responsible for reattaching
43/// its own `buffer_id` when writing entries back into the session-global
44/// marks map (see `Editor::restore_marks`).
45#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
46pub struct MarkSnapshot {
47 /// `ma`-`mz` local marks (`View::marks_cloned`).
48 pub local_marks: BTreeMap<char, (usize, usize)>,
49 /// Back-jumplist (`Ctrl-o` stack), newest at the back.
50 pub jump_back: Vec<(usize, usize)>,
51 /// Forward-jumplist (`Ctrl-i` stack), newest at the back.
52 pub jump_fwd: Vec<(usize, usize)>,
53 /// `` `. `` / `'.` — position of the most recent change.
54 pub change_last_edit: Option<(usize, usize)>,
55 /// Changelist ring (`g;` / `g,`).
56 pub change_list: Vec<(usize, usize)>,
57 /// Walk cursor into `change_list`; `None` outside a walk.
58 pub change_cursor: Option<usize>,
59 /// `mA`-`mZ` global marks that belong to THIS buffer (bare
60 /// `(row, col)` — the buffer-id is implicit, this buffer).
61 pub global_marks: BTreeMap<char, (usize, usize)>,
62}
63
64// ─── Reversible edge delta (Phase 3a) ──────────────────────────────────────────
65//
66// Phase 2b stored a FULL rope snapshot on every node. Phase 3a stores only a
67// reversible **delta** on each parent→child edge (the root keeps a full base
68// rope) plus a materialization cache, so the in-RAM hot path stays snapshot-fast
69// while a future undofile shrinks from hundreds of MB to KB. This slice changes
70// ONLY internal storage — every public signature, and every observable
71// behaviour, is byte-identical to Phase 2b.
72
73/// A reversible edit between two adjacent buffer states, expressed as a single
74/// spanning replacement in **char-offset space** on the rope.
75///
76/// The index space is ropey `char` offsets throughout — never bytes — so
77/// multi-byte UTF-8 round-trips (a byte offset could split a codepoint). In the
78/// PARENT state `chars[start .. start + old.chars().count()] == old`; replacing
79/// that region with `new` yields the CHILD state, and swapping the two inverts
80/// it. A whole undo group collapses to the one region spanning its edits
81/// (common-prefix / common-suffix diff); a `Vec<Delta>` for disjoint regions is
82/// an acceptable future generalization, but one spanning region is all Phase 3a
83/// needs.
84#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
85pub struct Delta {
86 /// Char offset of the first differing char (the common-prefix length).
87 pub start: usize,
88 /// Chars present in the PARENT but not the CHILD (removed going forward).
89 pub old: String,
90 /// Chars present in the CHILD but not the PARENT (inserted going forward).
91 pub new: String,
92}
93
94/// Is `byte_idx` a char boundary of `r`? (`str::is_char_boundary` for ropes.)
95///
96/// Ropey always splits chunks ON char boundaries, so the question is answerable
97/// inside the one chunk containing the byte — O(log N), no materialization.
98fn is_char_boundary(r: &ropey::Rope, byte_idx: usize) -> bool {
99 if byte_idx == 0 || byte_idx == r.len_bytes() {
100 return true;
101 }
102 let (chunk, chunk_start, _, _) = r.chunk_at_byte(byte_idx);
103 chunk.is_char_boundary(byte_idx - chunk_start)
104}
105
106/// Length of the longest common byte PREFIX of `a` and `b`, capped at `max`.
107///
108/// Walks both ropes' chunks in lockstep — never materializes either rope. Two
109/// fast paths keep the common editor case near-free: identical chunk pointers
110/// (ropey leaves are `Arc`-shared, so a clone-then-edit child shares every leaf
111/// outside the edit) are accepted without reading bytes, and otherwise whole
112/// overlapping runs are compared with one slice `==` (memcmp).
113fn common_prefix_bytes(a: &ropey::Rope, b: &ropey::Rope, max: usize) -> usize {
114 let mut a_chunks = a.chunks();
115 let mut b_chunks = b.chunks();
116 let mut at: &[u8] = &[];
117 let mut bt: &[u8] = &[];
118 let mut n = 0;
119 while n < max {
120 if at.is_empty() {
121 match a_chunks.next() {
122 Some(c) => at = c.as_bytes(),
123 None => break,
124 }
125 continue;
126 }
127 if bt.is_empty() {
128 match b_chunks.next() {
129 Some(c) => bt = c.as_bytes(),
130 None => break,
131 }
132 continue;
133 }
134 // Same shared leaf, wholly within the cap: equal without looking.
135 if at.as_ptr() == bt.as_ptr() && at.len() == bt.len() && at.len() <= max - n {
136 n += at.len();
137 at = &[];
138 bt = &[];
139 continue;
140 }
141 let m = at.len().min(bt.len()).min(max - n);
142 if at[..m] == bt[..m] {
143 n += m;
144 at = &at[m..];
145 bt = &bt[m..];
146 } else {
147 let mut i = 0;
148 while at[i] == bt[i] {
149 i += 1;
150 }
151 n += i;
152 break;
153 }
154 }
155 n
156}
157
158/// Length of the longest common byte SUFFIX of `a` and `b`, capped at `max`.
159///
160/// The mirror of [`common_prefix_bytes`], walking both ropes' chunk cursors
161/// backwards from the end via [`ropey::iter::Chunks::prev`].
162fn common_suffix_bytes(a: &ropey::Rope, b: &ropey::Rope, max: usize) -> usize {
163 let mut a_chunks = a.chunks_at_byte(a.len_bytes()).0;
164 let mut b_chunks = b.chunks_at_byte(b.len_bytes()).0;
165 let mut at: &[u8] = &[];
166 let mut bt: &[u8] = &[];
167 let mut n = 0;
168 while n < max {
169 if at.is_empty() {
170 match a_chunks.prev() {
171 Some(c) => at = c.as_bytes(),
172 None => break,
173 }
174 continue;
175 }
176 if bt.is_empty() {
177 match b_chunks.prev() {
178 Some(c) => bt = c.as_bytes(),
179 None => break,
180 }
181 continue;
182 }
183 if at.as_ptr() == bt.as_ptr() && at.len() == bt.len() && at.len() <= max - n {
184 n += at.len();
185 at = &[];
186 bt = &[];
187 continue;
188 }
189 let m = at.len().min(bt.len()).min(max - n);
190 let (a_tail, b_tail) = (&at[at.len() - m..], &bt[bt.len() - m..]);
191 if a_tail == b_tail {
192 n += m;
193 at = &at[..at.len() - m];
194 bt = &bt[..bt.len() - m];
195 } else {
196 let mut i = 0;
197 while a_tail[m - 1 - i] == b_tail[m - 1 - i] {
198 i += 1;
199 }
200 n += i;
201 break;
202 }
203 }
204 n
205}
206
207/// Common-prefix / common-suffix diff of two ropes → the minimal single spanning
208/// [`Delta`]. Guarantees `apply_forward(a, diff(a, b)) == b` and
209/// `apply_inverse(b, diff(a, b)) == a` for ALL `a`, `b` (see the property
210/// tests). Boundaries are found on bytes (fast) then snapped to char boundaries
211/// so `old`/`new` are always valid UTF-8 and `start` is a true char offset.
212///
213/// The scans walk the ropes' chunks directly and only the differing MIDDLE is
214/// materialized — a full `to_string()` of both sides used to dominate every edit
215/// on a multi-MB buffer (measured 1.55 ms + ~6.4 MB of allocation per push at
216/// 3.2 MB). `diff_reference` in the tests below is the old materializing
217/// implementation, kept as the differential-test oracle.
218fn diff(parent: &ropey::Rope, child: &ropey::Rope) -> Delta {
219 let a_len = parent.len_bytes();
220 let b_len = child.len_bytes();
221
222 // Longest common byte prefix, snapped DOWN to a char boundary.
223 let max_pre = a_len.min(b_len);
224 let mut pre = common_prefix_bytes(parent, child, max_pre);
225 while pre > 0 && !is_char_boundary(parent, pre) {
226 pre -= 1;
227 }
228
229 // Longest common byte suffix not overlapping the prefix. The cut points
230 // `a_end`/`b_end` sit at identical trailing bytes, so snapping `a_end` UP to
231 // a char boundary snaps `b_end` by the same byte delta simultaneously.
232 let suf = common_suffix_bytes(parent, child, max_pre - pre);
233 let mut a_end = a_len - suf;
234 while a_end < a_len && !is_char_boundary(parent, a_end) {
235 a_end += 1;
236 }
237 let b_end = b_len - (a_len - a_end);
238
239 Delta {
240 start: parent.byte_to_char(pre),
241 old: parent.byte_slice(pre..a_end).to_string(),
242 new: child.byte_slice(pre..b_end).to_string(),
243 }
244}
245
246/// Apply a forward delta (PARENT → CHILD) to `parent`, returning the child rope.
247fn apply_forward(parent: &ropey::Rope, d: &Delta) -> ropey::Rope {
248 let mut r = parent.clone();
249 let old_chars = d.old.chars().count();
250 r.remove(d.start..d.start + old_chars);
251 r.insert(d.start, &d.new);
252 r
253}
254
255/// Apply an inverse delta (CHILD → PARENT) to `child`, returning the parent rope.
256fn apply_inverse(child: &ropey::Rope, d: &Delta) -> ropey::Rope {
257 let mut r = child.clone();
258 let new_chars = d.new.chars().count();
259 r.remove(d.start..d.start + new_chars);
260 r.insert(d.start, &d.old);
261 r
262}
263
264// ─── Undo arena tree (Phase 2b + Phase 3a delta storage) ──────────────────────
265//
266// The undo history is a real arena TREE of buffer states (Phase 2a introduced
267// the arena; Phase 2b makes it branch; Phase 3a stores edges as deltas). An edit
268// after an undo FORKS a new child instead of truncating the forward branch, so
269// old branches stay reachable — matching nvim's undo tree. `seq` is
270// load-bearing: `g-`/`g+` and the `:earlier`/`:later` count forms walk ALL
271// states by global `seq` (see `seq_earlier_step`/`seq_later_step`), while
272// `u`/`<C-r>` stay branch-local (parent / `last_child`).
273//
274// The linear-history subset is unchanged: with no forks the tree is a single
275// root→current→leaf path and every operation degrades to the old two-stack
276// behaviour.
277//
278// - `current` points at the node representing the LIVE buffer state.
279// - The ancestors of `current` (parent, … up to `root`) are the reachable undo
280// line; `current.parent` is the `u` target.
281// - `current.last_child` is the `<C-r>` target. Landing on any node (undo,
282// redo, or a `g-`/`g+` jump) rewrites `last_child` down the root→node path so
283// a later `<C-r>` retraces the branch just taken.
284//
285// Storage (Phase 3a): each non-root node holds the reversible `delta` on its
286// edge from `parent`; the root holds a full `base` rope. A node's content is
287// reconstructed on demand (`materialize`) from the nearest cached ancestor (or
288// the root base) by replaying forward deltas, or — for the `u`/`<C-r>` hot path
289// — from the adjacent warm node by one delta apply. Recently materialized ropes
290// are kept in a bounded LRU (`warm`); `current` is always kept warm. A node's
291// `delta`/content is FINALIZED lazily on the way past it (whenever the live rope
292// is written into it), never read as a restore target until then — so the fresh
293// leaf `current` holds a placeholder edge that is corrected before it matters.
294//
295// Keyframes (issue #302): the warm LRU alone bounds nothing — a `g-` onto a node
296// far outside it replayed the WHOLE chain from the root, so one jump was O(depth)
297// and `:earlier 9999` was O(depth²) (measured 212 ms for a 1024-deep history).
298// Every node at a depth that is a multiple of `KEYFRAME_INTERVAL` therefore PINS
299// its materialized rope, capping any single replay at `KEYFRAME_INTERVAL - 1`
300// applies; and `materialize` caches every intermediate it replays, so the
301// step-by-step walk pays that replay once per interval rather than once per step.
302// Keyframes are a pure in-memory cache: they are recomputable from the root base
303// plus the deltas, so they are NOT part of the `SerTree` on-disk projection, and
304// dropping every one of them changes only speed, never content.
305
306/// Keyframe spacing, in nodes of depth. Every node whose depth from the root is
307/// a multiple of this pins its materialized rope, so `materialize` never replays
308/// more than `KEYFRAME_INTERVAL - 1` deltas from the nearest anchor.
309///
310/// **Why 16.** The cost of a keyframe is *not* a document copy. `ropey::Rope` is
311/// a persistent tree with `Arc`-shared leaves, so a snapshot taken between small
312/// edits shares every chunk outside the edited path with its neighbours and only
313/// retains the O(log N) interior nodes the edit rewrote. Measured marginal RSS of
314/// retaining one such snapshot (1024 small edits, keeping every 16th):
315///
316/// | document | bytes retained per keyframe |
317/// | --- | --- |
318/// | 119 KiB | ~3.0 KiB |
319/// | 11.9 MiB | ~7.2 KiB |
320/// | 11.9 MiB, 4 KiB edits | ~11.5 KiB |
321///
322/// i.e. essentially independent of document size — a 200 MB buffer does not pay
323/// 200 MB per keyframe. At one keyframe per 16 nodes that is well under a KiB of
324/// amortized overhead per undo state, next to the `Delta` (two `String`s of the
325/// changed span) every node already stores unconditionally. 16 also sits under
326/// [`WARM_CAP`], which is what lets a full walk stay linear (see there).
327///
328/// The one shape that would break the "cheap" argument — an edit that rewrites
329/// the entire document, so consecutive states share nothing — already costs two
330/// full-document `String`s in that node's own `Delta`, so the keyframe adds at
331/// most another 1/16 of a cost the tree was paying anyway.
332const KEYFRAME_INTERVAL: usize = 16;
333
334/// Hard ceiling on how many keyframes are pinned at once; beyond it the
335/// least-recently-touched keyframe is unpinned (it becomes an ordinary cold
336/// node, replayable as before — correctness is unaffected).
337///
338/// Deliberately a COUNT, not a byte budget: by the measurement on
339/// [`KEYFRAME_INTERVAL`] a keyframe's real retention is roughly document-size
340/// *independent*, so a byte budget computed from `len_bytes()` would be a wild
341/// over-estimate and would switch keyframes off precisely on the large documents
342/// that need them most. 512 keyframes covers 8192 undo states — past any sane
343/// `undolevels` — for a measured ceiling of a few MiB.
344const KEYFRAME_CAP: usize = 512;
345
346/// Index into [`UndoTree::nodes`]. Slots are reused via a free list, so an id is
347/// only valid while the node it names is live — the tree never hands ids out.
348pub type NodeId = usize;
349
350/// How many recently-materialized ORDINARY node ropes to keep warm (besides the
351/// root base, `current`, and the pinned keyframes, which are always available).
352///
353/// Kept above [`KEYFRAME_INTERVAL`] on purpose: `materialize` caches every
354/// intermediate it replays, so one keyframe interval's worth of intermediates has
355/// to survive here for a step-by-step history walk (`:earlier 9999`) to cost one
356/// replay per INTERVAL rather than one per step — the difference between an O(N)
357/// and an O(N·K) walk.
358const WARM_CAP: usize = 32;
359
360/// One node of the undo arena tree: a buffer state the user could land on, plus
361/// its links and the reversible edge to its parent. A node with `> 1` child is a
362/// branch point (Phase 2b); `last_child` records which child `<C-r>` follows.
363#[derive(Debug, Clone)]
364pub struct UndoNode {
365 pub parent: Option<NodeId>,
366 pub children: Vec<NodeId>,
367 pub last_child: Option<NodeId>,
368 /// Reversible edit from the parent's content to this node's content. `None`
369 /// only for the root (and any node promoted to root by pruning), which holds
370 /// `base` instead.
371 pub delta: Option<Delta>,
372 /// Full base rope. `Some` ONLY for the root — the anchor the delta chain
373 /// replays from. Non-root nodes leave this `None` and carry a `delta`.
374 pub base: Option<ropey::Rope>,
375 /// Materialized content, LRU-managed. Warm for `current`, recently visited
376 /// nodes, and keyframe-depth nodes (which are pinned rather than aged out);
377 /// `None` (cold) otherwise, reconstructable from deltas.
378 pub rope_cache: Option<ropey::Rope>,
379 /// Distance from the root, root == 0. Assigned once at creation and never
380 /// renumbered — root-side pruning shifts the whole numbering down uniformly,
381 /// which leaves keyframes exactly [`KEYFRAME_INTERVAL`] apart either way.
382 /// Purely a cache-placement input: a wrong depth costs speed, never content.
383 pub depth: usize,
384 /// Post-state cursor for this node (restored alongside the text).
385 pub cursor: (usize, usize),
386 /// Wall-clock time this state was created — drives `:earlier`/`:later`.
387 pub timestamp: SystemTime,
388 /// Marks / jumplist / changelist snapshot restored with the text.
389 ///
390 /// Shared (`Arc`) rather than owned: a `push` writes the SAME snapshot
391 /// into the node being left and into the fresh child, and a
392 /// `MarkSnapshot` is up to five collections. Nodes never mutate it in
393 /// place — it is only ever replaced wholesale — so sharing is invisible.
394 pub marks: Arc<MarkSnapshot>,
395 /// Global monotonic order across the whole tree — the change number that
396 /// `g-`/`g+`, `:earlier`/`:later`, and `:undolist` traverse and display.
397 pub seq: u64,
398 /// Is this node on the root→`current` path (inclusive of both ends)?
399 ///
400 /// A maintained INVARIANT, not a hint, and the whole reason
401 /// [`UndoTree::retarget_current`] no longer walks the chain: the set of
402 /// flagged nodes is exactly the chain, so the first flagged node found
403 /// walking up from a landing target is the fork point, and every ancestor
404 /// above it already names its on-path child. Every site that moves
405 /// `current` — `push`, `undo_step`, `redo_step`, `retarget_current`,
406 /// `pop_committed` — maintains it, and the bulk paths (`new`,
407 /// `clear_all`, `from_serializable`) establish it outright.
408 /// `path_flags_track_the_root_to_current_walk` pins it against a
409 /// brute-force parent walk after each of those.
410 ///
411 /// Runtime-only and derivable, so — like `depth` and `rope_cache` — it is
412 /// not part of the [`SerTree`] projection.
413 pub on_path: bool,
414}
415
416/// Arena tree of [`UndoNode`]s. Replaces the old `undo_stack`/`redo_stack`
417/// `Vec<UndoEntry>` pair on [`crate::Buffer`]; see the module comment for how
418/// `u`/`<C-r>` (branch-local) and `g-`/`g+` (seq-ordered) map onto it, and how
419/// Phase 3a stores edges as deltas behind a materialization cache.
420#[derive(Debug)]
421pub struct UndoTree {
422 /// Slab; `None` slots are free and recorded in `free`.
423 nodes: Vec<Option<UndoNode>>,
424 /// Reusable slot indices (frees push here, allocs pop here first).
425 free: Vec<NodeId>,
426 /// LRU of ORDINARY node ids with a warm `rope_cache` (root and keyframe-depth
427 /// nodes excluded — they live in `base` / `keyframes`), most-recently-touched
428 /// last. Bounded by [`WARM_CAP`]; `current` is never evicted.
429 warm: Vec<NodeId>,
430 /// Node ids at a keyframe depth whose `rope_cache` is PINNED — the replay
431 /// anchors that bound `materialize` at [`KEYFRAME_INTERVAL`] applies.
432 /// Most-recently-touched last, bounded by [`KEYFRAME_CAP`].
433 keyframes: Vec<NodeId>,
434 /// `seq` → node id for every LIVE node, ordered.
435 ///
436 /// `g-` / `g+` / `:earlier` / `:later` need the neighbouring node in seq
437 /// order tree-wide, which was a full arena scan per step — an O(N) floor
438 /// under every history step, so holding `g-` over a deep history was
439 /// O(N * depth) in the lookup alone, dwarfing the bounded delta replay
440 /// keyframes had already achieved. A `BTreeMap` answers both directions in
441 /// O(log N) via `range`.
442 ///
443 /// Every live node appears exactly once. `seq` is assigned at creation and
444 /// never mutated, so the only maintenance points are `alloc` and `free`
445 /// (plus the two bulk paths, `clear_all` and `from_serializable`, which
446 /// rebuild it). `seq_index_matches_the_arena` pins that invariant against a
447 /// brute-force scan.
448 by_seq: std::collections::BTreeMap<u64, NodeId>,
449 root: NodeId,
450 current: NodeId,
451 next_seq: u64,
452}
453
454/// Trim `list` (an LRU, oldest first) down to `cap`, dropping the evicted
455/// nodes' materialized ropes. `current` is never evicted — the live state must
456/// stay available without a replay.
457///
458/// A free function over the pieces rather than a method so it can hold `&mut`
459/// on one arena field and one list at the same time.
460fn evict_to(list: &mut Vec<NodeId>, nodes: &mut [Option<UndoNode>], current: NodeId, cap: usize) {
461 while list.len() > cap {
462 let Some(pos) = list.iter().position(|&n| n != current) else {
463 break;
464 };
465 let victim = list.remove(pos);
466 if let Some(node) = nodes[victim].as_mut() {
467 node.rope_cache = None;
468 }
469 }
470}
471
472impl UndoTree {
473 /// New tree with a single root == current node holding `rope` as its base
474 /// state (the buffer as opened / last saved). The root is always
475 /// materializable from this base.
476 pub(crate) fn new(rope: ropey::Rope) -> Self {
477 let root = UndoNode {
478 parent: None,
479 children: Vec::new(),
480 last_child: None,
481 delta: None,
482 base: Some(rope),
483 rope_cache: None,
484 depth: 0,
485 cursor: (0, 0),
486 timestamp: SystemTime::now(),
487 marks: Arc::default(),
488 seq: 0,
489 // The root is `current`, so it is the whole path.
490 on_path: true,
491 };
492 Self {
493 nodes: vec![Some(root)],
494 free: Vec::new(),
495 warm: Vec::new(),
496 keyframes: Vec::new(),
497 by_seq: std::iter::once((0, 0)).collect(),
498 root: 0,
499 current: 0,
500 next_seq: 1,
501 }
502 }
503
504 // ── slab helpers ─────────────────────────────────────────────────────────
505
506 fn get(&self, id: NodeId) -> &UndoNode {
507 self.nodes[id].as_ref().expect("live NodeId")
508 }
509
510 fn get_mut(&mut self, id: NodeId) -> &mut UndoNode {
511 self.nodes[id].as_mut().expect("live NodeId")
512 }
513
514 fn alloc(&mut self, node: UndoNode) -> NodeId {
515 let seq = node.seq;
516 let id = if let Some(id) = self.free.pop() {
517 self.nodes[id] = Some(node);
518 id
519 } else {
520 self.nodes.push(Some(node));
521 self.nodes.len() - 1
522 };
523 self.by_seq.insert(seq, id);
524 id
525 }
526
527 /// Free a single slot (does NOT recurse into children — callers detach
528 /// links first). Drops the node's delta + materialized cache and purges it
529 /// from both cache LRUs.
530 fn free(&mut self, id: NodeId) {
531 if let Some(n) = self.nodes[id].as_ref() {
532 self.by_seq.remove(&n.seq);
533 }
534 self.nodes[id] = None;
535 self.free.push(id);
536 self.warm.retain(|&n| n != id);
537 self.keyframes.retain(|&n| n != id);
538 }
539
540 // ── materialization (Phase 3a + keyframes) ───────────────────────────────
541
542 /// Is `id` at a keyframe depth, i.e. should its materialized rope be PINNED
543 /// as a replay anchor rather than aged out of the ordinary warm LRU?
544 ///
545 /// The root qualifies arithmetically (depth 0) but is excluded: it carries a
546 /// full `base` and is already an anchor.
547 fn is_keyframe(&self, id: NodeId) -> bool {
548 id != self.root && self.get(id).depth.is_multiple_of(KEYFRAME_INTERVAL)
549 }
550
551 /// Record `id` as freshly materialized. Keyframe-depth nodes go into the
552 /// pinned `keyframes` LRU (bounded by [`KEYFRAME_CAP`]), everything else into
553 /// the ordinary `warm` LRU (bounded by [`WARM_CAP`]). Neither ever evicts
554 /// `current`; the root is skipped entirely (it has no cache, it has `base`).
555 fn touch_warm(&mut self, id: NodeId) {
556 if id == self.root {
557 return;
558 }
559 let (list, cap) = if self.is_keyframe(id) {
560 (&mut self.keyframes, KEYFRAME_CAP)
561 } else {
562 (&mut self.warm, WARM_CAP)
563 };
564 list.retain(|&n| n != id);
565 list.push(id);
566 evict_to(list, &mut self.nodes, self.current, cap);
567 }
568
569 /// Materialize node `id`'s content, warming its cache. Uses the node's own
570 /// cache if present, else the root `base`, else replays forward deltas from
571 /// the nearest materialized ancestor — a warm node, a pinned keyframe, or the
572 /// root. Always terminates: the root carries a base.
573 ///
574 /// Every intermediate along the replay is cached too, not just the target:
575 /// they were computed anyway and a `ropey::Rope` clone is an `Arc` bump, so
576 /// caching them is free — and it is what makes a step-by-step history walk
577 /// (`g-` held down, `:earlier 9999`) pay ONE replay per keyframe interval
578 /// instead of one per step.
579 fn materialize(&mut self, id: NodeId) -> ropey::Rope {
580 if let Some(r) = &self.get(id).rope_cache {
581 return r.clone();
582 }
583 if let Some(base) = &self.get(id).base {
584 return base.clone();
585 }
586 // Walk up to the nearest ancestor that holds content (warm cache, pinned
587 // keyframe, or the root base), recording the path to replay forward.
588 // Bounded by the keyframe spacing whenever the ancestor chain has been
589 // materialized before.
590 let mut path = Vec::new();
591 let base_rope;
592 let mut anchor = id;
593 loop {
594 path.push(anchor);
595 let par = self
596 .get(anchor)
597 .parent
598 .expect("a non-root, non-based node always has a parent");
599 if let Some(r) = &self.get(par).rope_cache {
600 base_rope = r.clone();
601 break;
602 }
603 if let Some(b) = &self.get(par).base {
604 base_rope = b.clone();
605 break;
606 }
607 anchor = par;
608 }
609 let mut rope = base_rope;
610 // `path` is target-first, so replaying in reverse ends on `id` — which
611 // therefore lands last in its LRU and cannot be the eviction picked by
612 // its own `touch_warm`.
613 for &node in path.iter().rev() {
614 let d = self
615 .get(node)
616 .delta
617 .as_ref()
618 .expect("a non-root node always carries its edge delta");
619 rope = apply_forward(&rope, d);
620 self.get_mut(node).rope_cache = Some(rope.clone());
621 self.touch_warm(node);
622 }
623 rope
624 }
625
626 /// Reconstruct node `id`'s restorable [`UndoEntry`] — the byte-for-byte
627 /// equivalent of Phase 2b's `node.snapshot.clone()`.
628 fn entry_of(&mut self, id: NodeId) -> UndoEntry {
629 let rope = self.materialize(id);
630 let n = self.get(id);
631 UndoEntry {
632 rope,
633 cursor: n.cursor,
634 timestamp: n.timestamp,
635 marks: (*n.marks).clone(),
636 }
637 }
638
639 /// Finalize node `id` to hold `rope` as its content, recomputing its edge
640 /// delta (or the root base) and updating cursor/timestamp/marks. A no-op
641 /// diff is skipped when the content is unchanged (the common case on a
642 /// history walk, where only the fields move) — which also avoids
643 /// materializing the parent, keeping the walk cheap.
644 fn set_node_state(
645 &mut self,
646 id: NodeId,
647 rope: ropey::Rope,
648 cursor: (usize, usize),
649 timestamp: SystemTime,
650 marks: Arc<MarkSnapshot>,
651 ) {
652 let is_root = self.get(id).parent.is_none();
653 // `Rope`'s `PartialEq` walks both ropes, so this comparison alone was
654 // O(document) on every history step. `is_instance` is an `Arc::ptr_eq`
655 // on the shared root and answers the case that actually occurs here —
656 // the caller stashes back the very rope it was handed by the previous
657 // step, still a clone of what the node cached. It is only a sufficient
658 // test for equality, never a necessary one, so an unrelated-but-equal
659 // rope still falls through to the full compare and is found unchanged.
660 let same = |a: Option<&ropey::Rope>| a.is_some_and(|a| a.is_instance(&rope) || *a == rope);
661 let unchanged =
662 same(self.get(id).rope_cache.as_ref()) || (is_root && same(self.get(id).base.as_ref()));
663 {
664 let node = self.get_mut(id);
665 node.cursor = cursor;
666 node.timestamp = timestamp;
667 node.marks = marks;
668 }
669 if unchanged {
670 return;
671 }
672 if is_root {
673 self.get_mut(id).base = Some(rope);
674 // The root is materialized from `base`; keep no stale cache.
675 self.get_mut(id).rope_cache = None;
676 self.warm.retain(|&n| n != id);
677 self.keyframes.retain(|&n| n != id);
678 } else {
679 let par = self.get(id).parent.expect("non-root has a parent");
680 let par_rope = self.materialize(par);
681 let d = diff(&par_rope, &rope);
682 let node = self.get_mut(id);
683 node.delta = Some(d);
684 node.rope_cache = Some(rope);
685 self.touch_warm(id);
686 }
687 }
688
689 /// Free `id` and its whole subtree (iteratively, so a long redo chain can't
690 /// overflow the stack).
691 fn free_subtree(&mut self, id: NodeId) {
692 let mut stack = vec![id];
693 while let Some(n) = stack.pop() {
694 let kids = std::mem::take(&mut self.get_mut(n).children);
695 stack.extend(kids);
696 self.free(n);
697 }
698 }
699
700 // ── read-only queries (mirror the old stack accessors) ───────────────────
701
702 /// `undo_stack.is_empty()` ⇔ `current` has no parent (is the root).
703 pub(crate) fn is_at_root(&self) -> bool {
704 self.get(self.current).parent.is_none()
705 }
706
707 /// `!redo_stack.is_empty()` ⇔ `current` has a forward child.
708 pub(crate) fn has_redo(&self) -> bool {
709 self.get(self.current).last_child.is_some()
710 }
711
712 /// Number of ancestors of `current`, i.e. its distance from the root.
713 ///
714 /// Walked rather than read off `Node::depth` so it stays right after a
715 /// prune or a load has renumbered the tree. Backs `View::undo_stack_len`,
716 /// which is the `undo_stack.len()` of the pre-tree implementation.
717 pub(crate) fn depth_from_root(&self) -> usize {
718 let mut d = 0;
719 let mut n = self.get(self.current).parent;
720 while let Some(p) = n {
721 d += 1;
722 n = self.get(p).parent;
723 }
724 d
725 }
726
727 /// `undo_stack.last().timestamp` == `current.parent`'s timestamp.
728 pub(crate) fn parent_timestamp(&self) -> Option<SystemTime> {
729 self.get(self.current).parent.map(|p| self.get(p).timestamp)
730 }
731
732 /// `redo_stack.last().timestamp` == `current.last_child`'s timestamp.
733 pub(crate) fn child_timestamp(&self) -> Option<SystemTime> {
734 self.get(self.current)
735 .last_child
736 .map(|c| self.get(c).timestamp)
737 }
738
739 // ── mutations ────────────────────────────────────────────────────────────
740
741 /// Commit a new boundary from `current`, growing the tree (Phase 2b).
742 ///
743 /// `entry` is the pre-edit LIVE state. It is written into `current`'s
744 /// snapshot (making `current` a real, restorable state), then a fresh child
745 /// is APPENDED and becomes the new `current` for the edit about to happen.
746 ///
747 /// Unlike Phase 2a this does NOT drop `current`'s existing children: an edit
748 /// after an undo now forks a new branch and the old forward branch(es) stay
749 /// reachable via `g-`/`g+` and `:undolist`, matching nvim's undo tree. The
750 /// new child is made `last_child` so a subsequent `<C-r>` follows the branch
751 /// just created.
752 pub(crate) fn push(&mut self, entry: UndoEntry) {
753 let cur = self.current;
754 // ONE snapshot serves both the node being left and the fresh child —
755 // this runs per edit, and a deep `MarkSnapshot` copy is up to five
756 // collection allocations.
757 let marks = Arc::new(entry.marks);
758 // Finalize the node being left with the pre-edit live state, recomputing
759 // its edge delta from its parent (or the root base).
760 self.set_node_state(
761 cur,
762 entry.rope.clone(),
763 entry.cursor,
764 entry.timestamp,
765 Arc::clone(&marks),
766 );
767 let seq = self.next_seq;
768 self.next_seq += 1;
769 // Fresh child: identical to `cur` for now (empty edge delta + warm cache
770 // holding the pre-edit rope). Its true post-edit content is finalized on
771 // the way past it (next move) or by the next `push`, at which point the
772 // edge delta is recomputed against `cur`.
773 let child_depth = self.get(cur).depth + 1;
774 let child = self.alloc(UndoNode {
775 parent: Some(cur),
776 children: Vec::new(),
777 last_child: None,
778 delta: Some(Delta::default()),
779 base: None,
780 rope_cache: Some(entry.rope),
781 depth: child_depth,
782 cursor: entry.cursor,
783 timestamp: entry.timestamp,
784 marks,
785 seq,
786 // The fresh child becomes `current`, extending the path by one.
787 on_path: true,
788 });
789 let cur_node = self.get_mut(cur);
790 // Append (retain old branches); the freshest child is the redo target.
791 cur_node.children.push(child);
792 cur_node.last_child = Some(child);
793 self.current = child;
794 self.touch_warm(child);
795 }
796
797 /// One undo step. `live` is the current buffer state (the node being left);
798 /// it is written into that node but INHERITS the destination (parent)
799 /// timestamp — byte-parity with the old dance, where the pushed redo entry
800 /// took the popped undo entry's timestamp. Returns the parent snapshot to
801 /// restore, or `None` at the root.
802 pub(crate) fn undo_step(
803 &mut self,
804 rope: ropey::Rope,
805 cursor: (usize, usize),
806 marks: MarkSnapshot,
807 ) -> Option<UndoEntry> {
808 let cur = self.current;
809 let par = self.get(cur).parent?;
810 let dest_ts = self.get(par).timestamp;
811 self.set_node_state(cur, rope, cursor, dest_ts, Arc::new(marks));
812 // Redo from the parent must return to the node we just left.
813 self.get_mut(par).last_child = Some(cur);
814 // The path shortens by one: `cur` drops off its tip, `par` is the new
815 // tip and was already on it.
816 self.get_mut(cur).on_path = false;
817 self.current = par;
818 // Hot-path materialization: derive the (possibly cold) parent from the
819 // just-finalized child by one inverse delta apply, so `u` never walks the
820 // ancestor chain even far outside the warm window.
821 if self.get(par).rope_cache.is_none() && self.get(par).base.is_none() {
822 let child_rope = self.get(cur).rope_cache.clone();
823 let child_delta = self.get(cur).delta.clone();
824 if let (Some(cr), Some(d)) = (child_rope, child_delta) {
825 let par_rope = apply_inverse(&cr, &d);
826 self.get_mut(par).rope_cache = Some(par_rope);
827 self.touch_warm(par);
828 }
829 }
830 Some(self.entry_of(par))
831 }
832
833 /// One redo step. Symmetric to [`Self::undo_step`]: `live` is written into
834 /// the node being left (which becomes an undo ancestor) with the
835 /// destination (child) timestamp. Returns the child snapshot to restore, or
836 /// `None` when there is no forward branch.
837 pub(crate) fn redo_step(
838 &mut self,
839 rope: ropey::Rope,
840 cursor: (usize, usize),
841 marks: MarkSnapshot,
842 ) -> Option<UndoEntry> {
843 let cur = self.current;
844 let child = self.get(cur).last_child?;
845 let dest_ts = self.get(child).timestamp;
846 self.set_node_state(cur, rope, cursor, dest_ts, Arc::new(marks));
847 // The path extends by one onto `child`; `cur.last_child` already names
848 // it (it is what we followed), so the ancestor chain stays correct.
849 self.get_mut(child).on_path = true;
850 self.current = child;
851 // `cur` is now warm, so materializing the child is one forward apply.
852 Some(self.entry_of(child))
853 }
854
855 // ── seq-ordered tree walk (`g-` / `g+`, `:earlier`/`:later` — Phase 2b) ───
856 //
857 // `u`/`<C-r>` are branch-local (parent / `last_child`); `g-`/`g+` traverse
858 // ALL states by global `seq`, crossing branch boundaries. `g-` restores the
859 // node with the greatest `seq` strictly below `current`'s; `g+` the least
860 // `seq` strictly above. Confirmed against nvim v0.12.4 (`iA<Esc>uiB<Esc>`
861 // then `g-`/`g-g-`/`g-g+` walks empty↔A↔B by change number).
862
863 /// `seq` of the node the buffer currently shows.
864 fn current_seq(&self) -> u64 {
865 self.get(self.current).seq
866 }
867
868 /// Live node with the greatest `seq` strictly below `s` (the `g-` target).
869 ///
870 /// O(log N) through [`Self::by_seq`]; this used to scan the whole arena on
871 /// every history step.
872 fn node_below(&self, s: u64) -> Option<NodeId> {
873 self.by_seq.range(..s).next_back().map(|(_, &id)| id)
874 }
875
876 /// Live node with the least `seq` strictly above `s` (the `g+` target).
877 ///
878 /// O(log N) through [`Self::by_seq`]; see [`Self::node_below`].
879 fn node_above(&self, s: u64) -> Option<NodeId> {
880 use std::ops::Bound;
881 self.by_seq
882 .range((Bound::Excluded(s), Bound::Unbounded))
883 .next()
884 .map(|(_, &id)| id)
885 }
886
887 /// Point `current` at `target`, re-establishing the invariant that every
888 /// ancestor of `current` names the child on the root→`current` path as its
889 /// `last_child`, so a later `<C-r>` retraces the branch just landed on
890 /// (nvim parity: landing on a node updates its ancestors' redo direction).
891 ///
892 /// Costs O(tree distance from the old `current` to `target`) — 1 for a step
893 /// along a branch — not O(depth). It used to rewrite the whole root→target
894 /// chain on every landing, which was the remaining per-step floor under
895 /// `g-`/`g+` once keyframes had bounded the materialization.
896 ///
897 /// What makes that sound is [`UndoNode::on_path`] being a maintained
898 /// invariant rather than a local test: the flagged nodes are EXACTLY the
899 /// root→`current` chain, and every flagged non-tip node already names its
900 /// on-path child. So the first flagged node found walking up from `target`
901 /// is the fork of the two paths, everything above it is already correct by
902 /// that invariant, and only the segment below it can be wrong.
903 ///
904 /// Do NOT replace this with an early exit out of the old full-chain walk at
905 /// the first ancestor that already names the right child. That is unsound
906 /// and was tried: `last_child` is also written by [`Self::push`] (immediate
907 /// parent only) and by leaf removal, so an ancestor can point the right way
908 /// while ITS ancestors still point down an abandoned branch; the early exit
909 /// leaves those stale and a later `<C-r>` walks into the wrong subtree —
910 /// caught by `tree_matches_full_snapshot_reference_over_random_ops` as a
911 /// redo returning another branch's text. The fix has to come from the path
912 /// being known, which is what `on_path` supplies.
913 fn retarget_current(&mut self, target: NodeId) {
914 // Walk up from `target`, linking each node as its parent's `last_child`,
915 // until a node already on the path: the fork. The root is always on the
916 // path, so this terminates there at the latest.
917 let mut node = target;
918 while !self.get(node).on_path {
919 let p = self
920 .get(node)
921 .parent
922 .expect("the root is always on the path, so the walk stops there");
923 self.get_mut(p).last_child = Some(node);
924 self.get_mut(node).on_path = true;
925 node = p;
926 }
927 let fork = node;
928 // Everything from the old `current` up to (not including) the fork
929 // leaves the path. Their stored `last_child` is deliberately left alone:
930 // an off-path node remembers the branch last taken through it, which is
931 // what lets `<C-r>` retrace that branch if the user lands back on it.
932 let mut leaving = self.current;
933 while leaving != fork {
934 self.get_mut(leaving).on_path = false;
935 leaving = self
936 .get(leaving)
937 .parent
938 .expect("the fork is on the path, hence an ancestor of `current`");
939 }
940 self.current = target;
941 }
942
943 /// Stash the live buffer state into the node being left (it may be a fresh,
944 /// still-stale leaf), preserving that node's own timestamp, then move.
945 fn stash_and_move(
946 &mut self,
947 target: NodeId,
948 rope: ropey::Rope,
949 cursor: (usize, usize),
950 marks: MarkSnapshot,
951 ) {
952 let cur = self.current;
953 let ts = self.get(cur).timestamp;
954 self.set_node_state(cur, rope, cursor, ts, Arc::new(marks));
955 self.retarget_current(target);
956 }
957
958 /// One `g-` / `:earlier` step: move to the next-lower-`seq` node tree-wide.
959 /// Returns its snapshot to restore, or `None` at the lowest state.
960 pub(crate) fn seq_earlier_step(
961 &mut self,
962 rope: ropey::Rope,
963 cursor: (usize, usize),
964 marks: MarkSnapshot,
965 ) -> Option<UndoEntry> {
966 let target = self.node_below(self.current_seq())?;
967 self.stash_and_move(target, rope, cursor, marks);
968 Some(self.entry_of(target))
969 }
970
971 /// One `g+` / `:later` step: move to the next-higher-`seq` node tree-wide.
972 /// Returns its snapshot to restore, or `None` at the highest state.
973 pub(crate) fn seq_later_step(
974 &mut self,
975 rope: ropey::Rope,
976 cursor: (usize, usize),
977 marks: MarkSnapshot,
978 ) -> Option<UndoEntry> {
979 let target = self.node_above(self.current_seq())?;
980 self.stash_and_move(target, rope, cursor, marks);
981 Some(self.entry_of(target))
982 }
983
984 /// Timestamp of the next-lower-`seq` node (the `:earlier Ns` predicate walks
985 /// the seq order tree-wide, stopping once this dips to/below the cutoff).
986 pub(crate) fn seq_earlier_timestamp(&self) -> Option<SystemTime> {
987 self.node_below(self.current_seq())
988 .map(|id| self.get(id).timestamp)
989 }
990
991 /// Timestamp of the next-higher-`seq` node (the `:later Ns` predicate).
992 pub(crate) fn seq_later_timestamp(&self) -> Option<SystemTime> {
993 self.node_above(self.current_seq())
994 .map(|id| self.get(id).timestamp)
995 }
996
997 /// Leaves of the tree (nodes with no children), each as
998 /// `(seq, depth-from-root, timestamp, is_current)`, sorted by `seq`.
999 /// Drives `:undolist`, which — like nvim — lists only branch leaves.
1000 pub(crate) fn leaves(&self) -> Vec<(u64, usize, SystemTime, bool)> {
1001 let mut out: Vec<(u64, usize, SystemTime, bool)> = Vec::new();
1002 for (id, slot) in self.nodes.iter().enumerate() {
1003 let Some(n) = slot else { continue };
1004 // The root is the base state (change number 0), never a listed
1005 // "change" — like nvim, an untouched buffer lists nothing.
1006 if id == self.root || !n.children.is_empty() {
1007 continue;
1008 }
1009 // Depth = number of ancestors (root leaf ⇒ 0).
1010 let mut depth = 0;
1011 let mut p = n.parent;
1012 while let Some(pid) = p {
1013 depth += 1;
1014 p = self.get(pid).parent;
1015 }
1016 out.push((n.seq, depth, n.timestamp, id == self.current));
1017 }
1018 out.sort_by_key(|&(seq, ..)| seq);
1019 out
1020 }
1021
1022 /// Number of live nodes (used by [`Self::cap`] as the state budget).
1023 fn live_count(&self) -> usize {
1024 self.nodes.iter().filter(|n| n.is_some()).count()
1025 }
1026
1027 /// `undo_stack.pop()` — discard the most-recent boundary WITHOUT moving the
1028 /// live state. Used by `:s` with zero replacements and by a no-op undo
1029 /// group; in both, `current` is the childless leaf the last [`Self::push`]
1030 /// created, so reverse that push: drop the leaf, step `current` back to its
1031 /// parent (its snapshot equals the unchanged buffer), and restore the
1032 /// parent's `last_child`. Retains any sibling branches the push appended to.
1033 /// Returns `false` at the root, or if `current` is not a childless leaf
1034 /// (nothing safe to pop).
1035 pub(crate) fn pop_committed(&mut self) -> bool {
1036 let cur = self.current;
1037 if !self.get(cur).children.is_empty() {
1038 return false;
1039 }
1040 let Some(par) = self.get(cur).parent else {
1041 return false;
1042 };
1043 let par_node = self.get_mut(par);
1044 par_node.children.retain(|&c| c != cur);
1045 // The freshest surviving sibling (if any) becomes the redo target again.
1046 par_node.last_child = par_node.children.last().copied();
1047 // The path shortens onto `par` (already on it); `cur` is about to be
1048 // freed, but clear its flag so the invariant holds at every point.
1049 self.get_mut(cur).on_path = false;
1050 self.current = par;
1051 // The popped leaf always holds the highest seq (push assigns it last),
1052 // so reclaim the seq to keep numbering gapless.
1053 if self.get(cur).seq + 1 == self.next_seq {
1054 self.next_seq -= 1;
1055 }
1056 self.free(cur);
1057 true
1058 }
1059
1060 /// Node budget (`undolevels`). While the number of undo states (live nodes
1061 /// minus the root) exceeds `cap`, prune — branch-aware (Phase 2b):
1062 ///
1063 /// 1. First drop the lowest-`seq` LEAF that is NOT on the root→`current`
1064 /// path — an abandoned branch tip. This never touches `current` or its
1065 /// ancestors, so the state you're on and its full undo line survive.
1066 /// 2. When only the main line remains (no off-path leaves left), fall back
1067 /// to promoting the root's on-path child to root and dropping the old
1068 /// root — the Phase 2a root-side prune, which matches nvim's linear
1069 /// `undolevels` trimming (oldest states drop first).
1070 ///
1071 /// `cap == 0` means unlimited (matches the old guard).
1072 pub(crate) fn cap(&mut self, cap: usize) {
1073 if cap == 0 {
1074 return;
1075 }
1076 // Guard against a pathological loop: at most one prune per live node.
1077 let mut budget_iters = self.live_count() + 1;
1078 while self.live_count().saturating_sub(1) > cap && budget_iters > 0 {
1079 budget_iters -= 1;
1080 if let Some(leaf) = self.lowest_offpath_leaf() {
1081 self.detach_leaf(leaf);
1082 } else if !self.prune_root_side() {
1083 break;
1084 }
1085 }
1086 }
1087
1088 /// Lowest-`seq` leaf that is not on the root→`current` path, if any.
1089 fn lowest_offpath_leaf(&self) -> Option<NodeId> {
1090 let mut best: Option<(u64, NodeId)> = None;
1091 for (id, slot) in self.nodes.iter().enumerate() {
1092 if let Some(n) = slot
1093 && n.children.is_empty()
1094 && !n.on_path
1095 && best.is_none_or(|(bs, _)| n.seq < bs)
1096 {
1097 best = Some((n.seq, id));
1098 }
1099 }
1100 best.map(|(_, id)| id)
1101 }
1102
1103 /// Unlink `leaf` from its parent and free it (leaf ⇒ no subtree to recurse).
1104 fn detach_leaf(&mut self, leaf: NodeId) {
1105 if let Some(par) = self.get(leaf).parent {
1106 let par_node = self.get_mut(par);
1107 par_node.children.retain(|&c| c != leaf);
1108 if par_node.last_child == Some(leaf) {
1109 par_node.last_child = par_node.children.last().copied();
1110 }
1111 }
1112 self.free(leaf);
1113 }
1114
1115 /// Promote the root's on-path child to the new root and free the old root.
1116 /// Returns `false` when the root is `current` (nothing left to trim).
1117 fn prune_root_side(&mut self) -> bool {
1118 let root = self.root;
1119 if root == self.current {
1120 return false;
1121 }
1122 // The child on the path to `current` (the root always has one here).
1123 let Some(&child) = self
1124 .get(root)
1125 .children
1126 .iter()
1127 .find(|c| self.get(**c).on_path)
1128 else {
1129 return false;
1130 };
1131 // Any OTHER root children are off-path branches; drop them with the root.
1132 let others: Vec<NodeId> = self
1133 .get(root)
1134 .children
1135 .iter()
1136 .copied()
1137 .filter(|&c| c != child)
1138 .collect();
1139 for c in others {
1140 self.free_subtree(c);
1141 }
1142 // The promoted child becomes the new root: materialize it (while the old
1143 // root still anchors the chain) into a full base rope, then drop its
1144 // now-meaningless parent edge. This keeps every delta below it valid.
1145 let base = self.materialize(child);
1146 {
1147 let node = self.get_mut(child);
1148 node.parent = None;
1149 node.base = Some(base);
1150 node.delta = None;
1151 node.rope_cache = None;
1152 }
1153 self.warm.retain(|&n| n != child);
1154 self.keyframes.retain(|&n| n != child);
1155 // `child` was already on the path (it is the on-path child) and is now
1156 // its root end; the old root drops off it as it is freed.
1157 self.get_mut(root).on_path = false;
1158 self.root = child;
1159 self.free(root);
1160 true
1161 }
1162
1163 /// `redo_stack.clear()` — drop `current`'s forward branch.
1164 pub(crate) fn clear_redo(&mut self) {
1165 let cur = self.current;
1166 let kids = std::mem::take(&mut self.get_mut(cur).children);
1167 self.get_mut(cur).last_child = None;
1168 for c in kids {
1169 self.free_subtree(c);
1170 }
1171 }
1172
1173 /// `undo_stack.clear(); redo_stack.clear()` — collapse to a single root ==
1174 /// current node, preserving the live state. Frees every other node.
1175 pub(crate) fn clear_all(&mut self) {
1176 let cur = self.current;
1177 // The survivor becomes a self-contained root: give it a full base rope
1178 // (materialized while the chain is still intact) so it needs no parent.
1179 let base = self.materialize(cur);
1180 for id in 0..self.nodes.len() {
1181 if id != cur && self.nodes[id].is_some() {
1182 self.nodes[id] = None;
1183 self.free.push(id);
1184 }
1185 }
1186 self.warm.clear();
1187 self.keyframes.clear();
1188 // Bulk free bypassed `free`, so rebuild the index around the survivor.
1189 self.by_seq.clear();
1190 self.by_seq.insert(self.get(cur).seq, cur);
1191 let node = self.get_mut(cur);
1192 node.parent = None;
1193 node.children.clear();
1194 node.last_child = None;
1195 node.delta = None;
1196 node.base = Some(base);
1197 node.rope_cache = None;
1198 // The survivor is the new root: restart the depth numbering under it so
1199 // its descendants land on the keyframe ladder from 0 again.
1200 node.depth = 0;
1201 // Sole survivor ⇒ root == current ⇒ it is the whole path.
1202 node.on_path = true;
1203 self.root = cur;
1204 }
1205}
1206
1207// ─── Serializable projection (Phase 3b) ───────────────────────────────────────
1208//
1209// The undofile persists the tree as a compact, self-consistent projection: the
1210// root's full base text (String) plus, per node, its edge `delta` and links.
1211// `rope_cache`/`warm` are runtime-only and dropped — every node reconstructs
1212// from the root base + deltas, so the round-trip reproduces identical content
1213// at every node. NodeIds are DENSE in the projection (the live-slab holes are
1214// compacted away and links remapped), so `from_serializable` rebuilds a fresh
1215// arena 1:1 with no free list.
1216
1217/// One node of the serialized undo tree. Mirrors [`UndoNode`] minus the
1218/// runtime-only materialization cache; ids are dense indices into
1219/// [`SerTree::nodes`].
1220#[derive(Debug, Clone, Serialize, Deserialize)]
1221pub struct SerNode {
1222 /// Parent index, `None` only for the root.
1223 pub parent: Option<u32>,
1224 /// Child indices (order preserved; `> 1` ⇒ branch point).
1225 pub children: Vec<u32>,
1226 /// `<C-r>` target child index.
1227 pub last_child: Option<u32>,
1228 /// Reversible edge delta from the parent, `None` only for the root.
1229 pub delta: Option<Delta>,
1230 /// Post-state cursor `(row, col)`.
1231 pub cursor: (u32, u32),
1232 /// Wall-clock creation time, ms since the UNIX epoch.
1233 pub timestamp_unix_ms: u64,
1234 /// Marks / jumplist / changelist snapshot.
1235 pub marks: MarkSnapshot,
1236 /// Global monotonic change number.
1237 pub seq: u64,
1238}
1239
1240/// Serializable projection of an [`UndoTree`] for the undofile and the swap
1241/// undo section. Postcard-encoded (non-self-describing, so a schema/version
1242/// drift surfaces as a parse `Err` that the reader discards). See
1243/// [`UndoTree::to_serializable`] / [`UndoTree::from_serializable`].
1244#[derive(Debug, Clone, Serialize, Deserialize)]
1245pub struct SerTree {
1246 /// Root base text (the anchor the delta chain replays from). Always
1247 /// `Some` on write; `from_serializable` rejects `None` — a tree without an
1248 /// anchor is structurally invalid outside the swap context. The swap
1249 /// writer drops it for single-node trees (the body IS the base) and the
1250 /// swap reader re-substitutes the body text before the tree is rebuilt.
1251 pub base: Option<String>,
1252 /// Dense node arena (no holes).
1253 pub nodes: Vec<SerNode>,
1254 /// Root index into `nodes`.
1255 pub root: u32,
1256 /// Current (live) index into `nodes`.
1257 pub current: u32,
1258 /// Next `seq` to assign.
1259 pub next_seq: u64,
1260}
1261
1262/// [`SystemTime`] → ms since the UNIX epoch (saturating, pre-epoch ⇒ 0).
1263fn system_time_to_unix_ms(t: SystemTime) -> u64 {
1264 t.duration_since(UNIX_EPOCH)
1265 .map_or(0, |d| d.as_millis() as u64)
1266}
1267
1268/// ms since the UNIX epoch → [`SystemTime`].
1269fn unix_ms_to_system_time(ms: u64) -> SystemTime {
1270 UNIX_EPOCH + Duration::from_millis(ms)
1271}
1272
1273impl UndoTree {
1274 /// `seq` of the current (live) node — the header's `current_seq` for the
1275 /// undofile (the just-saved content per the §6 invariant).
1276 pub(crate) fn current_node_seq(&self) -> u64 {
1277 self.get(self.current).seq
1278 }
1279
1280 /// Materialize the current (live) node's content. Used by the swap
1281 /// recovery consistency guard (docs §6c) to check a deserialized tree
1282 /// agrees with the freshly-recovered buffer text before it's installed.
1283 pub(crate) fn current_content(&mut self) -> ropey::Rope {
1284 let cur = self.current;
1285 self.materialize(cur)
1286 }
1287
1288 /// Stash `rope` into the current node as the live buffer state, preserving
1289 /// that node's own cursor/timestamp/marks. Called just before serializing so
1290 /// the on-disk tree's `current` edge is exact even when `current` is a fresh
1291 /// (still-stale) leaf — the in-session self-heal (first undo/edit stashes
1292 /// live) applied eagerly at save time.
1293 pub(crate) fn sync_current(&mut self, rope: ropey::Rope) {
1294 let cur = self.current;
1295 let (cursor, ts, marks) = {
1296 let n = self.get(cur);
1297 (n.cursor, n.timestamp, n.marks.clone())
1298 };
1299 self.set_node_state(cur, rope, cursor, ts, marks);
1300 }
1301
1302 /// Project the live tree into a serializable, dense form (holes compacted,
1303 /// links remapped). `rope_cache`/`warm` are dropped; the root's `base`
1304 /// carries the anchor text and every non-root node its edge `delta`.
1305 pub(crate) fn to_serializable(&self) -> SerTree {
1306 // Dense remap: old NodeId → new index, in slab order.
1307 let mut map: Vec<Option<u32>> = vec![None; self.nodes.len()];
1308 let mut order: Vec<NodeId> = Vec::new();
1309 for (id, slot) in self.nodes.iter().enumerate() {
1310 if slot.is_some() {
1311 map[id] = Some(order.len() as u32);
1312 order.push(id);
1313 }
1314 }
1315 let remap = |id: NodeId| map[id].expect("live link points at a live node");
1316 let nodes = order
1317 .iter()
1318 .map(|&id| {
1319 let n = self.get(id);
1320 SerNode {
1321 parent: n.parent.map(remap),
1322 children: n.children.iter().map(|&c| remap(c)).collect(),
1323 last_child: n.last_child.map(remap),
1324 delta: n.delta.clone(),
1325 cursor: (n.cursor.0 as u32, n.cursor.1 as u32),
1326 timestamp_unix_ms: system_time_to_unix_ms(n.timestamp),
1327 marks: (*n.marks).clone(),
1328 seq: n.seq,
1329 }
1330 })
1331 .collect();
1332 // Always `Some`: the undofile and the swap's multi-node trees anchor on
1333 // the root text; only the swap writer may drop it (single-node trees,
1334 // where the body IS the base) and it re-substitutes on read.
1335 let base = Some(
1336 self.get(self.root)
1337 .base
1338 .as_ref()
1339 .map(|r| r.to_string())
1340 .unwrap_or_default(),
1341 );
1342 SerTree {
1343 base,
1344 nodes,
1345 root: remap(self.root),
1346 current: remap(self.current),
1347 next_seq: self.next_seq,
1348 }
1349 }
1350
1351 /// Rebuild an arena tree from a projection. Returns `None` on any structural
1352 /// inconsistency (out-of-range link, a non-root node missing its delta, a
1353 /// root carrying one, `children` lists that do not partition the non-root
1354 /// nodes, a child disagreeing with the node that listed it, a node
1355 /// unreachable from the root, a repeated `seq`) so a corrupt-but-parseable
1356 /// file degrades to a fresh tree rather than a broken one. The root's
1357 /// content comes from `base`; the current node's is materialized on demand
1358 /// from base + deltas.
1359 ///
1360 /// The partition + reachability pair is what makes the parent links a tree,
1361 /// and that is load-bearing rather than tidiness: [`Self::materialize`] and
1362 /// [`Self::retarget_current`] follow `parent` in unbounded loops, so a
1363 /// parent-link cycle is a hang (or an unbounded `path`) rather than a
1364 /// degraded tree. Rejecting it here is the only place that can see it.
1365 pub(crate) fn from_serializable(s: &SerTree) -> Option<Self> {
1366 let len = s.nodes.len();
1367 if len == 0 || s.root as usize >= len || s.current as usize >= len {
1368 return None;
1369 }
1370 // A tree without an anchor is structurally invalid outside the swap
1371 // context, where the body supplies the base (the swap reader fills it
1372 // before this is ever reached). Reject rather than guess.
1373 let base_str = s.base.as_deref()?;
1374 // Validate links and the root/non-root delta discipline up front.
1375 let mut seqs = std::collections::BTreeSet::new();
1376 for (i, n) in s.nodes.iter().enumerate() {
1377 let is_root = i as u32 == s.root;
1378 match (is_root, &n.delta, &n.parent) {
1379 (true, None, None) => {}
1380 (false, Some(_), Some(_)) => {}
1381 _ => return None,
1382 }
1383 if let Some(p) = n.parent
1384 && p as usize >= len
1385 {
1386 return None;
1387 }
1388 if n.children.iter().any(|&c| c as usize >= len) {
1389 return None;
1390 }
1391 if let Some(c) = n.last_child
1392 && c as usize >= len
1393 {
1394 return None;
1395 }
1396 // `by_seq` is keyed by `seq`, so a repeat would drop a node from the
1397 // `g-` / `g+` index without dropping it from the arena.
1398 if !seqs.insert(n.seq) {
1399 return None;
1400 }
1401 }
1402 // The `children` lists must PARTITION the non-root nodes — each listed
1403 // once, by nobody for the root — and every child's own `parent` must
1404 // name the node that listed it. Mutual agreement alone is not enough:
1405 // two nodes can name each other as parent AND as child while both stay
1406 // reachable from the root through the lists they were originally in.
1407 // Requiring a single lister is what turns the two directions into one
1408 // tree. Runs after the range loop so the indices below are in bounds.
1409 let mut listed_by: Vec<Option<u32>> = vec![None; len];
1410 for (i, n) in s.nodes.iter().enumerate() {
1411 for &c in &n.children {
1412 if c == s.root || listed_by[c as usize].is_some() {
1413 return None;
1414 }
1415 listed_by[c as usize] = Some(i as u32);
1416 }
1417 }
1418 if s.nodes
1419 .iter()
1420 .zip(listed_by.iter())
1421 .any(|(n, &lister)| n.parent != lister)
1422 {
1423 return None;
1424 }
1425 let base = ropey::Rope::from_str(base_str);
1426 let (depths, reachable) = depths_from_root(s);
1427 // With the lists partitioning the nodes, reachability from the root is
1428 // what rules out a cycle: a node inside one is reachable from no root.
1429 if reachable.iter().any(|&r| !r) {
1430 return None;
1431 }
1432 let mut nodes: Vec<Option<UndoNode>> = s
1433 .nodes
1434 .iter()
1435 .enumerate()
1436 .map(|(i, n)| {
1437 let is_root = i as u32 == s.root;
1438 Some(UndoNode {
1439 parent: n.parent.map(|p| p as NodeId),
1440 children: n.children.iter().map(|&c| c as NodeId).collect(),
1441 last_child: n.last_child.map(|c| c as NodeId),
1442 delta: n.delta.clone(),
1443 base: if is_root { Some(base.clone()) } else { None },
1444 rope_cache: None,
1445 depth: depths[i],
1446 cursor: (n.cursor.0 as usize, n.cursor.1 as usize),
1447 timestamp: unix_ms_to_system_time(n.timestamp_unix_ms),
1448 marks: Arc::new(n.marks.clone()),
1449 seq: n.seq,
1450 // Set below, once the whole arena exists to walk.
1451 on_path: false,
1452 })
1453 })
1454 .collect();
1455 // Establish the root→`current` path invariant on the loaded tree: flag
1456 // the chain, and make each ancestor name its on-path child. A projection
1457 // written by `to_serializable` already agrees (it came from a tree
1458 // holding the invariant), so this is a no-op on any file we produced —
1459 // doing it unconditionally is what stops a hand-edited or truncated one
1460 // from loading into a tree whose `<C-r>` direction contradicts its own
1461 // `current`, which `retarget_current` would no longer repair on the way
1462 // past. The validation above already rules out a parent-link cycle, so
1463 // the `len` bound is belt-and-braces on the one walk that runs before
1464 // any invariant of the rebuilt tree holds.
1465 let mut node = s.current as NodeId;
1466 for _ in 0..len {
1467 let n = nodes[node].as_mut().expect("the projection is dense");
1468 n.on_path = true;
1469 let Some(p) = n.parent else { break };
1470 nodes[p]
1471 .as_mut()
1472 .expect("the projection is dense")
1473 .last_child = Some(node);
1474 node = p;
1475 }
1476 // A node's `last_child` is the `<C-r>` direction and must name one of
1477 // its OWN children. Range validation above is all a hand-edited file
1478 // trips otherwise — a `last_child` pointing at any in-bounds node
1479 // loads, and the first `<C-r>` from that node walks into the wrong
1480 // subtree. Repair rather than reject (consistent with the path-chain
1481 // repair above): clear a `last_child` that names a non-child. The
1482 // path loop just ran only ever sets an ancestor's `last_child` to its
1483 // on-path child, which IS in its children list, so nothing it did is
1484 // undone here.
1485 for slot in nodes.iter_mut().flatten() {
1486 if let Some(c) = slot.last_child
1487 && !slot.children.contains(&c)
1488 {
1489 slot.last_child = None;
1490 }
1491 }
1492 let by_seq = nodes
1493 .iter()
1494 .enumerate()
1495 .filter_map(|(id, slot)| slot.as_ref().map(|n| (n.seq, id)))
1496 .collect();
1497 Some(Self {
1498 nodes,
1499 free: Vec::new(),
1500 warm: Vec::new(),
1501 keyframes: Vec::new(),
1502 by_seq,
1503 root: s.root as NodeId,
1504 current: s.current as NodeId,
1505 next_seq: s.next_seq,
1506 })
1507 }
1508}
1509
1510/// Depth-from-root of every node in a projection, by BFS over `children`, plus
1511/// the reachable set the walk visited.
1512///
1513/// Depth is NOT part of the on-disk format — it is derivable, and the undofile
1514/// deliberately stores only what is not (issue #302: keyframes are an in-memory
1515/// cache, so nothing about them enters `SerTree`). The `seen` guard makes this
1516/// terminate on a malformed file whose links form a cycle; anything unreachable
1517/// from the root keeps depth 0. `from_serializable` rejects a projection with
1518/// any unreachable node, so the returned depths are only ever used on a tree
1519/// where every one of them was computed by the walk.
1520fn depths_from_root(s: &SerTree) -> (Vec<usize>, Vec<bool>) {
1521 let mut depths = vec![0usize; s.nodes.len()];
1522 let mut seen = vec![false; s.nodes.len()];
1523 let mut queue = std::collections::VecDeque::new();
1524 seen[s.root as usize] = true;
1525 queue.push_back(s.root as usize);
1526 while let Some(i) = queue.pop_front() {
1527 for &c in &s.nodes[i].children {
1528 let c = c as usize;
1529 if !seen[c] {
1530 seen[c] = true;
1531 depths[c] = depths[i] + 1;
1532 queue.push_back(c);
1533 }
1534 }
1535 }
1536 (depths, seen)
1537}
1538
1539#[cfg(test)]
1540impl UndoTree {
1541 /// Ids of every live node, for warm-vs-cold materialization checks.
1542 fn live_ids(&self) -> Vec<NodeId> {
1543 (0..self.nodes.len())
1544 .filter(|&i| self.nodes[i].is_some())
1545 .collect()
1546 }
1547
1548 /// Materialize `id` for a test (public wrapper over the private method).
1549 fn materialize_for_test(&mut self, id: NodeId) -> ropey::Rope {
1550 self.materialize(id)
1551 }
1552
1553 /// Evict every cache INCLUDING the pinned keyframes (root keeps its `base`),
1554 /// forcing the next materialization of any node to reconstruct purely from
1555 /// deltas off the root — the strongest cold path there is.
1556 fn drop_all_caches(&mut self) {
1557 for n in self.nodes.iter_mut().flatten() {
1558 n.rope_cache = None;
1559 }
1560 self.warm.clear();
1561 self.keyframes.clear();
1562 }
1563
1564 /// Evict only the ordinary warm LRU, leaving the pinned keyframes — the
1565 /// steady state a deep history walk actually runs in.
1566 fn drop_warm_caches(&mut self) {
1567 for id in std::mem::take(&mut self.warm) {
1568 if let Some(n) = self.nodes[id].as_mut() {
1569 n.rope_cache = None;
1570 }
1571 }
1572 }
1573
1574 /// How many forward delta applies `materialize(id)` would perform right now
1575 /// (0 when `id` already holds content). This is the cost keyframes exist to
1576 /// bound, made assertable.
1577 fn replay_distance(&self, id: NodeId) -> usize {
1578 let mut n = 0;
1579 let mut cur = id;
1580 loop {
1581 let node = self.get(cur);
1582 if node.rope_cache.is_some() || node.base.is_some() {
1583 return n;
1584 }
1585 n += 1;
1586 match node.parent {
1587 Some(p) => cur = p,
1588 None => return n,
1589 }
1590 }
1591 }
1592
1593 /// The root→`current` path, by brute force: walk `parent` links up from
1594 /// `current` and reverse. Deliberately consults NEITHER `on_path` nor
1595 /// `last_child`, so it is an independent oracle for both.
1596 fn brute_force_path(&self) -> Vec<NodeId> {
1597 let mut walk = Vec::new();
1598 let mut n = Some(self.current);
1599 while let Some(id) = n {
1600 walk.push(id);
1601 n = self.get(id).parent;
1602 }
1603 walk.reverse();
1604 walk
1605 }
1606
1607 /// The two halves of the path invariant `retarget_current` now relies on,
1608 /// checked against [`Self::brute_force_path`]:
1609 ///
1610 /// 1. `on_path` is set on EXACTLY the nodes of the root→`current` walk.
1611 /// 2. every node on that walk except the tip names its successor as
1612 /// `last_child` — the observable property (`<C-r>` retraces the branch
1613 /// landed on) that the old full-chain rewrite established directly.
1614 ///
1615 /// (1) is the maintenance; (2) is what the maintenance buys. Checking only
1616 /// (2) would pass on a tree whose flags had drifted but whose links happened
1617 /// to be right; checking only (1) would pass on a tree that had stopped
1618 /// linking. Both must hold after every operation that moves `current`,
1619 /// allocates, or frees.
1620 #[track_caller]
1621 fn assert_path_invariant(&self, when: &str) {
1622 let walk = self.brute_force_path();
1623 assert_eq!(
1624 walk.first().copied(),
1625 Some(self.root),
1626 "the root→current walk does not start at the root after {when}"
1627 );
1628 let mut want = walk.clone();
1629 want.sort_unstable();
1630 let flagged: Vec<NodeId> = (0..self.nodes.len())
1631 .filter(|&i| self.nodes[i].as_ref().is_some_and(|n| n.on_path))
1632 .collect();
1633 assert_eq!(
1634 flagged, want,
1635 "on_path flags name {flagged:?}, the root→current walk is {want:?}, after {when}"
1636 );
1637 for w in walk.windows(2) {
1638 assert_eq!(
1639 self.get(w[0]).last_child,
1640 Some(w[1]),
1641 "node {} last_child is {:?}, not its on-path child {}, after {when}",
1642 w[0],
1643 self.get(w[0]).last_child,
1644 w[1]
1645 );
1646 }
1647 }
1648
1649 /// Reconstruct `id`'s content the naive way: walk to the root and replay
1650 /// every forward delta off the root `base`, consulting NO cache and NO
1651 /// keyframe. The differential oracle for keyframe-accelerated
1652 /// [`Self::materialize`] — the two must agree exactly, always.
1653 fn materialize_naive(&self, id: NodeId) -> ropey::Rope {
1654 let mut path = vec![id];
1655 let mut cur = id;
1656 while let Some(p) = self.get(cur).parent {
1657 path.push(p);
1658 cur = p;
1659 }
1660 let mut rope = self
1661 .get(cur)
1662 .base
1663 .clone()
1664 .expect("the root always carries a base");
1665 // Skip the root itself (it has no edge delta); replay root-ward → target.
1666 for &node in path.iter().rev().skip(1) {
1667 let d = self
1668 .get(node)
1669 .delta
1670 .as_ref()
1671 .expect("a non-root node always carries its edge delta");
1672 rope = apply_forward(&rope, d);
1673 }
1674 rope
1675 }
1676}
1677
1678#[cfg(test)]
1679mod tree_tests {
1680 use super::*;
1681
1682 fn entry(text: &str) -> UndoEntry {
1683 UndoEntry {
1684 rope: ropey::Rope::from_str(text),
1685 cursor: (0, 0),
1686 timestamp: SystemTime::now(),
1687 marks: MarkSnapshot::default(),
1688 }
1689 }
1690
1691 fn live(text: &str) -> (ropey::Rope, (usize, usize), MarkSnapshot) {
1692 (ropey::Rope::from_str(text), (0, 0), MarkSnapshot::default())
1693 }
1694
1695 #[test]
1696 fn fresh_tree_is_root_current_empty() {
1697 let t = UndoTree::new(ropey::Rope::from_str("hello"));
1698 assert!(t.is_at_root());
1699 assert!(!t.has_redo());
1700 assert_eq!(t.depth_from_root(), 0);
1701 assert_eq!(t.root, t.current);
1702 }
1703
1704 #[test]
1705 fn push_links_child_and_advances_current() {
1706 let mut t = UndoTree::new(ropey::Rope::from_str("hello"));
1707 let root = t.current;
1708 t.push(entry("hello"));
1709 // root now parents current; current is a fresh leaf.
1710 assert_eq!(t.get(t.current).parent, Some(root));
1711 assert_eq!(t.get(root).last_child, Some(t.current));
1712 assert_eq!(t.get(root).children, vec![t.current]);
1713 assert_eq!(t.depth_from_root(), 1);
1714 assert!(!t.has_redo());
1715 assert!(!t.is_at_root());
1716 }
1717
1718 #[test]
1719 fn undo_then_redo_round_trips_links() {
1720 let mut t = UndoTree::new(ropey::Rope::from_str("s0"));
1721 t.push(entry("s0")); // commit s0, current = n1 (live s1)
1722 let n0 = t.root;
1723 let n1 = t.current;
1724 // undo: current -> n0, restores s0.
1725 let (r, c, m) = live("s1");
1726 let restored = t.undo_step(r, c, m).unwrap();
1727 assert_eq!(restored.rope.to_string(), "s0");
1728 assert_eq!(t.current, n0);
1729 assert!(t.has_redo());
1730 assert_eq!(t.get(n0).last_child, Some(n1));
1731 // redo: current -> n1, restores what we left (s1).
1732 let (r, c, m) = live("s0");
1733 let restored = t.redo_step(r, c, m).unwrap();
1734 assert_eq!(restored.rope.to_string(), "s1");
1735 assert_eq!(t.current, n1);
1736 assert!(!t.has_redo());
1737 }
1738
1739 #[test]
1740 fn undo_at_root_and_redo_at_leaf_are_noops() {
1741 let mut t = UndoTree::new(ropey::Rope::from_str("x"));
1742 let (r, c, m) = live("x");
1743 assert!(t.undo_step(r, c, m).is_none());
1744 let (r, c, m) = live("x");
1745 assert!(t.redo_step(r, c, m).is_none());
1746 assert_eq!(t.depth_from_root(), 0);
1747 }
1748
1749 #[test]
1750 fn push_retains_forward_branch() {
1751 // Phase 2b: an edit after an undo forks a new branch; the old forward
1752 // branch is NOT dropped and remains reachable by seq.
1753 let mut t = UndoTree::new(ropey::Rope::from_str("s0"));
1754 t.push(entry("A")); // root -> nA (seq1, "A")
1755 let root = t.root;
1756 let na = t.current;
1757 let (r, c, m) = live("A");
1758 t.undo_step(r, c, m); // back to root, nA is the redo child
1759 assert!(t.has_redo());
1760 // A new edit from the root forks a SECOND child (nB, seq2).
1761 t.push(entry("B"));
1762 let nb = t.current;
1763 assert_ne!(nb, na);
1764 // Both branches live: root now has two children.
1765 assert_eq!(t.get(root).children.len(), 2);
1766 assert!(t.get(root).children.contains(&na));
1767 assert!(t.get(root).children.contains(&nb));
1768 // `<C-r>` follows the freshest branch (nB).
1769 assert_eq!(t.get(root).last_child, Some(nb));
1770 // Four live nodes: root + nA + nB + (nB is current/leaf). No leak of nA.
1771 let live = t.nodes.iter().filter(|n| n.is_some()).count();
1772 assert_eq!(live, 3);
1773 }
1774
1775 #[test]
1776 fn seq_walk_crosses_branches() {
1777 // Mirror nvim `iA<Esc>uiB<Esc>` then g-/g+ (buffer starts empty "").
1778 // `push(entry)` writes `entry` into the node being LEFT (its true
1779 // pre-edit content); the fresh leaf holds the live post-edit state only
1780 // once it is stashed on the way past — exactly the engine's discipline.
1781 let mut t = UndoTree::new(ropey::Rope::from_str(""));
1782 t.push(entry("")); // leave root("") -> nA(seq1), live "A"
1783 let (r, c, m) = live("A");
1784 t.undo_step(r, c, m); // stash "A" into nA, back to root("")
1785 t.push(entry("")); // leave root("") -> nB(seq2), branch, live "B"
1786 let nb = t.current;
1787 // At B (seq2). g- -> greatest seq below 2 = seq1 = "A".
1788 let (r, c, m) = live("B");
1789 let a = t.seq_earlier_step(r, c, m).unwrap();
1790 assert_eq!(a.rope.to_string(), "A");
1791 // g- again -> root "".
1792 let (r, c, m) = live("A");
1793 let root_snap = t.seq_earlier_step(r, c, m).unwrap();
1794 assert_eq!(root_snap.rope.to_string(), "");
1795 // g+ -> back up to seq1 "A".
1796 let (r, c, m) = live("");
1797 let a2 = t.seq_later_step(r, c, m).unwrap();
1798 assert_eq!(a2.rope.to_string(), "A");
1799 // g+ -> seq2 "B" (crosses to the other branch).
1800 let (r, c, m) = live("A");
1801 let b = t.seq_later_step(r, c, m).unwrap();
1802 assert_eq!(b.rope.to_string(), "B");
1803 assert_eq!(t.current, nb);
1804 // At the tip: no higher seq.
1805 let (r, c, m) = live("B");
1806 assert!(t.seq_later_step(r, c, m).is_none());
1807 }
1808
1809 #[test]
1810 fn seq_walk_updates_retrace_path() {
1811 // Land on a deep leaf via g-, then u/u and <C-r>/<C-r> must retrace it
1812 // (nvim `iX<Esc>iY<Esc>uiZ<Esc>g-uu<C-r><C-r>`). State labels: root "R".
1813 let mut t = UndoTree::new(ropey::Rope::from_str("R"));
1814 t.push(entry("R")); // leave root("R") -> nX(seq1), live "X"
1815 t.push(entry("X")); // leave nX("X") -> nY(seq2), live "Y"
1816 let (r, c, m) = live("Y");
1817 t.undo_step(r, c, m); // stash "Y" into nY, back to nX("X")
1818 t.push(entry("X")); // leave nX("X") -> nZ(seq3), branch, live "Z"
1819 // g- from Z(seq3) -> nY(seq2) "Y".
1820 let (r, c, m) = live("Z");
1821 let y = t.seq_earlier_step(r, c, m).unwrap();
1822 assert_eq!(y.rope.to_string(), "Y");
1823 // u,u back to root.
1824 let (r, c, m) = live("Y");
1825 t.undo_step(r, c, m);
1826 let (r, c, m) = live("X");
1827 t.undo_step(r, c, m);
1828 assert!(t.is_at_root());
1829 // <C-r>,<C-r> retraces the branch we landed on: root->X->Y.
1830 let (r, c, m) = live("R");
1831 let x = t.redo_step(r, c, m).unwrap();
1832 assert_eq!(x.rope.to_string(), "X");
1833 let (r, c, m) = live("X");
1834 let y2 = t.redo_step(r, c, m).unwrap();
1835 assert_eq!(y2.rope.to_string(), "Y");
1836 }
1837
1838 #[test]
1839 fn leaves_lists_branch_tips_by_seq() {
1840 // root -> nX -> nY -> nW (leaf, seq3, depth3) and nX -> nZ (leaf, seq4,
1841 // depth2). Mirrors nvim `iX iY iW uu iZ`.
1842 let mut t = UndoTree::new(ropey::Rope::from_str(""));
1843 t.push(entry("X"));
1844 t.push(entry("Y"));
1845 t.push(entry("W"));
1846 let (r, c, m) = live("W");
1847 t.undo_step(r, c, m);
1848 let (r, c, m) = live("Y");
1849 t.undo_step(r, c, m); // back to nX
1850 t.push(entry("Z")); // nX -> nZ(seq4)
1851 let leaves = t.leaves();
1852 // Two leaves: W(seq3, depth3) and Z(seq4, depth2). Z is current.
1853 let dims: Vec<(u64, usize, bool)> =
1854 leaves.iter().map(|&(s, d, _, cur)| (s, d, cur)).collect();
1855 assert_eq!(dims, vec![(3, 3, false), (4, 2, true)]);
1856 }
1857
1858 #[test]
1859 fn cap_prunes_oldest_from_root_side() {
1860 let mut t = UndoTree::new(ropey::Rope::from_str("s"));
1861 for _ in 0..5 {
1862 t.push(entry("s"));
1863 }
1864 assert_eq!(t.depth_from_root(), 5);
1865 t.cap(3);
1866 assert_eq!(t.depth_from_root(), 3);
1867 // Redo side untouched (there is none), current unchanged.
1868 assert!(!t.has_redo());
1869 // Two oldest slots were reclaimed.
1870 assert_eq!(t.free.len(), 2);
1871 }
1872
1873 #[test]
1874 fn cap_drops_offpath_leaf_before_main_line() {
1875 // Fork two abandoned branches off the root, then extend the main line,
1876 // and cap: the lowest-seq OFF-PATH leaf must go first, and `current`
1877 // plus its ancestors must survive.
1878 let mut t = UndoTree::new(ropey::Rope::from_str(""));
1879 t.push(entry("A")); // root -> nA(seq1) [abandoned branch tip]
1880 let na = t.current;
1881 let (r, c, m) = live("A");
1882 t.undo_step(r, c, m);
1883 t.push(entry("B")); // root -> nB(seq2) [abandoned branch tip]
1884 let nb = t.current;
1885 let (r, c, m) = live("B");
1886 t.undo_step(r, c, m);
1887 t.push(entry("C")); // root -> nC(seq3), the live main line
1888 let nc = t.current;
1889 // 4 live nodes (root, nA, nB, nC) => 3 states. Cap to 2.
1890 assert_eq!(t.leaves().len(), 3);
1891 t.cap(2);
1892 // The lowest-seq off-path leaf (nA, seq1) was dropped; current (nC) and
1893 // its ancestor (root) survive, and the newer off-path leaf nB survives.
1894 assert!(t.nodes[na].is_none());
1895 assert!(t.nodes[nb].is_some());
1896 assert_eq!(t.current, nc);
1897 assert!(!t.is_at_root());
1898 assert!(t.get(t.root).children.contains(&nb));
1899 assert!(t.get(t.root).children.contains(&nc));
1900 }
1901
1902 #[test]
1903 fn pop_committed_reverses_last_push() {
1904 let mut t = UndoTree::new(ropey::Rope::from_str("s0"));
1905 t.push(entry("s0")); // depth 1, current = fresh leaf
1906 assert_eq!(t.depth_from_root(), 1);
1907 assert!(t.pop_committed());
1908 // The just-pushed leaf is gone; current stepped back to the root.
1909 assert_eq!(t.depth_from_root(), 0);
1910 assert!(t.is_at_root());
1911 assert_eq!(t.free.len(), 1);
1912 // Seq reclaimed so the next push is gapless.
1913 assert_eq!(t.next_seq, 1);
1914 }
1915
1916 #[test]
1917 fn pop_committed_retains_sibling_branches() {
1918 // Fork a branch, then a no-op push at the fork must pop cleanly without
1919 // orphaning the sibling branch.
1920 let mut t = UndoTree::new(ropey::Rope::from_str(""));
1921 t.push(entry("A")); // root -> nA(seq1)
1922 let na = t.current;
1923 let (r, c, m) = live("A");
1924 t.undo_step(r, c, m); // back to root
1925 t.push(entry("B")); // root -> nB(seq2); root children [nA, nB]
1926 let root = t.root;
1927 // A spurious no-op push at nB, then pop it.
1928 assert!(t.pop_committed());
1929 // nB is gone, current back at root; nA branch still intact & reachable.
1930 assert!(t.get(root).children.contains(&na));
1931 assert_eq!(t.get(root).children.len(), 1);
1932 assert_eq!(t.current, root);
1933 let live = t.nodes.iter().filter(|n| n.is_some()).count();
1934 assert_eq!(live, 2); // root + nA
1935 }
1936
1937 #[test]
1938 fn pop_committed_at_root_is_false() {
1939 let mut t = UndoTree::new(ropey::Rope::from_str("s"));
1940 assert!(!t.pop_committed());
1941 }
1942
1943 #[test]
1944 fn clear_redo_drops_forward_only() {
1945 let mut t = UndoTree::new(ropey::Rope::from_str("s0"));
1946 t.push(entry("s0"));
1947 let (r, c, m) = live("s1");
1948 t.undo_step(r, c, m);
1949 assert!(t.has_redo());
1950 assert_eq!(t.depth_from_root(), 0);
1951 t.clear_redo();
1952 assert!(!t.has_redo());
1953 assert_eq!(t.depth_from_root(), 0);
1954 }
1955
1956 #[test]
1957 fn clear_all_collapses_to_single_node() {
1958 let mut t = UndoTree::new(ropey::Rope::from_str("s"));
1959 for _ in 0..3 {
1960 t.push(entry("s"));
1961 }
1962 t.clear_all();
1963 assert!(t.is_at_root());
1964 assert!(!t.has_redo());
1965 assert_eq!(t.depth_from_root(), 0);
1966 assert_eq!(t.root, t.current);
1967 }
1968
1969 /// The depth measures where `current` sits, not how many nodes exist: an
1970 /// undo walks it back down while the branch it came from stays live, and a
1971 /// redo climbs the same steps again.
1972 #[test]
1973 fn depth_from_root_follows_current_not_tree_size() {
1974 let mut t = UndoTree::new(ropey::Rope::from_str("s0"));
1975 t.push(entry("s0"));
1976 t.push(entry("s1"));
1977 t.push(entry("s2"));
1978 assert_eq!(t.depth_from_root(), 3);
1979
1980 let (r, c, m) = live("s3");
1981 assert!(t.undo_step(r, c, m).is_some());
1982 let (r, c, m) = live("s2");
1983 assert!(t.undo_step(r, c, m).is_some());
1984 assert_eq!(t.depth_from_root(), 1);
1985 // Nothing was pruned — the three pushed nodes are still reachable.
1986 assert!(t.has_redo());
1987 assert_eq!(t.live_count(), 4);
1988
1989 let (r, c, m) = live("s1");
1990 assert!(t.redo_step(r, c, m).is_some());
1991 assert_eq!(t.depth_from_root(), 2);
1992 }
1993
1994 /// `on_path` and the ancestors' `last_child` chain must agree with a
1995 /// brute-force root→`current` walk after EVERY operation that moves
1996 /// `current`, allocates, or frees — that invariant is the only thing making
1997 /// `retarget_current`'s fork-point shortcut sound, and a drift in it is
1998 /// silent (a later `<C-r>` restores another branch's text, no error).
1999 ///
2000 /// One tree walked through every such site in turn: `new`, `push`,
2001 /// `undo_step`, `redo_step`, `seq_earlier_step`/`seq_later_step` (i.e.
2002 /// `retarget_current`, including a landing that crosses to a sibling
2003 /// branch), `clear_redo`, `cap` (both the off-path-leaf and the
2004 /// root-promotion prune), `pop_committed`, `from_serializable` and
2005 /// `clear_all`.
2006 #[test]
2007 fn path_flags_track_the_root_to_current_walk() {
2008 let mut t = UndoTree::new(ropey::Rope::from_str("R"));
2009 t.assert_path_invariant("new");
2010
2011 // Main line: R -> A -> B -> C.
2012 for s in ["R", "A", "B"] {
2013 t.push(entry(s));
2014 t.assert_path_invariant("push");
2015 }
2016 // Back down two, forking a sibling branch off the middle.
2017 let (r, c, m) = live("C");
2018 assert!(t.undo_step(r, c, m).is_some());
2019 t.assert_path_invariant("undo_step");
2020 let (r, c, m) = live("B");
2021 assert!(t.undo_step(r, c, m).is_some());
2022 t.assert_path_invariant("undo_step");
2023 let (r, c, m) = live("A");
2024 assert!(t.redo_step(r, c, m).is_some());
2025 t.assert_path_invariant("redo_step");
2026 let (r, c, m) = live("B");
2027 assert!(t.undo_step(r, c, m).is_some());
2028 t.push(entry("A")); // A -> X, a second branch under A
2029 t.assert_path_invariant("push forking a branch");
2030
2031 // `g-` / `g+` walk the whole tree by seq, so they land on nodes in the
2032 // OTHER branch — the case that actually exercises the fork point rather
2033 // than a parent/child step.
2034 let mut cur = String::from("X");
2035 for _ in 0..6 {
2036 if let Some(e) =
2037 t.seq_earlier_step(ropey::Rope::from_str(&cur), (0, 0), MarkSnapshot::default())
2038 {
2039 cur = e.rope.to_string();
2040 }
2041 t.assert_path_invariant("seq_earlier_step");
2042 }
2043 for _ in 0..6 {
2044 if let Some(e) =
2045 t.seq_later_step(ropey::Rope::from_str(&cur), (0, 0), MarkSnapshot::default())
2046 {
2047 cur = e.rope.to_string();
2048 }
2049 t.assert_path_invariant("seq_later_step");
2050 }
2051
2052 // A serialize round trip has to rebuild the flags from scratch.
2053 let round = UndoTree::from_serializable(&t.to_serializable()).expect("round trip");
2054 round.assert_path_invariant("from_serializable");
2055
2056 // Deepen, then prune: `cap` drops off-path leaves first and only then
2057 // promotes the root's on-path child, so both prune shapes run.
2058 for s in ["P", "Q", "S", "T"] {
2059 t.push(entry(s));
2060 }
2061 t.assert_path_invariant("pushes before cap");
2062 assert!(t.live_count() > 3, "cap has something to prune");
2063 t.cap(2);
2064 t.assert_path_invariant("cap");
2065
2066 assert!(t.pop_committed());
2067 t.assert_path_invariant("pop_committed");
2068
2069 t.push(entry("Z"));
2070 t.clear_redo();
2071 t.assert_path_invariant("clear_redo");
2072
2073 t.clear_all();
2074 t.assert_path_invariant("clear_all");
2075 }
2076}
2077
2078// ─── Phase 3a delta-storage tests ─────────────────────────────────────────────
2079//
2080// Correctness of the reversible delta and the warm/cold materialization is
2081// where text gets silently corrupted, so these lean hard on it: exact diff
2082// round-trips over random (incl. multi-byte) content, every node reconstructing
2083// identically warm and cold, and a random op stream cross-checked against a
2084// full-snapshot reference model kept alongside. All randomness is a deterministic
2085// xorshift seeded from a fixed constant — never `SystemTime`/entropy — so a
2086// failure reproduces exactly.
2087#[cfg(test)]
2088mod delta_tests {
2089 use super::*;
2090
2091 /// Iteration count for the randomized differential loops below, capped
2092 /// hard under miri.
2093 ///
2094 /// These loops are worth thousands of steps on a normal run: their value
2095 /// is statistical, shaking out logic bugs in the diff / keyframe code from
2096 /// a random op mix. That is a property of *executing* them, and miri
2097 /// interprets instead — six loops totalling ~22 300 iterations is the bulk
2098 /// of the weekly miri job's runtime, enough to push it past an hour.
2099 ///
2100 /// miri is there to catch UB, and UB shows up on the code *paths*, not on
2101 /// the thousandth repetition of one — so a short pass covers what miri can
2102 /// actually detect. Normal runs are untouched and keep the full count.
2103 fn stress_iters(n: usize) -> usize {
2104 if cfg!(miri) { n.min(50) } else { n }
2105 }
2106
2107 /// Deterministic xorshift64* PRNG, fixed-seeded so runs are reproducible.
2108 struct Rng(u64);
2109 impl Rng {
2110 fn new(seed: u64) -> Self {
2111 // xorshift needs a non-zero state.
2112 Self(if seed == 0 {
2113 0x9E37_79B9_7F4A_7C15
2114 } else {
2115 seed
2116 })
2117 }
2118 fn next_u64(&mut self) -> u64 {
2119 let mut x = self.0;
2120 x ^= x >> 12;
2121 x ^= x << 25;
2122 x ^= x >> 27;
2123 self.0 = x;
2124 x.wrapping_mul(0x2545_F491_4F6C_DD1D)
2125 }
2126 fn below(&mut self, n: usize) -> usize {
2127 (self.next_u64() % n as u64) as usize
2128 }
2129 }
2130
2131 /// A random char-granular mutation of `s`: insert, delete, or replace a
2132 /// span, drawing from an alphabet that mixes ASCII, accented, CJK, and
2133 /// emoji so multi-byte boundaries are exercised.
2134 fn mutate(s: &str, rng: &mut Rng) -> String {
2135 const ALPHABET: [char; 10] = ['a', 'b', '\n', 'é', '日', '本', '🎉', '語', 'x', 'z'];
2136 let chars: Vec<char> = s.chars().collect();
2137 let pick = |rng: &mut Rng| ALPHABET[rng.below(ALPHABET.len())];
2138 match rng.below(3) {
2139 0 => {
2140 let pos = rng.below(chars.len() + 1);
2141 let mut v = chars.clone();
2142 v.insert(pos, pick(rng));
2143 v.into_iter().collect()
2144 }
2145 1 if !chars.is_empty() => {
2146 let pos = rng.below(chars.len());
2147 let mut v = chars.clone();
2148 v.remove(pos);
2149 v.into_iter().collect()
2150 }
2151 _ => {
2152 if chars.is_empty() {
2153 return pick(rng).to_string();
2154 }
2155 let a = rng.below(chars.len());
2156 let b = (a + rng.below(chars.len() - a + 1)).min(chars.len());
2157 let mut v = chars[..a].to_vec();
2158 v.push(pick(rng));
2159 v.extend_from_slice(&chars[b..]);
2160 v.into_iter().collect()
2161 }
2162 }
2163 }
2164
2165 fn entry_str(s: &str) -> UndoEntry {
2166 UndoEntry {
2167 rope: ropey::Rope::from_str(s),
2168 cursor: (0, 0),
2169 timestamp: SystemTime::now(),
2170 marks: MarkSnapshot::default(),
2171 }
2172 }
2173
2174 // ── (0) differential oracle: the pre-chunk-walk `diff` ────────────────────
2175 //
2176 // The original implementation, verbatim, materializing BOTH ropes with
2177 // `to_string()` before scanning bytes. `diff` was rewritten to walk chunks
2178 // instead (no full materialization); this is the semantic pin — the two must
2179 // agree on the EXACT `Delta` for every input, not merely round-trip.
2180
2181 fn diff_reference(parent: &ropey::Rope, child: &ropey::Rope) -> Delta {
2182 let a = parent.to_string();
2183 let b = child.to_string();
2184 let ab = a.as_bytes();
2185 let bb = b.as_bytes();
2186
2187 let max_pre = ab.len().min(bb.len());
2188 let mut pre = 0;
2189 while pre < max_pre && ab[pre] == bb[pre] {
2190 pre += 1;
2191 }
2192 while pre > 0 && !a.is_char_boundary(pre) {
2193 pre -= 1;
2194 }
2195
2196 let max_suf = max_pre - pre;
2197 let mut suf = 0;
2198 while suf < max_suf && ab[ab.len() - 1 - suf] == bb[bb.len() - 1 - suf] {
2199 suf += 1;
2200 }
2201 let mut a_end = ab.len() - suf;
2202 while a_end < ab.len() && !a.is_char_boundary(a_end) {
2203 a_end += 1;
2204 }
2205 let b_end = bb.len() - (ab.len() - a_end);
2206
2207 Delta {
2208 start: a[..pre].chars().count(),
2209 old: a[pre..a_end].to_string(),
2210 new: b[pre..b_end].to_string(),
2211 }
2212 }
2213
2214 /// Assert the chunk-walking `diff` is byte-identical to `diff_reference`,
2215 /// over BOTH single-chunk ropes and multi-chunk ones (ropey only splits past
2216 /// its ~1 KiB leaf size, so short fixtures alone would never exercise the
2217 /// cross-chunk cursor logic).
2218 #[track_caller]
2219 fn assert_diff_matches_reference(sa: &str, sb: &str) {
2220 let a = ropey::Rope::from_str(sa);
2221 let b = ropey::Rope::from_str(sb);
2222 assert_eq!(
2223 diff(&a, &b),
2224 diff_reference(&a, &b),
2225 "diff != reference for {sa:?} -> {sb:?}"
2226 );
2227 // Same content, but built by insertion so the two ropes have DIFFERENT,
2228 // misaligned chunk layouts — the reference sees only bytes, the walker
2229 // sees chunk seams, and they must still agree.
2230 let mut a2 = ropey::Rope::new();
2231 a2.insert(0, sa);
2232 let mut b2 = ropey::Rope::new();
2233 for (i, c) in sb.chars().enumerate() {
2234 b2.insert_char(i, c);
2235 }
2236 assert_eq!(
2237 diff(&a2, &b2),
2238 diff_reference(&a2, &b2),
2239 "diff != reference (misaligned chunks) for {sa:?} -> {sb:?}"
2240 );
2241 }
2242
2243 #[test]
2244 // Deliberately NOT size-scaled for miri: the documents here are sized to span
2245 // several of ropey's ~1 KB leaf chunks, which is the entire property under
2246 // test, so shrinking them would quietly test something weaker. Running them
2247 // interpreted costs >10 min on its own. hjkl-buffer has no `unsafe`, so miri's
2248 // reach is UB in ropey/std on these code paths — already covered by the ~185
2249 // other tests in this crate that do run under it.
2250 #[cfg_attr(miri, ignore = "multi-chunk documents are too slow interpreted")]
2251 fn diff_matches_reference_on_edge_cases() {
2252 let cases: &[(&str, &str)] = &[
2253 // equal / empty
2254 ("", ""),
2255 ("", "a"),
2256 ("a", ""),
2257 ("abc", "abc"),
2258 ("café🎉", "café🎉"),
2259 // prefix-only / suffix-only change
2260 ("abcdef", "abcdefXY"),
2261 ("abcdefXY", "abcdef"),
2262 ("Xabcdef", "abcdef"),
2263 ("abcdef", "Xabcdef"),
2264 // change at position 0 and at the very end
2265 ("abcdef", "Zbcdef"),
2266 ("abcdef", "abcdeZ"),
2267 // overlapping repeats — prefix and suffix scans would collide
2268 ("abcabc", "abc"),
2269 ("abc", "abcabc"),
2270 ("aaaa", "aa"),
2271 ("aa", "aaaa"),
2272 ("abab", "ababab"),
2273 ("xyxyxy", "xyxy"),
2274 // multi-byte chars sitting exactly on the cut points
2275 ("café", "cafés"),
2276 ("cafés", "café"),
2277 ("café", "cafè"),
2278 ("日本語", "日語"),
2279 ("日本語", "日本本語"),
2280 ("🎉🎉🎉", "🎉🎉"),
2281 ("🎉🎉", "🎉🎉🎉"),
2282 ("🎉x🎉", "🎉y🎉"),
2283 ("a🎉b", "a🎊b"),
2284 ("é", "e"),
2285 ("e", "é"),
2286 ("🎉", ""),
2287 ("", "🎉"),
2288 // byte-level suffix match that is NOT a char boundary: the tails of
2289 // 'é' (0xC3 0xA9) and 'é' share no byte, but 日 (E6 97 A5) vs 旦
2290 // (E6 97 A6) share a two-byte prefix mid-codepoint.
2291 ("日", "旦"),
2292 ("x日y", "x旦y"),
2293 ("語", "誤"),
2294 // long enough to be multi-chunk in both ropes
2295 (
2296 &"the quick brown fox ".repeat(400),
2297 &"the quick brown fox ".repeat(400),
2298 ),
2299 ];
2300 for (sa, sb) in cases {
2301 assert_diff_matches_reference(sa, sb);
2302 }
2303
2304 // Multi-chunk with an edit in the middle / at each end.
2305 let big: String = "the quick brown fox jumps over the lazy dog\n".repeat(200);
2306 let mid = big.len() / 2;
2307 let mut edited = big.clone();
2308 edited.insert(mid, 'Z');
2309 assert_diff_matches_reference(&big, &edited);
2310 assert_diff_matches_reference(&edited, &big);
2311 assert_diff_matches_reference(&big, &format!("Z{big}"));
2312 assert_diff_matches_reference(&big, &format!("{big}Z"));
2313 assert_diff_matches_reference(&big, &big.repeat(2));
2314
2315 // Multi-chunk with multi-byte chars straddling likely leaf seams.
2316 let uni: String = "café 日本語 🎉 αβγ\n".repeat(200);
2317 let umid = uni.len() / 2;
2318 let umid = (0..=umid).rev().find(|i| uni.is_char_boundary(*i)).unwrap();
2319 let mut uedited = uni.clone();
2320 uedited.insert(umid, '🎊');
2321 assert_diff_matches_reference(&uni, &uedited);
2322 assert_diff_matches_reference(&uedited, &uni);
2323 }
2324
2325 #[test]
2326 fn diff_matches_reference_over_random_evolving_content() {
2327 let mut rng = Rng::new(0x0BAD_F00D_1234_5678);
2328 let mut s = String::from("seed café 日本語\n🎉");
2329 for _ in 0..stress_iters(4000) {
2330 let t = mutate(&s, &mut rng);
2331 let a = ropey::Rope::from_str(&s);
2332 let b = ropey::Rope::from_str(&t);
2333 assert_eq!(diff(&a, &b), diff_reference(&a, &b), "{s:?} -> {t:?}");
2334 assert_eq!(diff(&b, &a), diff_reference(&b, &a), "{t:?} -> {s:?}");
2335 s = t;
2336 }
2337 }
2338
2339 #[test]
2340 fn diff_matches_reference_on_shared_leaf_clones() {
2341 // The `Arc`-shared-leaf fast path: `child` is a CLONE of `parent` plus
2342 // one edit, so most chunks are pointer-identical. Exercised at several
2343 // edit positions across a multi-chunk rope, plus deletes and the
2344 // degenerate no-op clone.
2345 let base: String = "the quick brown fox jumps over the lazy dog\n".repeat(300);
2346 let parent = ropey::Rope::from_str(&base);
2347 assert_eq!(
2348 diff(&parent, &parent.clone()),
2349 diff_reference(&parent, &parent.clone())
2350 );
2351 let n = parent.len_chars();
2352 for at in [0, 1, n / 4, n / 2, n - 1, n] {
2353 let mut child = parent.clone();
2354 child.insert_char(at, '𝄞');
2355 assert_eq!(
2356 diff(&parent, &child),
2357 diff_reference(&parent, &child),
2358 "@{at}"
2359 );
2360 assert_eq!(
2361 diff(&child, &parent),
2362 diff_reference(&child, &parent),
2363 "@{at}"
2364 );
2365 }
2366 for at in [0, n / 3, n - 10] {
2367 let mut child = parent.clone();
2368 child.remove(at..at + 5);
2369 assert_eq!(
2370 diff(&parent, &child),
2371 diff_reference(&parent, &child),
2372 "-{at}"
2373 );
2374 assert_eq!(
2375 diff(&child, &parent),
2376 diff_reference(&child, &parent),
2377 "-{at}"
2378 );
2379 }
2380 }
2381
2382 #[test]
2383 // Same reasoning as `diff_matches_reference_on_edge_cases`: the 300-line base
2384 // document exists to force multi-chunk ropes, so it is not size-scaled and the
2385 // test is skipped under miri rather than weakened.
2386 #[cfg_attr(miri, ignore = "multi-chunk documents are too slow interpreted")]
2387 fn diff_matches_reference_over_random_multi_chunk_pairs() {
2388 // Random pairs built from a multi-chunk corpus, so chunk seams land in
2389 // arbitrary places relative to the common prefix/suffix.
2390 let mut rng = Rng::new(0xF00D_BEEF_0BAD_C0DE);
2391 let units = ["ab", "café ", "日本語", "🎉", "\n", "x", "語日", "é"];
2392 let build = |rng: &mut Rng| -> String {
2393 let mut s = String::new();
2394 for _ in 0..rng.below(400) {
2395 s.push_str(units[rng.below(units.len())]);
2396 }
2397 s
2398 };
2399 for _ in 0..stress_iters(300) {
2400 let sa = build(&mut rng);
2401 // Half the pairs share a long common prefix/suffix with `sa`.
2402 let sb = if rng.below(2) == 0 {
2403 build(&mut rng)
2404 } else {
2405 let mut t = sa.clone();
2406 if !t.is_empty() {
2407 let cut = rng.below(t.chars().count() + 1);
2408 let byte = t.char_indices().nth(cut).map_or(t.len(), |(i, _)| i);
2409 t.insert_str(byte, "🎊zz");
2410 }
2411 t
2412 };
2413 let a = ropey::Rope::from_str(&sa);
2414 let b = ropey::Rope::from_str(&sb);
2415 assert_eq!(diff(&a, &b), diff_reference(&a, &b));
2416 assert_eq!(diff(&b, &a), diff_reference(&b, &a));
2417 }
2418 }
2419
2420 // ── (i) delta round-trip: apply(diff(a,b))==b and apply_inverse==a ────────
2421
2422 #[test]
2423 fn diff_round_trips_over_random_evolving_content() {
2424 let mut rng = Rng::new(0x1234_5678_9ABC_DEF0);
2425 let mut s = String::from("seed café 日本語\n🎉");
2426 for _ in 0..stress_iters(4000) {
2427 let t = mutate(&s, &mut rng);
2428 let a = ropey::Rope::from_str(&s);
2429 let b = ropey::Rope::from_str(&t);
2430 let d = diff(&a, &b);
2431 assert_eq!(
2432 apply_forward(&a, &d).to_string(),
2433 t,
2434 "forward a->b failed (start={}, old={:?}, new={:?})",
2435 d.start,
2436 d.old,
2437 d.new
2438 );
2439 assert_eq!(
2440 apply_inverse(&b, &d).to_string(),
2441 s,
2442 "inverse b->a failed (start={}, old={:?}, new={:?})",
2443 d.start,
2444 d.old,
2445 d.new
2446 );
2447 s = t;
2448 }
2449 }
2450
2451 #[test]
2452 fn diff_round_trips_over_unrelated_pairs() {
2453 // Disjoint corpus pairs (not just single-edit neighbours) so the diff's
2454 // prefix/suffix logic is stressed on wholly different multi-byte text.
2455 let corpus = [
2456 "",
2457 "a",
2458 "café\n日本語\n",
2459 "🎉🎉🎉",
2460 "abcdef",
2461 "日本",
2462 "x\ny\nz\n",
2463 "aXb",
2464 "café",
2465 "語日本",
2466 "\n\n\n",
2467 "🎉x🎉y🎉",
2468 ];
2469 let mut rng = Rng::new(0xDEAD_BEEF_CAFE_1234);
2470 for _ in 0..stress_iters(3000) {
2471 let sa = corpus[rng.below(corpus.len())];
2472 let sb = corpus[rng.below(corpus.len())];
2473 let a = ropey::Rope::from_str(sa);
2474 let b = ropey::Rope::from_str(sb);
2475 let d = diff(&a, &b);
2476 assert_eq!(apply_forward(&a, &d).to_string(), sb);
2477 assert_eq!(apply_inverse(&b, &d).to_string(), sa);
2478 }
2479 }
2480
2481 // ── non-ASCII edit → undo → redo round-trip (multi-byte across a leave) ───
2482
2483 #[test]
2484 fn non_ascii_edit_undo_redo_round_trip() {
2485 // Edits land INSIDE multi-byte lines; undo/redo must round-trip the exact
2486 // bytes, proving the char-offset delta never splits a codepoint.
2487 let mut d = Driver::new("café\n日本語\n");
2488 d.edit("cafés\n日本語\n");
2489 d.edit("cafés\n日本語です\n");
2490 d.edit("cafés\n日本語です🎉\n");
2491 assert_eq!(d.undo().as_deref(), Some("cafés\n日本語です\n"));
2492 assert_eq!(d.undo().as_deref(), Some("cafés\n日本語\n"));
2493 assert_eq!(d.undo().as_deref(), Some("café\n日本語\n"));
2494 assert_eq!(d.redo().as_deref(), Some("cafés\n日本語\n"));
2495 assert_eq!(d.redo().as_deref(), Some("cafés\n日本語です\n"));
2496 assert_eq!(d.redo().as_deref(), Some("cafés\n日本語です🎉\n"));
2497 // Cold reconstruction of every node still matches (drop all caches).
2498 assert_warm_equals_cold(&mut d.t);
2499 }
2500
2501 // ── (ii) + (iii) random op stream vs a full-snapshot reference model ──────
2502
2503 #[test]
2504 fn tree_matches_full_snapshot_reference_over_random_ops() {
2505 let mut rng = Rng::new(0x9E37_79B9_7F4A_7C15);
2506 let start = "α\nβγ\n日本🎉\n";
2507 let mut real = UndoTree::new(ropey::Rope::from_str(start));
2508 let mut refr = RefTree::new(start);
2509 let mut live = start.to_string();
2510
2511 for step in 0..stress_iters(6000) {
2512 // Structural predicates stay in lockstep with the reference.
2513 assert_eq!(real.is_at_root(), refr.is_at_root(), "is_at_root @ {step}");
2514 assert_eq!(real.has_redo(), refr.has_redo(), "has_redo @ {step}");
2515 assert_eq!(real.depth_from_root(), refr.depth(), "depth @ {step}");
2516
2517 match rng.below(6) {
2518 0 | 1 => {
2519 // Edit: push the PRE-edit state (engine discipline), then
2520 // mutate the live buffer.
2521 let pre = live.clone();
2522 real.push(entry_str(&pre));
2523 refr.push(&pre);
2524 live = mutate(&live, &mut rng);
2525 }
2526 2 => {
2527 let got = real
2528 .undo_step(
2529 ropey::Rope::from_str(&live),
2530 (0, 0),
2531 MarkSnapshot::default(),
2532 )
2533 .map(|e| e.rope.to_string());
2534 let want = refr.undo_step(&live);
2535 assert_eq!(got, want, "undo @ {step}");
2536 if let Some(c) = got {
2537 live = c;
2538 }
2539 }
2540 3 => {
2541 let got = real
2542 .redo_step(
2543 ropey::Rope::from_str(&live),
2544 (0, 0),
2545 MarkSnapshot::default(),
2546 )
2547 .map(|e| e.rope.to_string());
2548 let want = refr.redo_step(&live);
2549 assert_eq!(got, want, "redo @ {step}");
2550 if let Some(c) = got {
2551 live = c;
2552 }
2553 }
2554 4 => {
2555 let got = real
2556 .seq_earlier_step(
2557 ropey::Rope::from_str(&live),
2558 (0, 0),
2559 MarkSnapshot::default(),
2560 )
2561 .map(|e| e.rope.to_string());
2562 let want = refr.seq_earlier_step(&live);
2563 assert_eq!(got, want, "g- @ {step}");
2564 if let Some(c) = got {
2565 live = c;
2566 }
2567 }
2568 _ => {
2569 let got = real
2570 .seq_later_step(
2571 ropey::Rope::from_str(&live),
2572 (0, 0),
2573 MarkSnapshot::default(),
2574 )
2575 .map(|e| e.rope.to_string());
2576 let want = refr.seq_later_step(&live);
2577 assert_eq!(got, want, "g+ @ {step}");
2578 if let Some(c) = got {
2579 live = c;
2580 }
2581 }
2582 }
2583
2584 // The reference model still rewrites the whole root→target chain on
2585 // every landing, so the two agreeing above already says the shortcut
2586 // reproduces it. Check the structure it relies on directly too: an
2587 // `on_path` drift is what would let the shortcut skip a stale
2588 // ancestor, and it is cheap to catch here at every step.
2589 real.assert_path_invariant(&format!("op @ {step}"));
2590
2591 // (ii) Every so often, assert warm and cold materialization agree
2592 // for every node — a cold-reconstructed node must equal the rope the
2593 // full-snapshot model would have held.
2594 if step % 200 == 0 {
2595 assert_warm_equals_cold(&mut real);
2596 }
2597 }
2598 assert_warm_equals_cold(&mut real);
2599 }
2600
2601 // ── (iv) keyframes: accelerated materialize vs the naive root replay ──────
2602 //
2603 // Keyframes (issue #302) pin a materialized rope every `KEYFRAME_INTERVAL`
2604 // nodes so a cold `g-` replays O(K) deltas instead of O(depth). They are a
2605 // CACHE: whatever they accelerate must be bit-identical to replaying every
2606 // delta from the root base with no cache at all. `materialize_naive` is that
2607 // oracle, in the same spirit as `diff_reference` above.
2608
2609 /// For every live node: the keyframe-accelerated `materialize` must equal the
2610 /// naive root-base replay exactly.
2611 #[track_caller]
2612 fn assert_materialize_matches_naive(t: &mut UndoTree) {
2613 for id in t.live_ids() {
2614 let naive = t.materialize_naive(id).to_string();
2615 let got = t.materialize_for_test(id).to_string();
2616 assert_eq!(got, naive, "accelerated != naive root replay for node {id}");
2617 }
2618 }
2619
2620 /// A linear history `n` states deep (so it crosses many keyframe intervals),
2621 /// plus the expected content of each state indexed by `seq`/depth. Every node
2622 /// is finalized, including the tip.
2623 fn deep_linear_history(n: usize) -> (UndoTree, Vec<String>) {
2624 // Under miri the document is shrunk 10x. `n` is deliberately NOT
2625 // touched: the keyframe ladder, the `n > 4 * KEYFRAME_INTERVAL`
2626 // assertion and every depth-related property stay exactly as they are
2627 // on a normal run — only the per-step rope volume drops. Without this
2628 // a single one of these tests ran for over 22 minutes under miri
2629 // (interpreted, not executed) and stalled the weekly job. The base
2630 // keeps its multi-line and multi-byte content, which is the part that
2631 // matters for the rope/delta paths.
2632 let reps = if cfg!(miri) { 2 } else { 20 };
2633 let base: String =
2634 "the quick brown fox\njumps over the lazy dog\ncafé 日本語 🎉\n".repeat(reps);
2635 let mut t = UndoTree::new(ropey::Rope::from_str(&base));
2636 let mut states = vec![base.clone()];
2637 let mut live = base;
2638 for i in 0..n {
2639 // Engine discipline: commit the PRE-edit state, then mutate.
2640 t.push(entry_str(&live));
2641 live = format!("e{i} {live}");
2642 states.push(live.clone());
2643 }
2644 // Stash the tip's live content so no node is left holding a stale edge.
2645 t.sync_current(ropey::Rope::from_str(&live));
2646 (t, states)
2647 }
2648
2649 #[test]
2650 fn deep_history_walks_back_and_forward_exactly() {
2651 // The `:earlier 9999` / `:later 9999` shape, deep enough that most jumps
2652 // land outside the warm window and go through a keyframe.
2653 // 65 under miri still satisfies the `> 4 * KEYFRAME_INTERVAL` floor
2654 // asserted below, so the walk still crosses four keyframes — the
2655 // property under test. See `deep_linear_history` for why.
2656 let n = if cfg!(miri) { 65 } else { 200 };
2657 assert!(n > 4 * KEYFRAME_INTERVAL);
2658 let (mut t, states) = deep_linear_history(n);
2659
2660 let mut live = states[n].clone();
2661 for want in (0..n).rev() {
2662 let got = t
2663 .seq_earlier_step(
2664 ropey::Rope::from_str(&live),
2665 (0, 0),
2666 MarkSnapshot::default(),
2667 )
2668 .expect("history is deeper than the walk");
2669 live = got.rope.to_string();
2670 assert_eq!(live, states[want], "g- onto seq {want}");
2671 }
2672 assert!(
2673 t.seq_earlier_step(
2674 ropey::Rope::from_str(&live),
2675 (0, 0),
2676 MarkSnapshot::default()
2677 )
2678 .is_none(),
2679 "walk ended at the oldest state"
2680 );
2681 for (seq, want) in states.iter().enumerate().skip(1) {
2682 let got = t
2683 .seq_later_step(
2684 ropey::Rope::from_str(&live),
2685 (0, 0),
2686 MarkSnapshot::default(),
2687 )
2688 .expect("history is deeper than the walk");
2689 live = got.rope.to_string();
2690 assert_eq!(&live, want, "g+ onto seq {seq}");
2691 }
2692 assert_materialize_matches_naive(&mut t);
2693 assert_warm_equals_cold(&mut t);
2694 }
2695
2696 /// `by_seq` must name exactly the live nodes, with the right ids. It is a
2697 /// second source of truth for `g-`/`g+` targets, so drift here silently
2698 /// sends history steps to the wrong state (or reports the end of history
2699 /// early) rather than failing loudly.
2700 ///
2701 /// Checked against a brute-force arena scan — the code `node_below` /
2702 /// `node_above` used before the index existed — after pushes, branch
2703 /// creation, undo/redo, pruning to a node budget, `clear_all`, and a
2704 /// serialize round trip, since those are the paths that allocate and free.
2705 #[test]
2706 fn seq_index_matches_the_arena() {
2707 fn brute(t: &UndoTree) -> std::collections::BTreeMap<u64, NodeId> {
2708 t.nodes
2709 .iter()
2710 .enumerate()
2711 .filter_map(|(id, slot)| slot.as_ref().map(|n| (n.seq, id)))
2712 .collect()
2713 }
2714 fn check(t: &UndoTree, when: &str) {
2715 assert_eq!(t.by_seq, brute(t), "seq index drifted after {when}");
2716 // Every step target agrees with a scan of the arena, which is what
2717 // the index replaced.
2718 for probe in 0..t.next_seq + 1 {
2719 let below = t
2720 .nodes
2721 .iter()
2722 .enumerate()
2723 .filter_map(|(id, s)| s.as_ref().map(|n| (n.seq, id)))
2724 .filter(|&(sq, _)| sq < probe)
2725 .max_by_key(|&(sq, _)| sq)
2726 .map(|(_, id)| id);
2727 let above = t
2728 .nodes
2729 .iter()
2730 .enumerate()
2731 .filter_map(|(id, s)| s.as_ref().map(|n| (n.seq, id)))
2732 .filter(|&(sq, _)| sq > probe)
2733 .min_by_key(|&(sq, _)| sq)
2734 .map(|(_, id)| id);
2735 assert_eq!(
2736 t.node_below(probe),
2737 below,
2738 "node_below({probe}) after {when}"
2739 );
2740 assert_eq!(
2741 t.node_above(probe),
2742 above,
2743 "node_above({probe}) after {when}"
2744 );
2745 }
2746 }
2747
2748 let mk = |text: &str| UndoEntry {
2749 rope: ropey::Rope::from_str(text),
2750 cursor: (0, 0),
2751 timestamp: SystemTime::now(),
2752 marks: MarkSnapshot::default(),
2753 };
2754
2755 let mut t = UndoTree::new(ropey::Rope::from_str("s0"));
2756 check(&t, "new");
2757
2758 for i in 1..=8 {
2759 t.push(mk(&format!("s{i}")));
2760 }
2761 check(&t, "pushes");
2762
2763 // Undo twice then push: forks a branch, frees nothing.
2764 t.undo_step(ropey::Rope::from_str("s8"), (0, 0), MarkSnapshot::default());
2765 t.undo_step(ropey::Rope::from_str("s7"), (0, 0), MarkSnapshot::default());
2766 t.push(mk("branch"));
2767 check(&t, "branch");
2768
2769 // Enforcing a node budget frees through `free` / `free_subtree`.
2770 t.cap(3);
2771 check(&t, "cap");
2772
2773 let round = UndoTree::from_serializable(&t.to_serializable()).expect("round trip");
2774 check(&round, "from_serializable");
2775
2776 t.clear_all();
2777 check(&t, "clear_all");
2778 }
2779
2780 #[test]
2781 fn keyframes_bound_the_cold_replay_distance() {
2782 // 65 still spans four keyframe intervals, which is what makes the
2783 // per-node replay-distance bound below meaningful. See
2784 // `deep_linear_history` for why miri gets a smaller history.
2785 let n = if cfg!(miri) { 65 } else { 200 };
2786 let (mut t, _) = deep_linear_history(n);
2787 // Steady state: the ordinary warm entries have aged out, the keyframes
2788 // are still pinned. Every node must be within one interval of an anchor.
2789 t.drop_warm_caches();
2790 for id in t.live_ids() {
2791 let d = t.replay_distance(id);
2792 assert!(
2793 d < KEYFRAME_INTERVAL,
2794 "node {id} (depth {}) replays {d} deltas, over the keyframe bound",
2795 t.get(id).depth
2796 );
2797 }
2798 // Drop the keyframes too and the bound is gone — proof that it is the
2799 // keyframes doing the bounding and not the warm LRU or the tree shape.
2800 let deepest = *t
2801 .live_ids()
2802 .iter()
2803 .max_by_key(|&&id| t.get(id).depth)
2804 .unwrap();
2805 t.drop_all_caches();
2806 assert!(t.replay_distance(deepest) > KEYFRAME_INTERVAL);
2807 // One materialize off the fully-cold tree re-pins the whole ladder.
2808 t.materialize_for_test(deepest);
2809 t.drop_warm_caches();
2810 for id in t.live_ids() {
2811 assert!(t.replay_distance(id) < KEYFRAME_INTERVAL, "node {id}");
2812 }
2813 }
2814
2815 #[test]
2816 fn keyframe_materialize_matches_naive_over_random_ops() {
2817 // Push-heavy op mix so the tree gets deep enough to cross many keyframe
2818 // intervals, with undo/redo/g-/g+ and periodic `cap` pruning mixed in —
2819 // pruning renumbers nothing but does free nodes and re-root the tree, so
2820 // it is where a stale keyframe would surface as corrupted text.
2821 let mut rng = Rng::new(0x0FF1_CE00_D15E_A5E5);
2822 let start = "α\nβγ\n日本🎉\nthe quick brown fox\n";
2823 let mut t = UndoTree::new(ropey::Rope::from_str(start));
2824 let mut live = start.to_string();
2825
2826 for step in 0..stress_iters(5000) {
2827 match rng.below(10) {
2828 0..=5 => {
2829 let pre = live.clone();
2830 t.push(entry_str(&pre));
2831 live = mutate(&live, &mut rng);
2832 }
2833 6 => {
2834 if let Some(e) = t.undo_step(
2835 ropey::Rope::from_str(&live),
2836 (0, 0),
2837 MarkSnapshot::default(),
2838 ) {
2839 live = e.rope.to_string();
2840 }
2841 }
2842 7 => {
2843 if let Some(e) = t.redo_step(
2844 ropey::Rope::from_str(&live),
2845 (0, 0),
2846 MarkSnapshot::default(),
2847 ) {
2848 live = e.rope.to_string();
2849 }
2850 }
2851 8 => {
2852 if let Some(e) = t.seq_earlier_step(
2853 ropey::Rope::from_str(&live),
2854 (0, 0),
2855 MarkSnapshot::default(),
2856 ) {
2857 live = e.rope.to_string();
2858 }
2859 }
2860 _ => {
2861 if let Some(e) = t.seq_later_step(
2862 ropey::Rope::from_str(&live),
2863 (0, 0),
2864 MarkSnapshot::default(),
2865 ) {
2866 live = e.rope.to_string();
2867 }
2868 }
2869 }
2870 // Whatever the tree hands back must be what the naive replay of the
2871 // node it landed on says — checked every step, cheaply.
2872 let cur = t.current;
2873 assert_eq!(
2874 t.materialize_for_test(cur).to_string(),
2875 t.materialize_naive(cur).to_string(),
2876 "current node diverged @ {step}"
2877 );
2878 t.assert_path_invariant(&format!("op @ {step}"));
2879 if step % 250 == 0 {
2880 assert_materialize_matches_naive(&mut t);
2881 }
2882 if step % 700 == 0 {
2883 t.cap(60);
2884 t.assert_path_invariant(&format!("cap @ {step}"));
2885 }
2886 }
2887 assert_materialize_matches_naive(&mut t);
2888 assert_warm_equals_cold(&mut t);
2889 }
2890
2891 #[test]
2892 fn deserialized_deep_tree_rebuilds_the_keyframe_ladder() {
2893 // Depth is NOT serialized (keyframes are a cache, the on-disk format is
2894 // untouched), so a loaded tree has to recompute it — otherwise every
2895 // cross-session `g-` would be a full replay again.
2896 // 65 still spans four keyframe intervals, so the reloaded tree must
2897 // still rebuild a real ladder rather than a trivial one. See
2898 // `deep_linear_history` for why miri gets a smaller history.
2899 let n = if cfg!(miri) { 65 } else { 100 };
2900 let (t, states) = deep_linear_history(n);
2901 let ser = t.to_serializable();
2902 let mut back = UndoTree::from_serializable(&ser).expect("valid projection");
2903
2904 let deepest = *back
2905 .live_ids()
2906 .iter()
2907 .max_by_key(|&&id| back.get(id).depth)
2908 .unwrap();
2909 assert_eq!(back.get(deepest).depth, n, "depths recomputed on load");
2910 assert_eq!(back.materialize_for_test(deepest).to_string(), states[n]);
2911 assert_materialize_matches_naive(&mut back);
2912
2913 back.drop_warm_caches();
2914 for id in back.live_ids() {
2915 assert!(back.replay_distance(id) < KEYFRAME_INTERVAL, "node {id}");
2916 }
2917 }
2918
2919 /// For every live node: materialize warm, drop all caches, materialize cold,
2920 /// assert identical. Restores nothing else (test-local).
2921 fn assert_warm_equals_cold(t: &mut UndoTree) {
2922 let ids = t.live_ids();
2923 let warm: Vec<String> = ids
2924 .iter()
2925 .map(|&id| t.materialize_for_test(id).to_string())
2926 .collect();
2927 t.drop_all_caches();
2928 for (i, &id) in ids.iter().enumerate() {
2929 let cold = t.materialize_for_test(id).to_string();
2930 assert_eq!(cold, warm[i], "warm != cold for node {id}");
2931 }
2932 }
2933
2934 /// Engine-faithful driver over the real (delta) [`UndoTree`]: mirrors how
2935 /// `editor.rs` pushes the PRE-edit state and restores returned content.
2936 struct Driver {
2937 t: UndoTree,
2938 live: String,
2939 }
2940 impl Driver {
2941 fn new(s: &str) -> Self {
2942 Self {
2943 t: UndoTree::new(ropey::Rope::from_str(s)),
2944 live: s.to_string(),
2945 }
2946 }
2947 fn edit(&mut self, new: &str) {
2948 self.t.push(entry_str(&self.live));
2949 self.live = new.to_string();
2950 }
2951 fn undo(&mut self) -> Option<String> {
2952 let e = self.t.undo_step(
2953 ropey::Rope::from_str(&self.live),
2954 (0, 0),
2955 MarkSnapshot::default(),
2956 )?;
2957 self.live = e.rope.to_string();
2958 Some(self.live.clone())
2959 }
2960 fn redo(&mut self) -> Option<String> {
2961 let e = self.t.redo_step(
2962 ropey::Rope::from_str(&self.live),
2963 (0, 0),
2964 MarkSnapshot::default(),
2965 )?;
2966 self.live = e.rope.to_string();
2967 Some(self.live.clone())
2968 }
2969 }
2970
2971 /// Full-snapshot reference tree — Phase 2b's model (a whole rope per node),
2972 /// the oracle the delta tree is cross-checked against. Content only (cursor /
2973 /// marks / timestamps are covered by the existing tree tests).
2974 struct RefNode {
2975 parent: Option<usize>,
2976 children: Vec<usize>,
2977 last_child: Option<usize>,
2978 content: String,
2979 seq: u64,
2980 }
2981 struct RefTree {
2982 nodes: Vec<Option<RefNode>>,
2983 current: usize,
2984 next_seq: u64,
2985 }
2986 impl RefTree {
2987 fn new(s: &str) -> Self {
2988 let root = RefNode {
2989 parent: None,
2990 children: Vec::new(),
2991 last_child: None,
2992 content: s.to_string(),
2993 seq: 0,
2994 };
2995 Self {
2996 nodes: vec![Some(root)],
2997 current: 0,
2998 next_seq: 1,
2999 }
3000 }
3001 fn get(&self, id: usize) -> &RefNode {
3002 self.nodes[id].as_ref().unwrap()
3003 }
3004 fn get_mut(&mut self, id: usize) -> &mut RefNode {
3005 self.nodes[id].as_mut().unwrap()
3006 }
3007 fn alloc(&mut self, n: RefNode) -> usize {
3008 self.nodes.push(Some(n));
3009 self.nodes.len() - 1
3010 }
3011 fn is_at_root(&self) -> bool {
3012 self.get(self.current).parent.is_none()
3013 }
3014 fn has_redo(&self) -> bool {
3015 self.get(self.current).last_child.is_some()
3016 }
3017 fn depth(&self) -> usize {
3018 let mut d = 0;
3019 let mut n = self.get(self.current).parent;
3020 while let Some(p) = n {
3021 d += 1;
3022 n = self.get(p).parent;
3023 }
3024 d
3025 }
3026 fn push(&mut self, pre: &str) {
3027 let cur = self.current;
3028 self.get_mut(cur).content = pre.to_string();
3029 let seq = self.next_seq;
3030 self.next_seq += 1;
3031 let child = self.alloc(RefNode {
3032 parent: Some(cur),
3033 children: Vec::new(),
3034 last_child: None,
3035 content: pre.to_string(),
3036 seq,
3037 });
3038 let c = self.get_mut(cur);
3039 c.children.push(child);
3040 c.last_child = Some(child);
3041 self.current = child;
3042 }
3043 fn undo_step(&mut self, live: &str) -> Option<String> {
3044 let cur = self.current;
3045 let par = self.get(cur).parent?;
3046 self.get_mut(cur).content = live.to_string();
3047 self.get_mut(par).last_child = Some(cur);
3048 self.current = par;
3049 Some(self.get(par).content.clone())
3050 }
3051 fn redo_step(&mut self, live: &str) -> Option<String> {
3052 let cur = self.current;
3053 let child = self.get(cur).last_child?;
3054 self.get_mut(cur).content = live.to_string();
3055 self.current = child;
3056 Some(self.get(child).content.clone())
3057 }
3058 fn current_seq(&self) -> u64 {
3059 self.get(self.current).seq
3060 }
3061 fn node_below(&self, s: u64) -> Option<usize> {
3062 let mut best: Option<(u64, usize)> = None;
3063 for (id, slot) in self.nodes.iter().enumerate() {
3064 if let Some(n) = slot
3065 && n.seq < s
3066 && best.is_none_or(|(bs, _)| n.seq > bs)
3067 {
3068 best = Some((n.seq, id));
3069 }
3070 }
3071 best.map(|(_, id)| id)
3072 }
3073 fn node_above(&self, s: u64) -> Option<usize> {
3074 let mut best: Option<(u64, usize)> = None;
3075 for (id, slot) in self.nodes.iter().enumerate() {
3076 if let Some(n) = slot
3077 && n.seq > s
3078 && best.is_none_or(|(bs, _)| n.seq < bs)
3079 {
3080 best = Some((n.seq, id));
3081 }
3082 }
3083 best.map(|(_, id)| id)
3084 }
3085 fn retarget(&mut self, target: usize) {
3086 self.current = target;
3087 let mut node = target;
3088 while let Some(p) = self.get(node).parent {
3089 self.get_mut(p).last_child = Some(node);
3090 node = p;
3091 }
3092 }
3093 fn stash_and_move(&mut self, target: usize, live: &str) {
3094 let cur = self.current;
3095 self.get_mut(cur).content = live.to_string();
3096 self.retarget(target);
3097 }
3098 fn seq_earlier_step(&mut self, live: &str) -> Option<String> {
3099 let target = self.node_below(self.current_seq())?;
3100 self.stash_and_move(target, live);
3101 Some(self.get(target).content.clone())
3102 }
3103 fn seq_later_step(&mut self, live: &str) -> Option<String> {
3104 let target = self.node_above(self.current_seq())?;
3105 self.stash_and_move(target, live);
3106 Some(self.get(target).content.clone())
3107 }
3108 }
3109}
3110
3111// ─── Phase 3b serialize/deserialize tests ─────────────────────────────────────
3112//
3113// The undofile is only as trustworthy as this round-trip: a projection that
3114// loses a branch, mislinks a parent, or reconstructs a node's content wrong
3115// would silently corrupt cross-session undo. These build the headline tree
3116// (5 edits, u, u), project it, rebuild, and assert BOTH the per-node content
3117// (keyed by the stable `seq`) and the live walk (`<C-r>` forward, `u` back)
3118// survive the trip.
3119#[cfg(test)]
3120mod serialize_tests {
3121 use super::*;
3122
3123 fn e(text: &str) -> UndoEntry {
3124 UndoEntry {
3125 rope: ropey::Rope::from_str(text),
3126 cursor: (0, 0),
3127 timestamp: SystemTime::now(),
3128 marks: MarkSnapshot::default(),
3129 }
3130 }
3131 fn l(text: &str) -> (ropey::Rope, (usize, usize), MarkSnapshot) {
3132 (ropey::Rope::from_str(text), (0, 0), MarkSnapshot::default())
3133 }
3134
3135 /// The headline tree: root "s0", five edits to live "s5", then `u` twice so
3136 /// `current` sits on "s3" with the forward branch (s4/s5) retained — exactly
3137 /// the state a `:wq` would persist.
3138 fn headline_tree() -> UndoTree {
3139 let mut t = UndoTree::new(ropey::Rope::from_str("s0"));
3140 for pre in ["s0", "s1", "s2", "s3", "s4"] {
3141 t.push(e(pre)); // engine discipline: push the PRE-edit live state
3142 }
3143 let (r, c, m) = l("s5");
3144 t.undo_step(r, c, m); // -> s4
3145 let (r, c, m) = l("s4");
3146 t.undo_step(r, c, m); // -> s3
3147 t.sync_current(ropey::Rope::from_str("s3")); // stash exact live, like save
3148 t
3149 }
3150
3151 /// Every node's content (keyed by `seq`), materialized cold-then-warm.
3152 fn content_by_seq(t: &mut UndoTree) -> std::collections::BTreeMap<u64, String> {
3153 t.live_ids()
3154 .into_iter()
3155 .map(|id| {
3156 let seq = t.get(id).seq;
3157 (seq, t.materialize_for_test(id).to_string())
3158 })
3159 .collect()
3160 }
3161
3162 #[test]
3163 fn round_trip_reproduces_structure_and_content() {
3164 let mut orig = headline_tree();
3165 let cur_seq = orig.current_node_seq();
3166 let ser = orig.to_serializable();
3167 let orig_content = content_by_seq(&mut orig);
3168
3169 let mut back = UndoTree::from_serializable(&ser).expect("valid projection");
3170 assert_eq!(back.current_node_seq(), cur_seq, "current preserved");
3171 assert_eq!(back.next_seq, orig.next_seq, "next_seq preserved");
3172 // Force cold reconstruction (fresh tree has no warm caches) and compare.
3173 assert_eq!(
3174 content_by_seq(&mut back),
3175 orig_content,
3176 "content at every node reproduced"
3177 );
3178 // Six states: s0..s5.
3179 assert_eq!(orig_content.len(), 6);
3180 assert_eq!(orig_content[&3], "s3");
3181 assert_eq!(orig_content[&5], "s5");
3182 }
3183
3184 #[test]
3185 fn deserialized_tree_walks_forward_and_back() {
3186 let ser = headline_tree().to_serializable();
3187 let mut t = UndoTree::from_serializable(&ser).unwrap();
3188 // `<C-r>` twice: s3 -> s4 -> s5 (the retained forward branch).
3189 let (r, c, m) = l("s3");
3190 assert_eq!(t.redo_step(r, c, m).unwrap().rope.to_string(), "s4");
3191 let (r, c, m) = l("s4");
3192 assert_eq!(t.redo_step(r, c, m).unwrap().rope.to_string(), "s5");
3193 // `u` all the way back to the root.
3194 let mut live = "s5".to_string();
3195 for want in ["s4", "s3", "s2", "s1", "s0"] {
3196 let (r, c, m) = l(&live);
3197 assert_eq!(t.undo_step(r, c, m).unwrap().rope.to_string(), want);
3198 live = want.to_string();
3199 }
3200 assert!(t.is_at_root());
3201 }
3202
3203 #[test]
3204 fn from_serializable_rejects_out_of_range_current() {
3205 let mut ser = headline_tree().to_serializable();
3206 ser.current = ser.nodes.len() as u32; // past the end
3207 assert!(UndoTree::from_serializable(&ser).is_none());
3208 }
3209
3210 /// A tree without a root base is structurally invalid outside the swap
3211 /// context (where the body supplies the base). `to_serializable` always
3212 /// emits `Some`; a `None` can only come from the swap writer's dedup, which
3213 /// the swap reader reverses before rebuilding.
3214 #[test]
3215 fn from_serializable_rejects_missing_base() {
3216 let mut ser = headline_tree().to_serializable();
3217 ser.base = None;
3218 assert!(UndoTree::from_serializable(&ser).is_none());
3219 }
3220
3221 /// A `last_child` naming an in-bounds node that is not one of the node's
3222 /// own children is a hand-edit a `to_serializable` file could never
3223 /// produce. Range validation passes it; without the repair the first
3224 /// `<C-r>` from that node would walk into the wrong subtree. The loader
3225 /// repairs rather than rejects (matching the path-chain repair).
3226 #[test]
3227 fn from_serializable_clears_last_child_that_names_a_non_child() {
3228 let mut ser = headline_tree().to_serializable();
3229 let cur = ser.current as usize;
3230 let non_child = (0..ser.nodes.len() as u32)
3231 .find(|&i| i != ser.root && !ser.nodes[cur].children.contains(&i))
3232 .expect("the headline tree has an off-path node");
3233 ser.nodes[cur].last_child = Some(non_child);
3234 let t = UndoTree::from_serializable(&ser).expect("repair, not rejection");
3235 assert_eq!(
3236 t.get(t.current).last_child,
3237 None,
3238 "a last_child that is not a child must be cleared on load"
3239 );
3240 // And the structural validity of every other link survives: the tree
3241 // still walks (a redo from current finds no forward branch).
3242 assert!(t.current_node_seq() > 0);
3243 }
3244
3245 #[test]
3246 fn from_serializable_rejects_non_root_missing_delta() {
3247 let mut ser = headline_tree().to_serializable();
3248 // Blank a non-root node's delta ⇒ structurally invalid ⇒ rejected.
3249 let victim = if ser.root == 0 { 1 } else { 0 };
3250 ser.nodes[victim].delta = None;
3251 assert!(UndoTree::from_serializable(&ser).is_none());
3252 }
3253
3254 /// A parent-link cycle must be rejected at load, not walked at use.
3255 /// `materialize` follows `parent` in an unbounded loop, so a cycle there
3256 /// hangs while growing `path` — this is the only place that can see it.
3257 #[test]
3258 fn from_serializable_rejects_a_parent_link_cycle() {
3259 let mut ser = headline_tree().to_serializable();
3260 assert!(ser.nodes.len() > 2, "fixture needs three nodes");
3261 // Point two non-root nodes at each other, both ways, so the links stay
3262 // mutually consistent and every node stays reachable from the root
3263 // through the list it was already in. Only the single-lister rule
3264 // rejects this.
3265 let (a, b) = match ser.root {
3266 0 => (1, 2),
3267 1 => (0, 2),
3268 _ => (0, 1),
3269 };
3270 ser.nodes[a].parent = Some(b as u32);
3271 ser.nodes[b].parent = Some(a as u32);
3272 ser.nodes[a].children.push(b as u32);
3273 ser.nodes[b].children.push(a as u32);
3274 assert!(UndoTree::from_serializable(&ser).is_none());
3275 }
3276
3277 /// A parent link that the named parent does not mirror as a child leaves
3278 /// the two walks (`children` forward, `parent` back) disagreeing.
3279 #[test]
3280 fn from_serializable_rejects_an_unmirrored_parent_link() {
3281 let mut ser = headline_tree().to_serializable();
3282 let victim = if ser.root == 0 { 1 } else { 0 };
3283 let parent = ser.nodes[victim].parent.expect("non-root") as usize;
3284 ser.nodes[parent].children.retain(|&c| c != victim as u32);
3285 assert!(UndoTree::from_serializable(&ser).is_none());
3286 }
3287
3288 /// A cycle that hangs off no root at all: nodes 1 and 2 name each other
3289 /// both ways, so the lists still partition — reachability from the root is
3290 /// the guard that catches this one. This is the shape that hung
3291 /// `install_recovered_undo_tree` before the loader checked for it.
3292 #[test]
3293 fn from_serializable_rejects_a_cycle_unreachable_from_the_root() {
3294 let d = || {
3295 Some(Delta {
3296 start: 0,
3297 old: String::new(),
3298 new: String::from("x"),
3299 })
3300 };
3301 let n = |parent, children, delta, seq| SerNode {
3302 parent,
3303 children,
3304 last_child: None,
3305 delta,
3306 cursor: (0, 0),
3307 timestamp_unix_ms: 0,
3308 marks: MarkSnapshot::default(),
3309 seq,
3310 };
3311 let ser = SerTree {
3312 base: Some("hello".into()),
3313 nodes: vec![
3314 n(None, vec![], None, 0),
3315 n(Some(2), vec![2], d(), 1),
3316 n(Some(1), vec![1], d(), 2),
3317 ],
3318 root: 0,
3319 current: 1,
3320 next_seq: 3,
3321 };
3322 assert!(UndoTree::from_serializable(&ser).is_none());
3323 }
3324
3325 /// `by_seq` is keyed by `seq`, so a repeat would silently drop a node from
3326 /// the `g-` / `g+` index while leaving it in the arena.
3327 #[test]
3328 fn from_serializable_rejects_a_repeated_seq() {
3329 let mut ser = headline_tree().to_serializable();
3330 let victim = if ser.root == 0 { 1 } else { 0 };
3331 let other = if victim == 0 { 1 } else { 0 };
3332 ser.nodes[victim].seq = ser.nodes[other].seq;
3333 assert!(UndoTree::from_serializable(&ser).is_none());
3334 }
3335
3336 #[test]
3337 fn multibyte_content_survives_round_trip() {
3338 let mut t = UndoTree::new(ropey::Rope::from_str("café\n日本語"));
3339 t.push(e("café\n日本語"));
3340 t.push(e("cafés\n日本語"));
3341 t.sync_current(ropey::Rope::from_str("cafés\n日本語です🎉"));
3342 let want = content_by_seq(&mut t);
3343 let ser = t.to_serializable();
3344 let mut back = UndoTree::from_serializable(&ser).unwrap();
3345 assert_eq!(content_by_seq(&mut back), want);
3346 }
3347}