Skip to main content

kode_core/
transaction.rs

1/// A single edit step: insert, delete, or replace at a char offset.
2/// Each step stores enough information to be inverted.
3#[derive(Debug, Clone, PartialEq, Eq)]
4pub struct EditStep {
5    /// Char offset where the edit starts.
6    pub offset: usize,
7    /// Text that was deleted (empty for pure inserts).
8    pub deleted: String,
9    /// Text that was inserted (empty for pure deletes).
10    pub inserted: String,
11}
12
13impl EditStep {
14    /// Create an insert step.
15    pub fn insert(offset: usize, text: impl Into<String>) -> Self {
16        Self {
17            offset,
18            deleted: String::new(),
19            inserted: text.into(),
20        }
21    }
22
23    /// Create a delete step.
24    pub fn delete(offset: usize, deleted: impl Into<String>) -> Self {
25        Self {
26            offset,
27            deleted: deleted.into(),
28            inserted: String::new(),
29        }
30    }
31
32    /// Create a replace step.
33    pub fn replace(offset: usize, deleted: impl Into<String>, inserted: impl Into<String>) -> Self {
34        Self {
35            offset,
36            deleted: deleted.into(),
37            inserted: inserted.into(),
38        }
39    }
40
41    /// Create the inverse of this step (for undo).
42    pub fn inverse(&self) -> Self {
43        Self {
44            offset: self.offset,
45            deleted: self.inserted.clone(),
46            inserted: self.deleted.clone(),
47        }
48    }
49
50    /// Number of chars inserted.
51    pub fn inserted_len(&self) -> usize {
52        self.inserted.chars().count()
53    }
54
55    /// Number of chars deleted.
56    pub fn deleted_len(&self) -> usize {
57        self.deleted.chars().count()
58    }
59
60    /// True if this is a single character insert (for coalescing).
61    pub fn is_single_char_insert(&self) -> bool {
62        self.deleted.is_empty() && self.inserted.chars().count() == 1
63    }
64
65    /// True if this is a single character delete (for coalescing).
66    pub fn is_single_char_delete(&self) -> bool {
67        self.inserted.is_empty() && self.deleted.chars().count() == 1
68    }
69}
70
71/// A transaction groups one or more edit steps into an atomic operation.
72/// Applying a transaction is all-or-nothing. Transactions can be inverted for undo.
73#[derive(Debug, Clone)]
74pub struct Transaction {
75    pub steps: Vec<EditStep>,
76    /// Cursor position before this transaction was applied (for undo restoration).
77    pub cursor_before: Option<crate::Position>,
78    /// Cursor position after this transaction was applied.
79    pub cursor_after: Option<crate::Position>,
80}
81
82impl Transaction {
83    /// Create a transaction from a single step.
84    pub fn single(step: EditStep) -> Self {
85        Self {
86            steps: vec![step],
87            cursor_before: None,
88            cursor_after: None,
89        }
90    }
91
92    /// Create a transaction from multiple steps.
93    pub fn new(steps: Vec<EditStep>) -> Self {
94        Self {
95            steps,
96            cursor_before: None,
97            cursor_after: None,
98        }
99    }
100
101    /// Set cursor positions for undo/redo.
102    pub fn with_cursors(mut self, before: crate::Position, after: crate::Position) -> Self {
103        self.cursor_before = Some(before);
104        self.cursor_after = Some(after);
105        self
106    }
107
108    /// Create the inverse transaction (for undo). Steps are reversed and individually inverted.
109    /// Cursor positions are swapped.
110    pub fn inverse(&self) -> Self {
111        Self {
112            steps: self.steps.iter().rev().map(|s| s.inverse()).collect(),
113            cursor_before: self.cursor_after,
114            cursor_after: self.cursor_before,
115        }
116    }
117
118    /// True if this transaction can be coalesced with another.
119    /// A single-char insert/delete can merge into an existing run if it
120    /// continues at the expected position. Newlines break coalescing.
121    pub fn can_coalesce(&self, other: &Transaction) -> bool {
122        if self.steps.len() != 1 || other.steps.len() != 1 {
123            return false;
124        }
125        let a = &self.steps[0];
126        let b = &other.steps[0];
127
128        // Coalesce a single-char insert continuing an insert run
129        if a.deleted.is_empty() && !a.inserted.is_empty()
130            && b.is_single_char_insert()
131        {
132            let a_last = a.inserted.chars().last().unwrap();
133            let b_char = b.inserted.chars().next().unwrap();
134            // Don't coalesce across newlines or word boundaries.
135            // A space typed starts a new undo group (b_char == ' '), and
136            // the first non-space after a space also starts a new group.
137            if a_last == '\n' || b_char == '\n' || b_char == ' ' || a_last == ' ' {
138                return false;
139            }
140            return b.offset == a.offset + a.inserted_len();
141        }
142
143        // Coalesce a single-char delete continuing a delete run
144        if a.inserted.is_empty() && !a.deleted.is_empty()
145            && b.is_single_char_delete()
146        {
147            let b_char = b.deleted.chars().next().unwrap();
148            if b_char == '\n' {
149                return false;
150            }
151            // Backspace: offset decreases by 1
152            if b.offset + 1 == a.offset {
153                return true;
154            }
155            // Forward delete: same offset as end of existing delete
156            if b.offset == a.offset {
157                return true;
158            }
159        }
160
161        false
162    }
163
164    /// Merge another transaction into this one (for coalescing).
165    pub fn merge(&mut self, other: &Transaction) {
166        if self.steps.len() != 1 || other.steps.len() != 1 {
167            return;
168        }
169        // Update cursor_after to the merged transaction's end position
170        if other.cursor_after.is_some() {
171            self.cursor_after = other.cursor_after;
172        }
173
174        let a = &mut self.steps[0];
175        let b = &other.steps[0];
176
177        if a.deleted.is_empty() && b.is_single_char_insert() {
178            // Append insert
179            a.inserted.push_str(&b.inserted);
180        } else if a.inserted.is_empty() && b.is_single_char_delete() {
181            if b.offset + 1 == a.offset {
182                // Backspace: prepend deleted char
183                a.deleted.insert_str(0, &b.deleted);
184                a.offset = b.offset;
185            } else if b.offset == a.offset {
186                // Forward delete: append deleted char
187                a.deleted.push_str(&b.deleted);
188            }
189        }
190    }
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196
197    #[test]
198    fn insert_inverse() {
199        let step = EditStep::insert(5, "hello");
200        let inv = step.inverse();
201        assert_eq!(inv.offset, 5);
202        assert_eq!(inv.deleted, "hello");
203        assert!(inv.inserted.is_empty());
204    }
205
206    #[test]
207    fn delete_inverse() {
208        let step = EditStep::delete(3, "abc");
209        let inv = step.inverse();
210        assert_eq!(inv.offset, 3);
211        assert!(inv.deleted.is_empty());
212        assert_eq!(inv.inserted, "abc");
213    }
214
215    #[test]
216    fn replace_inverse() {
217        let step = EditStep::replace(0, "old", "new");
218        let inv = step.inverse();
219        assert_eq!(inv.deleted, "new");
220        assert_eq!(inv.inserted, "old");
221    }
222
223    #[test]
224    fn transaction_inverse() {
225        let tx = Transaction::new(vec![
226            EditStep::insert(0, "a"),
227            EditStep::insert(1, "b"),
228        ]);
229        let inv = tx.inverse();
230        assert_eq!(inv.steps.len(), 2);
231        // Reversed order
232        assert_eq!(inv.steps[0].offset, 1);
233        assert_eq!(inv.steps[0].deleted, "b");
234        assert_eq!(inv.steps[1].offset, 0);
235        assert_eq!(inv.steps[1].deleted, "a");
236    }
237
238    #[test]
239    fn coalesce_inserts() {
240        let a = Transaction::single(EditStep::insert(0, "a"));
241        let b = Transaction::single(EditStep::insert(1, "b"));
242        let c = Transaction::single(EditStep::insert(2, "\n"));
243        assert!(a.can_coalesce(&b));
244        assert!(!b.can_coalesce(&c)); // newline breaks coalescing
245    }
246
247    #[test]
248    fn coalesce_backspaces() {
249        let a = Transaction::single(EditStep::delete(3, "d"));
250        let b = Transaction::single(EditStep::delete(2, "c"));
251        assert!(a.can_coalesce(&b));
252    }
253
254    #[test]
255    fn merge_inserts() {
256        let mut a = Transaction::single(EditStep::insert(0, "a"));
257        let b = Transaction::single(EditStep::insert(1, "b"));
258        a.merge(&b);
259        assert_eq!(a.steps[0].inserted, "ab");
260    }
261
262    #[test]
263    fn merge_backspaces() {
264        let mut a = Transaction::single(EditStep::delete(3, "d"));
265        let b = Transaction::single(EditStep::delete(2, "c"));
266        a.merge(&b);
267        assert_eq!(a.steps[0].deleted, "cd");
268        assert_eq!(a.steps[0].offset, 2);
269    }
270
271    // ── Coalescing space bug ──────────────────────────────────────────────
272
273    /// Typing a space after a word should start a new undo group, not append
274    /// to the current word's group.  The rule only fires to break coalescing
275    /// when a_last==' ' AND b_char!=' ', which means the space itself slips
276    /// into the existing word group instead of opening a fresh one.
277    ///
278    /// Correct behaviour: `can_coalesce` returns false when b_char == ' ',
279    /// so each space-separated word is its own undo unit.
280    #[test]
281    fn space_typed_after_word_breaks_coalescing() {
282        let a = Transaction::single(EditStep::insert(0, "hello"));
283        let b = Transaction::single(EditStep::insert(5, " "));
284        // Currently returns true (bug): space gets coalesced into "hello"'s group.
285        // Should return false so the space is a separate undo step.
286        assert!(
287            !a.can_coalesce(&b),
288            "space after a word should break coalescing, not join the word's group"
289        );
290    }
291}