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, BufferSnapshot, 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 Argument,
31}
32
33#[derive(Clone, Deserialize, PartialEq)]
34#[serde(rename_all = "camelCase")]
35struct Word {
36 #[serde(default)]
37 ignore_punctuation: bool,
38}
39
40impl_actions!(vim, [Word]);
41
42actions!(
43 vim,
44 [
45 Sentence,
46 Quotes,
47 BackQuotes,
48 DoubleQuotes,
49 VerticalBars,
50 Parentheses,
51 SquareBrackets,
52 CurlyBrackets,
53 AngleBrackets,
54 Argument
55 ]
56);
57
58pub fn register(workspace: &mut Workspace, _: &mut ViewContext<Workspace>) {
59 workspace.register_action(
60 |_: &mut Workspace, &Word { ignore_punctuation }: &Word, cx: _| {
61 object(Object::Word { ignore_punctuation }, cx)
62 },
63 );
64 workspace
65 .register_action(|_: &mut Workspace, _: &Sentence, cx: _| object(Object::Sentence, cx));
66 workspace.register_action(|_: &mut Workspace, _: &Quotes, cx: _| object(Object::Quotes, cx));
67 workspace
68 .register_action(|_: &mut Workspace, _: &BackQuotes, cx: _| object(Object::BackQuotes, cx));
69 workspace.register_action(|_: &mut Workspace, _: &DoubleQuotes, cx: _| {
70 object(Object::DoubleQuotes, cx)
71 });
72 workspace.register_action(|_: &mut Workspace, _: &Parentheses, cx: _| {
73 object(Object::Parentheses, cx)
74 });
75 workspace.register_action(|_: &mut Workspace, _: &SquareBrackets, cx: _| {
76 object(Object::SquareBrackets, cx)
77 });
78 workspace.register_action(|_: &mut Workspace, _: &CurlyBrackets, cx: _| {
79 object(Object::CurlyBrackets, cx)
80 });
81 workspace.register_action(|_: &mut Workspace, _: &AngleBrackets, cx: _| {
82 object(Object::AngleBrackets, cx)
83 });
84 workspace.register_action(|_: &mut Workspace, _: &VerticalBars, cx: _| {
85 object(Object::VerticalBars, cx)
86 });
87 workspace
88 .register_action(|_: &mut Workspace, _: &Argument, cx: _| object(Object::Argument, cx));
89}
90
91fn object(object: Object, cx: &mut WindowContext) {
92 match Vim::read(cx).state().mode {
93 Mode::Normal => normal_object(object, cx),
94 Mode::Visual | Mode::VisualLine | Mode::VisualBlock => visual_object(object, cx),
95 Mode::Insert => {
96 // Shouldn't execute a text object in insert mode. Ignoring
97 }
98 }
99}
100
101impl Object {
102 pub fn is_multiline(self) -> bool {
103 match self {
104 Object::Word { .. }
105 | Object::Quotes
106 | Object::BackQuotes
107 | Object::VerticalBars
108 | Object::DoubleQuotes => false,
109 Object::Sentence
110 | Object::Parentheses
111 | Object::AngleBrackets
112 | Object::CurlyBrackets
113 | Object::SquareBrackets
114 | Object::Argument => true,
115 }
116 }
117
118 pub fn always_expands_both_ways(self) -> bool {
119 match self {
120 Object::Word { .. } | Object::Sentence | Object::Argument => false,
121 Object::Quotes
122 | Object::BackQuotes
123 | Object::DoubleQuotes
124 | Object::VerticalBars
125 | Object::Parentheses
126 | Object::SquareBrackets
127 | Object::CurlyBrackets
128 | Object::AngleBrackets => true,
129 }
130 }
131
132 pub fn target_visual_mode(self, current_mode: Mode) -> Mode {
133 match self {
134 Object::Word { .. }
135 | Object::Sentence
136 | Object::Quotes
137 | Object::BackQuotes
138 | Object::DoubleQuotes => {
139 if current_mode == Mode::VisualBlock {
140 Mode::VisualBlock
141 } else {
142 Mode::Visual
143 }
144 }
145 Object::Parentheses
146 | Object::SquareBrackets
147 | Object::CurlyBrackets
148 | Object::AngleBrackets
149 | Object::VerticalBars
150 | Object::Argument => Mode::Visual,
151 }
152 }
153
154 pub fn range(
155 self,
156 map: &DisplaySnapshot,
157 relative_to: DisplayPoint,
158 around: bool,
159 ) -> Option<Range<DisplayPoint>> {
160 match self {
161 Object::Word { ignore_punctuation } => {
162 if around {
163 around_word(map, relative_to, ignore_punctuation)
164 } else {
165 in_word(map, relative_to, ignore_punctuation)
166 }
167 }
168 Object::Sentence => sentence(map, relative_to, around),
169 Object::Quotes => {
170 surrounding_markers(map, relative_to, around, self.is_multiline(), '\'', '\'')
171 }
172 Object::BackQuotes => {
173 surrounding_markers(map, relative_to, around, self.is_multiline(), '`', '`')
174 }
175 Object::DoubleQuotes => {
176 surrounding_markers(map, relative_to, around, self.is_multiline(), '"', '"')
177 }
178 Object::VerticalBars => {
179 surrounding_markers(map, relative_to, around, self.is_multiline(), '|', '|')
180 }
181 Object::Parentheses => {
182 surrounding_markers(map, relative_to, around, self.is_multiline(), '(', ')')
183 }
184 Object::SquareBrackets => {
185 surrounding_markers(map, relative_to, around, self.is_multiline(), '[', ']')
186 }
187 Object::CurlyBrackets => {
188 surrounding_markers(map, relative_to, around, self.is_multiline(), '{', '}')
189 }
190 Object::AngleBrackets => {
191 surrounding_markers(map, relative_to, around, self.is_multiline(), '<', '>')
192 }
193 Object::Argument => argument(map, relative_to, around),
194 }
195 }
196
197 pub fn expand_selection(
198 self,
199 map: &DisplaySnapshot,
200 selection: &mut Selection<DisplayPoint>,
201 around: bool,
202 ) -> bool {
203 if let Some(range) = self.range(map, selection.head(), around) {
204 selection.start = range.start;
205 selection.end = range.end;
206 true
207 } else {
208 false
209 }
210 }
211}
212
213/// Returns a range that surrounds the word `relative_to` is in.
214///
215/// If `relative_to` is at the start of a word, return the word.
216/// If `relative_to` is between words, return the space between.
217fn in_word(
218 map: &DisplaySnapshot,
219 relative_to: DisplayPoint,
220 ignore_punctuation: bool,
221) -> Option<Range<DisplayPoint>> {
222 // Use motion::right so that we consider the character under the cursor when looking for the start
223 let scope = map
224 .buffer_snapshot
225 .language_scope_at(relative_to.to_point(map));
226 let start = movement::find_preceding_boundary_display_point(
227 map,
228 right(map, relative_to, 1),
229 movement::FindRange::SingleLine,
230 |left, right| {
231 coerce_punctuation(char_kind(&scope, left), ignore_punctuation)
232 != coerce_punctuation(char_kind(&scope, right), ignore_punctuation)
233 },
234 );
235
236 let end = movement::find_boundary(map, relative_to, FindRange::SingleLine, |left, right| {
237 coerce_punctuation(char_kind(&scope, left), ignore_punctuation)
238 != coerce_punctuation(char_kind(&scope, right), ignore_punctuation)
239 });
240
241 Some(start..end)
242}
243
244/// Returns a range that surrounds the word and following whitespace
245/// relative_to is in.
246///
247/// If `relative_to` is at the start of a word, return the word and following whitespace.
248/// If `relative_to` is between words, return the whitespace back and the following word.
249///
250/// if in word
251/// delete that word
252/// if there is whitespace following the word, delete that as well
253/// otherwise, delete any preceding whitespace
254/// otherwise
255/// delete whitespace around cursor
256/// delete word following the cursor
257fn around_word(
258 map: &DisplaySnapshot,
259 relative_to: DisplayPoint,
260 ignore_punctuation: bool,
261) -> Option<Range<DisplayPoint>> {
262 let scope = map
263 .buffer_snapshot
264 .language_scope_at(relative_to.to_point(map));
265 let in_word = map
266 .chars_at(relative_to)
267 .next()
268 .map(|(c, _)| char_kind(&scope, c) != CharKind::Whitespace)
269 .unwrap_or(false);
270
271 if in_word {
272 around_containing_word(map, relative_to, ignore_punctuation)
273 } else {
274 around_next_word(map, relative_to, ignore_punctuation)
275 }
276}
277
278fn around_containing_word(
279 map: &DisplaySnapshot,
280 relative_to: DisplayPoint,
281 ignore_punctuation: bool,
282) -> Option<Range<DisplayPoint>> {
283 in_word(map, relative_to, ignore_punctuation)
284 .map(|range| expand_to_include_whitespace(map, range, true))
285}
286
287fn around_next_word(
288 map: &DisplaySnapshot,
289 relative_to: DisplayPoint,
290 ignore_punctuation: bool,
291) -> Option<Range<DisplayPoint>> {
292 let scope = map
293 .buffer_snapshot
294 .language_scope_at(relative_to.to_point(map));
295 // Get the start of the word
296 let start = movement::find_preceding_boundary_display_point(
297 map,
298 right(map, relative_to, 1),
299 FindRange::SingleLine,
300 |left, right| {
301 coerce_punctuation(char_kind(&scope, left), ignore_punctuation)
302 != coerce_punctuation(char_kind(&scope, right), ignore_punctuation)
303 },
304 );
305
306 let mut word_found = false;
307 let end = movement::find_boundary(map, relative_to, FindRange::MultiLine, |left, right| {
308 let left_kind = coerce_punctuation(char_kind(&scope, left), ignore_punctuation);
309 let right_kind = coerce_punctuation(char_kind(&scope, right), ignore_punctuation);
310
311 let found = (word_found && left_kind != right_kind) || right == '\n' && left == '\n';
312
313 if right_kind != CharKind::Whitespace {
314 word_found = true;
315 }
316
317 found
318 });
319
320 Some(start..end)
321}
322
323fn argument(
324 map: &DisplaySnapshot,
325 relative_to: DisplayPoint,
326 around: bool,
327) -> Option<Range<DisplayPoint>> {
328 let snapshot = &map.buffer_snapshot;
329 let offset = relative_to.to_offset(map, Bias::Left);
330
331 // The `argument` vim text object uses the syntax tree, so we operate at the buffer level and map back to the display level
332 let excerpt = snapshot.excerpt_containing(offset..offset)?;
333 let buffer = excerpt.buffer();
334
335 fn comma_delimited_range_at(
336 buffer: &BufferSnapshot,
337 mut offset: usize,
338 include_comma: bool,
339 ) -> Option<Range<usize>> {
340 // Seek to the first non-whitespace character
341 offset += buffer
342 .chars_at(offset)
343 .take_while(|c| c.is_whitespace())
344 .map(char::len_utf8)
345 .sum::<usize>();
346
347 let bracket_filter = |open: Range<usize>, close: Range<usize>| {
348 // Filter out empty ranges
349 if open.end == close.start {
350 return false;
351 }
352
353 // If the cursor is outside the brackets, ignore them
354 if open.start == offset || close.end == offset {
355 return false;
356 }
357
358 // TODO: Is there any better way to filter out string brackets?
359 // Used to filter out string brackets
360 return matches!(
361 buffer.chars_at(open.start).next(),
362 Some('(' | '[' | '{' | '<' | '|')
363 );
364 };
365
366 // Find the brackets containing the cursor
367 let (open_bracket, close_bracket) =
368 buffer.innermost_enclosing_bracket_ranges(offset..offset, Some(&bracket_filter))?;
369
370 let inner_bracket_range = open_bracket.end..close_bracket.start;
371
372 let layer = buffer.syntax_layer_at(offset)?;
373 let node = layer.node();
374 let mut cursor = node.walk();
375
376 // Loop until we find the smallest node whose parent covers the bracket range. This node is the argument in the parent argument list
377 let mut parent_covers_bracket_range = false;
378 loop {
379 let node = cursor.node();
380 let range = node.byte_range();
381 let covers_bracket_range =
382 range.start == open_bracket.start && range.end == close_bracket.end;
383 if parent_covers_bracket_range && !covers_bracket_range {
384 break;
385 }
386 parent_covers_bracket_range = covers_bracket_range;
387
388 // Unable to find a child node with a parent that covers the bracket range, so no argument to select
389 if !cursor.goto_first_child_for_byte(offset).is_some() {
390 return None;
391 }
392 }
393
394 let mut argument_node = cursor.node();
395
396 // If the child node is the open bracket, move to the next sibling.
397 if argument_node.byte_range() == open_bracket {
398 if !cursor.goto_next_sibling() {
399 return Some(inner_bracket_range);
400 }
401 argument_node = cursor.node();
402 }
403 // While the child node is the close bracket or a comma, move to the previous sibling
404 while argument_node.byte_range() == close_bracket || argument_node.kind() == "," {
405 if !cursor.goto_previous_sibling() {
406 return Some(inner_bracket_range);
407 }
408 argument_node = cursor.node();
409 if argument_node.byte_range() == open_bracket {
410 return Some(inner_bracket_range);
411 }
412 }
413
414 // The start and end of the argument range, defaulting to the start and end of the argument node
415 let mut start = argument_node.start_byte();
416 let mut end = argument_node.end_byte();
417
418 let mut needs_surrounding_comma = include_comma;
419
420 // Seek backwards to find the start of the argument - either the previous comma or the opening bracket.
421 // We do this because multiple nodes can represent a single argument, such as with rust `vec![a.b.c, d.e.f]`
422 while cursor.goto_previous_sibling() {
423 let prev = cursor.node();
424
425 if prev.start_byte() < open_bracket.end {
426 start = open_bracket.end;
427 break;
428 } else if prev.kind() == "," {
429 if needs_surrounding_comma {
430 start = prev.start_byte();
431 needs_surrounding_comma = false;
432 }
433 break;
434 } else if prev.start_byte() < start {
435 start = prev.start_byte();
436 }
437 }
438
439 // Do the same for the end of the argument, extending to next comma or the end of the argument list
440 while cursor.goto_next_sibling() {
441 let next = cursor.node();
442
443 if next.end_byte() > close_bracket.start {
444 end = close_bracket.start;
445 break;
446 } else if next.kind() == "," {
447 if needs_surrounding_comma {
448 // Select up to the beginning of the next argument if there is one, otherwise to the end of the comma
449 if let Some(next_arg) = next.next_sibling() {
450 end = next_arg.start_byte();
451 } else {
452 end = next.end_byte();
453 }
454 }
455 break;
456 } else if next.end_byte() > end {
457 end = next.end_byte();
458 }
459 }
460
461 Some(start..end)
462 }
463
464 let result = comma_delimited_range_at(buffer, excerpt.map_offset_to_buffer(offset), around)?;
465
466 if excerpt.contains_buffer_range(result.clone()) {
467 let result = excerpt.map_range_from_buffer(result);
468 Some(result.start.to_display_point(map)..result.end.to_display_point(map))
469 } else {
470 None
471 }
472}
473
474fn sentence(
475 map: &DisplaySnapshot,
476 relative_to: DisplayPoint,
477 around: bool,
478) -> Option<Range<DisplayPoint>> {
479 let mut start = None;
480 let mut previous_end = relative_to;
481
482 let mut chars = map.chars_at(relative_to).peekable();
483
484 // Search backwards for the previous sentence end or current sentence start. Include the character under relative_to
485 for (char, point) in chars
486 .peek()
487 .cloned()
488 .into_iter()
489 .chain(map.reverse_chars_at(relative_to))
490 {
491 if is_sentence_end(map, point) {
492 break;
493 }
494
495 if is_possible_sentence_start(char) {
496 start = Some(point);
497 }
498
499 previous_end = point;
500 }
501
502 // Search forward for the end of the current sentence or if we are between sentences, the start of the next one
503 let mut end = relative_to;
504 for (char, point) in chars {
505 if start.is_none() && is_possible_sentence_start(char) {
506 if around {
507 start = Some(point);
508 continue;
509 } else {
510 end = point;
511 break;
512 }
513 }
514
515 end = point;
516 *end.column_mut() += char.len_utf8() as u32;
517 end = map.clip_point(end, Bias::Left);
518
519 if is_sentence_end(map, end) {
520 break;
521 }
522 }
523
524 let mut range = start.unwrap_or(previous_end)..end;
525 if around {
526 range = expand_to_include_whitespace(map, range, false);
527 }
528
529 Some(range)
530}
531
532fn is_possible_sentence_start(character: char) -> bool {
533 !character.is_whitespace() && character != '.'
534}
535
536const SENTENCE_END_PUNCTUATION: &[char] = &['.', '!', '?'];
537const SENTENCE_END_FILLERS: &[char] = &[')', ']', '"', '\''];
538const SENTENCE_END_WHITESPACE: &[char] = &[' ', '\t', '\n'];
539fn is_sentence_end(map: &DisplaySnapshot, point: DisplayPoint) -> bool {
540 let mut next_chars = map.chars_at(point).peekable();
541 if let Some((char, _)) = next_chars.next() {
542 // We are at a double newline. This position is a sentence end.
543 if char == '\n' && next_chars.peek().map(|(c, _)| c == &'\n').unwrap_or(false) {
544 return true;
545 }
546
547 // The next text is not a valid whitespace. This is not a sentence end
548 if !SENTENCE_END_WHITESPACE.contains(&char) {
549 return false;
550 }
551 }
552
553 for (char, _) in map.reverse_chars_at(point) {
554 if SENTENCE_END_PUNCTUATION.contains(&char) {
555 return true;
556 }
557
558 if !SENTENCE_END_FILLERS.contains(&char) {
559 return false;
560 }
561 }
562
563 return false;
564}
565
566/// Expands the passed range to include whitespace on one side or the other in a line. Attempts to add the
567/// whitespace to the end first and falls back to the start if there was none.
568fn expand_to_include_whitespace(
569 map: &DisplaySnapshot,
570 mut range: Range<DisplayPoint>,
571 stop_at_newline: bool,
572) -> Range<DisplayPoint> {
573 let mut whitespace_included = false;
574
575 let mut chars = map.chars_at(range.end).peekable();
576 while let Some((char, point)) = chars.next() {
577 if char == '\n' && stop_at_newline {
578 break;
579 }
580
581 if char.is_whitespace() {
582 // Set end to the next display_point or the character position after the current display_point
583 range.end = chars.peek().map(|(_, point)| *point).unwrap_or_else(|| {
584 let mut end = point;
585 *end.column_mut() += char.len_utf8() as u32;
586 map.clip_point(end, Bias::Left)
587 });
588
589 if char != '\n' {
590 whitespace_included = true;
591 }
592 } else {
593 // Found non whitespace. Quit out.
594 break;
595 }
596 }
597
598 if !whitespace_included {
599 for (char, point) in map.reverse_chars_at(range.start) {
600 if char == '\n' && stop_at_newline {
601 break;
602 }
603
604 if !char.is_whitespace() {
605 break;
606 }
607
608 range.start = point;
609 }
610 }
611
612 range
613}
614
615fn surrounding_markers(
616 map: &DisplaySnapshot,
617 relative_to: DisplayPoint,
618 around: bool,
619 search_across_lines: bool,
620 open_marker: char,
621 close_marker: char,
622) -> Option<Range<DisplayPoint>> {
623 let point = relative_to.to_offset(map, Bias::Left);
624
625 let mut matched_closes = 0;
626 let mut opening = None;
627
628 if let Some((ch, range)) = movement::chars_after(map, point).next() {
629 if ch == open_marker {
630 if open_marker == close_marker {
631 let mut total = 0;
632 for (ch, _) in movement::chars_before(map, point) {
633 if ch == '\n' {
634 break;
635 }
636 if ch == open_marker {
637 total += 1;
638 }
639 }
640 if total % 2 == 0 {
641 opening = Some(range)
642 }
643 } else {
644 opening = Some(range)
645 }
646 }
647 }
648
649 if opening.is_none() {
650 for (ch, range) in movement::chars_before(map, point) {
651 if ch == '\n' && !search_across_lines {
652 break;
653 }
654
655 if ch == open_marker {
656 if matched_closes == 0 {
657 opening = Some(range);
658 break;
659 }
660 matched_closes -= 1;
661 } else if ch == close_marker {
662 matched_closes += 1
663 }
664 }
665 }
666
667 if opening.is_none() {
668 for (ch, range) in movement::chars_after(map, point) {
669 if ch == open_marker {
670 opening = Some(range);
671 break;
672 } else if ch == close_marker {
673 break;
674 }
675 }
676 }
677
678 let Some(mut opening) = opening else {
679 return None;
680 };
681
682 let mut matched_opens = 0;
683 let mut closing = None;
684
685 for (ch, range) in movement::chars_after(map, opening.end) {
686 if ch == '\n' && !search_across_lines {
687 break;
688 }
689
690 if ch == close_marker {
691 if matched_opens == 0 {
692 closing = Some(range);
693 break;
694 }
695 matched_opens -= 1;
696 } else if ch == open_marker {
697 matched_opens += 1;
698 }
699 }
700
701 let Some(mut closing) = closing else {
702 return None;
703 };
704
705 if around && !search_across_lines {
706 let mut found = false;
707
708 for (ch, range) in movement::chars_after(map, closing.end) {
709 if ch.is_whitespace() && ch != '\n' {
710 found = true;
711 closing.end = range.end;
712 } else {
713 break;
714 }
715 }
716
717 if !found {
718 for (ch, range) in movement::chars_before(map, opening.start) {
719 if ch.is_whitespace() && ch != '\n' {
720 opening.start = range.start
721 } else {
722 break;
723 }
724 }
725 }
726 }
727
728 if !around && search_across_lines {
729 if let Some((ch, range)) = movement::chars_after(map, opening.end).next() {
730 if ch == '\n' {
731 opening.end = range.end
732 }
733 }
734
735 for (ch, range) in movement::chars_before(map, closing.start) {
736 if !ch.is_whitespace() {
737 break;
738 }
739 if ch != '\n' {
740 closing.start = range.start
741 }
742 }
743 }
744
745 let result = if around {
746 opening.start..closing.end
747 } else {
748 opening.end..closing.start
749 };
750
751 Some(
752 map.clip_point(result.start.to_display_point(map), Bias::Left)
753 ..map.clip_point(result.end.to_display_point(map), Bias::Right),
754 )
755}
756
757#[cfg(test)]
758mod test {
759 use indoc::indoc;
760
761 use crate::{
762 state::Mode,
763 test::{ExemptionFeatures, NeovimBackedTestContext, VimTestContext},
764 };
765
766 const WORD_LOCATIONS: &'static str = indoc! {"
767 The quick ˇbrowˇnˇ•••
768 fox ˇjuˇmpsˇ over
769 the lazy dogˇ••
770 ˇ
771 ˇ
772 ˇ
773 Thˇeˇ-ˇquˇickˇ ˇbrownˇ•
774 ˇ••
775 ˇ••
776 ˇ fox-jumpˇs over
777 the lazy dogˇ•
778 ˇ
779 "
780 };
781
782 #[gpui::test]
783 async fn test_change_word_object(cx: &mut gpui::TestAppContext) {
784 let mut cx = NeovimBackedTestContext::new(cx).await;
785
786 cx.assert_binding_matches_all(["c", "i", "w"], WORD_LOCATIONS)
787 .await;
788 cx.assert_binding_matches_all(["c", "i", "shift-w"], WORD_LOCATIONS)
789 .await;
790 cx.assert_binding_matches_all(["c", "a", "w"], WORD_LOCATIONS)
791 .await;
792 cx.assert_binding_matches_all(["c", "a", "shift-w"], WORD_LOCATIONS)
793 .await;
794 }
795
796 #[gpui::test]
797 async fn test_delete_word_object(cx: &mut gpui::TestAppContext) {
798 let mut cx = NeovimBackedTestContext::new(cx).await;
799
800 cx.assert_binding_matches_all(["d", "i", "w"], WORD_LOCATIONS)
801 .await;
802 cx.assert_binding_matches_all(["d", "i", "shift-w"], WORD_LOCATIONS)
803 .await;
804 cx.assert_binding_matches_all(["d", "a", "w"], WORD_LOCATIONS)
805 .await;
806 cx.assert_binding_matches_all(["d", "a", "shift-w"], WORD_LOCATIONS)
807 .await;
808 }
809
810 #[gpui::test]
811 async fn test_visual_word_object(cx: &mut gpui::TestAppContext) {
812 let mut cx = NeovimBackedTestContext::new(cx).await;
813
814 /*
815 cx.set_shared_state("The quick ˇbrown\nfox").await;
816 cx.simulate_shared_keystrokes(["v"]).await;
817 cx.assert_shared_state("The quick «bˇ»rown\nfox").await;
818 cx.simulate_shared_keystrokes(["i", "w"]).await;
819 cx.assert_shared_state("The quick «brownˇ»\nfox").await;
820 */
821 cx.set_shared_state("The quick brown\nˇ\nfox").await;
822 cx.simulate_shared_keystrokes(["v"]).await;
823 cx.assert_shared_state("The quick brown\n«\nˇ»fox").await;
824 cx.simulate_shared_keystrokes(["i", "w"]).await;
825 cx.assert_shared_state("The quick brown\n«\nˇ»fox").await;
826
827 cx.assert_binding_matches_all(["v", "i", "w"], WORD_LOCATIONS)
828 .await;
829 cx.assert_binding_matches_all_exempted(
830 ["v", "h", "i", "w"],
831 WORD_LOCATIONS,
832 ExemptionFeatures::NonEmptyVisualTextObjects,
833 )
834 .await;
835 cx.assert_binding_matches_all_exempted(
836 ["v", "l", "i", "w"],
837 WORD_LOCATIONS,
838 ExemptionFeatures::NonEmptyVisualTextObjects,
839 )
840 .await;
841 cx.assert_binding_matches_all(["v", "i", "shift-w"], WORD_LOCATIONS)
842 .await;
843
844 cx.assert_binding_matches_all_exempted(
845 ["v", "i", "h", "shift-w"],
846 WORD_LOCATIONS,
847 ExemptionFeatures::NonEmptyVisualTextObjects,
848 )
849 .await;
850 cx.assert_binding_matches_all_exempted(
851 ["v", "i", "l", "shift-w"],
852 WORD_LOCATIONS,
853 ExemptionFeatures::NonEmptyVisualTextObjects,
854 )
855 .await;
856
857 cx.assert_binding_matches_all_exempted(
858 ["v", "a", "w"],
859 WORD_LOCATIONS,
860 ExemptionFeatures::AroundObjectLeavesWhitespaceAtEndOfLine,
861 )
862 .await;
863 cx.assert_binding_matches_all_exempted(
864 ["v", "a", "shift-w"],
865 WORD_LOCATIONS,
866 ExemptionFeatures::AroundObjectLeavesWhitespaceAtEndOfLine,
867 )
868 .await;
869 }
870
871 const SENTENCE_EXAMPLES: &[&'static str] = &[
872 "ˇThe quick ˇbrownˇ?ˇ ˇFox Jˇumpsˇ!ˇ Ovˇer theˇ lazyˇ.",
873 indoc! {"
874 ˇThe quick ˇbrownˇ
875 fox jumps over
876 the lazy doˇgˇ.ˇ ˇThe quick ˇ
877 brown fox jumps over
878 "},
879 indoc! {"
880 The quick brown fox jumps.
881 Over the lazy dog
882 ˇ
883 ˇ
884 ˇ fox-jumpˇs over
885 the lazy dog.ˇ
886 ˇ
887 "},
888 r#"ˇThe ˇquick brownˇ.)ˇ]ˇ'ˇ" Brown ˇfox jumpsˇ.ˇ "#,
889 ];
890
891 #[gpui::test]
892 async fn test_change_sentence_object(cx: &mut gpui::TestAppContext) {
893 let mut cx = NeovimBackedTestContext::new(cx)
894 .await
895 .binding(["c", "i", "s"]);
896 cx.add_initial_state_exemptions(
897 "The quick brown fox jumps.\nOver the lazy dog\nˇ\nˇ\n fox-jumps over\nthe lazy dog.\n\n",
898 ExemptionFeatures::SentenceOnEmptyLines);
899 cx.add_initial_state_exemptions(
900 "The quick brown fox jumps.\nOver the lazy dog\n\n\nˇ foxˇ-ˇjumpˇs over\nthe lazy dog.\n\n",
901 ExemptionFeatures::SentenceAtStartOfLineWithWhitespace);
902 cx.add_initial_state_exemptions(
903 "The quick brown fox jumps.\nOver the lazy dog\n\n\n fox-jumps over\nthe lazy dog.ˇ\nˇ\n",
904 ExemptionFeatures::SentenceAfterPunctuationAtEndOfFile);
905 for sentence_example in SENTENCE_EXAMPLES {
906 cx.assert_all(sentence_example).await;
907 }
908
909 let mut cx = cx.binding(["c", "a", "s"]);
910 cx.add_initial_state_exemptions(
911 "The quick brown?ˇ Fox Jumps! Over the lazy.",
912 ExemptionFeatures::IncorrectLandingPosition,
913 );
914 cx.add_initial_state_exemptions(
915 "The quick brown.)]\'\" Brown fox jumps.ˇ ",
916 ExemptionFeatures::AroundObjectLeavesWhitespaceAtEndOfLine,
917 );
918
919 for sentence_example in SENTENCE_EXAMPLES {
920 cx.assert_all(sentence_example).await;
921 }
922 }
923
924 #[gpui::test]
925 async fn test_delete_sentence_object(cx: &mut gpui::TestAppContext) {
926 let mut cx = NeovimBackedTestContext::new(cx)
927 .await
928 .binding(["d", "i", "s"]);
929 cx.add_initial_state_exemptions(
930 "The quick brown fox jumps.\nOver the lazy dog\nˇ\nˇ\n fox-jumps over\nthe lazy dog.\n\n",
931 ExemptionFeatures::SentenceOnEmptyLines);
932 cx.add_initial_state_exemptions(
933 "The quick brown fox jumps.\nOver the lazy dog\n\n\nˇ foxˇ-ˇjumpˇs over\nthe lazy dog.\n\n",
934 ExemptionFeatures::SentenceAtStartOfLineWithWhitespace);
935 cx.add_initial_state_exemptions(
936 "The quick brown fox jumps.\nOver the lazy dog\n\n\n fox-jumps over\nthe lazy dog.ˇ\nˇ\n",
937 ExemptionFeatures::SentenceAfterPunctuationAtEndOfFile);
938
939 for sentence_example in SENTENCE_EXAMPLES {
940 cx.assert_all(sentence_example).await;
941 }
942
943 let mut cx = cx.binding(["d", "a", "s"]);
944 cx.add_initial_state_exemptions(
945 "The quick brown?ˇ Fox Jumps! Over the lazy.",
946 ExemptionFeatures::IncorrectLandingPosition,
947 );
948 cx.add_initial_state_exemptions(
949 "The quick brown.)]\'\" Brown fox jumps.ˇ ",
950 ExemptionFeatures::AroundObjectLeavesWhitespaceAtEndOfLine,
951 );
952
953 for sentence_example in SENTENCE_EXAMPLES {
954 cx.assert_all(sentence_example).await;
955 }
956 }
957
958 #[gpui::test]
959 async fn test_visual_sentence_object(cx: &mut gpui::TestAppContext) {
960 let mut cx = NeovimBackedTestContext::new(cx)
961 .await
962 .binding(["v", "i", "s"]);
963 for sentence_example in SENTENCE_EXAMPLES {
964 cx.assert_all_exempted(sentence_example, ExemptionFeatures::SentenceOnEmptyLines)
965 .await;
966 }
967
968 let mut cx = cx.binding(["v", "a", "s"]);
969 for sentence_example in SENTENCE_EXAMPLES {
970 cx.assert_all_exempted(
971 sentence_example,
972 ExemptionFeatures::AroundSentenceStartingBetweenIncludesWrongWhitespace,
973 )
974 .await;
975 }
976 }
977
978 // Test string with "`" for opening surrounders and "'" for closing surrounders
979 const SURROUNDING_MARKER_STRING: &str = indoc! {"
980 ˇTh'ˇe ˇ`ˇ'ˇquˇi`ˇck broˇ'wn`
981 'ˇfox juˇmps ovˇ`ˇer
982 the ˇlazy dˇ'ˇoˇ`ˇg"};
983
984 const SURROUNDING_OBJECTS: &[(char, char)] = &[
985 ('\'', '\''), // Quote
986 ('`', '`'), // Back Quote
987 ('"', '"'), // Double Quote
988 ('(', ')'), // Parentheses
989 ('[', ']'), // SquareBrackets
990 ('{', '}'), // CurlyBrackets
991 ('<', '>'), // AngleBrackets
992 ];
993
994 #[gpui::test]
995 async fn test_change_surrounding_character_objects(cx: &mut gpui::TestAppContext) {
996 let mut cx = NeovimBackedTestContext::new(cx).await;
997
998 for (start, end) in SURROUNDING_OBJECTS {
999 let marked_string = SURROUNDING_MARKER_STRING
1000 .replace('`', &start.to_string())
1001 .replace('\'', &end.to_string());
1002
1003 cx.assert_binding_matches_all(["c", "i", &start.to_string()], &marked_string)
1004 .await;
1005 cx.assert_binding_matches_all(["c", "i", &end.to_string()], &marked_string)
1006 .await;
1007 cx.assert_binding_matches_all(["c", "a", &start.to_string()], &marked_string)
1008 .await;
1009 cx.assert_binding_matches_all(["c", "a", &end.to_string()], &marked_string)
1010 .await;
1011 }
1012 }
1013 #[gpui::test]
1014 async fn test_singleline_surrounding_character_objects(cx: &mut gpui::TestAppContext) {
1015 let mut cx = NeovimBackedTestContext::new(cx).await;
1016 cx.set_shared_wrap(12).await;
1017
1018 cx.set_shared_state(indoc! {
1019 "helˇlo \"world\"!"
1020 })
1021 .await;
1022 cx.simulate_shared_keystrokes(["v", "i", "\""]).await;
1023 cx.assert_shared_state(indoc! {
1024 "hello \"«worldˇ»\"!"
1025 })
1026 .await;
1027
1028 cx.set_shared_state(indoc! {
1029 "hello \"wˇorld\"!"
1030 })
1031 .await;
1032 cx.simulate_shared_keystrokes(["v", "i", "\""]).await;
1033 cx.assert_shared_state(indoc! {
1034 "hello \"«worldˇ»\"!"
1035 })
1036 .await;
1037
1038 cx.set_shared_state(indoc! {
1039 "hello \"wˇorld\"!"
1040 })
1041 .await;
1042 cx.simulate_shared_keystrokes(["v", "a", "\""]).await;
1043 cx.assert_shared_state(indoc! {
1044 "hello« \"world\"ˇ»!"
1045 })
1046 .await;
1047
1048 cx.set_shared_state(indoc! {
1049 "hello \"wˇorld\" !"
1050 })
1051 .await;
1052 cx.simulate_shared_keystrokes(["v", "a", "\""]).await;
1053 cx.assert_shared_state(indoc! {
1054 "hello «\"world\" ˇ»!"
1055 })
1056 .await;
1057
1058 cx.set_shared_state(indoc! {
1059 "hello \"wˇorld\"•
1060 goodbye"
1061 })
1062 .await;
1063 cx.simulate_shared_keystrokes(["v", "a", "\""]).await;
1064 cx.assert_shared_state(indoc! {
1065 "hello «\"world\" ˇ»
1066 goodbye"
1067 })
1068 .await;
1069 }
1070
1071 #[gpui::test]
1072 async fn test_multiline_surrounding_character_objects(cx: &mut gpui::TestAppContext) {
1073 let mut cx = NeovimBackedTestContext::new(cx).await;
1074
1075 cx.set_shared_state(indoc! {
1076 "func empty(a string) bool {
1077 if a == \"\" {
1078 return true
1079 }
1080 ˇreturn false
1081 }"
1082 })
1083 .await;
1084 cx.simulate_shared_keystrokes(["v", "i", "{"]).await;
1085 cx.assert_shared_state(indoc! {"
1086 func empty(a string) bool {
1087 « if a == \"\" {
1088 return true
1089 }
1090 return false
1091 ˇ»}"})
1092 .await;
1093 cx.set_shared_state(indoc! {
1094 "func empty(a string) bool {
1095 if a == \"\" {
1096 ˇreturn true
1097 }
1098 return false
1099 }"
1100 })
1101 .await;
1102 cx.simulate_shared_keystrokes(["v", "i", "{"]).await;
1103 cx.assert_shared_state(indoc! {"
1104 func empty(a string) bool {
1105 if a == \"\" {
1106 « return true
1107 ˇ» }
1108 return false
1109 }"})
1110 .await;
1111
1112 cx.set_shared_state(indoc! {
1113 "func empty(a string) bool {
1114 if a == \"\" ˇ{
1115 return true
1116 }
1117 return false
1118 }"
1119 })
1120 .await;
1121 cx.simulate_shared_keystrokes(["v", "i", "{"]).await;
1122 cx.assert_shared_state(indoc! {"
1123 func empty(a string) bool {
1124 if a == \"\" {
1125 « return true
1126 ˇ» }
1127 return false
1128 }"})
1129 .await;
1130 }
1131
1132 #[gpui::test]
1133 async fn test_vertical_bars(cx: &mut gpui::TestAppContext) {
1134 let mut cx = VimTestContext::new(cx, true).await;
1135 cx.set_state(
1136 indoc! {"
1137 fn boop() {
1138 baz(ˇ|a, b| { bar(|j, k| { })})
1139 }"
1140 },
1141 Mode::Normal,
1142 );
1143 cx.simulate_keystrokes(["c", "i", "|"]);
1144 cx.assert_state(
1145 indoc! {"
1146 fn boop() {
1147 baz(|ˇ| { bar(|j, k| { })})
1148 }"
1149 },
1150 Mode::Insert,
1151 );
1152 cx.simulate_keystrokes(["escape", "1", "8", "|"]);
1153 cx.assert_state(
1154 indoc! {"
1155 fn boop() {
1156 baz(|| { bar(ˇ|j, k| { })})
1157 }"
1158 },
1159 Mode::Normal,
1160 );
1161
1162 cx.simulate_keystrokes(["v", "a", "|"]);
1163 cx.assert_state(
1164 indoc! {"
1165 fn boop() {
1166 baz(|| { bar(«|j, k| ˇ»{ })})
1167 }"
1168 },
1169 Mode::Visual,
1170 );
1171 }
1172
1173 #[gpui::test]
1174 async fn test_argument_object(cx: &mut gpui::TestAppContext) {
1175 let mut cx = VimTestContext::new(cx, true).await;
1176
1177 // Generic arguments
1178 cx.set_state("fn boop<A: ˇDebug, B>() {}", Mode::Normal);
1179 cx.simulate_keystrokes(["v", "i", "a"]);
1180 cx.assert_state("fn boop<«A: Debugˇ», B>() {}", Mode::Visual);
1181
1182 // Function arguments
1183 cx.set_state(
1184 "fn boop(ˇarg_a: (Tuple, Of, Types), arg_b: String) {}",
1185 Mode::Normal,
1186 );
1187 cx.simulate_keystrokes(["d", "a", "a"]);
1188 cx.assert_state("fn boop(ˇarg_b: String) {}", Mode::Normal);
1189
1190 cx.set_state("std::namespace::test(\"strinˇg\", a.b.c())", Mode::Normal);
1191 cx.simulate_keystrokes(["v", "a", "a"]);
1192 cx.assert_state("std::namespace::test(«\"string\", ˇ»a.b.c())", Mode::Visual);
1193
1194 // Tuple, vec, and array arguments
1195 cx.set_state(
1196 "fn boop(arg_a: (Tuple, Ofˇ, Types), arg_b: String) {}",
1197 Mode::Normal,
1198 );
1199 cx.simulate_keystrokes(["c", "i", "a"]);
1200 cx.assert_state(
1201 "fn boop(arg_a: (Tuple, ˇ, Types), arg_b: String) {}",
1202 Mode::Insert,
1203 );
1204
1205 cx.set_state("let a = (test::call(), 'p', my_macro!{ˇ});", Mode::Normal);
1206 cx.simulate_keystrokes(["c", "a", "a"]);
1207 cx.assert_state("let a = (test::call(), 'p'ˇ);", Mode::Insert);
1208
1209 cx.set_state("let a = [test::call(ˇ), 300];", Mode::Normal);
1210 cx.simulate_keystrokes(["c", "i", "a"]);
1211 cx.assert_state("let a = [ˇ, 300];", Mode::Insert);
1212
1213 cx.set_state(
1214 "let a = vec![Vec::new(), vecˇ![test::call(), 300]];",
1215 Mode::Normal,
1216 );
1217 cx.simulate_keystrokes(["c", "a", "a"]);
1218 cx.assert_state("let a = vec![Vec::new()ˇ];", Mode::Insert);
1219
1220 // Cursor immediately before / after brackets
1221 cx.set_state("let a = [test::call(first_arg)ˇ]", Mode::Normal);
1222 cx.simulate_keystrokes(["v", "i", "a"]);
1223 cx.assert_state("let a = [«test::call(first_arg)ˇ»]", Mode::Visual);
1224
1225 cx.set_state("let a = [test::callˇ(first_arg)]", Mode::Normal);
1226 cx.simulate_keystrokes(["v", "i", "a"]);
1227 cx.assert_state("let a = [«test::call(first_arg)ˇ»]", Mode::Visual);
1228 }
1229
1230 #[gpui::test]
1231 async fn test_delete_surrounding_character_objects(cx: &mut gpui::TestAppContext) {
1232 let mut cx = NeovimBackedTestContext::new(cx).await;
1233
1234 for (start, end) in SURROUNDING_OBJECTS {
1235 let marked_string = SURROUNDING_MARKER_STRING
1236 .replace('`', &start.to_string())
1237 .replace('\'', &end.to_string());
1238
1239 cx.assert_binding_matches_all(["d", "i", &start.to_string()], &marked_string)
1240 .await;
1241 cx.assert_binding_matches_all(["d", "i", &end.to_string()], &marked_string)
1242 .await;
1243 cx.assert_binding_matches_all(["d", "a", &start.to_string()], &marked_string)
1244 .await;
1245 cx.assert_binding_matches_all(["d", "a", &end.to_string()], &marked_string)
1246 .await;
1247 }
1248 }
1249}