use std::path::Path;
use crate::containment::PathGuard;
use crate::plan::Operation;
use super::{ApplyMode, ContentEditResult, EditResult, ReplaceOptions};
pub fn replace_text(
path: &Path,
from: &str,
to: &str,
opts: &ReplaceOptions,
mode: ApplyMode,
guard: Option<&PathGuard>,
) -> anyhow::Result<EditResult> {
if from.is_empty() && !opts.regex {
return Err(anyhow::Error::new(crate::exit::InvalidInputError {
msg: "empty search pattern".into(),
}));
}
if opts.range.is_some() && !opts.whole_line {
return Err(anyhow::Error::new(crate::exit::InvalidInputError {
msg: "range requires whole_line to be true".into(),
}));
}
if opts.whole_line && opts.multiline {
return Err(anyhow::Error::new(crate::exit::InvalidInputError {
msg: "whole_line and multiline cannot be combined".into(),
}));
}
let range_str = opts.range.map(|(start, end)| {
if let Some(e) = end {
format!("{start}:{e}")
} else {
format!("{start}:")
}
});
if opts.command_position {
let original = std::fs::read_to_string(path).map_err(|e| {
crate::fallback::EditError::new(
crate::fallback::EditErrorKind::OperationFailed,
format!("failed to read {}: {e}", path.display()),
)
})?;
let content_result = replace_in_content(&original, from, to, opts)?;
let policy = crate::write::WritePolicy::default();
let applied =
super::write_if_apply(path, &content_result.new_content, mode, &policy, guard)?;
let path_str = path.to_string_lossy();
let mut result = super::build_edit_result(
&path_str,
content_result.original,
content_result.new_content,
applied,
"replace",
None,
);
result.match_count = content_result.match_count;
return Ok(result);
}
let op = Operation::Replace {
glob: None,
path: Some(path.to_string_lossy().into()),
regex: opts.regex,
old: from.into(),
new_text: Some(to.into()),
nth: opts.nth,
insert_before: opts.insert_before.clone(),
insert_after: opts.insert_after.clone(),
case_insensitive: opts.case_insensitive,
multiline: opts.multiline,
if_exists: opts.if_exists,
whole_line: opts.whole_line,
range: range_str,
word_boundary: opts.word_boundary,
before_context: opts.before_context.clone(),
after_context: opts.after_context.clone(),
unique: opts.unique,
require_change: false,
command_position: opts.command_position,
};
let result = replace_write(op, path, mode, guard, opts.fuzzy)?;
if opts.require_change && !opts.if_exists && !result.changed && result.match_count == 0 {
let similar = crate::fallback::find_similar_targets(&result.original_content, from, 3);
let mut msg = format!("no matches for {:?}", from);
if !similar.is_empty() {
msg.push_str(&format!(" (did you mean: {}?)", similar.join(", ")));
}
return Err(
crate::fallback::EditError::new(crate::fallback::EditErrorKind::NoMatch, msg)
.with_similar(similar)
.into(),
);
}
Ok(result)
}
#[cfg(any(feature = "cli", feature = "files"))]
fn replace_write(
op: Operation,
path: &Path,
mode: ApplyMode,
guard: Option<&PathGuard>,
_fuzzy: bool,
) -> anyhow::Result<EditResult> {
let cwd = path.parent().unwrap_or_else(|| Path::new("."));
super::execute_as_edit_result(op, mode, cwd, guard, "replace", None)
}
#[cfg(not(any(feature = "cli", feature = "files")))]
fn replace_write(
op: Operation,
path: &Path,
mode: ApplyMode,
guard: Option<&PathGuard>,
fuzzy: bool,
) -> anyhow::Result<EditResult> {
use crate::ops;
use anyhow::{Context, bail};
if let Operation::Replace {
old,
new_text,
regex: regex_mode,
insert_before,
insert_after,
case_insensitive,
multiline,
if_exists,
whole_line,
range,
word_boundary,
nth,
unique,
before_context,
after_context,
..
} = op
{
let path_str = path.to_string_lossy();
let original = std::fs::read_to_string(path)
.with_context(|| format!("failed to read {}", path.display()))?;
let is_regex = regex_mode;
if old.is_empty() && !is_regex {
return Err(anyhow::Error::new(crate::exit::InvalidInputError {
msg: "empty search pattern".into(),
}));
}
let compiled_re = ops::replace::compile_replace_regex(
&old,
is_regex,
case_insensitive,
multiline,
word_boundary,
)?;
let direct_to = if insert_before.is_none() && insert_after.is_none() {
new_text.clone()
} else {
None
};
let replacement = ops::replace::replacement_text(
&old,
&direct_to,
&insert_before,
&insert_after,
compiled_re.is_some(),
is_regex,
);
let parsed_range = range.as_deref().map(|r| {
let parts: Vec<&str> = r.splitn(2, ':').collect();
let start: usize = parts[0].parse().unwrap_or(1);
let end: Option<usize> = parts
.get(1)
.and_then(|s| if s.is_empty() { None } else { s.parse().ok() });
(start, end)
});
let (new_content, count) = if whole_line {
ops::replace::replace_whole_lines(
&original,
&old,
&replacement,
compiled_re.as_ref(),
nth,
parsed_range,
)
} else {
ops::replace::replace_content(&original, &old, &replacement, compiled_re.as_ref(), nth)
};
if count > 1
&& nth.is_none()
&& !whole_line
&& !is_regex
&& (before_context.is_some() || after_context.is_some())
{
if let Some(target_offset) = ops::replace::context_filtered_offset(
&original,
&old,
before_context.as_deref(),
after_context.as_deref(),
) {
let ctx_content = format!(
"{}{}{}",
&original[..target_offset],
&replacement,
&original[target_offset + old.len()..],
);
let policy = crate::write::WritePolicy::default();
let applied = super::write_if_apply(path, &ctx_content, mode, &policy, guard)?;
let mut result = super::build_edit_result(
&path_str,
original,
ctx_content,
applied,
"replace",
None,
);
result.match_count = 1;
return Ok(result);
}
}
let new_content = new_content.into_owned();
if unique && count > 1 {
return Err(anyhow::Error::new(crate::exit::AmbiguousError {
msg: format!(
"ambiguous match: pattern {old:?} matches {count} times; use --nth or add context to disambiguate"
),
}));
}
if count == 0 && !is_regex && (fuzzy || before_context.is_some() || after_context.is_some())
{
use crate::fallback;
match fallback::resolve_with_fallback(
&original,
&old,
before_context.as_deref(),
after_context.as_deref(),
) {
Ok(anchor) => {
let to_text = if let Some(ib) = &insert_before {
format!("{}{}", ib, anchor.matched_text)
} else if let Some(ia) = &insert_after {
format!("{}{}", anchor.matched_text, ia)
} else {
new_text.as_deref().unwrap_or("").to_string()
};
let fb_content = format!(
"{}{}{}",
&original[..anchor.start_offset],
to_text,
&original[anchor.start_offset + anchor.matched_text.len()..],
);
let policy = crate::write::WritePolicy::default();
let applied = super::write_if_apply(path, &fb_content, mode, &policy, guard)?;
let mut result = super::build_edit_result(
&path_str, original, fb_content, applied, "replace", None,
);
result.match_count = 1;
return Ok(result);
}
Err(edit_error) => {
if if_exists {
return Ok(super::build_edit_result(
&path_str,
original.clone(),
original,
false,
"replace",
None,
));
}
let similar = fallback::find_similar_targets(&original, &old, 3);
let mut msg = format!("no matches for {old:?}");
if let Some(suggestion) = &edit_error.suggestion {
msg.push_str(&format!(" (suggestion: {suggestion})"));
}
if !similar.is_empty() {
msg.push_str(&format!(" (did you mean: {}?)", similar.join(", ")));
}
return Err(anyhow::Error::new(crate::exit::NoMatchError { msg }));
}
}
}
if count == 0 && if_exists {
return Ok(super::build_edit_result(
&path_str,
original.clone(),
original,
false,
"replace",
None,
));
}
let policy = crate::write::WritePolicy::default();
let applied = super::write_if_apply(path, &new_content, mode, &policy, guard)?;
let mut result =
super::build_edit_result(&path_str, original, new_content, applied, "replace", None);
result.match_count = count;
Ok(result)
} else {
bail!("expected Replace operation")
}
}
pub fn replace_in_content(
content: &str,
from: &str,
to: &str,
opts: &ReplaceOptions,
) -> anyhow::Result<ContentEditResult> {
use crate::ops;
let is_regex = opts.regex;
if from.is_empty() && !is_regex {
return Err(anyhow::Error::new(crate::exit::InvalidInputError {
msg: "empty search pattern".into(),
}));
}
if opts.range.is_some() && !opts.whole_line {
return Err(anyhow::Error::new(crate::exit::InvalidInputError {
msg: "range requires whole_line to be true".into(),
}));
}
if opts.whole_line && opts.multiline {
return Err(anyhow::Error::new(crate::exit::InvalidInputError {
msg: "whole_line and multiline cannot be combined".into(),
}));
}
if opts.command_position {
if let Some(msg) = crate::ops::shell_token::command_position_combo_error(
crate::ops::shell_token::CommandPositionIncompat {
regex: is_regex,
case_insensitive: opts.case_insensitive,
word_boundary: opts.word_boundary,
whole_line: opts.whole_line,
multiline: opts.multiline,
nth: opts.nth.is_some(),
insert_before: opts.insert_before.is_some(),
insert_after: opts.insert_after.is_some(),
before_context: opts.before_context.is_some(),
after_context: opts.after_context.is_some(),
fuzzy: opts.fuzzy,
},
) {
return Err(crate::fallback::EditError::new(
crate::fallback::EditErrorKind::InvalidInput,
msg,
)
.into());
}
let (new_content, count) =
crate::ops::shell_token::replace_command_position(content, from, to);
return finalize_content_replace(content, from, new_content, count, opts);
}
let compiled_re = ops::replace::compile_replace_regex(
from,
is_regex,
opts.case_insensitive,
opts.multiline,
opts.word_boundary,
)?;
let direct_to = if opts.insert_before.is_none() && opts.insert_after.is_none() {
Some(to.to_string())
} else {
None
};
let replacement = ops::replace::replacement_text(
from,
&direct_to,
&opts.insert_before,
&opts.insert_after,
compiled_re.is_some(),
is_regex,
);
let parsed_range = opts.range;
let (new_content, count) = if opts.whole_line {
ops::replace::replace_whole_lines(
content,
from,
&replacement,
compiled_re.as_ref(),
opts.nth,
parsed_range,
)
} else {
ops::replace::replace_content(content, from, &replacement, compiled_re.as_ref(), opts.nth)
};
if count > 1
&& opts.nth.is_none()
&& !opts.whole_line
&& !is_regex
&& (opts.before_context.is_some() || opts.after_context.is_some())
&& let Some(target_offset) = ops::replace::context_filtered_offset(
content,
from,
opts.before_context.as_deref(),
opts.after_context.as_deref(),
)
{
let ctx_content = format!(
"{}{}{}",
&content[..target_offset],
replacement,
&content[target_offset + from.len()..],
);
let diff = super::make_diff("<content>", content, &ctx_content);
return Ok(ContentEditResult {
original: content.to_string(),
new_content: ctx_content,
diff,
changed: true,
match_count: 1,
});
}
let new_content = new_content.into_owned();
if count == 0
&& !is_regex
&& (opts.fuzzy || opts.before_context.is_some() || opts.after_context.is_some())
{
use crate::fallback;
match fallback::resolve_with_fallback(
content,
from,
opts.before_context.as_deref(),
opts.after_context.as_deref(),
) {
Ok(anchor) => {
let to_text = if let Some(ib) = &opts.insert_before {
format!("{}{}", ib, anchor.matched_text)
} else if let Some(ia) = &opts.insert_after {
format!("{}{}", anchor.matched_text, ia)
} else {
to.to_string()
};
let fuzzy_content = format!(
"{}{}{}",
&content[..anchor.start_offset],
to_text,
&content[anchor.start_offset + anchor.matched_text.len()..]
);
let diff = super::make_diff("<content>", content, &fuzzy_content);
return Ok(ContentEditResult {
original: content.to_string(),
new_content: fuzzy_content,
diff,
changed: true,
match_count: 1,
});
}
Err(edit_error) => {
if opts.if_exists {
return Ok(ContentEditResult {
original: content.to_string(),
new_content: content.to_string(),
diff: String::new(),
changed: false,
match_count: 0,
});
}
let similar = fallback::find_similar_targets(content, from, 3);
let mut msg = format!("no matches for {:?}", from);
if let Some(suggestion) = &edit_error.suggestion {
msg.push_str(&format!(" (suggestion: {})", suggestion));
}
if !similar.is_empty() {
msg.push_str(&format!(" (did you mean: {}?)", similar.join(", ")));
}
let mut err =
crate::fallback::EditError::new(crate::fallback::EditErrorKind::NoMatch, msg)
.with_similar(similar);
if let Some(s) = edit_error.suggestion.clone() {
err = err.with_suggestion(s);
}
return Err(err.into());
}
}
}
finalize_content_replace(content, from, new_content, count, opts)
}
fn finalize_content_replace(
content: &str,
from: &str,
new_content: String,
count: usize,
opts: &ReplaceOptions,
) -> anyhow::Result<ContentEditResult> {
if opts.unique && count > 1 {
return Err(crate::fallback::EditError::new(
crate::fallback::EditErrorKind::AmbiguousTarget,
format!(
"ambiguous match: pattern {:?} matches {} times; use --nth or add context to disambiguate",
from, count
),
)
.into());
}
if count == 0 && opts.if_exists {
return Ok(ContentEditResult {
original: content.to_string(),
new_content: content.to_string(),
diff: String::new(),
changed: false,
match_count: 0,
});
}
if count == 0 && opts.require_change {
let similar = crate::fallback::find_similar_targets(content, from, 3);
let mut msg = format!("no matches for {:?}", from);
if !similar.is_empty() {
msg.push_str(&format!(" (did you mean: {}?)", similar.join(", ")));
}
return Err(
crate::fallback::EditError::new(crate::fallback::EditErrorKind::NoMatch, msg)
.with_similar(similar)
.into(),
);
}
let changed = content != new_content;
let diff = if changed {
super::make_diff("<content>", content, &new_content)
} else {
String::new()
};
Ok(ContentEditResult {
original: content.to_string(),
new_content,
diff,
changed,
match_count: count,
})
}