Skip to main content

patchloom/ast/
replace.rs

1//! Symbol-scoped replacement: replace text only within a specific symbol's body.
2
3use std::path::Path;
4
5use super::Language;
6use super::symbols::{find_symbol, try_extract_symbols};
7
8/// Result of a symbol-scoped replacement.
9#[derive(Debug)]
10pub struct ScopedReplaceResult {
11    /// The full file content after replacement.
12    pub content: String,
13    /// Number of replacements made within the symbol.
14    pub replacements: usize,
15    /// 1-based start line of the symbol.
16    pub start_line: usize,
17    /// 1-based end line of the symbol.
18    pub end_line: usize,
19}
20
21/// Replace text only within the body of a specific symbol.
22pub fn replace_in_symbol(
23    source: &str,
24    symbol_name: &str,
25    from: &str,
26    to: &str,
27    regex: bool,
28    lang: Language,
29) -> anyhow::Result<Option<ScopedReplaceResult>> {
30    if from.is_empty() {
31        return Err(anyhow::Error::new(crate::exit::InvalidInputError {
32            msg: "ast replace pattern must not be empty".into(),
33        }));
34    }
35    let symbols = match try_extract_symbols(source, lang) {
36        Ok(s) => s,
37        Err(crate::ast::ParseFailure::DeadlineExceeded) => {
38            return Err(crate::exit::ParseTimeoutError {
39                msg: format!("parse deadline exceeded for {lang}"),
40            }
41            .into());
42        }
43        Err(crate::ast::ParseFailure::NoGrammar) => return Ok(None),
44    };
45    let sym = match find_symbol(&symbols, symbol_name) {
46        Some(s) => s,
47        None => return Ok(None),
48    };
49
50    // Use canonical majority-vote detection, consistent with all other AST modules.
51    let eol = crate::write::detect_eol(source);
52    let lines: Vec<&str> = crate::ops::file::text_lines(source).collect();
53    let start_idx = sym.start_line.saturating_sub(1);
54    let end_idx = sym.end_line.min(lines.len());
55
56    let body: String = lines[start_idx..end_idx]
57        .iter()
58        .map(|l| format!("{l}{eol}"))
59        .collect();
60
61    // Perform replacement within the body
62    let (new_body, count) = if regex {
63        let re = crate::bounded_regex_build(crate::bounded_regex_builder(from).multi_line(true))?;
64        let body_len = body.len();
65        // Filter out phantom zero-length matches at EOF (same as replace_content).
66        let count = re
67            .find_iter(&body)
68            .filter(|m| !(m.start() == body_len && m.end() == body_len))
69            .count();
70        let new = re
71            .replace_all(&body, |caps: &regex::Captures| {
72                if let Some(m) = caps.get(0)
73                    && m.start() == body_len
74                    && m.end() == body_len
75                {
76                    return String::new();
77                }
78                let mut expanded = String::new();
79                caps.expand(to, &mut expanded);
80                expanded
81            })
82            .into_owned();
83        (new, count)
84    } else {
85        let count = body.matches(from).count();
86        let new = body.replace(from, to);
87        (new, count)
88    };
89
90    if count == 0 {
91        return Ok(Some(ScopedReplaceResult {
92            content: source.to_string(),
93            replacements: 0,
94            start_line: sym.start_line,
95            end_line: sym.end_line,
96        }));
97    }
98
99    // Reconstruct the full file: prefix + new body + suffix
100    let mut result = String::new();
101    for line in &lines[..start_idx] {
102        result.push_str(line);
103        result.push_str(eol);
104    }
105    result.push_str(&new_body);
106    // new_body already has trailing line endings; add suffix
107    for line in &lines[end_idx..] {
108        result.push_str(line);
109        result.push_str(eol);
110    }
111
112    // Preserve original trailing newline behavior
113    let ends_with_eol = source.ends_with('\n') || source.ends_with('\r');
114    if !ends_with_eol && result.ends_with(eol) {
115        result.truncate(result.len() - eol.len());
116    }
117
118    Ok(Some(ScopedReplaceResult {
119        content: result,
120        replacements: count,
121        start_line: sym.start_line,
122        end_line: sym.end_line,
123    }))
124}
125
126/// Replace text within a symbol in a file.
127pub fn replace_in_symbol_file(
128    path: &Path,
129    symbol_name: &str,
130    from: &str,
131    to: &str,
132    regex: bool,
133    lang_hint: Option<Language>,
134) -> anyhow::Result<Option<ScopedReplaceResult>> {
135    let lang = lang_hint.unwrap_or_else(|| Language::from_path(path));
136    if !lang.has_grammar() {
137        return Ok(None);
138    }
139    // Strict sole-path (#1894): binary / invalid UTF-8 → Binary / InvalidEncoding.
140    let source = crate::files::load_text_strict(path, &path.display().to_string())?;
141    replace_in_symbol(&source, symbol_name, from, to, regex, lang)
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147
148    #[test]
149    fn replace_empty_pattern_is_invalid_input() {
150        let err = replace_in_symbol(
151            "fn foo() { let x = 1; }\n",
152            "foo",
153            "",
154            "y",
155            false,
156            Language::Rust,
157        )
158        .expect_err("empty from must fail");
159        assert!(
160            crate::exit::is_invalid_input(&err),
161            "empty from must be invalid_input, got {err}"
162        );
163        replace_in_symbol(
164            "fn foo() { let x = 1; }\n",
165            "foo",
166            "x",
167            "",
168            false,
169            Language::Rust,
170        )
171        .expect("empty to is a delete, not invalid_input")
172        .expect("symbol exists");
173    }
174
175    #[test]
176    fn replace_within_function() {
177        let source = r#"fn foo() {
178    let x = 1;
179    let y = 1;
180}
181
182fn bar() {
183    let x = 1;
184}
185"#;
186        let result = replace_in_symbol(source, "foo", "1", "42", false, Language::Rust)
187            .unwrap()
188            .unwrap();
189        assert_eq!(result.replacements, 2);
190        // foo's body should have 42, bar's should still have 1
191        assert!(result.content.contains("fn foo()"));
192        assert!(result.content.contains("let x = 42"));
193        let bar_section: &str = result.content.split("fn bar()").nth(1).unwrap();
194        assert!(bar_section.contains("let x = 1"));
195    }
196
197    #[test]
198    fn replace_with_regex() {
199        let source = "fn run() {\n    exit::FAILURE\n    exit::NO_MATCHES\n}\n";
200        let result = replace_in_symbol(
201            source,
202            "run",
203            r"exit::\w+",
204            "exit::SUCCESS",
205            true,
206            Language::Rust,
207        )
208        .unwrap()
209        .unwrap();
210        assert_eq!(result.replacements, 2);
211        assert!(result.content.contains("exit::SUCCESS"));
212        assert!(!result.content.contains("exit::FAILURE"));
213    }
214
215    #[test]
216    fn symbol_not_found_returns_none() {
217        let source = "fn foo() {}\n";
218        let result =
219            replace_in_symbol(source, "nonexistent", "x", "y", false, Language::Rust).unwrap();
220        assert!(result.is_none());
221    }
222
223    #[test]
224    fn replace_preserves_crlf_line_endings() {
225        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";
226        let result = replace_in_symbol(source, "foo", "1", "42", false, Language::Rust)
227            .unwrap()
228            .unwrap();
229        assert_eq!(result.replacements, 2);
230        // All CRLF line endings must be preserved
231        assert!(result.content.contains("\r\n"), "CRLF should be preserved");
232        assert!(!result.content.contains("\r\n\n"), "no mixed line endings");
233        // Verify no bare LF where CRLF was expected
234        let without_cr = result.content.replace("\r\n", "");
235        assert!(!without_cr.contains('\n'), "no bare LF in CRLF content");
236    }
237
238    #[test]
239    fn replace_in_symbol_cr_only_second_fn() {
240        let source = "fn one() { let x = 1; }\rfn two() { let y = 2; }\r";
241        let result = replace_in_symbol(
242            source,
243            "two",
244            "let y = 2;",
245            "let y = 3;",
246            false,
247            Language::Rust,
248        )
249        .unwrap()
250        .unwrap();
251        assert_eq!(result.replacements, 1);
252        assert!(
253            result.content.contains("let y = 3;"),
254            "content={}",
255            result.content
256        );
257        assert!(result.content.contains("let x = 1;"));
258        assert!(!result.content.contains("let y = 2;"));
259    }
260
261    /// Regression: a majority-LF file with one stray CRLF must stay LF,
262    /// not be converted to all-CRLF by the inline `contains("\r\n")` check.
263    #[test]
264    fn replace_majority_lf_with_stray_crlf_preserves_lf() {
265        // 3 LF lines + 1 CRLF line: majority is LF.
266        let source = "fn foo() {\n    let x = 1;\r\n    let y = 2;\n}\n";
267        let result = replace_in_symbol(source, "foo", "1", "42", false, Language::Rust)
268            .unwrap()
269            .unwrap();
270        assert_eq!(result.replacements, 1);
271        // The output should use LF (majority vote), not CRLF.
272        let lines: Vec<&str> = result.content.split('\n').collect();
273        let crlf_count = result.content.matches("\r\n").count();
274        let lf_count = lines.len().saturating_sub(1).saturating_sub(crlf_count);
275        assert!(
276            lf_count > crlf_count,
277            "majority-LF file should stay LF after replace, got {crlf_count} CRLF vs {lf_count} LF"
278        );
279    }
280
281    #[test]
282    fn no_match_returns_zero_replacements() {
283        let source = "fn foo() {\n    let x = 1;\n}\n";
284        let result = replace_in_symbol(source, "foo", "zzz", "yyy", false, Language::Rust)
285            .unwrap()
286            .unwrap();
287        assert_eq!(result.replacements, 0);
288    }
289}