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,
#[arg(long)]
pub require_change: bool,
#[arg(long)]
pub command_position: bool,
#[arg(long)]
pub fuzzy: bool,
#[arg(long, value_name = "SCORE")]
pub min_fuzzy_score: Option<f64>,
#[command(flatten)]
pub write: crate::cli::global::WriteFlags,
}
#[derive(Debug, Clone, Serialize)]
struct ReplaceFileResult {
path: String,
match_count: usize,
#[serde(skip_serializing_if = "Option::is_none")]
match_mode: Option<&'static str>,
#[serde(skip_serializing_if = "Option::is_none")]
match_score: Option<f64>,
}
#[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>,
#[serde(skip_serializing_if = "Option::is_none")]
identity: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
error_kind: Option<&'static str>,
#[serde(skip_serializing_if = "Option::is_none")]
match_mode: Option<&'static str>,
#[serde(skip_serializing_if = "Option::is_none")]
match_score: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
similar_targets: Option<Vec<String>>,
}
struct FileReplacement {
path: String,
display_path: String,
original: String,
replaced: String,
match_count: usize,
match_mode: Option<&'static str>,
match_score: Option<f64>,
}
fn match_mode_str(mode: crate::api::MatchMode) -> &'static str {
crate::tx::match_mode_label(mode)
}
fn similar_targets_for_no_match(
args: &ReplaceArgs,
global: &GlobalFlags,
cwd: &std::path::Path,
) -> Option<Vec<String>> {
if args.regex {
return None;
}
let mut files: Vec<std::path::PathBuf> = Vec::new();
for p in &args.paths {
let abs = cwd.join(p);
if abs.is_file() {
files.push(abs);
} else if abs.is_dir()
&& let Ok(mut walked) = crate::files::collect_file_paths_opts(
std::slice::from_ref(p),
global,
false,
Some(cwd),
)
{
walked.truncate(5);
files.extend(walked);
}
}
files.truncate(5);
for f in files {
let Ok(content) = std::fs::read_to_string(&f) else {
continue;
};
let similar = crate::fallback::find_similar_targets(&content, &args.old, 3);
if !similar.is_empty() {
return Some(similar);
}
}
None
}
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 command_position = args.command_position;
let to = args.new.as_deref().unwrap_or("");
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 command_position {
let (out, n) =
crate::ops::shell_token::replace_command_position(&content, from, to);
(std::borrow::Cow::Owned(out), n)
} else 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,
match_mode: Some("exact"),
match_score: None,
})
} else {
None
}
});
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,
match_mode: r.match_mode,
match_score: r.match_score,
})
.collect()
}
fn aggregate_match_meta(files: &[ReplaceFileResult]) -> (Option<&'static str>, Option<f64>) {
use crate::api::{MatchMode, merge_match_modes};
let mut agg: Option<MatchMode> = None;
let mut score: Option<f64> = None;
for f in files {
let Some(label) = f.match_mode else {
continue;
};
let mode = match label {
"fuzzy" => MatchMode::Fuzzy,
"anchored" => MatchMode::Anchored,
"exact" => MatchMode::Exact,
_ => continue,
};
agg = Some(merge_match_modes(agg, mode));
if mode == MatchMode::Fuzzy && score.is_none() {
score = f.match_score;
}
}
match agg {
Some(MatchMode::Fuzzy) => (Some("fuzzy"), score),
Some(MatchMode::Anchored) => (Some("anchored"), None),
Some(MatchMode::Exact) => (Some("exact"), None),
None => (None, None),
}
}
pub fn run(args: ReplaceArgs, global: &GlobalFlags) -> anyhow::Result<u8> {
crate::verbose!(
"replace: old={:?} regex={} paths={:?}",
args.old,
args.regex,
args.paths
);
if args.command_position
&& let Some(msg) = crate::ops::shell_token::command_position_combo_error(
crate::ops::shell_token::CommandPositionIncompat {
regex: args.regex,
case_insensitive: args.case_insensitive,
word_boundary: args.word_boundary,
whole_line: args.whole_line,
multiline: args.multiline,
nth: args.nth.is_some(),
insert_before: args.insert_before.is_some(),
insert_after: args.insert_after.is_some(),
before_context: args.before_context.is_some(),
after_context: args.after_context.is_some(),
fuzzy: args.fuzzy,
},
)
{
global.emit_error_json_kind(Some("invalid_input"), msg)?;
return Ok(exit::FAILURE);
}
use crate::ops::replace::{ReplaceValidationParams, validate_replace_args};
if let Err(e) = 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(),
}) {
global.emit_error_json_kind(Some("invalid_input"), &e.to_string())?;
return Ok(exit::FAILURE);
}
let cwd = global.resolve_cwd()?;
global.check_paths_contained(&cwd, &args.paths)?;
if args.fuzzy || args.before_context.is_some() || args.after_context.is_some() {
return run_context_replace(args, global, &cwd);
}
let mut replacements = collect_replacements(&args, global)?;
let raw_match_count: usize = replacements.iter().map(|r| r.match_count).sum();
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,
match_mode: r.match_mode,
match_score: r.match_score,
}],
diff: None,
identity: None,
error_kind: Some("ambiguous"),
match_mode: None,
match_score: None,
similar_targets: 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);
}
}
}
replacements.retain(|r| r.original != r.replaced);
if replacements.is_empty() {
if raw_match_count > 0 {
let output = ReplaceOutput {
ok: true,
match_count: raw_match_count,
file_count: 0,
files: vec![],
diff: None,
identity: Some(true),
error_kind: None,
match_mode: Some("exact"),
match_score: None,
similar_targets: None,
};
global.emit_json(&output)?;
if !global.quiet && !global.json && !global.jsonl {
eprintln!(
"matched {raw_match_count} occurrence(s) of '{}' but replacement is identical (no file changes)",
args.old
);
}
return Ok(exit::SUCCESS);
}
if args.if_exists {
let output = ReplaceOutput {
ok: true,
match_count: 0,
file_count: 0,
files: vec![],
diff: None,
identity: None,
error_kind: None,
match_mode: None,
match_score: None,
similar_targets: None,
};
global.emit_json(&output)?;
return Ok(exit::SUCCESS);
}
if crate::files::all_scan_targets_missing(global, &args.paths, Some(&cwd))? {
let msg = format!(
"no such file or directory: {}",
global.path_scope_description(&args.paths)
);
global.emit_error_json_kind(Some("not_found"), &msg)?;
return Ok(exit::FAILURE);
}
let similar = similar_targets_for_no_match(&args, global, &cwd);
let output = ReplaceOutput {
ok: false,
match_count: 0,
file_count: 0,
files: vec![],
diff: None,
identity: None,
error_kind: Some("no_matches"),
match_mode: None,
match_score: None,
similar_targets: similar.clone(),
};
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 let Some(ref s) = similar {
eprintln!("did you mean: {}?", s.join(", "));
}
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 (agg_mode, agg_score) = aggregate_match_meta(files);
let build_output = |diff: Option<String>| ReplaceOutput {
ok: true,
match_count: total_matches,
file_count,
files: files.to_vec(),
diff,
identity: None,
error_kind: None,
match_mode: agg_mode,
match_score: agg_score,
similar_targets: None,
};
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;
let paths = if args.paths.is_empty() {
vec![".".to_string()]
} else {
args.paths.clone()
};
let file_paths = crate::collect_file_paths_opts(&paths, global, false, Some(cwd))?;
if file_paths.is_empty() {
if crate::files::all_scan_targets_missing(global, &paths, Some(cwd))? {
let msg = format!(
"no such file or directory: {}",
global.path_scope_description(&paths)
);
global.emit_error_json_kind(Some("not_found"), &msg)?;
return Ok(exit::FAILURE);
}
let empty = ReplaceOutput {
ok: args.if_exists,
match_count: 0,
file_count: 0,
files: vec![],
diff: None,
identity: None,
error_kind: if args.if_exists {
None
} else {
Some("no_matches")
},
match_mode: None,
match_score: None,
similar_targets: 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 fuzzy/context replace ({})",
args.old,
global.path_scope_description(&paths)
);
}
return Ok(exit::NO_MATCHES);
}
let ops: Vec<Operation> = file_paths
.iter()
.map(|p| {
let rel = crate::files::relative_display(p, cwd)
.to_string_lossy()
.into_owned();
Operation::Replace {
glob: None,
path: Some(rel),
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 || file_paths.len() > 1,
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,
require_change: args.require_change && file_paths.len() == 1,
command_position: args.command_position,
fuzzy: args.fuzzy,
min_fuzzy_score: args.min_fuzzy_score,
}
})
.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,
identity: None,
error_kind: if args.if_exists {
None
} else {
Some("no_matches")
},
match_mode: None,
match_score: None,
similar_targets: 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 lib_opts = crate::api::ReplaceOptions {
regex: args.regex,
nth: args.nth,
case_insensitive: args.case_insensitive,
multiline: args.multiline,
insert_before: args.insert_before.clone(),
insert_after: args.insert_after.clone(),
whole_line: args.whole_line,
range: None,
if_exists: true,
word_boundary: args.word_boundary,
unique: args.unique,
fuzzy: args.fuzzy,
before_context: args.before_context.clone(),
after_context: args.after_context.clone(),
require_change: false,
command_position: args.command_position,
min_fuzzy_score: args.min_fuzzy_score,
post_write: None,
post_write_cwd: None,
};
let to = args.new.as_deref().unwrap_or("");
let files: Vec<ReplaceFileResult> = result
.exec_result
.changes
.iter()
.map(|(p, original, _new)| {
let (mode, score, count) = if let Some(m) = result.exec_result.replace_match_meta.get(p)
{
(Some(match_mode_str(m.mode)), m.score, m.match_count.max(1))
} else {
match crate::api::replace_in_content(original, &args.old, to, &lib_opts) {
Ok(r) => (
r.match_mode.map(match_mode_str),
r.match_score,
r.match_count.max(1),
),
Err(_) => (None, None, 1),
}
};
ReplaceFileResult {
path: crate::files::relative_display(p, &cwd)
.to_string_lossy()
.into_owned(),
match_count: count,
match_mode: mode,
match_score: score,
}
})
.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;