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}
399
400/// Arena tree of [`UndoNode`]s. Replaces the old `undo_stack`/`redo_stack`
401/// `Vec<UndoEntry>` pair on [`crate::Buffer`]; see the module comment for how
402/// `u`/`<C-r>` (branch-local) and `g-`/`g+` (seq-ordered) map onto it, and how
403/// Phase 3a stores edges as deltas behind a materialization cache.
404#[derive(Debug)]
405pub struct UndoTree {
406 /// Slab; `None` slots are free and recorded in `free`.
407 nodes: Vec<Option<UndoNode>>,
408 /// Reusable slot indices (frees push here, allocs pop here first).
409 free: Vec<NodeId>,
410 /// LRU of ORDINARY node ids with a warm `rope_cache` (root and keyframe-depth
411 /// nodes excluded — they live in `base` / `keyframes`), most-recently-touched
412 /// last. Bounded by [`WARM_CAP`]; `current` is never evicted.
413 warm: Vec<NodeId>,
414 /// Node ids at a keyframe depth whose `rope_cache` is PINNED — the replay
415 /// anchors that bound `materialize` at [`KEYFRAME_INTERVAL`] applies.
416 /// Most-recently-touched last, bounded by [`KEYFRAME_CAP`].
417 keyframes: Vec<NodeId>,
418 root: NodeId,
419 current: NodeId,
420 next_seq: u64,
421}
422
423/// Trim `list` (an LRU, oldest first) down to `cap`, dropping the evicted
424/// nodes' materialized ropes. `current` is never evicted — the live state must
425/// stay available without a replay.
426///
427/// A free function over the pieces rather than a method so it can hold `&mut`
428/// on one arena field and one list at the same time.
429fn evict_to(list: &mut Vec<NodeId>, nodes: &mut [Option<UndoNode>], current: NodeId, cap: usize) {
430 while list.len() > cap {
431 let Some(pos) = list.iter().position(|&n| n != current) else {
432 break;
433 };
434 let victim = list.remove(pos);
435 if let Some(node) = nodes[victim].as_mut() {
436 node.rope_cache = None;
437 }
438 }
439}
440
441impl UndoTree {
442 /// New tree with a single root == current node holding `rope` as its base
443 /// state (the buffer as opened / last saved). The root is always
444 /// materializable from this base.
445 pub(crate) fn new(rope: ropey::Rope) -> Self {
446 let root = UndoNode {
447 parent: None,
448 children: Vec::new(),
449 last_child: None,
450 delta: None,
451 base: Some(rope),
452 rope_cache: None,
453 depth: 0,
454 cursor: (0, 0),
455 timestamp: SystemTime::now(),
456 marks: Arc::default(),
457 seq: 0,
458 };
459 Self {
460 nodes: vec![Some(root)],
461 free: Vec::new(),
462 warm: Vec::new(),
463 keyframes: Vec::new(),
464 root: 0,
465 current: 0,
466 next_seq: 1,
467 }
468 }
469
470 // ── slab helpers ─────────────────────────────────────────────────────────
471
472 fn get(&self, id: NodeId) -> &UndoNode {
473 self.nodes[id].as_ref().expect("live NodeId")
474 }
475
476 fn get_mut(&mut self, id: NodeId) -> &mut UndoNode {
477 self.nodes[id].as_mut().expect("live NodeId")
478 }
479
480 fn alloc(&mut self, node: UndoNode) -> NodeId {
481 if let Some(id) = self.free.pop() {
482 self.nodes[id] = Some(node);
483 id
484 } else {
485 self.nodes.push(Some(node));
486 self.nodes.len() - 1
487 }
488 }
489
490 /// Free a single slot (does NOT recurse into children — callers detach
491 /// links first). Drops the node's delta + materialized cache and purges it
492 /// from both cache LRUs.
493 fn free(&mut self, id: NodeId) {
494 self.nodes[id] = None;
495 self.free.push(id);
496 self.warm.retain(|&n| n != id);
497 self.keyframes.retain(|&n| n != id);
498 }
499
500 // ── materialization (Phase 3a + keyframes) ───────────────────────────────
501
502 /// Is `id` at a keyframe depth, i.e. should its materialized rope be PINNED
503 /// as a replay anchor rather than aged out of the ordinary warm LRU?
504 ///
505 /// The root qualifies arithmetically (depth 0) but is excluded: it carries a
506 /// full `base` and is already an anchor.
507 fn is_keyframe(&self, id: NodeId) -> bool {
508 id != self.root && self.get(id).depth.is_multiple_of(KEYFRAME_INTERVAL)
509 }
510
511 /// Record `id` as freshly materialized. Keyframe-depth nodes go into the
512 /// pinned `keyframes` LRU (bounded by [`KEYFRAME_CAP`]), everything else into
513 /// the ordinary `warm` LRU (bounded by [`WARM_CAP`]). Neither ever evicts
514 /// `current`; the root is skipped entirely (it has no cache, it has `base`).
515 fn touch_warm(&mut self, id: NodeId) {
516 if id == self.root {
517 return;
518 }
519 let (list, cap) = if self.is_keyframe(id) {
520 (&mut self.keyframes, KEYFRAME_CAP)
521 } else {
522 (&mut self.warm, WARM_CAP)
523 };
524 list.retain(|&n| n != id);
525 list.push(id);
526 evict_to(list, &mut self.nodes, self.current, cap);
527 }
528
529 /// Materialize node `id`'s content, warming its cache. Uses the node's own
530 /// cache if present, else the root `base`, else replays forward deltas from
531 /// the nearest materialized ancestor — a warm node, a pinned keyframe, or the
532 /// root. Always terminates: the root carries a base.
533 ///
534 /// Every intermediate along the replay is cached too, not just the target:
535 /// they were computed anyway and a `ropey::Rope` clone is an `Arc` bump, so
536 /// caching them is free — and it is what makes a step-by-step history walk
537 /// (`g-` held down, `:earlier 9999`) pay ONE replay per keyframe interval
538 /// instead of one per step.
539 fn materialize(&mut self, id: NodeId) -> ropey::Rope {
540 if let Some(r) = &self.get(id).rope_cache {
541 return r.clone();
542 }
543 if let Some(base) = &self.get(id).base {
544 return base.clone();
545 }
546 // Walk up to the nearest ancestor that holds content (warm cache, pinned
547 // keyframe, or the root base), recording the path to replay forward.
548 // Bounded by the keyframe spacing whenever the ancestor chain has been
549 // materialized before.
550 let mut path = Vec::new();
551 let base_rope;
552 let mut anchor = id;
553 loop {
554 path.push(anchor);
555 let par = self
556 .get(anchor)
557 .parent
558 .expect("a non-root, non-based node always has a parent");
559 if let Some(r) = &self.get(par).rope_cache {
560 base_rope = r.clone();
561 break;
562 }
563 if let Some(b) = &self.get(par).base {
564 base_rope = b.clone();
565 break;
566 }
567 anchor = par;
568 }
569 let mut rope = base_rope;
570 // `path` is target-first, so replaying in reverse ends on `id` — which
571 // therefore lands last in its LRU and cannot be the eviction picked by
572 // its own `touch_warm`.
573 for &node in path.iter().rev() {
574 let d = self
575 .get(node)
576 .delta
577 .as_ref()
578 .expect("a non-root node always carries its edge delta");
579 rope = apply_forward(&rope, d);
580 self.get_mut(node).rope_cache = Some(rope.clone());
581 self.touch_warm(node);
582 }
583 rope
584 }
585
586 /// Reconstruct node `id`'s restorable [`UndoEntry`] — the byte-for-byte
587 /// equivalent of Phase 2b's `node.snapshot.clone()`.
588 fn entry_of(&mut self, id: NodeId) -> UndoEntry {
589 let rope = self.materialize(id);
590 let n = self.get(id);
591 UndoEntry {
592 rope,
593 cursor: n.cursor,
594 timestamp: n.timestamp,
595 marks: (*n.marks).clone(),
596 }
597 }
598
599 /// Finalize node `id` to hold `rope` as its content, recomputing its edge
600 /// delta (or the root base) and updating cursor/timestamp/marks. A no-op
601 /// diff is skipped when the content is unchanged (the common case on a
602 /// history walk, where only the fields move) — which also avoids
603 /// materializing the parent, keeping the walk cheap.
604 fn set_node_state(
605 &mut self,
606 id: NodeId,
607 rope: ropey::Rope,
608 cursor: (usize, usize),
609 timestamp: SystemTime,
610 marks: Arc<MarkSnapshot>,
611 ) {
612 let is_root = self.get(id).parent.is_none();
613 let unchanged = self.get(id).rope_cache.as_ref() == Some(&rope)
614 || (is_root && self.get(id).base.as_ref() == Some(&rope));
615 {
616 let node = self.get_mut(id);
617 node.cursor = cursor;
618 node.timestamp = timestamp;
619 node.marks = marks;
620 }
621 if unchanged {
622 return;
623 }
624 if is_root {
625 self.get_mut(id).base = Some(rope);
626 // The root is materialized from `base`; keep no stale cache.
627 self.get_mut(id).rope_cache = None;
628 self.warm.retain(|&n| n != id);
629 self.keyframes.retain(|&n| n != id);
630 } else {
631 let par = self.get(id).parent.expect("non-root has a parent");
632 let par_rope = self.materialize(par);
633 let d = diff(&par_rope, &rope);
634 let node = self.get_mut(id);
635 node.delta = Some(d);
636 node.rope_cache = Some(rope);
637 self.touch_warm(id);
638 }
639 }
640
641 /// Free `id` and its whole subtree (iteratively, so a long redo chain can't
642 /// overflow the stack).
643 fn free_subtree(&mut self, id: NodeId) {
644 let mut stack = vec![id];
645 while let Some(n) = stack.pop() {
646 let kids = std::mem::take(&mut self.get_mut(n).children);
647 stack.extend(kids);
648 self.free(n);
649 }
650 }
651
652 // ── read-only queries (mirror the old stack accessors) ───────────────────
653
654 /// `undo_stack.is_empty()` ⇔ `current` has no parent (is the root).
655 pub(crate) fn is_at_root(&self) -> bool {
656 self.get(self.current).parent.is_none()
657 }
658
659 /// `!redo_stack.is_empty()` ⇔ `current` has a forward child.
660 pub(crate) fn has_redo(&self) -> bool {
661 self.get(self.current).last_child.is_some()
662 }
663
664 /// `undo_stack.len()` == number of ancestors of `current` (depth from root).
665 pub(crate) fn depth(&self) -> usize {
666 let mut d = 0;
667 let mut n = self.get(self.current).parent;
668 while let Some(p) = n {
669 d += 1;
670 n = self.get(p).parent;
671 }
672 d
673 }
674
675 /// `undo_stack.last().timestamp` == `current.parent`'s timestamp.
676 pub(crate) fn parent_timestamp(&self) -> Option<SystemTime> {
677 self.get(self.current).parent.map(|p| self.get(p).timestamp)
678 }
679
680 /// `redo_stack.last().timestamp` == `current.last_child`'s timestamp.
681 pub(crate) fn child_timestamp(&self) -> Option<SystemTime> {
682 self.get(self.current)
683 .last_child
684 .map(|c| self.get(c).timestamp)
685 }
686
687 // ── mutations ────────────────────────────────────────────────────────────
688
689 /// Commit a new boundary from `current`, growing the tree (Phase 2b).
690 ///
691 /// `entry` is the pre-edit LIVE state. It is written into `current`'s
692 /// snapshot (making `current` a real, restorable state), then a fresh child
693 /// is APPENDED and becomes the new `current` for the edit about to happen.
694 ///
695 /// Unlike Phase 2a this does NOT drop `current`'s existing children: an edit
696 /// after an undo now forks a new branch and the old forward branch(es) stay
697 /// reachable via `g-`/`g+` and `:undolist`, matching nvim's undo tree. The
698 /// new child is made `last_child` so a subsequent `<C-r>` follows the branch
699 /// just created.
700 pub(crate) fn push(&mut self, entry: UndoEntry) {
701 let cur = self.current;
702 // ONE snapshot serves both the node being left and the fresh child —
703 // this runs per edit, and a deep `MarkSnapshot` copy is up to five
704 // collection allocations.
705 let marks = Arc::new(entry.marks);
706 // Finalize the node being left with the pre-edit live state, recomputing
707 // its edge delta from its parent (or the root base).
708 self.set_node_state(
709 cur,
710 entry.rope.clone(),
711 entry.cursor,
712 entry.timestamp,
713 Arc::clone(&marks),
714 );
715 let seq = self.next_seq;
716 self.next_seq += 1;
717 // Fresh child: identical to `cur` for now (empty edge delta + warm cache
718 // holding the pre-edit rope). Its true post-edit content is finalized on
719 // the way past it (next move) or by the next `push`, at which point the
720 // edge delta is recomputed against `cur`.
721 let child_depth = self.get(cur).depth + 1;
722 let child = self.alloc(UndoNode {
723 parent: Some(cur),
724 children: Vec::new(),
725 last_child: None,
726 delta: Some(Delta::default()),
727 base: None,
728 rope_cache: Some(entry.rope),
729 depth: child_depth,
730 cursor: entry.cursor,
731 timestamp: entry.timestamp,
732 marks,
733 seq,
734 });
735 let cur_node = self.get_mut(cur);
736 // Append (retain old branches); the freshest child is the redo target.
737 cur_node.children.push(child);
738 cur_node.last_child = Some(child);
739 self.current = child;
740 self.touch_warm(child);
741 }
742
743 /// One undo step. `live` is the current buffer state (the node being left);
744 /// it is written into that node but INHERITS the destination (parent)
745 /// timestamp — byte-parity with the old dance, where the pushed redo entry
746 /// took the popped undo entry's timestamp. Returns the parent snapshot to
747 /// restore, or `None` at the root.
748 pub(crate) fn undo_step(
749 &mut self,
750 rope: ropey::Rope,
751 cursor: (usize, usize),
752 marks: MarkSnapshot,
753 ) -> Option<UndoEntry> {
754 let cur = self.current;
755 let par = self.get(cur).parent?;
756 let dest_ts = self.get(par).timestamp;
757 self.set_node_state(cur, rope, cursor, dest_ts, Arc::new(marks));
758 // Redo from the parent must return to the node we just left.
759 self.get_mut(par).last_child = Some(cur);
760 self.current = par;
761 // Hot-path materialization: derive the (possibly cold) parent from the
762 // just-finalized child by one inverse delta apply, so `u` never walks the
763 // ancestor chain even far outside the warm window.
764 if self.get(par).rope_cache.is_none() && self.get(par).base.is_none() {
765 let child_rope = self.get(cur).rope_cache.clone();
766 let child_delta = self.get(cur).delta.clone();
767 if let (Some(cr), Some(d)) = (child_rope, child_delta) {
768 let par_rope = apply_inverse(&cr, &d);
769 self.get_mut(par).rope_cache = Some(par_rope);
770 self.touch_warm(par);
771 }
772 }
773 Some(self.entry_of(par))
774 }
775
776 /// One redo step. Symmetric to [`Self::undo_step`]: `live` is written into
777 /// the node being left (which becomes an undo ancestor) with the
778 /// destination (child) timestamp. Returns the child snapshot to restore, or
779 /// `None` when there is no forward branch.
780 pub(crate) fn redo_step(
781 &mut self,
782 rope: ropey::Rope,
783 cursor: (usize, usize),
784 marks: MarkSnapshot,
785 ) -> Option<UndoEntry> {
786 let cur = self.current;
787 let child = self.get(cur).last_child?;
788 let dest_ts = self.get(child).timestamp;
789 self.set_node_state(cur, rope, cursor, dest_ts, Arc::new(marks));
790 self.current = child;
791 // `cur` is now warm, so materializing the child is one forward apply.
792 Some(self.entry_of(child))
793 }
794
795 // ── seq-ordered tree walk (`g-` / `g+`, `:earlier`/`:later` — Phase 2b) ───
796 //
797 // `u`/`<C-r>` are branch-local (parent / `last_child`); `g-`/`g+` traverse
798 // ALL states by global `seq`, crossing branch boundaries. `g-` restores the
799 // node with the greatest `seq` strictly below `current`'s; `g+` the least
800 // `seq` strictly above. Confirmed against nvim v0.12.4 (`iA<Esc>uiB<Esc>`
801 // then `g-`/`g-g-`/`g-g+` walks empty↔A↔B by change number).
802
803 /// `seq` of the node the buffer currently shows.
804 fn current_seq(&self) -> u64 {
805 self.get(self.current).seq
806 }
807
808 /// Live node with the greatest `seq` strictly below `s` (the `g-` target).
809 fn node_below(&self, s: u64) -> Option<NodeId> {
810 let mut best: Option<(u64, NodeId)> = None;
811 for (id, slot) in self.nodes.iter().enumerate() {
812 if let Some(n) = slot
813 && n.seq < s
814 && best.is_none_or(|(bs, _)| n.seq > bs)
815 {
816 best = Some((n.seq, id));
817 }
818 }
819 best.map(|(_, id)| id)
820 }
821
822 /// Live node with the least `seq` strictly above `s` (the `g+` target).
823 fn node_above(&self, s: u64) -> Option<NodeId> {
824 let mut best: Option<(u64, NodeId)> = None;
825 for (id, slot) in self.nodes.iter().enumerate() {
826 if let Some(n) = slot
827 && n.seq > s
828 && best.is_none_or(|(bs, _)| n.seq < bs)
829 {
830 best = Some((n.seq, id));
831 }
832 }
833 best.map(|(_, id)| id)
834 }
835
836 /// Point `current` at `target` and rewrite `last_child` down the whole
837 /// root→target path, so a later `<C-r>` retraces the branch just landed on
838 /// (nvim parity: landing on a node updates its ancestors' redo direction).
839 fn retarget_current(&mut self, target: NodeId) {
840 self.current = target;
841 let mut node = target;
842 while let Some(p) = self.get(node).parent {
843 self.get_mut(p).last_child = Some(node);
844 node = p;
845 }
846 }
847
848 /// Stash the live buffer state into the node being left (it may be a fresh,
849 /// still-stale leaf), preserving that node's own timestamp, then move.
850 fn stash_and_move(
851 &mut self,
852 target: NodeId,
853 rope: ropey::Rope,
854 cursor: (usize, usize),
855 marks: MarkSnapshot,
856 ) {
857 let cur = self.current;
858 let ts = self.get(cur).timestamp;
859 self.set_node_state(cur, rope, cursor, ts, Arc::new(marks));
860 self.retarget_current(target);
861 }
862
863 /// One `g-` / `:earlier` step: move to the next-lower-`seq` node tree-wide.
864 /// Returns its snapshot to restore, or `None` at the lowest state.
865 pub(crate) fn seq_earlier_step(
866 &mut self,
867 rope: ropey::Rope,
868 cursor: (usize, usize),
869 marks: MarkSnapshot,
870 ) -> Option<UndoEntry> {
871 let target = self.node_below(self.current_seq())?;
872 self.stash_and_move(target, rope, cursor, marks);
873 Some(self.entry_of(target))
874 }
875
876 /// One `g+` / `:later` step: move to the next-higher-`seq` node tree-wide.
877 /// Returns its snapshot to restore, or `None` at the highest state.
878 pub(crate) fn seq_later_step(
879 &mut self,
880 rope: ropey::Rope,
881 cursor: (usize, usize),
882 marks: MarkSnapshot,
883 ) -> Option<UndoEntry> {
884 let target = self.node_above(self.current_seq())?;
885 self.stash_and_move(target, rope, cursor, marks);
886 Some(self.entry_of(target))
887 }
888
889 /// Timestamp of the next-lower-`seq` node (the `:earlier Ns` predicate walks
890 /// the seq order tree-wide, stopping once this dips to/below the cutoff).
891 pub(crate) fn seq_earlier_timestamp(&self) -> Option<SystemTime> {
892 self.node_below(self.current_seq())
893 .map(|id| self.get(id).timestamp)
894 }
895
896 /// Timestamp of the next-higher-`seq` node (the `:later Ns` predicate).
897 pub(crate) fn seq_later_timestamp(&self) -> Option<SystemTime> {
898 self.node_above(self.current_seq())
899 .map(|id| self.get(id).timestamp)
900 }
901
902 /// Leaves of the tree (nodes with no children), each as
903 /// `(seq, depth-from-root, timestamp, is_current)`, sorted by `seq`.
904 /// Drives `:undolist`, which — like nvim — lists only branch leaves.
905 pub(crate) fn leaves(&self) -> Vec<(u64, usize, SystemTime, bool)> {
906 let mut out: Vec<(u64, usize, SystemTime, bool)> = Vec::new();
907 for (id, slot) in self.nodes.iter().enumerate() {
908 let Some(n) = slot else { continue };
909 // The root is the base state (change number 0), never a listed
910 // "change" — like nvim, an untouched buffer lists nothing.
911 if id == self.root || !n.children.is_empty() {
912 continue;
913 }
914 // Depth = number of ancestors (root leaf ⇒ 0).
915 let mut depth = 0;
916 let mut p = n.parent;
917 while let Some(pid) = p {
918 depth += 1;
919 p = self.get(pid).parent;
920 }
921 out.push((n.seq, depth, n.timestamp, id == self.current));
922 }
923 out.sort_by_key(|&(seq, ..)| seq);
924 out
925 }
926
927 /// Number of live nodes (used by [`Self::cap`] as the state budget).
928 fn live_count(&self) -> usize {
929 self.nodes.iter().filter(|n| n.is_some()).count()
930 }
931
932 /// `undo_stack.pop()` — discard the most-recent boundary WITHOUT moving the
933 /// live state. Used by `:s` with zero replacements and by a no-op undo
934 /// group; in both, `current` is the childless leaf the last [`Self::push`]
935 /// created, so reverse that push: drop the leaf, step `current` back to its
936 /// parent (its snapshot equals the unchanged buffer), and restore the
937 /// parent's `last_child`. Retains any sibling branches the push appended to.
938 /// Returns `false` at the root, or if `current` is not a childless leaf
939 /// (nothing safe to pop).
940 pub(crate) fn pop_committed(&mut self) -> bool {
941 let cur = self.current;
942 if !self.get(cur).children.is_empty() {
943 return false;
944 }
945 let Some(par) = self.get(cur).parent else {
946 return false;
947 };
948 let par_node = self.get_mut(par);
949 par_node.children.retain(|&c| c != cur);
950 // The freshest surviving sibling (if any) becomes the redo target again.
951 par_node.last_child = par_node.children.last().copied();
952 self.current = par;
953 // The popped leaf always holds the highest seq (push assigns it last),
954 // so reclaim the seq to keep numbering gapless.
955 if self.get(cur).seq + 1 == self.next_seq {
956 self.next_seq -= 1;
957 }
958 self.free(cur);
959 true
960 }
961
962 /// Node budget (`undolevels`). While the number of undo states (live nodes
963 /// minus the root) exceeds `cap`, prune — branch-aware (Phase 2b):
964 ///
965 /// 1. First drop the lowest-`seq` LEAF that is NOT on the root→`current`
966 /// path — an abandoned branch tip. This never touches `current` or its
967 /// ancestors, so the state you're on and its full undo line survive.
968 /// 2. When only the main line remains (no off-path leaves left), fall back
969 /// to promoting the root's on-path child to root and dropping the old
970 /// root — the Phase 2a root-side prune, which matches nvim's linear
971 /// `undolevels` trimming (oldest states drop first).
972 ///
973 /// `cap == 0` means unlimited (matches the old guard).
974 pub(crate) fn cap(&mut self, cap: usize) {
975 if cap == 0 {
976 return;
977 }
978 // Guard against a pathological loop: at most one prune per live node.
979 let mut budget_iters = self.live_count() + 1;
980 while self.live_count().saturating_sub(1) > cap && budget_iters > 0 {
981 budget_iters -= 1;
982 if let Some(leaf) = self.lowest_offpath_leaf() {
983 self.detach_leaf(leaf);
984 } else if !self.prune_root_side() {
985 break;
986 }
987 }
988 }
989
990 /// Ids on the root→`current` path (inclusive), which pruning must never
991 /// touch. Small (one per undo level), so a `Vec` membership check is fine.
992 fn current_path(&self) -> Vec<NodeId> {
993 let mut path = Vec::new();
994 let mut n = Some(self.current);
995 while let Some(id) = n {
996 path.push(id);
997 n = self.get(id).parent;
998 }
999 path
1000 }
1001
1002 /// Lowest-`seq` leaf that is not on the root→`current` path, if any.
1003 fn lowest_offpath_leaf(&self) -> Option<NodeId> {
1004 let path = self.current_path();
1005 let mut best: Option<(u64, NodeId)> = None;
1006 for (id, slot) in self.nodes.iter().enumerate() {
1007 if let Some(n) = slot
1008 && n.children.is_empty()
1009 && !path.contains(&id)
1010 && best.is_none_or(|(bs, _)| n.seq < bs)
1011 {
1012 best = Some((n.seq, id));
1013 }
1014 }
1015 best.map(|(_, id)| id)
1016 }
1017
1018 /// Unlink `leaf` from its parent and free it (leaf ⇒ no subtree to recurse).
1019 fn detach_leaf(&mut self, leaf: NodeId) {
1020 if let Some(par) = self.get(leaf).parent {
1021 let par_node = self.get_mut(par);
1022 par_node.children.retain(|&c| c != leaf);
1023 if par_node.last_child == Some(leaf) {
1024 par_node.last_child = par_node.children.last().copied();
1025 }
1026 }
1027 self.free(leaf);
1028 }
1029
1030 /// Promote the root's on-path child to the new root and free the old root.
1031 /// Returns `false` when the root is `current` (nothing left to trim).
1032 fn prune_root_side(&mut self) -> bool {
1033 let root = self.root;
1034 if root == self.current {
1035 return false;
1036 }
1037 // The child on the path to `current` (the root always has one here).
1038 let path = self.current_path();
1039 let Some(&child) = self.get(root).children.iter().find(|c| path.contains(c)) else {
1040 return false;
1041 };
1042 // Any OTHER root children are off-path branches; drop them with the root.
1043 let others: Vec<NodeId> = self
1044 .get(root)
1045 .children
1046 .iter()
1047 .copied()
1048 .filter(|&c| c != child)
1049 .collect();
1050 for c in others {
1051 self.free_subtree(c);
1052 }
1053 // The promoted child becomes the new root: materialize it (while the old
1054 // root still anchors the chain) into a full base rope, then drop its
1055 // now-meaningless parent edge. This keeps every delta below it valid.
1056 let base = self.materialize(child);
1057 {
1058 let node = self.get_mut(child);
1059 node.parent = None;
1060 node.base = Some(base);
1061 node.delta = None;
1062 node.rope_cache = None;
1063 }
1064 self.warm.retain(|&n| n != child);
1065 self.keyframes.retain(|&n| n != child);
1066 self.root = child;
1067 self.free(root);
1068 true
1069 }
1070
1071 /// `redo_stack.clear()` — drop `current`'s forward branch.
1072 pub(crate) fn clear_redo(&mut self) {
1073 let cur = self.current;
1074 let kids = std::mem::take(&mut self.get_mut(cur).children);
1075 self.get_mut(cur).last_child = None;
1076 for c in kids {
1077 self.free_subtree(c);
1078 }
1079 }
1080
1081 /// `undo_stack.clear(); redo_stack.clear()` — collapse to a single root ==
1082 /// current node, preserving the live state. Frees every other node.
1083 pub(crate) fn clear_all(&mut self) {
1084 let cur = self.current;
1085 // The survivor becomes a self-contained root: give it a full base rope
1086 // (materialized while the chain is still intact) so it needs no parent.
1087 let base = self.materialize(cur);
1088 for id in 0..self.nodes.len() {
1089 if id != cur && self.nodes[id].is_some() {
1090 self.nodes[id] = None;
1091 self.free.push(id);
1092 }
1093 }
1094 self.warm.clear();
1095 self.keyframes.clear();
1096 let node = self.get_mut(cur);
1097 node.parent = None;
1098 node.children.clear();
1099 node.last_child = None;
1100 node.delta = None;
1101 node.base = Some(base);
1102 node.rope_cache = None;
1103 // The survivor is the new root: restart the depth numbering under it so
1104 // its descendants land on the keyframe ladder from 0 again.
1105 node.depth = 0;
1106 self.root = cur;
1107 }
1108}
1109
1110// ─── Serializable projection (Phase 3b) ───────────────────────────────────────
1111//
1112// The undofile persists the tree as a compact, self-consistent projection: the
1113// root's full base text (String) plus, per node, its edge `delta` and links.
1114// `rope_cache`/`warm` are runtime-only and dropped — every node reconstructs
1115// from the root base + deltas, so the round-trip reproduces identical content
1116// at every node. NodeIds are DENSE in the projection (the live-slab holes are
1117// compacted away and links remapped), so `from_serializable` rebuilds a fresh
1118// arena 1:1 with no free list.
1119
1120/// One node of the serialized undo tree. Mirrors [`UndoNode`] minus the
1121/// runtime-only materialization cache; ids are dense indices into
1122/// [`SerTree::nodes`].
1123#[derive(Debug, Clone, Serialize, Deserialize)]
1124pub struct SerNode {
1125 /// Parent index, `None` only for the root.
1126 pub parent: Option<u32>,
1127 /// Child indices (order preserved; `> 1` ⇒ branch point).
1128 pub children: Vec<u32>,
1129 /// `<C-r>` target child index.
1130 pub last_child: Option<u32>,
1131 /// Reversible edge delta from the parent, `None` only for the root.
1132 pub delta: Option<Delta>,
1133 /// Post-state cursor `(row, col)`.
1134 pub cursor: (u32, u32),
1135 /// Wall-clock creation time, ms since the UNIX epoch.
1136 pub timestamp_unix_ms: u64,
1137 /// Marks / jumplist / changelist snapshot.
1138 pub marks: MarkSnapshot,
1139 /// Global monotonic change number.
1140 pub seq: u64,
1141}
1142
1143/// Serializable projection of an [`UndoTree`] for the undofile. Postcard-encoded
1144/// (non-self-describing, so a schema/version drift surfaces as a parse `Err`
1145/// that the reader discards). See [`UndoTree::to_serializable`] /
1146/// [`UndoTree::from_serializable`].
1147#[derive(Debug, Clone, Serialize, Deserialize)]
1148pub struct SerTree {
1149 /// Root base text (the anchor the delta chain replays from).
1150 pub base: String,
1151 /// Dense node arena (no holes).
1152 pub nodes: Vec<SerNode>,
1153 /// Root index into `nodes`.
1154 pub root: u32,
1155 /// Current (live) index into `nodes`.
1156 pub current: u32,
1157 /// Next `seq` to assign.
1158 pub next_seq: u64,
1159}
1160
1161/// [`SystemTime`] → ms since the UNIX epoch (saturating, pre-epoch ⇒ 0).
1162fn system_time_to_unix_ms(t: SystemTime) -> u64 {
1163 t.duration_since(UNIX_EPOCH)
1164 .map_or(0, |d| d.as_millis() as u64)
1165}
1166
1167/// ms since the UNIX epoch → [`SystemTime`].
1168fn unix_ms_to_system_time(ms: u64) -> SystemTime {
1169 UNIX_EPOCH + Duration::from_millis(ms)
1170}
1171
1172impl UndoTree {
1173 /// `seq` of the current (live) node — the header's `current_seq` for the
1174 /// undofile (the just-saved content per the §6 invariant).
1175 pub(crate) fn current_node_seq(&self) -> u64 {
1176 self.get(self.current).seq
1177 }
1178
1179 /// Materialize the current (live) node's content. Used by the swap
1180 /// recovery consistency guard (docs §6c) to check a deserialized tree
1181 /// agrees with the freshly-recovered buffer text before it's installed.
1182 pub(crate) fn current_content(&mut self) -> ropey::Rope {
1183 let cur = self.current;
1184 self.materialize(cur)
1185 }
1186
1187 /// Stash `rope` into the current node as the live buffer state, preserving
1188 /// that node's own cursor/timestamp/marks. Called just before serializing so
1189 /// the on-disk tree's `current` edge is exact even when `current` is a fresh
1190 /// (still-stale) leaf — the in-session self-heal (first undo/edit stashes
1191 /// live) applied eagerly at save time.
1192 pub(crate) fn sync_current(&mut self, rope: ropey::Rope) {
1193 let cur = self.current;
1194 let (cursor, ts, marks) = {
1195 let n = self.get(cur);
1196 (n.cursor, n.timestamp, n.marks.clone())
1197 };
1198 self.set_node_state(cur, rope, cursor, ts, marks);
1199 }
1200
1201 /// Project the live tree into a serializable, dense form (holes compacted,
1202 /// links remapped). `rope_cache`/`warm` are dropped; the root's `base`
1203 /// carries the anchor text and every non-root node its edge `delta`.
1204 pub(crate) fn to_serializable(&self) -> SerTree {
1205 // Dense remap: old NodeId → new index, in slab order.
1206 let mut map: Vec<Option<u32>> = vec![None; self.nodes.len()];
1207 let mut order: Vec<NodeId> = Vec::new();
1208 for (id, slot) in self.nodes.iter().enumerate() {
1209 if slot.is_some() {
1210 map[id] = Some(order.len() as u32);
1211 order.push(id);
1212 }
1213 }
1214 let remap = |id: NodeId| map[id].expect("live link points at a live node");
1215 let nodes = order
1216 .iter()
1217 .map(|&id| {
1218 let n = self.get(id);
1219 SerNode {
1220 parent: n.parent.map(remap),
1221 children: n.children.iter().map(|&c| remap(c)).collect(),
1222 last_child: n.last_child.map(remap),
1223 delta: n.delta.clone(),
1224 cursor: (n.cursor.0 as u32, n.cursor.1 as u32),
1225 timestamp_unix_ms: system_time_to_unix_ms(n.timestamp),
1226 marks: (*n.marks).clone(),
1227 seq: n.seq,
1228 }
1229 })
1230 .collect();
1231 let base = self
1232 .get(self.root)
1233 .base
1234 .as_ref()
1235 .map(|r| r.to_string())
1236 .unwrap_or_default();
1237 SerTree {
1238 base,
1239 nodes,
1240 root: remap(self.root),
1241 current: remap(self.current),
1242 next_seq: self.next_seq,
1243 }
1244 }
1245
1246 /// Rebuild an arena tree from a projection. Returns `None` on any structural
1247 /// inconsistency (out-of-range link, a non-root node missing its delta, a
1248 /// root carrying one) so a corrupt-but-parseable file degrades to a fresh
1249 /// tree rather than a broken one. The root's content comes from `base`; the
1250 /// current node's is materialized on demand from base + deltas.
1251 pub(crate) fn from_serializable(s: &SerTree) -> Option<Self> {
1252 let len = s.nodes.len();
1253 if len == 0 || s.root as usize >= len || s.current as usize >= len {
1254 return None;
1255 }
1256 // Validate links and the root/non-root delta discipline up front.
1257 for (i, n) in s.nodes.iter().enumerate() {
1258 let is_root = i as u32 == s.root;
1259 match (is_root, &n.delta, &n.parent) {
1260 (true, None, None) => {}
1261 (false, Some(_), Some(_)) => {}
1262 _ => return None,
1263 }
1264 if let Some(p) = n.parent
1265 && p as usize >= len
1266 {
1267 return None;
1268 }
1269 if n.children.iter().any(|&c| c as usize >= len) {
1270 return None;
1271 }
1272 if let Some(c) = n.last_child
1273 && c as usize >= len
1274 {
1275 return None;
1276 }
1277 }
1278 let base = ropey::Rope::from_str(&s.base);
1279 let depths = depths_from_root(s);
1280 let nodes: Vec<Option<UndoNode>> = s
1281 .nodes
1282 .iter()
1283 .enumerate()
1284 .map(|(i, n)| {
1285 let is_root = i as u32 == s.root;
1286 Some(UndoNode {
1287 parent: n.parent.map(|p| p as NodeId),
1288 children: n.children.iter().map(|&c| c as NodeId).collect(),
1289 last_child: n.last_child.map(|c| c as NodeId),
1290 delta: n.delta.clone(),
1291 base: if is_root { Some(base.clone()) } else { None },
1292 rope_cache: None,
1293 depth: depths[i],
1294 cursor: (n.cursor.0 as usize, n.cursor.1 as usize),
1295 timestamp: unix_ms_to_system_time(n.timestamp_unix_ms),
1296 marks: Arc::new(n.marks.clone()),
1297 seq: n.seq,
1298 })
1299 })
1300 .collect();
1301 Some(Self {
1302 nodes,
1303 free: Vec::new(),
1304 warm: Vec::new(),
1305 keyframes: Vec::new(),
1306 root: s.root as NodeId,
1307 current: s.current as NodeId,
1308 next_seq: s.next_seq,
1309 })
1310 }
1311}
1312
1313/// Depth-from-root of every node in a projection, by BFS over `children`.
1314///
1315/// Depth is NOT part of the on-disk format — it is derivable, and the undofile
1316/// deliberately stores only what is not (issue #302: keyframes are an in-memory
1317/// cache, so nothing about them enters `SerTree`). The `seen` guard makes this
1318/// terminate on a malformed file whose links form a cycle; anything unreachable
1319/// from the root keeps depth 0, which at worst places a keyframe oddly.
1320fn depths_from_root(s: &SerTree) -> Vec<usize> {
1321 let mut depths = vec![0usize; s.nodes.len()];
1322 let mut seen = vec![false; s.nodes.len()];
1323 let mut queue = std::collections::VecDeque::new();
1324 seen[s.root as usize] = true;
1325 queue.push_back(s.root as usize);
1326 while let Some(i) = queue.pop_front() {
1327 for &c in &s.nodes[i].children {
1328 let c = c as usize;
1329 if !seen[c] {
1330 seen[c] = true;
1331 depths[c] = depths[i] + 1;
1332 queue.push_back(c);
1333 }
1334 }
1335 }
1336 depths
1337}
1338
1339#[cfg(test)]
1340impl UndoTree {
1341 /// Ids of every live node, for warm-vs-cold materialization checks.
1342 fn live_ids(&self) -> Vec<NodeId> {
1343 (0..self.nodes.len())
1344 .filter(|&i| self.nodes[i].is_some())
1345 .collect()
1346 }
1347
1348 /// Materialize `id` for a test (public wrapper over the private method).
1349 fn materialize_for_test(&mut self, id: NodeId) -> ropey::Rope {
1350 self.materialize(id)
1351 }
1352
1353 /// Evict every cache INCLUDING the pinned keyframes (root keeps its `base`),
1354 /// forcing the next materialization of any node to reconstruct purely from
1355 /// deltas off the root — the strongest cold path there is.
1356 fn drop_all_caches(&mut self) {
1357 for n in self.nodes.iter_mut().flatten() {
1358 n.rope_cache = None;
1359 }
1360 self.warm.clear();
1361 self.keyframes.clear();
1362 }
1363
1364 /// Evict only the ordinary warm LRU, leaving the pinned keyframes — the
1365 /// steady state a deep history walk actually runs in.
1366 fn drop_warm_caches(&mut self) {
1367 for id in std::mem::take(&mut self.warm) {
1368 if let Some(n) = self.nodes[id].as_mut() {
1369 n.rope_cache = None;
1370 }
1371 }
1372 }
1373
1374 /// How many forward delta applies `materialize(id)` would perform right now
1375 /// (0 when `id` already holds content). This is the cost keyframes exist to
1376 /// bound, made assertable.
1377 fn replay_distance(&self, id: NodeId) -> usize {
1378 let mut n = 0;
1379 let mut cur = id;
1380 loop {
1381 let node = self.get(cur);
1382 if node.rope_cache.is_some() || node.base.is_some() {
1383 return n;
1384 }
1385 n += 1;
1386 match node.parent {
1387 Some(p) => cur = p,
1388 None => return n,
1389 }
1390 }
1391 }
1392
1393 /// Reconstruct `id`'s content the naive way: walk to the root and replay
1394 /// every forward delta off the root `base`, consulting NO cache and NO
1395 /// keyframe. The differential oracle for keyframe-accelerated
1396 /// [`Self::materialize`] — the two must agree exactly, always.
1397 fn materialize_naive(&self, id: NodeId) -> ropey::Rope {
1398 let mut path = vec![id];
1399 let mut cur = id;
1400 while let Some(p) = self.get(cur).parent {
1401 path.push(p);
1402 cur = p;
1403 }
1404 let mut rope = self
1405 .get(cur)
1406 .base
1407 .clone()
1408 .expect("the root always carries a base");
1409 // Skip the root itself (it has no edge delta); replay root-ward → target.
1410 for &node in path.iter().rev().skip(1) {
1411 let d = self
1412 .get(node)
1413 .delta
1414 .as_ref()
1415 .expect("a non-root node always carries its edge delta");
1416 rope = apply_forward(&rope, d);
1417 }
1418 rope
1419 }
1420}
1421
1422#[cfg(test)]
1423mod tree_tests {
1424 use super::*;
1425
1426 fn entry(text: &str) -> UndoEntry {
1427 UndoEntry {
1428 rope: ropey::Rope::from_str(text),
1429 cursor: (0, 0),
1430 timestamp: SystemTime::now(),
1431 marks: MarkSnapshot::default(),
1432 }
1433 }
1434
1435 fn live(text: &str) -> (ropey::Rope, (usize, usize), MarkSnapshot) {
1436 (ropey::Rope::from_str(text), (0, 0), MarkSnapshot::default())
1437 }
1438
1439 #[test]
1440 fn fresh_tree_is_root_current_empty() {
1441 let t = UndoTree::new(ropey::Rope::from_str("hello"));
1442 assert!(t.is_at_root());
1443 assert!(!t.has_redo());
1444 assert_eq!(t.depth(), 0);
1445 assert_eq!(t.root, t.current);
1446 }
1447
1448 #[test]
1449 fn push_links_child_and_advances_current() {
1450 let mut t = UndoTree::new(ropey::Rope::from_str("hello"));
1451 let root = t.current;
1452 t.push(entry("hello"));
1453 // root now parents current; current is a fresh leaf.
1454 assert_eq!(t.get(t.current).parent, Some(root));
1455 assert_eq!(t.get(root).last_child, Some(t.current));
1456 assert_eq!(t.get(root).children, vec![t.current]);
1457 assert_eq!(t.depth(), 1);
1458 assert!(!t.has_redo());
1459 assert!(!t.is_at_root());
1460 }
1461
1462 #[test]
1463 fn undo_then_redo_round_trips_links() {
1464 let mut t = UndoTree::new(ropey::Rope::from_str("s0"));
1465 t.push(entry("s0")); // commit s0, current = n1 (live s1)
1466 let n0 = t.root;
1467 let n1 = t.current;
1468 // undo: current -> n0, restores s0.
1469 let (r, c, m) = live("s1");
1470 let restored = t.undo_step(r, c, m).unwrap();
1471 assert_eq!(restored.rope.to_string(), "s0");
1472 assert_eq!(t.current, n0);
1473 assert!(t.has_redo());
1474 assert_eq!(t.get(n0).last_child, Some(n1));
1475 // redo: current -> n1, restores what we left (s1).
1476 let (r, c, m) = live("s0");
1477 let restored = t.redo_step(r, c, m).unwrap();
1478 assert_eq!(restored.rope.to_string(), "s1");
1479 assert_eq!(t.current, n1);
1480 assert!(!t.has_redo());
1481 }
1482
1483 #[test]
1484 fn undo_at_root_and_redo_at_leaf_are_noops() {
1485 let mut t = UndoTree::new(ropey::Rope::from_str("x"));
1486 let (r, c, m) = live("x");
1487 assert!(t.undo_step(r, c, m).is_none());
1488 let (r, c, m) = live("x");
1489 assert!(t.redo_step(r, c, m).is_none());
1490 assert_eq!(t.depth(), 0);
1491 }
1492
1493 #[test]
1494 fn push_retains_forward_branch() {
1495 // Phase 2b: an edit after an undo forks a new branch; the old forward
1496 // branch is NOT dropped and remains reachable by seq.
1497 let mut t = UndoTree::new(ropey::Rope::from_str("s0"));
1498 t.push(entry("A")); // root -> nA (seq1, "A")
1499 let root = t.root;
1500 let na = t.current;
1501 let (r, c, m) = live("A");
1502 t.undo_step(r, c, m); // back to root, nA is the redo child
1503 assert!(t.has_redo());
1504 // A new edit from the root forks a SECOND child (nB, seq2).
1505 t.push(entry("B"));
1506 let nb = t.current;
1507 assert_ne!(nb, na);
1508 // Both branches live: root now has two children.
1509 assert_eq!(t.get(root).children.len(), 2);
1510 assert!(t.get(root).children.contains(&na));
1511 assert!(t.get(root).children.contains(&nb));
1512 // `<C-r>` follows the freshest branch (nB).
1513 assert_eq!(t.get(root).last_child, Some(nb));
1514 // Four live nodes: root + nA + nB + (nB is current/leaf). No leak of nA.
1515 let live = t.nodes.iter().filter(|n| n.is_some()).count();
1516 assert_eq!(live, 3);
1517 }
1518
1519 #[test]
1520 fn seq_walk_crosses_branches() {
1521 // Mirror nvim `iA<Esc>uiB<Esc>` then g-/g+ (buffer starts empty "").
1522 // `push(entry)` writes `entry` into the node being LEFT (its true
1523 // pre-edit content); the fresh leaf holds the live post-edit state only
1524 // once it is stashed on the way past — exactly the engine's discipline.
1525 let mut t = UndoTree::new(ropey::Rope::from_str(""));
1526 t.push(entry("")); // leave root("") -> nA(seq1), live "A"
1527 let (r, c, m) = live("A");
1528 t.undo_step(r, c, m); // stash "A" into nA, back to root("")
1529 t.push(entry("")); // leave root("") -> nB(seq2), branch, live "B"
1530 let nb = t.current;
1531 // At B (seq2). g- -> greatest seq below 2 = seq1 = "A".
1532 let (r, c, m) = live("B");
1533 let a = t.seq_earlier_step(r, c, m).unwrap();
1534 assert_eq!(a.rope.to_string(), "A");
1535 // g- again -> root "".
1536 let (r, c, m) = live("A");
1537 let root_snap = t.seq_earlier_step(r, c, m).unwrap();
1538 assert_eq!(root_snap.rope.to_string(), "");
1539 // g+ -> back up to seq1 "A".
1540 let (r, c, m) = live("");
1541 let a2 = t.seq_later_step(r, c, m).unwrap();
1542 assert_eq!(a2.rope.to_string(), "A");
1543 // g+ -> seq2 "B" (crosses to the other branch).
1544 let (r, c, m) = live("A");
1545 let b = t.seq_later_step(r, c, m).unwrap();
1546 assert_eq!(b.rope.to_string(), "B");
1547 assert_eq!(t.current, nb);
1548 // At the tip: no higher seq.
1549 let (r, c, m) = live("B");
1550 assert!(t.seq_later_step(r, c, m).is_none());
1551 }
1552
1553 #[test]
1554 fn seq_walk_updates_retrace_path() {
1555 // Land on a deep leaf via g-, then u/u and <C-r>/<C-r> must retrace it
1556 // (nvim `iX<Esc>iY<Esc>uiZ<Esc>g-uu<C-r><C-r>`). State labels: root "R".
1557 let mut t = UndoTree::new(ropey::Rope::from_str("R"));
1558 t.push(entry("R")); // leave root("R") -> nX(seq1), live "X"
1559 t.push(entry("X")); // leave nX("X") -> nY(seq2), live "Y"
1560 let (r, c, m) = live("Y");
1561 t.undo_step(r, c, m); // stash "Y" into nY, back to nX("X")
1562 t.push(entry("X")); // leave nX("X") -> nZ(seq3), branch, live "Z"
1563 // g- from Z(seq3) -> nY(seq2) "Y".
1564 let (r, c, m) = live("Z");
1565 let y = t.seq_earlier_step(r, c, m).unwrap();
1566 assert_eq!(y.rope.to_string(), "Y");
1567 // u,u back to root.
1568 let (r, c, m) = live("Y");
1569 t.undo_step(r, c, m);
1570 let (r, c, m) = live("X");
1571 t.undo_step(r, c, m);
1572 assert!(t.is_at_root());
1573 // <C-r>,<C-r> retraces the branch we landed on: root->X->Y.
1574 let (r, c, m) = live("R");
1575 let x = t.redo_step(r, c, m).unwrap();
1576 assert_eq!(x.rope.to_string(), "X");
1577 let (r, c, m) = live("X");
1578 let y2 = t.redo_step(r, c, m).unwrap();
1579 assert_eq!(y2.rope.to_string(), "Y");
1580 }
1581
1582 #[test]
1583 fn leaves_lists_branch_tips_by_seq() {
1584 // root -> nX -> nY -> nW (leaf, seq3, depth3) and nX -> nZ (leaf, seq4,
1585 // depth2). Mirrors nvim `iX iY iW uu iZ`.
1586 let mut t = UndoTree::new(ropey::Rope::from_str(""));
1587 t.push(entry("X"));
1588 t.push(entry("Y"));
1589 t.push(entry("W"));
1590 let (r, c, m) = live("W");
1591 t.undo_step(r, c, m);
1592 let (r, c, m) = live("Y");
1593 t.undo_step(r, c, m); // back to nX
1594 t.push(entry("Z")); // nX -> nZ(seq4)
1595 let leaves = t.leaves();
1596 // Two leaves: W(seq3, depth3) and Z(seq4, depth2). Z is current.
1597 let dims: Vec<(u64, usize, bool)> =
1598 leaves.iter().map(|&(s, d, _, cur)| (s, d, cur)).collect();
1599 assert_eq!(dims, vec![(3, 3, false), (4, 2, true)]);
1600 }
1601
1602 #[test]
1603 fn cap_prunes_oldest_from_root_side() {
1604 let mut t = UndoTree::new(ropey::Rope::from_str("s"));
1605 for _ in 0..5 {
1606 t.push(entry("s"));
1607 }
1608 assert_eq!(t.depth(), 5);
1609 t.cap(3);
1610 assert_eq!(t.depth(), 3);
1611 // Redo side untouched (there is none), current unchanged.
1612 assert!(!t.has_redo());
1613 // Two oldest slots were reclaimed.
1614 assert_eq!(t.free.len(), 2);
1615 }
1616
1617 #[test]
1618 fn cap_drops_offpath_leaf_before_main_line() {
1619 // Fork two abandoned branches off the root, then extend the main line,
1620 // and cap: the lowest-seq OFF-PATH leaf must go first, and `current`
1621 // plus its ancestors must survive.
1622 let mut t = UndoTree::new(ropey::Rope::from_str(""));
1623 t.push(entry("A")); // root -> nA(seq1) [abandoned branch tip]
1624 let na = t.current;
1625 let (r, c, m) = live("A");
1626 t.undo_step(r, c, m);
1627 t.push(entry("B")); // root -> nB(seq2) [abandoned branch tip]
1628 let nb = t.current;
1629 let (r, c, m) = live("B");
1630 t.undo_step(r, c, m);
1631 t.push(entry("C")); // root -> nC(seq3), the live main line
1632 let nc = t.current;
1633 // 4 live nodes (root, nA, nB, nC) => 3 states. Cap to 2.
1634 assert_eq!(t.leaves().len(), 3);
1635 t.cap(2);
1636 // The lowest-seq off-path leaf (nA, seq1) was dropped; current (nC) and
1637 // its ancestor (root) survive, and the newer off-path leaf nB survives.
1638 assert!(t.nodes[na].is_none());
1639 assert!(t.nodes[nb].is_some());
1640 assert_eq!(t.current, nc);
1641 assert!(!t.is_at_root());
1642 assert!(t.get(t.root).children.contains(&nb));
1643 assert!(t.get(t.root).children.contains(&nc));
1644 }
1645
1646 #[test]
1647 fn pop_committed_reverses_last_push() {
1648 let mut t = UndoTree::new(ropey::Rope::from_str("s0"));
1649 t.push(entry("s0")); // depth 1, current = fresh leaf
1650 assert_eq!(t.depth(), 1);
1651 assert!(t.pop_committed());
1652 // The just-pushed leaf is gone; current stepped back to the root.
1653 assert_eq!(t.depth(), 0);
1654 assert!(t.is_at_root());
1655 assert_eq!(t.free.len(), 1);
1656 // Seq reclaimed so the next push is gapless.
1657 assert_eq!(t.next_seq, 1);
1658 }
1659
1660 #[test]
1661 fn pop_committed_retains_sibling_branches() {
1662 // Fork a branch, then a no-op push at the fork must pop cleanly without
1663 // orphaning the sibling branch.
1664 let mut t = UndoTree::new(ropey::Rope::from_str(""));
1665 t.push(entry("A")); // root -> nA(seq1)
1666 let na = t.current;
1667 let (r, c, m) = live("A");
1668 t.undo_step(r, c, m); // back to root
1669 t.push(entry("B")); // root -> nB(seq2); root children [nA, nB]
1670 let root = t.root;
1671 // A spurious no-op push at nB, then pop it.
1672 assert!(t.pop_committed());
1673 // nB is gone, current back at root; nA branch still intact & reachable.
1674 assert!(t.get(root).children.contains(&na));
1675 assert_eq!(t.get(root).children.len(), 1);
1676 assert_eq!(t.current, root);
1677 let live = t.nodes.iter().filter(|n| n.is_some()).count();
1678 assert_eq!(live, 2); // root + nA
1679 }
1680
1681 #[test]
1682 fn pop_committed_at_root_is_false() {
1683 let mut t = UndoTree::new(ropey::Rope::from_str("s"));
1684 assert!(!t.pop_committed());
1685 }
1686
1687 #[test]
1688 fn clear_redo_drops_forward_only() {
1689 let mut t = UndoTree::new(ropey::Rope::from_str("s0"));
1690 t.push(entry("s0"));
1691 let (r, c, m) = live("s1");
1692 t.undo_step(r, c, m);
1693 assert!(t.has_redo());
1694 assert_eq!(t.depth(), 0);
1695 t.clear_redo();
1696 assert!(!t.has_redo());
1697 assert_eq!(t.depth(), 0);
1698 }
1699
1700 #[test]
1701 fn clear_all_collapses_to_single_node() {
1702 let mut t = UndoTree::new(ropey::Rope::from_str("s"));
1703 for _ in 0..3 {
1704 t.push(entry("s"));
1705 }
1706 t.clear_all();
1707 assert!(t.is_at_root());
1708 assert!(!t.has_redo());
1709 assert_eq!(t.depth(), 0);
1710 assert_eq!(t.root, t.current);
1711 }
1712}
1713
1714// ─── Phase 3a delta-storage tests ─────────────────────────────────────────────
1715//
1716// Correctness of the reversible delta and the warm/cold materialization is
1717// where text gets silently corrupted, so these lean hard on it: exact diff
1718// round-trips over random (incl. multi-byte) content, every node reconstructing
1719// identically warm and cold, and a random op stream cross-checked against a
1720// full-snapshot reference model kept alongside. All randomness is a deterministic
1721// xorshift seeded from a fixed constant — never `SystemTime`/entropy — so a
1722// failure reproduces exactly.
1723#[cfg(test)]
1724mod delta_tests {
1725 use super::*;
1726
1727 /// Iteration count for the randomized differential loops below, capped
1728 /// hard under miri.
1729 ///
1730 /// These loops are worth thousands of steps on a normal run: their value
1731 /// is statistical, shaking out logic bugs in the diff / keyframe code from
1732 /// a random op mix. That is a property of *executing* them, and miri
1733 /// interprets instead — six loops totalling ~22 300 iterations is the bulk
1734 /// of the weekly miri job's runtime, enough to push it past an hour.
1735 ///
1736 /// miri is there to catch UB, and UB shows up on the code *paths*, not on
1737 /// the thousandth repetition of one — so a short pass covers what miri can
1738 /// actually detect. Normal runs are untouched and keep the full count.
1739 fn stress_iters(n: usize) -> usize {
1740 if cfg!(miri) { n.min(50) } else { n }
1741 }
1742
1743 /// Deterministic xorshift64* PRNG, fixed-seeded so runs are reproducible.
1744 struct Rng(u64);
1745 impl Rng {
1746 fn new(seed: u64) -> Self {
1747 // xorshift needs a non-zero state.
1748 Self(if seed == 0 {
1749 0x9E37_79B9_7F4A_7C15
1750 } else {
1751 seed
1752 })
1753 }
1754 fn next_u64(&mut self) -> u64 {
1755 let mut x = self.0;
1756 x ^= x >> 12;
1757 x ^= x << 25;
1758 x ^= x >> 27;
1759 self.0 = x;
1760 x.wrapping_mul(0x2545_F491_4F6C_DD1D)
1761 }
1762 fn below(&mut self, n: usize) -> usize {
1763 (self.next_u64() % n as u64) as usize
1764 }
1765 }
1766
1767 /// A random char-granular mutation of `s`: insert, delete, or replace a
1768 /// span, drawing from an alphabet that mixes ASCII, accented, CJK, and
1769 /// emoji so multi-byte boundaries are exercised.
1770 fn mutate(s: &str, rng: &mut Rng) -> String {
1771 const ALPHABET: [char; 10] = ['a', 'b', '\n', 'é', '日', '本', '🎉', '語', 'x', 'z'];
1772 let chars: Vec<char> = s.chars().collect();
1773 let pick = |rng: &mut Rng| ALPHABET[rng.below(ALPHABET.len())];
1774 match rng.below(3) {
1775 0 => {
1776 let pos = rng.below(chars.len() + 1);
1777 let mut v = chars.clone();
1778 v.insert(pos, pick(rng));
1779 v.into_iter().collect()
1780 }
1781 1 if !chars.is_empty() => {
1782 let pos = rng.below(chars.len());
1783 let mut v = chars.clone();
1784 v.remove(pos);
1785 v.into_iter().collect()
1786 }
1787 _ => {
1788 if chars.is_empty() {
1789 return pick(rng).to_string();
1790 }
1791 let a = rng.below(chars.len());
1792 let b = (a + rng.below(chars.len() - a + 1)).min(chars.len());
1793 let mut v = chars[..a].to_vec();
1794 v.push(pick(rng));
1795 v.extend_from_slice(&chars[b..]);
1796 v.into_iter().collect()
1797 }
1798 }
1799 }
1800
1801 fn entry_str(s: &str) -> UndoEntry {
1802 UndoEntry {
1803 rope: ropey::Rope::from_str(s),
1804 cursor: (0, 0),
1805 timestamp: SystemTime::now(),
1806 marks: MarkSnapshot::default(),
1807 }
1808 }
1809
1810 // ── (0) differential oracle: the pre-chunk-walk `diff` ────────────────────
1811 //
1812 // The original implementation, verbatim, materializing BOTH ropes with
1813 // `to_string()` before scanning bytes. `diff` was rewritten to walk chunks
1814 // instead (no full materialization); this is the semantic pin — the two must
1815 // agree on the EXACT `Delta` for every input, not merely round-trip.
1816
1817 fn diff_reference(parent: &ropey::Rope, child: &ropey::Rope) -> Delta {
1818 let a = parent.to_string();
1819 let b = child.to_string();
1820 let ab = a.as_bytes();
1821 let bb = b.as_bytes();
1822
1823 let max_pre = ab.len().min(bb.len());
1824 let mut pre = 0;
1825 while pre < max_pre && ab[pre] == bb[pre] {
1826 pre += 1;
1827 }
1828 while pre > 0 && !a.is_char_boundary(pre) {
1829 pre -= 1;
1830 }
1831
1832 let max_suf = max_pre - pre;
1833 let mut suf = 0;
1834 while suf < max_suf && ab[ab.len() - 1 - suf] == bb[bb.len() - 1 - suf] {
1835 suf += 1;
1836 }
1837 let mut a_end = ab.len() - suf;
1838 while a_end < ab.len() && !a.is_char_boundary(a_end) {
1839 a_end += 1;
1840 }
1841 let b_end = bb.len() - (ab.len() - a_end);
1842
1843 Delta {
1844 start: a[..pre].chars().count(),
1845 old: a[pre..a_end].to_string(),
1846 new: b[pre..b_end].to_string(),
1847 }
1848 }
1849
1850 /// Assert the chunk-walking `diff` is byte-identical to `diff_reference`,
1851 /// over BOTH single-chunk ropes and multi-chunk ones (ropey only splits past
1852 /// its ~1 KiB leaf size, so short fixtures alone would never exercise the
1853 /// cross-chunk cursor logic).
1854 #[track_caller]
1855 fn assert_diff_matches_reference(sa: &str, sb: &str) {
1856 let a = ropey::Rope::from_str(sa);
1857 let b = ropey::Rope::from_str(sb);
1858 assert_eq!(
1859 diff(&a, &b),
1860 diff_reference(&a, &b),
1861 "diff != reference for {sa:?} -> {sb:?}"
1862 );
1863 // Same content, but built by insertion so the two ropes have DIFFERENT,
1864 // misaligned chunk layouts — the reference sees only bytes, the walker
1865 // sees chunk seams, and they must still agree.
1866 let mut a2 = ropey::Rope::new();
1867 a2.insert(0, sa);
1868 let mut b2 = ropey::Rope::new();
1869 for (i, c) in sb.chars().enumerate() {
1870 b2.insert_char(i, c);
1871 }
1872 assert_eq!(
1873 diff(&a2, &b2),
1874 diff_reference(&a2, &b2),
1875 "diff != reference (misaligned chunks) for {sa:?} -> {sb:?}"
1876 );
1877 }
1878
1879 #[test]
1880 // Deliberately NOT size-scaled for miri: the documents here are sized to span
1881 // several of ropey's ~1 KB leaf chunks, which is the entire property under
1882 // test, so shrinking them would quietly test something weaker. Running them
1883 // interpreted costs >10 min on its own. hjkl-buffer has no `unsafe`, so miri's
1884 // reach is UB in ropey/std on these code paths — already covered by the ~185
1885 // other tests in this crate that do run under it.
1886 #[cfg_attr(miri, ignore = "multi-chunk documents are too slow interpreted")]
1887 fn diff_matches_reference_on_edge_cases() {
1888 let cases: &[(&str, &str)] = &[
1889 // equal / empty
1890 ("", ""),
1891 ("", "a"),
1892 ("a", ""),
1893 ("abc", "abc"),
1894 ("café🎉", "café🎉"),
1895 // prefix-only / suffix-only change
1896 ("abcdef", "abcdefXY"),
1897 ("abcdefXY", "abcdef"),
1898 ("Xabcdef", "abcdef"),
1899 ("abcdef", "Xabcdef"),
1900 // change at position 0 and at the very end
1901 ("abcdef", "Zbcdef"),
1902 ("abcdef", "abcdeZ"),
1903 // overlapping repeats — prefix and suffix scans would collide
1904 ("abcabc", "abc"),
1905 ("abc", "abcabc"),
1906 ("aaaa", "aa"),
1907 ("aa", "aaaa"),
1908 ("abab", "ababab"),
1909 ("xyxyxy", "xyxy"),
1910 // multi-byte chars sitting exactly on the cut points
1911 ("café", "cafés"),
1912 ("cafés", "café"),
1913 ("café", "cafè"),
1914 ("日本語", "日語"),
1915 ("日本語", "日本本語"),
1916 ("🎉🎉🎉", "🎉🎉"),
1917 ("🎉🎉", "🎉🎉🎉"),
1918 ("🎉x🎉", "🎉y🎉"),
1919 ("a🎉b", "a🎊b"),
1920 ("é", "e"),
1921 ("e", "é"),
1922 ("🎉", ""),
1923 ("", "🎉"),
1924 // byte-level suffix match that is NOT a char boundary: the tails of
1925 // 'é' (0xC3 0xA9) and 'é' share no byte, but 日 (E6 97 A5) vs 旦
1926 // (E6 97 A6) share a two-byte prefix mid-codepoint.
1927 ("日", "旦"),
1928 ("x日y", "x旦y"),
1929 ("語", "誤"),
1930 // long enough to be multi-chunk in both ropes
1931 (
1932 &"the quick brown fox ".repeat(400),
1933 &"the quick brown fox ".repeat(400),
1934 ),
1935 ];
1936 for (sa, sb) in cases {
1937 assert_diff_matches_reference(sa, sb);
1938 }
1939
1940 // Multi-chunk with an edit in the middle / at each end.
1941 let big: String = "the quick brown fox jumps over the lazy dog\n".repeat(200);
1942 let mid = big.len() / 2;
1943 let mut edited = big.clone();
1944 edited.insert(mid, 'Z');
1945 assert_diff_matches_reference(&big, &edited);
1946 assert_diff_matches_reference(&edited, &big);
1947 assert_diff_matches_reference(&big, &format!("Z{big}"));
1948 assert_diff_matches_reference(&big, &format!("{big}Z"));
1949 assert_diff_matches_reference(&big, &big.repeat(2));
1950
1951 // Multi-chunk with multi-byte chars straddling likely leaf seams.
1952 let uni: String = "café 日本語 🎉 αβγ\n".repeat(200);
1953 let umid = uni.len() / 2;
1954 let umid = (0..=umid).rev().find(|i| uni.is_char_boundary(*i)).unwrap();
1955 let mut uedited = uni.clone();
1956 uedited.insert(umid, '🎊');
1957 assert_diff_matches_reference(&uni, &uedited);
1958 assert_diff_matches_reference(&uedited, &uni);
1959 }
1960
1961 #[test]
1962 fn diff_matches_reference_over_random_evolving_content() {
1963 let mut rng = Rng::new(0x0BAD_F00D_1234_5678);
1964 let mut s = String::from("seed café 日本語\n🎉");
1965 for _ in 0..stress_iters(4000) {
1966 let t = mutate(&s, &mut rng);
1967 let a = ropey::Rope::from_str(&s);
1968 let b = ropey::Rope::from_str(&t);
1969 assert_eq!(diff(&a, &b), diff_reference(&a, &b), "{s:?} -> {t:?}");
1970 assert_eq!(diff(&b, &a), diff_reference(&b, &a), "{t:?} -> {s:?}");
1971 s = t;
1972 }
1973 }
1974
1975 #[test]
1976 fn diff_matches_reference_on_shared_leaf_clones() {
1977 // The `Arc`-shared-leaf fast path: `child` is a CLONE of `parent` plus
1978 // one edit, so most chunks are pointer-identical. Exercised at several
1979 // edit positions across a multi-chunk rope, plus deletes and the
1980 // degenerate no-op clone.
1981 let base: String = "the quick brown fox jumps over the lazy dog\n".repeat(300);
1982 let parent = ropey::Rope::from_str(&base);
1983 assert_eq!(
1984 diff(&parent, &parent.clone()),
1985 diff_reference(&parent, &parent.clone())
1986 );
1987 let n = parent.len_chars();
1988 for at in [0, 1, n / 4, n / 2, n - 1, n] {
1989 let mut child = parent.clone();
1990 child.insert_char(at, '𝄞');
1991 assert_eq!(
1992 diff(&parent, &child),
1993 diff_reference(&parent, &child),
1994 "@{at}"
1995 );
1996 assert_eq!(
1997 diff(&child, &parent),
1998 diff_reference(&child, &parent),
1999 "@{at}"
2000 );
2001 }
2002 for at in [0, n / 3, n - 10] {
2003 let mut child = parent.clone();
2004 child.remove(at..at + 5);
2005 assert_eq!(
2006 diff(&parent, &child),
2007 diff_reference(&parent, &child),
2008 "-{at}"
2009 );
2010 assert_eq!(
2011 diff(&child, &parent),
2012 diff_reference(&child, &parent),
2013 "-{at}"
2014 );
2015 }
2016 }
2017
2018 #[test]
2019 // Same reasoning as `diff_matches_reference_on_edge_cases`: the 300-line base
2020 // document exists to force multi-chunk ropes, so it is not size-scaled and the
2021 // test is skipped under miri rather than weakened.
2022 #[cfg_attr(miri, ignore = "multi-chunk documents are too slow interpreted")]
2023 fn diff_matches_reference_over_random_multi_chunk_pairs() {
2024 // Random pairs built from a multi-chunk corpus, so chunk seams land in
2025 // arbitrary places relative to the common prefix/suffix.
2026 let mut rng = Rng::new(0xF00D_BEEF_0BAD_C0DE);
2027 let units = ["ab", "café ", "日本語", "🎉", "\n", "x", "語日", "é"];
2028 let build = |rng: &mut Rng| -> String {
2029 let mut s = String::new();
2030 for _ in 0..rng.below(400) {
2031 s.push_str(units[rng.below(units.len())]);
2032 }
2033 s
2034 };
2035 for _ in 0..stress_iters(300) {
2036 let sa = build(&mut rng);
2037 // Half the pairs share a long common prefix/suffix with `sa`.
2038 let sb = if rng.below(2) == 0 {
2039 build(&mut rng)
2040 } else {
2041 let mut t = sa.clone();
2042 if !t.is_empty() {
2043 let cut = rng.below(t.chars().count() + 1);
2044 let byte = t.char_indices().nth(cut).map_or(t.len(), |(i, _)| i);
2045 t.insert_str(byte, "🎊zz");
2046 }
2047 t
2048 };
2049 let a = ropey::Rope::from_str(&sa);
2050 let b = ropey::Rope::from_str(&sb);
2051 assert_eq!(diff(&a, &b), diff_reference(&a, &b));
2052 assert_eq!(diff(&b, &a), diff_reference(&b, &a));
2053 }
2054 }
2055
2056 // ── (i) delta round-trip: apply(diff(a,b))==b and apply_inverse==a ────────
2057
2058 #[test]
2059 fn diff_round_trips_over_random_evolving_content() {
2060 let mut rng = Rng::new(0x1234_5678_9ABC_DEF0);
2061 let mut s = String::from("seed café 日本語\n🎉");
2062 for _ in 0..stress_iters(4000) {
2063 let t = mutate(&s, &mut rng);
2064 let a = ropey::Rope::from_str(&s);
2065 let b = ropey::Rope::from_str(&t);
2066 let d = diff(&a, &b);
2067 assert_eq!(
2068 apply_forward(&a, &d).to_string(),
2069 t,
2070 "forward a->b failed (start={}, old={:?}, new={:?})",
2071 d.start,
2072 d.old,
2073 d.new
2074 );
2075 assert_eq!(
2076 apply_inverse(&b, &d).to_string(),
2077 s,
2078 "inverse b->a failed (start={}, old={:?}, new={:?})",
2079 d.start,
2080 d.old,
2081 d.new
2082 );
2083 s = t;
2084 }
2085 }
2086
2087 #[test]
2088 fn diff_round_trips_over_unrelated_pairs() {
2089 // Disjoint corpus pairs (not just single-edit neighbours) so the diff's
2090 // prefix/suffix logic is stressed on wholly different multi-byte text.
2091 let corpus = [
2092 "",
2093 "a",
2094 "café\n日本語\n",
2095 "🎉🎉🎉",
2096 "abcdef",
2097 "日本",
2098 "x\ny\nz\n",
2099 "aXb",
2100 "café",
2101 "語日本",
2102 "\n\n\n",
2103 "🎉x🎉y🎉",
2104 ];
2105 let mut rng = Rng::new(0xDEAD_BEEF_CAFE_1234);
2106 for _ in 0..stress_iters(3000) {
2107 let sa = corpus[rng.below(corpus.len())];
2108 let sb = corpus[rng.below(corpus.len())];
2109 let a = ropey::Rope::from_str(sa);
2110 let b = ropey::Rope::from_str(sb);
2111 let d = diff(&a, &b);
2112 assert_eq!(apply_forward(&a, &d).to_string(), sb);
2113 assert_eq!(apply_inverse(&b, &d).to_string(), sa);
2114 }
2115 }
2116
2117 // ── non-ASCII edit → undo → redo round-trip (multi-byte across a leave) ───
2118
2119 #[test]
2120 fn non_ascii_edit_undo_redo_round_trip() {
2121 // Edits land INSIDE multi-byte lines; undo/redo must round-trip the exact
2122 // bytes, proving the char-offset delta never splits a codepoint.
2123 let mut d = Driver::new("café\n日本語\n");
2124 d.edit("cafés\n日本語\n");
2125 d.edit("cafés\n日本語です\n");
2126 d.edit("cafés\n日本語です🎉\n");
2127 assert_eq!(d.undo().as_deref(), Some("cafés\n日本語です\n"));
2128 assert_eq!(d.undo().as_deref(), Some("cafés\n日本語\n"));
2129 assert_eq!(d.undo().as_deref(), Some("café\n日本語\n"));
2130 assert_eq!(d.redo().as_deref(), Some("cafés\n日本語\n"));
2131 assert_eq!(d.redo().as_deref(), Some("cafés\n日本語です\n"));
2132 assert_eq!(d.redo().as_deref(), Some("cafés\n日本語です🎉\n"));
2133 // Cold reconstruction of every node still matches (drop all caches).
2134 assert_warm_equals_cold(&mut d.t);
2135 }
2136
2137 // ── (ii) + (iii) random op stream vs a full-snapshot reference model ──────
2138
2139 #[test]
2140 fn tree_matches_full_snapshot_reference_over_random_ops() {
2141 let mut rng = Rng::new(0x9E37_79B9_7F4A_7C15);
2142 let start = "α\nβγ\n日本🎉\n";
2143 let mut real = UndoTree::new(ropey::Rope::from_str(start));
2144 let mut refr = RefTree::new(start);
2145 let mut live = start.to_string();
2146
2147 for step in 0..stress_iters(6000) {
2148 // Structural predicates stay in lockstep with the reference.
2149 assert_eq!(real.is_at_root(), refr.is_at_root(), "is_at_root @ {step}");
2150 assert_eq!(real.has_redo(), refr.has_redo(), "has_redo @ {step}");
2151 assert_eq!(real.depth(), refr.depth(), "depth @ {step}");
2152
2153 match rng.below(6) {
2154 0 | 1 => {
2155 // Edit: push the PRE-edit state (engine discipline), then
2156 // mutate the live buffer.
2157 let pre = live.clone();
2158 real.push(entry_str(&pre));
2159 refr.push(&pre);
2160 live = mutate(&live, &mut rng);
2161 }
2162 2 => {
2163 let got = real
2164 .undo_step(
2165 ropey::Rope::from_str(&live),
2166 (0, 0),
2167 MarkSnapshot::default(),
2168 )
2169 .map(|e| e.rope.to_string());
2170 let want = refr.undo_step(&live);
2171 assert_eq!(got, want, "undo @ {step}");
2172 if let Some(c) = got {
2173 live = c;
2174 }
2175 }
2176 3 => {
2177 let got = real
2178 .redo_step(
2179 ropey::Rope::from_str(&live),
2180 (0, 0),
2181 MarkSnapshot::default(),
2182 )
2183 .map(|e| e.rope.to_string());
2184 let want = refr.redo_step(&live);
2185 assert_eq!(got, want, "redo @ {step}");
2186 if let Some(c) = got {
2187 live = c;
2188 }
2189 }
2190 4 => {
2191 let got = real
2192 .seq_earlier_step(
2193 ropey::Rope::from_str(&live),
2194 (0, 0),
2195 MarkSnapshot::default(),
2196 )
2197 .map(|e| e.rope.to_string());
2198 let want = refr.seq_earlier_step(&live);
2199 assert_eq!(got, want, "g- @ {step}");
2200 if let Some(c) = got {
2201 live = c;
2202 }
2203 }
2204 _ => {
2205 let got = real
2206 .seq_later_step(
2207 ropey::Rope::from_str(&live),
2208 (0, 0),
2209 MarkSnapshot::default(),
2210 )
2211 .map(|e| e.rope.to_string());
2212 let want = refr.seq_later_step(&live);
2213 assert_eq!(got, want, "g+ @ {step}");
2214 if let Some(c) = got {
2215 live = c;
2216 }
2217 }
2218 }
2219
2220 // (ii) Every so often, assert warm and cold materialization agree
2221 // for every node — a cold-reconstructed node must equal the rope the
2222 // full-snapshot model would have held.
2223 if step % 200 == 0 {
2224 assert_warm_equals_cold(&mut real);
2225 }
2226 }
2227 assert_warm_equals_cold(&mut real);
2228 }
2229
2230 // ── (iv) keyframes: accelerated materialize vs the naive root replay ──────
2231 //
2232 // Keyframes (issue #302) pin a materialized rope every `KEYFRAME_INTERVAL`
2233 // nodes so a cold `g-` replays O(K) deltas instead of O(depth). They are a
2234 // CACHE: whatever they accelerate must be bit-identical to replaying every
2235 // delta from the root base with no cache at all. `materialize_naive` is that
2236 // oracle, in the same spirit as `diff_reference` above.
2237
2238 /// For every live node: the keyframe-accelerated `materialize` must equal the
2239 /// naive root-base replay exactly.
2240 #[track_caller]
2241 fn assert_materialize_matches_naive(t: &mut UndoTree) {
2242 for id in t.live_ids() {
2243 let naive = t.materialize_naive(id).to_string();
2244 let got = t.materialize_for_test(id).to_string();
2245 assert_eq!(got, naive, "accelerated != naive root replay for node {id}");
2246 }
2247 }
2248
2249 /// A linear history `n` states deep (so it crosses many keyframe intervals),
2250 /// plus the expected content of each state indexed by `seq`/depth. Every node
2251 /// is finalized, including the tip.
2252 fn deep_linear_history(n: usize) -> (UndoTree, Vec<String>) {
2253 // Under miri the document is shrunk 10x. `n` is deliberately NOT
2254 // touched: the keyframe ladder, the `n > 4 * KEYFRAME_INTERVAL`
2255 // assertion and every depth-related property stay exactly as they are
2256 // on a normal run — only the per-step rope volume drops. Without this
2257 // a single one of these tests ran for over 22 minutes under miri
2258 // (interpreted, not executed) and stalled the weekly job. The base
2259 // keeps its multi-line and multi-byte content, which is the part that
2260 // matters for the rope/delta paths.
2261 let reps = if cfg!(miri) { 2 } else { 20 };
2262 let base: String =
2263 "the quick brown fox\njumps over the lazy dog\ncafé 日本語 🎉\n".repeat(reps);
2264 let mut t = UndoTree::new(ropey::Rope::from_str(&base));
2265 let mut states = vec![base.clone()];
2266 let mut live = base;
2267 for i in 0..n {
2268 // Engine discipline: commit the PRE-edit state, then mutate.
2269 t.push(entry_str(&live));
2270 live = format!("e{i} {live}");
2271 states.push(live.clone());
2272 }
2273 // Stash the tip's live content so no node is left holding a stale edge.
2274 t.sync_current(ropey::Rope::from_str(&live));
2275 (t, states)
2276 }
2277
2278 #[test]
2279 fn deep_history_walks_back_and_forward_exactly() {
2280 // The `:earlier 9999` / `:later 9999` shape, deep enough that most jumps
2281 // land outside the warm window and go through a keyframe.
2282 // 65 under miri still satisfies the `> 4 * KEYFRAME_INTERVAL` floor
2283 // asserted below, so the walk still crosses four keyframes — the
2284 // property under test. See `deep_linear_history` for why.
2285 let n = if cfg!(miri) { 65 } else { 200 };
2286 assert!(n > 4 * KEYFRAME_INTERVAL);
2287 let (mut t, states) = deep_linear_history(n);
2288
2289 let mut live = states[n].clone();
2290 for want in (0..n).rev() {
2291 let got = t
2292 .seq_earlier_step(
2293 ropey::Rope::from_str(&live),
2294 (0, 0),
2295 MarkSnapshot::default(),
2296 )
2297 .expect("history is deeper than the walk");
2298 live = got.rope.to_string();
2299 assert_eq!(live, states[want], "g- onto seq {want}");
2300 }
2301 assert!(
2302 t.seq_earlier_step(
2303 ropey::Rope::from_str(&live),
2304 (0, 0),
2305 MarkSnapshot::default()
2306 )
2307 .is_none(),
2308 "walk ended at the oldest state"
2309 );
2310 for (seq, want) in states.iter().enumerate().skip(1) {
2311 let got = t
2312 .seq_later_step(
2313 ropey::Rope::from_str(&live),
2314 (0, 0),
2315 MarkSnapshot::default(),
2316 )
2317 .expect("history is deeper than the walk");
2318 live = got.rope.to_string();
2319 assert_eq!(&live, want, "g+ onto seq {seq}");
2320 }
2321 assert_materialize_matches_naive(&mut t);
2322 assert_warm_equals_cold(&mut t);
2323 }
2324
2325 #[test]
2326 fn keyframes_bound_the_cold_replay_distance() {
2327 // 65 still spans four keyframe intervals, which is what makes the
2328 // per-node replay-distance bound below meaningful. See
2329 // `deep_linear_history` for why miri gets a smaller history.
2330 let n = if cfg!(miri) { 65 } else { 200 };
2331 let (mut t, _) = deep_linear_history(n);
2332 // Steady state: the ordinary warm entries have aged out, the keyframes
2333 // are still pinned. Every node must be within one interval of an anchor.
2334 t.drop_warm_caches();
2335 for id in t.live_ids() {
2336 let d = t.replay_distance(id);
2337 assert!(
2338 d < KEYFRAME_INTERVAL,
2339 "node {id} (depth {}) replays {d} deltas, over the keyframe bound",
2340 t.get(id).depth
2341 );
2342 }
2343 // Drop the keyframes too and the bound is gone — proof that it is the
2344 // keyframes doing the bounding and not the warm LRU or the tree shape.
2345 let deepest = *t
2346 .live_ids()
2347 .iter()
2348 .max_by_key(|&&id| t.get(id).depth)
2349 .unwrap();
2350 t.drop_all_caches();
2351 assert!(t.replay_distance(deepest) > KEYFRAME_INTERVAL);
2352 // One materialize off the fully-cold tree re-pins the whole ladder.
2353 t.materialize_for_test(deepest);
2354 t.drop_warm_caches();
2355 for id in t.live_ids() {
2356 assert!(t.replay_distance(id) < KEYFRAME_INTERVAL, "node {id}");
2357 }
2358 }
2359
2360 #[test]
2361 fn keyframe_materialize_matches_naive_over_random_ops() {
2362 // Push-heavy op mix so the tree gets deep enough to cross many keyframe
2363 // intervals, with undo/redo/g-/g+ and periodic `cap` pruning mixed in —
2364 // pruning renumbers nothing but does free nodes and re-root the tree, so
2365 // it is where a stale keyframe would surface as corrupted text.
2366 let mut rng = Rng::new(0x0FF1_CE00_D15E_A5E5);
2367 let start = "α\nβγ\n日本🎉\nthe quick brown fox\n";
2368 let mut t = UndoTree::new(ropey::Rope::from_str(start));
2369 let mut live = start.to_string();
2370
2371 for step in 0..stress_iters(5000) {
2372 match rng.below(10) {
2373 0..=5 => {
2374 let pre = live.clone();
2375 t.push(entry_str(&pre));
2376 live = mutate(&live, &mut rng);
2377 }
2378 6 => {
2379 if let Some(e) = t.undo_step(
2380 ropey::Rope::from_str(&live),
2381 (0, 0),
2382 MarkSnapshot::default(),
2383 ) {
2384 live = e.rope.to_string();
2385 }
2386 }
2387 7 => {
2388 if let Some(e) = t.redo_step(
2389 ropey::Rope::from_str(&live),
2390 (0, 0),
2391 MarkSnapshot::default(),
2392 ) {
2393 live = e.rope.to_string();
2394 }
2395 }
2396 8 => {
2397 if let Some(e) = t.seq_earlier_step(
2398 ropey::Rope::from_str(&live),
2399 (0, 0),
2400 MarkSnapshot::default(),
2401 ) {
2402 live = e.rope.to_string();
2403 }
2404 }
2405 _ => {
2406 if let Some(e) = t.seq_later_step(
2407 ropey::Rope::from_str(&live),
2408 (0, 0),
2409 MarkSnapshot::default(),
2410 ) {
2411 live = e.rope.to_string();
2412 }
2413 }
2414 }
2415 // Whatever the tree hands back must be what the naive replay of the
2416 // node it landed on says — checked every step, cheaply.
2417 let cur = t.current;
2418 assert_eq!(
2419 t.materialize_for_test(cur).to_string(),
2420 t.materialize_naive(cur).to_string(),
2421 "current node diverged @ {step}"
2422 );
2423 if step % 250 == 0 {
2424 assert_materialize_matches_naive(&mut t);
2425 }
2426 if step % 700 == 0 {
2427 t.cap(60);
2428 }
2429 }
2430 assert_materialize_matches_naive(&mut t);
2431 assert_warm_equals_cold(&mut t);
2432 }
2433
2434 #[test]
2435 fn deserialized_deep_tree_rebuilds_the_keyframe_ladder() {
2436 // Depth is NOT serialized (keyframes are a cache, the on-disk format is
2437 // untouched), so a loaded tree has to recompute it — otherwise every
2438 // cross-session `g-` would be a full replay again.
2439 // 65 still spans four keyframe intervals, so the reloaded tree must
2440 // still rebuild a real ladder rather than a trivial one. See
2441 // `deep_linear_history` for why miri gets a smaller history.
2442 let n = if cfg!(miri) { 65 } else { 100 };
2443 let (t, states) = deep_linear_history(n);
2444 let ser = t.to_serializable();
2445 let mut back = UndoTree::from_serializable(&ser).expect("valid projection");
2446
2447 let deepest = *back
2448 .live_ids()
2449 .iter()
2450 .max_by_key(|&&id| back.get(id).depth)
2451 .unwrap();
2452 assert_eq!(back.get(deepest).depth, n, "depths recomputed on load");
2453 assert_eq!(back.materialize_for_test(deepest).to_string(), states[n]);
2454 assert_materialize_matches_naive(&mut back);
2455
2456 back.drop_warm_caches();
2457 for id in back.live_ids() {
2458 assert!(back.replay_distance(id) < KEYFRAME_INTERVAL, "node {id}");
2459 }
2460 }
2461
2462 /// For every live node: materialize warm, drop all caches, materialize cold,
2463 /// assert identical. Restores nothing else (test-local).
2464 fn assert_warm_equals_cold(t: &mut UndoTree) {
2465 let ids = t.live_ids();
2466 let warm: Vec<String> = ids
2467 .iter()
2468 .map(|&id| t.materialize_for_test(id).to_string())
2469 .collect();
2470 t.drop_all_caches();
2471 for (i, &id) in ids.iter().enumerate() {
2472 let cold = t.materialize_for_test(id).to_string();
2473 assert_eq!(cold, warm[i], "warm != cold for node {id}");
2474 }
2475 }
2476
2477 /// Engine-faithful driver over the real (delta) [`UndoTree`]: mirrors how
2478 /// `editor.rs` pushes the PRE-edit state and restores returned content.
2479 struct Driver {
2480 t: UndoTree,
2481 live: String,
2482 }
2483 impl Driver {
2484 fn new(s: &str) -> Self {
2485 Self {
2486 t: UndoTree::new(ropey::Rope::from_str(s)),
2487 live: s.to_string(),
2488 }
2489 }
2490 fn edit(&mut self, new: &str) {
2491 self.t.push(entry_str(&self.live));
2492 self.live = new.to_string();
2493 }
2494 fn undo(&mut self) -> Option<String> {
2495 let e = self.t.undo_step(
2496 ropey::Rope::from_str(&self.live),
2497 (0, 0),
2498 MarkSnapshot::default(),
2499 )?;
2500 self.live = e.rope.to_string();
2501 Some(self.live.clone())
2502 }
2503 fn redo(&mut self) -> Option<String> {
2504 let e = self.t.redo_step(
2505 ropey::Rope::from_str(&self.live),
2506 (0, 0),
2507 MarkSnapshot::default(),
2508 )?;
2509 self.live = e.rope.to_string();
2510 Some(self.live.clone())
2511 }
2512 }
2513
2514 /// Full-snapshot reference tree — Phase 2b's model (a whole rope per node),
2515 /// the oracle the delta tree is cross-checked against. Content only (cursor /
2516 /// marks / timestamps are covered by the existing tree tests).
2517 struct RefNode {
2518 parent: Option<usize>,
2519 children: Vec<usize>,
2520 last_child: Option<usize>,
2521 content: String,
2522 seq: u64,
2523 }
2524 struct RefTree {
2525 nodes: Vec<Option<RefNode>>,
2526 current: usize,
2527 next_seq: u64,
2528 }
2529 impl RefTree {
2530 fn new(s: &str) -> Self {
2531 let root = RefNode {
2532 parent: None,
2533 children: Vec::new(),
2534 last_child: None,
2535 content: s.to_string(),
2536 seq: 0,
2537 };
2538 Self {
2539 nodes: vec![Some(root)],
2540 current: 0,
2541 next_seq: 1,
2542 }
2543 }
2544 fn get(&self, id: usize) -> &RefNode {
2545 self.nodes[id].as_ref().unwrap()
2546 }
2547 fn get_mut(&mut self, id: usize) -> &mut RefNode {
2548 self.nodes[id].as_mut().unwrap()
2549 }
2550 fn alloc(&mut self, n: RefNode) -> usize {
2551 self.nodes.push(Some(n));
2552 self.nodes.len() - 1
2553 }
2554 fn is_at_root(&self) -> bool {
2555 self.get(self.current).parent.is_none()
2556 }
2557 fn has_redo(&self) -> bool {
2558 self.get(self.current).last_child.is_some()
2559 }
2560 fn depth(&self) -> usize {
2561 let mut d = 0;
2562 let mut n = self.get(self.current).parent;
2563 while let Some(p) = n {
2564 d += 1;
2565 n = self.get(p).parent;
2566 }
2567 d
2568 }
2569 fn push(&mut self, pre: &str) {
2570 let cur = self.current;
2571 self.get_mut(cur).content = pre.to_string();
2572 let seq = self.next_seq;
2573 self.next_seq += 1;
2574 let child = self.alloc(RefNode {
2575 parent: Some(cur),
2576 children: Vec::new(),
2577 last_child: None,
2578 content: pre.to_string(),
2579 seq,
2580 });
2581 let c = self.get_mut(cur);
2582 c.children.push(child);
2583 c.last_child = Some(child);
2584 self.current = child;
2585 }
2586 fn undo_step(&mut self, live: &str) -> Option<String> {
2587 let cur = self.current;
2588 let par = self.get(cur).parent?;
2589 self.get_mut(cur).content = live.to_string();
2590 self.get_mut(par).last_child = Some(cur);
2591 self.current = par;
2592 Some(self.get(par).content.clone())
2593 }
2594 fn redo_step(&mut self, live: &str) -> Option<String> {
2595 let cur = self.current;
2596 let child = self.get(cur).last_child?;
2597 self.get_mut(cur).content = live.to_string();
2598 self.current = child;
2599 Some(self.get(child).content.clone())
2600 }
2601 fn current_seq(&self) -> u64 {
2602 self.get(self.current).seq
2603 }
2604 fn node_below(&self, s: u64) -> Option<usize> {
2605 let mut best: Option<(u64, usize)> = None;
2606 for (id, slot) in self.nodes.iter().enumerate() {
2607 if let Some(n) = slot
2608 && n.seq < s
2609 && best.is_none_or(|(bs, _)| n.seq > bs)
2610 {
2611 best = Some((n.seq, id));
2612 }
2613 }
2614 best.map(|(_, id)| id)
2615 }
2616 fn node_above(&self, s: u64) -> Option<usize> {
2617 let mut best: Option<(u64, usize)> = None;
2618 for (id, slot) in self.nodes.iter().enumerate() {
2619 if let Some(n) = slot
2620 && n.seq > s
2621 && best.is_none_or(|(bs, _)| n.seq < bs)
2622 {
2623 best = Some((n.seq, id));
2624 }
2625 }
2626 best.map(|(_, id)| id)
2627 }
2628 fn retarget(&mut self, target: usize) {
2629 self.current = target;
2630 let mut node = target;
2631 while let Some(p) = self.get(node).parent {
2632 self.get_mut(p).last_child = Some(node);
2633 node = p;
2634 }
2635 }
2636 fn stash_and_move(&mut self, target: usize, live: &str) {
2637 let cur = self.current;
2638 self.get_mut(cur).content = live.to_string();
2639 self.retarget(target);
2640 }
2641 fn seq_earlier_step(&mut self, live: &str) -> Option<String> {
2642 let target = self.node_below(self.current_seq())?;
2643 self.stash_and_move(target, live);
2644 Some(self.get(target).content.clone())
2645 }
2646 fn seq_later_step(&mut self, live: &str) -> Option<String> {
2647 let target = self.node_above(self.current_seq())?;
2648 self.stash_and_move(target, live);
2649 Some(self.get(target).content.clone())
2650 }
2651 }
2652}
2653
2654// ─── Phase 3b serialize/deserialize tests ─────────────────────────────────────
2655//
2656// The undofile is only as trustworthy as this round-trip: a projection that
2657// loses a branch, mislinks a parent, or reconstructs a node's content wrong
2658// would silently corrupt cross-session undo. These build the headline tree
2659// (5 edits, u, u), project it, rebuild, and assert BOTH the per-node content
2660// (keyed by the stable `seq`) and the live walk (`<C-r>` forward, `u` back)
2661// survive the trip.
2662#[cfg(test)]
2663mod serialize_tests {
2664 use super::*;
2665
2666 fn e(text: &str) -> UndoEntry {
2667 UndoEntry {
2668 rope: ropey::Rope::from_str(text),
2669 cursor: (0, 0),
2670 timestamp: SystemTime::now(),
2671 marks: MarkSnapshot::default(),
2672 }
2673 }
2674 fn l(text: &str) -> (ropey::Rope, (usize, usize), MarkSnapshot) {
2675 (ropey::Rope::from_str(text), (0, 0), MarkSnapshot::default())
2676 }
2677
2678 /// The headline tree: root "s0", five edits to live "s5", then `u` twice so
2679 /// `current` sits on "s3" with the forward branch (s4/s5) retained — exactly
2680 /// the state a `:wq` would persist.
2681 fn headline_tree() -> UndoTree {
2682 let mut t = UndoTree::new(ropey::Rope::from_str("s0"));
2683 for pre in ["s0", "s1", "s2", "s3", "s4"] {
2684 t.push(e(pre)); // engine discipline: push the PRE-edit live state
2685 }
2686 let (r, c, m) = l("s5");
2687 t.undo_step(r, c, m); // -> s4
2688 let (r, c, m) = l("s4");
2689 t.undo_step(r, c, m); // -> s3
2690 t.sync_current(ropey::Rope::from_str("s3")); // stash exact live, like save
2691 t
2692 }
2693
2694 /// Every node's content (keyed by `seq`), materialized cold-then-warm.
2695 fn content_by_seq(t: &mut UndoTree) -> std::collections::BTreeMap<u64, String> {
2696 t.live_ids()
2697 .into_iter()
2698 .map(|id| {
2699 let seq = t.get(id).seq;
2700 (seq, t.materialize_for_test(id).to_string())
2701 })
2702 .collect()
2703 }
2704
2705 #[test]
2706 fn round_trip_reproduces_structure_and_content() {
2707 let mut orig = headline_tree();
2708 let cur_seq = orig.current_node_seq();
2709 let ser = orig.to_serializable();
2710 let orig_content = content_by_seq(&mut orig);
2711
2712 let mut back = UndoTree::from_serializable(&ser).expect("valid projection");
2713 assert_eq!(back.current_node_seq(), cur_seq, "current preserved");
2714 assert_eq!(back.next_seq, orig.next_seq, "next_seq preserved");
2715 // Force cold reconstruction (fresh tree has no warm caches) and compare.
2716 assert_eq!(
2717 content_by_seq(&mut back),
2718 orig_content,
2719 "content at every node reproduced"
2720 );
2721 // Six states: s0..s5.
2722 assert_eq!(orig_content.len(), 6);
2723 assert_eq!(orig_content[&3], "s3");
2724 assert_eq!(orig_content[&5], "s5");
2725 }
2726
2727 #[test]
2728 fn deserialized_tree_walks_forward_and_back() {
2729 let ser = headline_tree().to_serializable();
2730 let mut t = UndoTree::from_serializable(&ser).unwrap();
2731 // `<C-r>` twice: s3 -> s4 -> s5 (the retained forward branch).
2732 let (r, c, m) = l("s3");
2733 assert_eq!(t.redo_step(r, c, m).unwrap().rope.to_string(), "s4");
2734 let (r, c, m) = l("s4");
2735 assert_eq!(t.redo_step(r, c, m).unwrap().rope.to_string(), "s5");
2736 // `u` all the way back to the root.
2737 let mut live = "s5".to_string();
2738 for want in ["s4", "s3", "s2", "s1", "s0"] {
2739 let (r, c, m) = l(&live);
2740 assert_eq!(t.undo_step(r, c, m).unwrap().rope.to_string(), want);
2741 live = want.to_string();
2742 }
2743 assert!(t.is_at_root());
2744 }
2745
2746 #[test]
2747 fn from_serializable_rejects_out_of_range_current() {
2748 let mut ser = headline_tree().to_serializable();
2749 ser.current = ser.nodes.len() as u32; // past the end
2750 assert!(UndoTree::from_serializable(&ser).is_none());
2751 }
2752
2753 #[test]
2754 fn from_serializable_rejects_non_root_missing_delta() {
2755 let mut ser = headline_tree().to_serializable();
2756 // Blank a non-root node's delta ⇒ structurally invalid ⇒ rejected.
2757 let victim = if ser.root == 0 { 1 } else { 0 };
2758 ser.nodes[victim].delta = None;
2759 assert!(UndoTree::from_serializable(&ser).is_none());
2760 }
2761
2762 #[test]
2763 fn multibyte_content_survives_round_trip() {
2764 let mut t = UndoTree::new(ropey::Rope::from_str("café\n日本語"));
2765 t.push(e("café\n日本語"));
2766 t.push(e("cafés\n日本語"));
2767 t.sync_current(ropey::Rope::from_str("cafés\n日本語です🎉"));
2768 let want = content_by_seq(&mut t);
2769 let ser = t.to_serializable();
2770 let mut back = UndoTree::from_serializable(&ser).unwrap();
2771 assert_eq!(content_by_seq(&mut back), want);
2772 }
2773}