search: Treat non-word char as whole-char when searching (#19152)

CharlesChen0823 created

when search somethings like `clone(`, with search options `match case
sensitively` and `match whole words` in zed code base, only `clone(cx)`
hit match, `clone()` will not hit math.

Release Notes:

- Improved buffer search for queries ending with non-letter characters

Change summary

crates/project/src/search.rs       | 26 ++++++++--
crates/search/src/buffer_search.rs | 80 ++++++++++++++++++++++++++++++++
2 files changed, 101 insertions(+), 5 deletions(-)

Detailed changes

crates/project/src/search.rs 🔗

@@ -3,14 +3,14 @@ use anyhow::Result;
 use client::proto;
 use fancy_regex::{Captures, Regex, RegexBuilder};
 use gpui::Model;
-use language::{Buffer, BufferSnapshot};
+use language::{Buffer, BufferSnapshot, CharKind};
 use smol::future::yield_now;
 use std::{
     borrow::Cow,
     io::{BufRead, BufReader, Read},
     ops::Range,
     path::Path,
-    sync::{Arc, OnceLock},
+    sync::{Arc, LazyLock, OnceLock},
 };
 use text::Anchor;
 use util::paths::PathMatcher;
@@ -76,6 +76,12 @@ pub enum SearchQuery {
     },
 }
 
+static WORD_MATCH_TEST: LazyLock<Regex> = LazyLock::new(|| {
+    RegexBuilder::new(r"\B")
+        .build()
+        .expect("Failed to create WORD_MATCH_TEST")
+});
+
 impl SearchQuery {
     pub fn text(
         query: impl ToString,
@@ -119,9 +125,17 @@ impl SearchQuery {
         let initial_query = Arc::from(query.as_str());
         if whole_word {
             let mut word_query = String::new();
-            word_query.push_str("\\b");
+            if let Some(first) = query.get(0..1) {
+                if WORD_MATCH_TEST.is_match(first).is_ok_and(|x| !x) {
+                    word_query.push_str("\\b");
+                }
+            }
             word_query.push_str(&query);
-            word_query.push_str("\\b");
+            if let Some(last) = query.get(query.len() - 1..) {
+                if WORD_MATCH_TEST.is_match(last).is_ok_and(|x| !x) {
+                    word_query.push_str("\\b");
+                }
+            }
             query = word_query
         }
 
@@ -313,7 +327,9 @@ impl SearchQuery {
                         let end_kind =
                             classifier.kind(rope.reversed_chars_at(mat.end()).next().unwrap());
                         let next_kind = rope.chars_at(mat.end()).next().map(|c| classifier.kind(c));
-                        if Some(start_kind) == prev_kind || Some(end_kind) == next_kind {
+                        if (Some(start_kind) == prev_kind && start_kind == CharKind::Word)
+                            || (Some(end_kind) == next_kind && end_kind == CharKind::Word)
+                        {
                             continue;
                         }
                     }

crates/search/src/buffer_search.rs 🔗

@@ -1866,6 +1866,86 @@ mod tests {
             .unwrap();
     }
 
+    #[gpui::test]
+    async fn test_search_query_with_match_whole_word(cx: &mut TestAppContext) {
+        init_globals(cx);
+        let buffer_text = r#"
+        self.buffer.update(cx, |buffer, cx| {
+            buffer.edit(
+                edits,
+                Some(AutoindentMode::Block {
+                    original_indent_columns,
+                }),
+                cx,
+            )
+        });
+
+        this.buffer.update(cx, |buffer, cx| {
+            buffer.edit([(end_of_line..start_of_next_line, replace)], None, cx)
+        });
+        "#
+        .unindent();
+        let buffer = cx.new_model(|cx| Buffer::local(buffer_text, cx));
+        let cx = cx.add_empty_window();
+
+        let editor = cx.new_view(|cx| Editor::for_buffer(buffer.clone(), None, cx));
+
+        let search_bar = cx.new_view(|cx| {
+            let mut search_bar = BufferSearchBar::new(cx);
+            search_bar.set_active_pane_item(Some(&editor), cx);
+            search_bar.show(cx);
+            search_bar
+        });
+
+        search_bar
+            .update(cx, |search_bar, cx| {
+                search_bar.search(
+                    "edit\\(",
+                    Some(SearchOptions::WHOLE_WORD | SearchOptions::REGEX),
+                    cx,
+                )
+            })
+            .await
+            .unwrap();
+
+        search_bar.update(cx, |search_bar, cx| {
+            search_bar.select_all_matches(&SelectAllMatches, cx);
+        });
+        search_bar.update(cx, |_, cx| {
+            let all_selections =
+                editor.update(cx, |editor, cx| editor.selections.display_ranges(cx));
+            assert_eq!(
+                all_selections.len(),
+                2,
+                "Should select all `edit(` in the buffer, but got: {all_selections:?}"
+            );
+        });
+
+        search_bar
+            .update(cx, |search_bar, cx| {
+                search_bar.search(
+                    "edit(",
+                    Some(SearchOptions::WHOLE_WORD | SearchOptions::CASE_SENSITIVE),
+                    cx,
+                )
+            })
+            .await
+            .unwrap();
+
+        search_bar.update(cx, |search_bar, cx| {
+            search_bar.select_all_matches(&SelectAllMatches, cx);
+        });
+        search_bar.update(cx, |_, cx| {
+            let all_selections =
+                editor.update(cx, |editor, cx| editor.selections.display_ranges(cx));
+            assert_eq!(
+                all_selections.len(),
+                2,
+                "Should select all `edit(` in the buffer, but got: {all_selections:?}"
+            );
+        });
+    }
+
     #[gpui::test]
     async fn test_search_query_history(cx: &mut TestAppContext) {
         init_globals(cx);