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::time::{Duration, SystemTime, UNIX_EPOCH};
8
9use serde::{Deserialize, Serialize};
10
11/// A single entry in the undo or redo stack.
12///
13/// The `timestamp` records the wall-clock time at which the snapshot was
14/// taken (i.e. when `push_undo` was called), enabling the `:earlier` /
15/// `:later` time-travel ex commands to walk the stack by duration rather
16/// than by step count.
17///
18/// Stored as a `ropey::Rope` (O(1) Arc-clone) rather than a `String` so
19/// snapshot cost is negligible even on multi-MB buffers.
20#[derive(Debug, Clone)]
21pub struct UndoEntry {
22 pub rope: ropey::Rope,
23 pub cursor: (usize, usize),
24 pub timestamp: SystemTime,
25 /// Local marks / jumplist / changelist / this-buffer's-global-marks
26 /// snapshot, so undo/redo restore mark-ish positions alongside the
27 /// text instead of leaving them shifted by the edit being undone
28 /// (audit-r2 fix 2). `Default::default()` (all empty) for callers
29 /// that don't populate it — restoring an all-empty snapshot is a
30 /// no-op against a freshly-constructed buffer's own empty state, so
31 /// existing fixtures that only care about text/cursor stay valid.
32 pub marks: MarkSnapshot,
33}
34
35/// Buffer-scoped "edit coherence" state snapshotted alongside a
36/// [`UndoEntry`]'s rope so undo/redo can restore marks, not just text.
37///
38/// Positions are plain `(row, col)` (or `(row, col)` values keyed by
39/// mark char) — no buffer-id tagging needed here even for
40/// `global_marks`, because a `MarkSnapshot` always belongs to exactly
41/// one buffer's undo stack; the engine is responsible for reattaching
42/// its own `buffer_id` when writing entries back into the session-global
43/// marks map (see `Editor::restore_marks`).
44#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
45pub struct MarkSnapshot {
46 /// `ma`-`mz` local marks (`View::marks_cloned`).
47 pub local_marks: BTreeMap<char, (usize, usize)>,
48 /// Back-jumplist (`Ctrl-o` stack), newest at the back.
49 pub jump_back: Vec<(usize, usize)>,
50 /// Forward-jumplist (`Ctrl-i` stack), newest at the back.
51 pub jump_fwd: Vec<(usize, usize)>,
52 /// `` `. `` / `'.` — position of the most recent change.
53 pub change_last_edit: Option<(usize, usize)>,
54 /// Changelist ring (`g;` / `g,`).
55 pub change_list: Vec<(usize, usize)>,
56 /// Walk cursor into `change_list`; `None` outside a walk.
57 pub change_cursor: Option<usize>,
58 /// `mA`-`mZ` global marks that belong to THIS buffer (bare
59 /// `(row, col)` — the buffer-id is implicit, this buffer).
60 pub global_marks: BTreeMap<char, (usize, usize)>,
61}
62
63// ─── Reversible edge delta (Phase 3a) ──────────────────────────────────────────
64//
65// Phase 2b stored a FULL rope snapshot on every node. Phase 3a stores only a
66// reversible **delta** on each parent→child edge (the root keeps a full base
67// rope) plus a materialization cache, so the in-RAM hot path stays snapshot-fast
68// while a future undofile shrinks from hundreds of MB to KB. This slice changes
69// ONLY internal storage — every public signature, and every observable
70// behaviour, is byte-identical to Phase 2b.
71
72/// A reversible edit between two adjacent buffer states, expressed as a single
73/// spanning replacement in **char-offset space** on the rope.
74///
75/// The index space is ropey `char` offsets throughout — never bytes — so
76/// multi-byte UTF-8 round-trips (a byte offset could split a codepoint). In the
77/// PARENT state `chars[start .. start + old.chars().count()] == old`; replacing
78/// that region with `new` yields the CHILD state, and swapping the two inverts
79/// it. A whole undo group collapses to the one region spanning its edits
80/// (common-prefix / common-suffix diff); a `Vec<Delta>` for disjoint regions is
81/// an acceptable future generalization, but one spanning region is all Phase 3a
82/// needs.
83#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
84pub struct Delta {
85 /// Char offset of the first differing char (the common-prefix length).
86 pub start: usize,
87 /// Chars present in the PARENT but not the CHILD (removed going forward).
88 pub old: String,
89 /// Chars present in the CHILD but not the PARENT (inserted going forward).
90 pub new: String,
91}
92
93/// Common-prefix / common-suffix diff of two ropes → the minimal single spanning
94/// [`Delta`]. Guarantees `apply_forward(a, diff(a, b)) == b` and
95/// `apply_inverse(b, diff(a, b)) == a` for ALL `a`, `b` (see the property
96/// tests). Boundaries are found on bytes (fast) then snapped to char boundaries
97/// so `old`/`new` are always valid UTF-8 and `start` is a true char offset.
98fn diff(parent: &ropey::Rope, child: &ropey::Rope) -> Delta {
99 let a = parent.to_string();
100 let b = child.to_string();
101 let ab = a.as_bytes();
102 let bb = b.as_bytes();
103
104 // Longest common byte prefix, snapped DOWN to a char boundary.
105 let max_pre = ab.len().min(bb.len());
106 let mut pre = 0;
107 while pre < max_pre && ab[pre] == bb[pre] {
108 pre += 1;
109 }
110 while pre > 0 && !a.is_char_boundary(pre) {
111 pre -= 1;
112 }
113
114 // Longest common byte suffix not overlapping the prefix. The cut points
115 // `a_end`/`b_end` sit at identical trailing bytes, so snapping `a_end` UP to
116 // a char boundary snaps `b_end` by the same byte delta simultaneously.
117 let max_suf = max_pre - pre;
118 let mut suf = 0;
119 while suf < max_suf && ab[ab.len() - 1 - suf] == bb[bb.len() - 1 - suf] {
120 suf += 1;
121 }
122 let mut a_end = ab.len() - suf;
123 while a_end < ab.len() && !a.is_char_boundary(a_end) {
124 a_end += 1;
125 }
126 let b_end = bb.len() - (ab.len() - a_end);
127
128 Delta {
129 start: a[..pre].chars().count(),
130 old: a[pre..a_end].to_string(),
131 new: b[pre..b_end].to_string(),
132 }
133}
134
135/// Apply a forward delta (PARENT → CHILD) to `parent`, returning the child rope.
136fn apply_forward(parent: &ropey::Rope, d: &Delta) -> ropey::Rope {
137 let mut r = parent.clone();
138 let old_chars = d.old.chars().count();
139 r.remove(d.start..d.start + old_chars);
140 r.insert(d.start, &d.new);
141 r
142}
143
144/// Apply an inverse delta (CHILD → PARENT) to `child`, returning the parent rope.
145fn apply_inverse(child: &ropey::Rope, d: &Delta) -> ropey::Rope {
146 let mut r = child.clone();
147 let new_chars = d.new.chars().count();
148 r.remove(d.start..d.start + new_chars);
149 r.insert(d.start, &d.old);
150 r
151}
152
153// ─── Undo arena tree (Phase 2b + Phase 3a delta storage) ──────────────────────
154//
155// The undo history is a real arena TREE of buffer states (Phase 2a introduced
156// the arena; Phase 2b makes it branch; Phase 3a stores edges as deltas). An edit
157// after an undo FORKS a new child instead of truncating the forward branch, so
158// old branches stay reachable — matching nvim's undo tree. `seq` is
159// load-bearing: `g-`/`g+` and the `:earlier`/`:later` count forms walk ALL
160// states by global `seq` (see `seq_earlier_step`/`seq_later_step`), while
161// `u`/`<C-r>` stay branch-local (parent / `last_child`).
162//
163// The linear-history subset is unchanged: with no forks the tree is a single
164// root→current→leaf path and every operation degrades to the old two-stack
165// behaviour.
166//
167// - `current` points at the node representing the LIVE buffer state.
168// - The ancestors of `current` (parent, … up to `root`) are the reachable undo
169// line; `current.parent` is the `u` target.
170// - `current.last_child` is the `<C-r>` target. Landing on any node (undo,
171// redo, or a `g-`/`g+` jump) rewrites `last_child` down the root→node path so
172// a later `<C-r>` retraces the branch just taken.
173//
174// Storage (Phase 3a): each non-root node holds the reversible `delta` on its
175// edge from `parent`; the root holds a full `base` rope. A node's content is
176// reconstructed on demand (`materialize`) from the nearest cached ancestor (or
177// the root base) by replaying forward deltas, or — for the `u`/`<C-r>` hot path
178// — from the adjacent warm node by one delta apply. Recently materialized ropes
179// are kept in a bounded LRU (`warm`); `current` is always kept warm. A node's
180// `delta`/content is FINALIZED lazily on the way past it (whenever the live rope
181// is written into it), never read as a restore target until then — so the fresh
182// leaf `current` holds a placeholder edge that is corrected before it matters.
183
184/// Index into [`UndoTree::nodes`]. Slots are reused via a free list, so an id is
185/// only valid while the node it names is live — the tree never hands ids out.
186pub(crate) type NodeId = usize;
187
188/// How many recently-materialized node ropes to keep warm (besides the root
189/// base and `current`, which are always available). A cold jump beyond this
190/// window replays deltas from the nearest warm ancestor — rare and bounded.
191const WARM_CAP: usize = 16;
192
193/// One node of the undo arena tree: a buffer state the user could land on, plus
194/// its links and the reversible edge to its parent. A node with `> 1` child is a
195/// branch point (Phase 2b); `last_child` records which child `<C-r>` follows.
196#[derive(Debug, Clone)]
197pub(crate) struct UndoNode {
198 pub parent: Option<NodeId>,
199 pub children: Vec<NodeId>,
200 pub last_child: Option<NodeId>,
201 /// Reversible edit from the parent's content to this node's content. `None`
202 /// only for the root (and any node promoted to root by pruning), which holds
203 /// `base` instead.
204 pub delta: Option<Delta>,
205 /// Full base rope. `Some` ONLY for the root — the anchor the delta chain
206 /// replays from. Non-root nodes leave this `None` and carry a `delta`.
207 pub base: Option<ropey::Rope>,
208 /// Materialized content, LRU-managed. Warm for `current` and recently
209 /// visited nodes; `None` (cold) otherwise, reconstructable from deltas.
210 pub rope_cache: Option<ropey::Rope>,
211 /// Post-state cursor for this node (restored alongside the text).
212 pub cursor: (usize, usize),
213 /// Wall-clock time this state was created — drives `:earlier`/`:later`.
214 pub timestamp: SystemTime,
215 /// Marks / jumplist / changelist snapshot restored with the text.
216 pub marks: MarkSnapshot,
217 /// Global monotonic order across the whole tree — the change number that
218 /// `g-`/`g+`, `:earlier`/`:later`, and `:undolist` traverse and display.
219 pub seq: u64,
220}
221
222/// Arena tree of [`UndoNode`]s. Replaces the old `undo_stack`/`redo_stack`
223/// `Vec<UndoEntry>` pair on [`crate::Buffer`]; see the module comment for how
224/// `u`/`<C-r>` (branch-local) and `g-`/`g+` (seq-ordered) map onto it, and how
225/// Phase 3a stores edges as deltas behind a materialization cache.
226#[derive(Debug)]
227pub(crate) struct UndoTree {
228 /// Slab; `None` slots are free and recorded in `free`.
229 nodes: Vec<Option<UndoNode>>,
230 /// Reusable slot indices (frees push here, allocs pop here first).
231 free: Vec<NodeId>,
232 /// LRU of node ids with a warm `rope_cache` (root excluded — it uses
233 /// `base`), most-recently-touched last. Bounded by [`WARM_CAP`]; `current`
234 /// is never evicted.
235 warm: Vec<NodeId>,
236 root: NodeId,
237 current: NodeId,
238 next_seq: u64,
239}
240
241impl UndoTree {
242 /// New tree with a single root == current node holding `rope` as its base
243 /// state (the buffer as opened / last saved). The root is always
244 /// materializable from this base.
245 pub(crate) fn new(rope: ropey::Rope) -> Self {
246 let root = UndoNode {
247 parent: None,
248 children: Vec::new(),
249 last_child: None,
250 delta: None,
251 base: Some(rope),
252 rope_cache: None,
253 cursor: (0, 0),
254 timestamp: SystemTime::now(),
255 marks: MarkSnapshot::default(),
256 seq: 0,
257 };
258 Self {
259 nodes: vec![Some(root)],
260 free: Vec::new(),
261 warm: Vec::new(),
262 root: 0,
263 current: 0,
264 next_seq: 1,
265 }
266 }
267
268 // ── slab helpers ─────────────────────────────────────────────────────────
269
270 fn get(&self, id: NodeId) -> &UndoNode {
271 self.nodes[id].as_ref().expect("live NodeId")
272 }
273
274 fn get_mut(&mut self, id: NodeId) -> &mut UndoNode {
275 self.nodes[id].as_mut().expect("live NodeId")
276 }
277
278 fn alloc(&mut self, node: UndoNode) -> NodeId {
279 if let Some(id) = self.free.pop() {
280 self.nodes[id] = Some(node);
281 id
282 } else {
283 self.nodes.push(Some(node));
284 self.nodes.len() - 1
285 }
286 }
287
288 /// Free a single slot (does NOT recurse into children — callers detach
289 /// links first). Drops the node's delta + materialized cache and purges it
290 /// from the warm LRU.
291 fn free(&mut self, id: NodeId) {
292 self.nodes[id] = None;
293 self.free.push(id);
294 self.warm.retain(|&n| n != id);
295 }
296
297 // ── materialization (Phase 3a) ────────────────────────────────────────────
298
299 /// Record `id` as freshly materialized, evicting the coldest cache beyond
300 /// [`WARM_CAP`] (never the root — it has no cache — nor `current`).
301 fn touch_warm(&mut self, id: NodeId) {
302 if id == self.root {
303 return;
304 }
305 self.warm.retain(|&n| n != id);
306 self.warm.push(id);
307 while self.warm.len() > WARM_CAP {
308 let Some(pos) = self.warm.iter().position(|&n| n != self.current) else {
309 break;
310 };
311 let victim = self.warm.remove(pos);
312 if let Some(node) = self.nodes[victim].as_mut() {
313 node.rope_cache = None;
314 }
315 }
316 }
317
318 /// Materialize node `id`'s content, warming its cache. Uses the warm cache
319 /// if present, else the root `base`, else replays forward deltas from the
320 /// nearest materialized ancestor (or the root). Always terminates: the root
321 /// carries a base.
322 fn materialize(&mut self, id: NodeId) -> ropey::Rope {
323 if let Some(r) = &self.get(id).rope_cache {
324 return r.clone();
325 }
326 if let Some(base) = &self.get(id).base {
327 return base.clone();
328 }
329 // Walk up to the nearest ancestor that is warm or is the root, recording
330 // the path of nodes to replay forward.
331 let mut path = Vec::new();
332 let base_rope;
333 let mut anchor = id;
334 loop {
335 path.push(anchor);
336 let par = self
337 .get(anchor)
338 .parent
339 .expect("a non-root, non-based node always has a parent");
340 if let Some(r) = &self.get(par).rope_cache {
341 base_rope = r.clone();
342 break;
343 }
344 if let Some(b) = &self.get(par).base {
345 base_rope = b.clone();
346 break;
347 }
348 anchor = par;
349 }
350 let mut rope = base_rope;
351 for &node in path.iter().rev() {
352 let d = self
353 .get(node)
354 .delta
355 .clone()
356 .expect("a non-root node always carries its edge delta");
357 rope = apply_forward(&rope, &d);
358 }
359 self.get_mut(id).rope_cache = Some(rope.clone());
360 self.touch_warm(id);
361 rope
362 }
363
364 /// Reconstruct node `id`'s restorable [`UndoEntry`] — the byte-for-byte
365 /// equivalent of Phase 2b's `node.snapshot.clone()`.
366 fn entry_of(&mut self, id: NodeId) -> UndoEntry {
367 let rope = self.materialize(id);
368 let n = self.get(id);
369 UndoEntry {
370 rope,
371 cursor: n.cursor,
372 timestamp: n.timestamp,
373 marks: n.marks.clone(),
374 }
375 }
376
377 /// Finalize node `id` to hold `rope` as its content, recomputing its edge
378 /// delta (or the root base) and updating cursor/timestamp/marks. A no-op
379 /// diff is skipped when the content is unchanged (the common case on a
380 /// history walk, where only the fields move) — which also avoids
381 /// materializing the parent, keeping the walk cheap.
382 fn set_node_state(
383 &mut self,
384 id: NodeId,
385 rope: ropey::Rope,
386 cursor: (usize, usize),
387 timestamp: SystemTime,
388 marks: MarkSnapshot,
389 ) {
390 let is_root = self.get(id).parent.is_none();
391 let unchanged = self.get(id).rope_cache.as_ref() == Some(&rope)
392 || (is_root && self.get(id).base.as_ref() == Some(&rope));
393 {
394 let node = self.get_mut(id);
395 node.cursor = cursor;
396 node.timestamp = timestamp;
397 node.marks = marks;
398 }
399 if unchanged {
400 return;
401 }
402 if is_root {
403 self.get_mut(id).base = Some(rope);
404 // The root is materialized from `base`; keep no stale cache.
405 self.get_mut(id).rope_cache = None;
406 self.warm.retain(|&n| n != id);
407 } else {
408 let par = self.get(id).parent.expect("non-root has a parent");
409 let par_rope = self.materialize(par);
410 let d = diff(&par_rope, &rope);
411 let node = self.get_mut(id);
412 node.delta = Some(d);
413 node.rope_cache = Some(rope);
414 self.touch_warm(id);
415 }
416 }
417
418 /// Free `id` and its whole subtree (iteratively, so a long redo chain can't
419 /// overflow the stack).
420 fn free_subtree(&mut self, id: NodeId) {
421 let mut stack = vec![id];
422 while let Some(n) = stack.pop() {
423 let kids = std::mem::take(&mut self.get_mut(n).children);
424 stack.extend(kids);
425 self.free(n);
426 }
427 }
428
429 // ── read-only queries (mirror the old stack accessors) ───────────────────
430
431 /// `undo_stack.is_empty()` ⇔ `current` has no parent (is the root).
432 pub(crate) fn is_at_root(&self) -> bool {
433 self.get(self.current).parent.is_none()
434 }
435
436 /// `!redo_stack.is_empty()` ⇔ `current` has a forward child.
437 pub(crate) fn has_redo(&self) -> bool {
438 self.get(self.current).last_child.is_some()
439 }
440
441 /// `undo_stack.len()` == number of ancestors of `current` (depth from root).
442 pub(crate) fn depth(&self) -> usize {
443 let mut d = 0;
444 let mut n = self.get(self.current).parent;
445 while let Some(p) = n {
446 d += 1;
447 n = self.get(p).parent;
448 }
449 d
450 }
451
452 /// `undo_stack.last().timestamp` == `current.parent`'s timestamp.
453 pub(crate) fn parent_timestamp(&self) -> Option<SystemTime> {
454 self.get(self.current).parent.map(|p| self.get(p).timestamp)
455 }
456
457 /// `redo_stack.last().timestamp` == `current.last_child`'s timestamp.
458 pub(crate) fn child_timestamp(&self) -> Option<SystemTime> {
459 self.get(self.current)
460 .last_child
461 .map(|c| self.get(c).timestamp)
462 }
463
464 // ── mutations ────────────────────────────────────────────────────────────
465
466 /// Commit a new boundary from `current`, growing the tree (Phase 2b).
467 ///
468 /// `entry` is the pre-edit LIVE state. It is written into `current`'s
469 /// snapshot (making `current` a real, restorable state), then a fresh child
470 /// is APPENDED and becomes the new `current` for the edit about to happen.
471 ///
472 /// Unlike Phase 2a this does NOT drop `current`'s existing children: an edit
473 /// after an undo now forks a new branch and the old forward branch(es) stay
474 /// reachable via `g-`/`g+` and `:undolist`, matching nvim's undo tree. The
475 /// new child is made `last_child` so a subsequent `<C-r>` follows the branch
476 /// just created.
477 pub(crate) fn push(&mut self, entry: UndoEntry) {
478 let cur = self.current;
479 // Finalize the node being left with the pre-edit live state, recomputing
480 // its edge delta from its parent (or the root base).
481 self.set_node_state(
482 cur,
483 entry.rope.clone(),
484 entry.cursor,
485 entry.timestamp,
486 entry.marks.clone(),
487 );
488 let seq = self.next_seq;
489 self.next_seq += 1;
490 // Fresh child: identical to `cur` for now (empty edge delta + warm cache
491 // holding the pre-edit rope). Its true post-edit content is finalized on
492 // the way past it (next move) or by the next `push`, at which point the
493 // edge delta is recomputed against `cur`.
494 let child = self.alloc(UndoNode {
495 parent: Some(cur),
496 children: Vec::new(),
497 last_child: None,
498 delta: Some(Delta::default()),
499 base: None,
500 rope_cache: Some(entry.rope),
501 cursor: entry.cursor,
502 timestamp: entry.timestamp,
503 marks: entry.marks,
504 seq,
505 });
506 let cur_node = self.get_mut(cur);
507 // Append (retain old branches); the freshest child is the redo target.
508 cur_node.children.push(child);
509 cur_node.last_child = Some(child);
510 self.current = child;
511 self.touch_warm(child);
512 }
513
514 /// One undo step. `live` is the current buffer state (the node being left);
515 /// it is written into that node but INHERITS the destination (parent)
516 /// timestamp — byte-parity with the old dance, where the pushed redo entry
517 /// took the popped undo entry's timestamp. Returns the parent snapshot to
518 /// restore, or `None` at the root.
519 pub(crate) fn undo_step(
520 &mut self,
521 rope: ropey::Rope,
522 cursor: (usize, usize),
523 marks: MarkSnapshot,
524 ) -> Option<UndoEntry> {
525 let cur = self.current;
526 let par = self.get(cur).parent?;
527 let dest_ts = self.get(par).timestamp;
528 self.set_node_state(cur, rope, cursor, dest_ts, marks);
529 // Redo from the parent must return to the node we just left.
530 self.get_mut(par).last_child = Some(cur);
531 self.current = par;
532 // Hot-path materialization: derive the (possibly cold) parent from the
533 // just-finalized child by one inverse delta apply, so `u` never walks the
534 // ancestor chain even far outside the warm window.
535 if self.get(par).rope_cache.is_none() && self.get(par).base.is_none() {
536 let child_rope = self.get(cur).rope_cache.clone();
537 let child_delta = self.get(cur).delta.clone();
538 if let (Some(cr), Some(d)) = (child_rope, child_delta) {
539 let par_rope = apply_inverse(&cr, &d);
540 self.get_mut(par).rope_cache = Some(par_rope);
541 self.touch_warm(par);
542 }
543 }
544 Some(self.entry_of(par))
545 }
546
547 /// One redo step. Symmetric to [`Self::undo_step`]: `live` is written into
548 /// the node being left (which becomes an undo ancestor) with the
549 /// destination (child) timestamp. Returns the child snapshot to restore, or
550 /// `None` when there is no forward branch.
551 pub(crate) fn redo_step(
552 &mut self,
553 rope: ropey::Rope,
554 cursor: (usize, usize),
555 marks: MarkSnapshot,
556 ) -> Option<UndoEntry> {
557 let cur = self.current;
558 let child = self.get(cur).last_child?;
559 let dest_ts = self.get(child).timestamp;
560 self.set_node_state(cur, rope, cursor, dest_ts, marks);
561 self.current = child;
562 // `cur` is now warm, so materializing the child is one forward apply.
563 Some(self.entry_of(child))
564 }
565
566 // ── seq-ordered tree walk (`g-` / `g+`, `:earlier`/`:later` — Phase 2b) ───
567 //
568 // `u`/`<C-r>` are branch-local (parent / `last_child`); `g-`/`g+` traverse
569 // ALL states by global `seq`, crossing branch boundaries. `g-` restores the
570 // node with the greatest `seq` strictly below `current`'s; `g+` the least
571 // `seq` strictly above. Confirmed against nvim v0.12.4 (`iA<Esc>uiB<Esc>`
572 // then `g-`/`g-g-`/`g-g+` walks empty↔A↔B by change number).
573
574 /// `seq` of the node the buffer currently shows.
575 fn current_seq(&self) -> u64 {
576 self.get(self.current).seq
577 }
578
579 /// Live node with the greatest `seq` strictly below `s` (the `g-` target).
580 fn node_below(&self, s: u64) -> Option<NodeId> {
581 let mut best: Option<(u64, NodeId)> = None;
582 for (id, slot) in self.nodes.iter().enumerate() {
583 if let Some(n) = slot
584 && n.seq < s
585 && best.is_none_or(|(bs, _)| n.seq > bs)
586 {
587 best = Some((n.seq, id));
588 }
589 }
590 best.map(|(_, id)| id)
591 }
592
593 /// Live node with the least `seq` strictly above `s` (the `g+` target).
594 fn node_above(&self, s: u64) -> Option<NodeId> {
595 let mut best: Option<(u64, NodeId)> = None;
596 for (id, slot) in self.nodes.iter().enumerate() {
597 if let Some(n) = slot
598 && n.seq > s
599 && best.is_none_or(|(bs, _)| n.seq < bs)
600 {
601 best = Some((n.seq, id));
602 }
603 }
604 best.map(|(_, id)| id)
605 }
606
607 /// Point `current` at `target` and rewrite `last_child` down the whole
608 /// root→target path, so a later `<C-r>` retraces the branch just landed on
609 /// (nvim parity: landing on a node updates its ancestors' redo direction).
610 fn retarget_current(&mut self, target: NodeId) {
611 self.current = target;
612 let mut node = target;
613 while let Some(p) = self.get(node).parent {
614 self.get_mut(p).last_child = Some(node);
615 node = p;
616 }
617 }
618
619 /// Stash the live buffer state into the node being left (it may be a fresh,
620 /// still-stale leaf), preserving that node's own timestamp, then move.
621 fn stash_and_move(
622 &mut self,
623 target: NodeId,
624 rope: ropey::Rope,
625 cursor: (usize, usize),
626 marks: MarkSnapshot,
627 ) {
628 let cur = self.current;
629 let ts = self.get(cur).timestamp;
630 self.set_node_state(cur, rope, cursor, ts, marks);
631 self.retarget_current(target);
632 }
633
634 /// One `g-` / `:earlier` step: move to the next-lower-`seq` node tree-wide.
635 /// Returns its snapshot to restore, or `None` at the lowest state.
636 pub(crate) fn seq_earlier_step(
637 &mut self,
638 rope: ropey::Rope,
639 cursor: (usize, usize),
640 marks: MarkSnapshot,
641 ) -> Option<UndoEntry> {
642 let target = self.node_below(self.current_seq())?;
643 self.stash_and_move(target, rope, cursor, marks);
644 Some(self.entry_of(target))
645 }
646
647 /// One `g+` / `:later` step: move to the next-higher-`seq` node tree-wide.
648 /// Returns its snapshot to restore, or `None` at the highest state.
649 pub(crate) fn seq_later_step(
650 &mut self,
651 rope: ropey::Rope,
652 cursor: (usize, usize),
653 marks: MarkSnapshot,
654 ) -> Option<UndoEntry> {
655 let target = self.node_above(self.current_seq())?;
656 self.stash_and_move(target, rope, cursor, marks);
657 Some(self.entry_of(target))
658 }
659
660 /// Timestamp of the next-lower-`seq` node (the `:earlier Ns` predicate walks
661 /// the seq order tree-wide, stopping once this dips to/below the cutoff).
662 pub(crate) fn seq_earlier_timestamp(&self) -> Option<SystemTime> {
663 self.node_below(self.current_seq())
664 .map(|id| self.get(id).timestamp)
665 }
666
667 /// Timestamp of the next-higher-`seq` node (the `:later Ns` predicate).
668 pub(crate) fn seq_later_timestamp(&self) -> Option<SystemTime> {
669 self.node_above(self.current_seq())
670 .map(|id| self.get(id).timestamp)
671 }
672
673 /// Leaves of the tree (nodes with no children), each as
674 /// `(seq, depth-from-root, timestamp, is_current)`, sorted by `seq`.
675 /// Drives `:undolist`, which — like nvim — lists only branch leaves.
676 pub(crate) fn leaves(&self) -> Vec<(u64, usize, SystemTime, bool)> {
677 let mut out: Vec<(u64, usize, SystemTime, bool)> = Vec::new();
678 for (id, slot) in self.nodes.iter().enumerate() {
679 let Some(n) = slot else { continue };
680 // The root is the base state (change number 0), never a listed
681 // "change" — like nvim, an untouched buffer lists nothing.
682 if id == self.root || !n.children.is_empty() {
683 continue;
684 }
685 // Depth = number of ancestors (root leaf ⇒ 0).
686 let mut depth = 0;
687 let mut p = n.parent;
688 while let Some(pid) = p {
689 depth += 1;
690 p = self.get(pid).parent;
691 }
692 out.push((n.seq, depth, n.timestamp, id == self.current));
693 }
694 out.sort_by_key(|&(seq, ..)| seq);
695 out
696 }
697
698 /// Number of live nodes (used by [`Self::cap`] as the state budget).
699 fn live_count(&self) -> usize {
700 self.nodes.iter().filter(|n| n.is_some()).count()
701 }
702
703 /// `undo_stack.pop()` — discard the most-recent boundary WITHOUT moving the
704 /// live state. Used by `:s` with zero replacements and by a no-op undo
705 /// group; in both, `current` is the childless leaf the last [`Self::push`]
706 /// created, so reverse that push: drop the leaf, step `current` back to its
707 /// parent (its snapshot equals the unchanged buffer), and restore the
708 /// parent's `last_child`. Retains any sibling branches the push appended to.
709 /// Returns `false` at the root, or if `current` is not a childless leaf
710 /// (nothing safe to pop).
711 pub(crate) fn pop_committed(&mut self) -> bool {
712 let cur = self.current;
713 if !self.get(cur).children.is_empty() {
714 return false;
715 }
716 let Some(par) = self.get(cur).parent else {
717 return false;
718 };
719 let par_node = self.get_mut(par);
720 par_node.children.retain(|&c| c != cur);
721 // The freshest surviving sibling (if any) becomes the redo target again.
722 par_node.last_child = par_node.children.last().copied();
723 self.current = par;
724 // The popped leaf always holds the highest seq (push assigns it last),
725 // so reclaim the seq to keep numbering gapless.
726 if self.get(cur).seq + 1 == self.next_seq {
727 self.next_seq -= 1;
728 }
729 self.free(cur);
730 true
731 }
732
733 /// Node budget (`undolevels`). While the number of undo states (live nodes
734 /// minus the root) exceeds `cap`, prune — branch-aware (Phase 2b):
735 ///
736 /// 1. First drop the lowest-`seq` LEAF that is NOT on the root→`current`
737 /// path — an abandoned branch tip. This never touches `current` or its
738 /// ancestors, so the state you're on and its full undo line survive.
739 /// 2. When only the main line remains (no off-path leaves left), fall back
740 /// to promoting the root's on-path child to root and dropping the old
741 /// root — the Phase 2a root-side prune, which matches nvim's linear
742 /// `undolevels` trimming (oldest states drop first).
743 ///
744 /// `cap == 0` means unlimited (matches the old guard).
745 pub(crate) fn cap(&mut self, cap: usize) {
746 if cap == 0 {
747 return;
748 }
749 // Guard against a pathological loop: at most one prune per live node.
750 let mut budget_iters = self.live_count() + 1;
751 while self.live_count().saturating_sub(1) > cap && budget_iters > 0 {
752 budget_iters -= 1;
753 if let Some(leaf) = self.lowest_offpath_leaf() {
754 self.detach_leaf(leaf);
755 } else if !self.prune_root_side() {
756 break;
757 }
758 }
759 }
760
761 /// Ids on the root→`current` path (inclusive), which pruning must never
762 /// touch. Small (one per undo level), so a `Vec` membership check is fine.
763 fn current_path(&self) -> Vec<NodeId> {
764 let mut path = Vec::new();
765 let mut n = Some(self.current);
766 while let Some(id) = n {
767 path.push(id);
768 n = self.get(id).parent;
769 }
770 path
771 }
772
773 /// Lowest-`seq` leaf that is not on the root→`current` path, if any.
774 fn lowest_offpath_leaf(&self) -> Option<NodeId> {
775 let path = self.current_path();
776 let mut best: Option<(u64, NodeId)> = None;
777 for (id, slot) in self.nodes.iter().enumerate() {
778 if let Some(n) = slot
779 && n.children.is_empty()
780 && !path.contains(&id)
781 && best.is_none_or(|(bs, _)| n.seq < bs)
782 {
783 best = Some((n.seq, id));
784 }
785 }
786 best.map(|(_, id)| id)
787 }
788
789 /// Unlink `leaf` from its parent and free it (leaf ⇒ no subtree to recurse).
790 fn detach_leaf(&mut self, leaf: NodeId) {
791 if let Some(par) = self.get(leaf).parent {
792 let par_node = self.get_mut(par);
793 par_node.children.retain(|&c| c != leaf);
794 if par_node.last_child == Some(leaf) {
795 par_node.last_child = par_node.children.last().copied();
796 }
797 }
798 self.free(leaf);
799 }
800
801 /// Promote the root's on-path child to the new root and free the old root.
802 /// Returns `false` when the root is `current` (nothing left to trim).
803 fn prune_root_side(&mut self) -> bool {
804 let root = self.root;
805 if root == self.current {
806 return false;
807 }
808 // The child on the path to `current` (the root always has one here).
809 let path = self.current_path();
810 let Some(&child) = self.get(root).children.iter().find(|c| path.contains(c)) else {
811 return false;
812 };
813 // Any OTHER root children are off-path branches; drop them with the root.
814 let others: Vec<NodeId> = self
815 .get(root)
816 .children
817 .iter()
818 .copied()
819 .filter(|&c| c != child)
820 .collect();
821 for c in others {
822 self.free_subtree(c);
823 }
824 // The promoted child becomes the new root: materialize it (while the old
825 // root still anchors the chain) into a full base rope, then drop its
826 // now-meaningless parent edge. This keeps every delta below it valid.
827 let base = self.materialize(child);
828 {
829 let node = self.get_mut(child);
830 node.parent = None;
831 node.base = Some(base);
832 node.delta = None;
833 node.rope_cache = None;
834 }
835 self.warm.retain(|&n| n != child);
836 self.root = child;
837 self.free(root);
838 true
839 }
840
841 /// `redo_stack.clear()` — drop `current`'s forward branch.
842 pub(crate) fn clear_redo(&mut self) {
843 let cur = self.current;
844 let kids = std::mem::take(&mut self.get_mut(cur).children);
845 self.get_mut(cur).last_child = None;
846 for c in kids {
847 self.free_subtree(c);
848 }
849 }
850
851 /// `undo_stack.clear(); redo_stack.clear()` — collapse to a single root ==
852 /// current node, preserving the live state. Frees every other node.
853 pub(crate) fn clear_all(&mut self) {
854 let cur = self.current;
855 // The survivor becomes a self-contained root: give it a full base rope
856 // (materialized while the chain is still intact) so it needs no parent.
857 let base = self.materialize(cur);
858 for id in 0..self.nodes.len() {
859 if id != cur && self.nodes[id].is_some() {
860 self.nodes[id] = None;
861 self.free.push(id);
862 }
863 }
864 self.warm.clear();
865 let node = self.get_mut(cur);
866 node.parent = None;
867 node.children.clear();
868 node.last_child = None;
869 node.delta = None;
870 node.base = Some(base);
871 node.rope_cache = None;
872 self.root = cur;
873 }
874}
875
876// ─── Serializable projection (Phase 3b) ───────────────────────────────────────
877//
878// The undofile persists the tree as a compact, self-consistent projection: the
879// root's full base text (String) plus, per node, its edge `delta` and links.
880// `rope_cache`/`warm` are runtime-only and dropped — every node reconstructs
881// from the root base + deltas, so the round-trip reproduces identical content
882// at every node. NodeIds are DENSE in the projection (the live-slab holes are
883// compacted away and links remapped), so `from_serializable` rebuilds a fresh
884// arena 1:1 with no free list.
885
886/// One node of the serialized undo tree. Mirrors [`UndoNode`] minus the
887/// runtime-only materialization cache; ids are dense indices into
888/// [`SerTree::nodes`].
889#[derive(Debug, Clone, Serialize, Deserialize)]
890pub struct SerNode {
891 /// Parent index, `None` only for the root.
892 pub parent: Option<u32>,
893 /// Child indices (order preserved; `> 1` ⇒ branch point).
894 pub children: Vec<u32>,
895 /// `<C-r>` target child index.
896 pub last_child: Option<u32>,
897 /// Reversible edge delta from the parent, `None` only for the root.
898 pub delta: Option<Delta>,
899 /// Post-state cursor `(row, col)`.
900 pub cursor: (u32, u32),
901 /// Wall-clock creation time, ms since the UNIX epoch.
902 pub timestamp_unix_ms: u64,
903 /// Marks / jumplist / changelist snapshot.
904 pub marks: MarkSnapshot,
905 /// Global monotonic change number.
906 pub seq: u64,
907}
908
909/// Serializable projection of an [`UndoTree`] for the undofile. Postcard-encoded
910/// (non-self-describing, so a schema/version drift surfaces as a parse `Err`
911/// that the reader discards). See [`UndoTree::to_serializable`] /
912/// [`UndoTree::from_serializable`].
913#[derive(Debug, Clone, Serialize, Deserialize)]
914pub struct SerTree {
915 /// Root base text (the anchor the delta chain replays from).
916 pub base: String,
917 /// Dense node arena (no holes).
918 pub nodes: Vec<SerNode>,
919 /// Root index into `nodes`.
920 pub root: u32,
921 /// Current (live) index into `nodes`.
922 pub current: u32,
923 /// Next `seq` to assign.
924 pub next_seq: u64,
925}
926
927/// [`SystemTime`] → ms since the UNIX epoch (saturating, pre-epoch ⇒ 0).
928fn system_time_to_unix_ms(t: SystemTime) -> u64 {
929 t.duration_since(UNIX_EPOCH)
930 .map(|d| d.as_millis() as u64)
931 .unwrap_or(0)
932}
933
934/// ms since the UNIX epoch → [`SystemTime`].
935fn unix_ms_to_system_time(ms: u64) -> SystemTime {
936 UNIX_EPOCH + Duration::from_millis(ms)
937}
938
939impl UndoTree {
940 /// `seq` of the current (live) node — the header's `current_seq` for the
941 /// undofile (the just-saved content per the §6 invariant).
942 pub(crate) fn current_node_seq(&self) -> u64 {
943 self.get(self.current).seq
944 }
945
946 /// Materialize the current (live) node's content. Used by the swap
947 /// recovery consistency guard (docs §6c) to check a deserialized tree
948 /// agrees with the freshly-recovered buffer text before it's installed.
949 pub(crate) fn current_content(&mut self) -> ropey::Rope {
950 let cur = self.current;
951 self.materialize(cur)
952 }
953
954 /// Stash `rope` into the current node as the live buffer state, preserving
955 /// that node's own cursor/timestamp/marks. Called just before serializing so
956 /// the on-disk tree's `current` edge is exact even when `current` is a fresh
957 /// (still-stale) leaf — the in-session self-heal (first undo/edit stashes
958 /// live) applied eagerly at save time.
959 pub(crate) fn sync_current(&mut self, rope: ropey::Rope) {
960 let cur = self.current;
961 let (cursor, ts, marks) = {
962 let n = self.get(cur);
963 (n.cursor, n.timestamp, n.marks.clone())
964 };
965 self.set_node_state(cur, rope, cursor, ts, marks);
966 }
967
968 /// Project the live tree into a serializable, dense form (holes compacted,
969 /// links remapped). `rope_cache`/`warm` are dropped; the root's `base`
970 /// carries the anchor text and every non-root node its edge `delta`.
971 pub(crate) fn to_serializable(&self) -> SerTree {
972 // Dense remap: old NodeId → new index, in slab order.
973 let mut map: Vec<Option<u32>> = vec![None; self.nodes.len()];
974 let mut order: Vec<NodeId> = Vec::new();
975 for (id, slot) in self.nodes.iter().enumerate() {
976 if slot.is_some() {
977 map[id] = Some(order.len() as u32);
978 order.push(id);
979 }
980 }
981 let remap = |id: NodeId| map[id].expect("live link points at a live node");
982 let nodes = order
983 .iter()
984 .map(|&id| {
985 let n = self.get(id);
986 SerNode {
987 parent: n.parent.map(remap),
988 children: n.children.iter().map(|&c| remap(c)).collect(),
989 last_child: n.last_child.map(remap),
990 delta: n.delta.clone(),
991 cursor: (n.cursor.0 as u32, n.cursor.1 as u32),
992 timestamp_unix_ms: system_time_to_unix_ms(n.timestamp),
993 marks: n.marks.clone(),
994 seq: n.seq,
995 }
996 })
997 .collect();
998 let base = self
999 .get(self.root)
1000 .base
1001 .as_ref()
1002 .map(|r| r.to_string())
1003 .unwrap_or_default();
1004 SerTree {
1005 base,
1006 nodes,
1007 root: remap(self.root),
1008 current: remap(self.current),
1009 next_seq: self.next_seq,
1010 }
1011 }
1012
1013 /// Rebuild an arena tree from a projection. Returns `None` on any structural
1014 /// inconsistency (out-of-range link, a non-root node missing its delta, a
1015 /// root carrying one) so a corrupt-but-parseable file degrades to a fresh
1016 /// tree rather than a broken one. The root's content comes from `base`; the
1017 /// current node's is materialized on demand from base + deltas.
1018 pub(crate) fn from_serializable(s: &SerTree) -> Option<Self> {
1019 let len = s.nodes.len();
1020 if len == 0 || s.root as usize >= len || s.current as usize >= len {
1021 return None;
1022 }
1023 // Validate links and the root/non-root delta discipline up front.
1024 for (i, n) in s.nodes.iter().enumerate() {
1025 let is_root = i as u32 == s.root;
1026 match (is_root, &n.delta, &n.parent) {
1027 (true, None, None) => {}
1028 (false, Some(_), Some(_)) => {}
1029 _ => return None,
1030 }
1031 if let Some(p) = n.parent
1032 && p as usize >= len
1033 {
1034 return None;
1035 }
1036 if n.children.iter().any(|&c| c as usize >= len) {
1037 return None;
1038 }
1039 if let Some(c) = n.last_child
1040 && c as usize >= len
1041 {
1042 return None;
1043 }
1044 }
1045 let base = ropey::Rope::from_str(&s.base);
1046 let nodes: Vec<Option<UndoNode>> = s
1047 .nodes
1048 .iter()
1049 .enumerate()
1050 .map(|(i, n)| {
1051 let is_root = i as u32 == s.root;
1052 Some(UndoNode {
1053 parent: n.parent.map(|p| p as NodeId),
1054 children: n.children.iter().map(|&c| c as NodeId).collect(),
1055 last_child: n.last_child.map(|c| c as NodeId),
1056 delta: n.delta.clone(),
1057 base: if is_root { Some(base.clone()) } else { None },
1058 rope_cache: None,
1059 cursor: (n.cursor.0 as usize, n.cursor.1 as usize),
1060 timestamp: unix_ms_to_system_time(n.timestamp_unix_ms),
1061 marks: n.marks.clone(),
1062 seq: n.seq,
1063 })
1064 })
1065 .collect();
1066 Some(Self {
1067 nodes,
1068 free: Vec::new(),
1069 warm: Vec::new(),
1070 root: s.root as NodeId,
1071 current: s.current as NodeId,
1072 next_seq: s.next_seq,
1073 })
1074 }
1075}
1076
1077#[cfg(test)]
1078impl UndoTree {
1079 /// Ids of every live node, for warm-vs-cold materialization checks.
1080 fn live_ids(&self) -> Vec<NodeId> {
1081 (0..self.nodes.len())
1082 .filter(|&i| self.nodes[i].is_some())
1083 .collect()
1084 }
1085
1086 /// Materialize `id` for a test (public wrapper over the private method).
1087 fn materialize_for_test(&mut self, id: NodeId) -> ropey::Rope {
1088 self.materialize(id)
1089 }
1090
1091 /// Evict every warm cache (root keeps its `base`), forcing the next
1092 /// materialization of any node to reconstruct purely from deltas.
1093 fn drop_all_caches(&mut self) {
1094 for n in self.nodes.iter_mut().flatten() {
1095 n.rope_cache = None;
1096 }
1097 self.warm.clear();
1098 }
1099}
1100
1101#[cfg(test)]
1102mod tree_tests {
1103 use super::*;
1104
1105 fn entry(text: &str) -> UndoEntry {
1106 UndoEntry {
1107 rope: ropey::Rope::from_str(text),
1108 cursor: (0, 0),
1109 timestamp: SystemTime::now(),
1110 marks: MarkSnapshot::default(),
1111 }
1112 }
1113
1114 fn live(text: &str) -> (ropey::Rope, (usize, usize), MarkSnapshot) {
1115 (ropey::Rope::from_str(text), (0, 0), MarkSnapshot::default())
1116 }
1117
1118 #[test]
1119 fn fresh_tree_is_root_current_empty() {
1120 let t = UndoTree::new(ropey::Rope::from_str("hello"));
1121 assert!(t.is_at_root());
1122 assert!(!t.has_redo());
1123 assert_eq!(t.depth(), 0);
1124 assert_eq!(t.root, t.current);
1125 }
1126
1127 #[test]
1128 fn push_links_child_and_advances_current() {
1129 let mut t = UndoTree::new(ropey::Rope::from_str("hello"));
1130 let root = t.current;
1131 t.push(entry("hello"));
1132 // root now parents current; current is a fresh leaf.
1133 assert_eq!(t.get(t.current).parent, Some(root));
1134 assert_eq!(t.get(root).last_child, Some(t.current));
1135 assert_eq!(t.get(root).children, vec![t.current]);
1136 assert_eq!(t.depth(), 1);
1137 assert!(!t.has_redo());
1138 assert!(!t.is_at_root());
1139 }
1140
1141 #[test]
1142 fn undo_then_redo_round_trips_links() {
1143 let mut t = UndoTree::new(ropey::Rope::from_str("s0"));
1144 t.push(entry("s0")); // commit s0, current = n1 (live s1)
1145 let n0 = t.root;
1146 let n1 = t.current;
1147 // undo: current -> n0, restores s0.
1148 let (r, c, m) = live("s1");
1149 let restored = t.undo_step(r, c, m).unwrap();
1150 assert_eq!(restored.rope.to_string(), "s0");
1151 assert_eq!(t.current, n0);
1152 assert!(t.has_redo());
1153 assert_eq!(t.get(n0).last_child, Some(n1));
1154 // redo: current -> n1, restores what we left (s1).
1155 let (r, c, m) = live("s0");
1156 let restored = t.redo_step(r, c, m).unwrap();
1157 assert_eq!(restored.rope.to_string(), "s1");
1158 assert_eq!(t.current, n1);
1159 assert!(!t.has_redo());
1160 }
1161
1162 #[test]
1163 fn undo_at_root_and_redo_at_leaf_are_noops() {
1164 let mut t = UndoTree::new(ropey::Rope::from_str("x"));
1165 let (r, c, m) = live("x");
1166 assert!(t.undo_step(r, c, m).is_none());
1167 let (r, c, m) = live("x");
1168 assert!(t.redo_step(r, c, m).is_none());
1169 assert_eq!(t.depth(), 0);
1170 }
1171
1172 #[test]
1173 fn push_retains_forward_branch() {
1174 // Phase 2b: an edit after an undo forks a new branch; the old forward
1175 // branch is NOT dropped and remains reachable by seq.
1176 let mut t = UndoTree::new(ropey::Rope::from_str("s0"));
1177 t.push(entry("A")); // root -> nA (seq1, "A")
1178 let root = t.root;
1179 let na = t.current;
1180 let (r, c, m) = live("A");
1181 t.undo_step(r, c, m); // back to root, nA is the redo child
1182 assert!(t.has_redo());
1183 // A new edit from the root forks a SECOND child (nB, seq2).
1184 t.push(entry("B"));
1185 let nb = t.current;
1186 assert_ne!(nb, na);
1187 // Both branches live: root now has two children.
1188 assert_eq!(t.get(root).children.len(), 2);
1189 assert!(t.get(root).children.contains(&na));
1190 assert!(t.get(root).children.contains(&nb));
1191 // `<C-r>` follows the freshest branch (nB).
1192 assert_eq!(t.get(root).last_child, Some(nb));
1193 // Four live nodes: root + nA + nB + (nB is current/leaf). No leak of nA.
1194 let live = t.nodes.iter().filter(|n| n.is_some()).count();
1195 assert_eq!(live, 3);
1196 }
1197
1198 #[test]
1199 fn seq_walk_crosses_branches() {
1200 // Mirror nvim `iA<Esc>uiB<Esc>` then g-/g+ (buffer starts empty "").
1201 // `push(entry)` writes `entry` into the node being LEFT (its true
1202 // pre-edit content); the fresh leaf holds the live post-edit state only
1203 // once it is stashed on the way past — exactly the engine's discipline.
1204 let mut t = UndoTree::new(ropey::Rope::from_str(""));
1205 t.push(entry("")); // leave root("") -> nA(seq1), live "A"
1206 let (r, c, m) = live("A");
1207 t.undo_step(r, c, m); // stash "A" into nA, back to root("")
1208 t.push(entry("")); // leave root("") -> nB(seq2), branch, live "B"
1209 let nb = t.current;
1210 // At B (seq2). g- -> greatest seq below 2 = seq1 = "A".
1211 let (r, c, m) = live("B");
1212 let a = t.seq_earlier_step(r, c, m).unwrap();
1213 assert_eq!(a.rope.to_string(), "A");
1214 // g- again -> root "".
1215 let (r, c, m) = live("A");
1216 let root_snap = t.seq_earlier_step(r, c, m).unwrap();
1217 assert_eq!(root_snap.rope.to_string(), "");
1218 // g+ -> back up to seq1 "A".
1219 let (r, c, m) = live("");
1220 let a2 = t.seq_later_step(r, c, m).unwrap();
1221 assert_eq!(a2.rope.to_string(), "A");
1222 // g+ -> seq2 "B" (crosses to the other branch).
1223 let (r, c, m) = live("A");
1224 let b = t.seq_later_step(r, c, m).unwrap();
1225 assert_eq!(b.rope.to_string(), "B");
1226 assert_eq!(t.current, nb);
1227 // At the tip: no higher seq.
1228 let (r, c, m) = live("B");
1229 assert!(t.seq_later_step(r, c, m).is_none());
1230 }
1231
1232 #[test]
1233 fn seq_walk_updates_retrace_path() {
1234 // Land on a deep leaf via g-, then u/u and <C-r>/<C-r> must retrace it
1235 // (nvim `iX<Esc>iY<Esc>uiZ<Esc>g-uu<C-r><C-r>`). State labels: root "R".
1236 let mut t = UndoTree::new(ropey::Rope::from_str("R"));
1237 t.push(entry("R")); // leave root("R") -> nX(seq1), live "X"
1238 t.push(entry("X")); // leave nX("X") -> nY(seq2), live "Y"
1239 let (r, c, m) = live("Y");
1240 t.undo_step(r, c, m); // stash "Y" into nY, back to nX("X")
1241 t.push(entry("X")); // leave nX("X") -> nZ(seq3), branch, live "Z"
1242 // g- from Z(seq3) -> nY(seq2) "Y".
1243 let (r, c, m) = live("Z");
1244 let y = t.seq_earlier_step(r, c, m).unwrap();
1245 assert_eq!(y.rope.to_string(), "Y");
1246 // u,u back to root.
1247 let (r, c, m) = live("Y");
1248 t.undo_step(r, c, m);
1249 let (r, c, m) = live("X");
1250 t.undo_step(r, c, m);
1251 assert!(t.is_at_root());
1252 // <C-r>,<C-r> retraces the branch we landed on: root->X->Y.
1253 let (r, c, m) = live("R");
1254 let x = t.redo_step(r, c, m).unwrap();
1255 assert_eq!(x.rope.to_string(), "X");
1256 let (r, c, m) = live("X");
1257 let y2 = t.redo_step(r, c, m).unwrap();
1258 assert_eq!(y2.rope.to_string(), "Y");
1259 }
1260
1261 #[test]
1262 fn leaves_lists_branch_tips_by_seq() {
1263 // root -> nX -> nY -> nW (leaf, seq3, depth3) and nX -> nZ (leaf, seq4,
1264 // depth2). Mirrors nvim `iX iY iW uu iZ`.
1265 let mut t = UndoTree::new(ropey::Rope::from_str(""));
1266 t.push(entry("X"));
1267 t.push(entry("Y"));
1268 t.push(entry("W"));
1269 let (r, c, m) = live("W");
1270 t.undo_step(r, c, m);
1271 let (r, c, m) = live("Y");
1272 t.undo_step(r, c, m); // back to nX
1273 t.push(entry("Z")); // nX -> nZ(seq4)
1274 let leaves = t.leaves();
1275 // Two leaves: W(seq3, depth3) and Z(seq4, depth2). Z is current.
1276 let dims: Vec<(u64, usize, bool)> =
1277 leaves.iter().map(|&(s, d, _, cur)| (s, d, cur)).collect();
1278 assert_eq!(dims, vec![(3, 3, false), (4, 2, true)]);
1279 }
1280
1281 #[test]
1282 fn cap_prunes_oldest_from_root_side() {
1283 let mut t = UndoTree::new(ropey::Rope::from_str("s"));
1284 for _ in 0..5 {
1285 t.push(entry("s"));
1286 }
1287 assert_eq!(t.depth(), 5);
1288 t.cap(3);
1289 assert_eq!(t.depth(), 3);
1290 // Redo side untouched (there is none), current unchanged.
1291 assert!(!t.has_redo());
1292 // Two oldest slots were reclaimed.
1293 assert_eq!(t.free.len(), 2);
1294 }
1295
1296 #[test]
1297 fn cap_drops_offpath_leaf_before_main_line() {
1298 // Fork two abandoned branches off the root, then extend the main line,
1299 // and cap: the lowest-seq OFF-PATH leaf must go first, and `current`
1300 // plus its ancestors must survive.
1301 let mut t = UndoTree::new(ropey::Rope::from_str(""));
1302 t.push(entry("A")); // root -> nA(seq1) [abandoned branch tip]
1303 let na = t.current;
1304 let (r, c, m) = live("A");
1305 t.undo_step(r, c, m);
1306 t.push(entry("B")); // root -> nB(seq2) [abandoned branch tip]
1307 let nb = t.current;
1308 let (r, c, m) = live("B");
1309 t.undo_step(r, c, m);
1310 t.push(entry("C")); // root -> nC(seq3), the live main line
1311 let nc = t.current;
1312 // 4 live nodes (root, nA, nB, nC) => 3 states. Cap to 2.
1313 assert_eq!(t.leaves().len(), 3);
1314 t.cap(2);
1315 // The lowest-seq off-path leaf (nA, seq1) was dropped; current (nC) and
1316 // its ancestor (root) survive, and the newer off-path leaf nB survives.
1317 assert!(t.nodes[na].is_none());
1318 assert!(t.nodes[nb].is_some());
1319 assert_eq!(t.current, nc);
1320 assert!(!t.is_at_root());
1321 assert!(t.get(t.root).children.contains(&nb));
1322 assert!(t.get(t.root).children.contains(&nc));
1323 }
1324
1325 #[test]
1326 fn pop_committed_reverses_last_push() {
1327 let mut t = UndoTree::new(ropey::Rope::from_str("s0"));
1328 t.push(entry("s0")); // depth 1, current = fresh leaf
1329 assert_eq!(t.depth(), 1);
1330 assert!(t.pop_committed());
1331 // The just-pushed leaf is gone; current stepped back to the root.
1332 assert_eq!(t.depth(), 0);
1333 assert!(t.is_at_root());
1334 assert_eq!(t.free.len(), 1);
1335 // Seq reclaimed so the next push is gapless.
1336 assert_eq!(t.next_seq, 1);
1337 }
1338
1339 #[test]
1340 fn pop_committed_retains_sibling_branches() {
1341 // Fork a branch, then a no-op push at the fork must pop cleanly without
1342 // orphaning the sibling branch.
1343 let mut t = UndoTree::new(ropey::Rope::from_str(""));
1344 t.push(entry("A")); // root -> nA(seq1)
1345 let na = t.current;
1346 let (r, c, m) = live("A");
1347 t.undo_step(r, c, m); // back to root
1348 t.push(entry("B")); // root -> nB(seq2); root children [nA, nB]
1349 let root = t.root;
1350 // A spurious no-op push at nB, then pop it.
1351 assert!(t.pop_committed());
1352 // nB is gone, current back at root; nA branch still intact & reachable.
1353 assert!(t.get(root).children.contains(&na));
1354 assert_eq!(t.get(root).children.len(), 1);
1355 assert_eq!(t.current, root);
1356 let live = t.nodes.iter().filter(|n| n.is_some()).count();
1357 assert_eq!(live, 2); // root + nA
1358 }
1359
1360 #[test]
1361 fn pop_committed_at_root_is_false() {
1362 let mut t = UndoTree::new(ropey::Rope::from_str("s"));
1363 assert!(!t.pop_committed());
1364 }
1365
1366 #[test]
1367 fn clear_redo_drops_forward_only() {
1368 let mut t = UndoTree::new(ropey::Rope::from_str("s0"));
1369 t.push(entry("s0"));
1370 let (r, c, m) = live("s1");
1371 t.undo_step(r, c, m);
1372 assert!(t.has_redo());
1373 assert_eq!(t.depth(), 0);
1374 t.clear_redo();
1375 assert!(!t.has_redo());
1376 assert_eq!(t.depth(), 0);
1377 }
1378
1379 #[test]
1380 fn clear_all_collapses_to_single_node() {
1381 let mut t = UndoTree::new(ropey::Rope::from_str("s"));
1382 for _ in 0..3 {
1383 t.push(entry("s"));
1384 }
1385 t.clear_all();
1386 assert!(t.is_at_root());
1387 assert!(!t.has_redo());
1388 assert_eq!(t.depth(), 0);
1389 assert_eq!(t.root, t.current);
1390 }
1391}
1392
1393// ─── Phase 3a delta-storage tests ─────────────────────────────────────────────
1394//
1395// Correctness of the reversible delta and the warm/cold materialization is
1396// where text gets silently corrupted, so these lean hard on it: exact diff
1397// round-trips over random (incl. multi-byte) content, every node reconstructing
1398// identically warm and cold, and a random op stream cross-checked against a
1399// full-snapshot reference model kept alongside. All randomness is a deterministic
1400// xorshift seeded from a fixed constant — never `SystemTime`/entropy — so a
1401// failure reproduces exactly.
1402#[cfg(test)]
1403mod delta_tests {
1404 use super::*;
1405
1406 /// Deterministic xorshift64* PRNG, fixed-seeded so runs are reproducible.
1407 struct Rng(u64);
1408 impl Rng {
1409 fn new(seed: u64) -> Self {
1410 // xorshift needs a non-zero state.
1411 Rng(if seed == 0 {
1412 0x9E37_79B9_7F4A_7C15
1413 } else {
1414 seed
1415 })
1416 }
1417 fn next_u64(&mut self) -> u64 {
1418 let mut x = self.0;
1419 x ^= x >> 12;
1420 x ^= x << 25;
1421 x ^= x >> 27;
1422 self.0 = x;
1423 x.wrapping_mul(0x2545_F491_4F6C_DD1D)
1424 }
1425 fn below(&mut self, n: usize) -> usize {
1426 (self.next_u64() % n as u64) as usize
1427 }
1428 }
1429
1430 /// A random char-granular mutation of `s`: insert, delete, or replace a
1431 /// span, drawing from an alphabet that mixes ASCII, accented, CJK, and
1432 /// emoji so multi-byte boundaries are exercised.
1433 fn mutate(s: &str, rng: &mut Rng) -> String {
1434 const ALPHABET: [char; 10] = ['a', 'b', '\n', 'é', '日', '本', '🎉', '語', 'x', 'z'];
1435 let chars: Vec<char> = s.chars().collect();
1436 let pick = |rng: &mut Rng| ALPHABET[rng.below(ALPHABET.len())];
1437 match rng.below(3) {
1438 0 => {
1439 let pos = rng.below(chars.len() + 1);
1440 let mut v = chars.clone();
1441 v.insert(pos, pick(rng));
1442 v.into_iter().collect()
1443 }
1444 1 if !chars.is_empty() => {
1445 let pos = rng.below(chars.len());
1446 let mut v = chars.clone();
1447 v.remove(pos);
1448 v.into_iter().collect()
1449 }
1450 _ => {
1451 if chars.is_empty() {
1452 return pick(rng).to_string();
1453 }
1454 let a = rng.below(chars.len());
1455 let b = (a + rng.below(chars.len() - a + 1)).min(chars.len());
1456 let mut v = chars[..a].to_vec();
1457 v.push(pick(rng));
1458 v.extend_from_slice(&chars[b..]);
1459 v.into_iter().collect()
1460 }
1461 }
1462 }
1463
1464 fn entry_str(s: &str) -> UndoEntry {
1465 UndoEntry {
1466 rope: ropey::Rope::from_str(s),
1467 cursor: (0, 0),
1468 timestamp: SystemTime::now(),
1469 marks: MarkSnapshot::default(),
1470 }
1471 }
1472
1473 // ── (i) delta round-trip: apply(diff(a,b))==b and apply_inverse==a ────────
1474
1475 #[test]
1476 fn diff_round_trips_over_random_evolving_content() {
1477 let mut rng = Rng::new(0x1234_5678_9ABC_DEF0);
1478 let mut s = String::from("seed café 日本語\n🎉");
1479 for _ in 0..4000 {
1480 let t = mutate(&s, &mut rng);
1481 let a = ropey::Rope::from_str(&s);
1482 let b = ropey::Rope::from_str(&t);
1483 let d = diff(&a, &b);
1484 assert_eq!(
1485 apply_forward(&a, &d).to_string(),
1486 t,
1487 "forward a->b failed (start={}, old={:?}, new={:?})",
1488 d.start,
1489 d.old,
1490 d.new
1491 );
1492 assert_eq!(
1493 apply_inverse(&b, &d).to_string(),
1494 s,
1495 "inverse b->a failed (start={}, old={:?}, new={:?})",
1496 d.start,
1497 d.old,
1498 d.new
1499 );
1500 s = t;
1501 }
1502 }
1503
1504 #[test]
1505 fn diff_round_trips_over_unrelated_pairs() {
1506 // Disjoint corpus pairs (not just single-edit neighbours) so the diff's
1507 // prefix/suffix logic is stressed on wholly different multi-byte text.
1508 let corpus = [
1509 "",
1510 "a",
1511 "café\n日本語\n",
1512 "🎉🎉🎉",
1513 "abcdef",
1514 "日本",
1515 "x\ny\nz\n",
1516 "aXb",
1517 "café",
1518 "語日本",
1519 "\n\n\n",
1520 "🎉x🎉y🎉",
1521 ];
1522 let mut rng = Rng::new(0xDEAD_BEEF_CAFE_1234);
1523 for _ in 0..3000 {
1524 let sa = corpus[rng.below(corpus.len())];
1525 let sb = corpus[rng.below(corpus.len())];
1526 let a = ropey::Rope::from_str(sa);
1527 let b = ropey::Rope::from_str(sb);
1528 let d = diff(&a, &b);
1529 assert_eq!(apply_forward(&a, &d).to_string(), sb);
1530 assert_eq!(apply_inverse(&b, &d).to_string(), sa);
1531 }
1532 }
1533
1534 // ── non-ASCII edit → undo → redo round-trip (multi-byte across a leave) ───
1535
1536 #[test]
1537 fn non_ascii_edit_undo_redo_round_trip() {
1538 // Edits land INSIDE multi-byte lines; undo/redo must round-trip the exact
1539 // bytes, proving the char-offset delta never splits a codepoint.
1540 let mut d = Driver::new("café\n日本語\n");
1541 d.edit("cafés\n日本語\n");
1542 d.edit("cafés\n日本語です\n");
1543 d.edit("cafés\n日本語です🎉\n");
1544 assert_eq!(d.undo().as_deref(), Some("cafés\n日本語です\n"));
1545 assert_eq!(d.undo().as_deref(), Some("cafés\n日本語\n"));
1546 assert_eq!(d.undo().as_deref(), Some("café\n日本語\n"));
1547 assert_eq!(d.redo().as_deref(), Some("cafés\n日本語\n"));
1548 assert_eq!(d.redo().as_deref(), Some("cafés\n日本語です\n"));
1549 assert_eq!(d.redo().as_deref(), Some("cafés\n日本語です🎉\n"));
1550 // Cold reconstruction of every node still matches (drop all caches).
1551 assert_warm_equals_cold(&mut d.t);
1552 }
1553
1554 // ── (ii) + (iii) random op stream vs a full-snapshot reference model ──────
1555
1556 #[test]
1557 fn tree_matches_full_snapshot_reference_over_random_ops() {
1558 let mut rng = Rng::new(0x9E37_79B9_7F4A_7C15);
1559 let start = "α\nβγ\n日本🎉\n";
1560 let mut real = UndoTree::new(ropey::Rope::from_str(start));
1561 let mut refr = RefTree::new(start);
1562 let mut live = start.to_string();
1563
1564 for step in 0..6000 {
1565 // Structural predicates stay in lockstep with the reference.
1566 assert_eq!(real.is_at_root(), refr.is_at_root(), "is_at_root @ {step}");
1567 assert_eq!(real.has_redo(), refr.has_redo(), "has_redo @ {step}");
1568 assert_eq!(real.depth(), refr.depth(), "depth @ {step}");
1569
1570 match rng.below(6) {
1571 0 | 1 => {
1572 // Edit: push the PRE-edit state (engine discipline), then
1573 // mutate the live buffer.
1574 let pre = live.clone();
1575 real.push(entry_str(&pre));
1576 refr.push(&pre);
1577 live = mutate(&live, &mut rng);
1578 }
1579 2 => {
1580 let got = real
1581 .undo_step(
1582 ropey::Rope::from_str(&live),
1583 (0, 0),
1584 MarkSnapshot::default(),
1585 )
1586 .map(|e| e.rope.to_string());
1587 let want = refr.undo_step(&live);
1588 assert_eq!(got, want, "undo @ {step}");
1589 if let Some(c) = got {
1590 live = c;
1591 }
1592 }
1593 3 => {
1594 let got = real
1595 .redo_step(
1596 ropey::Rope::from_str(&live),
1597 (0, 0),
1598 MarkSnapshot::default(),
1599 )
1600 .map(|e| e.rope.to_string());
1601 let want = refr.redo_step(&live);
1602 assert_eq!(got, want, "redo @ {step}");
1603 if let Some(c) = got {
1604 live = c;
1605 }
1606 }
1607 4 => {
1608 let got = real
1609 .seq_earlier_step(
1610 ropey::Rope::from_str(&live),
1611 (0, 0),
1612 MarkSnapshot::default(),
1613 )
1614 .map(|e| e.rope.to_string());
1615 let want = refr.seq_earlier_step(&live);
1616 assert_eq!(got, want, "g- @ {step}");
1617 if let Some(c) = got {
1618 live = c;
1619 }
1620 }
1621 _ => {
1622 let got = real
1623 .seq_later_step(
1624 ropey::Rope::from_str(&live),
1625 (0, 0),
1626 MarkSnapshot::default(),
1627 )
1628 .map(|e| e.rope.to_string());
1629 let want = refr.seq_later_step(&live);
1630 assert_eq!(got, want, "g+ @ {step}");
1631 if let Some(c) = got {
1632 live = c;
1633 }
1634 }
1635 }
1636
1637 // (ii) Every so often, assert warm and cold materialization agree
1638 // for every node — a cold-reconstructed node must equal the rope the
1639 // full-snapshot model would have held.
1640 if step % 200 == 0 {
1641 assert_warm_equals_cold(&mut real);
1642 }
1643 }
1644 assert_warm_equals_cold(&mut real);
1645 }
1646
1647 /// For every live node: materialize warm, drop all caches, materialize cold,
1648 /// assert identical. Restores nothing else (test-local).
1649 fn assert_warm_equals_cold(t: &mut UndoTree) {
1650 let ids = t.live_ids();
1651 let warm: Vec<String> = ids
1652 .iter()
1653 .map(|&id| t.materialize_for_test(id).to_string())
1654 .collect();
1655 t.drop_all_caches();
1656 for (i, &id) in ids.iter().enumerate() {
1657 let cold = t.materialize_for_test(id).to_string();
1658 assert_eq!(cold, warm[i], "warm != cold for node {id}");
1659 }
1660 }
1661
1662 /// Engine-faithful driver over the real (delta) [`UndoTree`]: mirrors how
1663 /// `editor.rs` pushes the PRE-edit state and restores returned content.
1664 struct Driver {
1665 t: UndoTree,
1666 live: String,
1667 }
1668 impl Driver {
1669 fn new(s: &str) -> Self {
1670 Driver {
1671 t: UndoTree::new(ropey::Rope::from_str(s)),
1672 live: s.to_string(),
1673 }
1674 }
1675 fn edit(&mut self, new: &str) {
1676 self.t.push(entry_str(&self.live));
1677 self.live = new.to_string();
1678 }
1679 fn undo(&mut self) -> Option<String> {
1680 let e = self.t.undo_step(
1681 ropey::Rope::from_str(&self.live),
1682 (0, 0),
1683 MarkSnapshot::default(),
1684 )?;
1685 self.live = e.rope.to_string();
1686 Some(self.live.clone())
1687 }
1688 fn redo(&mut self) -> Option<String> {
1689 let e = self.t.redo_step(
1690 ropey::Rope::from_str(&self.live),
1691 (0, 0),
1692 MarkSnapshot::default(),
1693 )?;
1694 self.live = e.rope.to_string();
1695 Some(self.live.clone())
1696 }
1697 }
1698
1699 /// Full-snapshot reference tree — Phase 2b's model (a whole rope per node),
1700 /// the oracle the delta tree is cross-checked against. Content only (cursor /
1701 /// marks / timestamps are covered by the existing tree tests).
1702 struct RefNode {
1703 parent: Option<usize>,
1704 children: Vec<usize>,
1705 last_child: Option<usize>,
1706 content: String,
1707 seq: u64,
1708 }
1709 struct RefTree {
1710 nodes: Vec<Option<RefNode>>,
1711 current: usize,
1712 next_seq: u64,
1713 }
1714 impl RefTree {
1715 fn new(s: &str) -> Self {
1716 let root = RefNode {
1717 parent: None,
1718 children: Vec::new(),
1719 last_child: None,
1720 content: s.to_string(),
1721 seq: 0,
1722 };
1723 RefTree {
1724 nodes: vec![Some(root)],
1725 current: 0,
1726 next_seq: 1,
1727 }
1728 }
1729 fn get(&self, id: usize) -> &RefNode {
1730 self.nodes[id].as_ref().unwrap()
1731 }
1732 fn get_mut(&mut self, id: usize) -> &mut RefNode {
1733 self.nodes[id].as_mut().unwrap()
1734 }
1735 fn alloc(&mut self, n: RefNode) -> usize {
1736 self.nodes.push(Some(n));
1737 self.nodes.len() - 1
1738 }
1739 fn is_at_root(&self) -> bool {
1740 self.get(self.current).parent.is_none()
1741 }
1742 fn has_redo(&self) -> bool {
1743 self.get(self.current).last_child.is_some()
1744 }
1745 fn depth(&self) -> usize {
1746 let mut d = 0;
1747 let mut n = self.get(self.current).parent;
1748 while let Some(p) = n {
1749 d += 1;
1750 n = self.get(p).parent;
1751 }
1752 d
1753 }
1754 fn push(&mut self, pre: &str) {
1755 let cur = self.current;
1756 self.get_mut(cur).content = pre.to_string();
1757 let seq = self.next_seq;
1758 self.next_seq += 1;
1759 let child = self.alloc(RefNode {
1760 parent: Some(cur),
1761 children: Vec::new(),
1762 last_child: None,
1763 content: pre.to_string(),
1764 seq,
1765 });
1766 let c = self.get_mut(cur);
1767 c.children.push(child);
1768 c.last_child = Some(child);
1769 self.current = child;
1770 }
1771 fn undo_step(&mut self, live: &str) -> Option<String> {
1772 let cur = self.current;
1773 let par = self.get(cur).parent?;
1774 self.get_mut(cur).content = live.to_string();
1775 self.get_mut(par).last_child = Some(cur);
1776 self.current = par;
1777 Some(self.get(par).content.clone())
1778 }
1779 fn redo_step(&mut self, live: &str) -> Option<String> {
1780 let cur = self.current;
1781 let child = self.get(cur).last_child?;
1782 self.get_mut(cur).content = live.to_string();
1783 self.current = child;
1784 Some(self.get(child).content.clone())
1785 }
1786 fn current_seq(&self) -> u64 {
1787 self.get(self.current).seq
1788 }
1789 fn node_below(&self, s: u64) -> Option<usize> {
1790 let mut best: Option<(u64, usize)> = None;
1791 for (id, slot) in self.nodes.iter().enumerate() {
1792 if let Some(n) = slot
1793 && n.seq < s
1794 && best.is_none_or(|(bs, _)| n.seq > bs)
1795 {
1796 best = Some((n.seq, id));
1797 }
1798 }
1799 best.map(|(_, id)| id)
1800 }
1801 fn node_above(&self, s: u64) -> Option<usize> {
1802 let mut best: Option<(u64, usize)> = None;
1803 for (id, slot) in self.nodes.iter().enumerate() {
1804 if let Some(n) = slot
1805 && n.seq > s
1806 && best.is_none_or(|(bs, _)| n.seq < bs)
1807 {
1808 best = Some((n.seq, id));
1809 }
1810 }
1811 best.map(|(_, id)| id)
1812 }
1813 fn retarget(&mut self, target: usize) {
1814 self.current = target;
1815 let mut node = target;
1816 while let Some(p) = self.get(node).parent {
1817 self.get_mut(p).last_child = Some(node);
1818 node = p;
1819 }
1820 }
1821 fn stash_and_move(&mut self, target: usize, live: &str) {
1822 let cur = self.current;
1823 self.get_mut(cur).content = live.to_string();
1824 self.retarget(target);
1825 }
1826 fn seq_earlier_step(&mut self, live: &str) -> Option<String> {
1827 let target = self.node_below(self.current_seq())?;
1828 self.stash_and_move(target, live);
1829 Some(self.get(target).content.clone())
1830 }
1831 fn seq_later_step(&mut self, live: &str) -> Option<String> {
1832 let target = self.node_above(self.current_seq())?;
1833 self.stash_and_move(target, live);
1834 Some(self.get(target).content.clone())
1835 }
1836 }
1837}
1838
1839// ─── Phase 3b serialize/deserialize tests ─────────────────────────────────────
1840//
1841// The undofile is only as trustworthy as this round-trip: a projection that
1842// loses a branch, mislinks a parent, or reconstructs a node's content wrong
1843// would silently corrupt cross-session undo. These build the headline tree
1844// (5 edits, u, u), project it, rebuild, and assert BOTH the per-node content
1845// (keyed by the stable `seq`) and the live walk (`<C-r>` forward, `u` back)
1846// survive the trip.
1847#[cfg(test)]
1848mod serialize_tests {
1849 use super::*;
1850
1851 fn e(text: &str) -> UndoEntry {
1852 UndoEntry {
1853 rope: ropey::Rope::from_str(text),
1854 cursor: (0, 0),
1855 timestamp: SystemTime::now(),
1856 marks: MarkSnapshot::default(),
1857 }
1858 }
1859 fn l(text: &str) -> (ropey::Rope, (usize, usize), MarkSnapshot) {
1860 (ropey::Rope::from_str(text), (0, 0), MarkSnapshot::default())
1861 }
1862
1863 /// The headline tree: root "s0", five edits to live "s5", then `u` twice so
1864 /// `current` sits on "s3" with the forward branch (s4/s5) retained — exactly
1865 /// the state a `:wq` would persist.
1866 fn headline_tree() -> UndoTree {
1867 let mut t = UndoTree::new(ropey::Rope::from_str("s0"));
1868 for pre in ["s0", "s1", "s2", "s3", "s4"] {
1869 t.push(e(pre)); // engine discipline: push the PRE-edit live state
1870 }
1871 let (r, c, m) = l("s5");
1872 t.undo_step(r, c, m); // -> s4
1873 let (r, c, m) = l("s4");
1874 t.undo_step(r, c, m); // -> s3
1875 t.sync_current(ropey::Rope::from_str("s3")); // stash exact live, like save
1876 t
1877 }
1878
1879 /// Every node's content (keyed by `seq`), materialized cold-then-warm.
1880 fn content_by_seq(t: &mut UndoTree) -> std::collections::BTreeMap<u64, String> {
1881 t.live_ids()
1882 .into_iter()
1883 .map(|id| {
1884 let seq = t.get(id).seq;
1885 (seq, t.materialize_for_test(id).to_string())
1886 })
1887 .collect()
1888 }
1889
1890 #[test]
1891 fn round_trip_reproduces_structure_and_content() {
1892 let mut orig = headline_tree();
1893 let cur_seq = orig.current_node_seq();
1894 let ser = orig.to_serializable();
1895 let orig_content = content_by_seq(&mut orig);
1896
1897 let mut back = UndoTree::from_serializable(&ser).expect("valid projection");
1898 assert_eq!(back.current_node_seq(), cur_seq, "current preserved");
1899 assert_eq!(back.next_seq, orig.next_seq, "next_seq preserved");
1900 // Force cold reconstruction (fresh tree has no warm caches) and compare.
1901 assert_eq!(
1902 content_by_seq(&mut back),
1903 orig_content,
1904 "content at every node reproduced"
1905 );
1906 // Six states: s0..s5.
1907 assert_eq!(orig_content.len(), 6);
1908 assert_eq!(orig_content[&3], "s3");
1909 assert_eq!(orig_content[&5], "s5");
1910 }
1911
1912 #[test]
1913 fn deserialized_tree_walks_forward_and_back() {
1914 let ser = headline_tree().to_serializable();
1915 let mut t = UndoTree::from_serializable(&ser).unwrap();
1916 // `<C-r>` twice: s3 -> s4 -> s5 (the retained forward branch).
1917 let (r, c, m) = l("s3");
1918 assert_eq!(t.redo_step(r, c, m).unwrap().rope.to_string(), "s4");
1919 let (r, c, m) = l("s4");
1920 assert_eq!(t.redo_step(r, c, m).unwrap().rope.to_string(), "s5");
1921 // `u` all the way back to the root.
1922 let mut live = "s5".to_string();
1923 for want in ["s4", "s3", "s2", "s1", "s0"] {
1924 let (r, c, m) = l(&live);
1925 assert_eq!(t.undo_step(r, c, m).unwrap().rope.to_string(), want);
1926 live = want.to_string();
1927 }
1928 assert!(t.is_at_root());
1929 }
1930
1931 #[test]
1932 fn from_serializable_rejects_out_of_range_current() {
1933 let mut ser = headline_tree().to_serializable();
1934 ser.current = ser.nodes.len() as u32; // past the end
1935 assert!(UndoTree::from_serializable(&ser).is_none());
1936 }
1937
1938 #[test]
1939 fn from_serializable_rejects_non_root_missing_delta() {
1940 let mut ser = headline_tree().to_serializable();
1941 // Blank a non-root node's delta ⇒ structurally invalid ⇒ rejected.
1942 let victim = if ser.root == 0 { 1 } else { 0 };
1943 ser.nodes[victim].delta = None;
1944 assert!(UndoTree::from_serializable(&ser).is_none());
1945 }
1946
1947 #[test]
1948 fn multibyte_content_survives_round_trip() {
1949 let mut t = UndoTree::new(ropey::Rope::from_str("café\n日本語"));
1950 t.push(e("café\n日本語"));
1951 t.push(e("cafés\n日本語"));
1952 t.sync_current(ropey::Rope::from_str("cafés\n日本語です🎉"));
1953 let want = content_by_seq(&mut t);
1954 let ser = t.to_serializable();
1955 let mut back = UndoTree::from_serializable(&ser).unwrap();
1956 assert_eq!(content_by_seq(&mut back), want);
1957 }
1958}