search: Add support for case-sensitivity pattern items (#34762)

Dino and Conrad Irwin created

This Pull Request introduces support for pattern items in the buffer
search. It does so by splitting the `query` methods into two new
methods:

- `BufferSearchBar.raw_query` – returns the text from the search query
editor
- `BufferSearchBar.query` - returns the search query with pattern items
removed

Whenever the search query is updated, processing of the
`EditorEvent::Edited` event ends up calling the
`BufferSearchBar.apply_pattern_items` method, which parses the pattern
items from the raw query, and updates the buffer search bar's search
options accordingly. This `apply_pattern_items` function avoids updating
the `BufferSearchBar.default_options` field in order to be able to reset
the search options when a pattern items is removed. Lastly, new pattern
items can easily be added by updating the `PATTERN_ITEMS` array.

### Screen Capture


https://github.com/user-attachments/assets/ebd83c38-e480-4c24-9b8c-6edde69cf392

---

Closes #32390

Release Notes:

- Added support for the `\c` and `\C` query pattern items to control
case-sensitivity in buffer search

---------

Co-authored-by: Conrad Irwin <conrad.irwin@gmail.com>

Change summary

crates/project/src/search.rs        | 132 ++++++++++++++++++++++++++++++
crates/search/src/buffer_search.rs  |  21 +++-
crates/search/src/project_search.rs |   2 
3 files changed, 146 insertions(+), 9 deletions(-)

Detailed changes

crates/project/src/search.rs 🔗

@@ -143,7 +143,7 @@ impl SearchQuery {
     pub fn regex(
         query: impl ToString,
         whole_word: bool,
-        case_sensitive: bool,
+        mut case_sensitive: bool,
         include_ignored: bool,
         one_match_per_line: bool,
         files_to_include: PathMatcher,
@@ -153,6 +153,14 @@ impl SearchQuery {
     ) -> Result<Self> {
         let mut query = query.to_string();
         let initial_query = Arc::from(query.as_str());
+
+        if let Some((case_sensitive_from_pattern, new_query)) =
+            Self::case_sensitive_from_pattern(&query)
+        {
+            case_sensitive = case_sensitive_from_pattern;
+            query = new_query
+        }
+
         if whole_word {
             let mut word_query = String::new();
             if let Some(first) = query.get(0..1)
@@ -192,6 +200,45 @@ impl SearchQuery {
         })
     }
 
+    /// Extracts case sensitivity settings from pattern items in the provided
+    /// query and returns the same query, with the pattern items removed.
+    ///
+    /// The following pattern modifiers are supported:
+    ///
+    /// - `\c` (case_sensitive: false)
+    /// - `\C` (case_sensitive: true)
+    ///
+    /// If no pattern item were found, `None` will be returned.
+    fn case_sensitive_from_pattern(query: &str) -> Option<(bool, String)> {
+        if !(query.contains("\\c") || query.contains("\\C")) {
+            return None;
+        }
+
+        let mut was_escaped = false;
+        let mut new_query = String::new();
+        let mut is_case_sensitive = None;
+
+        for c in query.chars() {
+            if was_escaped {
+                if c == 'c' {
+                    is_case_sensitive = Some(false);
+                } else if c == 'C' {
+                    is_case_sensitive = Some(true);
+                } else {
+                    new_query.push('\\');
+                    new_query.push(c);
+                }
+                was_escaped = false
+            } else if c == '\\' {
+                was_escaped = true
+            } else {
+                new_query.push(c);
+            }
+        }
+
+        is_case_sensitive.map(|c| (c, new_query))
+    }
+
     pub fn from_proto(message: proto::SearchQuery) -> Result<Self> {
         let files_to_include = if message.files_to_include.is_empty() {
             message
@@ -596,4 +643,87 @@ mod tests {
             }
         }
     }
+
+    #[test]
+    fn test_case_sensitive_pattern_items() {
+        let case_sensitive = false;
+        let search_query = SearchQuery::regex(
+            "test\\C",
+            false,
+            case_sensitive,
+            false,
+            false,
+            Default::default(),
+            Default::default(),
+            false,
+            None,
+        )
+        .expect("Should be able to create a regex SearchQuery");
+
+        assert_eq!(
+            search_query.case_sensitive(),
+            true,
+            "Case sensitivity should be enabled when \\C pattern item is present in the query."
+        );
+
+        let case_sensitive = true;
+        let search_query = SearchQuery::regex(
+            "test\\c",
+            true,
+            case_sensitive,
+            false,
+            false,
+            Default::default(),
+            Default::default(),
+            false,
+            None,
+        )
+        .expect("Should be able to create a regex SearchQuery");
+
+        assert_eq!(
+            search_query.case_sensitive(),
+            false,
+            "Case sensitivity should be disabled when \\c pattern item is present, even if initially set to true."
+        );
+
+        let case_sensitive = false;
+        let search_query = SearchQuery::regex(
+            "test\\c\\C",
+            false,
+            case_sensitive,
+            false,
+            false,
+            Default::default(),
+            Default::default(),
+            false,
+            None,
+        )
+        .expect("Should be able to create a regex SearchQuery");
+
+        assert_eq!(
+            search_query.case_sensitive(),
+            true,
+            "Case sensitivity should be enabled when \\C is the last pattern item, even after a \\c."
+        );
+
+        let case_sensitive = false;
+        let search_query = SearchQuery::regex(
+            "tests\\\\C",
+            false,
+            case_sensitive,
+            false,
+            false,
+            Default::default(),
+            Default::default(),
+            false,
+            None,
+        )
+        .expect("Should be able to create a regex SearchQuery");
+
+        assert_eq!(
+            search_query.case_sensitive(),
+            false,
+            "Case sensitivity should not be enabled when \\C pattern item is preceded by a backslash."
+        );
+    }
 }

crates/search/src/buffer_search.rs 🔗

@@ -1516,18 +1516,25 @@ mod tests {
                 cx,
             )
         });
-        let cx = cx.add_empty_window();
-        let editor =
-            cx.new_window_entity(|window, cx| Editor::for_buffer(buffer.clone(), None, window, cx));
-
-        let search_bar = cx.new_window_entity(|window, cx| {
+        let mut editor = None;
+        let window = cx.add_window(|window, cx| {
+            let default_key_bindings = settings::KeymapFile::load_asset_allow_partial_failure(
+                "keymaps/default-macos.json",
+                cx,
+            )
+            .unwrap();
+            cx.bind_keys(default_key_bindings);
+            editor = Some(cx.new(|cx| Editor::for_buffer(buffer.clone(), None, window, cx)));
             let mut search_bar = BufferSearchBar::new(None, window, cx);
-            search_bar.set_active_pane_item(Some(&editor), window, cx);
+            search_bar.set_active_pane_item(Some(&editor.clone().unwrap()), window, cx);
             search_bar.show(window, cx);
             search_bar
         });
+        let search_bar = window.root(cx).unwrap();
+
+        let cx = VisualTestContext::from_window(*window, cx).into_mut();
 
-        (editor, search_bar, cx)
+        (editor.unwrap(), search_bar, cx)
     }
 
     #[gpui::test]

crates/search/src/project_search.rs 🔗

@@ -1139,7 +1139,7 @@ impl ProjectSearchView {
 
     fn build_search_query(&mut self, cx: &mut Context<Self>) -> Option<SearchQuery> {
         // Do not bail early in this function, as we want to fill out `self.panels_with_errors`.
-        let text = self.query_editor.read(cx).text(cx);
+        let text = self.search_query_text(cx);
         let open_buffers = if self.included_opened_only {
             Some(self.open_buffers(cx))
         } else {