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