search.rs

  1use gpui::{actions, impl_actions, ViewContext};
  2use search::{buffer_search, BufferSearchBar, SearchOptions};
  3use serde_derive::Deserialize;
  4use workspace::{searchable::Direction, Workspace};
  5
  6use crate::{
  7    motion::{search_motion, Motion},
  8    normal::move_cursor,
  9    state::{Mode, SearchState},
 10    Vim,
 11};
 12
 13#[derive(Clone, Deserialize, PartialEq)]
 14#[serde(rename_all = "camelCase")]
 15pub(crate) struct MoveToNext {
 16    #[serde(default)]
 17    partial_word: bool,
 18}
 19
 20#[derive(Clone, Deserialize, PartialEq)]
 21#[serde(rename_all = "camelCase")]
 22pub(crate) struct MoveToPrev {
 23    #[serde(default)]
 24    partial_word: bool,
 25}
 26
 27#[derive(Clone, Deserialize, PartialEq)]
 28pub(crate) struct Search {
 29    #[serde(default)]
 30    backwards: bool,
 31}
 32
 33#[derive(Debug, Clone, PartialEq, Deserialize)]
 34pub struct FindCommand {
 35    pub query: String,
 36    pub backwards: bool,
 37}
 38
 39#[derive(Debug, Clone, PartialEq, Deserialize)]
 40pub struct ReplaceCommand {
 41    pub query: String,
 42}
 43
 44#[derive(Debug, Default)]
 45struct Replacement {
 46    search: String,
 47    replacement: String,
 48    should_replace_all: bool,
 49    is_case_sensitive: bool,
 50}
 51
 52actions!(vim, [SearchSubmit, MoveToNextMatch, MoveToPrevMatch]);
 53impl_actions!(
 54    vim,
 55    [FindCommand, ReplaceCommand, Search, MoveToPrev, MoveToNext]
 56);
 57
 58pub(crate) fn register(workspace: &mut Workspace, _: &mut ViewContext<Workspace>) {
 59    workspace.register_action(move_to_next);
 60    workspace.register_action(move_to_prev);
 61    workspace.register_action(move_to_next_match);
 62    workspace.register_action(move_to_prev_match);
 63    workspace.register_action(search);
 64    workspace.register_action(search_submit);
 65    workspace.register_action(search_deploy);
 66
 67    workspace.register_action(find_command);
 68    workspace.register_action(replace_command);
 69}
 70
 71fn move_to_next(workspace: &mut Workspace, action: &MoveToNext, cx: &mut ViewContext<Workspace>) {
 72    move_to_internal(workspace, Direction::Next, !action.partial_word, cx)
 73}
 74
 75fn move_to_prev(workspace: &mut Workspace, action: &MoveToPrev, cx: &mut ViewContext<Workspace>) {
 76    move_to_internal(workspace, Direction::Prev, !action.partial_word, cx)
 77}
 78
 79fn move_to_next_match(
 80    workspace: &mut Workspace,
 81    _: &MoveToNextMatch,
 82    cx: &mut ViewContext<Workspace>,
 83) {
 84    move_to_match_internal(workspace, Direction::Next, cx)
 85}
 86
 87fn move_to_prev_match(
 88    workspace: &mut Workspace,
 89    _: &MoveToPrevMatch,
 90    cx: &mut ViewContext<Workspace>,
 91) {
 92    move_to_match_internal(workspace, Direction::Prev, cx)
 93}
 94
 95fn search(workspace: &mut Workspace, action: &Search, cx: &mut ViewContext<Workspace>) {
 96    let pane = workspace.active_pane().clone();
 97    let direction = if action.backwards {
 98        Direction::Prev
 99    } else {
100        Direction::Next
101    };
102    Vim::update(cx, |vim, cx| {
103        let count = vim.take_count(cx).unwrap_or(1);
104        let prior_selections = vim.editor_selections(cx);
105        pane.update(cx, |pane, cx| {
106            if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::<BufferSearchBar>() {
107                search_bar.update(cx, |search_bar, cx| {
108                    if !search_bar.show(cx) {
109                        return;
110                    }
111                    let query = search_bar.query(cx);
112
113                    search_bar.select_query(cx);
114                    cx.focus_self();
115
116                    if query.is_empty() {
117                        search_bar.set_replacement(None, cx);
118                        search_bar.set_search_options(SearchOptions::REGEX, cx);
119                    }
120                    vim.workspace_state.search = SearchState {
121                        direction,
122                        count,
123                        initial_query: query.clone(),
124                        prior_selections,
125                        prior_operator: vim.active_operator(),
126                        prior_mode: vim.state().mode,
127                    };
128                });
129            }
130        })
131    })
132}
133
134// hook into the existing to clear out any vim search state on cmd+f or edit -> find.
135fn search_deploy(_: &mut Workspace, _: &buffer_search::Deploy, cx: &mut ViewContext<Workspace>) {
136    Vim::update(cx, |vim, _| vim.workspace_state.search = Default::default());
137    cx.propagate();
138}
139
140fn search_submit(workspace: &mut Workspace, _: &SearchSubmit, cx: &mut ViewContext<Workspace>) {
141    let mut motion = None;
142    Vim::update(cx, |vim, cx| {
143        let pane = workspace.active_pane().clone();
144        pane.update(cx, |pane, cx| {
145            if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::<BufferSearchBar>() {
146                search_bar.update(cx, |search_bar, cx| {
147                    let state = &mut vim.workspace_state.search;
148                    let mut count = state.count;
149                    let direction = state.direction;
150
151                    // in the case that the query has changed, the search bar
152                    // will have selected the next match already.
153                    if (search_bar.query(cx) != state.initial_query)
154                        && state.direction == Direction::Next
155                    {
156                        count = count.saturating_sub(1)
157                    }
158                    state.count = 1;
159                    search_bar.select_match(direction, count, cx);
160                    search_bar.focus_editor(&Default::default(), cx);
161
162                    let mut prior_selections: Vec<_> = state.prior_selections.drain(..).collect();
163                    let prior_mode = state.prior_mode;
164                    let prior_operator = state.prior_operator.take();
165                    let new_selections = vim.editor_selections(cx);
166
167                    // If the active editor has changed during a search, don't panic.
168                    if prior_selections.iter().any(|s| {
169                        vim.update_active_editor(cx, |_vim, editor, cx| {
170                            !s.start.is_valid(&editor.snapshot(cx).buffer_snapshot)
171                        })
172                        .unwrap_or(true)
173                    }) {
174                        prior_selections.clear();
175                    }
176
177                    if prior_mode != vim.state().mode {
178                        vim.switch_mode(prior_mode, true, cx);
179                    }
180                    if let Some(operator) = prior_operator {
181                        vim.push_operator(operator, cx);
182                    };
183                    motion = Some(Motion::ZedSearchResult {
184                        prior_selections,
185                        new_selections,
186                    });
187                });
188            }
189        });
190    });
191
192    if let Some(motion) = motion {
193        search_motion(motion, cx)
194    }
195}
196
197pub fn move_to_match_internal(
198    workspace: &mut Workspace,
199    direction: Direction,
200    cx: &mut ViewContext<Workspace>,
201) {
202    let mut motion = None;
203    Vim::update(cx, |vim, cx| {
204        let pane = workspace.active_pane().clone();
205        let count = vim.take_count(cx).unwrap_or(1);
206        let prior_selections = vim.editor_selections(cx);
207
208        pane.update(cx, |pane, cx| {
209            if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::<BufferSearchBar>() {
210                search_bar.update(cx, |search_bar, cx| {
211                    search_bar.select_match(direction, count, cx);
212
213                    let new_selections = vim.editor_selections(cx);
214                    motion = Some(Motion::ZedSearchResult {
215                        prior_selections,
216                        new_selections,
217                    });
218                })
219            }
220        })
221    });
222    if let Some(motion) = motion {
223        search_motion(motion, cx);
224    }
225}
226
227pub fn move_to_internal(
228    workspace: &mut Workspace,
229    direction: Direction,
230    whole_word: bool,
231    cx: &mut ViewContext<Workspace>,
232) {
233    Vim::update(cx, |vim, cx| {
234        let pane = workspace.active_pane().clone();
235        let count = vim.take_count(cx).unwrap_or(1);
236        let prior_selections = vim.editor_selections(cx);
237
238        pane.update(cx, |pane, cx| {
239            if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::<BufferSearchBar>() {
240                let search = search_bar.update(cx, |search_bar, cx| {
241                    let options = SearchOptions::CASE_SENSITIVE | SearchOptions::REGEX;
242                    if !search_bar.show(cx) {
243                        return None;
244                    }
245                    let Some(query) = search_bar.query_suggestion(cx) else {
246                        vim.clear_operator(cx);
247                        let _ = search_bar.search("", None, cx);
248                        return None;
249                    };
250                    let mut query = regex::escape(&query);
251                    if whole_word {
252                        query = format!(r"\<{}\>", query);
253                    }
254                    Some(search_bar.search(&query, Some(options), cx))
255                });
256
257                if let Some(search) = search {
258                    let search_bar = search_bar.downgrade();
259                    cx.spawn(|_, mut cx| async move {
260                        search.await?;
261                        search_bar.update(&mut cx, |search_bar, cx| {
262                            search_bar.select_match(direction, count, cx);
263
264                            let new_selections =
265                                Vim::update(cx, |vim, cx| vim.editor_selections(cx));
266                            search_motion(
267                                Motion::ZedSearchResult {
268                                    prior_selections,
269                                    new_selections,
270                                },
271                                cx,
272                            )
273                        })?;
274                        anyhow::Ok(())
275                    })
276                    .detach_and_log_err(cx);
277                }
278            }
279        });
280
281        if vim.state().mode.is_visual() {
282            vim.switch_mode(Mode::Normal, false, cx)
283        }
284    });
285}
286
287fn find_command(workspace: &mut Workspace, action: &FindCommand, cx: &mut ViewContext<Workspace>) {
288    let pane = workspace.active_pane().clone();
289    pane.update(cx, |pane, cx| {
290        if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::<BufferSearchBar>() {
291            let search = search_bar.update(cx, |search_bar, cx| {
292                if !search_bar.show(cx) {
293                    return None;
294                }
295                let mut query = action.query.clone();
296                if query == "" {
297                    query = search_bar.query(cx);
298                };
299
300                Some(search_bar.search(
301                    &query,
302                    Some(SearchOptions::CASE_SENSITIVE | SearchOptions::REGEX),
303                    cx,
304                ))
305            });
306            let Some(search) = search else { return };
307            let search_bar = search_bar.downgrade();
308            let direction = if action.backwards {
309                Direction::Prev
310            } else {
311                Direction::Next
312            };
313            cx.spawn(|_, mut cx| async move {
314                search.await?;
315                search_bar.update(&mut cx, |search_bar, cx| {
316                    search_bar.select_match(direction, 1, cx)
317                })?;
318                anyhow::Ok(())
319            })
320            .detach_and_log_err(cx);
321        }
322    })
323}
324
325fn replace_command(
326    workspace: &mut Workspace,
327    action: &ReplaceCommand,
328    cx: &mut ViewContext<Workspace>,
329) {
330    let replacement = parse_replace_all(&action.query);
331    let pane = workspace.active_pane().clone();
332    pane.update(cx, |pane, cx| {
333        let Some(search_bar) = pane.toolbar().read(cx).item_of_type::<BufferSearchBar>() else {
334            return;
335        };
336        let search = search_bar.update(cx, |search_bar, cx| {
337            if !search_bar.show(cx) {
338                return None;
339            }
340
341            let mut options = SearchOptions::REGEX;
342            if replacement.is_case_sensitive {
343                options.set(SearchOptions::CASE_SENSITIVE, true)
344            }
345            let search = if replacement.search == "" {
346                search_bar.query(cx)
347            } else {
348                replacement.search
349            };
350
351            search_bar.set_replacement(Some(&replacement.replacement), cx);
352            Some(search_bar.search(&search, Some(options), cx))
353        });
354        let Some(search) = search else { return };
355        let search_bar = search_bar.downgrade();
356        cx.spawn(|_, mut cx| async move {
357            search.await?;
358            search_bar.update(&mut cx, |search_bar, cx| {
359                if replacement.should_replace_all {
360                    search_bar.select_last_match(cx);
361                    search_bar.replace_all(&Default::default(), cx);
362                    Vim::update(cx, |vim, cx| {
363                        move_cursor(
364                            vim,
365                            Motion::StartOfLine {
366                                display_lines: false,
367                            },
368                            None,
369                            cx,
370                        )
371                    })
372                }
373            })?;
374            anyhow::Ok(())
375        })
376        .detach_and_log_err(cx);
377    })
378}
379
380// convert a vim query into something more usable by zed.
381// we don't attempt to fully convert between the two regex syntaxes,
382// but we do flip \( and \) to ( and ) (and vice-versa) in the pattern,
383// and convert \0..\9 to $0..$9 in the replacement so that common idioms work.
384fn parse_replace_all(query: &str) -> Replacement {
385    let mut chars = query.chars();
386    if Some('%') != chars.next() || Some('s') != chars.next() {
387        return Replacement::default();
388    }
389
390    let Some(delimiter) = chars.next() else {
391        return Replacement::default();
392    };
393
394    let mut search = String::new();
395    let mut replacement = String::new();
396    let mut flags = String::new();
397
398    let mut buffer = &mut search;
399
400    let mut escaped = false;
401    // 0 - parsing search
402    // 1 - parsing replacement
403    // 2 - parsing flags
404    let mut phase = 0;
405
406    for c in chars {
407        if escaped {
408            escaped = false;
409            if phase == 1 && c.is_digit(10) {
410                buffer.push('$')
411            // unescape escaped parens
412            } else if phase == 0 && c == '(' || c == ')' {
413            } else if c != delimiter {
414                buffer.push('\\')
415            }
416            buffer.push(c)
417        } else if c == '\\' {
418            escaped = true;
419        } else if c == delimiter {
420            if phase == 0 {
421                buffer = &mut replacement;
422                phase = 1;
423            } else if phase == 1 {
424                buffer = &mut flags;
425                phase = 2;
426            } else {
427                break;
428            }
429        } else {
430            // escape unescaped parens
431            if phase == 0 && c == '(' || c == ')' {
432                buffer.push('\\')
433            }
434            buffer.push(c)
435        }
436    }
437
438    let mut replacement = Replacement {
439        search,
440        replacement,
441        should_replace_all: true,
442        is_case_sensitive: true,
443    };
444
445    for c in flags.chars() {
446        match c {
447            'g' | 'I' => {}
448            'c' | 'n' => replacement.should_replace_all = false,
449            'i' => replacement.is_case_sensitive = false,
450            _ => {}
451        }
452    }
453
454    replacement
455}
456
457#[cfg(test)]
458mod test {
459    use editor::DisplayPoint;
460    use indoc::indoc;
461    use search::BufferSearchBar;
462
463    use crate::{
464        state::Mode,
465        test::{NeovimBackedTestContext, VimTestContext},
466    };
467
468    #[gpui::test]
469    async fn test_move_to_next(cx: &mut gpui::TestAppContext) {
470        let mut cx = VimTestContext::new(cx, true).await;
471        cx.set_state("ˇhi\nhigh\nhi\n", Mode::Normal);
472
473        cx.simulate_keystrokes(["*"]);
474        cx.run_until_parked();
475        cx.assert_state("hi\nhigh\nˇhi\n", Mode::Normal);
476
477        cx.simulate_keystrokes(["*"]);
478        cx.run_until_parked();
479        cx.assert_state("ˇhi\nhigh\nhi\n", Mode::Normal);
480
481        cx.simulate_keystrokes(["#"]);
482        cx.run_until_parked();
483        cx.assert_state("hi\nhigh\nˇhi\n", Mode::Normal);
484
485        cx.simulate_keystrokes(["#"]);
486        cx.run_until_parked();
487        cx.assert_state("ˇhi\nhigh\nhi\n", Mode::Normal);
488
489        cx.simulate_keystrokes(["2", "*"]);
490        cx.run_until_parked();
491        cx.assert_state("ˇhi\nhigh\nhi\n", Mode::Normal);
492
493        cx.simulate_keystrokes(["g", "*"]);
494        cx.run_until_parked();
495        cx.assert_state("hi\nˇhigh\nhi\n", Mode::Normal);
496
497        cx.simulate_keystrokes(["n"]);
498        cx.assert_state("hi\nhigh\nˇhi\n", Mode::Normal);
499
500        cx.simulate_keystrokes(["g", "#"]);
501        cx.run_until_parked();
502        cx.assert_state("hi\nˇhigh\nhi\n", Mode::Normal);
503    }
504
505    #[gpui::test]
506    async fn test_search(cx: &mut gpui::TestAppContext) {
507        let mut cx = VimTestContext::new(cx, true).await;
508
509        cx.set_state("aa\nbˇb\ncc\ncc\ncc\n", Mode::Normal);
510        cx.simulate_keystrokes(["/", "c", "c"]);
511
512        let search_bar = cx.workspace(|workspace, cx| {
513            workspace
514                .active_pane()
515                .read(cx)
516                .toolbar()
517                .read(cx)
518                .item_of_type::<BufferSearchBar>()
519                .expect("Buffer search bar should be deployed")
520        });
521
522        cx.update_view(search_bar, |bar, cx| {
523            assert_eq!(bar.query(cx), "cc");
524        });
525
526        cx.run_until_parked();
527
528        cx.update_editor(|editor, cx| {
529            let highlights = editor.all_text_background_highlights(cx);
530            assert_eq!(3, highlights.len());
531            assert_eq!(
532                DisplayPoint::new(2, 0)..DisplayPoint::new(2, 2),
533                highlights[0].0
534            )
535        });
536
537        cx.simulate_keystrokes(["enter"]);
538        cx.assert_state("aa\nbb\nˇcc\ncc\ncc\n", Mode::Normal);
539
540        // n to go to next/N to go to previous
541        cx.simulate_keystrokes(["n"]);
542        cx.assert_state("aa\nbb\ncc\nˇcc\ncc\n", Mode::Normal);
543        cx.simulate_keystrokes(["shift-n"]);
544        cx.assert_state("aa\nbb\nˇcc\ncc\ncc\n", Mode::Normal);
545
546        // ?<enter> to go to previous
547        cx.simulate_keystrokes(["?", "enter"]);
548        cx.assert_state("aa\nbb\ncc\ncc\nˇcc\n", Mode::Normal);
549        cx.simulate_keystrokes(["?", "enter"]);
550        cx.assert_state("aa\nbb\ncc\nˇcc\ncc\n", Mode::Normal);
551
552        // /<enter> to go to next
553        cx.simulate_keystrokes(["/", "enter"]);
554        cx.assert_state("aa\nbb\ncc\ncc\nˇcc\n", Mode::Normal);
555
556        // ?{search}<enter> to search backwards
557        cx.simulate_keystrokes(["?", "b", "enter"]);
558        cx.assert_state("aa\nbˇb\ncc\ncc\ncc\n", Mode::Normal);
559
560        // works with counts
561        cx.simulate_keystrokes(["4", "/", "c"]);
562        cx.simulate_keystrokes(["enter"]);
563        cx.assert_state("aa\nbb\ncc\ncˇc\ncc\n", Mode::Normal);
564
565        // check that searching resumes from cursor, not previous match
566        cx.set_state("ˇaa\nbb\ndd\ncc\nbb\n", Mode::Normal);
567        cx.simulate_keystrokes(["/", "d"]);
568        cx.simulate_keystrokes(["enter"]);
569        cx.assert_state("aa\nbb\nˇdd\ncc\nbb\n", Mode::Normal);
570        cx.update_editor(|editor, cx| editor.move_to_beginning(&Default::default(), cx));
571        cx.assert_state("ˇaa\nbb\ndd\ncc\nbb\n", Mode::Normal);
572        cx.simulate_keystrokes(["/", "b"]);
573        cx.simulate_keystrokes(["enter"]);
574        cx.assert_state("aa\nˇbb\ndd\ncc\nbb\n", Mode::Normal);
575
576        // check that searching switches to normal mode if in visual mode
577        cx.set_state("ˇone two one", Mode::Normal);
578        cx.simulate_keystrokes(["v", "l", "l"]);
579        cx.assert_editor_state("«oneˇ» two one");
580        cx.simulate_keystrokes(["*"]);
581        cx.assert_state("one two ˇone", Mode::Normal);
582    }
583
584    #[gpui::test]
585    async fn test_non_vim_search(cx: &mut gpui::TestAppContext) {
586        let mut cx = VimTestContext::new(cx, false).await;
587        cx.set_state("ˇone one one one", Mode::Normal);
588        cx.simulate_keystrokes(["cmd-f"]);
589        cx.run_until_parked();
590
591        cx.assert_editor_state("«oneˇ» one one one");
592        cx.simulate_keystrokes(["enter"]);
593        cx.assert_editor_state("one «oneˇ» one one");
594        cx.simulate_keystrokes(["shift-enter"]);
595        cx.assert_editor_state("«oneˇ» one one one");
596    }
597
598    #[gpui::test]
599    async fn test_visual_star_hash(cx: &mut gpui::TestAppContext) {
600        let mut cx = NeovimBackedTestContext::new(cx).await;
601
602        cx.set_shared_state("ˇa.c. abcd a.c. abcd").await;
603        cx.simulate_shared_keystrokes(["v", "3", "l", "*"]).await;
604        cx.assert_shared_state("a.c. abcd ˇa.c. abcd").await;
605        cx.assert_shared_mode(Mode::Normal).await;
606    }
607
608    #[gpui::test]
609    async fn test_d_search(cx: &mut gpui::TestAppContext) {
610        let mut cx = NeovimBackedTestContext::new(cx).await;
611
612        cx.set_shared_state("ˇa.c. abcd a.c. abcd").await;
613        cx.simulate_shared_keystrokes(["d", "/", "c", "d"]).await;
614        cx.simulate_shared_keystrokes(["enter"]).await;
615        cx.assert_shared_state("ˇcd a.c. abcd").await;
616    }
617
618    #[gpui::test]
619    async fn test_v_search(cx: &mut gpui::TestAppContext) {
620        let mut cx = NeovimBackedTestContext::new(cx).await;
621
622        cx.set_shared_state("ˇa.c. abcd a.c. abcd").await;
623        cx.simulate_shared_keystrokes(["v", "/", "c", "d"]).await;
624        cx.simulate_shared_keystrokes(["enter"]).await;
625        cx.assert_shared_state("«a.c. abcˇ»d a.c. abcd").await;
626
627        cx.set_shared_state("a a aˇ a a a").await;
628        cx.simulate_shared_keystrokes(["v", "/", "a"]).await;
629        cx.simulate_shared_keystrokes(["enter"]).await;
630        cx.assert_shared_state("a a a« aˇ» a a").await;
631        cx.simulate_shared_keystrokes(["/", "enter"]).await;
632        cx.assert_shared_state("a a a« a aˇ» a").await;
633        cx.simulate_shared_keystrokes(["?", "enter"]).await;
634        cx.assert_shared_state("a a a« aˇ» a a").await;
635        cx.simulate_shared_keystrokes(["?", "enter"]).await;
636        cx.assert_shared_state("a a «ˇa »a a a").await;
637        cx.simulate_shared_keystrokes(["/", "enter"]).await;
638        cx.assert_shared_state("a a a« aˇ» a a").await;
639        cx.simulate_shared_keystrokes(["/", "enter"]).await;
640        cx.assert_shared_state("a a a« a aˇ» a").await;
641    }
642
643    #[gpui::test]
644    async fn test_visual_block_search(cx: &mut gpui::TestAppContext) {
645        let mut cx = NeovimBackedTestContext::new(cx).await;
646
647        cx.set_shared_state(indoc! {
648            "ˇone two
649             three four
650             five six
651             "
652        })
653        .await;
654        cx.simulate_shared_keystrokes(["ctrl-v", "j", "/", "f"])
655            .await;
656        cx.simulate_shared_keystrokes(["enter"]).await;
657        cx.assert_shared_state(indoc! {
658            "«one twoˇ»
659             «three fˇ»our
660             five six
661             "
662        })
663        .await;
664    }
665}