delete.rs

  1use crate::{
  2    ObjectScope, Vim,
  3    motion::{Motion, MotionKind},
  4    object::Object,
  5    state::Mode,
  6};
  7use collections::{HashMap, HashSet};
  8use editor::{
  9    Bias, DisplayPoint,
 10    display_map::{DisplaySnapshot, ToDisplayPoint},
 11};
 12use gpui::{Context, Window};
 13use language::{Point, Selection};
 14use multi_buffer::MultiBufferRow;
 15
 16impl Vim {
 17    pub fn delete_motion(
 18        &mut self,
 19        motion: Motion,
 20        times: Option<usize>,
 21        forced_motion: bool,
 22        window: &mut Window,
 23        cx: &mut Context<Self>,
 24    ) {
 25        self.stop_recording(cx);
 26        self.update_editor(cx, |vim, editor, cx| {
 27            let text_layout_details = editor.text_layout_details(window);
 28            editor.transact(window, cx, |editor, window, cx| {
 29                editor.set_clip_at_line_ends(false, cx);
 30                let mut original_columns: HashMap<_, _> = Default::default();
 31                let mut motion_kind = None;
 32                let mut ranges_to_copy = Vec::new();
 33                editor.change_selections(Default::default(), window, cx, |s| {
 34                    s.move_with(|map, selection| {
 35                        let original_head = selection.head();
 36                        original_columns.insert(selection.id, original_head.column());
 37                        let kind = motion.expand_selection(
 38                            map,
 39                            selection,
 40                            times,
 41                            &text_layout_details,
 42                            forced_motion,
 43                        );
 44                        ranges_to_copy
 45                            .push(selection.start.to_point(map)..selection.end.to_point(map));
 46
 47                        // When deleting line-wise, we always want to delete a newline.
 48                        // If there is one after the current line, it goes; otherwise we
 49                        // pick the one before.
 50                        if kind == Some(MotionKind::Linewise) {
 51                            let start = selection.start.to_point(map);
 52                            let end = selection.end.to_point(map);
 53                            if end.row < map.buffer_snapshot().max_point().row {
 54                                selection.end = Point::new(end.row + 1, 0).to_display_point(map)
 55                            } else if start.row > 0 {
 56                                selection.start = Point::new(
 57                                    start.row - 1,
 58                                    map.buffer_snapshot()
 59                                        .line_len(MultiBufferRow(start.row - 1)),
 60                                )
 61                                .to_display_point(map)
 62                            }
 63                        }
 64                        if let Some(kind) = kind {
 65                            motion_kind.get_or_insert(kind);
 66                        }
 67                    });
 68                });
 69                let Some(kind) = motion_kind else { return };
 70                vim.copy_ranges(editor, kind, false, ranges_to_copy, window, cx);
 71                editor.insert("", window, cx);
 72
 73                // Fixup cursor position after the deletion
 74                editor.set_clip_at_line_ends(true, cx);
 75                editor.change_selections(Default::default(), window, cx, |s| {
 76                    s.move_with(|map, selection| {
 77                        let mut cursor = selection.head();
 78                        if kind.linewise()
 79                            && let Some(column) = original_columns.get(&selection.id)
 80                        {
 81                            *cursor.column_mut() = *column
 82                        }
 83                        cursor = map.clip_point(cursor, Bias::Left);
 84                        selection.collapse_to(cursor, selection.goal)
 85                    });
 86                });
 87                editor.refresh_edit_prediction(true, false, window, cx);
 88            });
 89        });
 90    }
 91
 92    pub fn delete_object(
 93        &mut self,
 94        object: Object,
 95        scope: ObjectScope,
 96        times: Option<usize>,
 97        window: &mut Window,
 98        cx: &mut Context<Self>,
 99    ) {
100        self.stop_recording(cx);
101        self.update_editor(cx, |vim, editor, cx| {
102            editor.transact(window, cx, |editor, window, cx| {
103                editor.set_clip_at_line_ends(false, cx);
104                // Emulates behavior in vim where if we expanded backwards to include a newline
105                // the cursor gets set back to the start of the line
106                let mut should_move_to_start: HashSet<_> = Default::default();
107
108                // Emulates behavior in vim where after deletion the cursor should try to move
109                // to the same column it was before deletion if the line is not empty or only
110                // contains whitespace
111                let mut column_before_move: HashMap<_, _> = Default::default();
112                let target_mode = object.target_visual_mode(vim.mode, &scope);
113
114                editor.change_selections(Default::default(), window, cx, |s| {
115                    s.move_with(|map, selection| {
116                        let cursor_point = selection.head().to_point(map);
117                        if target_mode == Mode::VisualLine {
118                            column_before_move.insert(selection.id, cursor_point.column);
119                        }
120
121                        object.expand_selection(map, selection, &scope, times);
122                        let offset_range = selection.map(|p| p.to_offset(map, Bias::Left)).range();
123                        let mut move_selection_start_to_previous_line =
124                            |map: &DisplaySnapshot, selection: &mut Selection<DisplayPoint>| {
125                                let start = selection.start.to_offset(map, Bias::Left);
126                                if selection.start.row().0 > 0 {
127                                    should_move_to_start.insert(selection.id);
128                                    selection.start =
129                                        (start - '\n'.len_utf8()).to_display_point(map);
130                                }
131                            };
132                        let range = selection.start.to_offset(map, Bias::Left)
133                            ..selection.end.to_offset(map, Bias::Right);
134                        let contains_only_newlines = map
135                            .buffer_chars_at(range.start)
136                            .take_while(|(_, p)| p < &range.end)
137                            .all(|(char, _)| char == '\n')
138                            && !offset_range.is_empty();
139                        let end_at_newline = map
140                            .buffer_chars_at(range.end)
141                            .next()
142                            .map(|(c, _)| c == '\n')
143                            .unwrap_or(false);
144
145                        // If expanded range contains only newlines and
146                        // the object is around or sentence, expand to include a newline
147                        // at the end or start
148                        if (matches!(scope, ObjectScope::AroundTrimmed | ObjectScope::Around)
149                            || object == Object::Sentence)
150                            && contains_only_newlines
151                        {
152                            if end_at_newline {
153                                move_selection_end_to_next_line(map, selection);
154                            } else {
155                                move_selection_start_to_previous_line(map, selection);
156                            }
157                        }
158
159                        // Does post-processing for the trailing newline and EOF
160                        // when not cancelled.
161                        let cancelled =
162                            matches!(scope, ObjectScope::AroundTrimmed | ObjectScope::Around)
163                                && selection.start == selection.end;
164                        if object == Object::Paragraph && !cancelled {
165                            // EOF check should be done before including a trailing newline.
166                            if ends_at_eof(map, selection) {
167                                move_selection_start_to_previous_line(map, selection);
168                            }
169
170                            if end_at_newline {
171                                move_selection_end_to_next_line(map, selection);
172                            }
173                        }
174                    });
175                });
176                vim.copy_selections_content(editor, MotionKind::Exclusive, window, cx);
177                editor.insert("", window, cx);
178
179                // Fixup cursor position after the deletion
180                editor.set_clip_at_line_ends(true, cx);
181                editor.change_selections(Default::default(), window, cx, |s| {
182                    s.move_with(|map, selection| {
183                        let mut cursor = selection.head();
184                        if should_move_to_start.contains(&selection.id) {
185                            *cursor.column_mut() = 0;
186                        } else if let Some(column) = column_before_move.get(&selection.id)
187                            && *column > 0
188                        {
189                            let mut cursor_point = cursor.to_point(map);
190                            cursor_point.column = *column;
191                            cursor = map
192                                .buffer_snapshot()
193                                .clip_point(cursor_point, Bias::Left)
194                                .to_display_point(map);
195                        }
196                        cursor = map.clip_point(cursor, Bias::Left);
197                        selection.collapse_to(cursor, selection.goal)
198                    });
199                });
200                editor.refresh_edit_prediction(true, false, window, cx);
201            });
202        });
203    }
204}
205
206fn move_selection_end_to_next_line(map: &DisplaySnapshot, selection: &mut Selection<DisplayPoint>) {
207    let end = selection.end.to_offset(map, Bias::Left);
208    selection.end = (end + '\n'.len_utf8()).to_display_point(map);
209}
210
211fn ends_at_eof(map: &DisplaySnapshot, selection: &mut Selection<DisplayPoint>) -> bool {
212    selection.end.to_point(map) == map.buffer_snapshot().max_point()
213}
214
215#[cfg(test)]
216mod test {
217    use indoc::indoc;
218
219    use crate::{
220        state::Mode,
221        test::{NeovimBackedTestContext, VimTestContext},
222    };
223
224    #[gpui::test]
225    async fn test_delete_h(cx: &mut gpui::TestAppContext) {
226        let mut cx = NeovimBackedTestContext::new(cx).await;
227        cx.simulate("d h", "Teˇst").await.assert_matches();
228        cx.simulate("d h", "Tˇest").await.assert_matches();
229        cx.simulate("d h", "ˇTest").await.assert_matches();
230        cx.simulate(
231            "d h",
232            indoc! {"
233            Test
234            ˇtest"},
235        )
236        .await
237        .assert_matches();
238    }
239
240    #[gpui::test]
241    async fn test_delete_l(cx: &mut gpui::TestAppContext) {
242        let mut cx = NeovimBackedTestContext::new(cx).await;
243        cx.simulate("d l", "ˇTest").await.assert_matches();
244        cx.simulate("d l", "Teˇst").await.assert_matches();
245        cx.simulate("d l", "Tesˇt").await.assert_matches();
246        cx.simulate(
247            "d l",
248            indoc! {"
249                Tesˇt
250                test"},
251        )
252        .await
253        .assert_matches();
254    }
255
256    #[gpui::test]
257    async fn test_delete_w(cx: &mut gpui::TestAppContext) {
258        let mut cx = NeovimBackedTestContext::new(cx).await;
259        cx.simulate(
260            "d w",
261            indoc! {"
262            Test tesˇt
263                test"},
264        )
265        .await
266        .assert_matches();
267
268        cx.simulate("d w", "Teˇst").await.assert_matches();
269        cx.simulate("d w", "Tˇest test").await.assert_matches();
270        cx.simulate(
271            "d w",
272            indoc! {"
273            Test teˇst
274            test"},
275        )
276        .await
277        .assert_matches();
278        cx.simulate(
279            "d w",
280            indoc! {"
281            Test tesˇt
282            test"},
283        )
284        .await
285        .assert_matches();
286
287        cx.simulate(
288            "d w",
289            indoc! {"
290            Test test
291            ˇ
292            test"},
293        )
294        .await
295        .assert_matches();
296
297        cx.simulate("d shift-w", "Test teˇst-test test")
298            .await
299            .assert_matches();
300    }
301
302    #[gpui::test]
303    async fn test_delete_next_word_end(cx: &mut gpui::TestAppContext) {
304        let mut cx = NeovimBackedTestContext::new(cx).await;
305        cx.simulate("d e", "Teˇst Test\n").await.assert_matches();
306        cx.simulate("d e", "Tˇest test\n").await.assert_matches();
307        cx.simulate(
308            "d e",
309            indoc! {"
310            Test teˇst
311            test"},
312        )
313        .await
314        .assert_matches();
315        cx.simulate(
316            "d e",
317            indoc! {"
318            Test tesˇt
319            test"},
320        )
321        .await
322        .assert_matches();
323
324        cx.simulate("d e", "Test teˇst-test test")
325            .await
326            .assert_matches();
327    }
328
329    #[gpui::test]
330    async fn test_delete_b(cx: &mut gpui::TestAppContext) {
331        let mut cx = NeovimBackedTestContext::new(cx).await;
332        cx.simulate("d b", "Teˇst Test").await.assert_matches();
333        cx.simulate("d b", "Test ˇtest").await.assert_matches();
334        cx.simulate("d b", "Test1 test2 ˇtest3")
335            .await
336            .assert_matches();
337        cx.simulate(
338            "d b",
339            indoc! {"
340            Test test
341            ˇtest"},
342        )
343        .await
344        .assert_matches();
345        cx.simulate(
346            "d b",
347            indoc! {"
348            Test test
349            ˇ
350            test"},
351        )
352        .await
353        .assert_matches();
354
355        cx.simulate("d shift-b", "Test test-test ˇtest")
356            .await
357            .assert_matches();
358    }
359
360    #[gpui::test]
361    async fn test_delete_end_of_line(cx: &mut gpui::TestAppContext) {
362        let mut cx = NeovimBackedTestContext::new(cx).await;
363        cx.simulate(
364            "d $",
365            indoc! {"
366            The qˇuick
367            brown fox"},
368        )
369        .await
370        .assert_matches();
371        cx.simulate(
372            "d $",
373            indoc! {"
374            The quick
375            ˇ
376            brown fox"},
377        )
378        .await
379        .assert_matches();
380    }
381
382    #[gpui::test]
383    async fn test_delete_end_of_paragraph(cx: &mut gpui::TestAppContext) {
384        let mut cx = NeovimBackedTestContext::new(cx).await;
385        cx.simulate(
386            "d }",
387            indoc! {"
388            ˇhello world.
389
390            hello world."},
391        )
392        .await
393        .assert_matches();
394
395        cx.simulate(
396            "d }",
397            indoc! {"
398            ˇhello world.
399            hello world."},
400        )
401        .await
402        .assert_matches();
403    }
404
405    #[gpui::test]
406    async fn test_delete_0(cx: &mut gpui::TestAppContext) {
407        let mut cx = NeovimBackedTestContext::new(cx).await;
408        cx.simulate(
409            "d 0",
410            indoc! {"
411            The qˇuick
412            brown fox"},
413        )
414        .await
415        .assert_matches();
416        cx.simulate(
417            "d 0",
418            indoc! {"
419            The quick
420            ˇ
421            brown fox"},
422        )
423        .await
424        .assert_matches();
425    }
426
427    #[gpui::test]
428    async fn test_delete_k(cx: &mut gpui::TestAppContext) {
429        let mut cx = NeovimBackedTestContext::new(cx).await;
430        cx.simulate(
431            "d k",
432            indoc! {"
433            The quick
434            brown ˇfox
435            jumps over"},
436        )
437        .await
438        .assert_matches();
439        cx.simulate(
440            "d k",
441            indoc! {"
442            The quick
443            brown fox
444            jumps ˇover"},
445        )
446        .await
447        .assert_matches();
448        cx.simulate(
449            "d k",
450            indoc! {"
451            The qˇuick
452            brown fox
453            jumps over"},
454        )
455        .await
456        .assert_matches();
457        cx.simulate(
458            "d k",
459            indoc! {"
460            ˇbrown fox
461            jumps over"},
462        )
463        .await
464        .assert_matches();
465    }
466
467    #[gpui::test]
468    async fn test_delete_j(cx: &mut gpui::TestAppContext) {
469        let mut cx = NeovimBackedTestContext::new(cx).await;
470        cx.simulate(
471            "d j",
472            indoc! {"
473            The quick
474            brown ˇfox
475            jumps over"},
476        )
477        .await
478        .assert_matches();
479        cx.simulate(
480            "d j",
481            indoc! {"
482            The quick
483            brown fox
484            jumps ˇover"},
485        )
486        .await
487        .assert_matches();
488        cx.simulate(
489            "d j",
490            indoc! {"
491            The qˇuick
492            brown fox
493            jumps over"},
494        )
495        .await
496        .assert_matches();
497        cx.simulate(
498            "d j",
499            indoc! {"
500            The quick
501            brown fox
502            ˇ"},
503        )
504        .await
505        .assert_matches();
506    }
507
508    #[gpui::test]
509    async fn test_delete_end_of_document(cx: &mut gpui::TestAppContext) {
510        let mut cx = NeovimBackedTestContext::new(cx).await;
511        cx.simulate(
512            "d shift-g",
513            indoc! {"
514            The quick
515            brownˇ fox
516            jumps over
517            the lazy"},
518        )
519        .await
520        .assert_matches();
521        cx.simulate(
522            "d shift-g",
523            indoc! {"
524            The quick
525            brownˇ fox
526            jumps over
527            the lazy"},
528        )
529        .await
530        .assert_matches();
531        cx.simulate(
532            "d shift-g",
533            indoc! {"
534            The quick
535            brown fox
536            jumps over
537            the lˇazy"},
538        )
539        .await
540        .assert_matches();
541        cx.simulate(
542            "d shift-g",
543            indoc! {"
544            The quick
545            brown fox
546            jumps over
547            ˇ"},
548        )
549        .await
550        .assert_matches();
551    }
552
553    #[gpui::test]
554    async fn test_delete_to_line(cx: &mut gpui::TestAppContext) {
555        let mut cx = NeovimBackedTestContext::new(cx).await;
556        cx.simulate(
557            "d 3 shift-g",
558            indoc! {"
559            The quick
560            brownˇ fox
561            jumps over
562            the lazy"},
563        )
564        .await
565        .assert_matches();
566        cx.simulate(
567            "d 3 shift-g",
568            indoc! {"
569            The quick
570            brown fox
571            jumps over
572            the lˇazy"},
573        )
574        .await
575        .assert_matches();
576        cx.simulate(
577            "d 2 shift-g",
578            indoc! {"
579            The quick
580            brown fox
581            jumps over
582            ˇ"},
583        )
584        .await
585        .assert_matches();
586    }
587
588    #[gpui::test]
589    async fn test_delete_gg(cx: &mut gpui::TestAppContext) {
590        let mut cx = NeovimBackedTestContext::new(cx).await;
591        cx.simulate(
592            "d g g",
593            indoc! {"
594            The quick
595            brownˇ fox
596            jumps over
597            the lazy"},
598        )
599        .await
600        .assert_matches();
601        cx.simulate(
602            "d g g",
603            indoc! {"
604            The quick
605            brown fox
606            jumps over
607            the lˇazy"},
608        )
609        .await
610        .assert_matches();
611        cx.simulate(
612            "d g g",
613            indoc! {"
614            The qˇuick
615            brown fox
616            jumps over
617            the lazy"},
618        )
619        .await
620        .assert_matches();
621        cx.simulate(
622            "d g g",
623            indoc! {"
624            ˇ
625            brown fox
626            jumps over
627            the lazy"},
628        )
629        .await
630        .assert_matches();
631    }
632
633    #[gpui::test]
634    async fn test_cancel_delete_operator(cx: &mut gpui::TestAppContext) {
635        let mut cx = VimTestContext::new(cx, true).await;
636        cx.set_state(
637            indoc! {"
638                The quick brown
639                fox juˇmps over
640                the lazy dog"},
641            Mode::Normal,
642        );
643
644        // Canceling operator twice reverts to normal mode with no active operator
645        cx.simulate_keystrokes("d escape k");
646        assert_eq!(cx.active_operator(), None);
647        assert_eq!(cx.mode(), Mode::Normal);
648        cx.assert_editor_state(indoc! {"
649            The quˇick brown
650            fox jumps over
651            the lazy dog"});
652    }
653
654    #[gpui::test]
655    async fn test_unbound_command_cancels_pending_operator(cx: &mut gpui::TestAppContext) {
656        let mut cx = VimTestContext::new(cx, true).await;
657        cx.set_state(
658            indoc! {"
659                The quick brown
660                fox juˇmps over
661                the lazy dog"},
662            Mode::Normal,
663        );
664
665        // Canceling operator twice reverts to normal mode with no active operator
666        cx.simulate_keystrokes("d y");
667        assert_eq!(cx.active_operator(), None);
668        assert_eq!(cx.mode(), Mode::Normal);
669    }
670
671    #[gpui::test]
672    async fn test_delete_with_counts(cx: &mut gpui::TestAppContext) {
673        let mut cx = NeovimBackedTestContext::new(cx).await;
674        cx.set_shared_state(indoc! {"
675                The ˇquick brown
676                fox jumps over
677                the lazy dog"})
678            .await;
679        cx.simulate_shared_keystrokes("d 2 d").await;
680        cx.shared_state().await.assert_eq(indoc! {"
681        the ˇlazy dog"});
682
683        cx.set_shared_state(indoc! {"
684                The ˇquick brown
685                fox jumps over
686                the lazy dog"})
687            .await;
688        cx.simulate_shared_keystrokes("2 d d").await;
689        cx.shared_state().await.assert_eq(indoc! {"
690        the ˇlazy dog"});
691
692        cx.set_shared_state(indoc! {"
693                The ˇquick brown
694                fox jumps over
695                the moon,
696                a star, and
697                the lazy dog"})
698            .await;
699        cx.simulate_shared_keystrokes("2 d 2 d").await;
700        cx.shared_state().await.assert_eq(indoc! {"
701        the ˇlazy dog"});
702    }
703
704    #[gpui::test]
705    async fn test_delete_to_adjacent_character(cx: &mut gpui::TestAppContext) {
706        let mut cx = NeovimBackedTestContext::new(cx).await;
707        cx.simulate("d t x", "ˇax").await.assert_matches();
708        cx.simulate("d t x", "aˇx").await.assert_matches();
709    }
710
711    #[gpui::test]
712    async fn test_delete_sentence(cx: &mut gpui::TestAppContext) {
713        let mut cx = NeovimBackedTestContext::new(cx).await;
714        // cx.simulate(
715        //     "d )",
716        //     indoc! {"
717        //     Fiˇrst. Second. Third.
718        //     Fourth.
719        //     "},
720        // )
721        // .await
722        // .assert_matches();
723
724        // cx.simulate(
725        //     "d )",
726        //     indoc! {"
727        //     First. Secˇond. Third.
728        //     Fourth.
729        //     "},
730        // )
731        // .await
732        // .assert_matches();
733
734        // // Two deletes
735        // cx.simulate(
736        //     "d ) d )",
737        //     indoc! {"
738        //     First. Second. Thirˇd.
739        //     Fourth.
740        //     "},
741        // )
742        // .await
743        // .assert_matches();
744
745        // Should delete whole line if done on first column
746        cx.simulate(
747            "d )",
748            indoc! {"
749            ˇFirst.
750            Fourth.
751            "},
752        )
753        .await
754        .assert_matches();
755
756        // Backwards it should also delete the whole first line
757        cx.simulate(
758            "d (",
759            indoc! {"
760            First.
761            ˇSecond.
762            Fourth.
763            "},
764        )
765        .await
766        .assert_matches();
767    }
768}