vim: Implement partial increment/decrement for visual selection (#36553)

Kaem created

This change adds the ability to increment / decrement numbers that are
part of a visual selection. Previously Zed would resolve to the entire
number under visual selection for increment as oppposed to only
incrementing the part of the number that is selected

Release Notes: 

- vim: Fixed increment/decrement in visual mode

Change summary

crates/vim/src/normal/increment.rs                             | 111 +++
crates/vim/test_data/test_increment_visual_partial_number.json |  20 
2 files changed, 130 insertions(+), 1 deletion(-)

Detailed changes

crates/vim/src/normal/increment.rs 🔗

@@ -70,8 +70,19 @@ impl Vim {
                     } else {
                         Point::new(row, 0)
                     };
+                    let end = if row == selection.end.row {
+                        selection.end
+                    } else {
+                        Point::new(row, snapshot.line_len(multi_buffer::MultiBufferRow(row)))
+                    };
+
+                    let number_result = if !selection.is_empty() {
+                        find_number_in_range(&snapshot, start, end)
+                    } else {
+                        find_number(&snapshot, start)
+                    };
 
-                    if let Some((range, num, radix)) = find_number(&snapshot, start) {
+                    if let Some((range, num, radix)) = number_result {
                         let replace = match radix {
                             10 => increment_decimal_string(&num, delta),
                             16 => increment_hex_string(&num, delta),
@@ -189,6 +200,90 @@ fn increment_binary_string(num: &str, delta: i64) -> String {
     format!("{:0width$b}", result, width = num.len())
 }
 
+fn find_number_in_range(
+    snapshot: &MultiBufferSnapshot,
+    start: Point,
+    end: Point,
+) -> Option<(Range<Point>, String, u32)> {
+    let start_offset = start.to_offset(snapshot);
+    let end_offset = end.to_offset(snapshot);
+
+    let mut offset = start_offset;
+
+    // Backward scan to find the start of the number, but stop at start_offset
+    for ch in snapshot.reversed_chars_at(offset) {
+        if ch.is_ascii_hexdigit() || ch == '-' || ch == 'b' || ch == 'x' {
+            if offset == 0 {
+                break;
+            }
+            offset -= ch.len_utf8();
+            if offset < start_offset {
+                offset = start_offset;
+                break;
+            }
+        } else {
+            break;
+        }
+    }
+
+    let mut begin = None;
+    let mut end_num = None;
+    let mut num = String::new();
+    let mut radix = 10;
+
+    let mut chars = snapshot.chars_at(offset).peekable();
+
+    while let Some(ch) = chars.next() {
+        if offset >= end_offset {
+            break; // stop at end of selection
+        }
+
+        if num == "0" && ch == 'b' && chars.peek().is_some() && chars.peek().unwrap().is_digit(2) {
+            radix = 2;
+            begin = None;
+            num = String::new();
+        } else if num == "0"
+            && ch == 'x'
+            && chars.peek().is_some()
+            && chars.peek().unwrap().is_ascii_hexdigit()
+        {
+            radix = 16;
+            begin = None;
+            num = String::new();
+        }
+
+        if ch.is_digit(radix)
+            || (begin.is_none()
+                && ch == '-'
+                && chars.peek().is_some()
+                && chars.peek().unwrap().is_digit(radix))
+        {
+            if begin.is_none() {
+                begin = Some(offset);
+            }
+            num.push(ch);
+        } else if begin.is_some() {
+            end_num = Some(offset);
+            break;
+        } else if ch == '\n' {
+            break;
+        }
+
+        offset += ch.len_utf8();
+    }
+
+    if let Some(begin) = begin {
+        let end_num = end_num.unwrap_or(offset);
+        Some((
+            begin.to_point(snapshot)..end_num.to_point(snapshot),
+            num,
+            radix,
+        ))
+    } else {
+        None
+    }
+}
+
 fn find_number(
     snapshot: &MultiBufferSnapshot,
     start: Point,
@@ -764,4 +859,18 @@ mod test {
         cx.simulate_keystrokes("v b ctrl-a");
         cx.assert_state("let enabled = ˇOff;", Mode::Normal);
     }
+
+    #[gpui::test]
+    async fn test_increment_visual_partial_number(cx: &mut gpui::TestAppContext) {
+        let mut cx = NeovimBackedTestContext::new(cx).await;
+
+        cx.set_shared_state("ˇ123").await;
+        cx.simulate_shared_keystrokes("v l ctrl-a").await;
+        cx.shared_state().await.assert_eq(indoc! {"ˇ133"});
+        cx.simulate_shared_keystrokes("l v l ctrl-a").await;
+        cx.shared_state().await.assert_eq(indoc! {"1ˇ34"});
+        cx.simulate_shared_keystrokes("shift-v y p p ctrl-v k k l ctrl-a")
+            .await;
+        cx.shared_state().await.assert_eq(indoc! {"ˇ144\n144\n144"});
+    }
 }

crates/vim/test_data/test_increment_visual_partial_number.json 🔗

@@ -0,0 +1,20 @@
+{"Put":{"state":"ˇ123"}}
+{"Key":"v"}
+{"Key":"l"}
+{"Key":"ctrl-a"}
+{"Get":{"state":"ˇ133","mode":"Normal"}}
+{"Key":"l"}
+{"Key":"v"}
+{"Key":"l"}
+{"Key":"ctrl-a"}
+{"Get":{"state":"1ˇ34","mode":"Normal"}}
+{"Key":"shift-v"}
+{"Key":"y"}
+{"Key":"p"}
+{"Key":"p"}
+{"Key":"ctrl-v"}
+{"Key":"k"}
+{"Key":"k"}
+{"Key":"l"}
+{"Key":"ctrl-a"}
+{"Get":{"state":"ˇ144\n144\n144","mode":"Normal"}}