vim: Add `gdefault` setting to set `/g` as a default substitution flag (#47664)

Ran Benita and dino created

Add support for Vim's `gdefault` option which makes the `:substitute`
command replace all matches in a line by default, instead of just the
first match. When enabled, the `/g` flag inverts this behavior.

- Add `vim.gdefault` setting
- Add `:set gdefault`, `:set nogdefault` (and short forms `:set gd`, `:set nogd`)
- Fix handling of multiple `/g` flags so that each one inverts the one before

Closes #36209

Release Notes:

- vim: Add `vim.gdefault` setting to make `/g` (replace all matches in a line) the default for substitutions, along with `:set gdefault` and `:set nogdefault` commands (short forms: `gd`, `nogd`)

---------

Co-authored-by: dino <dinojoaocosta@gmail.com>

Change summary

assets/settings/default.json                    |  1 
crates/settings_content/src/settings_content.rs |  3 
crates/settings_ui/src/page_data.rs             | 15 ++++
crates/vim/src/command.rs                       | 23 ++++++
crates/vim/src/normal/search.rs                 | 61 +++++++++++++++++
crates/vim/src/vim.rs                           |  4 
crates/vim/test_data/test_replace_gdefault.json | 64 +++++++++++++++++++
docs/src/vim.md                                 |  2 
8 files changed, 166 insertions(+), 7 deletions(-)

Detailed changes

assets/settings/default.json 🔗

@@ -2275,6 +2275,7 @@
     "toggle_relative_line_numbers": false,
     "use_system_clipboard": "always",
     "use_smartcase_find": false,
+    "gdefault": false,
     "highlight_on_yank_duration": 200,
     "custom_digraphs": {},
     // Cursor shape for each mode.

crates/settings_content/src/settings_content.rs 🔗

@@ -701,6 +701,9 @@ pub struct VimSettingsContent {
     pub toggle_relative_line_numbers: Option<bool>,
     pub use_system_clipboard: Option<UseSystemClipboard>,
     pub use_smartcase_find: Option<bool>,
+    /// When enabled, the `:substitute` command replaces all matches in a line
+    /// by default. The 'g' flag then toggles this behavior.,
+    pub gdefault: Option<bool>,
     pub custom_digraphs: Option<HashMap<String, Arc<str>>>,
     pub highlight_on_yank_duration: Option<u64>,
     pub cursor_shape: Option<CursorShapeSettings>,

crates/settings_ui/src/page_data.rs 🔗

@@ -2410,7 +2410,7 @@ fn editor_page() -> SettingsPage {
         ]
     }
 
-    fn vim_settings_section() -> [SettingsPageItem; 11] {
+    fn vim_settings_section() -> [SettingsPageItem; 12] {
         [
             SettingsPageItem::SectionHeader("Vim"),
             SettingsPageItem::SettingItem(SettingItem {
@@ -2484,6 +2484,19 @@ fn editor_page() -> SettingsPage {
                 metadata: None,
                 files: USER,
             }),
+            SettingsPageItem::SettingItem(SettingItem {
+                title: "Global Substitution Default",
+                description: "When enabled, the :substitute command replaces all matches in a line by default. The 'g' flag then toggles this behavior.",
+                field: Box::new(SettingField {
+                    json_path: Some("vim.gdefault"),
+                    pick: |settings_content| settings_content.vim.as_ref()?.gdefault.as_ref(),
+                    write: |settings_content, value| {
+                        settings_content.vim.get_or_insert_default().gdefault = value;
+                    },
+                }),
+                metadata: None,
+                files: USER,
+            }),
             SettingsPageItem::SettingItem(SettingItem {
                 title: "Highlight on Yank Duration",
                 description: "Duration in milliseconds to highlight yanked text in Vim mode.",

crates/vim/src/command.rs 🔗

@@ -40,7 +40,7 @@ use workspace::{SplitDirection, notifications::DetachAndPromptErr};
 use zed_actions::{OpenDocs, RevealTarget};
 
 use crate::{
-    ToggleMarksView, ToggleRegistersView, Vim,
+    ToggleMarksView, ToggleRegistersView, Vim, VimSettings,
     motion::{EndOfDocument, Motion, MotionKind, StartOfDocument},
     normal::{
         JoinLines,
@@ -88,6 +88,7 @@ pub enum VimOption {
     Number(bool),
     RelativeNumber(bool),
     IgnoreCase(bool),
+    GDefault(bool),
 }
 
 impl VimOption {
@@ -134,6 +135,10 @@ impl VimOption {
             (None, VimOption::IgnoreCase(false)),
             (Some("ic"), VimOption::IgnoreCase(true)),
             (Some("noic"), VimOption::IgnoreCase(false)),
+            (None, VimOption::GDefault(true)),
+            (Some("gd"), VimOption::GDefault(true)),
+            (None, VimOption::GDefault(false)),
+            (Some("nogd"), VimOption::GDefault(false)),
         ]
         .into_iter()
         .filter(move |(prefix, option)| prefix.unwrap_or(option.to_string()).starts_with(query))
@@ -160,6 +165,11 @@ impl VimOption {
             "noignorecase" => Some(Self::IgnoreCase(false)),
             "noic" => Some(Self::IgnoreCase(false)),
 
+            "gdefault" => Some(Self::GDefault(true)),
+            "gd" => Some(Self::GDefault(true)),
+            "nogdefault" => Some(Self::GDefault(false)),
+            "nogd" => Some(Self::GDefault(false)),
+
             _ => None,
         }
     }
@@ -174,6 +184,8 @@ impl VimOption {
             VimOption::RelativeNumber(false) => "norelativenumber",
             VimOption::IgnoreCase(true) => "ignorecase",
             VimOption::IgnoreCase(false) => "noignorecase",
+            VimOption::GDefault(true) => "gdefault",
+            VimOption::GDefault(false) => "nogdefault",
         }
     }
 }
@@ -271,7 +283,6 @@ impl Deref for WrappedAction {
 }
 
 pub fn register(editor: &mut Editor, cx: &mut Context<Vim>) {
-    // Vim::action(editor, cx, |vim, action: &StartOfLine, window, cx| {
     Vim::action(editor, cx, |vim, action: &VimSet, _, cx| {
         for option in action.options.iter() {
             vim.update_editor(cx, |_, editor, cx| match option {
@@ -295,6 +306,14 @@ pub fn register(editor: &mut Editor, cx: &mut Context<Vim>) {
                         store.override_global(settings);
                     });
                 }
+                VimOption::GDefault(enabled) => {
+                    let mut settings = VimSettings::get_global(cx).clone();
+                    settings.gdefault = *enabled;
+
+                    SettingsStore::update(cx, |store, _| {
+                        store.override_global(settings);
+                    })
+                }
             });
         }
     });

crates/vim/src/normal/search.rs 🔗

@@ -10,7 +10,7 @@ use util::serde::default_true;
 use workspace::{notifications::NotifyResultExt, searchable::Direction};
 
 use crate::{
-    Vim,
+    Vim, VimSettings,
     command::CommandRange,
     motion::Motion,
     state::{Mode, SearchState},
@@ -581,7 +581,9 @@ impl Vim {
                 )
             }
 
-            if !replacement.flag_g {
+            // gdefault inverts the behavior of the 'g' flag.
+            let replace_all = VimSettings::get_global(cx).gdefault != replacement.flag_g;
+            if !replace_all {
                 options.set(SearchOptions::ONE_MATCH_PER_LINE, true);
             }
 
@@ -704,7 +706,7 @@ impl Replacement {
 
         for c in flags.chars() {
             match c {
-                'g' => replacement.flag_g = true,
+                'g' => replacement.flag_g = !replacement.flag_g,
                 'n' => replacement.flag_n = true,
                 'c' => replacement.flag_c = true,
                 'i' => replacement.case_sensitive = Some(false),
@@ -1143,6 +1145,59 @@ mod test {
         });
     }
 
+    #[gpui::test]
+    async fn test_replace_gdefault(cx: &mut gpui::TestAppContext) {
+        let mut cx = NeovimBackedTestContext::new(cx).await;
+
+        // Set the `gdefault` option in both Zed and Neovim.
+        cx.simulate_shared_keystrokes(": s e t space g d e f a u l t")
+            .await;
+        cx.simulate_shared_keystrokes("enter").await;
+
+        cx.set_shared_state(indoc! {
+            "ˇaa aa aa aa
+                aa
+                aa"
+        })
+        .await;
+
+        // With gdefault on, :s/// replaces all matches (like :s///g normally).
+        cx.simulate_shared_keystrokes(": s / a a / b b").await;
+        cx.simulate_shared_keystrokes("enter").await;
+        cx.shared_state().await.assert_eq(indoc! {
+            "ˇbb bb bb bb
+                aa
+                aa"
+        });
+
+        // With gdefault on, :s///g replaces only the first match.
+        cx.simulate_shared_keystrokes(": s / b b / c c / g").await;
+        cx.simulate_shared_keystrokes("enter").await;
+        cx.shared_state().await.assert_eq(indoc! {
+            "ˇcc bb bb bb
+                aa
+                aa"
+        });
+
+        // Each successive `/g` flag should invert the one before it.
+        cx.simulate_shared_keystrokes(": s / b b / d d / g g").await;
+        cx.simulate_shared_keystrokes("enter").await;
+        cx.shared_state().await.assert_eq(indoc! {
+            "ˇcc dd dd dd
+                aa
+                aa"
+        });
+
+        cx.simulate_shared_keystrokes(": s / c c / e e / g g g")
+            .await;
+        cx.simulate_shared_keystrokes("enter").await;
+        cx.shared_state().await.assert_eq(indoc! {
+            "ˇee dd dd dd
+                aa
+                aa"
+        });
+    }
+
     #[gpui::test]
     async fn test_replace_c(cx: &mut gpui::TestAppContext) {
         let mut cx = VimTestContext::new(cx, true).await;

crates/vim/src/vim.rs 🔗

@@ -2075,12 +2075,13 @@ struct VimEditorSettingsState {
     hide_edit_predictions: bool,
 }
 
-#[derive(RegisterSetting)]
+#[derive(Clone, RegisterSetting)]
 struct VimSettings {
     pub default_mode: Mode,
     pub toggle_relative_line_numbers: bool,
     pub use_system_clipboard: settings::UseSystemClipboard,
     pub use_smartcase_find: bool,
+    pub gdefault: bool,
     pub custom_digraphs: HashMap<String, Arc<str>>,
     pub highlight_on_yank_duration: u64,
     pub cursor_shape: CursorShapeSettings,
@@ -2166,6 +2167,7 @@ impl Settings for VimSettings {
             toggle_relative_line_numbers: vim.toggle_relative_line_numbers.unwrap(),
             use_system_clipboard: vim.use_system_clipboard.unwrap(),
             use_smartcase_find: vim.use_smartcase_find.unwrap(),
+            gdefault: vim.gdefault.unwrap(),
             custom_digraphs: vim.custom_digraphs.unwrap(),
             highlight_on_yank_duration: vim.highlight_on_yank_duration.unwrap(),
             cursor_shape: vim.cursor_shape.unwrap().into(),

crates/vim/test_data/test_replace_gdefault.json 🔗

@@ -0,0 +1,64 @@
+{"Key":":"}
+{"Key":"s"}
+{"Key":"e"}
+{"Key":"t"}
+{"Key":"space"}
+{"Key":"g"}
+{"Key":"d"}
+{"Key":"e"}
+{"Key":"f"}
+{"Key":"a"}
+{"Key":"u"}
+{"Key":"l"}
+{"Key":"t"}
+{"Key":"enter"}
+{"Put":{"state":"ˇaa aa aa aa\naa\naa"}}
+{"Key":":"}
+{"Key":"s"}
+{"Key":"/"}
+{"Key":"a"}
+{"Key":"a"}
+{"Key":"/"}
+{"Key":"b"}
+{"Key":"b"}
+{"Key":"enter"}
+{"Get":{"state":"ˇbb bb bb bb\naa\naa","mode":"Normal"}}
+{"Key":":"}
+{"Key":"s"}
+{"Key":"/"}
+{"Key":"b"}
+{"Key":"b"}
+{"Key":"/"}
+{"Key":"c"}
+{"Key":"c"}
+{"Key":"/"}
+{"Key":"g"}
+{"Key":"enter"}
+{"Get":{"state":"ˇcc bb bb bb\naa\naa","mode":"Normal"}}
+{"Key":":"}
+{"Key":"s"}
+{"Key":"/"}
+{"Key":"b"}
+{"Key":"b"}
+{"Key":"/"}
+{"Key":"d"}
+{"Key":"d"}
+{"Key":"/"}
+{"Key":"g"}
+{"Key":"g"}
+{"Key":"enter"}
+{"Get":{"state":"ˇcc dd dd dd\naa\naa","mode":"Normal"}}
+{"Key":":"}
+{"Key":"s"}
+{"Key":"/"}
+{"Key":"c"}
+{"Key":"c"}
+{"Key":"/"}
+{"Key":"e"}
+{"Key":"e"}
+{"Key":"/"}
+{"Key":"g"}
+{"Key":"g"}
+{"Key":"g"}
+{"Key":"enter"}
+{"Get":{"state":"ˇee dd dd dd\naa\naa","mode":"Normal"}}

docs/src/vim.md 🔗

@@ -569,6 +569,7 @@ You can change the following settings to modify vim mode's behavior:
 | use_system_clipboard         | Determines how system clipboard is used:<br><ul><li>"always": use for all operations</li><li>"never": only use when explicitly specified</li><li>"on_yank": use for yank operations</li></ul> | "always"      |
 | use_multiline_find           | deprecated                                                                                                                                                                                    |
 | use_smartcase_find           | If `true`, `f` and `t` motions are case-insensitive when the target letter is lowercase.                                                                                                      | false         |
+| gdefault                     | If `true`, the `:substitute` command replaces all matches in a line by default (as if `g` flag was given). The `g` flag then toggles this, replacing only the first match.                    | false         |
 | toggle_relative_line_numbers | If `true`, line numbers are relative in normal mode and absolute in insert mode, giving you the best of both options.                                                                         | false         |
 | custom_digraphs              | An object that allows you to add custom digraphs. Read below for an example.                                                                                                                  | {}            |
 | highlight_on_yank_duration   | The duration of the highlight animation(in ms). Set to `0` to disable                                                                                                                         | 200           |
@@ -593,6 +594,7 @@ Here's an example of these settings changed:
     "default_mode": "insert",
     "use_system_clipboard": "never",
     "use_smartcase_find": true,
+    "gdefault": true,
     "relative_line_numbers": "enabled",
     "highlight_on_yank_duration": 50,
     "custom_digraphs": {