change.rs

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