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}