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#[derive(Debug, Clone)]
22pub struct SearchSession {
23    pub open: bool,
24    pub replace_mode: bool,
25    pub case_insensitive: bool,
26    pub query: String,
27    pub replacement: String,
28    pub anchor_offset: Option<usize>,
29    pub matcher: SearchMatcher,
30}
31
32impl Default for SearchSession {
33    fn default() -> Self {
34        Self {
35            open: false,
36            replace_mode: false,
37            case_insensitive: true,
38            query: String::new(),
39            replacement: String::new(),
40            anchor_offset: None,
41            matcher: SearchMatcher::new(),
42        }
43    }
44}
45
46impl SearchSession {
47    pub(crate) fn open(&mut self, replace_mode: bool, replaceable: bool) {
48        self.open = true;
49        self.replace_mode = replace_mode && replaceable;
50    }
51
52    pub(crate) fn close(&mut self) {
53        self.open = false;
54    }
55
56    pub(crate) fn update_query(&mut self, query: impl Into<String>, case_insensitive: bool) {
57        let query = query.into();
58        if self.query == query && self.case_insensitive == case_insensitive {
59            return;
60        }
61
62        self.query = query;
63        self.case_insensitive = case_insensitive;
64        self.matcher.update_query(&self.query, case_insensitive);
65    }
66}
67
68impl<M: InputModeKind> InputBaseState<M> {
69    /// Open the search session, or re-invoke it if it is already open.
70    ///
71    /// This is not idempotent: every call advances
72    /// [`InputBaseState::search_activation_revision`], and the presentation
73    /// layer answers that by re-focusing the search field and selecting its
74    /// contents, the same as pressing the shortcut a second time. Call it from
75    /// an action or another user gesture, never from a render pass or an
76    /// observer that runs every frame — that would re-select the field under
77    /// the user on every frame and make it impossible to type.
78    pub fn open_search(&mut self, replace_mode: bool, cx: &mut Context<Self>) {
79        if !self.searchable {
80            return;
81        }
82        self.search_activation_revision = self.search_activation_revision.wrapping_add(1);
83        self.search_session
84            .open(replace_mode, self.is_replaceable());
85        let selected = self.selected_text().to_string();
86        let query = if selected.is_empty() {
87            self.search_session.query.clone()
88        } else {
89            selected
90        };
91        let query_changed = query != self.search_session.query;
92        // A retained query resumes its previous occurrence. Only a new query
93        // is anchored to the current viewport.
94        self.search_session.anchor_offset = if query_changed {
95            self.last_layout
96                .as_ref()
97                .map(|layout| layout.visible_range_offset.start)
98        } else {
99            None
100        };
101        let case_insensitive = self.search_session.case_insensitive;
102        self.search_session.update_query(query, case_insensitive);
103        self.search_session.matcher.update(&self.text);
104        if query_changed && let Some(anchor) = self.search_session.anchor_offset {
105            self.search_session.matcher.update_cursor_by_offset(anchor);
106        }
107        cx.notify();
108    }
109
110    pub fn search_session(&self) -> &SearchSession {
111        &self.search_session
112    }
113
114    /// A counter that advances every time [`InputBaseState::open_search`] runs,
115    /// including while the session is already open.
116    ///
117    /// Re-invoking search leaves the session itself identical, so a presentation
118    /// layer that decides what to rebuild by comparing session state cannot see
119    /// the second request. Fold this into that comparison to notice it.
120    pub fn search_activation_revision(&self) -> u64 {
121        self.search_activation_revision
122    }
123
124    #[doc(hidden)]
125    pub fn set_search_replace_mode(&mut self, replace_mode: bool, cx: &mut Context<Self>) {
126        self.search_session.replace_mode = replace_mode && self.is_replaceable();
127        cx.notify();
128    }
129
130    /// Returns true if the search panel can replace the matches.
131    ///
132    /// This is false when the input is not `replaceable`, or when it is
133    /// `disabled` or `readonly`.
134    pub fn is_replaceable(&self) -> bool {
135        self.replaceable && self.is_editable()
136    }
137
138    pub fn set_search_query(
139        &mut self,
140        query: impl Into<String>,
141        case_insensitive: bool,
142        cx: &mut Context<Self>,
143    ) {
144        self.search_session.update_query(query, case_insensitive);
145        self.search_session.matcher.update(&self.text);
146        cx.notify();
147    }
148
149    pub fn close_search(&mut self, cx: &mut Context<Self>) {
150        self.search_session.close();
151        cx.notify();
152    }
153
154    pub fn next_search_match(&mut self, cx: &mut Context<Self>) -> Option<Range<usize>> {
155        let range = self.search_session.matcher.next()?;
156        // Match order does not describe viewport direction after a manual
157        // scroll. Always allow search navigation to reveal the active match.
158        self.scroll_to_with_padding(range.end, None, ScrollPadding::SurroundingLines, cx);
159        Some(range)
160    }
161
162    pub fn previous_search_match(&mut self, cx: &mut Context<Self>) -> Option<Range<usize>> {
163        let range = self.search_session.matcher.next_back()?;
164        // Match order does not describe viewport direction after a manual
165        // scroll. Always allow search navigation to reveal the active match.
166        self.scroll_to_with_padding(range.start, None, ScrollPadding::SurroundingLines, cx);
167        Some(range)
168    }
169
170    pub fn replace_current_search_match(
171        &mut self,
172        replacement: &str,
173        window: &mut Window,
174        cx: &mut Context<Self>,
175    ) -> bool {
176        if !self.is_replaceable() {
177            return false;
178        }
179        let matcher = &mut self.search_session.matcher;
180        let Some(range) = matcher
181            .matched_ranges()
182            .get(matcher.current_match_index())
183            .cloned()
184        else {
185            return false;
186        };
187        let next = matcher.peek().unwrap_or_else(|| range.clone());
188        let direction = matcher
189            .has_next_without_wrap()
190            .then_some(MoveDirection::Down);
191        if direction.is_none() {
192            matcher.set_current_match_index(0);
193        }
194        matcher.begin_replacement();
195        let range_utf16 = self.range_to_utf16(&range);
196        self.scroll_to(next.end, direction, cx);
197        self.replace_text_in_range_silent(Some(range_utf16), replacement, window, cx);
198        true
199    }
200
201    pub fn replace_all_search_matches(
202        &mut self,
203        replacement: &str,
204        window: &mut Window,
205        cx: &mut Context<Self>,
206    ) -> usize {
207        if !self.is_replaceable() {
208            return 0;
209        }
210        let ranges = self.search_session.matcher.matched_ranges();
211        if ranges.is_empty() {
212            return 0;
213        }
214        let mut text = self.text.clone();
215        for range in ranges.iter().rev() {
216            text.replace(range.clone(), replacement);
217        }
218        self.search_session.matcher.begin_replacement();
219        let count = ranges.len();
220        self.replace_text_in_range_silent(Some(0..self.text.len()), &text.to_string(), window, cx);
221        self.scroll_to(0, Some(MoveDirection::Down), cx);
222        count
223    }
224
225    pub(super) fn update_search(&mut self, _cx: &mut gpui::App) {
226        self.search_session.matcher.update(&self.text);
227    }
228
229    pub(super) fn on_action_search(&mut self, _: &Search, _: &mut Window, cx: &mut Context<Self>) {
230        if !self.searchable {
231            return;
232        }
233        self.open_search(false, cx);
234    }
235
236    pub(super) fn on_action_replace(
237        &mut self,
238        _: &Replace,
239        _: &mut Window,
240        cx: &mut Context<Self>,
241    ) {
242        if !self.searchable {
243            return;
244        }
245        self.open_search(true, cx);
246    }
247}
248
249impl Default for SearchMatcher {
250    fn default() -> Self {
251        Self::new()
252    }
253}
254
255impl SearchMatcher {
256    pub fn new() -> Self {
257        Self {
258            text: "".into(),
259            query: None,
260            matched_ranges: Rc::new(Vec::new()),
261            current_match_ix: 0,
262            replacing: false,
263        }
264    }
265
266    /// Update the source text and recompute matches.
267    pub fn update(&mut self, text: &Rope) {
268        if self.text.eq(text) {
269            self.replacing = false;
270            return;
271        }
272        self.text = text.clone();
273        self.update_matches();
274    }
275
276    pub fn update_query(&mut self, query: &str, case_insensitive: bool) {
277        self.query = (!query.is_empty()).then(|| {
278            AhoCorasick::builder()
279                .ascii_case_insensitive(case_insensitive)
280                .build([query])
281                .expect("failed to build input search query")
282        });
283        self.update_matches();
284    }
285
286    pub fn matched_ranges(&self) -> Rc<Vec<Range<usize>>> {
287        self.matched_ranges.clone()
288    }
289
290    pub fn current_match_index(&self) -> usize {
291        self.current_match_ix
292    }
293
294    pub fn len(&self) -> usize {
295        self.matched_ranges.len()
296    }
297
298    pub fn is_empty(&self) -> bool {
299        self.matched_ranges.is_empty()
300    }
301
302    pub fn label(&self) -> String {
303        if self.is_empty() {
304            "0/0".into()
305        } else {
306            format!("{}/{}", self.current_match_ix + 1, self.len())
307        }
308    }
309
310    fn peek(&self) -> Option<Range<usize>> {
311        self.next_index()
312            .and_then(|ix| self.matched_ranges.get(ix).cloned())
313    }
314
315    fn has_next_without_wrap(&self) -> bool {
316        self.current_match_ix < self.matched_ranges.len().saturating_sub(1)
317    }
318
319    pub fn update_cursor_by_offset(&mut self, offset: usize) {
320        for (ix, range) in self.matched_ranges.iter().enumerate() {
321            self.current_match_ix = ix;
322            if range.contains(&offset) || range.end >= offset {
323                return;
324            }
325        }
326    }
327
328    /// Preserve the current logical match while a replacement mutates text.
329    fn begin_replacement(&mut self) {
330        self.replacing = true;
331    }
332
333    fn set_current_match_index(&mut self, index: usize) {
334        self.current_match_ix = index.min(self.matched_ranges.len().saturating_sub(1));
335    }
336
337    fn next_index(&self) -> Option<usize> {
338        if self.is_empty() {
339            None
340        } else if self.has_next_without_wrap() {
341            Some(self.current_match_ix + 1)
342        } else {
343            Some(0)
344        }
345    }
346
347    fn update_matches(&mut self) {
348        let mut ranges = Vec::new();
349        if let Some(query) = &self.query {
350            let text = self.text.to_string();
351            ranges.extend(
352                query
353                    .stream_find_iter(text.as_bytes())
354                    .map(|result| result.expect("input search match").range()),
355            );
356        }
357        self.matched_ranges = Rc::new(ranges);
358        if !self.replacing || self.is_empty() {
359            self.current_match_ix = 0;
360        } else {
361            self.current_match_ix = self.current_match_ix.min(self.len() - 1);
362        }
363        self.replacing = false;
364    }
365}
366
367impl Iterator for SearchMatcher {
368    type Item = Range<usize>;
369
370    fn next(&mut self) -> Option<Self::Item> {
371        let ix = self.next_index()?;
372        self.current_match_ix = ix;
373        self.matched_ranges.get(ix).cloned()
374    }
375}
376
377impl DoubleEndedIterator for SearchMatcher {
378    fn next_back(&mut self) -> Option<Self::Item> {
379        if self.is_empty() {
380            return None;
381        }
382        if self.current_match_ix == 0 {
383            self.current_match_ix = self.len();
384        }
385        self.current_match_ix -= 1;
386        self.matched_ranges.get(self.current_match_ix).cloned()
387    }
388}
389
390#[cfg(test)]
391mod tests {
392    use super::*;
393
394    #[test]
395    fn finds_navigates_and_preserves_replacement_position() {
396        let mut matcher = SearchMatcher::new();
397        matcher.update(&Rope::from("foo FOO foo"));
398        matcher.update_query("foo", true);
399        assert_eq!(&*matcher.matched_ranges(), &[0..3, 4..7, 8..11]);
400        assert_eq!(matcher.next(), Some(4..7));
401        assert_eq!(matcher.next_back(), Some(0..3));
402
403        matcher.set_current_match_index(2);
404        matcher.begin_replacement();
405        matcher.update(&Rope::from("foo FOO bar"));
406        assert_eq!(matcher.current_match_index(), 1);
407    }
408
409    #[test]
410    fn next_wraps_to_start() {
411        let mut matcher = SearchMatcher::new();
412        matcher.update(&Rope::from(".....aaaaa.....aaaaa.....aaaaa"));
413        matcher.update_query("aaaaa", false);
414        matcher.set_current_match_index(2);
415        assert_eq!(matcher.next(), Some(5..10));
416    }
417
418    #[test]
419    fn identical_query_keeps_the_current_match() {
420        let mut session = SearchSession::default();
421        session.update_query("foo", true);
422        session.matcher.update(&Rope::from("foo bar foo baz foo"));
423        session.matcher.update_cursor_by_offset(12);
424        assert_eq!(session.matcher.current_match_index(), 2);
425
426        // Reopening Find and the styled search panel's initial query echo both
427        // update the session with the same query. Neither should reset the
428        // previously active occurrence.
429        session.update_query("foo", true);
430
431        assert_eq!(session.matcher.current_match_index(), 2);
432        assert_eq!(session.matcher.label(), "3/3");
433    }
434
435    #[test]
436    fn replacement_keeps_current_match_index_on_next_match() {
437        let mut matcher = SearchMatcher::new();
438        matcher.update(&Rope::from("foo foo foo"));
439        matcher.update_query("foo", true);
440        assert_eq!(matcher.label(), "1/3");
441
442        assert!(matcher.has_next_without_wrap());
443        matcher.begin_replacement();
444        matcher.update(&Rope::from("bar foo foo"));
445        assert_eq!(matcher.current_match_index(), 0);
446        assert_eq!(matcher.matched_ranges()[0], 4..7);
447        assert_eq!(matcher.label(), "1/2");
448
449        matcher.set_current_match_index(1);
450        assert!(!matcher.has_next_without_wrap());
451        matcher.set_current_match_index(0);
452        matcher.begin_replacement();
453        matcher.update(&Rope::from("bar foo bar"));
454        assert_eq!(matcher.current_match_index(), 0);
455        assert_eq!(matcher.matched_ranges()[0], 4..7);
456        assert_eq!(matcher.label(), "1/1");
457    }
458
459    #[test]
460    fn update_matches_clamps_current_match_index_while_replacing() {
461        let mut matcher = SearchMatcher::new();
462        matcher.update(&Rope::from("foo foo foo"));
463        matcher.update_query("foo", true);
464        matcher.set_current_match_index(2);
465        matcher.begin_replacement();
466
467        matcher.update(&Rope::from("foo xoo foo"));
468
469        assert_eq!(matcher.len(), 2);
470        assert_eq!(matcher.current_match_index(), 1);
471        assert_eq!(matcher.label(), "2/2");
472    }
473}