vim: subword motions (#8725)

Rom Grk created

Add subword motions to vim, inspired by
[nvim-spider](https://github.com/chrisgrieser/nvim-spider),
[CamelCaseMotion](https://github.com/bkad/CamelCaseMotion).


Release Notes:

- Added subword motions to vim

Change summary

assets/keymaps/vim.json                      |  20 
crates/vim/src/motion.rs                     | 401 ++++++++++++++++++---
crates/vim/src/normal.rs                     |  72 +++
crates/vim/src/normal/change.rs              |  62 +-
crates/vim/src/test/vim_test_context.rs      |  13 
docs/src/configuring_zed__configuring_vim.md |  16 
6 files changed, 491 insertions(+), 93 deletions(-)

Detailed changes

assets/keymaps/vim.json 🔗

@@ -37,30 +37,42 @@
       "_": "vim::StartOfLineDownward",
       "g _": "vim::EndOfLineDownward",
       "shift-g": "vim::EndOfDocument",
-      "w": "vim::NextWordStart",
       "{": "vim::StartOfParagraph",
       "}": "vim::EndOfParagraph",
       "|": "vim::GoToColumn",
+
+      // Word motions
+      "w": "vim::NextWordStart",
+      "e": "vim::NextWordEnd",
+      "b": "vim::PreviousWordStart",
+      "g e": "vim::PreviousWordEnd",
+
+      // Subword motions
+      // "w": "vim::NextSubwordStart",
+      // "b": "vim::PreviousSubwordStart",
+      // "e": "vim::NextSubwordEnd",
+      // "g e": "vim::PreviousSubwordEnd",
+
       "shift-w": [
         "vim::NextWordStart",
         {
           "ignorePunctuation": true
         }
       ],
-      "e": "vim::NextWordEnd",
       "shift-e": [
         "vim::NextWordEnd",
         {
           "ignorePunctuation": true
         }
       ],
-      "b": "vim::PreviousWordStart",
       "shift-b": [
         "vim::PreviousWordStart",
         {
           "ignorePunctuation": true
         }
       ],
+      "g shift-e": ["vim::PreviousWordEnd", { "ignorePunctuation": true }],
+
       "n": "search::SelectNextMatch",
       "shift-n": "search::SelectPrevMatch",
       "%": "vim::Matching",
@@ -117,8 +129,6 @@
       "ctrl-e": "vim::LineDown",
       "ctrl-y": "vim::LineUp",
       // "g" commands
-      "g e": "vim::PreviousWordEnd",
-      "g shift-e": ["vim::PreviousWordEnd", { "ignorePunctuation": true }],
       "g g": "vim::StartOfDocument",
       "g h": "editor::Hover",
       "g t": "pane::ActivateNextItem",

crates/vim/src/motion.rs 🔗

@@ -42,6 +42,18 @@ pub enum Motion {
     PreviousWordEnd {
         ignore_punctuation: bool,
     },
+    NextSubwordStart {
+        ignore_punctuation: bool,
+    },
+    NextSubwordEnd {
+        ignore_punctuation: bool,
+    },
+    PreviousSubwordStart {
+        ignore_punctuation: bool,
+    },
+    PreviousSubwordEnd {
+        ignore_punctuation: bool,
+    },
     FirstNonWhitespace {
         display_lines: bool,
     },
@@ -110,6 +122,34 @@ struct PreviousWordEnd {
     ignore_punctuation: bool,
 }
 
+#[derive(Clone, Deserialize, PartialEq)]
+#[serde(rename_all = "camelCase")]
+pub(crate) struct NextSubwordStart {
+    #[serde(default)]
+    pub(crate) ignore_punctuation: bool,
+}
+
+#[derive(Clone, Deserialize, PartialEq)]
+#[serde(rename_all = "camelCase")]
+pub(crate) struct NextSubwordEnd {
+    #[serde(default)]
+    pub(crate) ignore_punctuation: bool,
+}
+
+#[derive(Clone, Deserialize, PartialEq)]
+#[serde(rename_all = "camelCase")]
+pub(crate) struct PreviousSubwordStart {
+    #[serde(default)]
+    pub(crate) ignore_punctuation: bool,
+}
+
+#[derive(Clone, Deserialize, PartialEq)]
+#[serde(rename_all = "camelCase")]
+pub(crate) struct PreviousSubwordEnd {
+    #[serde(default)]
+    pub(crate) ignore_punctuation: bool,
+}
+
 #[derive(Clone, Deserialize, PartialEq)]
 #[serde(rename_all = "camelCase")]
 pub(crate) struct Up {
@@ -153,10 +193,14 @@ impl_actions!(
         FirstNonWhitespace,
         Down,
         Up,
+        NextWordStart,
+        NextWordEnd,
         PreviousWordStart,
         PreviousWordEnd,
-        NextWordEnd,
-        NextWordStart
+        NextSubwordStart,
+        NextSubwordEnd,
+        PreviousSubwordStart,
+        PreviousSubwordEnd,
     ]
 );
 
@@ -264,6 +308,31 @@ pub fn register(workspace: &mut Workspace, _: &mut ViewContext<Workspace>) {
          &PreviousWordStart { ignore_punctuation }: &PreviousWordStart,
          cx: _| { motion(Motion::PreviousWordStart { ignore_punctuation }, cx) },
     );
+    workspace.register_action(
+        |_: &mut Workspace, &PreviousWordEnd { ignore_punctuation }, cx: _| {
+            motion(Motion::PreviousWordEnd { ignore_punctuation }, cx)
+        },
+    );
+    workspace.register_action(
+        |_: &mut Workspace, &NextSubwordStart { ignore_punctuation }: &NextSubwordStart, cx: _| {
+            motion(Motion::NextSubwordStart { ignore_punctuation }, cx)
+        },
+    );
+    workspace.register_action(
+        |_: &mut Workspace, &NextSubwordEnd { ignore_punctuation }: &NextSubwordEnd, cx: _| {
+            motion(Motion::NextSubwordEnd { ignore_punctuation }, cx)
+        },
+    );
+    workspace.register_action(
+        |_: &mut Workspace,
+         &PreviousSubwordStart { ignore_punctuation }: &PreviousSubwordStart,
+         cx: _| { motion(Motion::PreviousSubwordStart { ignore_punctuation }, cx) },
+    );
+    workspace.register_action(
+        |_: &mut Workspace, &PreviousSubwordEnd { ignore_punctuation }, cx: _| {
+            motion(Motion::PreviousSubwordEnd { ignore_punctuation }, cx)
+        },
+    );
     workspace.register_action(|_: &mut Workspace, &NextLineStart, cx: _| {
         motion(Motion::NextLineStart, cx)
     });
@@ -304,11 +373,6 @@ pub fn register(workspace: &mut Workspace, _: &mut ViewContext<Workspace>) {
     workspace.register_action(|_: &mut Workspace, &WindowBottom, cx: _| {
         motion(Motion::WindowBottom, cx)
     });
-    workspace.register_action(
-        |_: &mut Workspace, &PreviousWordEnd { ignore_punctuation }, cx: _| {
-            motion(Motion::PreviousWordEnd { ignore_punctuation }, cx)
-        },
-    );
 }
 
 pub(crate) fn motion(motion: Motion, cx: &mut WindowContext) {
@@ -349,7 +413,6 @@ impl Motion {
             | WindowBottom
             | EndOfParagraph => true,
             EndOfLine { .. }
-            | NextWordEnd { .. }
             | Matching
             | FindForward { .. }
             | Left
@@ -360,8 +423,13 @@ impl Motion {
             | EndOfLineDownward
             | GoToColumn
             | NextWordStart { .. }
+            | NextWordEnd { .. }
             | PreviousWordStart { .. }
             | PreviousWordEnd { .. }
+            | NextSubwordStart { .. }
+            | NextSubwordEnd { .. }
+            | PreviousSubwordStart { .. }
+            | PreviousSubwordEnd { .. }
             | FirstNonWhitespace { .. }
             | FindBackward { .. }
             | RepeatFind { .. }
@@ -376,7 +444,6 @@ impl Motion {
             Down { .. }
             | Up { .. }
             | EndOfLine { .. }
-            | NextWordEnd { .. }
             | Matching
             | FindForward { .. }
             | RepeatFind { .. }
@@ -391,14 +458,19 @@ impl Motion {
             | EndOfLineDownward
             | GoToColumn
             | NextWordStart { .. }
+            | NextWordEnd { .. }
             | PreviousWordStart { .. }
+            | PreviousWordEnd { .. }
+            | NextSubwordStart { .. }
+            | NextSubwordEnd { .. }
+            | PreviousSubwordStart { .. }
+            | PreviousSubwordEnd { .. }
             | FirstNonWhitespace { .. }
             | FindBackward { .. }
             | RepeatFindReversed { .. }
             | WindowTop
             | WindowMiddle
             | WindowBottom
-            | PreviousWordEnd { .. }
             | NextLineStart => false,
         }
     }
@@ -413,13 +485,15 @@ impl Motion {
             | CurrentLine
             | EndOfLine { .. }
             | EndOfLineDownward
-            | NextWordEnd { .. }
             | Matching
             | FindForward { .. }
             | WindowTop
             | WindowMiddle
             | WindowBottom
+            | NextWordEnd { .. }
             | PreviousWordEnd { .. }
+            | NextSubwordEnd { .. }
+            | PreviousSubwordEnd { .. }
             | NextLineStart => true,
             Left
             | Backspace
@@ -432,6 +506,8 @@ impl Motion {
             | GoToColumn
             | NextWordStart { .. }
             | PreviousWordStart { .. }
+            | NextSubwordStart { .. }
+            | PreviousSubwordStart { .. }
             | FirstNonWhitespace { .. }
             | FindBackward { .. } => false,
             RepeatFind { last_find: motion } | RepeatFindReversed { last_find: motion } => {
@@ -473,7 +549,7 @@ impl Motion {
                 SelectionGoal::None,
             ),
             NextWordEnd { ignore_punctuation } => (
-                next_word_end(map, point, *ignore_punctuation, times),
+                next_word_end(map, point, *ignore_punctuation, times, true),
                 SelectionGoal::None,
             ),
             PreviousWordStart { ignore_punctuation } => (
@@ -484,6 +560,22 @@ impl Motion {
                 previous_word_end(map, point, *ignore_punctuation, times),
                 SelectionGoal::None,
             ),
+            NextSubwordStart { ignore_punctuation } => (
+                next_subword_start(map, point, *ignore_punctuation, times),
+                SelectionGoal::None,
+            ),
+            NextSubwordEnd { ignore_punctuation } => (
+                next_subword_end(map, point, *ignore_punctuation, times, true),
+                SelectionGoal::None,
+            ),
+            PreviousSubwordStart { ignore_punctuation } => (
+                previous_subword_start(map, point, *ignore_punctuation, times),
+                SelectionGoal::None,
+            ),
+            PreviousSubwordEnd { ignore_punctuation } => (
+                previous_subword_end(map, point, *ignore_punctuation, times),
+                SelectionGoal::None,
+            ),
             FirstNonWhitespace { display_lines } => (
                 first_non_whitespace(map, *display_lines, point),
                 SelectionGoal::None,
@@ -819,6 +911,25 @@ pub(crate) fn right(map: &DisplaySnapshot, mut point: DisplayPoint, times: usize
     point
 }
 
+pub(crate) fn next_char(
+    map: &DisplaySnapshot,
+    point: DisplayPoint,
+    allow_cross_newline: bool,
+) -> DisplayPoint {
+    let mut new_point = point;
+    let mut max_column = map.line_len(new_point.row());
+    if !allow_cross_newline {
+        max_column -= 1;
+    }
+    if new_point.column() < max_column {
+        *new_point.column_mut() += 1;
+    } else if new_point < map.max_point() && allow_cross_newline {
+        *new_point.row_mut() += 1;
+        *new_point.column_mut() = 0;
+    }
+    new_point
+}
+
 pub(crate) fn next_word_start(
     map: &DisplaySnapshot,
     mut point: DisplayPoint,
@@ -848,22 +959,17 @@ pub(crate) fn next_word_start(
     point
 }
 
-fn next_word_end(
+pub(crate) fn next_word_end(
     map: &DisplaySnapshot,
     mut point: DisplayPoint,
     ignore_punctuation: bool,
     times: usize,
+    allow_cross_newline: bool,
 ) -> DisplayPoint {
     let scope = map.buffer_snapshot.language_scope_at(point.to_point(map));
     for _ in 0..times {
-        let mut new_point = point;
-        if new_point.column() < map.line_len(new_point.row()) {
-            *new_point.column_mut() += 1;
-        } else if new_point < map.max_point() {
-            *new_point.row_mut() += 1;
-            *new_point.column_mut() = 0;
-        }
-
+        let new_point = next_char(map, point, allow_cross_newline);
+        let mut need_next_char = false;
         let new_point = movement::find_boundary_exclusive(
             map,
             new_point,
@@ -871,10 +977,21 @@ fn next_word_end(
             |left, right| {
                 let left_kind = coerce_punctuation(char_kind(&scope, left), ignore_punctuation);
                 let right_kind = coerce_punctuation(char_kind(&scope, right), ignore_punctuation);
+                let at_newline = right == '\n';
+
+                if !allow_cross_newline && at_newline {
+                    need_next_char = true;
+                    return true;
+                }
 
                 left_kind != right_kind && left_kind != CharKind::Whitespace
             },
         );
+        let new_point = if need_next_char {
+            next_char(map, new_point, true)
+        } else {
+            new_point
+        };
         let new_point = map.clip_point(new_point, Bias::Left);
         if point == new_point {
             break;
@@ -913,6 +1030,210 @@ fn previous_word_start(
     point
 }
 
+fn previous_word_end(
+    map: &DisplaySnapshot,
+    point: DisplayPoint,
+    ignore_punctuation: bool,
+    times: usize,
+) -> DisplayPoint {
+    let scope = map.buffer_snapshot.language_scope_at(point.to_point(map));
+    let mut point = point.to_point(map);
+
+    if point.column < map.buffer_snapshot.line_len(point.row) {
+        point.column += 1;
+    }
+    for _ in 0..times {
+        let new_point = movement::find_preceding_boundary_point(
+            &map.buffer_snapshot,
+            point,
+            FindRange::MultiLine,
+            |left, right| {
+                let left_kind = coerce_punctuation(char_kind(&scope, left), ignore_punctuation);
+                let right_kind = coerce_punctuation(char_kind(&scope, right), ignore_punctuation);
+                match (left_kind, right_kind) {
+                    (CharKind::Punctuation, CharKind::Whitespace)
+                    | (CharKind::Punctuation, CharKind::Word)
+                    | (CharKind::Word, CharKind::Whitespace)
+                    | (CharKind::Word, CharKind::Punctuation) => true,
+                    (CharKind::Whitespace, CharKind::Whitespace) => left == '\n' && right == '\n',
+                    _ => false,
+                }
+            },
+        );
+        if new_point == point {
+            break;
+        }
+        point = new_point;
+    }
+    movement::saturating_left(map, point.to_display_point(map))
+}
+
+fn next_subword_start(
+    map: &DisplaySnapshot,
+    mut point: DisplayPoint,
+    ignore_punctuation: bool,
+    times: usize,
+) -> DisplayPoint {
+    let scope = map.buffer_snapshot.language_scope_at(point.to_point(map));
+    for _ in 0..times {
+        let mut crossed_newline = false;
+        let new_point = movement::find_boundary(map, point, FindRange::MultiLine, |left, right| {
+            let left_kind = coerce_punctuation(char_kind(&scope, left), ignore_punctuation);
+            let right_kind = coerce_punctuation(char_kind(&scope, right), ignore_punctuation);
+            let at_newline = right == '\n';
+
+            let is_word_start = (left_kind != right_kind) && !left.is_alphanumeric();
+            let is_subword_start =
+                left == '_' && right != '_' || left.is_lowercase() && right.is_uppercase();
+
+            let found = (!right.is_whitespace() && (is_word_start || is_subword_start))
+                || at_newline && crossed_newline
+                || at_newline && left == '\n'; // Prevents skipping repeated empty lines
+
+            crossed_newline |= at_newline;
+            found
+        });
+        if point == new_point {
+            break;
+        }
+        point = new_point;
+    }
+    point
+}
+
+pub(crate) fn next_subword_end(
+    map: &DisplaySnapshot,
+    mut point: DisplayPoint,
+    ignore_punctuation: bool,
+    times: usize,
+    allow_cross_newline: bool,
+) -> DisplayPoint {
+    let scope = map.buffer_snapshot.language_scope_at(point.to_point(map));
+    for _ in 0..times {
+        let new_point = next_char(map, point, allow_cross_newline);
+
+        let mut crossed_newline = false;
+        let mut need_backtrack = false;
+        let new_point =
+            movement::find_boundary(map, new_point, FindRange::MultiLine, |left, right| {
+                let left_kind = coerce_punctuation(char_kind(&scope, left), ignore_punctuation);
+                let right_kind = coerce_punctuation(char_kind(&scope, right), ignore_punctuation);
+                let at_newline = right == '\n';
+
+                if !allow_cross_newline && at_newline {
+                    return true;
+                }
+
+                let is_word_end = (left_kind != right_kind) && !right.is_alphanumeric();
+                let is_subword_end =
+                    left != '_' && right == '_' || left.is_lowercase() && right.is_uppercase();
+
+                let found = !left.is_whitespace() && !at_newline && (is_word_end || is_subword_end);
+
+                if found && (is_word_end || is_subword_end) {
+                    need_backtrack = true;
+                }
+
+                crossed_newline |= at_newline;
+                found
+            });
+        let mut new_point = map.clip_point(new_point, Bias::Left);
+        if need_backtrack {
+            *new_point.column_mut() -= 1;
+        }
+        if point == new_point {
+            break;
+        }
+        point = new_point;
+    }
+    point
+}
+
+fn previous_subword_start(
+    map: &DisplaySnapshot,
+    mut point: DisplayPoint,
+    ignore_punctuation: bool,
+    times: usize,
+) -> DisplayPoint {
+    let scope = map.buffer_snapshot.language_scope_at(point.to_point(map));
+    for _ in 0..times {
+        let mut crossed_newline = false;
+        // This works even though find_preceding_boundary is called for every character in the line containing
+        // cursor because the newline is checked only once.
+        let new_point = movement::find_preceding_boundary_display_point(
+            map,
+            point,
+            FindRange::MultiLine,
+            |left, right| {
+                let left_kind = coerce_punctuation(char_kind(&scope, left), ignore_punctuation);
+                let right_kind = coerce_punctuation(char_kind(&scope, right), ignore_punctuation);
+                let at_newline = right == '\n';
+
+                let is_word_start = (left_kind != right_kind) && !left.is_alphanumeric();
+                let is_subword_start =
+                    left == '_' && right != '_' || left.is_lowercase() && right.is_uppercase();
+
+                let found = (!right.is_whitespace() && (is_word_start || is_subword_start))
+                    || at_newline && crossed_newline
+                    || at_newline && left == '\n'; // Prevents skipping repeated empty lines
+
+                crossed_newline |= at_newline;
+
+                found
+            },
+        );
+        if point == new_point {
+            break;
+        }
+        point = new_point;
+    }
+    point
+}
+
+fn previous_subword_end(
+    map: &DisplaySnapshot,
+    point: DisplayPoint,
+    ignore_punctuation: bool,
+    times: usize,
+) -> DisplayPoint {
+    let scope = map.buffer_snapshot.language_scope_at(point.to_point(map));
+    let mut point = point.to_point(map);
+
+    if point.column < map.buffer_snapshot.line_len(point.row) {
+        point.column += 1;
+    }
+    for _ in 0..times {
+        let new_point = movement::find_preceding_boundary_point(
+            &map.buffer_snapshot,
+            point,
+            FindRange::MultiLine,
+            |left, right| {
+                let left_kind = coerce_punctuation(char_kind(&scope, left), ignore_punctuation);
+                let right_kind = coerce_punctuation(char_kind(&scope, right), ignore_punctuation);
+
+                let is_subword_end =
+                    left != '_' && right == '_' || left.is_lowercase() && right.is_uppercase();
+
+                if is_subword_end {
+                    return true;
+                }
+
+                match (left_kind, right_kind) {
+                    (CharKind::Word, CharKind::Whitespace)
+                    | (CharKind::Word, CharKind::Punctuation) => true,
+                    (CharKind::Whitespace, CharKind::Whitespace) => left == '\n' && right == '\n',
+                    _ => false,
+                }
+            },
+        );
+        if new_point == point {
+            break;
+        }
+        point = new_point;
+    }
+    movement::saturating_left(map, point.to_display_point(map))
+}
+
 pub(crate) fn first_non_whitespace(
     map: &DisplaySnapshot,
     display_lines: bool,
@@ -1217,44 +1538,6 @@ fn window_bottom(
     }
 }
 
-fn previous_word_end(
-    map: &DisplaySnapshot,
-    point: DisplayPoint,
-    ignore_punctuation: bool,
-    times: usize,
-) -> DisplayPoint {
-    let scope = map.buffer_snapshot.language_scope_at(point.to_point(map));
-    let mut point = point.to_point(map);
-
-    if point.column < map.buffer_snapshot.line_len(point.row) {
-        point.column += 1;
-    }
-    for _ in 0..times {
-        let new_point = movement::find_preceding_boundary_point(
-            &map.buffer_snapshot,
-            point,
-            FindRange::MultiLine,
-            |left, right| {
-                let left_kind = coerce_punctuation(char_kind(&scope, left), ignore_punctuation);
-                let right_kind = coerce_punctuation(char_kind(&scope, right), ignore_punctuation);
-                match (left_kind, right_kind) {
-                    (CharKind::Punctuation, CharKind::Whitespace)
-                    | (CharKind::Punctuation, CharKind::Word)
-                    | (CharKind::Word, CharKind::Whitespace)
-                    | (CharKind::Word, CharKind::Punctuation) => true,
-                    (CharKind::Whitespace, CharKind::Whitespace) => left == '\n' && right == '\n',
-                    _ => false,
-                }
-            },
-        );
-        if new_point == point {
-            break;
-        }
-        point = new_point;
-    }
-    movement::saturating_left(map, point.to_display_point(map))
-}
-
 #[cfg(test)]
 mod test {
 

crates/vim/src/normal.rs 🔗

@@ -407,11 +407,12 @@ pub(crate) fn normal_replace(text: Arc<str>, cx: &mut WindowContext) {
 
 #[cfg(test)]
 mod test {
-    use gpui::TestAppContext;
+    use gpui::{KeyBinding, TestAppContext};
     use indoc::indoc;
     use settings::SettingsStore;
 
     use crate::{
+        motion,
         state::Mode::{self},
         test::{NeovimBackedTestContext, VimTestContext},
         VimSettings,
@@ -1045,4 +1046,73 @@ mod test {
         cx.simulate_shared_keystrokes(["4", "$"]).await;
         cx.assert_shared_state("aa\nbb\ncˇc").await;
     }
+
+    #[gpui::test]
+    async fn test_subword_motions(cx: &mut gpui::TestAppContext) {
+        let mut cx = VimTestContext::new(cx, true).await;
+        cx.update(|cx| {
+            cx.bind_keys(vec![
+                KeyBinding::new(
+                    "w",
+                    motion::NextSubwordStart {
+                        ignore_punctuation: false,
+                    },
+                    Some("Editor && VimControl && !VimWaiting && !menu"),
+                ),
+                KeyBinding::new(
+                    "b",
+                    motion::PreviousSubwordStart {
+                        ignore_punctuation: false,
+                    },
+                    Some("Editor && VimControl && !VimWaiting && !menu"),
+                ),
+                KeyBinding::new(
+                    "e",
+                    motion::NextSubwordEnd {
+                        ignore_punctuation: false,
+                    },
+                    Some("Editor && VimControl && !VimWaiting && !menu"),
+                ),
+                KeyBinding::new(
+                    "g e",
+                    motion::PreviousSubwordEnd {
+                        ignore_punctuation: false,
+                    },
+                    Some("Editor && VimControl && !VimWaiting && !menu"),
+                ),
+            ]);
+        });
+
+        cx.assert_binding_normal(
+            ["w"],
+            indoc! {"ˇassert_binding"},
+            indoc! {"assert_ˇbinding"},
+        );
+        // Special case: In 'cw', 'w' acts like 'e'
+        cx.assert_binding(
+            ["c", "w"],
+            indoc! {"ˇassert_binding"},
+            Mode::Normal,
+            indoc! {"ˇ_binding"},
+            Mode::Insert,
+        );
+
+        cx.assert_binding_normal(
+            ["e"],
+            indoc! {"ˇassert_binding"},
+            indoc! {"asserˇt_binding"},
+        );
+
+        cx.assert_binding_normal(
+            ["b"],
+            indoc! {"assert_ˇbinding"},
+            indoc! {"ˇassert_binding"},
+        );
+
+        cx.assert_binding_normal(
+            ["g", "e"],
+            indoc! {"assert_bindinˇg"},
+            indoc! {"asserˇt_binding"},
+        );
+    }
 }

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

@@ -1,15 +1,12 @@
 use crate::{
-    motion::Motion,
+    motion::{self, Motion},
     object::Object,
     state::Mode,
-    utils::{coerce_punctuation, copy_selections_content},
+    utils::copy_selections_content,
     Vim,
 };
 use editor::{
-    display_map::DisplaySnapshot,
-    movement::{self, FindRange, TextLayoutDetails},
-    scroll::Autoscroll,
-    DisplayPoint,
+    display_map::DisplaySnapshot, movement::TextLayoutDetails, scroll::Autoscroll, DisplayPoint,
 };
 use gpui::WindowContext;
 use language::{char_kind, CharKind, Selection};
@@ -39,6 +36,16 @@ pub fn change_motion(vim: &mut Vim, motion: Motion, times: Option<usize>, cx: &m
                             times,
                             ignore_punctuation,
                             &text_layout_details,
+                            false,
+                        )
+                    } else if let Motion::NextSubwordStart { ignore_punctuation } = motion {
+                        expand_changed_word_selection(
+                            map,
+                            selection,
+                            times,
+                            ignore_punctuation,
+                            &text_layout_details,
+                            true,
                         )
                     } else {
                         motion.expand_selection(map, selection, times, false, &text_layout_details)
@@ -94,6 +101,7 @@ fn expand_changed_word_selection(
     times: Option<usize>,
     ignore_punctuation: bool,
     text_layout_details: &TextLayoutDetails,
+    use_subword: bool,
 ) -> bool {
     if times.is_none() || times.unwrap() == 1 {
         let scope = map
@@ -106,32 +114,30 @@ fn expand_changed_word_selection(
             .unwrap_or_default();
 
         if in_word {
-            selection.end =
-                movement::find_boundary(map, selection.end, FindRange::MultiLine, |left, right| {
-                    let left_kind = coerce_punctuation(char_kind(&scope, left), ignore_punctuation);
-                    let right_kind =
-                        coerce_punctuation(char_kind(&scope, right), ignore_punctuation);
-
-                    left_kind != right_kind && left_kind != CharKind::Whitespace
-                });
+            if !use_subword {
+                selection.end =
+                    motion::next_word_end(map, selection.end, ignore_punctuation, 1, false);
+            } else {
+                selection.end =
+                    motion::next_subword_end(map, selection.end, ignore_punctuation, 1, false);
+            }
+            selection.end = motion::next_char(map, selection.end, false);
             true
         } else {
-            Motion::NextWordStart { ignore_punctuation }.expand_selection(
-                map,
-                selection,
-                None,
-                false,
-                &text_layout_details,
-            )
+            let motion = if use_subword {
+                Motion::NextSubwordStart { ignore_punctuation }
+            } else {
+                Motion::NextWordStart { ignore_punctuation }
+            };
+            motion.expand_selection(map, selection, None, false, &text_layout_details)
         }
     } else {
-        Motion::NextWordStart { ignore_punctuation }.expand_selection(
-            map,
-            selection,
-            times,
-            false,
-            &text_layout_details,
-        )
+        let motion = if use_subword {
+            Motion::NextSubwordStart { ignore_punctuation }
+        } else {
+            Motion::NextWordStart { ignore_punctuation }
+        };
+        motion.expand_selection(map, selection, times, false, &text_layout_details)
     }
 }
 

crates/vim/src/test/vim_test_context.rs 🔗

@@ -159,6 +159,19 @@ impl VimTestContext {
         assert_eq!(self.mode(), mode_after, "{}", self.assertion_context());
         assert_eq!(self.active_operator(), None, "{}", self.assertion_context());
     }
+
+    pub fn assert_binding_normal<const COUNT: usize>(
+        &mut self,
+        keystrokes: [&str; COUNT],
+        initial_state: &str,
+        state_after: &str,
+    ) {
+        self.set_state(initial_state, Mode::Normal);
+        self.cx.simulate_keystrokes(keystrokes);
+        self.cx.assert_editor_state(state_after);
+        assert_eq!(self.mode(), Mode::Normal, "{}", self.assertion_context());
+        assert_eq!(self.active_operator(), None, "{}", self.assertion_context());
+    }
 }
 
 impl Deref for VimTestContext {

docs/src/configuring_zed__configuring_vim.md 🔗

@@ -88,6 +88,22 @@ You can see the bindings that are enabled by default in vim mode [here](https://
 
 The details of the context are a little out of scope for this doc, but suffice to say that `menu` is true when a menu is open (e.g. the completions menu), `VimWaiting` is true after you type `f` or `t` when we’re waiting for a new key (and you probably don’t want bindings to happen). Please reach out on [GitHub](https://github.com/zed-industries/zed) if you want help making a key bindings work.
 
+## Subword motion
+
+Subword motion is not enabled by default. To enable it, add these bindings to your keymap.
+
+```json
+  {
+    "context": "Editor && VimControl && !VimWaiting && !menu",
+    "bindings": {
+      "w": "vim::NextSubwordStart",
+      "b": "vim::PreviousSubwordStart",
+      "e": "vim::NextSubwordEnd",
+      "g e": "vim::PreviousSubwordEnd"
+    }
+  },
+```
+
 ## Command palette
 
 Vim mode allows you to enable Zed’s command palette with `:`. This means that you can use vim's command palette to run any action that Zed supports.