vim: Fix subword motion near end of line (#45908)

Lionel Henry and dino created

Fixes subword motion incorrectly jumping to next line when near end of
line. Updates boundary detection to use exclusive boundaries with
need_next_char parameter, matching regular word motion behavior.
Refactors word and subword motion to share boundary detection logic.

Closes #17780

Release Notes:

- Fixed subword motion incorrectly jumping to the next line when near
the end of a line

---------

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

Change summary

crates/vim/src/motion.rs | 127 ++++++++++++++++++++---------------------
crates/vim/src/normal.rs |  13 ++++
2 files changed, 75 insertions(+), 65 deletions(-)

Detailed changes

crates/vim/src/motion.rs 🔗

@@ -1715,18 +1715,14 @@ pub(crate) fn next_word_start(
     point
 }
 
-pub(crate) fn next_word_end(
+fn next_end_impl(
     map: &DisplaySnapshot,
     mut point: DisplayPoint,
-    ignore_punctuation: bool,
     times: usize,
     allow_cross_newline: bool,
     always_advance: bool,
+    mut is_boundary: impl FnMut(char, char) -> bool,
 ) -> DisplayPoint {
-    let classifier = map
-        .buffer_snapshot()
-        .char_classifier_at(point.to_point(map))
-        .ignore_punctuation(ignore_punctuation);
     for _ in 0..times {
         let mut need_next_char = false;
         let new_point = if always_advance {
@@ -1739,8 +1735,6 @@ pub(crate) fn next_word_end(
             new_point,
             FindRange::MultiLine,
             |left, right| {
-                let left_kind = classifier.kind(left);
-                let right_kind = classifier.kind(right);
                 let at_newline = right == '\n';
 
                 if !allow_cross_newline && at_newline {
@@ -1748,7 +1742,7 @@ pub(crate) fn next_word_end(
                     return true;
                 }
 
-                left_kind != right_kind && left_kind != CharKind::Whitespace
+                is_boundary(left, right)
             },
         );
         let new_point = if need_next_char {
@@ -1765,6 +1759,64 @@ pub(crate) fn next_word_end(
     point
 }
 
+pub(crate) fn next_word_end(
+    map: &DisplaySnapshot,
+    point: DisplayPoint,
+    ignore_punctuation: bool,
+    times: usize,
+    allow_cross_newline: bool,
+    always_advance: bool,
+) -> DisplayPoint {
+    let classifier = map
+        .buffer_snapshot()
+        .char_classifier_at(point.to_point(map))
+        .ignore_punctuation(ignore_punctuation);
+
+    next_end_impl(
+        map,
+        point,
+        times,
+        allow_cross_newline,
+        always_advance,
+        |left, right| {
+            let left_kind = classifier.kind(left);
+            let right_kind = classifier.kind(right);
+            left_kind != right_kind && left_kind != CharKind::Whitespace
+        },
+    )
+}
+
+pub(crate) fn next_subword_end(
+    map: &DisplaySnapshot,
+    point: DisplayPoint,
+    ignore_punctuation: bool,
+    times: usize,
+    allow_cross_newline: bool,
+) -> DisplayPoint {
+    let classifier = map
+        .buffer_snapshot()
+        .char_classifier_at(point.to_point(map))
+        .ignore_punctuation(ignore_punctuation);
+
+    next_end_impl(
+        map,
+        point,
+        times,
+        allow_cross_newline,
+        true,
+        |left, right| {
+            let left_kind = classifier.kind(left);
+            let right_kind = classifier.kind(right);
+            let is_stopping_punct = |c: char| ".\"'{}[]()<>".contains(c);
+            let found_subword_end = is_subword_end(left, right, "_-");
+            let is_word_end = (left_kind != right_kind)
+                && (!left.is_ascii_punctuation() || is_stopping_punct(left));
+
+            !left.is_whitespace() && (is_word_end || found_subword_end)
+        },
+    )
+}
+
 fn previous_word_start(
     map: &DisplaySnapshot,
     mut point: DisplayPoint,
@@ -1870,11 +1922,10 @@ fn next_subword_start(
             let left_kind = classifier.kind(left);
             let right_kind = classifier.kind(right);
             let at_newline = right == '\n';
-
             let is_stopping_punct = |c: char| "\"'{}[]()<>".contains(c);
+            let found_subword_start = is_subword_start(left, right, "._-");
             let is_word_start = (left_kind != right_kind)
                 && (!right.is_ascii_punctuation() || is_stopping_punct(right));
-            let found_subword_start = is_subword_start(left, right, "._-");
             let found = (!right.is_whitespace() && (is_word_start || found_subword_start))
                 || at_newline && crossed_newline
                 || at_newline && left == '\n'; // Prevents skipping repeated empty lines
@@ -1890,60 +1941,6 @@ fn next_subword_start(
     point
 }
 
-pub(crate) fn next_subword_end(
-    map: &DisplaySnapshot,
-    mut point: DisplayPoint,
-    ignore_punctuation: bool,
-    times: usize,
-    allow_cross_newline: bool,
-) -> DisplayPoint {
-    let classifier = map
-        .buffer_snapshot()
-        .char_classifier_at(point.to_point(map))
-        .ignore_punctuation(ignore_punctuation);
-    for _ in 0..times {
-        let new_point = next_char(map, point, allow_cross_newline);
-
-        let mut crossed_newline = false;
-        let mut need_backtrack = false;
-        let new_point =
-            movement::find_boundary(map, new_point, FindRange::MultiLine, |left, right| {
-                let left_kind = classifier.kind(left);
-                let right_kind = classifier.kind(right);
-                let at_newline = right == '\n';
-
-                if !allow_cross_newline && at_newline {
-                    return true;
-                }
-
-                let is_stopping_punct = |c: char| ".\"'{}[]()<>".contains(c);
-                let is_word_end = (left_kind != right_kind)
-                    && (!left.is_ascii_punctuation() || is_stopping_punct(left));
-                let found_subword_end = is_subword_end(left, right, "_-");
-
-                let found =
-                    !left.is_whitespace() && !at_newline && (is_word_end || found_subword_end);
-
-                if found {
-                    need_backtrack = true;
-                }
-
-                crossed_newline |= at_newline;
-                found
-            });
-        let mut new_point = map.clip_point(new_point, Bias::Left);
-        if need_backtrack {
-            *new_point.column_mut() -= 1;
-        }
-        let new_point = map.clip_point(new_point, Bias::Left);
-        if point == new_point {
-            break;
-        }
-        point = new_point;
-    }
-    point
-}
-
 fn previous_subword_start(
     map: &DisplaySnapshot,
     mut point: DisplayPoint,

crates/vim/src/normal.rs 🔗

@@ -1950,6 +1950,19 @@ mod test {
 
         cx.assert_binding_normal("e", indoc! {"ˇassert_binding"}, indoc! {"asserˇt_binding"});
 
+        // Subword end should stop at EOL
+        cx.assert_binding_normal("e", indoc! {"foo_bˇar\nbaz"}, indoc! {"foo_baˇr\nbaz"});
+
+        // Already at subword end, should move to next subword on next line
+        cx.assert_binding_normal(
+            "e",
+            indoc! {"foo_barˇ\nbaz_qux"},
+            indoc! {"foo_bar\nbaˇz_qux"},
+        );
+
+        // CamelCase at EOL
+        cx.assert_binding_normal("e", indoc! {"fooˇBar\nbaz"}, indoc! {"fooBaˇr\nbaz"});
+
         cx.assert_binding_normal("b", indoc! {"assert_ˇbinding"}, indoc! {"ˇassert_binding"});
 
         cx.assert_binding_normal(