Allow to handle autoclosed characters differently (#8666)

Tim Masliuchenko and Thorsten Ball created

Adds the `always_treat_brackets_as_autoclosed` setting to control how
the autoclosed characters are handled.

The setting is off by default, meaning the behaviour stays the same
(following how VSCode handles autoclosed characters).
When set to `true`, the autoclosed characters are always skipped over
and auto-removed no matter how they were inserted (following how Sublime
Text/Xcode handle this).


https://github.com/zed-industries/zed/assets/471335/304cd04a-59fe-450f-9c65-cc31b781b0db


https://github.com/zed-industries/zed/assets/471335/0f5b09c2-260f-48d4-8528-23f122dee45f

Release Notes:

- Added the setting `always_treat_brackets_as_autoclosed` (default:
`false`) to always treat brackets as "auto-closed" brackets, i.e.
deleting the pair when deleting start/end, etc.
([#7146](https://github.com/zed-industries/zed/issues/7146)).

---------

Co-authored-by: Thorsten Ball <mrnugget@gmail.com>

Change summary

assets/settings/default.json             |   6 
crates/editor/src/editor.rs              |  88 +++++++++--
crates/editor/src/editor_tests.rs        | 199 ++++++++++++++++++++++++++
crates/language/src/language_settings.rs |  15 +
docs/src/configuring_zed.md              |  22 ++
5 files changed, 313 insertions(+), 17 deletions(-)

Detailed changes

assets/settings/default.json 🔗

@@ -92,6 +92,12 @@
   // Whether to automatically type closing characters for you. For example,
   // when you type (, Zed will automatically add a closing ) at the correct position.
   "use_autoclose": 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.
+  // Otherwise(when `true`), the closing characters are always skipped over and auto-removed
+  // no matter how they were inserted.
+  "always_treat_brackets_as_autoclosed": false,
   // Controls whether copilot provides suggestion immediately
   // or waits for a `copilot::Toggle`
   "show_copilot_suggestions": true,

crates/editor/src/editor.rs 🔗

@@ -2432,16 +2432,23 @@ impl Editor {
                 // bracket of any of this language's bracket pairs.
                 let mut bracket_pair = None;
                 let mut is_bracket_pair_start = false;
+                let mut is_bracket_pair_end = false;
                 if !text.is_empty() {
                     // `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 enabled && pair.close && pair.start.ends_with(text.as_ref()) {
+                        if !pair.close {
+                            continue;
+                        }
+
+                        if enabled && pair.start.ends_with(text.as_ref()) {
                             bracket_pair = Some(pair.clone());
                             is_bracket_pair_start = true;
                             break;
-                        } else if pair.end.as_str() == text.as_ref() {
+                        }
+                        if pair.end.as_str() == text.as_ref() {
                             bracket_pair = Some(pair.clone());
+                            is_bracket_pair_end = true;
                             break;
                         }
                     }
@@ -2504,6 +2511,21 @@ impl Editor {
                                 continue;
                             }
                         }
+
+                        let always_treat_brackets_as_autoclosed = snapshot
+                            .settings_at(selection.start, cx)
+                            .always_treat_brackets_as_autoclosed;
+                        if always_treat_brackets_as_autoclosed
+                            && is_bracket_pair_end
+                            && snapshot.contains_str_at(selection.end, text.as_ref())
+                        {
+                            // Otherwise, when `always_treat_brackets_as_autoclosed` is set to `true
+                            // and the inserted text is a closing bracket and the selection is followed
+                            // by the closing bracket then move the selection past the closing bracket.
+                            let anchor = snapshot.anchor_after(selection.end);
+                            new_selections.push((selection.map(|_| anchor), text.len()));
+                            continue;
+                        }
                     }
                     // If an opening bracket is 1 character long and is typed while
                     // text is selected, then surround that text with the bracket pair.
@@ -3024,25 +3046,59 @@ impl Editor {
     fn select_autoclose_pair(&mut self, cx: &mut ViewContext<Self>) {
         let selections = self.selections.all::<usize>(cx);
         let buffer = self.buffer.read(cx).read(cx);
-        let mut new_selections = Vec::new();
-        for (mut selection, region) in self.selections_with_autoclose_regions(selections, &buffer) {
-            if let (Some(region), true) = (region, selection.is_empty()) {
-                let mut range = region.range.to_offset(&buffer);
-                if selection.start == range.start {
-                    if range.start >= region.pair.start.len() {
+        let new_selections = self
+            .selections_with_autoclose_regions(selections, &buffer)
+            .map(|(mut selection, region)| {
+                if !selection.is_empty() {
+                    return selection;
+                }
+
+                if let Some(region) = region {
+                    let mut range = region.range.to_offset(&buffer);
+                    if selection.start == range.start && range.start >= region.pair.start.len() {
                         range.start -= region.pair.start.len();
-                        if buffer.contains_str_at(range.start, &region.pair.start) {
-                            if buffer.contains_str_at(range.end, &region.pair.end) {
-                                range.end += region.pair.end.len();
-                                selection.start = range.start;
-                                selection.end = range.end;
+                        if buffer.contains_str_at(range.start, &region.pair.start)
+                            && buffer.contains_str_at(range.end, &region.pair.end)
+                        {
+                            range.end += region.pair.end.len();
+                            selection.start = range.start;
+                            selection.end = range.end;
+
+                            return selection;
+                        }
+                    }
+                }
+
+                let always_treat_brackets_as_autoclosed = buffer
+                    .settings_at(selection.start, cx)
+                    .always_treat_brackets_as_autoclosed;
+
+                if !always_treat_brackets_as_autoclosed {
+                    return selection;
+                }
+
+                if let Some(scope) = buffer.language_scope_at(selection.start) {
+                    for (pair, enabled) in scope.brackets() {
+                        if !enabled || !pair.close {
+                            continue;
+                        }
+
+                        if buffer.contains_str_at(selection.start, &pair.end) {
+                            let pair_start_len = pair.start.len();
+                            if buffer.contains_str_at(selection.start - pair_start_len, &pair.start)
+                            {
+                                selection.start -= pair_start_len;
+                                selection.end += pair.end.len();
+
+                                return selection;
                             }
                         }
                     }
                 }
-            }
-            new_selections.push(selection);
-        }
+
+                selection
+            })
+            .collect();
 
         drop(buffer);
         self.change_selections(None, cx, |selections| selections.select(new_selections));

crates/editor/src/editor_tests.rs 🔗

@@ -4566,6 +4566,105 @@ async fn test_autoclose_pairs(cx: &mut gpui::TestAppContext) {
     cx.assert_editor_state("a\"\"ˇ");
 }
 
+#[gpui::test]
+async fn test_always_treat_brackets_as_autoclosed_skip_over(cx: &mut gpui::TestAppContext) {
+    init_test(cx, |settings| {
+        settings.defaults.always_treat_brackets_as_autoclosed = Some(true);
+    });
+
+    let mut cx = EditorTestContext::new(cx).await;
+
+    let language = Arc::new(Language::new(
+        LanguageConfig {
+            brackets: BracketPairConfig {
+                pairs: vec![
+                    BracketPair {
+                        start: "{".to_string(),
+                        end: "}".to_string(),
+                        close: true,
+                        newline: true,
+                    },
+                    BracketPair {
+                        start: "(".to_string(),
+                        end: ")".to_string(),
+                        close: true,
+                        newline: true,
+                    },
+                    BracketPair {
+                        start: "[".to_string(),
+                        end: "]".to_string(),
+                        close: false,
+                        newline: true,
+                    },
+                ],
+                ..Default::default()
+            },
+            autoclose_before: "})]".to_string(),
+            ..Default::default()
+        },
+        Some(tree_sitter_rust::language()),
+    ));
+
+    cx.language_registry().add(language.clone());
+    cx.update_buffer(|buffer, cx| {
+        buffer.set_language(Some(language), cx);
+    });
+
+    cx.set_state(
+        &"
+            ˇ
+            ˇ
+            ˇ
+        "
+        .unindent(),
+    );
+
+    // ensure only matching closing brackets are skipped over
+    cx.update_editor(|view, cx| {
+        view.handle_input("}", cx);
+        view.move_left(&MoveLeft, cx);
+        view.handle_input(")", cx);
+        view.move_left(&MoveLeft, cx);
+    });
+    cx.assert_editor_state(
+        &"
+            ˇ)}
+            ˇ)}
+            ˇ)}
+        "
+        .unindent(),
+    );
+
+    // skip-over closing brackets at multiple cursors
+    cx.update_editor(|view, cx| {
+        view.handle_input(")", cx);
+        view.handle_input("}", cx);
+    });
+    cx.assert_editor_state(
+        &"
+            )}ˇ
+            )}ˇ
+            )}ˇ
+        "
+        .unindent(),
+    );
+
+    // ignore non-close brackets
+    cx.update_editor(|view, cx| {
+        view.handle_input("]", cx);
+        view.move_left(&MoveLeft, cx);
+        view.handle_input("]", cx);
+    });
+    cx.assert_editor_state(
+        &"
+            )}]ˇ]
+            )}]ˇ]
+            )}]ˇ]
+        "
+        .unindent(),
+    );
+}
+
 #[gpui::test]
 async fn test_autoclose_with_embedded_language(cx: &mut gpui::TestAppContext) {
     init_test(cx, |_| {});
@@ -5163,6 +5262,106 @@ async fn test_delete_autoclose_pair(cx: &mut gpui::TestAppContext) {
     });
 }
 
+#[gpui::test]
+async fn test_always_treat_brackets_as_autoclosed_delete(cx: &mut gpui::TestAppContext) {
+    init_test(cx, |settings| {
+        settings.defaults.always_treat_brackets_as_autoclosed = Some(true);
+    });
+
+    let mut cx = EditorTestContext::new(cx).await;
+
+    let language = Arc::new(Language::new(
+        LanguageConfig {
+            brackets: BracketPairConfig {
+                pairs: vec![
+                    BracketPair {
+                        start: "{".to_string(),
+                        end: "}".to_string(),
+                        close: true,
+                        newline: true,
+                    },
+                    BracketPair {
+                        start: "(".to_string(),
+                        end: ")".to_string(),
+                        close: true,
+                        newline: true,
+                    },
+                    BracketPair {
+                        start: "[".to_string(),
+                        end: "]".to_string(),
+                        close: false,
+                        newline: true,
+                    },
+                ],
+                ..Default::default()
+            },
+            autoclose_before: "})]".to_string(),
+            ..Default::default()
+        },
+        Some(tree_sitter_rust::language()),
+    ));
+
+    cx.language_registry().add(language.clone());
+    cx.update_buffer(|buffer, cx| {
+        buffer.set_language(Some(language), cx);
+    });
+
+    cx.set_state(
+        &"
+            {(ˇ)}
+            [[ˇ]]
+            {(ˇ)}
+        "
+        .unindent(),
+    );
+
+    cx.update_editor(|view, cx| {
+        view.backspace(&Default::default(), cx);
+        view.backspace(&Default::default(), cx);
+    });
+
+    cx.assert_editor_state(
+        &"
+            ˇ
+            ˇ]]
+            ˇ
+        "
+        .unindent(),
+    );
+
+    cx.update_editor(|view, cx| {
+        view.handle_input("{", cx);
+        view.handle_input("{", cx);
+        view.move_right(&MoveRight, cx);
+        view.move_right(&MoveRight, cx);
+        view.move_left(&MoveLeft, cx);
+        view.move_left(&MoveLeft, cx);
+        view.backspace(&Default::default(), cx);
+    });
+
+    cx.assert_editor_state(
+        &"
+            {ˇ}
+            {ˇ}]]
+            {ˇ}
+        "
+        .unindent(),
+    );
+
+    cx.update_editor(|view, cx| {
+        view.backspace(&Default::default(), cx);
+    });
+
+    cx.assert_editor_state(
+        &"
+            ˇ
+            ˇ]]
+            ˇ
+        "
+        .unindent(),
+    );
+}
+
 #[gpui::test]
 async fn test_auto_replace_emoji_shortcode(cx: &mut gpui::TestAppContext) {
     init_test(cx, |_| {});

crates/language/src/language_settings.rs 🔗

@@ -103,6 +103,8 @@ pub struct LanguageSettings {
     pub inlay_hints: InlayHintSettings,
     /// Whether to automatically close brackets.
     pub use_autoclose: bool,
+    // Controls how the editor handles the autoclosed characters.
+    pub always_treat_brackets_as_autoclosed: bool,
     /// Which code actions to run on save
     pub code_actions_on_format: HashMap<String, bool>,
 }
@@ -231,7 +233,14 @@ pub struct LanguageSettingsContent {
     ///
     /// Default: true
     pub use_autoclose: 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.
+    // Otherwise(when `true`), the closing characters are always skipped over and auto-removed
+    // no matter how they were inserted.
+    ///
+    /// Default: false
+    pub always_treat_brackets_as_autoclosed: Option<bool>,
     /// Which code actions to run on save
     ///
     /// Default: {} (or {"source.organizeImports": true} for Go).
@@ -602,6 +611,10 @@ 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.always_treat_brackets_as_autoclosed,
+        src.always_treat_brackets_as_autoclosed,
+    );
     merge(&mut settings.show_wrap_guides, src.show_wrap_guides);
     merge(&mut settings.wrap_guides, src.wrap_guides.clone());
     merge(

docs/src/configuring_zed.md 🔗

@@ -380,6 +380,26 @@ To override settings for a language, add an entry for that language server's nam
 
 `boolean` values
 
+## Always Treat Brackets As Autoclosed
+
+- Description: Controls how the editor handles the autoclosed characters.
+- Setting: `always_treat_brackets_as_autoclosed`
+- Default: `false`
+
+**Options**
+
+`boolean` values
+
+**Example**
+
+If the setting is set to `true`:
+
+1. Enter in the editor: `)))`
+2. Move the cursor to the start: `^)))`
+3. Enter again: `)))`
+
+The result is still `)))` and not `))))))`, which is what it would be by default.
+
 ## File Types
 
 - Setting: `file_types`
@@ -573,6 +593,8 @@ The following settings can be overridden for each specific language:
 - `show_whitespaces`
 - `soft_wrap`
 - `tab_size`
+- `use_autoclose`
+- `always_treat_brackets_as_autoclosed`
 
 These values take in the same options as the root-level settings with the same name.