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