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