Merge pull request #687 from zed-industries/vim-word-and-line-movement

Antonio Scandurra created

Add word and line movement in vim normal mode

Change summary

crates/editor/src/editor.rs        |  34 +-
crates/language/src/buffer.rs      |  10 
crates/vim/src/editor_events.rs    |   2 
crates/vim/src/insert.rs           |  19 +
crates/vim/src/mode.rs             |  44 +++
crates/vim/src/normal.rs           | 376 +++++++++++++++++++++++++++++++
crates/vim/src/normal/g_prefix.rs  |  82 ++++++
crates/vim/src/vim.rs              |  45 +++
crates/vim/src/vim_test_context.rs | 179 +++++++++++++++
crates/vim/src/vim_tests.rs        | 253 ---------------------
10 files changed, 766 insertions(+), 278 deletions(-)

Detailed changes

crates/editor/src/editor.rs 🔗

@@ -1388,6 +1388,24 @@ impl Editor {
         }
     }
 
+    pub fn replace_selections_with(
+        &mut self,
+        cx: &mut ViewContext<Self>,
+        find_replacement: impl Fn(&DisplaySnapshot) -> DisplayPoint,
+    ) {
+        let display_map = self.snapshot(cx);
+        let cursor = find_replacement(&display_map);
+        let selection = Selection {
+            id: post_inc(&mut self.next_selection_id),
+            start: cursor,
+            end: cursor,
+            reversed: false,
+            goal: SelectionGoal::None,
+        }
+        .map(|display_point| display_point.to_point(&display_map));
+        self.update_selections(vec![selection], None, cx);
+    }
+
     pub fn move_selections(
         &mut self,
         cx: &mut ViewContext<Self>,
@@ -1398,21 +1416,9 @@ impl Editor {
             .local_selections::<Point>(cx)
             .into_iter()
             .map(|selection| {
-                let mut selection = Selection {
-                    id: selection.id,
-                    start: selection.start.to_display_point(&display_map),
-                    end: selection.end.to_display_point(&display_map),
-                    reversed: selection.reversed,
-                    goal: selection.goal,
-                };
+                let mut selection = selection.map(|point| point.to_display_point(&display_map));
                 move_selection(&display_map, &mut selection);
-                Selection {
-                    id: selection.id,
-                    start: selection.start.to_point(&display_map),
-                    end: selection.end.to_point(&display_map),
-                    reversed: selection.reversed,
-                    goal: selection.goal,
-                }
+                selection.map(|display_point| display_point.to_point(&display_map))
             })
             .collect();
         self.update_selections(selections, Some(Autoscroll::Fit), cx);

crates/language/src/buffer.rs 🔗

@@ -275,6 +275,16 @@ pub enum CharKind {
     Word,
 }
 
+impl CharKind {
+    pub fn coerce_punctuation(self, treat_punctuation_as_word: bool) -> Self {
+        if treat_punctuation_as_word && self == CharKind::Punctuation {
+            CharKind::Word
+        } else {
+            self
+        }
+    }
+}
+
 impl Buffer {
     pub fn new<T: Into<Arc<str>>>(
         replica_id: ReplicaId,

crates/vim/src/editor_events.rs 🔗

@@ -21,7 +21,7 @@ fn editor_focused(EditorFocused(editor): &EditorFocused, cx: &mut MutableAppCont
     let mode = if matches!(editor.read(cx).mode(), EditorMode::SingleLine) {
         Mode::Insert
     } else {
-        Mode::Normal
+        Mode::normal()
     };
 
     VimState::update_global(cx, |state, cx| {

crates/vim/src/insert.rs 🔗

@@ -25,6 +25,23 @@ fn normal_before(_: &mut Workspace, _: &NormalBefore, cx: &mut ViewContext<Works
                 (map.clip_point(cursor, Bias::Left), SelectionGoal::None)
             });
         });
-        state.switch_mode(&SwitchMode(Mode::Normal), cx);
+        state.switch_mode(&SwitchMode(Mode::normal()), cx);
     })
 }
+
+#[cfg(test)]
+mod test {
+    use crate::{mode::Mode, vim_test_context::VimTestContext};
+
+    #[gpui::test]
+    async fn test_enter_and_exit_insert_mode(cx: &mut gpui::TestAppContext) {
+        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"]);
+        cx.assert_editor_state("Test|");
+        cx.simulate_keystroke("escape");
+        assert_eq!(cx.mode(), Mode::normal());
+        cx.assert_editor_state("Tes|t");
+    }
+}

crates/vim/src/mode.rs 🔗

@@ -3,14 +3,14 @@ use gpui::keymap::Context;
 
 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
 pub enum Mode {
-    Normal,
+    Normal(NormalState),
     Insert,
 }
 
 impl Mode {
     pub fn cursor_shape(&self) -> CursorShape {
         match self {
-            Mode::Normal => CursorShape::Block,
+            Mode::Normal(_) => CursorShape::Block,
             Mode::Insert => CursorShape::Bar,
         }
     }
@@ -20,17 +20,53 @@ impl Mode {
         context.map.insert(
             "vim_mode".to_string(),
             match self {
-                Self::Normal => "normal",
+                Self::Normal(_) => "normal",
                 Self::Insert => "insert",
             }
             .to_string(),
         );
+
+        match self {
+            Self::Normal(normal_state) => normal_state.set_context(&mut context),
+            _ => {}
+        }
         context
     }
+
+    pub fn normal() -> Mode {
+        Mode::Normal(Default::default())
+    }
 }
 
 impl Default for Mode {
     fn default() -> Self {
-        Self::Normal
+        Self::Normal(Default::default())
+    }
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum NormalState {
+    None,
+    GPrefix,
+}
+
+impl NormalState {
+    pub fn set_context(&self, context: &mut Context) {
+        let submode = match self {
+            Self::GPrefix => Some("g"),
+            _ => None,
+        };
+
+        if let Some(submode) = submode {
+            context
+                .map
+                .insert("vim_submode".to_string(), submode.to_string());
+        }
+    }
+}
+
+impl Default for NormalState {
+    fn default() -> Self {
+        NormalState::None
     }
 }

crates/vim/src/normal.rs 🔗

@@ -1,30 +1,55 @@
-use editor::{movement, Bias};
+mod g_prefix;
+
+use editor::{char_kind, movement, Bias};
 use gpui::{action, keymap::Binding, MutableAppContext, ViewContext};
 use language::SelectionGoal;
 use workspace::Workspace;
 
-use crate::{Mode, SwitchMode, VimState};
+use crate::{mode::NormalState, Mode, SwitchMode, VimState};
 
-action!(InsertBefore);
+action!(GPrefix);
 action!(MoveLeft);
 action!(MoveDown);
 action!(MoveUp);
 action!(MoveRight);
+action!(MoveToStartOfLine);
+action!(MoveToEndOfLine);
+action!(MoveToEnd);
+action!(MoveToNextWordStart, bool);
+action!(MoveToNextWordEnd, bool);
+action!(MoveToPreviousWordStart, bool);
 
 pub fn init(cx: &mut MutableAppContext) {
     let context = Some("Editor && vim_mode == normal");
     cx.add_bindings(vec![
         Binding::new("i", SwitchMode(Mode::Insert), context),
+        Binding::new("g", SwitchMode(Mode::Normal(NormalState::GPrefix)), context),
         Binding::new("h", MoveLeft, context),
         Binding::new("j", MoveDown, context),
         Binding::new("k", MoveUp, context),
         Binding::new("l", MoveRight, context),
+        Binding::new("0", MoveToStartOfLine, context),
+        Binding::new("shift-$", MoveToEndOfLine, context),
+        Binding::new("shift-G", MoveToEnd, context),
+        Binding::new("w", MoveToNextWordStart(false), context),
+        Binding::new("shift-W", MoveToNextWordStart(true), context),
+        Binding::new("e", MoveToNextWordEnd(false), context),
+        Binding::new("shift-E", MoveToNextWordEnd(true), context),
+        Binding::new("b", MoveToPreviousWordStart(false), context),
+        Binding::new("shift-B", MoveToPreviousWordStart(true), context),
     ]);
+    g_prefix::init(cx);
 
     cx.add_action(move_left);
     cx.add_action(move_down);
     cx.add_action(move_up);
     cx.add_action(move_right);
+    cx.add_action(move_to_start_of_line);
+    cx.add_action(move_to_end_of_line);
+    cx.add_action(move_to_end);
+    cx.add_action(move_to_next_word_start);
+    cx.add_action(move_to_next_word_end);
+    cx.add_action(move_to_previous_word_start);
 }
 
 fn move_left(_: &mut Workspace, _: &MoveLeft, cx: &mut ViewContext<Workspace>) {
@@ -64,3 +89,348 @@ fn move_right(_: &mut Workspace, _: &MoveRight, cx: &mut ViewContext<Workspace>)
         });
     });
 }
+
+fn move_to_start_of_line(
+    _: &mut Workspace,
+    _: &MoveToStartOfLine,
+    cx: &mut ViewContext<Workspace>,
+) {
+    VimState::update_global(cx, |state, cx| {
+        state.update_active_editor(cx, |editor, cx| {
+            editor.move_cursors(cx, |map, cursor, _| {
+                (
+                    movement::line_beginning(map, cursor, false),
+                    SelectionGoal::None,
+                )
+            });
+        });
+    });
+}
+
+fn move_to_end_of_line(_: &mut Workspace, _: &MoveToEndOfLine, cx: &mut ViewContext<Workspace>) {
+    VimState::update_global(cx, |state, cx| {
+        state.update_active_editor(cx, |editor, cx| {
+            editor.move_cursors(cx, |map, cursor, _| {
+                (
+                    map.clip_point(movement::line_end(map, cursor, false), Bias::Left),
+                    SelectionGoal::None,
+                )
+            });
+        });
+    });
+}
+
+fn move_to_end(_: &mut Workspace, _: &MoveToEnd, cx: &mut ViewContext<Workspace>) {
+    VimState::update_global(cx, |state, cx| {
+        state.update_active_editor(cx, |editor, cx| {
+            editor.replace_selections_with(cx, |map| map.clip_point(map.max_point(), Bias::Left));
+        });
+    });
+}
+
+fn move_to_next_word_start(
+    _: &mut Workspace,
+    &MoveToNextWordStart(treat_punctuation_as_word): &MoveToNextWordStart,
+    cx: &mut ViewContext<Workspace>,
+) {
+    VimState::update_global(cx, |state, cx| {
+        state.update_active_editor(cx, |editor, cx| {
+            editor.move_cursors(cx, |map, mut cursor, _| {
+                let mut crossed_newline = false;
+                cursor = movement::find_boundary(map, cursor, |left, right| {
+                    let left_kind = char_kind(left).coerce_punctuation(treat_punctuation_as_word);
+                    let right_kind = char_kind(right).coerce_punctuation(treat_punctuation_as_word);
+                    let at_newline = right == '\n';
+
+                    let found = (left_kind != right_kind && !right.is_whitespace())
+                        || (at_newline && crossed_newline)
+                        || (at_newline && left == '\n'); // Prevents skipping repeated empty lines
+
+                    if at_newline {
+                        crossed_newline = true;
+                    }
+                    found
+                });
+                (cursor, SelectionGoal::None)
+            });
+        });
+    });
+}
+
+fn move_to_next_word_end(
+    _: &mut Workspace,
+    &MoveToNextWordEnd(treat_punctuation_as_word): &MoveToNextWordEnd,
+    cx: &mut ViewContext<Workspace>,
+) {
+    VimState::update_global(cx, |state, cx| {
+        state.update_active_editor(cx, |editor, cx| {
+            editor.move_cursors(cx, |map, mut cursor, _| {
+                *cursor.column_mut() += 1;
+                cursor = movement::find_boundary(map, cursor, |left, right| {
+                    let left_kind = char_kind(left).coerce_punctuation(treat_punctuation_as_word);
+                    let right_kind = char_kind(right).coerce_punctuation(treat_punctuation_as_word);
+
+                    left_kind != right_kind && !left.is_whitespace()
+                });
+                // 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 !map
+                    .chars_at(cursor)
+                    .skip(1)
+                    .next()
+                    .map(|c| c == '\n')
+                    .unwrap_or(true)
+                {
+                    *cursor.column_mut() = cursor.column().saturating_sub(1);
+                }
+                (map.clip_point(cursor, Bias::Left), SelectionGoal::None)
+            });
+        });
+    });
+}
+
+fn move_to_previous_word_start(
+    _: &mut Workspace,
+    &MoveToPreviousWordStart(treat_punctuation_as_word): &MoveToPreviousWordStart,
+    cx: &mut ViewContext<Workspace>,
+) {
+    VimState::update_global(cx, |state, cx| {
+        state.update_active_editor(cx, |editor, cx| {
+            editor.move_cursors(cx, |map, mut cursor, _| {
+                // This works even though find_preceding_boundary is called for every character in the line containing
+                // cursor because the newline is checked only once.
+                cursor = movement::find_preceding_boundary(map, cursor, |left, right| {
+                    let left_kind = char_kind(left).coerce_punctuation(treat_punctuation_as_word);
+                    let right_kind = char_kind(right).coerce_punctuation(treat_punctuation_as_word);
+
+                    (left_kind != right_kind && !right.is_whitespace()) || left == '\n'
+                });
+                (cursor, SelectionGoal::None)
+            });
+        });
+    });
+}
+
+#[cfg(test)]
+mod test {
+    use indoc::indoc;
+    use util::test::marked_text;
+
+    use crate::vim_test_context::VimTestContext;
+
+    #[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"});
+
+        // When moving left, cursor does not wrap to the previous line
+        cx.simulate_keystroke("h");
+        cx.assert_editor_state(indoc! {"
+            Test
+            |TestTest
+            Test"});
+
+        // 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"});
+
+        // 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_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"});
+
+        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"});
+    }
+
+    #[gpui::test]
+    async fn test_jump_to_end(cx: &mut gpui::TestAppContext) {
+        let initial_content = indoc! {"
+            The quick
+            
+            brown fox jumps
+            over the lazy dog"};
+        let mut cx = VimTestContext::new(cx, true, initial_content).await;
+
+        cx.simulate_keystroke("shift-G");
+        cx.assert_editor_state(indoc! {"
+            The quick
+            
+            brown fox jumps
+            over the lazy do|g"});
+
+        // Repeat the action doesn't move
+        cx.simulate_keystroke("shift-G");
+        cx.assert_editor_state(indoc! {"
+            The quick
+            
+            brown fox jumps
+            over the lazy do|g"});
+    }
+
+    #[gpui::test]
+    async fn test_next_word_start(cx: &mut gpui::TestAppContext) {
+        let (initial_content, cursor_offsets) = marked_text(indoc! {"
+            The |quick|-|brown
+            |
+            |
+            |fox_jumps |over
+            |th||e"});
+        let mut cx = VimTestContext::new(cx, true, &initial_content).await;
+
+        for cursor_offset in cursor_offsets {
+            cx.simulate_keystroke("w");
+            cx.assert_newest_selection_head_offset(cursor_offset);
+        }
+
+        // Reset and test ignoring punctuation
+        cx.simulate_keystrokes(&["g", "g"]);
+        let (_, cursor_offsets) = marked_text(indoc! {"
+            The |quick-brown
+            |
+            |
+            |fox_jumps |over
+            |th||e"});
+
+        for cursor_offset in cursor_offsets {
+            cx.simulate_keystroke("shift-W");
+            cx.assert_newest_selection_head_offset(cursor_offset);
+        }
+    }
+
+    #[gpui::test]
+    async fn test_next_word_end(cx: &mut gpui::TestAppContext) {
+        let (initial_content, 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;
+
+        for cursor_offset in cursor_offsets {
+            cx.simulate_keystroke("e");
+            cx.assert_newest_selection_head_offset(cursor_offset);
+        }
+
+        // Reset and test ignoring punctuation
+        cx.simulate_keystrokes(&["g", "g"]);
+        let (_, cursor_offsets) = marked_text(indoc! {"
+            Th|e quick-brow|n
+            
+            
+            fox_jump|s ove|r
+            th||e"});
+        for cursor_offset in cursor_offsets {
+            cx.simulate_keystroke("shift-E");
+            cx.assert_newest_selection_head_offset(cursor_offset);
+        }
+    }
+
+    #[gpui::test]
+    async fn test_previous_word_start(cx: &mut gpui::TestAppContext) {
+        let (initial_content, cursor_offsets) = marked_text(indoc! {"
+            ||The |quick|-|brown
+            |
+            |
+            |fox_jumps |over
+            |the"});
+        let mut cx = VimTestContext::new(cx, true, &initial_content).await;
+        cx.simulate_keystroke("shift-G");
+
+        for cursor_offset in cursor_offsets.into_iter().rev() {
+            cx.simulate_keystroke("b");
+            cx.assert_newest_selection_head_offset(cursor_offset);
+        }
+
+        // Reset and test ignoring punctuation
+        cx.simulate_keystroke("shift-G");
+        let (_, cursor_offsets) = marked_text(indoc! {"
+            ||The |quick-brown
+            |
+            |
+            |fox_jumps |over
+            |the"});
+        for cursor_offset in cursor_offsets.into_iter().rev() {
+            cx.simulate_keystroke("shift-B");
+            cx.assert_newest_selection_head_offset(cursor_offset);
+        }
+    }
+}

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

@@ -0,0 +1,82 @@
+use gpui::{action, keymap::Binding, MutableAppContext, ViewContext};
+use workspace::Workspace;
+
+use crate::{mode::Mode, SwitchMode, VimState};
+
+action!(MoveToStart);
+
+pub fn init(cx: &mut MutableAppContext) {
+    let context = Some("Editor && vim_mode == normal && vim_submode == g");
+    cx.add_bindings(vec![
+        Binding::new("g", MoveToStart, context),
+        Binding::new("escape", SwitchMode(Mode::normal()), context),
+    ]);
+
+    cx.add_action(move_to_start);
+}
+
+fn move_to_start(_: &mut Workspace, _: &MoveToStart, cx: &mut ViewContext<Workspace>) {
+    VimState::update_global(cx, |state, cx| {
+        state.update_active_editor(cx, |editor, cx| {
+            editor.move_to_beginning(&editor::MoveToBeginning, cx);
+        });
+        state.switch_mode(&SwitchMode(Mode::normal()), cx);
+    })
+}
+
+#[cfg(test)]
+mod test {
+    use indoc::indoc;
+
+    use crate::{
+        mode::{Mode, NormalState},
+        vim_test_context::VimTestContext,
+    };
+
+    #[gpui::test]
+    async fn test_g_prefix_and_abort(cx: &mut gpui::TestAppContext) {
+        let mut cx = VimTestContext::new(cx, true, "").await;
+
+        // Can abort with escape to get back to normal mode
+        cx.simulate_keystroke("g");
+        assert_eq!(cx.mode(), Mode::Normal(NormalState::GPrefix));
+        cx.simulate_keystroke("escape");
+        assert_eq!(cx.mode(), Mode::normal());
+    }
+
+    #[gpui::test]
+    async fn test_move_to_start(cx: &mut gpui::TestAppContext) {
+        let initial_content = indoc! {"
+            The quick
+            
+            brown fox jumps
+            over the lazy dog"};
+        let mut cx = VimTestContext::new(cx, true, initial_content).await;
+
+        // Jump to the end to
+        cx.simulate_keystroke("shift-G");
+        cx.assert_editor_state(indoc! {"
+            The quick
+            
+            brown fox jumps
+            over the lazy do|g"});
+
+        // Jump to the start
+        cx.simulate_keystrokes(&["g", "g"]);
+        cx.assert_editor_state(indoc! {"
+            |The quick
+            
+            brown fox jumps
+            over the lazy dog"});
+        assert_eq!(cx.mode(), Mode::normal());
+
+        // Repeat action doesn't change
+        cx.simulate_keystrokes(&["g", "g"]);
+        cx.assert_editor_state(indoc! {"
+            |The quick
+            
+            brown fox jumps
+            over the lazy dog"});
+        assert_eq!(cx.mode(), Mode::normal());
+    }
+}

crates/vim/src/vim.rs 🔗

@@ -3,7 +3,7 @@ mod insert;
 mod mode;
 mod normal;
 #[cfg(test)]
-mod vim_tests;
+mod vim_test_context;
 
 use collections::HashMap;
 use editor::{CursorShape, Editor};
@@ -65,8 +65,9 @@ impl VimState {
     fn set_enabled(&mut self, enabled: bool, cx: &mut MutableAppContext) {
         if self.enabled != enabled {
             self.enabled = enabled;
+            self.mode = Default::default();
             if enabled {
-                self.mode = Mode::Normal;
+                self.mode = Mode::normal();
             }
             self.sync_editor_options(cx);
         }
@@ -95,3 +96,43 @@ impl VimState {
         }
     }
 }
+
+#[cfg(test)]
+mod test {
+    use crate::{mode::Mode, vim_test_context::VimTestContext};
+
+    #[gpui::test]
+    async fn test_initially_disabled(cx: &mut gpui::TestAppContext) {
+        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;
+
+        cx.simulate_keystroke("i");
+        assert_eq!(cx.mode(), Mode::Insert);
+
+        // Editor acts as though vim is disabled
+        cx.disable_vim();
+        cx.simulate_keystrokes(&["h", "j", "k", "l"]);
+        cx.assert_editor_state("hjkl|");
+
+        // Enabling dynamically sets vim mode again and restores normal mode
+        cx.enable_vim();
+        assert_eq!(cx.mode(), Mode::normal());
+        cx.simulate_keystrokes(&["h", "h", "h", "l"]);
+        assert_eq!(cx.editor_text(), "hjkl".to_owned());
+        cx.assert_editor_state("hj|kl");
+        cx.simulate_keystrokes(&["i", "T", "e", "s", "t"]);
+        cx.assert_editor_state("hjTest|kl");
+
+        // Disabling and enabling resets to normal mode
+        assert_eq!(cx.mode(), Mode::Insert);
+        cx.disable_vim();
+        cx.enable_vim();
+        assert_eq!(cx.mode(), Mode::normal());
+    }
+}

crates/vim/src/vim_test_context.rs 🔗

@@ -0,0 +1,179 @@
+use std::ops::Deref;
+
+use editor::{display_map::ToDisplayPoint, Bias, DisplayPoint};
+use gpui::{json::json, keymap::Keystroke, ViewHandle};
+use language::{Point, Selection};
+use util::test::marked_text;
+use workspace::{WorkspaceHandle, WorkspaceParams};
+
+use crate::*;
+
+pub struct VimTestContext<'a> {
+    cx: &'a mut gpui::TestAppContext,
+    window_id: usize,
+    editor: ViewHandle<Editor>,
+}
+
+impl<'a> VimTestContext<'a> {
+    pub async fn new(
+        cx: &'a mut gpui::TestAppContext,
+        enabled: bool,
+        initial_editor_text: &str,
+    ) -> VimTestContext<'a> {
+        cx.update(|cx| {
+            editor::init(cx);
+            crate::init(cx);
+        });
+        let params = cx.update(WorkspaceParams::test);
+
+        cx.update(|cx| {
+            cx.update_global(|settings: &mut Settings, _| {
+                settings.vim_mode = enabled;
+            });
+        });
+
+        params
+            .fs
+            .as_fake()
+            .insert_tree(
+                "/root",
+                json!({ "dir": { "test.txt": initial_editor_text } }),
+            )
+            .await;
+
+        let (window_id, workspace) = cx.add_window(|cx| Workspace::new(&params, cx));
+        params
+            .project
+            .update(cx, |project, cx| {
+                project.find_or_create_local_worktree("/root", true, cx)
+            })
+            .await
+            .unwrap();
+        cx.read(|cx| workspace.read(cx).worktree_scans_complete(cx))
+            .await;
+
+        let file = cx.read(|cx| workspace.file_project_paths(cx)[0].clone());
+        let item = workspace
+            .update(cx, |workspace, cx| workspace.open_path(file, cx))
+            .await
+            .expect("Could not open test file");
+
+        let editor = cx.update(|cx| {
+            item.act_as::<Editor>(cx)
+                .expect("Opened test file wasn't an editor")
+        });
+        editor.update(cx, |_, cx| cx.focus_self());
+
+        Self {
+            cx,
+            window_id,
+            editor,
+        }
+    }
+
+    pub fn enable_vim(&mut self) {
+        self.cx.update(|cx| {
+            cx.update_global(|settings: &mut Settings, _| {
+                settings.vim_mode = true;
+            });
+        })
+    }
+
+    pub fn disable_vim(&mut self) {
+        self.cx.update(|cx| {
+            cx.update_global(|settings: &mut Settings, _| {
+                settings.vim_mode = false;
+            });
+        })
+    }
+
+    pub fn newest_selection(&mut self) -> Selection<DisplayPoint> {
+        self.editor.update(self.cx, |editor, cx| {
+            let snapshot = editor.snapshot(cx);
+            editor
+                .newest_selection::<Point>(cx)
+                .map(|point| point.to_display_point(&snapshot.display_snapshot))
+        })
+    }
+
+    pub fn mode(&mut self) -> Mode {
+        self.cx.update(|cx| cx.global::<VimState>().mode)
+    }
+
+    pub fn editor_text(&mut self) -> String {
+        self.editor
+            .update(self.cx, |editor, cx| editor.snapshot(cx).text())
+    }
+
+    pub fn simulate_keystroke(&mut self, keystroke_text: &str) {
+        let keystroke = Keystroke::parse(keystroke_text).unwrap();
+        let input = if keystroke.modified() {
+            None
+        } else {
+            Some(keystroke.key.clone())
+        };
+        self.cx
+            .dispatch_keystroke(self.window_id, keystroke, input, false);
+    }
+
+    pub fn simulate_keystrokes(&mut self, keystroke_texts: &[&str]) {
+        for keystroke_text in keystroke_texts.into_iter() {
+            self.simulate_keystroke(keystroke_text);
+        }
+    }
+
+    pub fn assert_newest_selection_head_offset(&mut self, expected_offset: usize) {
+        let actual_head = self.newest_selection().head();
+        let (actual_offset, expected_head) = self.editor.update(self.cx, |editor, cx| {
+            let snapshot = editor.snapshot(cx);
+            (
+                actual_head.to_offset(&snapshot, Bias::Left),
+                expected_offset.to_display_point(&snapshot),
+            )
+        });
+        let mut actual_position_text = self.editor_text();
+        let mut expected_position_text = actual_position_text.clone();
+        actual_position_text.insert(actual_offset, '|');
+        expected_position_text.insert(expected_offset, '|');
+        assert_eq!(
+            actual_head, expected_head,
+            "\nActual Position: {}\nExpected Position: {}",
+            actual_position_text, expected_position_text
+        )
+    }
+
+    pub fn assert_editor_state(&mut self, text: &str) {
+        let (unmarked_text, markers) = marked_text(&text);
+        let editor_text = self.editor_text();
+        assert_eq!(
+            editor_text, unmarked_text,
+            "Unmarked text doesn't match editor text"
+        );
+        let expected_offset = markers[0];
+        let actual_head = self.newest_selection().head();
+        let (actual_offset, expected_head) = self.editor.update(self.cx, |editor, cx| {
+            let snapshot = editor.snapshot(cx);
+            (
+                actual_head.to_offset(&snapshot, Bias::Left),
+                expected_offset.to_display_point(&snapshot),
+            )
+        });
+        let mut actual_position_text = self.editor_text();
+        let mut expected_position_text = actual_position_text.clone();
+        actual_position_text.insert(actual_offset, '|');
+        expected_position_text.insert(expected_offset, '|');
+        assert_eq!(
+            actual_head, expected_head,
+            "\nActual Position: {}\nExpected Position: {}",
+            actual_position_text, expected_position_text
+        )
+    }
+}
+
+impl<'a> Deref for VimTestContext<'a> {
+    type Target = gpui::TestAppContext;
+
+    fn deref(&self) -> &Self::Target {
+        self.cx
+    }
+}

crates/vim/src/vim_tests.rs 🔗

@@ -1,253 +0,0 @@
-use indoc::indoc;
-use std::ops::Deref;
-
-use editor::{display_map::ToDisplayPoint, DisplayPoint};
-use gpui::{json::json, keymap::Keystroke, ViewHandle};
-use language::{Point, Selection};
-use util::test::marked_text;
-use workspace::{WorkspaceHandle, WorkspaceParams};
-
-use crate::*;
-
-#[gpui::test]
-async fn test_insert_mode(cx: &mut gpui::TestAppContext) {
-    let mut cx = VimTestAppContext::new(cx, true, "").await;
-    cx.simulate_keystroke("i");
-    assert_eq!(cx.mode(), Mode::Insert);
-    cx.simulate_keystrokes(&["T", "e", "s", "t"]);
-    cx.assert_newest_selection_head("Test|");
-    cx.simulate_keystroke("escape");
-    assert_eq!(cx.mode(), Mode::Normal);
-    cx.assert_newest_selection_head("Tes|t");
-}
-
-#[gpui::test]
-async fn test_normal_hjkl(cx: &mut gpui::TestAppContext) {
-    let mut cx = VimTestAppContext::new(cx, true, "Test\nTestTest\nTest").await;
-    cx.simulate_keystroke("l");
-    cx.assert_newest_selection_head(indoc! {"
-        T|est
-        TestTest
-        Test"});
-    cx.simulate_keystroke("h");
-    cx.assert_newest_selection_head(indoc! {"
-        |Test
-        TestTest
-        Test"});
-    cx.simulate_keystroke("j");
-    cx.assert_newest_selection_head(indoc! {"
-        Test
-        |TestTest
-        Test"});
-    cx.simulate_keystroke("k");
-    cx.assert_newest_selection_head(indoc! {"
-        |Test
-        TestTest
-        Test"});
-    cx.simulate_keystroke("j");
-    cx.assert_newest_selection_head(indoc! {"
-        Test
-        |TestTest
-        Test"});
-
-    // When moving left, cursor does not wrap to the previous line
-    cx.simulate_keystroke("h");
-    cx.assert_newest_selection_head(indoc! {"
-        Test
-        |TestTest
-        Test"});
-
-    // 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_newest_selection_head(indoc! {"
-        Test
-        TestTes|t
-        Test"});
-
-    // Goal column respects the inability to reach the end of the line
-    cx.simulate_keystroke("k");
-    cx.assert_newest_selection_head(indoc! {"
-        Tes|t
-        TestTest
-        Test"});
-    cx.simulate_keystroke("j");
-    cx.assert_newest_selection_head(indoc! {"
-        Test
-        TestTes|t
-        Test"});
-}
-
-#[gpui::test]
-async fn test_toggle_through_settings(cx: &mut gpui::TestAppContext) {
-    let mut cx = VimTestAppContext::new(cx, true, "").await;
-
-    cx.simulate_keystroke("i");
-    assert_eq!(cx.mode(), Mode::Insert);
-
-    // Editor acts as though vim is disabled
-    cx.disable_vim();
-    cx.simulate_keystrokes(&["h", "j", "k", "l"]);
-    cx.assert_newest_selection_head("hjkl|");
-
-    // Enabling dynamically sets vim mode again and restores normal mode
-    cx.enable_vim();
-    assert_eq!(cx.mode(), Mode::Normal);
-    cx.simulate_keystrokes(&["h", "h", "h", "l"]);
-    assert_eq!(cx.editor_text(), "hjkl".to_owned());
-    cx.assert_newest_selection_head("hj|kl");
-    cx.simulate_keystrokes(&["i", "T", "e", "s", "t"]);
-    cx.assert_newest_selection_head("hjTest|kl");
-
-    // Disabling and enabling resets to normal mode
-    assert_eq!(cx.mode(), Mode::Insert);
-    cx.disable_vim();
-    assert_eq!(cx.mode(), Mode::Insert);
-    cx.enable_vim();
-    assert_eq!(cx.mode(), Mode::Normal);
-}
-
-#[gpui::test]
-async fn test_initially_disabled(cx: &mut gpui::TestAppContext) {
-    let mut cx = VimTestAppContext::new(cx, false, "").await;
-    cx.simulate_keystrokes(&["h", "j", "k", "l"]);
-    cx.assert_newest_selection_head("hjkl|");
-}
-
-struct VimTestAppContext<'a> {
-    cx: &'a mut gpui::TestAppContext,
-    window_id: usize,
-    editor: ViewHandle<Editor>,
-}
-
-impl<'a> VimTestAppContext<'a> {
-    async fn new(
-        cx: &'a mut gpui::TestAppContext,
-        enabled: bool,
-        initial_editor_text: &str,
-    ) -> VimTestAppContext<'a> {
-        cx.update(|cx| {
-            editor::init(cx);
-            crate::init(cx);
-        });
-        let params = cx.update(WorkspaceParams::test);
-
-        cx.update(|cx| {
-            cx.update_global(|settings: &mut Settings, _| {
-                settings.vim_mode = enabled;
-            });
-        });
-
-        params
-            .fs
-            .as_fake()
-            .insert_tree(
-                "/root",
-                json!({ "dir": { "test.txt": initial_editor_text } }),
-            )
-            .await;
-
-        let (window_id, workspace) = cx.add_window(|cx| Workspace::new(&params, cx));
-        params
-            .project
-            .update(cx, |project, cx| {
-                project.find_or_create_local_worktree("/root", true, cx)
-            })
-            .await
-            .unwrap();
-        cx.read(|cx| workspace.read(cx).worktree_scans_complete(cx))
-            .await;
-
-        let file = cx.read(|cx| workspace.file_project_paths(cx)[0].clone());
-        let item = workspace
-            .update(cx, |workspace, cx| workspace.open_path(file, cx))
-            .await
-            .expect("Could not open test file");
-
-        let editor = cx.update(|cx| {
-            item.act_as::<Editor>(cx)
-                .expect("Opened test file wasn't an editor")
-        });
-        editor.update(cx, |_, cx| cx.focus_self());
-
-        Self {
-            cx,
-            window_id,
-            editor,
-        }
-    }
-
-    fn enable_vim(&mut self) {
-        self.cx.update(|cx| {
-            cx.update_global(|settings: &mut Settings, _| {
-                settings.vim_mode = true;
-            });
-        })
-    }
-
-    fn disable_vim(&mut self) {
-        self.cx.update(|cx| {
-            cx.update_global(|settings: &mut Settings, _| {
-                settings.vim_mode = false;
-            });
-        })
-    }
-
-    fn newest_selection(&mut self) -> Selection<DisplayPoint> {
-        self.editor.update(self.cx, |editor, cx| {
-            let snapshot = editor.snapshot(cx);
-            editor
-                .newest_selection::<Point>(cx)
-                .map(|point| point.to_display_point(&snapshot.display_snapshot))
-        })
-    }
-
-    fn mode(&mut self) -> Mode {
-        self.cx.update(|cx| cx.global::<VimState>().mode)
-    }
-
-    fn editor_text(&mut self) -> String {
-        self.editor
-            .update(self.cx, |editor, cx| editor.snapshot(cx).text())
-    }
-
-    fn simulate_keystroke(&mut self, keystroke_text: &str) {
-        let keystroke = Keystroke::parse(keystroke_text).unwrap();
-        let input = if keystroke.modified() {
-            None
-        } else {
-            Some(keystroke.key.clone())
-        };
-        self.cx
-            .dispatch_keystroke(self.window_id, keystroke, input, false);
-    }
-
-    fn simulate_keystrokes(&mut self, keystroke_texts: &[&str]) {
-        for keystroke_text in keystroke_texts.into_iter() {
-            self.simulate_keystroke(keystroke_text);
-        }
-    }
-
-    fn assert_newest_selection_head(&mut self, text: &str) {
-        let (unmarked_text, markers) = marked_text(&text);
-        assert_eq!(
-            self.editor_text(),
-            unmarked_text,
-            "Unmarked text doesn't match editor text"
-        );
-        let newest_selection = self.newest_selection();
-        let expected_head = self.editor.update(self.cx, |editor, cx| {
-            markers[0].to_display_point(&editor.snapshot(cx))
-        });
-        assert_eq!(newest_selection.head(), expected_head)
-    }
-}
-
-impl<'a> Deref for VimTestAppContext<'a> {
-    type Target = gpui::TestAppContext;
-
-    fn deref(&self) -> &Self::Target {
-        self.cx
-    }
-}