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::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]);
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(search);
62 workspace.register_action(search_submit);
63 workspace.register_action(search_deploy);
64
65 workspace.register_action(find_command);
66 workspace.register_action(replace_command);
67}
68
69fn move_to_next(workspace: &mut Workspace, action: &MoveToNext, cx: &mut ViewContext<Workspace>) {
70 move_to_internal(workspace, Direction::Next, !action.partial_word, cx)
71}
72
73fn move_to_prev(workspace: &mut Workspace, action: &MoveToPrev, cx: &mut ViewContext<Workspace>) {
74 move_to_internal(workspace, Direction::Prev, !action.partial_word, cx)
75}
76
77fn search(workspace: &mut Workspace, action: &Search, cx: &mut ViewContext<Workspace>) {
78 let pane = workspace.active_pane().clone();
79 let direction = if action.backwards {
80 Direction::Prev
81 } else {
82 Direction::Next
83 };
84 Vim::update(cx, |vim, cx| {
85 let count = vim.take_count(cx).unwrap_or(1);
86 pane.update(cx, |pane, cx| {
87 if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::<BufferSearchBar>() {
88 search_bar.update(cx, |search_bar, cx| {
89 if !search_bar.show(cx) {
90 return;
91 }
92 let query = search_bar.query(cx);
93
94 search_bar.select_query(cx);
95 cx.focus_self();
96
97 if query.is_empty() {
98 search_bar.set_replacement(None, cx);
99 search_bar.activate_search_mode(SearchMode::Regex, cx);
100 }
101 vim.workspace_state.search = SearchState {
102 direction,
103 count,
104 initial_query: query.clone(),
105 };
106 });
107 }
108 })
109 })
110}
111
112// hook into the existing to clear out any vim search state on cmd+f or edit -> find.
113fn search_deploy(_: &mut Workspace, _: &buffer_search::Deploy, cx: &mut ViewContext<Workspace>) {
114 Vim::update(cx, |vim, _| vim.workspace_state.search = Default::default());
115 cx.propagate();
116}
117
118fn search_submit(workspace: &mut Workspace, _: &SearchSubmit, cx: &mut ViewContext<Workspace>) {
119 Vim::update(cx, |vim, cx| {
120 let pane = workspace.active_pane().clone();
121 pane.update(cx, |pane, cx| {
122 if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::<BufferSearchBar>() {
123 search_bar.update(cx, |search_bar, cx| {
124 let state = &mut vim.workspace_state.search;
125 let mut count = state.count;
126 let direction = state.direction;
127
128 // in the case that the query has changed, the search bar
129 // will have selected the next match already.
130 if (search_bar.query(cx) != state.initial_query)
131 && state.direction == Direction::Next
132 {
133 count = count.saturating_sub(1)
134 }
135 state.count = 1;
136 search_bar.select_match(direction, count, cx);
137 search_bar.focus_editor(&Default::default(), cx);
138 });
139 }
140 });
141 })
142}
143
144pub fn move_to_internal(
145 workspace: &mut Workspace,
146 direction: Direction,
147 whole_word: bool,
148 cx: &mut ViewContext<Workspace>,
149) {
150 Vim::update(cx, |vim, cx| {
151 let pane = workspace.active_pane().clone();
152 let count = vim.take_count(cx).unwrap_or(1);
153
154 pane.update(cx, |pane, cx| {
155 if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::<BufferSearchBar>() {
156 let search = search_bar.update(cx, |search_bar, cx| {
157 let options = SearchOptions::CASE_SENSITIVE;
158 if !search_bar.show(cx) {
159 return None;
160 }
161 let Some(query) = search_bar.query_suggestion(cx) else {
162 return None;
163 };
164 let mut query = regex::escape(&query);
165 if whole_word {
166 query = format!(r"\b{}\b", query);
167 }
168 search_bar.activate_search_mode(SearchMode::Regex, cx);
169 Some(search_bar.search(&query, Some(options), cx))
170 });
171
172 if let Some(search) = search {
173 let search_bar = search_bar.downgrade();
174 cx.spawn(|_, mut cx| async move {
175 search.await?;
176 search_bar.update(&mut cx, |search_bar, cx| {
177 search_bar.select_match(direction, count, cx)
178 })?;
179 anyhow::Ok(())
180 })
181 .detach_and_log_err(cx);
182 }
183 }
184 });
185
186 if vim.state().mode.is_visual() {
187 vim.switch_mode(Mode::Normal, false, cx)
188 }
189
190 vim.clear_operator(cx);
191 });
192}
193
194fn find_command(workspace: &mut Workspace, action: &FindCommand, cx: &mut ViewContext<Workspace>) {
195 let pane = workspace.active_pane().clone();
196 pane.update(cx, |pane, cx| {
197 if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::<BufferSearchBar>() {
198 let search = search_bar.update(cx, |search_bar, cx| {
199 if !search_bar.show(cx) {
200 return None;
201 }
202 let mut query = action.query.clone();
203 if query == "" {
204 query = search_bar.query(cx);
205 };
206
207 search_bar.activate_search_mode(SearchMode::Regex, cx);
208 Some(search_bar.search(&query, Some(SearchOptions::CASE_SENSITIVE), cx))
209 });
210 let Some(search) = search else { return };
211 let search_bar = search_bar.downgrade();
212 let direction = if action.backwards {
213 Direction::Prev
214 } else {
215 Direction::Next
216 };
217 cx.spawn(|_, mut cx| async move {
218 search.await?;
219 search_bar.update(&mut cx, |search_bar, cx| {
220 search_bar.select_match(direction, 1, cx)
221 })?;
222 anyhow::Ok(())
223 })
224 .detach_and_log_err(cx);
225 }
226 })
227}
228
229fn replace_command(
230 workspace: &mut Workspace,
231 action: &ReplaceCommand,
232 cx: &mut ViewContext<Workspace>,
233) {
234 let replacement = parse_replace_all(&action.query);
235 let pane = workspace.active_pane().clone();
236 pane.update(cx, |pane, cx| {
237 let Some(search_bar) = pane.toolbar().read(cx).item_of_type::<BufferSearchBar>() else {
238 return;
239 };
240 let search = search_bar.update(cx, |search_bar, cx| {
241 if !search_bar.show(cx) {
242 return None;
243 }
244
245 let mut options = SearchOptions::default();
246 if replacement.is_case_sensitive {
247 options.set(SearchOptions::CASE_SENSITIVE, true)
248 }
249 let search = if replacement.search == "" {
250 search_bar.query(cx)
251 } else {
252 replacement.search
253 };
254
255 search_bar.set_replacement(Some(&replacement.replacement), cx);
256 search_bar.activate_search_mode(SearchMode::Regex, cx);
257 Some(search_bar.search(&search, Some(options), cx))
258 });
259 let Some(search) = search else { return };
260 let search_bar = search_bar.downgrade();
261 cx.spawn(|_, mut cx| async move {
262 search.await?;
263 search_bar.update(&mut cx, |search_bar, cx| {
264 if replacement.should_replace_all {
265 search_bar.select_last_match(cx);
266 search_bar.replace_all(&Default::default(), cx);
267 Vim::update(cx, |vim, cx| {
268 move_cursor(
269 vim,
270 Motion::StartOfLine {
271 display_lines: false,
272 },
273 None,
274 cx,
275 )
276 })
277 }
278 })?;
279 anyhow::Ok(())
280 })
281 .detach_and_log_err(cx);
282 })
283}
284
285// convert a vim query into something more usable by zed.
286// we don't attempt to fully convert between the two regex syntaxes,
287// but we do flip \( and \) to ( and ) (and vice-versa) in the pattern,
288// and convert \0..\9 to $0..$9 in the replacement so that common idioms work.
289fn parse_replace_all(query: &str) -> Replacement {
290 let mut chars = query.chars();
291 if Some('%') != chars.next() || Some('s') != chars.next() {
292 return Replacement::default();
293 }
294
295 let Some(delimiter) = chars.next() else {
296 return Replacement::default();
297 };
298
299 let mut search = String::new();
300 let mut replacement = String::new();
301 let mut flags = String::new();
302
303 let mut buffer = &mut search;
304
305 let mut escaped = false;
306 // 0 - parsing search
307 // 1 - parsing replacement
308 // 2 - parsing flags
309 let mut phase = 0;
310
311 for c in chars {
312 if escaped {
313 escaped = false;
314 if phase == 1 && c.is_digit(10) {
315 buffer.push('$')
316 // unescape escaped parens
317 } else if phase == 0 && c == '(' || c == ')' {
318 } else if c != delimiter {
319 buffer.push('\\')
320 }
321 buffer.push(c)
322 } else if c == '\\' {
323 escaped = true;
324 } else if c == delimiter {
325 if phase == 0 {
326 buffer = &mut replacement;
327 phase = 1;
328 } else if phase == 1 {
329 buffer = &mut flags;
330 phase = 2;
331 } else {
332 break;
333 }
334 } else {
335 // escape unescaped parens
336 if phase == 0 && c == '(' || c == ')' {
337 buffer.push('\\')
338 }
339 buffer.push(c)
340 }
341 }
342
343 let mut replacement = Replacement {
344 search,
345 replacement,
346 should_replace_all: true,
347 is_case_sensitive: true,
348 };
349
350 for c in flags.chars() {
351 match c {
352 'g' | 'I' => {}
353 'c' | 'n' => replacement.should_replace_all = false,
354 'i' => replacement.is_case_sensitive = false,
355 _ => {}
356 }
357 }
358
359 replacement
360}
361
362#[cfg(test)]
363mod test {
364 use editor::DisplayPoint;
365 use search::BufferSearchBar;
366
367 use crate::{
368 state::Mode,
369 test::{NeovimBackedTestContext, VimTestContext},
370 };
371
372 #[gpui::test]
373 async fn test_move_to_next(cx: &mut gpui::TestAppContext) {
374 let mut cx = VimTestContext::new(cx, true).await;
375 cx.set_state("ˇhi\nhigh\nhi\n", Mode::Normal);
376
377 cx.simulate_keystrokes(["*"]);
378 cx.run_until_parked();
379 cx.assert_state("hi\nhigh\nˇhi\n", Mode::Normal);
380
381 cx.simulate_keystrokes(["*"]);
382 cx.run_until_parked();
383 cx.assert_state("ˇhi\nhigh\nhi\n", Mode::Normal);
384
385 cx.simulate_keystrokes(["#"]);
386 cx.run_until_parked();
387 cx.assert_state("hi\nhigh\nˇhi\n", Mode::Normal);
388
389 cx.simulate_keystrokes(["#"]);
390 cx.run_until_parked();
391 cx.assert_state("ˇhi\nhigh\nhi\n", Mode::Normal);
392
393 cx.simulate_keystrokes(["2", "*"]);
394 cx.run_until_parked();
395 cx.assert_state("ˇhi\nhigh\nhi\n", Mode::Normal);
396
397 cx.simulate_keystrokes(["g", "*"]);
398 cx.run_until_parked();
399 cx.assert_state("hi\nˇhigh\nhi\n", Mode::Normal);
400
401 cx.simulate_keystrokes(["n"]);
402 cx.assert_state("hi\nhigh\nˇhi\n", Mode::Normal);
403
404 cx.simulate_keystrokes(["g", "#"]);
405 cx.run_until_parked();
406 cx.assert_state("hi\nˇhigh\nhi\n", Mode::Normal);
407 }
408
409 #[gpui::test]
410 async fn test_search(cx: &mut gpui::TestAppContext) {
411 let mut cx = VimTestContext::new(cx, true).await;
412
413 cx.set_state("aa\nbˇb\ncc\ncc\ncc\n", Mode::Normal);
414 cx.simulate_keystrokes(["/", "c", "c"]);
415
416 let search_bar = cx.workspace(|workspace, cx| {
417 workspace
418 .active_pane()
419 .read(cx)
420 .toolbar()
421 .read(cx)
422 .item_of_type::<BufferSearchBar>()
423 .expect("Buffer search bar should be deployed")
424 });
425
426 cx.update_view(search_bar, |bar, cx| {
427 assert_eq!(bar.query(cx), "cc");
428 });
429
430 cx.run_until_parked();
431
432 cx.update_editor(|editor, cx| {
433 let highlights = editor.all_text_background_highlights(cx);
434 assert_eq!(3, highlights.len());
435 assert_eq!(
436 DisplayPoint::new(2, 0)..DisplayPoint::new(2, 2),
437 highlights[0].0
438 )
439 });
440
441 cx.simulate_keystrokes(["enter"]);
442 cx.assert_state("aa\nbb\nˇcc\ncc\ncc\n", Mode::Normal);
443
444 // n to go to next/N to go to previous
445 cx.simulate_keystrokes(["n"]);
446 cx.assert_state("aa\nbb\ncc\nˇcc\ncc\n", Mode::Normal);
447 cx.simulate_keystrokes(["shift-n"]);
448 cx.assert_state("aa\nbb\nˇcc\ncc\ncc\n", Mode::Normal);
449
450 // ?<enter> to go to previous
451 cx.simulate_keystrokes(["?", "enter"]);
452 cx.assert_state("aa\nbb\ncc\ncc\nˇcc\n", Mode::Normal);
453 cx.simulate_keystrokes(["?", "enter"]);
454 cx.assert_state("aa\nbb\ncc\nˇcc\ncc\n", Mode::Normal);
455
456 // /<enter> to go to next
457 cx.simulate_keystrokes(["/", "enter"]);
458 cx.assert_state("aa\nbb\ncc\ncc\nˇcc\n", Mode::Normal);
459
460 // ?{search}<enter> to search backwards
461 cx.simulate_keystrokes(["?", "b", "enter"]);
462 cx.assert_state("aa\nbˇb\ncc\ncc\ncc\n", Mode::Normal);
463
464 // works with counts
465 cx.simulate_keystrokes(["4", "/", "c"]);
466 cx.simulate_keystrokes(["enter"]);
467 cx.assert_state("aa\nbb\ncc\ncˇc\ncc\n", Mode::Normal);
468
469 // check that searching resumes from cursor, not previous match
470 cx.set_state("ˇaa\nbb\ndd\ncc\nbb\n", Mode::Normal);
471 cx.simulate_keystrokes(["/", "d"]);
472 cx.simulate_keystrokes(["enter"]);
473 cx.assert_state("aa\nbb\nˇdd\ncc\nbb\n", Mode::Normal);
474 cx.update_editor(|editor, cx| editor.move_to_beginning(&Default::default(), cx));
475 cx.assert_state("ˇaa\nbb\ndd\ncc\nbb\n", Mode::Normal);
476 cx.simulate_keystrokes(["/", "b"]);
477 cx.simulate_keystrokes(["enter"]);
478 cx.assert_state("aa\nˇbb\ndd\ncc\nbb\n", Mode::Normal);
479
480 // check that searching switches to normal mode if in visual mode
481 cx.set_state("ˇone two one", Mode::Normal);
482 cx.simulate_keystrokes(["v", "l", "l"]);
483 cx.assert_editor_state("«oneˇ» two one");
484 cx.simulate_keystrokes(["*"]);
485 cx.assert_state("one two ˇone", Mode::Normal);
486 }
487
488 #[gpui::test]
489 async fn test_non_vim_search(cx: &mut gpui::TestAppContext) {
490 let mut cx = VimTestContext::new(cx, false).await;
491 cx.set_state("ˇone one one one", Mode::Normal);
492 cx.simulate_keystrokes(["cmd-f"]);
493 cx.run_until_parked();
494
495 cx.assert_editor_state("«oneˇ» one one one");
496 cx.simulate_keystrokes(["enter"]);
497 cx.assert_editor_state("one «oneˇ» one one");
498 cx.simulate_keystrokes(["shift-enter"]);
499 cx.assert_editor_state("«oneˇ» one one one");
500 }
501
502 #[gpui::test]
503 async fn test_visual_star_hash(cx: &mut gpui::TestAppContext) {
504 let mut cx = NeovimBackedTestContext::new(cx).await;
505
506 cx.set_shared_state("ˇa.c. abcd a.c. abcd").await;
507 cx.simulate_shared_keystrokes(["v", "3", "l", "*"]).await;
508 cx.assert_shared_state("a.c. abcd ˇa.c. abcd").await;
509 cx.assert_shared_mode(Mode::Normal).await;
510 }
511}