vim: Apply strict paragraph motion only in vim mode (#48024)

lex00 , Claude Opus 4.5 , and dino created

Reverts the editor's paragraph navigation behavior that was changed in
#47734. Whitespace-only lines are now treated as paragraph boundaries
again for non-vim mode users.

Vim mode retains its own implementation where only truly empty lines
are paragraph boundaries.

Release Notes:

- Fixed editor paragraph navigation to treat whitespace-only lines as
paragraph boundaries again

---------

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

Change summary

crates/editor/src/editor_tests.rs | 86 +++-----------------------------
crates/editor/src/movement.rs     | 24 ++++----
crates/vim/src/motion.rs          | 69 ++++++++++++++++++++++++-
3 files changed, 86 insertions(+), 93 deletions(-)

Detailed changes

crates/editor/src/editor_tests.rs 🔗

@@ -2243,107 +2243,41 @@ async fn test_move_start_of_paragraph_end_of_paragraph(cx: &mut TestAppContext)
     });
     cx.simulate_window_resize(cx.window, size(px(100.), 4. * line_height));
 
-    cx.set_state(
-        &r#"ˇone
-        two
-
-        three
-        fourˇ
-        five
-
-        six"#
-            .unindent(),
-    );
+    // The third line only contains a single space so we can later assert that the
+    // editor's paragraph movement considers a non-blank line as a paragraph
+    // boundary.
+    cx.set_state(&"ˇone\ntwo\n \nthree\nfourˇ\nfive\n\nsix");
 
     cx.update_editor(|editor, window, cx| {
         editor.move_to_end_of_paragraph(&MoveToEndOfParagraph, window, cx)
     });
-    cx.assert_editor_state(
-        &r#"one
-        two
-        ˇ
-        three
-        four
-        five
-        ˇ
-        six"#
-            .unindent(),
-    );
+    cx.assert_editor_state(&"one\ntwo\nˇ \nthree\nfour\nfive\nˇ\nsix");
 
     cx.update_editor(|editor, window, cx| {
         editor.move_to_end_of_paragraph(&MoveToEndOfParagraph, window, cx)
     });
-    cx.assert_editor_state(
-        &r#"one
-        two
-
-        three
-        four
-        five
-        ˇ
-        sixˇ"#
-            .unindent(),
-    );
+    cx.assert_editor_state(&"one\ntwo\n \nthree\nfour\nfive\nˇ\nsixˇ");
 
     cx.update_editor(|editor, window, cx| {
         editor.move_to_end_of_paragraph(&MoveToEndOfParagraph, window, cx)
     });
-    cx.assert_editor_state(
-        &r#"one
-        two
-
-        three
-        four
-        five
-
-        sixˇ"#
-            .unindent(),
-    );
+    cx.assert_editor_state(&"one\ntwo\n \nthree\nfour\nfive\n\nsixˇ");
 
     cx.update_editor(|editor, window, cx| {
         editor.move_to_start_of_paragraph(&MoveToStartOfParagraph, window, cx)
     });
-    cx.assert_editor_state(
-        &r#"one
-        two
-
-        three
-        four
-        five
-        ˇ
-        six"#
-            .unindent(),
-    );
+    cx.assert_editor_state(&"one\ntwo\n \nthree\nfour\nfive\nˇ\nsix");
 
     cx.update_editor(|editor, window, cx| {
         editor.move_to_start_of_paragraph(&MoveToStartOfParagraph, window, cx)
     });
-    cx.assert_editor_state(
-        &r#"one
-        two
-        ˇ
-        three
-        four
-        five
 
-        six"#
-            .unindent(),
-    );
+    cx.assert_editor_state(&"one\ntwo\nˇ \nthree\nfour\nfive\n\nsix");
 
     cx.update_editor(|editor, window, cx| {
         editor.move_to_start_of_paragraph(&MoveToStartOfParagraph, window, cx)
     });
-    cx.assert_editor_state(
-        &r#"ˇone
-        two
-
-        three
-        four
-        five
-
-        six"#
-            .unindent(),
-    );
+    cx.assert_editor_state(&"ˇone\ntwo\n \nthree\nfour\nfive\n\nsix");
 }
 
 #[gpui::test]

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-empty lines.
+/// is defined as a run of non-blank 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_empty_line = false;
+    let mut found_non_blank_line = false;
     for row in (0..point.row + 1).rev() {
-        let empty = map.buffer_snapshot().line_len(MultiBufferRow(row)) == 0;
-        if found_non_empty_line && empty {
+        let blank = map.buffer_snapshot().is_line_blank(MultiBufferRow(row));
+        if found_non_blank_line && blank {
             if count <= 1 {
                 return Point::new(row, 0).to_display_point(map);
             }
             count -= 1;
-            found_non_empty_line = false;
+            found_non_blank_line = false;
         }
 
-        found_non_empty_line |= !empty;
+        found_non_blank_line |= !blank;
     }
 
     DisplayPoint::zero()
 }
 
 /// Returns a position of the end of the current paragraph, where a paragraph
-/// is defined as a run of non-empty lines.
+/// is defined as a run of non-blank 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_empty_line = false;
+    let mut found_non_blank_line = false;
     for row in point.row..=map.buffer_snapshot().max_row().0 {
-        let empty = map.buffer_snapshot().line_len(MultiBufferRow(row)) == 0;
-        if found_non_empty_line && empty {
+        let blank = map.buffer_snapshot().is_line_blank(MultiBufferRow(row));
+        if found_non_blank_line && blank {
             if count <= 1 {
                 return Point::new(row, 0).to_display_point(map);
             }
             count -= 1;
-            found_non_empty_line = false;
+            found_non_blank_line = false;
         }
 
-        found_non_empty_line |= !empty;
+        found_non_blank_line |= !blank;
     }
 
     map.max_point()

crates/vim/src/motion.rs 🔗

@@ -1038,12 +1038,9 @@ impl Motion {
             ),
             SentenceBackward => (sentence_backwards(map, point, times), SelectionGoal::None),
             SentenceForward => (sentence_forwards(map, point, times), SelectionGoal::None),
-            StartOfParagraph => (
-                movement::start_of_paragraph(map, point, times),
-                SelectionGoal::None,
-            ),
+            StartOfParagraph => (start_of_paragraph(map, point, times), SelectionGoal::None),
             EndOfParagraph => (
-                map.clip_at_line_end(movement::end_of_paragraph(map, point, times)),
+                map.clip_at_line_end(end_of_paragraph(map, point, times)),
                 SelectionGoal::None,
             ),
             CurrentLine => (next_line_end(map, point, times), SelectionGoal::None),
@@ -2238,6 +2235,68 @@ pub(crate) fn sentence_forwards(
     map.max_point()
 }
 
+/// Returns a position of the start of the current paragraph for vim motions,
+/// where a paragraph is defined as a run of non-empty lines. Lines containing
+/// only whitespace are not considered empty and do not act as paragraph
+/// boundaries.
+pub(crate) fn start_of_paragraph(
+    map: &DisplaySnapshot,
+    display_point: DisplayPoint,
+    mut count: usize,
+) -> DisplayPoint {
+    let point = display_point.to_point(map);
+    if point.row == 0 {
+        return DisplayPoint::zero();
+    }
+
+    let mut found_non_empty_line = false;
+    for row in (0..point.row + 1).rev() {
+        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_empty_line = false;
+        }
+
+        found_non_empty_line |= !empty;
+    }
+
+    DisplayPoint::zero()
+}
+
+/// Returns a position of the end of the current paragraph for vim motions,
+/// where a paragraph is defined as a run of non-empty lines. Lines containing
+/// only whitespace are not considered empty and do not act as paragraph
+/// boundaries.
+pub(crate) fn end_of_paragraph(
+    map: &DisplaySnapshot,
+    display_point: DisplayPoint,
+    mut count: usize,
+) -> DisplayPoint {
+    let point = display_point.to_point(map);
+    if point.row == map.buffer_snapshot().max_row().0 {
+        return map.max_point();
+    }
+
+    let mut found_non_empty_line = false;
+    for row in point.row..=map.buffer_snapshot().max_row().0 {
+        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_empty_line = false;
+        }
+
+        found_non_empty_line |= !empty;
+    }
+
+    map.max_point()
+}
+
 fn next_non_blank(map: &DisplaySnapshot, start: MultiBufferOffset) -> MultiBufferOffset {
     for (c, o) in map.buffer_chars_at(start) {
         if c == '\n' || !c.is_whitespace() {