vim: Fix increment/decrement command (#17644)

hekmyr and Conrad Irwin created

Improving vim increment and decrement command.

Closes: #16672

## Release Notes:

- vim: Improved edge-case handling for ctrl-a/ctrl-x

---------

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

Change summary

crates/vim/src/normal/increment.rs                                | 241 
crates/vim/test_data/test_increment_bin_wrapping_and_padding.json |  10 
crates/vim/test_data/test_increment_hex_casing.json               |   5 
crates/vim/test_data/test_increment_hex_wrapping_and_padding.json |  10 
crates/vim/test_data/test_increment_inline.json                   |  10 
crates/vim/test_data/test_increment_sign_change.json              |   6 
crates/vim/test_data/test_increment_wrapping.json                 |  13 
7 files changed, 273 insertions(+), 22 deletions(-)

Detailed changes

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

@@ -28,18 +28,18 @@ pub fn register(editor: &mut Editor, cx: &mut ViewContext<Vim>) {
         vim.record_current_action(cx);
         let count = vim.take_count(cx).unwrap_or(1);
         let step = if action.step { 1 } else { 0 };
-        vim.increment(count as i32, step, cx)
+        vim.increment(count as i64, step, cx)
     });
     Vim::action(editor, cx, |vim, action: &Decrement, cx| {
         vim.record_current_action(cx);
         let count = vim.take_count(cx).unwrap_or(1);
         let step = if action.step { -1 } else { 0 };
-        vim.increment(-(count as i32), step, cx)
+        vim.increment(-(count as i64), step, cx)
     });
 }
 
 impl Vim {
-    fn increment(&mut self, mut delta: i32, step: i32, cx: &mut ViewContext<Self>) {
+    fn increment(&mut self, mut delta: i64, step: i32, cx: &mut ViewContext<Self>) {
         self.store_visual_marks(cx);
         self.update_editor(cx, |vim, editor, cx| {
             let mut edits = Vec::new();
@@ -60,23 +60,14 @@ impl Vim {
                     };
 
                     if let Some((range, num, radix)) = find_number(&snapshot, start) {
-                        if let Ok(val) = i32::from_str_radix(&num, radix) {
-                            let result = val + delta;
-                            delta += step;
-                            let replace = match radix {
-                                10 => format!("{}", result),
-                                16 => {
-                                    if num.to_ascii_lowercase() == num {
-                                        format!("{:x}", result)
-                                    } else {
-                                        format!("{:X}", result)
-                                    }
-                                }
-                                2 => format!("{:b}", result),
-                                _ => unreachable!(),
-                            };
-                            edits.push((range.clone(), replace));
-                        }
+                        let replace = match radix {
+                            10 => increment_decimal_string(&num, delta),
+                            16 => increment_hex_string(&num, delta),
+                            2 => increment_binary_string(&num, delta),
+                            _ => unreachable!(),
+                        };
+                        delta += step as i64;
+                        edits.push((range.clone(), replace));
                         if selection.is_empty() {
                             new_anchors.push((false, snapshot.anchor_after(range.end)))
                         }
@@ -107,6 +98,70 @@ impl Vim {
     }
 }
 
+fn increment_decimal_string(mut num: &str, mut delta: i64) -> String {
+    let mut negative = false;
+    if num.chars().next() == Some('-') {
+        negative = true;
+        delta = 0 - delta;
+        num = &num[1..];
+    }
+    let result = if let Ok(value) = u64::from_str_radix(num, 10) {
+        let wrapped = value.wrapping_add_signed(delta);
+        if delta < 0 && wrapped > value {
+            negative = !negative;
+            (u64::MAX - wrapped).wrapping_add(1)
+        } else if delta > 0 && wrapped < value {
+            negative = !negative;
+            u64::MAX - wrapped
+        } else {
+            wrapped
+        }
+    } else {
+        u64::MAX
+    };
+
+    if result == 0 || !negative {
+        format!("{}", result)
+    } else {
+        format!("-{}", result)
+    }
+}
+
+fn increment_hex_string(num: &str, delta: i64) -> String {
+    let result = if let Ok(val) = u64::from_str_radix(&num, 16) {
+        val.wrapping_add_signed(delta)
+    } else {
+        u64::MAX
+    };
+    if should_use_lowercase(num) {
+        format!("{:0width$x}", result, width = num.len())
+    } else {
+        format!("{:0width$X}", result, width = num.len())
+    }
+}
+
+fn should_use_lowercase(num: &str) -> bool {
+    let mut use_uppercase = false;
+    for ch in num.chars() {
+        if ch.is_ascii_lowercase() {
+            return true;
+        }
+        if ch.is_ascii_uppercase() {
+            use_uppercase = true;
+        }
+    }
+    !use_uppercase
+}
+
+fn increment_binary_string(num: &str, delta: i64) -> String {
+    let result = if let Ok(val) = u64::from_str_radix(&num, 2) {
+        val.wrapping_add_signed(delta)
+    } else {
+        u64::MAX
+    };
+    format!("{:0width$b}", result, width = num.len())
+}
+
 fn find_number(
     snapshot: &MultiBufferSnapshot,
     start: Point,
@@ -114,10 +169,10 @@ fn find_number(
     let mut offset = start.to_offset(snapshot);
 
     let ch0 = snapshot.chars_at(offset).next();
-    if ch0.as_ref().is_some_and(char::is_ascii_digit) || matches!(ch0, Some('-' | 'b' | 'x')) {
+    if ch0.as_ref().is_some_and(char::is_ascii_hexdigit) || matches!(ch0, Some('-' | 'b' | 'x')) {
         // go backwards to the start of any number the selection is within
         for ch in snapshot.reversed_chars_at(offset) {
-            if ch.is_ascii_digit() || ch == '-' || ch == 'b' || ch == 'x' {
+            if ch.is_ascii_hexdigit() || ch == '-' || ch == 'b' || ch == 'x' {
                 offset -= ch.len_utf8();
                 continue;
             }
@@ -158,6 +213,8 @@ fn find_number(
                 begin = Some(offset);
             }
             num.push(ch);
+            println!("pushing {}", ch);
+            println!();
         } else if begin.is_some() {
             end = Some(offset);
             break;
@@ -250,6 +307,146 @@ mod test {
             "});
     }
 
+    #[gpui::test]
+    async fn test_increment_sign_change(cx: &mut gpui::TestAppContext) {
+        let mut cx = NeovimBackedTestContext::new(cx).await;
+        cx.set_shared_state(indoc! {"
+                ˇ0
+                "})
+            .await;
+        cx.simulate_shared_keystrokes("ctrl-x").await;
+        cx.shared_state().await.assert_eq(indoc! {"
+                -ˇ1
+                "});
+        cx.simulate_shared_keystrokes("2 ctrl-a").await;
+        cx.shared_state().await.assert_eq(indoc! {"
+                ˇ1
+                "});
+    }
+
+    #[gpui::test]
+    async fn test_increment_bin_wrapping_and_padding(cx: &mut gpui::TestAppContext) {
+        let mut cx = NeovimBackedTestContext::new(cx).await;
+        cx.set_shared_state(indoc! {"
+                    0b111111111111111111111111111111111111111111111111111111111111111111111ˇ1
+                    "})
+            .await;
+
+        cx.simulate_shared_keystrokes("ctrl-a").await;
+        cx.shared_state().await.assert_eq(indoc! {"
+                    0b000000111111111111111111111111111111111111111111111111111111111111111ˇ1
+                    "});
+        cx.simulate_shared_keystrokes("ctrl-a").await;
+        cx.shared_state().await.assert_eq(indoc! {"
+                    0b000000000000000000000000000000000000000000000000000000000000000000000ˇ0
+                    "});
+
+        cx.simulate_shared_keystrokes("ctrl-a").await;
+        cx.shared_state().await.assert_eq(indoc! {"
+                    0b000000000000000000000000000000000000000000000000000000000000000000000ˇ1
+                    "});
+        cx.simulate_shared_keystrokes("2 ctrl-x").await;
+        cx.shared_state().await.assert_eq(indoc! {"
+                    0b000000111111111111111111111111111111111111111111111111111111111111111ˇ1
+                    "});
+    }
+
+    #[gpui::test]
+    async fn test_increment_hex_wrapping_and_padding(cx: &mut gpui::TestAppContext) {
+        let mut cx = NeovimBackedTestContext::new(cx).await;
+        cx.set_shared_state(indoc! {"
+                    0xfffffffffffffffffffˇf
+                    "})
+            .await;
+
+        cx.simulate_shared_keystrokes("ctrl-a").await;
+        cx.shared_state().await.assert_eq(indoc! {"
+                    0x0000fffffffffffffffˇf
+                    "});
+        cx.simulate_shared_keystrokes("ctrl-a").await;
+        cx.shared_state().await.assert_eq(indoc! {"
+                    0x0000000000000000000ˇ0
+                    "});
+        cx.simulate_shared_keystrokes("ctrl-a").await;
+        cx.shared_state().await.assert_eq(indoc! {"
+                    0x0000000000000000000ˇ1
+                    "});
+        cx.simulate_shared_keystrokes("2 ctrl-x").await;
+        cx.shared_state().await.assert_eq(indoc! {"
+                    0x0000fffffffffffffffˇf
+                    "});
+    }
+
+    #[gpui::test]
+    async fn test_increment_wrapping(cx: &mut gpui::TestAppContext) {
+        let mut cx = NeovimBackedTestContext::new(cx).await;
+        cx.set_shared_state(indoc! {"
+                    1844674407370955161ˇ9
+                    "})
+            .await;
+
+        cx.simulate_shared_keystrokes("ctrl-a").await;
+        cx.shared_state().await.assert_eq(indoc! {"
+                    1844674407370955161ˇ5
+                    "});
+        cx.simulate_shared_keystrokes("ctrl-a").await;
+        cx.shared_state().await.assert_eq(indoc! {"
+                    -1844674407370955161ˇ5
+                    "});
+        cx.simulate_shared_keystrokes("ctrl-a").await;
+        cx.shared_state().await.assert_eq(indoc! {"
+                    -1844674407370955161ˇ4
+                    "});
+        cx.simulate_shared_keystrokes("3 ctrl-x").await;
+        cx.shared_state().await.assert_eq(indoc! {"
+                    1844674407370955161ˇ4
+                    "});
+        cx.simulate_shared_keystrokes("2 ctrl-a").await;
+        cx.shared_state().await.assert_eq(indoc! {"
+                    -1844674407370955161ˇ5
+                    "});
+    }
+
+    #[gpui::test]
+    async fn test_increment_inline(cx: &mut gpui::TestAppContext) {
+        let mut cx = NeovimBackedTestContext::new(cx).await;
+        cx.set_shared_state(indoc! {"
+                    inline0x3ˇ9u32
+                    "})
+            .await;
+
+        cx.simulate_shared_keystrokes("ctrl-a").await;
+        cx.shared_state().await.assert_eq(indoc! {"
+                    inline0x3ˇau32
+                    "});
+        cx.simulate_shared_keystrokes("ctrl-a").await;
+        cx.shared_state().await.assert_eq(indoc! {"
+                    inline0x3ˇbu32
+                    "});
+        cx.simulate_shared_keystrokes("l l l ctrl-a").await;
+        cx.shared_state().await.assert_eq(indoc! {"
+                    inline0x3bu3ˇ3
+                    "});
+    }
+
+    #[gpui::test]
+    async fn test_increment_hex_casing(cx: &mut gpui::TestAppContext) {
+        let mut cx = NeovimBackedTestContext::new(cx).await;
+        cx.set_shared_state(indoc! {"
+                        0xFˇa
+                    "})
+            .await;
+
+        cx.simulate_shared_keystrokes("ctrl-a").await;
+        cx.shared_state().await.assert_eq(indoc! {"
+                    0xfˇb
+                    "});
+        cx.simulate_shared_keystrokes("ctrl-a").await;
+        cx.shared_state().await.assert_eq(indoc! {"
+                    0xfˇc
+                    "});
+    }
+
     #[gpui::test]
     async fn test_increment_radix(cx: &mut gpui::TestAppContext) {
         let mut cx = NeovimBackedTestContext::new(cx).await;

crates/vim/test_data/test_increment_bin_wrapping_and_padding.json 🔗

@@ -0,0 +1,10 @@
+{"Put":{"state":"0b111111111111111111111111111111111111111111111111111111111111111111111ˇ1\n"}}
+{"Key":"ctrl-a"}
+{"Get":{"state":"0b000000111111111111111111111111111111111111111111111111111111111111111ˇ1\n", "mode":"Normal"}}
+{"Key":"ctrl-a"}
+{"Get":{"state":"0b000000000000000000000000000000000000000000000000000000000000000000000ˇ0\n","mode":"Normal"}}
+{"Key":"ctrl-a"}
+{"Get":{"state":"0b000000000000000000000000000000000000000000000000000000000000000000000ˇ1\n","mode":"Normal"}}
+{"Key":"2"}
+{"Key":"ctrl-x"}
+{"Get":{"state":"0b000000111111111111111111111111111111111111111111111111111111111111111ˇ1\n", "mode":"Normal"}}

crates/vim/test_data/test_increment_hex_wrapping_and_padding.json 🔗

@@ -0,0 +1,10 @@
+{"Put":{"state":"0xfffffffffffffffffffˇf\n"}}
+{"Key":"ctrl-a"}
+{"Get":{"state":"0x0000fffffffffffffffˇf\n", "mode":"Normal"}}
+{"Key":"ctrl-a"}
+{"Get":{"state":"0x0000000000000000000ˇ0\n","mode":"Normal"}}
+{"Key":"ctrl-a"}
+{"Get":{"state":"0x0000000000000000000ˇ1\n","mode":"Normal"}}
+{"Key":"2"}
+{"Key":"ctrl-x"}
+{"Get":{"state":"0x0000fffffffffffffffˇf\n", "mode":"Normal"}}

crates/vim/test_data/test_increment_inline.json 🔗

@@ -0,0 +1,10 @@
+{"Put":{"state":"inline0x3ˇ9u32\n"}}
+{"Key":"ctrl-a"}
+{"Get":{"state":"inline0x3ˇau32\n","mode":"Normal"}}
+{"Key":"ctrl-a"}
+{"Get":{"state":"inline0x3ˇbu32\n", "mode":"Normal"}}
+{"Key":"l"}
+{"Key":"l"}
+{"Key":"l"}
+{"Key":"ctrl-a"}
+{"Get":{"state":"inline0x3bu3ˇ3\n", "mode":"Normal"}}

crates/vim/test_data/test_increment_wrapping.json 🔗

@@ -0,0 +1,13 @@
+{"Put":{"state":"1844674407370955161ˇ9\n"}}
+{"Key":"ctrl-a"}
+{"Get":{"state":"1844674407370955161ˇ5\n","mode":"Normal"}}
+{"Key":"ctrl-a"}
+{"Get":{"state":"-1844674407370955161ˇ5\n", "mode":"Normal"}}
+{"Key":"ctrl-a"}
+{"Get":{"state":"-1844674407370955161ˇ4\n", "mode":"Normal"}}
+{"Key":"3"}
+{"Key":"ctrl-x"}
+{"Get":{"state":"1844674407370955161ˇ4\n", "mode":"Normal"}}
+{"Key":"2"}
+{"Key":"ctrl-a"}
+{"Get":{"state":"-1844674407370955161ˇ5\n", "mode":"Normal"}}