Support newline and tab literals in regex search-and-replace operations (#9609)

Mayfield created

Closes #7645

Release Notes:

- Added support for inserting newlines (`\n`) and tabs (`\t`) in editor
Regex search replacements
([#7645](https://github.com/zed-industries/zed/issues/7645)).

Change summary

crates/project/src/search.rs       |  16 ++++
crates/search/src/buffer_search.rs | 108 ++++++++++++++++++++++++++++++++
2 files changed, 122 insertions(+), 2 deletions(-)

Detailed changes

crates/project/src/search.rs 🔗

@@ -3,17 +3,19 @@ use anyhow::{Context, Result};
 use client::proto;
 use itertools::Itertools;
 use language::{char_kind, BufferSnapshot};
-use regex::{Regex, RegexBuilder};
+use regex::{Captures, Regex, RegexBuilder};
 use smol::future::yield_now;
 use std::{
     borrow::Cow,
     io::{BufRead, BufReader, Read},
     ops::Range,
     path::Path,
-    sync::Arc,
+    sync::{Arc, OnceLock},
 };
 use util::paths::PathMatcher;
 
+static TEXT_REPLACEMENT_SPECIAL_CHARACTERS_REGEX: OnceLock<Regex> = OnceLock::new();
+
 #[derive(Clone, Debug)]
 pub struct SearchInputs {
     query: Arc<str>,
@@ -231,6 +233,16 @@ impl SearchQuery {
                 regex, replacement, ..
             } => {
                 if let Some(replacement) = replacement {
+                    let replacement = TEXT_REPLACEMENT_SPECIAL_CHARACTERS_REGEX
+                        .get_or_init(|| Regex::new(r"\\\\|\\n|\\t").unwrap())
+                        .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

crates/search/src/buffer_search.rs 🔗

@@ -1918,6 +1918,114 @@ mod tests {
         );
     }
 
+    struct ReplacementTestParams<'a> {
+        editor: &'a View<Editor>,
+        search_bar: &'a View<BufferSearchBar>,
+        cx: &'a mut VisualTestContext,
+        search_mode: SearchMode,
+        search_text: &'static str,
+        search_options: Option<SearchOptions>,
+        replacement_text: &'static str,
+        replace_all: bool,
+        expected_text: String,
+    }
+
+    async fn run_replacement_test(options: ReplacementTestParams<'_>) {
+        options
+            .search_bar
+            .update(options.cx, |search_bar, cx| {
+                search_bar.activate_search_mode(options.search_mode, cx);
+                search_bar.search(options.search_text, options.search_options, cx)
+            })
+            .await
+            .unwrap();
+
+        options.search_bar.update(options.cx, |search_bar, cx| {
+            search_bar.replacement_editor.update(cx, |editor, cx| {
+                editor.set_text(options.replacement_text, cx);
+            });
+
+            if options.replace_all {
+                search_bar.replace_all(&ReplaceAll, cx)
+            } else {
+                search_bar.replace_next(&ReplaceNext, cx)
+            }
+        });
+
+        assert_eq!(
+            options
+                .editor
+                .update(options.cx, |this, cx| { this.text(cx) }),
+            options.expected_text
+        );
+    }
+
+    #[gpui::test]
+    async fn test_replace_special_characters(cx: &mut TestAppContext) {
+        let (editor, search_bar, cx) = init_test(cx);
+
+        run_replacement_test(ReplacementTestParams {
+            editor: &editor,
+            search_bar: &search_bar,
+            cx,
+            search_mode: SearchMode::Text,
+            search_text: "expression",
+            search_options: None,
+            replacement_text: r"\n",
+            replace_all: true,
+            expected_text: r#"
+            A regular \n (shortened as regex or regexp;[1] also referred to as
+            rational \n[2][3]) is a sequence of characters that specifies a search
+            pattern in text. Usually such patterns are used by string-searching algorithms
+            for "find" or "find and replace" operations on strings, or for input validation.
+            "#
+            .unindent(),
+        })
+        .await;
+
+        run_replacement_test(ReplacementTestParams {
+            editor: &editor,
+            search_bar: &search_bar,
+            cx,
+            search_mode: SearchMode::Regex,
+            search_text: "or",
+            search_options: Some(SearchOptions::WHOLE_WORD),
+            replacement_text: r"\\\n\\\\",
+            replace_all: false,
+            expected_text: r#"
+            A regular \n (shortened as regex \
+            \\ regexp;[1] also referred to as
+            rational \n[2][3]) is a sequence of characters that specifies a search
+            pattern in text. Usually such patterns are used by string-searching algorithms
+            for "find" or "find and replace" operations on strings, or for input validation.
+            "#
+            .unindent(),
+        })
+        .await;
+
+        run_replacement_test(ReplacementTestParams {
+            editor: &editor,
+            search_bar: &search_bar,
+            cx,
+            search_mode: SearchMode::Regex,
+            search_text: r"(that|used) ",
+            search_options: None,
+            replacement_text: r"$1\n",
+            replace_all: true,
+            expected_text: r#"
+            A regular \n (shortened as regex \
+            \\ regexp;[1] also referred to as
+            rational \n[2][3]) is a sequence of characters that
+            specifies a search
+            pattern in text. Usually such patterns are used
+            by string-searching algorithms
+            for "find" or "find and replace" operations on strings, or for input validation.
+            "#
+            .unindent(),
+        })
+        .await;
+    }
+
     #[gpui::test]
     async fn test_invalid_regexp_search_after_valid(cx: &mut TestAppContext) {
         let (editor, search_bar, cx) = init_test(cx);