refactor: implement case sensitive pattern items in search query

In order to simplify the implementation of pattern items in search
queries, this commit updates the `project::search::SearchQuery::regex`
function so as to support both `\\c` and `\\C` in the provided query.

This means that we no longer need to have both `BufferSearchBar` and
`ProjectSearchView` handling pattern items, so the
`search::pattern_items` module can now safely be removed.

It's worth noting that, since these are now handled at the `SearchQuery`
level, this removes the updates to the UI regarding search options, that
were being triggered by the pattern items being processed and applied to
the search options.

Co-authored-by: Conrad Irwin <conrad.irwin@gmail.com>
This commit is contained in:
dinocosta 2025-08-26 17:12:29 +01:00
parent d28e52d9e7
commit c69f4f49f0
3 changed files with 142 additions and 330 deletions

View file

@ -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."
);
}
}

View file

@ -4,7 +4,6 @@ use crate::{
FocusSearch, NextHistoryQuery, PreviousHistoryQuery, ReplaceAll, ReplaceNext, SearchOption,
SearchOptions, SearchSource, SelectAllMatches, SelectNextMatch, SelectPreviousMatch,
ToggleCaseSensitive, ToggleRegex, ToggleReplace, ToggleSelection, ToggleWholeWord,
pattern_items::PatternItems,
search_bar::{ActionButtonState, input_base_styles, render_action_button, render_text_input},
};
use any_vec::AnyVec;
@ -112,10 +111,6 @@ pub struct BufferSearchBar {
pending_search: Option<Task<()>>,
search_options: SearchOptions,
default_options: SearchOptions,
/// Pattern items that have been applied from the query to the search
/// options. Tracking these allows us to revert the search options to their
/// original state as pattern items are removed from the query.
pattern_items: PatternItems,
configured_options: SearchOptions,
query_error: Option<String>,
dismissed: bool,
@ -660,7 +655,6 @@ impl BufferSearchBar {
default_options: search_options,
configured_options: search_options,
search_options,
pattern_items: Default::default(),
pending_search: None,
query_error: None,
dismissed: true,
@ -816,14 +810,9 @@ impl BufferSearchBar {
});
}
/// Returns the raw query string without any of the pattern items removed.
pub fn raw_query(&self, cx: &App) -> String {
self.query_editor.read(cx).text(cx)
}
/// Returns the sanitized query string with pattern items removed.
pub fn query(&self, cx: &App) -> String {
PatternItems::clean_query(&self.raw_query(cx))
self.query_editor.read(cx).text(cx)
}
pub fn replacement(&self, cx: &mut App) -> String {
@ -1055,7 +1044,6 @@ impl BufferSearchBar {
editor::EditorEvent::Blurred => self.query_editor_focused = false,
editor::EditorEvent::Edited { .. } => {
self.smartcase(window, cx);
self.apply_pattern_items(cx);
self.clear_matches(window, cx);
let search = self.update_matches(false, window, cx);
@ -1465,22 +1453,6 @@ impl BufferSearchBar {
}
}
// Determines which pattern items are present in the search query and
// updates the search options accordingly, only if the regex search option
// is enabled.
fn apply_pattern_items(&mut self, cx: &mut Context<Self>) {
if self.search_options.contains(SearchOptions::REGEX) {
// Determine what the search options were before the pattern items
// were applied, so we can reapply them and determine which ones
// actually have an effect on the search options, which are the ones
// we need to keep track of.
let query = self.raw_query(cx);
let search_options = self.pattern_items.revert(self.search_options);
self.pattern_items = PatternItems::from_search_options(search_options, &query);
self.set_search_options(self.pattern_items.apply(search_options), cx);
}
}
fn adjust_query_regex_language(&self, cx: &mut App) {
let enable = self.search_options.contains(SearchOptions::REGEX);
let query_buffer = self
@ -2867,156 +2839,6 @@ mod tests {
});
}
#[gpui::test]
async fn test_pattern_items(cx: &mut TestAppContext) {
let (_editor, search_bar, cx) = init_test(cx);
update_search_settings(
SearchSettings {
button: true,
whole_word: false,
case_sensitive: false,
include_ignored: false,
regex: false,
},
cx,
);
search_bar.update_in(cx, |search_bar, window, cx| {
search_bar.show(window, cx);
assert_eq!(
search_bar.search_options,
SearchOptions::NONE,
"Should have no search options enabled by default"
);
cx.focus_view(&search_bar.query_editor, window);
});
cx.simulate_input("test\\C");
search_bar.update_in(cx, |search_bar, _, _| {
assert_eq!(
search_bar.search_options,
SearchOptions::NONE,
"Should not apply pattern items if regex not enabled"
);
});
cx.simulate_keystrokes("backspace backspace");
search_bar.update_in(cx, |search_bar, window, cx| {
search_bar.toggle_search_option(SearchOptions::REGEX, window, cx);
});
cx.simulate_input("\\C");
search_bar.update_in(cx, |search_bar, _, _| {
assert_eq!(
search_bar.search_options,
SearchOptions::REGEX | SearchOptions::CASE_SENSITIVE,
"Should have case sensitivity enabled when \\C pattern item is present and regex is enabled"
);
});
// Remove `\\C` from the query to check if the search option is
// correctly reverted to its default state.
cx.simulate_keystrokes("backspace backspace");
search_bar.update_in(cx, |search_bar, window, cx| {
assert_eq!(search_bar.raw_query(cx), "test");
assert_eq!(
search_bar.search_options,
SearchOptions::REGEX,
"Should have case sensitivity disabled when \\C pattern item is removed"
);
search_bar.toggle_search_option(SearchOptions::CASE_SENSITIVE, window, cx);
assert_eq!(
search_bar.search_options,
SearchOptions::REGEX | SearchOptions::CASE_SENSITIVE,
"Should have case sensitivity enabled by default"
);
});
cx.run_until_parked();
cx.simulate_input("\\c");
cx.run_until_parked();
search_bar.update(cx, |search_bar, cx| {
assert_eq!(search_bar.raw_query(cx), "test\\c");
assert_eq!(
search_bar.search_options,
SearchOptions::REGEX,
"Should have no case sensitivity enabled when \\c pattern item is present"
);
});
cx.simulate_input("\\C");
cx.run_until_parked();
search_bar.update(cx, |search_bar, cx| {
assert_eq!(search_bar.raw_query(cx), "test\\c\\C");
assert_eq!(
search_bar.search_options,
SearchOptions::REGEX | SearchOptions::CASE_SENSITIVE,
"Should have case sensitivity enabled when \\C pattern item is present, even if preceded by \\c"
);
});
cx.simulate_input("\\c");
cx.run_until_parked();
search_bar.update(cx, |search_bar, cx| {
assert_eq!(search_bar.raw_query(cx), "test\\c\\C\\c");
assert_eq!(
search_bar.search_options,
SearchOptions::REGEX,
"Should have no case sensitivity enabled when \\c pattern item is present, even if preceded by \\C"
);
});
cx.simulate_keystrokes("backspace backspace");
search_bar.update(cx, |search_bar, cx| {
assert_eq!(search_bar.raw_query(cx), "test\\c\\C");
assert_eq!(
search_bar.search_options,
SearchOptions::REGEX | SearchOptions::CASE_SENSITIVE,
"Should have case sensitivity enabled when suffix \\c pattern item is removed"
);
});
cx.simulate_keystrokes("backspace backspace");
search_bar.update(cx, |search_bar, cx| {
assert_eq!(search_bar.raw_query(cx), "test\\c");
assert_eq!(
search_bar.search_options,
SearchOptions::REGEX,
"Should have case sensitivity disabled when suffix \\C pattern item is removed and a \\c pattern item is still present"
);
});
cx.simulate_keystrokes("backspace backspace");
search_bar.update(cx, |search_bar, cx| {
assert_eq!(search_bar.raw_query(cx), "test");
assert_eq!(
search_bar.search_options,
SearchOptions::REGEX | SearchOptions::CASE_SENSITIVE,
"Should have case sensitivity enabled when all pattern items are removed and original search options are restored"
);
});
cx.simulate_input("\\\\c");
cx.run_until_parked();
search_bar.update(cx, |search_bar, cx| {
assert_eq!(search_bar.raw_query(cx), "test\\\\c");
assert_eq!(
search_bar.search_options,
SearchOptions::REGEX | SearchOptions::CASE_SENSITIVE,
"Should still have case sensitivity enabled when pattern item is preceded by another \\"
);
});
}
fn update_search_settings(search_settings: SearchSettings, cx: &mut TestAppContext) {
cx.update(|cx| {
SettingsStore::update_global(cx, |store, cx| {

View file

@ -3,7 +3,6 @@ use crate::{
SearchOption, SearchOptions, SearchSource, SelectNextMatch, SelectPreviousMatch,
ToggleCaseSensitive, ToggleIncludeIgnored, ToggleRegex, ToggleReplace, ToggleWholeWord,
buffer_search::Deploy,
pattern_items::PatternItems,
search_bar::{ActionButtonState, input_base_styles, render_action_button, render_text_input},
};
use anyhow::Context as _;
@ -211,7 +210,6 @@ pub struct ProjectSearchView {
replacement_editor: Entity<Editor>,
results_editor: Entity<Editor>,
search_options: SearchOptions,
pattern_items: PatternItems,
panels_with_errors: HashMap<InputPanel, String>,
active_match_index: Option<usize>,
search_id: usize,
@ -777,10 +775,9 @@ impl ProjectSearchView {
// Subscribe to query_editor in order to reraise editor events for workspace item activation purposes
subscriptions.push(
cx.subscribe(&query_editor, |this, _, event: &EditorEvent, cx| {
if let EditorEvent::Edited { .. } = event {
this.apply_pattern_items(&this.search_query_text_raw(cx));
if EditorSettings::get_global(cx).use_smartcase_search {
if let EditorEvent::Edited { .. } = event
&& EditorSettings::get_global(cx).use_smartcase_search
{
let query = this.search_query_text(cx);
if !query.is_empty()
&& this.search_options.contains(SearchOptions::CASE_SENSITIVE)
@ -789,7 +786,6 @@ impl ProjectSearchView {
this.toggle_search_option(SearchOptions::CASE_SENSITIVE, cx);
}
}
}
cx.emit(ViewEvent::EditorEvent(event.clone()))
}),
);
@ -884,7 +880,6 @@ impl ProjectSearchView {
query_editor,
results_editor,
search_options: options,
pattern_items: Default::default(),
panels_with_errors: HashMap::default(),
active_match_index: None,
included_files_editor,
@ -1138,14 +1133,9 @@ impl ProjectSearchView {
}
}
/// Returns the search query text with pattern items.
fn search_query_text_raw(&self, cx: &App) -> String {
self.query_editor.read(cx).text(cx)
}
/// Returns the search query text without pattern items.
pub fn search_query_text(&self, cx: &App) -> String {
PatternItems::clean_query(&self.search_query_text_raw(cx))
self.query_editor.read(cx).text(cx)
}
fn build_search_query(&mut self, cx: &mut Context<Self>) -> Option<SearchQuery> {
@ -1571,21 +1561,6 @@ impl ProjectSearchView {
})
}
}
// Determines which pattern items are present in the search query and
// updates the search options accordingly, only if the regex search option
// is enabled.
fn apply_pattern_items(&mut self, query: &str) {
if self.search_options.contains(SearchOptions::REGEX) {
// Determine what the search options were before the pattern items
// were applied, so we can reapply them and determine which ones
// actually have an effect on the search options, which are the ones
// we need to keep track of.
let search_options = self.pattern_items.revert(self.search_options);
self.pattern_items = PatternItems::from_search_options(search_options, &query);
self.search_options = self.pattern_items.apply(search_options);
}
}
}
fn buffer_search_query(
@ -4152,121 +4127,6 @@ pub mod tests {
});
}
#[gpui::test]
async fn test_pattern_items(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.background_executor.clone());
fs.insert_tree(
path!("/dir"),
json!({
"one.rs": "const ONE: usize = 1;",
"two.rs": "const TWO: usize = one::ONE + one::ONE;",
"three.rs": "const THREE: usize = one::ONE + two::TWO;",
"four.rs": "const FOUR: usize = one::ONE + three::THREE;",
}),
)
.await;
let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
let workspace = window.root(cx).unwrap();
let project_search = cx.new(|cx| ProjectSearch::new(project.clone(), cx));
let project_search_settings = ProjectSearchSettings {
filters_enabled: false,
search_options: SearchOptions::REGEX,
};
let project_search_view = cx.add_window(|window, cx| {
ProjectSearchView::new(
workspace.downgrade(),
project_search.clone(),
window,
cx,
Some(project_search_settings),
)
});
project_search_view
.update(cx, |view, window, cx| {
// Verify that `test\\c` does not change search options, as
// `SearchOptions::CASE_SENSITIVE` is disabled.
view.query_editor.update(cx, |editor, cx| {
editor.set_text("test\\c", window, cx);
});
assert_eq!(view.search_query_text_raw(cx), "test\\c");
assert_eq!(view.search_query_text(cx), "test");
view.apply_pattern_items(&view.search_query_text_raw(cx));
assert_eq!(
view.search_options,
SearchOptions::REGEX,
"Case sensitivity should remain disabled if only pattern item is '\\c'"
);
// Verify that `test\\c\\C\\c` does not change search options,
// as even though `\\C` should enabled
// `SearchOptions::CASE_SENSITIVE`, the last `\\c` should
// disable it.
view.query_editor.update(cx, |editor, cx| {
editor.set_text("test\\c\\C\\c", window, cx);
});
assert_eq!(view.search_query_text_raw(cx), "test\\c\\C\\c");
assert_eq!(view.search_query_text(cx), "test");
view.apply_pattern_items(&view.search_query_text_raw(cx));
assert_eq!(
view.search_options,
SearchOptions::REGEX,
"Case sensitivity should be disabled if '\\c' follows '\\C'"
);
// Verify that a single `\\C` pattern item in the search query
// enables the `SearchOptions::CASE_SENSITIVE` option.
view.query_editor.update(cx, |editor, cx| {
editor.set_text("test\\C", window, cx);
});
assert_eq!(view.search_query_text_raw(cx), "test\\C");
assert_eq!(view.search_query_text(cx), "test");
view.apply_pattern_items(&view.search_query_text_raw(cx));
assert_eq!(
view.search_options,
SearchOptions::REGEX | SearchOptions::CASE_SENSITIVE,
"Case sensitivity should be enabled when '\\C' is used"
);
// Since `SearchOptions::CASE_SENSITIVE` is now enabled, we can
// check that a pattern item preceded by a backslash, for
// example, `\\\\c`, does not affect the search option and is
// not even recognized as a valid pattern item.
// We need to clear the `pattern_items` before testing this, as
// calling `apply_pattern_items` will revert it, so the
// `CASE_SENSITIVE` option would be turned off.
view.pattern_items = PatternItems::default();
view.query_editor.update(cx, |editor, cx| {
editor.set_text("test\\\\c", window, cx);
});
assert_eq!(view.search_query_text_raw(cx), "test\\\\c");
assert_eq!(view.search_query_text(cx), "test\\\\c");
view.apply_pattern_items(&view.search_query_text_raw(cx));
assert_eq!(
view.search_options,
SearchOptions::REGEX | SearchOptions::CASE_SENSITIVE,
"Case sensitivity should remain enabled when pattern item is preceded by a backslash"
);
})
.unwrap();
}
fn init_test(cx: &mut TestAppContext) {
cx.update(|cx| {
let settings = SettingsStore::test(cx);