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 prior_selections = 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 prior_mode != vim.state().mode {
168                        vim.switch_mode(prior_mode, true, cx);
169                    }
170                    if let Some(operator) = prior_operator {
171                        vim.push_operator(operator, cx);
172                    };
173                    motion = Some(Motion::ZedSearchResult {
174                        prior_selections,
175                        new_selections,
176                    });
177                });
178            }
179        });
180    });
181
182    if let Some(motion) = motion {
183        search_motion(motion, cx)
184    }
185}
186
187pub fn move_to_match_internal(
188    workspace: &mut Workspace,
189    direction: Direction,
190    cx: &mut ViewContext<Workspace>,
191) {
192    let mut motion = None;
193    Vim::update(cx, |vim, cx| {
194        let pane = workspace.active_pane().clone();
195        let count = vim.take_count(cx).unwrap_or(1);
196        let prior_selections = vim.editor_selections(cx);
197
198        pane.update(cx, |pane, cx| {
199            if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::<BufferSearchBar>() {
200                search_bar.update(cx, |search_bar, cx| {
201                    search_bar.select_match(direction, count, cx);
202
203                    let new_selections = vim.editor_selections(cx);
204                    motion = Some(Motion::ZedSearchResult {
205                        prior_selections,
206                        new_selections,
207                    });
208                })
209            }
210        })
211    });
212    if let Some(motion) = motion {
213        search_motion(motion, cx);
214    }
215}
216
217pub fn move_to_internal(
218    workspace: &mut Workspace,
219    direction: Direction,
220    whole_word: bool,
221    cx: &mut ViewContext<Workspace>,
222) {
223    Vim::update(cx, |vim, cx| {
224        let pane = workspace.active_pane().clone();
225        let count = vim.take_count(cx).unwrap_or(1);
226        let prior_selections = vim.editor_selections(cx);
227
228        pane.update(cx, |pane, cx| {
229            if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::<BufferSearchBar>() {
230                let search = search_bar.update(cx, |search_bar, cx| {
231                    let options = SearchOptions::CASE_SENSITIVE | SearchOptions::REGEX;
232                    if !search_bar.show(cx) {
233                        return None;
234                    }
235                    let Some(query) = search_bar.query_suggestion(cx) else {
236                        vim.clear_operator(cx);
237                        let _ = search_bar.search("", None, cx);
238                        return None;
239                    };
240                    let mut query = regex::escape(&query);
241                    if whole_word {
242                        query = format!(r"\<{}\>", query);
243                    }
244                    Some(search_bar.search(&query, Some(options), cx))
245                });
246
247                if let Some(search) = search {
248                    let search_bar = search_bar.downgrade();
249                    cx.spawn(|_, mut cx| async move {
250                        search.await?;
251                        search_bar.update(&mut cx, |search_bar, cx| {
252                            search_bar.select_match(direction, count, cx);
253
254                            let new_selections =
255                                Vim::update(cx, |vim, cx| vim.editor_selections(cx));
256                            search_motion(
257                                Motion::ZedSearchResult {
258                                    prior_selections,
259                                    new_selections,
260                                },
261                                cx,
262                            )
263                        })?;
264                        anyhow::Ok(())
265                    })
266                    .detach_and_log_err(cx);
267                }
268            }
269        });
270
271        if vim.state().mode.is_visual() {
272            vim.switch_mode(Mode::Normal, false, cx)
273        }
274    });
275}
276
277fn find_command(workspace: &mut Workspace, action: &FindCommand, cx: &mut ViewContext<Workspace>) {
278    let pane = workspace.active_pane().clone();
279    pane.update(cx, |pane, cx| {
280        if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::<BufferSearchBar>() {
281            let search = search_bar.update(cx, |search_bar, cx| {
282                if !search_bar.show(cx) {
283                    return None;
284                }
285                let mut query = action.query.clone();
286                if query == "" {
287                    query = search_bar.query(cx);
288                };
289
290                Some(search_bar.search(
291                    &query,
292                    Some(SearchOptions::CASE_SENSITIVE | SearchOptions::REGEX),
293                    cx,
294                ))
295            });
296            let Some(search) = search else { return };
297            let search_bar = search_bar.downgrade();
298            let direction = if action.backwards {
299                Direction::Prev
300            } else {
301                Direction::Next
302            };
303            cx.spawn(|_, mut cx| async move {
304                search.await?;
305                search_bar.update(&mut cx, |search_bar, cx| {
306                    search_bar.select_match(direction, 1, cx)
307                })?;
308                anyhow::Ok(())
309            })
310            .detach_and_log_err(cx);
311        }
312    })
313}
314
315fn replace_command(
316    workspace: &mut Workspace,
317    action: &ReplaceCommand,
318    cx: &mut ViewContext<Workspace>,
319) {
320    let replacement = parse_replace_all(&action.query);
321    let pane = workspace.active_pane().clone();
322    pane.update(cx, |pane, cx| {
323        let Some(search_bar) = pane.toolbar().read(cx).item_of_type::<BufferSearchBar>() else {
324            return;
325        };
326        let search = search_bar.update(cx, |search_bar, cx| {
327            if !search_bar.show(cx) {
328                return None;
329            }
330
331            let mut options = SearchOptions::REGEX;
332            if replacement.is_case_sensitive {
333                options.set(SearchOptions::CASE_SENSITIVE, true)
334            }
335            let search = if replacement.search == "" {
336                search_bar.query(cx)
337            } else {
338                replacement.search
339            };
340
341            search_bar.set_replacement(Some(&replacement.replacement), cx);
342            Some(search_bar.search(&search, Some(options), cx))
343        });
344        let Some(search) = search else { return };
345        let search_bar = search_bar.downgrade();
346        cx.spawn(|_, mut cx| async move {
347            search.await?;
348            search_bar.update(&mut cx, |search_bar, cx| {
349                if replacement.should_replace_all {
350                    search_bar.select_last_match(cx);
351                    search_bar.replace_all(&Default::default(), cx);
352                    Vim::update(cx, |vim, cx| {
353                        move_cursor(
354                            vim,
355                            Motion::StartOfLine {
356                                display_lines: false,
357                            },
358                            None,
359                            cx,
360                        )
361                    })
362                }
363            })?;
364            anyhow::Ok(())
365        })
366        .detach_and_log_err(cx);
367    })
368}
369
370// convert a vim query into something more usable by zed.
371// we don't attempt to fully convert between the two regex syntaxes,
372// but we do flip \( and \) to ( and ) (and vice-versa) in the pattern,
373// and convert \0..\9 to $0..$9 in the replacement so that common idioms work.
374fn parse_replace_all(query: &str) -> Replacement {
375    let mut chars = query.chars();
376    if Some('%') != chars.next() || Some('s') != chars.next() {
377        return Replacement::default();
378    }
379
380    let Some(delimiter) = chars.next() else {
381        return Replacement::default();
382    };
383
384    let mut search = String::new();
385    let mut replacement = String::new();
386    let mut flags = String::new();
387
388    let mut buffer = &mut search;
389
390    let mut escaped = false;
391    // 0 - parsing search
392    // 1 - parsing replacement
393    // 2 - parsing flags
394    let mut phase = 0;
395
396    for c in chars {
397        if escaped {
398            escaped = false;
399            if phase == 1 && c.is_digit(10) {
400                buffer.push('$')
401            // unescape escaped parens
402            } else if phase == 0 && c == '(' || c == ')' {
403            } else if c != delimiter {
404                buffer.push('\\')
405            }
406            buffer.push(c)
407        } else if c == '\\' {
408            escaped = true;
409        } else if c == delimiter {
410            if phase == 0 {
411                buffer = &mut replacement;
412                phase = 1;
413            } else if phase == 1 {
414                buffer = &mut flags;
415                phase = 2;
416            } else {
417                break;
418            }
419        } else {
420            // escape unescaped parens
421            if phase == 0 && c == '(' || c == ')' {
422                buffer.push('\\')
423            }
424            buffer.push(c)
425        }
426    }
427
428    let mut replacement = Replacement {
429        search,
430        replacement,
431        should_replace_all: true,
432        is_case_sensitive: true,
433    };
434
435    for c in flags.chars() {
436        match c {
437            'g' | 'I' => {}
438            'c' | 'n' => replacement.should_replace_all = false,
439            'i' => replacement.is_case_sensitive = false,
440            _ => {}
441        }
442    }
443
444    replacement
445}
446
447#[cfg(test)]
448mod test {
449    use editor::DisplayPoint;
450    use indoc::indoc;
451    use search::BufferSearchBar;
452
453    use crate::{
454        state::Mode,
455        test::{NeovimBackedTestContext, VimTestContext},
456    };
457
458    #[gpui::test]
459    async fn test_move_to_next(cx: &mut gpui::TestAppContext) {
460        let mut cx = VimTestContext::new(cx, true).await;
461        cx.set_state("ˇhi\nhigh\nhi\n", Mode::Normal);
462
463        cx.simulate_keystrokes(["*"]);
464        cx.run_until_parked();
465        cx.assert_state("hi\nhigh\nˇhi\n", Mode::Normal);
466
467        cx.simulate_keystrokes(["*"]);
468        cx.run_until_parked();
469        cx.assert_state("ˇhi\nhigh\nhi\n", Mode::Normal);
470
471        cx.simulate_keystrokes(["#"]);
472        cx.run_until_parked();
473        cx.assert_state("hi\nhigh\nˇhi\n", Mode::Normal);
474
475        cx.simulate_keystrokes(["#"]);
476        cx.run_until_parked();
477        cx.assert_state("ˇhi\nhigh\nhi\n", Mode::Normal);
478
479        cx.simulate_keystrokes(["2", "*"]);
480        cx.run_until_parked();
481        cx.assert_state("ˇhi\nhigh\nhi\n", Mode::Normal);
482
483        cx.simulate_keystrokes(["g", "*"]);
484        cx.run_until_parked();
485        cx.assert_state("hi\nˇhigh\nhi\n", Mode::Normal);
486
487        cx.simulate_keystrokes(["n"]);
488        cx.assert_state("hi\nhigh\nˇhi\n", Mode::Normal);
489
490        cx.simulate_keystrokes(["g", "#"]);
491        cx.run_until_parked();
492        cx.assert_state("hi\nˇhigh\nhi\n", Mode::Normal);
493    }
494
495    #[gpui::test]
496    async fn test_search(cx: &mut gpui::TestAppContext) {
497        let mut cx = VimTestContext::new(cx, true).await;
498
499        cx.set_state("aa\nbˇb\ncc\ncc\ncc\n", Mode::Normal);
500        cx.simulate_keystrokes(["/", "c", "c"]);
501
502        let search_bar = cx.workspace(|workspace, cx| {
503            workspace
504                .active_pane()
505                .read(cx)
506                .toolbar()
507                .read(cx)
508                .item_of_type::<BufferSearchBar>()
509                .expect("Buffer search bar should be deployed")
510        });
511
512        cx.update_view(search_bar, |bar, cx| {
513            assert_eq!(bar.query(cx), "cc");
514        });
515
516        cx.run_until_parked();
517
518        cx.update_editor(|editor, cx| {
519            let highlights = editor.all_text_background_highlights(cx);
520            assert_eq!(3, highlights.len());
521            assert_eq!(
522                DisplayPoint::new(2, 0)..DisplayPoint::new(2, 2),
523                highlights[0].0
524            )
525        });
526
527        cx.simulate_keystrokes(["enter"]);
528        cx.assert_state("aa\nbb\nˇcc\ncc\ncc\n", Mode::Normal);
529
530        // n to go to next/N to go to previous
531        cx.simulate_keystrokes(["n"]);
532        cx.assert_state("aa\nbb\ncc\nˇcc\ncc\n", Mode::Normal);
533        cx.simulate_keystrokes(["shift-n"]);
534        cx.assert_state("aa\nbb\nˇcc\ncc\ncc\n", Mode::Normal);
535
536        // ?<enter> to go to previous
537        cx.simulate_keystrokes(["?", "enter"]);
538        cx.assert_state("aa\nbb\ncc\ncc\nˇcc\n", Mode::Normal);
539        cx.simulate_keystrokes(["?", "enter"]);
540        cx.assert_state("aa\nbb\ncc\nˇcc\ncc\n", Mode::Normal);
541
542        // /<enter> to go to next
543        cx.simulate_keystrokes(["/", "enter"]);
544        cx.assert_state("aa\nbb\ncc\ncc\nˇcc\n", Mode::Normal);
545
546        // ?{search}<enter> to search backwards
547        cx.simulate_keystrokes(["?", "b", "enter"]);
548        cx.assert_state("aa\nbˇb\ncc\ncc\ncc\n", Mode::Normal);
549
550        // works with counts
551        cx.simulate_keystrokes(["4", "/", "c"]);
552        cx.simulate_keystrokes(["enter"]);
553        cx.assert_state("aa\nbb\ncc\ncˇc\ncc\n", Mode::Normal);
554
555        // check that searching resumes from cursor, not previous match
556        cx.set_state("ˇaa\nbb\ndd\ncc\nbb\n", Mode::Normal);
557        cx.simulate_keystrokes(["/", "d"]);
558        cx.simulate_keystrokes(["enter"]);
559        cx.assert_state("aa\nbb\nˇdd\ncc\nbb\n", Mode::Normal);
560        cx.update_editor(|editor, cx| editor.move_to_beginning(&Default::default(), cx));
561        cx.assert_state("ˇaa\nbb\ndd\ncc\nbb\n", Mode::Normal);
562        cx.simulate_keystrokes(["/", "b"]);
563        cx.simulate_keystrokes(["enter"]);
564        cx.assert_state("aa\nˇbb\ndd\ncc\nbb\n", Mode::Normal);
565
566        // check that searching switches to normal mode if in visual mode
567        cx.set_state("ˇone two one", Mode::Normal);
568        cx.simulate_keystrokes(["v", "l", "l"]);
569        cx.assert_editor_state("«oneˇ» two one");
570        cx.simulate_keystrokes(["*"]);
571        cx.assert_state("one two ˇone", Mode::Normal);
572    }
573
574    #[gpui::test]
575    async fn test_non_vim_search(cx: &mut gpui::TestAppContext) {
576        let mut cx = VimTestContext::new(cx, false).await;
577        cx.set_state("ˇone one one one", Mode::Normal);
578        cx.simulate_keystrokes(["cmd-f"]);
579        cx.run_until_parked();
580
581        cx.assert_editor_state("«oneˇ» one one one");
582        cx.simulate_keystrokes(["enter"]);
583        cx.assert_editor_state("one «oneˇ» one one");
584        cx.simulate_keystrokes(["shift-enter"]);
585        cx.assert_editor_state("«oneˇ» one one one");
586    }
587
588    #[gpui::test]
589    async fn test_visual_star_hash(cx: &mut gpui::TestAppContext) {
590        let mut cx = NeovimBackedTestContext::new(cx).await;
591
592        cx.set_shared_state("ˇa.c. abcd a.c. abcd").await;
593        cx.simulate_shared_keystrokes(["v", "3", "l", "*"]).await;
594        cx.assert_shared_state("a.c. abcd ˇa.c. abcd").await;
595        cx.assert_shared_mode(Mode::Normal).await;
596    }
597
598    #[gpui::test]
599    async fn test_d_search(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(["d", "/", "c", "d"]).await;
604        cx.simulate_shared_keystrokes(["enter"]).await;
605        cx.assert_shared_state("ˇcd a.c. abcd").await;
606    }
607
608    #[gpui::test]
609    async fn test_v_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(["v", "/", "c", "d"]).await;
614        cx.simulate_shared_keystrokes(["enter"]).await;
615        cx.assert_shared_state("«a.c. abcˇ»d a.c. abcd").await;
616
617        cx.set_shared_state("a a aˇ a a a").await;
618        cx.simulate_shared_keystrokes(["v", "/", "a"]).await;
619        cx.simulate_shared_keystrokes(["enter"]).await;
620        cx.assert_shared_state("a a a« aˇ» a a").await;
621        cx.simulate_shared_keystrokes(["/", "enter"]).await;
622        cx.assert_shared_state("a a a« a aˇ» a").await;
623        cx.simulate_shared_keystrokes(["?", "enter"]).await;
624        cx.assert_shared_state("a a a« aˇ» a a").await;
625        cx.simulate_shared_keystrokes(["?", "enter"]).await;
626        cx.assert_shared_state("a a «ˇa »a a a").await;
627        cx.simulate_shared_keystrokes(["/", "enter"]).await;
628        cx.assert_shared_state("a a a« aˇ» a a").await;
629        cx.simulate_shared_keystrokes(["/", "enter"]).await;
630        cx.assert_shared_state("a a a« a aˇ» a").await;
631    }
632
633    #[gpui::test]
634    async fn test_visual_block_search(cx: &mut gpui::TestAppContext) {
635        let mut cx = NeovimBackedTestContext::new(cx).await;
636
637        cx.set_shared_state(indoc! {
638            "ˇone two
639             three four
640             five six
641             "
642        })
643        .await;
644        cx.simulate_shared_keystrokes(["ctrl-v", "j", "/", "f"])
645            .await;
646        cx.simulate_shared_keystrokes(["enter"]).await;
647        cx.assert_shared_state(indoc! {
648            "«one twoˇ»
649             «three fˇ»our
650             five six
651             "
652        })
653        .await;
654    }
655}