rs-hack 0.5.7

AST-aware Rust refactoring tool for AI agents - transform, rename, inspect & more
Documentation
//! Surgical edit engine: applies precise, minimal text replacements
//! to source code while preserving all formatting, comments, and whitespace.

use std::cmp::Ordering;

use proc_macro2::LineColumn;

/// Represents a single textual replacement in the source code.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Replacement {
    /// Starting position (line, column) - 1-indexed for lines, 0-indexed for columns
    pub start: LineColumn,
    /// Ending position (line, column) - 1-indexed for lines, 0-indexed for columns
    pub end: LineColumn,
    /// The text to replace with
    pub new_text: String,
}

impl Replacement {
    pub const fn new(start: LineColumn, end: LineColumn, new_text: String) -> Self {
        Self {
            start,
            end,
            new_text,
        }
    }
}

impl Ord for Replacement {
    fn cmp(&self, other: &Self) -> Ordering {
        // Sort by start position (line, then column)
        match self.start.line.cmp(&other.start.line) {
            Ordering::Equal => self.start.column.cmp(&other.start.column),
            other => other,
        }
    }
}

impl PartialOrd for Replacement {
    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
        Some(self.cmp(other))
    }
}

/// Apply surgical edits to source code, preserving all formatting.
///
/// This function takes the original source code and a list of replacements,
/// and produces a new string with only those specific changes applied.
///
/// # Arguments
/// * `original_source` - The original source code
/// * `replacements` - List of replacements to apply (will be sorted automatically)
///
/// # Returns
/// The modified source code with only the specified changes applied
///
/// # Example
/// ```
/// use proc_macro2::LineColumn;
/// use rs_hack::surgical::{Replacement, apply_surgical_edits};
///
/// let source = "fn foo() {\n    let x = 1;\n}\n";
/// let replacements = vec![Replacement::new(
///     LineColumn {
///         line: 2,
///         column: 12,
///     },
///     LineColumn {
///         line: 2,
///         column: 13,
///     },
///     "42".to_string(),
/// )];
///
/// let result = apply_surgical_edits(source, replacements);
/// assert_eq!(result, "fn foo() {\n    let x = 42;\n}");
/// ```
pub fn apply_surgical_edits(original_source: &str, mut replacements: Vec<Replacement>) -> String {
    if replacements.is_empty() {
        return original_source.to_string();
    }

    // Sort replacements by position
    replacements.sort();

    // Validate no overlapping replacements
    for i in 1..replacements.len() {
        let prev = &replacements[i - 1];
        let curr = &replacements[i];

        if prev.end.line > curr.start.line
            || (prev.end.line == curr.start.line && prev.end.column > curr.start.column)
        {
            panic!(
                "Overlapping replacements detected: {:?} and {:?}",
                prev, curr
            );
        }
    }

    let lines: Vec<&str> = original_source.lines().collect();
    let mut result = String::new();

    let mut current_line = 1usize; // 1-indexed to match proc_macro2
    let mut current_col = 0usize; // 0-indexed

    for replacement in replacements {
        // Copy unchanged text up to this replacement

        // Copy full lines before the replacement
        while current_line < replacement.start.line {
            if current_line <= lines.len() {
                // Add any remaining text on current line
                if let Some(line) = lines.get(current_line - 1)
                    && current_col < line.len()
                {
                    result.push_str(&line[current_col..]);
                }
                result.push('\n');
            }
            current_line += 1;
            current_col = 0;
        }

        // Copy partial line up to replacement start (on the same line)
        if current_line == replacement.start.line
            && let Some(line) = lines.get(current_line - 1)
            && current_col < replacement.start.column
            && replacement.start.column <= line.len()
        {
            result.push_str(&line[current_col..replacement.start.column]);
        }

        // Apply the replacement
        result.push_str(&replacement.new_text);

        // Update position to after the replacement
        current_line = replacement.end.line;
        current_col = replacement.end.column;
    }

    // Copy remaining text after all replacements
    while current_line <= lines.len() {
        if let Some(line) = lines.get(current_line - 1)
            && current_col < line.len()
        {
            result.push_str(&line[current_col..]);
        }
        if current_line < lines.len() {
            result.push('\n');
        }
        current_line += 1;
        current_col = 0;
    }

    result
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_single_replacement() {
        let source = "fn foo() {\n    let x = 1;\n}";
        let replacements = vec![Replacement::new(
            LineColumn {
                line: 2,
                column: 12,
            },
            LineColumn {
                line: 2,
                column: 13,
            },
            "42".to_string(),
        )];

        let result = apply_surgical_edits(source, replacements);
        assert_eq!(result, "fn foo() {\n    let x = 42;\n}");
    }

    #[test]
    fn test_multiple_replacements() {
        let source = "let a = 1;\nlet b = 2;";
        let replacements = vec![
            Replacement::new(
                LineColumn { line: 1, column: 8 },
                LineColumn { line: 1, column: 9 },
                "10".to_string(),
            ),
            Replacement::new(
                LineColumn { line: 2, column: 8 },
                LineColumn { line: 2, column: 9 },
                "20".to_string(),
            ),
        ];

        let result = apply_surgical_edits(source, replacements);
        assert_eq!(result, "let a = 10;\nlet b = 20;");
    }

    #[test]
    fn test_preserves_whitespace() {
        let source = "fn foo() {\n\n    // comment\n    let x = old;\n}";
        let replacements = vec![Replacement::new(
            LineColumn {
                line: 4,
                column: 12,
            },
            LineColumn {
                line: 4,
                column: 15,
            },
            "new".to_string(),
        )];

        let result = apply_surgical_edits(source, replacements);
        assert_eq!(result, "fn foo() {\n\n    // comment\n    let x = new;\n}");
    }

    #[test]
    fn test_no_replacements() {
        let source = "fn foo() {}\n";
        let replacements = vec![];

        let result = apply_surgical_edits(source, replacements);
        assert_eq!(result, source);
    }

    #[test]
    fn test_replacement_sorting() {
        let source = "let a = 1; let b = 2;";
        // Add replacements out of order
        let replacements = vec![
            Replacement::new(
                LineColumn {
                    line: 1,
                    column: 19,
                },
                LineColumn {
                    line: 1,
                    column: 20,
                },
                "20".to_string(),
            ),
            Replacement::new(
                LineColumn { line: 1, column: 8 },
                LineColumn { line: 1, column: 9 },
                "10".to_string(),
            ),
        ];

        let result = apply_surgical_edits(source, replacements);
        assert_eq!(result, "let a = 10; let b = 20;");
    }
}