Skip to main content

mago_text_edit/
lib.rs

1use serde::Deserialize;
2use serde::Serialize;
3
4/// Represents the safety of applying a specific edit.
5///
6/// Ordered from most safe to least safe.
7#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash, Serialize, Deserialize, PartialOrd, Ord)]
8#[serde(rename_all = "lowercase")]
9#[derive(Default)]
10pub enum Safety {
11    /// Safe to apply automatically. The semantic meaning of the code is preserved.
12    /// Example: Formatting, renaming a local variable.
13    #[default]
14    Safe,
15    /// Likely safe, but changes semantics slightly or relies on heuristics.
16    /// Example: Removing an unused variable (might have side effects in constructor).
17    PotentiallyUnsafe,
18    /// Requires manual user review. Valid code, but changes logic significantly.
19    /// Example: Changing type casts, altering control flow logic.
20    Unsafe,
21}
22
23/// Represents a range in the source text identified by byte offsets.
24#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash, Serialize, Deserialize)]
25pub struct TextRange {
26    pub start: u32,
27    pub end: u32,
28}
29
30impl TextRange {
31    #[inline(always)]
32    pub fn new(start: u32, end: u32) -> Self {
33        Self { start, end }
34    }
35
36    /// Returns the length of the range in bytes.
37    #[inline(always)]
38    pub fn len(&self) -> u32 {
39        self.end - self.start
40    }
41
42    /// Returns true if the range has a length of zero.
43    #[inline(always)]
44    pub fn is_empty(&self) -> bool {
45        self.start == self.end
46    }
47
48    /// Checks if this range overlaps with another.
49    ///
50    /// We consider touching ranges as overlapping (e.g. `0..5` and `5..10` overlap at 5).
51    /// This strictness is required to prevent ambiguity in insertion order (e.g. which
52    /// insertion happens first at offset 5?).
53    ///
54    /// Zero-length ranges (insertions) at the boundary of another range are also
55    /// considered overlapping (e.g. insert at 5 overlaps with `5..10`).
56    #[inline(always)]
57    pub fn overlaps(&self, other: &TextRange) -> bool {
58        self.start <= other.end && other.start <= self.end
59    }
60
61    /// Checks if this range contains a specific offset.
62    #[inline(always)]
63    pub fn contains(&self, offset: u32) -> bool {
64        offset >= self.start && offset < self.end
65    }
66}
67
68impl<T> From<T> for TextRange
69where
70    T: std::ops::RangeBounds<u32>,
71{
72    #[inline(always)]
73    fn from(r: T) -> Self {
74        let start = match r.start_bound() {
75            std::ops::Bound::Included(&s) => s,
76            std::ops::Bound::Excluded(&s) => s + 1,
77            std::ops::Bound::Unbounded => 0,
78        };
79
80        let end = match r.end_bound() {
81            std::ops::Bound::Included(&e) => e + 1,
82            std::ops::Bound::Excluded(&e) => e,
83            std::ops::Bound::Unbounded => u32::MAX, // Will fail bounds check later
84        };
85
86        Self::new(start, end)
87    }
88}
89
90/// A unified atomic edit operation.
91///
92/// This struct holds the data for a modification but does not execute it.
93/// It always refers to the byte offsets in the **ORIGINAL** source code.
94#[derive(Debug, Clone, Eq, PartialEq, Hash, Serialize, Deserialize)]
95pub struct TextEdit {
96    /// The range in the original text to be replaced.
97    pub range: TextRange,
98    /// The new text to replace the range with.
99    pub new_text: String,
100    /// How safe this specific edit is.
101    pub safety: Safety,
102}
103
104impl TextEdit {
105    /// Creates a delete edit (defaults to Safe).
106    pub fn delete(range: impl Into<TextRange>) -> Self {
107        Self { range: range.into(), new_text: String::new(), safety: Safety::Safe }
108    }
109
110    /// Creates an insert edit (defaults to Safe).
111    pub fn insert(offset: u32, text: impl Into<String>) -> Self {
112        Self { range: TextRange::new(offset, offset), new_text: text.into(), safety: Safety::Safe }
113    }
114
115    /// Creates a replace edit (defaults to Safe).
116    pub fn replace(range: impl Into<TextRange>, text: impl Into<String>) -> Self {
117        Self { range: range.into(), new_text: text.into(), safety: Safety::Safe }
118    }
119
120    /// Builder method to change the safety level of this edit.
121    ///
122    /// # Example
123    /// ```
124    /// use mago_text_edit::{TextEdit, Safety};
125    ///
126    /// let edit = TextEdit::replace(1..2, "b").with_safety(Safety::Unsafe);
127    /// assert_eq!(edit.safety, Safety::Unsafe);
128    /// ```
129    #[must_use]
130    pub fn with_safety(mut self, safety: Safety) -> Self {
131        self.safety = safety;
132        self
133    }
134}
135
136#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)]
137pub enum ApplyResult {
138    /// The edits were successfully applied.
139    Applied,
140    /// The edits were invalid (e.g., start > end or > file length).
141    OutOfBounds,
142    /// The edits overlapped with previously confirmed edits or each other.
143    Overlap,
144    /// The provided checker function returned `false`.
145    Rejected,
146    /// Edit rejected because it's unsafe and we're in safe or potentially-unsafe mode.
147    Unsafe,
148    /// Edit rejected because it's potentially-unsafe and we're in safe mode.
149    PotentiallyUnsafe,
150}
151
152/// A high-performance, transactional text editor.
153///
154/// It accumulates edits and applies them in a single pass when `finish()` is called.
155/// It ensures all edits are valid, non-overlapping, and safe according to optional user checks.
156#[derive(Debug, Clone)]
157pub struct TextEditor<'a> {
158    original_text: &'a str,
159    original_len: u32,
160    edits: Vec<TextEdit>,
161    /// Maximum safety level to accept. Edits above this level are rejected.
162    safety_threshold: Safety,
163}
164
165impl<'a> TextEditor<'a> {
166    /// Creates a new TextEditor with the default safety threshold (Unsafe - accepts all edits).
167    pub fn new(text: &'a str) -> Self {
168        Self {
169            original_text: text,
170            original_len: text.len() as u32,
171            edits: Vec::new(),
172            safety_threshold: Safety::Unsafe,
173        }
174    }
175
176    /// Creates a new TextEditor with a specific safety threshold.
177    ///
178    /// Edits with a safety level above the threshold will be rejected.
179    ///
180    /// # Example
181    /// ```
182    /// use mago_text_edit::{TextEditor, Safety};
183    ///
184    /// // Only accept Safe edits
185    /// let editor = TextEditor::with_safety("hello", Safety::Safe);
186    /// ```
187    pub fn with_safety(text: &'a str, threshold: Safety) -> Self {
188        Self { original_text: text, original_len: text.len() as u32, edits: Vec::new(), safety_threshold: threshold }
189    }
190
191    /// Checks if an edit's safety level exceeds the threshold.
192    /// Returns the appropriate rejection result, or None if the edit is acceptable.
193    #[inline]
194    fn check_safety(&self, edit_safety: Safety) -> Option<ApplyResult> {
195        if edit_safety > self.safety_threshold {
196            Some(match edit_safety {
197                Safety::Unsafe => ApplyResult::Unsafe,
198                Safety::PotentiallyUnsafe => ApplyResult::PotentiallyUnsafe,
199                Safety::Safe => unreachable!(),
200            })
201        } else {
202            None
203        }
204    }
205
206    /// Applies a single edit.
207    ///
208    /// Uses binary search to check for overlaps in O(log N).
209    /// Rejects edits that exceed the safety threshold.
210    pub fn apply<F>(&mut self, edit: TextEdit, checker: Option<F>) -> ApplyResult
211    where
212        F: FnOnce(&str) -> bool,
213    {
214        // Check safety first
215        if let Some(rejection) = self.check_safety(edit.safety) {
216            return rejection;
217        }
218
219        if edit.range.end > self.original_len || edit.range.start > edit.range.end {
220            return ApplyResult::OutOfBounds;
221        }
222
223        let search_idx = self.edits.partition_point(|e| e.range.end <= edit.range.start);
224
225        if let Some(existing) = self.edits.get(search_idx)
226            && existing.range.overlaps(&edit.range)
227        {
228            return ApplyResult::Overlap;
229        }
230
231        if let Some(check_fn) = checker {
232            let simulated_str = stitch_one(self.original_text, &self.edits, &edit);
233            if !check_fn(&simulated_str) {
234                return ApplyResult::Rejected;
235            }
236        }
237
238        self.edits.insert(search_idx, edit);
239
240        ApplyResult::Applied
241    }
242
243    /// Applies a batch of edits atomically.
244    ///
245    /// Either all edits are applied, or none are (if overlap/check/safety fails).
246    /// If any edit in the batch exceeds the safety threshold, the entire batch is rejected.
247    pub fn apply_batch<F>(&mut self, mut new_edits: Vec<TextEdit>, checker: Option<F>) -> ApplyResult
248    where
249        F: FnOnce(&str) -> bool,
250    {
251        if new_edits.is_empty() {
252            return ApplyResult::Applied;
253        }
254
255        // Check safety of all edits first
256        for edit in &new_edits {
257            if let Some(rejection) = self.check_safety(edit.safety) {
258                return rejection;
259            }
260        }
261
262        new_edits
263            .sort_unstable_by(|a, b| a.range.start.cmp(&b.range.start).then_with(|| a.range.end.cmp(&b.range.end)));
264
265        for i in 0..new_edits.len() {
266            let edit = &new_edits[i];
267
268            if edit.range.end > self.original_len || edit.range.start > edit.range.end {
269                return ApplyResult::OutOfBounds;
270            }
271
272            if i > 0 && new_edits[i - 1].range.overlaps(&edit.range) {
273                return ApplyResult::Overlap;
274            }
275        }
276
277        {
278            let mut old_iter = self.edits.iter();
279            let mut new_iter = new_edits.iter();
280            let mut next_old = old_iter.next();
281            let mut next_new = new_iter.next();
282
283            while let (Some(old), Some(new)) = (next_old, next_new) {
284                if old.range.overlaps(&new.range) {
285                    return ApplyResult::Overlap;
286                }
287                if old.range.start < new.range.start {
288                    next_old = old_iter.next();
289                } else {
290                    next_new = new_iter.next();
291                }
292            }
293        }
294
295        if let Some(check_fn) = checker {
296            let simulated_str = stitch_merged(self.original_text, &self.edits, &new_edits);
297            if !check_fn(&simulated_str) {
298                return ApplyResult::Rejected;
299            }
300        }
301
302        self.edits.reserve(new_edits.len());
303        self.edits.extend(new_edits);
304        self.edits.sort_by(|a, b| a.range.start.cmp(&b.range.start));
305
306        ApplyResult::Applied
307    }
308
309    /// Consumes the editor and returns the final modified string.
310    pub fn finish(self) -> String {
311        stitch(self.original_text, &self.edits)
312    }
313
314    /// Returns a slice of the currently applied edits.
315    pub fn get_edits(&self) -> &[TextEdit] {
316        &self.edits
317    }
318
319    /// Returns the current safety threshold.
320    pub fn safety_threshold(&self) -> Safety {
321        self.safety_threshold
322    }
323}
324
325/// Standard stitching of a sorted list.
326/// Calculates exact capacity first to guarantee exactly 1 allocation.
327fn stitch(original: &str, edits: &[TextEdit]) -> String {
328    let mut final_len = original.len();
329    for edit in edits {
330        final_len = final_len.saturating_sub(edit.range.len() as usize).saturating_add(edit.new_text.len());
331    }
332
333    let mut output = String::with_capacity(final_len);
334    let mut last_processed = 0;
335
336    for edit in edits {
337        let start = edit.range.start as usize;
338        let end = edit.range.end as usize;
339
340        if start > last_processed {
341            output.push_str(&original[last_processed..start]);
342        }
343        output.push_str(&edit.new_text);
344        last_processed = end;
345    }
346
347    if last_processed < original.len() {
348        output.push_str(&original[last_processed..]);
349    }
350
351    output
352}
353
354/// Simulation for a single new edit (avoids creating a new vector).
355fn stitch_one(original: &str, existing_edits: &[TextEdit], new_edit: &TextEdit) -> String {
356    let slice = std::slice::from_ref(new_edit);
357    stitch_merged(original, existing_edits, slice)
358}
359
360/// Simulation for merging two sorted lists of edits without mutating the original.
361/// Used by the checker to verify validity before committing.
362fn stitch_merged(original: &str, old_edits: &[TextEdit], new_edits: &[TextEdit]) -> String {
363    let mut final_len = original.len();
364    for e in old_edits {
365        final_len = final_len - e.range.len() as usize + e.new_text.len();
366    }
367    for e in new_edits {
368        final_len = final_len - e.range.len() as usize + e.new_text.len();
369    }
370
371    let mut output = String::with_capacity(final_len);
372    let mut last_processed = 0;
373
374    let mut old_iter = old_edits.iter();
375    let mut new_iter = new_edits.iter();
376    let mut next_old = old_iter.next();
377    let mut next_new = new_iter.next();
378
379    loop {
380        let next_edit = match (next_old, next_new) {
381            (Some(o), Some(n)) => {
382                if o.range.start < n.range.start {
383                    next_old = old_iter.next();
384                    o
385                } else {
386                    next_new = new_iter.next();
387                    n
388                }
389            }
390            (Some(o), None) => {
391                next_old = old_iter.next();
392                o
393            }
394            (None, Some(n)) => {
395                next_new = new_iter.next();
396                n
397            }
398            (None, None) => break,
399        };
400
401        let start = next_edit.range.start as usize;
402        let end = next_edit.range.end as usize;
403
404        if start > last_processed {
405            output.push_str(&original[last_processed..start]);
406        }
407        output.push_str(&next_edit.new_text);
408        last_processed = end;
409    }
410
411    if last_processed < original.len() {
412        output.push_str(&original[last_processed..]);
413    }
414
415    output
416}
417
418#[cfg(test)]
419mod tests {
420    use super::*;
421
422    #[test]
423    fn test_apply_single() {
424        let mut editor = TextEditor::new("hello world");
425        editor.apply(TextEdit::replace(0..5, "hi"), None::<fn(&str) -> bool>);
426        assert_eq!(editor.finish(), "hi world");
427    }
428
429    #[test]
430    fn test_checker_fail() {
431        let mut editor = TextEditor::new("abc");
432        // Fail if length of result > 10 (it won't be, so this passes check logic is inverted? No.)
433        // Checker logic: return TRUE if valid.
434        let res = editor.apply(TextEdit::delete(0..1), Some(|s: &str| s.len() > 10));
435        // "bc" len is 2. 2 > 10 is false. Checker returns false.
436        assert_eq!(res, ApplyResult::Rejected);
437        assert_eq!(editor.finish(), "abc"); // Unchanged
438    }
439
440    #[test]
441    fn test_overlap_search() {
442        let mut editor = TextEditor::new("0123456789");
443        editor.apply(TextEdit::replace(2..4, "x"), None::<fn(&str) -> bool>); // 2,3
444
445        // Try edit at 3..5 (Overlaps 2..4)
446        assert_eq!(editor.apply(TextEdit::replace(3..5, "y"), None::<fn(&str) -> bool>), ApplyResult::Overlap);
447
448        // Try edit at 1..3 (Overlaps 2..4)
449        assert_eq!(editor.apply(TextEdit::replace(1..3, "y"), None::<fn(&str) -> bool>), ApplyResult::Overlap);
450
451        // Try edit at 4..5 (Safe)
452        assert_eq!(editor.apply(TextEdit::replace(4..5, "y"), None::<fn(&str) -> bool>), ApplyResult::Applied);
453
454        assert_eq!(editor.finish(), "01xy56789");
455    }
456
457    #[test]
458    fn test_batch_apply_ordering() {
459        let mut editor = TextEditor::new("abcdef");
460
461        // Batch with mixed order inputs
462        let batch = vec![
463            TextEdit::replace(4..5, "E"), // e -> E
464            TextEdit::replace(0..1, "A"), // a -> A
465        ];
466
467        editor.apply_batch(batch, None::<fn(&str) -> bool>);
468        assert_eq!(editor.finish(), "AbcdEf");
469    }
470
471    #[test]
472    fn test_safety_default_is_safe() {
473        let edit = TextEdit::replace(0..1, "x");
474        assert_eq!(edit.safety, Safety::Safe);
475    }
476
477    #[test]
478    fn test_with_safety_builder() {
479        let edit = TextEdit::replace(0..1, "x").with_safety(Safety::Unsafe);
480        assert_eq!(edit.safety, Safety::Unsafe);
481
482        let edit = TextEdit::delete(0..1).with_safety(Safety::PotentiallyUnsafe);
483        assert_eq!(edit.safety, Safety::PotentiallyUnsafe);
484    }
485
486    #[test]
487    fn test_safety_threshold_safe_mode() {
488        let mut editor = TextEditor::with_safety("hello world", Safety::Safe);
489
490        // Safe edit should be accepted
491        let res = editor.apply(TextEdit::replace(0..5, "hi"), None::<fn(&str) -> bool>);
492        assert_eq!(res, ApplyResult::Applied);
493
494        // PotentiallyUnsafe edit should be rejected
495        let res = editor
496            .apply(TextEdit::replace(6..11, "there").with_safety(Safety::PotentiallyUnsafe), None::<fn(&str) -> bool>);
497        assert_eq!(res, ApplyResult::PotentiallyUnsafe);
498
499        // Unsafe edit should be rejected
500        let res = editor.apply(TextEdit::replace(6..11, "there").with_safety(Safety::Unsafe), None::<fn(&str) -> bool>);
501        assert_eq!(res, ApplyResult::Unsafe);
502
503        assert_eq!(editor.finish(), "hi world"); // Only safe edit applied
504    }
505
506    #[test]
507    fn test_safety_threshold_potentially_unsafe_mode() {
508        let mut editor = TextEditor::with_safety("hello world", Safety::PotentiallyUnsafe);
509
510        // Safe edit should be accepted
511        let res = editor.apply(TextEdit::replace(0..5, "hi"), None::<fn(&str) -> bool>);
512        assert_eq!(res, ApplyResult::Applied);
513
514        // PotentiallyUnsafe edit should be accepted
515        let res = editor
516            .apply(TextEdit::replace(6..11, "there").with_safety(Safety::PotentiallyUnsafe), None::<fn(&str) -> bool>);
517        assert_eq!(res, ApplyResult::Applied);
518
519        assert_eq!(editor.finish(), "hi there");
520    }
521
522    #[test]
523    fn test_safety_threshold_unsafe_mode() {
524        let mut editor = TextEditor::with_safety("hello world", Safety::Unsafe);
525
526        // All safety levels should be accepted
527        let res = editor.apply(TextEdit::replace(0..1, "H"), None::<fn(&str) -> bool>);
528        assert_eq!(res, ApplyResult::Applied);
529
530        let res =
531            editor.apply(TextEdit::replace(1..2, "E").with_safety(Safety::PotentiallyUnsafe), None::<fn(&str) -> bool>);
532        assert_eq!(res, ApplyResult::Applied);
533
534        let res = editor.apply(TextEdit::replace(2..3, "L").with_safety(Safety::Unsafe), None::<fn(&str) -> bool>);
535        assert_eq!(res, ApplyResult::Applied);
536
537        assert_eq!(editor.finish(), "HELlo world");
538    }
539
540    #[test]
541    fn test_batch_safety_rejection() {
542        let mut editor = TextEditor::with_safety("hello", Safety::Safe);
543
544        // Batch with one unsafe edit should reject entire batch
545        let batch = vec![
546            TextEdit::replace(0..1, "H"),                             // Safe
547            TextEdit::replace(1..2, "E").with_safety(Safety::Unsafe), // Unsafe - should cause rejection
548        ];
549
550        let res = editor.apply_batch(batch, None::<fn(&str) -> bool>);
551        assert_eq!(res, ApplyResult::Unsafe);
552
553        // Original text unchanged
554        assert_eq!(editor.finish(), "hello");
555    }
556
557    #[test]
558    fn test_safety_ordering() {
559        // Test that Safety enum orders correctly (Safe < PotentiallyUnsafe < Unsafe)
560        assert!(Safety::Safe < Safety::PotentiallyUnsafe);
561        assert!(Safety::PotentiallyUnsafe < Safety::Unsafe);
562        assert!(Safety::Safe < Safety::Unsafe);
563    }
564
565    #[test]
566    fn test_insert_at_same_offset_overlaps_replace() {
567        let mut editor = TextEditor::new("0123456789");
568
569        let res = editor.apply(TextEdit::replace(2..8, "replaced"), None::<fn(&str) -> bool>);
570        assert_eq!(res, ApplyResult::Applied);
571
572        let res = editor.apply(TextEdit::insert(2, "inserted"), None::<fn(&str) -> bool>);
573        assert_eq!(res, ApplyResult::Overlap);
574
575        assert_eq!(editor.finish(), "01replaced89");
576    }
577
578    #[test]
579    fn test_insert_after_replace_at_different_offset() {
580        let mut editor = TextEditor::new("0123456789");
581
582        let res = editor.apply(TextEdit::replace(2..5, "ABC"), None::<fn(&str) -> bool>);
583        assert_eq!(res, ApplyResult::Applied);
584
585        let res = editor.apply(TextEdit::insert(6, "X"), None::<fn(&str) -> bool>);
586        assert_eq!(res, ApplyResult::Applied);
587
588        assert_eq!(editor.finish(), "01ABC5X6789");
589    }
590
591    #[test]
592    fn test_insert_at_start_of_replace_overlaps() {
593        // Regression test for issue #828:
594        // An insert at the start of a replace should overlap
595        let mut editor = TextEditor::new("0123456789");
596
597        let res = editor.apply(TextEdit::replace(2..5, "ABC"), None::<fn(&str) -> bool>);
598        assert_eq!(res, ApplyResult::Applied);
599
600        let res = editor.apply(TextEdit::insert(2, "X"), None::<fn(&str) -> bool>);
601        assert_eq!(res, ApplyResult::Overlap);
602
603        assert_eq!(editor.finish(), "01ABC56789");
604    }
605
606    #[test]
607    fn test_batch_insert_and_replace_at_same_offset_overlap() {
608        let mut editor = TextEditor::new("0123456789");
609
610        let batch = vec![
611            TextEdit::insert(2, "inserted"), // insert at 2
612            TextEdit::replace(2..5, "ABC"),  // replace starting at 2
613        ];
614
615        let res = editor.apply_batch(batch, None::<fn(&str) -> bool>);
616        assert_eq!(res, ApplyResult::Overlap);
617
618        assert_eq!(editor.finish(), "0123456789");
619    }
620
621    #[test]
622    fn test_touching_ranges_overlap() {
623        let range1 = TextRange::new(0, 5);
624        let range2 = TextRange::new(5, 10);
625        assert!(range1.overlaps(&range2));
626        assert!(range2.overlaps(&range1));
627    }
628
629    #[test]
630    fn test_insert_range_overlaps_with_adjacent_range() {
631        let insert_range = TextRange::new(5, 5);
632        let replace_range = TextRange::new(5, 10);
633        assert!(insert_range.overlaps(&replace_range));
634        assert!(replace_range.overlaps(&insert_range));
635    }
636}