use std::path::Path;
use super::Language;
use super::insert::indent_content;
use super::symbols::{
SymbolDef, extract_symbols_or_timeout, find_symbol, full_symbol_span, 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),
};
replace_in_symbol_from_symbols(source, &symbols, symbol_name, from, to, regex, lang)
}
pub(crate) fn replace_in_symbol_from_symbols(
source: &str,
symbols: &[SymbolDef],
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 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)
}
fn reject_empty_symbol_name(symbol_name: &str, op: &str) -> anyhow::Result<()> {
if symbol_name.trim().is_empty() {
return Err(anyhow::Error::new(crate::exit::InvalidInputError {
msg: format!("{op} symbol must not be empty"),
}));
}
Ok(())
}
struct ResolvedSymbolSpan<'a> {
lines: Vec<&'a str>,
start_0: usize,
end_0: usize,
indent: String,
eol: &'a str,
}
fn resolve_symbol_span<'a>(
source: &'a str,
symbol_name: &str,
lang: Language,
op: &str,
) -> anyhow::Result<ResolvedSymbolSpan<'a>> {
reject_empty_symbol_name(symbol_name, op)?;
let symbols = extract_symbols_or_timeout(source, lang)?;
resolve_symbol_span_from(source, &symbols, symbol_name, lang, op)
}
fn resolve_symbol_span_from<'a>(
source: &'a str,
symbols: &[SymbolDef],
symbol_name: &str,
lang: Language,
op: &str,
) -> anyhow::Result<ResolvedSymbolSpan<'a>> {
reject_empty_symbol_name(symbol_name, op)?;
let sym = find_symbol(symbols, symbol_name).ok_or_else(|| {
anyhow::Error::new(crate::exit::NoMatchError {
msg: format!("symbol '{symbol_name}' not found"),
})
})?;
let (full_start, full_end) = full_symbol_span(source, sym, lang);
let lines: Vec<&str> = crate::ops::file::text_lines(source).collect();
let start_0 = full_start.saturating_sub(1);
let end_0 = full_end.min(lines.len());
let indent = symbol_column_indent(&lines, sym.start_line.saturating_sub(1));
Ok(ResolvedSymbolSpan {
lines,
start_0,
end_0,
indent,
eol: crate::write::detect_eol(source),
})
}
fn symbol_column_indent(lines: &[&str], start_idx: usize) -> String {
if start_idx < lines.len() {
let line = lines[start_idx];
let trimmed = line.trim_start();
line[..line.len() - trimmed.len()].to_string()
} else {
String::new()
}
}
fn join_source_lines(lines: &[String], eol: &str, source: &str) -> String {
let mut out = lines.join(eol);
if (source.ends_with('\n') || source.ends_with('\r')) && !out.ends_with(eol) {
out.push_str(eol);
}
out
}
fn collapse_surrounding_blanks(lines: &mut Vec<String>, hole: usize) {
let mut before = 0usize;
while hole > before && lines[hole - 1 - before].trim().is_empty() {
before += 1;
}
let mut after = 0usize;
while hole + after < lines.len() && lines[hole + after].trim().is_empty() {
after += 1;
}
let total = before.saturating_add(after);
if total > 1 {
let blank_start = hole - before;
lines.drain(blank_start + 1..blank_start + total);
}
}
pub fn replace_symbol(
source: &str,
symbol_name: &str,
content: &str,
lang: Language,
) -> anyhow::Result<String> {
let span = resolve_symbol_span(source, symbol_name, lang, "ast.replace_symbol")?;
replace_symbol_span(source, span, content)
}
pub(crate) fn replace_symbol_from_symbols(
source: &str,
symbols: &[SymbolDef],
symbol_name: &str,
content: &str,
lang: Language,
) -> anyhow::Result<String> {
let span = resolve_symbol_span_from(source, symbols, symbol_name, lang, "ast.replace_symbol")?;
replace_symbol_span(source, span, content)
}
fn replace_symbol_span(
source: &str,
span: ResolvedSymbolSpan<'_>,
content: &str,
) -> anyhow::Result<String> {
let mut lines: Vec<String> = span.lines.iter().map(|l| (*l).to_string()).collect();
lines.drain(span.start_0..span.end_0);
if content.is_empty() {
collapse_surrounding_blanks(&mut lines, span.start_0);
} else {
let adjusted = indent_content(content, &span.indent, span.eol);
let new_lines: Vec<String> = crate::ops::file::text_lines(&adjusted)
.map(str::to_string)
.collect();
for (i, line) in new_lines.into_iter().enumerate() {
lines.insert(span.start_0 + i, line);
}
}
Ok(join_source_lines(&lines, span.eol, source))
}
pub fn delete_symbol(source: &str, symbol_name: &str, lang: Language) -> anyhow::Result<String> {
let span = resolve_symbol_span(source, symbol_name, lang, "ast.delete_symbol")?;
delete_symbol_span(source, span)
}
pub(crate) fn delete_symbol_from_symbols(
source: &str,
symbols: &[SymbolDef],
symbol_name: &str,
lang: Language,
) -> anyhow::Result<String> {
let span = resolve_symbol_span_from(source, symbols, symbol_name, lang, "ast.delete_symbol")?;
delete_symbol_span(source, span)
}
fn delete_symbol_span(source: &str, span: ResolvedSymbolSpan<'_>) -> anyhow::Result<String> {
let mut lines: Vec<String> = span.lines.iter().map(|l| (*l).to_string()).collect();
lines.drain(span.start_0..span.end_0);
collapse_surrounding_blanks(&mut lines, span.start_0);
Ok(join_source_lines(&lines, span.eol, source))
}
#[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);
}
#[test]
fn replace_symbol_rewrites_function_span() {
let source = "fn keep() {}\n\nfn victim() {\n let x = 1;\n}\n\nfn other() {}\n";
let out = replace_symbol(
source,
"victim",
"fn victim() {\n let x = 2;\n}",
Language::Rust,
)
.unwrap();
assert!(out.contains("fn victim() {\n let x = 2;\n}"));
assert!(!out.contains("let x = 1"));
assert!(out.contains("fn keep() {}"));
assert!(out.contains("fn other() {}"));
}
#[test]
fn replace_symbol_includes_leading_docs_and_attrs() {
let source = "/// old docs\n#[allow(dead_code)]\nfn victim() {}\n\nfn keep() {}\n";
let out = replace_symbol(source, "victim", "fn victim() { 1 }", Language::Rust).unwrap();
assert!(
!out.contains("old docs") && !out.contains("allow(dead_code)"),
"full span must include docs/attrs: {out}"
);
assert!(out.contains("fn victim() { 1 }"));
assert!(out.contains("fn keep() {}"));
}
#[test]
fn replace_symbol_reindents_to_symbol_column() {
let source = "impl Foo {\n fn victim() {\n let x = 1;\n }\n}\n";
let out = replace_symbol(
source,
"victim",
"fn victim() {\n let x = 2;\n}",
Language::Rust,
)
.unwrap();
assert!(
out.contains(" fn victim() {\n let x = 2;\n }"),
"replacement must match impl column: {out}"
);
}
#[test]
fn delete_symbol_removes_function_and_collapses_blanks() {
let source = "fn keep() {}\n\nfn victim() {\n let x = 1;\n}\n\nfn other() {}\n";
let out = delete_symbol(source, "victim", Language::Rust).unwrap();
assert!(!out.contains("fn victim"));
assert!(!out.contains("let x = 1"));
assert!(out.contains("fn keep() {}"));
assert!(out.contains("fn other() {}"));
assert!(
!out.contains("\n\n\n"),
"surrounding blanks must collapse to one: {out:?}"
);
assert!(out.contains("fn keep() {}\n\nfn other() {}"));
}
#[test]
fn missing_symbol_is_no_matches() {
let source = "fn foo() {}\n";
let err = replace_symbol(source, "missing", "fn missing() {}", Language::Rust).unwrap_err();
assert!(
crate::exit::is_no_match(&err),
"replace missing symbol must be no_matches, got {err}"
);
let err = delete_symbol(source, "missing", Language::Rust).unwrap_err();
assert!(
crate::exit::is_no_match(&err),
"delete missing symbol must be no_matches, got {err}"
);
}
#[test]
fn empty_symbol_name_is_invalid_input() {
let source = "fn foo() {}\n";
let err = replace_symbol(source, " ", "fn x() {}", Language::Rust).unwrap_err();
assert!(
crate::exit::is_invalid_input(&err),
"empty replace symbol must be invalid_input, got {err}"
);
let err = delete_symbol(source, "", Language::Rust).unwrap_err();
assert!(
crate::exit::is_invalid_input(&err),
"empty delete symbol must be invalid_input, got {err}"
);
}
}