Skip to main content

rs_hack/
surgical.rs

1//! Surgical edit engine: applies precise, minimal text replacements
2//! to source code while preserving all formatting, comments, and whitespace.
3
4use std::cmp::Ordering;
5
6use proc_macro2::LineColumn;
7
8/// Represents a single textual replacement in the source code.
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct Replacement {
11    /// Starting position (line, column) - 1-indexed for lines, 0-indexed for columns
12    pub start: LineColumn,
13    /// Ending position (line, column) - 1-indexed for lines, 0-indexed for columns
14    pub end: LineColumn,
15    /// The text to replace with
16    pub new_text: String,
17}
18
19impl Replacement {
20    pub const fn new(start: LineColumn, end: LineColumn, new_text: String) -> Self {
21        Self {
22            start,
23            end,
24            new_text,
25        }
26    }
27}
28
29impl Ord for Replacement {
30    fn cmp(&self, other: &Self) -> Ordering {
31        // Sort by start position (line, then column)
32        match self.start.line.cmp(&other.start.line) {
33            Ordering::Equal => self.start.column.cmp(&other.start.column),
34            other => other,
35        }
36    }
37}
38
39impl PartialOrd for Replacement {
40    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
41        Some(self.cmp(other))
42    }
43}
44
45/// Apply surgical edits to source code, preserving all formatting.
46///
47/// This function takes the original source code and a list of replacements,
48/// and produces a new string with only those specific changes applied.
49///
50/// # Arguments
51/// * `original_source` - The original source code
52/// * `replacements` - List of replacements to apply (will be sorted automatically)
53///
54/// # Returns
55/// The modified source code with only the specified changes applied
56///
57/// # Example
58/// ```
59/// use proc_macro2::LineColumn;
60/// use rs_hack::surgical::{Replacement, apply_surgical_edits};
61///
62/// let source = "fn foo() {\n    let x = 1;\n}\n";
63/// let replacements = vec![Replacement::new(
64///     LineColumn {
65///         line: 2,
66///         column: 12,
67///     },
68///     LineColumn {
69///         line: 2,
70///         column: 13,
71///     },
72///     "42".to_string(),
73/// )];
74///
75/// let result = apply_surgical_edits(source, replacements);
76/// assert_eq!(result, "fn foo() {\n    let x = 42;\n}");
77/// ```
78pub fn apply_surgical_edits(original_source: &str, mut replacements: Vec<Replacement>) -> String {
79    if replacements.is_empty() {
80        return original_source.to_string();
81    }
82
83    // Sort replacements by position
84    replacements.sort();
85
86    // Validate no overlapping replacements
87    for i in 1..replacements.len() {
88        let prev = &replacements[i - 1];
89        let curr = &replacements[i];
90
91        if prev.end.line > curr.start.line
92            || (prev.end.line == curr.start.line && prev.end.column > curr.start.column)
93        {
94            panic!(
95                "Overlapping replacements detected: {:?} and {:?}",
96                prev, curr
97            );
98        }
99    }
100
101    let lines: Vec<&str> = original_source.lines().collect();
102    let mut result = String::new();
103
104    let mut current_line = 1usize; // 1-indexed to match proc_macro2
105    let mut current_col = 0usize; // 0-indexed
106
107    for replacement in replacements {
108        // Copy unchanged text up to this replacement
109
110        // Copy full lines before the replacement
111        while current_line < replacement.start.line {
112            if current_line <= lines.len() {
113                // Add any remaining text on current line
114                if let Some(line) = lines.get(current_line - 1)
115                    && current_col < line.len()
116                {
117                    result.push_str(&line[current_col..]);
118                }
119                result.push('\n');
120            }
121            current_line += 1;
122            current_col = 0;
123        }
124
125        // Copy partial line up to replacement start (on the same line)
126        if current_line == replacement.start.line
127            && let Some(line) = lines.get(current_line - 1)
128            && current_col < replacement.start.column
129            && replacement.start.column <= line.len()
130        {
131            result.push_str(&line[current_col..replacement.start.column]);
132        }
133
134        // Apply the replacement
135        result.push_str(&replacement.new_text);
136
137        // Update position to after the replacement
138        current_line = replacement.end.line;
139        current_col = replacement.end.column;
140    }
141
142    // Copy remaining text after all replacements
143    while current_line <= lines.len() {
144        if let Some(line) = lines.get(current_line - 1)
145            && current_col < line.len()
146        {
147            result.push_str(&line[current_col..]);
148        }
149        if current_line < lines.len() {
150            result.push('\n');
151        }
152        current_line += 1;
153        current_col = 0;
154    }
155
156    result
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162
163    #[test]
164    fn test_single_replacement() {
165        let source = "fn foo() {\n    let x = 1;\n}";
166        let replacements = vec![Replacement::new(
167            LineColumn {
168                line: 2,
169                column: 12,
170            },
171            LineColumn {
172                line: 2,
173                column: 13,
174            },
175            "42".to_string(),
176        )];
177
178        let result = apply_surgical_edits(source, replacements);
179        assert_eq!(result, "fn foo() {\n    let x = 42;\n}");
180    }
181
182    #[test]
183    fn test_multiple_replacements() {
184        let source = "let a = 1;\nlet b = 2;";
185        let replacements = vec![
186            Replacement::new(
187                LineColumn { line: 1, column: 8 },
188                LineColumn { line: 1, column: 9 },
189                "10".to_string(),
190            ),
191            Replacement::new(
192                LineColumn { line: 2, column: 8 },
193                LineColumn { line: 2, column: 9 },
194                "20".to_string(),
195            ),
196        ];
197
198        let result = apply_surgical_edits(source, replacements);
199        assert_eq!(result, "let a = 10;\nlet b = 20;");
200    }
201
202    #[test]
203    fn test_preserves_whitespace() {
204        let source = "fn foo() {\n\n    // comment\n    let x = old;\n}";
205        let replacements = vec![Replacement::new(
206            LineColumn {
207                line: 4,
208                column: 12,
209            },
210            LineColumn {
211                line: 4,
212                column: 15,
213            },
214            "new".to_string(),
215        )];
216
217        let result = apply_surgical_edits(source, replacements);
218        assert_eq!(result, "fn foo() {\n\n    // comment\n    let x = new;\n}");
219    }
220
221    #[test]
222    fn test_no_replacements() {
223        let source = "fn foo() {}\n";
224        let replacements = vec![];
225
226        let result = apply_surgical_edits(source, replacements);
227        assert_eq!(result, source);
228    }
229
230    #[test]
231    fn test_replacement_sorting() {
232        let source = "let a = 1; let b = 2;";
233        // Add replacements out of order
234        let replacements = vec![
235            Replacement::new(
236                LineColumn {
237                    line: 1,
238                    column: 19,
239                },
240                LineColumn {
241                    line: 1,
242                    column: 20,
243                },
244                "20".to_string(),
245            ),
246            Replacement::new(
247                LineColumn { line: 1, column: 8 },
248                LineColumn { line: 1, column: 9 },
249                "10".to_string(),
250            ),
251        ];
252
253        let result = apply_surgical_edits(source, replacements);
254        assert_eq!(result, "let a = 10; let b = 20;");
255    }
256}