diff --git a/crates/helm-text/src/buffer_set.rs b/crates/helm-text/src/buffer_set.rs index 66021bc..5cabf4a 100644 --- a/crates/helm-text/src/buffer_set.rs +++ b/crates/helm-text/src/buffer_set.rs @@ -8,8 +8,8 @@ use std::sync::Arc; use std::sync::atomic::{AtomicU64, Ordering}; use crate::{ - Buffer, BufferId, HistoryError, Snapshot, TextBuffer, Transaction, TransactionError, - TransactionId, Version, + AmendError, Buffer, BufferId, HistoryError, Snapshot, TextBuffer, Transaction, + TransactionError, TransactionId, Version, }; /// Buffers that edit, undo and redo through transactions, including @@ -17,8 +17,9 @@ use crate::{ /// /// The set owns its buffers. [`get`](Self::get) lends them out read-only, /// and the only way to change one is [`apply`](Self::apply), -/// [`undo`](Self::undo) or [`redo`](Self::redo). So no buffer can change -/// behind the history's back, and the rules below hold by construction. +/// [`amend`](Self::amend), [`undo`](Self::undo) or [`redo`](Self::redo). So +/// no buffer can change behind the history's back, and the rules below hold +/// by construction. /// /// # Applying a transaction /// @@ -291,6 +292,98 @@ impl BufferSet { Ok(TransactionId(id)) } + /// Folds `transaction`'s edits into the applied transaction `id`, so one + /// undo reverts both and one redo brings both back. + /// + /// This is how a run of keystrokes becomes one undo step (ED-2): the + /// editor applies the first keystroke as a transaction and amends it + /// with each keystroke after that. Every keystroke changes the buffer at + /// once, so language servers and agents, which read the buffer, see it. + /// + /// # When amending is allowed + /// + /// `id` must be applied, not undone, and must be on top of the undo + /// history of every buffer `transaction` touches. `transaction` may only + /// touch buffers `id` touched, though it needn't touch all of them. A + /// later transaction on top of a buffer that `transaction` doesn't touch + /// doesn't stop the amend. It still blocks undoing `id`, as the + /// [type docs](Self) describe. + /// + /// # Offsets + /// + /// Every edit's range is in bytes of its buffer's text after `id`, which + /// is the buffer's current text, since `id` is on top of its undo + /// history. Within `transaction`, ranges refer to that text, not to the + /// text after the edits listed before them, and edits are placed and + /// must not overlap exactly as for [`apply`](Self::apply). + /// + /// # Checks + /// + /// `amend` checks everything before it changes anything, in this order, + /// and returns the first problem it finds: + /// + /// 1. `id` must be in some history of the set + /// ([`AmendError::UnknownTransaction`]). An id from another set, or + /// of a transaction a later `apply` or `amend` dropped from the redo + /// histories, fails here. + /// 2. `id` must not be undone ([`AmendError::Undone`]). + /// 3. `transaction` must have edits + /// ([`AmendError::Transaction`] with [`TransactionError::Empty`]). + /// 4. For each edit in order: its buffer must be in the set + /// ([`TransactionError::UnknownBuffer`]); then `id` must have touched + /// it ([`AmendError::Untouched`]); then `id` must be on top of its + /// undo history ([`AmendError::NotOnTop`]); then its range must be + /// valid in the buffer's current text, checked the way + /// [`TextBuffer::slice`] documents + /// ([`TransactionError::InvalidRange`]); then it must not overlap an + /// earlier edit on the same buffer ([`TransactionError::Overlap`], + /// naming the earliest such edit). The [`TransactionError`]s come + /// wrapped in [`AmendError::Transaction`], with the same edit indexes + /// `apply` would give. + /// + /// If any check fails, no buffer, history or version changes. + /// + /// # What an amend does + /// + /// - Each buffer `transaction` touches gets its edits, exactly as if + /// `apply` had applied them. + /// - `id` keeps its id and its place in every history. + /// [`undo`](Self::undo) and [`redo`](Self::redo) return `id` as + /// before, and `amend` uses up no new id. + /// - Undoing `id` from any buffer it touched restores every one of those + /// buffers, byte for byte, to its text from before `id` was first + /// applied. Redoing it restores each one to its text after the last + /// amend. Buffers `id` touched that no amend touched get back the text + /// `id` alone gave them. + /// - The redo histories of the buffers `transaction` touches are + /// dropped, with the same knock-on drops as for `apply`. + /// - The version of each buffer `transaction` touches goes up by exactly + /// one, however many edits it had there, so snapshots taken before go + /// stale. Other buffers keep their versions, including ones `id` + /// touched that this amend didn't. A later undo or redo of `id` adds + /// one to every buffer `id` touched, as for any transaction. + /// + /// An amend whose edits leave the text as it was, such as an empty + /// insert, still counts: it drops redo and bumps versions. + /// + /// # Cost + /// + /// An amend costs about what applying `transaction` costs, plus work in + /// proportion to the changes `id` already holds for the buffers it + /// touches. It doesn't grow with the buffer's length, apart from the + /// text the edits replace, and it doesn't keep a list of amends to + /// replay: after any number of amends, undoing `id` is one change per + /// buffer, so a run of typed characters undoes like one insert. + /// + /// [`TextBuffer::slice`]: crate::TextBuffer::slice + #[expect( + unused_variables, + reason = "T-110 interface stub; the implementation deletes this" + )] + pub fn amend(&mut self, id: TransactionId, transaction: Transaction) -> Result<(), AmendError> { + todo!() + } + /// Reverts the transaction on top of `buffer`'s undo history in every /// buffer it touched, and returns its id. Returns `Ok(None)` if /// `buffer`'s undo history is empty. diff --git a/crates/helm-text/src/error.rs b/crates/helm-text/src/error.rs index c2db922..9b0b99b 100644 --- a/crates/helm-text/src/error.rs +++ b/crates/helm-text/src/error.rs @@ -196,3 +196,75 @@ impl fmt::Display for HistoryError { } impl std::error::Error for HistoryError {} + +/// Why [`BufferSet::amend`](crate::BufferSet::amend) rejected an amendment. +/// +/// `edit` is an index into [`Transaction::edits`](crate::Transaction::edits) +/// of the transaction passed to `amend`. A rejected amendment changes +/// nothing. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum AmendError { + /// The transaction isn't in any undo or redo history of the set. It was + /// never applied in this set, or a later transaction dropped it from the + /// redo histories. + UnknownTransaction { + /// The transaction that was asked for. + transaction: TransactionId, + }, + /// The transaction was undone and hasn't been redone, so it sits in the + /// redo histories. + Undone { + /// The transaction that was asked for. + transaction: TransactionId, + }, + /// An edit names a buffer in the set that the transaction didn't touch. + Untouched { + /// The edit's index. + edit: usize, + /// The buffer it names. + buffer: BufferId, + }, + /// An edit names a buffer whose undo history has a later transaction on + /// top of the one being amended. + NotOnTop { + /// The edit's index. + edit: usize, + /// The buffer it names. + buffer: BufferId, + }, + /// The edits fail one of the checks + /// [`BufferSet::apply`](crate::BufferSet::apply) makes, run against the + /// text after the transaction being amended. + Transaction(TransactionError), +} + +impl fmt::Display for AmendError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match *self { + Self::UnknownTransaction { transaction } => { + write!(f, "{transaction:?} isn't in any history of the set") + } + Self::Undone { transaction } => { + write!(f, "{transaction:?} was undone, so it can't be amended") + } + Self::Untouched { edit, buffer } => write!( + f, + "edit {edit} names {buffer:?}, which the transaction didn't touch" + ), + Self::NotOnTop { edit, buffer } => write!( + f, + "edit {edit} names {buffer:?}, which has a later transaction on top" + ), + Self::Transaction(error) => write!(f, "{error}"), + } + } +} + +impl std::error::Error for AmendError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Transaction(error) => Some(error), + _ => None, + } + } +} diff --git a/crates/helm-text/src/lib.rs b/crates/helm-text/src/lib.rs index 430d2f8..df9d776 100644 --- a/crates/helm-text/src/lib.rs +++ b/crates/helm-text/src/lib.rs @@ -75,7 +75,7 @@ pub use encoding::{ ByteOrder, DecodeError, Decoded, FileFormat, LineEnding, LineEndingStyle, LineEndings, UTF16_SAMPLE_LEN, }; -pub use error::{HistoryError, TextError, TransactionError}; +pub use error::{AmendError, HistoryError, TextError, TransactionError}; pub use snapshot::{Snapshot, Version}; pub use transaction::{BufferId, Edit, Transaction, TransactionId}; diff --git a/crates/helm-text/src/snapshot.rs b/crates/helm-text/src/snapshot.rs index 4575ef2..1b3dec9 100644 --- a/crates/helm-text/src/snapshot.rs +++ b/crates/helm-text/src/snapshot.rs @@ -15,10 +15,12 @@ use crate::{Buffer, BufferId, Point, TextBuffer, TextError, Utf16Point}; /// A buffer starts at version 0 when [`BufferSet::add`] adds it. Each /// successful [`apply`](BufferSet::apply), [`undo`](BufferSet::undo) and /// [`redo`](BufferSet::redo) adds exactly one to the version of every buffer -/// the transaction touched, however many edits it had there. A transaction -/// whose edits leave the text as it was still counts. Nothing else changes a -/// version: not a failed `apply`, not an undo or redo that returns -/// `Ok(None)` or an error, and not [`get`](BufferSet::get), +/// the transaction touched, however many edits it had there. Each +/// successful [`amend`](crate::BufferSet::amend) adds one to the version of +/// every buffer its new edits touched. A transaction or amend whose edits +/// leave the text as it was still counts. Nothing else changes a version: not a failed `apply` or +/// `amend`, not an undo or redo that returns `Ok(None)` or an error, and not +/// [`get`](BufferSet::get), /// [`version`](BufferSet::version) or [`snapshot`](BufferSet::snapshot). /// Buffers a transaction didn't touch keep their versions. /// diff --git a/crates/helm-text/tests/amend.rs b/crates/helm-text/tests/amend.rs new file mode 100644 index 0000000..de79124 --- /dev/null +++ b/crates/helm-text/tests/amend.rs @@ -0,0 +1,944 @@ +//! T-110 acceptance tests: `BufferSet::amend` folds new edits into an +//! applied transaction, so a run of keystrokes undoes in one step. Covers +//! offsets, multi-buffer amends, when an amend is allowed, the errors and +//! their order, redo dropping, versions and snapshots. + +use std::ops::Range; + +use helm_text::{ + AmendError, Buffer, BufferId, BufferSet, Edit, HistoryError, TextBuffer, TextError, + Transaction, TransactionError, TransactionId, +}; + +/// The range `start..end`, which may start after it ends. Built from +/// values so clippy's `reversed_empty_ranges` doesn't reject the literal. +fn reversed(start: usize, end: usize) -> Range { + start..end +} + +/// A set holding one buffer per text, and their ids in the same order. +fn set_of(texts: &[&str]) -> (BufferSet, Vec) { + let mut set = BufferSet::new(); + let ids = texts.iter().map(|&t| set.add(Buffer::from(t))).collect(); + (set, ids) +} + +/// The text of buffer `id`. +fn text(set: &BufferSet, id: BufferId) -> String { + set.get(id).expect("buffer is in the set").to_string() +} + +/// The text of every buffer in `ids`, in order. +fn texts(set: &BufferSet, ids: &[BufferId]) -> Vec { + ids.iter().map(|&id| text(set, id)).collect() +} + +/// The version number of every buffer in `ids`, in order. +fn versions(set: &BufferSet, ids: &[BufferId]) -> Vec { + ids.iter() + .map(|&id| set.version(id).expect("buffer is in the set").get()) + .collect() +} + +/// Applies `edits` as one transaction and returns its id. +fn apply(set: &mut BufferSet, edits: Vec) -> TransactionId { + set.apply(Transaction::from(edits)) + .expect("transaction is valid") +} + +/// Amends `id` with `edits`, which must be accepted. +fn amend(set: &mut BufferSet, id: TransactionId, edits: Vec) { + set.amend(id, Transaction::from(edits)) + .expect("amend is valid"); +} + +/// Everything a failed call must leave as it was: every buffer's text and +/// version. +fn state(set: &BufferSet, ids: &[BufferId]) -> (Vec, Vec) { + (texts(set, ids), versions(set, ids)) +} + +// Folding edits into one undo step. + +#[test] +#[ignore = "T-110: not implemented yet"] +fn typing_a_word_undoes_in_one_step() { + let (mut set, ids) = set_of(&["say !"]); + let a = ids[0]; + let t = apply(&mut set, vec![Edit::insert(a, 4, "h")]); + for (i, c) in "ello".chars().enumerate() { + amend(&mut set, t, vec![Edit::insert(a, 5 + i, c.to_string())]); + } + assert_eq!(text(&set, a), "say hello!"); + + assert_eq!(set.undo(a), Ok(Some(t))); + assert_eq!(text(&set, a), "say !"); + assert_eq!(set.undo(a), Ok(None)); + assert_eq!(set.redo(a), Ok(Some(t))); + assert_eq!(text(&set, a), "say hello!"); + assert_eq!(set.redo(a), Ok(None)); + assert_eq!(set.undo(a), Ok(Some(t))); + assert_eq!(text(&set, a), "say !"); +} + +#[test] +#[ignore = "T-110: not implemented yet"] +fn amend_returns_unit() { + let (mut set, ids) = set_of(&["x"]); + let a = ids[0]; + let t = apply(&mut set, vec![Edit::insert(a, 1, "y")]); + assert_eq!( + set.amend(t, Transaction::from(vec![Edit::insert(a, 2, "z")])), + Ok(()) + ); + assert_eq!(text(&set, a), "xyz"); +} + +#[test] +#[ignore = "T-110: not implemented yet"] +fn the_id_stays_the_same_and_no_id_is_used_up() { + let (mut set, ids) = set_of(&["", ""]); + let (a, b) = (ids[0], ids[1]); + let t1 = apply(&mut set, vec![Edit::insert(a, 0, "a")]); + amend(&mut set, t1, vec![Edit::insert(a, 1, "b")]); + amend(&mut set, t1, vec![Edit::insert(a, 2, "c")]); + let t2 = apply(&mut set, vec![Edit::insert(b, 0, "x")]); + assert!(t2 > t1, "ids still increase"); + + // An editor keys its undo record by the id `apply` returned, so undo + // and redo must keep returning it. + assert_eq!(set.undo(a), Ok(Some(t1))); + assert_eq!(set.redo(a), Ok(Some(t1))); + assert_eq!(set.undo(b), Ok(Some(t2))); + assert_eq!(set.undo(a), Ok(Some(t1))); + assert_eq!(texts(&set, &ids), ["", ""]); +} + +#[test] +#[ignore = "T-110: not implemented yet"] +fn offsets_refer_to_the_text_after_the_transaction() { + let (mut set, ids) = set_of(&["abcdef"]); + let a = ids[0]; + let t = apply(&mut set, vec![Edit::delete(a, 0..2)]); + assert_eq!(text(&set, a), "cdef"); + + // 0..1 is "c" in the text after `t`, not "a" in the text before it. + amend(&mut set, t, vec![Edit::replace(a, 0..1, "X")]); + assert_eq!(text(&set, a), "Xdef"); + + // Within one amend, ranges refer to the text before the amend, in any + // listing order, as for `apply`. + amend( + &mut set, + t, + vec![ + Edit::delete(a, 3..4), + Edit::insert(a, 0, "<"), + Edit::delete(a, 1..2), + ], + ); + assert_eq!(text(&set, a), ", TransactionError)> = vec![ + (Vec::new(), TransactionError::Empty), + ( + vec![Edit::insert(stranger, 0, "x")], + TransactionError::UnknownBuffer { + edit: 0, + buffer: stranger, + }, + ), + ( + vec![Edit::delete(a, reversed(4, 3))], + TransactionError::InvalidRange { + edit: 0, + error: TextError::InvalidRange { start: 4, end: 3 }, + }, + ), + ( + vec![Edit::insert(a, 0, "x"), Edit::delete(a, 6..8)], + TransactionError::InvalidRange { + edit: 1, + error: TextError::ByteOutOfBounds { offset: 8, len: 7 }, + }, + ), + ( + vec![Edit::insert(a, usize::MAX, "x")], + TransactionError::InvalidRange { + edit: 0, + error: TextError::ByteOutOfBounds { + offset: usize::MAX, + len: 7, + }, + }, + ), + ( + vec![Edit::delete(a, 2..4)], + TransactionError::InvalidRange { + edit: 0, + error: TextError::NotCharBoundary { offset: 2 }, + }, + ), + ( + vec![Edit::delete(a, 0..2)], + TransactionError::InvalidRange { + edit: 0, + error: TextError::NotCharBoundary { offset: 2 }, + }, + ), + ( + vec![ + Edit::delete(a, 0..1), + Edit::delete(a, 3..6), + Edit::delete(a, 5..7), + Edit::insert(a, 4, "x"), + ], + TransactionError::Overlap { edit: 2, other: 1 }, + ), + ( + vec![ + Edit::delete(a, 3..6), + Edit::delete(a, 0..1), + Edit::delete(a, 0..7), + ], + TransactionError::Overlap { edit: 2, other: 0 }, + ), + ]; + for (edits, error) in cases { + let got = set.amend(t, Transaction::from(edits.clone())); + assert_eq!(got, Err(AmendError::Transaction(error)), "{edits:?}"); + assert_eq!(state(&set, &ids), before, "{edits:?}"); + } +} + +#[test] +#[ignore = "T-110: not implemented yet"] +fn invalid_ranges_match_slice_on_the_text_after_the_transaction() { + let (mut set, ids) = set_of(&["a😀b"]); + let a = ids[0]; + let t = apply(&mut set, vec![Edit::insert(a, 0, "中")]); + let after = set.get(a).unwrap().clone(); + for start in 0..=after.len_bytes() + 1 { + for end in 0..=after.len_bytes() + 1 { + let Err(error) = after.slice(start..end) else { + continue; + }; + assert_eq!( + set.amend(t, Transaction::from(vec![Edit::delete(a, start..end)])), + Err(AmendError::Transaction(TransactionError::InvalidRange { + edit: 0, + error, + })), + "{start}..{end}" + ); + } + } + assert_eq!(text(&set, a), "中a😀b"); +} + +#[test] +#[ignore = "T-110: not implemented yet"] +fn the_checks_run_in_order() { + let (mut set, ids) = set_of(&["a", "b", "c"]); + let (a, b, c) = (ids[0], ids[1], ids[2]); + let (_, more) = set_of(&["", "", "", ""]); + let stranger = more[3]; + let t1 = apply( + &mut set, + vec![Edit::insert(a, 1, "1"), Edit::insert(b, 1, "1")], + ); + let t2 = apply( + &mut set, + vec![Edit::insert(b, 2, "2"), Edit::insert(c, 1, "2")], + ); + set.undo(b).unwrap(); + // Now `t2` is undone; `t1` is on top of `a` and `b`. Apply `t3` on `b` + // so `t1` is no longer on top there, and drop `t2`. + let t3 = apply(&mut set, vec![Edit::insert(b, 2, "3")]); + let t4 = apply(&mut set, vec![Edit::insert(c, 0, "4")]); + set.undo(c).unwrap(); + // `t4` is undone, `t2` is unknown. + let before = state(&set, &ids); + let check = |set: &mut BufferSet, id: TransactionId, edits: Vec| { + set.amend(id, Transaction::from(edits)) + }; + + // The id is checked before the edits. + assert_eq!( + check(&mut set, t2, Vec::new()), + Err(AmendError::UnknownTransaction { transaction: t2 }) + ); + assert_eq!( + check(&mut set, t4, Vec::new()), + Err(AmendError::Undone { transaction: t4 }) + ); + assert_eq!( + check(&mut set, t1, Vec::new()), + Err(AmendError::Transaction(TransactionError::Empty)) + ); + + // Per edit: unknown buffer, then untouched, then not on top, then the + // range, then overlap. + assert_eq!( + check(&mut set, t1, vec![Edit::delete(stranger, reversed(5, 0))]), + Err(AmendError::Transaction(TransactionError::UnknownBuffer { + edit: 0, + buffer: stranger, + })) + ); + // `c` has `t2` dropped and `t4` undone, but `t1` never touched it. + assert_eq!( + check(&mut set, t1, vec![Edit::delete(c, reversed(5, 0))]), + Err(AmendError::Untouched { edit: 0, buffer: c }) + ); + assert_eq!( + check(&mut set, t1, vec![Edit::delete(b, reversed(5, 0))]), + Err(AmendError::NotOnTop { edit: 0, buffer: b }) + ); + assert_eq!( + check( + &mut set, + t1, + vec![Edit::delete(a, 0..2), Edit::delete(a, reversed(5, 0))] + ), + Err(AmendError::Transaction(TransactionError::InvalidRange { + edit: 1, + error: TextError::InvalidRange { start: 5, end: 0 }, + })) + ); + + // An earlier edit's problem comes first, whatever its kind. + assert_eq!( + check( + &mut set, + t1, + vec![Edit::delete(a, reversed(5, 0)), Edit::insert(c, 0, "x")] + ), + Err(AmendError::Transaction(TransactionError::InvalidRange { + edit: 0, + error: TextError::InvalidRange { start: 5, end: 0 }, + })) + ); + assert_eq!( + check( + &mut set, + t1, + vec![ + Edit::delete(a, 0..2), + Edit::delete(a, 1..2), + Edit::insert(b, 0, "x") + ] + ), + Err(AmendError::Transaction(TransactionError::Overlap { + edit: 1, + other: 0, + })) + ); + assert_eq!( + check( + &mut set, + t1, + vec![ + Edit::insert(b, 0, "x"), + Edit::delete(a, 0..2), + Edit::delete(a, 1..2) + ] + ), + Err(AmendError::NotOnTop { edit: 0, buffer: b }) + ); + + assert_eq!(state(&set, &ids), before); + // `t1` can still be amended on `a`, and the histories still work. + assert_eq!(check(&mut set, t1, vec![Edit::insert(a, 2, "+")]), Ok(())); + assert_eq!(set.redo(c), Ok(Some(t4))); + assert_eq!(set.undo(b), Ok(Some(t3))); +} + +// Redo. + +#[test] +#[ignore = "T-110: not implemented yet"] +fn amend_drops_redo_like_apply() { + let (mut set, ids) = set_of(&["a", "b", "c", "d"]); + let (a, b, c, d) = (ids[0], ids[1], ids[2], ids[3]); + let t1 = apply( + &mut set, + vec![Edit::insert(a, 1, "1"), Edit::insert(b, 1, "1")], + ); + // `t2` is undone in `a`; `t3` across `b` and `c` is undone; `t4` on `d` + // is undone. + apply(&mut set, vec![Edit::insert(a, 2, "2")]); + apply( + &mut set, + vec![Edit::insert(b, 2, "3"), Edit::insert(c, 1, "3")], + ); + let t4 = apply(&mut set, vec![Edit::insert(d, 1, "4")]); + set.undo(a).unwrap(); + set.undo(b).unwrap(); + set.undo(d).unwrap(); + assert_eq!(texts(&set, &ids), ["a1", "b1", "c", "d"]); + + // Amending only on `a` drops `a`'s redo and keeps the rest. + amend(&mut set, t1, vec![Edit::insert(a, 2, "+")]); + assert_eq!(set.redo(a), Ok(None)); + assert_eq!(set.redo(d), Ok(Some(t4))); + set.undo(d).unwrap(); + + // Amending on `b` drops `t3`, which leaves `c`'s redo history too. + amend(&mut set, t1, vec![Edit::insert(b, 2, "+")]); + assert_eq!(set.redo(b), Ok(None)); + assert_eq!(set.redo(c), Ok(None)); + assert_eq!(texts(&set, &ids), ["a1+", "b1+", "c", "d"]); + // An unrelated buffer keeps its redo. + assert_eq!(set.redo(d), Ok(Some(t4))); + + assert_eq!(set.undo(a), Ok(Some(t1))); + assert_eq!(texts(&set, &ids), ["a", "b", "c", "d4"]); +} + +#[test] +#[ignore = "T-110: not implemented yet"] +fn amend_keeps_redo_in_buffers_it_does_not_touch() { + // `t1` touched `a` and `b`, but the amend touches only `a`, so `b`'s + // redo history stays. + let (mut set, ids) = set_of(&["a", "b"]); + let (a, b) = (ids[0], ids[1]); + let t1 = apply( + &mut set, + vec![Edit::insert(a, 1, "1"), Edit::insert(b, 1, "1")], + ); + let t2 = apply(&mut set, vec![Edit::insert(b, 2, "2")]); + set.undo(b).unwrap(); + + amend(&mut set, t1, vec![Edit::insert(a, 2, "+")]); + assert_eq!(set.redo(b), Ok(Some(t2))); + assert_eq!(texts(&set, &ids), ["a1+", "b12"]); +} + +#[test] +#[ignore = "T-110: not implemented yet"] +fn a_no_op_amend_still_drops_redo() { + let (mut set, ids) = set_of(&["a"]); + let a = ids[0]; + let t1 = apply(&mut set, vec![Edit::insert(a, 1, "b")]); + apply(&mut set, vec![Edit::insert(a, 2, "c")]); + set.undo(a).unwrap(); + amend(&mut set, t1, vec![Edit::insert(a, 0, "")]); + assert_eq!(set.redo(a), Ok(None)); + assert_eq!(text(&set, a), "ab"); +} + +#[test] +#[ignore = "T-110: not implemented yet"] +fn a_failed_amend_keeps_redo() { + let (mut set, ids) = set_of(&["a", "b"]); + let (a, b) = (ids[0], ids[1]); + let t1 = apply(&mut set, vec![Edit::insert(a, 1, "1")]); + let t2 = apply(&mut set, vec![Edit::insert(a, 2, "2")]); + set.undo(a).unwrap(); + + for edits in [ + vec![Edit::insert(a, 9, "x")], + vec![Edit::insert(b, 0, "x")], + vec![ + Edit::insert(a, 0, "x"), + Edit::delete(a, 0..1), + Edit::delete(a, 0..2), + ], + Vec::new(), + ] { + assert!(set.amend(t1, Transaction::from(edits)).is_err()); + } + assert_eq!(set.redo(a), Ok(Some(t2))); + assert_eq!(text(&set, a), "a12"); +} + +// Versions and snapshots. + +#[test] +#[ignore = "T-110: not implemented yet"] +fn amend_bumps_each_touched_buffer_once() { + let (mut set, ids) = set_of(&["a", "b", "c", "d"]); + let t = apply( + &mut set, + vec![ + Edit::insert(ids[0], 1, "1"), + Edit::insert(ids[1], 1, "1"), + Edit::insert(ids[2], 1, "1"), + ], + ); + assert_eq!(versions(&set, &ids), [1, 1, 1, 0]); + + // Three edits in `a` and one in `b` bump each once. `c`, which `t` + // touched but the amend doesn't, and `d` keep their versions. + amend( + &mut set, + t, + vec![ + Edit::insert(ids[0], 0, "x"), + Edit::insert(ids[1], 0, "x"), + Edit::insert(ids[0], 1, "y"), + Edit::insert(ids[0], 2, "z"), + ], + ); + assert_eq!(versions(&set, &ids), [2, 2, 1, 0]); + amend(&mut set, t, vec![Edit::insert(ids[0], 0, "w")]); + assert_eq!(versions(&set, &ids), [3, 2, 1, 0]); + + // Undo and redo bump every buffer `t` touched once. + set.undo(ids[2]).unwrap(); + assert_eq!(versions(&set, &ids), [4, 3, 2, 0]); + set.redo(ids[1]).unwrap(); + assert_eq!(versions(&set, &ids), [5, 4, 3, 0]); +} + +#[test] +#[ignore = "T-110: not implemented yet"] +fn a_failed_amend_keeps_every_version() { + let (mut set, ids) = set_of(&["a", "b"]); + let (a, b) = (ids[0], ids[1]); + let t1 = apply( + &mut set, + vec![Edit::insert(a, 1, "1"), Edit::insert(b, 1, "1")], + ); + let t2 = apply(&mut set, vec![Edit::insert(b, 2, "2")]); + set.undo(b).unwrap(); + apply(&mut set, vec![Edit::insert(b, 2, "3")]); + let before = versions(&set, &ids); + + let failures: Vec<(TransactionId, Vec)> = vec![ + (t2, vec![Edit::insert(a, 0, "x")]), + (t1, Vec::new()), + (t1, vec![Edit::insert(a, 0, "x"), Edit::insert(b, 0, "x")]), + (t1, vec![Edit::insert(a, 0, "x"), Edit::insert(a, 7, "x")]), + (t1, vec![Edit::delete(a, 0..2), Edit::delete(a, 1..2)]), + ]; + for (id, edits) in failures { + assert!(set.amend(id, Transaction::from(edits)).is_err()); + assert_eq!(versions(&set, &ids), before); + } + let t3 = set.undo(b).unwrap().unwrap(); + set.undo(a).unwrap(); + let before = versions(&set, &ids); + assert_eq!( + set.amend(t3, Transaction::from(vec![Edit::insert(b, 0, "x")])), + Err(AmendError::Undone { transaction: t3 }) + ); + assert_eq!(versions(&set, &ids), before); +} + +#[test] +#[ignore = "T-110: not implemented yet"] +fn snapshots_go_stale_on_amend() { + let (mut set, ids) = set_of(&["hello", "world"]); + let (a, b) = (ids[0], ids[1]); + let t = apply( + &mut set, + vec![Edit::insert(a, 5, "!"), Edit::insert(b, 5, "!")], + ); + let old_a = set.snapshot(a).unwrap(); + let old_b = set.snapshot(b).unwrap(); + + amend(&mut set, t, vec![Edit::insert(a, 6, "!")]); + assert!(old_a.is_stale()); + assert!(!old_b.is_stale(), "the amend didn't touch b"); + assert_eq!(old_a.to_string(), "hello!"); + assert_eq!(old_a.version().get(), 1); + + let new_a = set.snapshot(a).unwrap(); + assert_eq!(new_a.to_string(), "hello!!"); + assert_eq!(new_a.version().get(), 2); + assert!(!new_a.is_stale()); + + // A failed amend leaves snapshots fresh. + assert!( + set.amend(t, Transaction::from(vec![Edit::insert(a, 99, "x")])) + .is_err() + ); + assert!(!new_a.is_stale()); + + // Undo makes both stale, and gives `a` its text from before `t`. + set.undo(a).unwrap(); + assert!(new_a.is_stale()); + assert!(old_b.is_stale()); + assert_eq!(set.snapshot(a).unwrap().to_string(), "hello"); + assert_eq!(new_a.to_string(), "hello!!"); +} diff --git a/crates/helm-text/tests/amend_properties.rs b/crates/helm-text/tests/amend_properties.rs new file mode 100644 index 0000000..1ce9632 --- /dev/null +++ b/crates/helm-text/tests/amend_properties.rs @@ -0,0 +1,677 @@ +//! T-110 acceptance tests: property tests for `BufferSet::amend`. +//! +//! A chain of amends must leave every buffer as applying the same edits one +//! transaction at a time would, and then undo in one step where the separate +//! transactions take one undo each. Random histories that mix applies, +//! amends, undos and redos must match an oracle that keeps each buffer's +//! text, each transaction's texts before and after it, and the undo and +//! redo histories, written straight from the `BufferSet` docs. + +use std::collections::HashSet; +use std::ops::Range; + +use helm_text::{ + AmendError, Buffer, BufferId, BufferSet, Edit, HistoryError, TextError, Transaction, + TransactionError, TransactionId, +}; +use proptest::prelude::*; +use proptest::sample::Index; + +/// How many buffers each case edits. +const BUFFERS: usize = 3; + +/// One piece of random text: ASCII runs, each character width, each line +/// break, and any char. +fn piece() -> impl Strategy { + prop_oneof![ + 6 => "[a-z ]{1,4}", + 1 => Just("\n".to_owned()), + 1 => Just("\r".to_owned()), + 1 => Just("\r\n".to_owned()), + 1 => Just("é".to_owned()), + 1 => Just("中".to_owned()), + 1 => Just("😀".to_owned()), + 1 => any::().prop_map(String::from), + ] +} + +/// Random text of up to `max_pieces` pieces. +fn text(max_pieces: usize) -> impl Strategy { + prop::collection::vec(piece(), 0..max_pieces).prop_map(|pieces| pieces.concat()) +} + +/// One end of a random range, picked when the edit is made, since the text +/// changes as the case runs. +#[derive(Clone, Debug)] +enum Offset { + /// A char boundary of the buffer's current text. + Boundary(Index), + /// Any byte offset up to two past the end, valid or not. + Byte(Index), +} + +/// One random edit, before its offsets are picked. +#[derive(Clone, Debug)] +struct EditSpec { + buffer: usize, + start: Offset, + end: Offset, + text: String, +} + +fn offset() -> impl Strategy { + prop_oneof![ + 12 => any::().prop_map(Offset::Boundary), + 1 => any::().prop_map(Offset::Byte), + ] +} + +fn edit_spec() -> impl Strategy { + (0..BUFFERS, offset(), offset(), text(3)).prop_map(|(buffer, start, end, text)| EditSpec { + buffer, + start, + end, + text, + }) +} + +/// Up to four edits. An empty list tests the `Empty` error. +fn edit_specs() -> impl Strategy> { + prop::collection::vec(edit_spec(), 0..5) +} + +fn starts() -> impl Strategy> { + prop::collection::vec(text(12), BUFFERS) +} + +/// Picks a byte range in `text`. Two boundary offsets are put in order, so +/// most ranges are valid; a raw byte offset keeps the order it came in, so +/// reversed, out-of-bounds and mid-character ranges come up too. +fn pick_range(text: &str, start: &Offset, end: &Offset) -> Range { + let boundaries: Vec = text + .char_indices() + .map(|(byte, _)| byte) + .chain([text.len()]) + .collect(); + let pick = |offset: &Offset| match offset { + Offset::Boundary(index) => *index.get(&boundaries), + Offset::Byte(index) => index.index(text.len() + 3), + }; + let (a, b) = (pick(start), pick(end)); + match (start, end) { + (Offset::Boundary(_), Offset::Boundary(_)) => a.min(b)..a.max(b), + _ => a..b, + } +} + +/// What `TextBuffer::slice` returns for `range` of `text`, if it errors. +fn range_error(text: &str, range: &Range) -> Option { + let len = text.len(); + if range.start > range.end { + Some(TextError::InvalidRange { + start: range.start, + end: range.end, + }) + } else if range.end > len { + Some(TextError::ByteOutOfBounds { + offset: range.end, + len, + }) + } else if !text.is_char_boundary(range.start) { + Some(TextError::NotCharBoundary { + offset: range.start, + }) + } else if !text.is_char_boundary(range.end) { + Some(TextError::NotCharBoundary { offset: range.end }) + } else { + None + } +} + +/// An edit by buffer index, with its range picked. +type PlainEdit = (usize, Range, String); + +/// Picks every spec's range against `texts`. +fn pick_edits(texts: &[String], specs: &[EditSpec]) -> Vec { + specs + .iter() + .map(|spec| { + let range = pick_range(&texts[spec.buffer], &spec.start, &spec.end); + (spec.buffer, range, spec.text.clone()) + }) + .collect() +} + +/// `edits` as a transaction on the buffers `ids`. +fn transaction(ids: &[BufferId], edits: &[PlainEdit]) -> Transaction { + edits + .iter() + .map(|(buffer, range, text)| Edit::replace(ids[*buffer], range.clone(), text.as_str())) + .collect() +} + +/// The range checks `apply` makes on edit `i`: a range valid in `text`, +/// then no overlap with an earlier edit on the same buffer. +fn check_range(texts: &[String], edits: &[PlainEdit], i: usize) -> Result<(), TransactionError> { + let (buffer, range, _) = &edits[i]; + if let Some(error) = range_error(&texts[*buffer], range) { + return Err(TransactionError::InvalidRange { edit: i, error }); + } + let earlier = edits[..i].iter().position(|(other, earlier, _)| { + other == buffer && range.start < earlier.end && earlier.start < range.end + }); + match earlier { + Some(other) => Err(TransactionError::Overlap { edit: i, other }), + None => Ok(()), + } +} + +/// The new text of every buffer `edits` touch, by buffer index, lowest +/// first. The edits must have passed every check. +fn place(texts: &[String], edits: &[PlainEdit]) -> Vec<(usize, String)> { + let mut touched: Vec = edits.iter().map(|(buffer, ..)| *buffer).collect(); + touched.sort_unstable(); + touched.dedup(); + touched + .into_iter() + .map(|buffer| { + // Place edits by start, inserts before the non-empty range at + // the same start, then by transaction order. + let mut mine: Vec<_> = edits + .iter() + .enumerate() + .filter(|(_, (b, ..))| *b == buffer) + .map(|(i, (_, range, text))| (range.start, range.end, i, text)) + .collect(); + mine.sort_unstable_by_key(|&(start, end, i, _)| (start, end, i)); + let old = &texts[buffer]; + let mut out = String::new(); + let mut at = 0; + for (start, end, _, text) in mine { + out.push_str(&old[at..start]); + out.push_str(text); + at = end; + } + out.push_str(&old[at..]); + (buffer, out) + }) + .collect() +} + +/// One transaction in the model: each touched buffer's text before it was +/// applied and after its last amend, by buffer index, lowest first. +struct Recorded { + id: TransactionId, + texts: Vec<(usize, String, String)>, +} + +/// The oracle: a `String` and a version per buffer, plus the undo and redo +/// histories the `BufferSet` docs describe, as stacks of indexes into +/// `transactions`. +struct Model { + texts: Vec, + versions: Vec, + undo: Vec>, + redo: Vec>, + transactions: Vec, +} + +impl Model { + fn new(starts: &[String]) -> Self { + Self { + texts: starts.to_vec(), + versions: vec![0; starts.len()], + undo: vec![Vec::new(); starts.len()], + redo: vec![Vec::new(); starts.len()], + transactions: Vec::new(), + } + } + + /// The new texts `apply` gives, or the error it returns. + fn check_apply(&self, edits: &[PlainEdit]) -> Result, TransactionError> { + if edits.is_empty() { + return Err(TransactionError::Empty); + } + for i in 0..edits.len() { + check_range(&self.texts, edits, i)?; + } + Ok(place(&self.texts, edits)) + } + + /// Records a transaction `apply` accepted. + fn commit(&mut self, id: TransactionId, new: Vec<(usize, String)>) { + let t = self.transactions.len(); + let touched: Vec = new.iter().map(|(buffer, _)| *buffer).collect(); + self.drop_redo(&touched); + let texts = new + .into_iter() + .map(|(buffer, after)| { + let before = std::mem::replace(&mut self.texts[buffer], after.clone()); + self.undo[buffer].push(t); + self.versions[buffer] += 1; + (buffer, before, after) + }) + .collect(); + self.transactions.push(Recorded { id, texts }); + } + + /// The new texts amending transaction `t` with `edits` gives, or the + /// error `amend` returns, following the order in the `amend` docs. + /// `ids` are the set's buffer ids, by index. + fn check_amend( + &self, + t: usize, + edits: &[PlainEdit], + ids: &[BufferId], + ) -> Result, AmendError> { + let transaction = self.transactions[t].id; + let applied = self.undo.iter().any(|stack| stack.contains(&t)); + let undone = self.redo.iter().any(|stack| stack.contains(&t)); + if !applied && !undone { + return Err(AmendError::UnknownTransaction { transaction }); + } + if undone { + return Err(AmendError::Undone { transaction }); + } + if edits.is_empty() { + return Err(AmendError::Transaction(TransactionError::Empty)); + } + for (i, (buffer, ..)) in edits.iter().enumerate() { + let buffer_id = ids[*buffer]; + if !self.transactions[t].texts.iter().any(|(b, ..)| b == buffer) { + return Err(AmendError::Untouched { + edit: i, + buffer: buffer_id, + }); + } + if self.undo[*buffer].last() != Some(&t) { + return Err(AmendError::NotOnTop { + edit: i, + buffer: buffer_id, + }); + } + check_range(&self.texts, edits, i).map_err(AmendError::Transaction)?; + } + Ok(place(&self.texts, edits)) + } + + /// Records an amend of transaction `t` that `amend` accepted. + fn amend(&mut self, t: usize, new: Vec<(usize, String)>) { + let touched: Vec = new.iter().map(|(buffer, _)| *buffer).collect(); + self.drop_redo(&touched); + for (buffer, after) in new { + let recorded = self.transactions[t] + .texts + .iter_mut() + .find(|(b, ..)| *b == buffer) + .expect("checked: `t` touched the buffer"); + recorded.2 = after.clone(); + self.texts[buffer] = after; + self.versions[buffer] += 1; + } + } + + /// Drops the redo histories of `touched`, then everything below a + /// dropped transaction in any redo history, until nothing more drops. + fn drop_redo(&mut self, touched: &[usize]) { + let mut dropped: HashSet = touched + .iter() + .flat_map(|&buffer| self.redo[buffer].iter().copied()) + .collect(); + loop { + let before = dropped.len(); + for stack in &self.redo { + if let Some(top) = stack.iter().rposition(|t| dropped.contains(t)) { + dropped.extend(&stack[..=top]); + } + } + if dropped.len() == before { + break; + } + } + for stack in &mut self.redo { + stack.retain(|t| !dropped.contains(t)); + } + } + + /// Undoes (`undo == true`) or redoes the top of `buffer`'s history. + /// Returns the transaction's index, or the transaction and the buffer + /// that block it. + fn step(&mut self, buffer: usize, undo: bool) -> Result, (usize, usize)> { + let (from, to) = if undo { + (&mut self.undo, &mut self.redo) + } else { + (&mut self.redo, &mut self.undo) + }; + let Some(&t) = from[buffer].last() else { + return Ok(None); + }; + let recorded = &self.transactions[t]; + for (touched, ..) in &recorded.texts { + if from[*touched].last() != Some(&t) { + return Err((t, *touched)); + } + } + for (touched, before, after) in &recorded.texts { + from[*touched].pop(); + to[*touched].push(t); + self.texts[*touched] = if undo { before } else { after }.clone(); + self.versions[*touched] += 1; + } + Ok(Some(t)) + } +} + +/// Every buffer's text in `set`. +fn texts_of(set: &BufferSet, ids: &[BufferId]) -> Vec { + ids.iter() + .map(|&id| set.get(id).expect("buffer is in the set").to_string()) + .collect() +} + +/// Every buffer's version number in `set`. +fn versions_of(set: &BufferSet, ids: &[BufferId]) -> Vec { + ids.iter() + .map(|&id| set.version(id).expect("buffer is in the set").get()) + .collect() +} + +/// A set holding `starts`, and its buffer ids. +fn set_of(starts: &[String]) -> (BufferSet, Vec) { + let mut set = BufferSet::new(); + let ids = starts + .iter() + .map(|text| set.add(Buffer::from(text.as_str()))) + .collect(); + (set, ids) +} + +// Property 1: a chain of amends against the same edits applied one at a +// time. + +/// A first transaction that always applies: one to four inserts or +/// replaces at char boundaries, spread over random buffers, made valid by +/// dropping any edit that overlaps an earlier one. +fn first_specs() -> impl Strategy> { + prop::collection::vec((0..BUFFERS, any::(), any::(), text(3)), 1..5) +} + +fn chain() -> impl Strategy>> { + prop::collection::vec(edit_specs(), 1..16) +} + +/// Builds the first transaction's edits from `specs` against `texts`. +fn first_edits(texts: &[String], specs: &[(usize, Index, Index, String)]) -> Vec { + let mut edits: Vec = Vec::new(); + for (buffer, start, end, text) in specs { + let range = pick_range( + &texts[*buffer], + &Offset::Boundary(*start), + &Offset::Boundary(*end), + ); + let overlaps = edits.iter().any(|(other, earlier, _)| { + other == buffer && range.start < earlier.end && earlier.start < range.end + }); + if !overlaps { + edits.push((*buffer, range, text.clone())); + } + } + edits +} + +// Property 2 and 3: random histories with amends. + +/// One random step. +#[derive(Clone, Debug)] +enum Op { + Apply(Vec), + /// Amends a transaction the set returned earlier: the last one, or a + /// random one, which may since be undone, dropped or covered. + Amend { + last: bool, + pick: Index, + specs: Vec, + }, + Undo(usize), + Redo(usize), +} + +fn op() -> impl Strategy { + prop_oneof![ + 3 => edit_specs().prop_map(Op::Apply), + 5 => (any::(), any::(), edit_specs()) + .prop_map(|(last, pick, specs)| Op::Amend { last, pick, specs }), + 2 => (0..BUFFERS).prop_map(Op::Undo), + 2 => (0..BUFFERS).prop_map(Op::Redo), + ] +} + +fn ops() -> impl Strategy> { + prop::collection::vec(op(), 1..40) +} + +/// The set under test, its buffer ids and the oracle, side by side. +struct Case { + set: BufferSet, + ids: Vec, + model: Model, +} + +impl Case { + fn new(starts: &[String]) -> Self { + let (set, ids) = set_of(starts); + Self { + set, + ids, + model: Model::new(starts), + } + } + + /// Runs `op` on both and checks they agree. + fn run(&mut self, op: &Op) -> Result<(), TestCaseError> { + match op { + Op::Apply(specs) => { + let edits = pick_edits(&self.model.texts, specs); + let got = self.set.apply(transaction(&self.ids, &edits)); + match (self.model.check_apply(&edits), got) { + (Ok(new), Ok(id)) => self.model.commit(id, new), + (want, got) => prop_assert_eq!(got.err(), want.err()), + } + } + Op::Amend { last, pick, specs } => { + if self.model.transactions.is_empty() { + return Ok(()); + } + let t = if *last { + self.model.transactions.len() - 1 + } else { + pick.index(self.model.transactions.len()) + }; + let edits = pick_edits(&self.model.texts, specs); + let id = self.model.transactions[t].id; + let got = self.set.amend(id, transaction(&self.ids, &edits)); + match self.model.check_amend(t, &edits, &self.ids) { + Ok(new) => { + prop_assert_eq!(got, Ok(())); + self.model.amend(t, new); + } + Err(error) => prop_assert_eq!(got, Err(error)), + } + } + Op::Undo(buffer) => { + let got = self.set.undo(self.ids[*buffer]); + let want = self.model.step(*buffer, true); + prop_assert_eq!(got, self.history_result(want)); + } + Op::Redo(buffer) => { + let got = self.set.redo(self.ids[*buffer]); + let want = self.model.step(*buffer, false); + prop_assert_eq!(got, self.history_result(want)); + } + } + self.check_state() + } + + /// The model's undo or redo result, in the set's types. + fn history_result( + &self, + want: Result, (usize, usize)>, + ) -> Result, HistoryError> { + match want { + Ok(t) => Ok(t.map(|t| self.model.transactions[t].id)), + Err((t, buffer)) => Err(HistoryError::Blocked { + transaction: self.model.transactions[t].id, + buffer: self.ids[buffer], + }), + } + } + + fn check_state(&self) -> Result<(), TestCaseError> { + prop_assert_eq!(texts_of(&self.set, &self.ids), self.model.texts.clone()); + prop_assert_eq!( + versions_of(&self.set, &self.ids), + self.model.versions.clone() + ); + Ok(()) + } +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(256))] + + /// Applies a first transaction to two sets, then the same edits to + /// both: as amends of the first transaction in `amended`, and as + /// separate transactions in `separate`. Amends that touch a buffer the + /// first transaction didn't must fail with `Untouched`; other failures + /// must be the `apply` error, wrapped. After every step both sets hold + /// the same texts and versions. Then one undo returns `amended` to the + /// starting texts, as undoing every separate transaction does, and one + /// redo brings back the final texts. + #[test] + #[ignore = "T-110: not implemented yet"] + fn a_chain_of_amends_matches_separate_transactions( + starts in starts(), + first in first_specs(), + chain in chain(), + undo_from in any::(), + redo_from in any::(), + ) { + let (mut amended, ids) = set_of(&starts); + let (mut separate, _) = set_of(&starts); + + let edits = first_edits(&starts, &first); + let id = amended.apply(transaction(&ids, &edits)).expect("valid first edits"); + let first_id = separate.apply(transaction(&ids, &edits)).expect("valid first edits"); + let mut touched: Vec = edits.iter().map(|(buffer, ..)| *buffer).collect(); + touched.sort_unstable(); + touched.dedup(); + // Every separate transaction, with a buffer to undo it from. + let mut applied = vec![(first_id, edits[0].0)]; + + for specs in &chain { + let texts = texts_of(&separate, &ids); + let edits = pick_edits(&texts, specs); + let got = amended.amend(id, transaction(&ids, &edits)); + + let untouched = edits + .iter() + .enumerate() + .map(|(i, (buffer, ..))| (i, *buffer)) + .take_while(|&(i, _)| check_range_before(&texts, &edits, i)) + .find(|(_, buffer)| !touched.contains(buffer)); + if let Some((edit, buffer)) = untouched { + prop_assert_eq!(got, Err(AmendError::Untouched { edit, buffer: ids[buffer] })); + } else { + match separate.apply(transaction(&ids, &edits)) { + Ok(next) => { + prop_assert_eq!(got, Ok(())); + applied.push((next, edits[0].0)); + } + Err(error) => prop_assert_eq!(got, Err(AmendError::Transaction(error))), + } + } + prop_assert_eq!(texts_of(&amended, &ids), texts_of(&separate, &ids)); + prop_assert_eq!(versions_of(&amended, &ids), versions_of(&separate, &ids)); + } + + let finals = texts_of(&amended, &ids); + let versions = versions_of(&amended, &ids); + + // One undo, from any buffer the first transaction touched. + let from = ids[*undo_from.get(&touched)]; + prop_assert_eq!(amended.undo(from), Ok(Some(id))); + prop_assert_eq!(texts_of(&amended, &ids), starts.clone()); + for (i, &buffer) in ids.iter().enumerate() { + let bump = u64::from(touched.contains(&i)); + prop_assert_eq!(amended.version(buffer).unwrap().get(), versions[i] + bump); + prop_assert_eq!(amended.undo(buffer), Ok(None)); + } + + // The separate transactions take one undo each, latest first. + for &(next, buffer) in applied.iter().rev() { + prop_assert_eq!(separate.undo(ids[buffer]), Ok(Some(next))); + } + prop_assert_eq!(texts_of(&separate, &ids), starts.clone()); + + // One redo brings back the final texts. + let from = ids[*redo_from.get(&touched)]; + prop_assert_eq!(amended.redo(from), Ok(Some(id))); + prop_assert_eq!(texts_of(&amended, &ids), finals.clone()); + for &buffer in &ids { + prop_assert_eq!(amended.redo(buffer), Ok(None)); + } + for &(next, buffer) in &applied { + prop_assert_eq!(separate.redo(ids[buffer]), Ok(Some(next))); + } + prop_assert_eq!(texts_of(&separate, &ids), finals); + } + + /// Random applies, amends of the latest or any earlier transaction, + /// undos and redos: every result, text and version matches the oracle. + #[test] + #[ignore = "T-110: not implemented yet"] + fn random_histories_with_amends_match_the_oracle(starts in starts(), ops in ops()) { + let mut case = Case::new(&starts); + for op in &ops { + case.run(op)?; + } + } + + /// After random histories with amends, undoing whatever isn't blocked, + /// over and over, returns every buffer to its starting text. + #[test] + #[ignore = "T-110: not implemented yet"] + fn undoing_everything_after_amends_restores_every_buffer( + starts in starts(), + ops in ops(), + ) { + let mut case = Case::new(&starts); + for op in &ops { + case.run(op)?; + } + + loop { + let mut undone = false; + for buffer in 0..BUFFERS { + let got = case.set.undo(case.ids[buffer]); + let want = case.model.step(buffer, true); + undone |= matches!(want, Ok(Some(_))); + prop_assert_eq!(got, case.history_result(want)); + } + case.check_state()?; + if !undone { + break; + } + } + for (i, &id) in case.ids.iter().enumerate() { + prop_assert_eq!(case.set.undo(id), Ok(None)); + prop_assert_eq!(case.set.get(id).unwrap().to_string(), starts[i].as_str()); + } + } +} + +/// Whether every edit before `i` passes its range checks, so `amend` gets +/// as far as edit `i`'s buffer checks. +fn check_range_before(texts: &[String], edits: &[PlainEdit], i: usize) -> bool { + (0..i).all(|j| check_range(texts, edits, j).is_ok()) +} diff --git a/tasks/110-amend-transactions.md b/tasks/110-amend-transactions.md new file mode 100644 index 0000000..edc6e29 --- /dev/null +++ b/tasks/110-amend-transactions.md @@ -0,0 +1,111 @@ +# T-110: Amend a transaction, so a run of keystrokes undoes in one step + +After: T-102 and T-103. This task waits until both are merged. Both are. + +## Goal + +`BufferSet` gets `amend`, which folds new edits into a transaction that is already applied. The buffer changes at once, as with `apply`, but undo still treats the transaction as one step: one undo returns every buffer it touched to the text from before it, and one redo brings back the amended text. This is what lets the editor turn a run of typed characters into one undo step, and it starts from the method T-105 sketched in "Grouping keystrokes: a candidate for a later task". + +PRD: ED-2 + +## Crates and public interface + +Crates: `helm-text` (the implementation) and `helm-bench` (the new benchmark file and its `[[bench]]` entry only). + +The interface is already in `crates/helm-text/src/` with doc comments. `AmendError` is plain data and already works, `Display` and `Error` included. `BufferSet::amend` has a `todo!()` body. Fill it in. Don't change a signature, a type or a doc comment's contract, and don't change any T-101 to T-105 signature. If one looks wrong, say so in the PR description. Delete the `#[expect(unused_variables, ...)]` on `amend` once the stub is gone. Clippy with `-D warnings` fails until you do. + +You will need to change `BufferSet`'s private fields and helpers. `Record`, `Change` and `Splice` are private, so reshape them freely, and move code between `apply` and `amend` if they share it. Don't touch `buffer.rs`, `encoding.rs` or `crates/helm-editor`. + +```rust +// lib.rs +pub use error::{AmendError, HistoryError, TextError, TransactionError}; + +// buffer_set.rs +impl BufferSet { + pub fn amend(&mut self, id: TransactionId, transaction: Transaction) -> Result<(), AmendError>; +} + +// error.rs, next to TransactionError and HistoryError +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum AmendError { + UnknownTransaction { transaction: TransactionId }, + Undone { transaction: TransactionId }, + Untouched { edit: usize, buffer: BufferId }, + NotOnTop { edit: usize, buffer: BufferId }, + Transaction(TransactionError), +} +impl fmt::Display for AmendError; +impl std::error::Error for AmendError; // source() is the wrapped TransactionError +``` + +`amend` returns `()` on success because the id doesn't change: the caller already has it. Every check `apply` makes comes back as `AmendError::Transaction`, with the same `TransactionError` and the same edit indexes `apply` would give, so a caller that already handles `apply`'s errors handles these too. + +The spec PR also changed two doc comments to keep them true. `BufferSet`'s type docs now list `amend` among the ways to change a buffer, and `Version`'s docs in `snapshot.rs` say an amend adds one to each buffer it touches. + +### Rules the implementation must follow + +The doc comment on `amend` holds the full rules. In short: + +- **When amending is allowed.** `id` must be applied, not undone, and on top of the undo history of every buffer the new edits touch. The new edits may only touch buffers `id` touched, though not necessarily all of them. A later transaction on a buffer the new edits don't touch doesn't stop the amend. It still blocks undoing `id`, under T-102's rule. +- **Undone, dropped or covered.** If `id` was undone and not redone, `amend` returns `Undone`. If a later `apply` or `amend` dropped it from the redo histories, or it comes from another set, `amend` returns `UnknownTransaction`. If a later transaction is on top of a buffer an edit names, `amend` returns `NotOnTop` naming that edit and buffer. In each case nothing changes, and the editor starts a new undo step with `apply` instead. A transaction that was undone and then redone is on top again and can be amended. +- **Offsets.** Every range is in bytes of the buffer's text after `id`. Since `id` is on top of that buffer's undo history, that is the buffer's current text. Within one amend, ranges refer to that text, not to the text after the edits listed before them, and placement and overlap follow `apply`'s rules. +- **Checks.** Everything is checked before anything changes, in this order, returning the first problem: `id` unknown (`UnknownTransaction`), `id` undone (`Undone`), no edits (`Transaction(Empty)`), then for each edit in order: a buffer not in the set (`Transaction(UnknownBuffer)`), a buffer `id` didn't touch (`Untouched`), a buffer where `id` isn't on top (`NotOnTop`), a range `slice` rejects on the current text (`Transaction(InvalidRange)`, with `slice`'s `TextError`), and overlap with an earlier edit on the same buffer (`Transaction(Overlap)`, naming the earliest). On any error, no buffer, history or version changes. +- **One undo step.** After any number of amends, undoing `id` from any buffer it touched restores every one of those buffers, byte for byte, to its text from before `id` was first applied. Redoing it restores each to its text after the last amend. A buffer `id` touched that no amend touched gets back exactly what `id` alone did to it. +- **Same id.** `id` keeps its id and its place in every undo history. `undo` and `redo` return it as before, and `amend` uses up no id, so the next `apply` still gets a larger one. T-105's `Editor` keys its record of selections by the id `apply` returned, and that record keeps working. +- **Redo.** `amend` drops the redo histories of the buffers its edits touch, with the same knock-on drops as `apply`. Buffers `id` touched that this amend doesn't touch keep their redo. A failed amend drops nothing. +- **Versions and snapshots.** A successful amend adds exactly one to the version of each buffer its edits touch, however many edits it had there. Other buffers keep their versions, including ones `id` touched that this amend didn't. Snapshots of the bumped buffers go stale as usual, and their text doesn't change. A later undo or redo of `id` adds one to every buffer `id` touched, as for any transaction. A no-op amend, such as an empty insert, still counts: it bumps versions and drops redo, as a no-op `apply` does. +- **Cost.** An amend costs about what applying its edits costs, plus work in proportion to the change `id` already holds for those buffers. It must not grow with the buffer's length, apart from the text the edits replace, and must not keep a list of amends to replay on undo. After 100 single-character amends of a typing run, undoing `id` should be one splice per buffer, as if the whole run had been one insert. Copying a buffer's text into a `String` to diff it doesn't meet this. +- **No panics.** Any offset, including `usize::MAX`, returns an error value. + +### Composing the change + +Each `Change` in a `Record` holds `forward` splices (text before `id` to text after it) and `inverse` splices (the reverse). Amending buffer B with new edits `E`, which refer to B's current text, needs a new `forward` from the text before `id` to the text after `E`, and its inverse. + +One way that meets the cost rule: walk `forward` and `E` together in order of position. Map each `E` range back through `forward` to a range of the text before `id`. Where an `E` range touches or overlaps the output of a `forward` splice, merge them into one splice. Where it falls in text `id` didn't change, the text it replaces is the same before and after `id`, so you can read it from the buffer before splicing `E` in. The new `inverse` follows from the new `forward` and the replaced text. Merging splices that touch keeps a typing run at one splice. Any approach is fine if it meets the rules and the cost rule. Explain the one you chose in the PR. + +### Choices I rejected + +- **Return a new id from `amend`.** The editor's undo record is keyed by the id `apply` returned, so every amend would have to re-key it. Nothing gains from a new id. +- **Amend an undone transaction.** It sits in redo histories, and amending would either redo it silently or change text the user can't see. After an undo, the next keystroke should start a new step anyway. +- **Amend under a later transaction by rebasing.** Folding edits into `id` below a later transaction `t2` means undoing `id` later must also move `t2`'s splices. That is a history rewrite with no use in grouping keystrokes: once something else lands on the buffer, the group has ended. +- **Require `id` on top of every buffer it touched.** Only the buffers the new edits touch matter. The composition in buffer A depends only on A's history, and undoing `id` is still blocked by T-102's rule until the later transaction in B is undone. +- **Buffer keystrokes in the editor and apply them when the group ends.** T-105 already rejected this: the buffer must show each keystroke at once, because language servers and agents read the buffer. + +### Dependencies + +None new. `proptest` is already a dev-dependency. + +## Acceptance tests + +Two files, committed before the implementation: + +| File | What it proves | +| --- | --- | +| `crates/helm-text/tests/amend.rs` | 30 hand-picked cases. Typing a word as one apply and four amends undoes and redoes in one step. `amend` returns `Ok(())`, the id stays the same through undo and redo, and the next `apply` still gets a larger id. Offsets read against the text after the transaction, an offset valid only before it is rejected, and ranges within one amend read against the text before the amend, in any listing order. Amends that delete text the transaction inserted, delete across its replacement, or backspace past it. Edits far from the transaction, inserts at one offset keeping their order, wide characters and an insert inside a CRLF. No-op amends. Amending after a redo, and amending an earlier transaction once the later one is undone. Amends across two buffers, amends that touch only some of the transaction's buffers, and a later transaction in another buffer that doesn't stop the amend but still blocks the undo. The errors: `NotOnTop` (with nothing applied when a later edit fails), `Undone` (redo still works after), `UnknownTransaction` for a dropped transaction and for an id from another set, `Untouched`, every wrapped `apply` error, every `InvalidRange` matching `slice` on the text after the transaction, and the full check order. Redo dropped in the amended buffers with knock-on drops, kept in buffers the amend doesn't touch and in unrelated ones, dropped by a no-op amend, and kept by a failed one. Versions bump once per amend per touched buffer and not at all on failure. Snapshots go stale only for the buffers an amend touches. | +| `crates/helm-text/tests/amend_properties.rs` | proptest over three buffers with random texts mixing ASCII, é, 中, 😀, every line break and any `char`. The first property applies a random first transaction to two sets, then runs a chain of up to 15 random amends (0 to 4 edits, some with out-of-bounds, reversed, mid-character, overlapping or untouched-buffer edits). One set amends; the other applies each amend's edits as its own transaction. After every step both sets hold the same texts and versions, and each error is `Untouched` or the other set's `apply` error, wrapped. Then one undo from a random touched buffer returns the amending set to the starting texts with a single bump per touched buffer and an empty undo history, while the other set needs one undo per transaction. One redo then brings back the final texts. The second property runs up to 40 random steps of applies, amends of the latest or any earlier transaction (some undone, dropped or covered), undos and redos, against an oracle that keeps each transaction's texts before it and after its last amend. Every result, error, text and version matches after every step. The third undoes everything that isn't blocked and checks every buffer is back to its starting text. 256 cases per property. | + +Each test carries `#[ignore = "T-110: not implemented yet"]` so CI stays green until the implementation lands. Against the stubs, `cargo test -p helm-text -- --ignored` fails all 33 T-110 tests (30 + 3). + +**The rule for the implementer: the only allowed change to these two files is deleting the `#[ignore = "T-110: not implemented yet"]` lines.** Nothing else in them changes. No edited assertion, no new `#[ignore]`, no changed case count, chain length or step count. Khakha checks the diff of these files before adding the `approved-protected` label, which the protected-paths check needs for any edit under `crates/*/tests/`. If a test looks wrong, say so in the PR description instead of changing it. + +## Performance budget + +NF-7: no rope edit may get more than 5% slower than its base commit. Add a new file, `crates/helm-bench/benches/amend.rs`, with a `[[bench]] name = "amend"`, `harness = false` entry in `crates/helm-bench/Cargo.toml`. It holds one criterion benchmark in a group named `amend`: + +| Benchmark id | What one iteration does | +| --- | --- | +| `amend/type_100_chars_undo_10mb` | On a 10 MiB buffer in a `BufferSet`, applies a transaction that inserts one character at the char boundary at or below the middle, amends it 100 times, each amend inserting one character right after the one before, then undoes it once. | + +Use the same 10 MiB text as `benches/text.rs`: copy its `LINES`, `floor_boundary` and `build_text` into the new file unchanged, so every file measures the same text. The 101 characters cycle through the chars of `"helm 😀中é"`, so the run mixes 1-, 2-, 3- and 4-byte characters. Build the text, the set, the first transaction and the 100 amend transactions once, outside the timed code. Clone them in `iter_batched` setup (`BatchSize::SmallInput`), since `apply` and `amend` take transactions by value. The undo returns the buffer to the starting text, and the next `apply` drops the redo entry, so every iteration starts from the same state. + +This benchmark is the cost rule's guard. An `amend` that copies the buffer's text, or an undo that replays 100 amends, shows up here as a large number on its first run, though the gate only compares head with base. + +Budget: the default 5% from `budgets.toml`. Add no per-benchmark override. The base commit has no `amend` benchmark, so `bench-compare` reports it as head-only in this task's PR and gates it from the next PR on. Changes to `apply` or the history types also run in `transactions/apply_undo_100_edits_10mb` and both `snapshots/` benchmarks, which this task must not slow by more than 5%. + +## Out of scope + +- Wiring `amend` into `Editor`: deciding when a group starts and ends (a pause, a cursor move, switching between typing and deleting, a newline), keeping the "before" selections of the group's first command, and updating T-105's undo record. A later `helm-editor` task, after T-106. +- Amending an undone transaction, or one under a later transaction. Both are refused, and the rejected choices above say why. +- Splitting an amended transaction back into its steps, or merging two transactions that are already applied. +- Streaming an agent's edits into one transaction with `amend` (AG-4): Phase 7 decides. +- Limits on history size or memory: a later task, with T-102's.