use std::path::Path;
use anyhow::Context;
use super::Language;
use super::symbols::{extract_symbols, find_symbol};
#[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>> {
let symbols = extract_symbols(source, lang);
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> = 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();
let (new_body, count) = if regex {
let re = regex::RegexBuilder::new(from).multi_line(true).build()?;
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\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,
}))
}
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);
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_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);
}
}