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