vim: Ensure paragraph motions use empty and not blank lines (#47734)

lex00 and dino created

The `}` and `{` paragraph motions now correctly treat only truly empty
lines (zero characters) as paragraph boundaries, matching vim's
documented behavior. Whitespace-only lines are no longer treated as
boundaries.

Changed `start_of_paragraph()` and `end_of_paragraph()` in
`editor/src/movement.rs` to check `line_len() == 0` instead of
`is_line_blank()`.

Note: This change does NOT affect the `ap`/`ip` text objects. Per vim's
`:help ap`, those DO treat whitespace-only lines as boundaries, which is
the existing (correct) behavior in `vim/src/object.rs`.

Closes #36171

Release Notes:

- Fixed vim mode paragraph motions (`}` and `{`) to correctly ignore
whitespace-only lines

---------

Co-authored-by: dino <dinojoaocosta@gmail.com>

Change summary

crates/editor/src/movement.rs                                         | 24 
crates/vim/src/motion.rs                                              | 23 
crates/vim/test_data/test_paragraph_motion_with_whitespace_lines.json |  5 
3 files changed, 40 insertions(+), 12 deletions(-)

Detailed changes

crates/editor/src/movement.rs 🔗

@@ -523,7 +523,7 @@ fn is_subword_boundary_end(left: char, right: char, classifier: &CharClassifier)
 }
 
 /// Returns a position of the start of the current paragraph, where a paragraph
-/// is defined as a run of non-blank lines.
+/// is defined as a run of non-empty lines.
 pub fn start_of_paragraph(
     map: &DisplaySnapshot,
     display_point: DisplayPoint,
@@ -534,25 +534,25 @@ pub fn start_of_paragraph(
         return DisplayPoint::zero();
     }
 
-    let mut found_non_blank_line = false;
+    let mut found_non_empty_line = false;
     for row in (0..point.row + 1).rev() {
-        let blank = map.buffer_snapshot().is_line_blank(MultiBufferRow(row));
-        if found_non_blank_line && blank {
+        let empty = map.buffer_snapshot().line_len(MultiBufferRow(row)) == 0;
+        if found_non_empty_line && empty {
             if count <= 1 {
                 return Point::new(row, 0).to_display_point(map);
             }
             count -= 1;
-            found_non_blank_line = false;
+            found_non_empty_line = false;
         }
 
-        found_non_blank_line |= !blank;
+        found_non_empty_line |= !empty;
     }
 
     DisplayPoint::zero()
 }
 
 /// Returns a position of the end of the current paragraph, where a paragraph
-/// is defined as a run of non-blank lines.
+/// is defined as a run of non-empty lines.
 pub fn end_of_paragraph(
     map: &DisplaySnapshot,
     display_point: DisplayPoint,
@@ -563,18 +563,18 @@ pub fn end_of_paragraph(
         return map.max_point();
     }
 
-    let mut found_non_blank_line = false;
+    let mut found_non_empty_line = false;
     for row in point.row..=map.buffer_snapshot().max_row().0 {
-        let blank = map.buffer_snapshot().is_line_blank(MultiBufferRow(row));
-        if found_non_blank_line && blank {
+        let empty = map.buffer_snapshot().line_len(MultiBufferRow(row)) == 0;
+        if found_non_empty_line && empty {
             if count <= 1 {
                 return Point::new(row, 0).to_display_point(map);
             }
             count -= 1;
-            found_non_blank_line = false;
+            found_non_empty_line = false;
         }
 
-        found_non_blank_line |= !blank;
+        found_non_empty_line |= !empty;
     }
 
     map.max_point()

crates/vim/src/motion.rs 🔗

@@ -3292,6 +3292,29 @@ mod test {
                 final"});
     }
 
+    #[gpui::test]
+    async fn test_paragraph_motion_with_whitespace_lines(cx: &mut gpui::TestAppContext) {
+        let mut cx = NeovimBackedTestContext::new(cx).await;
+
+        // Test that whitespace-only lines are NOT treated as paragraph boundaries
+        // Per vim's :help paragraph - only truly empty lines are boundaries
+        // Line 2 has 4 spaces (whitespace-only), line 4 is truly empty
+        cx.set_shared_state("ˇfirst\n    \nstill first\n\nsecond")
+            .await;
+        cx.simulate_shared_keystrokes("}").await;
+
+        // Should skip whitespace-only line and stop at truly empty line
+        let mut shared_state = cx.shared_state().await;
+        shared_state.assert_eq("first\n    \nstill first\nˇ\nsecond");
+        shared_state.assert_matches();
+
+        // Should go back to original position
+        cx.simulate_shared_keystrokes("{").await;
+        let mut shared_state = cx.shared_state().await;
+        shared_state.assert_eq("ˇfirst\n    \nstill first\n\nsecond");
+        shared_state.assert_matches();
+    }
+
     #[gpui::test]
     async fn test_matching(cx: &mut gpui::TestAppContext) {
         let mut cx = NeovimBackedTestContext::new(cx).await;