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 start = movement::find_preceding_boundary_in_line(
181 map,
182 right(map, relative_to, 1),
183 |left, right| {
184 char_kind(left).coerce_punctuation(ignore_punctuation)
185 != char_kind(right).coerce_punctuation(ignore_punctuation)
186 },
187 );
188 let end = movement::find_boundary_in_line(map, relative_to, |left, right| {
189 char_kind(left).coerce_punctuation(ignore_punctuation)
190 != char_kind(right).coerce_punctuation(ignore_punctuation)
191 });
192
193 Some(start..end)
194}
195
196/// Return a range that surrounds the word and following whitespace
197/// relative_to is in.
198/// If relative_to is at the start of a word, return the word and following whitespace.
199/// If relative_to is between words, return the whitespace back and the following word
200
201/// if in word
202/// delete that word
203/// if there is whitespace following the word, delete that as well
204/// otherwise, delete any preceding whitespace
205/// otherwise
206/// delete whitespace around cursor
207/// delete word following the cursor
208fn around_word(
209 map: &DisplaySnapshot,
210 relative_to: DisplayPoint,
211 ignore_punctuation: bool,
212) -> Option<Range<DisplayPoint>> {
213 let in_word = map
214 .chars_at(relative_to)
215 .next()
216 .map(|(c, _)| char_kind(c) != CharKind::Whitespace)
217 .unwrap_or(false);
218
219 if in_word {
220 around_containing_word(map, relative_to, ignore_punctuation)
221 } else {
222 around_next_word(map, relative_to, ignore_punctuation)
223 }
224}
225
226fn around_containing_word(
227 map: &DisplaySnapshot,
228 relative_to: DisplayPoint,
229 ignore_punctuation: bool,
230) -> Option<Range<DisplayPoint>> {
231 in_word(map, relative_to, ignore_punctuation)
232 .map(|range| expand_to_include_whitespace(map, range, true))
233}
234
235fn around_next_word(
236 map: &DisplaySnapshot,
237 relative_to: DisplayPoint,
238 ignore_punctuation: bool,
239) -> Option<Range<DisplayPoint>> {
240 // Get the start of the word
241 let start = movement::find_preceding_boundary_in_line(
242 map,
243 right(map, relative_to, 1),
244 |left, right| {
245 char_kind(left).coerce_punctuation(ignore_punctuation)
246 != char_kind(right).coerce_punctuation(ignore_punctuation)
247 },
248 );
249
250 let mut word_found = false;
251 let end = movement::find_boundary(map, relative_to, |left, right| {
252 let left_kind = char_kind(left).coerce_punctuation(ignore_punctuation);
253 let right_kind = char_kind(right).coerce_punctuation(ignore_punctuation);
254
255 let found = (word_found && left_kind != right_kind) || right == '\n' && left == '\n';
256
257 if right_kind != CharKind::Whitespace {
258 word_found = true;
259 }
260
261 found
262 });
263
264 Some(start..end)
265}
266
267fn sentence(
268 map: &DisplaySnapshot,
269 relative_to: DisplayPoint,
270 around: bool,
271) -> Option<Range<DisplayPoint>> {
272 let mut start = None;
273 let mut previous_end = relative_to;
274
275 let mut chars = map.chars_at(relative_to).peekable();
276
277 // Search backwards for the previous sentence end or current sentence start. Include the character under relative_to
278 for (char, point) in chars
279 .peek()
280 .cloned()
281 .into_iter()
282 .chain(map.reverse_chars_at(relative_to))
283 {
284 if is_sentence_end(map, point) {
285 break;
286 }
287
288 if is_possible_sentence_start(char) {
289 start = Some(point);
290 }
291
292 previous_end = point;
293 }
294
295 // Search forward for the end of the current sentence or if we are between sentences, the start of the next one
296 let mut end = relative_to;
297 for (char, point) in chars {
298 if start.is_none() && is_possible_sentence_start(char) {
299 if around {
300 start = Some(point);
301 continue;
302 } else {
303 end = point;
304 break;
305 }
306 }
307
308 end = point;
309 *end.column_mut() += char.len_utf8() as u32;
310 end = map.clip_point(end, Bias::Left);
311
312 if is_sentence_end(map, end) {
313 break;
314 }
315 }
316
317 let mut range = start.unwrap_or(previous_end)..end;
318 if around {
319 range = expand_to_include_whitespace(map, range, false);
320 }
321
322 Some(range)
323}
324
325fn is_possible_sentence_start(character: char) -> bool {
326 !character.is_whitespace() && character != '.'
327}
328
329const SENTENCE_END_PUNCTUATION: &[char] = &['.', '!', '?'];
330const SENTENCE_END_FILLERS: &[char] = &[')', ']', '"', '\''];
331const SENTENCE_END_WHITESPACE: &[char] = &[' ', '\t', '\n'];
332fn is_sentence_end(map: &DisplaySnapshot, point: DisplayPoint) -> bool {
333 let mut next_chars = map.chars_at(point).peekable();
334 if let Some((char, _)) = next_chars.next() {
335 // We are at a double newline. This position is a sentence end.
336 if char == '\n' && next_chars.peek().map(|(c, _)| c == &'\n').unwrap_or(false) {
337 return true;
338 }
339
340 // The next text is not a valid whitespace. This is not a sentence end
341 if !SENTENCE_END_WHITESPACE.contains(&char) {
342 return false;
343 }
344 }
345
346 for (char, _) in map.reverse_chars_at(point) {
347 if SENTENCE_END_PUNCTUATION.contains(&char) {
348 return true;
349 }
350
351 if !SENTENCE_END_FILLERS.contains(&char) {
352 return false;
353 }
354 }
355
356 return false;
357}
358
359/// Expands the passed range to include whitespace on one side or the other in a line. Attempts to add the
360/// whitespace to the end first and falls back to the start if there was none.
361fn expand_to_include_whitespace(
362 map: &DisplaySnapshot,
363 mut range: Range<DisplayPoint>,
364 stop_at_newline: bool,
365) -> Range<DisplayPoint> {
366 let mut whitespace_included = false;
367
368 let mut chars = map.chars_at(range.end).peekable();
369 while let Some((char, point)) = chars.next() {
370 if char == '\n' && stop_at_newline {
371 break;
372 }
373
374 if char.is_whitespace() {
375 // Set end to the next display_point or the character position after the current display_point
376 range.end = chars.peek().map(|(_, point)| *point).unwrap_or_else(|| {
377 let mut end = point;
378 *end.column_mut() += char.len_utf8() as u32;
379 map.clip_point(end, Bias::Left)
380 });
381
382 if char != '\n' {
383 whitespace_included = true;
384 }
385 } else {
386 // Found non whitespace. Quit out.
387 break;
388 }
389 }
390
391 if !whitespace_included {
392 for (char, point) in map.reverse_chars_at(range.start) {
393 if char == '\n' && stop_at_newline {
394 break;
395 }
396
397 if !char.is_whitespace() {
398 break;
399 }
400
401 range.start = point;
402 }
403 }
404
405 range
406}
407
408fn surrounding_markers(
409 map: &DisplaySnapshot,
410 relative_to: DisplayPoint,
411 around: bool,
412 search_across_lines: bool,
413 start_marker: char,
414 end_marker: char,
415) -> Option<Range<DisplayPoint>> {
416 let mut matched_ends = 0;
417 let mut start = None;
418 for (char, mut point) in map.reverse_chars_at(relative_to) {
419 if char == start_marker {
420 if matched_ends > 0 {
421 matched_ends -= 1;
422 } else {
423 if around {
424 start = Some(point)
425 } else {
426 *point.column_mut() += char.len_utf8() as u32;
427 start = Some(point)
428 }
429 break;
430 }
431 } else if char == end_marker {
432 matched_ends += 1;
433 } else if char == '\n' && !search_across_lines {
434 break;
435 }
436 }
437
438 let mut matched_starts = 0;
439 let mut end = None;
440 for (char, mut point) in map.chars_at(relative_to) {
441 if char == end_marker {
442 if start.is_none() {
443 break;
444 }
445
446 if matched_starts > 0 {
447 matched_starts -= 1;
448 } else {
449 if around {
450 *point.column_mut() += char.len_utf8() as u32;
451 end = Some(point);
452 } else {
453 end = Some(point);
454 }
455
456 break;
457 }
458 }
459
460 if char == start_marker {
461 if start.is_none() {
462 if around {
463 start = Some(point);
464 } else {
465 *point.column_mut() += char.len_utf8() as u32;
466 start = Some(point);
467 }
468 } else {
469 matched_starts += 1;
470 }
471 }
472
473 if char == '\n' && !search_across_lines {
474 break;
475 }
476 }
477
478 let (Some(mut start), Some(mut end)) = (start, end) else {
479 return None;
480 };
481
482 if !around {
483 // if a block starts with a newline, move the start to after the newline.
484 let mut was_newline = false;
485 for (char, point) in map.chars_at(start) {
486 if was_newline {
487 start = point;
488 } else if char == '\n' {
489 was_newline = true;
490 continue;
491 }
492 break;
493 }
494 // if a block ends with a newline, then whitespace, then the delimeter,
495 // move the end to after the newline.
496 let mut new_end = end;
497 for (char, point) in map.reverse_chars_at(end) {
498 if char == '\n' {
499 end = new_end;
500 break;
501 }
502 if !char.is_whitespace() {
503 break;
504 }
505 new_end = point
506 }
507 }
508
509 Some(start..end)
510}
511
512#[cfg(test)]
513mod test {
514 use indoc::indoc;
515
516 use crate::test::{ExemptionFeatures, NeovimBackedTestContext};
517
518 const WORD_LOCATIONS: &'static str = indoc! {"
519 The quick ˇbrowˇnˇ•••
520 fox ˇjuˇmpsˇ over
521 the lazy dogˇ••
522 ˇ
523 ˇ
524 ˇ
525 Thˇeˇ-ˇquˇickˇ ˇbrownˇ•
526 ˇ••
527 ˇ••
528 ˇ fox-jumpˇs over
529 the lazy dogˇ•
530 ˇ
531 "
532 };
533
534 #[gpui::test]
535 async fn test_change_word_object(cx: &mut gpui::TestAppContext) {
536 let mut cx = NeovimBackedTestContext::new(cx).await;
537
538 cx.assert_binding_matches_all(["c", "i", "w"], WORD_LOCATIONS)
539 .await;
540 cx.assert_binding_matches_all(["c", "i", "shift-w"], WORD_LOCATIONS)
541 .await;
542 cx.assert_binding_matches_all(["c", "a", "w"], WORD_LOCATIONS)
543 .await;
544 cx.assert_binding_matches_all(["c", "a", "shift-w"], WORD_LOCATIONS)
545 .await;
546 }
547
548 #[gpui::test]
549 async fn test_delete_word_object(cx: &mut gpui::TestAppContext) {
550 let mut cx = NeovimBackedTestContext::new(cx).await;
551
552 cx.assert_binding_matches_all(["d", "i", "w"], WORD_LOCATIONS)
553 .await;
554 cx.assert_binding_matches_all(["d", "i", "shift-w"], WORD_LOCATIONS)
555 .await;
556 cx.assert_binding_matches_all(["d", "a", "w"], WORD_LOCATIONS)
557 .await;
558 cx.assert_binding_matches_all(["d", "a", "shift-w"], WORD_LOCATIONS)
559 .await;
560 }
561
562 #[gpui::test]
563 async fn test_visual_word_object(cx: &mut gpui::TestAppContext) {
564 let mut cx = NeovimBackedTestContext::new(cx).await;
565
566 cx.set_shared_state("The quick ˇbrown\nfox").await;
567 cx.simulate_shared_keystrokes(["v"]).await;
568 cx.assert_shared_state("The quick «bˇ»rown\nfox").await;
569 cx.simulate_shared_keystrokes(["i", "w"]).await;
570 cx.assert_shared_state("The quick «brownˇ»\nfox").await;
571
572 cx.assert_binding_matches_all(["v", "i", "w"], WORD_LOCATIONS)
573 .await;
574 cx.assert_binding_matches_all_exempted(
575 ["v", "h", "i", "w"],
576 WORD_LOCATIONS,
577 ExemptionFeatures::NonEmptyVisualTextObjects,
578 )
579 .await;
580 cx.assert_binding_matches_all_exempted(
581 ["v", "l", "i", "w"],
582 WORD_LOCATIONS,
583 ExemptionFeatures::NonEmptyVisualTextObjects,
584 )
585 .await;
586 cx.assert_binding_matches_all(["v", "i", "shift-w"], WORD_LOCATIONS)
587 .await;
588
589 cx.assert_binding_matches_all_exempted(
590 ["v", "i", "h", "shift-w"],
591 WORD_LOCATIONS,
592 ExemptionFeatures::NonEmptyVisualTextObjects,
593 )
594 .await;
595 cx.assert_binding_matches_all_exempted(
596 ["v", "i", "l", "shift-w"],
597 WORD_LOCATIONS,
598 ExemptionFeatures::NonEmptyVisualTextObjects,
599 )
600 .await;
601
602 cx.assert_binding_matches_all_exempted(
603 ["v", "a", "w"],
604 WORD_LOCATIONS,
605 ExemptionFeatures::AroundObjectLeavesWhitespaceAtEndOfLine,
606 )
607 .await;
608 cx.assert_binding_matches_all_exempted(
609 ["v", "a", "shift-w"],
610 WORD_LOCATIONS,
611 ExemptionFeatures::AroundObjectLeavesWhitespaceAtEndOfLine,
612 )
613 .await;
614 }
615
616 const SENTENCE_EXAMPLES: &[&'static str] = &[
617 "ˇThe quick ˇbrownˇ?ˇ ˇFox Jˇumpsˇ!ˇ Ovˇer theˇ lazyˇ.",
618 indoc! {"
619 ˇThe quick ˇbrownˇ
620 fox jumps over
621 the lazy doˇgˇ.ˇ ˇThe quick ˇ
622 brown fox jumps over
623 "},
624 indoc! {"
625 The quick brown fox jumps.
626 Over the lazy dog
627 ˇ
628 ˇ
629 ˇ fox-jumpˇs over
630 the lazy dog.ˇ
631 ˇ
632 "},
633 r#"ˇThe ˇquick brownˇ.)ˇ]ˇ'ˇ" Brown ˇfox jumpsˇ.ˇ "#,
634 ];
635
636 #[gpui::test]
637 async fn test_change_sentence_object(cx: &mut gpui::TestAppContext) {
638 let mut cx = NeovimBackedTestContext::new(cx)
639 .await
640 .binding(["c", "i", "s"]);
641 cx.add_initial_state_exemptions(
642 "The quick brown fox jumps.\nOver the lazy dog\nˇ\nˇ\n fox-jumps over\nthe lazy dog.\n\n",
643 ExemptionFeatures::SentenceOnEmptyLines);
644 cx.add_initial_state_exemptions(
645 "The quick brown fox jumps.\nOver the lazy dog\n\n\nˇ foxˇ-ˇjumpˇs over\nthe lazy dog.\n\n",
646 ExemptionFeatures::SentenceAtStartOfLineWithWhitespace);
647 cx.add_initial_state_exemptions(
648 "The quick brown fox jumps.\nOver the lazy dog\n\n\n fox-jumps over\nthe lazy dog.ˇ\nˇ\n",
649 ExemptionFeatures::SentenceAfterPunctuationAtEndOfFile);
650 for sentence_example in SENTENCE_EXAMPLES {
651 cx.assert_all(sentence_example).await;
652 }
653
654 let mut cx = cx.binding(["c", "a", "s"]);
655 cx.add_initial_state_exemptions(
656 "The quick brown?ˇ Fox Jumps! Over the lazy.",
657 ExemptionFeatures::IncorrectLandingPosition,
658 );
659 cx.add_initial_state_exemptions(
660 "The quick brown.)]\'\" Brown fox jumps.ˇ ",
661 ExemptionFeatures::AroundObjectLeavesWhitespaceAtEndOfLine,
662 );
663
664 for sentence_example in SENTENCE_EXAMPLES {
665 cx.assert_all(sentence_example).await;
666 }
667 }
668
669 #[gpui::test]
670 async fn test_delete_sentence_object(cx: &mut gpui::TestAppContext) {
671 let mut cx = NeovimBackedTestContext::new(cx)
672 .await
673 .binding(["d", "i", "s"]);
674 cx.add_initial_state_exemptions(
675 "The quick brown fox jumps.\nOver the lazy dog\nˇ\nˇ\n fox-jumps over\nthe lazy dog.\n\n",
676 ExemptionFeatures::SentenceOnEmptyLines);
677 cx.add_initial_state_exemptions(
678 "The quick brown fox jumps.\nOver the lazy dog\n\n\nˇ foxˇ-ˇjumpˇs over\nthe lazy dog.\n\n",
679 ExemptionFeatures::SentenceAtStartOfLineWithWhitespace);
680 cx.add_initial_state_exemptions(
681 "The quick brown fox jumps.\nOver the lazy dog\n\n\n fox-jumps over\nthe lazy dog.ˇ\nˇ\n",
682 ExemptionFeatures::SentenceAfterPunctuationAtEndOfFile);
683
684 for sentence_example in SENTENCE_EXAMPLES {
685 cx.assert_all(sentence_example).await;
686 }
687
688 let mut cx = cx.binding(["d", "a", "s"]);
689 cx.add_initial_state_exemptions(
690 "The quick brown?ˇ Fox Jumps! Over the lazy.",
691 ExemptionFeatures::IncorrectLandingPosition,
692 );
693 cx.add_initial_state_exemptions(
694 "The quick brown.)]\'\" Brown fox jumps.ˇ ",
695 ExemptionFeatures::AroundObjectLeavesWhitespaceAtEndOfLine,
696 );
697
698 for sentence_example in SENTENCE_EXAMPLES {
699 cx.assert_all(sentence_example).await;
700 }
701 }
702
703 #[gpui::test]
704 async fn test_visual_sentence_object(cx: &mut gpui::TestAppContext) {
705 let mut cx = NeovimBackedTestContext::new(cx)
706 .await
707 .binding(["v", "i", "s"]);
708 for sentence_example in SENTENCE_EXAMPLES {
709 cx.assert_all_exempted(sentence_example, ExemptionFeatures::SentenceOnEmptyLines)
710 .await;
711 }
712
713 let mut cx = cx.binding(["v", "a", "s"]);
714 for sentence_example in SENTENCE_EXAMPLES {
715 cx.assert_all_exempted(
716 sentence_example,
717 ExemptionFeatures::AroundSentenceStartingBetweenIncludesWrongWhitespace,
718 )
719 .await;
720 }
721 }
722
723 // Test string with "`" for opening surrounders and "'" for closing surrounders
724 const SURROUNDING_MARKER_STRING: &str = indoc! {"
725 ˇTh'ˇe ˇ`ˇ'ˇquˇi`ˇck broˇ'wn`
726 'ˇfox juˇmps ovˇ`ˇer
727 the ˇlazy dˇ'ˇoˇ`ˇg"};
728
729 const SURROUNDING_OBJECTS: &[(char, char)] = &[
730 ('\'', '\''), // Quote
731 ('`', '`'), // Back Quote
732 ('"', '"'), // Double Quote
733 ('(', ')'), // Parentheses
734 ('[', ']'), // SquareBrackets
735 ('{', '}'), // CurlyBrackets
736 ('<', '>'), // AngleBrackets
737 ];
738
739 #[gpui::test]
740 async fn test_change_surrounding_character_objects(cx: &mut gpui::TestAppContext) {
741 let mut cx = NeovimBackedTestContext::new(cx).await;
742
743 for (start, end) in SURROUNDING_OBJECTS {
744 if ((start == &'\'' || start == &'`' || start == &'"')
745 && !ExemptionFeatures::QuotesSeekForward.supported())
746 || (start == &'<' && !ExemptionFeatures::AngleBracketsFreezeNeovim.supported())
747 {
748 continue;
749 }
750
751 let marked_string = SURROUNDING_MARKER_STRING
752 .replace('`', &start.to_string())
753 .replace('\'', &end.to_string());
754
755 cx.assert_binding_matches_all(["c", "i", &start.to_string()], &marked_string)
756 .await;
757 cx.assert_binding_matches_all(["c", "i", &end.to_string()], &marked_string)
758 .await;
759 cx.assert_binding_matches_all(["c", "a", &start.to_string()], &marked_string)
760 .await;
761 cx.assert_binding_matches_all(["c", "a", &end.to_string()], &marked_string)
762 .await;
763 }
764 }
765
766 #[gpui::test]
767 async fn test_multiline_surrounding_character_objects(cx: &mut gpui::TestAppContext) {
768 let mut cx = NeovimBackedTestContext::new(cx).await;
769
770 cx.set_shared_state(indoc! {
771 "func empty(a string) bool {
772 if a == \"\" {
773 return true
774 }
775 ˇreturn false
776 }"
777 })
778 .await;
779 cx.simulate_shared_keystrokes(["v", "i", "{"]).await;
780 cx.assert_shared_state(indoc! {"
781 func empty(a string) bool {
782 « if a == \"\" {
783 return true
784 }
785 return false
786 ˇ»}"})
787 .await;
788 cx.set_shared_state(indoc! {
789 "func empty(a string) bool {
790 if a == \"\" {
791 ˇreturn true
792 }
793 return false
794 }"
795 })
796 .await;
797 cx.simulate_shared_keystrokes(["v", "i", "{"]).await;
798 cx.assert_shared_state(indoc! {"
799 func empty(a string) bool {
800 if a == \"\" {
801 « return true
802 ˇ» }
803 return false
804 }"})
805 .await;
806 }
807
808 #[gpui::test]
809 async fn test_delete_surrounding_character_objects(cx: &mut gpui::TestAppContext) {
810 let mut cx = NeovimBackedTestContext::new(cx).await;
811
812 for (start, end) in SURROUNDING_OBJECTS {
813 if ((start == &'\'' || start == &'`' || start == &'"')
814 && !ExemptionFeatures::QuotesSeekForward.supported())
815 || (start == &'<' && !ExemptionFeatures::AngleBracketsFreezeNeovim.supported())
816 {
817 continue;
818 }
819 let marked_string = SURROUNDING_MARKER_STRING
820 .replace('`', &start.to_string())
821 .replace('\'', &end.to_string());
822
823 cx.assert_binding_matches_all(["d", "i", &start.to_string()], &marked_string)
824 .await;
825 cx.assert_binding_matches_all(["d", "i", &end.to_string()], &marked_string)
826 .await;
827 cx.assert_binding_matches_all(["d", "a", &start.to_string()], &marked_string)
828 .await;
829 cx.assert_binding_matches_all(["d", "a", &end.to_string()], &marked_string)
830 .await;
831 }
832 }
833}