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