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