search.rs

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