vim: Prevent around word operations from selecting indentation (#24635)

5brian created

Closes https://github.com/zed-industries/zed/issues/15323

Changes:
Added check for first word on line

Tested `v/c/d/y aw`. Matches standard neovim.

|initial|old|new|
|---|---|---|

|![image](https://github.com/user-attachments/assets/725b74e6-3aa0-40dc-9fd2-4d2b593e9926)|![image](https://github.com/user-attachments/assets/eeebd267-b4c6-4ea6-bb9a-fb913614754c)|![image](https://github.com/user-attachments/assets/fb695e54-b4c2-44a6-a588-909c1fd415e0)



Release Notes:

- vim: Prevent around word operations from selecting indentation

Change summary

crates/vim/src/object.rs                                     | 53 +++++
crates/vim/test_data/test_around_containing_word_indent.json | 23 ++
2 files changed, 74 insertions(+), 2 deletions(-)

Detailed changes

crates/vim/src/object.rs 🔗

@@ -727,8 +727,25 @@ fn around_containing_word(
     relative_to: DisplayPoint,
     ignore_punctuation: bool,
 ) -> Option<Range<DisplayPoint>> {
-    in_word(map, relative_to, ignore_punctuation)
-        .map(|range| expand_to_include_whitespace(map, range, true))
+    in_word(map, relative_to, ignore_punctuation).map(|range| {
+        let line_start = DisplayPoint::new(range.start.row(), 0);
+        let is_first_word = map
+            .buffer_chars_at(line_start.to_offset(map, Bias::Left))
+            .take_while(|(ch, offset)| {
+                offset < &range.start.to_offset(map, Bias::Left) && ch.is_whitespace()
+            })
+            .count()
+            > 0;
+
+        if is_first_word {
+            // For first word on line, trim indentation
+            let mut expanded = expand_to_include_whitespace(map, range.clone(), true);
+            expanded.start = range.start;
+            expanded
+        } else {
+            expand_to_include_whitespace(map, range, true)
+        }
+    })
 }
 
 fn around_next_word(
@@ -2455,4 +2472,36 @@ mod test {
             Mode::Visual,
         );
     }
+    #[gpui::test]
+    async fn test_around_containing_word_indent(cx: &mut gpui::TestAppContext) {
+        let mut cx = NeovimBackedTestContext::new(cx).await;
+
+        cx.set_shared_state("    ˇconst f = (x: unknown) => {")
+            .await;
+        cx.simulate_shared_keystrokes("v a w").await;
+        cx.shared_state()
+            .await
+            .assert_eq("    «const ˇ»f = (x: unknown) => {");
+
+        cx.set_shared_state("    ˇconst f = (x: unknown) => {")
+            .await;
+        cx.simulate_shared_keystrokes("y a w").await;
+        cx.shared_clipboard().await.assert_eq("const ");
+
+        cx.set_shared_state("    ˇconst f = (x: unknown) => {")
+            .await;
+        cx.simulate_shared_keystrokes("d a w").await;
+        cx.shared_state()
+            .await
+            .assert_eq("    ˇf = (x: unknown) => {");
+        cx.shared_clipboard().await.assert_eq("const ");
+
+        cx.set_shared_state("    ˇconst f = (x: unknown) => {")
+            .await;
+        cx.simulate_shared_keystrokes("c a w").await;
+        cx.shared_state()
+            .await
+            .assert_eq("    ˇf = (x: unknown) => {");
+        cx.shared_clipboard().await.assert_eq("const ");
+    }
 }

crates/vim/test_data/test_around_containing_word_indent.json 🔗

@@ -0,0 +1,23 @@
+{"Put":{"state":"    ˇconst f = (x: unknown) => {"}}
+{"Key":"v"}
+{"Key":"a"}
+{"Key":"w"}
+{"Get":{"state":"    «const ˇ»f = (x: unknown) => {","mode":"Visual"}}
+{"Put":{"state":"    ˇconst f = (x: unknown) => {"}}
+{"Key":"y"}
+{"Key":"a"}
+{"Key":"w"}
+{"Get":{"state":"    ˇconst f = (x: unknown) => {","mode":"Normal"}}
+{"ReadRegister":{"name":"\"","value":"const "}}
+{"Put":{"state":"    ˇconst f = (x: unknown) => {"}}
+{"Key":"d"}
+{"Key":"a"}
+{"Key":"w"}
+{"Get":{"state":"    ˇf = (x: unknown) => {","mode":"Normal"}}
+{"ReadRegister":{"name":"\"","value":"const "}}
+{"Put":{"state":"    ˇconst f = (x: unknown) => {"}}
+{"Key":"c"}
+{"Key":"a"}
+{"Key":"w"}
+{"Get":{"state":"    ˇf = (x: unknown) => {","mode":"Insert"}}
+{"ReadRegister":{"name":"\"","value":"const "}}