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