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 prior_selections = 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 prior_mode != vim.state().mode {
168 vim.switch_mode(prior_mode, true, cx);
169 }
170 if let Some(operator) = prior_operator {
171 vim.push_operator(operator, cx);
172 };
173 motion = Some(Motion::ZedSearchResult {
174 prior_selections,
175 new_selections,
176 });
177 });
178 }
179 });
180 });
181
182 if let Some(motion) = motion {
183 search_motion(motion, cx)
184 }
185}
186
187pub fn move_to_match_internal(
188 workspace: &mut Workspace,
189 direction: Direction,
190 cx: &mut ViewContext<Workspace>,
191) {
192 let mut motion = None;
193 Vim::update(cx, |vim, cx| {
194 let pane = workspace.active_pane().clone();
195 let count = vim.take_count(cx).unwrap_or(1);
196 let prior_selections = vim.editor_selections(cx);
197
198 pane.update(cx, |pane, cx| {
199 if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::<BufferSearchBar>() {
200 search_bar.update(cx, |search_bar, cx| {
201 search_bar.select_match(direction, count, cx);
202
203 let new_selections = vim.editor_selections(cx);
204 motion = Some(Motion::ZedSearchResult {
205 prior_selections,
206 new_selections,
207 });
208 })
209 }
210 })
211 });
212 if let Some(motion) = motion {
213 search_motion(motion, cx);
214 }
215}
216
217pub fn move_to_internal(
218 workspace: &mut Workspace,
219 direction: Direction,
220 whole_word: bool,
221 cx: &mut ViewContext<Workspace>,
222) {
223 Vim::update(cx, |vim, cx| {
224 let pane = workspace.active_pane().clone();
225 let count = vim.take_count(cx).unwrap_or(1);
226 let prior_selections = vim.editor_selections(cx);
227
228 pane.update(cx, |pane, cx| {
229 if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::<BufferSearchBar>() {
230 let search = search_bar.update(cx, |search_bar, cx| {
231 let options = SearchOptions::CASE_SENSITIVE | SearchOptions::REGEX;
232 if !search_bar.show(cx) {
233 return None;
234 }
235 let Some(query) = search_bar.query_suggestion(cx) else {
236 vim.clear_operator(cx);
237 let _ = search_bar.search("", None, cx);
238 return None;
239 };
240 let mut query = regex::escape(&query);
241 if whole_word {
242 query = format!(r"\<{}\>", query);
243 }
244 Some(search_bar.search(&query, Some(options), cx))
245 });
246
247 if let Some(search) = search {
248 let search_bar = search_bar.downgrade();
249 cx.spawn(|_, mut cx| async move {
250 search.await?;
251 search_bar.update(&mut cx, |search_bar, cx| {
252 search_bar.select_match(direction, count, cx);
253
254 let new_selections =
255 Vim::update(cx, |vim, cx| vim.editor_selections(cx));
256 search_motion(
257 Motion::ZedSearchResult {
258 prior_selections,
259 new_selections,
260 },
261 cx,
262 )
263 })?;
264 anyhow::Ok(())
265 })
266 .detach_and_log_err(cx);
267 }
268 }
269 });
270
271 if vim.state().mode.is_visual() {
272 vim.switch_mode(Mode::Normal, false, cx)
273 }
274 });
275}
276
277fn find_command(workspace: &mut Workspace, action: &FindCommand, cx: &mut ViewContext<Workspace>) {
278 let pane = workspace.active_pane().clone();
279 pane.update(cx, |pane, cx| {
280 if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::<BufferSearchBar>() {
281 let search = search_bar.update(cx, |search_bar, cx| {
282 if !search_bar.show(cx) {
283 return None;
284 }
285 let mut query = action.query.clone();
286 if query == "" {
287 query = search_bar.query(cx);
288 };
289
290 Some(search_bar.search(
291 &query,
292 Some(SearchOptions::CASE_SENSITIVE | SearchOptions::REGEX),
293 cx,
294 ))
295 });
296 let Some(search) = search else { return };
297 let search_bar = search_bar.downgrade();
298 let direction = if action.backwards {
299 Direction::Prev
300 } else {
301 Direction::Next
302 };
303 cx.spawn(|_, mut cx| async move {
304 search.await?;
305 search_bar.update(&mut cx, |search_bar, cx| {
306 search_bar.select_match(direction, 1, cx)
307 })?;
308 anyhow::Ok(())
309 })
310 .detach_and_log_err(cx);
311 }
312 })
313}
314
315fn replace_command(
316 workspace: &mut Workspace,
317 action: &ReplaceCommand,
318 cx: &mut ViewContext<Workspace>,
319) {
320 let replacement = parse_replace_all(&action.query);
321 let pane = workspace.active_pane().clone();
322 pane.update(cx, |pane, cx| {
323 let Some(search_bar) = pane.toolbar().read(cx).item_of_type::<BufferSearchBar>() else {
324 return;
325 };
326 let search = search_bar.update(cx, |search_bar, cx| {
327 if !search_bar.show(cx) {
328 return None;
329 }
330
331 let mut options = SearchOptions::REGEX;
332 if replacement.is_case_sensitive {
333 options.set(SearchOptions::CASE_SENSITIVE, true)
334 }
335 let search = if replacement.search == "" {
336 search_bar.query(cx)
337 } else {
338 replacement.search
339 };
340
341 search_bar.set_replacement(Some(&replacement.replacement), cx);
342 Some(search_bar.search(&search, Some(options), cx))
343 });
344 let Some(search) = search else { return };
345 let search_bar = search_bar.downgrade();
346 cx.spawn(|_, mut cx| async move {
347 search.await?;
348 search_bar.update(&mut cx, |search_bar, cx| {
349 if replacement.should_replace_all {
350 search_bar.select_last_match(cx);
351 search_bar.replace_all(&Default::default(), cx);
352 Vim::update(cx, |vim, cx| {
353 move_cursor(
354 vim,
355 Motion::StartOfLine {
356 display_lines: false,
357 },
358 None,
359 cx,
360 )
361 })
362 }
363 })?;
364 anyhow::Ok(())
365 })
366 .detach_and_log_err(cx);
367 })
368}
369
370// convert a vim query into something more usable by zed.
371// we don't attempt to fully convert between the two regex syntaxes,
372// but we do flip \( and \) to ( and ) (and vice-versa) in the pattern,
373// and convert \0..\9 to $0..$9 in the replacement so that common idioms work.
374fn parse_replace_all(query: &str) -> Replacement {
375 let mut chars = query.chars();
376 if Some('%') != chars.next() || Some('s') != chars.next() {
377 return Replacement::default();
378 }
379
380 let Some(delimiter) = chars.next() else {
381 return Replacement::default();
382 };
383
384 let mut search = String::new();
385 let mut replacement = String::new();
386 let mut flags = String::new();
387
388 let mut buffer = &mut search;
389
390 let mut escaped = false;
391 // 0 - parsing search
392 // 1 - parsing replacement
393 // 2 - parsing flags
394 let mut phase = 0;
395
396 for c in chars {
397 if escaped {
398 escaped = false;
399 if phase == 1 && c.is_digit(10) {
400 buffer.push('$')
401 // unescape escaped parens
402 } else if phase == 0 && c == '(' || c == ')' {
403 } else if c != delimiter {
404 buffer.push('\\')
405 }
406 buffer.push(c)
407 } else if c == '\\' {
408 escaped = true;
409 } else if c == delimiter {
410 if phase == 0 {
411 buffer = &mut replacement;
412 phase = 1;
413 } else if phase == 1 {
414 buffer = &mut flags;
415 phase = 2;
416 } else {
417 break;
418 }
419 } else {
420 // escape unescaped parens
421 if phase == 0 && c == '(' || c == ')' {
422 buffer.push('\\')
423 }
424 buffer.push(c)
425 }
426 }
427
428 let mut replacement = Replacement {
429 search,
430 replacement,
431 should_replace_all: true,
432 is_case_sensitive: true,
433 };
434
435 for c in flags.chars() {
436 match c {
437 'g' | 'I' => {}
438 'c' | 'n' => replacement.should_replace_all = false,
439 'i' => replacement.is_case_sensitive = false,
440 _ => {}
441 }
442 }
443
444 replacement
445}
446
447#[cfg(test)]
448mod test {
449 use editor::DisplayPoint;
450 use indoc::indoc;
451 use search::BufferSearchBar;
452
453 use crate::{
454 state::Mode,
455 test::{NeovimBackedTestContext, VimTestContext},
456 };
457
458 #[gpui::test]
459 async fn test_move_to_next(cx: &mut gpui::TestAppContext) {
460 let mut cx = VimTestContext::new(cx, true).await;
461 cx.set_state("ˇhi\nhigh\nhi\n", Mode::Normal);
462
463 cx.simulate_keystrokes(["*"]);
464 cx.run_until_parked();
465 cx.assert_state("hi\nhigh\nˇhi\n", Mode::Normal);
466
467 cx.simulate_keystrokes(["*"]);
468 cx.run_until_parked();
469 cx.assert_state("ˇhi\nhigh\nhi\n", Mode::Normal);
470
471 cx.simulate_keystrokes(["#"]);
472 cx.run_until_parked();
473 cx.assert_state("hi\nhigh\nˇhi\n", Mode::Normal);
474
475 cx.simulate_keystrokes(["#"]);
476 cx.run_until_parked();
477 cx.assert_state("ˇhi\nhigh\nhi\n", Mode::Normal);
478
479 cx.simulate_keystrokes(["2", "*"]);
480 cx.run_until_parked();
481 cx.assert_state("ˇhi\nhigh\nhi\n", Mode::Normal);
482
483 cx.simulate_keystrokes(["g", "*"]);
484 cx.run_until_parked();
485 cx.assert_state("hi\nˇhigh\nhi\n", Mode::Normal);
486
487 cx.simulate_keystrokes(["n"]);
488 cx.assert_state("hi\nhigh\nˇhi\n", Mode::Normal);
489
490 cx.simulate_keystrokes(["g", "#"]);
491 cx.run_until_parked();
492 cx.assert_state("hi\nˇhigh\nhi\n", Mode::Normal);
493 }
494
495 #[gpui::test]
496 async fn test_search(cx: &mut gpui::TestAppContext) {
497 let mut cx = VimTestContext::new(cx, true).await;
498
499 cx.set_state("aa\nbˇb\ncc\ncc\ncc\n", Mode::Normal);
500 cx.simulate_keystrokes(["/", "c", "c"]);
501
502 let search_bar = cx.workspace(|workspace, cx| {
503 workspace
504 .active_pane()
505 .read(cx)
506 .toolbar()
507 .read(cx)
508 .item_of_type::<BufferSearchBar>()
509 .expect("Buffer search bar should be deployed")
510 });
511
512 cx.update_view(search_bar, |bar, cx| {
513 assert_eq!(bar.query(cx), "cc");
514 });
515
516 cx.run_until_parked();
517
518 cx.update_editor(|editor, cx| {
519 let highlights = editor.all_text_background_highlights(cx);
520 assert_eq!(3, highlights.len());
521 assert_eq!(
522 DisplayPoint::new(2, 0)..DisplayPoint::new(2, 2),
523 highlights[0].0
524 )
525 });
526
527 cx.simulate_keystrokes(["enter"]);
528 cx.assert_state("aa\nbb\nˇcc\ncc\ncc\n", Mode::Normal);
529
530 // n to go to next/N to go to previous
531 cx.simulate_keystrokes(["n"]);
532 cx.assert_state("aa\nbb\ncc\nˇcc\ncc\n", Mode::Normal);
533 cx.simulate_keystrokes(["shift-n"]);
534 cx.assert_state("aa\nbb\nˇcc\ncc\ncc\n", Mode::Normal);
535
536 // ?<enter> to go to previous
537 cx.simulate_keystrokes(["?", "enter"]);
538 cx.assert_state("aa\nbb\ncc\ncc\nˇcc\n", Mode::Normal);
539 cx.simulate_keystrokes(["?", "enter"]);
540 cx.assert_state("aa\nbb\ncc\nˇcc\ncc\n", Mode::Normal);
541
542 // /<enter> to go to next
543 cx.simulate_keystrokes(["/", "enter"]);
544 cx.assert_state("aa\nbb\ncc\ncc\nˇcc\n", Mode::Normal);
545
546 // ?{search}<enter> to search backwards
547 cx.simulate_keystrokes(["?", "b", "enter"]);
548 cx.assert_state("aa\nbˇb\ncc\ncc\ncc\n", Mode::Normal);
549
550 // works with counts
551 cx.simulate_keystrokes(["4", "/", "c"]);
552 cx.simulate_keystrokes(["enter"]);
553 cx.assert_state("aa\nbb\ncc\ncˇc\ncc\n", Mode::Normal);
554
555 // check that searching resumes from cursor, not previous match
556 cx.set_state("ˇaa\nbb\ndd\ncc\nbb\n", Mode::Normal);
557 cx.simulate_keystrokes(["/", "d"]);
558 cx.simulate_keystrokes(["enter"]);
559 cx.assert_state("aa\nbb\nˇdd\ncc\nbb\n", Mode::Normal);
560 cx.update_editor(|editor, cx| editor.move_to_beginning(&Default::default(), cx));
561 cx.assert_state("ˇaa\nbb\ndd\ncc\nbb\n", Mode::Normal);
562 cx.simulate_keystrokes(["/", "b"]);
563 cx.simulate_keystrokes(["enter"]);
564 cx.assert_state("aa\nˇbb\ndd\ncc\nbb\n", Mode::Normal);
565
566 // check that searching switches to normal mode if in visual mode
567 cx.set_state("ˇone two one", Mode::Normal);
568 cx.simulate_keystrokes(["v", "l", "l"]);
569 cx.assert_editor_state("«oneˇ» two one");
570 cx.simulate_keystrokes(["*"]);
571 cx.assert_state("one two ˇone", Mode::Normal);
572 }
573
574 #[gpui::test]
575 async fn test_non_vim_search(cx: &mut gpui::TestAppContext) {
576 let mut cx = VimTestContext::new(cx, false).await;
577 cx.set_state("ˇone one one one", Mode::Normal);
578 cx.simulate_keystrokes(["cmd-f"]);
579 cx.run_until_parked();
580
581 cx.assert_editor_state("«oneˇ» one one one");
582 cx.simulate_keystrokes(["enter"]);
583 cx.assert_editor_state("one «oneˇ» one one");
584 cx.simulate_keystrokes(["shift-enter"]);
585 cx.assert_editor_state("«oneˇ» one one one");
586 }
587
588 #[gpui::test]
589 async fn test_visual_star_hash(cx: &mut gpui::TestAppContext) {
590 let mut cx = NeovimBackedTestContext::new(cx).await;
591
592 cx.set_shared_state("ˇa.c. abcd a.c. abcd").await;
593 cx.simulate_shared_keystrokes(["v", "3", "l", "*"]).await;
594 cx.assert_shared_state("a.c. abcd ˇa.c. abcd").await;
595 cx.assert_shared_mode(Mode::Normal).await;
596 }
597
598 #[gpui::test]
599 async fn test_d_search(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(["d", "/", "c", "d"]).await;
604 cx.simulate_shared_keystrokes(["enter"]).await;
605 cx.assert_shared_state("ˇcd a.c. abcd").await;
606 }
607
608 #[gpui::test]
609 async fn test_v_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(["v", "/", "c", "d"]).await;
614 cx.simulate_shared_keystrokes(["enter"]).await;
615 cx.assert_shared_state("«a.c. abcˇ»d a.c. abcd").await;
616
617 cx.set_shared_state("a a aˇ a a a").await;
618 cx.simulate_shared_keystrokes(["v", "/", "a"]).await;
619 cx.simulate_shared_keystrokes(["enter"]).await;
620 cx.assert_shared_state("a a a« aˇ» a a").await;
621 cx.simulate_shared_keystrokes(["/", "enter"]).await;
622 cx.assert_shared_state("a a a« a aˇ» a").await;
623 cx.simulate_shared_keystrokes(["?", "enter"]).await;
624 cx.assert_shared_state("a a a« aˇ» a a").await;
625 cx.simulate_shared_keystrokes(["?", "enter"]).await;
626 cx.assert_shared_state("a a «ˇa »a a a").await;
627 cx.simulate_shared_keystrokes(["/", "enter"]).await;
628 cx.assert_shared_state("a a a« aˇ» a a").await;
629 cx.simulate_shared_keystrokes(["/", "enter"]).await;
630 cx.assert_shared_state("a a a« a aˇ» a").await;
631 }
632
633 #[gpui::test]
634 async fn test_visual_block_search(cx: &mut gpui::TestAppContext) {
635 let mut cx = NeovimBackedTestContext::new(cx).await;
636
637 cx.set_shared_state(indoc! {
638 "ˇone two
639 three four
640 five six
641 "
642 })
643 .await;
644 cx.simulate_shared_keystrokes(["ctrl-v", "j", "/", "f"])
645 .await;
646 cx.simulate_shared_keystrokes(["enter"]).await;
647 cx.assert_shared_state(indoc! {
648 "«one twoˇ»
649 «three fˇ»our
650 five six
651 "
652 })
653 .await;
654 }
655}