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. See `DIVERGE.md`.
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
257    while let Some(ch) = chars.next() {
258        if in_bracket {
259            out.push(ch);
260            if ch == ']' {
261                in_bracket = false;
262            }
263            continue;
264        }
265
266        if ch == '\\' {
267            match chars.next() {
268                Some('c') => override_mode = Some(true),  // \c → insensitive
269                Some('C') => override_mode = Some(false), // \C → sensitive
270                Some('v') => level = MagicLevel::VeryMagic,
271                Some('V') => level = MagicLevel::VeryNoMagic,
272                Some('m') => level = MagicLevel::Magic,
273                Some('M') => level = MagicLevel::NoMagic,
274                Some(d @ '0'..='9') => {
275                    // Backreference — unsupported by rust-regex. Pass through
276                    // unchanged (keeps prior no-match/error behavior).
277                    out.push('\\');
278                    out.push(d);
279                }
280                Some(c2) if very_magic_special(c2) || magic_special(c2) || nomagic_special(c2) => {
281                    if is_special_unescaped(c2, level) {
282                        // Already special unescaped at this level — backslash
283                        // forces the literal reading.
284                        emit_literal(&mut out, c2);
285                    } else if c2 == '[' {
286                        out.push('[');
287                        in_bracket = true;
288                    } else {
289                        emit_special(&mut out, c2, &mut chars, last_sub);
290                    }
291                }
292                Some(other) => {
293                    // \d \s \w \b \B \a \A \n \t \r \& \~ \\ etc. — already
294                    // valid rust-regex syntax (or handled by the caller) and
295                    // identical in vim's default magic. Pass through.
296                    out.push('\\');
297                    out.push(other);
298                }
299                None => out.push('\\'),
300            }
301            continue;
302        }
303
304        if is_special_unescaped(ch, level) {
305            if ch == '[' {
306                out.push('[');
307                in_bracket = true;
308            } else {
309                emit_special(&mut out, ch, &mut chars, last_sub);
310            }
311        } else {
312            emit_literal(&mut out, ch);
313        }
314    }
315
316    (out, override_mode)
317}
318
319/// Strip `\c` / `\C` overrides from `pat`, resolve the effective
320/// [`CaseMode`], and return the cleaned pattern together with the
321/// resolved mode.
322///
323/// ### Override rules (mirrors vim)
324///
325/// - `\c` anywhere in `pat` forces case-insensitive.
326/// - `\C` anywhere in `pat` forces case-sensitive.
327/// - When both appear the **last** one wins.
328/// - Both are stripped from the returned pattern.
329///
330/// ### Magic-mode translation
331///
332/// As of the default-magic regex fix, this function also translates vim's
333/// default-magic (and `\v`/`\V`/`\m`/`\M`-switched) regex syntax into
334/// rust-`regex` syntax — see [`translate_pattern`] for the full transform
335/// list. `vim_to_rust_regex` is a thin wrapper that discards the case mode.
336///
337/// ### Smart-case detection
338///
339/// When `base` is [`CaseMode::Smart`] and no `\c`/`\C` override was
340/// found, the pattern is scanned for uppercase Unicode letters. Any
341/// uppercase letter → `Sensitive`; otherwise → `Insensitive`.
342///
343/// ### Per-substitute flag interaction
344///
345/// The `:s/…/…/i` and `:s/…/…/I` flags are handled in
346/// `apply_substitute` **before** calling this function (they
347/// short-circuit entirely). This function is not involved.
348///
349/// ### Magic `~` expansion
350///
351/// `last_sub` is the previous `:s` replacement string (pass `""` when there is
352/// no substitute context, e.g. `*`/`#` word search). Callers get it from
353/// [`crate::editor::Editor::last_substitute_replacement`]. See
354/// [`translate_pattern`] for the expansion + escaping rules.
355pub fn resolve_case_mode(pat: &str, base: CaseMode, last_sub: &str) -> (String, CaseMode) {
356    let (out, override_mode) = translate_pattern(pat, last_sub);
357
358    let resolved = match override_mode {
359        Some(true) => CaseMode::Insensitive,
360        Some(false) => CaseMode::Sensitive,
361        None => match base {
362            CaseMode::Smart => {
363                // Any uppercase rune → sensitive. Scan the TRANSLATED
364                // pattern so control sequences consumed during translation
365                // (`\c` `\C` `\v` `\V` `\m` `\M`) don't spuriously count —
366                // matches the pre-existing behavior this function had before
367                // magic-mode translation was added.
368                if out.chars().any(|c| c.is_uppercase()) {
369                    CaseMode::Sensitive
370                } else {
371                    CaseMode::Insensitive
372                }
373            }
374            other => other,
375        },
376    };
377
378    (out, resolved)
379}
380
381/// Rewrite vim-style word-boundary escapes to Rust `regex`-compatible form
382/// **and** strip `\c`/`\C` case overrides.
383///
384/// The `regex` crate supports `\b` (symmetric word boundary) but not the
385/// vim/PCRE `\<` (word-boundary start) or `\>` (word-boundary end) variants.
386/// This function performs a single-pass rewrite:
387///
388/// - `\<` → `\b`
389/// - `\>` → `\b`
390/// - `\c` / `\C` stripped (case override — handled by [`resolve_case_mode`])
391/// - `\\<` / `\\>` (literal double-backslash followed by `<`/`>`) are left
392///   untouched — only the unescaped form transforms.
393/// - All other syntax (`\b`, `\B`, `\d`, anchors, …) passes through unchanged.
394///
395/// Call this on the raw user-typed pattern string **before** passing to
396/// `regex::Regex::new`. Keep the original string for display / history.
397///
398/// Prefer [`resolve_case_mode`] when you also need to apply case semantics;
399/// that function performs the same boundary rewrite internally.
400///
401/// This thin wrapper passes an empty last-substitute string, so a magic `~`
402/// expands to the empty string. Use [`resolve_case_mode`] directly with the
403/// editor's last-substitute replacement when `~` expansion matters.
404pub fn vim_to_rust_regex(pat: &str) -> String {
405    resolve_case_mode(pat, CaseMode::Sensitive, "").0
406}
407
408/// Per-row match cache keyed against the buffer's `dirty_gen`. Live
409/// alongside the active pattern so re-running `n` doesn't re-scan
410/// rows the buffer hasn't touched.
411#[derive(Debug, Clone, Default)]
412pub struct SearchState {
413    /// Active pattern, if any. `None` clears highlighting and makes
414    /// `n` / `N` no-op until the next `/` / `?` commit.
415    pub pattern: Option<Regex>,
416    /// `true` for `/`, `false` for `?` — drives `n` vs `N` direction.
417    /// Mirrors `vim.last_search_forward`; consolidated so future
418    /// patches can drop the duplicate.
419    pub forward: bool,
420    /// `matches[row]` is the `(byte_start, byte_end)` runs cached on
421    /// `row`, captured at `gen[row]`. Length grows lazily.
422    pub matches: Vec<Vec<(usize, usize)>>,
423    /// Per-row generation tag. When the buffer's `dirty_gen` for a
424    /// row diverges, the row gets re-scanned on next access.
425    pub generations: Vec<u64>,
426    /// Wrap past buffer ends. Mirrors `Settings::wrapscan`.
427    pub wrap_around: bool,
428}
429
430impl SearchState {
431    /// Empty state — no pattern, forward direction, wraps.
432    pub fn new() -> Self {
433        Self {
434            pattern: None,
435            forward: true,
436            matches: Vec::new(),
437            generations: Vec::new(),
438            wrap_around: true,
439        }
440    }
441
442    /// Replace the active pattern. Drops the cached match runs so
443    /// the next access re-scans against the new regex.
444    pub fn set_pattern(&mut self, re: Option<Regex>) {
445        self.pattern = re;
446        self.matches.clear();
447        self.generations.clear();
448    }
449
450    /// Refresh `matches[row]` if either the row's gen has rolled or
451    /// we never scanned it. Returns the cached slice.
452    ///
453    /// `get_line` is materialized lazily — only invoked on a cache
454    /// miss (never scanned, or the row's gen rolled). A steady-state
455    /// warm cache returns the cached runs without allocating the line.
456    pub fn matches_for(
457        &mut self,
458        row: usize,
459        dirty_gen: u64,
460        get_line: impl FnOnce() -> String,
461    ) -> &[(usize, usize)] {
462        let Some(ref re) = self.pattern else {
463            return &[];
464        };
465        if self.matches.len() <= row {
466            self.matches.resize_with(row + 1, Vec::new);
467            self.generations.resize(row + 1, u64::MAX);
468        }
469        if self.generations[row] != dirty_gen {
470            // Shared scanner (`hjkl_buffer::search_match_ranges`) — the same
471            // byte-range computation the hlsearch painter and the quickfix
472            // dock's match overlay use, so navigation and highlighting can
473            // never disagree about where a match is.
474            self.matches[row] = hjkl_buffer::search_match_ranges(re, &get_line());
475            self.generations[row] = dirty_gen;
476        }
477        &self.matches[row]
478    }
479}
480
481/// Move the cursor to the next match starting from (or just after,
482/// when `skip_current = true`) the cursor. Wraps end-of-buffer to
483/// row 0 when `state.wrap_around`. Returns `true` when a match was
484/// found.
485///
486/// Pure observe + cursor mutation — no auto-scroll. The Editor's
487/// post-step `ensure_cursor_in_scrolloff` reapplies viewport
488/// follow.
489pub fn search_forward<B: Cursor + Query + Search>(
490    buf: &mut B,
491    state: &mut SearchState,
492    skip_current: bool,
493) -> bool {
494    let Some(re) = state.pattern.clone() else {
495        return false;
496    };
497    let cursor = buf.cursor();
498    let total = buf.line_count();
499    if total == 0 {
500        return false;
501    }
502    // To "skip the current cell", advance `from` one byte past the
503    // cursor before asking `find_next` for the at-or-after match.
504    // `pos_at_byte` clamps overflow to end-of-buffer so this is
505    // safe even when the cursor sits at the trailing edge.
506    let from = if skip_current {
507        let from_byte = buf.byte_offset(cursor);
508        buf.pos_at_byte(from_byte.saturating_add(1))
509    } else {
510        cursor
511    };
512    if let Some(range) = buf.find_next(from, &re) {
513        // Honour engine wrap policy explicitly. The buffer impl uses
514        // its own (deprecated) wrap flag; for new search state the
515        // engine SearchState is the source of truth.
516        if !state.wrap_around && range.start.line < cursor.line {
517            return false;
518        }
519        Cursor::set_cursor(buf, range.start);
520        return true;
521    }
522    false
523}
524
525/// Symmetric counterpart of [`search_forward`].
526pub fn search_backward<B: Cursor + Query + Search>(
527    buf: &mut B,
528    state: &mut SearchState,
529    skip_current: bool,
530) -> bool {
531    let Some(re) = state.pattern.clone() else {
532        return false;
533    };
534    let cursor = buf.cursor();
535    let total = buf.line_count();
536    if total == 0 {
537        return false;
538    }
539    // View's `Search::find_prev` returns the at-or-before match
540    // for the anchor `from`. For `skip_current`, we want the
541    // rightmost match whose start is *strictly before* the cursor.
542    // Strategy: query find_prev(cursor); if the returned match
543    // covers/starts-at the cursor, step the anchor back one byte
544    // past that match's start and re-query so the next find_prev
545    // skips it. Otherwise the at-or-before match is already strictly
546    // before the cursor and we accept it.
547    let initial = buf.find_prev(cursor, &re);
548    let range = if skip_current {
549        match initial {
550            Some(m) if m.start == cursor => {
551                // Cursor sits exactly on a match start (typical post-
552                // commit state). Step past and re-query.
553                let cb = buf.byte_offset(m.start);
554                if cb == 0 {
555                    // No earlier byte — fall through to wrap.
556                    None
557                } else {
558                    let anchor = buf.pos_at_byte(cb.saturating_sub(1));
559                    buf.find_prev(anchor, &re)
560                }
561            }
562            other => other,
563        }
564    } else {
565        initial
566    };
567    if let Some(range) = range {
568        if !state.wrap_around && range.start.line > cursor.line {
569            return false;
570        }
571        Cursor::set_cursor(buf, range.start);
572        return true;
573    }
574    false
575}
576
577/// Match positions on `row` as `(byte_start, byte_end)`. Used by
578/// the engine's highlight pipeline. Reads through the cache so a
579/// steady-state buffer doesn't re-scan every frame.
580pub fn search_matches<B: Query>(
581    buf: &B,
582    state: &mut SearchState,
583    dirty_gen: u64,
584    row: usize,
585) -> Vec<(usize, usize)> {
586    if state.pattern.is_none() {
587        return Vec::new();
588    }
589    let line_count = buf.line_count() as usize;
590    if row >= line_count {
591        return Vec::new();
592    }
593    // Materialize the line lazily — only when the cache misses. A warm
594    // steady-state cache skips the per-row allocation entirely.
595    state
596        .matches_for(row, dirty_gen, || buf.line(row as u32))
597        .to_vec()
598}
599
600/// Warm the per-row match cache for `row` without producing a result.
601///
602/// Same cache path as [`search_matches`] (identical miss/hit behaviour),
603/// but returns nothing — the renderer's pre-pass only wants the cache
604/// populated, and building a `Vec` per visible row just to drop it is
605/// pure allocation churn.
606pub fn warm_matches<B: Query>(buf: &B, state: &mut SearchState, dirty_gen: u64, row: usize) {
607    if state.pattern.is_none() {
608        return;
609    }
610    let line_count = buf.line_count() as usize;
611    if row >= line_count {
612        return;
613    }
614    state.matches_for(row, dirty_gen, || buf.line(row as u32));
615}
616
617#[cfg(test)]
618mod tests {
619    use super::*;
620    use crate::types::Pos;
621    use hjkl_buffer::View;
622
623    fn re(pat: &str) -> Regex {
624        Regex::new(pat).unwrap()
625    }
626
627    fn vim_re(pat: &str) -> Regex {
628        Regex::new(&vim_to_rust_regex(pat)).unwrap()
629    }
630
631    // ── vim_to_rust_regex unit tests ─────────────────────────────────────────
632
633    /// `\<` and `\>` both rewrite to `\b`.
634    #[test]
635    fn vim_boundary_rewrites_to_b() {
636        assert_eq!(vim_to_rust_regex(r"\<foo\>"), r"\bfoo\b");
637        assert_eq!(vim_to_rust_regex(r"\<"), r"\b");
638        assert_eq!(vim_to_rust_regex(r"\>"), r"\b");
639    }
640
641    /// A literal double-backslash before `<`/`>` must not be consumed.
642    /// `\\<` in the source string is two chars: `\` `\`; the rewriter sees
643    /// the first `\` followed by `\`, emits `\\`, then `<` is plain text.
644    #[test]
645    fn escaped_backslash_left_alone() {
646        // Input: \\< (three chars in source: '\', '\', '<')
647        // Expected output: \\< (the first \ escapes the second, < is literal)
648        let input = r"\\<";
649        let output = vim_to_rust_regex(input);
650        assert_eq!(output, r"\\<");
651    }
652
653    /// Other escape sequences (`\b`, `\B`, `\d`, `\w`, anchors) pass through.
654    #[test]
655    fn other_escapes_unchanged() {
656        assert_eq!(vim_to_rust_regex(r"\b"), r"\b");
657        assert_eq!(vim_to_rust_regex(r"\B"), r"\B");
658        // vim default magic: `+` is a literal unless backslashed. `\d\+`
659        // (digit class, one-or-more quantifier) translates to `\d\+` in
660        // rust-regex syntax (identical spelling — `\+` IS rust-regex's own
661        // escaped-literal-plus, but since the quantifier here is coming from
662        // vim's `\+` we want the rust-regex QUANTIFIER `+`, unescaped).
663        assert_eq!(vim_to_rust_regex(r"\d\+"), r"\d+");
664        assert_eq!(vim_to_rust_regex(r"^\w\+$"), r"^\w+$");
665    }
666
667    /// Mixed: `\<\w\+\>` rewrites to `\b\w+\b` — matches whole words.
668    #[test]
669    fn mixed_boundary_and_word_class() {
670        assert_eq!(vim_to_rust_regex(r"\<\w\+\>"), r"\b\w+\b");
671    }
672
673    // ── Integration: compiled vim patterns match correctly ───────────────────
674
675    /// `/foo\<bar\>` — `bar` as a standalone word is matched, `foobar` is not.
676    #[test]
677    fn vim_boundary_matches_standalone_word_not_suffix() {
678        let re = vim_re(r"foo\<bar\>");
679        // "foobar" — `bar` follows directly after `foo` with no word boundary:
680        // the `\b` between `foo` and `bar` fails here.
681        assert!(!re.is_match("foobar"));
682        // "foo bar" — word boundary between `foo ` and `bar`:
683        // pattern `foo\bbar\b` does not match because `foo` is not adjacent.
684        // Use a pattern that directly tests the intent: `bar` as a whole word.
685        let re2 = vim_re(r"\<bar\>");
686        assert!(re2.is_match("foo bar baz"));
687        assert!(!re2.is_match("foobar"));
688    }
689
690    /// `\<word` matches `word` at start-of-word but not mid-word.
691    #[test]
692    fn vim_boundary_start_only() {
693        let re = vim_re(r"\<word");
694        assert!(re.is_match("word here"));
695        assert!(re.is_match("some word here"));
696        assert!(!re.is_match("sword"));
697        assert!(!re.is_match("aword"));
698    }
699
700    /// `word\>` matches `word` at end-of-word but not when followed by more.
701    #[test]
702    fn vim_boundary_end_only() {
703        let re = vim_re(r"word\>");
704        assert!(re.is_match("some word"));
705        assert!(re.is_match("word"));
706        assert!(!re.is_match("words"));
707        assert!(!re.is_match("wordsmith"));
708    }
709
710    /// Existing `\b` continues to work (sanity check — no double-transform).
711    #[test]
712    fn existing_b_boundary_unchanged() {
713        let re = vim_re(r"\bfoo\b");
714        assert!(re.is_match("foo"));
715        assert!(re.is_match("a foo b"));
716        assert!(!re.is_match("foobar"));
717        assert!(!re.is_match("afoo"));
718    }
719
720    /// Mixed: `\<\w+\>` matches whole words only.
721    #[test]
722    fn vim_whole_word_pattern() {
723        let re = vim_re(r"\<\w\+\>");
724        let matches: Vec<_> = re.find_iter("foo bar baz").map(|m| m.as_str()).collect();
725        assert_eq!(matches, vec!["foo", "bar", "baz"]);
726    }
727
728    #[test]
729    fn empty_state_no_match() {
730        let mut b = View::from_str("anything");
731        let mut s = SearchState::new();
732        assert!(!search_forward(&mut b, &mut s, false));
733        assert!(!search_backward(&mut b, &mut s, false));
734    }
735
736    // ── B8/B9: default-magic + \v/\V/\m/\M translation ───────────────────────
737
738    #[test]
739    fn default_magic_groups_and_backref_replacement_side() {
740        // \( \) → real groups; the PATTERN side is exercised end-to-end via
741        // substitute.rs (replacement-side \1 already worked before this fix).
742        assert_eq!(
743            vim_to_rust_regex(r"\(hello\) \(world\)"),
744            r"(hello) (world)"
745        );
746    }
747
748    #[test]
749    fn default_magic_quantifiers_and_alternation() {
750        assert_eq!(vim_to_rust_regex(r"a\+"), r"a+");
751        assert_eq!(vim_to_rust_regex(r"a\?"), r"a?");
752        assert_eq!(vim_to_rust_regex(r"a\="), r"a?");
753        assert_eq!(vim_to_rust_regex(r"a\|b"), r"a|b");
754    }
755
756    #[test]
757    fn default_magic_counted_repeat_bare_close() {
758        // vim allows `\{n,m}` with an UNESCAPED closing brace.
759        assert_eq!(vim_to_rust_regex(r"a\{1,2}"), r"a{1,2}");
760        // Fully-escaped form also works.
761        assert_eq!(vim_to_rust_regex(r"a\{1,2\}"), r"a{1,2}");
762    }
763
764    #[test]
765    fn default_magic_unescaped_group_chars_are_literal() {
766        // The INVERSE: unescaped ( ) + ? | { } are literals in default magic.
767        assert_eq!(vim_to_rust_regex("(a)"), r"\(a\)");
768        assert_eq!(vim_to_rust_regex("a+b"), r"a\+b");
769        assert_eq!(vim_to_rust_regex("a|b"), r"a\|b");
770        assert_eq!(vim_to_rust_regex("a?b"), r"a\?b");
771    }
772
773    #[test]
774    fn default_magic_dot_star_bracket_caret_dollar_stay_magic() {
775        assert_eq!(vim_to_rust_regex("a.b"), "a.b");
776        assert_eq!(vim_to_rust_regex("a*"), "a*");
777        assert_eq!(vim_to_rust_regex("[0-9]"), "[0-9]");
778        assert_eq!(vim_to_rust_regex("^foo$"), "^foo$");
779    }
780
781    #[test]
782    fn magic_tilde_expands_to_last_sub_empty_via_wrapper() {
783        // `vim_to_rust_regex` passes an empty last-substitute string, so a bare
784        // magic `~` expands to "" (nvim would `E33` with no prior `:s`; we pick
785        // the safe empty expansion). `\~` stays a literal tilde.
786        assert_eq!(vim_to_rust_regex("a~b"), "ab");
787        assert_eq!(vim_to_rust_regex(r"a\~b"), "a~b");
788    }
789
790    // ── Magic `~` PATTERN-side expansion (V5) ────────────────────────────────
791
792    /// `~` expands to the supplied last-substitute string; `\~` stays literal.
793    /// nvim-verified: after `:s/foo/BAR/`, `/~` matches the text `BAR`.
794    #[test]
795    fn magic_tilde_expands_to_last_sub() {
796        let (out, _) = resolve_case_mode("~", CaseMode::Sensitive, "BAR");
797        assert_eq!(out, "BAR");
798        // Surrounded by other pattern text.
799        let (out, _) = resolve_case_mode("x~y", CaseMode::Sensitive, "BAR");
800        assert_eq!(out, "xBARy");
801    }
802
803    /// `\~` is a literal tilde and must NOT expand, even with a last-sub set.
804    /// nvim-verified: `\~` in a pattern matches a real `~` character.
805    #[test]
806    fn escaped_tilde_stays_literal_and_does_not_expand() {
807        let (out, _) = resolve_case_mode(r"\~", CaseMode::Sensitive, "BAR");
808        assert_eq!(out, "~");
809        // Compiled: matches a real tilde, not "BAR".
810        let re = Regex::new(&out).unwrap();
811        assert!(re.is_match("a~b"));
812        assert!(!re.is_match("BAR"));
813    }
814
815    /// `~` inside a `[...]` class is a literal class member, never an
816    /// expansion. nvim-verified: `[~]` matches the tilde character.
817    #[test]
818    fn tilde_in_bracket_class_is_literal() {
819        let (out, _) = resolve_case_mode("[~]", CaseMode::Sensitive, "BAR");
820        assert_eq!(out, "[~]");
821    }
822
823    /// No previous substitute (empty last-sub) → `~` expands to empty.
824    /// Documented divergence from nvim's `E33`; the empty choice never
825    /// corrupts the buffer.
826    #[test]
827    fn magic_tilde_no_previous_sub_expands_empty() {
828        let (out, _) = resolve_case_mode("a~b", CaseMode::Sensitive, "");
829        assert_eq!(out, "ab");
830    }
831
832    #[test]
833    fn very_magic_mode_switch_at_start() {
834        // \v: groups/quantifiers/alternation/boundaries are magic unescaped.
835        assert_eq!(vim_to_rust_regex(r"\v(\w+) (\w+)"), r"(\w+) (\w+)");
836        assert_eq!(vim_to_rust_regex(r"\v\d+"), r"\d+");
837        assert_eq!(vim_to_rust_regex(r"\v<foo>"), r"\bfoo\b");
838        assert_eq!(vim_to_rust_regex(r"\va=b"), r"a?b");
839    }
840
841    #[test]
842    fn very_magic_mode_escaped_chars_are_literal() {
843        // In \v mode, backslash forces the LITERAL reading of an
844        // otherwise-special char.
845        assert_eq!(vim_to_rust_regex(r"\v\(a\)"), r"\(a\)");
846        assert_eq!(vim_to_rust_regex(r"\va\+b"), r"a\+b");
847    }
848
849    #[test]
850    fn very_nomagic_mode_is_all_literal_except_backslash() {
851        // \V: everything literal except `\`-escaped.
852        assert_eq!(vim_to_rust_regex(r"\Va.b"), r"a\.b");
853        assert_eq!(vim_to_rust_regex(r"\V(a)"), r"\(a\)");
854        // Backslash still activates special meaning (mirrors \v).
855        assert_eq!(vim_to_rust_regex(r"\Va\.b"), r"a.b");
856    }
857
858    #[test]
859    fn nomagic_mode_only_caret_dollar_special() {
860        // \M: only ^ $ special unescaped; `.` `*` `[` become literal.
861        assert_eq!(vim_to_rust_regex(r"\M^a.b$"), r"^a\.b$");
862        assert_eq!(vim_to_rust_regex(r"\Ma\.b"), r"a.b");
863    }
864
865    #[test]
866    fn mode_switch_mid_pattern() {
867        // Switching mode partway through the pattern applies from that point on.
868        assert_eq!(vim_to_rust_regex(r"(a)\v(b)"), r"\(a\)(b)");
869        assert_eq!(vim_to_rust_regex(r"\va\mb+"), r"ab\+");
870    }
871
872    #[test]
873    fn backreference_in_pattern_passes_through_unchanged() {
874        // \1-\9 in the PATTERN aren't supported by rust-regex (no
875        // backtracking) — kept as a literal backslash-digit escape so the
876        // net effect (no match / compile error) matches pre-fix behavior
877        // rather than silently corrupting text. See DIVERGE.md.
878        assert_eq!(vim_to_rust_regex(r"\(a\)\1"), r"(a)\1");
879    }
880
881    #[test]
882    fn character_class_contents_not_translated() {
883        // Unescaped `(` `)` inside `[...]` are literal class members in both
884        // vim and rust-regex — bracket tracking must not turn them into a
885        // group by escaping/unescaping their contents.
886        assert_eq!(vim_to_rust_regex("[()]"), "[()]");
887    }
888
889    // ── search reveals folds ─────────────────────────────────────────────────
890
891    /// `search_forward` on a buffer with a closed fold hiding the match row:
892    /// after finding the match, calling `reveal_row` opens the fold.
893    /// (Mirrors what `Editor::search_advance_forward` does.)
894    #[test]
895    fn search_forward_reveals_fold() {
896        use hjkl_buffer::View;
897
898        // View: row 0 = "header", row 1 = "needle", row 2 = "footer"
899        // Fold [0..2] closed → row 1 is hidden.
900        let mut buf = View::from_str("header\nneedle\nfooter");
901        buf.add_fold(0, 2, true);
902        assert!(buf.is_row_hidden(1), "row 1 must be hidden before search");
903
904        let mut state = SearchState::new();
905        state.set_pattern(Some(re("needle")));
906
907        // Use search_forward directly on the buffer.
908        let found = search_forward(&mut buf, &mut state, false);
909        assert!(found, "search_forward must find 'needle'");
910
911        // After search_forward, cursor is on row 1. Reveal as Editor does.
912        let row = crate::types::Cursor::cursor(&buf).line as usize;
913        buf.reveal_row(row);
914        assert!(
915            !buf.is_row_hidden(1),
916            "row 1 must be revealed after search finds it there"
917        );
918    }
919
920    /// `search_backward` similarly: finding a match then calling reveal_row opens folds.
921    #[test]
922    fn search_backward_reveals_fold() {
923        use hjkl_buffer::View;
924
925        // row 0 = "footer", row 1 = "needle", row 2 = "header"
926        // fold [0..2] closed → row 1 hidden. Start cursor at row 2.
927        let mut buf = View::from_str("footer\nneedle\nheader");
928        buf.add_fold(0, 2, true);
929        crate::types::Cursor::set_cursor(&mut buf, crate::types::Pos::new(2, 0));
930        assert!(buf.is_row_hidden(1), "row 1 must be hidden before search");
931
932        let mut state = SearchState::new();
933        state.set_pattern(Some(re("needle")));
934
935        let found = search_backward(&mut buf, &mut state, false);
936        assert!(found, "search_backward must find 'needle'");
937
938        let row = crate::types::Cursor::cursor(&buf).line as usize;
939        buf.reveal_row(row);
940        assert!(
941            !buf.is_row_hidden(1),
942            "row 1 must be revealed after backward search finds it"
943        );
944    }
945
946    #[test]
947    fn forward_finds_first_match() {
948        let mut b = View::from_str("foo bar foo baz");
949        let mut s = SearchState::new();
950        s.set_pattern(Some(re("foo")));
951        assert!(search_forward(&mut b, &mut s, false));
952        assert_eq!(Cursor::cursor(&b), Pos::new(0, 0));
953    }
954
955    #[test]
956    fn forward_skip_current_walks_past() {
957        let mut b = View::from_str("foo bar foo baz");
958        let mut s = SearchState::new();
959        s.set_pattern(Some(re("foo")));
960        search_forward(&mut b, &mut s, false);
961        search_forward(&mut b, &mut s, true);
962        assert_eq!(Cursor::cursor(&b), Pos::new(0, 8));
963    }
964
965    #[test]
966    fn forward_wraps_to_top() {
967        let mut b = View::from_str("zzz\nfoo");
968        // 0.0.37: wrap policy lives entirely on `SearchState::wrap_around`;
969        // the buffer-side `set_search_wrap` accessor is gone. Trait
970        // `find_next` always wraps; the engine search free function
971        // honours `s.wrap_around` directly.
972        Cursor::set_cursor(&mut b, Pos::new(1, 2));
973        let mut s = SearchState::new();
974        s.set_pattern(Some(re("zzz")));
975        s.wrap_around = true;
976        assert!(search_forward(&mut b, &mut s, true));
977        assert_eq!(Cursor::cursor(&b), Pos::new(0, 0));
978    }
979
980    #[test]
981    fn search_matches_caches_against_dirty_gen() {
982        let b = View::from_str("foo bar");
983        let mut s = SearchState::new();
984        s.set_pattern(Some(re("bar")));
985        let dgen = b.dirty_gen();
986        let initial = search_matches(&b, &mut s, dgen, 0);
987        assert_eq!(initial, vec![(4, 7)]);
988    }
989
990    // ── CaseMode::from_options matrix ────────────────────────────────────────
991
992    #[test]
993    fn case_mode_from_options_matrix() {
994        // ic=false, smart=* → Sensitive
995        assert_eq!(CaseMode::from_options(false, false), CaseMode::Sensitive);
996        assert_eq!(CaseMode::from_options(false, true), CaseMode::Sensitive);
997        // ic=true, smart=false → Insensitive
998        assert_eq!(CaseMode::from_options(true, false), CaseMode::Insensitive);
999        // ic=true, smart=true → Smart
1000        assert_eq!(CaseMode::from_options(true, true), CaseMode::Smart);
1001    }
1002
1003    // ── resolve_case_mode unit tests ─────────────────────────────────────────
1004
1005    #[test]
1006    fn resolve_case_mode_no_override_smart_lowercase() {
1007        let (stripped, mode) = resolve_case_mode("foo", CaseMode::Smart, "");
1008        assert_eq!(stripped, "foo");
1009        assert_eq!(mode, CaseMode::Insensitive);
1010    }
1011
1012    #[test]
1013    fn resolve_case_mode_no_override_smart_uppercase() {
1014        let (stripped, mode) = resolve_case_mode("Foo", CaseMode::Smart, "");
1015        assert_eq!(stripped, "Foo");
1016        assert_eq!(mode, CaseMode::Sensitive);
1017    }
1018
1019    #[test]
1020    fn resolve_case_mode_lower_c_override() {
1021        // \c overrides Sensitive → Insensitive; stripped pattern is "Foo"
1022        let (stripped, mode) = resolve_case_mode(r"\cFoo", CaseMode::Sensitive, "");
1023        assert_eq!(stripped, "Foo");
1024        assert_eq!(mode, CaseMode::Insensitive);
1025    }
1026
1027    #[test]
1028    fn resolve_case_mode_upper_c_override() {
1029        // \C overrides Smart → Sensitive; stripped pattern is "foo"
1030        let (stripped, mode) = resolve_case_mode(r"foo\C", CaseMode::Smart, "");
1031        assert_eq!(stripped, "foo");
1032        assert_eq!(mode, CaseMode::Sensitive);
1033    }
1034
1035    #[test]
1036    fn resolve_case_mode_last_wins() {
1037        // \c then \C → last-wins → Sensitive; stripped "foo"
1038        let (stripped, mode) = resolve_case_mode(r"\cfoo\C", CaseMode::Smart, "");
1039        assert_eq!(stripped, "foo");
1040        assert_eq!(mode, CaseMode::Sensitive);
1041    }
1042
1043    // ── Integration: search with smartcase / \c / \C ─────────────────────────
1044
1045    fn build_regex_from(pat: &str, ic: bool, smart: bool) -> Regex {
1046        let base = CaseMode::from_options(ic, smart);
1047        let (stripped, mode) = resolve_case_mode(pat, base, "");
1048        let src = if mode == CaseMode::Insensitive {
1049            format!("(?i){stripped}")
1050        } else {
1051            stripped
1052        };
1053        Regex::new(&src).unwrap()
1054    }
1055
1056    #[test]
1057    fn search_finds_capital_with_smartcase_lowercase_pattern() {
1058        // ic=true, smart=true, pattern "foo" → Insensitive → matches "FOO"
1059        let re = build_regex_from("foo", true, true);
1060        assert!(re.is_match("FOO"), "expected match on 'FOO'");
1061        assert!(re.is_match("foo"), "expected match on 'foo'");
1062    }
1063
1064    #[test]
1065    fn search_skips_capital_with_smartcase_mixed_pattern() {
1066        // ic=true, smart=true, pattern "Foo" → Sensitive → does NOT match "FOO"
1067        let re = build_regex_from("Foo", true, true);
1068        assert!(!re.is_match("FOO"), "must not match 'FOO' (case-sensitive)");
1069        assert!(re.is_match("Foo"), "must match exact 'Foo'");
1070    }
1071
1072    #[test]
1073    fn search_lower_c_override_finds_capital() {
1074        // \cFoo + Sensitive base → Insensitive override → matches "FOO"
1075        let re = build_regex_from(r"\cFoo", false, false);
1076        assert!(re.is_match("FOO"), "\\c override must match 'FOO'");
1077        assert!(re.is_match("foo"), "\\c override must match 'foo'");
1078    }
1079
1080    #[test]
1081    fn vim_to_rust_regex_strips_case_overrides() {
1082        // vim_to_rust_regex is now a thin wrapper; \c and \C are stripped
1083        assert_eq!(vim_to_rust_regex(r"\cfoo"), "foo");
1084        assert_eq!(vim_to_rust_regex(r"foo\C"), "foo");
1085        assert_eq!(vim_to_rust_regex(r"\<bar\>"), r"\bbar\b");
1086    }
1087
1088    /// `*` on word "foo" emits the pattern `\bfoo\b` (all lowercase). Under
1089    /// smartcase that resolves to Insensitive → should match "FOO". This test
1090    /// simulates the word_at_cursor_search pattern-build path.
1091    #[test]
1092    fn star_search_finds_lowercase_when_smartcase_lower_word() {
1093        // word_at_cursor_search escapes the word then wraps \b..\b.
1094        // "foo" is all-lowercase after word-extraction → Smart → Insensitive.
1095        let pat = r"\bfoo\b";
1096        let re = build_regex_from(pat, true, true);
1097        // Case-insensitive → matches "FOO foo Foo".
1098        let text = "FOO foo Foo";
1099        let hits: Vec<_> = re.find_iter(text).map(|m| m.as_str()).collect();
1100        assert!(
1101            hits.contains(&"FOO"),
1102            "smartcase lower-word * must match FOO: {hits:?}"
1103        );
1104        assert!(
1105            hits.contains(&"foo"),
1106            "smartcase lower-word * must match foo: {hits:?}"
1107        );
1108    }
1109}