1use regex::Regex;
8
9pub fn compile_replace_regex(
15 pattern: &str,
16 regex_mode: bool,
17 case_insensitive: bool,
18 multiline: bool,
19 word_boundary: bool,
20) -> anyhow::Result<Option<Regex>> {
21 if word_boundary && !regex_mode {
22 let escaped = regex::escape(pattern);
24 let wb_pattern = format!("\\b{escaped}\\b");
25 return Ok(Some(crate::bounded_regex_build(
26 crate::bounded_regex_builder(&wb_pattern)
27 .case_insensitive(case_insensitive)
28 .multi_line(true)
29 .dot_matches_new_line(multiline),
30 )?));
31 }
32 if regex_mode {
33 let rewritten = crlf_aware_dollar(pattern);
34 let effective = if word_boundary {
38 if let Some(core) = rewritten.strip_suffix(r"\r?$") {
39 format!("\\b(?:{core})\\b\\r?$")
40 } else {
41 format!("\\b(?:{rewritten})\\b")
42 }
43 } else {
44 rewritten
45 };
46 Ok(Some(crate::bounded_regex_build(
47 crate::bounded_regex_builder(&effective)
48 .case_insensitive(case_insensitive)
49 .multi_line(true)
50 .dot_matches_new_line(multiline),
51 )?))
52 } else if case_insensitive {
53 Ok(Some(crate::bounded_regex_build(
54 crate::bounded_regex_builder(®ex::escape(pattern))
55 .case_insensitive(true)
56 .multi_line(true),
57 )?))
58 } else {
59 Ok(None)
60 }
61}
62
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub enum ReplaceModeError {
65 MissingMode,
66 BothInsertModes,
67 ToWithInsert,
68}
69
70pub fn validate_replace_mode(
71 has_to: bool,
72 has_insert_before: bool,
73 has_insert_after: bool,
74) -> Result<(), ReplaceModeError> {
75 match (has_to, has_insert_before, has_insert_after) {
76 (false, false, false) => Err(ReplaceModeError::MissingMode),
77 (_, true, true) => Err(ReplaceModeError::BothInsertModes),
78 (true, true, false) | (true, false, true) => Err(ReplaceModeError::ToWithInsert),
79 _ => Ok(()),
80 }
81}
82
83fn crlf_aware_dollar(pattern: &str) -> String {
88 let mut out = String::with_capacity(pattern.len() + 8);
89 let mut escaped = false;
90 let mut in_class = false;
91 for c in pattern.chars() {
92 if escaped {
93 out.push(c);
94 escaped = false;
95 continue;
96 }
97 match c {
98 '\\' => {
99 escaped = true;
100 out.push(c);
101 }
102 '[' if !in_class => {
103 in_class = true;
104 out.push(c);
105 }
106 ']' if in_class => {
107 in_class = false;
108 out.push(c);
109 }
110 '$' if !in_class => out.push_str(r"\r?$"),
113 _ => out.push(c),
114 }
115 }
116 out
117}
118
119fn pattern_has_unescaped_caret(pattern: &str) -> bool {
122 let mut escaped = false;
123 let mut in_class = false;
124 for c in pattern.chars() {
125 if escaped {
126 escaped = false;
127 continue;
128 }
129 match c {
130 '\\' => escaped = true,
131 '[' if !in_class => in_class = true,
132 ']' if in_class => in_class = false,
133 '^' if !in_class => return true,
134 _ => {}
135 }
136 }
137 false
138}
139
140fn pattern_has_line_anchor(pattern: &str) -> bool {
141 pattern_has_unescaped_dollar(pattern) || pattern_has_unescaped_caret(pattern)
142}
143
144fn pattern_has_unescaped_dot(pattern: &str) -> bool {
145 let mut escaped = false;
146 let mut in_class = false;
147 for c in pattern.chars() {
148 if escaped {
149 escaped = false;
150 continue;
151 }
152 match c {
153 '\\' => escaped = true,
154 '[' if !in_class => in_class = true,
155 ']' if in_class => in_class = false,
156 '.' if !in_class => return true,
157 _ => {}
158 }
159 }
160 false
161}
162
163fn pattern_has_unescaped_dollar(pattern: &str) -> bool {
164 let mut escaped = false;
165 let mut in_class = false;
166 for c in pattern.chars() {
167 if escaped {
168 escaped = false;
169 continue;
170 }
171 match c {
172 '\\' => escaped = true,
173 '[' if !in_class => in_class = true,
174 ']' if in_class => in_class = false,
175 '$' if !in_class => return true,
176 _ => {}
177 }
178 }
179 false
180}
181
182fn keep_crlf_after_dollar_match(
186 content: &str,
187 from: &str,
188 m: regex::Match<'_>,
189 mut replacement: String,
190) -> String {
191 if !pattern_has_unescaped_dollar(from) {
192 return replacement;
193 }
194 if replacement.ends_with('\r') {
195 return replacement;
196 }
197 if m.end() > m.start()
198 && content.as_bytes()[m.end() - 1] == b'\r'
199 && content.as_bytes().get(m.end()) == Some(&b'\n')
200 {
201 replacement.push('\r');
202 }
203 replacement
204}
205
206#[derive(Debug, Clone, PartialEq, Eq)]
208pub enum ReplaceValidationError {
209 EmptyPattern,
210 NthZero,
211 RangeRequiresWholeLine,
212 WholeLineMultilineConflict,
213 WholeLineInsertConflict,
214 Mode(ReplaceModeError),
215}
216
217impl std::fmt::Display for ReplaceValidationError {
218 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
219 match self {
220 Self::EmptyPattern => write!(f, "replace pattern must not be empty"),
221 Self::NthZero => {
222 write!(
223 f,
224 "nth must be >= 1 (1-based); use nth=1 for the first occurrence"
225 )
226 }
227 Self::RangeRequiresWholeLine => write!(f, "range requires whole_line"),
228 Self::WholeLineMultilineConflict => {
229 write!(f, "whole_line and multiline cannot be combined")
230 }
231 Self::WholeLineInsertConflict => {
232 write!(
233 f,
234 "whole_line cannot be combined with insert_before or insert_after (would drop non-matched line content)"
235 )
236 }
237 Self::Mode(e) => match e {
238 ReplaceModeError::MissingMode => {
241 write!(
242 f,
243 "one of --new, --insert-before, or --insert-after must be provided \
244 (plan fields: new/to, insert_before, insert_after); \
245 replacement text is not positional — use: replace OLD --new NEW path"
246 )
247 }
248 ReplaceModeError::BothInsertModes => {
249 write!(
250 f,
251 "--insert-before and --insert-after cannot be combined \
252 (plan fields: insert_before, insert_after)"
253 )
254 }
255 ReplaceModeError::ToWithInsert => {
256 write!(
257 f,
258 "--new cannot be combined with --insert-before or --insert-after \
259 (plan fields: new/to, insert_before, insert_after)"
260 )
261 }
262 },
263 }
264 }
265}
266
267pub struct ReplaceValidationParams<'a> {
269 pub pattern: &'a str,
270 pub has_to: bool,
271 pub has_insert_before: bool,
272 pub has_insert_after: bool,
273 pub nth: Option<usize>,
274 pub whole_line: bool,
275 pub multiline: bool,
276 pub has_range: bool,
277}
278
279pub fn validate_replace_args(
281 p: &ReplaceValidationParams<'_>,
282) -> Result<(), ReplaceValidationError> {
283 if p.pattern.is_empty() {
284 return Err(ReplaceValidationError::EmptyPattern);
285 }
286 if p.nth == Some(0) {
287 return Err(ReplaceValidationError::NthZero);
288 }
289 if p.has_range && !p.whole_line {
290 return Err(ReplaceValidationError::RangeRequiresWholeLine);
291 }
292 if p.whole_line && p.multiline {
293 return Err(ReplaceValidationError::WholeLineMultilineConflict);
294 }
295 if p.whole_line && (p.has_insert_before || p.has_insert_after) {
296 return Err(ReplaceValidationError::WholeLineInsertConflict);
297 }
298 validate_replace_mode(p.has_to, p.has_insert_before, p.has_insert_after)
299 .map_err(ReplaceValidationError::Mode)
300}
301
302#[derive(Debug, Clone, Copy, PartialEq, Eq)]
304pub enum InsertSide {
305 Before,
306 After,
307}
308
309pub fn normalize_line_insert(
325 file_content: &str,
326 anchor: &str,
327 insert_content: &str,
328 side: InsertSide,
329) -> String {
330 let eol = preferred_line_ending(file_content);
331 match side {
332 InsertSide::After => {
333 if starts_with_line_ending(insert_content) || ends_with_line_ending(anchor) {
334 return insert_content.to_string();
335 }
336 if looks_like_new_line_payload(insert_content)
337 || anchor_is_whole_line(file_content, anchor)
338 {
339 let indent = if insert_content.starts_with([' ', '\t']) {
340 ""
341 } else {
342 indent_before_first_anchor(file_content, anchor)
343 };
344 let payload = strip_one_trailing_eol(insert_content);
345 format!("{eol}{indent}{payload}")
346 } else {
347 insert_content.to_string()
348 }
349 }
350 InsertSide::Before => {
351 if ends_with_line_ending(insert_content) || starts_with_line_ending(anchor) {
352 return insert_content.to_string();
353 }
354 if looks_like_new_line_payload(insert_content)
355 || anchor_is_whole_line(file_content, anchor)
356 {
357 format!("{insert_content}{eol}")
358 } else {
359 insert_content.to_string()
360 }
361 }
362 }
363}
364
365pub fn normalize_line_insert_ci(
369 file_content: &str,
370 anchor: &str,
371 insert_content: &str,
372 side: InsertSide,
373 case_insensitive: bool,
374) -> String {
375 if !case_insensitive {
376 return normalize_line_insert(file_content, anchor, insert_content, side);
377 }
378 let eol = preferred_line_ending(file_content);
379 match side {
380 InsertSide::After => {
381 if starts_with_line_ending(insert_content) || ends_with_line_ending(anchor) {
382 return insert_content.to_string();
383 }
384 if looks_like_new_line_payload(insert_content)
385 || anchor_is_whole_line_ci(file_content, anchor, true)
386 {
387 let indent = if insert_content.starts_with([' ', '\t']) {
388 ""
389 } else {
390 indent_before_first_anchor_ci(file_content, anchor, true)
391 };
392 let payload = strip_one_trailing_eol(insert_content);
393 format!("{eol}{indent}{payload}")
394 } else {
395 insert_content.to_string()
396 }
397 }
398 InsertSide::Before => {
399 if ends_with_line_ending(insert_content) || starts_with_line_ending(anchor) {
400 return insert_content.to_string();
401 }
402 if looks_like_new_line_payload(insert_content)
403 || anchor_is_whole_line_ci(file_content, anchor, true)
404 {
405 format!("{insert_content}{eol}")
406 } else {
407 insert_content.to_string()
408 }
409 }
410 }
411}
412
413pub fn preferred_line_ending(content: &str) -> &'static str {
417 if content.contains("\r\n") {
418 "\r\n"
419 } else if content.contains('\r') {
420 "\r"
421 } else {
422 "\n"
423 }
424}
425
426#[inline]
427fn starts_with_line_ending(s: &str) -> bool {
428 s.starts_with("\r\n") || s.starts_with('\n') || s.starts_with('\r')
429}
430
431#[inline]
432fn ends_with_line_ending(s: &str) -> bool {
433 s.ends_with("\r\n") || s.ends_with('\n') || s.ends_with('\r')
434}
435
436fn strip_one_trailing_eol(s: &str) -> &str {
440 s.strip_suffix("\r\n")
441 .or_else(|| s.strip_suffix('\n'))
442 .or_else(|| s.strip_suffix('\r'))
443 .unwrap_or(s)
444}
445
446fn looks_like_new_line_payload(insert_content: &str) -> bool {
447 let trimmed = insert_content.trim_start_matches([' ', '\t']);
448 insert_content.starts_with([' ', '\t'])
449 || trimmed.starts_with("//")
450 || trimmed.starts_with('#')
451 || insert_content.contains('\n')
452}
453
454pub fn anchor_is_whole_line(file_content: &str, anchor: &str) -> bool {
460 anchor_is_whole_line_ci(file_content, anchor, false)
461}
462
463pub fn anchor_is_whole_line_ci(file_content: &str, anchor: &str, case_insensitive: bool) -> bool {
466 let file_content = crate::ops::file::strip_utf8_bom(file_content);
467 if anchor.is_empty() || file_content.is_empty() {
468 return false;
469 }
470 if !case_insensitive {
471 let bytes = file_content.as_bytes();
472 let mut any = false;
473 for (i, _) in file_content.match_indices(anchor) {
474 any = true;
475 let bol = skip_horiz_back(bytes, i);
478 let before_ok = bol == 0 || is_line_boundary_byte(bytes[bol - 1]);
479 let after = skip_horiz_fwd(bytes, i + anchor.len());
480 let after_ok = after == file_content.len()
481 || bytes.get(after).copied().is_some_and(is_line_boundary_byte);
482 if !(before_ok && after_ok) {
483 return false;
484 }
485 }
486 return any;
487 }
488 let needle = anchor.to_ascii_lowercase();
491 let mut any = false;
492 let mut start = 0usize;
493 let bytes = file_content.as_bytes();
494 while start <= file_content.len() {
495 let rest = &file_content[start..];
496 let line_end = rest
497 .find(['\n', '\r'])
498 .map(|i| start + i)
499 .unwrap_or(file_content.len());
500 let line = &file_content[start..line_end];
501 let trimmed = line.trim_matches([' ', '\t']);
502 if trimmed.to_ascii_lowercase() == needle {
503 any = true;
504 } else if !line.is_empty() {
505 let lower = line.to_ascii_lowercase();
507 if lower.contains(&needle) && trimmed.to_ascii_lowercase() != needle {
508 return false;
509 }
510 }
511 if line_end >= file_content.len() {
512 break;
513 }
514 let mut next = line_end;
516 if file_content[next..].starts_with("\r\n") {
517 next += 2;
518 } else if matches!(bytes.get(next), Some(b'\n' | b'\r')) {
519 next += 1;
520 }
521 start = next;
522 }
523 any
524}
525
526#[inline]
527fn is_line_boundary_byte(b: u8) -> bool {
528 b == b'\n' || b == b'\r'
529}
530
531#[inline]
532fn skip_horiz_back(bytes: &[u8], mut i: usize) -> usize {
533 while i > 0 && (bytes[i - 1] == b' ' || bytes[i - 1] == b'\t') {
534 i -= 1;
535 }
536 i
537}
538
539#[inline]
540fn skip_horiz_fwd(bytes: &[u8], mut i: usize) -> usize {
541 while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') {
542 i += 1;
543 }
544 i
545}
546
547fn indent_before_first_anchor<'a>(file_content: &'a str, anchor: &str) -> &'a str {
553 indent_before_first_anchor_ci(file_content, anchor, false)
554}
555
556fn indent_before_first_anchor_ci<'a>(
557 file_content: &'a str,
558 anchor: &str,
559 case_insensitive: bool,
560) -> &'a str {
561 let i = if case_insensitive {
562 let needle = anchor.to_ascii_lowercase();
563 file_content.to_ascii_lowercase().find(&needle)
564 } else {
565 file_content.find(anchor)
566 };
567 let Some(i) = i else {
568 return "";
569 };
570 line_indent_at(file_content, i)
571}
572
573fn line_indent_at(file_content: &str, match_start: usize) -> &str {
576 let start = leading_line_indent_start(file_content, match_start);
577 let end = skip_horiz_fwd(file_content.as_bytes(), start);
578 &file_content[start..end]
579}
580
581#[derive(Clone, Copy)]
588pub struct ReplacementTextParams<'a> {
589 pub from: &'a str,
590 pub to: &'a Option<String>,
591 pub insert_before: &'a Option<String>,
592 pub insert_after: &'a Option<String>,
593 pub use_match_anchor: bool,
594 pub regex_mode: bool,
595 pub file_content: &'a str,
599 pub case_insensitive: bool,
601}
602
603pub fn build_replacement_text(p: &ReplacementTextParams<'_>) -> String {
608 let anchor = if p.use_match_anchor { "${0}" } else { p.from };
609
610 let needs_escape = p.use_match_anchor && !p.regex_mode;
614
615 if let Some(text) = p.insert_before {
616 let normalized = normalize_line_insert_ci(
619 p.file_content,
620 p.from,
621 text,
622 InsertSide::Before,
623 p.case_insensitive,
624 );
625 let safe = if needs_escape {
626 normalized.replace('$', "$$")
627 } else {
628 normalized
629 };
630 return format!("{safe}{anchor}");
631 }
632
633 if let Some(text) = p.insert_after {
634 let normalized = normalize_line_insert_ci(
635 p.file_content,
636 p.from,
637 text,
638 InsertSide::After,
639 p.case_insensitive,
640 );
641 let safe = if needs_escape {
642 normalized.replace('$', "$$")
643 } else {
644 normalized
645 };
646 return format!("{anchor}{safe}");
647 }
648
649 let raw = p.to.clone().unwrap_or_default();
650 if needs_escape {
651 raw.replace('$', "$$")
652 } else {
653 raw
654 }
655}
656
657pub fn replacement_text(
666 from: &str,
667 to: &Option<String>,
668 insert_before: &Option<String>,
669 insert_after: &Option<String>,
670 use_match_anchor: bool,
671 regex_mode: bool,
672 file_content: &str,
673) -> String {
674 replacement_text_ci(
675 from,
676 to,
677 insert_before,
678 insert_after,
679 use_match_anchor,
680 regex_mode,
681 file_content,
682 false,
683 )
684}
685
686#[allow(clippy::too_many_arguments)]
692pub fn replacement_text_ci(
693 from: &str,
694 to: &Option<String>,
695 insert_before: &Option<String>,
696 insert_after: &Option<String>,
697 use_match_anchor: bool,
698 regex_mode: bool,
699 file_content: &str,
700 case_insensitive: bool,
701) -> String {
702 build_replacement_text(&ReplacementTextParams {
703 from,
704 to,
705 insert_before,
706 insert_after,
707 use_match_anchor,
708 regex_mode,
709 file_content,
710 case_insensitive,
711 })
712}
713
714fn expand_regex_replacement(caps: ®ex::Captures<'_>, replacement: &str) -> String {
715 let mut expanded = String::new();
716 caps.expand(replacement, &mut expanded);
717 expanded
718}
719
720pub fn count_content_matches(content: &str, from: &str, compiled_re: Option<&Regex>) -> usize {
726 let content = crate::ops::file::strip_utf8_bom(content);
727 match compiled_re {
728 Some(re) if pattern_has_line_anchor(from) && !pattern_has_unescaped_dot(from) => {
729 crate::ops::file::text_lines(content)
730 .map(|line| re.find_iter(line).count())
731 .sum()
732 }
733 Some(re) => {
734 let content_len = content.len();
735 re.find_iter(content)
736 .filter(|m| !(m.start() == content_len && m.end() == content_len))
737 .count()
738 }
739 None => {
740 if from.is_empty() {
741 return 0;
742 }
743 content.match_indices(from).count()
744 }
745 }
746}
747
748pub fn count_whole_line_matches(
754 content: &str,
755 from: &str,
756 compiled_re: Option<&Regex>,
757 range: Option<(usize, Option<usize>)>,
758) -> usize {
759 let content = crate::ops::file::strip_utf8_bom(content);
760 crate::ops::file::text_lines(content)
761 .enumerate()
762 .filter(|(i, line)| {
763 let line_num = i + 1;
764 let in_range = match range {
765 Some((start, Some(end))) => line_num >= start && line_num <= end,
766 Some((start, None)) => line_num >= start,
767 None => true,
768 };
769 if !in_range {
770 return false;
771 }
772 if let Some(re) = compiled_re {
773 re.is_match(line)
774 } else if from.is_empty() {
775 false
776 } else {
777 line.contains(from)
778 }
779 })
780 .count()
781}
782
783pub fn count_nth_candidates(
787 content: &str,
788 from: &str,
789 compiled_re: Option<&Regex>,
790 whole_line: bool,
791 range: Option<(usize, Option<usize>)>,
792) -> usize {
793 if whole_line {
794 count_whole_line_matches(content, from, compiled_re, range)
795 } else {
796 count_content_matches(content, from, compiled_re)
797 }
798}
799
800fn apply_with_optional_bom<'a, F>(content: &'a str, f: F) -> (std::borrow::Cow<'a, str>, usize)
803where
804 F: FnOnce(&'a str) -> (std::borrow::Cow<'a, str>, usize),
805{
806 use std::borrow::Cow;
807 let (bom, rest) = crate::ops::file::split_utf8_bom(content);
808 let (out, n) = f(rest);
809 if n == 0 {
810 return (Cow::Borrowed(content), 0);
811 }
812 if bom.is_empty() {
813 return (out, n);
814 }
815 let mut s = String::with_capacity(bom.len() + out.len());
816 s.push_str(bom);
817 s.push_str(out.as_ref());
818 (Cow::Owned(s), n)
819}
820
821pub fn replace_content<'a>(
822 content: &'a str,
823 from: &str,
824 to: &str,
825 compiled_re: Option<&Regex>,
826 nth: Option<usize>,
827) -> (std::borrow::Cow<'a, str>, usize) {
828 use std::borrow::Cow;
829 apply_with_optional_bom(content, |content| {
830 if let Some(re) = compiled_re
831 && pattern_has_line_anchor(from)
832 && !pattern_has_unescaped_dot(from)
833 {
834 return replace_line_anchor_content(content, from, to, re, nth);
835 }
836 match (nth, compiled_re) {
837 (Some(n), Some(re)) => {
838 let content_len = content.len();
839 let mut count = 0usize;
840 let mut result = String::with_capacity(content.len());
841 for caps in re.captures_iter(content) {
842 if let Some(m) = caps.get(0)
847 && m.start() == content_len
848 && m.end() == content_len
849 {
850 continue;
851 }
852 count += 1;
853 if count != n {
854 continue;
855 }
856 let Some(m) = caps.get(0) else {
857 return (Cow::Borrowed(content), 0);
858 };
859 result.push_str(&content[..m.start()]);
860 result.push_str(&keep_crlf_after_dollar_match(
861 content,
862 from,
863 m,
864 expand_regex_replacement(&caps, to),
865 ));
866 result.push_str(&content[m.end()..]);
867 return (Cow::Owned(result), 1);
868 }
869 (Cow::Borrowed(content), 0)
870 }
871 (Some(n), None) => {
872 let mut count = 0usize;
873 let mut result = String::with_capacity(content.len());
874 for (start, _) in content.match_indices(from) {
875 count += 1;
876 if count != n {
877 continue;
878 }
879
880 result.push_str(&content[..start]);
881 result.push_str(to);
882 result.push_str(&content[start + from.len()..]);
883 return (Cow::Owned(result), 1);
884 }
885 (Cow::Borrowed(content), 0)
886 }
887 (None, Some(re)) => {
888 let content_len = content.len();
889 let mut count = 0usize;
890 let replaced = re.replace_all(content, |caps: ®ex::Captures| {
891 if let Some(m) = caps.get(0)
896 && m.start() == content_len
897 && m.end() == content_len
898 {
899 return String::new();
900 }
901 count += 1;
902 let repl = expand_regex_replacement(caps, to);
903 match caps.get(0) {
904 Some(m) => keep_crlf_after_dollar_match(content, from, m, repl),
905 None => repl,
906 }
907 });
908 match replaced {
909 Cow::Borrowed(_) => (Cow::Borrowed(content), 0),
910 Cow::Owned(s) => (Cow::Owned(s), count),
911 }
912 }
913 (None, None) => {
914 debug_assert!(!from.is_empty(), "replace_content called with empty `from`");
917 let finder = memchr::memmem::Finder::new(from.as_bytes());
918 let bytes = content.as_bytes();
919 let mut result = String::with_capacity(content.len());
920 let mut count = 0usize;
921 let mut last = 0;
922 while let Some(pos) = finder.find(&bytes[last..]) {
923 let abs = last + pos;
924 result.push_str(&content[last..abs]);
925 result.push_str(to);
926 last = abs + from.len();
927 count += 1;
928 }
929 if count == 0 {
930 return (Cow::Borrowed(content), 0);
931 }
932 result.push_str(&content[last..]);
933 (Cow::Owned(result), count)
934 }
935 }
936 })
937}
938
939fn replace_line_anchor_content<'a>(
940 content: &'a str,
941 from: &str,
942 to: &str,
943 re: &Regex,
944 nth: Option<usize>,
945) -> (std::borrow::Cow<'a, str>, usize) {
946 use std::borrow::Cow;
947 let parts: Vec<_> = crate::ops::file::text_lines_with_endings(content).collect();
948 if parts.is_empty() {
949 return (Cow::Borrowed(content), 0);
950 }
951 let n_parts = parts.len();
952 let mut out = String::with_capacity(content.len());
953 let mut total = 0usize;
954 let mut seen = 0usize;
955 for (i, (line, ending)) in parts.iter().enumerate() {
956 let mut line_out = String::with_capacity(line.len());
957 let mut last = 0usize;
958 let mut n_this = 0usize;
959 for caps in re.captures_iter(line) {
960 let Some(m) = caps.get(0) else {
961 continue;
962 };
963 seen += 1;
964 if let Some(want) = nth
965 && seen != want
966 {
967 continue;
968 }
969 n_this += 1;
970 line_out.push_str(&line[last..m.start()]);
971 line_out.push_str(&expand_regex_replacement(&caps, to));
972 last = m.end();
973 if nth.is_some() {
974 break;
975 }
976 }
977 if n_this == 0 {
978 out.push_str(line);
979 } else {
980 line_out.push_str(&line[last..]);
981 out.push_str(&line_out);
982 }
983 total += n_this;
984 let drop_eos_cr =
985 i + 1 == n_parts && *ending == "\r" && n_this > 0 && pattern_has_unescaped_dollar(from);
986 if !drop_eos_cr {
987 out.push_str(ending);
988 }
989 }
990 if total == 0 {
991 (Cow::Borrowed(content), 0)
992 } else {
993 (Cow::Owned(out), total)
994 }
995}
996
997pub fn leading_line_indent_start(content: &str, start: usize) -> usize {
1000 let bytes = content.as_bytes();
1001 let mut i = start;
1002 while i > 0 && (bytes[i - 1] == b' ' || bytes[i - 1] == b'\t') {
1003 i -= 1;
1004 }
1005 if i == 0 || matches!(bytes[i - 1], b'\n' | b'\r') {
1006 i
1007 } else {
1008 start
1009 }
1010}
1011
1012fn insert_before_is_line_oriented(
1013 file_content: &str,
1014 anchor: &str,
1015 insert: &str,
1016 case_insensitive: bool,
1017) -> bool {
1018 if ends_with_line_ending(insert) || starts_with_line_ending(anchor) {
1019 return looks_like_new_line_payload(insert)
1020 || anchor_is_whole_line_ci(file_content, anchor, case_insensitive);
1021 }
1022 looks_like_new_line_payload(insert)
1023 || anchor_is_whole_line_ci(file_content, anchor, case_insensitive)
1024}
1025
1026pub fn replace_insert_before<'a>(
1032 content: &'a str,
1033 from: &str,
1034 insert: &str,
1035 compiled_re: Option<&Regex>,
1036 nth: Option<usize>,
1037 case_insensitive: bool,
1038) -> (std::borrow::Cow<'a, str>, usize) {
1039 apply_with_optional_bom(content, |content| {
1040 replace_insert_before_body(content, from, insert, compiled_re, nth, case_insensitive)
1041 })
1042}
1043
1044fn replace_insert_before_body<'a>(
1045 content: &'a str,
1046 from: &str,
1047 insert: &str,
1048 compiled_re: Option<&Regex>,
1049 nth: Option<usize>,
1050 case_insensitive: bool,
1051) -> (std::borrow::Cow<'a, str>, usize) {
1052 use std::borrow::Cow;
1053
1054 struct Hit {
1055 start: usize,
1056 end: usize,
1057 }
1058
1059 let mut hits: Vec<Hit> = Vec::new();
1060 if let Some(re) = compiled_re {
1061 let content_len = content.len();
1062 for caps in re.captures_iter(content) {
1063 let Some(m) = caps.get(0) else {
1064 continue;
1065 };
1066 if m.start() == content_len && m.end() == content_len {
1067 continue;
1068 }
1069 hits.push(Hit {
1070 start: m.start(),
1071 end: m.end(),
1072 });
1073 }
1074 } else if !from.is_empty() {
1075 for (start, _) in content.match_indices(from) {
1076 hits.push(Hit {
1077 start,
1078 end: start + from.len(),
1079 });
1080 }
1081 }
1082
1083 let selected: Vec<Hit> = if let Some(n) = nth {
1084 hits.into_iter()
1085 .nth(n.saturating_sub(1))
1086 .into_iter()
1087 .collect()
1088 } else {
1089 hits
1090 };
1091 if selected.is_empty() {
1092 return (Cow::Borrowed(content), 0);
1093 }
1094
1095 let count = selected.len();
1096 let mut out = content.to_string();
1097 for hit in selected.into_iter().rev() {
1098 let matched = out[hit.start..hit.end].to_string();
1099 let line_oriented = insert_before_is_line_oriented(&out, from, insert, case_insensitive);
1103 let (span_start, replacement) = if line_oriented {
1104 let indent_start = leading_line_indent_start(&out, hit.start);
1105 let indent = line_indent_at(&out, hit.start).to_string();
1106 let normalized =
1108 normalize_line_insert_ci(&out, from, insert, InsertSide::Before, case_insensitive);
1109 let insert_out = if insert.starts_with([' ', '\t']) {
1110 normalized
1111 } else {
1112 format!("{indent}{normalized}")
1113 };
1114 let keep_indent = if hit.start == indent_start {
1117 ""
1118 } else {
1119 indent.as_str()
1120 };
1121 (indent_start, format!("{insert_out}{keep_indent}{matched}"))
1122 } else {
1123 (hit.start, format!("{insert}{matched}"))
1124 };
1125 out.replace_range(span_start..hit.end, &replacement);
1126 }
1127 (Cow::Owned(out), count)
1128}
1129
1130fn context_fragment_score(content_fragment: &str, ctx_fragment: &str) -> f64 {
1137 let a = content_fragment.trim();
1138 let b = ctx_fragment.trim();
1139 if b.is_empty() {
1140 return 0.0;
1141 }
1142 let jw = strsim::jaro_winkler(a, b);
1143 if b.len() >= 2 && a.contains(b) {
1146 jw.max(1.0)
1147 } else {
1148 jw
1149 }
1150}
1151
1152pub fn expand_match_anchor_template(template: &str, matched: &str) -> String {
1171 let Ok(re) = Regex::new(&format!("^{}$", regex::escape(matched))) else {
1173 return template.replace("${0}", matched);
1174 };
1175 match re.captures(matched) {
1176 Some(caps) => expand_regex_replacement(&caps, template),
1177 None => template.replace("${0}", matched),
1178 }
1179}
1180
1181pub fn context_filtered_span(
1184 content: &str,
1185 matches: &[(usize, usize)],
1186 old_for_line_count: &str,
1187 before_context: Option<&str>,
1188 after_context: Option<&str>,
1189) -> Option<(usize, usize)> {
1190 if before_context.is_none() && after_context.is_none() {
1191 return None;
1192 }
1193 if matches.len() < 2 {
1194 return None;
1195 }
1196
1197 let lines: Vec<&str> = content.lines().collect();
1198 let mut line_starts: Vec<usize> = Vec::with_capacity(lines.len());
1199 let mut off = 0;
1200 for line in &lines {
1201 line_starts.push(off);
1202 off += line.len();
1203 if content.as_bytes().get(off) == Some(&b'\r') {
1204 off += 1;
1205 }
1206 if content.as_bytes().get(off) == Some(&b'\n') {
1207 off += 1;
1208 }
1209 }
1210
1211 let line_index_at = |byte_offset: usize| -> usize {
1212 match line_starts.binary_search(&byte_offset) {
1213 Ok(idx) => idx,
1214 Err(idx) => idx.saturating_sub(1),
1215 }
1216 };
1217
1218 const MAX_CONTEXT_LINES: usize = 3;
1219 let old_line_count = old_for_line_count.lines().count().max(1);
1220 let single_line_old = old_line_count == 1 && !old_for_line_count.contains('\n');
1221
1222 let mut best: Option<(usize, usize, f64)> = None;
1223 for &(match_off, match_end) in matches {
1224 let match_line = line_index_at(match_off);
1225 let mut score = 0.0f64;
1226 let mut checks = 0u32;
1227
1228 if let Some(before) = before_context {
1229 let ctx_lines: Vec<&str> = before.lines().collect();
1230 let start = ctx_lines.len().saturating_sub(MAX_CONTEXT_LINES);
1231 let ctx_tail = &ctx_lines[start..];
1232 for (i, ctx_line) in ctx_tail.iter().rev().enumerate() {
1233 if i == 0 && single_line_old && match_line < lines.len() {
1234 let line = lines[match_line];
1235 let col = match_off
1236 .saturating_sub(line_starts[match_line])
1237 .min(line.len());
1238 if line.is_char_boundary(col) {
1239 checks += 1;
1240 let sim = context_fragment_score(&line[..col], ctx_line);
1241 if sim >= 0.8 {
1242 score += sim;
1243 }
1244 }
1245 }
1246 let content_idx = match_line.checked_sub(i + 1);
1247 if let Some(ci) = content_idx {
1248 checks += 1;
1249 let sim = context_fragment_score(lines[ci], ctx_line);
1250 if sim >= 0.8 {
1251 score += sim;
1252 }
1253 }
1254 }
1255 }
1256
1257 if let Some(after) = after_context {
1258 let ctx_lines: Vec<&str> = after.lines().collect();
1259 let n = ctx_lines.len().min(MAX_CONTEXT_LINES);
1260 let end_line = match_line + old_line_count;
1261 for (i, ctx_line) in ctx_lines[..n].iter().enumerate() {
1262 if i == 0 && single_line_old && match_line < lines.len() {
1263 let line = lines[match_line];
1264 let col = match_end
1265 .saturating_sub(line_starts[match_line])
1266 .min(line.len());
1267 if line.is_char_boundary(col) {
1268 checks += 1;
1269 let sim = context_fragment_score(&line[col..], ctx_line);
1270 if sim >= 0.8 {
1271 score += sim;
1272 }
1273 }
1274 }
1275 let content_idx = end_line + i;
1276 if content_idx < lines.len() {
1277 checks += 1;
1278 let sim = context_fragment_score(lines[content_idx], ctx_line);
1279 if sim >= 0.8 {
1280 score += sim;
1281 }
1282 }
1283 }
1284 }
1285
1286 if checks > 0 && score > 0.0 && best.is_none_or(|(_, _, s)| score > s) {
1287 best = Some((match_off, match_end, score));
1288 }
1289 }
1290
1291 best.map(|(s, e, _)| (s, e))
1292}
1293
1294pub fn context_filtered_offset(
1295 content: &str,
1296 old: &str,
1297 before_context: Option<&str>,
1298 after_context: Option<&str>,
1299) -> Option<usize> {
1300 context_filtered_offset_with_re(content, old, None, before_context, after_context)
1301}
1302
1303pub fn context_filtered_offset_with_re(
1306 content: &str,
1307 old: &str,
1308 compiled_re: Option<&Regex>,
1309 before_context: Option<&str>,
1310 after_context: Option<&str>,
1311) -> Option<usize> {
1312 context_filtered_span_with_re(content, old, compiled_re, before_context, after_context)
1313 .map(|(s, _)| s)
1314}
1315
1316pub fn context_filtered_span_with_re(
1318 content: &str,
1319 old: &str,
1320 compiled_re: Option<&Regex>,
1321 before_context: Option<&str>,
1322 after_context: Option<&str>,
1323) -> Option<(usize, usize)> {
1324 if before_context.is_none() && after_context.is_none() {
1325 return None;
1326 }
1327
1328 let matches: Vec<(usize, usize)> = match compiled_re {
1329 Some(re) => {
1330 let content_len = content.len();
1331 re.find_iter(content)
1332 .filter(|m| !(m.start() == content_len && m.end() == content_len))
1333 .map(|m| (m.start(), m.end()))
1334 .collect()
1335 }
1336 None => {
1337 if old.is_empty() {
1338 Vec::new()
1339 } else {
1340 content
1341 .match_indices(old)
1342 .map(|(i, s)| (i, i + s.len()))
1343 .collect()
1344 }
1345 }
1346 };
1347 context_filtered_span(content, &matches, old, before_context, after_context)
1348}
1349
1350pub fn replace_whole_lines<'a>(
1357 content: &'a str,
1358 from: &str,
1359 to: &str,
1360 compiled_re: Option<&Regex>,
1361 nth: Option<usize>,
1362 range: Option<(usize, Option<usize>)>,
1363) -> (std::borrow::Cow<'a, str>, usize) {
1364 apply_with_optional_bom(content, |content| {
1365 replace_whole_lines_body(content, from, to, compiled_re, nth, range)
1366 })
1367}
1368
1369fn replace_whole_lines_body<'a>(
1370 content: &'a str,
1371 from: &str,
1372 to: &str,
1373 compiled_re: Option<&Regex>,
1374 nth: Option<usize>,
1375 range: Option<(usize, Option<usize>)>,
1376) -> (std::borrow::Cow<'a, str>, usize) {
1377 use std::borrow::Cow;
1378
1379 let mut result = String::with_capacity(content.len());
1380 let mut match_count = 0usize;
1381 let mut rest = content;
1382 let mut line_num = 0usize; while !rest.is_empty() {
1385 line_num += 1;
1386
1387 let rest_bytes = rest.as_bytes();
1389 let (line_content, ending, advance) =
1390 if let Some(pos) = memchr::memchr2(b'\r', b'\n', rest_bytes) {
1391 if rest_bytes[pos] == b'\n' {
1392 (&rest[..pos], "\n", pos + 1)
1393 } else if pos + 1 < rest_bytes.len() && rest_bytes[pos + 1] == b'\n' {
1394 (&rest[..pos], "\r\n", pos + 2)
1395 } else {
1396 (&rest[..pos], "\r", pos + 1)
1397 }
1398 } else {
1399 (rest, "", rest.len())
1400 };
1401 let line_with_ending = &rest[..advance];
1402
1403 let in_range = match range {
1405 Some((start, Some(end))) => line_num >= start && line_num <= end,
1406 Some((start, None)) => line_num >= start,
1407 None => true,
1408 };
1409
1410 if !in_range {
1411 result.push_str(line_with_ending);
1412 rest = &rest[advance..];
1413 continue;
1414 }
1415
1416 let line_match = if let Some(re) = compiled_re {
1418 re.captures(line_content)
1419 } else if line_content.contains(from) {
1420 None } else {
1422 rest = &rest[advance..];
1425 result.push_str(line_with_ending);
1426 continue;
1427 };
1428
1429 let is_literal_match = compiled_re.is_none() && line_content.contains(from);
1431 let has_match = line_match.is_some() || is_literal_match;
1432
1433 if !has_match {
1434 result.push_str(line_with_ending);
1435 rest = &rest[advance..];
1436 continue;
1437 }
1438
1439 match_count += 1;
1440
1441 if let Some(n) = nth
1443 && match_count != n
1444 {
1445 result.push_str(line_with_ending);
1446 rest = &rest[advance..];
1447 continue;
1448 }
1449
1450 if to.is_empty() {
1452 } else if let Some(ref caps) = line_match {
1454 let mut expanded = String::new();
1456 caps.expand(to, &mut expanded);
1457 result.push_str(&expanded);
1458 result.push_str(ending);
1460 } else {
1461 result.push_str(to);
1463 result.push_str(ending);
1464 }
1465
1466 rest = &rest[advance..];
1467 }
1468
1469 let effective_count = if let Some(n) = nth {
1471 if match_count >= n { 1 } else { 0 }
1472 } else {
1473 match_count
1474 };
1475
1476 if effective_count == 0 {
1477 return (Cow::Borrowed(content), 0);
1478 }
1479
1480 (Cow::Owned(result), effective_count)
1481}
1482
1483#[path = "replace_tests.rs"]
1484#[cfg(test)]
1485mod tests;