Skip to main content

hjkl_engine/
search.rs

1//! Engine-owned search state + execution helpers.
2//!
3//! Patch 0.0.35 step 1 of the 33-method classification rollout
4//! (see `DESIGN_33_METHOD_CLASSIFICATION.md`). The pattern, per-row
5//! match cache, and `wrapscan` flag previously lived on
6//! [`hjkl_buffer::View`] (private `SearchState`). Moving the FSM
7//! state out of the buffer keeps multi-window hosts from sharing the
8//! "current search" across panes that happen to share content.
9//!
10//! The buffer keeps `Search::find_next` / `Search::find_prev` (the
11//! SPEC trait surface — pure observers, caller owns the regex). This
12//! module composes those primitives with the Editor-owned
13//! [`SearchState`] to drive `n` / `N` / `*` / `#` / `/` / `?`.
14//!
15//! 0.0.37: the buffer-inherent `search_forward` / `search_backward`
16//! / `search_matches` / `set_search_pattern` / `search_pattern` /
17//! `set_search_wrap` / `search_wraps` accessors are removed. Search
18//! state lives on `Editor::search_state`, the rendering path
19//! (`BufferView`) takes the active `&Regex` as a parameter, and the
20//! `Search` trait impl always wraps (engine controls non-wrap
21//! semantics).
22
23use regex::Regex;
24
25use crate::types::{Cursor, Query, Search};
26use hjkl_vim_types::Operator;
27
28/// Active `/` or `?` search prompt. Text mutations drive the textarea's
29/// live search pattern so matches highlight as the user types.
30#[derive(Debug, Clone)]
31pub struct SearchPrompt {
32    pub text: String,
33    pub cursor: usize,
34    pub forward: bool,
35    /// Operator-pending search (`d/pat`, `c/pat`, `y/pat`): the operator, its
36    /// count, and the cursor position where the operator started. `None` for a
37    /// plain `/` / `?` search. On commit the operator runs over the (exclusive,
38    /// charwise) range from `origin` to the match.
39    pub operator: Option<(Operator, usize, (usize, usize))>,
40}
41
42/// Case-sensitivity policy derived from `:set ignorecase` / `:set smartcase`.
43///
44/// Use [`CaseMode::from_options`] to build from two booleans, then pass to
45/// [`resolve_case_mode`] together with the raw pattern string.
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum CaseMode {
48    /// Always case-sensitive regardless of the pattern.
49    Sensitive,
50    /// Always case-insensitive regardless of the pattern.
51    Insensitive,
52    /// Case-insensitive unless the pattern contains an uppercase rune
53    /// (vim's `smartcase` behaviour).
54    Smart,
55}
56
57impl CaseMode {
58    /// Build a `CaseMode` from the two option booleans.
59    ///
60    /// | `ignorecase` | `smartcase` | Result        |
61    /// |---|---|---|
62    /// | `false` | `*`   | `Sensitive`   |
63    /// | `true`  | `false` | `Insensitive` |
64    /// | `true`  | `true`  | `Smart`       |
65    pub fn from_options(ignorecase: bool, smartcase: bool) -> Self {
66        if !ignorecase {
67            Self::Sensitive
68        } else if smartcase {
69            Self::Smart
70        } else {
71            Self::Insensitive
72        }
73    }
74}
75
76/// Vim's regex "magic" level — controls which characters are special
77/// (regex metacharacters) without a backslash prefix. See `:help magic`.
78///
79/// Ordering (most → least magic): `VeryMagic > Magic > NoMagic > VeryNoMagic`.
80/// A character's inherent level determines its behavior: it is special
81/// unescaped when the current level is *at or above* its inherent level, and
82/// backslash toggles that (forces the opposite treatment).
83#[derive(Debug, Clone, Copy, PartialEq, Eq)]
84enum MagicLevel {
85    /// `\v` — nearly every non-alnum/underscore ASCII character is special
86    /// unescaped (groups, quantifiers, alternation, anchors, boundaries).
87    VeryMagic,
88    /// Default / `\m` — vim's normal mode: `. * [ ] ~` are magic unescaped;
89    /// groups/quantifiers/alternation/boundaries need a backslash.
90    Magic,
91    /// `\M` — only `^ $` are magic unescaped; everything else (including
92    /// `. * [ ]`) is literal unless backslashed.
93    NoMagic,
94    /// `\V` — only `\` is special; every other character is literal unless
95    /// backslashed (mirrors `Magic`'s "very magic" meta chars).
96    VeryNoMagic,
97}
98
99/// Characters whose inherent magic level is "very magic" (`( ) + ? | { } = < >`).
100fn very_magic_special(ch: char) -> bool {
101    matches!(
102        ch,
103        '(' | ')' | '+' | '?' | '|' | '{' | '}' | '=' | '<' | '>'
104    )
105}
106
107/// Characters whose inherent magic level is "magic" (`. * [ ] ~`).
108fn magic_special(ch: char) -> bool {
109    matches!(ch, '.' | '*' | '[' | ']' | '~')
110}
111
112/// Characters whose inherent magic level is "nomagic" (`^ $`).
113fn nomagic_special(ch: char) -> bool {
114    matches!(ch, '^' | '$')
115}
116
117/// `true` when `ch` is a rust-`regex` metacharacter that must be
118/// backslash-escaped to appear as a literal.
119fn regex_meta(ch: char) -> bool {
120    matches!(
121        ch,
122        '\\' | '.' | '+' | '*' | '?' | '(' | ')' | '|' | '[' | ']' | '{' | '}' | '^' | '$'
123    )
124}
125
126/// Whether `ch` is special-without-a-backslash at the given magic `level`.
127fn is_special_unescaped(ch: char, level: MagicLevel) -> bool {
128    if very_magic_special(ch) {
129        level == MagicLevel::VeryMagic
130    } else if magic_special(ch) {
131        matches!(level, MagicLevel::VeryMagic | MagicLevel::Magic)
132    } else if nomagic_special(ch) {
133        level != MagicLevel::VeryNoMagic
134    } else {
135        false
136    }
137}
138
139/// Emit `ch`'s regex-special meaning into `out`. `chars` is consumed further
140/// only for `{` (counted-repeat body) and `[` (character class) is handled by
141/// the caller since it needs to flip a "bracket mode" flag.
142///
143/// `last_sub` is the previous `:s` replacement string, used to expand the
144/// magic `~` (`:h /~`, `:h s/~`).
145fn emit_special(
146    out: &mut String,
147    ch: char,
148    chars: &mut std::iter::Peekable<std::str::Chars>,
149    last_sub: &str,
150) {
151    match ch {
152        '(' => out.push('('),
153        ')' => out.push(')'),
154        '+' => out.push('+'),
155        '?' => out.push('?'),
156        '=' => out.push('?'), // vim `\=` / very-magic `=` — same as `\?`.
157        '|' => out.push('|'),
158        '<' | '>' => out.push_str(r"\b"),
159        '{' => {
160            out.push('{');
161            emit_counted_repeat(out, chars);
162        }
163        '}' => out.push('}'), // stray close — harmless as a literal.
164        '.' => out.push('.'),
165        '*' => out.push('*'),
166        ']' => out.push_str(r"\]"), // stray close — harmless as a literal.
167        // Magic `~` — expands to the previous `:s` replacement text
168        // (`:h /~`, `:h s/~`). Inserted verbatim into the translated
169        // (rust-regex) output: like vim, the text is dropped in "as pattern"
170        // without re-escaping. Ordinary word replacements (`BAR`) round-trip
171        // exactly; a replacement carrying regex metacharacters or vim
172        // replacement escapes (`\1`, `&`, `\u`…) is a documented
173        // sub-limitation and may not compile. Empty `last_sub` (no prior
174        // `:s`) → empty expansion (see `translate_pattern`).
175        '~' => out.push_str(last_sub),
176        '^' => out.push('^'),
177        '$' => out.push('$'),
178        _ => out.push(ch),
179    }
180}
181
182/// Copy a `\{n,m}` / `{n,m}` counted-repeat body through to `out`, closing on
183/// either a bare `}` (vim's permissive default-magic form, `\{n,m}`) or an
184/// escaped `\}`. Assumes the opening `{` has already been pushed to `out`.
185fn emit_counted_repeat(out: &mut String, chars: &mut std::iter::Peekable<std::str::Chars>) {
186    loop {
187        match chars.next() {
188            Some('\\') => {
189                if chars.peek() == Some(&'}') {
190                    chars.next();
191                    out.push('}');
192                    return;
193                } else if let Some(c2) = chars.next() {
194                    out.push(c2);
195                } else {
196                    return;
197                }
198            }
199            Some('}') => {
200                out.push('}');
201                return;
202            }
203            Some(c2) => out.push(c2),
204            None => return,
205        }
206    }
207}
208
209/// Emit `ch` as a literal character, escaping it if it happens to be a rust
210/// `regex` metacharacter.
211fn emit_literal(out: &mut String, ch: char) {
212    if regex_meta(ch) {
213        out.push('\\');
214    }
215    out.push(ch);
216}
217
218/// Translate a raw vim pattern into rust-`regex` syntax and extract any
219/// `\c`/`\C` case override. This is the core of [`resolve_case_mode`].
220///
221/// Handles vim's default-magic transforms (`\( \) \+ \? \= \|` → group /
222/// quantifier / alternation syntax; the inverse — unescaped `( ) + ? | { }`
223/// become literals), the `\<` / `\>` word-boundary rewrite (already
224/// magic-level-independent), `\{n,m}` counted repeats (including vim's
225/// permissive unescaped-closing-brace form), and the `\v` / `\V` / `\m` /
226/// `\M` magic-level mode switches (mid-pattern, not just at the start).
227///
228/// `\1`-`\9` backreferences in the PATTERN (not the replacement) are not
229/// supported by the rust `regex` crate (no backtracking engine) — they pass
230/// through unchanged, which either fails to compile or fails to match,
231/// preserving the pre-fix "silent no-match" behavior rather than corrupting
232/// text.
233///
234/// A simple bracket-depth flag skips translation inside `[...]` character
235/// classes, mirroring how vim (and rust-regex) treat class contents mostly
236/// literally. A `~` inside `[...]` is therefore a literal class member (as in
237/// vim), never a last-substitute expansion.
238///
239/// ### Magic `~` (last-substitute expansion)
240///
241/// `last_sub` is the previous `:s` replacement string. Under default magic a
242/// bare `~` expands to it (`\~` stays a literal tilde); under `\M`/`\V` the
243/// roles swap (`\~` expands, bare `~` is literal) — both fall out of the
244/// existing symmetric magic-level logic since `~` is a "magic"-inherent char.
245/// The expansion is inserted verbatim into the rust-regex output (see
246/// [`emit_special`]). When `last_sub` is empty (no `:s` has run yet) the
247/// expansion is empty rather than an error — nvim raises `E33` here, but the
248/// empty-string choice is safe (never corrupts the buffer) and matches this
249/// repo's "silent no-op over hard error" search convention.
250fn translate_pattern(pat: &str, last_sub: &str) -> (String, Option<bool>) {
251    let mut out = String::with_capacity(pat.len());
252    let mut level = MagicLevel::Magic;
253    let mut override_mode: Option<bool> = None;
254    let mut chars = pat.chars().peekable();
255    let mut in_bracket = false;
256    // Parity of the run of backslashes immediately preceding the current
257    // in-bracket char: `\]` is an ESCAPED literal `]` member (vim
258    // `[a\]b]` = {a, ], b}), so a `]` closes the class only when the run is
259    // even; `\\]` closes. Reset on any non-backslash.
260    let mut bracket_backslash_parity = false;
261
262    while let Some(ch) = chars.next() {
263        if in_bracket {
264            // vim treats the character-class escapes (`\a`/`\A`/`\d`/`\D`/
265            // `\s`/`\S`/`\w`/`\W`) inside `[...]` as LITERAL members:
266            // `[\d]` is the set {`\`, `d`}, not "digit" — measured against
267            // neovim 0.12.4 (`:s/[\d]/Q/` on "5" leaves it unchanged, and
268            // `:s/[\a]/Q/` on "x5 a" replaces only the literal `a`).
269            // rust-regex would read `\d` inside a class as the Unicode
270            // digit class, so emit the pair escaped — `[\\d]` — which is
271            // exactly the literal set.
272            if ch == '\\'
273                && matches!(
274                    chars.peek(),
275                    Some('a')
276                        | Some('A')
277                        | Some('d')
278                        | Some('D')
279                        | Some('s')
280                        | Some('S')
281                        | Some('w')
282                        | Some('W')
283                )
284            {
285                let c = chars.next().unwrap();
286                out.push('\\');
287                out.push('\\');
288                out.push(c);
289                // The consumed pair ends in a non-backslash.
290                bracket_backslash_parity = false;
291                continue;
292            }
293            if ch == '\\' {
294                bracket_backslash_parity = !bracket_backslash_parity;
295                out.push(ch);
296                continue;
297            }
298            if ch == ']' && !bracket_backslash_parity {
299                in_bracket = false;
300            }
301            bracket_backslash_parity = false;
302            out.push(ch);
303            continue;
304        }
305
306        if ch == '\\' {
307            match chars.next() {
308                Some('c') => override_mode = Some(true),  // \c → insensitive
309                Some('C') => override_mode = Some(false), // \C → sensitive
310                // vim `\Z` — ignore case for the rest of the pattern,
311                // identical to `\c` (`:h /\Z`).
312                Some('Z') => override_mode = Some(true),
313                // vim `\a` = `[A-Za-z]` (rust-regex `\a` is Bell) and
314                // `\A` = `[^A-Za-z]` (rust-regex `\A` is a start-of-text
315                // anchor). `\e` = ESC (rust-regex has no `\e`).
316                Some('a') => out.push_str("[A-Za-z]"),
317                Some('A') => out.push_str("[^A-Za-z]"),
318                Some('e') => out.push('\u{001b}'),
319                // vim `\d` = `[0-9]`, `\s` = `[ \t]`, `\w` =
320                // `[0-9A-Za-z_]`; rust-regex widens all three to Unicode
321                // (verified: rust `\d` matches U+0663, vim's does not). The
322                // negations `\D`/`\S`/`\W` are the same classes negated.
323                Some('d') => out.push_str("[0-9]"),
324                Some('D') => out.push_str("[^0-9]"),
325                Some('s') => out.push_str("[ \t]"),
326                Some('S') => out.push_str("[^ \t]"),
327                Some('w') => out.push_str("[0-9A-Za-z_]"),
328                Some('W') => out.push_str("[^0-9A-Za-z_]"),
329                Some('v') => level = MagicLevel::VeryMagic,
330                Some('V') => level = MagicLevel::VeryNoMagic,
331                Some('m') => level = MagicLevel::Magic,
332                Some('M') => level = MagicLevel::NoMagic,
333                Some(d @ '0'..='9') => {
334                    // Backreference — unsupported by rust-regex. Pass through
335                    // unchanged (keeps prior no-match/error behavior).
336                    out.push('\\');
337                    out.push(d);
338                }
339                Some(c2) if very_magic_special(c2) || magic_special(c2) || nomagic_special(c2) => {
340                    if is_special_unescaped(c2, level) {
341                        // Already special unescaped at this level — backslash
342                        // forces the literal reading.
343                        emit_literal(&mut out, c2);
344                    } else if c2 == '[' {
345                        out.push('[');
346                        in_bracket = true;
347                    } else {
348                        emit_special(&mut out, c2, &mut chars, last_sub);
349                    }
350                }
351                Some(other) => {
352                    // \d \s \w \b \B \n \t \r \& \~ \\ etc. — already
353                    // valid rust-regex syntax (or handled by the caller) and
354                    // identical in vim's default magic. Pass through.
355                    out.push('\\');
356                    out.push(other);
357                }
358                None => out.push('\\'),
359            }
360            continue;
361        }
362
363        if is_special_unescaped(ch, level) {
364            if ch == '[' {
365                out.push('[');
366                in_bracket = true;
367            } else {
368                emit_special(&mut out, ch, &mut chars, last_sub);
369            }
370        } else {
371            emit_literal(&mut out, ch);
372        }
373    }
374
375    (out, override_mode)
376}
377
378/// Strip `\c` / `\C` overrides from `pat`, resolve the effective
379/// [`CaseMode`], and return the cleaned pattern together with the
380/// resolved mode.
381///
382/// ### Override rules (mirrors vim)
383///
384/// - `\c` anywhere in `pat` forces case-insensitive.
385/// - `\C` anywhere in `pat` forces case-sensitive.
386/// - When both appear the **last** one wins.
387/// - Both are stripped from the returned pattern.
388///
389/// ### Magic-mode translation
390///
391/// As of the default-magic regex fix, this function also translates vim's
392/// default-magic (and `\v`/`\V`/`\m`/`\M`-switched) regex syntax into
393/// rust-`regex` syntax — see [`translate_pattern`] for the full transform
394/// list. `vim_to_rust_regex` is a thin wrapper that discards the case mode.
395///
396/// ### Smart-case detection
397///
398/// When `base` is [`CaseMode::Smart`] and no `\c`/`\C` override was
399/// found, the pattern is scanned for uppercase Unicode letters. Any
400/// uppercase letter → `Sensitive`; otherwise → `Insensitive`.
401///
402/// ### Per-substitute flag interaction
403///
404/// The `:s/…/…/i` and `:s/…/…/I` flags are handled in
405/// `apply_substitute` **before** calling this function (they
406/// short-circuit entirely). This function is not involved.
407///
408/// ### Magic `~` expansion
409///
410/// `last_sub` is the previous `:s` replacement string (pass `""` when there is
411/// no substitute context, e.g. `*`/`#` word search). Callers get it from
412/// [`crate::editor::Editor::last_substitute_replacement`]. See
413/// [`translate_pattern`] for the expansion + escaping rules.
414pub fn resolve_case_mode(pat: &str, base: CaseMode, last_sub: &str) -> (String, CaseMode) {
415    let (out, override_mode) = translate_pattern(pat, last_sub);
416
417    let resolved = match override_mode {
418        Some(true) => CaseMode::Insensitive,
419        Some(false) => CaseMode::Sensitive,
420        None => match base {
421            CaseMode::Smart => {
422                // Any uppercase rune → sensitive. Scan the TRANSLATED
423                // pattern so control sequences consumed during translation
424                // (`\c` `\C` `\v` `\V` `\m` `\M`) don't spuriously count —
425                // matches the pre-existing behavior this function had before
426                // magic-mode translation was added.
427                if out.chars().any(|c| c.is_uppercase()) {
428                    CaseMode::Sensitive
429                } else {
430                    CaseMode::Insensitive
431                }
432            }
433            other => other,
434        },
435    };
436
437    (out, resolved)
438}
439
440/// Rewrite vim-style word-boundary escapes to Rust `regex`-compatible form
441/// **and** strip `\c`/`\C` case overrides.
442///
443/// The `regex` crate supports `\b` (symmetric word boundary) but not the
444/// vim/PCRE `\<` (word-boundary start) or `\>` (word-boundary end) variants.
445/// This function performs a single-pass rewrite:
446///
447/// - `\<` → `\b`
448/// - `\>` → `\b`
449/// - `\c` / `\C` stripped (case override — handled by [`resolve_case_mode`])
450/// - `\\<` / `\\>` (literal double-backslash followed by `<`/`>`) are left
451///   untouched — only the unescaped form transforms.
452/// - All other syntax (`\b`, `\B`, `\d`, anchors, …) passes through unchanged.
453///
454/// Call this on the raw user-typed pattern string **before** passing to
455/// `regex::Regex::new`. Keep the original string for display / history.
456///
457/// Prefer [`resolve_case_mode`] when you also need to apply case semantics;
458/// that function performs the same boundary rewrite internally.
459///
460/// This thin wrapper passes an empty last-substitute string, so a magic `~`
461/// expands to the empty string. Use [`resolve_case_mode`] directly with the
462/// editor's last-substitute replacement when `~` expansion matters.
463pub fn vim_to_rust_regex(pat: &str) -> String {
464    resolve_case_mode(pat, CaseMode::Sensitive, "").0
465}
466
467/// Per-row match cache keyed against the buffer's `dirty_gen`. Live
468/// alongside the active pattern so re-running `n` doesn't re-scan
469/// rows the buffer hasn't touched.
470#[derive(Debug, Clone, Default)]
471pub struct SearchState {
472    /// Active pattern, if any. `None` clears highlighting and makes
473    /// `n` / `N` no-op until the next `/` / `?` commit.
474    pub pattern: Option<Regex>,
475    /// `true` for `/`, `false` for `?` — drives `n` vs `N` direction.
476    /// Mirrors `vim.last_search_forward`; consolidated so future
477    /// patches can drop the duplicate.
478    pub forward: bool,
479    /// `matches[row]` is the `(byte_start, byte_end)` runs cached on
480    /// `row`, captured at `gen[row]`. Length grows lazily.
481    pub matches: Vec<Vec<(usize, usize)>>,
482    /// Per-row generation tag. When the buffer's `dirty_gen` for a
483    /// row diverges, the row gets re-scanned on next access.
484    pub generations: Vec<u64>,
485    /// Wrap past buffer ends. Mirrors `Settings::wrapscan`.
486    pub wrap_around: bool,
487}
488
489impl SearchState {
490    /// Empty state — no pattern, forward direction, wraps.
491    pub fn new() -> Self {
492        Self {
493            pattern: None,
494            forward: true,
495            matches: Vec::new(),
496            generations: Vec::new(),
497            wrap_around: true,
498        }
499    }
500
501    /// Replace the active pattern. Drops the cached match runs so
502    /// the next access re-scans against the new regex.
503    pub fn set_pattern(&mut self, re: Option<Regex>) {
504        self.pattern = re;
505        self.matches.clear();
506        self.generations.clear();
507    }
508
509    /// Refresh `matches[row]` if either the row's gen has rolled or
510    /// we never scanned it. Returns the cached slice.
511    ///
512    /// `get_line` is materialized lazily — only invoked on a cache
513    /// miss (never scanned, or the row's gen rolled). A steady-state
514    /// warm cache returns the cached runs without allocating the line.
515    pub fn matches_for(
516        &mut self,
517        row: usize,
518        dirty_gen: u64,
519        get_line: impl FnOnce() -> String,
520    ) -> &[(usize, usize)] {
521        let Some(ref re) = self.pattern else {
522            return &[];
523        };
524        if self.matches.len() <= row {
525            self.matches.resize_with(row + 1, Vec::new);
526            self.generations.resize(row + 1, u64::MAX);
527        }
528        if self.generations[row] != dirty_gen {
529            // Shared scanner (`hjkl_buffer::search_match_ranges`) — the same
530            // byte-range computation the hlsearch painter and the quickfix
531            // dock's match overlay use, so navigation and highlighting can
532            // never disagree about where a match is.
533            self.matches[row] = hjkl_buffer::search_match_ranges(re, &get_line());
534            self.generations[row] = dirty_gen;
535        }
536        &self.matches[row]
537    }
538}
539
540/// Move the cursor to the next match starting from (or just after,
541/// when `skip_current = true`) the cursor. Wraps end-of-buffer to
542/// row 0 when `state.wrap_around`. Returns `true` when a match was
543/// found.
544///
545/// Pure observe + cursor mutation — no auto-scroll. The Editor's
546/// post-step `ensure_cursor_in_scrolloff` reapplies viewport
547/// follow.
548pub fn search_forward<B: Cursor + Query + Search>(
549    buf: &mut B,
550    state: &mut SearchState,
551    skip_current: bool,
552) -> bool {
553    let Some(re) = state.pattern.clone() else {
554        return false;
555    };
556    let cursor = buf.cursor();
557    let total = buf.line_count();
558    if total == 0 {
559        return false;
560    }
561    // To "skip the current cell", advance `from` one char past the
562    // cursor before asking `find_next` for the at-or-after match.
563    // `pos_at_byte` rounds a mid-char byte DOWN to the enclosing
564    // char's start, so stepping a single byte from the first byte of
565    // a multi-byte char lands back on the cursor itself and `n` never
566    // advances. Step by the full char width instead; when the cursor
567    // sits past end-of-line (no char there), fall back to one byte —
568    // `pos_at_byte` clamps overflow to end-of-buffer so this is safe
569    // even when the cursor sits at the trailing edge.
570    let from = if skip_current {
571        let from_byte = buf.byte_offset(cursor);
572        let width = buf
573            .line(cursor.line)
574            .chars()
575            .nth(cursor.col as usize)
576            .map_or(1, char::len_utf8);
577        buf.pos_at_byte(from_byte.saturating_add(width))
578    } else {
579        cursor
580    };
581    if let Some(range) = buf.find_next(from, &re) {
582        // Honour engine wrap policy explicitly. The buffer impl uses
583        // its own (deprecated) wrap flag; for new search state the
584        // engine SearchState is the source of truth.
585        if !state.wrap_around && range.start.line < cursor.line {
586            return false;
587        }
588        Cursor::set_cursor(buf, range.start);
589        return true;
590    }
591    false
592}
593
594/// Symmetric counterpart of [`search_forward`].
595pub fn search_backward<B: Cursor + Query + Search>(
596    buf: &mut B,
597    state: &mut SearchState,
598    skip_current: bool,
599) -> bool {
600    let Some(re) = state.pattern.clone() else {
601        return false;
602    };
603    let cursor = buf.cursor();
604    let total = buf.line_count();
605    if total == 0 {
606        return false;
607    }
608    // View's `Search::find_prev` returns the at-or-before match
609    // for the anchor `from`. For `skip_current`, we want the
610    // rightmost match whose start is *strictly before* the cursor.
611    // Strategy: query find_prev(cursor); if the returned match
612    // covers/starts-at the cursor, step the anchor back one byte
613    // past that match's start and re-query so the next find_prev
614    // skips it. Otherwise the at-or-before match is already strictly
615    // before the cursor and we accept it.
616    let initial = buf.find_prev(cursor, &re);
617    let range = if skip_current {
618        match initial {
619            Some(m) if m.start == cursor => {
620                // Cursor sits exactly on a match start (typical post-
621                // commit state). Step past and re-query.
622                let cb = buf.byte_offset(m.start);
623                if cb == 0 {
624                    // Current match starts at buffer byte 0 — there is
625                    // nothing earlier to step back to. Wrap to the
626                    // buffer's last match instead; when the current
627                    // match is the only one, `find_prev` from
628                    // end-of-buffer returns it again and the cursor
629                    // stays (matches vim).
630                    let end = buf.pos_at_byte(buf.len_bytes());
631                    buf.find_prev(end, &re)
632                } else {
633                    let anchor = buf.pos_at_byte(cb.saturating_sub(1));
634                    buf.find_prev(anchor, &re)
635                }
636            }
637            other => other,
638        }
639    } else {
640        initial
641    };
642    if let Some(range) = range {
643        if !state.wrap_around && range.start.line > cursor.line {
644            return false;
645        }
646        Cursor::set_cursor(buf, range.start);
647        return true;
648    }
649    false
650}
651
652/// Match positions on `row` as `(byte_start, byte_end)`. Used by
653/// the engine's highlight pipeline. Reads through the cache so a
654/// steady-state buffer doesn't re-scan every frame.
655///
656/// Returns a borrow of the per-row cache (no per-call `Vec` clone).
657pub fn search_matches<'a, B: Query>(
658    buf: &B,
659    state: &'a mut SearchState,
660    dirty_gen: u64,
661    row: usize,
662) -> &'a [(usize, usize)] {
663    if state.pattern.is_none() {
664        return &[];
665    }
666    let line_count = buf.line_count() as usize;
667    if row >= line_count {
668        return &[];
669    }
670    // Materialize the line lazily — only when the cache misses. A warm
671    // steady-state cache skips the per-row allocation entirely.
672    state.matches_for(row, dirty_gen, || buf.line(row as u32))
673}
674
675/// Warm the per-row match cache for `row` without producing a result.
676///
677/// Same cache path as [`search_matches`] (identical miss/hit behaviour),
678/// but returns nothing — the renderer's pre-pass only wants the cache
679/// populated, and building a `Vec` per visible row just to drop it is
680/// pure allocation churn.
681pub fn warm_matches<B: Query>(buf: &B, state: &mut SearchState, dirty_gen: u64, row: usize) {
682    if state.pattern.is_none() {
683        return;
684    }
685    let line_count = buf.line_count() as usize;
686    if row >= line_count {
687        return;
688    }
689    state.matches_for(row, dirty_gen, || buf.line(row as u32));
690}
691
692#[cfg(test)]
693mod tests {
694    use super::*;
695    use crate::types::Pos;
696    use hjkl_buffer::View;
697
698    fn re(pat: &str) -> Regex {
699        Regex::new(pat).unwrap()
700    }
701
702    fn vim_re(pat: &str) -> Regex {
703        Regex::new(&vim_to_rust_regex(pat)).unwrap()
704    }
705
706    // ── vim_to_rust_regex unit tests ─────────────────────────────────────────
707
708    /// `\<` and `\>` both rewrite to `\b`.
709    #[test]
710    fn vim_boundary_rewrites_to_b() {
711        assert_eq!(vim_to_rust_regex(r"\<foo\>"), r"\bfoo\b");
712        assert_eq!(vim_to_rust_regex(r"\<"), r"\b");
713        assert_eq!(vim_to_rust_regex(r"\>"), r"\b");
714    }
715
716    /// A literal double-backslash before `<`/`>` must not be consumed.
717    /// `\\<` in the source string is two chars: `\` `\`; the rewriter sees
718    /// the first `\` followed by `\`, emits `\\`, then `<` is plain text.
719    #[test]
720    fn escaped_backslash_left_alone() {
721        // Input: \\< (three chars in source: '\', '\', '<')
722        // Expected output: \\< (the first \ escapes the second, < is literal)
723        let input = r"\\<";
724        let output = vim_to_rust_regex(input);
725        assert_eq!(output, r"\\<");
726    }
727
728    /// Other escape sequences (`\b`, `\B`, `\d`, `\w`, anchors) pass through.
729    #[test]
730    fn other_escapes_unchanged() {
731        assert_eq!(vim_to_rust_regex(r"\b"), r"\b");
732        assert_eq!(vim_to_rust_regex(r"\B"), r"\B");
733        // vim default magic: `+` is a literal unless backslashed. `\d\+`
734        // (ASCII digit class, one-or-more quantifier) translates to
735        // `[0-9]+` — the class is rewritten to vim's ASCII set, the
736        // quantifier to rust-regex's unescaped `+`.
737        assert_eq!(vim_to_rust_regex(r"\d\+"), r"[0-9]+");
738        assert_eq!(vim_to_rust_regex(r"^\w\+$"), r"^[0-9A-Za-z_]+$");
739    }
740
741    /// Mixed: `\<\w\+\>` rewrites to `\b\w+\b` — matches whole words.
742    #[test]
743    fn mixed_boundary_and_word_class() {
744        assert_eq!(vim_to_rust_regex(r"\<\w\+\>"), r"\b[0-9A-Za-z_]+\b");
745    }
746
747    // ── Integration: compiled vim patterns match correctly ───────────────────
748
749    /// `/foo\<bar\>` — `bar` as a standalone word is matched, `foobar` is not.
750    #[test]
751    fn vim_boundary_matches_standalone_word_not_suffix() {
752        let re = vim_re(r"foo\<bar\>");
753        // "foobar" — `bar` follows directly after `foo` with no word boundary:
754        // the `\b` between `foo` and `bar` fails here.
755        assert!(!re.is_match("foobar"));
756        // "foo bar" — word boundary between `foo ` and `bar`:
757        // pattern `foo\bbar\b` does not match because `foo` is not adjacent.
758        // Use a pattern that directly tests the intent: `bar` as a whole word.
759        let re2 = vim_re(r"\<bar\>");
760        assert!(re2.is_match("foo bar baz"));
761        assert!(!re2.is_match("foobar"));
762    }
763
764    /// `\<word` matches `word` at start-of-word but not mid-word.
765    #[test]
766    fn vim_boundary_start_only() {
767        let re = vim_re(r"\<word");
768        assert!(re.is_match("word here"));
769        assert!(re.is_match("some word here"));
770        assert!(!re.is_match("sword"));
771        assert!(!re.is_match("aword"));
772    }
773
774    /// `word\>` matches `word` at end-of-word but not when followed by more.
775    #[test]
776    fn vim_boundary_end_only() {
777        let re = vim_re(r"word\>");
778        assert!(re.is_match("some word"));
779        assert!(re.is_match("word"));
780        assert!(!re.is_match("words"));
781        assert!(!re.is_match("wordsmith"));
782    }
783
784    /// Existing `\b` continues to work (sanity check — no double-transform).
785    #[test]
786    fn existing_b_boundary_unchanged() {
787        let re = vim_re(r"\bfoo\b");
788        assert!(re.is_match("foo"));
789        assert!(re.is_match("a foo b"));
790        assert!(!re.is_match("foobar"));
791        assert!(!re.is_match("afoo"));
792    }
793
794    /// Mixed: `\<\w+\>` matches whole words only.
795    #[test]
796    fn vim_whole_word_pattern() {
797        let re = vim_re(r"\<\w\+\>");
798        let matches: Vec<_> = re.find_iter("foo bar baz").map(|m| m.as_str()).collect();
799        assert_eq!(matches, vec!["foo", "bar", "baz"]);
800    }
801
802    #[test]
803    fn empty_state_no_match() {
804        let mut b = View::from_str("anything");
805        let mut s = SearchState::new();
806        assert!(!search_forward(&mut b, &mut s, false));
807        assert!(!search_backward(&mut b, &mut s, false));
808    }
809
810    #[test]
811    fn search_backward_from_inside_a_match_lands_on_its_start() {
812        // nvim 0.12.5: `?foo` / `N` from a cursor strictly inside a match land
813        // on that match's START, not the previous match. `find_prev` returns
814        // the rightmost match with start <= cursor, so the containing match is
815        // returned and its start is the correct landing spot.
816        let mut buf = View::from_str("abcabc");
817        crate::types::Cursor::set_cursor(&mut buf, crate::types::Pos::new(0, 1));
818        let mut state = SearchState::new();
819        state.set_pattern(Some(re("abc")));
820        assert!(search_backward(&mut buf, &mut state, true));
821        assert_eq!(
822            crate::types::Cursor::cursor(&buf),
823            crate::types::Pos::new(0, 0)
824        );
825    }
826
827    // ── B8/B9: default-magic + \v/\V/\m/\M translation ───────────────────────
828
829    #[test]
830    fn default_magic_groups_and_backref_replacement_side() {
831        // \( \) → real groups; the PATTERN side is exercised end-to-end via
832        // substitute.rs (replacement-side \1 already worked before this fix).
833        assert_eq!(
834            vim_to_rust_regex(r"\(hello\) \(world\)"),
835            r"(hello) (world)"
836        );
837    }
838
839    #[test]
840    fn default_magic_quantifiers_and_alternation() {
841        assert_eq!(vim_to_rust_regex(r"a\+"), r"a+");
842        assert_eq!(vim_to_rust_regex(r"a\?"), r"a?");
843        assert_eq!(vim_to_rust_regex(r"a\="), r"a?");
844        assert_eq!(vim_to_rust_regex(r"a\|b"), r"a|b");
845    }
846
847    #[test]
848    fn default_magic_counted_repeat_bare_close() {
849        // vim allows `\{n,m}` with an UNESCAPED closing brace.
850        assert_eq!(vim_to_rust_regex(r"a\{1,2}"), r"a{1,2}");
851        // Fully-escaped form also works.
852        assert_eq!(vim_to_rust_regex(r"a\{1,2\}"), r"a{1,2}");
853    }
854
855    #[test]
856    fn default_magic_unescaped_group_chars_are_literal() {
857        // The INVERSE: unescaped ( ) + ? | { } are literals in default magic.
858        assert_eq!(vim_to_rust_regex("(a)"), r"\(a\)");
859        assert_eq!(vim_to_rust_regex("a+b"), r"a\+b");
860        assert_eq!(vim_to_rust_regex("a|b"), r"a\|b");
861        assert_eq!(vim_to_rust_regex("a?b"), r"a\?b");
862    }
863
864    #[test]
865    fn default_magic_dot_star_bracket_caret_dollar_stay_magic() {
866        assert_eq!(vim_to_rust_regex("a.b"), "a.b");
867        assert_eq!(vim_to_rust_regex("a*"), "a*");
868        assert_eq!(vim_to_rust_regex("[0-9]"), "[0-9]");
869        assert_eq!(vim_to_rust_regex("^foo$"), "^foo$");
870    }
871
872    #[test]
873    fn magic_tilde_expands_to_last_sub_empty_via_wrapper() {
874        // `vim_to_rust_regex` passes an empty last-substitute string, so a bare
875        // magic `~` expands to "" (nvim would `E33` with no prior `:s`; we pick
876        // the safe empty expansion). `\~` stays a literal tilde.
877        assert_eq!(vim_to_rust_regex("a~b"), "ab");
878        assert_eq!(vim_to_rust_regex(r"a\~b"), "a~b");
879    }
880
881    // ── Magic `~` PATTERN-side expansion (V5) ────────────────────────────────
882
883    /// `~` expands to the supplied last-substitute string; `\~` stays literal.
884    /// nvim-verified: after `:s/foo/BAR/`, `/~` matches the text `BAR`.
885    #[test]
886    fn magic_tilde_expands_to_last_sub() {
887        let (out, _) = resolve_case_mode("~", CaseMode::Sensitive, "BAR");
888        assert_eq!(out, "BAR");
889        // Surrounded by other pattern text.
890        let (out, _) = resolve_case_mode("x~y", CaseMode::Sensitive, "BAR");
891        assert_eq!(out, "xBARy");
892    }
893
894    /// `\~` is a literal tilde and must NOT expand, even with a last-sub set.
895    /// nvim-verified: `\~` in a pattern matches a real `~` character.
896    #[test]
897    fn escaped_tilde_stays_literal_and_does_not_expand() {
898        let (out, _) = resolve_case_mode(r"\~", CaseMode::Sensitive, "BAR");
899        assert_eq!(out, "~");
900        // Compiled: matches a real tilde, not "BAR".
901        let re = Regex::new(&out).unwrap();
902        assert!(re.is_match("a~b"));
903        assert!(!re.is_match("BAR"));
904    }
905
906    /// `~` inside a `[...]` class is a literal class member, never an
907    /// expansion. nvim-verified: `[~]` matches the tilde character.
908    #[test]
909    fn tilde_in_bracket_class_is_literal() {
910        let (out, _) = resolve_case_mode("[~]", CaseMode::Sensitive, "BAR");
911        assert_eq!(out, "[~]");
912    }
913
914    /// No previous substitute (empty last-sub) → `~` expands to empty.
915    /// Documented divergence from nvim's `E33`; the empty choice never
916    /// corrupts the buffer.
917    #[test]
918    fn magic_tilde_no_previous_sub_expands_empty() {
919        let (out, _) = resolve_case_mode("a~b", CaseMode::Sensitive, "");
920        assert_eq!(out, "ab");
921    }
922
923    #[test]
924    fn very_magic_mode_switch_at_start() {
925        // \v: groups/quantifiers/alternation/boundaries are magic unescaped.
926        assert_eq!(
927            vim_to_rust_regex(r"\v(\w+) (\w+)"),
928            r"([0-9A-Za-z_]+) ([0-9A-Za-z_]+)"
929        );
930        assert_eq!(vim_to_rust_regex(r"\v\d+"), r"[0-9]+");
931        assert_eq!(vim_to_rust_regex(r"\v<foo>"), r"\bfoo\b");
932        assert_eq!(vim_to_rust_regex(r"\va=b"), r"a?b");
933    }
934
935    #[test]
936    fn very_magic_mode_escaped_chars_are_literal() {
937        // In \v mode, backslash forces the LITERAL reading of an
938        // otherwise-special char.
939        assert_eq!(vim_to_rust_regex(r"\v\(a\)"), r"\(a\)");
940        assert_eq!(vim_to_rust_regex(r"\va\+b"), r"a\+b");
941    }
942
943    #[test]
944    fn very_nomagic_mode_is_all_literal_except_backslash() {
945        // \V: everything literal except `\`-escaped.
946        assert_eq!(vim_to_rust_regex(r"\Va.b"), r"a\.b");
947        assert_eq!(vim_to_rust_regex(r"\V(a)"), r"\(a\)");
948        // Backslash still activates special meaning (mirrors \v).
949        assert_eq!(vim_to_rust_regex(r"\Va\.b"), r"a.b");
950    }
951
952    #[test]
953    fn nomagic_mode_only_caret_dollar_special() {
954        // \M: only ^ $ special unescaped; `.` `*` `[` become literal.
955        assert_eq!(vim_to_rust_regex(r"\M^a.b$"), r"^a\.b$");
956        assert_eq!(vim_to_rust_regex(r"\Ma\.b"), r"a.b");
957    }
958
959    #[test]
960    fn mode_switch_mid_pattern() {
961        // Switching mode partway through the pattern applies from that point on.
962        assert_eq!(vim_to_rust_regex(r"(a)\v(b)"), r"\(a\)(b)");
963        assert_eq!(vim_to_rust_regex(r"\va\mb+"), r"ab\+");
964    }
965
966    #[test]
967    fn backreference_in_pattern_passes_through_unchanged() {
968        // \1-\9 in the PATTERN aren't supported by rust-regex (no
969        // backtracking) — kept as a literal backslash-digit escape so the
970        // net effect (no match / compile error) matches pre-fix behavior
971        // rather than silently corrupting text.
972        assert_eq!(vim_to_rust_regex(r"\(a\)\1"), r"(a)\1");
973    }
974
975    #[test]
976    fn character_class_contents_not_translated() {
977        // Unescaped `(` `)` inside `[...]` are literal class members in both
978        // vim and rust-regex — bracket tracking must not turn them into a
979        // group by escaping/unescaping their contents.
980        assert_eq!(vim_to_rust_regex("[()]"), "[()]");
981    }
982
983    // ── vim ASCII character classes (`\d`/`\s`/`\w`) ─────────────────────────
984
985    /// vim's `\d`/`\s`/`\w` and their negations are ASCII-only; rust-regex
986    /// widens them to Unicode (verified: rust `\d` matches U+0663). The
987    /// translator must emit vim's ASCII classes, or `:s/\d/` would rewrite
988    /// text vim leaves alone.
989    #[test]
990    fn vim_ascii_classes_are_translated_outside_brackets() {
991        assert_eq!(vim_to_rust_regex(r"\d\+"), r"[0-9]+");
992        assert_eq!(vim_to_rust_regex(r"\d+"), r"[0-9]\+");
993        assert_eq!(vim_to_rust_regex(r"\D"), r"[^0-9]");
994        assert_eq!(vim_to_rust_regex(r"\s"), "[ \t]");
995        assert_eq!(vim_to_rust_regex(r"\S"), "[^ \t]");
996        assert_eq!(vim_to_rust_regex(r"\w"), r"[0-9A-Za-z_]");
997        assert_eq!(vim_to_rust_regex(r"\W"), r"[^0-9A-Za-z_]");
998    }
999
1000    /// Inside `[...]` vim treats the class escapes as LITERAL members —
1001    /// `[\d]` is the set {`\`, `d`}, not "digit" (measured against neovim
1002    /// 0.12.4). The pair is emitted escaped so rust-regex reads it literally
1003    /// instead of as its own (Unicode-wide) class.
1004    #[test]
1005    fn vim_ascii_classes_are_translated_inside_brackets() {
1006        assert_eq!(vim_to_rust_regex(r"[\d]"), r"[\\d]");
1007        assert_eq!(vim_to_rust_regex(r"[\D]"), r"[\\D]");
1008        assert_eq!(vim_to_rust_regex(r"[\s]"), r"[\\s]");
1009        assert_eq!(vim_to_rust_regex(r"[\S]"), r"[\\S]");
1010        assert_eq!(vim_to_rust_regex(r"[\w]"), r"[\\w]");
1011        assert_eq!(vim_to_rust_regex(r"[\W]"), r"[\\W]");
1012        assert_eq!(vim_to_rust_regex(r"[a\d]"), r"[a\\d]");
1013    }
1014
1015    /// The translated classes actually MATCH what they claim: ASCII-only.
1016    /// `\d` must not match the Arabic-Indic digit U+0663 (vim parity), `\s`
1017    /// is strictly `[ \t]`, and `\w` is strictly `[0-9A-Za-z_]` — neovim
1018    /// 0.12.4 does not match `é` with `\w` either.
1019    #[test]
1020    fn vim_ascii_classes_match_ascii_only() {
1021        let digit = regex::Regex::new(&vim_to_rust_regex(r"\d")).unwrap();
1022        assert!(digit.is_match("5"));
1023        assert!(!digit.is_match("٣"), "\\d must not match U+0663");
1024        let ws = regex::Regex::new(&vim_to_rust_regex(r"\s")).unwrap();
1025        assert!(ws.is_match("\t"));
1026        assert!(!ws.is_match("\u{00a0}"), "\\s must be strictly [ \\t]");
1027        let word = regex::Regex::new(&vim_to_rust_regex(r"\w")).unwrap();
1028        assert!(word.is_match("_"));
1029        assert!(!word.is_match("é"), "vim \\w is strictly [0-9A-Za-z_]");
1030        assert!(!word.is_match("中"), "CJK is not a vim word char");
1031    }
1032
1033    // ── search reveals folds ─────────────────────────────────────────────────
1034
1035    /// `search_forward` on a buffer with a closed fold hiding the match row:
1036    /// after finding the match, calling `reveal_row` opens the fold.
1037    /// (Mirrors what `Editor::search_advance_forward` does.)
1038    #[test]
1039    fn search_forward_reveals_fold() {
1040        use hjkl_buffer::View;
1041
1042        // View: row 0 = "header", row 1 = "needle", row 2 = "footer"
1043        // Fold [0..2] closed → row 1 is hidden.
1044        let mut buf = View::from_str("header\nneedle\nfooter");
1045        buf.add_fold(0, 2, true);
1046        assert!(buf.is_row_hidden(1), "row 1 must be hidden before search");
1047
1048        let mut state = SearchState::new();
1049        state.set_pattern(Some(re("needle")));
1050
1051        // Use search_forward directly on the buffer.
1052        let found = search_forward(&mut buf, &mut state, false);
1053        assert!(found, "search_forward must find 'needle'");
1054
1055        // After search_forward, cursor is on row 1. Reveal as Editor does.
1056        let row = crate::types::Cursor::cursor(&buf).line as usize;
1057        buf.reveal_row(row);
1058        assert!(
1059            !buf.is_row_hidden(1),
1060            "row 1 must be revealed after search finds it there"
1061        );
1062    }
1063
1064    /// `search_backward` similarly: finding a match then calling reveal_row opens folds.
1065    #[test]
1066    fn search_backward_reveals_fold() {
1067        use hjkl_buffer::View;
1068
1069        // row 0 = "footer", row 1 = "needle", row 2 = "header"
1070        // fold [0..2] closed → row 1 hidden. Start cursor at row 2.
1071        let mut buf = View::from_str("footer\nneedle\nheader");
1072        buf.add_fold(0, 2, true);
1073        crate::types::Cursor::set_cursor(&mut buf, crate::types::Pos::new(2, 0));
1074        assert!(buf.is_row_hidden(1), "row 1 must be hidden before search");
1075
1076        let mut state = SearchState::new();
1077        state.set_pattern(Some(re("needle")));
1078
1079        let found = search_backward(&mut buf, &mut state, false);
1080        assert!(found, "search_backward must find 'needle'");
1081
1082        let row = crate::types::Cursor::cursor(&buf).line as usize;
1083        buf.reveal_row(row);
1084        assert!(
1085            !buf.is_row_hidden(1),
1086            "row 1 must be revealed after backward search finds it"
1087        );
1088    }
1089
1090    #[test]
1091    fn forward_finds_first_match() {
1092        let mut b = View::from_str("foo bar foo baz");
1093        let mut s = SearchState::new();
1094        s.set_pattern(Some(re("foo")));
1095        assert!(search_forward(&mut b, &mut s, false));
1096        assert_eq!(Cursor::cursor(&b), Pos::new(0, 0));
1097    }
1098
1099    #[test]
1100    fn forward_skip_current_walks_past() {
1101        let mut b = View::from_str("foo bar foo baz");
1102        let mut s = SearchState::new();
1103        s.set_pattern(Some(re("foo")));
1104        search_forward(&mut b, &mut s, false);
1105        search_forward(&mut b, &mut s, true);
1106        assert_eq!(Cursor::cursor(&b), Pos::new(0, 8));
1107    }
1108
1109    #[test]
1110    fn forward_wraps_to_top() {
1111        let mut b = View::from_str("zzz\nfoo");
1112        // 0.0.37: wrap policy lives entirely on `SearchState::wrap_around`;
1113        // the buffer-side `set_search_wrap` accessor is gone. Trait
1114        // `find_next` always wraps; the engine search free function
1115        // honours `s.wrap_around` directly.
1116        Cursor::set_cursor(&mut b, Pos::new(1, 2));
1117        let mut s = SearchState::new();
1118        s.set_pattern(Some(re("zzz")));
1119        s.wrap_around = true;
1120        assert!(search_forward(&mut b, &mut s, true));
1121        assert_eq!(Cursor::cursor(&b), Pos::new(0, 0));
1122    }
1123
1124    /// `n` from a match whose first byte begins a multi-byte char must
1125    /// advance to the next match. `pos_at_byte` rounds a mid-char byte
1126    /// DOWN to the enclosing char's start, so a one-byte step from the
1127    /// char's first byte lands back on the cursor itself — regression:
1128    /// `n` was permanently stuck on "éé".
1129    #[test]
1130    fn forward_skip_current_past_multibyte_char() {
1131        let mut b = View::from_str("éé");
1132        let mut s = SearchState::new();
1133        s.set_pattern(Some(re("é")));
1134        // Cursor starts on the first `é` (col 0); `n` must land on the
1135        // second one (col 1), not re-find the current match.
1136        assert!(search_forward(&mut b, &mut s, true));
1137        assert_eq!(Cursor::cursor(&b), Pos::new(0, 1));
1138    }
1139
1140    /// `N` from a match that starts at buffer byte 0 wraps to the last
1141    /// match of the buffer instead of staying put — regression: the
1142    /// "no earlier byte" branch returned `None` and never wrapped.
1143    #[test]
1144    fn backward_skip_current_wraps_from_byte_zero() {
1145        let mut b = View::from_str("foo\nfoo");
1146        Cursor::set_cursor(&mut b, Pos::new(0, 0));
1147        let mut s = SearchState::new();
1148        s.set_pattern(Some(re("foo")));
1149        assert!(search_backward(&mut b, &mut s, true));
1150        assert_eq!(Cursor::cursor(&b), Pos::new(1, 0));
1151    }
1152
1153    #[test]
1154    fn search_matches_caches_against_dirty_gen() {
1155        let b = View::from_str("foo bar");
1156        let mut s = SearchState::new();
1157        s.set_pattern(Some(re("bar")));
1158        let dgen = b.dirty_gen();
1159        let initial = search_matches(&b, &mut s, dgen, 0);
1160        assert_eq!(initial, &[(4, 7)][..]);
1161    }
1162
1163    // ── CaseMode::from_options matrix ────────────────────────────────────────
1164
1165    #[test]
1166    fn case_mode_from_options_matrix() {
1167        // ic=false, smart=* → Sensitive
1168        assert_eq!(CaseMode::from_options(false, false), CaseMode::Sensitive);
1169        assert_eq!(CaseMode::from_options(false, true), CaseMode::Sensitive);
1170        // ic=true, smart=false → Insensitive
1171        assert_eq!(CaseMode::from_options(true, false), CaseMode::Insensitive);
1172        // ic=true, smart=true → Smart
1173        assert_eq!(CaseMode::from_options(true, true), CaseMode::Smart);
1174    }
1175
1176    // ── resolve_case_mode unit tests ─────────────────────────────────────────
1177
1178    #[test]
1179    fn resolve_case_mode_no_override_smart_lowercase() {
1180        let (stripped, mode) = resolve_case_mode("foo", CaseMode::Smart, "");
1181        assert_eq!(stripped, "foo");
1182        assert_eq!(mode, CaseMode::Insensitive);
1183    }
1184
1185    #[test]
1186    fn resolve_case_mode_no_override_smart_uppercase() {
1187        let (stripped, mode) = resolve_case_mode("Foo", CaseMode::Smart, "");
1188        assert_eq!(stripped, "Foo");
1189        assert_eq!(mode, CaseMode::Sensitive);
1190    }
1191
1192    #[test]
1193    fn resolve_case_mode_lower_c_override() {
1194        // \c overrides Sensitive → Insensitive; stripped pattern is "Foo"
1195        let (stripped, mode) = resolve_case_mode(r"\cFoo", CaseMode::Sensitive, "");
1196        assert_eq!(stripped, "Foo");
1197        assert_eq!(mode, CaseMode::Insensitive);
1198    }
1199
1200    #[test]
1201    fn resolve_case_mode_upper_c_override() {
1202        // \C overrides Smart → Sensitive; stripped pattern is "foo"
1203        let (stripped, mode) = resolve_case_mode(r"foo\C", CaseMode::Smart, "");
1204        assert_eq!(stripped, "foo");
1205        assert_eq!(mode, CaseMode::Sensitive);
1206    }
1207
1208    #[test]
1209    fn resolve_case_mode_last_wins() {
1210        // \c then \C → last-wins → Sensitive; stripped "foo"
1211        let (stripped, mode) = resolve_case_mode(r"\cfoo\C", CaseMode::Smart, "");
1212        assert_eq!(stripped, "foo");
1213        assert_eq!(mode, CaseMode::Sensitive);
1214    }
1215
1216    // ── Integration: search with smartcase / \c / \C ─────────────────────────
1217
1218    fn build_regex_from(pat: &str, ic: bool, smart: bool) -> Regex {
1219        let base = CaseMode::from_options(ic, smart);
1220        let (stripped, mode) = resolve_case_mode(pat, base, "");
1221        let src = if mode == CaseMode::Insensitive {
1222            format!("(?i){stripped}")
1223        } else {
1224            stripped
1225        };
1226        Regex::new(&src).unwrap()
1227    }
1228
1229    #[test]
1230    fn search_finds_capital_with_smartcase_lowercase_pattern() {
1231        // ic=true, smart=true, pattern "foo" → Insensitive → matches "FOO"
1232        let re = build_regex_from("foo", true, true);
1233        assert!(re.is_match("FOO"), "expected match on 'FOO'");
1234        assert!(re.is_match("foo"), "expected match on 'foo'");
1235    }
1236
1237    #[test]
1238    fn search_skips_capital_with_smartcase_mixed_pattern() {
1239        // ic=true, smart=true, pattern "Foo" → Sensitive → does NOT match "FOO"
1240        let re = build_regex_from("Foo", true, true);
1241        assert!(!re.is_match("FOO"), "must not match 'FOO' (case-sensitive)");
1242        assert!(re.is_match("Foo"), "must match exact 'Foo'");
1243    }
1244
1245    #[test]
1246    fn search_lower_c_override_finds_capital() {
1247        // \cFoo + Sensitive base → Insensitive override → matches "FOO"
1248        let re = build_regex_from(r"\cFoo", false, false);
1249        assert!(re.is_match("FOO"), "\\c override must match 'FOO'");
1250        assert!(re.is_match("foo"), "\\c override must match 'foo'");
1251    }
1252
1253    #[test]
1254    fn vim_to_rust_regex_strips_case_overrides() {
1255        // vim_to_rust_regex is now a thin wrapper; \c and \C are stripped
1256        assert_eq!(vim_to_rust_regex(r"\cfoo"), "foo");
1257        assert_eq!(vim_to_rust_regex(r"foo\C"), "foo");
1258        assert_eq!(vim_to_rust_regex(r"\<bar\>"), r"\bbar\b");
1259    }
1260
1261    /// `*` on word "foo" emits the pattern `\bfoo\b` (all lowercase). Under
1262    /// smartcase that resolves to Insensitive → should match "FOO". This test
1263    /// simulates the word_at_cursor_search pattern-build path.
1264    #[test]
1265    fn star_search_finds_lowercase_when_smartcase_lower_word() {
1266        // word_at_cursor_search escapes the word then wraps \b..\b.
1267        // "foo" is all-lowercase after word-extraction → Smart → Insensitive.
1268        let pat = r"\bfoo\b";
1269        let re = build_regex_from(pat, true, true);
1270        // Case-insensitive → matches "FOO foo Foo".
1271        let text = "FOO foo Foo";
1272        let hits: Vec<_> = re.find_iter(text).map(|m| m.as_str()).collect();
1273        assert!(
1274            hits.contains(&"FOO"),
1275            "smartcase lower-word * must match FOO: {hits:?}"
1276        );
1277        assert!(
1278            hits.contains(&"foo"),
1279            "smartcase lower-word * must match foo: {hits:?}"
1280        );
1281    }
1282
1283    // ── \a / \A / \Z / \e escape translation ─────────────────────────────────
1284
1285    /// vim `\a` = alphabetic. rust-regex would read `\a` as Bell (U+0007),
1286    /// so `:s/\a/x/g` used to silently no-op; it must now match letters.
1287    #[test]
1288    fn backslash_a_is_alphabetic() {
1289        let re = vim_re(r"\a");
1290        assert!(re.is_match("a"));
1291        assert!(re.is_match("B"));
1292        assert!(!re.is_match("1"));
1293        let hits: Vec<_> = Regex::new(&vim_to_rust_regex(r"\a"))
1294            .unwrap()
1295            .find_iter("ab1")
1296            .map(|m| m.as_str())
1297            .collect();
1298        assert_eq!(hits, vec!["a", "b"]);
1299    }
1300
1301    /// vim `\A` = non-alphabetic. rust-regex `\A` is a start-of-text anchor,
1302    /// so `:s/\A/x/` used to insert at position 0 ("ab1" → "xab1"); it must
1303    /// now match the `1` and give vim's "abx".
1304    #[test]
1305    fn backslash_upper_a_is_non_alphabetic() {
1306        let re = vim_re(r"\A");
1307        assert!(re.is_match("1"));
1308        assert!(!re.is_match("a"));
1309        let hits: Vec<_> = Regex::new(&vim_to_rust_regex(r"\A"))
1310            .unwrap()
1311            .find_iter("ab1")
1312            .map(|m| m.as_str())
1313            .collect();
1314        assert_eq!(hits, vec!["1"]);
1315    }
1316
1317    /// `[\a]` inside a class is the literal set {`\`, `a`}, not the
1318    /// alphabetic range and not a Bell escape — measured against neovim
1319    /// 0.12.4 (`:s/[\a]/Q/` on "x5 a" replaces only the literal `a`). The
1320    /// pair is emitted escaped so rust-regex reads it literally.
1321    #[test]
1322    fn backslash_a_inside_class_is_literal() {
1323        assert_eq!(vim_to_rust_regex(r"[\a]"), r"[\\a]");
1324        let re = vim_re(r"[\a]");
1325        assert!(re.is_match("a"));
1326        assert!(re.is_match("\\"));
1327        assert!(!re.is_match("x"));
1328        assert!(!re.is_match("1"));
1329    }
1330
1331    /// An ESCAPED `]` inside a class is a literal member — vim `[a\]b]` =
1332    /// {a, ], b} — and must not close the class early. The pre-fix translator
1333    /// closed on the escaped `]`, emitting `[a\]b\]` which rust-regex rejects
1334    /// as an unclosed class, so `:s/[a\]b]/x/` errored where vim substitutes.
1335    #[test]
1336    fn escaped_close_bracket_inside_class_is_literal_member() {
1337        let re = vim_re(r"[a\]b]");
1338        assert!(re.is_match("a"), "class must contain a");
1339        assert!(re.is_match("]"), "escaped ] must be a literal member");
1340        assert!(re.is_match("b"), "class must contain b");
1341        assert!(!re.is_match("x"), "class must be exactly {{a, ], b}}");
1342        // The canonical `[a\]]` form, and `\\]` (even backslash run) closing.
1343        assert!(vim_re(r"[a\]]").is_match("]"));
1344        assert!(vim_re(r"[\\]").is_match("\\"));
1345        assert!(!vim_re(r"[\\]").is_match("]"));
1346    }
1347
1348    /// vim `\Z` — ignore case for the rest of the pattern, identical to `\c`
1349    /// (rust-regex rejects `\Z` outright).
1350    #[test]
1351    fn backslash_z_makes_pattern_case_insensitive() {
1352        let (stripped, mode) = resolve_case_mode(r"\Zfoo", CaseMode::Sensitive, "");
1353        assert_eq!(stripped, "foo");
1354        assert_eq!(mode, CaseMode::Insensitive);
1355        // End-to-end: a Sensitive base must be overridden by `\Z`.
1356        let re = build_regex_from(r"\Zfoo", false, false);
1357        assert!(re.is_match("FOO"), "\\Z must make pattern insensitive");
1358        assert!(re.is_match("foo"));
1359    }
1360
1361    /// vim `\e` = ESC (U+001B); rust-regex has no `\e` escape and rejects it.
1362    #[test]
1363    fn backslash_e_is_esc() {
1364        assert_eq!(vim_to_rust_regex(r"\e"), "\u{1b}");
1365        let re = vim_re(r"\e");
1366        assert!(re.is_match("\u{1b}"));
1367        assert!(!re.is_match("e"));
1368    }
1369
1370    // ── substitution-level regression for \a / \A (bug 2) ────────────────────
1371
1372    fn editor_for_substitute(
1373        content: &str,
1374    ) -> crate::Editor<hjkl_buffer::View, crate::types::DefaultHost> {
1375        let mut e = crate::Editor::new(
1376            hjkl_buffer::View::new(),
1377            crate::types::DefaultHost::new(),
1378            crate::types::Options::default(),
1379        );
1380        e.set_content(content);
1381        e
1382    }
1383
1384    /// `:s/\a/x/g` on "ab1" must replace both letters — the pre-fix rust-regex
1385    /// reading of `\a` (Bell) matched nothing and the command silently no-oped.
1386    #[test]
1387    fn substitute_backslash_a_replaces_letters() {
1388        let mut e = editor_for_substitute("ab1");
1389        let cmd = crate::substitute::parse_substitute(r"/\a/x/g").unwrap();
1390        let out = crate::substitute::apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1391        assert_eq!(out.replacements, 2);
1392        assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 0), "xx1");
1393    }
1394
1395    /// `:s/\A/x/` (first match per line) on "ab1" gives "abx" — the pre-fix
1396    /// rust-regex `\A` (start-of-text anchor) replaced at position 0 instead,
1397    /// producing "xab1".
1398    #[test]
1399    fn substitute_backslash_upper_a_replaces_first_non_alpha() {
1400        let mut e = editor_for_substitute("ab1");
1401        let cmd = crate::substitute::parse_substitute(r"/\A/x/").unwrap();
1402        let out = crate::substitute::apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1403        assert_eq!(out.replacements, 1);
1404        assert_eq!(hjkl_buffer::rope_line_str(&e.buffer().rope(), 0), "abx");
1405    }
1406}