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