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