diff options
| author | rottedfm <rottedfm@proton.me> | 2026-08-19 11:24:55 -0400 |
|---|---|---|
| committer | rottedfm <rottedfm@proton.me> | 2026-08-19 11:24:55 -0400 |
| commit | 8e16347b0eb329e84892af8ece36886324c95f62 (patch) | |
| tree | be1267972b5de2f1ae592577dfabce67f1fe6e87 /src/buffer/history.rs | |
| parent | c6ae4660d1cc2414b22c492c5e819d009c8187c2 (diff) | |
| parent | ea0bd36167b684c0accdb5ce2b2e21b8d84aeb25 (diff) | |
Establishes the first working baseline: moji <file> opens a file into a
ropey rope and edits it with Helix selection-first modal editing, under a
DO-178C DAL-C requirements and traceability process.
Prior to this, main tracked four files and src/main.rs was still
println!("Hello, world!") — there was no buildable state to build on.
Verified on a fresh clone of the branch with no untracked files:
cargo build; clippy --all-targets -D warnings clean; 294 tests passing;
scripts/check-trace.sh reports 98/98 requirements traced in both
directions.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'src/buffer/history.rs')
| -rw-r--r-- | src/buffer/history.rs | 228 |
1 files changed, 228 insertions, 0 deletions
diff --git a/src/buffer/history.rs b/src/buffer/history.rs new file mode 100644 index 0000000..e4668fc --- /dev/null +++ b/src/buffer/history.rs @@ -0,0 +1,228 @@ +//! Undo/redo history (MJB-HLR-011). +//! +//! Each committed edit stores the pair (forward transaction, inverse +//! transaction). `cursor` is the number of entries currently *applied*, so +//! entries at or beyond it have been reverted and are available to redo. +//! +//! Undo at the start and redo at the end are no-ops, not errors — reaching +//! either end is ordinary use, not a fault (MJB-LLR-052, MJB-LLR-053). + +use super::transaction::Transaction; + +#[derive(Debug, Clone)] +struct Entry { + forward: Transaction, + inverse: Transaction, + /// Identifies the buffer state this entry produces. Never reused. + id: usize, +} + +#[derive(Debug, Clone, Default)] +pub struct History { + entries: Vec<Entry>, + /// Number of entries applied; also the index of the next redo. + cursor: usize, + /// Monotonic source of entry ids. + /// + /// Deliberately *not* the same thing as `cursor`. Using stack depth to + /// identify a state is wrong: saving at depth 3, undoing, then making a + /// different edit returns to depth 3 while the content differs, so a + /// depth comparison would report the buffer clean when it is not. + next_id: usize, +} + +impl History { + pub fn new() -> Self { + Self::default() + } + + /// MJB-LLR-051: record an applied edit. + /// + /// Anything previously undone is discarded — committing a new edit after an + /// undo abandons the branch that was reverted. + pub fn commit(&mut self, forward: Transaction, inverse: Transaction) { + self.entries.truncate(self.cursor); + self.next_id += 1; + self.entries.push(Entry { + forward, + inverse, + id: self.next_id, + }); + self.cursor = self.entries.len(); + } + + /// MJB-LLR-052: the transaction that reverts the most recent edit, or + /// `None` when nothing remains to undo. + pub fn undo(&mut self) -> Option<&Transaction> { + if self.cursor == 0 { + return None; + } + self.cursor -= 1; + Some(&self.entries[self.cursor].inverse) + } + + /// MJB-LLR-053: the transaction that reapplies the most recently undone + /// edit, or `None` when nothing remains to redo. + pub fn redo(&mut self) -> Option<&Transaction> { + if self.cursor >= self.entries.len() { + return None; + } + let t = &self.entries[self.cursor].forward; + self.cursor += 1; + Some(t) + } + + /// Identifies the current buffer state. + /// + /// Zero means pristine — no edit applied. Otherwise it is the id of the + /// most recently applied entry. Two calls return the same value exactly + /// when the buffer content is the same, which is what lets "modified" be + /// *derived* rather than latched; see + /// [`super::document::Document::is_modified`]. + pub fn revision(&self) -> usize { + match self.cursor { + 0 => 0, + n => self.entries[n - 1].id, + } + } + + pub fn can_undo(&self) -> bool { + self.cursor > 0 + } + + pub fn can_redo(&self) -> bool { + self.cursor < self.entries.len() + } + + pub fn len(&self) -> usize { + self.entries.len() + } + + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } +} + +#[cfg(test)] +mod tests { + use ropey::Rope; + + use super::*; + use crate::buffer::transaction::Transaction; + + fn edit(text: &str, from: usize, to: usize, ins: Option<&str>) -> (Transaction, Transaction) { + let r = Rope::from_str(text); + let t = Transaction::change(&r, [(from, to, ins.map(str::to_owned))]); + let inv = Transaction::new(t.changes.invert(&r)); + (t, inv) + } + + #[test] + fn mjb_llr_052_undo_past_start_is_a_noop() { + let mut h = History::new(); + assert!(h.undo().is_none()); + assert!(!h.can_undo()); + // Repeated attempts must stay safe, not underflow the cursor. + assert!(h.undo().is_none()); + assert!(h.undo().is_none()); + } + + #[test] + fn mjb_llr_053_redo_past_end_is_a_noop() { + let mut h = History::new(); + let (f, i) = edit("abc", 0, 1, None); + h.commit(f, i); + assert!(h.redo().is_none(), "nothing has been undone yet"); + assert!(!h.can_redo()); + } + + #[test] + fn mjb_llr_051_commit_then_undo_then_redo() { + let mut h = History::new(); + let (f, i) = edit("abc", 0, 1, None); + h.commit(f, i); + + assert!(h.can_undo()); + assert!(h.undo().is_some()); + assert!(!h.can_undo()); + assert!(h.can_redo()); + assert!(h.redo().is_some()); + assert!(!h.can_redo()); + } + + #[test] + fn mjb_llr_051_commit_after_undo_discards_the_redo_branch() { + let mut h = History::new(); + let (f1, i1) = edit("abc", 0, 1, None); + let (f2, i2) = edit("bc", 0, 1, None); + h.commit(f1, i1); + h.commit(f2, i2); + + h.undo(); + assert!(h.can_redo()); + + let (f3, i3) = edit("bc", 1, 2, None); + h.commit(f3, i3); + assert!(!h.can_redo(), "the undone branch must be discarded"); + assert_eq!(h.len(), 2); + } + + /// MJB-LLR-116: a revision identifies *content*, not stack depth. + /// + /// Regression guard for the trap this replaced: save at depth 2, undo, then + /// make a different edit. The cursor returns to 2, but the buffer no longer + /// matches what was saved, so the revision must differ. + #[test] + fn mjb_llr_116_revision_is_not_stack_depth() { + let mut h = History::new(); + let (f1, i1) = edit("abcdef", 0, 1, None); + let (f2, i2) = edit("bcdef", 0, 1, None); + h.commit(f1, i1); + h.commit(f2, i2); + + let saved = h.revision(); + + h.undo(); + assert_ne!(h.revision(), saved, "undo leaves a different state"); + + // A different second edit, landing at the same stack depth. + let (f3, i3) = edit("bcdef", 1, 2, None); + h.commit(f3, i3); + assert_eq!(h.len(), 2, "same depth as when we saved"); + assert_ne!( + h.revision(), + saved, + "same depth, different content: must not look saved" + ); + } + + #[test] + fn mjb_llr_116_revision_is_zero_when_pristine_and_returns_on_undo() { + let mut h = History::new(); + assert_eq!(h.revision(), 0); + + let (f, i) = edit("abc", 0, 1, None); + h.commit(f, i); + let after = h.revision(); + assert_ne!(after, 0); + + h.undo(); + assert_eq!(h.revision(), 0, "undoing to pristine returns to revision 0"); + h.redo(); + assert_eq!(h.revision(), after, "redo restores the same revision"); + } + + #[test] + fn mjb_llr_052_undo_walks_back_in_order() { + let mut h = History::new(); + for n in 0..3 { + let (f, i) = edit("abcdef", n, n + 1, None); + h.commit(f, i); + } + assert_eq!(h.len(), 3); + for _ in 0..3 { + assert!(h.undo().is_some()); + } + assert!(h.undo().is_none(), "exhausted"); + } +} |
