use super::execute::{TxState, read_file_content, update_file_content};
use crate::ops::replace::{
compile_replace_regex, replace_content, replace_whole_lines, replacement_text,
};
use crate::plan::Operation;
use globset::Glob;
use ignore::WalkBuilder;
use std::collections::HashSet;
use std::path::Path;
pub(crate) fn execute_replace_op(op: &Operation, tx: &mut TxState<'_>) -> anyhow::Result<usize> {
let Operation::Replace {
glob,
path,
mode,
from,
to,
nth,
insert_before,
insert_after,
case_insensitive,
multiline,
whole_line,
range,
before_context,
after_context,
..
} = op
else {
unreachable!()
};
let regex_mode = mode.as_deref() == Some("regex");
let word_boundary = matches!(
op,
Operation::Replace {
word_boundary: true,
..
}
);
let use_regex = regex_mode || *case_insensitive || word_boundary;
let replacement = replacement_text(from, to, insert_before, insert_after, use_regex);
let compiled_re = compile_replace_regex(
from,
regex_mode,
*case_insensitive,
*multiline,
word_boundary,
)?;
let parsed_range = range
.as_deref()
.map(crate::ops::read::parse_line_range)
.transpose()?;
if let Some(p) = path {
let file_path = tx.cwd.join(p);
let content = read_file_content(tx.pending, tx.existed_before, &file_path)?;
let (replaced, match_count) = if *whole_line {
replace_whole_lines(
content,
from,
&replacement,
compiled_re.as_ref(),
*nth,
parsed_range,
)
} else {
replace_content(content, from, &replacement, compiled_re.as_ref(), *nth)
};
if match_count > 0 {
let owned = replaced.into_owned();
update_file_content(tx.pending, tx.deletions, &file_path, owned);
Ok(match_count)
} else if !regex_mode && (before_context.is_some() || after_context.is_some()) {
match crate::fallback::resolve_with_fallback(
content,
from,
before_context.as_deref(),
after_context.as_deref(),
) {
Ok(anchor) => {
let to_text = to.as_deref().unwrap_or("");
let new_content = format!(
"{}{}{}",
&content[..anchor.start_offset],
to_text,
&content[anchor.start_offset + anchor.matched_text.len()..]
);
update_file_content(tx.pending, tx.deletions, &file_path, new_content);
tx.replace_hint = Some(format!(
"fallback matched via {:?} strategy in {}",
anchor.strategy, p,
));
Ok(1)
}
Err(edit_error) => {
tx.replace_hint = Some(edit_error.message.clone());
Ok(0)
}
}
} else {
if !regex_mode {
let similar = crate::fallback::find_similar_targets(content, from, 3);
if !similar.is_empty() {
tx.replace_hint = Some(format!(
"no matches for '{}' in {} (did you mean: {}?)",
crate::fallback::truncate_str(from, 60),
p,
similar.join(", ")
));
}
}
Ok(0)
}
} else if let Some(pattern) = glob {
let matcher = Glob::new(pattern)?.compile_matcher();
let matches_pattern = |path: &Path| {
matcher.is_match(path)
|| path.file_name().is_some_and(|name| matcher.is_match(name))
|| path.strip_prefix(tx.cwd).ok().is_some_and(|relative| {
!relative.as_os_str().is_empty()
&& (matcher.is_match(relative)
|| relative
.file_name()
.is_some_and(|name| matcher.is_match(name)))
})
};
let mut total_matches = 0usize;
let mut candidate_paths = Vec::new();
let mut seen_paths = HashSet::new();
let walker = WalkBuilder::new(tx.cwd).build();
for entry in walker {
let entry = match entry {
Ok(e) => e,
Err(_) => continue,
};
if !entry.file_type().is_some_and(|ft| ft.is_file()) {
continue;
}
let file_path = entry.path().to_path_buf();
if matches_pattern(&file_path) && seen_paths.insert(file_path.clone()) {
candidate_paths.push(file_path);
}
}
for pending_path in tx.pending.keys() {
if !pending_path.starts_with(tx.cwd)
|| pending_path.exists()
|| tx.deletions.contains(pending_path)
|| !matches_pattern(pending_path)
{
continue;
}
if seen_paths.insert(pending_path.clone()) {
candidate_paths.push(pending_path.clone());
}
}
for file_path in candidate_paths {
let content = match read_file_content(tx.pending, tx.existed_before, &file_path) {
Ok(c) => c.to_owned(),
Err(e) => {
if !tx.structured && !tx.quiet {
eprintln!("tx: replace: skipping {}: {e}", file_path.display());
}
continue;
}
};
let (replaced, match_count) = if *whole_line {
replace_whole_lines(
&content,
from,
&replacement,
compiled_re.as_ref(),
*nth,
parsed_range,
)
} else {
replace_content(&content, from, &replacement, compiled_re.as_ref(), *nth)
};
total_matches += match_count;
if match_count > 0 {
update_file_content(tx.pending, tx.deletions, &file_path, replaced.into_owned());
}
}
Ok(total_matches)
} else {
anyhow::bail!("replace operation requires either 'path' or 'glob'");
}
}