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