vim: { } to navigate by paragraph (#2668)

Conrad Irwin created

As part of this I added `assert_shared_state()` to the
NeovimBackedTestContext so that it is more like a drop-in replacement
for the VimTestContext.

The remaining part of zed-industries/community#682 is adding bracket
matching to plain text. It looks like the current logic requires there
to be a tree sitter language for the language in order to support
bracket matching. I didn't fix this in this PR because I was unsure
whether to try and work around that, or to try and add a plain text tree
sitter language.

Release Notes:

- vim: support `{` and `}` for paragraph motion
([#470](https://github.com/zed-industries/community/issues/470)).
- vim: fix `%` at the end of the line
([#682](https://github.com/zed-industries/community/issues/682)).

Change summary

assets/keymaps/vim.json                               |   2 
crates/editor/src/editor.rs                           |  14 
crates/editor/src/movement.rs                         |  24 +
crates/editor/src/test/editor_test_context.rs         |  26 +
crates/vim/src/motion.rs                              | 160 ++++++++++++
crates/vim/src/normal/case.rs                         |  96 +++++--
crates/vim/src/test/neovim_backed_test_context.rs     |  51 +++
crates/vim/src/test/neovim_connection.rs              |  16 +
crates/vim/test_data/test_change_case.json            |  18 +
crates/vim/test_data/test_matching.json               |  17 +
crates/vim/test_data/test_start_end_of_paragraph.json |  13 +
11 files changed, 377 insertions(+), 60 deletions(-)

Detailed changes

assets/keymaps/vim.json 🔗

@@ -38,6 +38,8 @@
       "^": "vim::FirstNonWhitespace",
       "shift-g": "vim::EndOfDocument",
       "w": "vim::NextWordStart",
+      "{": "vim::StartOfParagraph",
+      "}": "vim::EndOfParagraph",
       "shift-w": [
         "vim::NextWordStart",
         {

crates/editor/src/editor.rs 🔗

@@ -5123,7 +5123,7 @@ impl Editor {
         self.change_selections(Some(Autoscroll::fit()), cx, |s| {
             s.move_with(|map, selection| {
                 selection.collapse_to(
-                    movement::start_of_paragraph(map, selection.head()),
+                    movement::start_of_paragraph(map, selection.head(), 1),
                     SelectionGoal::None,
                 )
             });
@@ -5143,7 +5143,7 @@ impl Editor {
         self.change_selections(Some(Autoscroll::fit()), cx, |s| {
             s.move_with(|map, selection| {
                 selection.collapse_to(
-                    movement::end_of_paragraph(map, selection.head()),
+                    movement::end_of_paragraph(map, selection.head(), 1),
                     SelectionGoal::None,
                 )
             });
@@ -5162,7 +5162,10 @@ impl Editor {
 
         self.change_selections(Some(Autoscroll::fit()), cx, |s| {
             s.move_heads_with(|map, head, _| {
-                (movement::start_of_paragraph(map, head), SelectionGoal::None)
+                (
+                    movement::start_of_paragraph(map, head, 1),
+                    SelectionGoal::None,
+                )
             });
         })
     }
@@ -5179,7 +5182,10 @@ impl Editor {
 
         self.change_selections(Some(Autoscroll::fit()), cx, |s| {
             s.move_heads_with(|map, head, _| {
-                (movement::end_of_paragraph(map, head), SelectionGoal::None)
+                (
+                    movement::end_of_paragraph(map, head, 1),
+                    SelectionGoal::None,
+                )
             });
         })
     }

crates/editor/src/movement.rs 🔗

@@ -193,7 +193,11 @@ pub fn next_subword_end(map: &DisplaySnapshot, point: DisplayPoint) -> DisplayPo
     })
 }
 
-pub fn start_of_paragraph(map: &DisplaySnapshot, display_point: DisplayPoint) -> DisplayPoint {
+pub fn start_of_paragraph(
+    map: &DisplaySnapshot,
+    display_point: DisplayPoint,
+    mut count: usize,
+) -> DisplayPoint {
     let point = display_point.to_point(map);
     if point.row == 0 {
         return map.max_point();
@@ -203,7 +207,11 @@ pub fn start_of_paragraph(map: &DisplaySnapshot, display_point: DisplayPoint) ->
     for row in (0..point.row + 1).rev() {
         let blank = map.buffer_snapshot.is_line_blank(row);
         if found_non_blank_line && blank {
-            return Point::new(row, 0).to_display_point(map);
+            if count <= 1 {
+                return Point::new(row, 0).to_display_point(map);
+            }
+            count -= 1;
+            found_non_blank_line = false;
         }
 
         found_non_blank_line |= !blank;
@@ -212,7 +220,11 @@ pub fn start_of_paragraph(map: &DisplaySnapshot, display_point: DisplayPoint) ->
     DisplayPoint::zero()
 }
 
-pub fn end_of_paragraph(map: &DisplaySnapshot, display_point: DisplayPoint) -> DisplayPoint {
+pub fn end_of_paragraph(
+    map: &DisplaySnapshot,
+    display_point: DisplayPoint,
+    mut count: usize,
+) -> DisplayPoint {
     let point = display_point.to_point(map);
     if point.row == map.max_buffer_row() {
         return DisplayPoint::zero();
@@ -222,7 +234,11 @@ pub fn end_of_paragraph(map: &DisplaySnapshot, display_point: DisplayPoint) -> D
     for row in point.row..map.max_buffer_row() + 1 {
         let blank = map.buffer_snapshot.is_line_blank(row);
         if found_non_blank_line && blank {
-            return Point::new(row, 0).to_display_point(map);
+            if count <= 1 {
+                return Point::new(row, 0).to_display_point(map);
+            }
+            count -= 1;
+            found_non_blank_line = false;
         }
 
         found_non_blank_line |= !blank;

crates/editor/src/test/editor_test_context.rs 🔗

@@ -210,6 +210,10 @@ impl<'a> EditorTestContext<'a> {
         self.assert_selections(expected_selections, marked_text.to_string())
     }
 
+    pub fn editor_state(&mut self) -> String {
+        generate_marked_text(self.buffer_text().as_str(), &self.editor_selections(), true)
+    }
+
     #[track_caller]
     pub fn assert_editor_background_highlights<Tag: 'static>(&mut self, marked_text: &str) {
         let expected_ranges = self.ranges(marked_text);
@@ -248,14 +252,8 @@ impl<'a> EditorTestContext<'a> {
         self.assert_selections(expected_selections, expected_marked_text)
     }
 
-    #[track_caller]
-    fn assert_selections(
-        &mut self,
-        expected_selections: Vec<Range<usize>>,
-        expected_marked_text: String,
-    ) {
-        let actual_selections = self
-            .editor
+    fn editor_selections(&self) -> Vec<Range<usize>> {
+        self.editor
             .read_with(self.cx, |editor, cx| editor.selections.all::<usize>(cx))
             .into_iter()
             .map(|s| {
@@ -265,12 +263,22 @@ impl<'a> EditorTestContext<'a> {
                     s.start..s.end
                 }
             })
-            .collect::<Vec<_>>();
+            .collect::<Vec<_>>()
+    }
+
+    #[track_caller]
+    fn assert_selections(
+        &mut self,
+        expected_selections: Vec<Range<usize>>,
+        expected_marked_text: String,
+    ) {
+        let actual_selections = self.editor_selections();
         let actual_marked_text =
             generate_marked_text(&self.buffer_text(), &actual_selections, true);
         if expected_selections != actual_selections {
             panic!(
                 indoc! {"
+
                     {}Editor has unexpected selections.
 
                     Expected selections:

crates/vim/src/motion.rs 🔗

@@ -31,6 +31,8 @@ pub enum Motion {
     CurrentLine,
     StartOfLine,
     EndOfLine,
+    StartOfParagraph,
+    EndOfParagraph,
     StartOfDocument,
     EndOfDocument,
     Matching,
@@ -72,6 +74,8 @@ actions!(
         StartOfLine,
         EndOfLine,
         CurrentLine,
+        StartOfParagraph,
+        EndOfParagraph,
         StartOfDocument,
         EndOfDocument,
         Matching,
@@ -92,6 +96,12 @@ pub fn init(cx: &mut AppContext) {
     cx.add_action(|_: &mut Workspace, _: &StartOfLine, cx: _| motion(Motion::StartOfLine, cx));
     cx.add_action(|_: &mut Workspace, _: &EndOfLine, cx: _| motion(Motion::EndOfLine, cx));
     cx.add_action(|_: &mut Workspace, _: &CurrentLine, cx: _| motion(Motion::CurrentLine, cx));
+    cx.add_action(|_: &mut Workspace, _: &StartOfParagraph, cx: _| {
+        motion(Motion::StartOfParagraph, cx)
+    });
+    cx.add_action(|_: &mut Workspace, _: &EndOfParagraph, cx: _| {
+        motion(Motion::EndOfParagraph, cx)
+    });
     cx.add_action(|_: &mut Workspace, _: &StartOfDocument, cx: _| {
         motion(Motion::StartOfDocument, cx)
     });
@@ -142,7 +152,8 @@ impl Motion {
     pub fn linewise(&self) -> bool {
         use Motion::*;
         match self {
-            Down | Up | StartOfDocument | EndOfDocument | CurrentLine | NextLineStart => true,
+            Down | Up | StartOfDocument | EndOfDocument | CurrentLine | NextLineStart
+            | StartOfParagraph | EndOfParagraph => true,
             EndOfLine
             | NextWordEnd { .. }
             | Matching
@@ -172,6 +183,8 @@ impl Motion {
             | Backspace
             | Right
             | StartOfLine
+            | StartOfParagraph
+            | EndOfParagraph
             | NextWordStart { .. }
             | PreviousWordStart { .. }
             | FirstNonWhitespace
@@ -197,6 +210,8 @@ impl Motion {
             | Backspace
             | Right
             | StartOfLine
+            | StartOfParagraph
+            | EndOfParagraph
             | NextWordStart { .. }
             | PreviousWordStart { .. }
             | FirstNonWhitespace
@@ -235,6 +250,14 @@ impl Motion {
             FirstNonWhitespace => (first_non_whitespace(map, point), SelectionGoal::None),
             StartOfLine => (start_of_line(map, point), SelectionGoal::None),
             EndOfLine => (end_of_line(map, point), SelectionGoal::None),
+            StartOfParagraph => (
+                movement::start_of_paragraph(map, point, times),
+                SelectionGoal::None,
+            ),
+            EndOfParagraph => (
+                map.clip_at_line_end(movement::end_of_paragraph(map, point, times)),
+                SelectionGoal::None,
+            ),
             CurrentLine => (end_of_line(map, point), SelectionGoal::None),
             StartOfDocument => (start_of_document(map, point, times), SelectionGoal::None),
             EndOfDocument => (
@@ -502,10 +525,13 @@ fn matching(map: &DisplaySnapshot, display_point: DisplayPoint) -> DisplayPoint
     if line_end == point {
         line_end = map.max_point().to_point(map);
     }
-    line_end.column = line_end.column.saturating_sub(1);
 
     let line_range = map.prev_line_boundary(point).0..line_end;
-    let ranges = map.buffer_snapshot.bracket_ranges(line_range.clone());
+    let visible_line_range =
+        line_range.start..Point::new(line_range.end.row, line_range.end.column.saturating_sub(1));
+    let ranges = map
+        .buffer_snapshot
+        .bracket_ranges(visible_line_range.clone());
     if let Some(ranges) = ranges {
         let line_range = line_range.start.to_offset(&map.buffer_snapshot)
             ..line_range.end.to_offset(&map.buffer_snapshot);
@@ -590,3 +616,131 @@ fn next_line_start(map: &DisplaySnapshot, point: DisplayPoint, times: usize) ->
     let new_row = (point.row() + times as u32).min(map.max_buffer_row());
     map.clip_point(DisplayPoint::new(new_row, 0), Bias::Left)
 }
+
+#[cfg(test)]
+
+mod test {
+
+    use crate::test::NeovimBackedTestContext;
+    use indoc::indoc;
+
+    #[gpui::test]
+    async fn test_start_end_of_paragraph(cx: &mut gpui::TestAppContext) {
+        let mut cx = NeovimBackedTestContext::new(cx).await;
+
+        let initial_state = indoc! {r"ˇabc
+            def
+
+            paragraph
+            the second
+
+
+
+            third and
+            final"};
+
+        // goes down once
+        cx.set_shared_state(initial_state).await;
+        cx.simulate_shared_keystrokes(["}"]).await;
+        cx.assert_shared_state(indoc! {r"abc
+            def
+            ˇ
+            paragraph
+            the second
+
+
+
+            third and
+            final"})
+            .await;
+
+        // goes up once
+        cx.simulate_shared_keystrokes(["{"]).await;
+        cx.assert_shared_state(initial_state).await;
+
+        // goes down twice
+        cx.simulate_shared_keystrokes(["2", "}"]).await;
+        cx.assert_shared_state(indoc! {r"abc
+            def
+
+            paragraph
+            the second
+            ˇ
+
+
+            third and
+            final"})
+            .await;
+
+        // goes down over multiple blanks
+        cx.simulate_shared_keystrokes(["}"]).await;
+        cx.assert_shared_state(indoc! {r"abc
+                def
+
+                paragraph
+                the second
+
+
+
+                third and
+                finaˇl"})
+            .await;
+
+        // goes up twice
+        cx.simulate_shared_keystrokes(["2", "{"]).await;
+        cx.assert_shared_state(indoc! {r"abc
+                def
+                ˇ
+                paragraph
+                the second
+
+
+
+                third and
+                final"})
+            .await
+    }
+
+    #[gpui::test]
+    async fn test_matching(cx: &mut gpui::TestAppContext) {
+        let mut cx = NeovimBackedTestContext::new(cx).await;
+
+        cx.set_shared_state(indoc! {r"func ˇ(a string) {
+                do(something(with<Types>.and_arrays[0, 2]))
+            }"})
+            .await;
+        cx.simulate_shared_keystrokes(["%"]).await;
+        cx.assert_shared_state(indoc! {r"func (a stringˇ) {
+                do(something(with<Types>.and_arrays[0, 2]))
+            }"})
+            .await;
+
+        // test it works on the last character of the line
+        cx.set_shared_state(indoc! {r"func (a string) ˇ{
+            do(something(with<Types>.and_arrays[0, 2]))
+            }"})
+            .await;
+        cx.simulate_shared_keystrokes(["%"]).await;
+        cx.assert_shared_state(indoc! {r"func (a string) {
+            do(something(with<Types>.and_arrays[0, 2]))
+            ˇ}"})
+            .await;
+
+        // test it works on immediate nesting
+        cx.set_shared_state("ˇ{()}").await;
+        cx.simulate_shared_keystrokes(["%"]).await;
+        cx.assert_shared_state("{()ˇ}").await;
+        cx.simulate_shared_keystrokes(["%"]).await;
+        cx.assert_shared_state("ˇ{()}").await;
+
+        // test it works on immediate nesting inside braces
+        cx.set_shared_state("{\n    ˇ{()}\n}").await;
+        cx.simulate_shared_keystrokes(["%"]).await;
+        cx.assert_shared_state("{\n    {()ˇ}\n}").await;
+
+        // test it jumps to the next paren on a line
+        cx.set_shared_state("func ˇboop() {\n}").await;
+        cx.simulate_shared_keystrokes(["%"]).await;
+        cx.assert_shared_state("func boop(ˇ) {\n}").await;
+    }
+}

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

@@ -1,29 +1,51 @@
+use editor::scroll::autoscroll::Autoscroll;
 use gpui::ViewContext;
-use language::Point;
+use language::{Bias, Point};
 use workspace::Workspace;
 
-use crate::{motion::Motion, normal::ChangeCase, Vim};
+use crate::{normal::ChangeCase, state::Mode, Vim};
 
 pub fn change_case(_: &mut Workspace, _: &ChangeCase, cx: &mut ViewContext<Workspace>) {
     Vim::update(cx, |vim, cx| {
-        let count = vim.pop_number_operator(cx);
+        let count = vim.pop_number_operator(cx).unwrap_or(1) as u32;
         vim.update_active_editor(cx, |editor, cx| {
-            editor.set_clip_at_line_ends(false, cx);
-            editor.transact(cx, |editor, cx| {
-                editor.change_selections(None, cx, |s| {
-                    s.move_with(|map, selection| {
-                        if selection.start == selection.end {
-                            Motion::Right.expand_selection(map, selection, count, true);
+            let mut ranges = Vec::new();
+            let mut cursor_positions = Vec::new();
+            let snapshot = editor.buffer().read(cx).snapshot(cx);
+            for selection in editor.selections.all::<Point>(cx) {
+                match vim.state.mode {
+                    Mode::Visual { line: true } => {
+                        let start = Point::new(selection.start.row, 0);
+                        let end =
+                            Point::new(selection.end.row, snapshot.line_len(selection.end.row));
+                        ranges.push(start..end);
+                        cursor_positions.push(start..start);
+                    }
+                    Mode::Visual { line: false } => {
+                        ranges.push(selection.start..selection.end);
+                        cursor_positions.push(selection.start..selection.start);
+                    }
+                    Mode::Insert | Mode::Normal => {
+                        let start = selection.start;
+                        let mut end = start;
+                        for _ in 0..count {
+                            end = snapshot.clip_point(end + Point::new(0, 1), Bias::Right);
                         }
-                    })
-                });
-                let selections = editor.selections.all::<Point>(cx);
-                for selection in selections.into_iter().rev() {
+                        ranges.push(start..end);
+
+                        if end.column == snapshot.line_len(end.row) {
+                            end = snapshot.clip_point(end - Point::new(0, 1), Bias::Left);
+                        }
+                        cursor_positions.push(end..end)
+                    }
+                }
+            }
+            editor.transact(cx, |editor, cx| {
+                for range in ranges.into_iter().rev() {
                     let snapshot = editor.buffer().read(cx).snapshot(cx);
                     editor.buffer().update(cx, |buffer, cx| {
-                        let range = selection.start..selection.end;
                         let text = snapshot
-                            .text_for_range(selection.start..selection.end)
+                            .text_for_range(range.start..range.end)
                             .flat_map(|s| s.chars())
                             .flat_map(|c| {
                                 if c.is_lowercase() {
@@ -37,28 +59,46 @@ pub fn change_case(_: &mut Workspace, _: &ChangeCase, cx: &mut ViewContext<Works
                         buffer.edit([(range, text)], None, cx)
                     })
                 }
+                editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
+                    s.select_ranges(cursor_positions)
+                })
             });
-            editor.set_clip_at_line_ends(true, cx);
         });
+        vim.switch_mode(Mode::Normal, true, cx)
     })
 }
-
 #[cfg(test)]
 mod test {
-    use crate::{state::Mode, test::VimTestContext};
-    use indoc::indoc;
+    use crate::{state::Mode, test::NeovimBackedTestContext};
 
     #[gpui::test]
     async fn test_change_case(cx: &mut gpui::TestAppContext) {
-        let mut cx = VimTestContext::new(cx, true).await;
-        cx.set_state(indoc! {"ˇabC\n"}, Mode::Normal);
-        cx.simulate_keystrokes(["~"]);
-        cx.assert_editor_state("AˇbC\n");
-        cx.simulate_keystrokes(["2", "~"]);
-        cx.assert_editor_state("ABcˇ\n");
+        let mut cx = NeovimBackedTestContext::new(cx).await;
+        cx.set_shared_state("ˇabC\n").await;
+        cx.simulate_shared_keystrokes(["~"]).await;
+        cx.assert_shared_state("AˇbC\n").await;
+        cx.simulate_shared_keystrokes(["2", "~"]).await;
+        cx.assert_shared_state("ABˇc\n").await;
+
+        // works in visual mode
+        cx.set_shared_state("a😀C«dÉ1*fˇ»\n").await;
+        cx.simulate_shared_keystrokes(["~"]).await;
+        cx.assert_shared_state("a😀CˇDé1*F\n").await;
+
+        // works with multibyte characters
+        cx.simulate_shared_keystrokes(["~"]).await;
+        cx.set_shared_state("aˇC😀é1*F\n").await;
+        cx.simulate_shared_keystrokes(["4", "~"]).await;
+        cx.assert_shared_state("ac😀É1ˇ*F\n").await;
+
+        // works with line selections
+        cx.set_shared_state("abˇC\n").await;
+        cx.simulate_shared_keystrokes(["shift-v", "~"]).await;
+        cx.assert_shared_state("ˇABc\n").await;
 
-        cx.set_state(indoc! {"a😀C«dÉ1*fˇ»\n"}, Mode::Normal);
-        cx.simulate_keystrokes(["~"]);
-        cx.assert_editor_state("a😀CDé1*Fˇ\n");
+        // works with multiple cursors (zed only)
+        cx.set_state("aˇßcdˇe\n", Mode::Normal);
+        cx.simulate_keystroke("~");
+        cx.assert_state("aSSˇcdˇE\n", Mode::Normal);
     }
 }

crates/vim/src/test/neovim_backed_test_context.rs 🔗

@@ -1,9 +1,10 @@
-use std::ops::{Deref, DerefMut};
+use indoc::indoc;
+use std::ops::{Deref, DerefMut, Range};
 
 use collections::{HashMap, HashSet};
 use gpui::ContextHandle;
 use language::OffsetRangeExt;
-use util::test::marked_text_offsets;
+use util::test::{generate_marked_text, marked_text_offsets};
 
 use super::{neovim_connection::NeovimConnection, NeovimBackedBindingTestContext, VimTestContext};
 use crate::state::Mode;
@@ -112,6 +113,43 @@ impl<'a> NeovimBackedTestContext<'a> {
         context_handle
     }
 
+    pub async fn assert_shared_state(&mut self, marked_text: &str) {
+        let neovim = self.neovim_state().await;
+        if neovim != marked_text {
+            panic!(
+                indoc! {"Test is incorrect (currently expected != neovim state)
+
+                # currently expected:
+                {}
+                # neovim state:
+                {}
+                # zed state:
+                {}"},
+                marked_text,
+                neovim,
+                self.editor_state(),
+            )
+        }
+        self.assert_editor_state(marked_text)
+    }
+
+    pub async fn neovim_state(&mut self) -> String {
+        generate_marked_text(
+            self.neovim.text().await.as_str(),
+            &vec![self.neovim_selection().await],
+            true,
+        )
+    }
+
+    async fn neovim_selection(&mut self) -> Range<usize> {
+        let mut neovim_selection = self.neovim.selection().await;
+        // Zed selections adjust themselves to make the end point visually make sense
+        if neovim_selection.start > neovim_selection.end {
+            neovim_selection.start.column += 1;
+        }
+        neovim_selection.to_offset(&self.buffer_snapshot())
+    }
+
     pub async fn assert_state_matches(&mut self) {
         assert_eq!(
             self.neovim.text().await,
@@ -120,13 +158,8 @@ impl<'a> NeovimBackedTestContext<'a> {
             self.assertion_context()
         );
 
-        let mut neovim_selection = self.neovim.selection().await;
-        // Zed selections adjust themselves to make the end point visually make sense
-        if neovim_selection.start > neovim_selection.end {
-            neovim_selection.start.column += 1;
-        }
-        let neovim_selection = neovim_selection.to_offset(&self.buffer_snapshot());
-        self.assert_editor_selections(vec![neovim_selection]);
+        let selections = vec![self.neovim_selection().await];
+        self.assert_editor_selections(selections);
 
         if let Some(neovim_mode) = self.neovim.mode().await {
             assert_eq!(neovim_mode, self.mode(), "{}", self.assertion_context(),);

crates/vim/src/test/neovim_connection.rs 🔗

@@ -167,15 +167,25 @@ impl NeovimConnection {
             .await
             .expect("Could not get neovim window");
 
-        if !selection.is_empty() {
-            panic!("Setting neovim state with non empty selection not yet supported");
-        }
         let cursor = selection.start;
         nvim_window
             .set_cursor((cursor.row as i64 + 1, cursor.column as i64))
             .await
             .expect("Could not set nvim cursor position");
 
+        if !selection.is_empty() {
+            self.nvim
+                .input("v")
+                .await
+                .expect("could not enter visual mode");
+
+            let cursor = selection.end;
+            nvim_window
+                .set_cursor((cursor.row as i64 + 1, cursor.column as i64))
+                .await
+                .expect("Could not set nvim cursor position");
+        }
+
         if let Some(NeovimData::Get { mode, state }) = self.data.back() {
             if *mode == Some(Mode::Normal) && *state == marked_text {
                 return;

crates/vim/test_data/test_change_case.json 🔗

@@ -0,0 +1,18 @@
+{"Put":{"state":"ˇabC\n"}}
+{"Key":"~"}
+{"Get":{"state":"AˇbC\n","mode":"Normal"}}
+{"Key":"2"}
+{"Key":"~"}
+{"Get":{"state":"ABˇc\n","mode":"Normal"}}
+{"Put":{"state":"a😀C«dÉ1*fˇ»\n"}}
+{"Key":"~"}
+{"Get":{"state":"a😀CˇDé1*F\n","mode":"Normal"}}
+{"Key":"~"}
+{"Put":{"state":"aˇC😀é1*F\n"}}
+{"Key":"4"}
+{"Key":"~"}
+{"Get":{"state":"ac😀É1ˇ*F\n","mode":"Normal"}}
+{"Put":{"state":"abˇC\n"}}
+{"Key":"shift-v"}
+{"Key":"~"}
+{"Get":{"state":"ˇABc\n","mode":"Normal"}}

crates/vim/test_data/test_matching.json 🔗

@@ -0,0 +1,17 @@
+{"Put":{"state":"func ˇ(a string) {\n    do(something(with<Types>.and_arrays[0, 2]))\n}"}}
+{"Key":"%"}
+{"Get":{"state":"func (a stringˇ) {\n    do(something(with<Types>.and_arrays[0, 2]))\n}","mode":"Normal"}}
+{"Put":{"state":"func (a string) ˇ{\ndo(something(with<Types>.and_arrays[0, 2]))\n}"}}
+{"Key":"%"}
+{"Get":{"state":"func (a string) {\ndo(something(with<Types>.and_arrays[0, 2]))\nˇ}","mode":"Normal"}}
+{"Put":{"state":"ˇ{()}"}}
+{"Key":"%"}
+{"Get":{"state":"{()ˇ}","mode":"Normal"}}
+{"Key":"%"}
+{"Get":{"state":"ˇ{()}","mode":"Normal"}}
+{"Put":{"state":"{\n    ˇ{()}\n}"}}
+{"Key":"%"}
+{"Get":{"state":"{\n    {()ˇ}\n}","mode":"Normal"}}
+{"Put":{"state":"func ˇboop() {\n}"}}
+{"Key":"%"}
+{"Get":{"state":"func boop(ˇ) {\n}","mode":"Normal"}}

crates/vim/test_data/test_start_end_of_paragraph.json 🔗

@@ -0,0 +1,13 @@
+{"Put":{"state":"ˇabc\ndef\n\nparagraph\nthe second\n\n\n\nthird and\nfinal"}}
+{"Key":"}"}
+{"Get":{"state":"abc\ndef\nˇ\nparagraph\nthe second\n\n\n\nthird and\nfinal","mode":"Normal"}}
+{"Key":"{"}
+{"Get":{"state":"ˇabc\ndef\n\nparagraph\nthe second\n\n\n\nthird and\nfinal","mode":"Normal"}}
+{"Key":"2"}
+{"Key":"}"}
+{"Get":{"state":"abc\ndef\n\nparagraph\nthe second\nˇ\n\n\nthird and\nfinal","mode":"Normal"}}
+{"Key":"}"}
+{"Get":{"state":"abc\ndef\n\nparagraph\nthe second\n\n\n\nthird and\nfinaˇl","mode":"Normal"}}
+{"Key":"2"}
+{"Key":"{"}
+{"Get":{"state":"abc\ndef\nˇ\nparagraph\nthe second\n\n\n\nthird and\nfinal","mode":"Normal"}}