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}