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