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