From 1e09884a22d6292a5d4bc8d31721740c26ef131f Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Thu, 5 Sep 2024 11:18:52 -0600 Subject: [PATCH] vim: Sentence motion (#17425) Closes #12161 Release Notes: - vim: Added `(` and `)` for sentence motion --- assets/keymaps/vim.json | 2 + crates/vim/src/motion.rs | 146 +++++++++++++++++- crates/vim/src/test.rs | 90 +++++++++++ .../test_data/test_sentence_backwards.json | 32 ++++ .../vim/test_data/test_sentence_forwards.json | 8 + 5 files changed, 276 insertions(+), 2 deletions(-) create mode 100644 crates/vim/test_data/test_sentence_backwards.json create mode 100644 crates/vim/test_data/test_sentence_forwards.json diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index 37a3ee0780d51e27ce6370d2a2628a15e42b97ad..ed5d99782f95609d69076d37187dcc7c67004f76 100644 --- a/assets/keymaps/vim.json +++ b/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", diff --git a/crates/vim/src/motion.rs b/crates/vim/src/motion.rs index 1e3366a91bca01010554816a80ab824a1e11d863..a8541eb7ff4bfc3c123fd98000acdd519a984d4f 100644 --- a/crates/vim/src/motion.rs +++ b/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::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 { + 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(); diff --git a/crates/vim/src/test.rs b/crates/vim/src/test.rs index b46017b1d34f175d75180112a3d4c350cea98a7b..2752b039de04bd4ecd188f59b2026606514a5fd5 100644 --- a/crates/vim/src/test.rs +++ b/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; +} diff --git a/crates/vim/test_data/test_sentence_backwards.json b/crates/vim/test_data/test_sentence_backwards.json new file mode 100644 index 0000000000000000000000000000000000000000..3126c3d39c25e9da7982ca40b75745568b00a979 --- /dev/null +++ b/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"}} diff --git a/crates/vim/test_data/test_sentence_forwards.json b/crates/vim/test_data/test_sentence_forwards.json new file mode 100644 index 0000000000000000000000000000000000000000..47939aae373f8b6c97365a20508d29f66b90eded --- /dev/null +++ b/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."}}