vim: Update change surrounds to match vim's behavior (#38721)

These changes refactor the whitespace handling logic for Vim's change
surrounds command (`cs`), making its behavior closely match
[tpope/vim-surround](https://github.com/tpope/vim-surround), following
[this
discussion](https://github.com/zed-industries/zed/issues/38169#issuecomment-3304129461).

Zed's current implementation has two main differences when compared to
[tpope/vim-surround](https://github.com/tpope/vim-surround):

- It only considers whether a single space should be added or removed,
instead of all the space that is between the surrounding character and
the content
- It only takes into consideration the new surrounding characters in
order to determine whether to add or remove that space

A review of
[tpope/vim-surround](https://github.com/tpope/vim-surround)'s behavior
reveals these rules for whitespace:

* Quote to Quote
    * Whitespace is never changed
* Quote to Bracket
    * If opening bracket, add one space
    * If closing bracket, do not add space
* Bracket to Bracket
    * If opening to opening, keep only one space
    * If opening to closing, remove all space
    * If closing to opening, add one space
    * If closing to closing, do not change space
* Bracket to Quote
    * If opening, remove all space
    * If closing, preserve all space

Below is a table with examples for each scenario. A new test has also
been added to specifically check the scenarios outlined above,
`vim::surrounds::test::test_change_surrounds_vim`.

| Type              | Before      | Command | After         |
|-------------------|-------------|---------|---------------|
| Quote → Quote     | `'   a   '` | `cs'"`  | `"   a   "`   |
| Quote → Quote     | `"   a   "` | `cs"'`  | `'   a   '`   |
| Quote → Bracket   | `'   a   '` | `cs'{`  | `{    a    }` |
| Quote → Bracket   | `'   a   '` | `cs'}`  | `{   a   }`   |
| Bracket → Bracket | `[   a   ]` | `cs[{`  | `{ a }`       |
| Bracket → Bracket | `[   a   ]` | `cs[}`  | `{a}`         |
| Bracket → Bracket | `[   a   ]` | `cs]{`  | `{    a    }` |
| Bracket → Bracket | `[   a   ]` | `cs]}`  | `{   a   }`   |
| Bracket → Quote   | `[   a   ]` | `cs['`  | `'a'`         |
| Bracket → Quote   | `[   a   ]` | `cs]'`  | `'   a   '`   |

These changes diverge from
[tpope/vim-surround](https://github.com/tpope/vim-surround) when
handling newlines. For example, with the following snippet:

```rust
fn test_surround() {
    if 2 > 1 {
        println!("place cursor here");
    }
};
```

Placing the cursor inside the string and running any combination of
‎`cs{[`, ‎`cs{]`, ‎`cs}[`, or ‎`cs}]` would previously remove newline
characters. With these changes, using commands like ‎`cs}]` will now
preserve newlines.

Related to #38169
Closes #39334

Release Notes:

- Improved Vim’s change surround command to closely match
[tpope/vim-surround](https://github.com/tpope/vim-surround) behavior.

---------

Co-authored-by: Conrad Irwin <conrad.irwin@gmail.com>
This commit is contained in:
Dino
2025-10-09 12:18:48 +01:00
committed by GitHub
co-authored by Conrad Irwin
parent ba2337ffb9
commit 3d4f488d46
6 changed files with 210 additions and 53 deletions
+56 -17
View File
@@ -85,6 +85,41 @@ pub struct CandidateWithRanges {
close_range: Range<usize>,
}
/// Selects text at the same indentation level.
#[derive(Clone, Deserialize, JsonSchema, PartialEq, Action)]
#[action(namespace = vim)]
#[serde(deny_unknown_fields)]
struct Parentheses {
#[serde(default)]
opening: bool,
}
/// Selects text at the same indentation level.
#[derive(Clone, Deserialize, JsonSchema, PartialEq, Action)]
#[action(namespace = vim)]
#[serde(deny_unknown_fields)]
struct SquareBrackets {
#[serde(default)]
opening: bool,
}
/// Selects text at the same indentation level.
#[derive(Clone, Deserialize, JsonSchema, PartialEq, Action)]
#[action(namespace = vim)]
#[serde(deny_unknown_fields)]
struct AngleBrackets {
#[serde(default)]
opening: bool,
}
/// Selects text at the same indentation level.
#[derive(Clone, Deserialize, JsonSchema, PartialEq, Action)]
#[action(namespace = vim)]
#[serde(deny_unknown_fields)]
struct CurlyBrackets {
#[serde(default)]
opening: bool,
}
fn cover_or_next<I: Iterator<Item = (Range<usize>, Range<usize>)>>(
candidates: Option<I>,
caret: DisplayPoint,
@@ -275,18 +310,10 @@ actions!(
DoubleQuotes,
/// Selects text within vertical bars (pipes).
VerticalBars,
/// Selects text within parentheses.
Parentheses,
/// Selects text within the nearest brackets.
MiniBrackets,
/// Selects text within any type of brackets.
AnyBrackets,
/// Selects text within square brackets.
SquareBrackets,
/// Selects text within curly brackets.
CurlyBrackets,
/// Selects text within angle brackets.
AngleBrackets,
/// Selects a function argument.
Argument,
/// Selects an HTML/XML tag.
@@ -350,17 +377,17 @@ pub fn register(editor: &mut Editor, cx: &mut Context<Vim>) {
Vim::action(editor, cx, |vim, _: &DoubleQuotes, window, cx| {
vim.object(Object::DoubleQuotes, window, cx)
});
Vim::action(editor, cx, |vim, _: &Parentheses, window, cx| {
vim.object(Object::Parentheses, window, cx)
Vim::action(editor, cx, |vim, action: &Parentheses, window, cx| {
vim.object_impl(Object::Parentheses, action.opening, window, cx)
});
Vim::action(editor, cx, |vim, _: &SquareBrackets, window, cx| {
vim.object(Object::SquareBrackets, window, cx)
Vim::action(editor, cx, |vim, action: &SquareBrackets, window, cx| {
vim.object_impl(Object::SquareBrackets, action.opening, window, cx)
});
Vim::action(editor, cx, |vim, _: &CurlyBrackets, window, cx| {
vim.object(Object::CurlyBrackets, window, cx)
Vim::action(editor, cx, |vim, action: &CurlyBrackets, window, cx| {
vim.object_impl(Object::CurlyBrackets, action.opening, window, cx)
});
Vim::action(editor, cx, |vim, _: &AngleBrackets, window, cx| {
vim.object(Object::AngleBrackets, window, cx)
Vim::action(editor, cx, |vim, action: &AngleBrackets, window, cx| {
vim.object_impl(Object::AngleBrackets, action.opening, window, cx)
});
Vim::action(editor, cx, |vim, _: &VerticalBars, window, cx| {
vim.object(Object::VerticalBars, window, cx)
@@ -394,10 +421,22 @@ pub fn register(editor: &mut Editor, cx: &mut Context<Vim>) {
impl Vim {
fn object(&mut self, object: Object, window: &mut Window, cx: &mut Context<Self>) {
self.object_impl(object, false, window, cx);
}
fn object_impl(
&mut self,
object: Object,
opening: bool,
window: &mut Window,
cx: &mut Context<Self>,
) {
let count = Self::take_count(cx);
match self.mode {
Mode::Normal | Mode::HelixNormal => self.normal_object(object, count, window, cx),
Mode::Normal | Mode::HelixNormal => {
self.normal_object(object, count, opening, window, cx)
}
Mode::Visual | Mode::VisualLine | Mode::VisualBlock | Mode::HelixSelect => {
self.visual_object(object, count, window, cx)
}