delete.rs

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