vim: Add "unmatched" motions `]}`, `])`, `[{` and `[(` (#21098)

Helge Mahrt and Conrad Irwin created

Closes #20791

Release Notes:

- Added vim ["unmatched"
motions](https://github.com/vim/vim/blob/1d87e11a1ef201b26ed87585fba70182ad0c468a/runtime/doc/motion.txt#L1238-L1255)
`]}`, `])`, `[{` and `[(`

---------

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

Change summary

assets/keymaps/vim.json                           |   4 
crates/vim/src/motion.rs                          | 234 ++++++++++++++++
crates/vim/test_data/test_unmatched_backward.json |  24 +
crates/vim/test_data/test_unmatched_forward.json  |  28 ++
4 files changed, 289 insertions(+), 1 deletion(-)

Detailed changes

assets/keymaps/vim.json 🔗

@@ -55,6 +55,10 @@
       "n": "vim::MoveToNextMatch",
       "shift-n": "vim::MoveToPrevMatch",
       "%": "vim::Matching",
+      "] }": ["vim::UnmatchedForward", { "char": "}" } ],
+      "[ {": ["vim::UnmatchedBackward", { "char": "{" } ],
+      "] )": ["vim::UnmatchedForward", { "char": ")" } ],
+      "[ (": ["vim::UnmatchedBackward", { "char": "(" } ],
       "f": ["vim::PushOperator", { "FindForward": { "before": false } }],
       "t": ["vim::PushOperator", { "FindForward": { "before": true } }],
       "shift-f": ["vim::PushOperator", { "FindBackward": { "after": false } }],

crates/vim/src/motion.rs 🔗

@@ -72,6 +72,12 @@ pub enum Motion {
     StartOfDocument,
     EndOfDocument,
     Matching,
+    UnmatchedForward {
+        char: char,
+    },
+    UnmatchedBackward {
+        char: char,
+    },
     FindForward {
         before: bool,
         char: char,
@@ -203,6 +209,20 @@ pub struct StartOfLine {
     pub(crate) display_lines: bool,
 }
 
+#[derive(Clone, Deserialize, PartialEq)]
+#[serde(rename_all = "camelCase")]
+struct UnmatchedForward {
+    #[serde(default)]
+    char: char,
+}
+
+#[derive(Clone, Deserialize, PartialEq)]
+#[serde(rename_all = "camelCase")]
+struct UnmatchedBackward {
+    #[serde(default)]
+    char: char,
+}
+
 impl_actions!(
     vim,
     [
@@ -219,6 +239,8 @@ impl_actions!(
         NextSubwordEnd,
         PreviousSubwordStart,
         PreviousSubwordEnd,
+        UnmatchedForward,
+        UnmatchedBackward
     ]
 );
 
@@ -326,7 +348,20 @@ pub fn register(editor: &mut Editor, cx: &mut ViewContext<Vim>) {
     Vim::action(editor, cx, |vim, _: &Matching, cx| {
         vim.motion(Motion::Matching, cx)
     });
-
+    Vim::action(
+        editor,
+        cx,
+        |vim, &UnmatchedForward { char }: &UnmatchedForward, cx| {
+            vim.motion(Motion::UnmatchedForward { char }, cx)
+        },
+    );
+    Vim::action(
+        editor,
+        cx,
+        |vim, &UnmatchedBackward { char }: &UnmatchedBackward, cx| {
+            vim.motion(Motion::UnmatchedBackward { char }, cx)
+        },
+    );
     Vim::action(
         editor,
         cx,
@@ -504,6 +539,8 @@ impl Motion {
             | Jump { line: true, .. } => true,
             EndOfLine { .. }
             | Matching
+            | UnmatchedForward { .. }
+            | UnmatchedBackward { .. }
             | FindForward { .. }
             | Left
             | Backspace
@@ -537,6 +574,8 @@ impl Motion {
             | Up { .. }
             | EndOfLine { .. }
             | Matching
+            | UnmatchedForward { .. }
+            | UnmatchedBackward { .. }
             | FindForward { .. }
             | RepeatFind { .. }
             | Left
@@ -583,6 +622,8 @@ impl Motion {
             | EndOfLine { .. }
             | EndOfLineDownward
             | Matching
+            | UnmatchedForward { .. }
+            | UnmatchedBackward { .. }
             | FindForward { .. }
             | WindowTop
             | WindowMiddle
@@ -707,6 +748,14 @@ impl Motion {
                 SelectionGoal::None,
             ),
             Matching => (matching(map, point), SelectionGoal::None),
+            UnmatchedForward { char } => (
+                unmatched_forward(map, point, *char, times),
+                SelectionGoal::None,
+            ),
+            UnmatchedBackward { char } => (
+                unmatched_backward(map, point, *char, times),
+                SelectionGoal::None,
+            ),
             // t f
             FindForward {
                 before,
@@ -1792,6 +1841,92 @@ fn matching(map: &DisplaySnapshot, display_point: DisplayPoint) -> DisplayPoint
     }
 }
 
+fn unmatched_forward(
+    map: &DisplaySnapshot,
+    mut display_point: DisplayPoint,
+    char: char,
+    times: usize,
+) -> DisplayPoint {
+    for _ in 0..times {
+        // https://github.com/vim/vim/blob/1d87e11a1ef201b26ed87585fba70182ad0c468a/runtime/doc/motion.txt#L1245
+        let point = display_point.to_point(map);
+        let offset = point.to_offset(&map.buffer_snapshot);
+
+        let ranges = map.buffer_snapshot.enclosing_bracket_ranges(point..point);
+        let Some(ranges) = ranges else { break };
+        let mut closest_closing_destination = None;
+        let mut closest_distance = usize::MAX;
+
+        for (_, close_range) in ranges {
+            if close_range.start > offset {
+                let mut chars = map.buffer_snapshot.chars_at(close_range.start);
+                if Some(char) == chars.next() {
+                    let distance = close_range.start - offset;
+                    if distance < closest_distance {
+                        closest_closing_destination = Some(close_range.start);
+                        closest_distance = distance;
+                        continue;
+                    }
+                }
+            }
+        }
+
+        let new_point = closest_closing_destination
+            .map(|destination| destination.to_display_point(map))
+            .unwrap_or(display_point);
+        if new_point == display_point {
+            break;
+        }
+        display_point = new_point;
+    }
+    return display_point;
+}
+
+fn unmatched_backward(
+    map: &DisplaySnapshot,
+    mut display_point: DisplayPoint,
+    char: char,
+    times: usize,
+) -> DisplayPoint {
+    for _ in 0..times {
+        // https://github.com/vim/vim/blob/1d87e11a1ef201b26ed87585fba70182ad0c468a/runtime/doc/motion.txt#L1239
+        let point = display_point.to_point(map);
+        let offset = point.to_offset(&map.buffer_snapshot);
+
+        let ranges = map.buffer_snapshot.enclosing_bracket_ranges(point..point);
+        let Some(ranges) = ranges else {
+            break;
+        };
+
+        let mut closest_starting_destination = None;
+        let mut closest_distance = usize::MAX;
+
+        for (start_range, _) in ranges {
+            if start_range.start < offset {
+                let mut chars = map.buffer_snapshot.chars_at(start_range.start);
+                if Some(char) == chars.next() {
+                    let distance = offset - start_range.start;
+                    if distance < closest_distance {
+                        closest_starting_destination = Some(start_range.start);
+                        closest_distance = distance;
+                        continue;
+                    }
+                }
+            }
+        }
+
+        let new_point = closest_starting_destination
+            .map(|destination| destination.to_display_point(map))
+            .unwrap_or(display_point);
+        if new_point == display_point {
+            break;
+        } else {
+            display_point = new_point;
+        }
+    }
+    display_point
+}
+
 fn find_forward(
     map: &DisplaySnapshot,
     from: DisplayPoint,
@@ -2118,6 +2253,103 @@ mod test {
         cx.shared_state().await.assert_eq("func boop(ˇ) {\n}");
     }
 
+    #[gpui::test]
+    async fn test_unmatched_forward(cx: &mut gpui::TestAppContext) {
+        let mut cx = NeovimBackedTestContext::new(cx).await;
+
+        // test it works with curly braces
+        cx.set_shared_state(indoc! {r"func (a string) {
+                do(something(with<Types>.anˇd_arrays[0, 2]))
+            }"})
+            .await;
+        cx.simulate_shared_keystrokes("] }").await;
+        cx.shared_state()
+            .await
+            .assert_eq(indoc! {r"func (a string) {
+                do(something(with<Types>.and_arrays[0, 2]))
+            ˇ}"});
+
+        // test it works with brackets
+        cx.set_shared_state(indoc! {r"func (a string) {
+                do(somethiˇng(with<Types>.and_arrays[0, 2]))
+            }"})
+            .await;
+        cx.simulate_shared_keystrokes("] )").await;
+        cx.shared_state()
+            .await
+            .assert_eq(indoc! {r"func (a string) {
+                do(something(with<Types>.and_arrays[0, 2])ˇ)
+            }"});
+
+        cx.set_shared_state(indoc! {r"func (a string) { a((b, cˇ))}"})
+            .await;
+        cx.simulate_shared_keystrokes("] )").await;
+        cx.shared_state()
+            .await
+            .assert_eq(indoc! {r"func (a string) { a((b, c)ˇ)}"});
+
+        // test it works on immediate nesting
+        cx.set_shared_state("{ˇ {}{}}").await;
+        cx.simulate_shared_keystrokes("] }").await;
+        cx.shared_state().await.assert_eq("{ {}{}ˇ}");
+        cx.set_shared_state("(ˇ ()())").await;
+        cx.simulate_shared_keystrokes("] )").await;
+        cx.shared_state().await.assert_eq("( ()()ˇ)");
+
+        // test it works on immediate nesting inside braces
+        cx.set_shared_state("{\n    ˇ {()}\n}").await;
+        cx.simulate_shared_keystrokes("] }").await;
+        cx.shared_state().await.assert_eq("{\n     {()}\nˇ}");
+        cx.set_shared_state("(\n    ˇ {()}\n)").await;
+        cx.simulate_shared_keystrokes("] )").await;
+        cx.shared_state().await.assert_eq("(\n     {()}\nˇ)");
+    }
+
+    #[gpui::test]
+    async fn test_unmatched_backward(cx: &mut gpui::TestAppContext) {
+        let mut cx = NeovimBackedTestContext::new(cx).await;
+
+        // test it works with curly braces
+        cx.set_shared_state(indoc! {r"func (a string) {
+                do(something(with<Types>.anˇd_arrays[0, 2]))
+            }"})
+            .await;
+        cx.simulate_shared_keystrokes("[ {").await;
+        cx.shared_state()
+            .await
+            .assert_eq(indoc! {r"func (a string) ˇ{
+                do(something(with<Types>.and_arrays[0, 2]))
+            }"});
+
+        // test it works with brackets
+        cx.set_shared_state(indoc! {r"func (a string) {
+                do(somethiˇng(with<Types>.and_arrays[0, 2]))
+            }"})
+            .await;
+        cx.simulate_shared_keystrokes("[ (").await;
+        cx.shared_state()
+            .await
+            .assert_eq(indoc! {r"func (a string) {
+                doˇ(something(with<Types>.and_arrays[0, 2]))
+            }"});
+
+        // test it works on immediate nesting
+        cx.set_shared_state("{{}{} ˇ }").await;
+        cx.simulate_shared_keystrokes("[ {").await;
+        cx.shared_state().await.assert_eq("ˇ{{}{}  }");
+        cx.set_shared_state("(()() ˇ )").await;
+        cx.simulate_shared_keystrokes("[ (").await;
+        cx.shared_state().await.assert_eq("ˇ(()()  )");
+
+        // test it works on immediate nesting inside braces
+        cx.set_shared_state("{\n    {()} ˇ\n}").await;
+        cx.simulate_shared_keystrokes("[ {").await;
+        cx.shared_state().await.assert_eq("ˇ{\n    {()} \n}");
+        cx.set_shared_state("(\n    {()} ˇ\n)").await;
+        cx.simulate_shared_keystrokes("[ (").await;
+        cx.shared_state().await.assert_eq("ˇ(\n    {()} \n)");
+    }
+
     #[gpui::test]
     async fn test_matching_tags(cx: &mut gpui::TestAppContext) {
         let mut cx = NeovimBackedTestContext::new_html(cx).await;

crates/vim/test_data/test_unmatched_backward.json 🔗

@@ -0,0 +1,24 @@
+{"Put":{"state":"func (a string) {\n    do(something(with<Types>.anˇd_arrays[0, 2]))\n}"}}
+{"Key":"["}
+{"Key":"{"}
+{"Get":{"state":"func (a string) ˇ{\n    do(something(with<Types>.and_arrays[0, 2]))\n}","mode":"Normal"}}
+{"Put":{"state":"func (a string) {\n    do(somethiˇng(with<Types>.and_arrays[0, 2]))\n}"}}
+{"Key":"["}
+{"Key":"("}
+{"Get":{"state":"func (a string) {\n    doˇ(something(with<Types>.and_arrays[0, 2]))\n}","mode":"Normal"}}
+{"Put":{"state":"{{}{} ˇ }"}}
+{"Key":"["}
+{"Key":"{"}
+{"Get":{"state":"ˇ{{}{}  }","mode":"Normal"}}
+{"Put":{"state":"(()() ˇ )"}}
+{"Key":"["}
+{"Key":"("}
+{"Get":{"state":"ˇ(()()  )","mode":"Normal"}}
+{"Put":{"state":"{\n    {()} ˇ\n}"}}
+{"Key":"["}
+{"Key":"{"}
+{"Get":{"state":"ˇ{\n    {()} \n}","mode":"Normal"}}
+{"Put":{"state":"(\n    {()} ˇ\n)"}}
+{"Key":"["}
+{"Key":"("}
+{"Get":{"state":"ˇ(\n    {()} \n)","mode":"Normal"}}

crates/vim/test_data/test_unmatched_forward.json 🔗

@@ -0,0 +1,28 @@
+{"Put":{"state":"func (a string) {\n    do(something(with<Types>.anˇd_arrays[0, 2]))\n}"}}
+{"Key":"]"}
+{"Key":"}"}
+{"Get":{"state":"func (a string) {\n    do(something(with<Types>.and_arrays[0, 2]))\nˇ}","mode":"Normal"}}
+{"Put":{"state":"func (a string) {\n    do(somethiˇng(with<Types>.and_arrays[0, 2]))\n}"}}
+{"Key":"]"}
+{"Key":")"}
+{"Get":{"state":"func (a string) {\n    do(something(with<Types>.and_arrays[0, 2])ˇ)\n}","mode":"Normal"}}
+{"Put":{"state":"func (a string) { a((b, cˇ))}"}}
+{"Key":"]"}
+{"Key":")"}
+{"Get":{"state":"func (a string) { a((b, c)ˇ)}","mode":"Normal"}}
+{"Put":{"state":"{ˇ {}{}}"}}
+{"Key":"]"}
+{"Key":"}"}
+{"Get":{"state":"{ {}{}ˇ}","mode":"Normal"}}
+{"Put":{"state":"(ˇ ()())"}}
+{"Key":"]"}
+{"Key":")"}
+{"Get":{"state":"( ()()ˇ)","mode":"Normal"}}
+{"Put":{"state":"{\n    ˇ {()}\n}"}}
+{"Key":"]"}
+{"Key":"}"}
+{"Get":{"state":"{\n     {()}\nˇ}","mode":"Normal"}}
+{"Put":{"state":"(\n    ˇ {()}\n)"}}
+{"Key":"]"}
+{"Key":")"}
+{"Get":{"state":"(\n     {()}\nˇ)","mode":"Normal"}}