aboutsummaryrefslogtreecommitdiff
path: root/src/buffer/history.rs
diff options
context:
space:
mode:
authorrottedfm <rottedfm@proton.me>2026-08-19 11:24:55 -0400
committerrottedfm <rottedfm@proton.me>2026-08-19 11:24:55 -0400
commit8e16347b0eb329e84892af8ece36886324c95f62 (patch)
treebe1267972b5de2f1ae592577dfabce67f1fe6e87 /src/buffer/history.rs
parentc6ae4660d1cc2414b22c492c5e819d009c8187c2 (diff)
parentea0bd36167b684c0accdb5ce2b2e21b8d84aeb25 (diff)
Merge branch 'buffer-implementation'HEADmain
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.rs228
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");
+ }
+}