Skip to main content

hjkl_engine/
substitute.rs

1//! Public substitute command parser and applicator.
2//!
3//! Exposes [`parse_substitute`] and [`apply_substitute`] for the
4//! `:[range]s/pattern/replacement/[flags]` ex command.
5//!
6//! ## Vim compatibility notes (v1 limitations)
7//!
8//! - Delimiter is **always `/`**. Alternate delimiters (`s|x|y|`,
9//!   `s#x#y#`) are not supported. The parser returns an error when the
10//!   first character after the keyword is not `/`.
11//! - The `c` (confirm) flag triggers interactive replacement. Each match
12//!   is presented one-by-one; the user chooses y/n/a/q/l. See
13//!   [`collect_substitute_matches`] and [`apply_collected_matches`].
14//! - The `\v` very-magic mode is not supported. The regex crate uses
15//!   ERE syntax by default. Most ERE patterns work, but vim-specific
16//!   extensions (`\<`, `\>`, `\s`, `\+`) may not. Use POSIX ERE
17//!   equivalents or the `regex` crate's syntax.
18//! - Capture-group references use vim notation (`\0`…`\9`, `&`); the parser
19//!   translates them to braced `${0}`…`${9}` for the `regex` crate, escapes a
20//!   literal `$` to `$$`, and maps `\r`/`\t`/`\n` to a line break / tab / NUL.
21//!
22//! See vim's `:help :substitute` for the full spec.
23
24use regex::Regex;
25
26use crate::Editor;
27
28/// Error type returned by [`parse_substitute`] and [`apply_substitute`].
29pub type SubstError = String;
30
31/// Parsed `:s/pattern/replacement/flags` command.
32///
33/// Produced by [`parse_substitute`]. Pass to [`apply_substitute`].
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct SubstituteCmd {
36    /// The literal pattern string. `None` means "reuse `last_search`
37    /// from the editor" (the user typed `:s//replacement/`).
38    pub pattern: Option<String>,
39    /// The replacement string in vim notation (`&`, `\1`…`\9`).
40    /// Empty string deletes the match.
41    pub replacement: String,
42    /// Parsed flags.
43    pub flags: SubstFlags,
44}
45
46/// Flags for the substitute command.
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
48pub struct SubstFlags {
49    /// `g` — replace all occurrences on each line (default: first only).
50    pub all: bool,
51    /// `i` — case-insensitive (overrides editor `ignorecase`).
52    pub ignore_case: bool,
53    /// `I` — case-sensitive (overrides editor `ignorecase`).
54    pub case_sensitive: bool,
55    /// `c` — confirm mode. When set, [`apply_substitute`] skips all matches
56    /// and the caller must use [`collect_substitute_matches`] +
57    /// [`apply_collected_matches`] for interactive replacement.
58    pub confirm: bool,
59}
60
61/// Result of [`apply_substitute`].
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
63pub struct SubstituteOutcome {
64    /// Total number of individual replacements made across all lines.
65    pub replacements: usize,
66    /// Number of lines that had at least one replacement.
67    pub lines_changed: usize,
68}
69
70/// Parse the tail of a substitute command (everything after the leading
71/// `s` / `substitute` keyword).
72///
73/// # Examples
74///
75/// ```
76/// use hjkl_engine::substitute::parse_substitute;
77///
78/// let cmd = parse_substitute("/foo/bar/gi").unwrap();
79/// assert_eq!(cmd.pattern.as_deref(), Some("foo"));
80/// assert_eq!(cmd.replacement, "bar");
81/// assert!(cmd.flags.all);
82/// assert!(cmd.flags.ignore_case);
83///
84/// // Empty pattern — reuse last_search.
85/// let cmd = parse_substitute("//bar/").unwrap();
86/// assert!(cmd.pattern.is_none());
87/// assert_eq!(cmd.replacement, "bar");
88/// ```
89///
90/// # Errors
91///
92/// Returns an error when:
93/// - `s` is not followed by `/` (no delimiter or alternate delimiter).
94/// - The flag string contains an unknown character.
95/// - The separator `/` is absent (less than two fields).
96pub fn parse_substitute(s: &str) -> Result<SubstituteCmd, SubstError> {
97    // Require leading `/`. Alternate delimiters are out of scope for v1.
98    let rest = s
99        .strip_prefix('/')
100        .ok_or_else(|| format!("substitute: expected '/' delimiter, got {s:?}"))?;
101
102    // Split on unescaped `/`, collecting at most 3 segments:
103    // [pattern, replacement, flags?]
104    let parts = split_on_slash(rest);
105
106    if parts.len() < 2 {
107        return Err("substitute needs /pattern/replacement/".into());
108    }
109
110    let raw_pattern = &parts[0];
111    let raw_replacement = &parts[1];
112    let raw_flags = parts.get(2).map(String::as_str).unwrap_or("");
113
114    // Empty pattern → reuse last_search.
115    let pattern = if raw_pattern.is_empty() {
116        None
117    } else {
118        Some(raw_pattern.clone())
119    };
120
121    // Translate vim replacement notation to regex crate notation.
122    let replacement = translate_replacement(raw_replacement);
123
124    let mut flags = SubstFlags::default();
125    for ch in raw_flags.chars() {
126        match ch {
127            'g' => flags.all = true,
128            'i' => flags.ignore_case = true,
129            'I' => flags.case_sensitive = true,
130            'c' => flags.confirm = true, // parsed, silently ignored
131            other => return Err(format!("unknown flag '{other}' in substitute")),
132        }
133    }
134
135    Ok(SubstituteCmd {
136        pattern,
137        replacement,
138        flags,
139    })
140}
141
142/// Apply a parsed substitute command to `line_range` (0-based inclusive)
143/// in the editor's buffer.
144///
145/// # Pattern resolution
146///
147/// If `cmd.pattern` is `None` (user typed `:s//rep/`), the editor's
148/// `last_search()` is used. Returns an error with `"no previous regular
149/// expression"` when both are empty.
150///
151/// # Case-sensitivity precedence
152///
153/// `flags.case_sensitive` wins over `flags.ignore_case`, which wins over
154/// the editor's `settings().ignore_case`.
155///
156/// # Cursor
157///
158/// After a successful substitution the cursor is placed on the first
159/// non-blank of the **last line that changed**, matching vim semantics. When
160/// no replacements are made the cursor is left unchanged.
161///
162/// # Undo
163///
164/// One undo snapshot is pushed before the first edit. If no replacements
165/// occur the snapshot is popped so the undo stack stays clean.
166///
167/// # Errors
168///
169/// Returns an error when pattern resolution fails or the regex is invalid.
170pub fn apply_substitute<H: crate::types::Host>(
171    ed: &mut Editor<hjkl_buffer::Buffer, H>,
172    cmd: &SubstituteCmd,
173    line_range: std::ops::RangeInclusive<u32>,
174) -> Result<SubstituteOutcome, SubstError> {
175    // Resolve pattern.
176    let pattern_str: String = match &cmd.pattern {
177        Some(p) => p.clone(),
178        None => ed
179            .last_search()
180            .map(str::to_owned)
181            .ok_or_else(|| "no previous regular expression".to_string())?,
182    };
183
184    // Case-sensitivity.
185    // Per-substitute `/I` (case-sensitive) and `/i` (case-insensitive) flags
186    // short-circuit all other resolution — they win over `\c`/`\C` in the
187    // pattern (matching vim's documented precedence: flag > inline override).
188    let effective_pattern = if cmd.flags.case_sensitive {
189        // /I flag: force case-sensitive — run vim_to_rust_regex to strip \c/\C
190        // but do NOT add (?i).
191        use crate::search::{CaseMode, resolve_case_mode};
192        let (stripped, _) = resolve_case_mode(&pattern_str, CaseMode::Sensitive);
193        stripped
194    } else if cmd.flags.ignore_case {
195        // /i flag: force case-insensitive — strip \c/\C and prepend (?i).
196        use crate::search::{CaseMode, resolve_case_mode};
197        let (stripped, _) = resolve_case_mode(&pattern_str, CaseMode::Sensitive);
198        format!("(?i){stripped}")
199    } else {
200        // No explicit flag: honour ignorecase + smartcase + inline \c/\C.
201        use crate::search::{CaseMode, resolve_case_mode};
202        let base = CaseMode::from_options(ed.settings().ignore_case, ed.settings().smartcase);
203        let (stripped, mode) = resolve_case_mode(&pattern_str, base);
204        if mode == CaseMode::Insensitive {
205            format!("(?i){stripped}")
206        } else {
207            stripped
208        }
209    };
210
211    let regex = Regex::new(&effective_pattern).map_err(|e| format!("bad pattern: {e}"))?;
212
213    ed.push_undo();
214
215    let start = *line_range.start() as usize;
216    let end = *line_range.end() as usize;
217    let rope = crate::types::Query::rope(ed.buffer());
218    let total = rope.len_lines();
219
220    let clamp_end = end.min(total.saturating_sub(1));
221    let mut new_lines: Vec<String> = crate::vim::rope_to_lines_vec(&rope);
222    let mut replacements = 0usize;
223    let mut lines_changed = 0usize;
224    let mut last_changed_row = 0usize;
225
226    if start <= clamp_end {
227        for (row, line) in new_lines[start..=clamp_end].iter_mut().enumerate() {
228            let (replaced, n) = do_replace(&regex, line, &cmd.replacement, cmd.flags.all);
229            if n > 0 {
230                *line = replaced;
231                replacements += n;
232                lines_changed += 1;
233                last_changed_row = start + row;
234            }
235        }
236    }
237
238    if replacements == 0 {
239        ed.pop_last_undo();
240        return Ok(SubstituteOutcome {
241            replacements: 0,
242            lines_changed: 0,
243        });
244    }
245
246    // Apply the new content in one shot.
247    ed.buffer_mut().replace_all(&new_lines.join("\n"));
248
249    // Cursor lands on the first non-blank of the last changed line (vim). Clamp
250    // the row in case a replacement inserted line breaks (e.g. `\r`).
251    let final_total = crate::types::Query::rope(ed.buffer()).len_lines();
252    let cursor_row = last_changed_row.min(final_total.saturating_sub(1));
253    let first_non_blank = crate::buf_helpers::buf_line(ed.buffer(), cursor_row)
254        .unwrap_or_default()
255        .chars()
256        .take_while(|c| *c == ' ' || *c == '\t')
257        .count();
258    let line_len = crate::buf_helpers::buf_line(ed.buffer(), cursor_row)
259        .unwrap_or_default()
260        .chars()
261        .count();
262    let cursor_col = first_non_blank.min(line_len.saturating_sub(1));
263    ed.buffer_mut()
264        .set_cursor(hjkl_buffer::Position::new(cursor_row, cursor_col));
265
266    ed.mark_content_dirty();
267
268    // Update last_search so n/N can repeat the same pattern.
269    ed.set_last_search(Some(pattern_str), true);
270
271    Ok(SubstituteOutcome {
272        replacements,
273        lines_changed,
274    })
275}
276
277/// A single candidate match discovered by [`collect_substitute_matches`].
278///
279/// Positions are 0-based byte offsets within their line. The `replacement`
280/// field already has all capture-group references expanded (e.g. `$1`) to
281/// their literal values so the caller can display it and apply without
282/// running the regex again.
283#[derive(Debug, Clone, PartialEq, Eq)]
284pub struct SubstituteMatch {
285    /// 0-based row index in the buffer.
286    pub row: u32,
287    /// Byte offset of the first byte of the match within that row's text.
288    pub byte_start: u32,
289    /// Byte offset one past the last byte of the match (exclusive).
290    pub byte_end: u32,
291    /// The literal replacement string (captures expanded).
292    pub replacement: String,
293}
294
295/// Collect all candidate matches for a `:s/pat/rep/[gc]` command without
296/// mutating the buffer.
297///
298/// Uses the same pattern-resolution and case-sensitivity logic as
299/// [`apply_substitute`]. The returned vec is in document order (low row +
300/// low byte first). Each entry's `replacement` has capture groups already
301/// expanded so the caller can display it without re-running the regex.
302///
303/// # Errors
304///
305/// Returns an error when pattern resolution fails or the regex is invalid.
306pub fn collect_substitute_matches<H: crate::types::Host>(
307    ed: &crate::Editor<hjkl_buffer::Buffer, H>,
308    cmd: &SubstituteCmd,
309    line_range: std::ops::RangeInclusive<u32>,
310) -> Result<Vec<SubstituteMatch>, SubstError> {
311    // Resolve pattern — same logic as apply_substitute.
312    let pattern_str: String = match &cmd.pattern {
313        Some(p) => p.clone(),
314        None => ed
315            .last_search()
316            .map(str::to_owned)
317            .ok_or_else(|| "no previous regular expression".to_string())?,
318    };
319
320    let effective_pattern = if cmd.flags.case_sensitive {
321        use crate::search::{CaseMode, resolve_case_mode};
322        let (stripped, _) = resolve_case_mode(&pattern_str, CaseMode::Sensitive);
323        stripped
324    } else if cmd.flags.ignore_case {
325        use crate::search::{CaseMode, resolve_case_mode};
326        let (stripped, _) = resolve_case_mode(&pattern_str, CaseMode::Sensitive);
327        format!("(?i){stripped}")
328    } else {
329        use crate::search::{CaseMode, resolve_case_mode};
330        let base = CaseMode::from_options(ed.settings().ignore_case, ed.settings().smartcase);
331        let (stripped, mode) = resolve_case_mode(&pattern_str, base);
332        if mode == CaseMode::Insensitive {
333            format!("(?i){stripped}")
334        } else {
335            stripped
336        }
337    };
338
339    let regex = Regex::new(&effective_pattern).map_err(|e| format!("bad pattern: {e}"))?;
340
341    let start = *line_range.start() as usize;
342    let end = *line_range.end() as usize;
343    let rope = crate::types::Query::rope(ed.buffer());
344    let total = rope.len_lines();
345    let clamp_end = end.min(total.saturating_sub(1));
346
347    let mut matches: Vec<SubstituteMatch> = Vec::new();
348
349    if start <= clamp_end {
350        for row in start..=clamp_end {
351            let line = hjkl_buffer::rope_line_str(&rope, row);
352            // Strip trailing newline so byte offsets refer to printable content.
353            let line = line.trim_end_matches('\n');
354
355            if cmd.flags.all {
356                for m in regex.find_iter(line) {
357                    // Expand capture groups into the literal replacement text.
358                    // Capture against the whole line at the match position, not
359                    // the isolated substring, so anchors / lookaround keep their
360                    // context and group expansion matches what was found.
361                    let replacement = regex
362                        .captures_at(line, m.start())
363                        .map(|caps| {
364                            let mut rep = String::new();
365                            caps.expand(&cmd.replacement, &mut rep);
366                            rep
367                        })
368                        .unwrap_or_else(|| cmd.replacement.clone());
369
370                    matches.push(SubstituteMatch {
371                        row: row as u32,
372                        byte_start: m.start() as u32,
373                        byte_end: m.end() as u32,
374                        replacement,
375                    });
376                }
377            } else {
378                // First match per line only.
379                if let Some(m) = regex.find(line) {
380                    // Capture against the whole line at the match position, not
381                    // the isolated substring, so anchors / lookaround keep their
382                    // context and group expansion matches what was found.
383                    let replacement = regex
384                        .captures_at(line, m.start())
385                        .map(|caps| {
386                            let mut rep = String::new();
387                            caps.expand(&cmd.replacement, &mut rep);
388                            rep
389                        })
390                        .unwrap_or_else(|| cmd.replacement.clone());
391
392                    matches.push(SubstituteMatch {
393                        row: row as u32,
394                        byte_start: m.start() as u32,
395                        byte_end: m.end() as u32,
396                        replacement,
397                    });
398                }
399            }
400        }
401    }
402
403    Ok(matches)
404}
405
406/// Apply a subset of matches collected by [`collect_substitute_matches`].
407///
408/// Applies the matches in REVERSE document order (high row → low row, and
409/// within a row high byte → low byte) so earlier byte offsets remain valid
410/// after each replacement. Only matches for which the corresponding
411/// `accepted` entry is `true` are written; all others are skipped.
412///
413/// Returns the number of replacements actually applied.
414///
415/// # Panics
416///
417/// Panics when `accepted.len() != matches.len()`.
418pub fn apply_collected_matches<H: crate::types::Host>(
419    ed: &mut crate::Editor<hjkl_buffer::Buffer, H>,
420    matches: &[SubstituteMatch],
421    accepted: &[bool],
422) -> usize {
423    assert_eq!(
424        matches.len(),
425        accepted.len(),
426        "apply_collected_matches: accepted.len() must equal matches.len()"
427    );
428
429    // Collect accepted matches and sort reverse — high row first, high
430    // byte_start first within the same row.
431    let mut to_apply: Vec<&SubstituteMatch> = matches
432        .iter()
433        .zip(accepted.iter())
434        .filter_map(|(m, &ok)| if ok { Some(m) } else { None })
435        .collect();
436
437    if to_apply.is_empty() {
438        return 0;
439    }
440
441    to_apply.sort_unstable_by(|a, b| b.row.cmp(&a.row).then(b.byte_start.cmp(&a.byte_start)));
442
443    let rope = crate::types::Query::rope(ed.buffer());
444    let mut lines_vec: Vec<String> = crate::vim::rope_to_lines_vec(&rope);
445    let mut applied = 0usize;
446    let mut last_changed_row: Option<usize> = None;
447
448    for sm in &to_apply {
449        let row = sm.row as usize;
450        if row >= lines_vec.len() {
451            continue;
452        }
453        let line = &lines_vec[row];
454        let bs = sm.byte_start as usize;
455        let be = sm.byte_end as usize;
456        if be > line.len() || bs > be {
457            continue;
458        }
459        // Stale matches (buffer changed between collect and apply) can land
460        // mid-char on multibyte text; skip instead of panicking on the slice.
461        if !line.is_char_boundary(bs) || !line.is_char_boundary(be) {
462            continue;
463        }
464        // Splice the replacement in.
465        let mut new_line = String::with_capacity(line.len() + sm.replacement.len());
466        new_line.push_str(&line[..bs]);
467        new_line.push_str(&sm.replacement);
468        new_line.push_str(&line[be..]);
469        lines_vec[row] = new_line;
470        applied += 1;
471        last_changed_row = Some(row);
472    }
473
474    if applied > 0 {
475        ed.buffer_mut().replace_all(&lines_vec.join("\n"));
476        if let Some(row) = last_changed_row {
477            ed.buffer_mut()
478                .set_cursor(hjkl_buffer::Position::new(row, 0));
479        }
480        ed.mark_content_dirty();
481    }
482
483    applied
484}
485
486/// Split `s` on unescaped `/`. Each `\/` in `s` becomes a literal `/`
487/// in the output segment. Other `\x` sequences pass through unchanged
488/// (so regex escape syntax survives).
489///
490/// Returns at most 3 segments: `[pattern, replacement, flags]`. Anything
491/// after the third `/` is absorbed into the flags segment.
492fn split_on_slash(s: &str) -> Vec<String> {
493    let mut out: Vec<String> = Vec::new();
494    let mut cur = String::new();
495    let mut chars = s.chars().peekable();
496    while let Some(c) = chars.next() {
497        if c == '\\' {
498            match chars.peek() {
499                Some(&'/') => {
500                    // Escaped delimiter → literal slash in this segment.
501                    cur.push('/');
502                    chars.next();
503                }
504                Some(_) => {
505                    // Any other escape: preserve both chars so regex
506                    // syntax (\d, \s, \1, \n …) survives.
507                    let next = chars.next().unwrap();
508                    cur.push('\\');
509                    cur.push(next);
510                }
511                None => cur.push('\\'),
512            }
513        } else if c == '/' {
514            if out.len() < 2 {
515                out.push(std::mem::take(&mut cur));
516            } else {
517                // Third delimiter found: treat rest as flags.
518                // Everything up to this point was the replacement;
519                // collect the flags into `cur` and break.
520                cur.push(c);
521                // Keep going to collect remaining chars as flags.
522                // (Actually we already consumed the `/`, so just let
523                // the outer loop continue accumulating into cur.)
524            }
525        } else {
526            cur.push(c);
527        }
528    }
529    out.push(cur);
530    out
531}
532
533/// Translate vim-style replacement tokens to regex-crate syntax.
534///
535/// The result is fed to the `regex` crate's `$`-based expansion, so capture
536/// refs are emitted BRACED (`${1}`) — unbraced `$1abc` would be parsed as the
537/// group name `1abc` — and any literal `$` the user typed is escaped to `$$`.
538///
539/// - `&` → `${0}` (whole match); `\&` → literal `&`
540/// - `\0`…`\9` → `${0}`…`${9}` (capture groups; `\0` is the whole match)
541/// - `\r` → line break, `\t` → tab, `\n` → NUL (vim `:h sub-replace-special`)
542/// - `\\` → `\` (literal backslash)
543/// - literal `$` → `$$` (so the regex crate treats it as a literal)
544/// - Any other `\x` → `x` (drop the backslash)
545fn translate_replacement(s: &str) -> String {
546    let mut out = String::with_capacity(s.len() + 4);
547    let mut chars = s.chars().peekable();
548    while let Some(c) = chars.next() {
549        match c {
550            '&' => out.push_str("${0}"),
551            // A literal `$` in the vim replacement — escape so the regex crate
552            // does not treat it as a capture-group sigil.
553            '$' => out.push_str("$$"),
554            '\\' => match chars.next() {
555                Some('&') => out.push('&'),   // \& → literal &
556                Some('\\') => out.push('\\'), // \\ → literal \
557                Some('r') => out.push('\n'),  // \r → line break
558                Some('t') => out.push('\t'),  // \t → tab
559                Some('n') => out.push('\0'),  // \n → NUL
560                Some(d @ '0'..='9') => {
561                    out.push_str("${");
562                    out.push(d);
563                    out.push('}');
564                }
565                Some(other) => out.push(other), // drop backslash
566                None => {}                      // trailing \ ignored
567            },
568            _ => out.push(c),
569        }
570    }
571    out
572}
573
574/// Replace first or all occurrences of `regex` in `text` using the
575/// already-translated `replacement` string. Returns `(new_text, count)`.
576fn do_replace(regex: &Regex, text: &str, replacement: &str, all: bool) -> (String, usize) {
577    let matches = regex.find_iter(text).count();
578    if matches == 0 {
579        return (text.to_string(), 0);
580    }
581    let replaced = if all {
582        regex.replace_all(text, replacement).into_owned()
583    } else {
584        regex.replace(text, replacement).into_owned()
585    };
586    let count = if all { matches } else { 1 };
587    (replaced, count)
588}
589
590#[cfg(test)]
591mod tests {
592    use super::*;
593    use crate::types::{DefaultHost, Options};
594    use hjkl_buffer::Buffer;
595
596    fn editor_with(content: &str) -> Editor<Buffer, DefaultHost> {
597        let mut e = Editor::new(Buffer::new(), DefaultHost::new(), Options::default());
598        e.set_content(content);
599        e
600    }
601
602    fn buf_line(e: &Editor<Buffer, DefaultHost>, row: usize) -> String {
603        hjkl_buffer::rope_line_str(&e.buffer().rope(), row)
604    }
605
606    // ── Parser tests ─────────────────────────────────────────────────
607
608    #[test]
609    fn parse_basic() {
610        let cmd = parse_substitute("/foo/bar/").unwrap();
611        assert_eq!(cmd.pattern.as_deref(), Some("foo"));
612        assert_eq!(cmd.replacement, "bar");
613        assert!(!cmd.flags.all);
614    }
615
616    #[test]
617    fn parse_trailing_slash_optional() {
618        let cmd = parse_substitute("/foo/bar").unwrap();
619        assert_eq!(cmd.pattern.as_deref(), Some("foo"));
620        assert_eq!(cmd.replacement, "bar");
621    }
622
623    #[test]
624    fn parse_global_flag() {
625        let cmd = parse_substitute("/x/y/g").unwrap();
626        assert!(cmd.flags.all);
627    }
628
629    #[test]
630    fn parse_ignore_case_flag() {
631        let cmd = parse_substitute("/x/y/i").unwrap();
632        assert!(cmd.flags.ignore_case);
633    }
634
635    #[test]
636    fn parse_case_sensitive_flag() {
637        let cmd = parse_substitute("/x/y/I").unwrap();
638        assert!(cmd.flags.case_sensitive);
639    }
640
641    #[test]
642    fn parse_confirm_flag_accepted() {
643        let cmd = parse_substitute("/x/y/c").unwrap();
644        assert!(cmd.flags.confirm);
645    }
646
647    #[test]
648    fn parse_multi_flags() {
649        let cmd = parse_substitute("/x/y/gi").unwrap();
650        assert!(cmd.flags.all);
651        assert!(cmd.flags.ignore_case);
652    }
653
654    #[test]
655    fn parse_unknown_flag_errors() {
656        let err = parse_substitute("/x/y/z").unwrap_err();
657        assert!(err.to_string().contains("unknown flag 'z'"), "{err}");
658    }
659
660    #[test]
661    fn parse_empty_pattern_is_none() {
662        let cmd = parse_substitute("//bar/").unwrap();
663        assert!(cmd.pattern.is_none());
664        assert_eq!(cmd.replacement, "bar");
665    }
666
667    #[test]
668    fn parse_empty_replacement_ok() {
669        let cmd = parse_substitute("/foo//").unwrap();
670        assert_eq!(cmd.pattern.as_deref(), Some("foo"));
671        assert_eq!(cmd.replacement, "");
672    }
673
674    #[test]
675    fn parse_escaped_slash_in_pattern() {
676        let cmd = parse_substitute("/a\\/b/c/").unwrap();
677        assert_eq!(cmd.pattern.as_deref(), Some("a/b"));
678    }
679
680    #[test]
681    fn parse_escaped_slash_in_replacement() {
682        let cmd = parse_substitute("/a/b\\/c/").unwrap();
683        // Replacement is already translated; literal / survives.
684        assert_eq!(cmd.replacement, "b/c");
685    }
686
687    #[test]
688    fn parse_ampersand_becomes_dollar_zero() {
689        let cmd = parse_substitute("/foo/[&]/").unwrap();
690        assert_eq!(cmd.replacement, "[${0}]");
691    }
692
693    #[test]
694    fn parse_escaped_ampersand_is_literal() {
695        let cmd = parse_substitute("/foo/\\&/").unwrap();
696        assert_eq!(cmd.replacement, "&");
697    }
698
699    #[test]
700    fn parse_group_ref_translates() {
701        let cmd = parse_substitute("/(foo)/\\1/").unwrap();
702        assert_eq!(cmd.replacement, "${1}");
703    }
704
705    #[test]
706    fn parse_group_ref_nine() {
707        let cmd = parse_substitute("/(x)/\\9/").unwrap();
708        assert_eq!(cmd.replacement, "${9}");
709    }
710
711    #[test]
712    fn parse_wrong_delimiter_errors() {
713        let err = parse_substitute("|foo|bar|").unwrap_err();
714        assert!(err.to_string().contains("'/'"), "{err}");
715    }
716
717    #[test]
718    fn parse_too_few_fields_errors() {
719        let err = parse_substitute("/foo").unwrap_err();
720        assert!(
721            err.to_string().contains("needs /pattern/replacement"),
722            "{err}"
723        );
724    }
725
726    // ── Apply tests ──────────────────────────────────────────────────
727
728    #[test]
729    fn apply_single_line_first_only() {
730        let mut e = editor_with("foo foo");
731        let cmd = parse_substitute("/foo/bar/").unwrap();
732        let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
733        assert_eq!(out.replacements, 1);
734        assert_eq!(out.lines_changed, 1);
735        assert_eq!(buf_line(&e, 0), "bar foo");
736    }
737
738    #[test]
739    fn apply_single_line_global() {
740        let mut e = editor_with("foo foo foo");
741        let cmd = parse_substitute("/foo/bar/g").unwrap();
742        let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
743        assert_eq!(out.replacements, 3);
744        assert_eq!(out.lines_changed, 1);
745        assert_eq!(buf_line(&e, 0), "bar bar bar");
746    }
747
748    #[test]
749    fn apply_multi_line_range() {
750        let mut e = editor_with("foo\nfoo foo\nbar");
751        let cmd = parse_substitute("/foo/xyz/g").unwrap();
752        let out = apply_substitute(&mut e, &cmd, 0..=2).unwrap();
753        assert_eq!(out.replacements, 3);
754        assert_eq!(out.lines_changed, 2);
755        assert_eq!(buf_line(&e, 0), "xyz");
756        assert_eq!(buf_line(&e, 1), "xyz xyz");
757        assert_eq!(buf_line(&e, 2), "bar");
758    }
759
760    #[test]
761    fn apply_no_match_returns_zero() {
762        let mut e = editor_with("hello");
763        let original = buf_line(&e, 0);
764        let cmd = parse_substitute("/xyz/abc/").unwrap();
765        let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
766        assert_eq!(out.replacements, 0);
767        assert_eq!(out.lines_changed, 0);
768        assert_eq!(buf_line(&e, 0), original);
769    }
770
771    #[test]
772    fn apply_case_insensitive_flag() {
773        let mut e = editor_with("Foo FOO foo");
774        let cmd = parse_substitute("/foo/bar/gi").unwrap();
775        let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
776        assert_eq!(out.replacements, 3);
777        assert_eq!(buf_line(&e, 0), "bar bar bar");
778    }
779
780    #[test]
781    fn apply_case_sensitive_flag_overrides_editor_setting() {
782        let mut e = editor_with("Foo foo");
783        // Enable ignorecase on the editor.
784        e.settings_mut().ignore_case = true;
785        // `I` (capital) forces case-sensitive.
786        let cmd = parse_substitute("/foo/bar/I").unwrap();
787        let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
788        // Only the lowercase "foo" matches.
789        assert_eq!(out.replacements, 1);
790        assert_eq!(buf_line(&e, 0), "Foo bar");
791    }
792
793    #[test]
794    fn apply_empty_pattern_reuses_last_search() {
795        let mut e = editor_with("hello world");
796        e.set_last_search(Some("world".to_string()), true);
797        let cmd = parse_substitute("//planet/").unwrap();
798        let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
799        assert_eq!(out.replacements, 1);
800        assert_eq!(buf_line(&e, 0), "hello planet");
801    }
802
803    #[test]
804    fn apply_empty_pattern_no_last_search_errors() {
805        let mut e = editor_with("hello");
806        let cmd = parse_substitute("//bar/").unwrap();
807        let err = apply_substitute(&mut e, &cmd, 0..=0).unwrap_err();
808        assert!(
809            err.to_string().contains("no previous regular expression"),
810            "{err}"
811        );
812    }
813
814    #[test]
815    fn apply_updates_last_search() {
816        let mut e = editor_with("foo");
817        let cmd = parse_substitute("/foo/bar/").unwrap();
818        apply_substitute(&mut e, &cmd, 0..=0).unwrap();
819        assert_eq!(e.last_search(), Some("foo"));
820    }
821
822    #[test]
823    fn apply_empty_replacement_deletes_match() {
824        let mut e = editor_with("hello world");
825        let cmd = parse_substitute("/world//").unwrap();
826        let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
827        assert_eq!(out.replacements, 1);
828        assert_eq!(buf_line(&e, 0), "hello ");
829    }
830
831    #[test]
832    fn apply_undo_reverts_in_one_step() {
833        let mut e = editor_with("foo");
834        let cmd = parse_substitute("/foo/bar/").unwrap();
835        apply_substitute(&mut e, &cmd, 0..=0).unwrap();
836        assert_eq!(buf_line(&e, 0), "bar");
837        e.undo();
838        assert_eq!(buf_line(&e, 0), "foo");
839    }
840
841    #[test]
842    fn apply_ampersand_in_replacement() {
843        let mut e = editor_with("foo");
844        let cmd = parse_substitute("/foo/[&]/").unwrap();
845        apply_substitute(&mut e, &cmd, 0..=0).unwrap();
846        assert_eq!(buf_line(&e, 0), "[foo]");
847    }
848
849    #[test]
850    fn apply_capture_group_reference() {
851        let mut e = editor_with("hello world");
852        let cmd = parse_substitute("/(\\w+)/<<\\1>>/g").unwrap();
853        apply_substitute(&mut e, &cmd, 0..=0).unwrap();
854        assert_eq!(buf_line(&e, 0), "<<hello>> <<world>>");
855    }
856
857    #[test]
858    fn apply_backslash_r_splits_line() {
859        // `\r` in the replacement inserts a line break (the split-on-delimiter
860        // idiom): `:s/,/\r/g` turns one line into three.
861        let mut e = editor_with("a,b,c");
862        let cmd = parse_substitute("/,/\\r/g").unwrap();
863        apply_substitute(&mut e, &cmd, 0..=0).unwrap();
864        assert_eq!(buf_line(&e, 0), "a");
865        assert_eq!(buf_line(&e, 1), "b");
866        assert_eq!(buf_line(&e, 2), "c");
867    }
868
869    #[test]
870    fn apply_backslash_t_inserts_tab() {
871        let mut e = editor_with("a,b");
872        let cmd = parse_substitute("/,/\\t/").unwrap();
873        apply_substitute(&mut e, &cmd, 0..=0).unwrap();
874        assert_eq!(buf_line(&e, 0), "a\tb");
875    }
876
877    #[test]
878    fn apply_literal_dollar_in_replacement() {
879        // A literal `$` in the replacement stays literal (vim uses `\1` for
880        // groups, so `$5` is not a capture ref).
881        let mut e = editor_with("x");
882        let cmd = parse_substitute("/x/$5/").unwrap();
883        apply_substitute(&mut e, &cmd, 0..=0).unwrap();
884        assert_eq!(buf_line(&e, 0), "$5");
885    }
886
887    #[test]
888    fn apply_backslash_zero_is_whole_match() {
889        // `\0` is the whole match (like `&`).
890        let mut e = editor_with("foo");
891        let cmd = parse_substitute("/foo/[\\0]/").unwrap();
892        apply_substitute(&mut e, &cmd, 0..=0).unwrap();
893        assert_eq!(buf_line(&e, 0), "[foo]");
894    }
895
896    #[test]
897    fn apply_group_ref_then_literal_digits() {
898        // Braced capture refs let a digit follow a group ref: `\1` then `1`.
899        let mut e = editor_with("ab");
900        let cmd = parse_substitute("/(.)/\\11/g").unwrap();
901        apply_substitute(&mut e, &cmd, 0..=0).unwrap();
902        assert_eq!(buf_line(&e, 0), "a1b1");
903    }
904
905    // ── smartcase + \c/\C tests ───────────────────────────────────────────────
906
907    /// `:s/foo/bar/` on `"Foo"` — ignorecase+smartcase on by default, all-
908    /// lowercase pattern → Insensitive → matches `Foo` → becomes `bar`.
909    #[test]
910    fn substitute_respects_smartcase() {
911        let mut e = editor_with("Foo");
912        // Default Options has ignorecase=true, smartcase=true.
913        let cmd = parse_substitute("/foo/bar/").unwrap();
914        let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
915        assert_eq!(out.replacements, 1);
916        assert_eq!(buf_line(&e, 0), "bar");
917    }
918
919    /// `:s/Foo/bar/i` — `/i` flag overrides smartcase (mixed pattern would
920    /// normally be Sensitive) → case-insensitive → matches `"foo"`.
921    #[test]
922    fn substitute_i_flag_overrides_c() {
923        let mut e = editor_with("foo");
924        // /i forces insensitive regardless of pattern case or smartcase.
925        let cmd = parse_substitute("/Foo/bar/i").unwrap();
926        let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
927        assert_eq!(out.replacements, 1, "expected match on 'foo' with /i flag");
928        assert_eq!(buf_line(&e, 0), "bar");
929    }
930
931    /// `\c` inline override in a pattern with no `/i`/`/I` flag — forces
932    /// insensitive even though `Foo` has uppercase (smartcase trip).
933    #[test]
934    fn substitute_lower_c_inline_overrides_smartcase() {
935        let mut e = editor_with("FOO");
936        // \cFoo — override wins, Insensitive → matches "FOO"
937        let cmd = parse_substitute("/\\cFoo/bar/").unwrap();
938        let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
939        assert_eq!(out.replacements, 1);
940        assert_eq!(buf_line(&e, 0), "bar");
941    }
942
943    // ── collect_substitute_matches tests ────────────────────────────────────
944
945    #[test]
946    fn collect_substitute_matches_finds_all_occurrences() {
947        let e = editor_with("foo bar foo");
948        let cmd = parse_substitute("/foo/baz/g").unwrap();
949        let matches = collect_substitute_matches(&e, &cmd, 0..=0).unwrap();
950        assert_eq!(matches.len(), 2, "expected 2 matches for /g flag");
951        assert_eq!(matches[0].byte_start, 0);
952        assert_eq!(matches[0].byte_end, 3);
953        assert_eq!(matches[1].byte_start, 8);
954        assert_eq!(matches[1].byte_end, 11);
955        assert_eq!(matches[0].replacement, "baz");
956        assert_eq!(matches[1].replacement, "baz");
957    }
958
959    #[test]
960    fn collect_substitute_matches_respects_g_flag() {
961        // Without /g only the first match per line.
962        let e = editor_with("foo foo foo");
963        let cmd = parse_substitute("/foo/baz/").unwrap();
964        let matches = collect_substitute_matches(&e, &cmd, 0..=0).unwrap();
965        assert_eq!(matches.len(), 1, "expected 1 match without /g");
966        assert_eq!(matches[0].byte_start, 0);
967    }
968
969    #[test]
970    fn collect_substitute_matches_respects_range() {
971        let e = editor_with("foo\nfoo\nfoo\nfoo\nfoo");
972        let cmd = parse_substitute("/foo/bar/g").unwrap();
973        // Only rows 1 and 2 (0-based) — should return 2 matches, not 5.
974        let matches = collect_substitute_matches(&e, &cmd, 1..=2).unwrap();
975        assert_eq!(matches.len(), 2);
976        assert_eq!(matches[0].row, 1);
977        assert_eq!(matches[1].row, 2);
978    }
979
980    #[test]
981    fn collect_substitute_matches_expands_template() {
982        let e = editor_with("hello world");
983        // /(\\w+)/<<\\1>>/ — the replacement template has a capture group.
984        let cmd = parse_substitute("/(\\w+)/<<\\1>>/g").unwrap();
985        let matches = collect_substitute_matches(&e, &cmd, 0..=0).unwrap();
986        assert_eq!(matches.len(), 2);
987        assert_eq!(matches[0].replacement, "<<hello>>");
988        assert_eq!(matches[1].replacement, "<<world>>");
989    }
990
991    // ── apply_collected_matches tests ───────────────────────────────────────
992
993    #[test]
994    fn apply_collected_matches_reverse_order_preserves_offsets() {
995        // Three matches at byte offsets 0..3, 4..7, 8..11.
996        // Applying in forward order would shift byte offsets; reverse must
997        // keep the final buffer consistent.
998        let mut e = editor_with("foo bar baz");
999        let cmd = parse_substitute("/(foo|bar|baz)/X/g").unwrap();
1000        let matches = collect_substitute_matches(&e, &cmd, 0..=0).unwrap();
1001        assert_eq!(matches.len(), 3);
1002        let accepted = vec![true; 3];
1003        let applied = apply_collected_matches(&mut e, &matches, &accepted);
1004        assert_eq!(applied, 3);
1005        assert_eq!(buf_line(&e, 0), "X X X");
1006    }
1007
1008    #[test]
1009    fn apply_collected_matches_subset_only() {
1010        // 3 matches; accept only first and third.
1011        let mut e = editor_with("foo bar foo");
1012        let cmd = parse_substitute("/foo/ZZZ/g").unwrap();
1013        let matches = collect_substitute_matches(&e, &cmd, 0..=0).unwrap();
1014        assert_eq!(matches.len(), 2, "expected 2 foo matches");
1015        // Accept only the first (index 0), skip the second (index 1).
1016        let accepted = vec![true, false];
1017        let applied = apply_collected_matches(&mut e, &matches, &accepted);
1018        assert_eq!(applied, 1);
1019        // First "foo" replaced; second "foo" untouched.
1020        assert_eq!(buf_line(&e, 0), "ZZZ bar foo");
1021    }
1022
1023    #[test]
1024    fn apply_collected_matches_zero_accepted() {
1025        let mut e = editor_with("foo bar foo");
1026        let cmd = parse_substitute("/foo/ZZZ/g").unwrap();
1027        let matches = collect_substitute_matches(&e, &cmd, 0..=0).unwrap();
1028        let accepted = vec![false; matches.len()];
1029        let applied = apply_collected_matches(&mut e, &matches, &accepted);
1030        assert_eq!(applied, 0);
1031        assert_eq!(buf_line(&e, 0), "foo bar foo");
1032    }
1033
1034    #[test]
1035    fn apply_collected_matches_expands_template() {
1036        let mut e = editor_with("hello world");
1037        let cmd = parse_substitute("/(\\w+)/<<\\1>>/g").unwrap();
1038        let matches = collect_substitute_matches(&e, &cmd, 0..=0).unwrap();
1039        let accepted = vec![true; matches.len()];
1040        let applied = apply_collected_matches(&mut e, &matches, &accepted);
1041        assert_eq!(applied, 2);
1042        assert_eq!(buf_line(&e, 0), "<<hello>> <<world>>");
1043    }
1044}