diff options
| author | rottedfm <rottedfm@proton.me> | 2026-08-19 11:21:47 -0400 |
|---|---|---|
| committer | rottedfm <rottedfm@proton.me> | 2026-08-19 11:21:47 -0400 |
| commit | ea0bd36167b684c0accdb5ce2b2e21b8d84aeb25 (patch) | |
| tree | be1267972b5de2f1ae592577dfabce67f1fe6e87 /src/buffer/history.rs | |
| parent | 8c0b4c53b130555f040884c1f52b90f16b23e241 (diff) | |
feat: implement Helix-style modal buffer under DO-178C DAL-C
The repository was an unmodified ratatui component template: no editor
code, JSON5 config, and placeholder widgets. This establishes the first
working baseline — `moji <file>` opens a file into a ropey rope and edits
it with Helix selection-first semantics.
Requirements, implementation and tests land together because they must:
the traceability check rejects requirements with no implementation and
tests naming requirements that do not exist, so neither half is a valid
commit on its own.
Package renamed to mojibake-editor (mojibake was taken on crates.io);
binary is moji, library target stays mojibake.
Class: New behaviour
Requirements: MJB-HLR-001..019, MJB-LLR-001..205
Derived: MJB-DR-001..007 (DR-001 resolved, six open for review)
Verified: cargo build; clippy --all-targets -D warnings clean;
cargo test 294 passing; ./scripts/check-trace.sh 98/98/98;
cargo package clean
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"); + } +} |
