vim: Sentence motion (#17425)

Conrad Irwin created

Closes #12161

Release Notes:

- vim: Added `(` and `)` for sentence motion

Change summary

assets/keymaps/vim.json                           |   2 
crates/vim/src/motion.rs                          | 146 ++++++++++++++++
crates/vim/src/test.rs                            |  90 ++++++++++
crates/vim/test_data/test_sentence_backwards.json |  32 +++
crates/vim/test_data/test_sentence_forwards.json  |   8 
5 files changed, 276 insertions(+), 2 deletions(-)

Detailed changes

assets/keymaps/vim.json 🔗

@@ -29,6 +29,8 @@
       "shift-g": "vim::EndOfDocument",
       "{": "vim::StartOfParagraph",
       "}": "vim::EndOfParagraph",
+      "(": "vim::SentenceBackward",
+      ")": "vim::SentenceForward",
       "|": "vim::GoToColumn",
       // Word motions
       "w": "vim::NextWordStart",

crates/vim/src/motion.rs 🔗

@@ -65,6 +65,8 @@ pub enum Motion {
     EndOfLine {
         display_lines: bool,
     },
+    SentenceBackward,
+    SentenceForward,
     StartOfParagraph,
     EndOfParagraph,
     StartOfDocument,
@@ -228,6 +230,8 @@ actions!(
         Right,
         Space,
         CurrentLine,
+        SentenceForward,
+        SentenceBackward,
         StartOfParagraph,
         EndOfParagraph,
         StartOfDocument,
@@ -306,6 +310,13 @@ pub fn register(editor: &mut Editor, cx: &mut ViewContext<Vim>) {
     Vim::action(editor, cx, |vim, _: &EndOfParagraph, cx| {
         vim.motion(Motion::EndOfParagraph, cx)
     });
+
+    Vim::action(editor, cx, |vim, _: &SentenceForward, cx| {
+        vim.motion(Motion::SentenceForward, cx)
+    });
+    Vim::action(editor, cx, |vim, _: &SentenceBackward, cx| {
+        vim.motion(Motion::SentenceBackward, cx)
+    });
     Vim::action(editor, cx, |vim, _: &StartOfDocument, cx| {
         vim.motion(Motion::StartOfDocument, cx)
     });
@@ -483,12 +494,14 @@ impl Motion {
             | NextLineStart
             | PreviousLineStart
             | StartOfLineDownward
+            | SentenceBackward
+            | SentenceForward
             | StartOfParagraph
+            | EndOfParagraph
             | WindowTop
             | WindowMiddle
             | WindowBottom
-            | Jump { line: true, .. }
-            | EndOfParagraph => true,
+            | Jump { line: true, .. } => true,
             EndOfLine { .. }
             | Matching
             | FindForward { .. }
@@ -533,6 +546,8 @@ impl Motion {
             | StartOfLine { .. }
             | StartOfParagraph
             | EndOfParagraph
+            | SentenceBackward
+            | SentenceForward
             | StartOfLineDownward
             | EndOfLineDownward
             | GoToColumn
@@ -586,6 +601,8 @@ impl Motion {
             | StartOfLineDownward
             | StartOfParagraph
             | EndOfParagraph
+            | SentenceBackward
+            | SentenceForward
             | GoToColumn
             | NextWordStart { .. }
             | PreviousWordStart { .. }
@@ -673,6 +690,8 @@ impl Motion {
                 end_of_line(map, *display_lines, point, times),
                 SelectionGoal::None,
             ),
+            SentenceBackward => (sentence_backwards(map, point, times), SelectionGoal::None),
+            SentenceForward => (sentence_forwards(map, point, times), SelectionGoal::None),
             StartOfParagraph => (
                 movement::start_of_paragraph(map, point, times),
                 SelectionGoal::None,
@@ -1534,6 +1553,129 @@ pub(crate) fn end_of_line(
     }
 }
 
+fn sentence_backwards(
+    map: &DisplaySnapshot,
+    point: DisplayPoint,
+    mut times: usize,
+) -> DisplayPoint {
+    let mut start = point.to_point(&map).to_offset(&map.buffer_snapshot);
+    let mut chars = map.reverse_buffer_chars_at(start).peekable();
+
+    let mut was_newline = map
+        .buffer_chars_at(start)
+        .next()
+        .is_some_and(|(c, _)| c == '\n');
+
+    while let Some((ch, offset)) = chars.next() {
+        let start_of_next_sentence = if was_newline && ch == '\n' {
+            Some(offset + ch.len_utf8())
+        } else if ch == '\n' && chars.peek().is_some_and(|(c, _)| *c == '\n') {
+            Some(next_non_blank(map, offset + ch.len_utf8()))
+        } else if ch == '.' || ch == '?' || ch == '!' {
+            start_of_next_sentence(map, offset + ch.len_utf8())
+        } else {
+            None
+        };
+
+        if let Some(start_of_next_sentence) = start_of_next_sentence {
+            if start_of_next_sentence < start {
+                times = times.saturating_sub(1);
+            }
+            if times == 0 || offset == 0 {
+                return map.clip_point(
+                    start_of_next_sentence
+                        .to_offset(&map.buffer_snapshot)
+                        .to_display_point(&map),
+                    Bias::Left,
+                );
+            }
+        }
+        if was_newline {
+            start = offset;
+        }
+        was_newline = ch == '\n';
+    }
+
+    return DisplayPoint::zero();
+}
+
+fn sentence_forwards(map: &DisplaySnapshot, point: DisplayPoint, mut times: usize) -> DisplayPoint {
+    let start = point.to_point(&map).to_offset(&map.buffer_snapshot);
+    let mut chars = map.buffer_chars_at(start).peekable();
+
+    let mut was_newline = map
+        .reverse_buffer_chars_at(start)
+        .next()
+        .is_some_and(|(c, _)| c == '\n')
+        && chars.peek().is_some_and(|(c, _)| *c == '\n');
+
+    while let Some((ch, offset)) = chars.next() {
+        if was_newline && ch == '\n' {
+            continue;
+        }
+        let start_of_next_sentence = if was_newline {
+            Some(next_non_blank(map, offset))
+        } else if ch == '\n' && chars.peek().is_some_and(|(c, _)| *c == '\n') {
+            Some(next_non_blank(map, offset + ch.len_utf8()))
+        } else if ch == '.' || ch == '?' || ch == '!' {
+            start_of_next_sentence(map, offset + ch.len_utf8())
+        } else {
+            None
+        };
+
+        if let Some(start_of_next_sentence) = start_of_next_sentence {
+            times = times.saturating_sub(1);
+            if times == 0 {
+                return map.clip_point(
+                    start_of_next_sentence
+                        .to_offset(&map.buffer_snapshot)
+                        .to_display_point(&map),
+                    Bias::Right,
+                );
+            }
+        }
+
+        was_newline = ch == '\n' && chars.peek().is_some_and(|(c, _)| *c == '\n');
+    }
+
+    return map.max_point();
+}
+
+fn next_non_blank(map: &DisplaySnapshot, start: usize) -> usize {
+    for (c, o) in map.buffer_chars_at(start) {
+        if c == '\n' || !c.is_whitespace() {
+            return o;
+        }
+    }
+
+    return map.buffer_snapshot.len();
+}
+
+// given the offset after a ., !, or ? find the start of the next sentence.
+// if this is not a sentence boundary, returns None.
+fn start_of_next_sentence(map: &DisplaySnapshot, end_of_sentence: usize) -> Option<usize> {
+    let mut chars = map.buffer_chars_at(end_of_sentence);
+    let mut seen_space = false;
+
+    while let Some((char, offset)) = chars.next() {
+        if !seen_space && (char == ')' || char == ']' || char == '"' || char == '\'') {
+            continue;
+        }
+
+        if char == '\n' && seen_space {
+            return Some(offset);
+        } else if char.is_whitespace() {
+            seen_space = true;
+        } else if seen_space {
+            return Some(offset);
+        } else {
+            return None;
+        }
+    }
+
+    return Some(map.buffer_snapshot.len());
+}
+
 fn start_of_document(map: &DisplaySnapshot, point: DisplayPoint, line: usize) -> DisplayPoint {
     let mut new_point = Point::new((line - 1) as u32, 0).to_display_point(map);
     *new_point.column_mut() = point.column();

crates/vim/src/test.rs 🔗

@@ -1426,3 +1426,93 @@ async fn test_record_replay_recursion(cx: &mut gpui::TestAppContext) {
     cx.simulate_shared_keystrokes(".").await;
     cx.shared_state().await.assert_eq("ˇhello world"); // takes a _long_ time
 }
+
+#[gpui::test]
+async fn test_sentence_backwards(cx: &mut gpui::TestAppContext) {
+    let mut cx = NeovimBackedTestContext::new(cx).await;
+
+    cx.set_shared_state("one\n\ntwo\nthree\nˇ\nfour").await;
+    cx.simulate_shared_keystrokes("(").await;
+    cx.shared_state()
+        .await
+        .assert_eq("one\n\nˇtwo\nthree\n\nfour");
+
+    cx.set_shared_state("hello.\n\n\nworˇld.").await;
+    cx.simulate_shared_keystrokes("(").await;
+    cx.shared_state().await.assert_eq("hello.\n\n\nˇworld.");
+    cx.simulate_shared_keystrokes("(").await;
+    cx.shared_state().await.assert_eq("hello.\n\nˇ\nworld.");
+    cx.simulate_shared_keystrokes("(").await;
+    cx.shared_state().await.assert_eq("ˇhello.\n\n\nworld.");
+
+    cx.set_shared_state("hello. worlˇd.").await;
+    cx.simulate_shared_keystrokes("(").await;
+    cx.shared_state().await.assert_eq("hello. ˇworld.");
+    cx.simulate_shared_keystrokes("(").await;
+    cx.shared_state().await.assert_eq("ˇhello. world.");
+
+    cx.set_shared_state(". helˇlo.").await;
+    cx.simulate_shared_keystrokes("(").await;
+    cx.shared_state().await.assert_eq(". ˇhello.");
+    cx.simulate_shared_keystrokes("(").await;
+    cx.shared_state().await.assert_eq(". ˇhello.");
+
+    cx.set_shared_state(indoc! {
+        "{
+            hello_world();
+        ˇ}"
+    })
+    .await;
+    cx.simulate_shared_keystrokes("(").await;
+    cx.shared_state().await.assert_eq(indoc! {
+        "ˇ{
+            hello_world();
+        }"
+    });
+
+    cx.set_shared_state(indoc! {
+        "Hello! World..?
+
+        \tHello! World... ˇ"
+    })
+    .await;
+    cx.simulate_shared_keystrokes("(").await;
+    cx.shared_state().await.assert_eq(indoc! {
+        "Hello! World..?
+
+        \tHello! ˇWorld... "
+    });
+    cx.simulate_shared_keystrokes("(").await;
+    cx.shared_state().await.assert_eq(indoc! {
+        "Hello! World..?
+
+        \tˇHello! World... "
+    });
+    cx.simulate_shared_keystrokes("(").await;
+    cx.shared_state().await.assert_eq(indoc! {
+        "Hello! World..?
+        ˇ
+        \tHello! World... "
+    });
+    cx.simulate_shared_keystrokes("(").await;
+    cx.shared_state().await.assert_eq(indoc! {
+        "Hello! ˇWorld..?
+
+        \tHello! World... "
+    });
+}
+
+#[gpui::test]
+async fn test_sentence_forwards(cx: &mut gpui::TestAppContext) {
+    let mut cx = NeovimBackedTestContext::new(cx).await;
+
+    cx.set_shared_state("helˇlo.\n\n\nworld.").await;
+    cx.simulate_shared_keystrokes(")").await;
+    cx.shared_state().await.assert_eq("hello.\nˇ\n\nworld.");
+    cx.simulate_shared_keystrokes(")").await;
+    cx.shared_state().await.assert_eq("hello.\n\n\nˇworld.");
+    cx.simulate_shared_keystrokes(")").await;
+    cx.shared_state().await.assert_eq("hello.\n\n\nworldˇ.");
+
+    cx.set_shared_state("helˇlo.\n\n\nworld.").await;
+}

crates/vim/test_data/test_sentence_backwards.json 🔗

@@ -0,0 +1,32 @@
+{"Put":{"state":"one\n\ntwo\nthree\nˇ\nfour"}}
+{"Key":"("}
+{"Get":{"state":"one\n\nˇtwo\nthree\n\nfour","mode":"Normal"}}
+{"Put":{"state":"hello.\n\n\nworˇld."}}
+{"Key":"("}
+{"Get":{"state":"hello.\n\n\nˇworld.","mode":"Normal"}}
+{"Key":"("}
+{"Get":{"state":"hello.\n\nˇ\nworld.","mode":"Normal"}}
+{"Key":"("}
+{"Get":{"state":"ˇhello.\n\n\nworld.","mode":"Normal"}}
+{"Put":{"state":"hello. worlˇd."}}
+{"Key":"("}
+{"Get":{"state":"hello. ˇworld.","mode":"Normal"}}
+{"Key":"("}
+{"Get":{"state":"ˇhello. world.","mode":"Normal"}}
+{"Put":{"state":". helˇlo."}}
+{"Key":"("}
+{"Get":{"state":". ˇhello.","mode":"Normal"}}
+{"Key":"("}
+{"Get":{"state":". ˇhello.","mode":"Normal"}}
+{"Put":{"state":"{\n    hello_world();\nˇ}"}}
+{"Key":"("}
+{"Get":{"state":"ˇ{\n    hello_world();\n}","mode":"Normal"}}
+{"Put":{"state":"Hello! World..?\n\n\tHello! World... ˇ"}}
+{"Key":"("}
+{"Get":{"state":"Hello! World..?\n\n\tHello! ˇWorld... ","mode":"Normal"}}
+{"Key":"("}
+{"Get":{"state":"Hello! World..?\n\n\tˇHello! World... ","mode":"Normal"}}
+{"Key":"("}
+{"Get":{"state":"Hello! World..?\nˇ\n\tHello! World... ","mode":"Normal"}}
+{"Key":"("}
+{"Get":{"state":"Hello! ˇWorld..?\n\n\tHello! World... ","mode":"Normal"}}

crates/vim/test_data/test_sentence_forwards.json 🔗

@@ -0,0 +1,8 @@
+{"Put":{"state":"helˇlo.\n\n\nworld."}}
+{"Key":")"}
+{"Get":{"state":"hello.\nˇ\n\nworld.","mode":"Normal"}}
+{"Key":")"}
+{"Get":{"state":"hello.\n\n\nˇworld.","mode":"Normal"}}
+{"Key":")"}
+{"Get":{"state":"hello.\n\n\nworldˇ.","mode":"Normal"}}
+{"Put":{"state":"helˇlo.\n\n\nworld."}}