Fix vim increment/decrement on Markdown list markers (#47978)

lex00 , Claude Opus 4.5 , and dino created

`find_target()` failed to match numbers followed by a dot and a
non-digit (e.g. `1. item`), because the dot unconditionally reset
the scan state, discarding the number. Additionally, numbers at the
start of non-first lines were missed because the backward scan
stopped on the preceding newline and the forward scan immediately
broke on it.

Closes #47761

Release Notes:

- Fixed vim increment (`ctrl-a`) and decrement (`ctrl-x`) not working on Markdown ordered list markers like `1.`, `2.`, etc.

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: dino <dinojoaocosta@gmail.com>

Change summary

crates/vim/src/normal/increment.rs                                       | 49 
crates/vim/test_data/test_increment_markdown_list_markers_multiline.json |  9 
crates/vim/test_data/test_increment_radix.json                           |  3 
3 files changed, 61 insertions(+)

Detailed changes

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

@@ -241,6 +241,15 @@ fn find_target(
         offset -= ch.len_utf8();
     }
 
+    // The backward scan breaks on whitespace, including newlines. Without this
+    // skip, the forward scan would start on the newline and immediately break
+    // (since it also breaks on newlines), finding nothing on the current line.
+    if let Some(ch) = snapshot.chars_at(offset).next() {
+        if ch == '\n' {
+            offset += ch.len_utf8();
+        }
+    }
+
     let mut begin = None;
     let mut end = None;
     let mut target = String::new();
@@ -271,6 +280,21 @@ fn find_target(
             begin = None;
             target = String::new();
         } else if ch == '.' {
+            // When the cursor is on a number followed by a dot and a non-digit
+            // (`ˇ1. item`), terminate the match so the number is incrementable.
+            // Without this, the dot unconditionally resets the scan and the
+            // number is skipped. We only do this when the cursor is on the
+            // number, when it's past (`111.ˇ.2`), we still reset so the forward
+            // scan can find the number after the dots.
+            let next_is_non_digit = chars.peek().map_or(true, |char| !char.is_digit(radix));
+            let on_number =
+                is_num && begin.is_some_and(|begin| begin >= start_offset || start_offset < offset);
+
+            if on_number && next_is_non_digit {
+                end = Some(offset);
+                break;
+            }
+
             is_num = false;
             begin = None;
             target = String::new();
@@ -701,6 +725,7 @@ mod test {
             .assert_matches();
         cx.simulate("ctrl-a", "(ˇ0b10f)").await.assert_matches();
         cx.simulate("ctrl-a", "ˇ-1").await.assert_matches();
+        cx.simulate("ctrl-a", "-ˇ1").await.assert_matches();
         cx.simulate("ctrl-a", "banˇana").await.assert_matches();
     }
 
@@ -846,4 +871,28 @@ mod test {
             .await;
         cx.shared_state().await.assert_eq(indoc! {"ˇ144\n144\n144"});
     }
+
+    #[gpui::test]
+    async fn test_increment_markdown_list_markers_multiline(cx: &mut gpui::TestAppContext) {
+        let mut cx = NeovimBackedTestContext::new(cx).await;
+
+        cx.set_shared_state("# Title\nˇ1. item\n2. item\n3. item")
+            .await;
+        cx.simulate_shared_keystrokes("ctrl-a").await;
+        cx.shared_state()
+            .await
+            .assert_eq("# Title\nˇ2. item\n2. item\n3. item");
+        cx.simulate_shared_keystrokes("j").await;
+        cx.shared_state()
+            .await
+            .assert_eq("# Title\n2. item\nˇ2. item\n3. item");
+        cx.simulate_shared_keystrokes("ctrl-a").await;
+        cx.shared_state()
+            .await
+            .assert_eq("# Title\n2. item\nˇ3. item\n3. item");
+        cx.simulate_shared_keystrokes("ctrl-x").await;
+        cx.shared_state()
+            .await
+            .assert_eq("# Title\n2. item\nˇ2. item\n3. item");
+    }
 }

crates/vim/test_data/test_increment_markdown_list_markers_multiline.json 🔗

@@ -0,0 +1,9 @@
+{"Put":{"state":"# Title\nˇ1. item\n2. item\n3. item"}}
+{"Key":"ctrl-a"}
+{"Get":{"state":"# Title\nˇ2. item\n2. item\n3. item","mode":"Normal"}}
+{"Key":"j"}
+{"Get":{"state":"# Title\n2. item\nˇ2. item\n3. item","mode":"Normal"}}
+{"Key":"ctrl-a"}
+{"Get":{"state":"# Title\n2. item\nˇ3. item\n3. item","mode":"Normal"}}
+{"Key":"ctrl-x"}
+{"Get":{"state":"# Title\n2. item\nˇ2. item\n3. item","mode":"Normal"}}

crates/vim/test_data/test_increment_radix.json 🔗

@@ -13,6 +13,9 @@
 {"Put":{"state":"ˇ-1"}}
 {"Key":"ctrl-a"}
 {"Get":{"state":"ˇ0","mode":"Normal"}}
+{"Put":{"state":"-ˇ1"}}
+{"Key":"ctrl-a"}
+{"Get":{"state":"ˇ0","mode":"Normal"}}
 {"Put":{"state":"banˇana"}}
 {"Key":"ctrl-a"}
 {"Get":{"state":"banˇana","mode":"Normal"}}