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