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