Skip to main content

gpui_base/input/editor/
search.rs

1use crate::input::InputModeKind;
2use aho_corasick::AhoCorasick;
3use gpui::{Context, Window};
4use ropey::Rope;
5use std::{ops::Range, rc::Rc};
6
7use super::{
8    InputBaseState, Replace, RopeExt as _, Search, movement::MoveDirection, state::ScrollPadding,
9};
10
11/// Stateful, presentation-independent search engine used by text inputs.
12#[derive(Debug, Clone)]
13pub struct SearchMatcher {
14    text: Rope,
15    pub query: Option<AhoCorasick>,
16    matched_ranges: Rc<Vec<Range<usize>>>,
17    current_match_ix: usize,
18    replacing: bool,
19}
20
21/// One search over an input: the query, how the built-in panel shows it, and
22/// its matches. Read it through [`InputBaseState::search_session`]; it is
23/// written only through the input state's search methods, and it grows, so
24/// build it with `Default` and do not destructure it exhaustively.
25#[derive(Debug, Clone)]
26#[non_exhaustive]
27pub struct SearchSession {
28    /// The built-in search panel is showing.
29    pub open: bool,
30    pub replace_mode: bool,
31    pub case_insensitive: bool,
32    pub query: String,
33    pub replacement: String,
34    pub anchor_offset: Option<usize>,
35    pub matcher: SearchMatcher,
36    /// A search is in progress and its matches are highlighted: the panel is
37    /// open, or a query was set without it and not closed since.
38    active: bool,
39}
40
41impl Default for SearchSession {
42    fn default() -> Self {
43        Self {
44            open: false,
45            active: false,
46            replace_mode: false,
47            case_insensitive: true,
48            query: String::new(),
49            replacement: String::new(),
50            anchor_offset: None,
51            matcher: SearchMatcher::new(),
52        }
53    }
54}
55
56impl SearchSession {
57    pub(crate) fn open(&mut self, replace_mode: bool, replaceable: bool) {
58        self.open = true;
59        self.active = true;
60        self.replace_mode = replace_mode && replaceable;
61    }
62
63    /// Start a search without the built-in panel. A custom search UI drives
64    /// the session through [`InputBaseState::set_search_query`], and the
65    /// editor highlights the matches the same way it does for the panel.
66    pub(crate) fn activate(&mut self) {
67        self.active = true;
68    }
69
70    pub(crate) fn close(&mut self) {
71        self.open = false;
72        self.active = false;
73    }
74
75    /// Whether a search is in progress: the built-in panel is open, or a
76    /// query was set without it and [`InputBaseState::close_search`] has not
77    /// run since. Matches are highlighted while this holds.
78    pub fn is_active(&self) -> bool {
79        self.active
80    }
81
82    pub(crate) fn update_query(&mut self, query: impl Into<String>, case_insensitive: bool) {
83        let query = query.into();
84        if self.query == query && self.case_insensitive == case_insensitive {
85            return;
86        }
87
88        self.query = query;
89        self.case_insensitive = case_insensitive;
90        self.matcher.update_query(&self.query, case_insensitive);
91    }
92}
93
94impl<M: InputModeKind> InputBaseState<M> {
95    /// Open the search session, or re-invoke it if it is already open.
96    ///
97    /// This is not idempotent: every call advances
98    /// [`InputBaseState::search_activation_revision`], and the presentation
99    /// layer answers that by re-focusing the search field and selecting its
100    /// contents, the same as pressing the shortcut a second time. Call it from
101    /// an action or another user gesture, never from a render pass or an
102    /// observer that runs every frame — that would re-select the field under
103    /// the user on every frame and make it impossible to type.
104    pub fn open_search(&mut self, replace_mode: bool, cx: &mut Context<Self>) {
105        if !self.searchable {
106            return;
107        }
108        self.search_activation_revision = self.search_activation_revision.wrapping_add(1);
109        self.search_session
110            .open(replace_mode, self.is_replaceable());
111        let selected = self.selected_text().to_string();
112        let query = if selected.is_empty() {
113            self.search_session.query.clone()
114        } else {
115            selected
116        };
117        let query_changed = query != self.search_session.query;
118        // A retained query resumes its previous occurrence. Only a new query
119        // is anchored to the current viewport.
120        self.search_session.anchor_offset = if query_changed {
121            self.last_layout
122                .as_ref()
123                .map(|layout| layout.visible_range_offset.start)
124        } else {
125            None
126        };
127        let case_insensitive = self.search_session.case_insensitive;
128        self.search_session.update_query(query, case_insensitive);
129        self.search_session.matcher.update(&self.text);
130        if query_changed && let Some(anchor) = self.search_session.anchor_offset {
131            self.search_session.matcher.update_cursor_by_offset(anchor);
132        }
133        cx.notify();
134    }
135
136    pub fn search_session(&self) -> &SearchSession {
137        &self.search_session
138    }
139
140    /// A counter that advances every time [`InputBaseState::open_search`] runs,
141    /// including while the session is already open.
142    ///
143    /// Re-invoking search leaves the session itself identical, so a presentation
144    /// layer that decides what to rebuild by comparing session state cannot see
145    /// the second request. Fold this into that comparison to notice it.
146    pub fn search_activation_revision(&self) -> u64 {
147        self.search_activation_revision
148    }
149
150    #[doc(hidden)]
151    pub fn set_search_replace_mode(&mut self, replace_mode: bool, cx: &mut Context<Self>) {
152        self.search_session.replace_mode = replace_mode && self.is_replaceable();
153        cx.notify();
154    }
155
156    /// Returns true if the search panel can replace the matches.
157    ///
158    /// This is false when the input is not `replaceable`, or when it is
159    /// `disabled` or `readonly`.
160    pub fn is_replaceable(&self) -> bool {
161        self.replaceable && self.is_editable()
162    }
163
164    /// Set the search query and highlight its matches.
165    ///
166    /// This is the entry point for a custom search UI: it needs neither
167    /// `searchable` nor the built-in panel. Navigate the matches with
168    /// [`InputBaseState::next_search_match`] and
169    /// [`InputBaseState::previous_search_match`], read the count and the
170    /// current index from [`InputBaseState::search_session`], and end the
171    /// search with [`InputBaseState::close_search`].
172    ///
173    /// A changed query or case mode starts at the current viewport. Setting
174    /// the same query and case mode again retains the current occurrence.
175    pub fn set_search_query(
176        &mut self,
177        query: impl Into<String>,
178        case_insensitive: bool,
179        cx: &mut Context<Self>,
180    ) {
181        let query = query.into();
182        let query_changed = query != self.search_session.query
183            || case_insensitive != self.search_session.case_insensitive;
184        self.search_session.anchor_offset = if query_changed {
185            self.last_layout
186                .as_ref()
187                .map(|layout| layout.visible_range_offset.start)
188        } else {
189            None
190        };
191        self.search_session.activate();
192        self.search_session.update_query(query, case_insensitive);
193        self.search_session.matcher.update(&self.text);
194        if query_changed && let Some(anchor) = self.search_session.anchor_offset {
195            self.search_session.matcher.update_cursor_by_offset(anchor);
196        }
197        cx.notify();
198    }
199
200    /// End the search: hide the built-in panel and the match highlights. The
201    /// query is kept so the next [`InputBaseState::open_search`] resumes it.
202    pub fn close_search(&mut self, cx: &mut Context<Self>) {
203        self.search_session.close();
204        cx.notify();
205    }
206
207    pub fn next_search_match(&mut self, cx: &mut Context<Self>) -> Option<Range<usize>> {
208        self.sync_search_matcher();
209        let range = self.search_session.matcher.next()?;
210        // Match order does not describe viewport direction after a manual
211        // scroll. Always allow search navigation to reveal the active match.
212        self.scroll_to_with_padding(range.end, None, ScrollPadding::SurroundingLines, cx);
213        Some(range)
214    }
215
216    pub fn previous_search_match(&mut self, cx: &mut Context<Self>) -> Option<Range<usize>> {
217        self.sync_search_matcher();
218        let range = self.search_session.matcher.next_back()?;
219        // Match order does not describe viewport direction after a manual
220        // scroll. Always allow search navigation to reveal the active match.
221        self.scroll_to_with_padding(range.start, None, ScrollPadding::SurroundingLines, cx);
222        Some(range)
223    }
224
225    /// Replace the current match and move on to the next one. Returns whether
226    /// there was a match to replace.
227    pub fn replace_current_search_match(
228        &mut self,
229        replacement: &str,
230        window: &mut Window,
231        cx: &mut Context<Self>,
232    ) -> bool {
233        if !self.is_replaceable() {
234            return false;
235        }
236        self.sync_search_matcher();
237        let matcher = &mut self.search_session.matcher;
238        let Some(range) = matcher
239            .matched_ranges()
240            .get(matcher.current_match_index())
241            .cloned()
242        else {
243            return false;
244        };
245        let next = matcher.peek().unwrap_or_else(|| range.clone());
246        let direction = matcher
247            .has_next_without_wrap()
248            .then_some(MoveDirection::Down);
249        if direction.is_none() {
250            matcher.set_current_match_index(0);
251        }
252        matcher.begin_replacement();
253        let range_utf16 = self.range_to_utf16(&range);
254        self.scroll_to(next.end, direction, cx);
255        self.replace_text_in_range_silent(Some(range_utf16), replacement, window, cx);
256        true
257    }
258
259    /// Replace every match. Returns how many were replaced.
260    pub fn replace_all_search_matches(
261        &mut self,
262        replacement: &str,
263        window: &mut Window,
264        cx: &mut Context<Self>,
265    ) -> usize {
266        if !self.is_replaceable() {
267            return 0;
268        }
269        self.sync_search_matcher();
270        let ranges = self.search_session.matcher.matched_ranges();
271        if ranges.is_empty() {
272            return 0;
273        }
274        let mut text = self.text.clone();
275        for range in ranges.iter().rev() {
276            text.replace(range.clone(), replacement);
277        }
278        self.search_session.matcher.begin_replacement();
279        let count = ranges.len();
280        self.replace_text_in_range_silent(Some(0..self.text.len()), &text.to_string(), window, cx);
281        self.scroll_to(0, Some(MoveDirection::Down), cx);
282        count
283    }
284
285    /// Keep the matches in step with an edit. A closed search skips the scan:
286    /// it copies and searches the whole document, and nothing reads the
287    /// matches until the search is resumed or navigated, which sync first.
288    pub(super) fn update_search(&mut self, _cx: &mut gpui::App) {
289        if !self.search_session.is_active() {
290            return;
291        }
292        self.sync_search_matcher();
293    }
294
295    /// Recompute the matches if the text changed since the last scan.
296    fn sync_search_matcher(&mut self) {
297        self.search_session.matcher.update(&self.text);
298    }
299
300    /// An input that is not `searchable` leaves the shortcut to its
301    /// ancestors, so a custom search UI can take it.
302    pub(super) fn on_action_search(&mut self, _: &Search, _: &mut Window, cx: &mut Context<Self>) {
303        if !self.searchable {
304            cx.propagate();
305            return;
306        }
307        self.open_search(false, cx);
308    }
309
310    pub(super) fn on_action_replace(
311        &mut self,
312        _: &Replace,
313        _: &mut Window,
314        cx: &mut Context<Self>,
315    ) {
316        if !self.searchable {
317            cx.propagate();
318            return;
319        }
320        self.open_search(true, cx);
321    }
322}
323
324impl Default for SearchMatcher {
325    fn default() -> Self {
326        Self::new()
327    }
328}
329
330impl SearchMatcher {
331    pub fn new() -> Self {
332        Self {
333            text: "".into(),
334            query: None,
335            matched_ranges: Rc::new(Vec::new()),
336            current_match_ix: 0,
337            replacing: false,
338        }
339    }
340
341    /// Update the source text and recompute matches.
342    pub fn update(&mut self, text: &Rope) {
343        if self.text.eq(text) {
344            self.replacing = false;
345            return;
346        }
347        self.text = text.clone();
348        self.update_matches();
349    }
350
351    pub fn update_query(&mut self, query: &str, case_insensitive: bool) {
352        self.query = (!query.is_empty()).then(|| {
353            AhoCorasick::builder()
354                .ascii_case_insensitive(case_insensitive)
355                .build([query])
356                .expect("failed to build input search query")
357        });
358        self.update_matches();
359    }
360
361    pub fn matched_ranges(&self) -> Rc<Vec<Range<usize>>> {
362        self.matched_ranges.clone()
363    }
364
365    pub fn current_match_index(&self) -> usize {
366        self.current_match_ix
367    }
368
369    /// The index of the current match into [`SearchMatcher::matched_ranges`],
370    /// `None` while there is no match.
371    pub fn current(&self) -> Option<usize> {
372        (!self.is_empty()).then_some(self.current_match_ix)
373    }
374
375    pub fn len(&self) -> usize {
376        self.matched_ranges.len()
377    }
378
379    pub fn is_empty(&self) -> bool {
380        self.matched_ranges.is_empty()
381    }
382
383    /// `2/5`: the current match and the total, `0/0` without matches.
384    pub fn label(&self) -> String {
385        match self.current() {
386            Some(ix) => format!("{}/{}", ix + 1, self.len()),
387            None => "0/0".into(),
388        }
389    }
390
391    fn peek(&self) -> Option<Range<usize>> {
392        self.next_index()
393            .and_then(|ix| self.matched_ranges.get(ix).cloned())
394    }
395
396    fn has_next_without_wrap(&self) -> bool {
397        self.current_match_ix < self.matched_ranges.len().saturating_sub(1)
398    }
399
400    pub fn update_cursor_by_offset(&mut self, offset: usize) {
401        for (ix, range) in self.matched_ranges.iter().enumerate() {
402            self.current_match_ix = ix;
403            if range.contains(&offset) || range.end >= offset {
404                return;
405            }
406        }
407    }
408
409    /// Preserve the current logical match while a replacement mutates text.
410    fn begin_replacement(&mut self) {
411        self.replacing = true;
412    }
413
414    fn set_current_match_index(&mut self, index: usize) {
415        self.current_match_ix = index.min(self.matched_ranges.len().saturating_sub(1));
416    }
417
418    fn next_index(&self) -> Option<usize> {
419        if self.is_empty() {
420            None
421        } else if self.has_next_without_wrap() {
422            Some(self.current_match_ix + 1)
423        } else {
424            Some(0)
425        }
426    }
427
428    fn update_matches(&mut self) {
429        let mut ranges = Vec::new();
430        if let Some(query) = &self.query {
431            let text = self.text.to_string();
432            ranges.extend(
433                query
434                    .stream_find_iter(text.as_bytes())
435                    .map(|result| result.expect("input search match").range()),
436            );
437        }
438        self.matched_ranges = Rc::new(ranges);
439        if !self.replacing || self.is_empty() {
440            self.current_match_ix = 0;
441        } else {
442            self.current_match_ix = self.current_match_ix.min(self.len() - 1);
443        }
444        self.replacing = false;
445    }
446}
447
448impl Iterator for SearchMatcher {
449    type Item = Range<usize>;
450
451    fn next(&mut self) -> Option<Self::Item> {
452        let ix = self.next_index()?;
453        self.current_match_ix = ix;
454        self.matched_ranges.get(ix).cloned()
455    }
456}
457
458impl DoubleEndedIterator for SearchMatcher {
459    fn next_back(&mut self) -> Option<Self::Item> {
460        if self.is_empty() {
461            return None;
462        }
463        if self.current_match_ix == 0 {
464            self.current_match_ix = self.len();
465        }
466        self.current_match_ix -= 1;
467        self.matched_ranges.get(self.current_match_ix).cloned()
468    }
469}
470
471#[cfg(test)]
472mod tests {
473    use super::*;
474
475    #[test]
476    fn finds_navigates_and_preserves_replacement_position() {
477        let mut matcher = SearchMatcher::new();
478        matcher.update(&Rope::from("foo FOO foo"));
479        matcher.update_query("foo", true);
480        assert_eq!(&*matcher.matched_ranges(), &[0..3, 4..7, 8..11]);
481        assert_eq!(matcher.next(), Some(4..7));
482        assert_eq!(matcher.next_back(), Some(0..3));
483
484        matcher.set_current_match_index(2);
485        matcher.begin_replacement();
486        matcher.update(&Rope::from("foo FOO bar"));
487        assert_eq!(matcher.current_match_index(), 1);
488    }
489
490    #[test]
491    fn next_wraps_to_start() {
492        let mut matcher = SearchMatcher::new();
493        matcher.update(&Rope::from(".....aaaaa.....aaaaa.....aaaaa"));
494        matcher.update_query("aaaaa", false);
495        matcher.set_current_match_index(2);
496        assert_eq!(matcher.next(), Some(5..10));
497    }
498
499    #[test]
500    fn a_query_set_without_the_panel_keeps_the_session_active_until_closed() {
501        let mut session = SearchSession::default();
502        assert!(!session.is_active());
503
504        session.open(false, true);
505        assert!(session.is_active());
506        session.close();
507        assert!(!session.is_active());
508
509        // A custom search UI never opens the panel; setting a query is what
510        // turns the match highlights on, and closing turns them off again.
511        session.activate();
512        assert!(session.is_active());
513        assert!(!session.open);
514        session.close();
515        assert!(!session.is_active());
516    }
517
518    #[test]
519    fn identical_query_keeps_the_current_match() {
520        let mut session = SearchSession::default();
521        session.update_query("foo", true);
522        session.matcher.update(&Rope::from("foo bar foo baz foo"));
523        session.matcher.update_cursor_by_offset(12);
524        assert_eq!(session.matcher.current_match_index(), 2);
525
526        // Reopening Find and the styled search panel's initial query echo both
527        // update the session with the same query. Neither should reset the
528        // previously active occurrence.
529        session.update_query("foo", true);
530
531        assert_eq!(session.matcher.current_match_index(), 2);
532        assert_eq!(session.matcher.label(), "3/3");
533    }
534
535    #[test]
536    fn replacement_keeps_current_match_index_on_next_match() {
537        let mut matcher = SearchMatcher::new();
538        matcher.update(&Rope::from("foo foo foo"));
539        matcher.update_query("foo", true);
540        assert_eq!(matcher.label(), "1/3");
541
542        assert!(matcher.has_next_without_wrap());
543        matcher.begin_replacement();
544        matcher.update(&Rope::from("bar foo foo"));
545        assert_eq!(matcher.current_match_index(), 0);
546        assert_eq!(matcher.matched_ranges()[0], 4..7);
547        assert_eq!(matcher.label(), "1/2");
548
549        matcher.set_current_match_index(1);
550        assert!(!matcher.has_next_without_wrap());
551        matcher.set_current_match_index(0);
552        matcher.begin_replacement();
553        matcher.update(&Rope::from("bar foo bar"));
554        assert_eq!(matcher.current_match_index(), 0);
555        assert_eq!(matcher.matched_ranges()[0], 4..7);
556        assert_eq!(matcher.label(), "1/1");
557    }
558
559    #[test]
560    fn update_matches_clamps_current_match_index_while_replacing() {
561        let mut matcher = SearchMatcher::new();
562        matcher.update(&Rope::from("foo foo foo"));
563        matcher.update_query("foo", true);
564        matcher.set_current_match_index(2);
565        matcher.begin_replacement();
566
567        matcher.update(&Rope::from("foo xoo foo"));
568
569        assert_eq!(matcher.len(), 2);
570        assert_eq!(matcher.current_match_index(), 1);
571        assert_eq!(matcher.label(), "2/2");
572    }
573}