Add support for auto surround (#13217)

แด€แดแด›แดแด€แด‡ส€ created

![result](https://github.com/zed-industries/zed/assets/32017007/c400081f-be5d-48fa-994f-90a00e2be359)

In the past, Zed used a single switch called `autoclose` to control both
`autoclose` and `auto_surround` functionalities:
+ `autoclose`: when input '(', append ')' automatically.
+ `auto_surround`: when select text and input '(', surround text with
'(' and ')' automatically.

This PR separates `auto_surround` from `autoclose` to support `<`. 

Previously, if `autoclose` of `<` was set to `false`, `auto_surround`
couldn't be used. However, setting `autoclose` to `true` would affect
the default behavior of simple expression. For example, `a < b` would
become `a <> b`.

For more information, see #13187.

Fix #12898.

Release Notes:

- Added support for `auto_surround`
([#12898](https://github.com/zed-industries/zed/issues/12898)).

Change summary

assets/settings/default.json                      |  4 +
crates/editor/src/editor.rs                       | 22 +++++++--
crates/editor/src/editor_tests.rs                 | 38 ++++++++++++++++
crates/editor/src/highlight_matching_bracket.rs   |  2 
crates/editor/src/test/editor_lsp_test_context.rs |  1 
crates/language/src/buffer_tests.rs               |  4 +
crates/language/src/language.rs                   |  4 +
crates/language/src/language_settings.rs          |  8 +++
crates/vim/src/surrounds.rs                       | 19 ++++++++
9 files changed, 96 insertions(+), 6 deletions(-)

Detailed changes

assets/settings/default.json ๐Ÿ”—

@@ -146,6 +146,10 @@
   // opening parenthesis, bracket, brace, single or double quote characters.
   // For example, when you type (, Zed will add a closing ) at the correct position.
   "use_autoclose": true,
+  // Whether to automatically surround selected text when typing opening parenthesis,
+  // bracket, brace, single or double quote characters.
+  // For example, when you select text and type (, Zed will surround the text with ().
+  "use_auto_surround": true,
   // Controls how the editor handles the autoclosed characters.
   // When set to `false`(default), skipping over and auto-removing of the closing characters
   // happen only for auto-inserted characters.

crates/editor/src/editor.rs ๐Ÿ”—

@@ -532,6 +532,7 @@ pub struct Editor {
     next_editor_action_id: EditorActionId,
     editor_actions: Rc<RefCell<BTreeMap<EditorActionId, Box<dyn Fn(&mut ViewContext<Self>)>>>>,
     use_autoclose: bool,
+    use_auto_surround: bool,
     auto_replace_emoji_shortcode: bool,
     show_git_blame_gutter: bool,
     show_git_blame_inline: bool,
@@ -1811,6 +1812,7 @@ impl Editor {
             use_modal_editing: mode == EditorMode::Full,
             read_only: false,
             use_autoclose: true,
+            use_auto_surround: true,
             auto_replace_emoji_shortcode: false,
             leader_peer_id: None,
             remote_id: None,
@@ -2188,6 +2190,10 @@ impl Editor {
         self.use_autoclose = autoclose;
     }
 
+    pub fn set_use_auto_surround(&mut self, auto_surround: bool) {
+        self.use_auto_surround = auto_surround;
+    }
+
     pub fn set_auto_replace_emoji_shortcode(&mut self, auto_replace: bool) {
         self.auto_replace_emoji_shortcode = auto_replace;
     }
@@ -2887,7 +2893,7 @@ impl Editor {
                     // `text` can be empty when a user is using IME (e.g. Chinese Wubi Simplified)
                     //  and they are removing the character that triggered IME popup.
                     for (pair, enabled) in scope.brackets() {
-                        if !pair.close {
+                        if !pair.close && !pair.surround {
                             continue;
                         }
 
@@ -2905,9 +2911,10 @@ impl Editor {
                 }
 
                 if let Some(bracket_pair) = bracket_pair {
-                    let autoclose = self.use_autoclose
-                        && snapshot.settings_at(selection.start, cx).use_autoclose;
-
+                    let snapshot_settings = snapshot.settings_at(selection.start, cx);
+                    let autoclose = self.use_autoclose && snapshot_settings.use_autoclose;
+                    let auto_surround =
+                        self.use_auto_surround && snapshot_settings.use_auto_surround;
                     if selection.is_empty() {
                         if is_bracket_pair_start {
                             let prefix_len = bracket_pair.start.len() - text.len();
@@ -2929,6 +2936,7 @@ impl Editor {
                                         &bracket_pair.start[..prefix_len],
                                     ));
                             if autoclose
+                                && bracket_pair.close
                                 && following_text_allows_autoclose
                                 && preceding_text_matches_prefix
                             {
@@ -2980,7 +2988,8 @@ impl Editor {
                     }
                     // If an opening bracket is 1 character long and is typed while
                     // text is selected, then surround that text with the bracket pair.
-                    else if autoclose
+                    else if auto_surround
+                        && bracket_pair.surround
                         && is_bracket_pair_start
                         && bracket_pair.start.chars().count() == 1
                     {
@@ -12188,9 +12197,12 @@ impl ViewInputHandler for Editor {
 
             // Disable auto-closing when composing text (i.e. typing a `"` on a Brazilian keyboard)
             let use_autoclose = this.use_autoclose;
+            let use_auto_surround = this.use_auto_surround;
             this.set_use_autoclose(false);
+            this.set_use_auto_surround(false);
             this.handle_input(text, cx);
             this.set_use_autoclose(use_autoclose);
+            this.set_use_auto_surround(use_auto_surround);
 
             if let Some(new_selected_range) = new_selected_range_utf16 {
                 let snapshot = this.buffer.read(cx).read(cx);

crates/editor/src/editor_tests.rs ๐Ÿ”—

@@ -4635,12 +4635,14 @@ async fn test_autoindent_selections(cx: &mut gpui::TestAppContext) {
                             start: "{".to_string(),
                             end: "}".to_string(),
                             close: false,
+                            surround: false,
                             newline: true,
                         },
                         BracketPair {
                             start: "(".to_string(),
                             end: ")".to_string(),
                             close: false,
+                            surround: false,
                             newline: true,
                         },
                     ],
@@ -4684,7 +4686,7 @@ async fn test_autoindent_selections(cx: &mut gpui::TestAppContext) {
 }
 
 #[gpui::test]
-async fn test_autoclose_pairs(cx: &mut gpui::TestAppContext) {
+async fn test_autoclose_and_auto_surround_pairs(cx: &mut gpui::TestAppContext) {
     init_test(cx, |_| {});
 
     let mut cx = EditorTestContext::new(cx).await;
@@ -4697,32 +4699,44 @@ async fn test_autoclose_pairs(cx: &mut gpui::TestAppContext) {
                         start: "{".to_string(),
                         end: "}".to_string(),
                         close: true,
+                        surround: true,
                         newline: true,
                     },
                     BracketPair {
                         start: "(".to_string(),
                         end: ")".to_string(),
                         close: true,
+                        surround: true,
                         newline: true,
                     },
                     BracketPair {
                         start: "/*".to_string(),
                         end: " */".to_string(),
                         close: true,
+                        surround: true,
                         newline: true,
                     },
                     BracketPair {
                         start: "[".to_string(),
                         end: "]".to_string(),
                         close: false,
+                        surround: false,
                         newline: true,
                     },
                     BracketPair {
                         start: "\"".to_string(),
                         end: "\"".to_string(),
                         close: true,
+                        surround: true,
                         newline: false,
                     },
+                    BracketPair {
+                        start: "<".to_string(),
+                        end: ">".to_string(),
+                        close: false,
+                        surround: true,
+                        newline: true,
+                    },
                 ],
                 ..Default::default()
             },
@@ -4850,6 +4864,16 @@ async fn test_autoclose_pairs(cx: &mut gpui::TestAppContext) {
     cx.assert_editor_state("a\"ห‡\"");
     cx.update_editor(|view, cx| view.handle_input("\"", cx));
     cx.assert_editor_state("a\"\"ห‡");
+
+    // Don't autoclose pair if autoclose is disabled
+    cx.set_state("ห‡");
+    cx.update_editor(|view, cx| view.handle_input("<", cx));
+    cx.assert_editor_state("<ห‡");
+
+    // Surround with brackets if text is selected and auto_surround is enabled, even if autoclose is disabled
+    cx.set_state("ยซaห‡ยป b");
+    cx.update_editor(|view, cx| view.handle_input("<", cx));
+    cx.assert_editor_state("<ยซaห‡ยป> b");
 }
 
 #[gpui::test]
@@ -4868,18 +4892,21 @@ async fn test_always_treat_brackets_as_autoclosed_skip_over(cx: &mut gpui::TestA
                         start: "{".to_string(),
                         end: "}".to_string(),
                         close: true,
+                        surround: true,
                         newline: true,
                     },
                     BracketPair {
                         start: "(".to_string(),
                         end: ")".to_string(),
                         close: true,
+                        surround: true,
                         newline: true,
                     },
                     BracketPair {
                         start: "[".to_string(),
                         end: "]".to_string(),
                         close: false,
+                        surround: false,
                         newline: true,
                     },
                 ],
@@ -5293,12 +5320,14 @@ async fn test_surround_with_pair(cx: &mut gpui::TestAppContext) {
                         start: "{".to_string(),
                         end: "}".to_string(),
                         close: true,
+                        surround: true,
                         newline: true,
                     },
                     BracketPair {
                         start: "/* ".to_string(),
                         end: "*/".to_string(),
                         close: true,
+                        surround: true,
                         ..Default::default()
                     },
                 ],
@@ -5447,6 +5476,7 @@ async fn test_delete_autoclose_pair(cx: &mut gpui::TestAppContext) {
                     start: "{".to_string(),
                     end: "}".to_string(),
                     close: true,
+                    surround: true,
                     newline: true,
                 }],
                 ..Default::default()
@@ -5558,18 +5588,21 @@ async fn test_always_treat_brackets_as_autoclosed_delete(cx: &mut gpui::TestAppC
                         start: "{".to_string(),
                         end: "}".to_string(),
                         close: true,
+                        surround: true,
                         newline: true,
                     },
                     BracketPair {
                         start: "(".to_string(),
                         end: ")".to_string(),
                         close: true,
+                        surround: true,
                         newline: true,
                     },
                     BracketPair {
                         start: "[".to_string(),
                         end: "]".to_string(),
                         close: false,
+                        surround: true,
                         newline: true,
                     },
                 ],
@@ -7537,12 +7570,14 @@ async fn test_extra_newline_insertion(cx: &mut gpui::TestAppContext) {
                             start: "{".to_string(),
                             end: "}".to_string(),
                             close: true,
+                            surround: true,
                             newline: true,
                         },
                         BracketPair {
                             start: "/* ".to_string(),
                             end: " */".to_string(),
                             close: true,
+                            surround: true,
                             newline: true,
                         },
                     ],
@@ -8344,6 +8379,7 @@ async fn test_on_type_formatting_not_triggered(cx: &mut gpui::TestAppContext) {
                     start: "{".to_string(),
                     end: "}".to_string(),
                     close: true,
+                    surround: true,
                     newline: true,
                 }],
                 disabled_scopes_by_bracket_ix: Vec::new(),

crates/editor/src/highlight_matching_bracket.rs ๐Ÿ”—

@@ -55,12 +55,14 @@ mod tests {
                                 start: "{".to_string(),
                                 end: "}".to_string(),
                                 close: false,
+                                surround: false,
                                 newline: true,
                             },
                             BracketPair {
                                 start: "(".to_string(),
                                 end: ")".to_string(),
                                 close: false,
+                                surround: false,
                                 newline: true,
                             },
                         ],

crates/language/src/buffer_tests.rs ๐Ÿ”—

@@ -1782,12 +1782,14 @@ fn test_language_scope_at_with_javascript(cx: &mut AppContext) {
                             start: "{".into(),
                             end: "}".into(),
                             close: true,
+                            surround: true,
                             newline: false,
                         },
                         BracketPair {
                             start: "'".into(),
                             end: "'".into(),
                             close: true,
+                            surround: true,
                             newline: false,
                         },
                     ],
@@ -1910,12 +1912,14 @@ fn test_language_scope_at_with_rust(cx: &mut AppContext) {
                             start: "{".into(),
                             end: "}".into(),
                             close: true,
+                            surround: true,
                             newline: false,
                         },
                         BracketPair {
                             start: "'".into(),
                             end: "'".into(),
                             close: true,
+                            surround: true,
                             newline: false,
                         },
                     ],

crates/language/src/language.rs ๐Ÿ”—

@@ -61,6 +61,7 @@ use task::RunnableTag;
 pub use task_context::{ContextProvider, RunnableRange};
 use theme::SyntaxTheme;
 use tree_sitter::{self, wasmtime, Query, QueryCursor, WasmStore};
+use util::serde::default_true;
 
 pub use buffer::Operation;
 pub use buffer::*;
@@ -803,6 +804,9 @@ pub struct BracketPair {
     pub end: String,
     /// True if `end` should be automatically inserted right after `start` characters.
     pub close: bool,
+    /// True if selected text should be surrounded by `start` and `end` characters.
+    #[serde(default = "default_true")]
+    pub surround: bool,
     /// True if an extra newline should be inserted while the cursor is in the middle
     /// of that bracket pair.
     pub newline: bool,

crates/language/src/language_settings.rs ๐Ÿ”—

@@ -112,6 +112,8 @@ pub struct LanguageSettings {
     pub inlay_hints: InlayHintSettings,
     /// Whether to automatically close brackets.
     pub use_autoclose: bool,
+    /// Whether to automatically surround text with brackets.
+    pub use_auto_surround: bool,
     // Controls how the editor handles the autoclosed characters.
     pub always_treat_brackets_as_autoclosed: bool,
     /// Which code actions to run on save
@@ -315,6 +317,11 @@ pub struct LanguageSettingsContent {
     ///
     /// Default: true
     pub use_autoclose: Option<bool>,
+    /// Whether to automatically surround text with characters for you. For example,
+    /// when you select text and type (, Zed will automatically surround text with ().
+    ///
+    /// Default: true
+    pub use_auto_surround: Option<bool>,
     // Controls how the editor handles the autoclosed characters.
     // When set to `false`(default), skipping over and auto-removing of the closing characters
     // happen only for auto-inserted characters.
@@ -774,6 +781,7 @@ fn merge_settings(settings: &mut LanguageSettings, src: &LanguageSettingsContent
     merge(&mut settings.hard_tabs, src.hard_tabs);
     merge(&mut settings.soft_wrap, src.soft_wrap);
     merge(&mut settings.use_autoclose, src.use_autoclose);
+    merge(&mut settings.use_auto_surround, src.use_auto_surround);
     merge(
         &mut settings.always_treat_brackets_as_autoclosed,
         src.always_treat_brackets_as_autoclosed,

crates/vim/src/surrounds.rs ๐Ÿ”—

@@ -40,6 +40,7 @@ pub fn add_surrounds(text: Arc<str>, target: SurroundsType, cx: &mut WindowConte
                         start: text.to_string(),
                         end: text.to_string(),
                         close: true,
+                        surround: true,
                         newline: false,
                     },
                 };
@@ -227,6 +228,7 @@ pub fn change_surrounds(text: Arc<str>, target: Object, cx: &mut WindowContext)
                             start: text.to_string(),
                             end: text.to_string(),
                             close: true,
+                            surround: true,
                             newline: false,
                         },
                     };
@@ -388,54 +390,63 @@ fn all_support_surround_pair() -> Vec<BracketPair> {
             start: "{".into(),
             end: "}".into(),
             close: true,
+            surround: true,
             newline: false,
         },
         BracketPair {
             start: "'".into(),
             end: "'".into(),
             close: true,
+            surround: true,
             newline: false,
         },
         BracketPair {
             start: "`".into(),
             end: "`".into(),
             close: true,
+            surround: true,
             newline: false,
         },
         BracketPair {
             start: "\"".into(),
             end: "\"".into(),
             close: true,
+            surround: true,
             newline: false,
         },
         BracketPair {
             start: "(".into(),
             end: ")".into(),
             close: true,
+            surround: true,
             newline: false,
         },
         BracketPair {
             start: "|".into(),
             end: "|".into(),
             close: true,
+            surround: true,
             newline: false,
         },
         BracketPair {
             start: "[".into(),
             end: "]".into(),
             close: true,
+            surround: true,
             newline: false,
         },
         BracketPair {
             start: "{".into(),
             end: "}".into(),
             close: true,
+            surround: true,
             newline: false,
         },
         BracketPair {
             start: "<".into(),
             end: ">".into(),
             close: true,
+            surround: true,
             newline: false,
         },
     ];
@@ -461,48 +472,56 @@ fn object_to_bracket_pair(object: Object) -> Option<BracketPair> {
             start: "'".to_string(),
             end: "'".to_string(),
             close: true,
+            surround: true,
             newline: false,
         }),
         Object::BackQuotes => Some(BracketPair {
             start: "`".to_string(),
             end: "`".to_string(),
             close: true,
+            surround: true,
             newline: false,
         }),
         Object::DoubleQuotes => Some(BracketPair {
             start: "\"".to_string(),
             end: "\"".to_string(),
             close: true,
+            surround: true,
             newline: false,
         }),
         Object::VerticalBars => Some(BracketPair {
             start: "|".to_string(),
             end: "|".to_string(),
             close: true,
+            surround: true,
             newline: false,
         }),
         Object::Parentheses => Some(BracketPair {
             start: "(".to_string(),
             end: ")".to_string(),
             close: true,
+            surround: true,
             newline: false,
         }),
         Object::SquareBrackets => Some(BracketPair {
             start: "[".to_string(),
             end: "]".to_string(),
             close: true,
+            surround: true,
             newline: false,
         }),
         Object::CurlyBrackets => Some(BracketPair {
             start: "{".to_string(),
             end: "}".to_string(),
             close: true,
+            surround: true,
             newline: false,
         }),
         Object::AngleBrackets => Some(BracketPair {
             start: "<".to_string(),
             end: ">".to_string(),
             close: true,
+            surround: true,
             newline: false,
         }),
         _ => None,