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