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/transaction.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/transaction.rs')
| -rw-r--r-- | src/buffer/transaction.rs | 424 |
1 files changed, 424 insertions, 0 deletions
diff --git a/src/buffer/transaction.rs b/src/buffer/transaction.rs new file mode 100644 index 0000000..b58bed9 --- /dev/null +++ b/src/buffer/transaction.rs @@ -0,0 +1,424 @@ +//! Change sets and transactions — after Helix's `helix-core/src/transaction.rs`. +//! +//! Every buffer modification is expressed as a [`Transaction`]. Undo is not a +//! separate mechanism: it is the *inverse* transaction, computed against the +//! document as it stood before the change (MJB-HLR-011). +//! +//! All counts are **byte** lengths. + +use ropey::Rope; + +use super::selection::Selection; + +/// MJB-LLR-040 +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Operation { + /// Leave `n` bytes untouched. + Retain(usize), + /// Remove `n` bytes. + Delete(usize), + /// Insert this text. + Insert(String), +} + +impl Operation { + /// Bytes of the *pre-application* document this operation consumes. + fn consumed(&self) -> usize { + match self { + Operation::Retain(n) | Operation::Delete(n) => *n, + Operation::Insert(_) => 0, + } + } + + /// Bytes this operation contributes to the *post-application* document. + fn produced(&self) -> usize { + match self { + Operation::Retain(n) => *n, + Operation::Delete(_) => 0, + Operation::Insert(s) => s.len(), + } + } +} + +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum ChangeError { + #[error("change set expects a document of {expected} bytes, got {actual}")] + LengthMismatch { expected: usize, actual: usize }, + #[error("operation boundary at byte {0} is not a character boundary")] + NonCharBoundary(usize), + #[error("operation at byte {0} extends past the end of the document")] + OutOfBounds(usize), +} + +/// MJB-LLR-041 +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct ChangeSet { + changes: Vec<Operation>, + /// Document length this change set requires before application. + len: usize, + /// Document length after application. + len_after: usize, +} + +impl ChangeSet { + pub fn new(rope: &Rope) -> Self { + let len = rope.len(); + Self { + changes: Vec::new(), + len, + len_after: len, + } + } + + pub fn from_ops(ops: Vec<Operation>) -> Self { + let len = ops.iter().map(Operation::consumed).sum(); + let len_after = ops.iter().map(Operation::produced).sum(); + Self { + changes: ops, + len, + len_after, + } + } + + pub fn ops(&self) -> &[Operation] { + &self.changes + } + + pub fn len(&self) -> usize { + self.len + } + + pub fn len_after(&self) -> usize { + self.len_after + } + + pub fn is_empty(&self) -> bool { + self.changes + .iter() + .all(|op| matches!(op, Operation::Retain(_))) + } + + /// MJB-LLR-042, MJB-LLR-043, MJB-LLR-044: apply to `rope`. + /// + /// Validation runs to completion *before* any mutation, so a rejected + /// change set leaves the rope untouched rather than half-applied. + pub fn apply(&self, rope: &mut Rope) -> Result<(), ChangeError> { + // MJB-LLR-042 + if rope.len() != self.len { + return Err(ChangeError::LengthMismatch { + expected: self.len, + actual: rope.len(), + }); + } + + // MJB-LLR-044: verify every boundary first. ropey panics on a non-char + // boundary, and a panic is not an acceptable failure mode (MJB-DR-002). + let mut probe = 0usize; + for op in &self.changes { + if !rope.is_char_boundary(probe) { + return Err(ChangeError::NonCharBoundary(probe)); + } + probe += op.consumed(); + if probe > rope.len() { + return Err(ChangeError::OutOfBounds(probe)); + } + } + if !rope.is_char_boundary(probe) { + return Err(ChangeError::NonCharBoundary(probe)); + } + + // MJB-LLR-043: apply front-to-back in a single pass. + // + // `pos` tracks the cursor in the *output*, which is what makes this + // work without buffering the edits or walking backwards: `Retain` + // advances over text present in both images, `Delete` removes at `pos` + // and so leaves it pointing at the next surviving byte, and `Insert` + // advances past what it added. Later offsets therefore stay valid as + // the rope shifts beneath them. + let mut pos = 0usize; + for op in &self.changes { + match op { + Operation::Retain(n) => pos += n, + Operation::Delete(n) => rope.remove(pos..pos + n), + Operation::Insert(s) => { + rope.insert(pos, s); + pos += s.len(); + } + } + } + + debug_assert_eq!(rope.len(), self.len_after); + Ok(()) + } + + /// MJB-LLR-045: the change set that undoes this one. + /// + /// MJB-LLR-046: applying this change set and then its inverse reproduces + /// the original contents exactly — the property undo rests on. + /// + /// `original` must be the document as it stood *before* this change set was + /// applied — deleted text is recovered from it. + pub fn invert(&self, original: &Rope) -> ChangeSet { + let mut ops = Vec::with_capacity(self.changes.len()); + let mut pos = 0usize; + + for op in &self.changes { + match op { + Operation::Retain(n) => { + ops.push(Operation::Retain(*n)); + pos += n; + } + Operation::Delete(n) => { + let text: String = original.slice(pos..pos + n).chunks().collect(); + ops.push(Operation::Insert(text)); + pos += n; + } + Operation::Insert(s) => ops.push(Operation::Delete(s.len())), + } + } + + ChangeSet { + changes: ops, + len: self.len_after, + len_after: self.len, + } + } +} + +/// MJB-LLR-047: a change set plus the selection that should result from it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Transaction { + pub changes: ChangeSet, + pub selection: Option<Selection>, +} + +impl Transaction { + pub fn new(changes: ChangeSet) -> Self { + Self { + changes, + selection: None, + } + } + + pub fn with_selection(mut self, selection: Selection) -> Self { + self.selection = Some(selection); + self + } + + /// MJB-LLR-048: build from `(from, to, Option<text>)` triples, which must + /// arrive ordered by ascending `from` and must not overlap. + pub fn change<I>(rope: &Rope, changes: I) -> Self + where + I: IntoIterator<Item = (usize, usize, Option<String>)>, + { + let mut ops = Vec::new(); + let mut pos = 0usize; + + for (from, to, text) in changes { + if from > pos { + ops.push(Operation::Retain(from - pos)); + } + if to > from { + ops.push(Operation::Delete(to - from)); + } + if let Some(s) = text + && !s.is_empty() + { + ops.push(Operation::Insert(s)); + } + pos = to.max(from); + } + + let len = rope.len(); + if pos < len { + ops.push(Operation::Retain(len - pos)); + } + + Self::new(ChangeSet::from_ops(ops)) + } + + /// MJB-LLR-049: insert `text` at the selection's cursor. + pub fn insert(rope: &Rope, selection: &Selection, text: &str) -> Self { + let at = selection.primary().cursor(rope.slice(..)); + Self::change(rope, [(at, at, Some(text.to_owned()))]) + } + + /// MJB-LLR-050: delete the selection's primary span. + pub fn delete(rope: &Rope, selection: &Selection) -> Self { + let r = selection.primary(); + Self::change(rope, [(r.from(), r.to(), None)]) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn rope(s: &str) -> Rope { + Rope::from_str(s) + } + + /// MJB-LLR-040: operation counts are byte lengths, so a multi-byte + /// character contributes its byte width. + #[test] + fn mjb_llr_040_operation_counts_are_byte_lengths() { + let insert = Operation::Insert("文".to_owned()); + assert_eq!(insert.produced(), 3, "one character, three bytes"); + assert_eq!(insert.consumed(), 0, "an insert consumes no input"); + + assert_eq!(Operation::Retain(4).consumed(), 4); + assert_eq!(Operation::Retain(4).produced(), 4); + assert_eq!(Operation::Delete(4).consumed(), 4); + assert_eq!(Operation::Delete(4).produced(), 0); + } + + /// MJB-LLR-041: `len` is the required pre-image length, `len_after` the + /// post-image length. + #[test] + fn mjb_llr_041_changeset_records_both_lengths() { + let r = rope("abcdef"); + // Replace two bytes with three. + let t = Transaction::change(&r, [(1, 3, Some("XYZ".into()))]); + assert_eq!(t.changes.len(), 6, "must match the source document"); + assert_eq!(t.changes.len_after(), 7, "6 - 2 + 3"); + + let mut m = r.clone(); + t.changes.apply(&mut m).unwrap(); + assert_eq!(m.len(), t.changes.len_after()); + } + + #[test] + fn mjb_llr_041_empty_changeset_reports_equal_lengths() { + let r = rope("abc"); + let cs = ChangeSet::new(&r); + assert_eq!(cs.len(), 3); + assert_eq!(cs.len_after(), 3); + assert!(cs.is_empty()); + } + + /// MJB-LLR-047: a transaction carries the selection that should result. + #[test] + fn mjb_llr_047_transaction_carries_a_selection() { + use super::super::selection::Range; + let r = rope("abcdef"); + let t = Transaction::change(&r, [(0, 1, None)]); + assert_eq!(t.selection, None, "none by default"); + + let sel = Selection::single(Range::new(0, 2)); + let t = t.with_selection(sel.clone()); + assert_eq!(t.selection, Some(sel)); + } + + #[test] + fn mjb_llr_043_apply_insert_and_delete() { + let mut r = rope("hello world"); + let t = Transaction::change(&r, [(0, 5, Some("goodbye".into()))]); + t.changes.apply(&mut r).unwrap(); + assert_eq!(r.to_string(), "goodbye world"); + } + + #[test] + fn mjb_llr_042_length_mismatch_is_rejected() { + let r = rope("abcdef"); + let t = Transaction::change(&r, [(0, 1, None)]); + let mut other = rope("shorter than expected? no — different"); + let err = t.changes.apply(&mut other).unwrap_err(); + assert!(matches!(err, ChangeError::LengthMismatch { .. })); + } + + #[test] + fn mjb_llr_042_rejected_change_leaves_rope_untouched() { + let r = rope("abcdef"); + let t = Transaction::change(&r, [(0, 3, Some("xyz".into()))]); + let mut other = rope("12345678"); + let before = other.to_string(); + assert!(t.changes.apply(&mut other).is_err()); + assert_eq!(other.to_string(), before, "must not partially apply"); + } + + #[test] + fn mjb_llr_044_non_char_boundary_errors_rather_than_panics() { + // Split "文" (3 bytes) after its first byte. + let mut r = rope("文"); + let cs = ChangeSet::from_ops(vec![Operation::Retain(1), Operation::Delete(2)]); + let err = cs.apply(&mut r).unwrap_err(); + assert_eq!(err, ChangeError::NonCharBoundary(1)); + assert_eq!(r.to_string(), "文", "rope must be unchanged"); + } + + #[test] + fn mjb_llr_045_invert_maps_each_operation() { + let r = rope("abcdef"); + let t = Transaction::change(&r, [(1, 3, Some("XY".into()))]); + let inv = t.changes.invert(&r); + // Delete(2) became Insert("bc"); Insert("XY") became Delete(2). + assert!(inv.ops().contains(&Operation::Insert("bc".into()))); + assert!(inv.ops().contains(&Operation::Delete(2))); + } + + #[test] + fn mjb_llr_046_apply_then_invert_round_trips() { + for (text, from, to, ins) in [ + ("hello world", 0usize, 5usize, Some("goodbye")), + ("hello world", 5, 11, None), + ("", 0, 0, Some("new")), + ("文字化け", 0, 3, Some("X")), + ("no trailing newline", 3, 3, Some(" inserted")), + ] { + let original = rope(text); + let mut r = original.clone(); + let t = Transaction::change(&r, [(from, to, ins.map(str::to_owned))]); + let inverse = t.changes.invert(&original); + + t.changes.apply(&mut r).unwrap(); + inverse.apply(&mut r).unwrap(); + + assert_eq!( + r.to_string(), + original.to_string(), + "round trip failed for {text:?}" + ); + } + } + + #[test] + fn mjb_llr_048_multiple_ordered_changes() { + let mut r = rope("aaa bbb ccc"); + let t = Transaction::change(&r, [(0, 3, Some("XXX".into())), (8, 11, Some("ZZZ".into()))]); + t.changes.apply(&mut r).unwrap(); + assert_eq!(r.to_string(), "XXX bbb ZZZ"); + } + + #[test] + fn mjb_llr_049_insert_at_cursor() { + let r = rope("ab"); + let sel = Selection::point(1); + let mut m = r.clone(); + Transaction::insert(&r, &sel, "X") + .changes + .apply(&mut m) + .unwrap(); + assert_eq!(m.to_string(), "aXb"); + } + + #[test] + fn mjb_llr_050_delete_selection_span() { + use super::super::selection::Range; + let r = rope("abcdef"); + let sel = Selection::single(Range::new(1, 4)); + let mut m = r.clone(); + Transaction::delete(&r, &sel) + .changes + .apply(&mut m) + .unwrap(); + assert_eq!(m.to_string(), "aef"); + } + + #[test] + fn empty_document_accepts_insert() { + let mut r = rope(""); + let t = Transaction::change(&r, [(0, 0, Some("x".into()))]); + t.changes.apply(&mut r).unwrap(); + assert_eq!(r.to_string(), "x"); + } +} |
