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 mut opening = opening?;
879
880 let mut matched_opens = 0;
881 let mut closing = None;
882 before_ch = match movement::chars_before(map, opening.end).next() {
883 Some((ch, _)) => ch,
884 _ => '\0',
885 };
886 for (ch, range) in movement::chars_after(map, opening.end) {
887 if ch == '\n' && !search_across_lines {
888 break;
889 }
890
891 if before_ch != '\\' {
892 if ch == close_marker {
893 if matched_opens == 0 {
894 closing = Some(range);
895 break;
896 }
897 matched_opens -= 1;
898 } else if ch == open_marker {
899 matched_opens += 1;
900 }
901 }
902
903 before_ch = ch;
904 }
905
906 let mut closing = closing?;
907
908 if around && !search_across_lines {
909 let mut found = false;
910
911 for (ch, range) in movement::chars_after(map, closing.end) {
912 if ch.is_whitespace() && ch != '\n' {
913 found = true;
914 closing.end = range.end;
915 } else {
916 break;
917 }
918 }
919
920 if !found {
921 for (ch, range) in movement::chars_before(map, opening.start) {
922 if ch.is_whitespace() && ch != '\n' {
923 opening.start = range.start
924 } else {
925 break;
926 }
927 }
928 }
929 }
930
931 if !around && search_across_lines {
932 if let Some((ch, range)) = movement::chars_after(map, opening.end).next() {
933 if ch == '\n' {
934 opening.end = range.end
935 }
936 }
937
938 for (ch, range) in movement::chars_before(map, closing.start) {
939 if !ch.is_whitespace() {
940 break;
941 }
942 if ch != '\n' {
943 closing.start = range.start
944 }
945 }
946 }
947
948 let result = if around {
949 opening.start..closing.end
950 } else {
951 opening.end..closing.start
952 };
953
954 Some(
955 map.clip_point(result.start.to_display_point(map), Bias::Left)
956 ..map.clip_point(result.end.to_display_point(map), Bias::Right),
957 )
958}
959
960#[cfg(test)]
961mod test {
962 use indoc::indoc;
963
964 use crate::{
965 state::Mode,
966 test::{NeovimBackedTestContext, VimTestContext},
967 };
968
969 const WORD_LOCATIONS: &str = indoc! {"
970 The quick ˇbrowˇnˇ•••
971 fox ˇjuˇmpsˇ over
972 the lazy dogˇ••
973 ˇ
974 ˇ
975 ˇ
976 Thˇeˇ-ˇquˇickˇ ˇbrownˇ•
977 ˇ••
978 ˇ••
979 ˇ fox-jumpˇs over
980 the lazy dogˇ•
981 ˇ
982 "
983 };
984
985 #[gpui::test]
986 async fn test_change_word_object(cx: &mut gpui::TestAppContext) {
987 let mut cx = NeovimBackedTestContext::new(cx).await;
988
989 cx.simulate_at_each_offset("c i w", WORD_LOCATIONS)
990 .await
991 .assert_matches();
992 cx.simulate_at_each_offset("c i shift-w", WORD_LOCATIONS)
993 .await
994 .assert_matches();
995 cx.simulate_at_each_offset("c a w", WORD_LOCATIONS)
996 .await
997 .assert_matches();
998 cx.simulate_at_each_offset("c a shift-w", WORD_LOCATIONS)
999 .await
1000 .assert_matches();
1001 }
1002
1003 #[gpui::test]
1004 async fn test_delete_word_object(cx: &mut gpui::TestAppContext) {
1005 let mut cx = NeovimBackedTestContext::new(cx).await;
1006
1007 cx.simulate_at_each_offset("d i w", WORD_LOCATIONS)
1008 .await
1009 .assert_matches();
1010 cx.simulate_at_each_offset("d i shift-w", WORD_LOCATIONS)
1011 .await
1012 .assert_matches();
1013 cx.simulate_at_each_offset("d a w", WORD_LOCATIONS)
1014 .await
1015 .assert_matches();
1016 cx.simulate_at_each_offset("d a shift-w", WORD_LOCATIONS)
1017 .await
1018 .assert_matches();
1019 }
1020
1021 #[gpui::test]
1022 async fn test_visual_word_object(cx: &mut gpui::TestAppContext) {
1023 let mut cx = NeovimBackedTestContext::new(cx).await;
1024
1025 /*
1026 cx.set_shared_state("The quick ˇbrown\nfox").await;
1027 cx.simulate_shared_keystrokes(["v"]).await;
1028 cx.assert_shared_state("The quick «bˇ»rown\nfox").await;
1029 cx.simulate_shared_keystrokes(["i", "w"]).await;
1030 cx.assert_shared_state("The quick «brownˇ»\nfox").await;
1031 */
1032 cx.set_shared_state("The quick brown\nˇ\nfox").await;
1033 cx.simulate_shared_keystrokes("v").await;
1034 cx.shared_state()
1035 .await
1036 .assert_eq("The quick brown\n«\nˇ»fox");
1037 cx.simulate_shared_keystrokes("i w").await;
1038 cx.shared_state()
1039 .await
1040 .assert_eq("The quick brown\n«\nˇ»fox");
1041
1042 cx.simulate_at_each_offset("v i w", WORD_LOCATIONS)
1043 .await
1044 .assert_matches();
1045 cx.simulate_at_each_offset("v i shift-w", WORD_LOCATIONS)
1046 .await
1047 .assert_matches();
1048 }
1049
1050 const PARAGRAPH_EXAMPLES: &[&str] = &[
1051 // Single line
1052 "ˇThe quick brown fox jumpˇs over the lazy dogˇ.ˇ",
1053 // Multiple lines without empty lines
1054 indoc! {"
1055 ˇThe quick brownˇ
1056 ˇfox jumps overˇ
1057 the lazy dog.ˇ
1058 "},
1059 // Heading blank paragraph and trailing normal paragraph
1060 indoc! {"
1061 ˇ
1062 ˇ
1063 ˇThe quick brown fox jumps
1064 ˇover the lazy dog.
1065 ˇ
1066 ˇ
1067 ˇThe quick brown fox jumpsˇ
1068 ˇover the lazy dog.ˇ
1069 "},
1070 // Inserted blank paragraph and trailing blank paragraph
1071 indoc! {"
1072 ˇThe quick brown fox jumps
1073 ˇover the lazy dog.
1074 ˇ
1075 ˇ
1076 ˇ
1077 ˇThe quick brown fox jumpsˇ
1078 ˇover the lazy dog.ˇ
1079 ˇ
1080 ˇ
1081 ˇ
1082 "},
1083 // "Blank" paragraph with whitespace characters
1084 indoc! {"
1085 ˇThe quick brown fox jumps
1086 over the lazy dog.
1087
1088 ˇ \t
1089
1090 ˇThe quick brown fox jumps
1091 over the lazy dog.ˇ
1092 ˇ
1093 ˇ \t
1094 \t \t
1095 "},
1096 // Single line "paragraphs", where selection size might be zero.
1097 indoc! {"
1098 ˇThe quick brown fox jumps over the lazy dog.
1099 ˇ
1100 ˇThe quick brown fox jumpˇs over the lazy dog.ˇ
1101 ˇ
1102 "},
1103 ];
1104
1105 #[gpui::test]
1106 async fn test_change_paragraph_object(cx: &mut gpui::TestAppContext) {
1107 let mut cx = NeovimBackedTestContext::new(cx).await;
1108
1109 for paragraph_example in PARAGRAPH_EXAMPLES {
1110 cx.simulate_at_each_offset("c i p", paragraph_example)
1111 .await
1112 .assert_matches();
1113 cx.simulate_at_each_offset("c a p", paragraph_example)
1114 .await
1115 .assert_matches();
1116 }
1117 }
1118
1119 #[gpui::test]
1120 async fn test_delete_paragraph_object(cx: &mut gpui::TestAppContext) {
1121 let mut cx = NeovimBackedTestContext::new(cx).await;
1122
1123 for paragraph_example in PARAGRAPH_EXAMPLES {
1124 cx.simulate_at_each_offset("d i p", paragraph_example)
1125 .await
1126 .assert_matches();
1127 cx.simulate_at_each_offset("d a p", paragraph_example)
1128 .await
1129 .assert_matches();
1130 }
1131 }
1132
1133 #[gpui::test]
1134 async fn test_visual_paragraph_object(cx: &mut gpui::TestAppContext) {
1135 let mut cx = NeovimBackedTestContext::new(cx).await;
1136
1137 const EXAMPLES: &[&str] = &[
1138 indoc! {"
1139 ˇThe quick brown
1140 fox jumps over
1141 the lazy dog.
1142 "},
1143 indoc! {"
1144 ˇ
1145
1146 ˇThe quick brown fox jumps
1147 over the lazy dog.
1148 ˇ
1149
1150 ˇThe quick brown fox jumps
1151 over the lazy dog.
1152 "},
1153 indoc! {"
1154 ˇThe quick brown fox jumps over the lazy dog.
1155 ˇ
1156 ˇThe quick brown fox jumps over the lazy dog.
1157
1158 "},
1159 ];
1160
1161 for paragraph_example in EXAMPLES {
1162 cx.simulate_at_each_offset("v i p", paragraph_example)
1163 .await
1164 .assert_matches();
1165 cx.simulate_at_each_offset("v a p", paragraph_example)
1166 .await
1167 .assert_matches();
1168 }
1169 }
1170
1171 // Test string with "`" for opening surrounders and "'" for closing surrounders
1172 const SURROUNDING_MARKER_STRING: &str = indoc! {"
1173 ˇTh'ˇe ˇ`ˇ'ˇquˇi`ˇck broˇ'wn`
1174 'ˇfox juˇmps ov`ˇer
1175 the ˇlazy d'o`ˇg"};
1176
1177 const SURROUNDING_OBJECTS: &[(char, char)] = &[
1178 ('"', '"'), // Double Quote
1179 ('(', ')'), // Parentheses
1180 ];
1181
1182 #[gpui::test]
1183 async fn test_change_surrounding_character_objects(cx: &mut gpui::TestAppContext) {
1184 let mut cx = NeovimBackedTestContext::new(cx).await;
1185
1186 for (start, end) in SURROUNDING_OBJECTS {
1187 let marked_string = SURROUNDING_MARKER_STRING
1188 .replace('`', &start.to_string())
1189 .replace('\'', &end.to_string());
1190
1191 cx.simulate_at_each_offset(&format!("c i {start}"), &marked_string)
1192 .await
1193 .assert_matches();
1194 cx.simulate_at_each_offset(&format!("c i {end}"), &marked_string)
1195 .await
1196 .assert_matches();
1197 cx.simulate_at_each_offset(&format!("c a {start}"), &marked_string)
1198 .await
1199 .assert_matches();
1200 cx.simulate_at_each_offset(&format!("c a {end}"), &marked_string)
1201 .await
1202 .assert_matches();
1203 }
1204 }
1205 #[gpui::test]
1206 async fn test_singleline_surrounding_character_objects(cx: &mut gpui::TestAppContext) {
1207 let mut cx = NeovimBackedTestContext::new(cx).await;
1208 cx.set_shared_wrap(12).await;
1209
1210 cx.set_shared_state(indoc! {
1211 "\"ˇhello world\"!"
1212 })
1213 .await;
1214 cx.simulate_shared_keystrokes("v i \"").await;
1215 cx.shared_state().await.assert_eq(indoc! {
1216 "\"«hello worldˇ»\"!"
1217 });
1218
1219 cx.set_shared_state(indoc! {
1220 "\"hˇello world\"!"
1221 })
1222 .await;
1223 cx.simulate_shared_keystrokes("v i \"").await;
1224 cx.shared_state().await.assert_eq(indoc! {
1225 "\"«hello worldˇ»\"!"
1226 });
1227
1228 cx.set_shared_state(indoc! {
1229 "helˇlo \"world\"!"
1230 })
1231 .await;
1232 cx.simulate_shared_keystrokes("v i \"").await;
1233 cx.shared_state().await.assert_eq(indoc! {
1234 "hello \"«worldˇ»\"!"
1235 });
1236
1237 cx.set_shared_state(indoc! {
1238 "hello \"wˇorld\"!"
1239 })
1240 .await;
1241 cx.simulate_shared_keystrokes("v i \"").await;
1242 cx.shared_state().await.assert_eq(indoc! {
1243 "hello \"«worldˇ»\"!"
1244 });
1245
1246 cx.set_shared_state(indoc! {
1247 "hello \"wˇorld\"!"
1248 })
1249 .await;
1250 cx.simulate_shared_keystrokes("v a \"").await;
1251 cx.shared_state().await.assert_eq(indoc! {
1252 "hello« \"world\"ˇ»!"
1253 });
1254
1255 cx.set_shared_state(indoc! {
1256 "hello \"wˇorld\" !"
1257 })
1258 .await;
1259 cx.simulate_shared_keystrokes("v a \"").await;
1260 cx.shared_state().await.assert_eq(indoc! {
1261 "hello «\"world\" ˇ»!"
1262 });
1263
1264 cx.set_shared_state(indoc! {
1265 "hello \"wˇorld\"•
1266 goodbye"
1267 })
1268 .await;
1269 cx.simulate_shared_keystrokes("v a \"").await;
1270 cx.shared_state().await.assert_eq(indoc! {
1271 "hello «\"world\" ˇ»
1272 goodbye"
1273 });
1274 }
1275
1276 #[gpui::test]
1277 async fn test_multiline_surrounding_character_objects(cx: &mut gpui::TestAppContext) {
1278 let mut cx = NeovimBackedTestContext::new(cx).await;
1279
1280 cx.set_shared_state(indoc! {
1281 "func empty(a string) bool {
1282 if a == \"\" {
1283 return true
1284 }
1285 ˇreturn false
1286 }"
1287 })
1288 .await;
1289 cx.simulate_shared_keystrokes("v i {").await;
1290 cx.shared_state().await.assert_eq(indoc! {"
1291 func empty(a string) bool {
1292 « if a == \"\" {
1293 return true
1294 }
1295 return false
1296 ˇ»}"});
1297 cx.set_shared_state(indoc! {
1298 "func empty(a string) bool {
1299 if a == \"\" {
1300 ˇreturn true
1301 }
1302 return false
1303 }"
1304 })
1305 .await;
1306 cx.simulate_shared_keystrokes("v i {").await;
1307 cx.shared_state().await.assert_eq(indoc! {"
1308 func empty(a string) bool {
1309 if a == \"\" {
1310 « return true
1311 ˇ» }
1312 return false
1313 }"});
1314
1315 cx.set_shared_state(indoc! {
1316 "func empty(a string) bool {
1317 if a == \"\" ˇ{
1318 return true
1319 }
1320 return false
1321 }"
1322 })
1323 .await;
1324 cx.simulate_shared_keystrokes("v i {").await;
1325 cx.shared_state().await.assert_eq(indoc! {"
1326 func empty(a string) bool {
1327 if a == \"\" {
1328 « return true
1329 ˇ» }
1330 return false
1331 }"});
1332 }
1333
1334 #[gpui::test]
1335 async fn test_singleline_surrounding_character_objects_with_escape(
1336 cx: &mut gpui::TestAppContext,
1337 ) {
1338 let mut cx = NeovimBackedTestContext::new(cx).await;
1339 cx.set_shared_state(indoc! {
1340 "h\"e\\\"lˇlo \\\"world\"!"
1341 })
1342 .await;
1343 cx.simulate_shared_keystrokes("v i \"").await;
1344 cx.shared_state().await.assert_eq(indoc! {
1345 "h\"«e\\\"llo \\\"worldˇ»\"!"
1346 });
1347
1348 cx.set_shared_state(indoc! {
1349 "hello \"teˇst \\\"inside\\\" world\""
1350 })
1351 .await;
1352 cx.simulate_shared_keystrokes("v i \"").await;
1353 cx.shared_state().await.assert_eq(indoc! {
1354 "hello \"«test \\\"inside\\\" worldˇ»\""
1355 });
1356 }
1357
1358 #[gpui::test]
1359 async fn test_vertical_bars(cx: &mut gpui::TestAppContext) {
1360 let mut cx = VimTestContext::new(cx, true).await;
1361 cx.set_state(
1362 indoc! {"
1363 fn boop() {
1364 baz(ˇ|a, b| { bar(|j, k| { })})
1365 }"
1366 },
1367 Mode::Normal,
1368 );
1369 cx.simulate_keystrokes("c i |");
1370 cx.assert_state(
1371 indoc! {"
1372 fn boop() {
1373 baz(|ˇ| { bar(|j, k| { })})
1374 }"
1375 },
1376 Mode::Insert,
1377 );
1378 cx.simulate_keystrokes("escape 1 8 |");
1379 cx.assert_state(
1380 indoc! {"
1381 fn boop() {
1382 baz(|| { bar(ˇ|j, k| { })})
1383 }"
1384 },
1385 Mode::Normal,
1386 );
1387
1388 cx.simulate_keystrokes("v a |");
1389 cx.assert_state(
1390 indoc! {"
1391 fn boop() {
1392 baz(|| { bar(«|j, k| ˇ»{ })})
1393 }"
1394 },
1395 Mode::Visual,
1396 );
1397 }
1398
1399 #[gpui::test]
1400 async fn test_argument_object(cx: &mut gpui::TestAppContext) {
1401 let mut cx = VimTestContext::new(cx, true).await;
1402
1403 // Generic arguments
1404 cx.set_state("fn boop<A: ˇDebug, B>() {}", Mode::Normal);
1405 cx.simulate_keystrokes("v i a");
1406 cx.assert_state("fn boop<«A: Debugˇ», B>() {}", Mode::Visual);
1407
1408 // Function arguments
1409 cx.set_state(
1410 "fn boop(ˇarg_a: (Tuple, Of, Types), arg_b: String) {}",
1411 Mode::Normal,
1412 );
1413 cx.simulate_keystrokes("d a a");
1414 cx.assert_state("fn boop(ˇarg_b: String) {}", Mode::Normal);
1415
1416 cx.set_state("std::namespace::test(\"strinˇg\", a.b.c())", Mode::Normal);
1417 cx.simulate_keystrokes("v a a");
1418 cx.assert_state("std::namespace::test(«\"string\", ˇ»a.b.c())", Mode::Visual);
1419
1420 // Tuple, vec, and array arguments
1421 cx.set_state(
1422 "fn boop(arg_a: (Tuple, Ofˇ, Types), arg_b: String) {}",
1423 Mode::Normal,
1424 );
1425 cx.simulate_keystrokes("c i a");
1426 cx.assert_state(
1427 "fn boop(arg_a: (Tuple, ˇ, Types), arg_b: String) {}",
1428 Mode::Insert,
1429 );
1430
1431 cx.set_state("let a = (test::call(), 'p', my_macro!{ˇ});", Mode::Normal);
1432 cx.simulate_keystrokes("c a a");
1433 cx.assert_state("let a = (test::call(), 'p'ˇ);", Mode::Insert);
1434
1435 cx.set_state("let a = [test::call(ˇ), 300];", Mode::Normal);
1436 cx.simulate_keystrokes("c i a");
1437 cx.assert_state("let a = [ˇ, 300];", Mode::Insert);
1438
1439 cx.set_state(
1440 "let a = vec![Vec::new(), vecˇ![test::call(), 300]];",
1441 Mode::Normal,
1442 );
1443 cx.simulate_keystrokes("c a a");
1444 cx.assert_state("let a = vec![Vec::new()ˇ];", Mode::Insert);
1445
1446 // Cursor immediately before / after brackets
1447 cx.set_state("let a = [test::call(first_arg)ˇ]", Mode::Normal);
1448 cx.simulate_keystrokes("v i a");
1449 cx.assert_state("let a = [«test::call(first_arg)ˇ»]", Mode::Visual);
1450
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
1456 #[gpui::test]
1457 async fn test_delete_surrounding_character_objects(cx: &mut gpui::TestAppContext) {
1458 let mut cx = NeovimBackedTestContext::new(cx).await;
1459
1460 for (start, end) in SURROUNDING_OBJECTS {
1461 let marked_string = SURROUNDING_MARKER_STRING
1462 .replace('`', &start.to_string())
1463 .replace('\'', &end.to_string());
1464
1465 cx.simulate_at_each_offset(&format!("d i {start}"), &marked_string)
1466 .await
1467 .assert_matches();
1468 cx.simulate_at_each_offset(&format!("d i {end}"), &marked_string)
1469 .await
1470 .assert_matches();
1471 cx.simulate_at_each_offset(&format!("d a {start}"), &marked_string)
1472 .await
1473 .assert_matches();
1474 cx.simulate_at_each_offset(&format!("d a {end}"), &marked_string)
1475 .await
1476 .assert_matches();
1477 }
1478 }
1479
1480 #[gpui::test]
1481 async fn test_tags(cx: &mut gpui::TestAppContext) {
1482 let mut cx = VimTestContext::new_html(cx).await;
1483
1484 cx.set_state("<html><head></head><body><b>hˇi!</b></body>", Mode::Normal);
1485 cx.simulate_keystrokes("v i t");
1486 cx.assert_state(
1487 "<html><head></head><body><b>«hi!ˇ»</b></body>",
1488 Mode::Visual,
1489 );
1490 cx.simulate_keystrokes("a t");
1491 cx.assert_state(
1492 "<html><head></head><body>«<b>hi!</b>ˇ»</body>",
1493 Mode::Visual,
1494 );
1495 cx.simulate_keystrokes("a t");
1496 cx.assert_state(
1497 "<html><head></head>«<body><b>hi!</b></body>ˇ»",
1498 Mode::Visual,
1499 );
1500
1501 // The cursor is before the tag
1502 cx.set_state(
1503 "<html><head></head><body> ˇ <b>hi!</b></body>",
1504 Mode::Normal,
1505 );
1506 cx.simulate_keystrokes("v i t");
1507 cx.assert_state(
1508 "<html><head></head><body> <b>«hi!ˇ»</b></body>",
1509 Mode::Visual,
1510 );
1511 cx.simulate_keystrokes("a t");
1512 cx.assert_state(
1513 "<html><head></head><body> «<b>hi!</b>ˇ»</body>",
1514 Mode::Visual,
1515 );
1516
1517 // The cursor is in the open tag
1518 cx.set_state(
1519 "<html><head></head><body><bˇ>hi!</b><b>hello!</b></body>",
1520 Mode::Normal,
1521 );
1522 cx.simulate_keystrokes("v a t");
1523 cx.assert_state(
1524 "<html><head></head><body>«<b>hi!</b>ˇ»<b>hello!</b></body>",
1525 Mode::Visual,
1526 );
1527 cx.simulate_keystrokes("i t");
1528 cx.assert_state(
1529 "<html><head></head><body>«<b>hi!</b><b>hello!</b>ˇ»</body>",
1530 Mode::Visual,
1531 );
1532
1533 // current selection length greater than 1
1534 cx.set_state(
1535 "<html><head></head><body><«b>hi!ˇ»</b></body>",
1536 Mode::Visual,
1537 );
1538 cx.simulate_keystrokes("i t");
1539 cx.assert_state(
1540 "<html><head></head><body><b>«hi!ˇ»</b></body>",
1541 Mode::Visual,
1542 );
1543 cx.simulate_keystrokes("a t");
1544 cx.assert_state(
1545 "<html><head></head><body>«<b>hi!</b>ˇ»</body>",
1546 Mode::Visual,
1547 );
1548
1549 cx.set_state(
1550 "<html><head></head><body><«b>hi!</ˇ»b></body>",
1551 Mode::Visual,
1552 );
1553 cx.simulate_keystrokes("a t");
1554 cx.assert_state(
1555 "<html><head></head>«<body><b>hi!</b></body>ˇ»",
1556 Mode::Visual,
1557 );
1558 }
1559}