search.rs

  1use gpui::{actions, impl_actions, AppContext, ViewContext};
  2use search::{buffer_search, BufferSearchBar, SearchMode, SearchOptions};
  3use serde_derive::Deserialize;
  4use workspace::{searchable::Direction, Pane, Workspace};
  5
  6use crate::{motion::Motion, normal::move_cursor, state::SearchState, Vim};
  7
  8#[derive(Clone, Deserialize, PartialEq)]
  9#[serde(rename_all = "camelCase")]
 10pub(crate) struct MoveToNext {
 11    #[serde(default)]
 12    partial_word: bool,
 13}
 14
 15#[derive(Clone, Deserialize, PartialEq)]
 16#[serde(rename_all = "camelCase")]
 17pub(crate) struct MoveToPrev {
 18    #[serde(default)]
 19    partial_word: bool,
 20}
 21
 22#[derive(Clone, Deserialize, PartialEq)]
 23pub(crate) struct Search {
 24    #[serde(default)]
 25    backwards: bool,
 26}
 27
 28#[derive(Debug, Clone, PartialEq, Deserialize)]
 29pub struct FindCommand {
 30    pub query: String,
 31    pub backwards: bool,
 32}
 33
 34#[derive(Debug, Clone, PartialEq, Deserialize)]
 35pub struct ReplaceCommand {
 36    pub query: String,
 37}
 38
 39#[derive(Debug, Default)]
 40struct Replacement {
 41    search: String,
 42    replacement: String,
 43    should_replace_all: bool,
 44    is_case_sensitive: bool,
 45}
 46
 47impl_actions!(
 48    vim,
 49    [MoveToNext, MoveToPrev, Search, FindCommand, ReplaceCommand]
 50);
 51actions!(vim, [SearchSubmit]);
 52
 53pub(crate) fn init(cx: &mut AppContext) {
 54    cx.add_action(move_to_next);
 55    cx.add_action(move_to_prev);
 56    cx.add_action(search);
 57    cx.add_action(search_submit);
 58    cx.add_action(search_deploy);
 59
 60    cx.add_action(find_command);
 61    cx.add_action(replace_command);
 62}
 63
 64fn move_to_next(workspace: &mut Workspace, action: &MoveToNext, cx: &mut ViewContext<Workspace>) {
 65    move_to_internal(workspace, Direction::Next, !action.partial_word, cx)
 66}
 67
 68fn move_to_prev(workspace: &mut Workspace, action: &MoveToPrev, cx: &mut ViewContext<Workspace>) {
 69    move_to_internal(workspace, Direction::Prev, !action.partial_word, cx)
 70}
 71
 72fn search(workspace: &mut Workspace, action: &Search, cx: &mut ViewContext<Workspace>) {
 73    let pane = workspace.active_pane().clone();
 74    let direction = if action.backwards {
 75        Direction::Prev
 76    } else {
 77        Direction::Next
 78    };
 79    Vim::update(cx, |vim, cx| {
 80        let count = vim.take_count(cx).unwrap_or(1);
 81        pane.update(cx, |pane, cx| {
 82            if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::<BufferSearchBar>() {
 83                search_bar.update(cx, |search_bar, cx| {
 84                    if !search_bar.show(cx) {
 85                        return;
 86                    }
 87                    let query = search_bar.query(cx);
 88
 89                    search_bar.select_query(cx);
 90                    cx.focus_self();
 91
 92                    if query.is_empty() {
 93                        search_bar.set_replacement(None, cx);
 94                        search_bar.set_search_options(SearchOptions::CASE_SENSITIVE, cx);
 95                        search_bar.activate_search_mode(SearchMode::Regex, cx);
 96                    }
 97                    vim.workspace_state.search = SearchState {
 98                        direction,
 99                        count,
100                        initial_query: query.clone(),
101                    };
102                });
103            }
104        })
105    })
106}
107
108// hook into the existing to clear out any vim search state on cmd+f or edit -> find.
109fn search_deploy(_: &mut Pane, _: &buffer_search::Deploy, cx: &mut ViewContext<Pane>) {
110    Vim::update(cx, |vim, _| vim.workspace_state.search = Default::default());
111    cx.propagate_action();
112}
113
114fn search_submit(workspace: &mut Workspace, _: &SearchSubmit, cx: &mut ViewContext<Workspace>) {
115    Vim::update(cx, |vim, cx| {
116        let pane = workspace.active_pane().clone();
117        pane.update(cx, |pane, cx| {
118            if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::<BufferSearchBar>() {
119                search_bar.update(cx, |search_bar, cx| {
120                    let state = &mut vim.workspace_state.search;
121                    let mut count = state.count;
122                    let direction = state.direction;
123
124                    // in the case that the query has changed, the search bar
125                    // will have selected the next match already.
126                    if (search_bar.query(cx) != state.initial_query)
127                        && state.direction == Direction::Next
128                    {
129                        count = count.saturating_sub(1)
130                    }
131                    state.count = 1;
132                    search_bar.select_match(direction, count, cx);
133                    search_bar.focus_editor(&Default::default(), cx);
134                });
135            }
136        });
137    })
138}
139
140pub fn move_to_internal(
141    workspace: &mut Workspace,
142    direction: Direction,
143    whole_word: bool,
144    cx: &mut ViewContext<Workspace>,
145) {
146    Vim::update(cx, |vim, cx| {
147        let pane = workspace.active_pane().clone();
148        let count = vim.take_count(cx).unwrap_or(1);
149        pane.update(cx, |pane, cx| {
150            if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::<BufferSearchBar>() {
151                let search = search_bar.update(cx, |search_bar, cx| {
152                    let mut options = SearchOptions::CASE_SENSITIVE;
153                    options.set(SearchOptions::WHOLE_WORD, whole_word);
154                    if search_bar.show(cx) {
155                        search_bar
156                            .query_suggestion(cx)
157                            .map(|query| search_bar.search(&query, Some(options), cx))
158                    } else {
159                        None
160                    }
161                });
162
163                if let Some(search) = search {
164                    let search_bar = search_bar.downgrade();
165                    cx.spawn(|_, mut cx| async move {
166                        search.await?;
167                        search_bar.update(&mut cx, |search_bar, cx| {
168                            search_bar.select_match(direction, count, cx)
169                        })?;
170                        anyhow::Ok(())
171                    })
172                    .detach_and_log_err(cx);
173                }
174            }
175        });
176        vim.clear_operator(cx);
177    });
178}
179
180fn find_command(workspace: &mut Workspace, action: &FindCommand, cx: &mut ViewContext<Workspace>) {
181    let pane = workspace.active_pane().clone();
182    pane.update(cx, |pane, cx| {
183        if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::<BufferSearchBar>() {
184            let search = search_bar.update(cx, |search_bar, cx| {
185                if !search_bar.show(cx) {
186                    return None;
187                }
188                let mut query = action.query.clone();
189                if query == "" {
190                    query = search_bar.query(cx);
191                };
192
193                search_bar.activate_search_mode(SearchMode::Regex, cx);
194                Some(search_bar.search(&query, Some(SearchOptions::CASE_SENSITIVE), cx))
195            });
196            let Some(search) = search else { return };
197            let search_bar = search_bar.downgrade();
198            let direction = if action.backwards {
199                Direction::Prev
200            } else {
201                Direction::Next
202            };
203            cx.spawn(|_, mut cx| async move {
204                search.await?;
205                search_bar.update(&mut cx, |search_bar, cx| {
206                    search_bar.select_match(direction, 1, cx)
207                })?;
208                anyhow::Ok(())
209            })
210            .detach_and_log_err(cx);
211        }
212    })
213}
214
215fn replace_command(
216    workspace: &mut Workspace,
217    action: &ReplaceCommand,
218    cx: &mut ViewContext<Workspace>,
219) {
220    let replacement = parse_replace_all(&action.query);
221    let pane = workspace.active_pane().clone();
222    pane.update(cx, |pane, cx| {
223        let Some(search_bar) = pane.toolbar().read(cx).item_of_type::<BufferSearchBar>() else {
224            return;
225        };
226        let search = search_bar.update(cx, |search_bar, cx| {
227            if !search_bar.show(cx) {
228                return None;
229            }
230
231            let mut options = SearchOptions::default();
232            if replacement.is_case_sensitive {
233                options.set(SearchOptions::CASE_SENSITIVE, true)
234            }
235            let search = if replacement.search == "" {
236                search_bar.query(cx)
237            } else {
238                replacement.search
239            };
240
241            search_bar.set_replacement(Some(&replacement.replacement), cx);
242            search_bar.activate_search_mode(SearchMode::Regex, cx);
243            Some(search_bar.search(&search, Some(options), cx))
244        });
245        let Some(search) = search else { return };
246        let search_bar = search_bar.downgrade();
247        cx.spawn(|_, mut cx| async move {
248            search.await?;
249            search_bar.update(&mut cx, |search_bar, cx| {
250                if replacement.should_replace_all {
251                    search_bar.select_last_match(cx);
252                    search_bar.replace_all(&Default::default(), cx);
253                    Vim::update(cx, |vim, cx| {
254                        move_cursor(
255                            vim,
256                            Motion::StartOfLine {
257                                display_lines: false,
258                            },
259                            None,
260                            cx,
261                        )
262                    })
263                }
264            })?;
265            anyhow::Ok(())
266        })
267        .detach_and_log_err(cx);
268    })
269}
270
271// convert a vim query into something more usable by zed.
272// we don't attempt to fully convert between the two regex syntaxes,
273// but we do flip \( and \) to ( and ) (and vice-versa) in the pattern,
274// and convert \0..\9 to $0..$9 in the replacement so that common idioms work.
275fn parse_replace_all(query: &str) -> Replacement {
276    let mut chars = query.chars();
277    if Some('%') != chars.next() || Some('s') != chars.next() {
278        return Replacement::default();
279    }
280
281    let Some(delimeter) = chars.next() else {
282        return Replacement::default();
283    };
284
285    let mut search = String::new();
286    let mut replacement = String::new();
287    let mut flags = String::new();
288
289    let mut buffer = &mut search;
290
291    let mut escaped = false;
292    // 0 - parsing search
293    // 1 - parsing replacement
294    // 2 - parsing flags
295    let mut phase = 0;
296
297    for c in chars {
298        if escaped {
299            escaped = false;
300            if phase == 1 && c.is_digit(10) {
301                buffer.push('$')
302            // unescape escaped parens
303            } else if phase == 0 && c == '(' || c == ')' {
304            } else if c != delimeter {
305                buffer.push('\\')
306            }
307            buffer.push(c)
308        } else if c == '\\' {
309            escaped = true;
310        } else if c == delimeter {
311            if phase == 0 {
312                buffer = &mut replacement;
313                phase = 1;
314            } else if phase == 1 {
315                buffer = &mut flags;
316                phase = 2;
317            } else {
318                break;
319            }
320        } else {
321            // escape unescaped parens
322            if phase == 0 && c == '(' || c == ')' {
323                buffer.push('\\')
324            }
325            buffer.push(c)
326        }
327    }
328
329    let mut replacement = Replacement {
330        search,
331        replacement,
332        should_replace_all: true,
333        is_case_sensitive: true,
334    };
335
336    for c in flags.chars() {
337        match c {
338            'g' | 'I' => {}
339            'c' | 'n' => replacement.should_replace_all = false,
340            'i' => replacement.is_case_sensitive = false,
341            _ => {}
342        }
343    }
344
345    replacement
346}
347
348#[cfg(test)]
349mod test {
350    use std::sync::Arc;
351
352    use editor::DisplayPoint;
353    use search::BufferSearchBar;
354
355    use crate::{state::Mode, test::VimTestContext};
356
357    #[gpui::test]
358    async fn test_move_to_next(
359        cx: &mut gpui::TestAppContext,
360        deterministic: Arc<gpui::executor::Deterministic>,
361    ) {
362        let mut cx = VimTestContext::new(cx, true).await;
363        cx.set_state("ˇhi\nhigh\nhi\n", Mode::Normal);
364
365        cx.simulate_keystrokes(["*"]);
366        deterministic.run_until_parked();
367        cx.assert_state("hi\nhigh\nˇhi\n", Mode::Normal);
368
369        cx.simulate_keystrokes(["*"]);
370        deterministic.run_until_parked();
371        cx.assert_state("ˇhi\nhigh\nhi\n", Mode::Normal);
372
373        cx.simulate_keystrokes(["#"]);
374        deterministic.run_until_parked();
375        cx.assert_state("hi\nhigh\nˇhi\n", Mode::Normal);
376
377        cx.simulate_keystrokes(["#"]);
378        deterministic.run_until_parked();
379        cx.assert_state("ˇhi\nhigh\nhi\n", Mode::Normal);
380
381        cx.simulate_keystrokes(["2", "*"]);
382        deterministic.run_until_parked();
383        cx.assert_state("ˇhi\nhigh\nhi\n", Mode::Normal);
384
385        cx.simulate_keystrokes(["g", "*"]);
386        deterministic.run_until_parked();
387        cx.assert_state("hi\nˇhigh\nhi\n", Mode::Normal);
388
389        cx.simulate_keystrokes(["n"]);
390        cx.assert_state("hi\nhigh\nˇhi\n", Mode::Normal);
391
392        cx.simulate_keystrokes(["g", "#"]);
393        deterministic.run_until_parked();
394        cx.assert_state("hi\nˇhigh\nhi\n", Mode::Normal);
395    }
396
397    #[gpui::test]
398    async fn test_search(
399        cx: &mut gpui::TestAppContext,
400        deterministic: Arc<gpui::executor::Deterministic>,
401    ) {
402        let mut cx = VimTestContext::new(cx, true).await;
403
404        cx.set_state("aa\nbˇb\ncc\ncc\ncc\n", Mode::Normal);
405        cx.simulate_keystrokes(["/", "c", "c"]);
406
407        let search_bar = cx.workspace(|workspace, cx| {
408            workspace
409                .active_pane()
410                .read(cx)
411                .toolbar()
412                .read(cx)
413                .item_of_type::<BufferSearchBar>()
414                .expect("Buffer search bar should be deployed")
415        });
416
417        search_bar.read_with(cx.cx, |bar, cx| {
418            assert_eq!(bar.query(cx), "cc");
419        });
420
421        deterministic.run_until_parked();
422
423        cx.update_editor(|editor, cx| {
424            let highlights = editor.all_text_background_highlights(cx);
425            assert_eq!(3, highlights.len());
426            assert_eq!(
427                DisplayPoint::new(2, 0)..DisplayPoint::new(2, 2),
428                highlights[0].0
429            )
430        });
431
432        cx.simulate_keystrokes(["enter"]);
433        cx.assert_state("aa\nbb\nˇcc\ncc\ncc\n", Mode::Normal);
434
435        // n to go to next/N to go to previous
436        cx.simulate_keystrokes(["n"]);
437        cx.assert_state("aa\nbb\ncc\nˇcc\ncc\n", Mode::Normal);
438        cx.simulate_keystrokes(["shift-n"]);
439        cx.assert_state("aa\nbb\nˇcc\ncc\ncc\n", Mode::Normal);
440
441        // ?<enter> to go to previous
442        cx.simulate_keystrokes(["?", "enter"]);
443        deterministic.run_until_parked();
444        cx.assert_state("aa\nbb\ncc\ncc\nˇcc\n", Mode::Normal);
445        cx.simulate_keystrokes(["?", "enter"]);
446        deterministic.run_until_parked();
447        cx.assert_state("aa\nbb\ncc\nˇcc\ncc\n", Mode::Normal);
448
449        // /<enter> to go to next
450        cx.simulate_keystrokes(["/", "enter"]);
451        deterministic.run_until_parked();
452        cx.assert_state("aa\nbb\ncc\ncc\nˇcc\n", Mode::Normal);
453
454        // ?{search}<enter> to search backwards
455        cx.simulate_keystrokes(["?", "b", "enter"]);
456        deterministic.run_until_parked();
457        cx.assert_state("aa\nbˇb\ncc\ncc\ncc\n", Mode::Normal);
458
459        // works with counts
460        cx.simulate_keystrokes(["4", "/", "c"]);
461        deterministic.run_until_parked();
462        cx.simulate_keystrokes(["enter"]);
463        cx.assert_state("aa\nbb\ncc\ncˇc\ncc\n", Mode::Normal);
464
465        // check that searching resumes from cursor, not previous match
466        cx.set_state("ˇaa\nbb\ndd\ncc\nbb\n", Mode::Normal);
467        cx.simulate_keystrokes(["/", "d"]);
468        deterministic.run_until_parked();
469        cx.simulate_keystrokes(["enter"]);
470        cx.assert_state("aa\nbb\nˇdd\ncc\nbb\n", Mode::Normal);
471        cx.update_editor(|editor, cx| editor.move_to_beginning(&Default::default(), cx));
472        cx.assert_state("ˇaa\nbb\ndd\ncc\nbb\n", Mode::Normal);
473        cx.simulate_keystrokes(["/", "b"]);
474        deterministic.run_until_parked();
475        cx.simulate_keystrokes(["enter"]);
476        cx.assert_state("aa\nˇbb\ndd\ncc\nbb\n", Mode::Normal);
477    }
478
479    #[gpui::test]
480    async fn test_non_vim_search(
481        cx: &mut gpui::TestAppContext,
482        deterministic: Arc<gpui::executor::Deterministic>,
483    ) {
484        let mut cx = VimTestContext::new(cx, false).await;
485        cx.set_state("ˇone one one one", Mode::Normal);
486        cx.simulate_keystrokes(["cmd-f"]);
487        deterministic.run_until_parked();
488
489        cx.assert_editor_state("«oneˇ» one one one");
490        cx.simulate_keystrokes(["enter"]);
491        cx.assert_editor_state("one «oneˇ» one one");
492        cx.simulate_keystrokes(["shift-enter"]);
493        cx.assert_editor_state("«oneˇ» one one one");
494    }
495}