Skip to main content

reovim_kernel/mm/
edit.rs

1//! Edit operations for undo/redo support.
2//!
3//! This module defines atomic edit operations that can be recorded
4//! and inverted for undo/redo functionality.
5
6use super::Position;
7
8/// A single atomic edit operation.
9///
10/// Edits are self-contained and can be inverted for undo/redo.
11/// Each edit records the position where it occurred and the text involved.
12///
13/// # Undo/Redo
14///
15/// Use [`Edit::inverse`] to get the operation that undoes this edit:
16/// - `Insert` becomes `Delete`
17/// - `Delete` becomes `Insert`
18///
19/// # Example
20///
21/// ```
22/// use reovim_kernel::api::v1::*;
23///
24/// let insert = Edit::insert(Position::new(0, 5), "Hello");
25/// assert!(insert.is_insert());
26/// assert_eq!(insert.text(), "Hello");
27///
28/// // Get the inverse for undo
29/// let undo = insert.inverse();
30/// assert!(undo.is_delete());
31/// assert_eq!(undo.position(), Position::new(0, 5));
32/// ```
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub enum Edit {
35    /// Text was inserted at a position.
36    Insert {
37        /// Position where text was inserted.
38        position: Position,
39        /// The inserted text.
40        text: String,
41    },
42    /// Text was deleted at a position.
43    Delete {
44        /// Position where deletion started.
45        position: Position,
46        /// The deleted text.
47        text: String,
48    },
49}
50
51impl Edit {
52    /// Create an insert edit.
53    #[must_use]
54    pub fn insert(position: Position, text: impl Into<String>) -> Self {
55        Self::Insert {
56            position,
57            text: text.into(),
58        }
59    }
60
61    /// Create a delete edit.
62    #[must_use]
63    pub fn delete(position: Position, text: impl Into<String>) -> Self {
64        Self::Delete {
65            position,
66            text: text.into(),
67        }
68    }
69
70    /// Get the inverse of this edit (for undo).
71    ///
72    /// Insert becomes Delete and vice versa. The position and text
73    /// are preserved.
74    #[must_use]
75    pub fn inverse(&self) -> Self {
76        match self {
77            Self::Insert { position, text } => Self::Delete {
78                position: *position,
79                text: text.clone(),
80            },
81            Self::Delete { position, text } => Self::Insert {
82                position: *position,
83                text: text.clone(),
84            },
85        }
86    }
87
88    /// Get the position where this edit occurred.
89    #[must_use]
90    pub const fn position(&self) -> Position {
91        match self {
92            Self::Insert { position, .. } | Self::Delete { position, .. } => *position,
93        }
94    }
95
96    /// Get the text involved in this edit.
97    #[must_use]
98    pub fn text(&self) -> &str {
99        match self {
100            Self::Insert { text, .. } | Self::Delete { text, .. } => text,
101        }
102    }
103
104    /// Check if this edit is an insertion.
105    #[must_use]
106    pub const fn is_insert(&self) -> bool {
107        matches!(self, Self::Insert { .. })
108    }
109
110    /// Check if this edit is a deletion.
111    #[must_use]
112    pub const fn is_delete(&self) -> bool {
113        matches!(self, Self::Delete { .. })
114    }
115
116    /// Check if this edit has no effect (empty text).
117    #[must_use]
118    pub fn is_empty(&self) -> bool {
119        self.text().is_empty()
120    }
121
122    /// Transform this edit's position through another edit.
123    ///
124    /// Returns a new edit with the same text but a position adjusted
125    /// to account for the effect of `against`. This is the OT inclusion
126    /// transformation (IT) applied at the edit level.
127    ///
128    /// # Example
129    ///
130    /// ```
131    /// use reovim_kernel::api::v1::*;
132    ///
133    /// // An insert at (0,5) transformed through an earlier insert of "abc" at (0,2)
134    /// let edit = Edit::insert(Position::new(0, 5), "hello");
135    /// let against = Edit::insert(Position::new(0, 2), "abc");
136    /// let transformed = edit.transform(&against);
137    /// assert_eq!(transformed.position(), Position::new(0, 8)); // 5 + 3
138    /// assert_eq!(transformed.text(), "hello"); // text unchanged
139    /// ```
140    #[must_use]
141    pub fn transform(&self, against: &Self) -> Self {
142        match self {
143            Self::Insert { position, text } => Self::Insert {
144                position: transform_position(*position, against),
145                text: text.clone(),
146            },
147            Self::Delete { position, text } => Self::Delete {
148                position: transform_position(*position, against),
149                text: text.clone(),
150            },
151        }
152    }
153}
154
155/// Dimensions of text for OT position transformation.
156///
157/// Describes the shape of a text string in terms of line count and
158/// the length of the last line. Used by [`transform_position`] to
159/// compute how an edit shifts positions in a 2D text buffer.
160///
161/// All lengths are in Unicode scalar values (chars), consistent with
162/// [`Position::column`] semantics throughout reovim.
163#[derive(Debug, Clone, Copy, PartialEq, Eq)]
164pub struct TextDimensions {
165    /// Number of newline characters in the text.
166    pub line_count: usize,
167    /// Character count of text after the last newline,
168    /// or the full character count if there are no newlines.
169    pub last_line_len: usize,
170}
171
172/// Compute the dimensions of a text string.
173///
174/// Returns the number of newlines and the character length of the
175/// text after the last newline.
176///
177/// # Example
178///
179/// ```
180/// use reovim_kernel::api::v1::text_dimensions;
181///
182/// let dims = text_dimensions("hello");
183/// assert_eq!(dims.line_count, 0);
184/// assert_eq!(dims.last_line_len, 5);
185///
186/// let dims = text_dimensions("ab\ncd\ne");
187/// assert_eq!(dims.line_count, 2);
188/// assert_eq!(dims.last_line_len, 1);
189/// ```
190#[must_use]
191pub fn text_dimensions(text: &str) -> TextDimensions {
192    let line_count = text.chars().filter(|&c| c == '\n').count();
193    let last_line_len = text.rsplit('\n').next().map_or(0, |s| s.chars().count());
194    TextDimensions {
195        line_count,
196        last_line_len,
197    }
198}
199
200/// Compute the end position of deleted text in the pre-delete buffer state.
201///
202/// Given a deletion starting at `pos` with the specified `text`, returns
203/// the position just past the end of the deleted region.
204#[must_use]
205pub fn delete_end(pos: Position, text: &str) -> Position {
206    let dims = text_dimensions(text);
207    if dims.line_count == 0 {
208        Position::new(pos.line, pos.column + dims.last_line_len)
209    } else {
210        Position::new(pos.line + dims.line_count, dims.last_line_len)
211    }
212}
213
214/// Transform a position through the effect of an edit.
215///
216/// This is the OT inclusion transformation (IT): given a position `pos`
217/// in the buffer state *before* `against` was applied, returns the
218/// equivalent position in the buffer state *after* `against`.
219///
220/// # Tie-breaking (same-position edits)
221///
222/// Uses left-bias convention: when `pos` equals the edit position,
223/// an Insert shifts `pos` right (the insert "happened before" our position),
224/// while a Delete leaves `pos` unchanged (`pos <= del_pos` → no shift).
225///
226/// # Character counting
227///
228/// All column arithmetic uses Unicode scalar values (`.chars().count()`),
229/// consistent with [`Position::column`] semantics.
230///
231/// # Example
232///
233/// ```
234/// use reovim_kernel::api::v1::*;
235///
236/// // Insert "abc" at (0,2) shifts position (0,5) to (0,8)
237/// let pos = transform_position(
238///     Position::new(0, 5),
239///     &Edit::insert(Position::new(0, 2), "abc"),
240/// );
241/// assert_eq!(pos, Position::new(0, 8));
242/// ```
243#[must_use]
244pub fn transform_position(pos: Position, against: &Edit) -> Position {
245    if against.is_empty() {
246        return pos;
247    }
248
249    match against {
250        Edit::Insert {
251            position: ins_pos,
252            text,
253        } => transform_position_against_insert(pos, *ins_pos, text),
254        Edit::Delete {
255            position: del_pos,
256            text,
257        } => transform_position_against_delete(pos, *del_pos, text),
258    }
259}
260
261/// Transform a position through an insert edit.
262fn transform_position_against_insert(pos: Position, ins_pos: Position, text: &str) -> Position {
263    // Position strictly before the insert is unaffected.
264    if pos < ins_pos {
265        return pos;
266    }
267
268    let dims = text_dimensions(text);
269
270    if pos.line == ins_pos.line {
271        // Same line as insert. Column >= ins_pos.column (due to pos >= ins_pos).
272        if dims.line_count == 0 {
273            // Single-line insert: shift column right.
274            Position::new(pos.line, pos.column + dims.last_line_len)
275        } else {
276            // Multi-line insert: position moves down and column resets
277            // relative to the end of the inserted text.
278            Position::new(
279                pos.line + dims.line_count,
280                pos.column - ins_pos.column + dims.last_line_len,
281            )
282        }
283    } else {
284        // Position is on a line after the insert line: shift line down.
285        Position::new(pos.line + dims.line_count, pos.column)
286    }
287}
288
289/// Transform a position through a delete edit.
290fn transform_position_against_delete(pos: Position, del_pos: Position, text: &str) -> Position {
291    // Position at or before the delete start is unaffected.
292    if pos <= del_pos {
293        return pos;
294    }
295
296    let del_end = delete_end(del_pos, text);
297    let dims = text_dimensions(text);
298
299    // Position within the deleted region collapses to the delete start.
300    if pos <= del_end {
301        return del_pos;
302    }
303
304    if pos.line == del_end.line {
305        // Position is on the last line of the deletion but after it.
306        if dims.line_count == 0 {
307            // Single-line delete: shift column left.
308            Position::new(pos.line, pos.column - dims.last_line_len)
309        } else {
310            // Multi-line delete: column merges onto the delete-start line.
311            Position::new(del_pos.line, del_pos.column + pos.column - del_end.column)
312        }
313    } else {
314        // Position is on a line after the deletion: shift line up.
315        Position::new(pos.line - dims.line_count, pos.column)
316    }
317}