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