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            CaseMode::Sensitive
68        } else if smartcase {
69            CaseMode::Smart
70        } else {
71            CaseMode::Insensitive
72        }
73    }
74}
75
76/// Strip `\c` / `\C` overrides from `pat`, resolve the effective
77/// [`CaseMode`], and return the cleaned pattern together with the
78/// resolved mode.
79///
80/// ### Override rules (mirrors vim)
81///
82/// - `\c` anywhere in `pat` forces case-insensitive.
83/// - `\C` anywhere in `pat` forces case-sensitive.
84/// - When both appear the **last** one wins.
85/// - Both are stripped from the returned pattern.
86///
87/// ### Smart-case detection
88///
89/// When `base` is [`CaseMode::Smart`] and no `\c`/`\C` override was
90/// found, the pattern is scanned for uppercase Unicode letters. Any
91/// uppercase letter → `Sensitive`; otherwise → `Insensitive`.
92///
93/// ### Per-substitute flag interaction
94///
95/// The `:s/…/…/i` and `:s/…/…/I` flags are handled in
96/// `apply_substitute` **before** calling this function (they
97/// short-circuit entirely). This function is not involved.
98pub fn resolve_case_mode(pat: &str, base: CaseMode) -> (String, CaseMode) {
99    let mut out = String::with_capacity(pat.len());
100    let mut chars = pat.chars().peekable();
101    // None = no override seen yet; Some(true) = \c (insensitive); Some(false) = \C (sensitive).
102    let mut override_mode: Option<bool> = None;
103
104    while let Some(ch) = chars.next() {
105        if ch == '\\' {
106            match chars.peek() {
107                Some('c') => {
108                    chars.next();
109                    override_mode = Some(true); // \c → insensitive
110                }
111                Some('C') => {
112                    chars.next();
113                    override_mode = Some(false); // \C → sensitive
114                }
115                Some('<') => {
116                    chars.next();
117                    out.push_str(r"\b");
118                }
119                Some('>') => {
120                    chars.next();
121                    out.push_str(r"\b");
122                }
123                _ => {
124                    out.push('\\');
125                    if let Some(next) = chars.next() {
126                        out.push(next);
127                    }
128                }
129            }
130        } else {
131            out.push(ch);
132        }
133    }
134
135    let resolved = match override_mode {
136        Some(true) => CaseMode::Insensitive,
137        Some(false) => CaseMode::Sensitive,
138        None => match base {
139            CaseMode::Smart => {
140                // Any uppercase rune → sensitive.
141                if out.chars().any(|c| c.is_uppercase()) {
142                    CaseMode::Sensitive
143                } else {
144                    CaseMode::Insensitive
145                }
146            }
147            other => other,
148        },
149    };
150
151    (out, resolved)
152}
153
154/// Rewrite vim-style word-boundary escapes to Rust `regex`-compatible form
155/// **and** strip `\c`/`\C` case overrides.
156///
157/// The `regex` crate supports `\b` (symmetric word boundary) but not the
158/// vim/PCRE `\<` (word-boundary start) or `\>` (word-boundary end) variants.
159/// This function performs a single-pass rewrite:
160///
161/// - `\<` → `\b`
162/// - `\>` → `\b`
163/// - `\c` / `\C` stripped (case override — handled by [`resolve_case_mode`])
164/// - `\\<` / `\\>` (literal double-backslash followed by `<`/`>`) are left
165///   untouched — only the unescaped form transforms.
166/// - All other syntax (`\b`, `\B`, `\d`, anchors, …) passes through unchanged.
167///
168/// Call this on the raw user-typed pattern string **before** passing to
169/// `regex::Regex::new`. Keep the original string for display / history.
170///
171/// Prefer [`resolve_case_mode`] when you also need to apply case semantics;
172/// that function performs the same boundary rewrite internally.
173pub fn vim_to_rust_regex(pat: &str) -> String {
174    resolve_case_mode(pat, CaseMode::Sensitive).0
175}
176
177/// Per-row match cache keyed against the buffer's `dirty_gen`. Live
178/// alongside the active pattern so re-running `n` doesn't re-scan
179/// rows the buffer hasn't touched.
180#[derive(Debug, Clone, Default)]
181pub struct SearchState {
182    /// Active pattern, if any. `None` clears highlighting and makes
183    /// `n` / `N` no-op until the next `/` / `?` commit.
184    pub pattern: Option<Regex>,
185    /// `true` for `/`, `false` for `?` — drives `n` vs `N` direction.
186    /// Mirrors `vim.last_search_forward`; consolidated so future
187    /// patches can drop the duplicate.
188    pub forward: bool,
189    /// `matches[row]` is the `(byte_start, byte_end)` runs cached on
190    /// `row`, captured at `gen[row]`. Length grows lazily.
191    pub matches: Vec<Vec<(usize, usize)>>,
192    /// Per-row generation tag. When the buffer's `dirty_gen` for a
193    /// row diverges, the row gets re-scanned on next access.
194    pub generations: Vec<u64>,
195    /// Wrap past buffer ends. Mirrors `Settings::wrapscan`.
196    pub wrap_around: bool,
197}
198
199impl SearchState {
200    /// Empty state — no pattern, forward direction, wraps.
201    pub fn new() -> Self {
202        Self {
203            pattern: None,
204            forward: true,
205            matches: Vec::new(),
206            generations: Vec::new(),
207            wrap_around: true,
208        }
209    }
210
211    /// Replace the active pattern. Drops the cached match runs so
212    /// the next access re-scans against the new regex.
213    pub fn set_pattern(&mut self, re: Option<Regex>) {
214        self.pattern = re;
215        self.matches.clear();
216        self.generations.clear();
217    }
218
219    /// Refresh `matches[row]` if either the row's gen has rolled or
220    /// we never scanned it. Returns the cached slice.
221    pub fn matches_for(&mut self, row: usize, line: &str, dirty_gen: u64) -> &[(usize, usize)] {
222        let Some(ref re) = self.pattern else {
223            return &[];
224        };
225        if self.matches.len() <= row {
226            self.matches.resize_with(row + 1, Vec::new);
227            self.generations.resize(row + 1, u64::MAX);
228        }
229        if self.generations[row] != dirty_gen {
230            self.matches[row] = re.find_iter(line).map(|m| (m.start(), m.end())).collect();
231            self.generations[row] = dirty_gen;
232        }
233        &self.matches[row]
234    }
235}
236
237/// Move the cursor to the next match starting from (or just after,
238/// when `skip_current = true`) the cursor. Wraps end-of-buffer to
239/// row 0 when `state.wrap_around`. Returns `true` when a match was
240/// found.
241///
242/// Pure observe + cursor mutation — no auto-scroll. The Editor's
243/// post-step `ensure_cursor_in_scrolloff` reapplies viewport
244/// follow.
245pub fn search_forward<B: Cursor + Query + Search>(
246    buf: &mut B,
247    state: &mut SearchState,
248    skip_current: bool,
249) -> bool {
250    let Some(re) = state.pattern.clone() else {
251        return false;
252    };
253    let cursor = buf.cursor();
254    let total = buf.line_count();
255    if total == 0 {
256        return false;
257    }
258    // To "skip the current cell", advance `from` one byte past the
259    // cursor before asking `find_next` for the at-or-after match.
260    // `pos_at_byte` clamps overflow to end-of-buffer so this is
261    // safe even when the cursor sits at the trailing edge.
262    let from = if skip_current {
263        let from_byte = buf.byte_offset(cursor);
264        buf.pos_at_byte(from_byte.saturating_add(1))
265    } else {
266        cursor
267    };
268    if let Some(range) = buf.find_next(from, &re) {
269        // Honour engine wrap policy explicitly. The buffer impl uses
270        // its own (deprecated) wrap flag; for new search state the
271        // engine SearchState is the source of truth.
272        if !state.wrap_around && range.start.line < cursor.line {
273            return false;
274        }
275        Cursor::set_cursor(buf, range.start);
276        return true;
277    }
278    false
279}
280
281/// Symmetric counterpart of [`search_forward`].
282pub fn search_backward<B: Cursor + Query + Search>(
283    buf: &mut B,
284    state: &mut SearchState,
285    skip_current: bool,
286) -> bool {
287    let Some(re) = state.pattern.clone() else {
288        return false;
289    };
290    let cursor = buf.cursor();
291    let total = buf.line_count();
292    if total == 0 {
293        return false;
294    }
295    // View's `Search::find_prev` returns the at-or-before match
296    // for the anchor `from`. For `skip_current`, we want the
297    // rightmost match whose start is *strictly before* the cursor.
298    // Strategy: query find_prev(cursor); if the returned match
299    // covers/starts-at the cursor, step the anchor back one byte
300    // past that match's start and re-query so the next find_prev
301    // skips it. Otherwise the at-or-before match is already strictly
302    // before the cursor and we accept it.
303    let initial = buf.find_prev(cursor, &re);
304    let range = if skip_current {
305        match initial {
306            Some(m) if m.start == cursor => {
307                // Cursor sits exactly on a match start (typical post-
308                // commit state). Step past and re-query.
309                let cb = buf.byte_offset(m.start);
310                if cb == 0 {
311                    // No earlier byte — fall through to wrap.
312                    None
313                } else {
314                    let anchor = buf.pos_at_byte(cb.saturating_sub(1));
315                    buf.find_prev(anchor, &re)
316                }
317            }
318            other => other,
319        }
320    } else {
321        initial
322    };
323    if let Some(range) = range {
324        if !state.wrap_around && range.start.line > cursor.line {
325            return false;
326        }
327        Cursor::set_cursor(buf, range.start);
328        return true;
329    }
330    false
331}
332
333/// Match positions on `row` as `(byte_start, byte_end)`. Used by
334/// the engine's highlight pipeline. Reads through the cache so a
335/// steady-state buffer doesn't re-scan every frame.
336pub fn search_matches<B: Query>(
337    buf: &B,
338    state: &mut SearchState,
339    dirty_gen: u64,
340    row: usize,
341) -> Vec<(usize, usize)> {
342    if state.pattern.is_none() {
343        return Vec::new();
344    }
345    let line_count = buf.line_count() as usize;
346    if row >= line_count {
347        return Vec::new();
348    }
349    let line = buf.line(row as u32);
350    state.matches_for(row, &line, dirty_gen).to_vec()
351}
352
353#[cfg(test)]
354mod tests {
355    use super::*;
356    use crate::types::Pos;
357    use hjkl_buffer::View;
358
359    fn re(pat: &str) -> Regex {
360        Regex::new(pat).unwrap()
361    }
362
363    fn vim_re(pat: &str) -> Regex {
364        Regex::new(&vim_to_rust_regex(pat)).unwrap()
365    }
366
367    // ── vim_to_rust_regex unit tests ─────────────────────────────────────────
368
369    /// `\<` and `\>` both rewrite to `\b`.
370    #[test]
371    fn vim_boundary_rewrites_to_b() {
372        assert_eq!(vim_to_rust_regex(r"\<foo\>"), r"\bfoo\b");
373        assert_eq!(vim_to_rust_regex(r"\<"), r"\b");
374        assert_eq!(vim_to_rust_regex(r"\>"), r"\b");
375    }
376
377    /// A literal double-backslash before `<`/`>` must not be consumed.
378    /// `\\<` in the source string is two chars: `\` `\`; the rewriter sees
379    /// the first `\` followed by `\`, emits `\\`, then `<` is plain text.
380    #[test]
381    fn escaped_backslash_left_alone() {
382        // Input: \\< (three chars in source: '\', '\', '<')
383        // Expected output: \\< (the first \ escapes the second, < is literal)
384        let input = r"\\<";
385        let output = vim_to_rust_regex(input);
386        assert_eq!(output, r"\\<");
387    }
388
389    /// Other escape sequences (`\b`, `\B`, `\d`, `\w`, anchors) pass through.
390    #[test]
391    fn other_escapes_unchanged() {
392        assert_eq!(vim_to_rust_regex(r"\b"), r"\b");
393        assert_eq!(vim_to_rust_regex(r"\B"), r"\B");
394        assert_eq!(vim_to_rust_regex(r"\d+"), r"\d+");
395        assert_eq!(vim_to_rust_regex(r"^\w+$"), r"^\w+$");
396    }
397
398    /// Mixed: `\<\w+\>` rewrites to `\b\w+\b` — matches whole words.
399    #[test]
400    fn mixed_boundary_and_word_class() {
401        assert_eq!(vim_to_rust_regex(r"\<\w+\>"), r"\b\w+\b");
402    }
403
404    // ── Integration: compiled vim patterns match correctly ───────────────────
405
406    /// `/foo\<bar\>` — `bar` as a standalone word is matched, `foobar` is not.
407    #[test]
408    fn vim_boundary_matches_standalone_word_not_suffix() {
409        let re = vim_re(r"foo\<bar\>");
410        // "foobar" — `bar` follows directly after `foo` with no word boundary:
411        // the `\b` between `foo` and `bar` fails here.
412        assert!(!re.is_match("foobar"));
413        // "foo bar" — word boundary between `foo ` and `bar`:
414        // pattern `foo\bbar\b` does not match because `foo` is not adjacent.
415        // Use a pattern that directly tests the intent: `bar` as a whole word.
416        let re2 = vim_re(r"\<bar\>");
417        assert!(re2.is_match("foo bar baz"));
418        assert!(!re2.is_match("foobar"));
419    }
420
421    /// `\<word` matches `word` at start-of-word but not mid-word.
422    #[test]
423    fn vim_boundary_start_only() {
424        let re = vim_re(r"\<word");
425        assert!(re.is_match("word here"));
426        assert!(re.is_match("some word here"));
427        assert!(!re.is_match("sword"));
428        assert!(!re.is_match("aword"));
429    }
430
431    /// `word\>` matches `word` at end-of-word but not when followed by more.
432    #[test]
433    fn vim_boundary_end_only() {
434        let re = vim_re(r"word\>");
435        assert!(re.is_match("some word"));
436        assert!(re.is_match("word"));
437        assert!(!re.is_match("words"));
438        assert!(!re.is_match("wordsmith"));
439    }
440
441    /// Existing `\b` continues to work (sanity check — no double-transform).
442    #[test]
443    fn existing_b_boundary_unchanged() {
444        let re = vim_re(r"\bfoo\b");
445        assert!(re.is_match("foo"));
446        assert!(re.is_match("a foo b"));
447        assert!(!re.is_match("foobar"));
448        assert!(!re.is_match("afoo"));
449    }
450
451    /// Mixed: `\<\w+\>` matches whole words only.
452    #[test]
453    fn vim_whole_word_pattern() {
454        let re = vim_re(r"\<\w+\>");
455        let matches: Vec<_> = re.find_iter("foo bar baz").map(|m| m.as_str()).collect();
456        assert_eq!(matches, vec!["foo", "bar", "baz"]);
457    }
458
459    #[test]
460    fn empty_state_no_match() {
461        let mut b = View::from_str("anything");
462        let mut s = SearchState::new();
463        assert!(!search_forward(&mut b, &mut s, false));
464        assert!(!search_backward(&mut b, &mut s, false));
465    }
466
467    // ── search reveals folds ─────────────────────────────────────────────────
468
469    /// `search_forward` on a buffer with a closed fold hiding the match row:
470    /// after finding the match, calling `reveal_row` opens the fold.
471    /// (Mirrors what `Editor::search_advance_forward` does.)
472    #[test]
473    fn search_forward_reveals_fold() {
474        use hjkl_buffer::View;
475
476        // View: row 0 = "header", row 1 = "needle", row 2 = "footer"
477        // Fold [0..2] closed → row 1 is hidden.
478        let mut buf = View::from_str("header\nneedle\nfooter");
479        buf.add_fold(0, 2, true);
480        assert!(buf.is_row_hidden(1), "row 1 must be hidden before search");
481
482        let mut state = SearchState::new();
483        state.set_pattern(Some(re("needle")));
484
485        // Use search_forward directly on the buffer.
486        let found = search_forward(&mut buf, &mut state, false);
487        assert!(found, "search_forward must find 'needle'");
488
489        // After search_forward, cursor is on row 1. Reveal as Editor does.
490        let row = crate::types::Cursor::cursor(&buf).line as usize;
491        buf.reveal_row(row);
492        assert!(
493            !buf.is_row_hidden(1),
494            "row 1 must be revealed after search finds it there"
495        );
496    }
497
498    /// `search_backward` similarly: finding a match then calling reveal_row opens folds.
499    #[test]
500    fn search_backward_reveals_fold() {
501        use hjkl_buffer::View;
502
503        // row 0 = "footer", row 1 = "needle", row 2 = "header"
504        // fold [0..2] closed → row 1 hidden. Start cursor at row 2.
505        let mut buf = View::from_str("footer\nneedle\nheader");
506        buf.add_fold(0, 2, true);
507        crate::types::Cursor::set_cursor(&mut buf, crate::types::Pos::new(2, 0));
508        assert!(buf.is_row_hidden(1), "row 1 must be hidden before search");
509
510        let mut state = SearchState::new();
511        state.set_pattern(Some(re("needle")));
512
513        let found = search_backward(&mut buf, &mut state, false);
514        assert!(found, "search_backward must find 'needle'");
515
516        let row = crate::types::Cursor::cursor(&buf).line as usize;
517        buf.reveal_row(row);
518        assert!(
519            !buf.is_row_hidden(1),
520            "row 1 must be revealed after backward search finds it"
521        );
522    }
523
524    #[test]
525    fn forward_finds_first_match() {
526        let mut b = View::from_str("foo bar foo baz");
527        let mut s = SearchState::new();
528        s.set_pattern(Some(re("foo")));
529        assert!(search_forward(&mut b, &mut s, false));
530        assert_eq!(Cursor::cursor(&b), Pos::new(0, 0));
531    }
532
533    #[test]
534    fn forward_skip_current_walks_past() {
535        let mut b = View::from_str("foo bar foo baz");
536        let mut s = SearchState::new();
537        s.set_pattern(Some(re("foo")));
538        search_forward(&mut b, &mut s, false);
539        search_forward(&mut b, &mut s, true);
540        assert_eq!(Cursor::cursor(&b), Pos::new(0, 8));
541    }
542
543    #[test]
544    fn forward_wraps_to_top() {
545        let mut b = View::from_str("zzz\nfoo");
546        // 0.0.37: wrap policy lives entirely on `SearchState::wrap_around`;
547        // the buffer-side `set_search_wrap` accessor is gone. Trait
548        // `find_next` always wraps; the engine search free function
549        // honours `s.wrap_around` directly.
550        Cursor::set_cursor(&mut b, Pos::new(1, 2));
551        let mut s = SearchState::new();
552        s.set_pattern(Some(re("zzz")));
553        s.wrap_around = true;
554        assert!(search_forward(&mut b, &mut s, true));
555        assert_eq!(Cursor::cursor(&b), Pos::new(0, 0));
556    }
557
558    #[test]
559    fn search_matches_caches_against_dirty_gen() {
560        let b = View::from_str("foo bar");
561        let mut s = SearchState::new();
562        s.set_pattern(Some(re("bar")));
563        let dgen = b.dirty_gen();
564        let initial = search_matches(&b, &mut s, dgen, 0);
565        assert_eq!(initial, vec![(4, 7)]);
566    }
567
568    // ── CaseMode::from_options matrix ────────────────────────────────────────
569
570    #[test]
571    fn case_mode_from_options_matrix() {
572        // ic=false, smart=* → Sensitive
573        assert_eq!(CaseMode::from_options(false, false), CaseMode::Sensitive);
574        assert_eq!(CaseMode::from_options(false, true), CaseMode::Sensitive);
575        // ic=true, smart=false → Insensitive
576        assert_eq!(CaseMode::from_options(true, false), CaseMode::Insensitive);
577        // ic=true, smart=true → Smart
578        assert_eq!(CaseMode::from_options(true, true), CaseMode::Smart);
579    }
580
581    // ── resolve_case_mode unit tests ─────────────────────────────────────────
582
583    #[test]
584    fn resolve_case_mode_no_override_smart_lowercase() {
585        let (stripped, mode) = resolve_case_mode("foo", CaseMode::Smart);
586        assert_eq!(stripped, "foo");
587        assert_eq!(mode, CaseMode::Insensitive);
588    }
589
590    #[test]
591    fn resolve_case_mode_no_override_smart_uppercase() {
592        let (stripped, mode) = resolve_case_mode("Foo", CaseMode::Smart);
593        assert_eq!(stripped, "Foo");
594        assert_eq!(mode, CaseMode::Sensitive);
595    }
596
597    #[test]
598    fn resolve_case_mode_lower_c_override() {
599        // \c overrides Sensitive → Insensitive; stripped pattern is "Foo"
600        let (stripped, mode) = resolve_case_mode(r"\cFoo", CaseMode::Sensitive);
601        assert_eq!(stripped, "Foo");
602        assert_eq!(mode, CaseMode::Insensitive);
603    }
604
605    #[test]
606    fn resolve_case_mode_upper_c_override() {
607        // \C overrides Smart → Sensitive; stripped pattern is "foo"
608        let (stripped, mode) = resolve_case_mode(r"foo\C", CaseMode::Smart);
609        assert_eq!(stripped, "foo");
610        assert_eq!(mode, CaseMode::Sensitive);
611    }
612
613    #[test]
614    fn resolve_case_mode_last_wins() {
615        // \c then \C → last-wins → Sensitive; stripped "foo"
616        let (stripped, mode) = resolve_case_mode(r"\cfoo\C", CaseMode::Smart);
617        assert_eq!(stripped, "foo");
618        assert_eq!(mode, CaseMode::Sensitive);
619    }
620
621    // ── Integration: search with smartcase / \c / \C ─────────────────────────
622
623    fn build_regex_from(pat: &str, ic: bool, smart: bool) -> Regex {
624        let base = CaseMode::from_options(ic, smart);
625        let (stripped, mode) = resolve_case_mode(pat, base);
626        let src = if mode == CaseMode::Insensitive {
627            format!("(?i){stripped}")
628        } else {
629            stripped
630        };
631        Regex::new(&src).unwrap()
632    }
633
634    #[test]
635    fn search_finds_capital_with_smartcase_lowercase_pattern() {
636        // ic=true, smart=true, pattern "foo" → Insensitive → matches "FOO"
637        let re = build_regex_from("foo", true, true);
638        assert!(re.is_match("FOO"), "expected match on 'FOO'");
639        assert!(re.is_match("foo"), "expected match on 'foo'");
640    }
641
642    #[test]
643    fn search_skips_capital_with_smartcase_mixed_pattern() {
644        // ic=true, smart=true, pattern "Foo" → Sensitive → does NOT match "FOO"
645        let re = build_regex_from("Foo", true, true);
646        assert!(!re.is_match("FOO"), "must not match 'FOO' (case-sensitive)");
647        assert!(re.is_match("Foo"), "must match exact 'Foo'");
648    }
649
650    #[test]
651    fn search_lower_c_override_finds_capital() {
652        // \cFoo + Sensitive base → Insensitive override → matches "FOO"
653        let re = build_regex_from(r"\cFoo", false, false);
654        assert!(re.is_match("FOO"), "\\c override must match 'FOO'");
655        assert!(re.is_match("foo"), "\\c override must match 'foo'");
656    }
657
658    #[test]
659    fn vim_to_rust_regex_strips_case_overrides() {
660        // vim_to_rust_regex is now a thin wrapper; \c and \C are stripped
661        assert_eq!(vim_to_rust_regex(r"\cfoo"), "foo");
662        assert_eq!(vim_to_rust_regex(r"foo\C"), "foo");
663        assert_eq!(vim_to_rust_regex(r"\<bar\>"), r"\bbar\b");
664    }
665
666    /// `*` on word "foo" emits the pattern `\bfoo\b` (all lowercase). Under
667    /// smartcase that resolves to Insensitive → should match "FOO". This test
668    /// simulates the word_at_cursor_search pattern-build path.
669    #[test]
670    fn star_search_finds_lowercase_when_smartcase_lower_word() {
671        // word_at_cursor_search escapes the word then wraps \b..\b.
672        // "foo" is all-lowercase after word-extraction → Smart → Insensitive.
673        let pat = r"\bfoo\b";
674        let re = build_regex_from(pat, true, true);
675        // Case-insensitive → matches "FOO foo Foo".
676        let text = "FOO foo Foo";
677        let hits: Vec<_> = re.find_iter(text).map(|m| m.as_str()).collect();
678        assert!(
679            hits.contains(&"FOO"),
680            "smartcase lower-word * must match FOO: {hits:?}"
681        );
682        assert!(
683            hits.contains(&"foo"),
684            "smartcase lower-word * must match foo: {hits:?}"
685        );
686    }
687}