search.rs

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