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