patchloom 0.9.0

Structured file editing library and CLI for AI agents: parser-backed JSON/YAML/TOML edits, AST-aware code operations, multi-file batching, markdown operations, and MCP server
Documentation
//! Symbol-scoped replacement: replace text only within a specific symbol's body.

use std::path::Path;

use anyhow::Context;

use super::Language;
use super::symbols::{extract_symbols, find_symbol};

/// Result of a symbol-scoped replacement.
#[derive(Debug)]
pub struct ScopedReplaceResult {
    /// The full file content after replacement.
    pub content: String,
    /// Number of replacements made within the symbol.
    pub replacements: usize,
    /// 1-based start line of the symbol.
    pub start_line: usize,
    /// 1-based end line of the symbol.
    pub end_line: usize,
}

/// Replace text only within the body of a specific symbol.
pub fn replace_in_symbol(
    source: &str,
    symbol_name: &str,
    from: &str,
    to: &str,
    regex: bool,
    lang: Language,
) -> anyhow::Result<Option<ScopedReplaceResult>> {
    let symbols = extract_symbols(source, lang);
    let sym = match find_symbol(&symbols, symbol_name) {
        Some(s) => s,
        None => return Ok(None),
    };

    // Use canonical majority-vote detection, consistent with all other AST modules.
    let eol = crate::write::detect_eol(source);
    let lines: Vec<&str> = source.lines().collect();
    let start_idx = sym.start_line.saturating_sub(1);
    let end_idx = sym.end_line.min(lines.len());

    let body: String = lines[start_idx..end_idx]
        .iter()
        .map(|l| format!("{l}{eol}"))
        .collect();

    // Perform replacement within the body
    let (new_body, count) = if regex {
        let re = regex::RegexBuilder::new(from).multi_line(true).build()?;
        let body_len = body.len();
        // Filter out phantom zero-length matches at EOF (same as replace_content).
        let count = re
            .find_iter(&body)
            .filter(|m| !(m.start() == body_len && m.end() == body_len))
            .count();
        let new = re
            .replace_all(&body, |caps: &regex::Captures| {
                if let Some(m) = caps.get(0)
                    && m.start() == body_len
                    && m.end() == body_len
                {
                    return String::new();
                }
                let mut expanded = String::new();
                caps.expand(to, &mut expanded);
                expanded
            })
            .into_owned();
        (new, count)
    } else {
        let count = body.matches(from).count();
        let new = body.replace(from, to);
        (new, count)
    };

    if count == 0 {
        return Ok(Some(ScopedReplaceResult {
            content: source.to_string(),
            replacements: 0,
            start_line: sym.start_line,
            end_line: sym.end_line,
        }));
    }

    // Reconstruct the full file: prefix + new body + suffix
    let mut result = String::new();
    for line in &lines[..start_idx] {
        result.push_str(line);
        result.push_str(eol);
    }
    result.push_str(&new_body);
    // new_body already has trailing line endings; add suffix
    for line in &lines[end_idx..] {
        result.push_str(line);
        result.push_str(eol);
    }

    // Preserve original trailing newline behavior
    let ends_with_eol = source.ends_with('\n') || source.ends_with("\r\n");
    if !ends_with_eol && result.ends_with(eol) {
        result.truncate(result.len() - eol.len());
    }

    Ok(Some(ScopedReplaceResult {
        content: result,
        replacements: count,
        start_line: sym.start_line,
        end_line: sym.end_line,
    }))
}

/// Replace text within a symbol in a file.
pub fn replace_in_symbol_file(
    path: &Path,
    symbol_name: &str,
    from: &str,
    to: &str,
    regex: bool,
    lang_hint: Option<Language>,
) -> anyhow::Result<Option<ScopedReplaceResult>> {
    let lang = lang_hint.unwrap_or_else(|| Language::from_path(path));
    if !lang.has_grammar() {
        return Ok(None);
    }
    let source =
        std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
    replace_in_symbol(&source, symbol_name, from, to, regex, lang)
}

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

    #[test]
    fn replace_within_function() {
        let source = r#"fn foo() {
    let x = 1;
    let y = 1;
}

fn bar() {
    let x = 1;
}
"#;
        let result = replace_in_symbol(source, "foo", "1", "42", false, Language::Rust)
            .unwrap()
            .unwrap();
        assert_eq!(result.replacements, 2);
        // foo's body should have 42, bar's should still have 1
        assert!(result.content.contains("fn foo()"));
        assert!(result.content.contains("let x = 42"));
        let bar_section: &str = result.content.split("fn bar()").nth(1).unwrap();
        assert!(bar_section.contains("let x = 1"));
    }

    #[test]
    fn replace_with_regex() {
        let source = "fn run() {\n    exit::FAILURE\n    exit::NO_MATCHES\n}\n";
        let result = replace_in_symbol(
            source,
            "run",
            r"exit::\w+",
            "exit::SUCCESS",
            true,
            Language::Rust,
        )
        .unwrap()
        .unwrap();
        assert_eq!(result.replacements, 2);
        assert!(result.content.contains("exit::SUCCESS"));
        assert!(!result.content.contains("exit::FAILURE"));
    }

    #[test]
    fn symbol_not_found_returns_none() {
        let source = "fn foo() {}\n";
        let result =
            replace_in_symbol(source, "nonexistent", "x", "y", false, Language::Rust).unwrap();
        assert!(result.is_none());
    }

    #[test]
    fn replace_preserves_crlf_line_endings() {
        let source = "fn foo() {\r\n    let x = 1;\r\n    let y = 1;\r\n}\r\n\r\nfn bar() {\r\n    let x = 1;\r\n}\r\n";
        let result = replace_in_symbol(source, "foo", "1", "42", false, Language::Rust)
            .unwrap()
            .unwrap();
        assert_eq!(result.replacements, 2);
        // All CRLF line endings must be preserved
        assert!(result.content.contains("\r\n"), "CRLF should be preserved");
        assert!(!result.content.contains("\r\n\n"), "no mixed line endings");
        // Verify no bare LF where CRLF was expected
        let without_cr = result.content.replace("\r\n", "");
        assert!(!without_cr.contains('\n'), "no bare LF in CRLF content");
    }

    /// Regression: a majority-LF file with one stray CRLF must stay LF,
    /// not be converted to all-CRLF by the inline `contains("\r\n")` check.
    #[test]
    fn replace_majority_lf_with_stray_crlf_preserves_lf() {
        // 3 LF lines + 1 CRLF line: majority is LF.
        let source = "fn foo() {\n    let x = 1;\r\n    let y = 2;\n}\n";
        let result = replace_in_symbol(source, "foo", "1", "42", false, Language::Rust)
            .unwrap()
            .unwrap();
        assert_eq!(result.replacements, 1);
        // The output should use LF (majority vote), not CRLF.
        let lines: Vec<&str> = result.content.split('\n').collect();
        let crlf_count = result.content.matches("\r\n").count();
        let lf_count = lines.len().saturating_sub(1).saturating_sub(crlf_count);
        assert!(
            lf_count > crlf_count,
            "majority-LF file should stay LF after replace, got {crlf_count} CRLF vs {lf_count} LF"
        );
    }

    #[test]
    fn no_match_returns_zero_replacements() {
        let source = "fn foo() {\n    let x = 1;\n}\n";
        let result = replace_in_symbol(source, "foo", "zzz", "yyy", false, Language::Rust)
            .unwrap()
            .unwrap();
        assert_eq!(result.replacements, 0);
    }
}