fn compile_pattern(args: &ReplaceArgs) -> Result<Regex> {
if args.pattern.is_empty() {
return Err(crate::error::AtomwriteError::InvalidInput {
reason: "pattern must not be empty".to_string(),
}
.into());
}
let pattern_str = if args.literal || !args.regex {
regex::escape(&args.pattern)
} else {
args.pattern.clone()
};
let pattern_str = if args.word {
format!(r"\b{pattern_str}\b")
} else {
pattern_str
};
let pattern_str = if args.preserve_case {
format!("(?i){pattern_str}")
} else {
pattern_str
};
Regex::new(&pattern_str).map_err(|e| {
crate::error::AtomwriteError::invalid(&format!("invalid pattern '{}'", args.pattern), e)
.into()
})
}
fn adapt_case(original: &str, replacement: &str) -> String {
if original
.chars()
.all(|c| !c.is_alphabetic() || c.is_uppercase())
&& original.chars().any(|c| c.is_alphabetic())
{
replacement.to_uppercase()
} else if original
.chars()
.all(|c| !c.is_alphabetic() || c.is_lowercase())
{
replacement.to_lowercase()
} else if original.starts_with(|c: char| c.is_uppercase()) {
let mut chars = replacement.chars();
match chars.next() {
Some(first) => {
let mut s = first.to_uppercase().to_string();
s.push_str(chars.as_str());
s
}
None => String::new(),
}
} else {
replacement.to_owned()
}
}
fn apply_replacement_cancellable<'a>(
pattern: &Regex,
content: &'a str,
replacement: &str,
max_replacements: Option<usize>,
preserve_case: bool,
mut cancel: Option<&mut crate::signal::CancelCheck>,
) -> std::result::Result<(Cow<'a, str>, u64, u64), crate::error::AtomwriteError> {
let count = match cancel.as_mut() {
Some(check) => {
let mut seen = 0u64;
for _ in pattern.find_iter(content) {
check.advance(1)?;
seen += 1;
}
seen
}
None => pattern.find_iter(content).count() as u64,
};
if count == 0 {
return Ok((Cow::Borrowed(content), 0, 0));
}
let allowed = match max_replacements.filter(|n| *n > 0) {
Some(n) => count.min(n as u64),
None => count,
};
let Some(check) = cancel.as_mut() else {
if preserve_case {
let mut result = String::with_capacity(content.len());
let mut last_end = 0;
let mut applied = 0u64;
for m in pattern.find_iter(content) {
if applied >= allowed {
break;
}
result.push_str(&content[last_end..m.start()]);
result.push_str(&adapt_case(m.as_str(), replacement));
last_end = m.end();
applied += 1;
}
result.push_str(&content[last_end..]);
return Ok((Cow::Owned(result), applied, count.saturating_sub(applied)));
}
let replaced = match max_replacements {
Some(n) if n > 0 => {
let result = pattern.replacen(content, n, replacement);
return Ok((
Cow::Owned(result.into_owned()),
allowed,
count.saturating_sub(allowed),
));
}
_ => pattern.replace_all(content, replacement),
};
return Ok(match replaced {
Cow::Borrowed(_) => (Cow::Borrowed(content), 0, count),
Cow::Owned(s) => (Cow::Owned(s), count, 0),
});
};
let mut result = String::with_capacity(content.len());
let mut last_end = 0;
let mut applied = 0u64;
if preserve_case {
for m in pattern.find_iter(content) {
if applied >= allowed {
break;
}
check.advance(1)?;
result.push_str(&content[last_end..m.start()]);
result.push_str(&adapt_case(m.as_str(), replacement));
last_end = m.end();
applied += 1;
}
result.push_str(&content[last_end..]);
return Ok((Cow::Owned(result), applied, count.saturating_sub(applied)));
}
for caps in pattern.captures_iter(content) {
if applied >= allowed {
break;
}
check.advance(1)?;
let Some(m) = caps.get(0) else {
continue;
};
result.push_str(&content[last_end..m.start()]);
caps.expand(replacement, &mut result);
last_end = m.end();
applied += 1;
}
result.push_str(&content[last_end..]);
Ok((Cow::Owned(result), applied, count.saturating_sub(applied)))
}
#[cfg(test)]
fn apply_replacement<'a>(
pattern: &Regex,
content: &'a str,
replacement: &str,
max_replacements: Option<usize>,
preserve_case: bool,
) -> (Cow<'a, str>, u64) {
apply_replacement_cancellable(
pattern,
content,
replacement,
max_replacements,
preserve_case,
None,
)
.map(|(text, applied, _suppressed)| (text, applied))
.expect("a replacement run without a check point cannot observe a cancel")
}
fn build_walker(
args: &ReplaceArgs,
canonical_paths: &[std::path::PathBuf],
global: &GlobalArgs,
) -> Result<ignore::WalkBuilder> {
crate::commands::build_scoped_walker(canonical_paths, global, &args.include, &args.exclude)
}
#[cfg(test)]
mod apply_replacement_tests {
use super::*;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
fn armed_but_clear() -> crate::signal::CancelCheck {
crate::signal::CancelCheck::for_bytes(Arc::new(AtomicBool::new(false)))
}
fn assert_paths_agree(
pattern: &str,
content: &str,
replacement: &str,
max_replacements: Option<usize>,
preserve_case: bool,
) {
let regex = Regex::new(pattern).expect("valid test regex");
let (legacy_text, legacy_count) = apply_replacement(
®ex,
content,
replacement,
max_replacements,
preserve_case,
);
let mut check = armed_but_clear();
let (cancel_text, cancel_count, _suppressed) = apply_replacement_cancellable(
®ex,
content,
replacement,
max_replacements,
preserve_case,
Some(&mut check),
)
.expect("clear check point must not cancel");
assert_eq!(
cancel_text.as_ref(),
legacy_text.as_ref(),
"text diverged for pattern {pattern:?}"
);
assert_eq!(
cancel_count, legacy_count,
"count diverged for pattern {pattern:?}"
);
}
#[test]
fn cancel_path_matches_legacy_for_literal_patterns() {
assert_paths_agree("foo", "foo bar foo baz foo", "X", None, false);
assert_paths_agree("foo", "foo bar foo baz foo", "X", Some(2), false);
assert_paths_agree("foo", "foo bar foo baz foo", "X", Some(1), false);
assert_paths_agree("foo", "nothing here", "X", None, false);
}
#[test]
fn cancel_path_expands_capture_references() {
assert_paths_agree(r"(\w+),(\w+)", "a,b c,d e,f", "$2 $1", None, false);
assert_paths_agree(r"(\w+),(\w+)", "a,b c,d e,f", "$2 $1", Some(2), false);
assert_paths_agree(r"(\w+),(\w+)", "a,b c,d e,f", "${1}-${2}", None, false);
}
#[test]
fn cancel_path_matches_legacy_with_preserve_case() {
assert_paths_agree("foo", "FOO Foo foo", "bar", None, true);
assert_paths_agree("foo", "FOO Foo foo", "bar", Some(2), true);
assert_paths_agree("foo", "FOO Foo foo", "Bar Baz", None, true);
}
#[test]
fn cancel_path_matches_legacy_for_zero_limit() {
assert_paths_agree("foo", "foo foo foo", "X", Some(0), false);
}
#[test]
fn cancel_path_matches_legacy_for_empty_matches() {
assert_paths_agree("x*", "axbxc", "-", None, false);
assert_paths_agree("x*", "axbxc", "-", Some(3), false);
}
#[test]
fn cancelled_flag_reports_cancelled_error() {
let flag = Arc::new(AtomicBool::new(false));
let check = crate::signal::CancelCheck::for_bytes(Arc::clone(&flag));
assert!(check.check().is_ok());
flag.store(true, Ordering::Release);
let err = check.check().expect_err("flag set must cancel");
assert!(
matches!(err, crate::error::AtomwriteError::Cancelled { .. }),
"expected Cancelled, got {err:?}"
);
}
#[test]
fn cancel_path_stops_before_writing_when_flag_is_set() {
let regex = Regex::new("foo").expect("valid test regex");
let flag = Arc::new(AtomicBool::new(true));
let mut check = crate::signal::CancelCheck::new(flag, 1);
let result = apply_replacement_cancellable(
®ex,
"foo foo foo",
"X",
None,
false,
Some(&mut check),
);
assert!(
matches!(result, Err(crate::error::AtomwriteError::Cancelled { .. })),
"expected Cancelled, got {result:?}"
);
}
#[test]
fn block_budget_polls_once_after_the_block() {
let flag = Arc::new(AtomicBool::new(false));
let mut check = crate::signal::CancelCheck::new(Arc::clone(&flag), 2);
assert!(check.advance(1).is_ok());
assert!(check.advance(1).is_ok());
flag.store(true, Ordering::Release);
assert!(check.advance(1).is_ok());
assert!(check.advance(1).is_err());
}
#[test]
fn block_size_one_polls_on_every_unit() {
let flag = Arc::new(AtomicBool::new(false));
let mut check = crate::signal::CancelCheck::new(Arc::clone(&flag), 1);
assert!(check.advance(1).is_ok());
flag.store(true, Ordering::Release);
assert!(check.advance(1).is_err());
}
}