vim: Implement Go To Previous Word End (#7505)

Vishal Bhavsar and Conrad Irwin created

Activated by keystrokes g-e.



Release Notes:

- vim: Added `ge` and `gE` for go to Previous Word End.

---------

Co-authored-by: Conrad Irwin <conrad.irwin@gmail.com>

Change summary

assets/keymaps/vim.json                          |   2 
crates/editor/src/movement.rs                    |  42 +++-
crates/vim/src/motion.rs                         | 171 +++++++++++++++++
crates/vim/src/object.rs                         |   4 
crates/vim/test_data/test_previous_word_end.json |  29 +++
5 files changed, 230 insertions(+), 18 deletions(-)

Detailed changes

assets/keymaps/vim.json šŸ”—

@@ -117,6 +117,8 @@
       "ctrl-e": "vim::LineDown",
       "ctrl-y": "vim::LineUp",
       // "g" commands
+      "g e": "vim::PreviousWordEnd",
+      "g shift-e": ["vim::PreviousWordEnd", { "ignorePunctuation": true }],
       "g g": "vim::StartOfDocument",
       "g h": "editor::Hover",
       "g t": "pane::ActivateNextItem",

crates/editor/src/movement.rs šŸ”—

@@ -5,6 +5,7 @@ use super::{Bias, DisplayPoint, DisplaySnapshot, SelectionGoal, ToDisplayPoint};
 use crate::{char_kind, scroll::ScrollAnchor, CharKind, EditorStyle, ToOffset, ToPoint};
 use gpui::{px, Pixels, WindowTextSystem};
 use language::Point;
+use multi_buffer::MultiBufferSnapshot;
 
 use std::{ops::Range, sync::Arc};
 
@@ -254,7 +255,7 @@ pub fn previous_word_start(map: &DisplaySnapshot, point: DisplayPoint) -> Displa
     let raw_point = point.to_point(map);
     let scope = map.buffer_snapshot.language_scope_at(raw_point);
 
-    find_preceding_boundary(map, point, FindRange::MultiLine, |left, right| {
+    find_preceding_boundary_display_point(map, point, FindRange::MultiLine, |left, right| {
         (char_kind(&scope, left) != char_kind(&scope, right) && !right.is_whitespace())
             || left == '\n'
     })
@@ -267,7 +268,7 @@ pub fn previous_subword_start(map: &DisplaySnapshot, point: DisplayPoint) -> Dis
     let raw_point = point.to_point(map);
     let scope = map.buffer_snapshot.language_scope_at(raw_point);
 
-    find_preceding_boundary(map, point, FindRange::MultiLine, |left, right| {
+    find_preceding_boundary_display_point(map, point, FindRange::MultiLine, |left, right| {
         let is_word_start =
             char_kind(&scope, left) != char_kind(&scope, right) && !right.is_whitespace();
         let is_subword_start =
@@ -366,16 +367,16 @@ pub fn end_of_paragraph(
 /// indicated by the given predicate returning true.
 /// The predicate is called with the character to the left and right of the candidate boundary location.
 /// If FindRange::SingleLine is specified and no boundary is found before the start of the current line, the start of the current line will be returned.
-pub fn find_preceding_boundary(
-    map: &DisplaySnapshot,
-    from: DisplayPoint,
+pub fn find_preceding_boundary_point(
+    buffer_snapshot: &MultiBufferSnapshot,
+    from: Point,
     find_range: FindRange,
     mut is_boundary: impl FnMut(char, char) -> bool,
-) -> DisplayPoint {
+) -> Point {
     let mut prev_ch = None;
-    let mut offset = from.to_point(map).to_offset(&map.buffer_snapshot);
+    let mut offset = from.to_offset(&buffer_snapshot);
 
-    for ch in map.buffer_snapshot.reversed_chars_at(offset) {
+    for ch in buffer_snapshot.reversed_chars_at(offset) {
         if find_range == FindRange::SingleLine && ch == '\n' {
             break;
         }
@@ -389,7 +390,26 @@ pub fn find_preceding_boundary(
         prev_ch = Some(ch);
     }
 
-    map.clip_point(offset.to_display_point(map), Bias::Left)
+    offset.to_point(&buffer_snapshot)
+}
+
+/// Scans for a boundary preceding the given start point `from` until a boundary is found,
+/// indicated by the given predicate returning true.
+/// The predicate is called with the character to the left and right of the candidate boundary location.
+/// If FindRange::SingleLine is specified and no boundary is found before the start of the current line, the start of the current line will be returned.
+pub fn find_preceding_boundary_display_point(
+    map: &DisplaySnapshot,
+    from: DisplayPoint,
+    find_range: FindRange,
+    is_boundary: impl FnMut(char, char) -> bool,
+) -> DisplayPoint {
+    let result = find_preceding_boundary_point(
+        &map.buffer_snapshot,
+        from.to_point(map),
+        find_range,
+        is_boundary,
+    );
+    map.clip_point(result.to_display_point(map), Bias::Left)
 }
 
 /// Scans for a boundary following the given start point until a boundary is found, indicated by the
@@ -626,7 +646,7 @@ mod tests {
         ) {
             let (snapshot, display_points) = marked_display_snapshot(marked_text, cx);
             assert_eq!(
-                find_preceding_boundary(
+                find_preceding_boundary_display_point(
                     &snapshot,
                     display_points[1],
                     FindRange::MultiLine,
@@ -700,7 +720,7 @@ mod tests {
         });
 
         assert_eq!(
-            find_preceding_boundary(
+            find_preceding_boundary_display_point(
                 &snapshot,
                 buffer_snapshot.len().to_display_point(&snapshot),
                 FindRange::MultiLine,

crates/vim/src/motion.rs šŸ”—

@@ -1,6 +1,8 @@
 use editor::{
     display_map::{DisplaySnapshot, FoldPoint, ToDisplayPoint},
-    movement::{self, find_boundary, find_preceding_boundary, FindRange, TextLayoutDetails},
+    movement::{
+        self, find_boundary, find_preceding_boundary_display_point, FindRange, TextLayoutDetails,
+    },
     Bias, DisplayPoint, ToOffset,
 };
 use gpui::{actions, impl_actions, px, ViewContext, WindowContext};
@@ -27,6 +29,7 @@ pub enum Motion {
     NextWordStart { ignore_punctuation: bool },
     NextWordEnd { ignore_punctuation: bool },
     PreviousWordStart { ignore_punctuation: bool },
+    PreviousWordEnd { ignore_punctuation: bool },
     FirstNonWhitespace { display_lines: bool },
     CurrentLine,
     StartOfLine { display_lines: bool },
@@ -70,6 +73,13 @@ struct PreviousWordStart {
     ignore_punctuation: bool,
 }
 
+#[derive(Clone, Deserialize, PartialEq)]
+#[serde(rename_all = "camelCase")]
+struct PreviousWordEnd {
+    #[serde(default)]
+    ignore_punctuation: bool,
+}
+
 #[derive(Clone, Deserialize, PartialEq)]
 #[serde(rename_all = "camelCase")]
 pub(crate) struct Up {
@@ -114,6 +124,7 @@ impl_actions!(
         Down,
         Up,
         PreviousWordStart,
+        PreviousWordEnd,
         NextWordEnd,
         NextWordStart
     ]
@@ -263,6 +274,11 @@ pub fn register(workspace: &mut Workspace, _: &mut ViewContext<Workspace>) {
     workspace.register_action(|_: &mut Workspace, &WindowBottom, cx: _| {
         motion(Motion::WindowBottom, cx)
     });
+    workspace.register_action(
+        |_: &mut Workspace, &PreviousWordEnd { ignore_punctuation }, cx: _| {
+            motion(Motion::PreviousWordEnd { ignore_punctuation }, cx)
+        },
+    );
 }
 
 pub(crate) fn motion(motion: Motion, cx: &mut WindowContext) {
@@ -315,6 +331,7 @@ impl Motion {
             | GoToColumn
             | NextWordStart { .. }
             | PreviousWordStart { .. }
+            | PreviousWordEnd { .. }
             | FirstNonWhitespace { .. }
             | FindBackward { .. }
             | RepeatFind { .. }
@@ -351,6 +368,7 @@ impl Motion {
             | WindowTop
             | WindowMiddle
             | WindowBottom
+            | PreviousWordEnd { .. }
             | NextLineStart => false,
         }
     }
@@ -371,6 +389,7 @@ impl Motion {
             | WindowTop
             | WindowMiddle
             | WindowBottom
+            | PreviousWordEnd { .. }
             | NextLineStart => true,
             Left
             | Backspace
@@ -431,6 +450,10 @@ impl Motion {
                 previous_word_start(map, point, *ignore_punctuation, times),
                 SelectionGoal::None,
             ),
+            PreviousWordEnd { ignore_punctuation } => (
+                previous_word_end(map, point, *ignore_punctuation, times),
+                SelectionGoal::None,
+            ),
             FirstNonWhitespace { display_lines } => (
                 first_non_whitespace(map, *display_lines, point),
                 SelectionGoal::None,
@@ -840,13 +863,17 @@ fn previous_word_start(
     for _ in 0..times {
         // This works even though find_preceding_boundary is called for every character in the line containing
         // cursor because the newline is checked only once.
-        let new_point =
-            movement::find_preceding_boundary(map, point, FindRange::MultiLine, |left, right| {
+        let new_point = movement::find_preceding_boundary_display_point(
+            map,
+            point,
+            FindRange::MultiLine,
+            |left, right| {
                 let left_kind = coerce_punctuation(char_kind(&scope, left), ignore_punctuation);
                 let right_kind = coerce_punctuation(char_kind(&scope, right), ignore_punctuation);
 
                 (left_kind != right_kind && !right.is_whitespace()) || left == '\n'
-            });
+            },
+        );
         if point == new_point {
             break;
         }
@@ -1023,7 +1050,9 @@ fn find_backward(
 
     for _ in 0..times {
         let new_to =
-            find_preceding_boundary(map, to, FindRange::SingleLine, |_, right| right == target);
+            find_preceding_boundary_display_point(map, to, FindRange::SingleLine, |_, right| {
+                right == target
+            });
         if to == new_to {
             break;
         }
@@ -1147,6 +1176,44 @@ fn window_bottom(
     }
 }
 
+fn previous_word_end(
+    map: &DisplaySnapshot,
+    point: DisplayPoint,
+    ignore_punctuation: bool,
+    times: usize,
+) -> DisplayPoint {
+    let scope = map.buffer_snapshot.language_scope_at(point.to_point(map));
+    let mut point = point.to_point(map);
+
+    if point.column < map.buffer_snapshot.line_len(point.row) {
+        point.column += 1;
+    }
+    for _ in 0..times {
+        let new_point = movement::find_preceding_boundary_point(
+            &map.buffer_snapshot,
+            point,
+            FindRange::MultiLine,
+            |left, right| {
+                let left_kind = coerce_punctuation(char_kind(&scope, left), ignore_punctuation);
+                let right_kind = coerce_punctuation(char_kind(&scope, right), ignore_punctuation);
+                match (left_kind, right_kind) {
+                    (CharKind::Punctuation, CharKind::Whitespace)
+                    | (CharKind::Punctuation, CharKind::Word)
+                    | (CharKind::Word, CharKind::Whitespace)
+                    | (CharKind::Word, CharKind::Punctuation) => true,
+                    (CharKind::Whitespace, CharKind::Whitespace) => left == '\n' && right == '\n',
+                    _ => false,
+                }
+            },
+        );
+        if new_point == point {
+            break;
+        }
+        point = new_point;
+    }
+    movement::saturating_left(map, point.to_display_point(map))
+}
+
 #[cfg(test)]
 mod test {
 
@@ -1564,4 +1631,98 @@ mod test {
           "})
             .await;
     }
+
+    #[gpui::test]
+    async fn test_previous_word_end(cx: &mut gpui::TestAppContext) {
+        let mut cx = NeovimBackedTestContext::new(cx).await;
+        cx.set_shared_state(indoc! {r"
+        456 5ˇ67 678
+        "})
+            .await;
+        cx.simulate_shared_keystrokes(["g", "e"]).await;
+        cx.assert_shared_state(indoc! {r"
+        45ˇ6 567 678
+        "})
+            .await;
+
+        // Test times
+        cx.set_shared_state(indoc! {r"
+        123 234 345
+        456 5ˇ67 678
+        "})
+            .await;
+        cx.simulate_shared_keystrokes(["4", "g", "e"]).await;
+        cx.assert_shared_state(indoc! {r"
+        12ˇ3 234 345
+        456 567 678
+        "})
+            .await;
+
+        // With punctuation
+        cx.set_shared_state(indoc! {r"
+        123 234 345
+        4;5.6 5ˇ67 678
+        789 890 901
+        "})
+            .await;
+        cx.simulate_shared_keystrokes(["g", "e"]).await;
+        cx.assert_shared_state(indoc! {r"
+          123 234 345
+          4;5.ˇ6 567 678
+          789 890 901
+        "})
+            .await;
+
+        // With punctuation and count
+        cx.set_shared_state(indoc! {r"
+        123 234 345
+        4;5.6 5ˇ67 678
+        789 890 901
+        "})
+            .await;
+        cx.simulate_shared_keystrokes(["5", "g", "e"]).await;
+        cx.assert_shared_state(indoc! {r"
+          123 234 345
+          ˇ4;5.6 567 678
+          789 890 901
+        "})
+            .await;
+
+        // newlines
+        cx.set_shared_state(indoc! {r"
+        123 234 345
+
+        78ˇ9 890 901
+        "})
+            .await;
+        cx.simulate_shared_keystrokes(["g", "e"]).await;
+        cx.assert_shared_state(indoc! {r"
+          123 234 345
+          ˇ
+          789 890 901
+        "})
+            .await;
+        cx.simulate_shared_keystrokes(["g", "e"]).await;
+        cx.assert_shared_state(indoc! {r"
+          123 234 34ˇ5
+
+          789 890 901
+        "})
+            .await;
+
+        // With punctuation
+        cx.set_shared_state(indoc! {r"
+        123 234 345
+        4;5.ˇ6 567 678
+        789 890 901
+        "})
+            .await;
+        cx.simulate_shared_keystrokes(["g", "shift-e"]).await;
+        cx.assert_shared_state(indoc! {r"
+          123 234 34ˇ5
+          4;5.6 567 678
+          789 890 901
+        "})
+            .await;
+    }
 }

crates/vim/src/object.rs šŸ”—

@@ -211,7 +211,7 @@ fn in_word(
     let scope = map
         .buffer_snapshot
         .language_scope_at(relative_to.to_point(map));
-    let start = movement::find_preceding_boundary(
+    let start = movement::find_preceding_boundary_display_point(
         map,
         right(map, relative_to, 1),
         movement::FindRange::SingleLine,
@@ -281,7 +281,7 @@ fn around_next_word(
         .buffer_snapshot
         .language_scope_at(relative_to.to_point(map));
     // Get the start of the word
-    let start = movement::find_preceding_boundary(
+    let start = movement::find_preceding_boundary_display_point(
         map,
         right(map, relative_to, 1),
         FindRange::SingleLine,

crates/vim/test_data/test_previous_word_end.json šŸ”—

@@ -0,0 +1,29 @@
+{"Put":{"state":"456 5ˇ67 678\n"}}
+{"Key":"g"}
+{"Key":"e"}
+{"Get":{"state":"45ˇ6 567 678\n","mode":"Normal"}}
+{"Put":{"state":"123 234 345\n456 5ˇ67 678\n"}}
+{"Key":"4"}
+{"Key":"g"}
+{"Key":"e"}
+{"Get":{"state":"12ˇ3 234 345\n456 567 678\n","mode":"Normal"}}
+{"Put":{"state":"123 234 345\n4;5.6 5ˇ67 678\n789 890 901\n"}}
+{"Key":"g"}
+{"Key":"e"}
+{"Get":{"state":"123 234 345\n4;5.ˇ6 567 678\n789 890 901\n","mode":"Normal"}}
+{"Put":{"state":"123 234 345\n4;5.6 5ˇ67 678\n789 890 901\n"}}
+{"Key":"5"}
+{"Key":"g"}
+{"Key":"e"}
+{"Get":{"state":"123 234 345\nˇ4;5.6 567 678\n789 890 901\n","mode":"Normal"}}
+{"Put":{"state":"123 234 345\n\n78ˇ9 890 901\n"}}
+{"Key":"g"}
+{"Key":"e"}
+{"Get":{"state":"123 234 345\nˇ\n789 890 901\n","mode":"Normal"}}
+{"Key":"g"}
+{"Key":"e"}
+{"Get":{"state":"123 234 34ˇ5\n\n789 890 901\n","mode":"Normal"}}
+{"Put":{"state":"123 234 345\n4;5.ˇ6 567 678\n789 890 901\n"}}
+{"Key":"g"}
+{"Key":"shift-e"}
+{"Get":{"state":"123 234 34ˇ5\n4;5.6 567 678\n789 890 901\n","mode":"Normal"}}