use regex::Regex;
pub fn compile_replace_regex(
pattern: &str,
regex_mode: bool,
case_insensitive: bool,
multiline: bool,
word_boundary: bool,
) -> anyhow::Result<Option<Regex>> {
if word_boundary && !regex_mode {
let escaped = regex::escape(pattern);
let wb_pattern = format!("\\b{escaped}\\b");
return Ok(Some(crate::bounded_regex_build(
crate::bounded_regex_builder(&wb_pattern)
.case_insensitive(case_insensitive)
.multi_line(true)
.dot_matches_new_line(multiline),
)?));
}
if regex_mode {
let effective = if word_boundary {
format!("\\b(?:{pattern})\\b")
} else {
pattern.to_string()
};
Ok(Some(crate::bounded_regex_build(
crate::bounded_regex_builder(&effective)
.case_insensitive(case_insensitive)
.multi_line(true)
.dot_matches_new_line(multiline),
)?))
} else if case_insensitive {
Ok(Some(crate::bounded_regex_build(
crate::bounded_regex_builder(®ex::escape(pattern))
.case_insensitive(true)
.multi_line(true),
)?))
} else {
Ok(None)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReplaceModeError {
MissingMode,
BothInsertModes,
ToWithInsert,
}
pub fn validate_replace_mode(
has_to: bool,
has_insert_before: bool,
has_insert_after: bool,
) -> Result<(), ReplaceModeError> {
match (has_to, has_insert_before, has_insert_after) {
(false, false, false) => Err(ReplaceModeError::MissingMode),
(_, true, true) => Err(ReplaceModeError::BothInsertModes),
(true, true, false) | (true, false, true) => Err(ReplaceModeError::ToWithInsert),
_ => Ok(()),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ReplaceValidationError {
EmptyPattern,
NthZero,
RangeRequiresWholeLine,
WholeLineMultilineConflict,
WholeLineInsertConflict,
Mode(ReplaceModeError),
}
impl std::fmt::Display for ReplaceValidationError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::EmptyPattern => write!(f, "replace pattern must not be empty"),
Self::NthZero => {
write!(
f,
"nth must be >= 1 (1-based); use nth=1 for the first occurrence"
)
}
Self::RangeRequiresWholeLine => write!(f, "range requires whole_line"),
Self::WholeLineMultilineConflict => {
write!(f, "whole_line and multiline cannot be combined")
}
Self::WholeLineInsertConflict => {
write!(
f,
"whole_line cannot be combined with insert_before or insert_after (would drop non-matched line content)"
)
}
Self::Mode(e) => match e {
ReplaceModeError::MissingMode => {
write!(
f,
"one of --new, --insert-before, or --insert-after must be provided \
(plan fields: new/to, insert_before, insert_after); \
replacement text is not positional — use: replace OLD --new NEW path"
)
}
ReplaceModeError::BothInsertModes => {
write!(
f,
"--insert-before and --insert-after cannot be combined \
(plan fields: insert_before, insert_after)"
)
}
ReplaceModeError::ToWithInsert => {
write!(
f,
"--new cannot be combined with --insert-before or --insert-after \
(plan fields: new/to, insert_before, insert_after)"
)
}
},
}
}
}
pub struct ReplaceValidationParams<'a> {
pub pattern: &'a str,
pub has_to: bool,
pub has_insert_before: bool,
pub has_insert_after: bool,
pub nth: Option<usize>,
pub whole_line: bool,
pub multiline: bool,
pub has_range: bool,
}
pub fn validate_replace_args(
p: &ReplaceValidationParams<'_>,
) -> Result<(), ReplaceValidationError> {
if p.pattern.is_empty() {
return Err(ReplaceValidationError::EmptyPattern);
}
if p.nth == Some(0) {
return Err(ReplaceValidationError::NthZero);
}
if p.has_range && !p.whole_line {
return Err(ReplaceValidationError::RangeRequiresWholeLine);
}
if p.whole_line && p.multiline {
return Err(ReplaceValidationError::WholeLineMultilineConflict);
}
if p.whole_line && (p.has_insert_before || p.has_insert_after) {
return Err(ReplaceValidationError::WholeLineInsertConflict);
}
validate_replace_mode(p.has_to, p.has_insert_before, p.has_insert_after)
.map_err(ReplaceValidationError::Mode)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InsertSide {
Before,
After,
}
pub fn normalize_line_insert(
file_content: &str,
anchor: &str,
insert_content: &str,
side: InsertSide,
) -> String {
let eol = preferred_line_ending(file_content);
match side {
InsertSide::After => {
if starts_with_line_ending(insert_content) || ends_with_line_ending(anchor) {
return insert_content.to_string();
}
if looks_like_new_line_payload(insert_content)
|| anchor_is_whole_line(file_content, anchor)
{
format!("{eol}{insert_content}")
} else {
insert_content.to_string()
}
}
InsertSide::Before => {
if ends_with_line_ending(insert_content) || starts_with_line_ending(anchor) {
return insert_content.to_string();
}
if looks_like_new_line_payload(insert_content)
|| anchor_is_whole_line(file_content, anchor)
{
format!("{insert_content}{eol}")
} else {
insert_content.to_string()
}
}
}
}
pub fn normalize_line_insert_ci(
file_content: &str,
anchor: &str,
insert_content: &str,
side: InsertSide,
case_insensitive: bool,
) -> String {
if !case_insensitive {
return normalize_line_insert(file_content, anchor, insert_content, side);
}
let eol = preferred_line_ending(file_content);
match side {
InsertSide::After => {
if starts_with_line_ending(insert_content) || ends_with_line_ending(anchor) {
return insert_content.to_string();
}
if looks_like_new_line_payload(insert_content)
|| anchor_is_whole_line_ci(file_content, anchor, true)
{
format!("{eol}{insert_content}")
} else {
insert_content.to_string()
}
}
InsertSide::Before => {
if ends_with_line_ending(insert_content) || starts_with_line_ending(anchor) {
return insert_content.to_string();
}
if looks_like_new_line_payload(insert_content)
|| anchor_is_whole_line_ci(file_content, anchor, true)
{
format!("{insert_content}{eol}")
} else {
insert_content.to_string()
}
}
}
}
pub fn preferred_line_ending(content: &str) -> &'static str {
if content.contains("\r\n") {
"\r\n"
} else if content.contains('\r') {
"\r"
} else {
"\n"
}
}
#[inline]
fn starts_with_line_ending(s: &str) -> bool {
s.starts_with("\r\n") || s.starts_with('\n') || s.starts_with('\r')
}
#[inline]
fn ends_with_line_ending(s: &str) -> bool {
s.ends_with("\r\n") || s.ends_with('\n') || s.ends_with('\r')
}
fn looks_like_new_line_payload(insert_content: &str) -> bool {
let trimmed = insert_content.trim_start_matches([' ', '\t']);
insert_content.starts_with([' ', '\t'])
|| trimmed.starts_with("//")
|| trimmed.starts_with('#')
|| insert_content.contains('\n')
}
pub fn anchor_is_whole_line(file_content: &str, anchor: &str) -> bool {
anchor_is_whole_line_ci(file_content, anchor, false)
}
pub fn anchor_is_whole_line_ci(file_content: &str, anchor: &str, case_insensitive: bool) -> bool {
if anchor.is_empty() || file_content.is_empty() {
return false;
}
if !case_insensitive {
let bytes = file_content.as_bytes();
let mut any = false;
for (i, _) in file_content.match_indices(anchor) {
any = true;
let before_ok = i == 0 || is_line_boundary_byte(bytes[i - 1]);
let end = i + anchor.len();
let after_ok = end == file_content.len()
|| bytes.get(end).copied().is_some_and(is_line_boundary_byte);
if !(before_ok && after_ok) {
return false;
}
}
return any;
}
let needle = anchor.to_ascii_lowercase();
let mut any = false;
let mut start = 0usize;
let bytes = file_content.as_bytes();
while start <= file_content.len() {
let rest = &file_content[start..];
let line_end = rest
.find(['\n', '\r'])
.map(|i| start + i)
.unwrap_or(file_content.len());
let line = &file_content[start..line_end];
if line.to_ascii_lowercase() == needle {
any = true;
} else if !line.is_empty() {
let lower = line.to_ascii_lowercase();
if lower.contains(&needle) && lower != needle {
return false;
}
}
if line_end >= file_content.len() {
break;
}
let mut next = line_end;
if file_content[next..].starts_with("\r\n") {
next += 2;
} else if matches!(bytes.get(next), Some(b'\n' | b'\r')) {
next += 1;
}
start = next;
}
any
}
#[inline]
fn is_line_boundary_byte(b: u8) -> bool {
b == b'\n' || b == b'\r'
}
pub fn replacement_text(
from: &str,
to: &Option<String>,
insert_before: &Option<String>,
insert_after: &Option<String>,
use_match_anchor: bool,
regex_mode: bool,
file_content: &str,
) -> String {
replacement_text_ci(
from,
to,
insert_before,
insert_after,
use_match_anchor,
regex_mode,
file_content,
false,
)
}
#[allow(clippy::too_many_arguments)]
pub fn replacement_text_ci(
from: &str,
to: &Option<String>,
insert_before: &Option<String>,
insert_after: &Option<String>,
use_match_anchor: bool,
regex_mode: bool,
file_content: &str,
case_insensitive: bool,
) -> String {
let anchor = if use_match_anchor { "${0}" } else { from };
let needs_escape = use_match_anchor && !regex_mode;
if let Some(text) = insert_before {
let normalized = normalize_line_insert_ci(
file_content,
from,
text,
InsertSide::Before,
case_insensitive,
);
let safe = if needs_escape {
normalized.replace('$', "$$")
} else {
normalized
};
return format!("{safe}{anchor}");
}
if let Some(text) = insert_after {
let normalized = normalize_line_insert_ci(
file_content,
from,
text,
InsertSide::After,
case_insensitive,
);
let safe = if needs_escape {
normalized.replace('$', "$$")
} else {
normalized
};
return format!("{anchor}{safe}");
}
let raw = to.clone().unwrap_or_default();
if needs_escape {
raw.replace('$', "$$")
} else {
raw
}
}
fn expand_regex_replacement(caps: ®ex::Captures<'_>, replacement: &str) -> String {
let mut expanded = String::new();
caps.expand(replacement, &mut expanded);
expanded
}
pub fn count_content_matches(content: &str, from: &str, compiled_re: Option<&Regex>) -> usize {
match compiled_re {
Some(re) => {
let content_len = content.len();
re.find_iter(content)
.filter(|m| !(m.start() == content_len && m.end() == content_len))
.count()
}
None => {
if from.is_empty() {
return 0;
}
content.match_indices(from).count()
}
}
}
pub fn count_whole_line_matches(
content: &str,
from: &str,
compiled_re: Option<&Regex>,
range: Option<(usize, Option<usize>)>,
) -> usize {
content
.lines()
.enumerate()
.filter(|(i, line)| {
let line_num = i + 1;
let in_range = match range {
Some((start, Some(end))) => line_num >= start && line_num <= end,
Some((start, None)) => line_num >= start,
None => true,
};
if !in_range {
return false;
}
if let Some(re) = compiled_re {
re.is_match(line)
} else if from.is_empty() {
false
} else {
line.contains(from)
}
})
.count()
}
pub fn count_nth_candidates(
content: &str,
from: &str,
compiled_re: Option<&Regex>,
whole_line: bool,
range: Option<(usize, Option<usize>)>,
) -> usize {
if whole_line {
count_whole_line_matches(content, from, compiled_re, range)
} else {
count_content_matches(content, from, compiled_re)
}
}
pub fn replace_content<'a>(
content: &'a str,
from: &str,
to: &str,
compiled_re: Option<&Regex>,
nth: Option<usize>,
) -> (std::borrow::Cow<'a, str>, usize) {
use std::borrow::Cow;
match (nth, compiled_re) {
(Some(n), Some(re)) => {
let content_len = content.len();
let mut count = 0usize;
let mut result = String::with_capacity(content.len());
for caps in re.captures_iter(content) {
if let Some(m) = caps.get(0)
&& m.start() == content_len
&& m.end() == content_len
{
continue;
}
count += 1;
if count != n {
continue;
}
let Some(m) = caps.get(0) else {
return (Cow::Borrowed(content), 0);
};
result.push_str(&content[..m.start()]);
result.push_str(&expand_regex_replacement(&caps, to));
result.push_str(&content[m.end()..]);
return (Cow::Owned(result), 1);
}
(Cow::Borrowed(content), 0)
}
(Some(n), None) => {
let mut count = 0usize;
let mut result = String::with_capacity(content.len());
for (start, _) in content.match_indices(from) {
count += 1;
if count != n {
continue;
}
result.push_str(&content[..start]);
result.push_str(to);
result.push_str(&content[start + from.len()..]);
return (Cow::Owned(result), 1);
}
(Cow::Borrowed(content), 0)
}
(None, Some(re)) => {
let content_len = content.len();
let mut count = 0usize;
let replaced = re.replace_all(content, |caps: ®ex::Captures| {
if let Some(m) = caps.get(0)
&& m.start() == content_len
&& m.end() == content_len
{
return String::new();
}
count += 1;
expand_regex_replacement(caps, to)
});
match replaced {
Cow::Borrowed(_) => (Cow::Borrowed(content), 0),
Cow::Owned(s) => (Cow::Owned(s), count),
}
}
(None, None) => {
debug_assert!(!from.is_empty(), "replace_content called with empty `from`");
let finder = memchr::memmem::Finder::new(from.as_bytes());
let bytes = content.as_bytes();
let mut result = String::with_capacity(content.len());
let mut count = 0usize;
let mut last = 0;
while let Some(pos) = finder.find(&bytes[last..]) {
let abs = last + pos;
result.push_str(&content[last..abs]);
result.push_str(to);
last = abs + from.len();
count += 1;
}
if count == 0 {
return (Cow::Borrowed(content), 0);
}
result.push_str(&content[last..]);
(Cow::Owned(result), count)
}
}
}
fn context_fragment_score(content_fragment: &str, ctx_fragment: &str) -> f64 {
let a = content_fragment.trim();
let b = ctx_fragment.trim();
if b.is_empty() {
return 0.0;
}
let jw = strsim::jaro_winkler(a, b);
if b.len() >= 2 && a.contains(b) {
jw.max(1.0)
} else {
jw
}
}
pub fn expand_match_anchor_template(template: &str, matched: &str) -> String {
let Ok(re) = Regex::new(&format!("^{}$", regex::escape(matched))) else {
return template.replace("${0}", matched);
};
match re.captures(matched) {
Some(caps) => expand_regex_replacement(&caps, template),
None => template.replace("${0}", matched),
}
}
pub fn context_filtered_span(
content: &str,
matches: &[(usize, usize)],
old_for_line_count: &str,
before_context: Option<&str>,
after_context: Option<&str>,
) -> Option<(usize, usize)> {
if before_context.is_none() && after_context.is_none() {
return None;
}
if matches.len() < 2 {
return None;
}
let lines: Vec<&str> = content.lines().collect();
let mut line_starts: Vec<usize> = Vec::with_capacity(lines.len());
let mut off = 0;
for line in &lines {
line_starts.push(off);
off += line.len();
if content.as_bytes().get(off) == Some(&b'\r') {
off += 1;
}
if content.as_bytes().get(off) == Some(&b'\n') {
off += 1;
}
}
let line_index_at = |byte_offset: usize| -> usize {
match line_starts.binary_search(&byte_offset) {
Ok(idx) => idx,
Err(idx) => idx.saturating_sub(1),
}
};
const MAX_CONTEXT_LINES: usize = 3;
let old_line_count = old_for_line_count.lines().count().max(1);
let single_line_old = old_line_count == 1 && !old_for_line_count.contains('\n');
let mut best: Option<(usize, usize, f64)> = None;
for &(match_off, match_end) in matches {
let match_line = line_index_at(match_off);
let mut score = 0.0f64;
let mut checks = 0u32;
if let Some(before) = before_context {
let ctx_lines: Vec<&str> = before.lines().collect();
let start = ctx_lines.len().saturating_sub(MAX_CONTEXT_LINES);
let ctx_tail = &ctx_lines[start..];
for (i, ctx_line) in ctx_tail.iter().rev().enumerate() {
if i == 0 && single_line_old && match_line < lines.len() {
let line = lines[match_line];
let col = match_off
.saturating_sub(line_starts[match_line])
.min(line.len());
if line.is_char_boundary(col) {
checks += 1;
let sim = context_fragment_score(&line[..col], ctx_line);
if sim >= 0.8 {
score += sim;
}
}
}
let content_idx = match_line.checked_sub(i + 1);
if let Some(ci) = content_idx {
checks += 1;
let sim = context_fragment_score(lines[ci], ctx_line);
if sim >= 0.8 {
score += sim;
}
}
}
}
if let Some(after) = after_context {
let ctx_lines: Vec<&str> = after.lines().collect();
let n = ctx_lines.len().min(MAX_CONTEXT_LINES);
let end_line = match_line + old_line_count;
for (i, ctx_line) in ctx_lines[..n].iter().enumerate() {
if i == 0 && single_line_old && match_line < lines.len() {
let line = lines[match_line];
let col = match_end
.saturating_sub(line_starts[match_line])
.min(line.len());
if line.is_char_boundary(col) {
checks += 1;
let sim = context_fragment_score(&line[col..], ctx_line);
if sim >= 0.8 {
score += sim;
}
}
}
let content_idx = end_line + i;
if content_idx < lines.len() {
checks += 1;
let sim = context_fragment_score(lines[content_idx], ctx_line);
if sim >= 0.8 {
score += sim;
}
}
}
}
if checks > 0 && score > 0.0 && best.is_none_or(|(_, _, s)| score > s) {
best = Some((match_off, match_end, score));
}
}
best.map(|(s, e, _)| (s, e))
}
pub fn context_filtered_offset(
content: &str,
old: &str,
before_context: Option<&str>,
after_context: Option<&str>,
) -> Option<usize> {
context_filtered_offset_with_re(content, old, None, before_context, after_context)
}
pub fn context_filtered_offset_with_re(
content: &str,
old: &str,
compiled_re: Option<&Regex>,
before_context: Option<&str>,
after_context: Option<&str>,
) -> Option<usize> {
context_filtered_span_with_re(content, old, compiled_re, before_context, after_context)
.map(|(s, _)| s)
}
pub fn context_filtered_span_with_re(
content: &str,
old: &str,
compiled_re: Option<&Regex>,
before_context: Option<&str>,
after_context: Option<&str>,
) -> Option<(usize, usize)> {
if before_context.is_none() && after_context.is_none() {
return None;
}
let matches: Vec<(usize, usize)> = match compiled_re {
Some(re) => {
let content_len = content.len();
re.find_iter(content)
.filter(|m| !(m.start() == content_len && m.end() == content_len))
.map(|m| (m.start(), m.end()))
.collect()
}
None => {
if old.is_empty() {
Vec::new()
} else {
content
.match_indices(old)
.map(|(i, s)| (i, i + s.len()))
.collect()
}
}
};
context_filtered_span(content, &matches, old, before_context, after_context)
}
pub fn replace_whole_lines<'a>(
content: &'a str,
from: &str,
to: &str,
compiled_re: Option<&Regex>,
nth: Option<usize>,
range: Option<(usize, Option<usize>)>,
) -> (std::borrow::Cow<'a, str>, usize) {
use std::borrow::Cow;
let mut result = String::with_capacity(content.len());
let mut match_count = 0usize;
let mut rest = content;
let mut line_num = 0usize;
while !rest.is_empty() {
line_num += 1;
let rest_bytes = rest.as_bytes();
let (line_content, ending, advance) =
if let Some(pos) = memchr::memchr2(b'\r', b'\n', rest_bytes) {
if rest_bytes[pos] == b'\n' {
(&rest[..pos], "\n", pos + 1)
} else if pos + 1 < rest_bytes.len() && rest_bytes[pos + 1] == b'\n' {
(&rest[..pos], "\r\n", pos + 2)
} else {
(&rest[..pos], "\r", pos + 1)
}
} else {
(rest, "", rest.len())
};
let line_with_ending = &rest[..advance];
let in_range = match range {
Some((start, Some(end))) => line_num >= start && line_num <= end,
Some((start, None)) => line_num >= start,
None => true,
};
if !in_range {
result.push_str(line_with_ending);
rest = &rest[advance..];
continue;
}
let line_match = if let Some(re) = compiled_re {
re.captures(line_content)
} else if line_content.contains(from) {
None } else {
rest = &rest[advance..];
result.push_str(line_with_ending);
continue;
};
let is_literal_match = compiled_re.is_none() && line_content.contains(from);
let has_match = line_match.is_some() || is_literal_match;
if !has_match {
result.push_str(line_with_ending);
rest = &rest[advance..];
continue;
}
match_count += 1;
if let Some(n) = nth
&& match_count != n
{
result.push_str(line_with_ending);
rest = &rest[advance..];
continue;
}
if to.is_empty() {
} else if let Some(ref caps) = line_match {
let mut expanded = String::new();
caps.expand(to, &mut expanded);
result.push_str(&expanded);
result.push_str(ending);
} else {
result.push_str(to);
result.push_str(ending);
}
rest = &rest[advance..];
}
let effective_count = if let Some(n) = nth {
if match_count >= n { 1 } else { 0 }
} else {
match_count
};
if effective_count == 0 {
return (Cow::Borrowed(content), 0);
}
(Cow::Owned(result), effective_count)
}
#[path = "replace_tests.rs"]
#[cfg(test)]
mod tests;