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 return 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 if cursor.goto_first_child_for_byte(offset).is_none() {
485 return None;
486 }
487 }
488
489 let mut argument_node = cursor.node();
490
491 // If the child node is the open bracket, move to the next sibling.
492 if argument_node.byte_range() == open_bracket {
493 if !cursor.goto_next_sibling() {
494 return Some(inner_bracket_range);
495 }
496 argument_node = cursor.node();
497 }
498 // While the child node is the close bracket or a comma, move to the previous sibling
499 while argument_node.byte_range() == close_bracket || argument_node.kind() == "," {
500 if !cursor.goto_previous_sibling() {
501 return Some(inner_bracket_range);
502 }
503 argument_node = cursor.node();
504 if argument_node.byte_range() == open_bracket {
505 return Some(inner_bracket_range);
506 }
507 }
508
509 // The start and end of the argument range, defaulting to the start and end of the argument node
510 let mut start = argument_node.start_byte();
511 let mut end = argument_node.end_byte();
512
513 let mut needs_surrounding_comma = include_comma;
514
515 // Seek backwards to find the start of the argument - either the previous comma or the opening bracket.
516 // We do this because multiple nodes can represent a single argument, such as with rust `vec![a.b.c, d.e.f]`
517 while cursor.goto_previous_sibling() {
518 let prev = cursor.node();
519
520 if prev.start_byte() < open_bracket.end {
521 start = open_bracket.end;
522 break;
523 } else if prev.kind() == "," {
524 if needs_surrounding_comma {
525 start = prev.start_byte();
526 needs_surrounding_comma = false;
527 }
528 break;
529 } else if prev.start_byte() < start {
530 start = prev.start_byte();
531 }
532 }
533
534 // Do the same for the end of the argument, extending to next comma or the end of the argument list
535 while cursor.goto_next_sibling() {
536 let next = cursor.node();
537
538 if next.end_byte() > close_bracket.start {
539 end = close_bracket.start;
540 break;
541 } else if next.kind() == "," {
542 if needs_surrounding_comma {
543 // Select up to the beginning of the next argument if there is one, otherwise to the end of the comma
544 if let Some(next_arg) = next.next_sibling() {
545 end = next_arg.start_byte();
546 } else {
547 end = next.end_byte();
548 }
549 }
550 break;
551 } else if next.end_byte() > end {
552 end = next.end_byte();
553 }
554 }
555
556 Some(start..end)
557 }
558
559 let result = comma_delimited_range_at(buffer, excerpt.map_offset_to_buffer(offset), around)?;
560
561 if excerpt.contains_buffer_range(result.clone()) {
562 let result = excerpt.map_range_from_buffer(result);
563 Some(result.start.to_display_point(map)..result.end.to_display_point(map))
564 } else {
565 None
566 }
567}
568
569fn sentence(
570 map: &DisplaySnapshot,
571 relative_to: DisplayPoint,
572 around: bool,
573) -> Option<Range<DisplayPoint>> {
574 let mut start = None;
575 let relative_offset = relative_to.to_offset(map, Bias::Left);
576 let mut previous_end = relative_offset;
577
578 let mut chars = map.buffer_chars_at(previous_end).peekable();
579
580 // Search backwards for the previous sentence end or current sentence start. Include the character under relative_to
581 for (char, offset) in chars
582 .peek()
583 .cloned()
584 .into_iter()
585 .chain(map.reverse_buffer_chars_at(previous_end))
586 {
587 if is_sentence_end(map, offset) {
588 break;
589 }
590
591 if is_possible_sentence_start(char) {
592 start = Some(offset);
593 }
594
595 previous_end = offset;
596 }
597
598 // Search forward for the end of the current sentence or if we are between sentences, the start of the next one
599 let mut end = relative_offset;
600 for (char, offset) in chars {
601 if start.is_none() && is_possible_sentence_start(char) {
602 if around {
603 start = Some(offset);
604 continue;
605 } else {
606 end = offset;
607 break;
608 }
609 }
610
611 if char != '\n' {
612 end = offset + char.len_utf8();
613 }
614
615 if is_sentence_end(map, end) {
616 break;
617 }
618 }
619
620 let mut range = start.unwrap_or(previous_end).to_display_point(map)..end.to_display_point(map);
621 if around {
622 range = expand_to_include_whitespace(map, range, false);
623 }
624
625 Some(range)
626}
627
628fn is_possible_sentence_start(character: char) -> bool {
629 !character.is_whitespace() && character != '.'
630}
631
632const SENTENCE_END_PUNCTUATION: &[char] = &['.', '!', '?'];
633const SENTENCE_END_FILLERS: &[char] = &[')', ']', '"', '\''];
634const SENTENCE_END_WHITESPACE: &[char] = &[' ', '\t', '\n'];
635fn is_sentence_end(map: &DisplaySnapshot, offset: usize) -> bool {
636 let mut next_chars = map.buffer_chars_at(offset).peekable();
637 if let Some((char, _)) = next_chars.next() {
638 // We are at a double newline. This position is a sentence end.
639 if char == '\n' && next_chars.peek().map(|(c, _)| c == &'\n').unwrap_or(false) {
640 return true;
641 }
642
643 // The next text is not a valid whitespace. This is not a sentence end
644 if !SENTENCE_END_WHITESPACE.contains(&char) {
645 return false;
646 }
647 }
648
649 for (char, _) in map.reverse_buffer_chars_at(offset) {
650 if SENTENCE_END_PUNCTUATION.contains(&char) {
651 return true;
652 }
653
654 if !SENTENCE_END_FILLERS.contains(&char) {
655 return false;
656 }
657 }
658
659 return false;
660}
661
662/// Expands the passed range to include whitespace on one side or the other in a line. Attempts to add the
663/// whitespace to the end first and falls back to the start if there was none.
664fn expand_to_include_whitespace(
665 map: &DisplaySnapshot,
666 range: Range<DisplayPoint>,
667 stop_at_newline: bool,
668) -> Range<DisplayPoint> {
669 let mut range = range.start.to_offset(map, Bias::Left)..range.end.to_offset(map, Bias::Right);
670 let mut whitespace_included = false;
671
672 let mut chars = map.buffer_chars_at(range.end).peekable();
673 while let Some((char, offset)) = chars.next() {
674 if char == '\n' && stop_at_newline {
675 break;
676 }
677
678 if char.is_whitespace() {
679 if char != '\n' {
680 range.end = offset + char.len_utf8();
681 whitespace_included = true;
682 }
683 } else {
684 // Found non whitespace. Quit out.
685 break;
686 }
687 }
688
689 if !whitespace_included {
690 for (char, point) in map.reverse_buffer_chars_at(range.start) {
691 if char == '\n' && stop_at_newline {
692 break;
693 }
694
695 if !char.is_whitespace() {
696 break;
697 }
698
699 range.start = point;
700 }
701 }
702
703 range.start.to_display_point(map)..range.end.to_display_point(map)
704}
705
706/// If not `around` (i.e. inner), returns a range that surrounds the paragraph
707/// where `relative_to` is in. If `around`, principally returns the range ending
708/// at the end of the next paragraph.
709///
710/// Here, the "paragraph" is defined as a block of non-blank lines or a block of
711/// blank lines. If the paragraph ends with a trailing newline (i.e. not with
712/// EOF), the returned range ends at the trailing newline of the paragraph (i.e.
713/// the trailing newline is not subject to subsequent operations).
714///
715/// Edge cases:
716/// - If `around` and if the current paragraph is the last paragraph of the
717/// file and is blank, then the selection results in an error.
718/// - If `around` and if the current paragraph is the last paragraph of the
719/// file and is not blank, then the returned range starts at the start of the
720/// previous paragraph, if it exists.
721fn paragraph(
722 map: &DisplaySnapshot,
723 relative_to: DisplayPoint,
724 around: bool,
725) -> Option<Range<DisplayPoint>> {
726 let mut paragraph_start = start_of_paragraph(map, relative_to);
727 let mut paragraph_end = end_of_paragraph(map, relative_to);
728
729 let paragraph_end_row = paragraph_end.row();
730 let paragraph_ends_with_eof = paragraph_end_row == map.max_point().row();
731 let point = relative_to.to_point(map);
732 let current_line_is_empty = map.buffer_snapshot.is_line_blank(MultiBufferRow(point.row));
733
734 if around {
735 if paragraph_ends_with_eof {
736 if current_line_is_empty {
737 return None;
738 }
739
740 let paragraph_start_row = paragraph_start.row();
741 if paragraph_start_row.0 != 0 {
742 let previous_paragraph_last_line_start =
743 Point::new(paragraph_start_row.0 - 1, 0).to_display_point(map);
744 paragraph_start = start_of_paragraph(map, previous_paragraph_last_line_start);
745 }
746 } else {
747 let next_paragraph_start = Point::new(paragraph_end_row.0 + 1, 0).to_display_point(map);
748 paragraph_end = end_of_paragraph(map, next_paragraph_start);
749 }
750 }
751
752 let range = paragraph_start..paragraph_end;
753 Some(range)
754}
755
756/// Returns a position of the start of the current paragraph, where a paragraph
757/// is defined as a run of non-blank lines or a run of blank lines.
758pub fn start_of_paragraph(map: &DisplaySnapshot, display_point: DisplayPoint) -> DisplayPoint {
759 let point = display_point.to_point(map);
760 if point.row == 0 {
761 return DisplayPoint::zero();
762 }
763
764 let is_current_line_blank = map.buffer_snapshot.is_line_blank(MultiBufferRow(point.row));
765
766 for row in (0..point.row).rev() {
767 let blank = map.buffer_snapshot.is_line_blank(MultiBufferRow(row));
768 if blank != is_current_line_blank {
769 return Point::new(row + 1, 0).to_display_point(map);
770 }
771 }
772
773 DisplayPoint::zero()
774}
775
776/// Returns a position of the end of the current paragraph, where a paragraph
777/// is defined as a run of non-blank lines or a run of blank lines.
778/// The trailing newline is excluded from the paragraph.
779pub fn end_of_paragraph(map: &DisplaySnapshot, display_point: DisplayPoint) -> DisplayPoint {
780 let point = display_point.to_point(map);
781 if point.row == map.max_buffer_row().0 {
782 return map.max_point();
783 }
784
785 let is_current_line_blank = map.buffer_snapshot.is_line_blank(MultiBufferRow(point.row));
786
787 for row in point.row + 1..map.max_buffer_row().0 + 1 {
788 let blank = map.buffer_snapshot.is_line_blank(MultiBufferRow(row));
789 if blank != is_current_line_blank {
790 let previous_row = row - 1;
791 return Point::new(
792 previous_row,
793 map.buffer_snapshot.line_len(MultiBufferRow(previous_row)),
794 )
795 .to_display_point(map);
796 }
797 }
798
799 map.max_point()
800}
801
802fn surrounding_markers(
803 map: &DisplaySnapshot,
804 relative_to: DisplayPoint,
805 around: bool,
806 search_across_lines: bool,
807 open_marker: char,
808 close_marker: char,
809) -> Option<Range<DisplayPoint>> {
810 let point = relative_to.to_offset(map, Bias::Left);
811
812 let mut matched_closes = 0;
813 let mut opening = None;
814
815 let mut before_ch = match movement::chars_before(map, point).next() {
816 Some((ch, _)) => ch,
817 _ => '\0',
818 };
819 if let Some((ch, range)) = movement::chars_after(map, point).next() {
820 if ch == open_marker && before_ch != '\\' {
821 if open_marker == close_marker {
822 let mut total = 0;
823 for ((ch, _), (before_ch, _)) in movement::chars_before(map, point).tuple_windows()
824 {
825 if ch == '\n' {
826 break;
827 }
828 if ch == open_marker && before_ch != '\\' {
829 total += 1;
830 }
831 }
832 if total % 2 == 0 {
833 opening = Some(range)
834 }
835 } else {
836 opening = Some(range)
837 }
838 }
839 }
840
841 if opening.is_none() {
842 let mut chars_before = movement::chars_before(map, point).peekable();
843 while let Some((ch, range)) = chars_before.next() {
844 if ch == '\n' && !search_across_lines {
845 break;
846 }
847
848 if let Some((before_ch, _)) = chars_before.peek() {
849 if *before_ch == '\\' {
850 continue;
851 }
852 }
853
854 if ch == open_marker {
855 if matched_closes == 0 {
856 opening = Some(range);
857 break;
858 }
859 matched_closes -= 1;
860 } else if ch == close_marker {
861 matched_closes += 1
862 }
863 }
864 }
865 if opening.is_none() {
866 for (ch, range) in movement::chars_after(map, point) {
867 if before_ch != '\\' {
868 if ch == open_marker {
869 opening = Some(range);
870 break;
871 } else if ch == close_marker {
872 break;
873 }
874 }
875
876 before_ch = ch;
877 }
878 }
879
880 let Some(mut opening) = opening else {
881 return None;
882 };
883
884 let mut matched_opens = 0;
885 let mut closing = None;
886 before_ch = match movement::chars_before(map, opening.end).next() {
887 Some((ch, _)) => ch,
888 _ => '\0',
889 };
890 for (ch, range) in movement::chars_after(map, opening.end) {
891 if ch == '\n' && !search_across_lines {
892 break;
893 }
894
895 if before_ch != '\\' {
896 if ch == close_marker {
897 if matched_opens == 0 {
898 closing = Some(range);
899 break;
900 }
901 matched_opens -= 1;
902 } else if ch == open_marker {
903 matched_opens += 1;
904 }
905 }
906
907 before_ch = ch;
908 }
909
910 let Some(mut closing) = closing else {
911 return None;
912 };
913
914 if around && !search_across_lines {
915 let mut found = false;
916
917 for (ch, range) in movement::chars_after(map, closing.end) {
918 if ch.is_whitespace() && ch != '\n' {
919 found = true;
920 closing.end = range.end;
921 } else {
922 break;
923 }
924 }
925
926 if !found {
927 for (ch, range) in movement::chars_before(map, opening.start) {
928 if ch.is_whitespace() && ch != '\n' {
929 opening.start = range.start
930 } else {
931 break;
932 }
933 }
934 }
935 }
936
937 if !around && search_across_lines {
938 if let Some((ch, range)) = movement::chars_after(map, opening.end).next() {
939 if ch == '\n' {
940 opening.end = range.end
941 }
942 }
943
944 for (ch, range) in movement::chars_before(map, closing.start) {
945 if !ch.is_whitespace() {
946 break;
947 }
948 if ch != '\n' {
949 closing.start = range.start
950 }
951 }
952 }
953
954 let result = if around {
955 opening.start..closing.end
956 } else {
957 opening.end..closing.start
958 };
959
960 Some(
961 map.clip_point(result.start.to_display_point(map), Bias::Left)
962 ..map.clip_point(result.end.to_display_point(map), Bias::Right),
963 )
964}
965
966#[cfg(test)]
967mod test {
968 use indoc::indoc;
969
970 use crate::{
971 state::Mode,
972 test::{NeovimBackedTestContext, VimTestContext},
973 };
974
975 const WORD_LOCATIONS: &str = indoc! {"
976 The quick ˇbrowˇnˇ•••
977 fox ˇjuˇmpsˇ over
978 the lazy dogˇ••
979 ˇ
980 ˇ
981 ˇ
982 Thˇeˇ-ˇquˇickˇ ˇbrownˇ•
983 ˇ••
984 ˇ••
985 ˇ fox-jumpˇs over
986 the lazy dogˇ•
987 ˇ
988 "
989 };
990
991 #[gpui::test]
992 async fn test_change_word_object(cx: &mut gpui::TestAppContext) {
993 let mut cx = NeovimBackedTestContext::new(cx).await;
994
995 cx.simulate_at_each_offset("c i w", WORD_LOCATIONS)
996 .await
997 .assert_matches();
998 cx.simulate_at_each_offset("c i shift-w", WORD_LOCATIONS)
999 .await
1000 .assert_matches();
1001 cx.simulate_at_each_offset("c a w", WORD_LOCATIONS)
1002 .await
1003 .assert_matches();
1004 cx.simulate_at_each_offset("c a shift-w", WORD_LOCATIONS)
1005 .await
1006 .assert_matches();
1007 }
1008
1009 #[gpui::test]
1010 async fn test_delete_word_object(cx: &mut gpui::TestAppContext) {
1011 let mut cx = NeovimBackedTestContext::new(cx).await;
1012
1013 cx.simulate_at_each_offset("d i w", WORD_LOCATIONS)
1014 .await
1015 .assert_matches();
1016 cx.simulate_at_each_offset("d i shift-w", WORD_LOCATIONS)
1017 .await
1018 .assert_matches();
1019 cx.simulate_at_each_offset("d a w", WORD_LOCATIONS)
1020 .await
1021 .assert_matches();
1022 cx.simulate_at_each_offset("d a shift-w", WORD_LOCATIONS)
1023 .await
1024 .assert_matches();
1025 }
1026
1027 #[gpui::test]
1028 async fn test_visual_word_object(cx: &mut gpui::TestAppContext) {
1029 let mut cx = NeovimBackedTestContext::new(cx).await;
1030
1031 /*
1032 cx.set_shared_state("The quick ˇbrown\nfox").await;
1033 cx.simulate_shared_keystrokes(["v"]).await;
1034 cx.assert_shared_state("The quick «bˇ»rown\nfox").await;
1035 cx.simulate_shared_keystrokes(["i", "w"]).await;
1036 cx.assert_shared_state("The quick «brownˇ»\nfox").await;
1037 */
1038 cx.set_shared_state("The quick brown\nˇ\nfox").await;
1039 cx.simulate_shared_keystrokes("v").await;
1040 cx.shared_state()
1041 .await
1042 .assert_eq("The quick brown\n«\nˇ»fox");
1043 cx.simulate_shared_keystrokes("i w").await;
1044 cx.shared_state()
1045 .await
1046 .assert_eq("The quick brown\n«\nˇ»fox");
1047
1048 cx.simulate_at_each_offset("v i w", WORD_LOCATIONS)
1049 .await
1050 .assert_matches();
1051 cx.simulate_at_each_offset("v i shift-w", WORD_LOCATIONS)
1052 .await
1053 .assert_matches();
1054 }
1055
1056 const PARAGRAPH_EXAMPLES: &[&'static str] = &[
1057 // Single line
1058 "ˇThe quick brown fox jumpˇs over the lazy dogˇ.ˇ",
1059 // Multiple lines without empty lines
1060 indoc! {"
1061 ˇThe quick brownˇ
1062 ˇfox jumps overˇ
1063 the lazy dog.ˇ
1064 "},
1065 // Heading blank paragraph and trailing normal paragraph
1066 indoc! {"
1067 ˇ
1068 ˇ
1069 ˇThe quick brown fox jumps
1070 ˇover the lazy dog.
1071 ˇ
1072 ˇ
1073 ˇThe quick brown fox jumpsˇ
1074 ˇover the lazy dog.ˇ
1075 "},
1076 // Inserted blank paragraph and trailing blank paragraph
1077 indoc! {"
1078 ˇThe quick brown fox jumps
1079 ˇover the lazy dog.
1080 ˇ
1081 ˇ
1082 ˇ
1083 ˇThe quick brown fox jumpsˇ
1084 ˇover the lazy dog.ˇ
1085 ˇ
1086 ˇ
1087 ˇ
1088 "},
1089 // "Blank" paragraph with whitespace characters
1090 indoc! {"
1091 ˇThe quick brown fox jumps
1092 over the lazy dog.
1093
1094 ˇ \t
1095
1096 ˇThe quick brown fox jumps
1097 over the lazy dog.ˇ
1098 ˇ
1099 ˇ \t
1100 \t \t
1101 "},
1102 // Single line "paragraphs", where selection size might be zero.
1103 indoc! {"
1104 ˇThe quick brown fox jumps over the lazy dog.
1105 ˇ
1106 ˇThe quick brown fox jumpˇs over the lazy dog.ˇ
1107 ˇ
1108 "},
1109 ];
1110
1111 #[gpui::test]
1112 async fn test_change_paragraph_object(cx: &mut gpui::TestAppContext) {
1113 let mut cx = NeovimBackedTestContext::new(cx).await;
1114
1115 for paragraph_example in PARAGRAPH_EXAMPLES {
1116 cx.simulate_at_each_offset("c i p", paragraph_example)
1117 .await
1118 .assert_matches();
1119 cx.simulate_at_each_offset("c a p", paragraph_example)
1120 .await
1121 .assert_matches();
1122 }
1123 }
1124
1125 #[gpui::test]
1126 async fn test_delete_paragraph_object(cx: &mut gpui::TestAppContext) {
1127 let mut cx = NeovimBackedTestContext::new(cx).await;
1128
1129 for paragraph_example in PARAGRAPH_EXAMPLES {
1130 cx.simulate_at_each_offset("d i p", paragraph_example)
1131 .await
1132 .assert_matches();
1133 cx.simulate_at_each_offset("d a p", paragraph_example)
1134 .await
1135 .assert_matches();
1136 }
1137 }
1138
1139 #[gpui::test]
1140 async fn test_visual_paragraph_object(cx: &mut gpui::TestAppContext) {
1141 let mut cx = NeovimBackedTestContext::new(cx).await;
1142
1143 const EXAMPLES: &[&'static str] = &[
1144 indoc! {"
1145 ˇThe quick brown
1146 fox jumps over
1147 the lazy dog.
1148 "},
1149 indoc! {"
1150 ˇ
1151
1152 ˇThe quick brown fox jumps
1153 over the lazy dog.
1154 ˇ
1155
1156 ˇThe quick brown fox jumps
1157 over the lazy dog.
1158 "},
1159 indoc! {"
1160 ˇThe quick brown fox jumps over the lazy dog.
1161 ˇ
1162 ˇThe quick brown fox jumps over the lazy dog.
1163
1164 "},
1165 ];
1166
1167 for paragraph_example in EXAMPLES {
1168 cx.simulate_at_each_offset("v i p", paragraph_example)
1169 .await
1170 .assert_matches();
1171 cx.simulate_at_each_offset("v a p", paragraph_example)
1172 .await
1173 .assert_matches();
1174 }
1175 }
1176
1177 // Test string with "`" for opening surrounders and "'" for closing surrounders
1178 const SURROUNDING_MARKER_STRING: &str = indoc! {"
1179 ˇTh'ˇe ˇ`ˇ'ˇquˇi`ˇck broˇ'wn`
1180 'ˇfox juˇmps ov`ˇer
1181 the ˇlazy d'o`ˇg"};
1182
1183 const SURROUNDING_OBJECTS: &[(char, char)] = &[
1184 ('"', '"'), // Double Quote
1185 ('(', ')'), // Parentheses
1186 ];
1187
1188 #[gpui::test]
1189 async fn test_change_surrounding_character_objects(cx: &mut gpui::TestAppContext) {
1190 let mut cx = NeovimBackedTestContext::new(cx).await;
1191
1192 for (start, end) in SURROUNDING_OBJECTS {
1193 let marked_string = SURROUNDING_MARKER_STRING
1194 .replace('`', &start.to_string())
1195 .replace('\'', &end.to_string());
1196
1197 cx.simulate_at_each_offset(&format!("c i {start}"), &marked_string)
1198 .await
1199 .assert_matches();
1200 cx.simulate_at_each_offset(&format!("c i {end}"), &marked_string)
1201 .await
1202 .assert_matches();
1203 cx.simulate_at_each_offset(&format!("c a {start}"), &marked_string)
1204 .await
1205 .assert_matches();
1206 cx.simulate_at_each_offset(&format!("c a {end}"), &marked_string)
1207 .await
1208 .assert_matches();
1209 }
1210 }
1211 #[gpui::test]
1212 async fn test_singleline_surrounding_character_objects(cx: &mut gpui::TestAppContext) {
1213 let mut cx = NeovimBackedTestContext::new(cx).await;
1214 cx.set_shared_wrap(12).await;
1215
1216 cx.set_shared_state(indoc! {
1217 "\"ˇhello world\"!"
1218 })
1219 .await;
1220 cx.simulate_shared_keystrokes("v i \"").await;
1221 cx.shared_state().await.assert_eq(indoc! {
1222 "\"«hello worldˇ»\"!"
1223 });
1224
1225 cx.set_shared_state(indoc! {
1226 "\"hˇello world\"!"
1227 })
1228 .await;
1229 cx.simulate_shared_keystrokes("v i \"").await;
1230 cx.shared_state().await.assert_eq(indoc! {
1231 "\"«hello worldˇ»\"!"
1232 });
1233
1234 cx.set_shared_state(indoc! {
1235 "helˇlo \"world\"!"
1236 })
1237 .await;
1238 cx.simulate_shared_keystrokes("v i \"").await;
1239 cx.shared_state().await.assert_eq(indoc! {
1240 "hello \"«worldˇ»\"!"
1241 });
1242
1243 cx.set_shared_state(indoc! {
1244 "hello \"wˇorld\"!"
1245 })
1246 .await;
1247 cx.simulate_shared_keystrokes("v i \"").await;
1248 cx.shared_state().await.assert_eq(indoc! {
1249 "hello \"«worldˇ»\"!"
1250 });
1251
1252 cx.set_shared_state(indoc! {
1253 "hello \"wˇorld\"!"
1254 })
1255 .await;
1256 cx.simulate_shared_keystrokes("v a \"").await;
1257 cx.shared_state().await.assert_eq(indoc! {
1258 "hello« \"world\"ˇ»!"
1259 });
1260
1261 cx.set_shared_state(indoc! {
1262 "hello \"wˇorld\" !"
1263 })
1264 .await;
1265 cx.simulate_shared_keystrokes("v a \"").await;
1266 cx.shared_state().await.assert_eq(indoc! {
1267 "hello «\"world\" ˇ»!"
1268 });
1269
1270 cx.set_shared_state(indoc! {
1271 "hello \"wˇorld\"•
1272 goodbye"
1273 })
1274 .await;
1275 cx.simulate_shared_keystrokes("v a \"").await;
1276 cx.shared_state().await.assert_eq(indoc! {
1277 "hello «\"world\" ˇ»
1278 goodbye"
1279 });
1280 }
1281
1282 #[gpui::test]
1283 async fn test_multiline_surrounding_character_objects(cx: &mut gpui::TestAppContext) {
1284 let mut cx = NeovimBackedTestContext::new(cx).await;
1285
1286 cx.set_shared_state(indoc! {
1287 "func empty(a string) bool {
1288 if a == \"\" {
1289 return true
1290 }
1291 ˇreturn false
1292 }"
1293 })
1294 .await;
1295 cx.simulate_shared_keystrokes("v i {").await;
1296 cx.shared_state().await.assert_eq(indoc! {"
1297 func empty(a string) bool {
1298 « if a == \"\" {
1299 return true
1300 }
1301 return false
1302 ˇ»}"});
1303 cx.set_shared_state(indoc! {
1304 "func empty(a string) bool {
1305 if a == \"\" {
1306 ˇreturn true
1307 }
1308 return false
1309 }"
1310 })
1311 .await;
1312 cx.simulate_shared_keystrokes("v i {").await;
1313 cx.shared_state().await.assert_eq(indoc! {"
1314 func empty(a string) bool {
1315 if a == \"\" {
1316 « return true
1317 ˇ» }
1318 return false
1319 }"});
1320
1321 cx.set_shared_state(indoc! {
1322 "func empty(a string) bool {
1323 if a == \"\" ˇ{
1324 return true
1325 }
1326 return false
1327 }"
1328 })
1329 .await;
1330 cx.simulate_shared_keystrokes("v i {").await;
1331 cx.shared_state().await.assert_eq(indoc! {"
1332 func empty(a string) bool {
1333 if a == \"\" {
1334 « return true
1335 ˇ» }
1336 return false
1337 }"});
1338 }
1339
1340 #[gpui::test]
1341 async fn test_singleline_surrounding_character_objects_with_escape(
1342 cx: &mut gpui::TestAppContext,
1343 ) {
1344 let mut cx = NeovimBackedTestContext::new(cx).await;
1345 cx.set_shared_state(indoc! {
1346 "h\"e\\\"lˇlo \\\"world\"!"
1347 })
1348 .await;
1349 cx.simulate_shared_keystrokes("v i \"").await;
1350 cx.shared_state().await.assert_eq(indoc! {
1351 "h\"«e\\\"llo \\\"worldˇ»\"!"
1352 });
1353
1354 cx.set_shared_state(indoc! {
1355 "hello \"teˇst \\\"inside\\\" world\""
1356 })
1357 .await;
1358 cx.simulate_shared_keystrokes("v i \"").await;
1359 cx.shared_state().await.assert_eq(indoc! {
1360 "hello \"«test \\\"inside\\\" worldˇ»\""
1361 });
1362 }
1363
1364 #[gpui::test]
1365 async fn test_vertical_bars(cx: &mut gpui::TestAppContext) {
1366 let mut cx = VimTestContext::new(cx, true).await;
1367 cx.set_state(
1368 indoc! {"
1369 fn boop() {
1370 baz(ˇ|a, b| { bar(|j, k| { })})
1371 }"
1372 },
1373 Mode::Normal,
1374 );
1375 cx.simulate_keystrokes("c i |");
1376 cx.assert_state(
1377 indoc! {"
1378 fn boop() {
1379 baz(|ˇ| { bar(|j, k| { })})
1380 }"
1381 },
1382 Mode::Insert,
1383 );
1384 cx.simulate_keystrokes("escape 1 8 |");
1385 cx.assert_state(
1386 indoc! {"
1387 fn boop() {
1388 baz(|| { bar(ˇ|j, k| { })})
1389 }"
1390 },
1391 Mode::Normal,
1392 );
1393
1394 cx.simulate_keystrokes("v a |");
1395 cx.assert_state(
1396 indoc! {"
1397 fn boop() {
1398 baz(|| { bar(«|j, k| ˇ»{ })})
1399 }"
1400 },
1401 Mode::Visual,
1402 );
1403 }
1404
1405 #[gpui::test]
1406 async fn test_argument_object(cx: &mut gpui::TestAppContext) {
1407 let mut cx = VimTestContext::new(cx, true).await;
1408
1409 // Generic arguments
1410 cx.set_state("fn boop<A: ˇDebug, B>() {}", Mode::Normal);
1411 cx.simulate_keystrokes("v i a");
1412 cx.assert_state("fn boop<«A: Debugˇ», B>() {}", Mode::Visual);
1413
1414 // Function arguments
1415 cx.set_state(
1416 "fn boop(ˇarg_a: (Tuple, Of, Types), arg_b: String) {}",
1417 Mode::Normal,
1418 );
1419 cx.simulate_keystrokes("d a a");
1420 cx.assert_state("fn boop(ˇarg_b: String) {}", Mode::Normal);
1421
1422 cx.set_state("std::namespace::test(\"strinˇg\", a.b.c())", Mode::Normal);
1423 cx.simulate_keystrokes("v a a");
1424 cx.assert_state("std::namespace::test(«\"string\", ˇ»a.b.c())", Mode::Visual);
1425
1426 // Tuple, vec, and array arguments
1427 cx.set_state(
1428 "fn boop(arg_a: (Tuple, Ofˇ, Types), arg_b: String) {}",
1429 Mode::Normal,
1430 );
1431 cx.simulate_keystrokes("c i a");
1432 cx.assert_state(
1433 "fn boop(arg_a: (Tuple, ˇ, Types), arg_b: String) {}",
1434 Mode::Insert,
1435 );
1436
1437 cx.set_state("let a = (test::call(), 'p', my_macro!{ˇ});", Mode::Normal);
1438 cx.simulate_keystrokes("c a a");
1439 cx.assert_state("let a = (test::call(), 'p'ˇ);", Mode::Insert);
1440
1441 cx.set_state("let a = [test::call(ˇ), 300];", Mode::Normal);
1442 cx.simulate_keystrokes("c i a");
1443 cx.assert_state("let a = [ˇ, 300];", Mode::Insert);
1444
1445 cx.set_state(
1446 "let a = vec![Vec::new(), vecˇ![test::call(), 300]];",
1447 Mode::Normal,
1448 );
1449 cx.simulate_keystrokes("c a a");
1450 cx.assert_state("let a = vec![Vec::new()ˇ];", Mode::Insert);
1451
1452 // Cursor immediately before / after brackets
1453 cx.set_state("let a = [test::call(first_arg)ˇ]", Mode::Normal);
1454 cx.simulate_keystrokes("v i a");
1455 cx.assert_state("let a = [«test::call(first_arg)ˇ»]", Mode::Visual);
1456
1457 cx.set_state("let a = [test::callˇ(first_arg)]", Mode::Normal);
1458 cx.simulate_keystrokes("v i a");
1459 cx.assert_state("let a = [«test::call(first_arg)ˇ»]", Mode::Visual);
1460 }
1461
1462 #[gpui::test]
1463 async fn test_delete_surrounding_character_objects(cx: &mut gpui::TestAppContext) {
1464 let mut cx = NeovimBackedTestContext::new(cx).await;
1465
1466 for (start, end) in SURROUNDING_OBJECTS {
1467 let marked_string = SURROUNDING_MARKER_STRING
1468 .replace('`', &start.to_string())
1469 .replace('\'', &end.to_string());
1470
1471 cx.simulate_at_each_offset(&format!("d i {start}"), &marked_string)
1472 .await
1473 .assert_matches();
1474 cx.simulate_at_each_offset(&format!("d i {end}"), &marked_string)
1475 .await
1476 .assert_matches();
1477 cx.simulate_at_each_offset(&format!("d a {start}"), &marked_string)
1478 .await
1479 .assert_matches();
1480 cx.simulate_at_each_offset(&format!("d a {end}"), &marked_string)
1481 .await
1482 .assert_matches();
1483 }
1484 }
1485
1486 #[gpui::test]
1487 async fn test_tags(cx: &mut gpui::TestAppContext) {
1488 let mut cx = VimTestContext::new_html(cx).await;
1489
1490 cx.set_state("<html><head></head><body><b>hˇi!</b></body>", Mode::Normal);
1491 cx.simulate_keystrokes("v i t");
1492 cx.assert_state(
1493 "<html><head></head><body><b>«hi!ˇ»</b></body>",
1494 Mode::Visual,
1495 );
1496 cx.simulate_keystrokes("a t");
1497 cx.assert_state(
1498 "<html><head></head><body>«<b>hi!</b>ˇ»</body>",
1499 Mode::Visual,
1500 );
1501 cx.simulate_keystrokes("a t");
1502 cx.assert_state(
1503 "<html><head></head>«<body><b>hi!</b></body>ˇ»",
1504 Mode::Visual,
1505 );
1506
1507 // The cursor is before the tag
1508 cx.set_state(
1509 "<html><head></head><body> ˇ <b>hi!</b></body>",
1510 Mode::Normal,
1511 );
1512 cx.simulate_keystrokes("v i t");
1513 cx.assert_state(
1514 "<html><head></head><body> <b>«hi!ˇ»</b></body>",
1515 Mode::Visual,
1516 );
1517 cx.simulate_keystrokes("a t");
1518 cx.assert_state(
1519 "<html><head></head><body> «<b>hi!</b>ˇ»</body>",
1520 Mode::Visual,
1521 );
1522
1523 // The cursor is in the open tag
1524 cx.set_state(
1525 "<html><head></head><body><bˇ>hi!</b><b>hello!</b></body>",
1526 Mode::Normal,
1527 );
1528 cx.simulate_keystrokes("v a t");
1529 cx.assert_state(
1530 "<html><head></head><body>«<b>hi!</b>ˇ»<b>hello!</b></body>",
1531 Mode::Visual,
1532 );
1533 cx.simulate_keystrokes("i t");
1534 cx.assert_state(
1535 "<html><head></head><body>«<b>hi!</b><b>hello!</b>ˇ»</body>",
1536 Mode::Visual,
1537 );
1538
1539 // current selection length greater than 1
1540 cx.set_state(
1541 "<html><head></head><body><«b>hi!ˇ»</b></body>",
1542 Mode::Visual,
1543 );
1544 cx.simulate_keystrokes("i t");
1545 cx.assert_state(
1546 "<html><head></head><body><b>«hi!ˇ»</b></body>",
1547 Mode::Visual,
1548 );
1549 cx.simulate_keystrokes("a t");
1550 cx.assert_state(
1551 "<html><head></head><body>«<b>hi!</b>ˇ»</body>",
1552 Mode::Visual,
1553 );
1554
1555 cx.set_state(
1556 "<html><head></head><body><«b>hi!</ˇ»b></body>",
1557 Mode::Visual,
1558 );
1559 cx.simulate_keystrokes("a t");
1560 cx.assert_state(
1561 "<html><head></head>«<body><b>hi!</b></body>ˇ»",
1562 Mode::Visual,
1563 );
1564 }
1565}