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