search.rs

  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}