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