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