use crate::cli::global::GlobalFlags;
use crate::diff::render_diffs_colored;
use crate::exit;
use crate::ops::replace::{
compile_replace_regex, replace_content, replace_whole_lines, replacement_text,
};
use crate::tx::engine::WriteSource;
use clap::Args;
use serde::Serialize;
#[derive(Debug, Args)]
#[command(after_help = "\
EXAMPLES:
patchloom replace 'old_name' --new 'new_name' src/
patchloom replace 'http://' --new 'https://' src/ --apply
patchloom replace 'v1\\.0' --new 'v2.0' --regex README.md")]
pub struct ReplaceArgs {
pub old: String,
#[arg(long)]
pub new: Option<String>,
#[arg(long, conflicts_with = "insert_after")]
pub insert_before: Option<String>,
#[arg(long, conflicts_with = "insert_before")]
pub insert_after: Option<String>,
pub paths: Vec<String>,
#[arg(long, short = 'F')]
pub literal: bool,
#[arg(long)]
pub regex: bool,
#[arg(long)]
pub if_exists: bool,
#[arg(long, short = 'U')]
pub multiline: bool,
#[arg(long)]
pub nth: Option<usize>,
#[arg(long, short = 'i')]
pub case_insensitive: bool,
#[arg(long, short = 'L')]
pub whole_line: bool,
#[arg(long, short = 'w')]
pub word_boundary: bool,
#[arg(long, short = 'R')]
pub range: Option<String>,
#[arg(long)]
pub before_context: Option<String>,
#[arg(long)]
pub after_context: Option<String>,
#[arg(long, short = 'u')]
pub unique: bool,
#[command(flatten)]
pub write: crate::cli::global::WriteFlags,
}
#[derive(Debug, Clone, Serialize)]
struct ReplaceFileResult {
path: String,
match_count: usize,
}
#[derive(Debug, Serialize)]
struct ReplaceOutput {
ok: bool,
match_count: usize,
file_count: usize,
files: Vec<ReplaceFileResult>,
#[serde(skip_serializing_if = "Option::is_none")]
diff: Option<String>,
}
struct FileReplacement {
path: String,
display_path: String,
original: String,
replaced: String,
match_count: usize,
}
fn parse_range_arg(spec: Option<&str>) -> anyhow::Result<Option<(usize, Option<usize>)>> {
match spec {
None => Ok(None),
Some(s) => {
let (start, end) = crate::cmd::read::parse_line_range(s)?;
Ok(Some((start, end)))
}
}
}
fn build_replacement(args: &ReplaceArgs) -> String {
replacement_text(
&args.old,
&args.new,
&args.insert_before,
&args.insert_after,
args.regex || args.case_insensitive || args.word_boundary,
args.regex,
)
}
fn collect_replacements(
args: &ReplaceArgs,
global: &GlobalFlags,
) -> anyhow::Result<Vec<FileReplacement>> {
let cwd = global.resolve_cwd()?;
let glob_matcher = crate::build_glob_matcher_from_global(global)?;
let file_paths = crate::collect_file_paths_opts(&args.paths, global, false, Some(&cwd))?;
let glob_roots = crate::collect_glob_roots_from_global(&args.paths, global, Some(&cwd))?;
let replacement = build_replacement(args);
let quiet = global.quiet;
let compiled_re = compile_replace_regex(
&args.old,
args.regex,
args.case_insensitive,
args.multiline,
args.word_boundary,
)?;
let from = &args.old;
let nth = args.nth;
let whole_line = args.whole_line;
let range = parse_range_arg(args.range.as_deref())?;
let cwd_ref = &cwd;
let mut replacements: Vec<FileReplacement> =
crate::par_process_files(&file_paths, glob_matcher.as_ref(), &glob_roots, |path| {
let content = crate::files::read_text_file_logged(path, "replace", quiet)?;
let (replaced, count) = if whole_line {
replace_whole_lines(
&content,
from,
&replacement,
compiled_re.as_ref(),
nth,
range,
)
} else {
replace_content(&content, from, &replacement, compiled_re.as_ref(), nth)
};
if count > 0 {
let replaced = replaced.into_owned();
let display_path = crate::files::relative_display(path, cwd_ref)
.to_string_lossy()
.into_owned();
Some(FileReplacement {
path: path.to_string_lossy().into_owned(),
display_path,
original: content,
replaced,
match_count: count,
})
} else {
None
}
});
replacements.retain(|r| r.original != r.replaced);
replacements.sort_unstable_by(|a, b| a.path.cmp(&b.path));
Ok(replacements)
}
fn make_file_results(replacements: &[FileReplacement]) -> Vec<ReplaceFileResult> {
replacements
.iter()
.map(|r| ReplaceFileResult {
path: r.display_path.clone(),
match_count: r.match_count,
})
.collect()
}
pub fn run(args: ReplaceArgs, global: &GlobalFlags) -> anyhow::Result<u8> {
crate::verbose!(
"replace: old={:?} regex={} paths={:?}",
args.old,
args.regex,
args.paths
);
use crate::ops::replace::{ReplaceValidationParams, validate_replace_args};
validate_replace_args(&ReplaceValidationParams {
pattern: &args.old,
has_to: args.new.is_some(),
has_insert_before: args.insert_before.is_some(),
has_insert_after: args.insert_after.is_some(),
nth: args.nth,
whole_line: args.whole_line,
multiline: args.multiline,
has_range: args.range.is_some(),
})
.map_err(|e| anyhow::anyhow!("{e}"))?;
let cwd = global.resolve_cwd()?;
global.check_paths_contained(&cwd, &args.paths)?;
if args.before_context.is_some() || args.after_context.is_some() {
return run_context_replace(args, global, &cwd);
}
let replacements = collect_replacements(&args, global)?;
if args.unique {
for r in &replacements {
if r.match_count > 1 {
let output = ReplaceOutput {
ok: false,
match_count: r.match_count,
file_count: 1,
files: vec![ReplaceFileResult {
path: r.display_path.clone(),
match_count: r.match_count,
}],
diff: None,
};
global.emit_json(&output)?;
if !global.quiet {
eprintln!(
"ambiguous match: pattern {:?} matches {} times in {}; use --nth or add context to disambiguate",
crate::fallback::truncate_str(&args.old, 60),
r.match_count,
r.display_path,
);
}
return Ok(exit::AMBIGUOUS);
}
}
}
if replacements.is_empty() {
if args.if_exists {
let output = ReplaceOutput {
ok: true,
match_count: 0,
file_count: 0,
files: vec![],
diff: None,
};
global.emit_json(&output)?;
return Ok(exit::SUCCESS);
}
let output = ReplaceOutput {
ok: false,
match_count: 0,
file_count: 0,
files: vec![],
diff: None,
};
global.emit_json(&output)?;
if !global.quiet && !global.json && !global.jsonl {
let path_desc = global.path_scope_description(&args.paths);
eprintln!("no matches for '{}' in {path_desc}", args.old);
if !args.regex && crate::files::has_regex_metacharacters(&args.old) {
eprintln!("hint: pattern contains regex characters, try --regex");
}
if !args.case_insensitive {
eprintln!("hint: try -i for case-insensitive matching");
}
}
return Ok(exit::NO_MATCHES);
}
let total_matches: usize = replacements.iter().map(|r| r.match_count).sum();
let file_count = replacements.len();
let files = make_file_results(&replacements);
let precomputed = replacements
.iter()
.map(|r| {
(
r.display_path.clone(),
r.original.clone(),
r.replaced.clone(),
)
})
.collect();
let (cwd, result) =
crate::cmd::output::stage_for_write(WriteSource::Precomputed(precomputed), global)?;
replace_output(global, result, &files, total_matches, file_count, &cwd)
}
fn replace_output(
global: &GlobalFlags,
result: crate::tx::engine::ExecutionResult,
files: &[ReplaceFileResult],
total_matches: usize,
file_count: usize,
cwd: &std::path::Path,
) -> anyhow::Result<u8> {
use crate::cmd::write_mode::{FinalizeCallbacks, finalize_report};
let build_output = |diff: Option<String>| ReplaceOutput {
ok: true,
match_count: total_matches,
file_count,
files: files.to_vec(),
diff,
};
finalize_report(
global,
cwd,
result,
true,
FinalizeCallbacks {
on_check: |g: &GlobalFlags, _has: bool, _diffs: &[crate::diff::FileDiff]| {
if g.json {
g.emit_json(&build_output(None))?;
} else if !g.emit_json_items(files)? && !g.quiet {
println!("{total_matches} match(es) in {file_count} file(s)");
for f in files {
println!(" {}: {} match(es)", f.path, f.match_count);
}
}
Ok(())
},
on_apply: |g: &GlobalFlags,
_has: bool,
diffs: &[crate::diff::FileDiff],
diff_text: Option<String>| {
if g.json {
g.emit_json(&build_output(diff_text))?;
} else if !g.emit_json_items(files)? {
if g.diff {
print!("{}", render_diffs_colored(diffs, g.should_color()));
} else if !g.quiet {
println!("replaced {total_matches} match(es) in {file_count} file(s)");
for f in files {
println!(" {}: {} match(es)", f.path, f.match_count);
}
}
}
Ok(())
},
on_preview: |g: &GlobalFlags,
_has: bool,
diffs: &[crate::diff::FileDiff],
diff_text: Option<String>| {
if g.json {
g.emit_json(&build_output(diff_text))?;
} else if !g.emit_json_items(files)? && !diffs.is_empty() {
print!("{}", render_diffs_colored(diffs, g.should_color()));
}
Ok(())
},
after_preview_emit: |g: &GlobalFlags| {
if g.show_status() {
eprintln!("{file_count} file(s) changed, {total_matches} replacement(s)");
}
},
after_preview_apply: |g: &GlobalFlags| {
if g.show_status() {
eprintln!("replaced {total_matches} match(es) in {file_count} file(s)");
}
},
},
)
}
fn run_context_replace(
args: ReplaceArgs,
global: &GlobalFlags,
_cwd: &std::path::Path,
) -> anyhow::Result<u8> {
use crate::plan::Operation;
if args.paths.is_empty() {
anyhow::bail!("--before-context/--after-context requires explicit file paths");
}
let ops: Vec<Operation> = args
.paths
.iter()
.map(|p| Operation::Replace {
glob: None,
path: Some(p.clone()),
regex: args.regex,
old: args.old.clone(),
new_text: args.new.clone(),
nth: args.nth,
insert_before: args.insert_before.clone(),
insert_after: args.insert_after.clone(),
case_insensitive: args.case_insensitive,
multiline: args.multiline,
if_exists: args.if_exists,
whole_line: args.whole_line,
range: args.range.clone(),
word_boundary: args.word_boundary,
before_context: args.before_context.clone(),
after_context: args.after_context.clone(),
unique: args.unique,
})
.collect();
let (cwd, result) = crate::cmd::output::stage_for_write(WriteSource::Operations(ops), global)?;
if !result.has_changes {
let empty = ReplaceOutput {
ok: args.if_exists,
match_count: 0,
file_count: 0,
files: vec![],
diff: None,
};
global.emit_json(&empty)?;
if args.if_exists {
return Ok(exit::SUCCESS);
}
if !global.quiet && !global.json && !global.jsonl {
eprintln!("no matches for '{}' in context-based replace", args.old);
}
return Ok(exit::NO_MATCHES);
}
let files: Vec<ReplaceFileResult> = result
.exec_result
.changes
.iter()
.map(|(p, _, _)| ReplaceFileResult {
path: crate::files::relative_display(p, &cwd)
.to_string_lossy()
.into_owned(),
match_count: 1,
})
.collect();
let total_matches = files.len();
let file_count = total_matches;
replace_output(global, result, &files, total_matches, file_count, &cwd)
}
#[path = "replace_tests.rs"]
#[cfg(test)]
mod tests;