search.rs

  1use gpui::{actions, impl_actions, AppContext, ViewContext};
  2use search::{buffer_search, BufferSearchBar, SearchMode, SearchOptions};
  3use serde_derive::Deserialize;
  4use workspace::{searchable::Direction, Pane, 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
 47impl_actions!(
 48    vim,
 49    [MoveToNext, MoveToPrev, Search, FindCommand, ReplaceCommand]
 50);
 51actions!(vim, [SearchSubmit]);
 52
 53pub(crate) fn init(cx: &mut AppContext) {
 54    cx.add_action(move_to_next);
 55    cx.add_action(move_to_prev);
 56    cx.add_action(search);
 57    cx.add_action(search_submit);
 58    cx.add_action(search_deploy);
 59
 60    cx.add_action(find_command);
 61    cx.add_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 Pane, _: &buffer_search::Deploy, cx: &mut ViewContext<Pane>) {
110    Vim::update(cx, |vim, _| vim.workspace_state.search = Default::default());
111    cx.propagate_action();
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            cx.spawn(|_, mut cx| async move {
199                search.await?;
200                search_bar.update(&mut cx, |search_bar, cx| {
201                    search_bar.select_match(Direction::Next, 1, cx)
202                })?;
203                anyhow::Ok(())
204            })
205            .detach_and_log_err(cx);
206        }
207    })
208}
209
210fn replace_command(
211    workspace: &mut Workspace,
212    action: &ReplaceCommand,
213    cx: &mut ViewContext<Workspace>,
214) {
215    let replacement = parse_replace_all(&action.query);
216    let pane = workspace.active_pane().clone();
217    pane.update(cx, |pane, cx| {
218        if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::<BufferSearchBar>() {
219            let search = search_bar.update(cx, |search_bar, cx| {
220                if !search_bar.show(cx) {
221                    return None;
222                }
223
224                let mut options = SearchOptions::default();
225                if replacement.is_case_sensitive {
226                    options.set(SearchOptions::CASE_SENSITIVE, true)
227                }
228                let search = if replacement.search == "" {
229                    search_bar.query(cx)
230                } else {
231                    replacement.search
232                };
233
234                search_bar.set_replacement(Some(&replacement.replacement), cx);
235                search_bar.activate_search_mode(SearchMode::Regex, cx);
236                Some(search_bar.search(&search, Some(options), cx))
237            });
238            let Some(search) = search else { return };
239            let search_bar = search_bar.downgrade();
240            cx.spawn(|_, mut cx| async move {
241                search.await?;
242                search_bar.update(&mut cx, |search_bar, cx| {
243                    if replacement.should_replace_all {
244                        search_bar.select_last_match(cx);
245                        search_bar.replace_all(&Default::default(), cx);
246                        Vim::update(cx, |vim, cx| {
247                            move_cursor(
248                                vim,
249                                Motion::StartOfLine {
250                                    display_lines: false,
251                                },
252                                None,
253                                cx,
254                            )
255                        })
256                    }
257                })?;
258                anyhow::Ok(())
259            })
260            .detach_and_log_err(cx);
261        }
262    })
263}
264
265fn parse_replace_all(query: &str) -> Replacement {
266    let mut chars = query.chars();
267    if Some('%') != chars.next() || Some('s') != chars.next() {
268        return Replacement::default();
269    }
270
271    let Some(delimeter) = chars.next() else {
272        return Replacement::default();
273    };
274
275    let mut search = String::new();
276    let mut replacement = String::new();
277    let mut flags = String::new();
278
279    let mut buffer = &mut search;
280
281    let mut escaped = false;
282    let mut phase = 0;
283
284    for c in chars {
285        if escaped {
286            escaped = false;
287            if phase == 1 && c.is_digit(10) {
288                // help vim users discover zed regex syntax
289                // (though we don't try and fix arbitrary patterns for them)
290                buffer.push('$')
291            } else if phase == 0 && c == '(' || c == ')' {
292                // un-escape parens
293            } else if c != delimeter {
294                buffer.push('\\')
295            }
296            buffer.push(c)
297        } else if c == '\\' {
298            escaped = true;
299        } else if c == delimeter {
300            if phase == 0 {
301                buffer = &mut replacement;
302                phase = 1;
303            } else if phase == 1 {
304                buffer = &mut flags;
305                phase = 2;
306            } else {
307                break;
308            }
309        } else {
310            buffer.push(c)
311        }
312    }
313
314    let mut replacement = Replacement {
315        search,
316        replacement,
317        should_replace_all: true,
318        is_case_sensitive: true,
319    };
320
321    for c in flags.chars() {
322        match c {
323            'g' | 'I' => {}
324            'c' | 'n' => replacement.should_replace_all = false,
325            'i' => replacement.is_case_sensitive = false,
326            _ => {}
327        }
328    }
329
330    replacement
331}
332
333#[cfg(test)]
334mod test {
335    use std::sync::Arc;
336
337    use editor::DisplayPoint;
338    use search::BufferSearchBar;
339
340    use crate::{state::Mode, test::VimTestContext};
341
342    #[gpui::test]
343    async fn test_move_to_next(
344        cx: &mut gpui::TestAppContext,
345        deterministic: Arc<gpui::executor::Deterministic>,
346    ) {
347        let mut cx = VimTestContext::new(cx, true).await;
348        cx.set_state("ˇhi\nhigh\nhi\n", Mode::Normal);
349
350        cx.simulate_keystrokes(["*"]);
351        deterministic.run_until_parked();
352        cx.assert_state("hi\nhigh\nˇhi\n", Mode::Normal);
353
354        cx.simulate_keystrokes(["*"]);
355        deterministic.run_until_parked();
356        cx.assert_state("ˇhi\nhigh\nhi\n", Mode::Normal);
357
358        cx.simulate_keystrokes(["#"]);
359        deterministic.run_until_parked();
360        cx.assert_state("hi\nhigh\nˇhi\n", Mode::Normal);
361
362        cx.simulate_keystrokes(["#"]);
363        deterministic.run_until_parked();
364        cx.assert_state("ˇhi\nhigh\nhi\n", Mode::Normal);
365
366        cx.simulate_keystrokes(["2", "*"]);
367        deterministic.run_until_parked();
368        cx.assert_state("ˇhi\nhigh\nhi\n", Mode::Normal);
369
370        cx.simulate_keystrokes(["g", "*"]);
371        deterministic.run_until_parked();
372        cx.assert_state("hi\nˇhigh\nhi\n", Mode::Normal);
373
374        cx.simulate_keystrokes(["n"]);
375        cx.assert_state("hi\nhigh\nˇhi\n", Mode::Normal);
376
377        cx.simulate_keystrokes(["g", "#"]);
378        deterministic.run_until_parked();
379        cx.assert_state("hi\nˇhigh\nhi\n", Mode::Normal);
380    }
381
382    #[gpui::test]
383    async fn test_search(
384        cx: &mut gpui::TestAppContext,
385        deterministic: Arc<gpui::executor::Deterministic>,
386    ) {
387        let mut cx = VimTestContext::new(cx, true).await;
388
389        cx.set_state("aa\nbˇb\ncc\ncc\ncc\n", Mode::Normal);
390        cx.simulate_keystrokes(["/", "c", "c"]);
391
392        let search_bar = cx.workspace(|workspace, cx| {
393            workspace
394                .active_pane()
395                .read(cx)
396                .toolbar()
397                .read(cx)
398                .item_of_type::<BufferSearchBar>()
399                .expect("Buffer search bar should be deployed")
400        });
401
402        search_bar.read_with(cx.cx, |bar, cx| {
403            assert_eq!(bar.query(cx), "cc");
404        });
405
406        deterministic.run_until_parked();
407
408        cx.update_editor(|editor, cx| {
409            let highlights = editor.all_text_background_highlights(cx);
410            assert_eq!(3, highlights.len());
411            assert_eq!(
412                DisplayPoint::new(2, 0)..DisplayPoint::new(2, 2),
413                highlights[0].0
414            )
415        });
416
417        cx.simulate_keystrokes(["enter"]);
418        cx.assert_state("aa\nbb\nˇcc\ncc\ncc\n", Mode::Normal);
419
420        // n to go to next/N to go to previous
421        cx.simulate_keystrokes(["n"]);
422        cx.assert_state("aa\nbb\ncc\nˇcc\ncc\n", Mode::Normal);
423        cx.simulate_keystrokes(["shift-n"]);
424        cx.assert_state("aa\nbb\nˇcc\ncc\ncc\n", Mode::Normal);
425
426        // ?<enter> to go to previous
427        cx.simulate_keystrokes(["?", "enter"]);
428        deterministic.run_until_parked();
429        cx.assert_state("aa\nbb\ncc\ncc\nˇcc\n", Mode::Normal);
430        cx.simulate_keystrokes(["?", "enter"]);
431        deterministic.run_until_parked();
432        cx.assert_state("aa\nbb\ncc\nˇcc\ncc\n", Mode::Normal);
433
434        // /<enter> to go to next
435        cx.simulate_keystrokes(["/", "enter"]);
436        deterministic.run_until_parked();
437        cx.assert_state("aa\nbb\ncc\ncc\nˇcc\n", Mode::Normal);
438
439        // ?{search}<enter> to search backwards
440        cx.simulate_keystrokes(["?", "b", "enter"]);
441        deterministic.run_until_parked();
442        cx.assert_state("aa\nbˇb\ncc\ncc\ncc\n", Mode::Normal);
443
444        // works with counts
445        cx.simulate_keystrokes(["4", "/", "c"]);
446        deterministic.run_until_parked();
447        cx.simulate_keystrokes(["enter"]);
448        cx.assert_state("aa\nbb\ncc\ncˇc\ncc\n", Mode::Normal);
449
450        // check that searching resumes from cursor, not previous match
451        cx.set_state("ˇaa\nbb\ndd\ncc\nbb\n", Mode::Normal);
452        cx.simulate_keystrokes(["/", "d"]);
453        deterministic.run_until_parked();
454        cx.simulate_keystrokes(["enter"]);
455        cx.assert_state("aa\nbb\nˇdd\ncc\nbb\n", Mode::Normal);
456        cx.update_editor(|editor, cx| editor.move_to_beginning(&Default::default(), cx));
457        cx.assert_state("ˇaa\nbb\ndd\ncc\nbb\n", Mode::Normal);
458        cx.simulate_keystrokes(["/", "b"]);
459        deterministic.run_until_parked();
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(
466        cx: &mut gpui::TestAppContext,
467        deterministic: Arc<gpui::executor::Deterministic>,
468    ) {
469        let mut cx = VimTestContext::new(cx, false).await;
470        cx.set_state("ˇone one one one", Mode::Normal);
471        cx.simulate_keystrokes(["cmd-f"]);
472        deterministic.run_until_parked();
473
474        cx.assert_editor_state("«oneˇ» one one one");
475        cx.simulate_keystrokes(["enter"]);
476        cx.assert_editor_state("one «oneˇ» one one");
477        cx.simulate_keystrokes(["shift-enter"]);
478        cx.assert_editor_state("«oneˇ» one one one");
479    }
480}