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