search.rs

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