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