vim: Enable `%` to jump between tags (#20536)

hrou0003 and Harrison created

Closes #12986

Release Notes:

- Enable `%` to jump between pairs of tags

---------

Co-authored-by: Harrison <hrouillard@sfi.com.au>

Change summary

a.html                                            |  1 
crates/editor/src/test/editor_lsp_test_context.rs | 11 +
crates/languages/src/tsx/brackets.scm             |  2 
crates/vim/src/motion.rs                          | 93 ++++++++++++++++
crates/vim/src/object.rs                          | 21 ++-
crates/vim/src/test/neovim_backed_test_context.rs | 24 ++++
crates/vim/test_data/test_matching_tags.json      | 15 ++
extensions/html/languages/html/brackets.scm       |  2 
8 files changed, 159 insertions(+), 10 deletions(-)

Detailed changes

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

@@ -234,7 +234,16 @@ impl EditorLspTestContext {
                 ..Default::default()
             },
             Some(tree_sitter_html::language()),
-        );
+        )
+        .with_queries(LanguageQueries {
+            brackets: Some(Cow::from(indoc! {r#"
+                ("<" @open "/>" @close)
+                ("</" @open ">" @close)
+                ("<" @open ">" @close)
+                ("\"" @open "\"" @close)"#})),
+            ..Default::default()
+        })
+        .expect("Could not parse queries");
         Self::new(language, Default::default(), cx).await
     }
 

crates/vim/src/motion.rs 🔗

@@ -1697,6 +1697,31 @@ fn end_of_document(
     map.clip_point(new_point.to_display_point(map), Bias::Left)
 }
 
+fn matching_tag(map: &DisplaySnapshot, head: DisplayPoint) -> Option<DisplayPoint> {
+    let inner = crate::object::surrounding_html_tag(map, head, head..head, false)?;
+    let outer = crate::object::surrounding_html_tag(map, head, head..head, true)?;
+
+    if head > outer.start && head < inner.start {
+        let mut offset = inner.end.to_offset(map, Bias::Left);
+        for c in map.buffer_snapshot.chars_at(offset) {
+            if c == '/' || c == '\n' || c == '>' {
+                return Some(offset.to_display_point(map));
+            }
+            offset += c.len_utf8();
+        }
+    } else {
+        let mut offset = outer.start.to_offset(map, Bias::Left);
+        for c in map.buffer_snapshot.chars_at(offset) {
+            offset += c.len_utf8();
+            if c == '<' || c == '\n' {
+                return Some(offset.to_display_point(map));
+            }
+        }
+    }
+
+    return None;
+}
+
 fn matching(map: &DisplaySnapshot, display_point: DisplayPoint) -> DisplayPoint {
     // https://github.com/vim/vim/blob/1d87e11a1ef201b26ed87585fba70182ad0c468a/runtime/doc/motion.txt#L1200
     let display_point = map.clip_at_line_end(display_point);
@@ -1722,10 +1747,26 @@ fn matching(map: &DisplaySnapshot, display_point: DisplayPoint) -> DisplayPoint
         let mut closest_distance = usize::MAX;
 
         for (open_range, close_range) in ranges {
+            if map.buffer_snapshot.chars_at(open_range.start).next() == Some('<') {
+                if offset > open_range.start && offset < close_range.start {
+                    let mut chars = map.buffer_snapshot.chars_at(close_range.start);
+                    if (Some('/'), Some('>')) == (chars.next(), chars.next()) {
+                        return display_point;
+                    }
+                    if let Some(tag) = matching_tag(map, display_point) {
+                        return tag;
+                    }
+                } else if close_range.contains(&offset) {
+                    return open_range.start.to_display_point(map);
+                } else if open_range.contains(&offset) {
+                    return (close_range.end - 1).to_display_point(map);
+                }
+            }
+
             if open_range.start >= offset && line_range.contains(&open_range.start) {
                 let distance = open_range.start - offset;
                 if distance < closest_distance {
-                    closest_pair_destination = Some(close_range.start);
+                    closest_pair_destination = Some(close_range.end - 1);
                     closest_distance = distance;
                     continue;
                 }
@@ -2077,6 +2118,56 @@ mod test {
         cx.shared_state().await.assert_eq("func boop(ˇ) {\n}");
     }
 
+    #[gpui::test]
+    async fn test_matching_tags(cx: &mut gpui::TestAppContext) {
+        let mut cx = NeovimBackedTestContext::new_html(cx).await;
+
+        cx.neovim.exec("set filetype=html").await;
+
+        cx.set_shared_state(indoc! {r"<bˇody></body>"}).await;
+        cx.simulate_shared_keystrokes("%").await;
+        cx.shared_state()
+            .await
+            .assert_eq(indoc! {r"<body><ˇ/body>"});
+        cx.simulate_shared_keystrokes("%").await;
+
+        // test jumping backwards
+        cx.shared_state()
+            .await
+            .assert_eq(indoc! {r"<ˇbody></body>"});
+
+        // test self-closing tags
+        cx.set_shared_state(indoc! {r"<a><bˇr/></a>"}).await;
+        cx.simulate_shared_keystrokes("%").await;
+        cx.shared_state().await.assert_eq(indoc! {r"<a><bˇr/></a>"});
+
+        // test tag with attributes
+        cx.set_shared_state(indoc! {r"<div class='test' ˇid='main'>
+            </div>
+            "})
+            .await;
+        cx.simulate_shared_keystrokes("%").await;
+        cx.shared_state()
+            .await
+            .assert_eq(indoc! {r"<div class='test' id='main'>
+            <ˇ/div>
+            "});
+
+        // test multi-line self-closing tag
+        cx.set_shared_state(indoc! {r#"<a>
+            <br
+                test = "test"
+            /ˇ>
+        </a>"#})
+            .await;
+        cx.simulate_shared_keystrokes("%").await;
+        cx.shared_state().await.assert_eq(indoc! {r#"<a>
+            ˇ<br
+                test = "test"
+            />
+        </a>"#});
+    }
+
     #[gpui::test]
     async fn test_comma_semicolon(cx: &mut gpui::TestAppContext) {
         let mut cx = NeovimBackedTestContext::new(cx).await;

crates/vim/src/object.rs 🔗

@@ -204,7 +204,11 @@ impl Object {
             Object::Parentheses => {
                 surrounding_markers(map, relative_to, around, self.is_multiline(), '(', ')')
             }
-            Object::Tag => surrounding_html_tag(map, selection, around),
+            Object::Tag => {
+                let head = selection.head();
+                let range = selection.range();
+                surrounding_html_tag(map, head, range, around)
+            }
             Object::SquareBrackets => {
                 surrounding_markers(map, relative_to, around, self.is_multiline(), '[', ']')
             }
@@ -262,9 +266,10 @@ fn in_word(
     Some(start..end)
 }
 
-fn surrounding_html_tag(
+pub fn surrounding_html_tag(
     map: &DisplaySnapshot,
-    selection: Selection<DisplayPoint>,
+    head: DisplayPoint,
+    range: Range<DisplayPoint>,
     around: bool,
 ) -> Option<Range<DisplayPoint>> {
     fn read_tag(chars: impl Iterator<Item = char>) -> String {
@@ -286,7 +291,7 @@ fn surrounding_html_tag(
     }
 
     let snapshot = &map.buffer_snapshot;
-    let offset = selection.head().to_offset(map, Bias::Left);
+    let offset = head.to_offset(map, Bias::Left);
     let excerpt = snapshot.excerpt_containing(offset..offset)?;
     let buffer = excerpt.buffer();
     let offset = excerpt.map_offset_to_buffer(offset);
@@ -307,14 +312,14 @@ fn surrounding_html_tag(
                 let open_tag = open_tag(buffer.chars_for_range(first_child.byte_range()));
                 let close_tag = close_tag(buffer.chars_for_range(last_child.byte_range()));
                 // It needs to be handled differently according to the selection length
-                let is_valid = if selection.end.to_offset(map, Bias::Left)
-                    - selection.start.to_offset(map, Bias::Left)
+                let is_valid = if range.end.to_offset(map, Bias::Left)
+                    - range.start.to_offset(map, Bias::Left)
                     <= 1
                 {
                     offset <= last_child.end_byte()
                 } else {
-                    selection.start.to_offset(map, Bias::Left) >= first_child.start_byte()
-                        && selection.end.to_offset(map, Bias::Left) <= last_child.start_byte() + 1
+                    range.start.to_offset(map, Bias::Left) >= first_child.start_byte()
+                        && range.end.to_offset(map, Bias::Left) <= last_child.start_byte() + 1
                 };
                 if open_tag.is_some() && open_tag == close_tag && is_valid {
                     let range = if around {

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

@@ -162,6 +162,30 @@ impl NeovimBackedTestContext {
         }
     }
 
+    pub async fn new_html(cx: &mut gpui::TestAppContext) -> NeovimBackedTestContext {
+        #[cfg(feature = "neovim")]
+        cx.executor().allow_parking();
+        // rust stores the name of the test on the current thread.
+        // We use this to automatically name a file that will store
+        // the neovim connection's requests/responses so that we can
+        // run without neovim on CI.
+        let thread = thread::current();
+        let test_name = thread
+            .name()
+            .expect("thread is not named")
+            .split(':')
+            .last()
+            .unwrap()
+            .to_string();
+        Self {
+            cx: VimTestContext::new_html(cx).await,
+            neovim: NeovimConnection::new(test_name).await,
+
+            last_set_state: None,
+            recent_keystrokes: Default::default(),
+        }
+    }
+
     pub async fn set_shared_state(&mut self, marked_text: &str) {
         let mode = if marked_text.contains('»') {
             Mode::Visual

crates/vim/test_data/test_matching_tags.json 🔗

@@ -0,0 +1,15 @@
+{"Exec":{"command":"set filetype=html"}}
+{"Put":{"state":"<bˇody></body>"}}
+{"Key":"%"}
+{"Get":{"state":"<body><ˇ/body>","mode":"Normal"}}
+{"Key":"%"}
+{"Get":{"state":"<ˇbody></body>","mode":"Normal"}}
+{"Put":{"state":"<a><bˇr/></a>"}}
+{"Key":"%"}
+{"Get":{"state":"<a><bˇr/></a>","mode":"Normal"}}
+{"Put":{"state":"<div class='test' ˇid='main'>\n</div>\n"}}
+{"Key":"%"}
+{"Get":{"state":"<div class='test' id='main'>\n<ˇ/div>\n","mode":"Normal"}}
+{"Put":{"state":"<a>\n    <br\n        test = \"test\"\n    /ˇ>\n</a>"}}
+{"Key":"%"}
+{"Get":{"state":"<a>\n    ˇ<br\n        test = \"test\"\n    />\n</a>","mode":"Normal"}}