vim: Add HTML tag support for #4503 (#8175)

Hans and Conrad Irwin created

a simple code for html tag support, I've only done the basics, and if
it's okay, I'll optimize and organize the code, and adapt other parts
like `is_multiline`, `always_expands_both_ways`, `target_visual_mode`,
etc

---------

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

Change summary

assets/keymaps/vim.json                           |   1 
crates/editor/Cargo.toml                          |   1 
crates/editor/src/test/editor_lsp_test_context.rs |  16 ++
crates/vim/src/object.rs                          | 106 ++++++++++++++++
crates/vim/src/test/vim_test_context.rs           |   5 
5 files changed, 123 insertions(+), 6 deletions(-)

Detailed changes

assets/keymaps/vim.json ๐Ÿ”—

@@ -383,6 +383,7 @@
           "ignorePunctuation": true
         }
       ],
+      "t": "vim::Tag",
       "s": "vim::Sentence",
       "'": "vim::Quotes",
       "`": "vim::BackQuotes",

crates/editor/Cargo.toml ๐Ÿ”—

@@ -21,6 +21,7 @@ test-support = [
     "workspace/test-support",
     "tree-sitter-rust",
     "tree-sitter-typescript",
+    "tree-sitter-html"
 ]
 
 [dependencies]

crates/editor/src/test/editor_lsp_test_context.rs ๐Ÿ”—

@@ -214,6 +214,22 @@ impl EditorLspTestContext {
         Self::new(language, capabilities, cx).await
     }
 
+    pub async fn new_html(cx: &mut gpui::TestAppContext) -> Self {
+        let language = Language::new(
+            LanguageConfig {
+                name: "HTML".into(),
+                matcher: LanguageMatcher {
+                    path_suffixes: vec!["html".into()],
+                    ..Default::default()
+                },
+                block_comment: Some(("<!-- ".into(), " -->".into())),
+                ..Default::default()
+            },
+            Some(tree_sitter_html::language()),
+        );
+        Self::new(language, Default::default(), cx).await
+    }
+
     // Constructs lsp range using a marked string with '[', ']' range delimiters
     pub fn lsp_range(&mut self, marked_text: &str) -> lsp::Range {
         let ranges = self.ranges(marked_text);

crates/vim/src/object.rs ๐Ÿ”—

@@ -1,5 +1,9 @@
 use std::ops::Range;
 
+use crate::{
+    motion::right, normal::normal_object, state::Mode, utils::coerce_punctuation,
+    visual::visual_object, Vim,
+};
 use editor::{
     display_map::{DisplaySnapshot, ToDisplayPoint},
     movement::{self, FindRange},
@@ -10,11 +14,6 @@ use language::{char_kind, BufferSnapshot, CharKind, Selection};
 use serde::Deserialize;
 use workspace::Workspace;
 
-use crate::{
-    motion::right, normal::normal_object, state::Mode, utils::coerce_punctuation,
-    visual::visual_object, Vim,
-};
-
 #[derive(Copy, Clone, Debug, PartialEq)]
 pub enum Object {
     Word { ignore_punctuation: bool },
@@ -28,6 +27,7 @@ pub enum Object {
     CurlyBrackets,
     AngleBrackets,
     Argument,
+    Tag,
 }
 
 #[derive(Clone, Deserialize, PartialEq)]
@@ -51,7 +51,8 @@ actions!(
         SquareBrackets,
         CurlyBrackets,
         AngleBrackets,
-        Argument
+        Argument,
+        Tag
     ]
 );
 
@@ -61,6 +62,7 @@ pub fn register(workspace: &mut Workspace, _: &mut ViewContext<Workspace>) {
             object(Object::Word { ignore_punctuation }, cx)
         },
     );
+    workspace.register_action(|_: &mut Workspace, _: &Tag, cx: _| object(Object::Tag, cx));
     workspace
         .register_action(|_: &mut Workspace, _: &Sentence, cx: _| object(Object::Sentence, cx));
     workspace.register_action(|_: &mut Workspace, _: &Quotes, cx: _| object(Object::Quotes, cx));
@@ -108,6 +110,7 @@ impl Object {
             | Object::DoubleQuotes => false,
             Object::Sentence
             | Object::Parentheses
+            | Object::Tag
             | Object::AngleBrackets
             | Object::CurlyBrackets
             | Object::SquareBrackets
@@ -124,6 +127,7 @@ impl Object {
             | Object::VerticalBars
             | Object::Parentheses
             | Object::SquareBrackets
+            | Object::Tag
             | Object::CurlyBrackets
             | Object::AngleBrackets => true,
         }
@@ -147,6 +151,7 @@ impl Object {
             | Object::CurlyBrackets
             | Object::AngleBrackets
             | Object::VerticalBars
+            | Object::Tag
             | Object::Argument => Mode::Visual,
         }
     }
@@ -181,6 +186,7 @@ impl Object {
             Object::Parentheses => {
                 surrounding_markers(map, relative_to, around, self.is_multiline(), '(', ')')
             }
+            Object::Tag => surrounding_html_tag(map, relative_to, around),
             Object::SquareBrackets => {
                 surrounding_markers(map, relative_to, around, self.is_multiline(), '[', ']')
             }
@@ -241,6 +247,72 @@ fn in_word(
     Some(start..end)
 }
 
+fn surrounding_html_tag(
+    map: &DisplaySnapshot,
+    relative_to: DisplayPoint,
+    surround: bool,
+) -> Option<Range<DisplayPoint>> {
+    fn read_tag(chars: impl Iterator<Item = char>) -> String {
+        chars
+            .take_while(|c| c.is_alphanumeric() || *c == ':' || *c == '-' || *c == '_' || *c == '.')
+            .collect()
+    }
+    fn open_tag(mut chars: impl Iterator<Item = char>) -> Option<String> {
+        if Some('<') != chars.next() {
+            return None;
+        }
+        Some(read_tag(chars))
+    }
+    fn close_tag(mut chars: impl Iterator<Item = char>) -> Option<String> {
+        if (Some('<'), Some('/')) != (chars.next(), chars.next()) {
+            return None;
+        }
+        Some(read_tag(chars))
+    }
+
+    let snapshot = &map.buffer_snapshot;
+    let offset = relative_to.to_offset(map, Bias::Left);
+    let excerpt = snapshot.excerpt_containing(offset..offset)?;
+    let buffer = excerpt.buffer();
+    let offset = excerpt.map_offset_to_buffer(offset);
+
+    // Find the most closest to current offset
+    let mut cursor = buffer.syntax_layer_at(offset)?.node().walk();
+    let mut last_child_node = cursor.node();
+    while cursor.goto_first_child_for_byte(offset).is_some() {
+        last_child_node = cursor.node();
+    }
+
+    let mut last_child_node = Some(last_child_node);
+    while let Some(cur_node) = last_child_node {
+        if cur_node.child_count() >= 2 {
+            let first_child = cur_node.child(0);
+            let last_child = cur_node.child(cur_node.child_count() - 1);
+            if let (Some(first_child), Some(last_child)) = (first_child, last_child) {
+                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()));
+                if open_tag.is_some()
+                    && open_tag == close_tag
+                    && (first_child.end_byte() + 1..last_child.start_byte()).contains(&offset)
+                {
+                    let range = if surround {
+                        first_child.byte_range().start..last_child.byte_range().end
+                    } else {
+                        first_child.byte_range().end..last_child.byte_range().start
+                    };
+                    if excerpt.contains_buffer_range(range.clone()) {
+                        let result = excerpt.map_range_from_buffer(range);
+                        return Some(
+                            result.start.to_display_point(map)..result.end.to_display_point(map),
+                        );
+                    }
+                }
+            }
+        }
+        last_child_node = cur_node.parent();
+    }
+    None
+}
 /// Returns a range that surrounds the word and following whitespace
 /// relative_to is in.
 ///
@@ -1246,4 +1318,26 @@ mod test {
                 .await;
         }
     }
+
+    #[gpui::test]
+    async fn test_tags(cx: &mut gpui::TestAppContext) {
+        let mut cx = VimTestContext::new_html(cx).await;
+
+        cx.set_state("<html><head></head><body><b>hห‡i!</b></body>", Mode::Normal);
+        cx.simulate_keystrokes(["v", "i", "t"]);
+        cx.assert_state(
+            "<html><head></head><body><b>ยซhi!ห‡ยป</b></body>",
+            Mode::Visual,
+        );
+        cx.simulate_keystrokes(["a", "t"]);
+        cx.assert_state(
+            "<html><head></head><body>ยซ<b>hi!</b>ห‡ยป</body>",
+            Mode::Visual,
+        );
+        cx.simulate_keystrokes(["a", "t"]);
+        cx.assert_state(
+            "<html><head></head>ยซ<body><b>hi!</b></body>ห‡ยป",
+            Mode::Visual,
+        );
+    }
 }

crates/vim/src/test/vim_test_context.rs ๐Ÿ”—

@@ -31,6 +31,11 @@ impl VimTestContext {
         Self::new_with_lsp(lsp, enabled)
     }
 
+    pub async fn new_html(cx: &mut gpui::TestAppContext) -> VimTestContext {
+        Self::init(cx);
+        Self::new_with_lsp(EditorLspTestContext::new_html(cx).await, true)
+    }
+
     pub async fn new_typescript(cx: &mut gpui::TestAppContext) -> VimTestContext {
         Self::init(cx);
         Self::new_with_lsp(