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//! - Patterns are translated from vim's default-magic syntax (and the
15//!   `\v` / `\V` / `\m` / `\M` mode switches) into rust-`regex` syntax by
16//!   [`crate::search::resolve_case_mode`] before compiling — see that
17//!   module for the full transform table. `\1`-`\9` **backreferences in the
18//!   pattern** (as opposed to the replacement, which supports them) are not
19//!   supported by the rust `regex` crate (no backtracking engine).
20//! - The replacement is kept in raw vim notation and expanded per match by
21//!   [`expand_replacement`]: capture refs (`&`, `\0`…`\9`), case escapes
22//!   (`\u`/`\l`/`\U`/`\L`/`\E`), control chars (`\r`/`\t`/`\n`), and `~` (the
23//!   previous replacement). A plain `$` is literal.
24//! - Flags: `g` (all), `i`/`I` (case), `c` (confirm), `n` (report count only,
25//!   no change), `e` (accepted — hjkl already succeeds on no match),
26//!   `p`/`#`/`l` (print the last changed line, optionally with number /
27//!   `:list`-style — surfaced by the ex layer), and `&` (reuse the previous
28//!   substitute's flags — resolved by the ex layer). A trailing `[count]`
29//!   operates on `count` lines from the range's last line.
30//!
31//! See vim's `:help :substitute` for the full spec.
32
33use regex::Regex;
34
35use crate::Editor;
36
37/// Error type returned by [`parse_substitute`] and [`apply_substitute`].
38pub type SubstError = String;
39
40/// Parsed `:s/pattern/replacement/flags` command.
41///
42/// Produced by [`parse_substitute`]. Pass to [`apply_substitute`].
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct SubstituteCmd {
45    /// The literal pattern string. `None` means "reuse `last_search`
46    /// from the editor" (the user typed `:s//replacement/`).
47    pub pattern: Option<String>,
48    /// The replacement string in **raw vim notation** (`&`, `~`, `\0`…`\9`,
49    /// `\u`/`\U`/`\l`/`\L`/`\E`, `\r`/`\t`/`\n`). Expanded per match by
50    /// [`expand_replacement`]. Empty string deletes the match.
51    pub replacement: String,
52    /// Parsed flags.
53    pub flags: SubstFlags,
54    /// Optional trailing `[count]` (`:s/a/b/g 3`): operate on `count` lines
55    /// starting at the range's last line (vim semantics). `None` = no count.
56    pub count: Option<usize>,
57}
58
59/// Flags for the substitute command.
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
61pub struct SubstFlags {
62    /// `g` — replace all occurrences on each line (default: first only).
63    pub all: bool,
64    /// `i` — case-insensitive (overrides editor `ignorecase`).
65    pub ignore_case: bool,
66    /// `I` — case-sensitive (overrides editor `ignorecase`).
67    pub case_sensitive: bool,
68    /// `c` — confirm mode. When set, [`apply_substitute`] skips all matches
69    /// and the caller must use [`collect_substitute_matches`] +
70    /// [`apply_collected_matches`] for interactive replacement.
71    pub confirm: bool,
72    /// `n` — report the match count only; do not modify the buffer or move
73    /// the cursor. [`apply_substitute`] counts matches and returns without
74    /// mutating.
75    pub report_only: bool,
76    /// `e` — do not treat "pattern not found" as an error. hjkl already
77    /// returns success on no match, so this is accepted for compatibility.
78    pub no_error: bool,
79    /// `p` / `#` / `l` — print the last changed line (optionally with line
80    /// number `#` or `:list`-style `l`). Parsed and accepted; the print
81    /// itself is surfaced by the ex/host layer.
82    pub print: bool,
83    /// `#` — print with line number (implies `print`).
84    pub print_num: bool,
85    /// `l` — print `:list`-style (implies `print`).
86    pub print_list: bool,
87    /// `&` — reuse the flags from the previous substitute (`:h :s_flags`).
88    /// Resolved by the ex handler, which merges the stored `last_substitute`
89    /// flags into this command's before applying.
90    pub reuse_previous: bool,
91}
92
93/// Result of [`apply_substitute`].
94#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
95pub struct SubstituteOutcome {
96    /// Total number of individual replacements made across all lines.
97    pub replacements: usize,
98    /// Number of lines that had at least one replacement.
99    pub lines_changed: usize,
100    /// 0-based row of the last changed line (where the cursor lands). `None`
101    /// when nothing changed. Used by the ex layer to print the line for the
102    /// `p` / `#` / `l` flags.
103    pub last_row: Option<usize>,
104}
105
106/// Parse the tail of a substitute command (everything after the leading
107/// `s` / `substitute` keyword).
108///
109/// # Examples
110///
111/// ```
112/// use hjkl_engine::substitute::parse_substitute;
113///
114/// let cmd = parse_substitute("/foo/bar/gi").unwrap();
115/// assert_eq!(cmd.pattern.as_deref(), Some("foo"));
116/// assert_eq!(cmd.replacement, "bar");
117/// assert!(cmd.flags.all);
118/// assert!(cmd.flags.ignore_case);
119///
120/// // Empty pattern — reuse last_search.
121/// let cmd = parse_substitute("//bar/").unwrap();
122/// assert!(cmd.pattern.is_none());
123/// assert_eq!(cmd.replacement, "bar");
124/// ```
125///
126/// # Errors
127///
128/// Returns an error when:
129/// - `s` is not followed by `/` (no delimiter or alternate delimiter).
130/// - The flag string contains an unknown character.
131/// - The separator `/` is absent (less than two fields).
132pub fn parse_substitute(s: &str) -> Result<SubstituteCmd, SubstError> {
133    // Require leading `/`. Alternate delimiters are out of scope for v1.
134    let rest = s
135        .strip_prefix('/')
136        .ok_or_else(|| format!("substitute: expected '/' delimiter, got {s:?}"))?;
137
138    // Split on unescaped `/`, collecting at most 3 segments:
139    // [pattern, replacement, flags?]
140    let parts = split_on_slash(rest);
141
142    if parts.len() < 2 {
143        return Err("substitute needs /pattern/replacement/".into());
144    }
145
146    let raw_pattern = &parts[0];
147    let raw_replacement = &parts[1];
148    let raw_flags = parts.get(2).map_or("", String::as_str);
149
150    // Empty pattern → reuse last_search.
151    let pattern = if raw_pattern.is_empty() {
152        None
153    } else {
154        Some(raw_pattern.clone())
155    };
156
157    // Keep the replacement in raw vim notation; `expand_replacement` resolves
158    // capture refs, case escapes, and `~` per match.
159    let replacement = raw_replacement.clone();
160
161    let (flags, count) = parse_flags(raw_flags)?;
162
163    Ok(SubstituteCmd {
164        pattern,
165        replacement,
166        flags,
167        count,
168    })
169}
170
171/// Parse a substitute flags+count tail: `[flag-chars][ optional trailing
172/// count]`, e.g. `"g"`, `"gi 3"`, `""`. This is the segment after the
173/// closing `/` delimiter in `:s/pat/rep/flags`, and — for [B17] bare
174/// `:s [flags] [count]` (repeat-last-substitute) — the ENTIRE argument
175/// string, since there's no delimiter at all in that form.
176///
177/// # Errors
178///
179/// Returns an error on an unrecognized flag character or non-numeric
180/// trailing text.
181pub fn parse_flags(raw_flags: &str) -> Result<(SubstFlags, Option<usize>), SubstError> {
182    let mut flags = SubstFlags::default();
183    let mut count: Option<usize> = None;
184    let mut chars = raw_flags.chars().peekable();
185    while let Some(&ch) = chars.peek() {
186        match ch {
187            'g' => flags.all = true,
188            'i' => flags.ignore_case = true,
189            'I' => flags.case_sensitive = true,
190            'c' => flags.confirm = true,
191            'n' => flags.report_only = true,
192            'e' => flags.no_error = true,
193            'p' => flags.print = true,
194            '#' => {
195                flags.print = true;
196                flags.print_num = true;
197            }
198            'l' => {
199                flags.print = true;
200                flags.print_list = true;
201            }
202            // `&` — reuse the previous substitute's flags. Resolved by the ex
203            // handler (which holds `last_substitute`).
204            '&' => flags.reuse_previous = true,
205            ' ' | '\t' => {}
206            c if c.is_ascii_digit() => break, // trailing count begins
207            other => return Err(format!("unknown flag '{other}' in substitute")),
208        }
209        chars.next();
210    }
211    // Trailing count: the remainder (after any whitespace) must be a number.
212    let rest: String = chars.collect();
213    let rest = rest.trim();
214    if !rest.is_empty() {
215        match rest.parse::<usize>() {
216            Ok(n) if n > 0 => count = Some(n),
217            _ => return Err(format!("trailing characters in substitute: {rest:?}")),
218        }
219    }
220    Ok((flags, count))
221}
222
223/// Rebase marks / global marks / jumplist / folds after a substitute's
224/// whole-content swap. The swap (a `replace_all` or a range splice) only
225/// clamps the cursor — unlike `mutate_edit` it never reaches
226/// `Editor::shift_marks_after_edit`, so a `\r` (newline) in a replacement that
227/// splits rows leaves `'a`-style marks, folds and jump entries below the change
228/// pointing at stale rows.
229///
230/// `new_lines` holds the rows that were substituted, with their post-
231/// replacement text; row `i` of the slice maps to buffer row `row_offset + i`.
232/// Rows outside the substituted range never change their newline count (an
233/// unsubstituted row contains no `\n` — ropey splits rows at every line break),
234/// so only the changed rows need walking.
235///
236/// Each pre-substitute row that gained newlines is its own edit band: a row `i`
237/// whose replacement contains `k` newlines shifts every position below it by
238/// `k` (rows can only grow here — the substitution joins per-line entries, so
239/// `k >= 0`). Apply the bands in DESCENDING row order so earlier row indices
240/// stay valid: a higher band only moves rows below itself and never touches the
241/// index of a lower band, and the per-band rebase is order-independent for the
242/// marks it does shift (the shifts are additive).
243fn rebase_marks_after_row_growth<H: crate::types::Host>(
244    ed: &mut Editor<hjkl_buffer::View, H>,
245    new_lines: &[String],
246    row_offset: usize,
247) {
248    for (i, line) in new_lines.iter().enumerate().rev() {
249        let delta = line.matches('\n').count() as isize;
250        if delta != 0 {
251            ed.shift_marks_after_edit(row_offset + i, delta);
252        }
253    }
254}
255
256/// Emit the host-visible records `mutate_edit` produces for an edit, for the
257/// substitute path's whole-buffer swap (a `replace_all` or a range splice).
258/// `pre_end` is the position one past the pre-replace content's last char
259/// (captured before the swap); `new_text` is the joined post-swap content —
260/// used only for the change-log entry, which is one coarse whole-buffer
261/// Replace (per the `take_changes` contract, hosts wanting per-cell deltas
262/// diff their own snapshot). `new_text` may be empty when no host subscribed
263/// to the change log (recording defaults off): `extend_change_log` is then a
264/// no-op, and the caller skips materializing the full content. The
265/// content-reset flag tells syntax hosts to drop the retained tree and
266/// reparse, exactly as `Editor::set_content` does for whole-buffer replaces.
267fn emit_whole_buffer_change<H: crate::types::Host>(
268    ed: &mut Editor<hjkl_buffer::View, H>,
269    pre_end: crate::types::Pos,
270    new_text: &str,
271) {
272    ed.buffer_mut().extend_change_log([crate::types::Edit {
273        range: crate::types::Pos::new(0, 0)..pre_end,
274        replacement: new_text.to_string(),
275    }]);
276    ed.buffer_mut().clear_pending_content_edits();
277    ed.buffer_mut().set_pending_content_reset(true);
278}
279
280/// Apply a parsed substitute command to `line_range` (0-based inclusive)
281/// in the editor's buffer.
282///
283/// # Pattern resolution
284///
285/// If `cmd.pattern` is `None` (user typed `:s//rep/`), the editor's
286/// `last_search()` is used. Returns an error with `"no previous regular
287/// expression"` when both are empty.
288///
289/// # Case-sensitivity precedence
290///
291/// `flags.case_sensitive` wins over `flags.ignore_case`, which wins over
292/// the editor's `settings().ignore_case`.
293///
294/// # Cursor
295///
296/// After a successful substitution the cursor is placed on the first
297/// non-blank of the **last line that changed**, matching vim semantics. When
298/// no replacements are made the cursor is left unchanged.
299///
300/// # Undo
301///
302/// One undo snapshot is pushed before the first edit. If no replacements
303/// occur the snapshot is popped so the undo stack stays clean.
304///
305/// # Errors
306///
307/// Returns an error when pattern resolution fails or the regex is invalid.
308pub fn apply_substitute<H: crate::types::Host>(
309    ed: &mut Editor<hjkl_buffer::View, H>,
310    cmd: &SubstituteCmd,
311    line_range: std::ops::RangeInclusive<u32>,
312) -> Result<SubstituteOutcome, SubstError> {
313    // Resolve pattern.
314    let pattern_str: String = match &cmd.pattern {
315        Some(p) => p.clone(),
316        None => ed
317            .last_search()
318            .ok_or_else(|| "no previous regular expression".to_string())?,
319    };
320
321    // Previous `:s` replacement text (this command is stored only after it
322    // succeeds, so this is the *prior* one). Serves double duty: pattern-side
323    // magic `~` expands to it, and replacement-side `~` re-expands it per match.
324    let prev_replacement = ed.last_substitute_replacement();
325
326    // Case-sensitivity. Inline `\c` / `\C` overrides `/i` and `/I`.
327    let effective_pattern = {
328        use crate::search::{CaseMode, resolve_case_mode};
329        let base = if cmd.flags.case_sensitive {
330            CaseMode::Sensitive
331        } else if cmd.flags.ignore_case {
332            CaseMode::Insensitive
333        } else {
334            CaseMode::from_options(ed.settings().ignore_case, ed.settings().smartcase)
335        };
336        let (stripped, mode) = resolve_case_mode(&pattern_str, base, &prev_replacement);
337        if mode == CaseMode::Insensitive {
338            format!("(?i){stripped}")
339        } else {
340            stripped
341        }
342    };
343
344    let regex = Regex::new(&effective_pattern).map_err(|e| format!("bad pattern: {e}"))?;
345
346    ed.push_undo();
347
348    let start = *line_range.start() as usize;
349    let end = *line_range.end() as usize;
350    let rope = crate::types::Query::rope(ed.buffer());
351    let total = rope.len_lines();
352
353    let clamp_end = end.min(total.saturating_sub(1));
354    // Materialize only the rows being substituted (`start..=clamp_end`), not
355    // the whole buffer: a bare `:s/foo/bar/` defaults to the cursor line, so a
356    // 1M-line file must not pay a full-buffer lines-vec + join + rope rebuild
357    // to edit one line. `rope_line_to_str` yields exactly the row shape
358    // `rope_to_lines_vec` used (content without the line separator).
359    let mut new_lines: Vec<String> = (start..=clamp_end)
360        .map(|row| crate::rope_util::rope_line_to_str(&rope, row))
361        .collect();
362    // Pre-replace char count of row `clamp_end` — the splice's end column is a
363    // coordinate in the ORIGINAL rope, so it must be captured before
364    // `do_replace` mutates `new_lines`.
365    let clamp_row_chars = new_lines.last().map_or(0, |l| l.chars().count());
366    let mut replacements = 0usize;
367    let mut lines_changed = 0usize;
368    let mut last_changed_row = 0usize;
369
370    if start <= clamp_end {
371        for (row, line) in new_lines.iter_mut().enumerate() {
372            let (replaced, n) = do_replace(
373                &regex,
374                line,
375                &cmd.replacement,
376                &prev_replacement,
377                cmd.flags.all,
378            );
379            if n > 0 {
380                *line = replaced;
381                replacements += n;
382                lines_changed += 1;
383                last_changed_row = start + row;
384            }
385        }
386    }
387
388    if replacements == 0 {
389        ed.pop_last_undo();
390        return Ok(SubstituteOutcome {
391            replacements: 0,
392            lines_changed: 0,
393            last_row: None,
394        });
395    }
396
397    // `n` flag: report the match count without touching the buffer or cursor.
398    // Still refresh `last_search` so `n`/`N` can repeat the pattern.
399    if cmd.flags.report_only {
400        ed.pop_last_undo();
401        ed.set_last_search(Some(pattern_str), true);
402        return Ok(SubstituteOutcome {
403            replacements,
404            lines_changed,
405            last_row: None,
406        });
407    }
408
409    // `last_changed_row` above is a PRE-split row index into the substituted
410    // range: it counts one entry per original row, even though a `\r`/newline
411    // in the replacement can turn one entry into several physical rows once
412    // joined and re-split by the buffer. Map it into POST-split row space
413    // before placing the cursor: earlier rows may have grown (shifting this
414    // row's start down), and this row's own replacement may itself have split
415    // into multiple physical lines — vim lands on the LAST of those. Rows
416    // outside `start..=clamp_end` are untouched and an unsubstituted row never
417    // contains `\n` (ropey splits rows at every line break), so the
418    // full-buffer prefix sum reduces to the in-range rows before it.
419    let changed_local = last_changed_row - start;
420    let newlines_before: usize = new_lines[..changed_local]
421        .iter()
422        .map(|l| l.matches('\n').count())
423        .sum();
424    let newlines_within = new_lines[changed_local].matches('\n').count();
425    let last_changed_row = last_changed_row + newlines_before + newlines_within;
426
427    // Apply the new content by splicing the substituted rows back into the
428    // rope (a range remove + insert) instead of a whole-buffer `replace_all` —
429    // same resulting text, but O(range) instead of an O(N) rope rebuild. The
430    // splice goes through the buffer's raw `apply_edit` (not `mutate_edit`),
431    // so — like `replace_all` — it emits no host records and rebases nothing;
432    // capture the pre-replace end position (the change-log range must describe
433    // the OLD content) and emit the whole-buffer records after the swap.
434    let pre_rows = crate::types::Query::rope(ed.buffer()).len_lines();
435    let last_pre_row = pre_rows.saturating_sub(1);
436    let pre_end = crate::types::Pos::new(
437        last_pre_row as u32,
438        crate::buf_helpers::buf_line(ed.buffer(), last_pre_row)
439            .unwrap_or_default()
440            .chars()
441            .count() as u32,
442    );
443    let range_text = new_lines.join("\n");
444    let splice = hjkl_buffer::Edit::Replace {
445        // Char range from row `start` col 0 through the end of row
446        // `clamp_end`'s content — exactly the segment `range_text` reproduces
447        // (row contents joined by `\n`). The end position excludes the
448        // separator after `clamp_end`, so a trailing `\n` / phantom empty row
449        // is preserved rather than re-added.
450        start: hjkl_buffer::Position::new(start, 0),
451        end: hjkl_buffer::Position::new(clamp_end, clamp_row_chars),
452        with: range_text,
453    };
454    let _ = ed.buffer_mut().apply_edit(splice);
455
456    // Whole-buffer change record for hosts. The full post-state text is only
457    // materialized when a host is actually subscribed to the change log
458    // (recording defaults off) — the content-reset / content-edit flags are
459    // set regardless.
460    let new_text = if ed.buffer().change_log_enabled() {
461        crate::types::Query::rope(ed.buffer()).to_string()
462    } else {
463        String::new()
464    };
465    emit_whole_buffer_change(ed, pre_end, &new_text);
466
467    // Neither `replace_all` nor the splice rebases marks/jumplist/folds (only
468    // `mutate_edit` does); a `\r` in the replacement that adds rows would leave
469    // positions below the change stale. Rebase per changed pre-substitute row;
470    // rows outside the substituted range never grew, so only the in-range rows
471    // need walking.
472    rebase_marks_after_row_growth(ed, &new_lines, start);
473
474    // Cursor lands on the first non-blank of the last changed line (vim). Clamp
475    // the row defensively in case of any off-by-one at buffer edges.
476    let final_total = crate::types::Query::rope(ed.buffer()).len_lines();
477    let cursor_row = last_changed_row.min(final_total.saturating_sub(1));
478    let first_non_blank = crate::buf_helpers::buf_line(ed.buffer(), cursor_row)
479        .unwrap_or_default()
480        .chars()
481        .take_while(|c| *c == ' ' || *c == '\t')
482        .count();
483    let line_len = crate::buf_helpers::buf_line(ed.buffer(), cursor_row)
484        .unwrap_or_default()
485        .chars()
486        .count();
487    let cursor_col = first_non_blank.min(line_len.saturating_sub(1));
488    // `jump_cursor`, not `set_cursor`: `:s` is an explicit jump, so vim resets
489    // `curswant` to the column it lands on and the next `j`/`k` aims there
490    // rather than at the column the cursor held before the substitute.
491    ed.jump_cursor(cursor_row, cursor_col);
492
493    ed.mark_content_dirty();
494
495    // Update last_search so n/N can repeat the same pattern.
496    ed.set_last_search(Some(pattern_str), true);
497
498    Ok(SubstituteOutcome {
499        replacements,
500        lines_changed,
501        last_row: Some(cursor_row),
502    })
503}
504
505/// A single candidate match discovered by [`collect_substitute_matches`].
506///
507/// Positions are 0-based byte offsets within their line. The `replacement`
508/// field already has all capture-group references expanded (e.g. `$1`) to
509/// their literal values so the caller can display it and apply without
510/// running the regex again.
511#[derive(Debug, Clone, PartialEq, Eq)]
512pub struct SubstituteMatch {
513    /// 0-based row index in the buffer.
514    pub row: u32,
515    /// Byte offset of the first byte of the match within that row's text.
516    pub byte_start: u32,
517    /// Byte offset one past the last byte of the match (exclusive).
518    pub byte_end: u32,
519    /// The literal replacement string (captures expanded).
520    pub replacement: String,
521}
522
523/// Collect all candidate matches for a `:s/pat/rep/[gc]` command without
524/// mutating the buffer.
525///
526/// Uses the same pattern-resolution and case-sensitivity logic as
527/// [`apply_substitute`]. The returned vec is in document order (low row +
528/// low byte first). Each entry's `replacement` has capture groups already
529/// expanded so the caller can display it without re-running the regex.
530///
531/// # Errors
532///
533/// Returns an error when pattern resolution fails or the regex is invalid.
534pub fn collect_substitute_matches<H: crate::types::Host>(
535    ed: &crate::Editor<hjkl_buffer::View, H>,
536    cmd: &SubstituteCmd,
537    line_range: std::ops::RangeInclusive<u32>,
538) -> Result<Vec<SubstituteMatch>, SubstError> {
539    // Resolve pattern — same logic as apply_substitute.
540    let pattern_str: String = match &cmd.pattern {
541        Some(p) => p.clone(),
542        None => ed
543            .last_search()
544            .ok_or_else(|| "no previous regular expression".to_string())?,
545    };
546
547    // Previous `:s` replacement — pattern-side magic `~` expands to it, and
548    // replacement-side `~` re-expands it per match (same as apply_substitute).
549    let prev_replacement = ed.last_substitute_replacement();
550
551    let effective_pattern = {
552        use crate::search::{CaseMode, resolve_case_mode};
553        let base = if cmd.flags.case_sensitive {
554            CaseMode::Sensitive
555        } else if cmd.flags.ignore_case {
556            CaseMode::Insensitive
557        } else {
558            CaseMode::from_options(ed.settings().ignore_case, ed.settings().smartcase)
559        };
560        let (stripped, mode) = resolve_case_mode(&pattern_str, base, &prev_replacement);
561        if mode == CaseMode::Insensitive {
562            format!("(?i){stripped}")
563        } else {
564            stripped
565        }
566    };
567
568    let regex = Regex::new(&effective_pattern).map_err(|e| format!("bad pattern: {e}"))?;
569
570    let start = *line_range.start() as usize;
571    let end = *line_range.end() as usize;
572    let rope = crate::types::Query::rope(ed.buffer());
573    let total = rope.len_lines();
574    let clamp_end = end.min(total.saturating_sub(1));
575
576    let mut matches: Vec<SubstituteMatch> = Vec::new();
577
578    // Expand the raw vim replacement against the match at `m.start()`. Capture
579    // against the whole line (not the isolated substring) so anchors /
580    // lookaround keep their context and group expansion matches what was found.
581    let expand = |line: &str, start: usize| {
582        regex
583            .captures_at(line, start)
584            .map(|caps| expand_replacement(&cmd.replacement, &caps, &prev_replacement))
585            .unwrap_or_default()
586    };
587
588    if start <= clamp_end {
589        for row in start..=clamp_end {
590            // Borrow the rope chunk instead of materializing a String per row.
591            let line = crate::viewport_math::rope_line_slice(&rope, row);
592            // Strip trailing newline so byte offsets refer to printable content.
593            let line = line.trim_end_matches('\n');
594
595            if cmd.flags.all {
596                for m in regex.find_iter(line) {
597                    matches.push(SubstituteMatch {
598                        row: row as u32,
599                        byte_start: m.start() as u32,
600                        byte_end: m.end() as u32,
601                        replacement: expand(line, m.start()),
602                    });
603                }
604            } else if let Some(m) = regex.find(line) {
605                // First match per line only.
606                matches.push(SubstituteMatch {
607                    row: row as u32,
608                    byte_start: m.start() as u32,
609                    byte_end: m.end() as u32,
610                    replacement: expand(line, m.start()),
611                });
612            }
613        }
614    }
615
616    Ok(matches)
617}
618
619/// Apply a subset of matches collected by [`collect_substitute_matches`].
620///
621/// Applies the matches in REVERSE document order (high row → low row, and
622/// within a row high byte → low byte) so earlier byte offsets remain valid
623/// after each replacement. Only matches for which the corresponding
624/// `accepted` entry is `true` are written; all others are skipped.
625///
626/// Returns the number of replacements actually applied.
627///
628/// # Panics
629///
630/// Panics when `accepted.len() != matches.len()`.
631pub fn apply_collected_matches<H: crate::types::Host>(
632    ed: &mut crate::Editor<hjkl_buffer::View, H>,
633    matches: &[SubstituteMatch],
634    accepted: &[bool],
635) -> usize {
636    assert_eq!(
637        matches.len(),
638        accepted.len(),
639        "apply_collected_matches: accepted.len() must equal matches.len()"
640    );
641
642    // Collect accepted matches and sort reverse — high row first, high
643    // byte_start first within the same row.
644    let mut to_apply: Vec<&SubstituteMatch> = matches
645        .iter()
646        .zip(accepted.iter())
647        .filter_map(|(m, &ok)| if ok { Some(m) } else { None })
648        .collect();
649
650    if to_apply.is_empty() {
651        return 0;
652    }
653
654    to_apply.sort_unstable_by(|a, b| b.row.cmp(&a.row).then(b.byte_start.cmp(&a.byte_start)));
655
656    let rope = crate::types::Query::rope(ed.buffer());
657    let mut lines_vec: Vec<String> = crate::rope_util::rope_to_lines_vec(&rope);
658    let mut applied = 0usize;
659    let mut last_changed_row: Option<usize> = None;
660
661    for sm in &to_apply {
662        let row = sm.row as usize;
663        if row >= lines_vec.len() {
664            continue;
665        }
666        let line = &lines_vec[row];
667        let bs = sm.byte_start as usize;
668        let be = sm.byte_end as usize;
669        if be > line.len() || bs > be {
670            continue;
671        }
672        // Stale matches (buffer changed between collect and apply) can land
673        // mid-char on multibyte text; skip instead of panicking on the slice.
674        if !line.is_char_boundary(bs) || !line.is_char_boundary(be) {
675            continue;
676        }
677        // Splice the replacement in.
678        let mut new_line = String::with_capacity(line.len() + sm.replacement.len());
679        new_line.push_str(&line[..bs]);
680        new_line.push_str(&sm.replacement);
681        new_line.push_str(&line[be..]);
682        lines_vec[row] = new_line;
683        applied += 1;
684        // Matches are applied high-row-first (reverse document order) so
685        // earlier byte offsets stay valid; track the HIGHEST row touched so
686        // the cursor lands on the last-in-document-order changed line (vim),
687        // not merely the last one processed by this loop.
688        last_changed_row = Some(last_changed_row.map_or(row, |lr: usize| lr.max(row)));
689    }
690
691    if applied > 0 {
692        // Same host records as `apply_substitute`: `replace_all` bypasses
693        // `mutate_edit`, so emit the whole-buffer change-log entry and
694        // content-reset for the swap.
695        let pre_rows = crate::types::Query::rope(ed.buffer()).len_lines();
696        let last_pre_row = pre_rows.saturating_sub(1);
697        let pre_end = crate::types::Pos::new(
698            last_pre_row as u32,
699            crate::buf_helpers::buf_line(ed.buffer(), last_pre_row)
700                .unwrap_or_default()
701                .chars()
702                .count() as u32,
703        );
704        let new_text = lines_vec.join("\n");
705        ed.buffer_mut().replace_all(&new_text);
706        emit_whole_buffer_change(ed, pre_end, &new_text);
707        // Same rebase as `apply_substitute`: a `\r` in an accepted replacement
708        // adds rows, and the swap alone leaves marks/jumplist/folds stale. The
709        // full lines-vec walks every row here; `row_offset = 0` keeps the
710        // whole-buffer semantics identical.
711        rebase_marks_after_row_growth(ed, &lines_vec, 0);
712        if let Some(row) = last_changed_row {
713            // `row` is a PRE-split index into `lines_vec`: a `\r`/newline in
714            // an accepted replacement can turn one entry into several
715            // physical rows once joined and re-split by the buffer. Map into
716            // POST-split row space the same way `apply_substitute` does.
717            let newlines_before: usize = lines_vec[..row]
718                .iter()
719                .map(|l| l.matches('\n').count())
720                .sum();
721            let newlines_within = lines_vec[row].matches('\n').count();
722            let row = row + newlines_before + newlines_within;
723            // `jump_cursor`, not `set_cursor`: `:s` is an explicit jump, so vim
724            // resets `curswant` to the column it lands on.
725            ed.jump_cursor(row, 0);
726        }
727        ed.mark_content_dirty();
728    }
729
730    applied
731}
732
733/// Split `s` on unescaped `/`. Each `\/` in `s` becomes a literal `/`
734/// in the output segment. Other `\x` sequences pass through unchanged
735/// (so regex escape syntax survives).
736///
737/// Returns at most 3 segments: `[pattern, replacement, flags]`. Anything
738/// after the third `/` is absorbed into the flags segment.
739fn split_on_slash(s: &str) -> Vec<String> {
740    let mut out: Vec<String> = Vec::new();
741    let mut cur = String::new();
742    let mut chars = s.chars().peekable();
743    while let Some(c) = chars.next() {
744        if c == '\\' {
745            match chars.peek() {
746                Some(&'/') => {
747                    // Escaped delimiter → literal slash in this segment.
748                    cur.push('/');
749                    chars.next();
750                }
751                Some(_) => {
752                    // Any other escape: preserve both chars so regex
753                    // syntax (\d, \s, \1, \n …) survives.
754                    let next = chars.next().unwrap();
755                    cur.push('\\');
756                    cur.push(next);
757                }
758                None => cur.push('\\'),
759            }
760        } else if c == '/' {
761            if out.len() < 2 {
762                out.push(std::mem::take(&mut cur));
763            } else {
764                // Third delimiter found: treat rest as flags.
765                // Everything up to this point was the replacement;
766                // collect the flags into `cur` and break.
767                cur.push(c);
768                // Keep going to collect remaining chars as flags.
769                // (Actually we already consumed the `/`, so just let
770                // the outer loop continue accumulating into cur.)
771            }
772        } else {
773            cur.push(c);
774        }
775    }
776    out.push(cur);
777    out
778}
779
780/// The persistent (span) case transformation set by `\U` / `\L`, cleared by
781/// `\E` / `\e`.
782#[derive(Clone, Copy, PartialEq)]
783enum SpanCase {
784    None,
785    /// `\U` — uppercase until `\E`.
786    Upper,
787    /// `\L` — lowercase until `\E`.
788    Lower,
789}
790
791/// The one-shot case transformation set by `\u` / `\l`. Takes priority over
792/// [`SpanCase`] for exactly the next char, then reverts to whatever span was
793/// active — it does NOT clear the span (vim: `\U\l&` on `"hello"` produces
794/// `"hELLO"`, not `"hello"` — the lowercase-next-char applies, then the
795/// active `\U` span resumes for the rest of the match).
796#[derive(Clone, Copy, PartialEq)]
797enum OneShotCase {
798    Upper,
799    Lower,
800}
801
802/// Combined case-transformation state threaded through [`expand_into`].
803#[derive(Clone, Copy, PartialEq)]
804struct CaseState {
805    span: SpanCase,
806    one_shot: Option<OneShotCase>,
807}
808
809impl CaseState {
810    fn new() -> Self {
811        Self {
812            span: SpanCase::None,
813            one_shot: None,
814        }
815    }
816}
817
818/// Push `ch` into `out`, applying the active case state. A pending one-shot
819/// (`\u`/`\l`) wins for this single char and is then consumed, falling back
820/// to the span (`\U`/`\L`) state — which persists — for subsequent chars.
821fn push_cased(out: &mut String, case: &mut CaseState, ch: char) {
822    let effective = match case.one_shot.take() {
823        Some(OneShotCase::Upper) => Some(SpanCase::Upper),
824        Some(OneShotCase::Lower) => Some(SpanCase::Lower),
825        None => match case.span {
826            SpanCase::None => None,
827            other => Some(other),
828        },
829    };
830    match effective {
831        None => out.push(ch),
832        Some(SpanCase::Upper) => out.extend(ch.to_uppercase()),
833        Some(SpanCase::Lower) => out.extend(ch.to_lowercase()),
834        Some(SpanCase::None) => unreachable!(),
835    }
836}
837
838/// Expand a raw vim replacement string against a single regex match.
839///
840/// Handles vim's `:h sub-replace-special` tokens:
841/// - `&` / `\0` — whole match; `\1`…`\9` — capture groups; `\&` — literal `&`.
842/// - `\r` — line break, `\t` — tab, `\n` — NUL.
843/// - `\u`/`\l` — upper/lowercase the next char; `\U`/`\L` … `\E`/`\e` — upper/
844///   lowercase a run.
845/// - `~` — the previous replacement string (`prev`), re-expanded against this
846///   match; `\~` — literal `~`.
847/// - `\\` — literal backslash; any other `\x` — literal `x`.
848///
849/// A plain `$` is literal (unlike the regex crate's `$`-expansion, which this
850/// deliberately does not use).
851fn expand_replacement(raw: &str, caps: &regex::Captures, prev: &str) -> String {
852    let mut out = String::with_capacity(raw.len() + 8);
853    expand_into(&mut out, raw, caps, prev, true);
854    out
855}
856
857fn expand_into(out: &mut String, raw: &str, caps: &regex::Captures, prev: &str, allow_tilde: bool) {
858    let mut case = CaseState::new();
859    let mut chars = raw.chars();
860    while let Some(c) = chars.next() {
861        match c {
862            '&' => {
863                let g = caps.get(0).map_or("", |m| m.as_str());
864                for ch in g.chars() {
865                    push_cased(out, &mut case, ch);
866                }
867            }
868            '~' if allow_tilde => {
869                // Previous replacement, re-expanded against this match. A `~`
870                // nested inside `prev` is treated literally to avoid recursion.
871                let mut tmp = String::new();
872                expand_into(&mut tmp, prev, caps, "", false);
873                for ch in tmp.chars() {
874                    push_cased(out, &mut case, ch);
875                }
876            }
877            '\\' => match chars.next() {
878                Some('&') => push_cased(out, &mut case, '&'),
879                Some('~') => push_cased(out, &mut case, '~'),
880                Some('\\') => push_cased(out, &mut case, '\\'),
881                // Control chars ignore case state (nothing to case).
882                Some('r') => out.push('\n'),
883                Some('t') => out.push('\t'),
884                Some('n') => out.push('\0'),
885                Some(d @ '0'..='9') => {
886                    let idx = d as usize - '0' as usize;
887                    let g = caps.get(idx).map_or("", |m| m.as_str());
888                    for ch in g.chars() {
889                        push_cased(out, &mut case, ch);
890                    }
891                }
892                Some('u') => case.one_shot = Some(OneShotCase::Upper),
893                Some('l') => case.one_shot = Some(OneShotCase::Lower),
894                Some('U') => case.span = SpanCase::Upper,
895                Some('L') => case.span = SpanCase::Lower,
896                Some('e') | Some('E') => case.span = SpanCase::None,
897                Some(other) => push_cased(out, &mut case, other),
898                None => {} // trailing backslash ignored
899            },
900            _ => push_cased(out, &mut case, c),
901        }
902    }
903}
904
905/// Replace the first or all occurrences of `regex` in `text`, expanding the
906/// raw vim `replacement` (with `prev` for `~`) per match. Returns
907/// `(new_text, count)`.
908fn do_replace(
909    regex: &Regex,
910    text: &str,
911    replacement: &str,
912    prev: &str,
913    all: bool,
914) -> (String, usize) {
915    let matches = regex.find_iter(text).count();
916    if matches == 0 {
917        return (text.to_string(), 0);
918    }
919    let rep = |caps: &regex::Captures| expand_replacement(replacement, caps, prev);
920    let replaced = if all {
921        regex.replace_all(text, rep).into_owned()
922    } else {
923        regex.replace(text, rep).into_owned()
924    };
925    let count = if all { matches } else { 1 };
926    (replaced, count)
927}
928
929#[cfg(test)]
930mod tests {
931    use super::*;
932    use crate::types::{DefaultHost, Options};
933    use hjkl_buffer::View;
934
935    fn editor_with(content: &str) -> Editor<View, DefaultHost> {
936        let mut e = Editor::new(View::new(), DefaultHost::new(), Options::default());
937        e.buffer_mut().set_change_log_enabled(true);
938        e.set_content(content);
939        e
940    }
941
942    fn buf_line(e: &Editor<View, DefaultHost>, row: usize) -> String {
943        hjkl_buffer::rope_line_str(&e.buffer().rope(), row)
944    }
945
946    // ── Parser tests ─────────────────────────────────────────────────
947
948    #[test]
949    fn parse_basic() {
950        let cmd = parse_substitute("/foo/bar/").unwrap();
951        assert_eq!(cmd.pattern.as_deref(), Some("foo"));
952        assert_eq!(cmd.replacement, "bar");
953        assert!(!cmd.flags.all);
954    }
955
956    #[test]
957    fn parse_trailing_slash_optional() {
958        let cmd = parse_substitute("/foo/bar").unwrap();
959        assert_eq!(cmd.pattern.as_deref(), Some("foo"));
960        assert_eq!(cmd.replacement, "bar");
961    }
962
963    #[test]
964    fn parse_global_flag() {
965        let cmd = parse_substitute("/x/y/g").unwrap();
966        assert!(cmd.flags.all);
967    }
968
969    #[test]
970    fn parse_ignore_case_flag() {
971        let cmd = parse_substitute("/x/y/i").unwrap();
972        assert!(cmd.flags.ignore_case);
973    }
974
975    #[test]
976    fn parse_case_sensitive_flag() {
977        let cmd = parse_substitute("/x/y/I").unwrap();
978        assert!(cmd.flags.case_sensitive);
979    }
980
981    #[test]
982    fn parse_confirm_flag_accepted() {
983        let cmd = parse_substitute("/x/y/c").unwrap();
984        assert!(cmd.flags.confirm);
985    }
986
987    #[test]
988    fn parse_multi_flags() {
989        let cmd = parse_substitute("/x/y/gi").unwrap();
990        assert!(cmd.flags.all);
991        assert!(cmd.flags.ignore_case);
992    }
993
994    #[test]
995    fn parse_unknown_flag_errors() {
996        let err = parse_substitute("/x/y/z").unwrap_err();
997        assert!(err.to_string().contains("unknown flag 'z'"), "{err}");
998    }
999
1000    #[test]
1001    fn parse_empty_pattern_is_none() {
1002        let cmd = parse_substitute("//bar/").unwrap();
1003        assert!(cmd.pattern.is_none());
1004        assert_eq!(cmd.replacement, "bar");
1005    }
1006
1007    #[test]
1008    fn parse_empty_replacement_ok() {
1009        let cmd = parse_substitute("/foo//").unwrap();
1010        assert_eq!(cmd.pattern.as_deref(), Some("foo"));
1011        assert_eq!(cmd.replacement, "");
1012    }
1013
1014    #[test]
1015    fn parse_escaped_slash_in_pattern() {
1016        let cmd = parse_substitute("/a\\/b/c/").unwrap();
1017        assert_eq!(cmd.pattern.as_deref(), Some("a/b"));
1018    }
1019
1020    #[test]
1021    fn parse_escaped_slash_in_replacement() {
1022        let cmd = parse_substitute("/a/b\\/c/").unwrap();
1023        // Replacement is already translated; literal / survives.
1024        assert_eq!(cmd.replacement, "b/c");
1025    }
1026
1027    // The parser stores the replacement in RAW vim notation; expansion (below)
1028    // resolves `&` / `\1` / `\&` etc. per match.
1029    #[test]
1030    fn parse_keeps_replacement_raw() {
1031        assert_eq!(parse_substitute("/foo/[&]/").unwrap().replacement, "[&]");
1032        assert_eq!(parse_substitute("/foo/\\&/").unwrap().replacement, "\\&");
1033        assert_eq!(parse_substitute("/(foo)/\\1/").unwrap().replacement, "\\1");
1034        assert_eq!(parse_substitute("/(x)/\\9/").unwrap().replacement, "\\9");
1035    }
1036
1037    #[test]
1038    fn parse_wrong_delimiter_errors() {
1039        let err = parse_substitute("|foo|bar|").unwrap_err();
1040        assert!(err.to_string().contains("'/'"), "{err}");
1041    }
1042
1043    #[test]
1044    fn parse_too_few_fields_errors() {
1045        let err = parse_substitute("/foo").unwrap_err();
1046        assert!(
1047            err.to_string().contains("needs /pattern/replacement"),
1048            "{err}"
1049        );
1050    }
1051
1052    // ── Apply tests ──────────────────────────────────────────────────
1053
1054    #[test]
1055    fn apply_single_line_first_only() {
1056        let mut e = editor_with("foo foo");
1057        let cmd = parse_substitute("/foo/bar/").unwrap();
1058        let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1059        assert_eq!(out.replacements, 1);
1060        assert_eq!(out.lines_changed, 1);
1061        assert_eq!(buf_line(&e, 0), "bar foo");
1062    }
1063
1064    #[test]
1065    fn apply_single_line_global() {
1066        let mut e = editor_with("foo foo foo");
1067        let cmd = parse_substitute("/foo/bar/g").unwrap();
1068        let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1069        assert_eq!(out.replacements, 3);
1070        assert_eq!(out.lines_changed, 1);
1071        assert_eq!(buf_line(&e, 0), "bar bar bar");
1072    }
1073
1074    #[test]
1075    fn apply_multi_line_range() {
1076        let mut e = editor_with("foo\nfoo foo\nbar");
1077        let cmd = parse_substitute("/foo/xyz/g").unwrap();
1078        let out = apply_substitute(&mut e, &cmd, 0..=2).unwrap();
1079        assert_eq!(out.replacements, 3);
1080        assert_eq!(out.lines_changed, 2);
1081        assert_eq!(buf_line(&e, 0), "xyz");
1082        assert_eq!(buf_line(&e, 1), "xyz xyz");
1083        assert_eq!(buf_line(&e, 2), "bar");
1084    }
1085
1086    #[test]
1087    fn apply_no_match_returns_zero() {
1088        let mut e = editor_with("hello");
1089        let original = buf_line(&e, 0);
1090        let cmd = parse_substitute("/xyz/abc/").unwrap();
1091        let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1092        assert_eq!(out.replacements, 0);
1093        assert_eq!(out.lines_changed, 0);
1094        assert_eq!(buf_line(&e, 0), original);
1095    }
1096
1097    #[test]
1098    fn apply_case_insensitive_flag() {
1099        let mut e = editor_with("Foo FOO foo");
1100        let cmd = parse_substitute("/foo/bar/gi").unwrap();
1101        let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1102        assert_eq!(out.replacements, 3);
1103        assert_eq!(buf_line(&e, 0), "bar bar bar");
1104    }
1105
1106    #[test]
1107    fn apply_case_sensitive_flag_overrides_editor_setting() {
1108        let mut e = editor_with("Foo foo");
1109        // Enable ignorecase on the editor.
1110        e.settings_mut().ignore_case = true;
1111        // `I` (capital) forces case-sensitive.
1112        let cmd = parse_substitute("/foo/bar/I").unwrap();
1113        let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1114        // Only the lowercase "foo" matches.
1115        assert_eq!(out.replacements, 1);
1116        assert_eq!(buf_line(&e, 0), "Foo bar");
1117    }
1118
1119    #[test]
1120    fn apply_inline_case_override_wins_over_flag() {
1121        let mut insensitive = editor_with("Foo FOO foo");
1122        let cmd = parse_substitute("/\\cFOO/bar/I").unwrap();
1123        let out = apply_substitute(&mut insensitive, &cmd, 0..=0).unwrap();
1124        assert_eq!(out.replacements, 1);
1125        assert_eq!(buf_line(&insensitive, 0), "bar FOO foo");
1126
1127        let mut sensitive = editor_with("Foo FOO foo");
1128        let cmd = parse_substitute("/\\Cfoo/bar/i").unwrap();
1129        let out = apply_substitute(&mut sensitive, &cmd, 0..=0).unwrap();
1130        assert_eq!(out.replacements, 1);
1131        assert_eq!(buf_line(&sensitive, 0), "Foo FOO bar");
1132    }
1133
1134    #[test]
1135    fn apply_empty_pattern_reuses_last_search() {
1136        let mut e = editor_with("hello world");
1137        e.set_last_search(Some("world".to_string()), true);
1138        let cmd = parse_substitute("//planet/").unwrap();
1139        let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1140        assert_eq!(out.replacements, 1);
1141        assert_eq!(buf_line(&e, 0), "hello planet");
1142    }
1143
1144    #[test]
1145    fn apply_empty_pattern_no_last_search_errors() {
1146        let mut e = editor_with("hello");
1147        let cmd = parse_substitute("//bar/").unwrap();
1148        let err = apply_substitute(&mut e, &cmd, 0..=0).unwrap_err();
1149        assert!(
1150            err.to_string().contains("no previous regular expression"),
1151            "{err}"
1152        );
1153    }
1154
1155    #[test]
1156    fn apply_updates_last_search() {
1157        let mut e = editor_with("foo");
1158        let cmd = parse_substitute("/foo/bar/").unwrap();
1159        apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1160        assert_eq!(e.last_search(), Some("foo".to_string()));
1161    }
1162
1163    #[test]
1164    fn apply_empty_replacement_deletes_match() {
1165        let mut e = editor_with("hello world");
1166        let cmd = parse_substitute("/world//").unwrap();
1167        let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1168        assert_eq!(out.replacements, 1);
1169        assert_eq!(buf_line(&e, 0), "hello ");
1170    }
1171
1172    #[test]
1173    fn apply_undo_reverts_in_one_step() {
1174        let mut e = editor_with("foo");
1175        let cmd = parse_substitute("/foo/bar/").unwrap();
1176        apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1177        assert_eq!(buf_line(&e, 0), "bar");
1178        e.undo();
1179        assert_eq!(buf_line(&e, 0), "foo");
1180    }
1181
1182    #[test]
1183    fn apply_ampersand_in_replacement() {
1184        let mut e = editor_with("foo");
1185        let cmd = parse_substitute("/foo/[&]/").unwrap();
1186        apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1187        assert_eq!(buf_line(&e, 0), "[foo]");
1188    }
1189
1190    #[test]
1191    fn apply_capture_group_reference() {
1192        let mut e = editor_with("hello world");
1193        // Vim default magic: groups need `\(` `\)`; `+` needs `\+`.
1194        let cmd = parse_substitute("/\\(\\w\\+\\)/<<\\1>>/g").unwrap();
1195        apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1196        assert_eq!(buf_line(&e, 0), "<<hello>> <<world>>");
1197    }
1198
1199    #[test]
1200    fn apply_backslash_r_splits_line() {
1201        // `\r` in the replacement inserts a line break (the split-on-delimiter
1202        // idiom): `:s/,/\r/g` turns one line into three.
1203        let mut e = editor_with("a,b,c");
1204        let cmd = parse_substitute("/,/\\r/g").unwrap();
1205        apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1206        assert_eq!(buf_line(&e, 0), "a");
1207        assert_eq!(buf_line(&e, 1), "b");
1208        assert_eq!(buf_line(&e, 2), "c");
1209    }
1210
1211    /// Audit A5 regression: `:%s/,/\r/` across a multi-row range where an
1212    /// earlier row's replacement also splits into extra rows. The recorded
1213    /// "last changed row" must be adjusted into POST-substitution row space
1214    /// (vim lands on the first non-blank of the last changed line, `d`, real
1215    /// row 3) — not the PRE-split row index (which would land on `b`, row 1).
1216    #[test]
1217    fn apply_backslash_r_multi_row_cursor_lands_on_final_split_row() {
1218        let mut e = editor_with("a,b\nc,d\n");
1219        let cmd = parse_substitute("/,/\\r/").unwrap();
1220        let total = crate::types::Query::rope(e.buffer()).len_lines();
1221        let out = apply_substitute(&mut e, &cmd, 0..=(total.saturating_sub(1)) as u32).unwrap();
1222        assert_eq!(buf_line(&e, 0), "a");
1223        assert_eq!(buf_line(&e, 1), "b");
1224        assert_eq!(buf_line(&e, 2), "c");
1225        assert_eq!(buf_line(&e, 3), "d");
1226        assert_eq!(
1227            out.last_row,
1228            Some(3),
1229            "cursor should land on the last changed line ('d', real row 3) \
1230             in post-split coordinates, not the pre-split row index"
1231        );
1232        assert_eq!(e.buffer().cursor().row, 3);
1233    }
1234
1235    /// Single-row case: `\r` splitting one line into several must still put
1236    /// the cursor on the LAST resulting physical line (vim semantics for a
1237    /// single `:s` invocation whose replacement itself contains newlines).
1238    #[test]
1239    fn apply_backslash_r_single_row_cursor_lands_on_last_split_line() {
1240        let mut e = editor_with("a,b");
1241        let cmd = parse_substitute("/,/\\r/").unwrap();
1242        let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1243        assert_eq!(buf_line(&e, 0), "a");
1244        assert_eq!(buf_line(&e, 1), "b");
1245        assert_eq!(out.last_row, Some(1));
1246        assert_eq!(e.buffer().cursor().row, 1);
1247    }
1248
1249    /// Guard the common (no-newline) multi-row path: cursor still lands on
1250    /// the last changed row with no coordinate adjustment needed.
1251    #[test]
1252    fn apply_no_newline_multi_row_cursor_unaffected() {
1253        let mut e = editor_with("a\na\na");
1254        let cmd = parse_substitute("/a/X/").unwrap();
1255        let out = apply_substitute(&mut e, &cmd, 0..=2).unwrap();
1256        assert_eq!(buf_line(&e, 0), "X");
1257        assert_eq!(buf_line(&e, 1), "X");
1258        assert_eq!(buf_line(&e, 2), "X");
1259        assert_eq!(out.last_row, Some(2));
1260        assert_eq!(e.buffer().cursor().row, 2);
1261    }
1262
1263    /// Regression: `:s/a/b\r/` over the rows ABOVE a marked line adds one row
1264    /// per matched line. `replace_all` (unlike `mutate_edit`) never rebases
1265    /// marks, so the mark used to keep its stale pre-substitute row — `'a`
1266    /// landed on the row that slid down, not the marked text. Must FAIL on the
1267    /// old code.
1268    #[test]
1269    fn apply_backslash_r_rebases_marks_below_change() {
1270        // 10 rows; mark 'a' on row 8 ("X"). Rows 0..=7 each contain 'a'.
1271        let mut e = editor_with("a\na\na\na\na\na\na\na\nX\nlast");
1272        e.set_mark('a', (8, 0));
1273        let cmd = parse_substitute("/a/b\\r/").unwrap();
1274        let out = apply_substitute(&mut e, &cmd, 0..=7).unwrap();
1275        assert_eq!(out.replacements, 8);
1276        // Each matched row 0..=7 splits into two ("b", ""), so row 8 → 16.
1277        assert_eq!(
1278            e.mark('a'),
1279            Some((16, 0)),
1280            "mark below the substitution must shift by the rows added above it"
1281        );
1282        assert_eq!(buf_line(&e, 16), "X", "the marked text now lives on row 16");
1283    }
1284
1285    /// A substitute with no `\r` in the replacement changes no row count, so
1286    /// marks must be left exactly where they were.
1287    #[test]
1288    fn apply_no_newline_substitute_leaves_marks_untouched() {
1289        let mut e = editor_with("a\na\na\na\na\na\na\na\nX\nlast");
1290        e.set_mark('a', (8, 0));
1291        let cmd = parse_substitute("/a/b/").unwrap();
1292        let out = apply_substitute(&mut e, &cmd, 0..=7).unwrap();
1293        assert_eq!(out.replacements, 8);
1294        assert_eq!(e.mark('a'), Some((8, 0)), "delta 0 must not move the mark");
1295    }
1296
1297    /// Same rebase regression for the `:s///c` confirm path
1298    /// (`apply_collected_matches`), which has its own `replace_all`.
1299    #[test]
1300    fn apply_collected_matches_backslash_r_rebases_marks_below_change() {
1301        let mut e = editor_with("a\na\na\na\na\na\na\na\nX\nlast");
1302        e.set_mark('a', (8, 0));
1303        let cmd = parse_substitute("/a/b\\r/").unwrap();
1304        let matches = collect_substitute_matches(&e, &cmd, 0..=7).unwrap();
1305        assert_eq!(matches.len(), 8);
1306        let accepted = vec![true; matches.len()];
1307        let applied = apply_collected_matches(&mut e, &matches, &accepted);
1308        assert_eq!(applied, 8);
1309        assert_eq!(
1310            e.mark('a'),
1311            Some((16, 0)),
1312            "confirm-path mark must shift by the rows added above it"
1313        );
1314        assert_eq!(buf_line(&e, 16), "X");
1315    }
1316
1317    // ── range splice mechanics (perf: apply_substitute materializes only the
1318    //    substituted rows and splices them back via a range remove + insert) ──
1319
1320    /// A range that stops before the last row must splice only its own rows:
1321    /// the suffix (and the `\n` separating it from the range) stays untouched.
1322    #[test]
1323    fn splice_range_not_covering_last_row_keeps_suffix() {
1324        let mut e = editor_with("x\ny\nz");
1325        let cmd = parse_substitute("/x/X/").unwrap();
1326        let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1327        assert_eq!(out.replacements, 1);
1328        assert_eq!(e.buffer().rope().to_string(), "X\ny\nz");
1329        assert_eq!(buf_line(&e, 0), "X");
1330        assert_eq!(buf_line(&e, 2), "z");
1331    }
1332
1333    /// A buffer ending in `\n` has a phantom empty last row; splicing a range
1334    /// that stops before it must not re-add or drop the trailing newline.
1335    #[test]
1336    fn splice_preserves_trailing_newline_and_phantom_row() {
1337        let mut e = editor_with("a\nb\nc\n");
1338        let cmd = parse_substitute("/a/A/").unwrap();
1339        apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1340        assert_eq!(e.buffer().rope().to_string(), "A\nb\nc\n");
1341    }
1342
1343    /// The splice's end column is a char (not byte) coordinate: a multibyte
1344    /// row above the range must not shift the removal, and a multibyte last
1345    /// range row must be fully consumed.
1346    #[test]
1347    fn splice_char_columns_handle_multibyte() {
1348        // Multibyte row above the substituted row: line_to_char(1) must land
1349        // after "héllo", not after 5 bytes.
1350        let mut e = editor_with("héllo\nworld");
1351        let cmd = parse_substitute("/world/X/").unwrap();
1352        apply_substitute(&mut e, &cmd, 1..=1).unwrap();
1353        assert_eq!(e.buffer().rope().to_string(), "héllo\nX");
1354
1355        // Multibyte content inside the last range row: the whole row must be
1356        // removed (end col = char count, not byte count).
1357        let mut e = editor_with("wörld");
1358        let cmd = parse_substitute("/wörld/X/").unwrap();
1359        apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1360        assert_eq!(e.buffer().rope().to_string(), "X");
1361    }
1362
1363    /// The join reconstruction must round-trip CRLF rows exactly (a CRLF
1364    /// row's trailing `\r` is content, not a separator).
1365    #[test]
1366    fn splice_preserves_crlf_rows() {
1367        let mut e = editor_with("a\r\nb\r\nc");
1368        let cmd = parse_substitute("/b/X/").unwrap();
1369        apply_substitute(&mut e, &cmd, 1..=1).unwrap();
1370        assert_eq!(e.buffer().rope().to_string(), "a\r\nX\r\nc");
1371    }
1372
1373    // ── vim ASCII classes in substitute (`\d`/`\s`/`\w`) ─────────────────────
1374
1375    /// vim's `\d` is ASCII-only: `:s/\d/X/g` must leave the Arabic-Indic
1376    /// digit U+0663 alone while rewriting ASCII digits (the §1.11 finding —
1377    /// rust-regex's Unicode `\d` used to match it).
1378    #[test]
1379    fn substitute_ascii_digit_class_leaves_non_ascii_digits() {
1380        let mut e = editor_with("٣ a 123");
1381        let cmd = parse_substitute(r"/\d/X/g").unwrap();
1382        apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1383        assert_eq!(e.buffer().rope().to_string(), "٣ a XXX");
1384    }
1385
1386    #[test]
1387    fn substitute_ascii_digit_class_does_not_match_unicode_digit() {
1388        let mut e = editor_with("٣");
1389        let cmd = parse_substitute(r"/\d/X/g").unwrap();
1390        apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1391        assert_eq!(e.buffer().rope().to_string(), "٣");
1392    }
1393
1394    // ── host change records (`take_changes` / `take_content_reset` /
1395    //    `take_content_edits`) ──────────────────────────────────────────
1396    //
1397    // `:s` swaps the whole buffer via `replace_all`, bypassing `mutate_edit`,
1398    // so the substitute path must emit the host-visible records itself. Each
1399    // test drains the emission `editor_with`'s `set_content` left behind
1400    // first, so the assertions observe only what the substitute produced.
1401
1402    #[test]
1403    fn substitute_emits_change_log_and_reset() {
1404        let mut e = editor_with("foo\nbar\nbaz");
1405        let _ = e.take_changes();
1406        let _ = e.take_content_reset();
1407        let _ = e.take_content_edits();
1408        let cmd = parse_substitute("/foo/qux/").unwrap();
1409        apply_substitute(&mut e, &cmd, 0..=2).unwrap();
1410        let changes = e.take_changes();
1411        assert_eq!(changes.len(), 1, "one coarse whole-buffer Replace");
1412        assert_eq!(changes[0].range.start, crate::types::Pos::new(0, 0));
1413        assert_eq!(
1414            changes[0].replacement,
1415            e.buffer().rope().to_string(),
1416            "replacement is the full post-state content"
1417        );
1418        assert!(e.take_content_reset(), "syntax hosts must reparse");
1419        assert!(e.take_content_edits().is_empty());
1420        assert!(e.take_changes().is_empty(), "take_changes drains");
1421    }
1422
1423    #[test]
1424    fn substitute_with_newline_emits_change_log_and_reset() {
1425        let mut e = editor_with("a,b\nc,d");
1426        let _ = e.take_changes();
1427        let _ = e.take_content_reset();
1428        let _ = e.take_content_edits();
1429        let cmd = parse_substitute("/,/\\r/").unwrap();
1430        apply_substitute(&mut e, &cmd, 0..=1).unwrap();
1431        // `\r` in the replacement splits each row: rows must gain rows.
1432        assert_eq!(e.buffer().rope().to_string(), "a\nb\nc\nd");
1433        let changes = e.take_changes();
1434        assert_eq!(changes.len(), 1, "one coarse whole-buffer Replace");
1435        assert_eq!(changes[0].range.start, crate::types::Pos::new(0, 0));
1436        assert_eq!(
1437            changes[0].replacement,
1438            e.buffer().rope().to_string(),
1439            "replacement is the full post-state content"
1440        );
1441        assert!(e.take_content_reset(), "syntax hosts must reparse");
1442        assert!(e.take_content_edits().is_empty());
1443        assert!(e.take_changes().is_empty(), "take_changes drains");
1444    }
1445
1446    #[test]
1447    fn collected_substitute_emits_change_log_and_reset() {
1448        let mut e = editor_with("foo\nfoo\nbar");
1449        let _ = e.take_changes();
1450        let _ = e.take_content_reset();
1451        let _ = e.take_content_edits();
1452        let cmd = parse_substitute("/foo/qux/g").unwrap();
1453        let matches = collect_substitute_matches(&e, &cmd, 0..=2).unwrap();
1454        let accepted = vec![true; matches.len()];
1455        let applied = apply_collected_matches(&mut e, &matches, &accepted);
1456        assert_eq!(applied, 2);
1457        let changes = e.take_changes();
1458        assert_eq!(changes.len(), 1, "one coarse whole-buffer Replace");
1459        assert_eq!(changes[0].range.start, crate::types::Pos::new(0, 0));
1460        assert_eq!(
1461            changes[0].replacement,
1462            e.buffer().rope().to_string(),
1463            "replacement is the full post-state content"
1464        );
1465        assert!(e.take_content_reset(), "syntax hosts must reparse");
1466        assert!(e.take_content_edits().is_empty());
1467        assert!(e.take_changes().is_empty(), "take_changes drains");
1468    }
1469
1470    #[test]
1471    fn apply_backslash_t_inserts_tab() {
1472        let mut e = editor_with("a,b");
1473        let cmd = parse_substitute("/,/\\t/").unwrap();
1474        apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1475        assert_eq!(buf_line(&e, 0), "a\tb");
1476    }
1477
1478    #[test]
1479    fn apply_literal_dollar_in_replacement() {
1480        // A literal `$` in the replacement stays literal (vim uses `\1` for
1481        // groups, so `$5` is not a capture ref).
1482        let mut e = editor_with("x");
1483        let cmd = parse_substitute("/x/$5/").unwrap();
1484        apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1485        assert_eq!(buf_line(&e, 0), "$5");
1486    }
1487
1488    #[test]
1489    fn apply_backslash_zero_is_whole_match() {
1490        // `\0` is the whole match (like `&`).
1491        let mut e = editor_with("foo");
1492        let cmd = parse_substitute("/foo/[\\0]/").unwrap();
1493        apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1494        assert_eq!(buf_line(&e, 0), "[foo]");
1495    }
1496
1497    #[test]
1498    fn apply_group_ref_then_literal_digits() {
1499        // Braced capture refs let a digit follow a group ref: `\1` then `1`.
1500        let mut e = editor_with("ab");
1501        let cmd = parse_substitute("/\\(.\\)/\\11/g").unwrap();
1502        apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1503        assert_eq!(buf_line(&e, 0), "a1b1");
1504    }
1505
1506    // ── expand_replacement: case escapes + ~ ──────────────────────────────────
1507
1508    fn expand(raw: &str, pat: &str, text: &str, prev: &str) -> String {
1509        let re = Regex::new(pat).unwrap();
1510        let caps = re.captures(text).unwrap();
1511        expand_replacement(raw, &caps, prev)
1512    }
1513
1514    #[test]
1515    fn expand_case_upper_run_and_end() {
1516        // `\U…\E` uppercases a run; text after `\E` is unaffected.
1517        assert_eq!(expand("\\U\\0\\Ex", "foo", "foo", ""), "FOOx");
1518        assert_eq!(expand("\\L&\\E", "FOO", "FOO", ""), "foo");
1519    }
1520
1521    #[test]
1522    fn expand_case_one_shot() {
1523        // `\u` / `\l` affect only the next char.
1524        assert_eq!(expand("\\u\\0", "foo", "foo", ""), "Foo");
1525        assert_eq!(expand("\\l\\0", "FOO", "FOO", ""), "fOO");
1526    }
1527
1528    #[test]
1529    fn expand_case_applies_to_group() {
1530        // Case escape applied across a capture group and a following literal.
1531        assert_eq!(expand("\\U\\1-y\\E", "(f)oo", "foo", ""), "F-Y");
1532    }
1533
1534    /// B18: `\u&` on a whole-word match — matches vim's
1535    /// `:s/\w\+/\u&/` on `"hello world"` → `"Hello world"` (verified
1536    /// against nvim v0.12.4).
1537    #[test]
1538    fn expand_backslash_u_uppercases_first_char_of_group() {
1539        assert_eq!(expand("\\u\\1", "(\\w+)", "hello world", ""), "Hello");
1540    }
1541
1542    /// A one-shot `\u`/`\l` takes priority for exactly the next char, then
1543    /// FALLS BACK to any active `\U`/`\L` span rather than clearing it —
1544    /// verified against nvim: `:s/\w\+/\U\l&/` on `"hello"` → `"hELLO"`
1545    /// (not `"hello"`, and not `"HELLO"`).
1546    #[test]
1547    fn expand_one_shot_falls_back_to_active_span() {
1548        assert_eq!(expand("\\U\\l\\0", "hello", "hello", ""), "hELLO");
1549        // Same interaction the other way around: `\l\U\1 \2` on
1550        // "hello world" → "hELLO WORLD" (nvim-verified).
1551        assert_eq!(
1552            expand("\\l\\U\\1 \\2", "(\\w+) (\\w+)", "hello world", ""),
1553            "hELLO WORLD"
1554        );
1555    }
1556
1557    #[test]
1558    fn expand_literal_dollar_and_amp() {
1559        assert_eq!(expand("$\\0", "x", "x", ""), "$x");
1560        assert_eq!(expand("[&]", "foo", "foo", ""), "[foo]");
1561        assert_eq!(expand("\\&", "foo", "foo", ""), "&");
1562    }
1563
1564    #[test]
1565    fn expand_tilde_uses_previous_replacement() {
1566        // `~` expands to the previous replacement, re-evaluated against caps.
1567        assert_eq!(expand("~!", "x", "x", "PREV"), "PREV!");
1568        assert_eq!(expand("~", "(.)", "a", "[\\1]"), "[a]");
1569        // `\~` is a literal tilde.
1570        assert_eq!(expand("\\~", "x", "x", "PREV"), "~");
1571    }
1572
1573    // ── `n` flag: report count, no mutation ───────────────────────────────────
1574
1575    #[test]
1576    fn apply_report_only_counts_without_mutating() {
1577        let mut e = editor_with("foo foo foo");
1578        let cmd = parse_substitute("/foo/bar/gn").unwrap();
1579        assert!(cmd.flags.report_only);
1580        let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1581        assert_eq!(out.replacements, 3);
1582        // View is untouched.
1583        assert_eq!(buf_line(&e, 0), "foo foo foo");
1584    }
1585
1586    // ── case escapes through the full apply path ──────────────────────────────
1587
1588    #[test]
1589    fn apply_upper_run() {
1590        let mut e = editor_with("hello world");
1591        let cmd = parse_substitute("/world/\\U&\\E/").unwrap();
1592        apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1593        assert_eq!(buf_line(&e, 0), "hello WORLD");
1594    }
1595
1596    // ── smartcase + \c/\C tests ───────────────────────────────────────────────
1597
1598    /// `:s/foo/bar/` on `"Foo"` — ignorecase+smartcase on by default, all-
1599    /// lowercase pattern → Insensitive → matches `Foo` → becomes `bar`.
1600    #[test]
1601    fn substitute_respects_smartcase() {
1602        let mut e = editor_with("Foo");
1603        // Default Options has ignorecase=true, smartcase=true.
1604        let cmd = parse_substitute("/foo/bar/").unwrap();
1605        let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1606        assert_eq!(out.replacements, 1);
1607        assert_eq!(buf_line(&e, 0), "bar");
1608    }
1609
1610    /// `:s/Foo/bar/i` — `/i` flag overrides smartcase (mixed pattern would
1611    /// normally be Sensitive) → case-insensitive → matches `"foo"`.
1612    #[test]
1613    fn substitute_i_flag_overrides_c() {
1614        let mut e = editor_with("foo");
1615        // /i forces insensitive regardless of pattern case or smartcase.
1616        let cmd = parse_substitute("/Foo/bar/i").unwrap();
1617        let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1618        assert_eq!(out.replacements, 1, "expected match on 'foo' with /i flag");
1619        assert_eq!(buf_line(&e, 0), "bar");
1620    }
1621
1622    /// `\c` inline override in a pattern with no `/i`/`/I` flag — forces
1623    /// insensitive even though `Foo` has uppercase (smartcase trip).
1624    #[test]
1625    fn substitute_lower_c_inline_overrides_smartcase() {
1626        let mut e = editor_with("FOO");
1627        // \cFoo — override wins, Insensitive → matches "FOO"
1628        let cmd = parse_substitute("/\\cFoo/bar/").unwrap();
1629        let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1630        assert_eq!(out.replacements, 1);
1631        assert_eq!(buf_line(&e, 0), "bar");
1632    }
1633
1634    // ── collect_substitute_matches tests ────────────────────────────────────
1635
1636    #[test]
1637    fn collect_inline_case_override_wins_over_flag() {
1638        let e = editor_with("Foo FOO foo");
1639        let cmd = parse_substitute("/\\cFOO/bar/I").unwrap();
1640        assert_eq!(
1641            collect_substitute_matches(&e, &cmd, 0..=0).unwrap().len(),
1642            1
1643        );
1644
1645        let cmd = parse_substitute("/\\Cfoo/bar/i").unwrap();
1646        assert_eq!(
1647            collect_substitute_matches(&e, &cmd, 0..=0).unwrap().len(),
1648            1
1649        );
1650    }
1651
1652    #[test]
1653    fn collect_substitute_matches_finds_all_occurrences() {
1654        let e = editor_with("foo bar foo");
1655        let cmd = parse_substitute("/foo/baz/g").unwrap();
1656        let matches = collect_substitute_matches(&e, &cmd, 0..=0).unwrap();
1657        assert_eq!(matches.len(), 2, "expected 2 matches for /g flag");
1658        assert_eq!(matches[0].byte_start, 0);
1659        assert_eq!(matches[0].byte_end, 3);
1660        assert_eq!(matches[1].byte_start, 8);
1661        assert_eq!(matches[1].byte_end, 11);
1662        assert_eq!(matches[0].replacement, "baz");
1663        assert_eq!(matches[1].replacement, "baz");
1664    }
1665
1666    #[test]
1667    fn collect_substitute_matches_respects_g_flag() {
1668        // Without /g only the first match per line.
1669        let e = editor_with("foo foo foo");
1670        let cmd = parse_substitute("/foo/baz/").unwrap();
1671        let matches = collect_substitute_matches(&e, &cmd, 0..=0).unwrap();
1672        assert_eq!(matches.len(), 1, "expected 1 match without /g");
1673        assert_eq!(matches[0].byte_start, 0);
1674    }
1675
1676    #[test]
1677    fn collect_substitute_matches_respects_range() {
1678        let e = editor_with("foo\nfoo\nfoo\nfoo\nfoo");
1679        let cmd = parse_substitute("/foo/bar/g").unwrap();
1680        // Only rows 1 and 2 (0-based) — should return 2 matches, not 5.
1681        let matches = collect_substitute_matches(&e, &cmd, 1..=2).unwrap();
1682        assert_eq!(matches.len(), 2);
1683        assert_eq!(matches[0].row, 1);
1684        assert_eq!(matches[1].row, 2);
1685    }
1686
1687    #[test]
1688    fn collect_substitute_matches_expands_template() {
1689        let e = editor_with("hello world");
1690        // /\(\w\+\)/<<\1>>/ — the replacement template has a capture group.
1691        let cmd = parse_substitute("/\\(\\w\\+\\)/<<\\1>>/g").unwrap();
1692        let matches = collect_substitute_matches(&e, &cmd, 0..=0).unwrap();
1693        assert_eq!(matches.len(), 2);
1694        assert_eq!(matches[0].replacement, "<<hello>>");
1695        assert_eq!(matches[1].replacement, "<<world>>");
1696    }
1697
1698    // ── apply_collected_matches tests ───────────────────────────────────────
1699
1700    #[test]
1701    fn apply_collected_matches_reverse_order_preserves_offsets() {
1702        // Three matches at byte offsets 0..3, 4..7, 8..11.
1703        // Applying in forward order would shift byte offsets; reverse must
1704        // keep the final buffer consistent.
1705        let mut e = editor_with("foo bar baz");
1706        let cmd = parse_substitute("/\\(foo\\|bar\\|baz\\)/X/g").unwrap();
1707        let matches = collect_substitute_matches(&e, &cmd, 0..=0).unwrap();
1708        assert_eq!(matches.len(), 3);
1709        let accepted = vec![true; 3];
1710        let applied = apply_collected_matches(&mut e, &matches, &accepted);
1711        assert_eq!(applied, 3);
1712        assert_eq!(buf_line(&e, 0), "X X X");
1713    }
1714
1715    #[test]
1716    fn apply_collected_matches_subset_only() {
1717        // 3 matches; accept only first and third.
1718        let mut e = editor_with("foo bar foo");
1719        let cmd = parse_substitute("/foo/ZZZ/g").unwrap();
1720        let matches = collect_substitute_matches(&e, &cmd, 0..=0).unwrap();
1721        assert_eq!(matches.len(), 2, "expected 2 foo matches");
1722        // Accept only the first (index 0), skip the second (index 1).
1723        let accepted = vec![true, false];
1724        let applied = apply_collected_matches(&mut e, &matches, &accepted);
1725        assert_eq!(applied, 1);
1726        // First "foo" replaced; second "foo" untouched.
1727        assert_eq!(buf_line(&e, 0), "ZZZ bar foo");
1728    }
1729
1730    #[test]
1731    fn apply_collected_matches_zero_accepted() {
1732        let mut e = editor_with("foo bar foo");
1733        let cmd = parse_substitute("/foo/ZZZ/g").unwrap();
1734        let matches = collect_substitute_matches(&e, &cmd, 0..=0).unwrap();
1735        let accepted = vec![false; matches.len()];
1736        let applied = apply_collected_matches(&mut e, &matches, &accepted);
1737        assert_eq!(applied, 0);
1738        assert_eq!(buf_line(&e, 0), "foo bar foo");
1739    }
1740
1741    #[test]
1742    fn apply_collected_matches_expands_template() {
1743        let mut e = editor_with("hello world");
1744        let cmd = parse_substitute("/\\(\\w\\+\\)/<<\\1>>/g").unwrap();
1745        let matches = collect_substitute_matches(&e, &cmd, 0..=0).unwrap();
1746        let accepted = vec![true; matches.len()];
1747        let applied = apply_collected_matches(&mut e, &matches, &accepted);
1748        assert_eq!(applied, 2);
1749        assert_eq!(buf_line(&e, 0), "<<hello>> <<world>>");
1750    }
1751
1752    // ── V5: magic `~` on the PATTERN side of `:s` and `/`/`?` ─────────────────
1753    // `apply_substitute` does NOT store `last_substitute` itself (the ex layer
1754    // does), so these tests set it explicitly to simulate a prior `:s`.
1755
1756    /// nvim-verified: `:s/foo/BAR/` then `:s/~/baz/` — the second command's
1757    /// pattern `~` expands to `BAR`, matches the just-inserted `BAR`, → `baz`.
1758    #[test]
1759    fn pattern_tilde_expands_to_last_substitute() {
1760        let mut e = editor_with("foo");
1761        let first = parse_substitute("/foo/BAR/").unwrap();
1762        apply_substitute(&mut e, &first, 0..=0).unwrap();
1763        assert_eq!(buf_line(&e, 0), "BAR");
1764        e.set_last_substitute(first); // ex layer normally does this
1765
1766        let second = parse_substitute("/~/baz/").unwrap();
1767        let out = apply_substitute(&mut e, &second, 0..=0).unwrap();
1768        assert_eq!(out.replacements, 1, "pattern `~` must match `BAR`");
1769        assert_eq!(buf_line(&e, 0), "baz");
1770    }
1771
1772    /// nvim-verified: `\~` in the pattern is a literal tilde — it matches a real
1773    /// `~` character and does NOT expand to the last-substitute text.
1774    #[test]
1775    fn pattern_escaped_tilde_stays_literal() {
1776        let mut e = editor_with("a~b");
1777        // Prior `:s` set the last replacement to BAR; `\~` must ignore it.
1778        e.set_last_substitute(parse_substitute("/x/BAR/").unwrap());
1779        let cmd = parse_substitute("/\\~/X/").unwrap();
1780        let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1781        assert_eq!(out.replacements, 1, "`\\~` must match the literal tilde");
1782        assert_eq!(buf_line(&e, 0), "aXb");
1783    }
1784
1785    /// No previous substitute → pattern `~` expands to empty (documented
1786    /// divergence from nvim's `E33`; the empty choice never corrupts text).
1787    /// Here `:s/a~b/X/` on `"ab"` becomes pattern `ab`, which matches → `X`.
1788    #[test]
1789    fn pattern_tilde_no_previous_substitute_expands_empty() {
1790        let mut e = editor_with("ab");
1791        assert!(e.last_substitute().is_none());
1792        let cmd = parse_substitute("/a~b/X/").unwrap();
1793        let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1794        assert_eq!(out.replacements, 1, "`~`→empty so pattern is `ab`");
1795        assert_eq!(buf_line(&e, 0), "X");
1796    }
1797
1798    /// A `/` search routes through the SAME `resolve_case_mode` path as the
1799    /// `:s` LHS (`Editor::push_search_pattern`), so one test covers the shared
1800    /// path: after a prior `:s/foo/BAR/`, searching `/~` compiles a regex that
1801    /// matches `BAR`. nvim-verified: `/~` finds the last-substitute text.
1802    #[test]
1803    fn search_pattern_tilde_shares_expansion_path() {
1804        let mut e = editor_with("BAR");
1805        e.set_last_substitute(parse_substitute("/foo/BAR/").unwrap());
1806        e.push_search_pattern("~");
1807        let re = e
1808            .search_state()
1809            .pattern
1810            .as_ref()
1811            .expect("`/~` must compile to a pattern");
1812        assert!(re.is_match("BAR"), "search `~` must expand to `BAR`");
1813        assert!(
1814            !re.is_match("~"),
1815            "search `~` must not match a literal tilde"
1816        );
1817    }
1818
1819    // ── curswant (sticky_col) reset ────────────────────────────────────────
1820
1821    /// `:s` is an explicit jump, so vim resets `curswant` to the column the
1822    /// cursor lands on — the next `j`/`k` must aim there, not at the column
1823    /// held before the substitute. Verified against neovim 0.12.4: `$` on row
1824    /// 0 of `"abcdefgh\nab\nabcdefgh"`, then `:2s/ab/XX/`, then `j`, lands on
1825    /// `(2, 0)`.
1826    #[test]
1827    fn apply_substitute_resets_sticky_col_to_the_landed_column() {
1828        let mut e = editor_with("abcdefgh\nab\nabcdefgh");
1829        e.jump_cursor(0, 7);
1830        assert_eq!(e.sticky_col(), Some(7), "seeded curswant");
1831        let cmd = parse_substitute("/ab/XX/").unwrap();
1832        assert_eq!(
1833            apply_substitute(&mut e, &cmd, 1..=1).unwrap().replacements,
1834            1
1835        );
1836        assert_eq!(e.cursor(), (1, 0), "cursor lands on the changed line");
1837        assert_eq!(e.sticky_col(), Some(0), "curswant follows the cursor");
1838    }
1839
1840    /// Same reset on the `:s///c` confirm path, which lands the cursor through
1841    /// a different function.
1842    #[test]
1843    fn apply_collected_matches_resets_sticky_col_to_the_landed_column() {
1844        let mut e = editor_with("abcdefgh\nab\nabcdefgh");
1845        e.jump_cursor(0, 7);
1846        assert_eq!(e.sticky_col(), Some(7), "seeded curswant");
1847        let cmd = parse_substitute("/ab/XX/").unwrap();
1848        let matches = collect_substitute_matches(&e, &cmd, 1..=1).unwrap();
1849        assert_eq!(matches.len(), 1);
1850        let accepted: Vec<bool> = vec![true];
1851        assert_eq!(apply_collected_matches(&mut e, &matches, &accepted), 1);
1852        assert_eq!(e.cursor(), (1, 0));
1853        assert_eq!(e.sticky_col(), Some(0), "curswant follows the cursor");
1854    }
1855}