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