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