1use std::ops::Range;
2
3use editor::{
4 display_map::{DisplaySnapshot, ToDisplayPoint},
5 movement::{self, FindRange},
6 Bias, DisplayPoint,
7};
8use gpui::{actions, impl_actions, ViewContext, WindowContext};
9use language::{char_kind, CharKind, Selection};
10use serde::Deserialize;
11use workspace::Workspace;
12
13use crate::{
14 motion::right, normal::normal_object, state::Mode, utils::coerce_punctuation,
15 visual::visual_object, Vim,
16};
17
18#[derive(Copy, Clone, Debug, PartialEq)]
19pub enum Object {
20 Word { ignore_punctuation: bool },
21 Sentence,
22 Quotes,
23 BackQuotes,
24 DoubleQuotes,
25 VerticalBars,
26 Parentheses,
27 SquareBrackets,
28 CurlyBrackets,
29 AngleBrackets,
30}
31
32#[derive(Clone, Deserialize, PartialEq)]
33#[serde(rename_all = "camelCase")]
34struct Word {
35 #[serde(default)]
36 ignore_punctuation: bool,
37}
38
39impl_actions!(vim, [Word]);
40
41actions!(
42 vim,
43 [
44 Sentence,
45 Quotes,
46 BackQuotes,
47 DoubleQuotes,
48 VerticalBars,
49 Parentheses,
50 SquareBrackets,
51 CurlyBrackets,
52 AngleBrackets
53 ]
54);
55
56pub fn register(workspace: &mut Workspace, _: &mut ViewContext<Workspace>) {
57 workspace.register_action(
58 |_: &mut Workspace, &Word { ignore_punctuation }: &Word, cx: _| {
59 object(Object::Word { ignore_punctuation }, cx)
60 },
61 );
62 workspace
63 .register_action(|_: &mut Workspace, _: &Sentence, cx: _| object(Object::Sentence, cx));
64 workspace.register_action(|_: &mut Workspace, _: &Quotes, cx: _| object(Object::Quotes, cx));
65 workspace
66 .register_action(|_: &mut Workspace, _: &BackQuotes, cx: _| object(Object::BackQuotes, cx));
67 workspace.register_action(|_: &mut Workspace, _: &DoubleQuotes, cx: _| {
68 object(Object::DoubleQuotes, cx)
69 });
70 workspace.register_action(|_: &mut Workspace, _: &Parentheses, cx: _| {
71 object(Object::Parentheses, cx)
72 });
73 workspace.register_action(|_: &mut Workspace, _: &SquareBrackets, cx: _| {
74 object(Object::SquareBrackets, cx)
75 });
76 workspace.register_action(|_: &mut Workspace, _: &CurlyBrackets, cx: _| {
77 object(Object::CurlyBrackets, cx)
78 });
79 workspace.register_action(|_: &mut Workspace, _: &AngleBrackets, cx: _| {
80 object(Object::AngleBrackets, cx)
81 });
82 workspace.register_action(|_: &mut Workspace, _: &VerticalBars, cx: _| {
83 object(Object::VerticalBars, cx)
84 });
85}
86
87fn object(object: Object, cx: &mut WindowContext) {
88 match Vim::read(cx).state().mode {
89 Mode::Normal => normal_object(object, cx),
90 Mode::Visual | Mode::VisualLine | Mode::VisualBlock => visual_object(object, cx),
91 Mode::Insert => {
92 // Shouldn't execute a text object in insert mode. Ignoring
93 }
94 }
95}
96
97impl Object {
98 pub fn is_multiline(self) -> bool {
99 match self {
100 Object::Word { .. }
101 | Object::Quotes
102 | Object::BackQuotes
103 | Object::VerticalBars
104 | Object::DoubleQuotes => false,
105 Object::Sentence
106 | Object::Parentheses
107 | Object::AngleBrackets
108 | Object::CurlyBrackets
109 | Object::SquareBrackets => true,
110 }
111 }
112
113 pub fn always_expands_both_ways(self) -> bool {
114 match self {
115 Object::Word { .. } | Object::Sentence => false,
116 Object::Quotes
117 | Object::BackQuotes
118 | Object::DoubleQuotes
119 | Object::VerticalBars
120 | Object::Parentheses
121 | Object::SquareBrackets
122 | Object::CurlyBrackets
123 | Object::AngleBrackets => true,
124 }
125 }
126
127 pub fn target_visual_mode(self, current_mode: Mode) -> Mode {
128 match self {
129 Object::Word { .. } if current_mode == Mode::VisualLine => Mode::Visual,
130 Object::Word { .. } => current_mode,
131 Object::Sentence
132 | Object::Quotes
133 | Object::BackQuotes
134 | Object::DoubleQuotes
135 | Object::VerticalBars
136 | Object::Parentheses
137 | Object::SquareBrackets
138 | Object::CurlyBrackets
139 | Object::AngleBrackets => Mode::Visual,
140 }
141 }
142
143 pub fn range(
144 self,
145 map: &DisplaySnapshot,
146 relative_to: DisplayPoint,
147 around: bool,
148 ) -> Option<Range<DisplayPoint>> {
149 match self {
150 Object::Word { ignore_punctuation } => {
151 if around {
152 around_word(map, relative_to, ignore_punctuation)
153 } else {
154 in_word(map, relative_to, ignore_punctuation)
155 }
156 }
157 Object::Sentence => sentence(map, relative_to, around),
158 Object::Quotes => {
159 surrounding_markers(map, relative_to, around, self.is_multiline(), '\'', '\'')
160 }
161 Object::BackQuotes => {
162 surrounding_markers(map, relative_to, around, self.is_multiline(), '`', '`')
163 }
164 Object::DoubleQuotes => {
165 surrounding_markers(map, relative_to, around, self.is_multiline(), '"', '"')
166 }
167 Object::VerticalBars => {
168 surrounding_markers(map, relative_to, around, self.is_multiline(), '|', '|')
169 }
170 Object::Parentheses => {
171 surrounding_markers(map, relative_to, around, self.is_multiline(), '(', ')')
172 }
173 Object::SquareBrackets => {
174 surrounding_markers(map, relative_to, around, self.is_multiline(), '[', ']')
175 }
176 Object::CurlyBrackets => {
177 surrounding_markers(map, relative_to, around, self.is_multiline(), '{', '}')
178 }
179 Object::AngleBrackets => {
180 surrounding_markers(map, relative_to, around, self.is_multiline(), '<', '>')
181 }
182 }
183 }
184
185 pub fn expand_selection(
186 self,
187 map: &DisplaySnapshot,
188 selection: &mut Selection<DisplayPoint>,
189 around: bool,
190 ) -> bool {
191 if let Some(range) = self.range(map, selection.head(), around) {
192 selection.start = range.start;
193 selection.end = range.end;
194 true
195 } else {
196 false
197 }
198 }
199}
200
201/// Return a range that surrounds the word relative_to is in
202/// If relative_to is at the start of a word, return the word.
203/// If relative_to is between words, return the space between
204fn in_word(
205 map: &DisplaySnapshot,
206 relative_to: DisplayPoint,
207 ignore_punctuation: bool,
208) -> Option<Range<DisplayPoint>> {
209 // Use motion::right so that we consider the character under the cursor when looking for the start
210 let scope = map
211 .buffer_snapshot
212 .language_scope_at(relative_to.to_point(map));
213 let start = movement::find_preceding_boundary(
214 map,
215 right(map, relative_to, 1),
216 movement::FindRange::SingleLine,
217 |left, right| {
218 coerce_punctuation(char_kind(&scope, left), ignore_punctuation)
219 != coerce_punctuation(char_kind(&scope, right), ignore_punctuation)
220 },
221 );
222
223 let end = movement::find_boundary(map, relative_to, FindRange::SingleLine, |left, right| {
224 coerce_punctuation(char_kind(&scope, left), ignore_punctuation)
225 != coerce_punctuation(char_kind(&scope, right), ignore_punctuation)
226 });
227
228 Some(start..end)
229}
230
231/// Return a range that surrounds the word and following whitespace
232/// relative_to is in.
233/// If relative_to is at the start of a word, return the word and following whitespace.
234/// If relative_to is between words, return the whitespace back and the following word
235
236/// if in word
237/// delete that word
238/// if there is whitespace following the word, delete that as well
239/// otherwise, delete any preceding whitespace
240/// otherwise
241/// delete whitespace around cursor
242/// delete word following the cursor
243fn around_word(
244 map: &DisplaySnapshot,
245 relative_to: DisplayPoint,
246 ignore_punctuation: bool,
247) -> Option<Range<DisplayPoint>> {
248 let scope = map
249 .buffer_snapshot
250 .language_scope_at(relative_to.to_point(map));
251 let in_word = map
252 .chars_at(relative_to)
253 .next()
254 .map(|(c, _)| char_kind(&scope, c) != CharKind::Whitespace)
255 .unwrap_or(false);
256
257 if in_word {
258 around_containing_word(map, relative_to, ignore_punctuation)
259 } else {
260 around_next_word(map, relative_to, ignore_punctuation)
261 }
262}
263
264fn around_containing_word(
265 map: &DisplaySnapshot,
266 relative_to: DisplayPoint,
267 ignore_punctuation: bool,
268) -> Option<Range<DisplayPoint>> {
269 in_word(map, relative_to, ignore_punctuation)
270 .map(|range| expand_to_include_whitespace(map, range, true))
271}
272
273fn around_next_word(
274 map: &DisplaySnapshot,
275 relative_to: DisplayPoint,
276 ignore_punctuation: bool,
277) -> Option<Range<DisplayPoint>> {
278 let scope = map
279 .buffer_snapshot
280 .language_scope_at(relative_to.to_point(map));
281 // Get the start of the word
282 let start = movement::find_preceding_boundary(
283 map,
284 right(map, relative_to, 1),
285 FindRange::SingleLine,
286 |left, right| {
287 coerce_punctuation(char_kind(&scope, left), ignore_punctuation)
288 != coerce_punctuation(char_kind(&scope, right), ignore_punctuation)
289 },
290 );
291
292 let mut word_found = false;
293 let end = movement::find_boundary(map, relative_to, FindRange::MultiLine, |left, right| {
294 let left_kind = coerce_punctuation(char_kind(&scope, left), ignore_punctuation);
295 let right_kind = coerce_punctuation(char_kind(&scope, right), ignore_punctuation);
296
297 let found = (word_found && left_kind != right_kind) || right == '\n' && left == '\n';
298
299 if right_kind != CharKind::Whitespace {
300 word_found = true;
301 }
302
303 found
304 });
305
306 Some(start..end)
307}
308
309fn sentence(
310 map: &DisplaySnapshot,
311 relative_to: DisplayPoint,
312 around: bool,
313) -> Option<Range<DisplayPoint>> {
314 let mut start = None;
315 let mut previous_end = relative_to;
316
317 let mut chars = map.chars_at(relative_to).peekable();
318
319 // Search backwards for the previous sentence end or current sentence start. Include the character under relative_to
320 for (char, point) in chars
321 .peek()
322 .cloned()
323 .into_iter()
324 .chain(map.reverse_chars_at(relative_to))
325 {
326 if is_sentence_end(map, point) {
327 break;
328 }
329
330 if is_possible_sentence_start(char) {
331 start = Some(point);
332 }
333
334 previous_end = point;
335 }
336
337 // Search forward for the end of the current sentence or if we are between sentences, the start of the next one
338 let mut end = relative_to;
339 for (char, point) in chars {
340 if start.is_none() && is_possible_sentence_start(char) {
341 if around {
342 start = Some(point);
343 continue;
344 } else {
345 end = point;
346 break;
347 }
348 }
349
350 end = point;
351 *end.column_mut() += char.len_utf8() as u32;
352 end = map.clip_point(end, Bias::Left);
353
354 if is_sentence_end(map, end) {
355 break;
356 }
357 }
358
359 let mut range = start.unwrap_or(previous_end)..end;
360 if around {
361 range = expand_to_include_whitespace(map, range, false);
362 }
363
364 Some(range)
365}
366
367fn is_possible_sentence_start(character: char) -> bool {
368 !character.is_whitespace() && character != '.'
369}
370
371const SENTENCE_END_PUNCTUATION: &[char] = &['.', '!', '?'];
372const SENTENCE_END_FILLERS: &[char] = &[')', ']', '"', '\''];
373const SENTENCE_END_WHITESPACE: &[char] = &[' ', '\t', '\n'];
374fn is_sentence_end(map: &DisplaySnapshot, point: DisplayPoint) -> bool {
375 let mut next_chars = map.chars_at(point).peekable();
376 if let Some((char, _)) = next_chars.next() {
377 // We are at a double newline. This position is a sentence end.
378 if char == '\n' && next_chars.peek().map(|(c, _)| c == &'\n').unwrap_or(false) {
379 return true;
380 }
381
382 // The next text is not a valid whitespace. This is not a sentence end
383 if !SENTENCE_END_WHITESPACE.contains(&char) {
384 return false;
385 }
386 }
387
388 for (char, _) in map.reverse_chars_at(point) {
389 if SENTENCE_END_PUNCTUATION.contains(&char) {
390 return true;
391 }
392
393 if !SENTENCE_END_FILLERS.contains(&char) {
394 return false;
395 }
396 }
397
398 return false;
399}
400
401/// Expands the passed range to include whitespace on one side or the other in a line. Attempts to add the
402/// whitespace to the end first and falls back to the start if there was none.
403fn expand_to_include_whitespace(
404 map: &DisplaySnapshot,
405 mut range: Range<DisplayPoint>,
406 stop_at_newline: bool,
407) -> Range<DisplayPoint> {
408 let mut whitespace_included = false;
409
410 let mut chars = map.chars_at(range.end).peekable();
411 while let Some((char, point)) = chars.next() {
412 if char == '\n' && stop_at_newline {
413 break;
414 }
415
416 if char.is_whitespace() {
417 // Set end to the next display_point or the character position after the current display_point
418 range.end = chars.peek().map(|(_, point)| *point).unwrap_or_else(|| {
419 let mut end = point;
420 *end.column_mut() += char.len_utf8() as u32;
421 map.clip_point(end, Bias::Left)
422 });
423
424 if char != '\n' {
425 whitespace_included = true;
426 }
427 } else {
428 // Found non whitespace. Quit out.
429 break;
430 }
431 }
432
433 if !whitespace_included {
434 for (char, point) in map.reverse_chars_at(range.start) {
435 if char == '\n' && stop_at_newline {
436 break;
437 }
438
439 if !char.is_whitespace() {
440 break;
441 }
442
443 range.start = point;
444 }
445 }
446
447 range
448}
449
450fn surrounding_markers(
451 map: &DisplaySnapshot,
452 relative_to: DisplayPoint,
453 around: bool,
454 search_across_lines: bool,
455 open_marker: char,
456 close_marker: char,
457) -> Option<Range<DisplayPoint>> {
458 let point = relative_to.to_offset(map, Bias::Left);
459
460 let mut matched_closes = 0;
461 let mut opening = None;
462
463 if let Some((ch, range)) = movement::chars_after(map, point).next() {
464 if ch == open_marker {
465 if open_marker == close_marker {
466 let mut total = 0;
467 for (ch, _) in movement::chars_before(map, point) {
468 if ch == '\n' {
469 break;
470 }
471 if ch == open_marker {
472 total += 1;
473 }
474 }
475 if total % 2 == 0 {
476 opening = Some(range)
477 }
478 } else {
479 opening = Some(range)
480 }
481 }
482 }
483
484 if opening.is_none() {
485 for (ch, range) in movement::chars_before(map, point) {
486 if ch == '\n' && !search_across_lines {
487 break;
488 }
489
490 if ch == open_marker {
491 if matched_closes == 0 {
492 opening = Some(range);
493 break;
494 }
495 matched_closes -= 1;
496 } else if ch == close_marker {
497 matched_closes += 1
498 }
499 }
500 }
501
502 if opening.is_none() {
503 for (ch, range) in movement::chars_after(map, point) {
504 if ch == open_marker {
505 opening = Some(range);
506 break;
507 } else if ch == close_marker {
508 break;
509 }
510 }
511 }
512
513 let Some(mut opening) = opening else {
514 return None;
515 };
516
517 let mut matched_opens = 0;
518 let mut closing = None;
519
520 for (ch, range) in movement::chars_after(map, opening.end) {
521 if ch == '\n' && !search_across_lines {
522 break;
523 }
524
525 if ch == close_marker {
526 if matched_opens == 0 {
527 closing = Some(range);
528 break;
529 }
530 matched_opens -= 1;
531 } else if ch == open_marker {
532 matched_opens += 1;
533 }
534 }
535
536 let Some(mut closing) = closing else {
537 return None;
538 };
539
540 if around && !search_across_lines {
541 let mut found = false;
542
543 for (ch, range) in movement::chars_after(map, closing.end) {
544 if ch.is_whitespace() && ch != '\n' {
545 found = true;
546 closing.end = range.end;
547 } else {
548 break;
549 }
550 }
551
552 if !found {
553 for (ch, range) in movement::chars_before(map, opening.start) {
554 if ch.is_whitespace() && ch != '\n' {
555 opening.start = range.start
556 } else {
557 break;
558 }
559 }
560 }
561 }
562
563 if !around && search_across_lines {
564 if let Some((ch, range)) = movement::chars_after(map, opening.end).next() {
565 if ch == '\n' {
566 opening.end = range.end
567 }
568 }
569
570 for (ch, range) in movement::chars_before(map, closing.start) {
571 if !ch.is_whitespace() {
572 break;
573 }
574 if ch != '\n' {
575 closing.start = range.start
576 }
577 }
578 }
579
580 let result = if around {
581 opening.start..closing.end
582 } else {
583 opening.end..closing.start
584 };
585
586 Some(
587 map.clip_point(result.start.to_display_point(map), Bias::Left)
588 ..map.clip_point(result.end.to_display_point(map), Bias::Right),
589 )
590}
591
592#[cfg(test)]
593mod test {
594 use indoc::indoc;
595
596 use crate::{
597 state::Mode,
598 test::{ExemptionFeatures, NeovimBackedTestContext, VimTestContext},
599 };
600
601 const WORD_LOCATIONS: &'static str = indoc! {"
602 The quick ˇbrowˇnˇ•••
603 fox ˇjuˇmpsˇ over
604 the lazy dogˇ••
605 ˇ
606 ˇ
607 ˇ
608 Thˇeˇ-ˇquˇickˇ ˇbrownˇ•
609 ˇ••
610 ˇ••
611 ˇ fox-jumpˇs over
612 the lazy dogˇ•
613 ˇ
614 "
615 };
616
617 #[gpui::test]
618 async fn test_change_word_object(cx: &mut gpui::TestAppContext) {
619 let mut cx = NeovimBackedTestContext::new(cx).await;
620
621 cx.assert_binding_matches_all(["c", "i", "w"], WORD_LOCATIONS)
622 .await;
623 cx.assert_binding_matches_all(["c", "i", "shift-w"], WORD_LOCATIONS)
624 .await;
625 cx.assert_binding_matches_all(["c", "a", "w"], WORD_LOCATIONS)
626 .await;
627 cx.assert_binding_matches_all(["c", "a", "shift-w"], WORD_LOCATIONS)
628 .await;
629 }
630
631 #[gpui::test]
632 async fn test_delete_word_object(cx: &mut gpui::TestAppContext) {
633 let mut cx = NeovimBackedTestContext::new(cx).await;
634
635 cx.assert_binding_matches_all(["d", "i", "w"], WORD_LOCATIONS)
636 .await;
637 cx.assert_binding_matches_all(["d", "i", "shift-w"], WORD_LOCATIONS)
638 .await;
639 cx.assert_binding_matches_all(["d", "a", "w"], WORD_LOCATIONS)
640 .await;
641 cx.assert_binding_matches_all(["d", "a", "shift-w"], WORD_LOCATIONS)
642 .await;
643 }
644
645 #[gpui::test]
646 async fn test_visual_word_object(cx: &mut gpui::TestAppContext) {
647 let mut cx = NeovimBackedTestContext::new(cx).await;
648
649 /*
650 cx.set_shared_state("The quick ˇbrown\nfox").await;
651 cx.simulate_shared_keystrokes(["v"]).await;
652 cx.assert_shared_state("The quick «bˇ»rown\nfox").await;
653 cx.simulate_shared_keystrokes(["i", "w"]).await;
654 cx.assert_shared_state("The quick «brownˇ»\nfox").await;
655 */
656 cx.set_shared_state("The quick brown\nˇ\nfox").await;
657 cx.simulate_shared_keystrokes(["v"]).await;
658 cx.assert_shared_state("The quick brown\n«\nˇ»fox").await;
659 cx.simulate_shared_keystrokes(["i", "w"]).await;
660 cx.assert_shared_state("The quick brown\n«\nˇ»fox").await;
661
662 cx.assert_binding_matches_all(["v", "i", "w"], WORD_LOCATIONS)
663 .await;
664 cx.assert_binding_matches_all_exempted(
665 ["v", "h", "i", "w"],
666 WORD_LOCATIONS,
667 ExemptionFeatures::NonEmptyVisualTextObjects,
668 )
669 .await;
670 cx.assert_binding_matches_all_exempted(
671 ["v", "l", "i", "w"],
672 WORD_LOCATIONS,
673 ExemptionFeatures::NonEmptyVisualTextObjects,
674 )
675 .await;
676 cx.assert_binding_matches_all(["v", "i", "shift-w"], WORD_LOCATIONS)
677 .await;
678
679 cx.assert_binding_matches_all_exempted(
680 ["v", "i", "h", "shift-w"],
681 WORD_LOCATIONS,
682 ExemptionFeatures::NonEmptyVisualTextObjects,
683 )
684 .await;
685 cx.assert_binding_matches_all_exempted(
686 ["v", "i", "l", "shift-w"],
687 WORD_LOCATIONS,
688 ExemptionFeatures::NonEmptyVisualTextObjects,
689 )
690 .await;
691
692 cx.assert_binding_matches_all_exempted(
693 ["v", "a", "w"],
694 WORD_LOCATIONS,
695 ExemptionFeatures::AroundObjectLeavesWhitespaceAtEndOfLine,
696 )
697 .await;
698 cx.assert_binding_matches_all_exempted(
699 ["v", "a", "shift-w"],
700 WORD_LOCATIONS,
701 ExemptionFeatures::AroundObjectLeavesWhitespaceAtEndOfLine,
702 )
703 .await;
704 }
705
706 const SENTENCE_EXAMPLES: &[&'static str] = &[
707 "ˇThe quick ˇbrownˇ?ˇ ˇFox Jˇumpsˇ!ˇ Ovˇer theˇ lazyˇ.",
708 indoc! {"
709 ˇThe quick ˇbrownˇ
710 fox jumps over
711 the lazy doˇgˇ.ˇ ˇThe quick ˇ
712 brown fox jumps over
713 "},
714 indoc! {"
715 The quick brown fox jumps.
716 Over the lazy dog
717 ˇ
718 ˇ
719 ˇ fox-jumpˇs over
720 the lazy dog.ˇ
721 ˇ
722 "},
723 r#"ˇThe ˇquick brownˇ.)ˇ]ˇ'ˇ" Brown ˇfox jumpsˇ.ˇ "#,
724 ];
725
726 #[gpui::test]
727 async fn test_change_sentence_object(cx: &mut gpui::TestAppContext) {
728 let mut cx = NeovimBackedTestContext::new(cx)
729 .await
730 .binding(["c", "i", "s"]);
731 cx.add_initial_state_exemptions(
732 "The quick brown fox jumps.\nOver the lazy dog\nˇ\nˇ\n fox-jumps over\nthe lazy dog.\n\n",
733 ExemptionFeatures::SentenceOnEmptyLines);
734 cx.add_initial_state_exemptions(
735 "The quick brown fox jumps.\nOver the lazy dog\n\n\nˇ foxˇ-ˇjumpˇs over\nthe lazy dog.\n\n",
736 ExemptionFeatures::SentenceAtStartOfLineWithWhitespace);
737 cx.add_initial_state_exemptions(
738 "The quick brown fox jumps.\nOver the lazy dog\n\n\n fox-jumps over\nthe lazy dog.ˇ\nˇ\n",
739 ExemptionFeatures::SentenceAfterPunctuationAtEndOfFile);
740 for sentence_example in SENTENCE_EXAMPLES {
741 cx.assert_all(sentence_example).await;
742 }
743
744 let mut cx = cx.binding(["c", "a", "s"]);
745 cx.add_initial_state_exemptions(
746 "The quick brown?ˇ Fox Jumps! Over the lazy.",
747 ExemptionFeatures::IncorrectLandingPosition,
748 );
749 cx.add_initial_state_exemptions(
750 "The quick brown.)]\'\" Brown fox jumps.ˇ ",
751 ExemptionFeatures::AroundObjectLeavesWhitespaceAtEndOfLine,
752 );
753
754 for sentence_example in SENTENCE_EXAMPLES {
755 cx.assert_all(sentence_example).await;
756 }
757 }
758
759 #[gpui::test]
760 async fn test_delete_sentence_object(cx: &mut gpui::TestAppContext) {
761 let mut cx = NeovimBackedTestContext::new(cx)
762 .await
763 .binding(["d", "i", "s"]);
764 cx.add_initial_state_exemptions(
765 "The quick brown fox jumps.\nOver the lazy dog\nˇ\nˇ\n fox-jumps over\nthe lazy dog.\n\n",
766 ExemptionFeatures::SentenceOnEmptyLines);
767 cx.add_initial_state_exemptions(
768 "The quick brown fox jumps.\nOver the lazy dog\n\n\nˇ foxˇ-ˇjumpˇs over\nthe lazy dog.\n\n",
769 ExemptionFeatures::SentenceAtStartOfLineWithWhitespace);
770 cx.add_initial_state_exemptions(
771 "The quick brown fox jumps.\nOver the lazy dog\n\n\n fox-jumps over\nthe lazy dog.ˇ\nˇ\n",
772 ExemptionFeatures::SentenceAfterPunctuationAtEndOfFile);
773
774 for sentence_example in SENTENCE_EXAMPLES {
775 cx.assert_all(sentence_example).await;
776 }
777
778 let mut cx = cx.binding(["d", "a", "s"]);
779 cx.add_initial_state_exemptions(
780 "The quick brown?ˇ Fox Jumps! Over the lazy.",
781 ExemptionFeatures::IncorrectLandingPosition,
782 );
783 cx.add_initial_state_exemptions(
784 "The quick brown.)]\'\" Brown fox jumps.ˇ ",
785 ExemptionFeatures::AroundObjectLeavesWhitespaceAtEndOfLine,
786 );
787
788 for sentence_example in SENTENCE_EXAMPLES {
789 cx.assert_all(sentence_example).await;
790 }
791 }
792
793 #[gpui::test]
794 async fn test_visual_sentence_object(cx: &mut gpui::TestAppContext) {
795 let mut cx = NeovimBackedTestContext::new(cx)
796 .await
797 .binding(["v", "i", "s"]);
798 for sentence_example in SENTENCE_EXAMPLES {
799 cx.assert_all_exempted(sentence_example, ExemptionFeatures::SentenceOnEmptyLines)
800 .await;
801 }
802
803 let mut cx = cx.binding(["v", "a", "s"]);
804 for sentence_example in SENTENCE_EXAMPLES {
805 cx.assert_all_exempted(
806 sentence_example,
807 ExemptionFeatures::AroundSentenceStartingBetweenIncludesWrongWhitespace,
808 )
809 .await;
810 }
811 }
812
813 // Test string with "`" for opening surrounders and "'" for closing surrounders
814 const SURROUNDING_MARKER_STRING: &str = indoc! {"
815 ˇTh'ˇe ˇ`ˇ'ˇquˇi`ˇck broˇ'wn`
816 'ˇfox juˇmps ovˇ`ˇer
817 the ˇlazy dˇ'ˇoˇ`ˇg"};
818
819 const SURROUNDING_OBJECTS: &[(char, char)] = &[
820 ('\'', '\''), // Quote
821 ('`', '`'), // Back Quote
822 ('"', '"'), // Double Quote
823 ('(', ')'), // Parentheses
824 ('[', ']'), // SquareBrackets
825 ('{', '}'), // CurlyBrackets
826 ('<', '>'), // AngleBrackets
827 ];
828
829 #[gpui::test]
830 async fn test_change_surrounding_character_objects(cx: &mut gpui::TestAppContext) {
831 let mut cx = NeovimBackedTestContext::new(cx).await;
832
833 for (start, end) in SURROUNDING_OBJECTS {
834 let marked_string = SURROUNDING_MARKER_STRING
835 .replace('`', &start.to_string())
836 .replace('\'', &end.to_string());
837
838 cx.assert_binding_matches_all(["c", "i", &start.to_string()], &marked_string)
839 .await;
840 cx.assert_binding_matches_all(["c", "i", &end.to_string()], &marked_string)
841 .await;
842 cx.assert_binding_matches_all(["c", "a", &start.to_string()], &marked_string)
843 .await;
844 cx.assert_binding_matches_all(["c", "a", &end.to_string()], &marked_string)
845 .await;
846 }
847 }
848 #[gpui::test]
849 async fn test_singleline_surrounding_character_objects(cx: &mut gpui::TestAppContext) {
850 let mut cx = NeovimBackedTestContext::new(cx).await;
851 cx.set_shared_wrap(12).await;
852
853 cx.set_shared_state(indoc! {
854 "helˇlo \"world\"!"
855 })
856 .await;
857 cx.simulate_shared_keystrokes(["v", "i", "\""]).await;
858 cx.assert_shared_state(indoc! {
859 "hello \"«worldˇ»\"!"
860 })
861 .await;
862
863 cx.set_shared_state(indoc! {
864 "hello \"wˇorld\"!"
865 })
866 .await;
867 cx.simulate_shared_keystrokes(["v", "i", "\""]).await;
868 cx.assert_shared_state(indoc! {
869 "hello \"«worldˇ»\"!"
870 })
871 .await;
872
873 cx.set_shared_state(indoc! {
874 "hello \"wˇorld\"!"
875 })
876 .await;
877 cx.simulate_shared_keystrokes(["v", "a", "\""]).await;
878 cx.assert_shared_state(indoc! {
879 "hello« \"world\"ˇ»!"
880 })
881 .await;
882
883 cx.set_shared_state(indoc! {
884 "hello \"wˇorld\" !"
885 })
886 .await;
887 cx.simulate_shared_keystrokes(["v", "a", "\""]).await;
888 cx.assert_shared_state(indoc! {
889 "hello «\"world\" ˇ»!"
890 })
891 .await;
892
893 cx.set_shared_state(indoc! {
894 "hello \"wˇorld\"•
895 goodbye"
896 })
897 .await;
898 cx.simulate_shared_keystrokes(["v", "a", "\""]).await;
899 cx.assert_shared_state(indoc! {
900 "hello «\"world\" ˇ»
901 goodbye"
902 })
903 .await;
904 }
905
906 #[gpui::test]
907 async fn test_multiline_surrounding_character_objects(cx: &mut gpui::TestAppContext) {
908 let mut cx = NeovimBackedTestContext::new(cx).await;
909
910 cx.set_shared_state(indoc! {
911 "func empty(a string) bool {
912 if a == \"\" {
913 return true
914 }
915 ˇreturn false
916 }"
917 })
918 .await;
919 cx.simulate_shared_keystrokes(["v", "i", "{"]).await;
920 cx.assert_shared_state(indoc! {"
921 func empty(a string) bool {
922 « if a == \"\" {
923 return true
924 }
925 return false
926 ˇ»}"})
927 .await;
928 cx.set_shared_state(indoc! {
929 "func empty(a string) bool {
930 if a == \"\" {
931 ˇreturn true
932 }
933 return false
934 }"
935 })
936 .await;
937 cx.simulate_shared_keystrokes(["v", "i", "{"]).await;
938 cx.assert_shared_state(indoc! {"
939 func empty(a string) bool {
940 if a == \"\" {
941 « return true
942 ˇ» }
943 return false
944 }"})
945 .await;
946
947 cx.set_shared_state(indoc! {
948 "func empty(a string) bool {
949 if a == \"\" ˇ{
950 return true
951 }
952 return false
953 }"
954 })
955 .await;
956 cx.simulate_shared_keystrokes(["v", "i", "{"]).await;
957 cx.assert_shared_state(indoc! {"
958 func empty(a string) bool {
959 if a == \"\" {
960 « return true
961 ˇ» }
962 return false
963 }"})
964 .await;
965 }
966
967 #[gpui::test]
968 async fn test_vertical_bars(cx: &mut gpui::TestAppContext) {
969 let mut cx = VimTestContext::new(cx, true).await;
970 cx.set_state(
971 indoc! {"
972 fn boop() {
973 baz(ˇ|a, b| { bar(|j, k| { })})
974 }"
975 },
976 Mode::Normal,
977 );
978 cx.simulate_keystrokes(["c", "i", "|"]);
979 cx.assert_state(
980 indoc! {"
981 fn boop() {
982 baz(|ˇ| { bar(|j, k| { })})
983 }"
984 },
985 Mode::Insert,
986 );
987 cx.simulate_keystrokes(["escape", "1", "8", "|"]);
988 cx.assert_state(
989 indoc! {"
990 fn boop() {
991 baz(|| { bar(ˇ|j, k| { })})
992 }"
993 },
994 Mode::Normal,
995 );
996
997 cx.simulate_keystrokes(["v", "a", "|"]);
998 cx.assert_state(
999 indoc! {"
1000 fn boop() {
1001 baz(|| { bar(«|j, k| ˇ»{ })})
1002 }"
1003 },
1004 Mode::Visual,
1005 );
1006 }
1007
1008 #[gpui::test]
1009 async fn test_delete_surrounding_character_objects(cx: &mut gpui::TestAppContext) {
1010 let mut cx = NeovimBackedTestContext::new(cx).await;
1011
1012 for (start, end) in SURROUNDING_OBJECTS {
1013 let marked_string = SURROUNDING_MARKER_STRING
1014 .replace('`', &start.to_string())
1015 .replace('\'', &end.to_string());
1016
1017 cx.assert_binding_matches_all(["d", "i", &start.to_string()], &marked_string)
1018 .await;
1019 cx.assert_binding_matches_all(["d", "i", &end.to_string()], &marked_string)
1020 .await;
1021 cx.assert_binding_matches_all(["d", "a", &start.to_string()], &marked_string)
1022 .await;
1023 cx.assert_binding_matches_all(["d", "a", &end.to_string()], &marked_string)
1024 .await;
1025 }
1026 }
1027}