vim: Fix space forward bug with non-ASCII characters at EOL (#27860)

Peter Finn and KyleBarton created

Closes https://github.com/zed-industries/zed/issues/27619

Fixes issue with right wrapped movement when a multi-byte character is
at the end of the line. This is done by grabbing the last character on
the current row and using that characters size to calculate the
`max_column` variable, which is used to decide if the next right
movement should move down the line or not.

We did notice a bit of code that could be an issue that we wanted to
call out.
[Here](https://github.com/zed-industries/zed/blob/main/crates/editor/src/display_map.rs#L1070)
inside of `clip_at_line_end` it also does a saturating_sub(1), assuming
a single byte character. We didn't run into any issues due to this line
but felt like a similar bug. We can apply a similar fix if wanted to
pose the question first.

Test case: Moving to next line when eol is a multi-byte character


https://github.com/user-attachments/assets/1021ab1f-f49d-4986-8f9a-8cfc7e5c91bc


Release Notes:

- Fixed issue in vim forward spacing when a multi-byte character is at
the eol

---------

Co-authored-by: KyleBarton <kjbarton4@gmail.com>

Change summary

crates/vim/src/motion.rs                               | 49 +++++++++--
crates/vim/test_data/test_backspace_non_ascii_bol.json |  4 
crates/vim/test_data/test_space_non_ascii_eol.json     |  4 
crates/vim/test_data/test_space_only_ascii_eol.json    |  4 
4 files changed, 52 insertions(+), 9 deletions(-)

Detailed changes

crates/vim/src/motion.rs 🔗

@@ -1273,16 +1273,19 @@ fn wrapping_right(map: &DisplaySnapshot, mut point: DisplayPoint, times: usize)
     point
 }
 
-fn wrapping_right_single(map: &DisplaySnapshot, mut point: DisplayPoint) -> DisplayPoint {
-    let max_column = map.line_len(point.row()).saturating_sub(1);
-    if point.column() < max_column {
-        *point.column_mut() += 1;
-        point = map.clip_point(point, Bias::Right);
-    } else if point.row() < map.max_point().row() {
-        *point.row_mut() += 1;
-        *point.column_mut() = 0;
+fn wrapping_right_single(map: &DisplaySnapshot, point: DisplayPoint) -> DisplayPoint {
+    let mut next_point = point;
+    *next_point.column_mut() += 1;
+    next_point = map.clip_point(next_point, Bias::Right);
+    if next_point == point {
+        if next_point.row() == map.max_point().row() {
+            next_point
+        } else {
+            DisplayPoint::new(next_point.row().next_row(), 0)
+        }
+    } else {
+        next_point
     }
-    point
 }
 
 pub(crate) fn start_of_relative_buffer_row(
@@ -3563,4 +3566,32 @@ mod test {
         cx.simulate_shared_keystrokes("3 space").await;
         cx.shared_state().await.assert_eq("πππˇππ");
     }
+
+    #[gpui::test]
+    async fn test_space_non_ascii_eol(cx: &mut gpui::TestAppContext) {
+        let mut cx = NeovimBackedTestContext::new(cx).await;
+
+        cx.set_shared_state(indoc! {"
+            ππππˇπ
+            πanotherline"})
+            .await;
+        cx.simulate_shared_keystrokes("4 space").await;
+        cx.shared_state().await.assert_eq(indoc! {"
+            πππππ
+            πanˇotherline"});
+    }
+
+    #[gpui::test]
+    async fn test_backspace_non_ascii_bol(cx: &mut gpui::TestAppContext) {
+        let mut cx = NeovimBackedTestContext::new(cx).await;
+
+        cx.set_shared_state(indoc! {"
+                        ππππ
+                        πanˇotherline"})
+            .await;
+        cx.simulate_shared_keystrokes("4 backspace").await;
+        cx.shared_state().await.assert_eq(indoc! {"
+                        πππˇπ
+                        πanotherline"});
+    }
 }