use std::path::Path;
use super::Language;
use super::symbols::{find_symbol, try_extract_symbols};
#[derive(Debug)]
pub struct ScopedReplaceResult {
pub content: String,
pub replacements: usize,
pub start_line: usize,
pub end_line: usize,
}
pub fn replace_in_symbol(
source: &str,
symbol_name: &str,
from: &str,
to: &str,
regex: bool,
lang: Language,
) -> anyhow::Result<Option<ScopedReplaceResult>> {
if from.is_empty() {
return Err(anyhow::Error::new(crate::exit::InvalidInputError {
msg: "ast replace pattern must not be empty".into(),
}));
}
let symbols = match try_extract_symbols(source, lang) {
Ok(s) => s,
Err(crate::ast::ParseFailure::DeadlineExceeded) => {
return Err(crate::exit::ParseTimeoutError {
msg: format!("parse deadline exceeded for {lang}"),
}
.into());
}
Err(crate::ast::ParseFailure::NoGrammar) => return Ok(None),
};
let sym = match find_symbol(&symbols, symbol_name) {
Some(s) => s,
None => return Ok(None),
};
let eol = crate::write::detect_eol(source);
let lines: Vec<&str> = crate::ops::file::text_lines(source).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();
let (new_body, count) = if regex {
let re = crate::bounded_regex_build(crate::bounded_regex_builder(from).multi_line(true))?;
let body_len = body.len();
let count = re
.find_iter(&body)
.filter(|m| !(m.start() == body_len && m.end() == body_len))
.count();
let new = re
.replace_all(&body, |caps: ®ex::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,
}));
}
let mut result = String::new();
for line in &lines[..start_idx] {
result.push_str(line);
result.push_str(eol);
}
result.push_str(&new_body);
for line in &lines[end_idx..] {
result.push_str(line);
result.push_str(eol);
}
let ends_with_eol = source.ends_with('\n') || source.ends_with('\r');
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,
}))
}
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 = crate::files::load_text_strict(path, &path.display().to_string())?;
replace_in_symbol(&source, symbol_name, from, to, regex, lang)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn replace_empty_pattern_is_invalid_input() {
let err = replace_in_symbol(
"fn foo() { let x = 1; }\n",
"foo",
"",
"y",
false,
Language::Rust,
)
.expect_err("empty from must fail");
assert!(
crate::exit::is_invalid_input(&err),
"empty from must be invalid_input, got {err}"
);
replace_in_symbol(
"fn foo() { let x = 1; }\n",
"foo",
"x",
"",
false,
Language::Rust,
)
.expect("empty to is a delete, not invalid_input")
.expect("symbol exists");
}
#[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);
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);
assert!(result.content.contains("\r\n"), "CRLF should be preserved");
assert!(!result.content.contains("\r\n\n"), "no mixed line endings");
let without_cr = result.content.replace("\r\n", "");
assert!(!without_cr.contains('\n'), "no bare LF in CRLF content");
}
#[test]
fn replace_in_symbol_cr_only_second_fn() {
let source = "fn one() { let x = 1; }\rfn two() { let y = 2; }\r";
let result = replace_in_symbol(
source,
"two",
"let y = 2;",
"let y = 3;",
false,
Language::Rust,
)
.unwrap()
.unwrap();
assert_eq!(result.replacements, 1);
assert!(
result.content.contains("let y = 3;"),
"content={}",
result.content
);
assert!(result.content.contains("let x = 1;"));
assert!(!result.content.contains("let y = 2;"));
}
#[test]
fn replace_majority_lf_with_stray_crlf_preserves_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);
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);
}
}