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