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