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::{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}