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

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>
This commit is contained in:
lex00 2026-01-27 06:33:53 -07:00 committed by GitHub
parent 757ee0571e
commit 9ecafe1960
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 40 additions and 12 deletions

View file

@ -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()

View file

@ -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;

View file

@ -0,0 +1,5 @@
{"Put":{"state":"ˇfirst\n \nstill first\n\nsecond"}}
{"Key":"}"}
{"Get":{"state":"first\n \nstill first\nˇ\nsecond","mode":"Normal"}}
{"Key":"{"}
{"Get":{"state":"ˇfirst\n \nstill first\n\nsecond","mode":"Normal"}}