1use editor::{Editor, EditorSettings};
2use gpui::{Action, Context, Window, actions};
3use language::Point;
4use schemars::JsonSchema;
5use search::{BufferSearchBar, SearchOptions, buffer_search};
6use serde::Deserialize;
7use settings::Settings;
8use std::{iter::Peekable, str::Chars};
9use util::serde::default_true;
10use workspace::{notifications::NotifyResultExt, searchable::Direction};
11
12use crate::{
13 Vim, VimSettings,
14 command::CommandRange,
15 motion::Motion,
16 state::{Mode, SearchState},
17};
18
19/// Moves to the next search match.
20#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Action)]
21#[action(namespace = vim)]
22#[serde(deny_unknown_fields)]
23pub(crate) struct MoveToNext {
24 #[serde(default = "default_true")]
25 case_sensitive: bool,
26 #[serde(default)]
27 partial_word: bool,
28 #[serde(default = "default_true")]
29 regex: bool,
30}
31
32/// Moves to the previous search match.
33#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Action)]
34#[action(namespace = vim)]
35#[serde(deny_unknown_fields)]
36pub(crate) struct MoveToPrevious {
37 #[serde(default = "default_true")]
38 case_sensitive: bool,
39 #[serde(default)]
40 partial_word: bool,
41 #[serde(default = "default_true")]
42 regex: bool,
43}
44
45/// Searches for the word under the cursor without moving.
46#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Action)]
47#[action(namespace = vim)]
48#[serde(deny_unknown_fields)]
49pub(crate) struct SearchUnderCursor {
50 #[serde(default = "default_true")]
51 case_sensitive: bool,
52 #[serde(default)]
53 partial_word: bool,
54 #[serde(default = "default_true")]
55 regex: bool,
56}
57
58/// Searches for the word under the cursor without moving (backwards).
59#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Action)]
60#[action(namespace = vim)]
61#[serde(deny_unknown_fields)]
62pub(crate) struct SearchUnderCursorPrevious {
63 #[serde(default = "default_true")]
64 case_sensitive: bool,
65 #[serde(default)]
66 partial_word: bool,
67 #[serde(default = "default_true")]
68 regex: bool,
69}
70
71/// Initiates a search operation with the specified parameters.
72#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Action)]
73#[action(namespace = vim)]
74#[serde(deny_unknown_fields)]
75pub(crate) struct Search {
76 #[serde(default)]
77 backwards: bool,
78 #[serde(default = "default_true")]
79 regex: bool,
80}
81
82/// Executes a find command to search for patterns in the buffer.
83#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Action)]
84#[action(namespace = vim)]
85#[serde(deny_unknown_fields)]
86pub struct FindCommand {
87 pub query: String,
88 pub backwards: bool,
89}
90
91/// Executes a search and replace command within the specified range.
92#[derive(Clone, Debug, PartialEq, Action)]
93#[action(namespace = vim, no_json, no_register)]
94pub struct ReplaceCommand {
95 pub(crate) range: CommandRange,
96 pub(crate) replacement: Replacement,
97}
98
99#[derive(Clone, Debug, PartialEq)]
100pub struct Replacement {
101 search: String,
102 replacement: String,
103 case_sensitive: Option<bool>,
104 flag_n: bool,
105 flag_g: bool,
106 flag_c: bool,
107}
108
109actions!(
110 vim,
111 [
112 /// Submits the current search query.
113 SearchSubmit,
114 /// Moves to the next search match.
115 MoveToNextMatch,
116 /// Moves to the previous search match.
117 MoveToPreviousMatch
118 ]
119);
120
121pub(crate) fn register(editor: &mut Editor, cx: &mut Context<Vim>) {
122 Vim::action(editor, cx, Vim::move_to_next);
123 Vim::action(editor, cx, Vim::move_to_previous);
124 Vim::action(editor, cx, Vim::search_under_cursor);
125 Vim::action(editor, cx, Vim::search_under_cursor_previous);
126 Vim::action(editor, cx, Vim::move_to_next_match);
127 Vim::action(editor, cx, Vim::move_to_previous_match);
128 Vim::action(editor, cx, Vim::search);
129 Vim::action(editor, cx, Vim::search_deploy);
130 Vim::action(editor, cx, Vim::find_command);
131 Vim::action(editor, cx, Vim::replace_command);
132}
133
134impl Vim {
135 fn search_under_cursor(
136 &mut self,
137 action: &SearchUnderCursor,
138 window: &mut Window,
139 cx: &mut Context<Self>,
140 ) {
141 self.move_to_internal(
142 Direction::Next,
143 action.case_sensitive,
144 !action.partial_word,
145 action.regex,
146 false,
147 window,
148 cx,
149 )
150 }
151
152 fn search_under_cursor_previous(
153 &mut self,
154 action: &SearchUnderCursorPrevious,
155 window: &mut Window,
156 cx: &mut Context<Self>,
157 ) {
158 self.move_to_internal(
159 Direction::Prev,
160 action.case_sensitive,
161 !action.partial_word,
162 action.regex,
163 false,
164 window,
165 cx,
166 )
167 }
168
169 fn move_to_next(&mut self, action: &MoveToNext, window: &mut Window, cx: &mut Context<Self>) {
170 self.move_to_internal(
171 Direction::Next,
172 action.case_sensitive,
173 !action.partial_word,
174 action.regex,
175 true,
176 window,
177 cx,
178 )
179 }
180
181 fn move_to_previous(
182 &mut self,
183 action: &MoveToPrevious,
184 window: &mut Window,
185 cx: &mut Context<Self>,
186 ) {
187 self.move_to_internal(
188 Direction::Prev,
189 action.case_sensitive,
190 !action.partial_word,
191 action.regex,
192 true,
193 window,
194 cx,
195 )
196 }
197
198 fn move_to_next_match(
199 &mut self,
200 _: &MoveToNextMatch,
201 window: &mut Window,
202 cx: &mut Context<Self>,
203 ) {
204 self.move_to_match_internal(self.search.direction, window, cx)
205 }
206
207 fn move_to_previous_match(
208 &mut self,
209 _: &MoveToPreviousMatch,
210 window: &mut Window,
211 cx: &mut Context<Self>,
212 ) {
213 self.move_to_match_internal(self.search.direction.opposite(), window, cx)
214 }
215
216 fn search(&mut self, action: &Search, window: &mut Window, cx: &mut Context<Self>) {
217 let Some(pane) = self.pane(window, cx) else {
218 return;
219 };
220 let direction = if action.backwards {
221 Direction::Prev
222 } else {
223 Direction::Next
224 };
225 let count = Vim::take_count(cx).unwrap_or(1);
226 Vim::take_forced_motion(cx);
227 let prior_selections = self.editor_selections(window, cx);
228
229 let Some(search_bar) = pane
230 .read(cx)
231 .toolbar()
232 .read(cx)
233 .item_of_type::<BufferSearchBar>()
234 else {
235 return;
236 };
237
238 let shown = search_bar.update(cx, |search_bar, cx| {
239 if !search_bar.show(window, cx) {
240 return false;
241 }
242
243 search_bar.select_query(window, cx);
244 cx.focus_self(window);
245
246 search_bar.set_replacement(None, cx);
247 let mut options = SearchOptions::NONE;
248 if action.regex {
249 options |= SearchOptions::REGEX;
250 }
251 if action.backwards {
252 options |= SearchOptions::BACKWARDS;
253 }
254 if EditorSettings::get_global(cx).search.case_sensitive {
255 options |= SearchOptions::CASE_SENSITIVE;
256 }
257 search_bar.set_search_options(options, cx);
258 true
259 });
260
261 if !shown {
262 return;
263 }
264
265 let subscription = cx.subscribe_in(&search_bar, window, |vim, _, event, window, cx| {
266 if let buffer_search::Event::Dismissed = event {
267 if !vim.search.prior_selections.is_empty() {
268 let prior_selections: Vec<_> = vim.search.prior_selections.drain(..).collect();
269 vim.update_editor(cx, |_, editor, cx| {
270 editor.change_selections(Default::default(), window, cx, |s| {
271 s.select_ranges(prior_selections);
272 });
273 });
274 }
275 }
276 });
277
278 let prior_mode = if self.temp_mode {
279 Mode::Insert
280 } else {
281 self.mode
282 };
283
284 self.search = SearchState {
285 direction,
286 count,
287 prior_selections,
288 prior_operator: self.operator_stack.last().cloned(),
289 prior_mode,
290 helix_select: false,
291 _dismiss_subscription: Some(subscription),
292 }
293 }
294
295 // hook into the existing to clear out any vim search state on cmd+f or edit -> find.
296 fn search_deploy(&mut self, _: &buffer_search::Deploy, _: &mut Window, cx: &mut Context<Self>) {
297 // Preserve the current mode when resetting search state
298 let current_mode = self.mode;
299 self.search = Default::default();
300 self.search.prior_mode = current_mode;
301 cx.propagate();
302 }
303
304 pub fn search_submit(&mut self, window: &mut Window, cx: &mut Context<Self>) {
305 self.store_visual_marks(window, cx);
306 let Some(pane) = self.pane(window, cx) else {
307 return;
308 };
309 let new_selections = self.editor_selections(window, cx);
310 let result = pane.update(cx, |pane, cx| {
311 let search_bar = pane.toolbar().read(cx).item_of_type::<BufferSearchBar>()?;
312 if self.search.helix_select {
313 search_bar.update(cx, |search_bar, cx| {
314 search_bar.select_all_matches(&Default::default(), window, cx)
315 });
316 return None;
317 }
318 search_bar.update(cx, |search_bar, cx| {
319 let mut count = self.search.count;
320 let direction = self.search.direction;
321 search_bar.has_active_match();
322 let new_head = new_selections.last()?.start;
323 let is_different_head = self
324 .search
325 .prior_selections
326 .last()
327 .is_none_or(|range| range.start != new_head);
328
329 if is_different_head {
330 count = count.saturating_sub(1)
331 }
332 self.search.count = 1;
333 search_bar.select_match(direction, count, window, cx);
334 search_bar.focus_editor(&Default::default(), window, cx);
335
336 let prior_selections: Vec<_> = self.search.prior_selections.drain(..).collect();
337 let prior_mode = self.search.prior_mode;
338 let prior_operator = self.search.prior_operator.take();
339
340 let query = search_bar.query(cx).into();
341 Vim::globals(cx).registers.insert('/', query);
342 Some((prior_selections, prior_mode, prior_operator))
343 })
344 });
345
346 let Some((mut prior_selections, prior_mode, prior_operator)) = result else {
347 return;
348 };
349
350 let new_selections = self.editor_selections(window, cx);
351
352 // If the active editor has changed during a search, don't panic.
353 if prior_selections.iter().any(|s| {
354 self.update_editor(cx, |_, editor, cx| {
355 !s.start
356 .is_valid(&editor.snapshot(window, cx).buffer_snapshot())
357 })
358 .unwrap_or(true)
359 }) {
360 prior_selections.clear();
361 }
362
363 if prior_mode != self.mode {
364 self.switch_mode(prior_mode, true, window, cx);
365 }
366 if let Some(operator) = prior_operator {
367 self.push_operator(operator, window, cx);
368 };
369 self.search_motion(
370 Motion::ZedSearchResult {
371 prior_selections,
372 new_selections,
373 },
374 window,
375 cx,
376 );
377 }
378
379 pub fn move_to_match_internal(
380 &mut self,
381 direction: Direction,
382 window: &mut Window,
383 cx: &mut Context<Self>,
384 ) {
385 let Some(pane) = self.pane(window, cx) else {
386 return;
387 };
388 let count = Vim::take_count(cx).unwrap_or(1);
389 Vim::take_forced_motion(cx);
390 let prior_selections = self.editor_selections(window, cx);
391
392 let success = pane.update(cx, |pane, cx| {
393 let Some(search_bar) = pane.toolbar().read(cx).item_of_type::<BufferSearchBar>() else {
394 return false;
395 };
396 search_bar.update(cx, |search_bar, cx| {
397 if !search_bar.has_active_match() || !search_bar.show(window, cx) {
398 return false;
399 }
400 search_bar.select_match(direction, count, window, cx);
401 true
402 })
403 });
404 if !success {
405 return;
406 }
407
408 let new_selections = self.editor_selections(window, cx);
409 self.search_motion(
410 Motion::ZedSearchResult {
411 prior_selections,
412 new_selections,
413 },
414 window,
415 cx,
416 );
417 }
418
419 pub fn move_to_internal(
420 &mut self,
421 direction: Direction,
422 case_sensitive: bool,
423 whole_word: bool,
424 regex: bool,
425 move_cursor: bool,
426 window: &mut Window,
427 cx: &mut Context<Self>,
428 ) {
429 let Some(pane) = self.pane(window, cx) else {
430 return;
431 };
432 let count = Vim::take_count(cx).unwrap_or(1);
433 Vim::take_forced_motion(cx);
434 let prior_selections = self.editor_selections(window, cx);
435 let cursor_word = self.editor_cursor_word(window, cx);
436 let vim = cx.entity();
437
438 let searched = pane.update(cx, |pane, cx| {
439 self.search.direction = direction;
440 let Some(search_bar) = pane.toolbar().read(cx).item_of_type::<BufferSearchBar>() else {
441 return false;
442 };
443 let search = search_bar.update(cx, |search_bar, cx| {
444 let mut options = SearchOptions::NONE;
445 if case_sensitive {
446 options |= SearchOptions::CASE_SENSITIVE;
447 }
448 if regex {
449 options |= SearchOptions::REGEX;
450 }
451 if whole_word {
452 options |= SearchOptions::WHOLE_WORD;
453 }
454 if !search_bar.show(window, cx) {
455 return None;
456 }
457 let Some(query) = search_bar
458 .query_suggestion(window, cx)
459 .or_else(|| cursor_word)
460 else {
461 drop(search_bar.search("", None, false, window, cx));
462 return None;
463 };
464
465 let query = regex::escape(&query);
466 Some(search_bar.search(&query, Some(options), true, window, cx))
467 });
468
469 let Some(search) = search else { return false };
470
471 if move_cursor {
472 let search_bar = search_bar.downgrade();
473 cx.spawn_in(window, async move |_, cx| {
474 search.await?;
475 search_bar.update_in(cx, |search_bar, window, cx| {
476 search_bar.select_match(direction, count, window, cx);
477
478 vim.update(cx, |vim, cx| {
479 let new_selections = vim.editor_selections(window, cx);
480 vim.search_motion(
481 Motion::ZedSearchResult {
482 prior_selections,
483 new_selections,
484 },
485 window,
486 cx,
487 )
488 });
489 })?;
490 anyhow::Ok(())
491 })
492 .detach_and_log_err(cx);
493 }
494 true
495 });
496 if !searched {
497 self.clear_operator(window, cx)
498 }
499
500 if self.mode.is_visual() {
501 self.switch_mode(Mode::Normal, false, window, cx)
502 }
503 }
504
505 fn find_command(&mut self, action: &FindCommand, window: &mut Window, cx: &mut Context<Self>) {
506 let Some(pane) = self.pane(window, cx) else {
507 return;
508 };
509 pane.update(cx, |pane, cx| {
510 if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::<BufferSearchBar>() {
511 let search = search_bar.update(cx, |search_bar, cx| {
512 if !search_bar.show(window, cx) {
513 return None;
514 }
515 let mut query = action.query.clone();
516 if query.is_empty() {
517 query = search_bar.query(cx);
518 };
519
520 let mut options = SearchOptions::REGEX | SearchOptions::CASE_SENSITIVE;
521 if search_bar.should_use_smartcase_search(cx) {
522 options.set(
523 SearchOptions::CASE_SENSITIVE,
524 search_bar.is_contains_uppercase(&query),
525 );
526 }
527
528 Some(search_bar.search(&query, Some(options), true, window, cx))
529 });
530 let Some(search) = search else { return };
531 let search_bar = search_bar.downgrade();
532 let direction = if action.backwards {
533 Direction::Prev
534 } else {
535 Direction::Next
536 };
537 cx.spawn_in(window, async move |_, cx| {
538 search.await?;
539 search_bar.update_in(cx, |search_bar, window, cx| {
540 search_bar.select_match(direction, 1, window, cx)
541 })?;
542 anyhow::Ok(())
543 })
544 .detach_and_log_err(cx);
545 }
546 })
547 }
548
549 fn replace_command(
550 &mut self,
551 action: &ReplaceCommand,
552 window: &mut Window,
553 cx: &mut Context<Self>,
554 ) {
555 let replacement = action.replacement.clone();
556 let Some(((pane, workspace), editor)) = self
557 .pane(window, cx)
558 .zip(self.workspace(window, cx))
559 .zip(self.editor())
560 else {
561 return;
562 };
563 if let Some(result) = self.update_editor(cx, |vim, editor, cx| {
564 let range = action.range.buffer_range(vim, editor, window, cx)?;
565 let snapshot = editor.snapshot(window, cx);
566 let snapshot = snapshot.buffer_snapshot();
567 let end_point = Point::new(range.end.0, snapshot.line_len(range.end));
568 let range = snapshot.anchor_before(Point::new(range.start.0, 0))
569 ..snapshot.anchor_after(end_point);
570 editor.set_search_within_ranges(&[range], cx);
571 anyhow::Ok(())
572 }) {
573 workspace.update(cx, |workspace, cx| {
574 result.notify_err(workspace, cx);
575 })
576 }
577 let Some(search_bar) = pane.update(cx, |pane, cx| {
578 pane.toolbar().read(cx).item_of_type::<BufferSearchBar>()
579 }) else {
580 return;
581 };
582 let mut options = SearchOptions::REGEX;
583 let search = search_bar.update(cx, |search_bar, cx| {
584 if !search_bar.show(window, cx) {
585 return None;
586 }
587
588 let search = if replacement.search.is_empty() {
589 search_bar.query(cx)
590 } else {
591 replacement.search
592 };
593
594 if let Some(case) = replacement.case_sensitive {
595 options.set(SearchOptions::CASE_SENSITIVE, case)
596 } else if search_bar.should_use_smartcase_search(cx) {
597 options.set(
598 SearchOptions::CASE_SENSITIVE,
599 search_bar.is_contains_uppercase(&search),
600 );
601 } else {
602 // Fallback: no explicit i/I flags and smartcase disabled;
603 // use global editor.search.case_sensitive.
604 options.set(
605 SearchOptions::CASE_SENSITIVE,
606 EditorSettings::get_global(cx).search.case_sensitive,
607 )
608 }
609
610 // gdefault inverts the behavior of the 'g' flag.
611 let replace_all = VimSettings::get_global(cx).gdefault != replacement.flag_g;
612 if !replace_all {
613 options.set(SearchOptions::ONE_MATCH_PER_LINE, true);
614 }
615
616 search_bar.set_replacement(Some(&replacement.replacement), cx);
617 if replacement.flag_c {
618 search_bar.focus_replace(window, cx);
619 }
620 Some(search_bar.search(&search, Some(options), true, window, cx))
621 });
622 if replacement.flag_n {
623 self.move_cursor(
624 Motion::StartOfLine {
625 display_lines: false,
626 },
627 None,
628 window,
629 cx,
630 );
631 return;
632 }
633 let Some(search) = search else { return };
634 let search_bar = search_bar.downgrade();
635 cx.spawn_in(window, async move |vim, cx| {
636 search.await?;
637 search_bar.update_in(cx, |search_bar, window, cx| {
638 if replacement.flag_c {
639 search_bar.select_first_match(window, cx);
640 return;
641 }
642 search_bar.select_last_match(window, cx);
643 search_bar.replace_all(&Default::default(), window, cx);
644 editor.update(cx, |editor, cx| editor.clear_search_within_ranges(cx));
645 let _ = search_bar.search(&search_bar.query(cx), None, false, window, cx);
646 vim.update(cx, |vim, cx| {
647 vim.move_cursor(
648 Motion::StartOfLine {
649 display_lines: false,
650 },
651 None,
652 window,
653 cx,
654 )
655 })
656 .ok();
657
658 // Disable the `ONE_MATCH_PER_LINE` search option when finished, as
659 // this is not properly supported outside of vim mode, and
660 // not disabling it makes the "Replace All Matches" button
661 // actually replace only the first match on each line.
662 options.set(SearchOptions::ONE_MATCH_PER_LINE, false);
663 search_bar.set_search_options(options, cx);
664 })
665 })
666 .detach_and_log_err(cx);
667 }
668}
669
670impl Replacement {
671 // convert a vim query into something more usable by zed.
672 // we don't attempt to fully convert between the two regex syntaxes,
673 // but we do flip \( and \) to ( and ) (and vice-versa) in the pattern,
674 // and convert \0..\9 to $0..$9 in the replacement so that common idioms work.
675 pub(crate) fn parse(mut chars: Peekable<Chars>) -> Option<Replacement> {
676 let delimiter = chars
677 .next()
678 .filter(|c| !c.is_alphanumeric() && *c != '"' && *c != '|' && *c != '\'')?;
679
680 let mut search = String::new();
681 let mut replacement = String::new();
682 let mut flags = String::new();
683
684 let mut buffer = &mut search;
685
686 let mut escaped = false;
687 // 0 - parsing search
688 // 1 - parsing replacement
689 // 2 - parsing flags
690 let mut phase = 0;
691
692 for c in chars {
693 if escaped {
694 escaped = false;
695 if phase == 1 && c.is_ascii_digit() {
696 buffer.push('$')
697 // unescape escaped parens
698 } else if phase == 0 && (c == '(' || c == ')') {
699 } else if c != delimiter {
700 buffer.push('\\')
701 }
702 buffer.push(c)
703 } else if c == '\\' {
704 escaped = true;
705 } else if c == delimiter {
706 if phase == 0 {
707 buffer = &mut replacement;
708 phase = 1;
709 } else if phase == 1 {
710 buffer = &mut flags;
711 phase = 2;
712 } else {
713 break;
714 }
715 } else {
716 // escape unescaped parens
717 if phase == 0 && (c == '(' || c == ')') {
718 buffer.push('\\')
719 }
720 buffer.push(c)
721 }
722 }
723
724 let mut replacement = Replacement {
725 search,
726 replacement,
727 case_sensitive: None,
728 flag_g: false,
729 flag_n: false,
730 flag_c: false,
731 };
732
733 for c in flags.chars() {
734 match c {
735 'g' => replacement.flag_g = !replacement.flag_g,
736 'n' => replacement.flag_n = true,
737 'c' => replacement.flag_c = true,
738 'i' => replacement.case_sensitive = Some(false),
739 'I' => replacement.case_sensitive = Some(true),
740 _ => {}
741 }
742 }
743
744 Some(replacement)
745 }
746}
747
748#[cfg(test)]
749mod test {
750 use std::time::Duration;
751
752 use crate::{
753 state::Mode,
754 test::{NeovimBackedTestContext, VimTestContext},
755 };
756 use editor::{DisplayPoint, display_map::DisplayRow};
757
758 use indoc::indoc;
759 use search::BufferSearchBar;
760 use settings::SettingsStore;
761
762 #[gpui::test]
763 async fn test_move_to_next(cx: &mut gpui::TestAppContext) {
764 let mut cx = VimTestContext::new(cx, true).await;
765 cx.set_state("ˇhi\nhigh\nhi\n", Mode::Normal);
766
767 cx.simulate_keystrokes("*");
768 cx.run_until_parked();
769 cx.assert_state("hi\nhigh\nˇhi\n", Mode::Normal);
770
771 cx.simulate_keystrokes("*");
772 cx.run_until_parked();
773 cx.assert_state("ˇhi\nhigh\nhi\n", Mode::Normal);
774
775 cx.simulate_keystrokes("#");
776 cx.run_until_parked();
777 cx.assert_state("hi\nhigh\nˇhi\n", Mode::Normal);
778
779 cx.simulate_keystrokes("#");
780 cx.run_until_parked();
781 cx.assert_state("ˇhi\nhigh\nhi\n", Mode::Normal);
782
783 cx.simulate_keystrokes("2 *");
784 cx.run_until_parked();
785 cx.assert_state("ˇhi\nhigh\nhi\n", Mode::Normal);
786
787 cx.simulate_keystrokes("g *");
788 cx.run_until_parked();
789 cx.assert_state("hi\nˇhigh\nhi\n", Mode::Normal);
790
791 cx.simulate_keystrokes("n");
792 cx.assert_state("hi\nhigh\nˇhi\n", Mode::Normal);
793
794 cx.simulate_keystrokes("g #");
795 cx.run_until_parked();
796 cx.assert_state("hi\nˇhigh\nhi\n", Mode::Normal);
797 }
798
799 #[gpui::test]
800 async fn test_move_to_next_with_no_search_wrap(cx: &mut gpui::TestAppContext) {
801 let mut cx = VimTestContext::new(cx, true).await;
802
803 cx.update_global(|store: &mut SettingsStore, cx| {
804 store.update_user_settings(cx, |s| s.editor.search_wrap = Some(false));
805 });
806
807 cx.set_state("ˇhi\nhigh\nhi\n", Mode::Normal);
808
809 cx.simulate_keystrokes("*");
810 cx.run_until_parked();
811 cx.assert_state("hi\nhigh\nˇhi\n", Mode::Normal);
812
813 cx.simulate_keystrokes("*");
814 cx.run_until_parked();
815 cx.assert_state("hi\nhigh\nˇhi\n", Mode::Normal);
816
817 cx.simulate_keystrokes("#");
818 cx.run_until_parked();
819 cx.assert_state("ˇhi\nhigh\nhi\n", Mode::Normal);
820
821 cx.simulate_keystrokes("3 *");
822 cx.run_until_parked();
823 cx.assert_state("ˇhi\nhigh\nhi\n", Mode::Normal);
824
825 cx.simulate_keystrokes("g *");
826 cx.run_until_parked();
827 cx.assert_state("hi\nˇhigh\nhi\n", Mode::Normal);
828
829 cx.simulate_keystrokes("n");
830 cx.assert_state("hi\nhigh\nˇhi\n", Mode::Normal);
831
832 cx.simulate_keystrokes("g #");
833 cx.run_until_parked();
834 cx.assert_state("hi\nˇhigh\nhi\n", Mode::Normal);
835 }
836
837 #[gpui::test]
838 async fn test_search(cx: &mut gpui::TestAppContext) {
839 let mut cx = VimTestContext::new(cx, true).await;
840
841 cx.set_state("aa\nbˇb\ncc\ncc\ncc\n", Mode::Normal);
842 cx.simulate_keystrokes("/ c c");
843
844 let search_bar = cx.workspace(|workspace, _, cx| {
845 workspace
846 .active_pane()
847 .read(cx)
848 .toolbar()
849 .read(cx)
850 .item_of_type::<BufferSearchBar>()
851 .expect("Buffer search bar should be deployed")
852 });
853
854 cx.update_entity(search_bar, |bar, _window, cx| {
855 assert_eq!(bar.query(cx), "cc");
856 });
857
858 cx.run_until_parked();
859
860 cx.update_editor(|editor, window, cx| {
861 let highlights = editor.all_text_background_highlights(window, cx);
862 assert_eq!(3, highlights.len());
863 assert_eq!(
864 DisplayPoint::new(DisplayRow(2), 0)..DisplayPoint::new(DisplayRow(2), 2),
865 highlights[0].0
866 )
867 });
868
869 cx.simulate_keystrokes("enter");
870 cx.assert_state("aa\nbb\nˇcc\ncc\ncc\n", Mode::Normal);
871
872 // n to go to next/N to go to previous
873 cx.simulate_keystrokes("n");
874 cx.assert_state("aa\nbb\ncc\nˇcc\ncc\n", Mode::Normal);
875 cx.simulate_keystrokes("shift-n");
876 cx.assert_state("aa\nbb\nˇcc\ncc\ncc\n", Mode::Normal);
877
878 // ?<enter> to go to previous
879 cx.simulate_keystrokes("? enter");
880 cx.assert_state("aa\nbb\ncc\ncc\nˇcc\n", Mode::Normal);
881 cx.simulate_keystrokes("? enter");
882 cx.assert_state("aa\nbb\ncc\nˇcc\ncc\n", Mode::Normal);
883
884 // /<enter> to go to next
885 cx.simulate_keystrokes("/ enter");
886 cx.assert_state("aa\nbb\ncc\ncc\nˇcc\n", Mode::Normal);
887
888 // ?{search}<enter> to search backwards
889 cx.simulate_keystrokes("? b enter");
890 cx.assert_state("aa\nbˇb\ncc\ncc\ncc\n", Mode::Normal);
891
892 // works with counts
893 cx.simulate_keystrokes("4 / c");
894 cx.simulate_keystrokes("enter");
895 cx.assert_state("aa\nbb\ncc\ncˇc\ncc\n", Mode::Normal);
896
897 // check that searching resumes from cursor, not previous match
898 cx.set_state("ˇaa\nbb\ndd\ncc\nbb\n", Mode::Normal);
899 cx.simulate_keystrokes("/ d");
900 cx.simulate_keystrokes("enter");
901 cx.assert_state("aa\nbb\nˇdd\ncc\nbb\n", Mode::Normal);
902 cx.update_editor(|editor, window, cx| {
903 editor.move_to_beginning(&Default::default(), window, cx)
904 });
905 cx.assert_state("ˇaa\nbb\ndd\ncc\nbb\n", Mode::Normal);
906 cx.simulate_keystrokes("/ b");
907 cx.simulate_keystrokes("enter");
908 cx.assert_state("aa\nˇbb\ndd\ncc\nbb\n", Mode::Normal);
909
910 // check that searching switches to normal mode if in visual mode
911 cx.set_state("ˇone two one", Mode::Normal);
912 cx.simulate_keystrokes("v l l");
913 cx.assert_editor_state("«oneˇ» two one");
914 cx.simulate_keystrokes("*");
915 cx.assert_state("one two ˇone", Mode::Normal);
916
917 // check that a backward search after last match works correctly
918 cx.set_state("aa\naa\nbbˇ", Mode::Normal);
919 cx.simulate_keystrokes("? a a");
920 cx.simulate_keystrokes("enter");
921 cx.assert_state("aa\nˇaa\nbb", Mode::Normal);
922
923 // check that searching with unable search wrap
924 cx.update_global(|store: &mut SettingsStore, cx| {
925 store.update_user_settings(cx, |s| s.editor.search_wrap = Some(false));
926 });
927 cx.set_state("aa\nbˇb\ncc\ncc\ncc\n", Mode::Normal);
928 cx.simulate_keystrokes("/ c c enter");
929
930 cx.assert_state("aa\nbb\nˇcc\ncc\ncc\n", Mode::Normal);
931
932 // n to go to next/N to go to previous
933 cx.simulate_keystrokes("n");
934 cx.assert_state("aa\nbb\ncc\nˇcc\ncc\n", Mode::Normal);
935 cx.simulate_keystrokes("shift-n");
936 cx.assert_state("aa\nbb\nˇcc\ncc\ncc\n", Mode::Normal);
937
938 // ?<enter> to go to previous
939 cx.simulate_keystrokes("? enter");
940 cx.assert_state("aa\nbb\nˇcc\ncc\ncc\n", Mode::Normal);
941 cx.simulate_keystrokes("? enter");
942 cx.assert_state("aa\nbb\nˇcc\ncc\ncc\n", Mode::Normal);
943 }
944
945 #[gpui::test]
946 async fn test_non_vim_search(cx: &mut gpui::TestAppContext) {
947 let mut cx = VimTestContext::new(cx, false).await;
948 cx.cx.set_state("ˇone one one one");
949 cx.run_until_parked();
950 cx.simulate_keystrokes("cmd-f");
951 cx.run_until_parked();
952
953 cx.assert_editor_state("«oneˇ» one one one");
954 cx.simulate_keystrokes("enter");
955 cx.assert_editor_state("one «oneˇ» one one");
956 cx.simulate_keystrokes("shift-enter");
957 cx.assert_editor_state("«oneˇ» one one one");
958 }
959
960 #[gpui::test]
961 async fn test_visual_star_hash(cx: &mut gpui::TestAppContext) {
962 let mut cx = NeovimBackedTestContext::new(cx).await;
963
964 cx.set_shared_state("ˇa.c. abcd a.c. abcd").await;
965 cx.simulate_shared_keystrokes("v 3 l *").await;
966 cx.shared_state().await.assert_eq("a.c. abcd ˇa.c. abcd");
967 }
968
969 #[gpui::test]
970 async fn test_d_search(cx: &mut gpui::TestAppContext) {
971 let mut cx = NeovimBackedTestContext::new(cx).await;
972
973 cx.set_shared_state("ˇa.c. abcd a.c. abcd").await;
974 cx.simulate_shared_keystrokes("d / c d").await;
975 cx.simulate_shared_keystrokes("enter").await;
976 cx.shared_state().await.assert_eq("ˇcd a.c. abcd");
977 }
978
979 #[gpui::test]
980 async fn test_backwards_n(cx: &mut gpui::TestAppContext) {
981 let mut cx = NeovimBackedTestContext::new(cx).await;
982
983 cx.set_shared_state("ˇa b a b a b a").await;
984 cx.simulate_shared_keystrokes("*").await;
985 cx.simulate_shared_keystrokes("n").await;
986 cx.shared_state().await.assert_eq("a b a b ˇa b a");
987 cx.simulate_shared_keystrokes("#").await;
988 cx.shared_state().await.assert_eq("a b ˇa b a b a");
989 cx.simulate_shared_keystrokes("n").await;
990 cx.shared_state().await.assert_eq("ˇa b a b a b a");
991 }
992
993 #[gpui::test]
994 async fn test_v_search(cx: &mut gpui::TestAppContext) {
995 let mut cx = NeovimBackedTestContext::new(cx).await;
996
997 cx.set_shared_state("ˇa.c. abcd a.c. abcd").await;
998 cx.simulate_shared_keystrokes("v / c d").await;
999 cx.simulate_shared_keystrokes("enter").await;
1000 cx.shared_state().await.assert_eq("«a.c. abcˇ»d a.c. abcd");
1001
1002 cx.set_shared_state("a a aˇ a a a").await;
1003 cx.simulate_shared_keystrokes("v / a").await;
1004 cx.simulate_shared_keystrokes("enter").await;
1005 cx.shared_state().await.assert_eq("a a a« aˇ» a a");
1006 cx.simulate_shared_keystrokes("/ enter").await;
1007 cx.shared_state().await.assert_eq("a a a« a aˇ» a");
1008 cx.simulate_shared_keystrokes("? enter").await;
1009 cx.shared_state().await.assert_eq("a a a« aˇ» a a");
1010 cx.simulate_shared_keystrokes("? enter").await;
1011 cx.shared_state().await.assert_eq("a a «ˇa »a a a");
1012 cx.simulate_shared_keystrokes("/ enter").await;
1013 cx.shared_state().await.assert_eq("a a a« aˇ» a a");
1014 cx.simulate_shared_keystrokes("/ enter").await;
1015 cx.shared_state().await.assert_eq("a a a« a aˇ» a");
1016 }
1017
1018 #[gpui::test]
1019 async fn test_v_search_aa(cx: &mut gpui::TestAppContext) {
1020 let mut cx = NeovimBackedTestContext::new(cx).await;
1021
1022 cx.set_shared_state("ˇaa aa").await;
1023 cx.simulate_shared_keystrokes("v / a a").await;
1024 cx.simulate_shared_keystrokes("enter").await;
1025 cx.shared_state().await.assert_eq("«aa aˇ»a");
1026 }
1027
1028 #[gpui::test]
1029 async fn test_visual_block_search(cx: &mut gpui::TestAppContext) {
1030 let mut cx = NeovimBackedTestContext::new(cx).await;
1031
1032 cx.set_shared_state(indoc! {
1033 "ˇone two
1034 three four
1035 five six
1036 "
1037 })
1038 .await;
1039 cx.simulate_shared_keystrokes("ctrl-v j / f").await;
1040 cx.simulate_shared_keystrokes("enter").await;
1041 cx.shared_state().await.assert_eq(indoc! {
1042 "«one twoˇ»
1043 «three fˇ»our
1044 five six
1045 "
1046 });
1047 }
1048
1049 #[gpui::test]
1050 async fn test_replace_with_range_at_start(cx: &mut gpui::TestAppContext) {
1051 let mut cx = NeovimBackedTestContext::new(cx).await;
1052
1053 cx.set_shared_state(indoc! {
1054 "ˇa
1055 a
1056 a
1057 a
1058 a
1059 a
1060 a
1061 "
1062 })
1063 .await;
1064 cx.simulate_shared_keystrokes(": 2 , 5 s / ^ / b").await;
1065 cx.simulate_shared_keystrokes("enter").await;
1066 cx.shared_state().await.assert_eq(indoc! {
1067 "a
1068 ba
1069 ba
1070 ba
1071 ˇba
1072 a
1073 a
1074 "
1075 });
1076
1077 cx.simulate_shared_keystrokes("/ a").await;
1078 cx.simulate_shared_keystrokes("enter").await;
1079 cx.shared_state().await.assert_eq(indoc! {
1080 "a
1081 ba
1082 ba
1083 ba
1084 bˇa
1085 a
1086 a
1087 "
1088 });
1089 }
1090
1091 #[gpui::test]
1092 async fn test_search_skipping(cx: &mut gpui::TestAppContext) {
1093 let mut cx = NeovimBackedTestContext::new(cx).await;
1094 cx.set_shared_state(indoc! {
1095 "ˇaa aa aa"
1096 })
1097 .await;
1098
1099 cx.simulate_shared_keystrokes("/ a a").await;
1100 cx.simulate_shared_keystrokes("enter").await;
1101
1102 cx.shared_state().await.assert_eq(indoc! {
1103 "aa ˇaa aa"
1104 });
1105
1106 cx.simulate_shared_keystrokes("left / a a").await;
1107 cx.simulate_shared_keystrokes("enter").await;
1108
1109 cx.shared_state().await.assert_eq(indoc! {
1110 "aa ˇaa aa"
1111 });
1112 }
1113
1114 #[gpui::test]
1115 async fn test_replace_n(cx: &mut gpui::TestAppContext) {
1116 let mut cx = NeovimBackedTestContext::new(cx).await;
1117 cx.set_shared_state(indoc! {
1118 "ˇaa
1119 bb
1120 aa"
1121 })
1122 .await;
1123
1124 cx.simulate_shared_keystrokes(": s / b b / d d / n").await;
1125 cx.simulate_shared_keystrokes("enter").await;
1126
1127 cx.shared_state().await.assert_eq(indoc! {
1128 "ˇaa
1129 bb
1130 aa"
1131 });
1132
1133 let search_bar = cx.update_workspace(|workspace, _, cx| {
1134 workspace.active_pane().update(cx, |pane, cx| {
1135 pane.toolbar()
1136 .read(cx)
1137 .item_of_type::<BufferSearchBar>()
1138 .unwrap()
1139 })
1140 });
1141 cx.update_entity(search_bar, |search_bar, _, cx| {
1142 assert!(!search_bar.is_dismissed());
1143 assert_eq!(search_bar.query(cx), "bb".to_string());
1144 assert_eq!(search_bar.replacement(cx), "dd".to_string());
1145 })
1146 }
1147
1148 #[gpui::test]
1149 async fn test_replace_g(cx: &mut gpui::TestAppContext) {
1150 let mut cx = NeovimBackedTestContext::new(cx).await;
1151 cx.set_shared_state(indoc! {
1152 "ˇaa aa aa aa
1153 aa
1154 aa"
1155 })
1156 .await;
1157
1158 cx.simulate_shared_keystrokes(": s / a a / b b").await;
1159 cx.simulate_shared_keystrokes("enter").await;
1160 cx.shared_state().await.assert_eq(indoc! {
1161 "ˇbb aa aa aa
1162 aa
1163 aa"
1164 });
1165 cx.simulate_shared_keystrokes(": s / a a / b b / g").await;
1166 cx.simulate_shared_keystrokes("enter").await;
1167 cx.shared_state().await.assert_eq(indoc! {
1168 "ˇbb bb bb bb
1169 aa
1170 aa"
1171 });
1172 }
1173
1174 #[gpui::test]
1175 async fn test_replace_gdefault(cx: &mut gpui::TestAppContext) {
1176 let mut cx = NeovimBackedTestContext::new(cx).await;
1177
1178 // Set the `gdefault` option in both Zed and Neovim.
1179 cx.simulate_shared_keystrokes(": s e t space g d e f a u l t")
1180 .await;
1181 cx.simulate_shared_keystrokes("enter").await;
1182
1183 cx.set_shared_state(indoc! {
1184 "ˇaa aa aa aa
1185 aa
1186 aa"
1187 })
1188 .await;
1189
1190 // With gdefault on, :s/// replaces all matches (like :s///g normally).
1191 cx.simulate_shared_keystrokes(": s / a a / b b").await;
1192 cx.simulate_shared_keystrokes("enter").await;
1193 cx.shared_state().await.assert_eq(indoc! {
1194 "ˇbb bb bb bb
1195 aa
1196 aa"
1197 });
1198
1199 // With gdefault on, :s///g replaces only the first match.
1200 cx.simulate_shared_keystrokes(": s / b b / c c / g").await;
1201 cx.simulate_shared_keystrokes("enter").await;
1202 cx.shared_state().await.assert_eq(indoc! {
1203 "ˇcc bb bb bb
1204 aa
1205 aa"
1206 });
1207
1208 // Each successive `/g` flag should invert the one before it.
1209 cx.simulate_shared_keystrokes(": s / b b / d d / g g").await;
1210 cx.simulate_shared_keystrokes("enter").await;
1211 cx.shared_state().await.assert_eq(indoc! {
1212 "ˇcc dd dd dd
1213 aa
1214 aa"
1215 });
1216
1217 cx.simulate_shared_keystrokes(": s / c c / e e / g g g")
1218 .await;
1219 cx.simulate_shared_keystrokes("enter").await;
1220 cx.shared_state().await.assert_eq(indoc! {
1221 "ˇee dd dd dd
1222 aa
1223 aa"
1224 });
1225 }
1226
1227 #[gpui::test]
1228 async fn test_replace_c(cx: &mut gpui::TestAppContext) {
1229 let mut cx = VimTestContext::new(cx, true).await;
1230 cx.set_state(
1231 indoc! {
1232 "ˇaa
1233 aa
1234 aa"
1235 },
1236 Mode::Normal,
1237 );
1238
1239 cx.simulate_keystrokes("v j : s / a a / d d / c");
1240 cx.simulate_keystrokes("enter");
1241
1242 cx.assert_state(
1243 indoc! {
1244 "ˇaa
1245 aa
1246 aa"
1247 },
1248 Mode::Normal,
1249 );
1250
1251 cx.simulate_keystrokes("enter");
1252
1253 cx.assert_state(
1254 indoc! {
1255 "dd
1256 ˇaa
1257 aa"
1258 },
1259 Mode::Normal,
1260 );
1261
1262 cx.simulate_keystrokes("enter");
1263 cx.assert_state(
1264 indoc! {
1265 "dd
1266 ddˇ
1267 aa"
1268 },
1269 Mode::Normal,
1270 );
1271 cx.simulate_keystrokes("enter");
1272 cx.assert_state(
1273 indoc! {
1274 "dd
1275 ddˇ
1276 aa"
1277 },
1278 Mode::Normal,
1279 );
1280 }
1281
1282 #[gpui::test]
1283 async fn test_replace_with_range(cx: &mut gpui::TestAppContext) {
1284 let mut cx = NeovimBackedTestContext::new(cx).await;
1285
1286 cx.set_shared_state(indoc! {
1287 "ˇa
1288 a
1289 a
1290 a
1291 a
1292 a
1293 a
1294 "
1295 })
1296 .await;
1297 cx.simulate_shared_keystrokes(": 2 , 5 s / a / b").await;
1298 cx.simulate_shared_keystrokes("enter").await;
1299 cx.shared_state().await.assert_eq(indoc! {
1300 "a
1301 b
1302 b
1303 b
1304 ˇb
1305 a
1306 a
1307 "
1308 });
1309 cx.executor().advance_clock(Duration::from_millis(250));
1310 cx.run_until_parked();
1311
1312 cx.simulate_shared_keystrokes("/ a enter").await;
1313 cx.shared_state().await.assert_eq(indoc! {
1314 "a
1315 b
1316 b
1317 b
1318 b
1319 ˇa
1320 a
1321 "
1322 });
1323 }
1324
1325 #[gpui::test]
1326 async fn test_search_dismiss_restores_cursor(cx: &mut gpui::TestAppContext) {
1327 let mut cx = VimTestContext::new(cx, true).await;
1328 cx.set_state("ˇhello world\nfoo bar\nhello again\n", Mode::Normal);
1329
1330 // Move cursor to line 2
1331 cx.simulate_keystrokes("j");
1332 cx.run_until_parked();
1333 cx.assert_state("hello world\nˇfoo bar\nhello again\n", Mode::Normal);
1334
1335 // Open search
1336 cx.simulate_keystrokes("/");
1337 cx.run_until_parked();
1338
1339 // Dismiss search with Escape - cursor should return to line 2
1340 cx.simulate_keystrokes("escape");
1341 cx.run_until_parked();
1342 // Cursor should be restored to line 2 where it was when search was opened
1343 cx.assert_state("hello world\nˇfoo bar\nhello again\n", Mode::Normal);
1344 }
1345
1346 #[gpui::test]
1347 async fn test_search_dismiss_restores_cursor_no_matches(cx: &mut gpui::TestAppContext) {
1348 let mut cx = VimTestContext::new(cx, true).await;
1349 cx.set_state("ˇapple\nbanana\ncherry\n", Mode::Normal);
1350
1351 // Move cursor to line 2
1352 cx.simulate_keystrokes("j");
1353 cx.run_until_parked();
1354 cx.assert_state("apple\nˇbanana\ncherry\n", Mode::Normal);
1355
1356 // Open search and type query for something that doesn't exist
1357 cx.simulate_keystrokes("/ n o n e x i s t e n t");
1358 cx.run_until_parked();
1359
1360 // Dismiss search with Escape - cursor should still be at original position
1361 cx.simulate_keystrokes("escape");
1362 cx.run_until_parked();
1363 cx.assert_state("apple\nˇbanana\ncherry\n", Mode::Normal);
1364 }
1365
1366 #[gpui::test]
1367 async fn test_search_dismiss_after_editor_focus_does_not_restore(
1368 cx: &mut gpui::TestAppContext,
1369 ) {
1370 let mut cx = VimTestContext::new(cx, true).await;
1371 cx.set_state("ˇhello world\nfoo bar\nhello again\n", Mode::Normal);
1372
1373 // Move cursor to line 2
1374 cx.simulate_keystrokes("j");
1375 cx.run_until_parked();
1376 cx.assert_state("hello world\nˇfoo bar\nhello again\n", Mode::Normal);
1377
1378 // Open search and type a query that matches line 3
1379 cx.simulate_keystrokes("/ a g a i n");
1380 cx.run_until_parked();
1381
1382 // Simulate the editor gaining focus while search is still open
1383 // This represents the user clicking in the editor
1384 cx.update_editor(|_, window, cx| cx.focus_self(window));
1385 cx.run_until_parked();
1386
1387 // Now dismiss the search bar directly
1388 cx.workspace(|workspace, window, cx| {
1389 let pane = workspace.active_pane().read(cx);
1390 if let Some(search_bar) = pane
1391 .toolbar()
1392 .read(cx)
1393 .item_of_type::<search::BufferSearchBar>()
1394 {
1395 search_bar.update(cx, |bar, cx| {
1396 bar.dismiss(&search::buffer_search::Dismiss, window, cx)
1397 });
1398 }
1399 });
1400 cx.run_until_parked();
1401
1402 // Cursor should NOT be restored to line 2 (row 1) where search was opened.
1403 // Since the user "clicked" in the editor (by focusing it), prior_selections
1404 // was cleared, so dismiss should not restore the cursor.
1405 // The cursor should be at the match location on line 3 (row 2).
1406 cx.assert_state("hello world\nfoo bar\nhello ˇagain\n", Mode::Normal);
1407 }
1408}