vim: Add Subword Textobject (#22387)

0x2CA and Conrad Irwin created

Closes #22761

[Vim: subword text object?
#22280](https://github.com/zed-industries/zed/discussions/22280)

Release Notes:

- Added Vim SubWord TextObject

---------

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

Change summary

assets/keymaps/vim.json  |   3 +
crates/vim/src/object.rs | 117 +++++++++++++++++++++++++++++++++++++++++
2 files changed, 119 insertions(+), 1 deletion(-)

Detailed changes

assets/keymaps/vim.json 🔗

@@ -391,6 +391,9 @@
     "bindings": {
       "w": "vim::Word",
       "shift-w": ["vim::Word", { "ignorePunctuation": true }],
+      // Subword TextObject
+      // "w": "vim::Subword",
+      // "shift-w": ["vim::Subword", { "ignorePunctuation": true }],
       "t": "vim::Tag",
       "s": "vim::Sentence",
       "p": "vim::Paragraph",

crates/vim/src/object.rs 🔗

@@ -20,6 +20,7 @@ use serde::Deserialize;
 #[derive(Copy, Clone, Debug, PartialEq, Eq, Deserialize, JsonSchema)]
 pub enum Object {
     Word { ignore_punctuation: bool },
+    Subword { ignore_punctuation: bool },
     Sentence,
     Paragraph,
     Quotes,
@@ -46,6 +47,12 @@ struct Word {
     ignore_punctuation: bool,
 }
 
+#[derive(Clone, Deserialize, JsonSchema, PartialEq)]
+#[serde(rename_all = "camelCase")]
+struct Subword {
+    #[serde(default)]
+    ignore_punctuation: bool,
+}
 #[derive(Clone, Deserialize, JsonSchema, PartialEq)]
 #[serde(rename_all = "camelCase")]
 struct IndentObj {
@@ -53,7 +60,7 @@ struct IndentObj {
     include_below: bool,
 }
 
-impl_actions!(vim, [Word, IndentObj]);
+impl_actions!(vim, [Word, Subword, IndentObj]);
 
 actions!(
     vim,
@@ -85,6 +92,13 @@ pub fn register(editor: &mut Editor, cx: &mut ViewContext<Vim>) {
             vim.object(Object::Word { ignore_punctuation }, cx)
         },
     );
+    Vim::action(
+        editor,
+        cx,
+        |vim, &Subword { ignore_punctuation }: &Subword, cx| {
+            vim.object(Object::Subword { ignore_punctuation }, cx)
+        },
+    );
     Vim::action(editor, cx, |vim, _: &Tag, cx| vim.object(Object::Tag, cx));
     Vim::action(editor, cx, |vim, _: &Sentence, cx| {
         vim.object(Object::Sentence, cx)
@@ -159,6 +173,7 @@ impl Object {
     pub fn is_multiline(self) -> bool {
         match self {
             Object::Word { .. }
+            | Object::Subword { .. }
             | Object::Quotes
             | Object::BackQuotes
             | Object::AnyQuotes
@@ -182,6 +197,7 @@ impl Object {
     pub fn always_expands_both_ways(self) -> bool {
         match self {
             Object::Word { .. }
+            | Object::Subword { .. }
             | Object::Sentence
             | Object::Paragraph
             | Object::Argument
@@ -205,6 +221,7 @@ impl Object {
     pub fn target_visual_mode(self, current_mode: Mode, around: bool) -> Mode {
         match self {
             Object::Word { .. }
+            | Object::Subword { .. }
             | Object::Sentence
             | Object::Quotes
             | Object::AnyQuotes
@@ -251,6 +268,13 @@ impl Object {
                     in_word(map, relative_to, ignore_punctuation)
                 }
             }
+            Object::Subword { ignore_punctuation } => {
+                if around {
+                    around_subword(map, relative_to, ignore_punctuation)
+                } else {
+                    in_subword(map, relative_to, ignore_punctuation)
+                }
+            }
             Object::Sentence => sentence(map, relative_to, around),
             Object::Paragraph => paragraph(map, relative_to, around),
             Object::Quotes => {
@@ -387,6 +411,63 @@ fn in_word(
     Some(start..end)
 }
 
+fn in_subword(
+    map: &DisplaySnapshot,
+    relative_to: DisplayPoint,
+    ignore_punctuation: bool,
+) -> Option<Range<DisplayPoint>> {
+    let offset = relative_to.to_offset(map, Bias::Left);
+    // Use motion::right so that we consider the character under the cursor when looking for the start
+    let classifier = map
+        .buffer_snapshot
+        .char_classifier_at(relative_to.to_point(map))
+        .ignore_punctuation(ignore_punctuation);
+    let in_subword = map
+        .buffer_chars_at(offset)
+        .next()
+        .map(|(c, _)| {
+            if classifier.is_word('-') {
+                !classifier.is_whitespace(c) && c != '_' && c != '-'
+            } else {
+                !classifier.is_whitespace(c) && c != '_'
+            }
+        })
+        .unwrap_or(false);
+
+    let start = if in_subword {
+        movement::find_preceding_boundary_display_point(
+            map,
+            right(map, relative_to, 1),
+            movement::FindRange::SingleLine,
+            |left, right| {
+                let is_word_start = classifier.kind(left) != classifier.kind(right);
+                let is_subword_start = classifier.is_word('-') && left == '-' && right != '-'
+                    || left == '_' && right != '_'
+                    || left.is_lowercase() && right.is_uppercase();
+                is_word_start || is_subword_start
+            },
+        )
+    } else {
+        movement::find_boundary(map, relative_to, FindRange::SingleLine, |left, right| {
+            let is_word_start = classifier.kind(left) != classifier.kind(right);
+            let is_subword_start = classifier.is_word('-') && left == '-' && right != '-'
+                || left == '_' && right != '_'
+                || left.is_lowercase() && right.is_uppercase();
+            is_word_start || is_subword_start
+        })
+    };
+
+    let end = movement::find_boundary(map, relative_to, FindRange::SingleLine, |left, right| {
+        let is_word_end = classifier.kind(left) != classifier.kind(right);
+        let is_subword_end = classifier.is_word('-') && left != '-' && right == '-'
+            || left != '_' && right == '_'
+            || left.is_lowercase() && right.is_uppercase();
+        is_word_end || is_subword_end
+    });
+
+    Some(start..end)
+}
+
 pub fn surrounding_html_tag(
     map: &DisplaySnapshot,
     head: DisplayPoint,
@@ -498,6 +579,40 @@ fn around_word(
     }
 }
 
+fn around_subword(
+    map: &DisplaySnapshot,
+    relative_to: DisplayPoint,
+    ignore_punctuation: bool,
+) -> Option<Range<DisplayPoint>> {
+    // Use motion::right so that we consider the character under the cursor when looking for the start
+    let classifier = map
+        .buffer_snapshot
+        .char_classifier_at(relative_to.to_point(map))
+        .ignore_punctuation(ignore_punctuation);
+    let start = movement::find_preceding_boundary_display_point(
+        map,
+        right(map, relative_to, 1),
+        movement::FindRange::SingleLine,
+        |left, right| {
+            let is_word_start = classifier.kind(left) != classifier.kind(right);
+            let is_subword_start = classifier.is_word('-') && left != '-' && right == '-'
+                || left != '_' && right == '_'
+                || left.is_lowercase() && right.is_uppercase();
+            is_word_start || is_subword_start
+        },
+    );
+
+    let end = movement::find_boundary(map, relative_to, FindRange::SingleLine, |left, right| {
+        let is_word_end = classifier.kind(left) != classifier.kind(right);
+        let is_subword_end = classifier.is_word('-') && left != '-' && right == '-'
+            || left != '_' && right == '_'
+            || left.is_lowercase() && right.is_uppercase();
+        is_word_end || is_subword_end
+    });
+
+    Some(start..end)
+}
+
 fn around_containing_word(
     map: &DisplaySnapshot,
     relative_to: DisplayPoint,