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