change.rs

  1use crate::{
  2    Vim,
  3    motion::{self, Motion, MotionKind},
  4    object::Object,
  5    state::Mode,
  6};
  7use editor::{
  8    Bias, DisplayPoint,
  9    display_map::{DisplaySnapshot, ToDisplayPoint},
 10    movement::TextLayoutDetails,
 11};
 12use gpui::{Context, Window};
 13use language::Selection;
 14
 15impl Vim {
 16    pub fn change_motion(
 17        &mut self,
 18        motion: Motion,
 19        times: Option<usize>,
 20        forced_motion: bool,
 21        window: &mut Window,
 22        cx: &mut Context<Self>,
 23    ) {
 24        // Some motions ignore failure when switching to normal mode
 25        let mut motion_kind = if matches!(
 26            motion,
 27            Motion::Left
 28                | Motion::Right
 29                | Motion::EndOfLine { .. }
 30                | Motion::WrappingLeft
 31                | Motion::StartOfLine { .. }
 32        ) {
 33            Some(MotionKind::Exclusive)
 34        } else {
 35            None
 36        };
 37        self.update_editor(cx, |vim, editor, cx| {
 38            let text_layout_details = editor.text_layout_details(window, cx);
 39            editor.transact(window, cx, |editor, window, cx| {
 40                // We are swapping to insert mode anyway. Just set the line end clipping behavior now
 41                editor.set_clip_at_line_ends(false, cx);
 42                editor.change_selections(Default::default(), window, cx, |s| {
 43                    s.move_with(&mut |map, selection| {
 44                        let kind = match motion {
 45                            Motion::NextWordStart { ignore_punctuation }
 46                            | Motion::NextSubwordStart { ignore_punctuation } => {
 47                                expand_changed_word_selection(
 48                                    map,
 49                                    selection,
 50                                    times,
 51                                    ignore_punctuation,
 52                                    &text_layout_details,
 53                                    motion == Motion::NextSubwordStart { ignore_punctuation },
 54                                    !matches!(motion, Motion::NextWordStart { .. }),
 55                                )
 56                            }
 57                            _ => {
 58                                let kind = motion.expand_selection(
 59                                    map,
 60                                    selection,
 61                                    times,
 62                                    &text_layout_details,
 63                                    forced_motion,
 64                                );
 65                                if matches!(
 66                                    motion,
 67                                    Motion::CurrentLine | Motion::Down { .. } | Motion::Up { .. }
 68                                ) {
 69                                    let mut start_offset =
 70                                        selection.start.to_offset(map, Bias::Left);
 71                                    let classifier = map
 72                                        .buffer_snapshot()
 73                                        .char_classifier_at(selection.start.to_point(map));
 74                                    for (ch, offset) in map.buffer_chars_at(start_offset) {
 75                                        if ch == '\n' || !classifier.is_whitespace(ch) {
 76                                            break;
 77                                        }
 78                                        start_offset = offset + ch.len_utf8();
 79                                    }
 80                                    selection.start = start_offset.to_display_point(map);
 81                                }
 82                                kind
 83                            }
 84                        };
 85                        if let Some(kind) = kind {
 86                            motion_kind.get_or_insert(kind);
 87                        }
 88                    });
 89                });
 90                if let Some(kind) = motion_kind {
 91                    vim.copy_selections_content(editor, kind, window, cx);
 92                    editor.delete_selections_with_linked_edits(window, cx);
 93                    editor.refresh_edit_prediction(true, false, window, cx);
 94                }
 95            });
 96        });
 97
 98        if motion_kind.is_some() {
 99            self.switch_mode(Mode::Insert, false, window, cx)
100        } else {
101            self.switch_mode(Mode::Normal, false, window, cx)
102        }
103    }
104
105    pub fn change_object(
106        &mut self,
107        object: Object,
108        around: bool,
109        times: Option<usize>,
110        window: &mut Window,
111        cx: &mut Context<Self>,
112    ) {
113        let mut objects_found = false;
114        self.update_editor(cx, |vim, editor, cx| {
115            // We are swapping to insert mode anyway. Just set the line end clipping behavior now
116            editor.set_clip_at_line_ends(false, cx);
117            editor.transact(window, cx, |editor, window, cx| {
118                editor.change_selections(Default::default(), window, cx, |s| {
119                    s.move_with(&mut |map, selection| {
120                        objects_found |= object.expand_selection(map, selection, around, times);
121                    });
122                });
123                if objects_found {
124                    let kind = match object.target_visual_mode(vim.mode, around) {
125                        Mode::VisualLine => MotionKind::Linewise,
126                        _ => MotionKind::Exclusive,
127                    };
128                    vim.copy_selections_content(editor, kind, window, cx);
129                    editor.delete_selections_with_linked_edits(window, cx);
130                    editor.refresh_edit_prediction(true, false, window, cx);
131                }
132            });
133        });
134
135        if objects_found {
136            self.switch_mode(Mode::Insert, false, window, cx);
137        } else {
138            self.switch_mode(Mode::Normal, false, window, cx);
139        }
140    }
141}
142
143// From the docs https://vimdoc.sourceforge.net/htmldoc/motion.html
144// Special case: "cw" and "cW" are treated like "ce" and "cE" if the cursor is
145// on a non-blank.  This is because "cw" is interpreted as change-word, and a
146// word does not include the following white space.  {Vi: "cw" when on a blank
147// followed by other blanks changes only the first blank; this is probably a
148// bug, because "dw" deletes all the blanks}
149fn expand_changed_word_selection(
150    map: &DisplaySnapshot,
151    selection: &mut Selection<DisplayPoint>,
152    times: Option<usize>,
153    ignore_punctuation: bool,
154    text_layout_details: &TextLayoutDetails,
155    use_subword: bool,
156    always_advance: bool,
157) -> Option<MotionKind> {
158    let is_in_word = || {
159        let classifier = map
160            .buffer_snapshot()
161            .char_classifier_at(selection.start.to_point(map));
162
163        map.buffer_chars_at(selection.head().to_offset(map, Bias::Left))
164            .next()
165            .map(|(c, _)| !classifier.is_whitespace(c))
166            .unwrap_or_default()
167    };
168    if (times.is_none() || times.unwrap() == 1) && is_in_word() {
169        let next_char = map
170            .buffer_chars_at(
171                motion::next_char(map, selection.end, false).to_offset(map, Bias::Left),
172            )
173            .next();
174        match next_char {
175            Some((' ', _)) => selection.end = motion::next_char(map, selection.end, false),
176            _ => {
177                if use_subword {
178                    selection.end =
179                        motion::next_subword_end(map, selection.end, ignore_punctuation, 1, false);
180                } else {
181                    selection.end = motion::next_word_end(
182                        map,
183                        selection.end,
184                        ignore_punctuation,
185                        1,
186                        false,
187                        always_advance,
188                    );
189                }
190                selection.end = motion::next_char(map, selection.end, false);
191            }
192        }
193        Some(MotionKind::Inclusive)
194    } else {
195        let motion = if use_subword {
196            Motion::NextSubwordStart { ignore_punctuation }
197        } else {
198            Motion::NextWordStart { ignore_punctuation }
199        };
200        motion.expand_selection(map, selection, times, text_layout_details, false)
201    }
202}
203
204#[cfg(test)]
205mod test {
206    use indoc::indoc;
207
208    use crate::state::Mode;
209    use crate::test::{NeovimBackedTestContext, VimTestContext};
210
211    #[gpui::test]
212    async fn test_change_h(cx: &mut gpui::TestAppContext) {
213        let mut cx = NeovimBackedTestContext::new(cx).await;
214        cx.simulate("c h", "Teˇst").await.assert_matches();
215        cx.simulate("c h", "Tˇest").await.assert_matches();
216        cx.simulate("c h", "ˇTest").await.assert_matches();
217        cx.simulate(
218            "c h",
219            indoc! {"
220            Test
221            ˇtest"},
222        )
223        .await
224        .assert_matches();
225    }
226
227    #[gpui::test]
228    async fn test_change_backspace(cx: &mut gpui::TestAppContext) {
229        let mut cx = NeovimBackedTestContext::new(cx).await;
230        cx.simulate("c backspace", "Teˇst").await.assert_matches();
231        cx.simulate("c backspace", "Tˇest").await.assert_matches();
232        cx.simulate("c backspace", "ˇTest").await.assert_matches();
233        cx.simulate(
234            "c backspace",
235            indoc! {"
236            Test
237            ˇtest"},
238        )
239        .await
240        .assert_matches();
241    }
242
243    #[gpui::test]
244    async fn test_change_l(cx: &mut gpui::TestAppContext) {
245        let mut cx = NeovimBackedTestContext::new(cx).await;
246        cx.simulate("c l", "Teˇst").await.assert_matches();
247        cx.simulate("c l", "Tesˇt").await.assert_matches();
248    }
249
250    #[gpui::test]
251    async fn test_change_w(cx: &mut gpui::TestAppContext) {
252        let mut cx = NeovimBackedTestContext::new(cx).await;
253        cx.simulate("c w", "Teˇst").await.assert_matches();
254        cx.simulate("c w", "Tˇest test").await.assert_matches();
255        cx.simulate("c w", "Testˇ  test").await.assert_matches();
256        cx.simulate("c w", "Tesˇt  test").await.assert_matches();
257        cx.simulate(
258            "c w",
259            indoc! {"
260                Test teˇst
261                test"},
262        )
263        .await
264        .assert_matches();
265        cx.simulate(
266            "c w",
267            indoc! {"
268                Test tesˇt
269                test"},
270        )
271        .await
272        .assert_matches();
273        cx.simulate(
274            "c w",
275            indoc! {"
276                Test test
277                ˇ
278                test"},
279        )
280        .await
281        .assert_matches();
282
283        cx.simulate("c shift-w", "Test teˇst-test test")
284            .await
285            .assert_matches();
286
287        // on last character of word, `cw` doesn't eat subsequent punctuation
288        // see https://github.com/zed-industries/zed/issues/35269
289        cx.simulate("c w", "tesˇt-test").await.assert_matches();
290    }
291
292    #[gpui::test]
293    async fn test_change_e(cx: &mut gpui::TestAppContext) {
294        let mut cx = NeovimBackedTestContext::new(cx).await;
295        cx.simulate("c e", "Teˇst Test").await.assert_matches();
296        cx.simulate("c e", "Tˇest test").await.assert_matches();
297        cx.simulate(
298            "c e",
299            indoc! {"
300                Test teˇst
301                test"},
302        )
303        .await
304        .assert_matches();
305        cx.simulate(
306            "c e",
307            indoc! {"
308                Test tesˇt
309                test"},
310        )
311        .await
312        .assert_matches();
313        cx.simulate(
314            "c e",
315            indoc! {"
316                Test test
317                ˇ
318                test"},
319        )
320        .await
321        .assert_matches();
322
323        cx.simulate("c shift-e", "Test teˇst-test test")
324            .await
325            .assert_matches();
326    }
327
328    #[gpui::test]
329    async fn test_change_b(cx: &mut gpui::TestAppContext) {
330        let mut cx = NeovimBackedTestContext::new(cx).await;
331        cx.simulate("c b", "Teˇst Test").await.assert_matches();
332        cx.simulate("c b", "Test ˇtest").await.assert_matches();
333        cx.simulate("c b", "Test1 test2 ˇtest3")
334            .await
335            .assert_matches();
336        cx.simulate(
337            "c b",
338            indoc! {"
339                Test test
340                ˇtest"},
341        )
342        .await
343        .assert_matches();
344        cx.simulate(
345            "c b",
346            indoc! {"
347                Test test
348                ˇ
349                test"},
350        )
351        .await
352        .assert_matches();
353
354        cx.simulate("c shift-b", "Test test-test ˇtest")
355            .await
356            .assert_matches();
357    }
358
359    #[gpui::test]
360    async fn test_change_end_of_line(cx: &mut gpui::TestAppContext) {
361        let mut cx = NeovimBackedTestContext::new(cx).await;
362        cx.simulate(
363            "c $",
364            indoc! {"
365            The qˇuick
366            brown fox"},
367        )
368        .await
369        .assert_matches();
370        cx.simulate(
371            "c $",
372            indoc! {"
373            The quick
374            ˇ
375            brown fox"},
376        )
377        .await
378        .assert_matches();
379    }
380
381    #[gpui::test]
382    async fn test_change_0(cx: &mut gpui::TestAppContext) {
383        let mut cx = NeovimBackedTestContext::new(cx).await;
384
385        cx.simulate(
386            "c 0",
387            indoc! {"
388            The qˇuick
389            brown fox"},
390        )
391        .await
392        .assert_matches();
393        cx.simulate(
394            "c 0",
395            indoc! {"
396            The quick
397            ˇ
398            brown fox"},
399        )
400        .await
401        .assert_matches();
402    }
403
404    #[gpui::test]
405    async fn test_change_k(cx: &mut gpui::TestAppContext) {
406        let mut cx = NeovimBackedTestContext::new(cx).await;
407
408        cx.simulate(
409            "c k",
410            indoc! {"
411            The quick
412            brown ˇfox
413            jumps over"},
414        )
415        .await
416        .assert_matches();
417        cx.simulate(
418            "c k",
419            indoc! {"
420            The quick
421            brown fox
422            jumps ˇover"},
423        )
424        .await
425        .assert_matches();
426        cx.simulate(
427            "c k",
428            indoc! {"
429            The qˇuick
430            brown fox
431            jumps over"},
432        )
433        .await
434        .assert_matches();
435        cx.simulate(
436            "c k",
437            indoc! {"
438            ˇ
439            brown fox
440            jumps over"},
441        )
442        .await
443        .assert_matches();
444        cx.simulate(
445            "c k",
446            indoc! {"
447            The quick
448              brown fox
449              ˇjumps over"},
450        )
451        .await
452        .assert_matches();
453    }
454
455    #[gpui::test]
456    async fn test_change_j(cx: &mut gpui::TestAppContext) {
457        let mut cx = NeovimBackedTestContext::new(cx).await;
458        cx.simulate(
459            "c j",
460            indoc! {"
461            The quick
462            brown ˇfox
463            jumps over"},
464        )
465        .await
466        .assert_matches();
467        cx.simulate(
468            "c j",
469            indoc! {"
470            The quick
471            brown fox
472            jumps ˇover"},
473        )
474        .await
475        .assert_matches();
476        cx.simulate(
477            "c j",
478            indoc! {"
479            The qˇuick
480            brown fox
481            jumps over"},
482        )
483        .await
484        .assert_matches();
485        cx.simulate(
486            "c j",
487            indoc! {"
488            The quick
489            brown fox
490            ˇ"},
491        )
492        .await
493        .assert_matches();
494        cx.simulate(
495            "c j",
496            indoc! {"
497            The quick
498              ˇbrown fox
499              jumps over"},
500        )
501        .await
502        .assert_matches();
503    }
504
505    #[gpui::test]
506    async fn test_change_end_of_document(cx: &mut gpui::TestAppContext) {
507        let mut cx = NeovimBackedTestContext::new(cx).await;
508        cx.simulate(
509            "c shift-g",
510            indoc! {"
511            The quick
512            brownˇ fox
513            jumps over
514            the lazy"},
515        )
516        .await
517        .assert_matches();
518        cx.simulate(
519            "c shift-g",
520            indoc! {"
521            The quick
522            brownˇ fox
523            jumps over
524            the lazy"},
525        )
526        .await
527        .assert_matches();
528        cx.simulate(
529            "c shift-g",
530            indoc! {"
531            The quick
532            brown fox
533            jumps over
534            the lˇazy"},
535        )
536        .await
537        .assert_matches();
538        cx.simulate(
539            "c shift-g",
540            indoc! {"
541            The quick
542            brown fox
543            jumps over
544            ˇ"},
545        )
546        .await
547        .assert_matches();
548    }
549
550    #[gpui::test]
551    async fn test_change_cc(cx: &mut gpui::TestAppContext) {
552        let mut cx = NeovimBackedTestContext::new(cx).await;
553        cx.simulate(
554            "c c",
555            indoc! {"
556           The quick
557             brownˇ fox
558           jumps over
559           the lazy"},
560        )
561        .await
562        .assert_matches();
563
564        cx.simulate(
565            "c c",
566            indoc! {"
567           ˇThe quick
568           brown fox
569           jumps over
570           the lazy"},
571        )
572        .await
573        .assert_matches();
574
575        cx.simulate(
576            "c c",
577            indoc! {"
578           The quick
579             broˇwn fox
580           jumps over
581           the lazy"},
582        )
583        .await
584        .assert_matches();
585    }
586
587    #[gpui::test]
588    async fn test_change_gg(cx: &mut gpui::TestAppContext) {
589        let mut cx = NeovimBackedTestContext::new(cx).await;
590        cx.simulate(
591            "c g g",
592            indoc! {"
593            The quick
594            brownˇ fox
595            jumps over
596            the lazy"},
597        )
598        .await
599        .assert_matches();
600        cx.simulate(
601            "c g g",
602            indoc! {"
603            The quick
604            brown fox
605            jumps over
606            the lˇazy"},
607        )
608        .await
609        .assert_matches();
610        cx.simulate(
611            "c g g",
612            indoc! {"
613            The qˇuick
614            brown fox
615            jumps over
616            the lazy"},
617        )
618        .await
619        .assert_matches();
620        cx.simulate(
621            "c g g",
622            indoc! {"
623            ˇ
624            brown fox
625            jumps over
626            the lazy"},
627        )
628        .await
629        .assert_matches();
630    }
631
632    #[gpui::test]
633    async fn test_repeated_cj(cx: &mut gpui::TestAppContext) {
634        let mut cx = NeovimBackedTestContext::new(cx).await;
635
636        for count in 1..=5 {
637            cx.simulate_at_each_offset(
638                &format!("c {count} j"),
639                indoc! {"
640                    ˇThe quˇickˇ browˇn
641                    ˇ
642                    ˇfox ˇjumpsˇ-ˇoˇver
643                    ˇthe lazy dog
644                    "},
645            )
646            .await
647            .assert_matches();
648        }
649    }
650
651    #[gpui::test]
652    async fn test_repeated_cl(cx: &mut gpui::TestAppContext) {
653        let mut cx = NeovimBackedTestContext::new(cx).await;
654
655        for count in 1..=5 {
656            cx.simulate_at_each_offset(
657                &format!("c {count} l"),
658                indoc! {"
659                    ˇThe quˇickˇ browˇn
660                    ˇ
661                    ˇfox ˇjumpsˇ-ˇoˇver
662                    ˇthe lazy dog
663                    "},
664            )
665            .await
666            .assert_matches();
667        }
668    }
669
670    #[gpui::test]
671    async fn test_repeated_cb(cx: &mut gpui::TestAppContext) {
672        let mut cx = NeovimBackedTestContext::new(cx).await;
673
674        for count in 1..=5 {
675            cx.simulate_at_each_offset(
676                &format!("c {count} b"),
677                indoc! {"
678                ˇThe quˇickˇ browˇn
679                ˇ
680                ˇfox ˇjumpsˇ-ˇoˇver
681                ˇthe lazy dog
682                "},
683            )
684            .await
685            .assert_matches()
686        }
687    }
688
689    #[gpui::test]
690    async fn test_repeated_ce(cx: &mut gpui::TestAppContext) {
691        let mut cx = NeovimBackedTestContext::new(cx).await;
692
693        for count in 1..=5 {
694            cx.simulate_at_each_offset(
695                &format!("c {count} e"),
696                indoc! {"
697                    ˇThe quˇickˇ browˇn
698                    ˇ
699                    ˇfox ˇjumpsˇ-ˇoˇver
700                    ˇthe lazy dog
701                    "},
702            )
703            .await
704            .assert_matches();
705        }
706    }
707
708    #[gpui::test]
709    async fn test_change_with_selection_spanning_expanded_diff_hunk(cx: &mut gpui::TestAppContext) {
710        let mut cx = VimTestContext::new(cx, true).await;
711
712        let diff_base = indoc! {"
713            fn main() {
714                println!(\"old\");
715            }
716        "};
717
718        cx.set_state(
719            indoc! {"
720                fn main() {
721                    ˇprintln!(\"new\");
722                }
723            "},
724            Mode::Normal,
725        );
726        cx.set_head_text(diff_base);
727        cx.update_editor(|editor, window, cx| {
728            editor.expand_all_diff_hunks(&editor::actions::ExpandAllDiffHunks, window, cx);
729        });
730
731        // Enter visual mode and move up so the selection spans from the
732        // insertion (current line) into the deletion (diff base line).
733        // Then press `c` which in visual mode dispatches `vim::Substitute`,
734        // performing the change operation across the insertion/deletion boundary.
735        cx.simulate_keystrokes("v k c");
736    }
737}