Skip to main content

hjkl_engine/
substitute.rs

1//! Public substitute command parser and applicator.
2//!
3//! Exposes [`parse_substitute`] and [`apply_substitute`] for the
4//! `:[range]s/pattern/replacement/[flags]` ex command.
5//!
6//! ## Vim compatibility notes (v1 limitations)
7//!
8//! - Delimiter is **always `/`**. Alternate delimiters (`s|x|y|`,
9//!   `s#x#y#`) are not supported. The parser returns an error when the
10//!   first character after the keyword is not `/`.
11//! - The `c` (confirm) flag triggers interactive replacement. Each match
12//!   is presented one-by-one; the user chooses y/n/a/q/l. See
13//!   [`collect_substitute_matches`] and [`apply_collected_matches`].
14//! - The `\v` very-magic mode is not supported. The regex crate uses
15//!   ERE syntax by default. Most ERE patterns work, but vim-specific
16//!   extensions (`\<`, `\>`, `\s`, `\+`) may not. Use POSIX ERE
17//!   equivalents or the `regex` crate's syntax.
18//! - The replacement is kept in raw vim notation and expanded per match by
19//!   [`expand_replacement`]: capture refs (`&`, `\0`…`\9`), case escapes
20//!   (`\u`/`\l`/`\U`/`\L`/`\E`), control chars (`\r`/`\t`/`\n`), and `~` (the
21//!   previous replacement). A plain `$` is literal.
22//! - Flags: `g` (all), `i`/`I` (case), `c` (confirm), `n` (report count only,
23//!   no change), `e` (accepted — hjkl already succeeds on no match),
24//!   `p`/`#`/`l` (print the last changed line, optionally with number /
25//!   `:list`-style — surfaced by the ex layer), and `&` (reuse the previous
26//!   substitute's flags — resolved by the ex layer). A trailing `[count]`
27//!   operates on `count` lines from the range's last line.
28//!
29//! See vim's `:help :substitute` for the full spec.
30
31use regex::Regex;
32
33use crate::Editor;
34
35/// Error type returned by [`parse_substitute`] and [`apply_substitute`].
36pub type SubstError = String;
37
38/// Parsed `:s/pattern/replacement/flags` command.
39///
40/// Produced by [`parse_substitute`]. Pass to [`apply_substitute`].
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct SubstituteCmd {
43    /// The literal pattern string. `None` means "reuse `last_search`
44    /// from the editor" (the user typed `:s//replacement/`).
45    pub pattern: Option<String>,
46    /// The replacement string in **raw vim notation** (`&`, `~`, `\0`…`\9`,
47    /// `\u`/`\U`/`\l`/`\L`/`\E`, `\r`/`\t`/`\n`). Expanded per match by
48    /// [`expand_replacement`]. Empty string deletes the match.
49    pub replacement: String,
50    /// Parsed flags.
51    pub flags: SubstFlags,
52    /// Optional trailing `[count]` (`:s/a/b/g 3`): operate on `count` lines
53    /// starting at the range's last line (vim semantics). `None` = no count.
54    pub count: Option<usize>,
55}
56
57/// Flags for the substitute command.
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
59pub struct SubstFlags {
60    /// `g` — replace all occurrences on each line (default: first only).
61    pub all: bool,
62    /// `i` — case-insensitive (overrides editor `ignorecase`).
63    pub ignore_case: bool,
64    /// `I` — case-sensitive (overrides editor `ignorecase`).
65    pub case_sensitive: bool,
66    /// `c` — confirm mode. When set, [`apply_substitute`] skips all matches
67    /// and the caller must use [`collect_substitute_matches`] +
68    /// [`apply_collected_matches`] for interactive replacement.
69    pub confirm: bool,
70    /// `n` — report the match count only; do not modify the buffer or move
71    /// the cursor. [`apply_substitute`] counts matches and returns without
72    /// mutating.
73    pub report_only: bool,
74    /// `e` — do not treat "pattern not found" as an error. hjkl already
75    /// returns success on no match, so this is accepted for compatibility.
76    pub no_error: bool,
77    /// `p` / `#` / `l` — print the last changed line (optionally with line
78    /// number `#` or `:list`-style `l`). Parsed and accepted; the print
79    /// itself is surfaced by the ex/host layer.
80    pub print: bool,
81    /// `#` — print with line number (implies `print`).
82    pub print_num: bool,
83    /// `l` — print `:list`-style (implies `print`).
84    pub print_list: bool,
85    /// `&` — reuse the flags from the previous substitute (`:h :s_flags`).
86    /// Resolved by the ex handler, which merges the stored `last_substitute`
87    /// flags into this command's before applying.
88    pub reuse_previous: bool,
89}
90
91/// Result of [`apply_substitute`].
92#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
93pub struct SubstituteOutcome {
94    /// Total number of individual replacements made across all lines.
95    pub replacements: usize,
96    /// Number of lines that had at least one replacement.
97    pub lines_changed: usize,
98    /// 0-based row of the last changed line (where the cursor lands). `None`
99    /// when nothing changed. Used by the ex layer to print the line for the
100    /// `p` / `#` / `l` flags.
101    pub last_row: Option<usize>,
102}
103
104/// Parse the tail of a substitute command (everything after the leading
105/// `s` / `substitute` keyword).
106///
107/// # Examples
108///
109/// ```
110/// use hjkl_engine::substitute::parse_substitute;
111///
112/// let cmd = parse_substitute("/foo/bar/gi").unwrap();
113/// assert_eq!(cmd.pattern.as_deref(), Some("foo"));
114/// assert_eq!(cmd.replacement, "bar");
115/// assert!(cmd.flags.all);
116/// assert!(cmd.flags.ignore_case);
117///
118/// // Empty pattern — reuse last_search.
119/// let cmd = parse_substitute("//bar/").unwrap();
120/// assert!(cmd.pattern.is_none());
121/// assert_eq!(cmd.replacement, "bar");
122/// ```
123///
124/// # Errors
125///
126/// Returns an error when:
127/// - `s` is not followed by `/` (no delimiter or alternate delimiter).
128/// - The flag string contains an unknown character.
129/// - The separator `/` is absent (less than two fields).
130pub fn parse_substitute(s: &str) -> Result<SubstituteCmd, SubstError> {
131    // Require leading `/`. Alternate delimiters are out of scope for v1.
132    let rest = s
133        .strip_prefix('/')
134        .ok_or_else(|| format!("substitute: expected '/' delimiter, got {s:?}"))?;
135
136    // Split on unescaped `/`, collecting at most 3 segments:
137    // [pattern, replacement, flags?]
138    let parts = split_on_slash(rest);
139
140    if parts.len() < 2 {
141        return Err("substitute needs /pattern/replacement/".into());
142    }
143
144    let raw_pattern = &parts[0];
145    let raw_replacement = &parts[1];
146    let raw_flags = parts.get(2).map(String::as_str).unwrap_or("");
147
148    // Empty pattern → reuse last_search.
149    let pattern = if raw_pattern.is_empty() {
150        None
151    } else {
152        Some(raw_pattern.clone())
153    };
154
155    // Keep the replacement in raw vim notation; `expand_replacement` resolves
156    // capture refs, case escapes, and `~` per match.
157    let replacement = raw_replacement.clone();
158
159    // The flags segment is `[flag-chars][ optional trailing count]`, e.g.
160    // `g 3`. Parse the leading flag characters, then an optional numeric count.
161    let mut flags = SubstFlags::default();
162    let mut count: Option<usize> = None;
163    let mut chars = raw_flags.chars().peekable();
164    while let Some(&ch) = chars.peek() {
165        match ch {
166            'g' => flags.all = true,
167            'i' => flags.ignore_case = true,
168            'I' => flags.case_sensitive = true,
169            'c' => flags.confirm = true,
170            'n' => flags.report_only = true,
171            'e' => flags.no_error = true,
172            'p' => flags.print = true,
173            '#' => {
174                flags.print = true;
175                flags.print_num = true;
176            }
177            'l' => {
178                flags.print = true;
179                flags.print_list = true;
180            }
181            // `&` — reuse the previous substitute's flags. Resolved by the ex
182            // handler (which holds `last_substitute`).
183            '&' => flags.reuse_previous = true,
184            ' ' | '\t' => {}
185            c if c.is_ascii_digit() => break, // trailing count begins
186            other => return Err(format!("unknown flag '{other}' in substitute")),
187        }
188        chars.next();
189    }
190    // Trailing count: the remainder (after any whitespace) must be a number.
191    let rest: String = chars.collect();
192    let rest = rest.trim();
193    if !rest.is_empty() {
194        match rest.parse::<usize>() {
195            Ok(n) if n > 0 => count = Some(n),
196            _ => return Err(format!("trailing characters in substitute: {rest:?}")),
197        }
198    }
199
200    Ok(SubstituteCmd {
201        pattern,
202        replacement,
203        flags,
204        count,
205    })
206}
207
208/// Apply a parsed substitute command to `line_range` (0-based inclusive)
209/// in the editor's buffer.
210///
211/// # Pattern resolution
212///
213/// If `cmd.pattern` is `None` (user typed `:s//rep/`), the editor's
214/// `last_search()` is used. Returns an error with `"no previous regular
215/// expression"` when both are empty.
216///
217/// # Case-sensitivity precedence
218///
219/// `flags.case_sensitive` wins over `flags.ignore_case`, which wins over
220/// the editor's `settings().ignore_case`.
221///
222/// # Cursor
223///
224/// After a successful substitution the cursor is placed on the first
225/// non-blank of the **last line that changed**, matching vim semantics. When
226/// no replacements are made the cursor is left unchanged.
227///
228/// # Undo
229///
230/// One undo snapshot is pushed before the first edit. If no replacements
231/// occur the snapshot is popped so the undo stack stays clean.
232///
233/// # Errors
234///
235/// Returns an error when pattern resolution fails or the regex is invalid.
236pub fn apply_substitute<H: crate::types::Host>(
237    ed: &mut Editor<hjkl_buffer::View, H>,
238    cmd: &SubstituteCmd,
239    line_range: std::ops::RangeInclusive<u32>,
240) -> Result<SubstituteOutcome, SubstError> {
241    // Resolve pattern.
242    let pattern_str: String = match &cmd.pattern {
243        Some(p) => p.clone(),
244        None => ed
245            .last_search()
246            .ok_or_else(|| "no previous regular expression".to_string())?,
247    };
248
249    // Case-sensitivity.
250    // Per-substitute `/I` (case-sensitive) and `/i` (case-insensitive) flags
251    // short-circuit all other resolution — they win over `\c`/`\C` in the
252    // pattern (matching vim's documented precedence: flag > inline override).
253    let effective_pattern = if cmd.flags.case_sensitive {
254        // /I flag: force case-sensitive — run vim_to_rust_regex to strip \c/\C
255        // but do NOT add (?i).
256        use crate::search::{CaseMode, resolve_case_mode};
257        let (stripped, _) = resolve_case_mode(&pattern_str, CaseMode::Sensitive);
258        stripped
259    } else if cmd.flags.ignore_case {
260        // /i flag: force case-insensitive — strip \c/\C and prepend (?i).
261        use crate::search::{CaseMode, resolve_case_mode};
262        let (stripped, _) = resolve_case_mode(&pattern_str, CaseMode::Sensitive);
263        format!("(?i){stripped}")
264    } else {
265        // No explicit flag: honour ignorecase + smartcase + inline \c/\C.
266        use crate::search::{CaseMode, resolve_case_mode};
267        let base = CaseMode::from_options(ed.settings().ignore_case, ed.settings().smartcase);
268        let (stripped, mode) = resolve_case_mode(&pattern_str, base);
269        if mode == CaseMode::Insensitive {
270            format!("(?i){stripped}")
271        } else {
272            stripped
273        }
274    };
275
276    let regex = Regex::new(&effective_pattern).map_err(|e| format!("bad pattern: {e}"))?;
277
278    // `~` in the replacement expands to the previous substitute's replacement,
279    // which is the currently-stored `last_substitute` (this command is stored
280    // only after it succeeds).
281    let prev_replacement = ed
282        .last_substitute()
283        .map(|c| c.replacement.clone())
284        .unwrap_or_default();
285
286    ed.push_undo();
287
288    let start = *line_range.start() as usize;
289    let end = *line_range.end() as usize;
290    let rope = crate::types::Query::rope(ed.buffer());
291    let total = rope.len_lines();
292
293    let clamp_end = end.min(total.saturating_sub(1));
294    let mut new_lines: Vec<String> = crate::rope_util::rope_to_lines_vec(&rope);
295    let mut replacements = 0usize;
296    let mut lines_changed = 0usize;
297    let mut last_changed_row = 0usize;
298
299    if start <= clamp_end {
300        for (row, line) in new_lines[start..=clamp_end].iter_mut().enumerate() {
301            let (replaced, n) = do_replace(
302                &regex,
303                line,
304                &cmd.replacement,
305                &prev_replacement,
306                cmd.flags.all,
307            );
308            if n > 0 {
309                *line = replaced;
310                replacements += n;
311                lines_changed += 1;
312                last_changed_row = start + row;
313            }
314        }
315    }
316
317    if replacements == 0 {
318        ed.pop_last_undo();
319        return Ok(SubstituteOutcome {
320            replacements: 0,
321            lines_changed: 0,
322            last_row: None,
323        });
324    }
325
326    // `n` flag: report the match count without touching the buffer or cursor.
327    // Still refresh `last_search` so `n`/`N` can repeat the pattern.
328    if cmd.flags.report_only {
329        ed.pop_last_undo();
330        ed.set_last_search(Some(pattern_str), true);
331        return Ok(SubstituteOutcome {
332            replacements,
333            lines_changed,
334            last_row: None,
335        });
336    }
337
338    // `last_changed_row` above is a PRE-split row index into `new_lines`: it
339    // counts one entry per original row, even though a `\r`/newline in the
340    // replacement can turn one entry into several physical rows once joined
341    // and re-split by the buffer. Map it into POST-split row space before
342    // placing the cursor: earlier rows may have grown (shifting this row's
343    // start down), and this row's own replacement may itself have split into
344    // multiple physical lines — vim lands on the LAST of those.
345    let newlines_before: usize = new_lines[..last_changed_row]
346        .iter()
347        .map(|l| l.matches('\n').count())
348        .sum();
349    let newlines_within = new_lines[last_changed_row].matches('\n').count();
350    let last_changed_row = last_changed_row + newlines_before + newlines_within;
351
352    // Apply the new content in one shot.
353    ed.buffer_mut().replace_all(&new_lines.join("\n"));
354
355    // Cursor lands on the first non-blank of the last changed line (vim). Clamp
356    // the row defensively in case of any off-by-one at buffer edges.
357    let final_total = crate::types::Query::rope(ed.buffer()).len_lines();
358    let cursor_row = last_changed_row.min(final_total.saturating_sub(1));
359    let first_non_blank = crate::buf_helpers::buf_line(ed.buffer(), cursor_row)
360        .unwrap_or_default()
361        .chars()
362        .take_while(|c| *c == ' ' || *c == '\t')
363        .count();
364    let line_len = crate::buf_helpers::buf_line(ed.buffer(), cursor_row)
365        .unwrap_or_default()
366        .chars()
367        .count();
368    let cursor_col = first_non_blank.min(line_len.saturating_sub(1));
369    ed.buffer_mut()
370        .set_cursor(hjkl_buffer::Position::new(cursor_row, cursor_col));
371
372    ed.mark_content_dirty();
373
374    // Update last_search so n/N can repeat the same pattern.
375    ed.set_last_search(Some(pattern_str), true);
376
377    Ok(SubstituteOutcome {
378        replacements,
379        lines_changed,
380        last_row: Some(cursor_row),
381    })
382}
383
384/// A single candidate match discovered by [`collect_substitute_matches`].
385///
386/// Positions are 0-based byte offsets within their line. The `replacement`
387/// field already has all capture-group references expanded (e.g. `$1`) to
388/// their literal values so the caller can display it and apply without
389/// running the regex again.
390#[derive(Debug, Clone, PartialEq, Eq)]
391pub struct SubstituteMatch {
392    /// 0-based row index in the buffer.
393    pub row: u32,
394    /// Byte offset of the first byte of the match within that row's text.
395    pub byte_start: u32,
396    /// Byte offset one past the last byte of the match (exclusive).
397    pub byte_end: u32,
398    /// The literal replacement string (captures expanded).
399    pub replacement: String,
400}
401
402/// Collect all candidate matches for a `:s/pat/rep/[gc]` command without
403/// mutating the buffer.
404///
405/// Uses the same pattern-resolution and case-sensitivity logic as
406/// [`apply_substitute`]. The returned vec is in document order (low row +
407/// low byte first). Each entry's `replacement` has capture groups already
408/// expanded so the caller can display it without re-running the regex.
409///
410/// # Errors
411///
412/// Returns an error when pattern resolution fails or the regex is invalid.
413pub fn collect_substitute_matches<H: crate::types::Host>(
414    ed: &crate::Editor<hjkl_buffer::View, H>,
415    cmd: &SubstituteCmd,
416    line_range: std::ops::RangeInclusive<u32>,
417) -> Result<Vec<SubstituteMatch>, SubstError> {
418    // Resolve pattern — same logic as apply_substitute.
419    let pattern_str: String = match &cmd.pattern {
420        Some(p) => p.clone(),
421        None => ed
422            .last_search()
423            .ok_or_else(|| "no previous regular expression".to_string())?,
424    };
425
426    let effective_pattern = if cmd.flags.case_sensitive {
427        use crate::search::{CaseMode, resolve_case_mode};
428        let (stripped, _) = resolve_case_mode(&pattern_str, CaseMode::Sensitive);
429        stripped
430    } else if cmd.flags.ignore_case {
431        use crate::search::{CaseMode, resolve_case_mode};
432        let (stripped, _) = resolve_case_mode(&pattern_str, CaseMode::Sensitive);
433        format!("(?i){stripped}")
434    } else {
435        use crate::search::{CaseMode, resolve_case_mode};
436        let base = CaseMode::from_options(ed.settings().ignore_case, ed.settings().smartcase);
437        let (stripped, mode) = resolve_case_mode(&pattern_str, base);
438        if mode == CaseMode::Insensitive {
439            format!("(?i){stripped}")
440        } else {
441            stripped
442        }
443    };
444
445    let regex = Regex::new(&effective_pattern).map_err(|e| format!("bad pattern: {e}"))?;
446
447    let prev_replacement = ed
448        .last_substitute()
449        .map(|c| c.replacement.clone())
450        .unwrap_or_default();
451
452    let start = *line_range.start() as usize;
453    let end = *line_range.end() as usize;
454    let rope = crate::types::Query::rope(ed.buffer());
455    let total = rope.len_lines();
456    let clamp_end = end.min(total.saturating_sub(1));
457
458    let mut matches: Vec<SubstituteMatch> = Vec::new();
459
460    // Expand the raw vim replacement against the match at `m.start()`. Capture
461    // against the whole line (not the isolated substring) so anchors /
462    // lookaround keep their context and group expansion matches what was found.
463    let expand = |line: &str, start: usize| {
464        regex
465            .captures_at(line, start)
466            .map(|caps| expand_replacement(&cmd.replacement, &caps, &prev_replacement))
467            .unwrap_or_default()
468    };
469
470    if start <= clamp_end {
471        for row in start..=clamp_end {
472            let line = hjkl_buffer::rope_line_str(&rope, row);
473            // Strip trailing newline so byte offsets refer to printable content.
474            let line = line.trim_end_matches('\n');
475
476            if cmd.flags.all {
477                for m in regex.find_iter(line) {
478                    matches.push(SubstituteMatch {
479                        row: row as u32,
480                        byte_start: m.start() as u32,
481                        byte_end: m.end() as u32,
482                        replacement: expand(line, m.start()),
483                    });
484                }
485            } else if let Some(m) = regex.find(line) {
486                // First match per line only.
487                matches.push(SubstituteMatch {
488                    row: row as u32,
489                    byte_start: m.start() as u32,
490                    byte_end: m.end() as u32,
491                    replacement: expand(line, m.start()),
492                });
493            }
494        }
495    }
496
497    Ok(matches)
498}
499
500/// Apply a subset of matches collected by [`collect_substitute_matches`].
501///
502/// Applies the matches in REVERSE document order (high row → low row, and
503/// within a row high byte → low byte) so earlier byte offsets remain valid
504/// after each replacement. Only matches for which the corresponding
505/// `accepted` entry is `true` are written; all others are skipped.
506///
507/// Returns the number of replacements actually applied.
508///
509/// # Panics
510///
511/// Panics when `accepted.len() != matches.len()`.
512pub fn apply_collected_matches<H: crate::types::Host>(
513    ed: &mut crate::Editor<hjkl_buffer::View, H>,
514    matches: &[SubstituteMatch],
515    accepted: &[bool],
516) -> usize {
517    assert_eq!(
518        matches.len(),
519        accepted.len(),
520        "apply_collected_matches: accepted.len() must equal matches.len()"
521    );
522
523    // Collect accepted matches and sort reverse — high row first, high
524    // byte_start first within the same row.
525    let mut to_apply: Vec<&SubstituteMatch> = matches
526        .iter()
527        .zip(accepted.iter())
528        .filter_map(|(m, &ok)| if ok { Some(m) } else { None })
529        .collect();
530
531    if to_apply.is_empty() {
532        return 0;
533    }
534
535    to_apply.sort_unstable_by(|a, b| b.row.cmp(&a.row).then(b.byte_start.cmp(&a.byte_start)));
536
537    let rope = crate::types::Query::rope(ed.buffer());
538    let mut lines_vec: Vec<String> = crate::rope_util::rope_to_lines_vec(&rope);
539    let mut applied = 0usize;
540    let mut last_changed_row: Option<usize> = None;
541
542    for sm in &to_apply {
543        let row = sm.row as usize;
544        if row >= lines_vec.len() {
545            continue;
546        }
547        let line = &lines_vec[row];
548        let bs = sm.byte_start as usize;
549        let be = sm.byte_end as usize;
550        if be > line.len() || bs > be {
551            continue;
552        }
553        // Stale matches (buffer changed between collect and apply) can land
554        // mid-char on multibyte text; skip instead of panicking on the slice.
555        if !line.is_char_boundary(bs) || !line.is_char_boundary(be) {
556            continue;
557        }
558        // Splice the replacement in.
559        let mut new_line = String::with_capacity(line.len() + sm.replacement.len());
560        new_line.push_str(&line[..bs]);
561        new_line.push_str(&sm.replacement);
562        new_line.push_str(&line[be..]);
563        lines_vec[row] = new_line;
564        applied += 1;
565        // Matches are applied high-row-first (reverse document order) so
566        // earlier byte offsets stay valid; track the HIGHEST row touched so
567        // the cursor lands on the last-in-document-order changed line (vim),
568        // not merely the last one processed by this loop.
569        last_changed_row = Some(last_changed_row.map_or(row, |lr: usize| lr.max(row)));
570    }
571
572    if applied > 0 {
573        ed.buffer_mut().replace_all(&lines_vec.join("\n"));
574        if let Some(row) = last_changed_row {
575            // `row` is a PRE-split index into `lines_vec`: a `\r`/newline in
576            // an accepted replacement can turn one entry into several
577            // physical rows once joined and re-split by the buffer. Map into
578            // POST-split row space the same way `apply_substitute` does.
579            let newlines_before: usize = lines_vec[..row]
580                .iter()
581                .map(|l| l.matches('\n').count())
582                .sum();
583            let newlines_within = lines_vec[row].matches('\n').count();
584            let row = row + newlines_before + newlines_within;
585            ed.buffer_mut()
586                .set_cursor(hjkl_buffer::Position::new(row, 0));
587        }
588        ed.mark_content_dirty();
589    }
590
591    applied
592}
593
594/// Split `s` on unescaped `/`. Each `\/` in `s` becomes a literal `/`
595/// in the output segment. Other `\x` sequences pass through unchanged
596/// (so regex escape syntax survives).
597///
598/// Returns at most 3 segments: `[pattern, replacement, flags]`. Anything
599/// after the third `/` is absorbed into the flags segment.
600fn split_on_slash(s: &str) -> Vec<String> {
601    let mut out: Vec<String> = Vec::new();
602    let mut cur = String::new();
603    let mut chars = s.chars().peekable();
604    while let Some(c) = chars.next() {
605        if c == '\\' {
606            match chars.peek() {
607                Some(&'/') => {
608                    // Escaped delimiter → literal slash in this segment.
609                    cur.push('/');
610                    chars.next();
611                }
612                Some(_) => {
613                    // Any other escape: preserve both chars so regex
614                    // syntax (\d, \s, \1, \n …) survives.
615                    let next = chars.next().unwrap();
616                    cur.push('\\');
617                    cur.push(next);
618                }
619                None => cur.push('\\'),
620            }
621        } else if c == '/' {
622            if out.len() < 2 {
623                out.push(std::mem::take(&mut cur));
624            } else {
625                // Third delimiter found: treat rest as flags.
626                // Everything up to this point was the replacement;
627                // collect the flags into `cur` and break.
628                cur.push(c);
629                // Keep going to collect remaining chars as flags.
630                // (Actually we already consumed the `/`, so just let
631                // the outer loop continue accumulating into cur.)
632            }
633        } else {
634            cur.push(c);
635        }
636    }
637    out.push(cur);
638    out
639}
640
641/// Active case transformation while expanding a replacement.
642#[derive(Clone, Copy, PartialEq)]
643enum CaseState {
644    None,
645    /// `\u` — uppercase the next char, then reset.
646    OneUpper,
647    /// `\l` — lowercase the next char, then reset.
648    OneLower,
649    /// `\U` — uppercase until `\E`.
650    AllUpper,
651    /// `\L` — lowercase until `\E`.
652    AllLower,
653}
654
655/// Push `ch` into `out`, applying (and, for the one-shot modes, consuming) the
656/// active case state.
657fn push_cased(out: &mut String, case: &mut CaseState, ch: char) {
658    match *case {
659        CaseState::None => out.push(ch),
660        CaseState::OneUpper => {
661            out.extend(ch.to_uppercase());
662            *case = CaseState::None;
663        }
664        CaseState::OneLower => {
665            out.extend(ch.to_lowercase());
666            *case = CaseState::None;
667        }
668        CaseState::AllUpper => out.extend(ch.to_uppercase()),
669        CaseState::AllLower => out.extend(ch.to_lowercase()),
670    }
671}
672
673/// Expand a raw vim replacement string against a single regex match.
674///
675/// Handles vim's `:h sub-replace-special` tokens:
676/// - `&` / `\0` — whole match; `\1`…`\9` — capture groups; `\&` — literal `&`.
677/// - `\r` — line break, `\t` — tab, `\n` — NUL.
678/// - `\u`/`\l` — upper/lowercase the next char; `\U`/`\L` … `\E`/`\e` — upper/
679///   lowercase a run.
680/// - `~` — the previous replacement string (`prev`), re-expanded against this
681///   match; `\~` — literal `~`.
682/// - `\\` — literal backslash; any other `\x` — literal `x`.
683///
684/// A plain `$` is literal (unlike the regex crate's `$`-expansion, which this
685/// deliberately does not use).
686fn expand_replacement(raw: &str, caps: &regex::Captures, prev: &str) -> String {
687    let mut out = String::with_capacity(raw.len() + 8);
688    expand_into(&mut out, raw, caps, prev, true);
689    out
690}
691
692fn expand_into(out: &mut String, raw: &str, caps: &regex::Captures, prev: &str, allow_tilde: bool) {
693    let mut case = CaseState::None;
694    let mut chars = raw.chars();
695    while let Some(c) = chars.next() {
696        match c {
697            '&' => {
698                let g = caps.get(0).map(|m| m.as_str()).unwrap_or("");
699                for ch in g.chars() {
700                    push_cased(out, &mut case, ch);
701                }
702            }
703            '~' if allow_tilde => {
704                // Previous replacement, re-expanded against this match. A `~`
705                // nested inside `prev` is treated literally to avoid recursion.
706                let mut tmp = String::new();
707                expand_into(&mut tmp, prev, caps, "", false);
708                for ch in tmp.chars() {
709                    push_cased(out, &mut case, ch);
710                }
711            }
712            '\\' => match chars.next() {
713                Some('&') => push_cased(out, &mut case, '&'),
714                Some('~') => push_cased(out, &mut case, '~'),
715                Some('\\') => push_cased(out, &mut case, '\\'),
716                // Control chars ignore case state (nothing to case).
717                Some('r') => out.push('\n'),
718                Some('t') => out.push('\t'),
719                Some('n') => out.push('\0'),
720                Some(d @ '0'..='9') => {
721                    let idx = d as usize - '0' as usize;
722                    let g = caps.get(idx).map(|m| m.as_str()).unwrap_or("");
723                    for ch in g.chars() {
724                        push_cased(out, &mut case, ch);
725                    }
726                }
727                Some('u') => case = CaseState::OneUpper,
728                Some('l') => case = CaseState::OneLower,
729                Some('U') => case = CaseState::AllUpper,
730                Some('L') => case = CaseState::AllLower,
731                Some('e') | Some('E') => case = CaseState::None,
732                Some(other) => push_cased(out, &mut case, other),
733                None => {} // trailing backslash ignored
734            },
735            _ => push_cased(out, &mut case, c),
736        }
737    }
738}
739
740/// Replace the first or all occurrences of `regex` in `text`, expanding the
741/// raw vim `replacement` (with `prev` for `~`) per match. Returns
742/// `(new_text, count)`.
743fn do_replace(
744    regex: &Regex,
745    text: &str,
746    replacement: &str,
747    prev: &str,
748    all: bool,
749) -> (String, usize) {
750    let matches = regex.find_iter(text).count();
751    if matches == 0 {
752        return (text.to_string(), 0);
753    }
754    let rep = |caps: &regex::Captures| expand_replacement(replacement, caps, prev);
755    let replaced = if all {
756        regex.replace_all(text, rep).into_owned()
757    } else {
758        regex.replace(text, rep).into_owned()
759    };
760    let count = if all { matches } else { 1 };
761    (replaced, count)
762}
763
764#[cfg(test)]
765mod tests {
766    use super::*;
767    use crate::types::{DefaultHost, Options};
768    use hjkl_buffer::View;
769
770    fn editor_with(content: &str) -> Editor<View, DefaultHost> {
771        let mut e = Editor::new(View::new(), DefaultHost::new(), Options::default());
772        e.set_content(content);
773        e
774    }
775
776    fn buf_line(e: &Editor<View, DefaultHost>, row: usize) -> String {
777        hjkl_buffer::rope_line_str(&e.buffer().rope(), row)
778    }
779
780    // ── Parser tests ─────────────────────────────────────────────────
781
782    #[test]
783    fn parse_basic() {
784        let cmd = parse_substitute("/foo/bar/").unwrap();
785        assert_eq!(cmd.pattern.as_deref(), Some("foo"));
786        assert_eq!(cmd.replacement, "bar");
787        assert!(!cmd.flags.all);
788    }
789
790    #[test]
791    fn parse_trailing_slash_optional() {
792        let cmd = parse_substitute("/foo/bar").unwrap();
793        assert_eq!(cmd.pattern.as_deref(), Some("foo"));
794        assert_eq!(cmd.replacement, "bar");
795    }
796
797    #[test]
798    fn parse_global_flag() {
799        let cmd = parse_substitute("/x/y/g").unwrap();
800        assert!(cmd.flags.all);
801    }
802
803    #[test]
804    fn parse_ignore_case_flag() {
805        let cmd = parse_substitute("/x/y/i").unwrap();
806        assert!(cmd.flags.ignore_case);
807    }
808
809    #[test]
810    fn parse_case_sensitive_flag() {
811        let cmd = parse_substitute("/x/y/I").unwrap();
812        assert!(cmd.flags.case_sensitive);
813    }
814
815    #[test]
816    fn parse_confirm_flag_accepted() {
817        let cmd = parse_substitute("/x/y/c").unwrap();
818        assert!(cmd.flags.confirm);
819    }
820
821    #[test]
822    fn parse_multi_flags() {
823        let cmd = parse_substitute("/x/y/gi").unwrap();
824        assert!(cmd.flags.all);
825        assert!(cmd.flags.ignore_case);
826    }
827
828    #[test]
829    fn parse_unknown_flag_errors() {
830        let err = parse_substitute("/x/y/z").unwrap_err();
831        assert!(err.to_string().contains("unknown flag 'z'"), "{err}");
832    }
833
834    #[test]
835    fn parse_empty_pattern_is_none() {
836        let cmd = parse_substitute("//bar/").unwrap();
837        assert!(cmd.pattern.is_none());
838        assert_eq!(cmd.replacement, "bar");
839    }
840
841    #[test]
842    fn parse_empty_replacement_ok() {
843        let cmd = parse_substitute("/foo//").unwrap();
844        assert_eq!(cmd.pattern.as_deref(), Some("foo"));
845        assert_eq!(cmd.replacement, "");
846    }
847
848    #[test]
849    fn parse_escaped_slash_in_pattern() {
850        let cmd = parse_substitute("/a\\/b/c/").unwrap();
851        assert_eq!(cmd.pattern.as_deref(), Some("a/b"));
852    }
853
854    #[test]
855    fn parse_escaped_slash_in_replacement() {
856        let cmd = parse_substitute("/a/b\\/c/").unwrap();
857        // Replacement is already translated; literal / survives.
858        assert_eq!(cmd.replacement, "b/c");
859    }
860
861    // The parser stores the replacement in RAW vim notation; expansion (below)
862    // resolves `&` / `\1` / `\&` etc. per match.
863    #[test]
864    fn parse_keeps_replacement_raw() {
865        assert_eq!(parse_substitute("/foo/[&]/").unwrap().replacement, "[&]");
866        assert_eq!(parse_substitute("/foo/\\&/").unwrap().replacement, "\\&");
867        assert_eq!(parse_substitute("/(foo)/\\1/").unwrap().replacement, "\\1");
868        assert_eq!(parse_substitute("/(x)/\\9/").unwrap().replacement, "\\9");
869    }
870
871    #[test]
872    fn parse_wrong_delimiter_errors() {
873        let err = parse_substitute("|foo|bar|").unwrap_err();
874        assert!(err.to_string().contains("'/'"), "{err}");
875    }
876
877    #[test]
878    fn parse_too_few_fields_errors() {
879        let err = parse_substitute("/foo").unwrap_err();
880        assert!(
881            err.to_string().contains("needs /pattern/replacement"),
882            "{err}"
883        );
884    }
885
886    // ── Apply tests ──────────────────────────────────────────────────
887
888    #[test]
889    fn apply_single_line_first_only() {
890        let mut e = editor_with("foo foo");
891        let cmd = parse_substitute("/foo/bar/").unwrap();
892        let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
893        assert_eq!(out.replacements, 1);
894        assert_eq!(out.lines_changed, 1);
895        assert_eq!(buf_line(&e, 0), "bar foo");
896    }
897
898    #[test]
899    fn apply_single_line_global() {
900        let mut e = editor_with("foo foo foo");
901        let cmd = parse_substitute("/foo/bar/g").unwrap();
902        let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
903        assert_eq!(out.replacements, 3);
904        assert_eq!(out.lines_changed, 1);
905        assert_eq!(buf_line(&e, 0), "bar bar bar");
906    }
907
908    #[test]
909    fn apply_multi_line_range() {
910        let mut e = editor_with("foo\nfoo foo\nbar");
911        let cmd = parse_substitute("/foo/xyz/g").unwrap();
912        let out = apply_substitute(&mut e, &cmd, 0..=2).unwrap();
913        assert_eq!(out.replacements, 3);
914        assert_eq!(out.lines_changed, 2);
915        assert_eq!(buf_line(&e, 0), "xyz");
916        assert_eq!(buf_line(&e, 1), "xyz xyz");
917        assert_eq!(buf_line(&e, 2), "bar");
918    }
919
920    #[test]
921    fn apply_no_match_returns_zero() {
922        let mut e = editor_with("hello");
923        let original = buf_line(&e, 0);
924        let cmd = parse_substitute("/xyz/abc/").unwrap();
925        let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
926        assert_eq!(out.replacements, 0);
927        assert_eq!(out.lines_changed, 0);
928        assert_eq!(buf_line(&e, 0), original);
929    }
930
931    #[test]
932    fn apply_case_insensitive_flag() {
933        let mut e = editor_with("Foo FOO foo");
934        let cmd = parse_substitute("/foo/bar/gi").unwrap();
935        let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
936        assert_eq!(out.replacements, 3);
937        assert_eq!(buf_line(&e, 0), "bar bar bar");
938    }
939
940    #[test]
941    fn apply_case_sensitive_flag_overrides_editor_setting() {
942        let mut e = editor_with("Foo foo");
943        // Enable ignorecase on the editor.
944        e.settings_mut().ignore_case = true;
945        // `I` (capital) forces case-sensitive.
946        let cmd = parse_substitute("/foo/bar/I").unwrap();
947        let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
948        // Only the lowercase "foo" matches.
949        assert_eq!(out.replacements, 1);
950        assert_eq!(buf_line(&e, 0), "Foo bar");
951    }
952
953    #[test]
954    fn apply_empty_pattern_reuses_last_search() {
955        let mut e = editor_with("hello world");
956        e.set_last_search(Some("world".to_string()), true);
957        let cmd = parse_substitute("//planet/").unwrap();
958        let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
959        assert_eq!(out.replacements, 1);
960        assert_eq!(buf_line(&e, 0), "hello planet");
961    }
962
963    #[test]
964    fn apply_empty_pattern_no_last_search_errors() {
965        let mut e = editor_with("hello");
966        let cmd = parse_substitute("//bar/").unwrap();
967        let err = apply_substitute(&mut e, &cmd, 0..=0).unwrap_err();
968        assert!(
969            err.to_string().contains("no previous regular expression"),
970            "{err}"
971        );
972    }
973
974    #[test]
975    fn apply_updates_last_search() {
976        let mut e = editor_with("foo");
977        let cmd = parse_substitute("/foo/bar/").unwrap();
978        apply_substitute(&mut e, &cmd, 0..=0).unwrap();
979        assert_eq!(e.last_search(), Some("foo".to_string()));
980    }
981
982    #[test]
983    fn apply_empty_replacement_deletes_match() {
984        let mut e = editor_with("hello world");
985        let cmd = parse_substitute("/world//").unwrap();
986        let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
987        assert_eq!(out.replacements, 1);
988        assert_eq!(buf_line(&e, 0), "hello ");
989    }
990
991    #[test]
992    fn apply_undo_reverts_in_one_step() {
993        let mut e = editor_with("foo");
994        let cmd = parse_substitute("/foo/bar/").unwrap();
995        apply_substitute(&mut e, &cmd, 0..=0).unwrap();
996        assert_eq!(buf_line(&e, 0), "bar");
997        e.undo();
998        assert_eq!(buf_line(&e, 0), "foo");
999    }
1000
1001    #[test]
1002    fn apply_ampersand_in_replacement() {
1003        let mut e = editor_with("foo");
1004        let cmd = parse_substitute("/foo/[&]/").unwrap();
1005        apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1006        assert_eq!(buf_line(&e, 0), "[foo]");
1007    }
1008
1009    #[test]
1010    fn apply_capture_group_reference() {
1011        let mut e = editor_with("hello world");
1012        let cmd = parse_substitute("/(\\w+)/<<\\1>>/g").unwrap();
1013        apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1014        assert_eq!(buf_line(&e, 0), "<<hello>> <<world>>");
1015    }
1016
1017    #[test]
1018    fn apply_backslash_r_splits_line() {
1019        // `\r` in the replacement inserts a line break (the split-on-delimiter
1020        // idiom): `:s/,/\r/g` turns one line into three.
1021        let mut e = editor_with("a,b,c");
1022        let cmd = parse_substitute("/,/\\r/g").unwrap();
1023        apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1024        assert_eq!(buf_line(&e, 0), "a");
1025        assert_eq!(buf_line(&e, 1), "b");
1026        assert_eq!(buf_line(&e, 2), "c");
1027    }
1028
1029    /// Audit A5 regression: `:%s/,/\r/` across a multi-row range where an
1030    /// earlier row's replacement also splits into extra rows. The recorded
1031    /// "last changed row" must be adjusted into POST-substitution row space
1032    /// (vim lands on the first non-blank of the last changed line, `d`, real
1033    /// row 3) — not the PRE-split row index (which would land on `b`, row 1).
1034    #[test]
1035    fn apply_backslash_r_multi_row_cursor_lands_on_final_split_row() {
1036        let mut e = editor_with("a,b\nc,d\n");
1037        let cmd = parse_substitute("/,/\\r/").unwrap();
1038        let total = crate::types::Query::rope(e.buffer()).len_lines();
1039        let out = apply_substitute(&mut e, &cmd, 0..=(total.saturating_sub(1)) as u32).unwrap();
1040        assert_eq!(buf_line(&e, 0), "a");
1041        assert_eq!(buf_line(&e, 1), "b");
1042        assert_eq!(buf_line(&e, 2), "c");
1043        assert_eq!(buf_line(&e, 3), "d");
1044        assert_eq!(
1045            out.last_row,
1046            Some(3),
1047            "cursor should land on the last changed line ('d', real row 3) \
1048             in post-split coordinates, not the pre-split row index"
1049        );
1050        assert_eq!(e.buffer().cursor().row, 3);
1051    }
1052
1053    /// Single-row case: `\r` splitting one line into several must still put
1054    /// the cursor on the LAST resulting physical line (vim semantics for a
1055    /// single `:s` invocation whose replacement itself contains newlines).
1056    #[test]
1057    fn apply_backslash_r_single_row_cursor_lands_on_last_split_line() {
1058        let mut e = editor_with("a,b");
1059        let cmd = parse_substitute("/,/\\r/").unwrap();
1060        let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1061        assert_eq!(buf_line(&e, 0), "a");
1062        assert_eq!(buf_line(&e, 1), "b");
1063        assert_eq!(out.last_row, Some(1));
1064        assert_eq!(e.buffer().cursor().row, 1);
1065    }
1066
1067    /// Guard the common (no-newline) multi-row path: cursor still lands on
1068    /// the last changed row with no coordinate adjustment needed.
1069    #[test]
1070    fn apply_no_newline_multi_row_cursor_unaffected() {
1071        let mut e = editor_with("a\na\na");
1072        let cmd = parse_substitute("/a/X/").unwrap();
1073        let out = apply_substitute(&mut e, &cmd, 0..=2).unwrap();
1074        assert_eq!(buf_line(&e, 0), "X");
1075        assert_eq!(buf_line(&e, 1), "X");
1076        assert_eq!(buf_line(&e, 2), "X");
1077        assert_eq!(out.last_row, Some(2));
1078        assert_eq!(e.buffer().cursor().row, 2);
1079    }
1080
1081    #[test]
1082    fn apply_backslash_t_inserts_tab() {
1083        let mut e = editor_with("a,b");
1084        let cmd = parse_substitute("/,/\\t/").unwrap();
1085        apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1086        assert_eq!(buf_line(&e, 0), "a\tb");
1087    }
1088
1089    #[test]
1090    fn apply_literal_dollar_in_replacement() {
1091        // A literal `$` in the replacement stays literal (vim uses `\1` for
1092        // groups, so `$5` is not a capture ref).
1093        let mut e = editor_with("x");
1094        let cmd = parse_substitute("/x/$5/").unwrap();
1095        apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1096        assert_eq!(buf_line(&e, 0), "$5");
1097    }
1098
1099    #[test]
1100    fn apply_backslash_zero_is_whole_match() {
1101        // `\0` is the whole match (like `&`).
1102        let mut e = editor_with("foo");
1103        let cmd = parse_substitute("/foo/[\\0]/").unwrap();
1104        apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1105        assert_eq!(buf_line(&e, 0), "[foo]");
1106    }
1107
1108    #[test]
1109    fn apply_group_ref_then_literal_digits() {
1110        // Braced capture refs let a digit follow a group ref: `\1` then `1`.
1111        let mut e = editor_with("ab");
1112        let cmd = parse_substitute("/(.)/\\11/g").unwrap();
1113        apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1114        assert_eq!(buf_line(&e, 0), "a1b1");
1115    }
1116
1117    // ── expand_replacement: case escapes + ~ ──────────────────────────────────
1118
1119    fn expand(raw: &str, pat: &str, text: &str, prev: &str) -> String {
1120        let re = Regex::new(pat).unwrap();
1121        let caps = re.captures(text).unwrap();
1122        expand_replacement(raw, &caps, prev)
1123    }
1124
1125    #[test]
1126    fn expand_case_upper_run_and_end() {
1127        // `\U…\E` uppercases a run; text after `\E` is unaffected.
1128        assert_eq!(expand("\\U\\0\\Ex", "foo", "foo", ""), "FOOx");
1129        assert_eq!(expand("\\L&\\E", "FOO", "FOO", ""), "foo");
1130    }
1131
1132    #[test]
1133    fn expand_case_one_shot() {
1134        // `\u` / `\l` affect only the next char.
1135        assert_eq!(expand("\\u\\0", "foo", "foo", ""), "Foo");
1136        assert_eq!(expand("\\l\\0", "FOO", "FOO", ""), "fOO");
1137    }
1138
1139    #[test]
1140    fn expand_case_applies_to_group() {
1141        // Case escape applied across a capture group and a following literal.
1142        assert_eq!(expand("\\U\\1-y\\E", "(f)oo", "foo", ""), "F-Y");
1143    }
1144
1145    #[test]
1146    fn expand_literal_dollar_and_amp() {
1147        assert_eq!(expand("$\\0", "x", "x", ""), "$x");
1148        assert_eq!(expand("[&]", "foo", "foo", ""), "[foo]");
1149        assert_eq!(expand("\\&", "foo", "foo", ""), "&");
1150    }
1151
1152    #[test]
1153    fn expand_tilde_uses_previous_replacement() {
1154        // `~` expands to the previous replacement, re-evaluated against caps.
1155        assert_eq!(expand("~!", "x", "x", "PREV"), "PREV!");
1156        assert_eq!(expand("~", "(.)", "a", "[\\1]"), "[a]");
1157        // `\~` is a literal tilde.
1158        assert_eq!(expand("\\~", "x", "x", "PREV"), "~");
1159    }
1160
1161    // ── `n` flag: report count, no mutation ───────────────────────────────────
1162
1163    #[test]
1164    fn apply_report_only_counts_without_mutating() {
1165        let mut e = editor_with("foo foo foo");
1166        let cmd = parse_substitute("/foo/bar/gn").unwrap();
1167        assert!(cmd.flags.report_only);
1168        let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1169        assert_eq!(out.replacements, 3);
1170        // View is untouched.
1171        assert_eq!(buf_line(&e, 0), "foo foo foo");
1172    }
1173
1174    // ── case escapes through the full apply path ──────────────────────────────
1175
1176    #[test]
1177    fn apply_upper_run() {
1178        let mut e = editor_with("hello world");
1179        let cmd = parse_substitute("/world/\\U&\\E/").unwrap();
1180        apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1181        assert_eq!(buf_line(&e, 0), "hello WORLD");
1182    }
1183
1184    // ── smartcase + \c/\C tests ───────────────────────────────────────────────
1185
1186    /// `:s/foo/bar/` on `"Foo"` — ignorecase+smartcase on by default, all-
1187    /// lowercase pattern → Insensitive → matches `Foo` → becomes `bar`.
1188    #[test]
1189    fn substitute_respects_smartcase() {
1190        let mut e = editor_with("Foo");
1191        // Default Options has ignorecase=true, smartcase=true.
1192        let cmd = parse_substitute("/foo/bar/").unwrap();
1193        let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1194        assert_eq!(out.replacements, 1);
1195        assert_eq!(buf_line(&e, 0), "bar");
1196    }
1197
1198    /// `:s/Foo/bar/i` — `/i` flag overrides smartcase (mixed pattern would
1199    /// normally be Sensitive) → case-insensitive → matches `"foo"`.
1200    #[test]
1201    fn substitute_i_flag_overrides_c() {
1202        let mut e = editor_with("foo");
1203        // /i forces insensitive regardless of pattern case or smartcase.
1204        let cmd = parse_substitute("/Foo/bar/i").unwrap();
1205        let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1206        assert_eq!(out.replacements, 1, "expected match on 'foo' with /i flag");
1207        assert_eq!(buf_line(&e, 0), "bar");
1208    }
1209
1210    /// `\c` inline override in a pattern with no `/i`/`/I` flag — forces
1211    /// insensitive even though `Foo` has uppercase (smartcase trip).
1212    #[test]
1213    fn substitute_lower_c_inline_overrides_smartcase() {
1214        let mut e = editor_with("FOO");
1215        // \cFoo — override wins, Insensitive → matches "FOO"
1216        let cmd = parse_substitute("/\\cFoo/bar/").unwrap();
1217        let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1218        assert_eq!(out.replacements, 1);
1219        assert_eq!(buf_line(&e, 0), "bar");
1220    }
1221
1222    // ── collect_substitute_matches tests ────────────────────────────────────
1223
1224    #[test]
1225    fn collect_substitute_matches_finds_all_occurrences() {
1226        let e = editor_with("foo bar foo");
1227        let cmd = parse_substitute("/foo/baz/g").unwrap();
1228        let matches = collect_substitute_matches(&e, &cmd, 0..=0).unwrap();
1229        assert_eq!(matches.len(), 2, "expected 2 matches for /g flag");
1230        assert_eq!(matches[0].byte_start, 0);
1231        assert_eq!(matches[0].byte_end, 3);
1232        assert_eq!(matches[1].byte_start, 8);
1233        assert_eq!(matches[1].byte_end, 11);
1234        assert_eq!(matches[0].replacement, "baz");
1235        assert_eq!(matches[1].replacement, "baz");
1236    }
1237
1238    #[test]
1239    fn collect_substitute_matches_respects_g_flag() {
1240        // Without /g only the first match per line.
1241        let e = editor_with("foo foo foo");
1242        let cmd = parse_substitute("/foo/baz/").unwrap();
1243        let matches = collect_substitute_matches(&e, &cmd, 0..=0).unwrap();
1244        assert_eq!(matches.len(), 1, "expected 1 match without /g");
1245        assert_eq!(matches[0].byte_start, 0);
1246    }
1247
1248    #[test]
1249    fn collect_substitute_matches_respects_range() {
1250        let e = editor_with("foo\nfoo\nfoo\nfoo\nfoo");
1251        let cmd = parse_substitute("/foo/bar/g").unwrap();
1252        // Only rows 1 and 2 (0-based) — should return 2 matches, not 5.
1253        let matches = collect_substitute_matches(&e, &cmd, 1..=2).unwrap();
1254        assert_eq!(matches.len(), 2);
1255        assert_eq!(matches[0].row, 1);
1256        assert_eq!(matches[1].row, 2);
1257    }
1258
1259    #[test]
1260    fn collect_substitute_matches_expands_template() {
1261        let e = editor_with("hello world");
1262        // /(\\w+)/<<\\1>>/ — the replacement template has a capture group.
1263        let cmd = parse_substitute("/(\\w+)/<<\\1>>/g").unwrap();
1264        let matches = collect_substitute_matches(&e, &cmd, 0..=0).unwrap();
1265        assert_eq!(matches.len(), 2);
1266        assert_eq!(matches[0].replacement, "<<hello>>");
1267        assert_eq!(matches[1].replacement, "<<world>>");
1268    }
1269
1270    // ── apply_collected_matches tests ───────────────────────────────────────
1271
1272    #[test]
1273    fn apply_collected_matches_reverse_order_preserves_offsets() {
1274        // Three matches at byte offsets 0..3, 4..7, 8..11.
1275        // Applying in forward order would shift byte offsets; reverse must
1276        // keep the final buffer consistent.
1277        let mut e = editor_with("foo bar baz");
1278        let cmd = parse_substitute("/(foo|bar|baz)/X/g").unwrap();
1279        let matches = collect_substitute_matches(&e, &cmd, 0..=0).unwrap();
1280        assert_eq!(matches.len(), 3);
1281        let accepted = vec![true; 3];
1282        let applied = apply_collected_matches(&mut e, &matches, &accepted);
1283        assert_eq!(applied, 3);
1284        assert_eq!(buf_line(&e, 0), "X X X");
1285    }
1286
1287    #[test]
1288    fn apply_collected_matches_subset_only() {
1289        // 3 matches; accept only first and third.
1290        let mut e = editor_with("foo bar foo");
1291        let cmd = parse_substitute("/foo/ZZZ/g").unwrap();
1292        let matches = collect_substitute_matches(&e, &cmd, 0..=0).unwrap();
1293        assert_eq!(matches.len(), 2, "expected 2 foo matches");
1294        // Accept only the first (index 0), skip the second (index 1).
1295        let accepted = vec![true, false];
1296        let applied = apply_collected_matches(&mut e, &matches, &accepted);
1297        assert_eq!(applied, 1);
1298        // First "foo" replaced; second "foo" untouched.
1299        assert_eq!(buf_line(&e, 0), "ZZZ bar foo");
1300    }
1301
1302    #[test]
1303    fn apply_collected_matches_zero_accepted() {
1304        let mut e = editor_with("foo bar foo");
1305        let cmd = parse_substitute("/foo/ZZZ/g").unwrap();
1306        let matches = collect_substitute_matches(&e, &cmd, 0..=0).unwrap();
1307        let accepted = vec![false; matches.len()];
1308        let applied = apply_collected_matches(&mut e, &matches, &accepted);
1309        assert_eq!(applied, 0);
1310        assert_eq!(buf_line(&e, 0), "foo bar foo");
1311    }
1312
1313    #[test]
1314    fn apply_collected_matches_expands_template() {
1315        let mut e = editor_with("hello world");
1316        let cmd = parse_substitute("/(\\w+)/<<\\1>>/g").unwrap();
1317        let matches = collect_substitute_matches(&e, &cmd, 0..=0).unwrap();
1318        let accepted = vec![true; matches.len()];
1319        let applied = apply_collected_matches(&mut e, &matches, &accepted);
1320        assert_eq!(applied, 2);
1321        assert_eq!(buf_line(&e, 0), "<<hello>> <<world>>");
1322    }
1323}