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::{InputBaseState, Replace, RopeExt as _, Search, movement::MoveDirection};
8
9/// Stateful, presentation-independent search engine used by text inputs.
10#[derive(Debug, Clone)]
11pub struct SearchMatcher {
12    text: Rope,
13    pub query: Option<AhoCorasick>,
14    matched_ranges: Rc<Vec<Range<usize>>>,
15    current_match_ix: usize,
16    replacing: bool,
17}
18
19#[derive(Debug, Clone)]
20pub struct SearchSession {
21    pub open: bool,
22    pub replace_mode: bool,
23    pub case_insensitive: bool,
24    pub query: String,
25    pub replacement: String,
26    pub anchor_offset: Option<usize>,
27    pub matcher: SearchMatcher,
28}
29
30impl Default for SearchSession {
31    fn default() -> Self {
32        Self {
33            open: false,
34            replace_mode: false,
35            case_insensitive: true,
36            query: String::new(),
37            replacement: String::new(),
38            anchor_offset: None,
39            matcher: SearchMatcher::new(),
40        }
41    }
42}
43
44impl SearchSession {
45    pub(crate) fn open(&mut self, replace_mode: bool, replaceable: bool) {
46        self.open = true;
47        self.replace_mode = replace_mode && replaceable;
48    }
49
50    pub(crate) fn close(&mut self) {
51        self.open = false;
52    }
53
54    pub(crate) fn update_query(&mut self, query: impl Into<String>, case_insensitive: bool) {
55        self.query = query.into();
56        self.case_insensitive = case_insensitive;
57        self.matcher.update_query(&self.query, case_insensitive);
58    }
59}
60
61impl<M: InputModeKind> InputBaseState<M> {
62    pub fn open_search(&mut self, replace_mode: bool, cx: &mut Context<Self>) {
63        if !self.searchable {
64            return;
65        }
66        self.search_session
67            .open(replace_mode, self.is_replaceable());
68        let selected = self.selected_text().to_string();
69        if !selected.is_empty() {
70            self.search_session.query = selected;
71        }
72        self.search_session.anchor_offset = self
73            .last_layout
74            .as_ref()
75            .map(|layout| layout.visible_range_offset.start);
76        self.search_session.matcher.update_query(
77            &self.search_session.query,
78            self.search_session.case_insensitive,
79        );
80        self.search_session.matcher.update(&self.text);
81        if let Some(anchor) = self.search_session.anchor_offset {
82            self.search_session.matcher.update_cursor_by_offset(anchor);
83        }
84        cx.notify();
85    }
86
87    pub fn search_session(&self) -> &SearchSession {
88        &self.search_session
89    }
90
91    #[doc(hidden)]
92    pub fn set_search_replace_mode(&mut self, replace_mode: bool, cx: &mut Context<Self>) {
93        self.search_session.replace_mode = replace_mode && self.is_replaceable();
94        cx.notify();
95    }
96
97    /// Returns true if the search panel can replace the matches.
98    ///
99    /// This is false when the input is not `replaceable`, or when it is
100    /// `disabled` or `readonly`.
101    pub fn is_replaceable(&self) -> bool {
102        self.replaceable && self.is_editable()
103    }
104
105    pub fn set_search_query(
106        &mut self,
107        query: impl Into<String>,
108        case_insensitive: bool,
109        cx: &mut Context<Self>,
110    ) {
111        self.search_session.update_query(query, case_insensitive);
112        self.search_session.matcher.update(&self.text);
113        cx.notify();
114    }
115
116    pub fn close_search(&mut self, cx: &mut Context<Self>) {
117        self.search_session.close();
118        cx.notify();
119    }
120
121    pub fn next_search_match(&mut self, cx: &mut Context<Self>) -> Option<Range<usize>> {
122        let previous = self.search_session.matcher.current_match_index();
123        let range = self.search_session.matcher.next()?;
124        let direction = (self.search_session.matcher.current_match_index() > previous)
125            .then_some(MoveDirection::Down);
126        self.scroll_to(range.end, direction, cx);
127        Some(range)
128    }
129
130    pub fn previous_search_match(&mut self, cx: &mut Context<Self>) -> Option<Range<usize>> {
131        let previous = self.search_session.matcher.current_match_index();
132        let range = self.search_session.matcher.next_back()?;
133        let direction = (self.search_session.matcher.current_match_index() < previous)
134            .then_some(MoveDirection::Up);
135        self.scroll_to(range.start, direction, cx);
136        Some(range)
137    }
138
139    pub fn replace_current_search_match(
140        &mut self,
141        replacement: &str,
142        window: &mut Window,
143        cx: &mut Context<Self>,
144    ) -> bool {
145        if !self.is_replaceable() {
146            return false;
147        }
148        let matcher = &mut self.search_session.matcher;
149        let Some(range) = matcher
150            .matched_ranges()
151            .get(matcher.current_match_index())
152            .cloned()
153        else {
154            return false;
155        };
156        let next = matcher.peek().unwrap_or_else(|| range.clone());
157        let direction = matcher
158            .has_next_without_wrap()
159            .then_some(MoveDirection::Down);
160        if direction.is_none() {
161            matcher.set_current_match_index(0);
162        }
163        matcher.begin_replacement();
164        let range_utf16 = self.range_to_utf16(&range);
165        self.scroll_to(next.end, direction, cx);
166        self.replace_text_in_range_silent(Some(range_utf16), replacement, window, cx);
167        true
168    }
169
170    pub fn replace_all_search_matches(
171        &mut self,
172        replacement: &str,
173        window: &mut Window,
174        cx: &mut Context<Self>,
175    ) -> usize {
176        if !self.is_replaceable() {
177            return 0;
178        }
179        let ranges = self.search_session.matcher.matched_ranges();
180        if ranges.is_empty() {
181            return 0;
182        }
183        let mut text = self.text.clone();
184        for range in ranges.iter().rev() {
185            text.replace(range.clone(), replacement);
186        }
187        self.search_session.matcher.begin_replacement();
188        let count = ranges.len();
189        self.replace_text_in_range_silent(Some(0..self.text.len()), &text.to_string(), window, cx);
190        self.scroll_to(0, Some(MoveDirection::Down), cx);
191        count
192    }
193
194    pub(super) fn update_search(&mut self, _cx: &mut gpui::App) {
195        self.search_session.matcher.update(&self.text);
196    }
197
198    pub(super) fn on_action_search(&mut self, _: &Search, _: &mut Window, cx: &mut Context<Self>) {
199        if !self.searchable {
200            return;
201        }
202        self.open_search(false, cx);
203    }
204
205    pub(super) fn on_action_replace(
206        &mut self,
207        _: &Replace,
208        _: &mut Window,
209        cx: &mut Context<Self>,
210    ) {
211        if !self.searchable {
212            return;
213        }
214        self.open_search(true, cx);
215    }
216}
217
218impl Default for SearchMatcher {
219    fn default() -> Self {
220        Self::new()
221    }
222}
223
224impl SearchMatcher {
225    pub fn new() -> Self {
226        Self {
227            text: "".into(),
228            query: None,
229            matched_ranges: Rc::new(Vec::new()),
230            current_match_ix: 0,
231            replacing: false,
232        }
233    }
234
235    /// Update the source text and recompute matches.
236    pub fn update(&mut self, text: &Rope) {
237        if self.text.eq(text) {
238            self.replacing = false;
239            return;
240        }
241        self.text = text.clone();
242        self.update_matches();
243    }
244
245    pub fn update_query(&mut self, query: &str, case_insensitive: bool) {
246        self.query = (!query.is_empty()).then(|| {
247            AhoCorasick::builder()
248                .ascii_case_insensitive(case_insensitive)
249                .build([query])
250                .expect("failed to build input search query")
251        });
252        self.update_matches();
253    }
254
255    pub fn matched_ranges(&self) -> Rc<Vec<Range<usize>>> {
256        self.matched_ranges.clone()
257    }
258
259    pub fn current_match_index(&self) -> usize {
260        self.current_match_ix
261    }
262
263    pub fn len(&self) -> usize {
264        self.matched_ranges.len()
265    }
266
267    pub fn is_empty(&self) -> bool {
268        self.matched_ranges.is_empty()
269    }
270
271    pub fn label(&self) -> String {
272        if self.is_empty() {
273            "0/0".into()
274        } else {
275            format!("{}/{}", self.current_match_ix + 1, self.len())
276        }
277    }
278
279    fn peek(&self) -> Option<Range<usize>> {
280        self.next_index()
281            .and_then(|ix| self.matched_ranges.get(ix).cloned())
282    }
283
284    fn has_next_without_wrap(&self) -> bool {
285        self.current_match_ix < self.matched_ranges.len().saturating_sub(1)
286    }
287
288    pub fn update_cursor_by_offset(&mut self, offset: usize) {
289        for (ix, range) in self.matched_ranges.iter().enumerate() {
290            self.current_match_ix = ix;
291            if range.contains(&offset) || range.end >= offset {
292                return;
293            }
294        }
295    }
296
297    /// Preserve the current logical match while a replacement mutates text.
298    fn begin_replacement(&mut self) {
299        self.replacing = true;
300    }
301
302    fn set_current_match_index(&mut self, index: usize) {
303        self.current_match_ix = index.min(self.matched_ranges.len().saturating_sub(1));
304    }
305
306    fn next_index(&self) -> Option<usize> {
307        if self.is_empty() {
308            None
309        } else if self.has_next_without_wrap() {
310            Some(self.current_match_ix + 1)
311        } else {
312            Some(0)
313        }
314    }
315
316    fn update_matches(&mut self) {
317        let mut ranges = Vec::new();
318        if let Some(query) = &self.query {
319            let text = self.text.to_string();
320            ranges.extend(
321                query
322                    .stream_find_iter(text.as_bytes())
323                    .map(|result| result.expect("input search match").range()),
324            );
325        }
326        self.matched_ranges = Rc::new(ranges);
327        if !self.replacing || self.is_empty() {
328            self.current_match_ix = 0;
329        } else {
330            self.current_match_ix = self.current_match_ix.min(self.len() - 1);
331        }
332        self.replacing = false;
333    }
334}
335
336impl Iterator for SearchMatcher {
337    type Item = Range<usize>;
338
339    fn next(&mut self) -> Option<Self::Item> {
340        let ix = self.next_index()?;
341        self.current_match_ix = ix;
342        self.matched_ranges.get(ix).cloned()
343    }
344}
345
346impl DoubleEndedIterator for SearchMatcher {
347    fn next_back(&mut self) -> Option<Self::Item> {
348        if self.is_empty() {
349            return None;
350        }
351        if self.current_match_ix == 0 {
352            self.current_match_ix = self.len();
353        }
354        self.current_match_ix -= 1;
355        self.matched_ranges.get(self.current_match_ix).cloned()
356    }
357}
358
359#[cfg(test)]
360mod tests {
361    use super::*;
362
363    #[test]
364    fn finds_navigates_and_preserves_replacement_position() {
365        let mut matcher = SearchMatcher::new();
366        matcher.update(&Rope::from("foo FOO foo"));
367        matcher.update_query("foo", true);
368        assert_eq!(&*matcher.matched_ranges(), &[0..3, 4..7, 8..11]);
369        assert_eq!(matcher.next(), Some(4..7));
370        assert_eq!(matcher.next_back(), Some(0..3));
371
372        matcher.set_current_match_index(2);
373        matcher.begin_replacement();
374        matcher.update(&Rope::from("foo FOO bar"));
375        assert_eq!(matcher.current_match_index(), 1);
376    }
377
378    #[test]
379    fn next_wraps_to_start() {
380        let mut matcher = SearchMatcher::new();
381        matcher.update(&Rope::from(".....aaaaa.....aaaaa.....aaaaa"));
382        matcher.update_query("aaaaa", false);
383        matcher.set_current_match_index(2);
384        assert_eq!(matcher.next(), Some(5..10));
385    }
386
387    #[test]
388    fn replacement_keeps_current_match_index_on_next_match() {
389        let mut matcher = SearchMatcher::new();
390        matcher.update(&Rope::from("foo foo foo"));
391        matcher.update_query("foo", true);
392        assert_eq!(matcher.label(), "1/3");
393
394        assert!(matcher.has_next_without_wrap());
395        matcher.begin_replacement();
396        matcher.update(&Rope::from("bar foo foo"));
397        assert_eq!(matcher.current_match_index(), 0);
398        assert_eq!(matcher.matched_ranges()[0], 4..7);
399        assert_eq!(matcher.label(), "1/2");
400
401        matcher.set_current_match_index(1);
402        assert!(!matcher.has_next_without_wrap());
403        matcher.set_current_match_index(0);
404        matcher.begin_replacement();
405        matcher.update(&Rope::from("bar foo bar"));
406        assert_eq!(matcher.current_match_index(), 0);
407        assert_eq!(matcher.matched_ranges()[0], 4..7);
408        assert_eq!(matcher.label(), "1/1");
409    }
410
411    #[test]
412    fn update_matches_clamps_current_match_index_while_replacing() {
413        let mut matcher = SearchMatcher::new();
414        matcher.update(&Rope::from("foo foo foo"));
415        matcher.update_query("foo", true);
416        matcher.set_current_match_index(2);
417        matcher.begin_replacement();
418
419        matcher.update(&Rope::from("foo xoo foo"));
420
421        assert_eq!(matcher.len(), 2);
422        assert_eq!(matcher.current_match_index(), 1);
423        assert_eq!(matcher.label(), "2/2");
424    }
425}