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