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<strop_core::selection::Selection> = self
58            .extra_selections()
59            .iter()
60            .map(|s| strop_core::selection::Selection {
61                anchor: s.anchor,
62                head: seek(self.buf(), s.head).unwrap_or(s.head),
63            })
64            .collect();
65        self.sels_mut().set_extra_selections(extras);
66        match seek(self.buf(), self.head()) {
67            Some(h) => {
68                self.set_head(h);
69                self.flash(Range::charwise(self.head(), self.head()));
70            }
71            None => self.message = "find: no more matches".into(),
72        }
73        self.normalize_cursors();
74    }
75
76    /// `n` / `N`: repeat the armed search, wrapping at the file edges.
77    /// Cascades: every cursor seeks from its own position (0013 §3).
78    /// The query is the one compiled engine — whole-word and the regex
79    /// dialect live inside it, never re-filtered here.
80    pub(crate) fn repeat_search(&mut self, invert: bool) {
81        let Some(search) = self.last_search.clone() else {
82            self.message = "no previous search".into();
83            return;
84        };
85        let motion = if search.backward ^ invert {
86            grammar::Motion::SearchBackward(search.query.clone())
87        } else {
88            grammar::Motion::Search(search.query.clone())
89        };
90        let command = grammar::Command {
91            op: None,
92            register: None,
93            count: None,
94            target: grammar::Target::Motion(motion),
95            keys: if invert { "N" } else { "n" }.into(),
96        };
97        if self.defer_resolution(
98            &command,
99            self.all_cursors(),
100            super::super::resolution::ResolutionPurpose::RepeatSearch(invert),
101        ) {
102            return;
103        }
104        self.move_cursor(&command);
105        // N reverses this movement, not the saved search's direction.
106        self.last_search = Some(search);
107    }
108
109    /// `*` / `#` (vim): search the word under the cursor — whole-word,
110    /// forward / backward, wrapping. `n`/`N` keep the same anchors.
111    pub(crate) fn search_word_under_cursor(&mut self, backward: bool) {
112        // char-classified (0017): identifiers in every script count —
113        // é/fün/変数 are words. Walk scalar boundaries directly on the rope.
114        let word_char = |c: char| c.is_alphanumeric() || c == '_';
115        let buf_len = self.buf().len_bytes();
116        let head = self.buf().clamp_boundary(self.head());
117        if head >= buf_len {
118            self.message = "no word under cursor".into();
119            return;
120        }
121        let char_at = |position: usize| -> Option<char> {
122            (position < buf_len).then(|| {
123                self.buf()
124                    .text()
125                    .char(self.buf().text().byte_to_char(position))
126            })
127        };
128        if !char_at(head).is_some_and(word_char) {
129            self.message = "no word under cursor".into();
130            return;
131        }
132        let mut start = head;
133        while start > 0 {
134            let prev = self.buf().clamp_boundary(start - 1);
135            if char_at(prev).is_some_and(word_char) {
136                start = prev;
137            } else {
138                break;
139            }
140        }
141        let mut end = head;
142        while end < buf_len {
143            match char_at(end) {
144                Some(ch) if word_char(ch) => end += ch.len_utf8(),
145                _ => break,
146            }
147        }
148        let pattern = self.buf().text().byte_slice(start..end).to_string();
149        let query = match grammar::CompiledQuery::compile(&pattern, true) {
150            Ok(query) => query,
151            Err(error) => {
152                self.message = error.to_string();
153                return;
154            }
155        };
156        self.last_search = Some(LastSearch { query, backward });
157        // `#` seeks from the word's start so the current word isn't its
158        // own "previous" match (vim semantics)
159        if backward {
160            self.set_head(start);
161        }
162        self.repeat_search(false);
163    }
164
165    /// Pending `f/F/t/T` awaiting its char: the leap-style candidates.
166    /// The WALKER owns that state — the old check read the free-text
167    /// line's last byte, so any pattern ending in `f`/`t` (`/const`)
168    /// lit candidates over the cursor line (issue 13's "stale match").
169    pub fn find_candidates(&self) -> Option<FindPending> {
170        let m = self.walker.pending_motion();
171        let ch = m.chars().next()?;
172        (m.chars().count() == 1 && matches!(ch, 'f' | 'F' | 't' | 'T')).then_some(FindPending {
173            ch,
174            backward: matches!(ch, 'F' | 'T'),
175        })
176    }
177
178    /// The active query's identity. Painting consumes its revision-matched
179    /// worker summary; only grammatical test oracles enumerate all matches.
180    pub fn current_search_query(
181        &self,
182    ) -> Result<Option<grammar::CompiledQuery>, grammar::QueryError> {
183        if let Some(pattern) = self.search_pattern() {
184            grammar::CompiledQuery::compile(pattern, false).map(Some)
185        } else {
186            Ok(self.last_search.as_ref().map(|search| search.query.clone()))
187        }
188    }
189}