Merge pull request #877 from zed-industries/misc-normal-commands

Keith Simmons created

Add inclusive vs exclusive motions to vim mode

Change summary

assets/keymaps/vim.json            |  28 
crates/editor/src/display_map.rs   |  13 
crates/editor/src/editor.rs        |  14 
crates/editor/src/movement.rs      |  62 +
crates/vim/src/insert.rs           |   2 
crates/vim/src/motion.rs           | 144 +++-
crates/vim/src/normal.rs           | 926 +++++++++----------------------
crates/vim/src/normal/change.rs    | 436 +++++++++++++++
crates/vim/src/normal/delete.rs    | 386 +++++++++++++
crates/vim/src/vim.rs              |  10 
crates/vim/src/vim_test_context.rs |  77 ++
11 files changed, 1,349 insertions(+), 749 deletions(-)

Detailed changes

assets/keymaps/vim.json 🔗

@@ -75,37 +75,13 @@
     {
         "context": "Editor && vim_operator == c",
         "bindings": {
-            "w": [
-                "vim::NextWordEnd",
-                {
-                    "ignorePunctuation": false
-                }
-            ],
+            "w": "vim::ChangeWord",
             "shift-W": [
-                "vim::NextWordEnd",
+                "vim::ChangeWord",
                 {
                     "ignorePunctuation": true
                 }
             ]
         }
-    },
-    {
-        "context": "Editor && vim_operator == d",
-        "bindings": {
-            "w": [
-                "vim::NextWordStart",
-                {
-                    "ignorePunctuation": false,
-                    "stopAtNewline": true
-                }
-            ],
-            "shift-W": [
-                "vim::NextWordStart",
-                {
-                    "ignorePunctuation": true,
-                    "stopAtNewline": true
-                }
-            ]
-        }
     }
 ]

crates/editor/src/display_map.rs 🔗

@@ -814,14 +814,20 @@ pub mod tests {
             DisplayPoint::new(0, 7)
         );
         assert_eq!(
-            movement::up(&snapshot, DisplayPoint::new(1, 10), SelectionGoal::None),
+            movement::up(
+                &snapshot,
+                DisplayPoint::new(1, 10),
+                SelectionGoal::None,
+                false
+            ),
             (DisplayPoint::new(0, 7), SelectionGoal::Column(10))
         );
         assert_eq!(
             movement::down(
                 &snapshot,
                 DisplayPoint::new(0, 7),
-                SelectionGoal::Column(10)
+                SelectionGoal::Column(10),
+                false
             ),
             (DisplayPoint::new(1, 10), SelectionGoal::Column(10))
         );
@@ -829,7 +835,8 @@ pub mod tests {
             movement::down(
                 &snapshot,
                 DisplayPoint::new(1, 10),
-                SelectionGoal::Column(10)
+                SelectionGoal::Column(10),
+                false
             ),
             (DisplayPoint::new(2, 4), SelectionGoal::Column(10))
         );

crates/editor/src/editor.rs 🔗

@@ -1134,8 +1134,10 @@ impl Editor {
     }
 
     pub fn set_clip_at_line_ends(&mut self, clip: bool, cx: &mut ViewContext<Self>) {
-        self.display_map
-            .update(cx, |map, _| map.clip_at_line_ends = clip);
+        if self.display_map.read(cx).clip_at_line_ends != clip {
+            self.display_map
+                .update(cx, |map, _| map.clip_at_line_ends = clip);
+        }
     }
 
     pub fn set_keymap_context_layer<Tag: 'static>(&mut self, context: gpui::keymap::Context) {
@@ -3579,13 +3581,13 @@ impl Editor {
             if !selection.is_empty() {
                 selection.goal = SelectionGoal::None;
             }
-            let (cursor, goal) = movement::up(&map, selection.start, selection.goal);
+            let (cursor, goal) = movement::up(&map, selection.start, selection.goal, false);
             selection.collapse_to(cursor, goal);
         });
     }
 
     pub fn select_up(&mut self, _: &SelectUp, cx: &mut ViewContext<Self>) {
-        self.move_selection_heads(cx, movement::up)
+        self.move_selection_heads(cx, |map, head, goal| movement::up(map, head, goal, false))
     }
 
     pub fn move_down(&mut self, _: &MoveDown, cx: &mut ViewContext<Self>) {
@@ -3606,13 +3608,13 @@ impl Editor {
             if !selection.is_empty() {
                 selection.goal = SelectionGoal::None;
             }
-            let (cursor, goal) = movement::down(&map, selection.end, selection.goal);
+            let (cursor, goal) = movement::down(&map, selection.end, selection.goal, false);
             selection.collapse_to(cursor, goal);
         });
     }
 
     pub fn select_down(&mut self, _: &SelectDown, cx: &mut ViewContext<Self>) {
-        self.move_selection_heads(cx, movement::down)
+        self.move_selection_heads(cx, |map, head, goal| movement::down(map, head, goal, false))
     }
 
     pub fn move_to_previous_word_start(

crates/editor/src/movement.rs 🔗

@@ -28,6 +28,7 @@ pub fn up(
     map: &DisplaySnapshot,
     start: DisplayPoint,
     goal: SelectionGoal,
+    preserve_column_at_start: bool,
 ) -> (DisplayPoint, SelectionGoal) {
     let mut goal_column = if let SelectionGoal::Column(column) = goal {
         column
@@ -42,6 +43,8 @@ pub fn up(
     );
     if point.row() < start.row() {
         *point.column_mut() = map.column_from_chars(point.row(), goal_column);
+    } else if preserve_column_at_start {
+        return (start, goal);
     } else {
         point = DisplayPoint::new(0, 0);
         goal_column = 0;
@@ -63,6 +66,7 @@ pub fn down(
     map: &DisplaySnapshot,
     start: DisplayPoint,
     goal: SelectionGoal,
+    preserve_column_at_end: bool,
 ) -> (DisplayPoint, SelectionGoal) {
     let mut goal_column = if let SelectionGoal::Column(column) = goal {
         column
@@ -74,6 +78,8 @@ pub fn down(
     let mut point = map.clip_point(DisplayPoint::new(next_row, 0), Bias::Right);
     if point.row() > start.row() {
         *point.column_mut() = map.column_from_chars(point.row(), goal_column);
+    } else if preserve_column_at_end {
+        return (start, goal);
     } else {
         point = map.max_point();
         goal_column = map.column_to_chars(point.row(), point.column())
@@ -503,41 +509,81 @@ mod tests {
 
         // Can't move up into the first excerpt's header
         assert_eq!(
-            up(&snapshot, DisplayPoint::new(2, 2), SelectionGoal::Column(2)),
+            up(
+                &snapshot,
+                DisplayPoint::new(2, 2),
+                SelectionGoal::Column(2),
+                false
+            ),
             (DisplayPoint::new(2, 0), SelectionGoal::Column(0)),
         );
         assert_eq!(
-            up(&snapshot, DisplayPoint::new(2, 0), SelectionGoal::None),
+            up(
+                &snapshot,
+                DisplayPoint::new(2, 0),
+                SelectionGoal::None,
+                false
+            ),
             (DisplayPoint::new(2, 0), SelectionGoal::Column(0)),
         );
 
         // Move up and down within first excerpt
         assert_eq!(
-            up(&snapshot, DisplayPoint::new(3, 4), SelectionGoal::Column(4)),
+            up(
+                &snapshot,
+                DisplayPoint::new(3, 4),
+                SelectionGoal::Column(4),
+                false
+            ),
             (DisplayPoint::new(2, 3), SelectionGoal::Column(4)),
         );
         assert_eq!(
-            down(&snapshot, DisplayPoint::new(2, 3), SelectionGoal::Column(4)),
+            down(
+                &snapshot,
+                DisplayPoint::new(2, 3),
+                SelectionGoal::Column(4),
+                false
+            ),
             (DisplayPoint::new(3, 4), SelectionGoal::Column(4)),
         );
 
         // Move up and down across second excerpt's header
         assert_eq!(
-            up(&snapshot, DisplayPoint::new(6, 5), SelectionGoal::Column(5)),
+            up(
+                &snapshot,
+                DisplayPoint::new(6, 5),
+                SelectionGoal::Column(5),
+                false
+            ),
             (DisplayPoint::new(3, 4), SelectionGoal::Column(5)),
         );
         assert_eq!(
-            down(&snapshot, DisplayPoint::new(3, 4), SelectionGoal::Column(5)),
+            down(
+                &snapshot,
+                DisplayPoint::new(3, 4),
+                SelectionGoal::Column(5),
+                false
+            ),
             (DisplayPoint::new(6, 5), SelectionGoal::Column(5)),
         );
 
         // Can't move down off the end
         assert_eq!(
-            down(&snapshot, DisplayPoint::new(7, 0), SelectionGoal::Column(0)),
+            down(
+                &snapshot,
+                DisplayPoint::new(7, 0),
+                SelectionGoal::Column(0),
+                false
+            ),
             (DisplayPoint::new(7, 2), SelectionGoal::Column(2)),
         );
         assert_eq!(
-            down(&snapshot, DisplayPoint::new(7, 2), SelectionGoal::Column(2)),
+            down(
+                &snapshot,
+                DisplayPoint::new(7, 2),
+                SelectionGoal::Column(2),
+                false
+            ),
             (DisplayPoint::new(7, 2), SelectionGoal::Column(2)),
         );
     }

crates/vim/src/insert.rs 🔗

@@ -28,7 +28,7 @@ mod test {
 
     #[gpui::test]
     async fn test_enter_and_exit_insert_mode(cx: &mut gpui::TestAppContext) {
-        let mut cx = VimTestContext::new(cx, true, "").await;
+        let mut cx = VimTestContext::new(cx, true).await;
         cx.simulate_keystroke("i");
         assert_eq!(cx.mode(), Mode::Insert);
         cx.simulate_keystrokes(["T", "e", "s", "t"]);

crates/vim/src/motion.rs 🔗

@@ -4,7 +4,7 @@ use editor::{
     movement, Bias, DisplayPoint,
 };
 use gpui::{actions, impl_actions, MutableAppContext};
-use language::SelectionGoal;
+use language::{Selection, SelectionGoal};
 use serde::Deserialize;
 use workspace::Workspace;
 
@@ -14,22 +14,15 @@ use crate::{
     Vim,
 };
 
-#[derive(Copy, Clone)]
+#[derive(Copy, Clone, Debug)]
 pub enum Motion {
     Left,
     Down,
     Up,
     Right,
-    NextWordStart {
-        ignore_punctuation: bool,
-        stop_at_newline: bool,
-    },
-    NextWordEnd {
-        ignore_punctuation: bool,
-    },
-    PreviousWordStart {
-        ignore_punctuation: bool,
-    },
+    NextWordStart { ignore_punctuation: bool },
+    NextWordEnd { ignore_punctuation: bool },
+    PreviousWordStart { ignore_punctuation: bool },
     StartOfLine,
     EndOfLine,
     StartOfDocument,
@@ -41,8 +34,6 @@ pub enum Motion {
 struct NextWordStart {
     #[serde(default)]
     ignore_punctuation: bool,
-    #[serde(default)]
-    stop_at_newline: bool,
 }
 
 #[derive(Clone, Deserialize)]
@@ -87,19 +78,8 @@ pub fn init(cx: &mut MutableAppContext) {
     cx.add_action(|_: &mut Workspace, _: &EndOfDocument, cx: _| motion(Motion::EndOfDocument, cx));
 
     cx.add_action(
-        |_: &mut Workspace,
-         &NextWordStart {
-             ignore_punctuation,
-             stop_at_newline,
-         }: &NextWordStart,
-         cx: _| {
-            motion(
-                Motion::NextWordStart {
-                    ignore_punctuation,
-                    stop_at_newline,
-                },
-                cx,
-            )
+        |_: &mut Workspace, &NextWordStart { ignore_punctuation }: &NextWordStart, cx: _| {
+            motion(Motion::NextWordStart { ignore_punctuation }, cx)
         },
     );
     cx.add_action(
@@ -128,29 +108,48 @@ fn motion(motion: Motion, cx: &mut MutableAppContext) {
     }
 }
 
+// Motion handling is specified here:
+// https://github.com/vim/vim/blob/master/runtime/doc/motion.txt
 impl Motion {
+    pub fn linewise(self) -> bool {
+        use Motion::*;
+        match self {
+            Down | Up | StartOfDocument | EndOfDocument => true,
+            _ => false,
+        }
+    }
+
+    pub fn inclusive(self) -> bool {
+        use Motion::*;
+        if self.linewise() {
+            return true;
+        }
+
+        match self {
+            EndOfLine | NextWordEnd { .. } => true,
+            Left | Right | StartOfLine | NextWordStart { .. } | PreviousWordStart { .. } => false,
+            _ => panic!("Exclusivity not defined for {self:?}"),
+        }
+    }
+
     pub fn move_point(
         self,
         map: &DisplaySnapshot,
         point: DisplayPoint,
         goal: SelectionGoal,
-        block_cursor_positioning: bool,
     ) -> (DisplayPoint, SelectionGoal) {
         use Motion::*;
         match self {
             Left => (left(map, point), SelectionGoal::None),
-            Down => movement::down(map, point, goal),
-            Up => movement::up(map, point, goal),
+            Down => movement::down(map, point, goal, true),
+            Up => movement::up(map, point, goal, true),
             Right => (right(map, point), SelectionGoal::None),
-            NextWordStart {
-                ignore_punctuation,
-                stop_at_newline,
-            } => (
-                next_word_start(map, point, ignore_punctuation, stop_at_newline),
+            NextWordStart { ignore_punctuation } => (
+                next_word_start(map, point, ignore_punctuation),
                 SelectionGoal::None,
             ),
             NextWordEnd { ignore_punctuation } => (
-                next_word_end(map, point, ignore_punctuation, block_cursor_positioning),
+                next_word_end(map, point, ignore_punctuation),
                 SelectionGoal::None,
             ),
             PreviousWordStart { ignore_punctuation } => (
@@ -164,11 +163,55 @@ impl Motion {
         }
     }
 
-    pub fn line_wise(self) -> bool {
-        use Motion::*;
-        match self {
-            Down | Up | StartOfDocument | EndOfDocument => true,
-            _ => false,
+    // Expands a selection using self motion for an operator
+    pub fn expand_selection(
+        self,
+        map: &DisplaySnapshot,
+        selection: &mut Selection<DisplayPoint>,
+        expand_to_surrounding_newline: bool,
+    ) {
+        let (head, goal) = self.move_point(map, selection.head(), selection.goal);
+        selection.set_head(head, goal);
+
+        if self.linewise() {
+            selection.start = map.prev_line_boundary(selection.start.to_point(map)).1;
+
+            if expand_to_surrounding_newline {
+                if selection.end.row() < map.max_point().row() {
+                    *selection.end.row_mut() += 1;
+                    *selection.end.column_mut() = 0;
+                    // Don't reset the end here
+                    return;
+                } else if selection.start.row() > 0 {
+                    *selection.start.row_mut() -= 1;
+                    *selection.start.column_mut() = map.line_len(selection.start.row());
+                }
+            }
+
+            selection.end = map.next_line_boundary(selection.end.to_point(map)).1;
+        } else {
+            // If the motion is exclusive and the end of the motion is in column 1, the
+            // end of the motion is moved to the end of the previous line and the motion
+            // becomes inclusive. Example: "}" moves to the first line after a paragraph,
+            // but "d}" will not include that line.
+            let mut inclusive = self.inclusive();
+            if !inclusive
+                && selection.end.row() > selection.start.row()
+                && selection.end.column() == 0
+                && selection.end.row() > 0
+            {
+                inclusive = true;
+                *selection.end.row_mut() -= 1;
+                *selection.end.column_mut() = 0;
+                selection.end = map.clip_point(
+                    map.next_line_boundary(selection.end.to_point(map)).1,
+                    Bias::Left,
+                );
+            }
+
+            if inclusive && selection.end.column() < map.line_len(selection.end.row()) {
+                *selection.end.column_mut() += 1;
+            }
         }
     }
 }
@@ -187,7 +230,6 @@ fn next_word_start(
     map: &DisplaySnapshot,
     point: DisplayPoint,
     ignore_punctuation: bool,
-    stop_at_newline: bool,
 ) -> DisplayPoint {
     let mut crossed_newline = false;
     movement::find_boundary(map, point, |left, right| {
@@ -196,8 +238,8 @@ fn next_word_start(
         let at_newline = right == '\n';
 
         let found = (left_kind != right_kind && !right.is_whitespace())
-            || (at_newline && (crossed_newline || stop_at_newline))
-            || (at_newline && left == '\n'); // Prevents skipping repeated empty lines
+            || at_newline && crossed_newline
+            || at_newline && left == '\n'; // Prevents skipping repeated empty lines
 
         if at_newline {
             crossed_newline = true;
@@ -210,7 +252,6 @@ fn next_word_end(
     map: &DisplaySnapshot,
     mut point: DisplayPoint,
     ignore_punctuation: bool,
-    before_end_character: bool,
 ) -> DisplayPoint {
     *point.column_mut() += 1;
     point = movement::find_boundary(map, point, |left, right| {
@@ -221,13 +262,12 @@ fn next_word_end(
     });
     // find_boundary clips, so if the character after the next character is a newline or at the end of the document, we know
     // we have backtraced already
-    if before_end_character
-        && !map
-            .chars_at(point)
-            .skip(1)
-            .next()
-            .map(|c| c == '\n')
-            .unwrap_or(true)
+    if !map
+        .chars_at(point)
+        .skip(1)
+        .next()
+        .map(|c| c == '\n')
+        .unwrap_or(true)
     {
         *point.column_mut() = point.column().saturating_sub(1);
     }

crates/vim/src/normal.rs 🔗

@@ -1,11 +1,17 @@
-use crate::{
-    motion::Motion,
-    state::{Mode, Operator},
-    Vim,
-};
-use editor::Bias;
-use gpui::MutableAppContext;
-use language::SelectionGoal;
+mod change;
+mod delete;
+
+use crate::{motion::Motion, state::Operator, Vim};
+use change::init as change_init;
+use gpui::{actions, MutableAppContext};
+
+use self::{change::change_over, delete::delete_over};
+
+actions!(vim, [InsertLineAbove, InsertLineBelow, InsertAfter]);
+
+pub fn init(cx: &mut MutableAppContext) {
+    change_init(cx);
+}
 
 pub fn normal_motion(motion: Motion, cx: &mut MutableAppContext) {
     Vim::update(cx, |vim, cx| {
@@ -23,82 +29,7 @@ pub fn normal_motion(motion: Motion, cx: &mut MutableAppContext) {
 
 fn move_cursor(vim: &mut Vim, motion: Motion, cx: &mut MutableAppContext) {
     vim.update_active_editor(cx, |editor, cx| {
-        editor.move_cursors(cx, |map, cursor, goal| {
-            motion.move_point(map, cursor, goal, true)
-        })
-    });
-}
-
-fn change_over(vim: &mut Vim, motion: Motion, cx: &mut MutableAppContext) {
-    vim.update_active_editor(cx, |editor, cx| {
-        editor.transact(cx, |editor, cx| {
-            // Don't clip at line ends during change operation
-            editor.set_clip_at_line_ends(false, cx);
-            editor.move_selections(cx, |map, selection| {
-                let (head, goal) = motion.move_point(map, selection.head(), selection.goal, false);
-                selection.set_head(head, goal);
-
-                if motion.line_wise() {
-                    selection.start = map.prev_line_boundary(selection.start.to_point(map)).1;
-                    selection.end = map.next_line_boundary(selection.end.to_point(map)).1;
-                }
-            });
-            editor.set_clip_at_line_ends(true, cx);
-            editor.insert(&"", cx);
-        });
-    });
-    vim.switch_mode(Mode::Insert, cx)
-}
-
-fn delete_over(vim: &mut Vim, motion: Motion, cx: &mut MutableAppContext) {
-    vim.update_active_editor(cx, |editor, cx| {
-        editor.transact(cx, |editor, cx| {
-            // Use goal column to preserve previous position
-            editor.set_clip_at_line_ends(false, cx);
-            editor.move_selections(cx, |map, selection| {
-                let original_head = selection.head();
-                let (head, _) = motion.move_point(map, selection.head(), selection.goal, false);
-                // Set the goal column to the original position in order to fix it up
-                // after the deletion
-                selection.set_head(head, SelectionGoal::Column(original_head.column()));
-
-                if motion.line_wise() {
-                    if selection.end.row() == map.max_point().row() {
-                        // Delete previous line break since we are at the end of the document
-                        if selection.start.row() > 0 {
-                            *selection.start.row_mut() = selection.start.row().saturating_sub(1);
-                            selection.start = map.clip_point(selection.start, Bias::Left);
-                            selection.start =
-                                map.next_line_boundary(selection.start.to_point(map)).1;
-                        } else {
-                            // Selection covers the whole document. Just delete to the start of the
-                            // line.
-                            selection.start =
-                                map.prev_line_boundary(selection.start.to_point(map)).1;
-                        }
-                        selection.end = map.next_line_boundary(selection.end.to_point(map)).1;
-                    } else {
-                        // Delete next line break so that we leave the previous line alone
-                        selection.start = map.prev_line_boundary(selection.start.to_point(map)).1;
-                        *selection.end.column_mut() = 0;
-                        *selection.end.row_mut() += 1;
-                        selection.end = map.clip_point(selection.end, Bias::Left);
-                    }
-                }
-            });
-            editor.insert(&"", cx);
-
-            // Fixup cursor position after the deletion
-            editor.set_clip_at_line_ends(true, cx);
-            editor.move_cursors(cx, |map, mut cursor, goal| {
-                if motion.line_wise() {
-                    if let SelectionGoal::Column(column) = goal {
-                        *cursor.column_mut() = column
-                    }
-                }
-                (map.clip_point(cursor, Bias::Left), SelectionGoal::None)
-            });
-        });
+        editor.move_cursors(cx, |map, cursor, goal| motion.move_point(map, cursor, goal))
     });
 }
 
@@ -116,144 +47,218 @@ mod test {
     };
 
     #[gpui::test]
-    async fn test_hjkl(cx: &mut gpui::TestAppContext) {
-        let mut cx = VimTestContext::new(cx, true, "Test\nTestTest\nTest").await;
-        cx.simulate_keystroke("l");
-        cx.assert_editor_state(indoc! {"
-            T|est
-            TestTest
-            Test"});
-        cx.simulate_keystroke("h");
-        cx.assert_editor_state(indoc! {"
-            |Test
-            TestTest
-            Test"});
-        cx.simulate_keystroke("j");
-        cx.assert_editor_state(indoc! {"
-            Test
-            |TestTest
-            Test"});
-        cx.simulate_keystroke("k");
-        cx.assert_editor_state(indoc! {"
-            |Test
-            TestTest
-            Test"});
-        cx.simulate_keystroke("j");
-        cx.assert_editor_state(indoc! {"
-            Test
-            |TestTest
-            Test"});
+    async fn test_h(cx: &mut gpui::TestAppContext) {
+        let cx = VimTestContext::new(cx, true).await;
+        let mut cx = cx.binding(["h"]);
+        cx.assert("The q|uick", "The |quick");
+        cx.assert("|The quick", "|The quick");
+        cx.assert(
+            indoc! {"
+                The quick
+                |brown"},
+            indoc! {"
+                The quick
+                |brown"},
+        );
+    }
 
-        // When moving left, cursor does not wrap to the previous line
-        cx.simulate_keystroke("h");
-        cx.assert_editor_state(indoc! {"
-            Test
-            |TestTest
-            Test"});
+    #[gpui::test]
+    async fn test_l(cx: &mut gpui::TestAppContext) {
+        let cx = VimTestContext::new(cx, true).await;
+        let mut cx = cx.binding(["l"]);
+        cx.assert("The q|uick", "The qu|ick");
+        cx.assert("The quic|k", "The quic|k");
+        cx.assert(
+            indoc! {"
+                The quic|k
+                brown"},
+            indoc! {"
+                The quic|k
+                brown"},
+        );
+    }
 
-        // When moving right, cursor does not reach the line end or wrap to the next line
-        for _ in 0..9 {
-            cx.simulate_keystroke("l");
-        }
-        cx.assert_editor_state(indoc! {"
-            Test
-            TestTes|t
-            Test"});
+    #[gpui::test]
+    async fn test_j(cx: &mut gpui::TestAppContext) {
+        let cx = VimTestContext::new(cx, true).await;
+        let mut cx = cx.binding(["j"]);
+        cx.assert(
+            indoc! {"
+                The |quick
+                brown fox"},
+            indoc! {"
+                The quick
+                brow|n fox"},
+        );
+        cx.assert(
+            indoc! {"
+                The quick
+                brow|n fox"},
+            indoc! {"
+                The quick
+                brow|n fox"},
+        );
+        cx.assert(
+            indoc! {"
+                The quic|k
+                brown"},
+            indoc! {"
+                The quick
+                brow|n"},
+        );
+        cx.assert(
+            indoc! {"
+                The quick
+                |brown"},
+            indoc! {"
+                The quick
+                |brown"},
+        );
+    }
 
-        // Goal column respects the inability to reach the end of the line
-        cx.simulate_keystroke("k");
-        cx.assert_editor_state(indoc! {"
-            Tes|t
-            TestTest
-            Test"});
-        cx.simulate_keystroke("j");
-        cx.assert_editor_state(indoc! {"
-            Test
-            TestTes|t
-            Test"});
+    #[gpui::test]
+    async fn test_k(cx: &mut gpui::TestAppContext) {
+        let cx = VimTestContext::new(cx, true).await;
+        let mut cx = cx.binding(["k"]);
+        cx.assert(
+            indoc! {"
+                The |quick
+                brown fox"},
+            indoc! {"
+                The |quick
+                brown fox"},
+        );
+        cx.assert(
+            indoc! {"
+                The quick
+                brow|n fox"},
+            indoc! {"
+                The |quick
+                brown fox"},
+        );
+        cx.assert(
+            indoc! {"
+                The
+                quic|k"},
+            indoc! {"
+                Th|e
+                quick"},
+        );
     }
 
     #[gpui::test]
     async fn test_jump_to_line_boundaries(cx: &mut gpui::TestAppContext) {
-        let initial_content = indoc! {"
-            Test Test
-            
-            T"};
-        let mut cx = VimTestContext::new(cx, true, initial_content).await;
-
-        cx.simulate_keystroke("shift-$");
-        cx.assert_editor_state(indoc! {"
-            Test Tes|t
-            
-            T"});
-        cx.simulate_keystroke("0");
-        cx.assert_editor_state(indoc! {"
-            |Test Test
-            
-            T"});
-
-        cx.simulate_keystroke("j");
-        cx.simulate_keystroke("shift-$");
-        cx.assert_editor_state(indoc! {"
-            Test Test
-            |
-            T"});
-        cx.simulate_keystroke("0");
-        cx.assert_editor_state(indoc! {"
-            Test Test
-            |
-            T"});
+        let cx = VimTestContext::new(cx, true).await;
+        let mut cx = cx.binding(["shift-$"]);
+        cx.assert("T|est test", "Test tes|t");
+        cx.assert("Test tes|t", "Test tes|t");
+        cx.assert(
+            indoc! {"
+                The |quick
+                brown"},
+            indoc! {"
+                The quic|k
+                brown"},
+        );
+        cx.assert(
+            indoc! {"
+                The quic|k
+                brown"},
+            indoc! {"
+                The quic|k
+                brown"},
+        );
 
-        cx.simulate_keystroke("j");
-        cx.simulate_keystroke("shift-$");
-        cx.assert_editor_state(indoc! {"
-            Test Test
-            
-            |T"});
-        cx.simulate_keystroke("0");
-        cx.assert_editor_state(indoc! {"
-            Test Test
-            
-            |T"});
+        let mut cx = cx.binding(["0"]);
+        cx.assert("Test |test", "|Test test");
+        cx.assert("|Test test", "|Test test");
+        cx.assert(
+            indoc! {"
+                The |quick
+                brown"},
+            indoc! {"
+                |The quick
+                brown"},
+        );
+        cx.assert(
+            indoc! {"
+                |The quick
+                brown"},
+            indoc! {"
+                |The quick
+                brown"},
+        );
     }
 
     #[gpui::test]
     async fn test_jump_to_end(cx: &mut gpui::TestAppContext) {
-        let mut cx = VimTestContext::new(cx, true, "").await;
+        let cx = VimTestContext::new(cx, true).await;
+        let mut cx = cx.binding(["shift-G"]);
 
-        cx.set_state(
+        cx.assert(
             indoc! {"
-            The |quick
-            
-            brown fox jumps
-            over the lazy dog"},
-            Mode::Normal,
+                The |quick
+                
+                brown fox jumps
+                over the lazy dog"},
+            indoc! {"
+                The quick
+                
+                brown fox jumps
+                over| the lazy dog"},
         );
-        cx.simulate_keystroke("shift-G");
-        cx.assert_editor_state(indoc! {"
+        cx.assert(
+            indoc! {"
+                The quick
+                
+                brown fox jumps
+                over| the lazy dog"},
+            indoc! {"
+                The quick
+                
+                brown fox jumps
+                over| the lazy dog"},
+        );
+        cx.assert(
+            indoc! {"
+            The qui|ck
+            
+            brown"},
+            indoc! {"
             The quick
             
-            brown fox jumps
-            over| the lazy dog"});
-
-        // Repeat the action doesn't move
-        cx.simulate_keystroke("shift-G");
-        cx.assert_editor_state(indoc! {"
+            brow|n"},
+        );
+        cx.assert(
+            indoc! {"
+            The qui|ck
+            
+            "},
+            indoc! {"
             The quick
             
-            brown fox jumps
-            over| the lazy dog"});
+            |"},
+        );
     }
 
     #[gpui::test]
     async fn test_next_word_start(cx: &mut gpui::TestAppContext) {
-        let (initial_content, cursor_offsets) = marked_text(indoc! {"
+        let mut cx = VimTestContext::new(cx, true).await;
+        let (_, cursor_offsets) = marked_text(indoc! {"
             The |quick|-|brown
             |
             |
             |fox_jumps |over
             |th||e"});
-        let mut cx = VimTestContext::new(cx, true, &initial_content).await;
+        cx.set_state(
+            indoc! {"
+            |The quick-brown
+            
+            
+            fox_jumps over
+            the"},
+            Mode::Normal,
+        );
 
         for cursor_offset in cursor_offsets {
             cx.simulate_keystroke("w");
@@ -261,13 +266,21 @@ mod test {
         }
 
         // Reset and test ignoring punctuation
-        cx.simulate_keystrokes(["g", "g", "0"]);
         let (_, cursor_offsets) = marked_text(indoc! {"
             The |quick-brown
             |
             |
             |fox_jumps |over
             |th||e"});
+        cx.set_state(
+            indoc! {"
+            |The quick-brown
+            
+            
+            fox_jumps over
+            the"},
+            Mode::Normal,
+        );
 
         for cursor_offset in cursor_offsets {
             cx.simulate_keystroke("shift-W");
@@ -277,13 +290,22 @@ mod test {
 
     #[gpui::test]
     async fn test_next_word_end(cx: &mut gpui::TestAppContext) {
-        let (initial_content, cursor_offsets) = marked_text(indoc! {"
+        let mut cx = VimTestContext::new(cx, true).await;
+        let (_, cursor_offsets) = marked_text(indoc! {"
             Th|e quic|k|-brow|n
             
             
             fox_jump|s ove|r
             th|e"});
-        let mut cx = VimTestContext::new(cx, true, &initial_content).await;
+        cx.set_state(
+            indoc! {"
+            |The quick-brown
+            
+            
+            fox_jumps over
+            the"},
+            Mode::Normal,
+        );
 
         for cursor_offset in cursor_offsets {
             cx.simulate_keystroke("e");
@@ -291,13 +313,21 @@ mod test {
         }
 
         // Reset and test ignoring punctuation
-        cx.simulate_keystrokes(["g", "g", "0"]);
         let (_, cursor_offsets) = marked_text(indoc! {"
             Th|e quick-brow|n
             
             
             fox_jump|s ove|r
             th||e"});
+        cx.set_state(
+            indoc! {"
+            |The quick-brown
+            
+            
+            fox_jumps over
+            the"},
+            Mode::Normal,
+        );
         for cursor_offset in cursor_offsets {
             cx.simulate_keystroke("shift-E");
             cx.assert_newest_selection_head_offset(cursor_offset);
@@ -306,14 +336,22 @@ mod test {
 
     #[gpui::test]
     async fn test_previous_word_start(cx: &mut gpui::TestAppContext) {
-        let (initial_content, cursor_offsets) = marked_text(indoc! {"
+        let mut cx = VimTestContext::new(cx, true).await;
+        let (_, cursor_offsets) = marked_text(indoc! {"
             ||The |quick|-|brown
             |
             |
             |fox_jumps |over
             |the"});
-        let mut cx = VimTestContext::new(cx, true, &initial_content).await;
-        cx.simulate_keystrokes(["shift-G", "shift-$"]);
+        cx.set_state(
+            indoc! {"
+            The quick-brown
+            
+            
+            fox_jumps over
+            th|e"},
+            Mode::Normal,
+        );
 
         for cursor_offset in cursor_offsets.into_iter().rev() {
             cx.simulate_keystroke("b");
@@ -321,13 +359,21 @@ mod test {
         }
 
         // Reset and test ignoring punctuation
-        cx.simulate_keystrokes(["shift-G", "shift-$"]);
         let (_, cursor_offsets) = marked_text(indoc! {"
             ||The |quick-brown
             |
             |
             |fox_jumps |over
             |the"});
+        cx.set_state(
+            indoc! {"
+            The quick-brown
+            
+            
+            fox_jumps over
+            th|e"},
+            Mode::Normal,
+        );
         for cursor_offset in cursor_offsets.into_iter().rev() {
             cx.simulate_keystroke("shift-B");
             cx.assert_newest_selection_head_offset(cursor_offset);
@@ -336,7 +382,7 @@ mod test {
 
     #[gpui::test]
     async fn test_g_prefix_and_abort(cx: &mut gpui::TestAppContext) {
-        let mut cx = VimTestContext::new(cx, true, "").await;
+        let mut cx = VimTestContext::new(cx, true).await;
 
         // Can abort with escape to get back to normal mode
         cx.simulate_keystroke("g");
@@ -352,455 +398,55 @@ mod test {
 
     #[gpui::test]
     async fn test_move_to_start(cx: &mut gpui::TestAppContext) {
-        let mut cx = VimTestContext::new(cx, true, "").await;
-
-        cx.set_state(
+        let cx = VimTestContext::new(cx, true).await;
+        let mut cx = cx.binding(["g", "g"]);
+        cx.assert(
             indoc! {"
-            The q|uick
+                The quick
             
-            brown fox jumps
-            over the lazy dog"},
-            Mode::Normal,
-        );
-
-        // Jump to the end to
-        cx.simulate_keystroke("shift-G");
-        cx.assert_editor_state(indoc! {"
-            The quick
-            
-            brown fox jumps
-            over |the lazy dog"});
-
-        // Jump to the start
-        cx.simulate_keystrokes(["g", "g"]);
-        cx.assert_editor_state(indoc! {"
-            The q|uick
-            
-            brown fox jumps
-            over the lazy dog"});
-        assert_eq!(cx.mode(), Normal);
-        assert_eq!(cx.active_operator(), None);
-
-        // Repeat action doesn't change
-        cx.simulate_keystrokes(["g", "g"]);
-        cx.assert_editor_state(indoc! {"
-            The q|uick
-            
-            brown fox jumps
-            over the lazy dog"});
-        assert_eq!(cx.mode(), Normal);
-        assert_eq!(cx.active_operator(), None);
-    }
-
-    #[gpui::test]
-    async fn test_change(cx: &mut gpui::TestAppContext) {
-        fn assert(motion: &str, initial_state: &str, state_after: &str, cx: &mut VimTestContext) {
-            cx.assert_binding(
-                ["c", motion],
-                initial_state,
-                Mode::Normal,
-                state_after,
-                Mode::Insert,
-            );
-        }
-        let cx = &mut VimTestContext::new(cx, true, "").await;
-        assert("h", "Te|st", "T|st", cx);
-        assert("l", "Te|st", "Te|t", cx);
-        assert("w", "|Test", "|", cx);
-        assert("w", "Te|st", "Te|", cx);
-        assert("w", "Te|st Test", "Te| Test", cx);
-        assert("e", "Te|st Test", "Te| Test", cx);
-        assert("b", "Te|st", "|st", cx);
-        assert("b", "Test Te|st", "Test |st", cx);
-        assert(
-            "w",
-            indoc! {"
-            The quick
-            brown |fox
-            jumps over"},
-            indoc! {"
-            The quick
-            brown |
-            jumps over"},
-            cx,
-        );
-        assert(
-            "shift-W",
-            indoc! {"
-            The quick
-            brown |fox-fox
-            jumps over"},
-            indoc! {"
-            The quick
-            brown |
-            jumps over"},
-            cx,
-        );
-        assert(
-            "k",
-            indoc! {"
-            The quick
-            brown |fox"},
-            indoc! {"
-            |"},
-            cx,
-        );
-        assert(
-            "j",
-            indoc! {"
-            The q|uick
-            brown fox"},
-            indoc! {"
-            |"},
-            cx,
-        );
-        assert(
-            "shift-$",
-            indoc! {"
-            The q|uick
-            brown fox"},
-            indoc! {"
-            The q|
-            brown fox"},
-            cx,
-        );
-        assert(
-            "0",
-            indoc! {"
-            The q|uick
-            brown fox"},
-            indoc! {"
-            |uick
-            brown fox"},
-            cx,
-        );
-    }
-
-    #[gpui::test]
-    async fn test_delete(cx: &mut gpui::TestAppContext) {
-        fn assert(motion: &str, initial_state: &str, state_after: &str, cx: &mut VimTestContext) {
-            cx.assert_binding(
-                ["d", motion],
-                initial_state,
-                Mode::Normal,
-                state_after,
-                Mode::Normal,
-            );
-        }
-        let cx = &mut VimTestContext::new(cx, true, "").await;
-        assert("h", "Te|st", "T|st", cx);
-        assert("l", "Te|st", "Te|t", cx);
-        assert("w", "|Test", "|", cx);
-        assert("w", "Te|st", "T|e", cx);
-        assert("w", "Te|st Test", "Te|Test", cx);
-        assert("e", "Te|st Test", "Te| Test", cx);
-        assert("b", "Te|st", "|st", cx);
-        assert("b", "Test Te|st", "Test |st", cx);
-        assert(
-            "w",
-            indoc! {"
-            The quick
-            brown |fox
-            jumps over"},
-            // Trailing space after cursor
-            indoc! {"
-            The quick
-            brown| 
-            jumps over"},
-            cx,
-        );
-        assert(
-            "shift-W",
-            indoc! {"
-            The quick
-            brown |fox-fox
-            jumps over"},
-            // Trailing space after cursor
-            indoc! {"
-            The quick
-            brown| 
-            jumps over"},
-            cx,
-        );
-        assert(
-            "shift-$",
-            indoc! {"
-            The q|uick
-            brown fox"},
-            indoc! {"
-            The |q
-            brown fox"},
-            cx,
-        );
-        assert(
-            "0",
-            indoc! {"
-            The q|uick
-            brown fox"},
-            indoc! {"
-            |uick
-            brown fox"},
-            cx,
-        );
-    }
-
-    #[gpui::test]
-    async fn test_linewise_delete(cx: &mut gpui::TestAppContext) {
-        fn assert(motion: &str, initial_state: &str, state_after: &str, cx: &mut VimTestContext) {
-            cx.assert_binding(
-                ["d", motion],
-                initial_state,
-                Mode::Normal,
-                state_after,
-                Mode::Normal,
-            );
-        }
-        let cx = &mut VimTestContext::new(cx, true, "").await;
-        assert(
-            "k",
-            indoc! {"
-            The quick
-            brown |fox
-            jumps over"},
-            indoc! {"
-            jumps |over"},
-            cx,
-        );
-        assert(
-            "k",
-            indoc! {"
-            The quick
-            brown fox
-            jumps |over"},
-            indoc! {"
-            The qu|ick"},
-            cx,
-        );
-        assert(
-            "j",
+                brown fox jumps
+                over |the lazy dog"},
             indoc! {"
-            The q|uick
-            brown fox
-            jumps over"},
-            indoc! {"
-            jumps| over"},
-            cx,
-        );
-        assert(
-            "j",
-            indoc! {"
-            The quick
-            brown| fox
-            jumps over"},
-            indoc! {"
-            The q|uick"},
-            cx,
-        );
-        assert(
-            "j",
-            indoc! {"
-            The quick
-            brown| fox
-            jumps over"},
-            indoc! {"
-            The q|uick"},
-            cx,
-        );
-        cx.assert_binding(
-            ["d", "g", "g"],
-            indoc! {"
-            The quick
-            brown| fox
-            jumps over
-            the lazy"},
-            Mode::Normal,
-            indoc! {"
-            jumps| over
-            the lazy"},
-            Mode::Normal,
-        );
-        cx.assert_binding(
-            ["d", "g", "g"],
-            indoc! {"
-            The quick
-            brown fox
-            jumps over
-            the l|azy"},
-            Mode::Normal,
-            "|",
-            Mode::Normal,
-        );
-        assert(
-            "shift-G",
-            indoc! {"
-            The quick
-            brown| fox
-            jumps over
-            the lazy"},
-            indoc! {"
-            The q|uick"},
-            cx,
-        );
-        cx.assert_binding(
-            ["d", "g", "g"],
-            indoc! {"
-            The q|uick
-            brown fox
-            jumps over
-            the lazy"},
-            Mode::Normal,
-            indoc! {"
-            brown| fox
-            jumps over
-            the lazy"},
-            Mode::Normal,
-        );
-    }
-
-    #[gpui::test]
-    async fn test_linewise_change(cx: &mut gpui::TestAppContext) {
-        fn assert(motion: &str, initial_state: &str, state_after: &str, cx: &mut VimTestContext) {
-            cx.assert_binding(
-                ["c", motion],
-                initial_state,
-                Mode::Normal,
-                state_after,
-                Mode::Insert,
-            );
-        }
-        let cx = &mut VimTestContext::new(cx, true, "").await;
-        assert(
-            "k",
-            indoc! {"
-            The quick
-            brown |fox
-            jumps over"},
-            indoc! {"
-            |
-            jumps over"},
-            cx,
-        );
-        assert(
-            "k",
-            indoc! {"
-            The quick
-            brown fox
-            jumps |over"},
-            indoc! {"
-            The quick
-            |"},
-            cx,
-        );
-        assert(
-            "j",
-            indoc! {"
-            The q|uick
-            brown fox
-            jumps over"},
-            indoc! {"
-            |
-            jumps over"},
-            cx,
-        );
-        assert(
-            "j",
-            indoc! {"
-            The quick
-            brown| fox
-            jumps over"},
-            indoc! {"
-            The quick
-            |"},
-            cx,
-        );
-        assert(
-            "j",
-            indoc! {"
-            The quick
-            brown| fox
-            jumps over"},
-            indoc! {"
-            The quick
-            |"},
-            cx,
-        );
-        assert(
-            "shift-G",
-            indoc! {"
-            The quick
-            brown| fox
-            jumps over
-            the lazy"},
-            indoc! {"
-            The quick
-            |"},
-            cx,
-        );
-        assert(
-            "shift-G",
-            indoc! {"
-            The quick
-            brown| fox
-            jumps over
-            the lazy"},
-            indoc! {"
-            The quick
-            |"},
-            cx,
+                The q|uick
+            
+                brown fox jumps
+                over the lazy dog"},
         );
-        assert(
-            "shift-G",
+        cx.assert(
             indoc! {"
-            The quick
-            brown fox
-            jumps over
-            the l|azy"},
+                The q|uick
+            
+                brown fox jumps
+                over the lazy dog"},
             indoc! {"
-            The quick
-            brown fox
-            jumps over
-            |"},
-            cx,
+                The q|uick
+            
+                brown fox jumps
+                over the lazy dog"},
         );
-        cx.assert_binding(
-            ["c", "g", "g"],
+        cx.assert(
             indoc! {"
-            The quick
-            brown| fox
-            jumps over
-            the lazy"},
-            Mode::Normal,
-            indoc! {"
-            |
-            jumps over
-            the lazy"},
-            Mode::Insert,
-        );
-        cx.assert_binding(
-            ["c", "g", "g"],
+                The quick
+            
+                brown fox jumps
+                over the la|zy dog"},
             indoc! {"
-            The quick
-            brown fox
-            jumps over
-            the l|azy"},
-            Mode::Normal,
-            "|",
-            Mode::Insert,
+                The quic|k
+            
+                brown fox jumps
+                over the lazy dog"},
         );
-        cx.assert_binding(
-            ["c", "g", "g"],
+        cx.assert(
             indoc! {"
-            The q|uick
-            brown fox
-            jumps over
-            the lazy"},
-            Mode::Normal,
+                
+            
+                brown fox jumps
+                over the la|zy dog"},
             indoc! {"
-            |
-            brown fox
-            jumps over
-            the lazy"},
-            Mode::Insert,
+                |
+            
+                brown fox jumps
+                over the lazy dog"},
         );
     }
 }

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

@@ -0,0 +1,436 @@
+use crate::{motion::Motion, state::Mode, Vim};
+use editor::{char_kind, movement};
+use gpui::{impl_actions, MutableAppContext, ViewContext};
+use serde::Deserialize;
+use workspace::Workspace;
+
+#[derive(Clone, Deserialize)]
+#[serde(rename_all = "camelCase")]
+struct ChangeWord {
+    #[serde(default)]
+    ignore_punctuation: bool,
+}
+
+impl_actions!(vim, [ChangeWord]);
+
+pub fn init(cx: &mut MutableAppContext) {
+    cx.add_action(change_word);
+}
+
+pub fn change_over(vim: &mut Vim, motion: Motion, cx: &mut MutableAppContext) {
+    vim.update_active_editor(cx, |editor, cx| {
+        editor.transact(cx, |editor, cx| {
+            // We are swapping to insert mode anyway. Just set the line end clipping behavior now
+            editor.set_clip_at_line_ends(false, cx);
+            editor.move_selections(cx, |map, selection| {
+                motion.expand_selection(map, selection, false);
+            });
+            editor.insert(&"", cx);
+        });
+    });
+    vim.switch_mode(Mode::Insert, cx)
+}
+
+// From the docs https://vimhelp.org/change.txt.html#cw
+// Special case: When the cursor is in a word, "cw" and "cW" do not include the
+// white space after a word, they only change up to the end of the word. This is
+// because Vim interprets "cw" as change-word, and a word does not include the
+// following white space.
+fn change_word(
+    _: &mut Workspace,
+    &ChangeWord { ignore_punctuation }: &ChangeWord,
+    cx: &mut ViewContext<Workspace>,
+) {
+    Vim::update(cx, |vim, cx| {
+        vim.update_active_editor(cx, |editor, cx| {
+            editor.transact(cx, |editor, cx| {
+                // We are swapping to insert mode anyway. Just set the line end clipping behavior now
+                editor.set_clip_at_line_ends(false, cx);
+                editor.move_selections(cx, |map, selection| {
+                    if selection.end.column() == map.line_len(selection.end.row()) {
+                        return;
+                    }
+
+                    selection.end = movement::find_boundary(map, selection.end, |left, right| {
+                        let left_kind = char_kind(left).coerce_punctuation(ignore_punctuation);
+                        let right_kind = char_kind(right).coerce_punctuation(ignore_punctuation);
+
+                        left_kind != right_kind || left == '\n' || right == '\n'
+                    });
+                });
+                editor.insert(&"", cx);
+            });
+        });
+        vim.switch_mode(Mode::Insert, cx);
+    });
+}
+
+#[cfg(test)]
+mod test {
+    use indoc::indoc;
+
+    use crate::{state::Mode, vim_test_context::VimTestContext};
+
+    #[gpui::test]
+    async fn test_change_h(cx: &mut gpui::TestAppContext) {
+        let cx = VimTestContext::new(cx, true).await;
+        let mut cx = cx.binding(["c", "h"]).mode_after(Mode::Insert);
+        cx.assert("Te|st", "T|st");
+        cx.assert("T|est", "|est");
+        cx.assert("|Test", "|Test");
+        cx.assert(
+            indoc! {"
+                Test
+                |test"},
+            indoc! {"
+                Test
+                |test"},
+        );
+    }
+
+    #[gpui::test]
+    async fn test_change_l(cx: &mut gpui::TestAppContext) {
+        let cx = VimTestContext::new(cx, true).await;
+        let mut cx = cx.binding(["c", "l"]).mode_after(Mode::Insert);
+        cx.assert("Te|st", "Te|t");
+        cx.assert("Tes|t", "Tes|");
+    }
+
+    #[gpui::test]
+    async fn test_change_w(cx: &mut gpui::TestAppContext) {
+        let cx = VimTestContext::new(cx, true).await;
+        let mut cx = cx.binding(["c", "w"]).mode_after(Mode::Insert);
+        cx.assert("Te|st", "Te|");
+        cx.assert("T|est test", "T| test");
+        cx.assert("Test|  test", "Test|test");
+        cx.assert(
+            indoc! {"
+                Test te|st
+                test"},
+            indoc! {"
+                Test te|
+                test"},
+        );
+        cx.assert(
+            indoc! {"
+                Test tes|t
+                test"},
+            indoc! {"
+                Test tes|
+                test"},
+        );
+        cx.assert(
+            indoc! {"
+                Test test
+                |
+                test"},
+            indoc! {"
+                Test test
+                |
+                test"},
+        );
+
+        let mut cx = cx.binding(["c", "shift-W"]);
+        cx.assert("Test te|st-test test", "Test te| test");
+    }
+
+    #[gpui::test]
+    async fn test_change_e(cx: &mut gpui::TestAppContext) {
+        let cx = VimTestContext::new(cx, true).await;
+        let mut cx = cx.binding(["c", "e"]).mode_after(Mode::Insert);
+        cx.assert("Te|st Test", "Te| Test");
+        cx.assert("T|est test", "T| test");
+        cx.assert(
+            indoc! {"
+                Test te|st
+                test"},
+            indoc! {"
+                Test te|
+                test"},
+        );
+        cx.assert(
+            indoc! {"
+                Test tes|t
+                test"},
+            "Test tes|",
+        );
+        cx.assert(
+            indoc! {"
+                Test test
+                |
+                test"},
+            indoc! {"
+                Test test
+                |
+                test"},
+        );
+
+        let mut cx = cx.binding(["c", "shift-E"]);
+        cx.assert("Test te|st-test test", "Test te| test");
+    }
+
+    #[gpui::test]
+    async fn test_change_b(cx: &mut gpui::TestAppContext) {
+        let cx = VimTestContext::new(cx, true).await;
+        let mut cx = cx.binding(["c", "b"]).mode_after(Mode::Insert);
+        cx.assert("Te|st Test", "|st Test");
+        cx.assert("Test |test", "|test");
+        cx.assert("Test1 test2 |test3", "Test1 |test3");
+        cx.assert(
+            indoc! {"
+                Test test
+                |test"},
+            indoc! {"
+                Test |
+                test"},
+        );
+        cx.assert(
+            indoc! {"
+                Test test
+                |
+                test"},
+            indoc! {"
+                Test |
+                
+                test"},
+        );
+
+        let mut cx = cx.binding(["c", "shift-B"]);
+        cx.assert("Test test-test |test", "Test |test");
+    }
+
+    #[gpui::test]
+    async fn test_change_end_of_line(cx: &mut gpui::TestAppContext) {
+        let cx = VimTestContext::new(cx, true).await;
+        let mut cx = cx.binding(["c", "shift-$"]).mode_after(Mode::Insert);
+        cx.assert(
+            indoc! {"
+                The q|uick
+                brown fox"},
+            indoc! {"
+                The q|
+                brown fox"},
+        );
+        cx.assert(
+            indoc! {"
+                The quick
+                |
+                brown fox"},
+            indoc! {"
+                The quick
+                |
+                brown fox"},
+        );
+    }
+
+    #[gpui::test]
+    async fn test_change_0(cx: &mut gpui::TestAppContext) {
+        let cx = VimTestContext::new(cx, true).await;
+        let mut cx = cx.binding(["c", "0"]).mode_after(Mode::Insert);
+        cx.assert(
+            indoc! {"
+                The q|uick
+                brown fox"},
+            indoc! {"
+                |uick
+                brown fox"},
+        );
+        cx.assert(
+            indoc! {"
+                The quick
+                |
+                brown fox"},
+            indoc! {"
+                The quick
+                |
+                brown fox"},
+        );
+    }
+
+    #[gpui::test]
+    async fn test_change_k(cx: &mut gpui::TestAppContext) {
+        let cx = VimTestContext::new(cx, true).await;
+        let mut cx = cx.binding(["c", "k"]).mode_after(Mode::Insert);
+        cx.assert(
+            indoc! {"
+                The quick
+                brown |fox
+                jumps over"},
+            indoc! {"
+                |
+                jumps over"},
+        );
+        cx.assert(
+            indoc! {"
+                The quick
+                brown fox
+                jumps |over"},
+            indoc! {"
+                The quick
+                |"},
+        );
+        cx.assert(
+            indoc! {"
+                The q|uick
+                brown fox
+                jumps over"},
+            indoc! {"
+                |
+                brown fox
+                jumps over"},
+        );
+        cx.assert(
+            indoc! {"
+                |
+                brown fox
+                jumps over"},
+            indoc! {"
+                |
+                brown fox
+                jumps over"},
+        );
+    }
+
+    #[gpui::test]
+    async fn test_change_j(cx: &mut gpui::TestAppContext) {
+        let cx = VimTestContext::new(cx, true).await;
+        let mut cx = cx.binding(["c", "j"]).mode_after(Mode::Insert);
+        cx.assert(
+            indoc! {"
+                The quick
+                brown |fox
+                jumps over"},
+            indoc! {"
+                The quick
+                |"},
+        );
+        cx.assert(
+            indoc! {"
+                The quick
+                brown fox
+                jumps |over"},
+            indoc! {"
+                The quick
+                brown fox
+                |"},
+        );
+        cx.assert(
+            indoc! {"
+                The q|uick
+                brown fox
+                jumps over"},
+            indoc! {"
+                |
+                jumps over"},
+        );
+        cx.assert(
+            indoc! {"
+                The quick
+                brown fox
+                |"},
+            indoc! {"
+                The quick
+                brown fox
+                |"},
+        );
+    }
+
+    #[gpui::test]
+    async fn test_change_end_of_document(cx: &mut gpui::TestAppContext) {
+        let cx = VimTestContext::new(cx, true).await;
+        let mut cx = cx.binding(["c", "shift-G"]).mode_after(Mode::Insert);
+        cx.assert(
+            indoc! {"
+                The quick
+                brown| fox
+                jumps over
+                the lazy"},
+            indoc! {"
+                The quick
+                |"},
+        );
+        cx.assert(
+            indoc! {"
+                The quick
+                brown| fox
+                jumps over
+                the lazy"},
+            indoc! {"
+                The quick
+                |"},
+        );
+        cx.assert(
+            indoc! {"
+                The quick
+                brown fox
+                jumps over
+                the l|azy"},
+            indoc! {"
+                The quick
+                brown fox
+                jumps over
+                |"},
+        );
+        cx.assert(
+            indoc! {"
+                The quick
+                brown fox
+                jumps over
+                |"},
+            indoc! {"
+                The quick
+                brown fox
+                jumps over
+                |"},
+        );
+    }
+
+    #[gpui::test]
+    async fn test_change_gg(cx: &mut gpui::TestAppContext) {
+        let cx = VimTestContext::new(cx, true).await;
+        let mut cx = cx.binding(["c", "g", "g"]).mode_after(Mode::Insert);
+        cx.assert(
+            indoc! {"
+                The quick
+                brown| fox
+                jumps over
+                the lazy"},
+            indoc! {"
+                |
+                jumps over
+                the lazy"},
+        );
+        cx.assert(
+            indoc! {"
+                The quick
+                brown fox
+                jumps over
+                the l|azy"},
+            "|",
+        );
+        cx.assert(
+            indoc! {"
+                The q|uick
+                brown fox
+                jumps over
+                the lazy"},
+            indoc! {"
+                |
+                brown fox
+                jumps over
+                the lazy"},
+        );
+        cx.assert(
+            indoc! {"
+                |
+                brown fox
+                jumps over
+                the lazy"},
+            indoc! {"
+                |
+                brown fox
+                jumps over
+                the lazy"},
+        );
+    }
+}

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

@@ -0,0 +1,386 @@
+use crate::{motion::Motion, Vim};
+use editor::Bias;
+use gpui::MutableAppContext;
+use language::SelectionGoal;
+
+pub fn delete_over(vim: &mut Vim, motion: Motion, cx: &mut MutableAppContext) {
+    vim.update_active_editor(cx, |editor, cx| {
+        editor.transact(cx, |editor, cx| {
+            editor.set_clip_at_line_ends(false, cx);
+            editor.move_selections(cx, |map, selection| {
+                let original_head = selection.head();
+                motion.expand_selection(map, selection, true);
+                selection.goal = SelectionGoal::Column(original_head.column());
+            });
+            editor.insert(&"", cx);
+
+            // Fixup cursor position after the deletion
+            editor.set_clip_at_line_ends(true, cx);
+            editor.move_cursors(cx, |map, mut cursor, goal| {
+                if motion.linewise() {
+                    if let SelectionGoal::Column(column) = goal {
+                        *cursor.column_mut() = column
+                    }
+                }
+
+                (map.clip_point(cursor, Bias::Left), SelectionGoal::None)
+            });
+        });
+    });
+}
+
+#[cfg(test)]
+mod test {
+    use indoc::indoc;
+
+    use crate::vim_test_context::VimTestContext;
+
+    #[gpui::test]
+    async fn test_delete_h(cx: &mut gpui::TestAppContext) {
+        let cx = VimTestContext::new(cx, true).await;
+        let mut cx = cx.binding(["d", "h"]);
+        cx.assert("Te|st", "T|st");
+        cx.assert("T|est", "|est");
+        cx.assert("|Test", "|Test");
+        cx.assert(
+            indoc! {"
+                Test
+                |test"},
+            indoc! {"
+                Test
+                |test"},
+        );
+    }
+
+    #[gpui::test]
+    async fn test_delete_l(cx: &mut gpui::TestAppContext) {
+        let cx = VimTestContext::new(cx, true).await;
+        let mut cx = cx.binding(["d", "l"]);
+        cx.assert("|Test", "|est");
+        cx.assert("Te|st", "Te|t");
+        cx.assert("Tes|t", "Te|s");
+        cx.assert(
+            indoc! {"
+                Tes|t
+                test"},
+            indoc! {"
+                Te|s
+                test"},
+        );
+    }
+
+    #[gpui::test]
+    async fn test_delete_w(cx: &mut gpui::TestAppContext) {
+        let cx = VimTestContext::new(cx, true).await;
+        let mut cx = cx.binding(["d", "w"]);
+        cx.assert("Te|st", "T|e");
+        cx.assert("T|est test", "T|test");
+        cx.assert(
+            indoc! {"
+                Test te|st
+                test"},
+            indoc! {"
+                Test t|e
+                test"},
+        );
+        cx.assert(
+            indoc! {"
+                Test tes|t
+                test"},
+            indoc! {"
+                Test te|s
+                test"},
+        );
+        cx.assert(
+            indoc! {"
+                Test test
+                |
+                test"},
+            indoc! {"
+                Test test
+                |
+                test"},
+        );
+
+        let mut cx = cx.binding(["d", "shift-W"]);
+        cx.assert("Test te|st-test test", "Test te|test");
+    }
+
+    #[gpui::test]
+    async fn test_delete_e(cx: &mut gpui::TestAppContext) {
+        let cx = VimTestContext::new(cx, true).await;
+        let mut cx = cx.binding(["d", "e"]);
+        cx.assert("Te|st Test", "Te| Test");
+        cx.assert("T|est test", "T| test");
+        cx.assert(
+            indoc! {"
+                Test te|st
+                test"},
+            indoc! {"
+                Test t|e
+                test"},
+        );
+        cx.assert(
+            indoc! {"
+                Test tes|t
+                test"},
+            "Test te|s",
+        );
+        cx.assert(
+            indoc! {"
+                Test test
+                |
+                test"},
+            indoc! {"
+                Test test
+                |
+                test"},
+        );
+
+        let mut cx = cx.binding(["d", "shift-E"]);
+        cx.assert("Test te|st-test test", "Test te| test");
+    }
+
+    #[gpui::test]
+    async fn test_delete_b(cx: &mut gpui::TestAppContext) {
+        let cx = VimTestContext::new(cx, true).await;
+        let mut cx = cx.binding(["d", "b"]);
+        cx.assert("Te|st Test", "|st Test");
+        cx.assert("Test |test", "|test");
+        cx.assert("Test1 test2 |test3", "Test1 |test3");
+        cx.assert(
+            indoc! {"
+                Test test
+                |test"},
+            // Trailing whitespace after cursor
+            indoc! {"
+                Test| 
+                test"},
+        );
+        cx.assert(
+            indoc! {"
+                Test test
+                |
+                test"},
+            // Trailing whitespace after cursor
+            indoc! {"
+                Test| 
+                
+                test"},
+        );
+
+        let mut cx = cx.binding(["d", "shift-B"]);
+        cx.assert("Test test-test |test", "Test |test");
+    }
+
+    #[gpui::test]
+    async fn test_delete_end_of_line(cx: &mut gpui::TestAppContext) {
+        let cx = VimTestContext::new(cx, true).await;
+        let mut cx = cx.binding(["d", "shift-$"]);
+        cx.assert(
+            indoc! {"
+                The q|uick
+                brown fox"},
+            indoc! {"
+                The |q
+                brown fox"},
+        );
+        cx.assert(
+            indoc! {"
+                The quick
+                |
+                brown fox"},
+            indoc! {"
+                The quick
+                |
+                brown fox"},
+        );
+    }
+
+    #[gpui::test]
+    async fn test_delete_0(cx: &mut gpui::TestAppContext) {
+        let cx = VimTestContext::new(cx, true).await;
+        let mut cx = cx.binding(["d", "0"]);
+        cx.assert(
+            indoc! {"
+                The q|uick
+                brown fox"},
+            indoc! {"
+                |uick
+                brown fox"},
+        );
+        cx.assert(
+            indoc! {"
+                The quick
+                |
+                brown fox"},
+            indoc! {"
+                The quick
+                |
+                brown fox"},
+        );
+    }
+
+    #[gpui::test]
+    async fn test_delete_k(cx: &mut gpui::TestAppContext) {
+        let cx = VimTestContext::new(cx, true).await;
+        let mut cx = cx.binding(["d", "k"]);
+        cx.assert(
+            indoc! {"
+                The quick
+                brown |fox
+                jumps over"},
+            "jumps |over",
+        );
+        cx.assert(
+            indoc! {"
+                The quick
+                brown fox
+                jumps |over"},
+            "The qu|ick",
+        );
+        cx.assert(
+            indoc! {"
+                The q|uick
+                brown fox
+                jumps over"},
+            indoc! {"
+                brown| fox
+                jumps over"},
+        );
+        cx.assert(
+            indoc! {"
+                |brown fox
+                jumps over"},
+            "|jumps over",
+        );
+    }
+
+    #[gpui::test]
+    async fn test_delete_j(cx: &mut gpui::TestAppContext) {
+        let cx = VimTestContext::new(cx, true).await;
+        let mut cx = cx.binding(["d", "j"]);
+        cx.assert(
+            indoc! {"
+                The quick
+                brown |fox
+                jumps over"},
+            "The qu|ick",
+        );
+        cx.assert(
+            indoc! {"
+                The quick
+                brown fox
+                jumps |over"},
+            indoc! {"
+                The quick
+                brown |fox"},
+        );
+        cx.assert(
+            indoc! {"
+                The q|uick
+                brown fox
+                jumps over"},
+            "jumps| over",
+        );
+        cx.assert(
+            indoc! {"
+                The quick
+                brown fox
+                |"},
+            indoc! {"
+                The quick
+                |brown fox"},
+        );
+    }
+
+    #[gpui::test]
+    async fn test_delete_end_of_document(cx: &mut gpui::TestAppContext) {
+        let cx = VimTestContext::new(cx, true).await;
+        let mut cx = cx.binding(["d", "shift-G"]);
+        cx.assert(
+            indoc! {"
+                The quick
+                brown| fox
+                jumps over
+                the lazy"},
+            "The q|uick",
+        );
+        cx.assert(
+            indoc! {"
+                The quick
+                brown| fox
+                jumps over
+                the lazy"},
+            "The q|uick",
+        );
+        cx.assert(
+            indoc! {"
+                The quick
+                brown fox
+                jumps over
+                the l|azy"},
+            indoc! {"
+                The quick
+                brown fox
+                jumps| over"},
+        );
+        cx.assert(
+            indoc! {"
+                The quick
+                brown fox
+                jumps over
+                |"},
+            indoc! {"
+                The quick
+                brown fox
+                |jumps over"},
+        );
+    }
+
+    #[gpui::test]
+    async fn test_delete_gg(cx: &mut gpui::TestAppContext) {
+        let cx = VimTestContext::new(cx, true).await;
+        let mut cx = cx.binding(["d", "g", "g"]);
+        cx.assert(
+            indoc! {"
+                The quick
+                brown| fox
+                jumps over
+                the lazy"},
+            indoc! {"
+                jumps| over
+                the lazy"},
+        );
+        cx.assert(
+            indoc! {"
+                The quick
+                brown fox
+                jumps over
+                the l|azy"},
+            "|",
+        );
+        cx.assert(
+            indoc! {"
+                The q|uick
+                brown fox
+                jumps over
+                the lazy"},
+            indoc! {"
+                brown| fox
+                jumps over
+                the lazy"},
+        );
+        cx.assert(
+            indoc! {"
+                |
+                brown fox
+                jumps over
+                the lazy"},
+            indoc! {"
+                |brown fox
+                jumps over
+                the lazy"},
+        );
+    }
+}

crates/vim/src/vim.rs 🔗

@@ -1,10 +1,11 @@
+#[cfg(test)]
+mod vim_test_context;
+
 mod editor_events;
 mod insert;
 mod motion;
 mod normal;
 mod state;
-#[cfg(test)]
-mod vim_test_context;
 
 use collections::HashMap;
 use editor::{CursorShape, Editor};
@@ -25,6 +26,7 @@ impl_actions!(vim, [SwitchMode, PushOperator]);
 
 pub fn init(cx: &mut MutableAppContext) {
     editor_events::init(cx);
+    normal::init(cx);
     insert::init(cx);
     motion::init(cx);
 
@@ -142,14 +144,14 @@ mod test {
 
     #[gpui::test]
     async fn test_initially_disabled(cx: &mut gpui::TestAppContext) {
-        let mut cx = VimTestContext::new(cx, false, "").await;
+        let mut cx = VimTestContext::new(cx, false).await;
         cx.simulate_keystrokes(["h", "j", "k", "l"]);
         cx.assert_editor_state("hjkl|");
     }
 
     #[gpui::test]
     async fn test_toggle_through_settings(cx: &mut gpui::TestAppContext) {
-        let mut cx = VimTestContext::new(cx, true, "").await;
+        let mut cx = VimTestContext::new(cx, true).await;
 
         cx.simulate_keystroke("i");
         assert_eq!(cx.mode(), Mode::Insert);

crates/vim/src/vim_test_context.rs 🔗

@@ -15,11 +15,7 @@ pub struct VimTestContext<'a> {
 }
 
 impl<'a> VimTestContext<'a> {
-    pub async fn new(
-        cx: &'a mut gpui::TestAppContext,
-        enabled: bool,
-        initial_editor_text: &str,
-    ) -> VimTestContext<'a> {
+    pub async fn new(cx: &'a mut gpui::TestAppContext, enabled: bool) -> VimTestContext<'a> {
         cx.update(|cx| {
             editor::init(cx);
             crate::init(cx);
@@ -38,10 +34,7 @@ impl<'a> VimTestContext<'a> {
         params
             .fs
             .as_fake()
-            .insert_tree(
-                "/root",
-                json!({ "dir": { "test.txt": initial_editor_text } }),
-            )
+            .insert_tree("/root", json!({ "dir": { "test.txt": "" } }))
             .await;
 
         let (window_id, workspace) = cx.add_window(|cx| Workspace::new(&params, cx));
@@ -202,6 +195,14 @@ impl<'a> VimTestContext<'a> {
         assert_eq!(self.mode(), mode_after);
         assert_eq!(self.active_operator(), None);
     }
+
+    pub fn binding<const COUNT: usize>(
+        mut self,
+        keystrokes: [&'static str; COUNT],
+    ) -> VimBindingTestContext<'a, COUNT> {
+        let mode = self.mode();
+        VimBindingTestContext::new(keystrokes, mode, mode, self)
+    }
 }
 
 impl<'a> Deref for VimTestContext<'a> {
@@ -211,3 +212,61 @@ impl<'a> Deref for VimTestContext<'a> {
         self.cx
     }
 }
+
+pub struct VimBindingTestContext<'a, const COUNT: usize> {
+    cx: VimTestContext<'a>,
+    keystrokes_under_test: [&'static str; COUNT],
+    initial_mode: Mode,
+    mode_after: Mode,
+}
+
+impl<'a, const COUNT: usize> VimBindingTestContext<'a, COUNT> {
+    pub fn new(
+        keystrokes_under_test: [&'static str; COUNT],
+        initial_mode: Mode,
+        mode_after: Mode,
+        cx: VimTestContext<'a>,
+    ) -> Self {
+        Self {
+            cx,
+            keystrokes_under_test,
+            initial_mode,
+            mode_after,
+        }
+    }
+
+    pub fn binding<const NEW_COUNT: usize>(
+        self,
+        keystrokes_under_test: [&'static str; NEW_COUNT],
+    ) -> VimBindingTestContext<'a, NEW_COUNT> {
+        VimBindingTestContext {
+            keystrokes_under_test,
+            cx: self.cx,
+            initial_mode: self.initial_mode,
+            mode_after: self.mode_after,
+        }
+    }
+
+    pub fn mode_after(mut self, mode_after: Mode) -> Self {
+        self.mode_after = mode_after;
+        self
+    }
+
+    pub fn assert(&mut self, initial_state: &str, state_after: &str) {
+        self.cx.assert_binding(
+            self.keystrokes_under_test,
+            initial_state,
+            self.initial_mode,
+            state_after,
+            self.mode_after,
+        )
+    }
+}
+
+impl<'a, const COUNT: usize> Deref for VimBindingTestContext<'a, COUNT> {
+    type Target = VimTestContext<'a>;
+
+    fn deref(&self) -> &Self::Target {
+        &self.cx
+    }
+}