rs_hack/
surgical.rs

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