Skip to main content

strop_engine/editor/normal/
search.rs

1//! normal/search.rs — search: / ? * # n N and find candidates. The
2//! query engine lives in strop-grammar (CompiledQuery); the prompt's
3//! live resolution lives in `pending` (one owner, R7).
4
5use strop_core::Range;
6use strop_grammar::{self as grammar, Command};
7
8use crate::editor::{Editor, FindPending, LastSearch};
9
10impl Editor {
11    pub(crate) fn note_search(&mut self, cmd: &Command) {
12        match &cmd.target {
13            grammar::Target::Motion(grammar::Motion::Search(query)) => {
14                self.last_search = Some(LastSearch {
15                    query: query.clone(),
16                    backward: false,
17                });
18            }
19            grammar::Target::Motion(grammar::Motion::SearchBackward(query)) => {
20                self.last_search = Some(LastSearch {
21                    query: query.clone(),
22                    backward: true,
23                });
24            }
25            grammar::Target::Motion(grammar::Motion::FindChar { ch, till, backward }) => {
26                self.last_find = Some((*ch, *backward, *till));
27            }
28            _ => {}
29        }
30    }
31
32    /// `;` / `,`: replay the last f/F/t/T (vim: `,` inverts direction),
33    /// line-local like the original find, cascading over cursors.
34    pub(crate) fn repeat_find(&mut self, reverse: bool) {
35        let Some((ch, backward, till)) = self.last_find else {
36            self.message = "no previous find".into();
37            return;
38        };
39        let backward = backward ^ reverse;
40        // char-honest: ; , on f é must land on é (0014)
41        let seek = |buf: &strop_core::Buffer, cursor: usize| -> Option<usize> {
42            let landing = |target: usize| {
43                if !till {
44                    target
45                } else if backward {
46                    buf.ceil_boundary(target + 1)
47                } else {
48                    buf.clamp_boundary(target.saturating_sub(1))
49                }
50            };
51            let mut target = grammar::find_character(buf, cursor.into(), ch, backward, 1)?;
52            while landing(target.get()) == cursor {
53                target = grammar::find_character(buf, target, ch, backward, 1)?;
54            }
55            Some(landing(target.get()))
56        };
57        let extras: Vec<usize> = self
58            .extra_selections()
59            .iter()
60            .map(|s| seek(self.buf(), s.head).unwrap_or(s.head))
61            .collect();
62        self.sels_mut().set_extras(extras);
63        match seek(self.buf(), self.head()) {
64            Some(h) => {
65                self.set_head(h);
66                self.flash(Range::charwise(self.head(), self.head()));
67            }
68            None => self.message = "find: no more matches".into(),
69        }
70        self.normalize_cursors();
71    }
72
73    /// `n` / `N`: repeat the armed search, wrapping at the file edges.
74    /// Cascades: every cursor seeks from its own position (0013 §3).
75    /// The query is the one compiled engine — whole-word and the regex
76    /// dialect live inside it, never re-filtered here.
77    pub(crate) fn repeat_search(&mut self, invert: bool) {
78        let Some(search) = self.last_search.clone() else {
79            self.message = "no previous search".into();
80            return;
81        };
82        let motion = if search.backward ^ invert {
83            grammar::Motion::SearchBackward(search.query.clone())
84        } else {
85            grammar::Motion::Search(search.query.clone())
86        };
87        let command = grammar::Command {
88            op: None,
89            register: None,
90            count: None,
91            target: grammar::Target::Motion(motion),
92            keys: if invert { "N" } else { "n" }.into(),
93        };
94        if self.defer_resolution(
95            &command,
96            self.all_cursors(),
97            super::super::resolution::ResolutionPurpose::RepeatSearch(invert),
98        ) {
99            return;
100        }
101        self.move_cursor(&command);
102        // N reverses this movement, not the saved search's direction.
103        self.last_search = Some(search);
104    }
105
106    /// `*` / `#` (vim): search the word under the cursor — whole-word,
107    /// forward / backward, wrapping. `n`/`N` keep the same anchors.
108    pub(crate) fn search_word_under_cursor(&mut self, backward: bool) {
109        // char-classified (0017): identifiers in every script count —
110        // é/fün/変数 are words. Walk scalar boundaries directly on the rope.
111        let word_char = |c: char| c.is_alphanumeric() || c == '_';
112        let buf_len = self.buf().len_bytes();
113        let head = self.buf().clamp_boundary(self.head());
114        if head >= buf_len {
115            self.message = "no word under cursor".into();
116            return;
117        }
118        let char_at = |position: usize| -> Option<char> {
119            (position < buf_len).then(|| {
120                self.buf()
121                    .text()
122                    .char(self.buf().text().byte_to_char(position))
123            })
124        };
125        if !char_at(head).is_some_and(word_char) {
126            self.message = "no word under cursor".into();
127            return;
128        }
129        let mut start = head;
130        while start > 0 {
131            let prev = self.buf().clamp_boundary(start - 1);
132            if char_at(prev).is_some_and(word_char) {
133                start = prev;
134            } else {
135                break;
136            }
137        }
138        let mut end = head;
139        while end < buf_len {
140            match char_at(end) {
141                Some(ch) if word_char(ch) => end += ch.len_utf8(),
142                _ => break,
143            }
144        }
145        let pattern = self.buf().text().byte_slice(start..end).to_string();
146        let query = match grammar::CompiledQuery::compile(&pattern, true) {
147            Ok(query) => query,
148            Err(error) => {
149                self.message = error.to_string();
150                return;
151            }
152        };
153        self.last_search = Some(LastSearch { query, backward });
154        // `#` seeks from the word's start so the current word isn't its
155        // own "previous" match (vim semantics)
156        if backward {
157            self.set_head(start);
158        }
159        self.repeat_search(false);
160    }
161
162    /// Pending `f/F/t/T` awaiting its char: the leap-style candidates.
163    /// The WALKER owns that state — the old check read the free-text
164    /// line's last byte, so any pattern ending in `f`/`t` (`/const`)
165    /// lit candidates over the cursor line (issue 13's "stale match").
166    pub fn find_candidates(&self) -> Option<FindPending> {
167        let m = self.walker.pending_motion();
168        let ch = m.chars().next()?;
169        (m.chars().count() == 1 && matches!(ch, 'f' | 'F' | 't' | 'T')).then_some(FindPending {
170            ch,
171            backward: matches!(ch, 'F' | 'T'),
172        })
173    }
174
175    /// The active query's identity. Painting consumes its revision-matched
176    /// worker summary; only grammatical test oracles enumerate all matches.
177    pub fn current_search_query(
178        &self,
179    ) -> Result<Option<grammar::CompiledQuery>, grammar::QueryError> {
180        if let Some(pattern) = self.search_pattern() {
181            grammar::CompiledQuery::compile(pattern, false).map(Some)
182        } else {
183            Ok(self.last_search.as_ref().map(|search| search.query.clone()))
184        }
185    }
186}