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