1use std::path::Path;
4
5use super::Language;
6use super::symbols::{find_symbol, try_extract_symbols};
7
8#[derive(Debug)]
10pub struct ScopedReplaceResult {
11 pub content: String,
13 pub replacements: usize,
15 pub start_line: usize,
17 pub end_line: usize,
19}
20
21pub 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 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 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 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: ®ex::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 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 for line in &lines[end_idx..] {
108 result.push_str(line);
109 result.push_str(eol);
110 }
111
112 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
126pub 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 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 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 assert!(result.content.contains("\r\n"), "CRLF should be preserved");
232 assert!(!result.content.contains("\r\n\n"), "no mixed line endings");
233 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 #[test]
264 fn replace_majority_lf_with_stray_crlf_preserves_lf() {
265 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 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}