project: Treat replacement literally when dealing non-ASCII text search (#56123)

Self-Review Checklist:

- [x] I've reviewed my own diff for quality, security, and reliability
- [x] Unsafe blocks (if any) have justifying comments
- [x] The content is consistent with the [UI/UX
checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
- [x] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable

Closes #55503

In #28752, to support case-insensitive search for non-ASCII queries, Zed
internally falls back to a regex search. However, this also affected the
replacement behavior, as the replacement code implemented a simple match
logic:

dccea211ed/crates/project/src/search.rs (L452-L457)
Since the regex fallback is an internal implementation detail (the user
never enabled regex mode), the replacement should behave the same as a
normal text replacement. This PR fixes that.

Release Notes:

- Fixed replacement text being treated as a regex pattern when
performing case-insensitive text search with non-ASCII characters.
This commit is contained in:
Xin Zhao 2026-06-01 14:34:37 +08:00 committed by GitHub
parent 9cc78ac691
commit 3fde4e285e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 60 additions and 18 deletions

View file

@ -78,6 +78,7 @@ pub enum SearchQuery {
include_ignored: bool,
one_match_per_line: bool,
inner: SearchInputs,
escaped: bool,
},
}
@ -169,6 +170,7 @@ impl SearchQuery {
include_ignored,
one_match_per_line,
inner,
false,
)
}
@ -202,6 +204,7 @@ impl SearchQuery {
include_ignored,
false,
inner,
true,
)
}
@ -212,6 +215,7 @@ impl SearchQuery {
include_ignored: bool,
one_match_per_line: bool,
inner: SearchInputs,
escaped: bool,
) -> Result<Self> {
if let Some((case_sensitive_from_pattern, new_pattern)) =
Self::case_sensitive_from_pattern(&pattern)
@ -253,6 +257,7 @@ impl SearchQuery {
include_ignored,
inner,
one_match_per_line,
escaped,
})
}
@ -450,27 +455,36 @@ impl SearchQuery {
/// Replaces search hits if replacement is set. `text` is assumed to be a string that matches this `SearchQuery` exactly, without any leftovers on either side.
pub fn replacement_for<'a>(&self, text: &'a str) -> Option<Cow<'a, str>> {
match self {
SearchQuery::Text { replacement, .. } => replacement.clone().map(Cow::from),
SearchQuery::Text { replacement, .. }
| SearchQuery::Regex {
replacement,
escaped: true,
..
} => replacement.clone().map(Cow::from),
SearchQuery::Regex {
regex, replacement, ..
regex,
replacement: Some(replacement),
escaped: false,
..
} => {
if let Some(replacement) = replacement {
static TEXT_REPLACEMENT_SPECIAL_CHARACTERS_REGEX: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"\\\\|\\n|\\t").unwrap());
let replacement = TEXT_REPLACEMENT_SPECIAL_CHARACTERS_REGEX.replace_all(
replacement,
|c: &Captures| match c.get(0).unwrap().as_str() {
r"\\" => "\\",
r"\n" => "\n",
r"\t" => "\t",
x => unreachable!("Unexpected escape sequence: {}", x),
},
);
Some(regex.replace(text, replacement))
} else {
None
}
static TEXT_REPLACEMENT_SPECIAL_CHARACTERS_REGEX: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"\\\\|\\n|\\t").unwrap());
let replacement = TEXT_REPLACEMENT_SPECIAL_CHARACTERS_REGEX.replace_all(
replacement,
|c: &Captures| match c.get(0).unwrap().as_str() {
r"\\" => "\\",
r"\n" => "\n",
r"\t" => "\t",
x => unreachable!("Unexpected escape sequence: {}", x),
},
);
Some(regex.replace(text, replacement))
}
SearchQuery::Regex {
replacement: None, ..
} => None,
}
}

View file

@ -4036,6 +4036,34 @@ mod tests {
});
}
#[gpui::test]
async fn test_replace_with_non_ascii_characters(cx: &mut TestAppContext) {
let (editor, search_bar, cx) = init_test(cx);
editor.update_in(cx, |editor, window, cx| {
editor.set_text("¥100 ¥200 ¥100", window, cx)
});
search_bar
.update_in(cx, |search_bar, window, cx| {
search_bar.search("", None, true, window, cx)
})
.await
.unwrap();
search_bar.update_in(cx, |search_bar, window, cx| {
search_bar.replacement_editor.update(cx, |editor, cx| {
editor.set_text("\\n", window, cx);
});
search_bar.replace_all(&ReplaceAll, window, cx)
});
assert_eq!(
editor.read_with(cx, |this, cx| this.text(cx)),
"\\n100 \\n200 \\n100"
);
}
fn update_search_settings(search_settings: SearchSettings, cx: &mut TestAppContext) {
cx.update(|cx| {
SettingsStore::update_global(cx, |store, cx| {