Skip to main content

perl_edit/
lib.rs

1//! Edit tracking for incremental parsing
2//!
3//! This module provides types and algorithms for tracking edits to source code
4//! and applying them to an existing parse tree.
5
6use perl_position_tracking::{Position, Range};
7
8/// Represents an edit to the source text
9#[derive(Debug, Clone, PartialEq)]
10pub struct Edit {
11    /// Start byte offset of the edit
12    pub start_byte: usize,
13    /// End byte offset of the text being replaced (in old source)
14    pub old_end_byte: usize,
15    /// End byte offset after the edit (in new source)
16    pub new_end_byte: usize,
17    /// Start position (line/column)
18    pub start_position: Position,
19    /// Old end position before edit
20    pub old_end_position: Position,
21    /// New end position after edit
22    pub new_end_position: Position,
23}
24
25impl Edit {
26    /// Create a new edit
27    pub fn new(
28        start_byte: usize,
29        old_end_byte: usize,
30        new_end_byte: usize,
31        start_position: Position,
32        old_end_position: Position,
33        new_end_position: Position,
34    ) -> Self {
35        Edit {
36            start_byte,
37            old_end_byte,
38            new_end_byte,
39            start_position,
40            old_end_position,
41            new_end_position,
42        }
43    }
44
45    /// Calculate the byte shift caused by this edit
46    pub fn byte_shift(&self) -> isize {
47        self.new_end_byte as isize - self.old_end_byte as isize
48    }
49
50    /// Calculate the line shift caused by this edit
51    pub fn line_shift(&self) -> i32 {
52        self.new_end_position.line as i32 - self.old_end_position.line as i32
53    }
54
55    /// Check if a byte position is affected by this edit
56    pub fn affects_byte(&self, byte: usize) -> bool {
57        byte >= self.start_byte
58    }
59
60    /// Check if a range overlaps with this edit
61    pub fn overlaps_range(&self, range: &Range) -> bool {
62        range.start.byte < self.old_end_byte && range.end.byte > self.start_byte
63    }
64
65    /// Apply this edit to a position
66    pub fn apply_to_position(&self, pos: Position) -> Option<Position> {
67        if pos.byte < self.start_byte {
68            // Position is before the edit - unchanged
69            Some(pos)
70        } else if pos.byte >= self.old_end_byte {
71            // Position is after the edit - shift it
72            Some(Position {
73                byte: (pos.byte as isize + self.byte_shift()) as usize,
74                line: (pos.line as i32 + self.line_shift()) as u32,
75                column: if pos.line == self.old_end_position.line {
76                    // Same line as edit end - adjust column
77                    let col_shift =
78                        self.new_end_position.column as i32 - self.old_end_position.column as i32;
79                    (pos.column as i32 + col_shift) as u32
80                } else {
81                    // Different line - column unchanged
82                    pos.column
83                },
84            })
85        } else {
86            // Position is within the edit - invalidate
87            None
88        }
89    }
90
91    /// Apply this edit to a range
92    pub fn apply_to_range(&self, range: &Range) -> Option<Range> {
93        let new_start = self.apply_to_position(range.start)?;
94        let new_end = self.apply_to_position(range.end)?;
95        Some(Range::new(new_start, new_end))
96    }
97}
98
99/// Collection of edits that can be applied together
100#[derive(Debug, Clone, Default)]
101pub struct EditSet {
102    pub(crate) edits: Vec<Edit>,
103}
104
105impl EditSet {
106    /// Create a new empty edit set
107    pub fn new() -> Self {
108        EditSet { edits: Vec::new() }
109    }
110
111    /// Add an edit to the set
112    pub fn add(&mut self, edit: Edit) {
113        // Keep edits sorted by start position
114        let pos = self
115            .edits
116            .iter()
117            .position(|e| e.start_byte > edit.start_byte)
118            .unwrap_or(self.edits.len());
119        self.edits.insert(pos, edit);
120    }
121
122    /// Apply all edits to a position
123    pub fn apply_to_position(&self, mut pos: Position) -> Option<Position> {
124        for edit in &self.edits {
125            pos = edit.apply_to_position(pos)?;
126        }
127        Some(pos)
128    }
129
130    /// Apply all edits to a range
131    pub fn apply_to_range(&self, mut range: Range) -> Option<Range> {
132        for edit in &self.edits {
133            range = edit.apply_to_range(&range)?;
134        }
135        Some(range)
136    }
137
138    /// Returns the number of edits in the set.
139    pub fn len(&self) -> usize {
140        self.edits.len()
141    }
142
143    /// Returns true if the edit set is empty.
144    pub fn is_empty(&self) -> bool {
145        self.edits.is_empty()
146    }
147
148    /// Provides read-only access to the underlying edits.
149    pub fn edits(&self) -> &[Edit] {
150        &self.edits
151    }
152
153    /// Check if a range is affected by any edit
154    pub fn affects_range(&self, range: &Range) -> bool {
155        self.edits.iter().any(|edit| edit.overlaps_range(range))
156    }
157
158    /// Get the total byte shift at a given position
159    pub fn byte_shift_at(&self, byte: usize) -> isize {
160        self.edits
161            .iter()
162            .filter(|edit| edit.old_end_byte <= byte)
163            .map(|edit| edit.byte_shift())
164            .sum()
165    }
166
167    /// Get all ranges affected by the edits
168    pub fn affected_ranges(&self) -> Vec<Range> {
169        self.edits
170            .iter()
171            .map(|edit| Range::new(edit.start_position, edit.old_end_position))
172            .collect()
173    }
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179    use perl_tdd_support::must_some;
180
181    #[test]
182    fn test_simple_edit() {
183        // Replace "hello" with "goodbye" at position 10
184        let edit = Edit::new(
185            10,
186            15,
187            17,
188            Position::new(10, 2, 5),
189            Position::new(15, 2, 10),
190            Position::new(17, 2, 12),
191        );
192
193        assert_eq!(edit.byte_shift(), 2);
194        assert_eq!(edit.line_shift(), 0);
195
196        // Position before edit - unchanged
197        let pos = Position::new(5, 1, 5);
198        assert_eq!(edit.apply_to_position(pos), Some(pos));
199
200        // Position after edit - shifted
201        let pos = Position::new(20, 2, 15);
202        let new_pos = must_some(edit.apply_to_position(pos));
203        assert_eq!(new_pos.byte, 22);
204        assert_eq!(new_pos.column, 17);
205    }
206
207    #[test]
208    fn test_multiline_edit() {
209        // Replace multiple lines
210        let edit = Edit::new(
211            10,
212            30,
213            20,
214            Position::new(10, 2, 5),
215            Position::new(30, 4, 10),
216            Position::new(20, 2, 15),
217        );
218
219        assert_eq!(edit.byte_shift(), -10);
220        assert_eq!(edit.line_shift(), -2);
221
222        // Position on later line - shifted
223        let pos = Position::new(50, 6, 5);
224        let new_pos = must_some(edit.apply_to_position(pos));
225        assert_eq!(new_pos.byte, 40);
226        assert_eq!(new_pos.line, 4);
227        assert_eq!(new_pos.column, 5);
228    }
229
230    #[test]
231    fn test_edit_set() {
232        let mut edits = EditSet::new();
233
234        // Add two non-overlapping edits
235        edits.add(Edit::new(
236            10,
237            15,
238            17,
239            Position::new(10, 2, 5),
240            Position::new(15, 2, 10),
241            Position::new(17, 2, 12),
242        ));
243
244        edits.add(Edit::new(
245            30,
246            35,
247            40,
248            Position::new(30, 3, 5),
249            Position::new(35, 3, 10),
250            Position::new(40, 3, 15),
251        ));
252
253        // Check cumulative shift
254        assert_eq!(edits.byte_shift_at(50), 7); // +2 from first, +5 from second
255    }
256}