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