Skip to main content

patchloom/ops/
replace.rs

1//! Replace content helpers shared by CLI, tx, API, and MCP.
2//!
3//! size-waiver: accepted single-domain bulk (policy #1408). Regex compile,
4//! line-oriented insert normalize, replacement_text params/builders, and
5//! content/whole-line replace co-located; do not split for LOC alone (#2163).
6
7use regex::Regex;
8
9/// Build an optional compiled regex for replace operations.
10///
11/// Returns `Some(Regex)` when regex mode is active or case-insensitive
12/// matching is requested (which requires escaping the literal pattern).
13/// Returns `None` for plain literal, case-sensitive replacements.
14pub 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        // Escape the literal pattern and wrap with \b anchors.
23        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        // `\b(?:end\r?$)\b` fails on CRLF: after `end\r` both CR and LF
35        // are non-word, so the trailing `\b` does not match. Keep
36        // `\b` against the word, then optional CR (#2325).
37        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(&regex::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
83/// Map unescaped `$` (outside `[]`) to `\r?$` so content-mode `$` matches
84/// the same sites as search `str::lines()` (CR stripped). `\$` and `[$]`
85/// stay literal. [`keep_crlf_after_dollar_match`] puts a consumed CR back
86/// so the file stays CRLF (R145 / #2325). The `regex` crate has no look-ahead.
87fn 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            // Optional CR then `$` (before `\n` / EOS). Trailing CR is put
111            // back on the replacement so CRLF files stay CRLF.
112            '$' if !in_class => out.push_str(r"\r?$"),
113            _ => out.push(c),
114        }
115    }
116    out
117}
118
119/// True when `pattern` has an unescaped `$` outside `[]` (same scan as
120/// [`crlf_aware_dollar`]). Restore must not run for `\r` / `.` / `$0`.
121fn 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
182/// `\r?$` eats the CR of CRLF. Put it back so `end$` -> `END` stays `END\r\n`.
183/// Only when the user pattern had unescaped `$`. Skip if the replacement
184/// already ends with CR (`$0` / `${0}` already include it).
185fn 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/// Errors from [`validate_replace_args`].
207#[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                // Name CLI flags first so agents do not retry with --to (#1829).
239                // Plan/MCP still accept field aliases new/to and insert_*.
240                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
267/// Parameters for replace argument validation.
268pub 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
279/// Validate replace arguments. Shared by CLI and MCP entry points.
280pub 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/// Which side of the match an insert lands on (for line-oriented default).
303#[derive(Debug, Clone, Copy, PartialEq, Eq)]
304pub enum InsertSide {
305    Before,
306    After,
307}
308
309/// Normalize an insert payload so agent-style inserts land on their own line.
310///
311/// Default for all insert_before / insert_after paths (#1885). Byte-exact
312/// mid-line inserts (e.g. `"X"` after `"foo"` inside a line) are unchanged.
313///
314/// Rules (from Bline host glue, now product default):
315/// - insert_after: prepend a line ending when the payload looks like a new
316///   line, or every occurrence of `anchor` is alone on its line, unless the
317///   payload already starts with a line ending or the anchor ends with one.
318/// - insert_before: append a line ending under the same conditions (so the
319///   anchor stays on the next line), unless the payload already ends with a
320///   line ending or the anchor starts with one.
321///
322/// The separator matches the file's dominant line ending (CRLF / bare CR /
323/// LF) so Windows-style files do not get mixed LF inserts (fixrealloop).
324pub 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
365/// Like [`normalize_line_insert`], but whole-line detection honors case-insensitive
366/// anchors (CLI `-i` / plan `case_insensitive`) so `Debug` + insert after `debug`
367/// becomes a sibling line, not `Debugnote`.
368pub 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
413/// Dominant line ending in `content` for line-oriented insert separators.
414///
415/// Prefer CRLF when present, else bare CR, else LF. Empty content uses LF.
416pub 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
436/// Drop one trailing EOL so `{eol}{indent}{payload}` does not add a blank line
437/// when the payload already ends with a newline (Morph snippets, `--fragment`
438/// from a file). The file's original terminator after the splice stays.
439fn 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
454/// True when every occurrence of `anchor` is alone on its line (bounded by
455/// newlines / file edges). Empty content or empty anchor → false.
456///
457/// Accepts LF, CRLF, and bare CR as line boundaries so whole-line bare
458/// inserts on Windows-style files still line-orient (#1885 follow-up).
459pub fn anchor_is_whole_line(file_content: &str, anchor: &str) -> bool {
460    anchor_is_whole_line_ci(file_content, anchor, false)
461}
462
463/// Like [`anchor_is_whole_line`]; when `case_insensitive`, compare with
464/// ASCII case folding so `-i debug` matches a whole line `Debug`.
465pub 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            // Indent / trailing spaces on the same line still count as whole-line
476            // (#2200): `    let x = 1;` is the line for anchor `let x = 1;`.
477            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    // Case-insensitive: scan line contents (ASCII fold). Non-ASCII case
489    // folding is not required for typical agent/CLI patterns.
490    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            // Mid-line occurrence of the pattern is not whole-line.
506            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        // Advance past one line ending (CRLF, LF, or bare CR).
515        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
547/// Horizontal indent of the line that contains the first `anchor` match.
548///
549/// Uses the line indent even when `anchor` itself starts with those spaces
550/// (copy-paste of `    let x = 1;` as `--after` / `--before`). Mid-line
551/// matches still return empty.
552fn 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
573/// Indent of the line containing `match_start`, or empty when the match is
574/// mid-line (not preceded only by spaces/tabs back to a line boundary).
575fn 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/// Parameters for [`build_replacement_text`].
582///
583/// Preferred call shape for in-crate and new callers (avoids
584/// `clippy::too_many_arguments`). The multi-arg [`replacement_text`] /
585/// [`replacement_text_ci`] entry points remain for public API stability
586/// (semver: arity must not change on a patch release; see #2163 follow-up).
587#[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    /// File (or buffer) being edited; used for line-oriented insert
596    /// normalization (#1885). Pass `""` only in pure unit tests that do not
597    /// exercise whole-line-anchor detection.
598    pub file_content: &'a str,
599    /// When true, whole-line insert detection is case-insensitive.
600    pub case_insensitive: bool,
601}
602
603/// Build the replacement string for replace / insert modes.
604///
605/// Whole-line insert detection respects [`ReplacementTextParams::case_insensitive`].
606/// Prefer this over the multi-arg wrappers for new call sites.
607pub fn build_replacement_text(p: &ReplacementTextParams<'_>) -> String {
608    let anchor = if p.use_match_anchor { "${0}" } else { p.from };
609
610    // When a regex is compiled internally (case_insensitive / word_boundary)
611    // but the user did NOT request regex mode, dollar signs in user-provided
612    // replacement text must be escaped so caps.expand() treats them literally.
613    let needs_escape = p.use_match_anchor && !p.regex_mode;
614
615    if let Some(text) = p.insert_before {
616        // Normalize against the literal `from` pattern (not ${0}) so whole-line
617        // detection sees the real anchor text in the file.
618        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
657/// Build the replacement string for replace / insert modes.
658///
659/// `file_content` is the file (or buffer) being edited; it is used to decide
660/// line-oriented insert normalization (#1885). Pass `""` only in pure unit
661/// tests that do not exercise whole-line-anchor detection.
662///
663/// Stable multi-arg public API. New call sites should prefer
664/// [`build_replacement_text`] with [`ReplacementTextParams`].
665pub 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/// Like [`replacement_text`] with case-insensitive whole-line insert detection.
687///
688/// Stable multi-arg public API (arity locked for cargo-semver-checks). The
689/// `allow` is intentional for ABI compatibility; prefer
690/// [`build_replacement_text`] + [`ReplacementTextParams`] in new code (#2163).
691#[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: &regex::Captures<'_>, replacement: &str) -> String {
715    let mut expanded = String::new();
716    caps.expand(replacement, &mut expanded);
717    expanded
718}
719
720/// Count non-overlapping matches of `from` / `compiled_re` in `content`.
721///
722/// Used for agent-honest errors when `--nth` is past the last match (the
723/// replace path returns applied count 0, which is otherwise indistinguishable
724/// from a true no-match).
725pub 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
748/// Count lines that match for whole-line replace (one match per matching line).
749///
750/// Differs from [`count_content_matches`] when a line contains the pattern more
751/// than once: whole-line mode still counts that line once. Optional `range` is
752/// 1-based inclusive, matching [`replace_whole_lines`].
753pub 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
783/// Matches available for `--nth` under the active replace mode.
784///
785/// `range` only affects whole-line mode (same as replace).
786pub 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
800/// Run a content rewrite on the file body after a leading UTF-8 BOM.
801/// Search already ignores that BOM for `^`; keep it at byte 0 on write.
802fn 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                    // Skip zero-length matches at the very end of the content.
843                    // With multi_line(true), patterns like ^$ produce a trailing
844                    // match after the final newline that search (line-by-line) does
845                    // not see. Dropping it keeps nth consistent with search.
846                    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: &regex::Captures| {
891                    // Skip zero-length matches at the very end of the content.
892                    // With multi_line(true), patterns like ^$ produce a trailing
893                    // match after the final newline that search (line-by-line) does
894                    // not see. Dropping it keeps replace consistent with search.
895                    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                // Single-pass using SIMD-accelerated memchr::memmem::Finder.
915                // All callers validate `from` is non-empty via validate_replace_args().
916                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
997/// Start of horizontal whitespace before `start` when that whitespace is the
998/// whole indent of the line. Mid-line spaces (e.g. `xx foo`) are not indent.
999pub 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
1026/// Insert `insert` before each match of `from`, keeping line indent on the
1027/// original anchor when the insert is line-oriented (#2187).
1028///
1029/// `fn compute() {}` style `--before 'let x = 1;'` on an indented line must
1030/// not steal the indent onto the new line and leave `let x` at column 0.
1031pub 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        // Whole-line detection uses the search pattern (`from`), not the
1100        // matched text. A regex `b+` that matches a whole line `bbb` must
1101        // stay byte-exact (legacy `Xbbb`), same as build_replacement_text.
1102        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            // Detect whole-line using `from` (the pattern), not `matched`.
1107            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            // When the match already includes the line indent, do not
1115            // prefix it again (copy-paste `--before '    let x = 1;'`).
1116            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
1130/// Score how well a content fragment matches a context fragment.
1131///
1132/// Uses Jaro-Winkler (full-string similarity) and substring containment.
1133/// Agents often pass short anchors (`alpha`, `[cache]`) that appear inside
1134/// longer lines; pure JW of the whole line vs the short token can fall under
1135/// 0.8 even when the anchor is clearly present (fixrealloop 2026-07-15).
1136fn 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    // Containment for short anchors inside longer lines (min 2 chars avoids
1144    // matching single-character noise like "a" / "x" everywhere).
1145    if b.len() >= 2 && a.contains(b) {
1146        jw.max(1.0)
1147    } else {
1148        jw
1149    }
1150}
1151
1152/// Filter multiple exact matches by `before_context` / `after_context`.
1153///
1154/// When the `old` text occurs more than once in `content` and no `nth` is
1155/// specified, this function uses context lines to pick the right occurrence.
1156/// It compares up to 3 lines of context (nearest to the match first) using
1157/// Jaro-Winkler similarity and substring containment (threshold >= 0.8). The
1158/// occurrence with the highest aggregate score wins. Returns the byte offset
1159/// of the winning match, or `None` if no context is provided, only one match
1160/// exists, or all scores are zero.
1161///
1162/// For `before_context`, the last N lines are compared against the N lines
1163/// preceding the match, and (for single-line `old`) the same-line prefix
1164/// before the match is also scored against the nearest context fragment.
1165/// For `after_context`, the first N lines following the match are compared,
1166/// plus the same-line suffix after a single-line match.
1167/// Tie-breaking: first occurrence (lowest byte offset) wins on equal scores.
1168/// Expand a replace template that may contain `${0}` / `$$` (match-anchor form
1169/// used for case_insensitive / word_boundary inserts) against the matched text.
1170pub fn expand_match_anchor_template(template: &str, matched: &str) -> String {
1171    // Reuse regex::Captures::expand so $$ and ${0} match replace_content.
1172    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
1181/// Context disambiguation over precomputed match spans `(start, end)`.
1182/// Prefer this when matches come from a regex (case_insensitive / word_boundary).
1183pub 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
1303/// Like [`context_filtered_offset`], but when `compiled_re` is set uses regex
1304/// match spans (word_boundary / case_insensitive) instead of literal indices.
1305pub 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
1316/// Return `(start, end)` of the context-selected match.
1317pub 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
1350/// Whole-line replacement: when a line matches the pattern, the entire line
1351/// (including its newline) is replaced with `to`. When `to` is empty, the
1352/// line is deleted. Supports optional line-range restriction and nth match.
1353///
1354/// For regex patterns, capture groups in `to` are expanded using the match
1355/// found on each line.
1356pub 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; // 1-based
1383
1384    while !rest.is_empty() {
1385        line_num += 1;
1386
1387        // Find line boundary (\n, \r\n, or bare \r).
1388        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        // Check range restriction.
1404        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        // Check if this line matches the pattern.
1417        let line_match = if let Some(re) = compiled_re {
1418            re.captures(line_content)
1419        } else if line_content.contains(from) {
1420            None // Sentinel: literal match found, no captures.
1421        } else {
1422            // Use a special marker to distinguish "no match" from
1423            // "literal match with no captures".
1424            rest = &rest[advance..];
1425            result.push_str(line_with_ending);
1426            continue;
1427        };
1428
1429        // For literal matches, we set a flag and handle below.
1430        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        // Handle --nth: only act on the Nth occurrence.
1442        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        // Replace the line.
1451        if to.is_empty() {
1452            // Delete the line entirely (don't append anything).
1453        } else if let Some(ref caps) = line_match {
1454            // Regex with captures: expand replacement text.
1455            let mut expanded = String::new();
1456            caps.expand(to, &mut expanded);
1457            result.push_str(&expanded);
1458            // Preserve the original line ending.
1459            result.push_str(ending);
1460        } else {
1461            // Literal match: replacement is used as-is.
1462            result.push_str(to);
1463            result.push_str(ending);
1464        }
1465
1466        rest = &rest[advance..];
1467    }
1468
1469    // Determine actual match count for nth mode.
1470    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;