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 gpui::KeyBinding;
218    use indoc::indoc;
219
220    use crate::{
221        PushObject,
222        state::Mode,
223        test::{NeovimBackedTestContext, VimTestContext},
224    };
225
226    #[gpui::test]
227    async fn test_delete_h(cx: &mut gpui::TestAppContext) {
228        let mut cx = NeovimBackedTestContext::new(cx).await;
229        cx.simulate("d h", "Teˇst").await.assert_matches();
230        cx.simulate("d h", "Tˇest").await.assert_matches();
231        cx.simulate("d h", "ˇTest").await.assert_matches();
232        cx.simulate(
233            "d h",
234            indoc! {"
235            Test
236            ˇtest"},
237        )
238        .await
239        .assert_matches();
240    }
241
242    #[gpui::test]
243    async fn test_delete_l(cx: &mut gpui::TestAppContext) {
244        let mut cx = NeovimBackedTestContext::new(cx).await;
245        cx.simulate("d l", "ˇTest").await.assert_matches();
246        cx.simulate("d l", "Teˇst").await.assert_matches();
247        cx.simulate("d l", "Tesˇt").await.assert_matches();
248        cx.simulate(
249            "d l",
250            indoc! {"
251                Tesˇt
252                test"},
253        )
254        .await
255        .assert_matches();
256    }
257
258    #[gpui::test]
259    async fn test_delete_w(cx: &mut gpui::TestAppContext) {
260        let mut cx = NeovimBackedTestContext::new(cx).await;
261        cx.simulate(
262            "d w",
263            indoc! {"
264            Test tesˇt
265                test"},
266        )
267        .await
268        .assert_matches();
269
270        cx.simulate("d w", "Teˇst").await.assert_matches();
271        cx.simulate("d w", "Tˇest test").await.assert_matches();
272        cx.simulate(
273            "d w",
274            indoc! {"
275            Test teˇst
276            test"},
277        )
278        .await
279        .assert_matches();
280        cx.simulate(
281            "d w",
282            indoc! {"
283            Test tesˇt
284            test"},
285        )
286        .await
287        .assert_matches();
288
289        cx.simulate(
290            "d w",
291            indoc! {"
292            Test test
293            ˇ
294            test"},
295        )
296        .await
297        .assert_matches();
298
299        cx.simulate("d shift-w", "Test teˇst-test test")
300            .await
301            .assert_matches();
302    }
303
304    #[gpui::test]
305    async fn test_delete_next_word_end(cx: &mut gpui::TestAppContext) {
306        let mut cx = NeovimBackedTestContext::new(cx).await;
307        cx.simulate("d e", "Teˇst Test\n").await.assert_matches();
308        cx.simulate("d e", "Tˇest test\n").await.assert_matches();
309        cx.simulate(
310            "d e",
311            indoc! {"
312            Test teˇst
313            test"},
314        )
315        .await
316        .assert_matches();
317        cx.simulate(
318            "d e",
319            indoc! {"
320            Test tesˇt
321            test"},
322        )
323        .await
324        .assert_matches();
325
326        cx.simulate("d e", "Test teˇst-test test")
327            .await
328            .assert_matches();
329    }
330
331    #[gpui::test]
332    async fn test_delete_b(cx: &mut gpui::TestAppContext) {
333        let mut cx = NeovimBackedTestContext::new(cx).await;
334        cx.simulate("d b", "Teˇst Test").await.assert_matches();
335        cx.simulate("d b", "Test ˇtest").await.assert_matches();
336        cx.simulate("d b", "Test1 test2 ˇtest3")
337            .await
338            .assert_matches();
339        cx.simulate(
340            "d b",
341            indoc! {"
342            Test test
343            ˇtest"},
344        )
345        .await
346        .assert_matches();
347        cx.simulate(
348            "d b",
349            indoc! {"
350            Test test
351            ˇ
352            test"},
353        )
354        .await
355        .assert_matches();
356
357        cx.simulate("d shift-b", "Test test-test ˇtest")
358            .await
359            .assert_matches();
360    }
361
362    #[gpui::test]
363    async fn test_delete_end_of_line(cx: &mut gpui::TestAppContext) {
364        let mut cx = NeovimBackedTestContext::new(cx).await;
365        cx.simulate(
366            "d $",
367            indoc! {"
368            The qˇuick
369            brown fox"},
370        )
371        .await
372        .assert_matches();
373        cx.simulate(
374            "d $",
375            indoc! {"
376            The quick
377            ˇ
378            brown fox"},
379        )
380        .await
381        .assert_matches();
382    }
383
384    #[gpui::test]
385    async fn test_delete_end_of_paragraph(cx: &mut gpui::TestAppContext) {
386        let mut cx = NeovimBackedTestContext::new(cx).await;
387        cx.simulate(
388            "d }",
389            indoc! {"
390            ˇhello world.
391
392            hello world."},
393        )
394        .await
395        .assert_matches();
396
397        cx.simulate(
398            "d }",
399            indoc! {"
400            ˇhello world.
401            hello world."},
402        )
403        .await
404        .assert_matches();
405    }
406
407    #[gpui::test]
408    async fn test_delete_0(cx: &mut gpui::TestAppContext) {
409        let mut cx = NeovimBackedTestContext::new(cx).await;
410        cx.simulate(
411            "d 0",
412            indoc! {"
413            The qˇuick
414            brown fox"},
415        )
416        .await
417        .assert_matches();
418        cx.simulate(
419            "d 0",
420            indoc! {"
421            The quick
422            ˇ
423            brown fox"},
424        )
425        .await
426        .assert_matches();
427    }
428
429    #[gpui::test]
430    async fn test_delete_k(cx: &mut gpui::TestAppContext) {
431        let mut cx = NeovimBackedTestContext::new(cx).await;
432        cx.simulate(
433            "d k",
434            indoc! {"
435            The quick
436            brown ˇfox
437            jumps over"},
438        )
439        .await
440        .assert_matches();
441        cx.simulate(
442            "d k",
443            indoc! {"
444            The quick
445            brown fox
446            jumps ˇover"},
447        )
448        .await
449        .assert_matches();
450        cx.simulate(
451            "d k",
452            indoc! {"
453            The qˇuick
454            brown fox
455            jumps over"},
456        )
457        .await
458        .assert_matches();
459        cx.simulate(
460            "d k",
461            indoc! {"
462            ˇbrown fox
463            jumps over"},
464        )
465        .await
466        .assert_matches();
467    }
468
469    #[gpui::test]
470    async fn test_delete_j(cx: &mut gpui::TestAppContext) {
471        let mut cx = NeovimBackedTestContext::new(cx).await;
472        cx.simulate(
473            "d j",
474            indoc! {"
475            The quick
476            brown ˇfox
477            jumps over"},
478        )
479        .await
480        .assert_matches();
481        cx.simulate(
482            "d j",
483            indoc! {"
484            The quick
485            brown fox
486            jumps ˇover"},
487        )
488        .await
489        .assert_matches();
490        cx.simulate(
491            "d j",
492            indoc! {"
493            The qˇuick
494            brown fox
495            jumps over"},
496        )
497        .await
498        .assert_matches();
499        cx.simulate(
500            "d j",
501            indoc! {"
502            The quick
503            brown fox
504            ˇ"},
505        )
506        .await
507        .assert_matches();
508    }
509
510    #[gpui::test]
511    async fn test_delete_end_of_document(cx: &mut gpui::TestAppContext) {
512        let mut cx = NeovimBackedTestContext::new(cx).await;
513        cx.simulate(
514            "d shift-g",
515            indoc! {"
516            The quick
517            brownˇ fox
518            jumps over
519            the lazy"},
520        )
521        .await
522        .assert_matches();
523        cx.simulate(
524            "d shift-g",
525            indoc! {"
526            The quick
527            brownˇ fox
528            jumps over
529            the lazy"},
530        )
531        .await
532        .assert_matches();
533        cx.simulate(
534            "d shift-g",
535            indoc! {"
536            The quick
537            brown fox
538            jumps over
539            the lˇazy"},
540        )
541        .await
542        .assert_matches();
543        cx.simulate(
544            "d shift-g",
545            indoc! {"
546            The quick
547            brown fox
548            jumps over
549            ˇ"},
550        )
551        .await
552        .assert_matches();
553    }
554
555    #[gpui::test]
556    async fn test_delete_to_line(cx: &mut gpui::TestAppContext) {
557        let mut cx = NeovimBackedTestContext::new(cx).await;
558        cx.simulate(
559            "d 3 shift-g",
560            indoc! {"
561            The quick
562            brownˇ fox
563            jumps over
564            the lazy"},
565        )
566        .await
567        .assert_matches();
568        cx.simulate(
569            "d 3 shift-g",
570            indoc! {"
571            The quick
572            brown fox
573            jumps over
574            the lˇazy"},
575        )
576        .await
577        .assert_matches();
578        cx.simulate(
579            "d 2 shift-g",
580            indoc! {"
581            The quick
582            brown fox
583            jumps over
584            ˇ"},
585        )
586        .await
587        .assert_matches();
588    }
589
590    #[gpui::test]
591    async fn test_delete_gg(cx: &mut gpui::TestAppContext) {
592        let mut cx = NeovimBackedTestContext::new(cx).await;
593        cx.simulate(
594            "d g g",
595            indoc! {"
596            The quick
597            brownˇ fox
598            jumps over
599            the lazy"},
600        )
601        .await
602        .assert_matches();
603        cx.simulate(
604            "d g g",
605            indoc! {"
606            The quick
607            brown fox
608            jumps over
609            the lˇazy"},
610        )
611        .await
612        .assert_matches();
613        cx.simulate(
614            "d g g",
615            indoc! {"
616            The qˇuick
617            brown fox
618            jumps over
619            the lazy"},
620        )
621        .await
622        .assert_matches();
623        cx.simulate(
624            "d g g",
625            indoc! {"
626            ˇ
627            brown fox
628            jumps over
629            the lazy"},
630        )
631        .await
632        .assert_matches();
633    }
634
635    #[gpui::test]
636    async fn test_cancel_delete_operator(cx: &mut gpui::TestAppContext) {
637        let mut cx = VimTestContext::new(cx, true).await;
638        cx.set_state(
639            indoc! {"
640                The quick brown
641                fox juˇmps over
642                the lazy dog"},
643            Mode::Normal,
644        );
645
646        // Canceling operator twice reverts to normal mode with no active operator
647        cx.simulate_keystrokes("d escape k");
648        assert_eq!(cx.active_operator(), None);
649        assert_eq!(cx.mode(), Mode::Normal);
650        cx.assert_editor_state(indoc! {"
651            The quˇick brown
652            fox jumps over
653            the lazy dog"});
654    }
655
656    #[gpui::test]
657    async fn test_unbound_command_cancels_pending_operator(cx: &mut gpui::TestAppContext) {
658        let mut cx = VimTestContext::new(cx, true).await;
659        cx.set_state(
660            indoc! {"
661                The quick brown
662                fox juˇmps over
663                the lazy dog"},
664            Mode::Normal,
665        );
666
667        // Canceling operator twice reverts to normal mode with no active operator
668        cx.simulate_keystrokes("d y");
669        assert_eq!(cx.active_operator(), None);
670        assert_eq!(cx.mode(), Mode::Normal);
671    }
672
673    #[gpui::test]
674    async fn test_delete_with_counts(cx: &mut gpui::TestAppContext) {
675        let mut cx = NeovimBackedTestContext::new(cx).await;
676        cx.set_shared_state(indoc! {"
677                The ˇquick brown
678                fox jumps over
679                the lazy dog"})
680            .await;
681        cx.simulate_shared_keystrokes("d 2 d").await;
682        cx.shared_state().await.assert_eq(indoc! {"
683        the ˇlazy dog"});
684
685        cx.set_shared_state(indoc! {"
686                The ˇquick brown
687                fox jumps over
688                the lazy dog"})
689            .await;
690        cx.simulate_shared_keystrokes("2 d d").await;
691        cx.shared_state().await.assert_eq(indoc! {"
692        the ˇlazy dog"});
693
694        cx.set_shared_state(indoc! {"
695                The ˇquick brown
696                fox jumps over
697                the moon,
698                a star, and
699                the lazy dog"})
700            .await;
701        cx.simulate_shared_keystrokes("2 d 2 d").await;
702        cx.shared_state().await.assert_eq(indoc! {"
703        the ˇlazy dog"});
704    }
705
706    #[gpui::test]
707    async fn test_delete_to_adjacent_character(cx: &mut gpui::TestAppContext) {
708        let mut cx = NeovimBackedTestContext::new(cx).await;
709        cx.simulate("d t x", "ˇax").await.assert_matches();
710        cx.simulate("d t x", "aˇx").await.assert_matches();
711    }
712
713    #[gpui::test]
714    async fn test_delete_sentence(cx: &mut gpui::TestAppContext) {
715        let mut cx = NeovimBackedTestContext::new(cx).await;
716        // cx.simulate(
717        //     "d )",
718        //     indoc! {"
719        //     Fiˇrst. Second. Third.
720        //     Fourth.
721        //     "},
722        // )
723        // .await
724        // .assert_matches();
725
726        // cx.simulate(
727        //     "d )",
728        //     indoc! {"
729        //     First. Secˇond. Third.
730        //     Fourth.
731        //     "},
732        // )
733        // .await
734        // .assert_matches();
735
736        // // Two deletes
737        // cx.simulate(
738        //     "d ) d )",
739        //     indoc! {"
740        //     First. Second. Thirˇd.
741        //     Fourth.
742        //     "},
743        // )
744        // .await
745        // .assert_matches();
746
747        // Should delete whole line if done on first column
748        cx.simulate(
749            "d )",
750            indoc! {"
751            ˇFirst.
752            Fourth.
753            "},
754        )
755        .await
756        .assert_matches();
757
758        // Backwards it should also delete the whole first line
759        cx.simulate(
760            "d (",
761            indoc! {"
762            First.
763            ˇSecond.
764            Fourth.
765            "},
766        )
767        .await
768        .assert_matches();
769    }
770
771    #[gpui::test]
772    async fn test_delete_object_scope(cx: &mut gpui::TestAppContext) {
773        let mut cx = VimTestContext::new(cx, true).await;
774
775        cx.update(|_, cx| {
776            cx.bind_keys([KeyBinding::new(
777                "a",
778                PushObject {
779                    around: true,
780                    whitespace: false,
781                },
782                Some("VimControl && !menu"),
783            )]);
784        });
785
786        cx.set_state("some 'ˇquotes' here", Mode::Normal);
787        cx.simulate_keystrokes("d a '");
788        cx.assert_editor_state("some ˇ here");
789    }
790}