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}