1use std::ops::Range;
2
3use crate::{
4 motion::right,
5 state::{Mode, Operator},
6 Vim,
7};
8use editor::{
9 display_map::{DisplaySnapshot, ToDisplayPoint},
10 movement::{self, FindRange},
11 Bias, DisplayPoint, Editor,
12};
13use gpui::{actions, impl_actions, Window};
14use itertools::Itertools;
15use language::{BufferSnapshot, CharKind, Point, Selection, TextObject, TreeSitterOptions};
16use multi_buffer::MultiBufferRow;
17use schemars::JsonSchema;
18use serde::Deserialize;
19use ui::Context;
20
21#[derive(Copy, Clone, Debug, PartialEq, Eq, Deserialize, JsonSchema)]
22pub enum Object {
23 Word { ignore_punctuation: bool },
24 Subword { ignore_punctuation: bool },
25 Sentence,
26 Paragraph,
27 Quotes,
28 BackQuotes,
29 AnyQuotes,
30 DoubleQuotes,
31 VerticalBars,
32 AnyBrackets,
33 Parentheses,
34 SquareBrackets,
35 CurlyBrackets,
36 AngleBrackets,
37 Argument,
38 IndentObj { include_below: bool },
39 Tag,
40 Method,
41 Class,
42 Comment,
43 EntireFile,
44}
45
46#[derive(Clone, Deserialize, JsonSchema, PartialEq)]
47#[serde(rename_all = "camelCase")]
48struct Word {
49 #[serde(default)]
50 ignore_punctuation: bool,
51}
52
53#[derive(Clone, Deserialize, JsonSchema, PartialEq)]
54#[serde(rename_all = "camelCase")]
55struct Subword {
56 #[serde(default)]
57 ignore_punctuation: bool,
58}
59#[derive(Clone, Deserialize, JsonSchema, PartialEq)]
60#[serde(rename_all = "camelCase")]
61struct IndentObj {
62 #[serde(default)]
63 include_below: bool,
64}
65
66impl_actions!(vim, [Word, Subword, IndentObj]);
67
68actions!(
69 vim,
70 [
71 Sentence,
72 Paragraph,
73 Quotes,
74 BackQuotes,
75 AnyQuotes,
76 DoubleQuotes,
77 VerticalBars,
78 Parentheses,
79 AnyBrackets,
80 SquareBrackets,
81 CurlyBrackets,
82 AngleBrackets,
83 Argument,
84 Tag,
85 Method,
86 Class,
87 Comment,
88 EntireFile
89 ]
90);
91
92pub fn register(editor: &mut Editor, cx: &mut Context<Vim>) {
93 Vim::action(
94 editor,
95 cx,
96 |vim, &Word { ignore_punctuation }: &Word, window, cx| {
97 vim.object(Object::Word { ignore_punctuation }, window, cx)
98 },
99 );
100 Vim::action(
101 editor,
102 cx,
103 |vim, &Subword { ignore_punctuation }: &Subword, window, cx| {
104 vim.object(Object::Subword { ignore_punctuation }, window, cx)
105 },
106 );
107 Vim::action(editor, cx, |vim, _: &Tag, window, cx| {
108 vim.object(Object::Tag, window, cx)
109 });
110 Vim::action(editor, cx, |vim, _: &Sentence, window, cx| {
111 vim.object(Object::Sentence, window, cx)
112 });
113 Vim::action(editor, cx, |vim, _: &Paragraph, window, cx| {
114 vim.object(Object::Paragraph, window, cx)
115 });
116 Vim::action(editor, cx, |vim, _: &Quotes, window, cx| {
117 vim.object(Object::Quotes, window, cx)
118 });
119 Vim::action(editor, cx, |vim, _: &AnyQuotes, window, cx| {
120 vim.object(Object::AnyQuotes, window, cx)
121 });
122 Vim::action(editor, cx, |vim, _: &AnyBrackets, window, cx| {
123 vim.object(Object::AnyBrackets, window, cx)
124 });
125 Vim::action(editor, cx, |vim, _: &DoubleQuotes, window, cx| {
126 vim.object(Object::DoubleQuotes, window, cx)
127 });
128 Vim::action(editor, cx, |vim, _: &DoubleQuotes, window, cx| {
129 vim.object(Object::DoubleQuotes, window, cx)
130 });
131 Vim::action(editor, cx, |vim, _: &Parentheses, window, cx| {
132 vim.object(Object::Parentheses, window, cx)
133 });
134 Vim::action(editor, cx, |vim, _: &SquareBrackets, window, cx| {
135 vim.object(Object::SquareBrackets, window, cx)
136 });
137 Vim::action(editor, cx, |vim, _: &CurlyBrackets, window, cx| {
138 vim.object(Object::CurlyBrackets, window, cx)
139 });
140 Vim::action(editor, cx, |vim, _: &AngleBrackets, window, cx| {
141 vim.object(Object::AngleBrackets, window, cx)
142 });
143 Vim::action(editor, cx, |vim, _: &VerticalBars, window, cx| {
144 vim.object(Object::VerticalBars, window, cx)
145 });
146 Vim::action(editor, cx, |vim, _: &Argument, window, cx| {
147 vim.object(Object::Argument, window, cx)
148 });
149 Vim::action(editor, cx, |vim, _: &Method, window, cx| {
150 vim.object(Object::Method, window, cx)
151 });
152 Vim::action(editor, cx, |vim, _: &Class, window, cx| {
153 vim.object(Object::Class, window, cx)
154 });
155 Vim::action(editor, cx, |vim, _: &EntireFile, window, cx| {
156 vim.object(Object::EntireFile, window, cx)
157 });
158 Vim::action(editor, cx, |vim, _: &Comment, window, cx| {
159 if !matches!(vim.active_operator(), Some(Operator::Object { .. })) {
160 vim.push_operator(Operator::Object { around: true }, window, cx);
161 }
162 vim.object(Object::Comment, window, cx)
163 });
164 Vim::action(
165 editor,
166 cx,
167 |vim, &IndentObj { include_below }: &IndentObj, window, cx| {
168 vim.object(Object::IndentObj { include_below }, window, cx)
169 },
170 );
171}
172
173impl Vim {
174 fn object(&mut self, object: Object, window: &mut Window, cx: &mut Context<Self>) {
175 match self.mode {
176 Mode::Normal => self.normal_object(object, window, cx),
177 Mode::Visual | Mode::VisualLine | Mode::VisualBlock => {
178 self.visual_object(object, window, cx)
179 }
180 Mode::Insert | Mode::Replace | Mode::HelixNormal => {
181 // Shouldn't execute a text object in insert mode. Ignoring
182 }
183 }
184 }
185}
186
187impl Object {
188 pub fn is_multiline(self) -> bool {
189 match self {
190 Object::Word { .. }
191 | Object::Subword { .. }
192 | Object::Quotes
193 | Object::BackQuotes
194 | Object::AnyQuotes
195 | Object::VerticalBars
196 | Object::DoubleQuotes => false,
197 Object::Sentence
198 | Object::Paragraph
199 | Object::AnyBrackets
200 | Object::Parentheses
201 | Object::Tag
202 | Object::AngleBrackets
203 | Object::CurlyBrackets
204 | Object::SquareBrackets
205 | Object::Argument
206 | Object::Method
207 | Object::Class
208 | Object::EntireFile
209 | Object::Comment
210 | Object::IndentObj { .. } => true,
211 }
212 }
213
214 pub fn always_expands_both_ways(self) -> bool {
215 match self {
216 Object::Word { .. }
217 | Object::Subword { .. }
218 | Object::Sentence
219 | Object::Paragraph
220 | Object::Argument
221 | Object::IndentObj { .. } => false,
222 Object::Quotes
223 | Object::BackQuotes
224 | Object::AnyQuotes
225 | Object::DoubleQuotes
226 | Object::VerticalBars
227 | Object::AnyBrackets
228 | Object::Parentheses
229 | Object::SquareBrackets
230 | Object::Tag
231 | Object::Method
232 | Object::Class
233 | Object::Comment
234 | Object::EntireFile
235 | Object::CurlyBrackets
236 | Object::AngleBrackets => true,
237 }
238 }
239
240 pub fn target_visual_mode(self, current_mode: Mode, around: bool) -> Mode {
241 match self {
242 Object::Word { .. }
243 | Object::Subword { .. }
244 | Object::Sentence
245 | Object::Quotes
246 | Object::AnyQuotes
247 | Object::BackQuotes
248 | Object::DoubleQuotes => {
249 if current_mode == Mode::VisualBlock {
250 Mode::VisualBlock
251 } else {
252 Mode::Visual
253 }
254 }
255 Object::Parentheses
256 | Object::AnyBrackets
257 | Object::SquareBrackets
258 | Object::CurlyBrackets
259 | Object::AngleBrackets
260 | Object::VerticalBars
261 | Object::Tag
262 | Object::Comment
263 | Object::Argument
264 | Object::IndentObj { .. } => Mode::Visual,
265 Object::Method | Object::Class => {
266 if around {
267 Mode::VisualLine
268 } else {
269 Mode::Visual
270 }
271 }
272 Object::Paragraph | Object::EntireFile => Mode::VisualLine,
273 }
274 }
275
276 pub fn range(
277 self,
278 map: &DisplaySnapshot,
279 selection: Selection<DisplayPoint>,
280 around: bool,
281 ) -> Option<Range<DisplayPoint>> {
282 let relative_to = selection.head();
283 match self {
284 Object::Word { ignore_punctuation } => {
285 if around {
286 around_word(map, relative_to, ignore_punctuation)
287 } else {
288 in_word(map, relative_to, ignore_punctuation)
289 }
290 }
291 Object::Subword { ignore_punctuation } => {
292 if around {
293 around_subword(map, relative_to, ignore_punctuation)
294 } else {
295 in_subword(map, relative_to, ignore_punctuation)
296 }
297 }
298 Object::Sentence => sentence(map, relative_to, around),
299 Object::Paragraph => paragraph(map, relative_to, around),
300 Object::Quotes => {
301 surrounding_markers(map, relative_to, around, self.is_multiline(), '\'', '\'')
302 }
303 Object::BackQuotes => {
304 surrounding_markers(map, relative_to, around, self.is_multiline(), '`', '`')
305 }
306 Object::AnyQuotes => {
307 let quote_types = ['\'', '"', '`']; // Types of quotes to handle
308 let relative_offset = relative_to.to_offset(map, Bias::Left) as isize;
309
310 // Find the closest matching quote range
311 quote_types
312 .iter()
313 .flat_map(|"e| {
314 // Get ranges for each quote type
315 surrounding_markers(
316 map,
317 relative_to,
318 around,
319 self.is_multiline(),
320 quote,
321 quote,
322 )
323 })
324 .min_by_key(|range| calculate_range_distance(range, relative_offset, map))
325 }
326 Object::DoubleQuotes => {
327 surrounding_markers(map, relative_to, around, self.is_multiline(), '"', '"')
328 }
329 Object::VerticalBars => {
330 surrounding_markers(map, relative_to, around, self.is_multiline(), '|', '|')
331 }
332 Object::Parentheses => {
333 surrounding_markers(map, relative_to, around, self.is_multiline(), '(', ')')
334 }
335 Object::Tag => {
336 let head = selection.head();
337 let range = selection.range();
338 surrounding_html_tag(map, head, range, around)
339 }
340 Object::AnyBrackets => {
341 let bracket_pairs = [('(', ')'), ('[', ']'), ('{', '}'), ('<', '>')];
342 let relative_offset = relative_to.to_offset(map, Bias::Left) as isize;
343
344 bracket_pairs
345 .iter()
346 .flat_map(|&(open_bracket, close_bracket)| {
347 surrounding_markers(
348 map,
349 relative_to,
350 around,
351 self.is_multiline(),
352 open_bracket,
353 close_bracket,
354 )
355 })
356 .min_by_key(|range| calculate_range_distance(range, relative_offset, map))
357 }
358 Object::SquareBrackets => {
359 surrounding_markers(map, relative_to, around, self.is_multiline(), '[', ']')
360 }
361 Object::CurlyBrackets => {
362 surrounding_markers(map, relative_to, around, self.is_multiline(), '{', '}')
363 }
364 Object::AngleBrackets => {
365 surrounding_markers(map, relative_to, around, self.is_multiline(), '<', '>')
366 }
367 Object::Method => text_object(
368 map,
369 relative_to,
370 if around {
371 TextObject::AroundFunction
372 } else {
373 TextObject::InsideFunction
374 },
375 ),
376 Object::Comment => text_object(
377 map,
378 relative_to,
379 if around {
380 TextObject::AroundComment
381 } else {
382 TextObject::InsideComment
383 },
384 ),
385 Object::Class => text_object(
386 map,
387 relative_to,
388 if around {
389 TextObject::AroundClass
390 } else {
391 TextObject::InsideClass
392 },
393 ),
394 Object::Argument => argument(map, relative_to, around),
395 Object::IndentObj { include_below } => indent(map, relative_to, around, include_below),
396 Object::EntireFile => entire_file(map),
397 }
398 }
399
400 pub fn expand_selection(
401 self,
402 map: &DisplaySnapshot,
403 selection: &mut Selection<DisplayPoint>,
404 around: bool,
405 ) -> bool {
406 if let Some(range) = self.range(map, selection.clone(), around) {
407 selection.start = range.start;
408 selection.end = range.end;
409 true
410 } else {
411 false
412 }
413 }
414}
415
416/// Returns a range that surrounds the word `relative_to` is in.
417///
418/// If `relative_to` is at the start of a word, return the word.
419/// If `relative_to` is between words, return the space between.
420fn in_word(
421 map: &DisplaySnapshot,
422 relative_to: DisplayPoint,
423 ignore_punctuation: bool,
424) -> Option<Range<DisplayPoint>> {
425 // Use motion::right so that we consider the character under the cursor when looking for the start
426 let classifier = map
427 .buffer_snapshot
428 .char_classifier_at(relative_to.to_point(map))
429 .ignore_punctuation(ignore_punctuation);
430 let start = movement::find_preceding_boundary_display_point(
431 map,
432 right(map, relative_to, 1),
433 movement::FindRange::SingleLine,
434 |left, right| classifier.kind(left) != classifier.kind(right),
435 );
436
437 let end = movement::find_boundary(map, relative_to, FindRange::SingleLine, |left, right| {
438 classifier.kind(left) != classifier.kind(right)
439 });
440
441 Some(start..end)
442}
443
444fn in_subword(
445 map: &DisplaySnapshot,
446 relative_to: DisplayPoint,
447 ignore_punctuation: bool,
448) -> Option<Range<DisplayPoint>> {
449 let offset = relative_to.to_offset(map, Bias::Left);
450 // Use motion::right so that we consider the character under the cursor when looking for the start
451 let classifier = map
452 .buffer_snapshot
453 .char_classifier_at(relative_to.to_point(map))
454 .ignore_punctuation(ignore_punctuation);
455 let in_subword = map
456 .buffer_chars_at(offset)
457 .next()
458 .map(|(c, _)| {
459 if classifier.is_word('-') {
460 !classifier.is_whitespace(c) && c != '_' && c != '-'
461 } else {
462 !classifier.is_whitespace(c) && c != '_'
463 }
464 })
465 .unwrap_or(false);
466
467 let start = if in_subword {
468 movement::find_preceding_boundary_display_point(
469 map,
470 right(map, relative_to, 1),
471 movement::FindRange::SingleLine,
472 |left, right| {
473 let is_word_start = classifier.kind(left) != classifier.kind(right);
474 let is_subword_start = classifier.is_word('-') && left == '-' && right != '-'
475 || left == '_' && right != '_'
476 || left.is_lowercase() && right.is_uppercase();
477 is_word_start || is_subword_start
478 },
479 )
480 } else {
481 movement::find_boundary(map, relative_to, FindRange::SingleLine, |left, right| {
482 let is_word_start = classifier.kind(left) != classifier.kind(right);
483 let is_subword_start = classifier.is_word('-') && left == '-' && right != '-'
484 || left == '_' && right != '_'
485 || left.is_lowercase() && right.is_uppercase();
486 is_word_start || is_subword_start
487 })
488 };
489
490 let end = movement::find_boundary(map, relative_to, FindRange::SingleLine, |left, right| {
491 let is_word_end = classifier.kind(left) != classifier.kind(right);
492 let is_subword_end = classifier.is_word('-') && left != '-' && right == '-'
493 || left != '_' && right == '_'
494 || left.is_lowercase() && right.is_uppercase();
495 is_word_end || is_subword_end
496 });
497
498 Some(start..end)
499}
500
501pub fn surrounding_html_tag(
502 map: &DisplaySnapshot,
503 head: DisplayPoint,
504 range: Range<DisplayPoint>,
505 around: bool,
506) -> Option<Range<DisplayPoint>> {
507 fn read_tag(chars: impl Iterator<Item = char>) -> String {
508 chars
509 .take_while(|c| c.is_alphanumeric() || *c == ':' || *c == '-' || *c == '_' || *c == '.')
510 .collect()
511 }
512 fn open_tag(mut chars: impl Iterator<Item = char>) -> Option<String> {
513 if Some('<') != chars.next() {
514 return None;
515 }
516 Some(read_tag(chars))
517 }
518 fn close_tag(mut chars: impl Iterator<Item = char>) -> Option<String> {
519 if (Some('<'), Some('/')) != (chars.next(), chars.next()) {
520 return None;
521 }
522 Some(read_tag(chars))
523 }
524
525 let snapshot = &map.buffer_snapshot;
526 let offset = head.to_offset(map, Bias::Left);
527 let mut excerpt = snapshot.excerpt_containing(offset..offset)?;
528 let buffer = excerpt.buffer();
529 let offset = excerpt.map_offset_to_buffer(offset);
530
531 // Find the most closest to current offset
532 let mut cursor = buffer.syntax_layer_at(offset)?.node().walk();
533 let mut last_child_node = cursor.node();
534 while cursor.goto_first_child_for_byte(offset).is_some() {
535 last_child_node = cursor.node();
536 }
537
538 let mut last_child_node = Some(last_child_node);
539 while let Some(cur_node) = last_child_node {
540 if cur_node.child_count() >= 2 {
541 let first_child = cur_node.child(0);
542 let last_child = cur_node.child(cur_node.child_count() - 1);
543 if let (Some(first_child), Some(last_child)) = (first_child, last_child) {
544 let open_tag = open_tag(buffer.chars_for_range(first_child.byte_range()));
545 let close_tag = close_tag(buffer.chars_for_range(last_child.byte_range()));
546 // It needs to be handled differently according to the selection length
547 let is_valid = if range.end.to_offset(map, Bias::Left)
548 - range.start.to_offset(map, Bias::Left)
549 <= 1
550 {
551 offset <= last_child.end_byte()
552 } else {
553 range.start.to_offset(map, Bias::Left) >= first_child.start_byte()
554 && range.end.to_offset(map, Bias::Left) <= last_child.start_byte() + 1
555 };
556 if open_tag.is_some() && open_tag == close_tag && is_valid {
557 let range = if around {
558 first_child.byte_range().start..last_child.byte_range().end
559 } else {
560 first_child.byte_range().end..last_child.byte_range().start
561 };
562 if excerpt.contains_buffer_range(range.clone()) {
563 let result = excerpt.map_range_from_buffer(range);
564 return Some(
565 result.start.to_display_point(map)..result.end.to_display_point(map),
566 );
567 }
568 }
569 }
570 }
571 last_child_node = cur_node.parent();
572 }
573 None
574}
575
576/// Returns a range that surrounds the word and following whitespace
577/// relative_to is in.
578///
579/// If `relative_to` is at the start of a word, return the word and following whitespace.
580/// If `relative_to` is between words, return the whitespace back and the following word.
581///
582/// if in word
583/// delete that word
584/// if there is whitespace following the word, delete that as well
585/// otherwise, delete any preceding whitespace
586/// otherwise
587/// delete whitespace around cursor
588/// delete word following the cursor
589fn around_word(
590 map: &DisplaySnapshot,
591 relative_to: DisplayPoint,
592 ignore_punctuation: bool,
593) -> Option<Range<DisplayPoint>> {
594 let offset = relative_to.to_offset(map, Bias::Left);
595 let classifier = map
596 .buffer_snapshot
597 .char_classifier_at(offset)
598 .ignore_punctuation(ignore_punctuation);
599 let in_word = map
600 .buffer_chars_at(offset)
601 .next()
602 .map(|(c, _)| !classifier.is_whitespace(c))
603 .unwrap_or(false);
604
605 if in_word {
606 around_containing_word(map, relative_to, ignore_punctuation)
607 } else {
608 around_next_word(map, relative_to, ignore_punctuation)
609 }
610}
611
612/// Calculate distance between a range and a cursor position
613///
614/// Returns a score where:
615/// - Lower values indicate better matches
616/// - Range containing cursor gets priority (returns range length)
617/// - For non-containing ranges, uses minimum distance to boundaries as primary factor
618/// - Range length is used as secondary factor for tiebreaking
619fn calculate_range_distance(
620 range: &Range<DisplayPoint>,
621 cursor_offset: isize,
622 map: &DisplaySnapshot,
623) -> isize {
624 let start_offset = range.start.to_offset(map, Bias::Left) as isize;
625 let end_offset = range.end.to_offset(map, Bias::Right) as isize;
626 let range_length = end_offset - start_offset;
627
628 // If cursor is inside the range, return range length
629 if cursor_offset >= start_offset && cursor_offset <= end_offset {
630 return range_length;
631 }
632
633 // Calculate minimum distance to range boundaries
634 let start_distance = (cursor_offset - start_offset).abs();
635 let end_distance = (cursor_offset - end_offset).abs();
636 let min_distance = start_distance.min(end_distance);
637
638 // Use min_distance as primary factor, range_length as secondary
639 // Multiply by large number to ensure distance is primary factor
640 min_distance * 10000 + range_length
641}
642
643fn around_subword(
644 map: &DisplaySnapshot,
645 relative_to: DisplayPoint,
646 ignore_punctuation: bool,
647) -> Option<Range<DisplayPoint>> {
648 // Use motion::right so that we consider the character under the cursor when looking for the start
649 let classifier = map
650 .buffer_snapshot
651 .char_classifier_at(relative_to.to_point(map))
652 .ignore_punctuation(ignore_punctuation);
653 let start = movement::find_preceding_boundary_display_point(
654 map,
655 right(map, relative_to, 1),
656 movement::FindRange::SingleLine,
657 |left, right| {
658 let is_word_start = classifier.kind(left) != classifier.kind(right);
659 let is_subword_start = classifier.is_word('-') && left != '-' && right == '-'
660 || left != '_' && right == '_'
661 || left.is_lowercase() && right.is_uppercase();
662 is_word_start || is_subword_start
663 },
664 );
665
666 let end = movement::find_boundary(map, relative_to, FindRange::SingleLine, |left, right| {
667 let is_word_end = classifier.kind(left) != classifier.kind(right);
668 let is_subword_end = classifier.is_word('-') && left != '-' && right == '-'
669 || left != '_' && right == '_'
670 || left.is_lowercase() && right.is_uppercase();
671 is_word_end || is_subword_end
672 });
673
674 Some(start..end).map(|range| expand_to_include_whitespace(map, range, true))
675}
676
677fn around_containing_word(
678 map: &DisplaySnapshot,
679 relative_to: DisplayPoint,
680 ignore_punctuation: bool,
681) -> Option<Range<DisplayPoint>> {
682 in_word(map, relative_to, ignore_punctuation)
683 .map(|range| expand_to_include_whitespace(map, range, true))
684}
685
686fn around_next_word(
687 map: &DisplaySnapshot,
688 relative_to: DisplayPoint,
689 ignore_punctuation: bool,
690) -> Option<Range<DisplayPoint>> {
691 let classifier = map
692 .buffer_snapshot
693 .char_classifier_at(relative_to.to_point(map))
694 .ignore_punctuation(ignore_punctuation);
695 // Get the start of the word
696 let start = movement::find_preceding_boundary_display_point(
697 map,
698 right(map, relative_to, 1),
699 FindRange::SingleLine,
700 |left, right| classifier.kind(left) != classifier.kind(right),
701 );
702
703 let mut word_found = false;
704 let end = movement::find_boundary(map, relative_to, FindRange::MultiLine, |left, right| {
705 let left_kind = classifier.kind(left);
706 let right_kind = classifier.kind(right);
707
708 let found = (word_found && left_kind != right_kind) || right == '\n' && left == '\n';
709
710 if right_kind != CharKind::Whitespace {
711 word_found = true;
712 }
713
714 found
715 });
716
717 Some(start..end)
718}
719
720fn entire_file(map: &DisplaySnapshot) -> Option<Range<DisplayPoint>> {
721 Some(DisplayPoint::zero()..map.max_point())
722}
723
724fn text_object(
725 map: &DisplaySnapshot,
726 relative_to: DisplayPoint,
727 target: TextObject,
728) -> Option<Range<DisplayPoint>> {
729 let snapshot = &map.buffer_snapshot;
730 let offset = relative_to.to_offset(map, Bias::Left);
731
732 let mut excerpt = snapshot.excerpt_containing(offset..offset)?;
733 let buffer = excerpt.buffer();
734 let offset = excerpt.map_offset_to_buffer(offset);
735
736 let mut matches: Vec<Range<usize>> = buffer
737 .text_object_ranges(offset..offset, TreeSitterOptions::default())
738 .filter_map(|(r, m)| if m == target { Some(r) } else { None })
739 .collect();
740 matches.sort_by_key(|r| (r.end - r.start));
741 if let Some(buffer_range) = matches.first() {
742 let range = excerpt.map_range_from_buffer(buffer_range.clone());
743 return Some(range.start.to_display_point(map)..range.end.to_display_point(map));
744 }
745
746 let around = target.around()?;
747 let mut matches: Vec<Range<usize>> = buffer
748 .text_object_ranges(offset..offset, TreeSitterOptions::default())
749 .filter_map(|(r, m)| if m == around { Some(r) } else { None })
750 .collect();
751 matches.sort_by_key(|r| (r.end - r.start));
752 let around_range = matches.first()?;
753
754 let mut matches: Vec<Range<usize>> = buffer
755 .text_object_ranges(around_range.clone(), TreeSitterOptions::default())
756 .filter_map(|(r, m)| if m == target { Some(r) } else { None })
757 .collect();
758 matches.sort_by_key(|r| r.start);
759 if let Some(buffer_range) = matches.first() {
760 if !buffer_range.is_empty() {
761 let range = excerpt.map_range_from_buffer(buffer_range.clone());
762 return Some(range.start.to_display_point(map)..range.end.to_display_point(map));
763 }
764 }
765 let buffer_range = excerpt.map_range_from_buffer(around_range.clone());
766 return Some(buffer_range.start.to_display_point(map)..buffer_range.end.to_display_point(map));
767}
768
769fn argument(
770 map: &DisplaySnapshot,
771 relative_to: DisplayPoint,
772 around: bool,
773) -> Option<Range<DisplayPoint>> {
774 let snapshot = &map.buffer_snapshot;
775 let offset = relative_to.to_offset(map, Bias::Left);
776
777 // The `argument` vim text object uses the syntax tree, so we operate at the buffer level and map back to the display level
778 let mut excerpt = snapshot.excerpt_containing(offset..offset)?;
779 let buffer = excerpt.buffer();
780
781 fn comma_delimited_range_at(
782 buffer: &BufferSnapshot,
783 mut offset: usize,
784 include_comma: bool,
785 ) -> Option<Range<usize>> {
786 // Seek to the first non-whitespace character
787 offset += buffer
788 .chars_at(offset)
789 .take_while(|c| c.is_whitespace())
790 .map(char::len_utf8)
791 .sum::<usize>();
792
793 let bracket_filter = |open: Range<usize>, close: Range<usize>| {
794 // Filter out empty ranges
795 if open.end == close.start {
796 return false;
797 }
798
799 // If the cursor is outside the brackets, ignore them
800 if open.start == offset || close.end == offset {
801 return false;
802 }
803
804 // TODO: Is there any better way to filter out string brackets?
805 // Used to filter out string brackets
806 matches!(
807 buffer.chars_at(open.start).next(),
808 Some('(' | '[' | '{' | '<' | '|')
809 )
810 };
811
812 // Find the brackets containing the cursor
813 let (open_bracket, close_bracket) =
814 buffer.innermost_enclosing_bracket_ranges(offset..offset, Some(&bracket_filter))?;
815
816 let inner_bracket_range = open_bracket.end..close_bracket.start;
817
818 let layer = buffer.syntax_layer_at(offset)?;
819 let node = layer.node();
820 let mut cursor = node.walk();
821
822 // Loop until we find the smallest node whose parent covers the bracket range. This node is the argument in the parent argument list
823 let mut parent_covers_bracket_range = false;
824 loop {
825 let node = cursor.node();
826 let range = node.byte_range();
827 let covers_bracket_range =
828 range.start == open_bracket.start && range.end == close_bracket.end;
829 if parent_covers_bracket_range && !covers_bracket_range {
830 break;
831 }
832 parent_covers_bracket_range = covers_bracket_range;
833
834 // Unable to find a child node with a parent that covers the bracket range, so no argument to select
835 cursor.goto_first_child_for_byte(offset)?;
836 }
837
838 let mut argument_node = cursor.node();
839
840 // If the child node is the open bracket, move to the next sibling.
841 if argument_node.byte_range() == open_bracket {
842 if !cursor.goto_next_sibling() {
843 return Some(inner_bracket_range);
844 }
845 argument_node = cursor.node();
846 }
847 // While the child node is the close bracket or a comma, move to the previous sibling
848 while argument_node.byte_range() == close_bracket || argument_node.kind() == "," {
849 if !cursor.goto_previous_sibling() {
850 return Some(inner_bracket_range);
851 }
852 argument_node = cursor.node();
853 if argument_node.byte_range() == open_bracket {
854 return Some(inner_bracket_range);
855 }
856 }
857
858 // The start and end of the argument range, defaulting to the start and end of the argument node
859 let mut start = argument_node.start_byte();
860 let mut end = argument_node.end_byte();
861
862 let mut needs_surrounding_comma = include_comma;
863
864 // Seek backwards to find the start of the argument - either the previous comma or the opening bracket.
865 // We do this because multiple nodes can represent a single argument, such as with rust `vec![a.b.c, d.e.f]`
866 while cursor.goto_previous_sibling() {
867 let prev = cursor.node();
868
869 if prev.start_byte() < open_bracket.end {
870 start = open_bracket.end;
871 break;
872 } else if prev.kind() == "," {
873 if needs_surrounding_comma {
874 start = prev.start_byte();
875 needs_surrounding_comma = false;
876 }
877 break;
878 } else if prev.start_byte() < start {
879 start = prev.start_byte();
880 }
881 }
882
883 // Do the same for the end of the argument, extending to next comma or the end of the argument list
884 while cursor.goto_next_sibling() {
885 let next = cursor.node();
886
887 if next.end_byte() > close_bracket.start {
888 end = close_bracket.start;
889 break;
890 } else if next.kind() == "," {
891 if needs_surrounding_comma {
892 // Select up to the beginning of the next argument if there is one, otherwise to the end of the comma
893 if let Some(next_arg) = next.next_sibling() {
894 end = next_arg.start_byte();
895 } else {
896 end = next.end_byte();
897 }
898 }
899 break;
900 } else if next.end_byte() > end {
901 end = next.end_byte();
902 }
903 }
904
905 Some(start..end)
906 }
907
908 let result = comma_delimited_range_at(buffer, excerpt.map_offset_to_buffer(offset), around)?;
909
910 if excerpt.contains_buffer_range(result.clone()) {
911 let result = excerpt.map_range_from_buffer(result);
912 Some(result.start.to_display_point(map)..result.end.to_display_point(map))
913 } else {
914 None
915 }
916}
917
918fn indent(
919 map: &DisplaySnapshot,
920 relative_to: DisplayPoint,
921 around: bool,
922 include_below: bool,
923) -> Option<Range<DisplayPoint>> {
924 let point = relative_to.to_point(map);
925 let row = point.row;
926
927 let desired_indent = map.line_indent_for_buffer_row(MultiBufferRow(row));
928
929 // Loop backwards until we find a non-blank line with less indent
930 let mut start_row = row;
931 for prev_row in (0..row).rev() {
932 let indent = map.line_indent_for_buffer_row(MultiBufferRow(prev_row));
933 if indent.is_line_empty() {
934 continue;
935 }
936 if indent.spaces < desired_indent.spaces || indent.tabs < desired_indent.tabs {
937 if around {
938 // When around is true, include the first line with less indent
939 start_row = prev_row;
940 }
941 break;
942 }
943 start_row = prev_row;
944 }
945
946 // Loop forwards until we find a non-blank line with less indent
947 let mut end_row = row;
948 let max_rows = map.buffer_snapshot.max_row().0;
949 for next_row in (row + 1)..=max_rows {
950 let indent = map.line_indent_for_buffer_row(MultiBufferRow(next_row));
951 if indent.is_line_empty() {
952 continue;
953 }
954 if indent.spaces < desired_indent.spaces || indent.tabs < desired_indent.tabs {
955 if around && include_below {
956 // When around is true and including below, include this line
957 end_row = next_row;
958 }
959 break;
960 }
961 end_row = next_row;
962 }
963
964 let end_len = map.buffer_snapshot.line_len(MultiBufferRow(end_row));
965 let start = map.point_to_display_point(Point::new(start_row, 0), Bias::Right);
966 let end = map.point_to_display_point(Point::new(end_row, end_len), Bias::Left);
967 Some(start..end)
968}
969
970fn sentence(
971 map: &DisplaySnapshot,
972 relative_to: DisplayPoint,
973 around: bool,
974) -> Option<Range<DisplayPoint>> {
975 let mut start = None;
976 let relative_offset = relative_to.to_offset(map, Bias::Left);
977 let mut previous_end = relative_offset;
978
979 let mut chars = map.buffer_chars_at(previous_end).peekable();
980
981 // Search backwards for the previous sentence end or current sentence start. Include the character under relative_to
982 for (char, offset) in chars
983 .peek()
984 .cloned()
985 .into_iter()
986 .chain(map.reverse_buffer_chars_at(previous_end))
987 {
988 if is_sentence_end(map, offset) {
989 break;
990 }
991
992 if is_possible_sentence_start(char) {
993 start = Some(offset);
994 }
995
996 previous_end = offset;
997 }
998
999 // Search forward for the end of the current sentence or if we are between sentences, the start of the next one
1000 let mut end = relative_offset;
1001 for (char, offset) in chars {
1002 if start.is_none() && is_possible_sentence_start(char) {
1003 if around {
1004 start = Some(offset);
1005 continue;
1006 } else {
1007 end = offset;
1008 break;
1009 }
1010 }
1011
1012 if char != '\n' {
1013 end = offset + char.len_utf8();
1014 }
1015
1016 if is_sentence_end(map, end) {
1017 break;
1018 }
1019 }
1020
1021 let mut range = start.unwrap_or(previous_end).to_display_point(map)..end.to_display_point(map);
1022 if around {
1023 range = expand_to_include_whitespace(map, range, false);
1024 }
1025
1026 Some(range)
1027}
1028
1029fn is_possible_sentence_start(character: char) -> bool {
1030 !character.is_whitespace() && character != '.'
1031}
1032
1033const SENTENCE_END_PUNCTUATION: &[char] = &['.', '!', '?'];
1034const SENTENCE_END_FILLERS: &[char] = &[')', ']', '"', '\''];
1035const SENTENCE_END_WHITESPACE: &[char] = &[' ', '\t', '\n'];
1036fn is_sentence_end(map: &DisplaySnapshot, offset: usize) -> bool {
1037 let mut next_chars = map.buffer_chars_at(offset).peekable();
1038 if let Some((char, _)) = next_chars.next() {
1039 // We are at a double newline. This position is a sentence end.
1040 if char == '\n' && next_chars.peek().map(|(c, _)| c == &'\n').unwrap_or(false) {
1041 return true;
1042 }
1043
1044 // The next text is not a valid whitespace. This is not a sentence end
1045 if !SENTENCE_END_WHITESPACE.contains(&char) {
1046 return false;
1047 }
1048 }
1049
1050 for (char, _) in map.reverse_buffer_chars_at(offset) {
1051 if SENTENCE_END_PUNCTUATION.contains(&char) {
1052 return true;
1053 }
1054
1055 if !SENTENCE_END_FILLERS.contains(&char) {
1056 return false;
1057 }
1058 }
1059
1060 false
1061}
1062
1063/// Expands the passed range to include whitespace on one side or the other in a line. Attempts to add the
1064/// whitespace to the end first and falls back to the start if there was none.
1065fn expand_to_include_whitespace(
1066 map: &DisplaySnapshot,
1067 range: Range<DisplayPoint>,
1068 stop_at_newline: bool,
1069) -> Range<DisplayPoint> {
1070 let mut range = range.start.to_offset(map, Bias::Left)..range.end.to_offset(map, Bias::Right);
1071 let mut whitespace_included = false;
1072
1073 let chars = map.buffer_chars_at(range.end).peekable();
1074 for (char, offset) in chars {
1075 if char == '\n' && stop_at_newline {
1076 break;
1077 }
1078
1079 if char.is_whitespace() {
1080 if char != '\n' {
1081 range.end = offset + char.len_utf8();
1082 whitespace_included = true;
1083 }
1084 } else {
1085 // Found non whitespace. Quit out.
1086 break;
1087 }
1088 }
1089
1090 if !whitespace_included {
1091 for (char, point) in map.reverse_buffer_chars_at(range.start) {
1092 if char == '\n' && stop_at_newline {
1093 break;
1094 }
1095
1096 if !char.is_whitespace() {
1097 break;
1098 }
1099
1100 range.start = point;
1101 }
1102 }
1103
1104 range.start.to_display_point(map)..range.end.to_display_point(map)
1105}
1106
1107/// If not `around` (i.e. inner), returns a range that surrounds the paragraph
1108/// where `relative_to` is in. If `around`, principally returns the range ending
1109/// at the end of the next paragraph.
1110///
1111/// Here, the "paragraph" is defined as a block of non-blank lines or a block of
1112/// blank lines. If the paragraph ends with a trailing newline (i.e. not with
1113/// EOF), the returned range ends at the trailing newline of the paragraph (i.e.
1114/// the trailing newline is not subject to subsequent operations).
1115///
1116/// Edge cases:
1117/// - If `around` and if the current paragraph is the last paragraph of the
1118/// file and is blank, then the selection results in an error.
1119/// - If `around` and if the current paragraph is the last paragraph of the
1120/// file and is not blank, then the returned range starts at the start of the
1121/// previous paragraph, if it exists.
1122fn paragraph(
1123 map: &DisplaySnapshot,
1124 relative_to: DisplayPoint,
1125 around: bool,
1126) -> Option<Range<DisplayPoint>> {
1127 let mut paragraph_start = start_of_paragraph(map, relative_to);
1128 let mut paragraph_end = end_of_paragraph(map, relative_to);
1129
1130 let paragraph_end_row = paragraph_end.row();
1131 let paragraph_ends_with_eof = paragraph_end_row == map.max_point().row();
1132 let point = relative_to.to_point(map);
1133 let current_line_is_empty = map.buffer_snapshot.is_line_blank(MultiBufferRow(point.row));
1134
1135 if around {
1136 if paragraph_ends_with_eof {
1137 if current_line_is_empty {
1138 return None;
1139 }
1140
1141 let paragraph_start_row = paragraph_start.row();
1142 if paragraph_start_row.0 != 0 {
1143 let previous_paragraph_last_line_start =
1144 DisplayPoint::new(paragraph_start_row - 1, 0);
1145 paragraph_start = start_of_paragraph(map, previous_paragraph_last_line_start);
1146 }
1147 } else {
1148 let next_paragraph_start = DisplayPoint::new(paragraph_end_row + 1, 0);
1149 paragraph_end = end_of_paragraph(map, next_paragraph_start);
1150 }
1151 }
1152
1153 let range = paragraph_start..paragraph_end;
1154 Some(range)
1155}
1156
1157/// Returns a position of the start of the current paragraph, where a paragraph
1158/// is defined as a run of non-blank lines or a run of blank lines.
1159pub fn start_of_paragraph(map: &DisplaySnapshot, display_point: DisplayPoint) -> DisplayPoint {
1160 let point = display_point.to_point(map);
1161 if point.row == 0 {
1162 return DisplayPoint::zero();
1163 }
1164
1165 let is_current_line_blank = map.buffer_snapshot.is_line_blank(MultiBufferRow(point.row));
1166
1167 for row in (0..point.row).rev() {
1168 let blank = map.buffer_snapshot.is_line_blank(MultiBufferRow(row));
1169 if blank != is_current_line_blank {
1170 return Point::new(row + 1, 0).to_display_point(map);
1171 }
1172 }
1173
1174 DisplayPoint::zero()
1175}
1176
1177/// Returns a position of the end of the current paragraph, where a paragraph
1178/// is defined as a run of non-blank lines or a run of blank lines.
1179/// The trailing newline is excluded from the paragraph.
1180pub fn end_of_paragraph(map: &DisplaySnapshot, display_point: DisplayPoint) -> DisplayPoint {
1181 let point = display_point.to_point(map);
1182 if point.row == map.buffer_snapshot.max_row().0 {
1183 return map.max_point();
1184 }
1185
1186 let is_current_line_blank = map.buffer_snapshot.is_line_blank(MultiBufferRow(point.row));
1187
1188 for row in point.row + 1..map.buffer_snapshot.max_row().0 + 1 {
1189 let blank = map.buffer_snapshot.is_line_blank(MultiBufferRow(row));
1190 if blank != is_current_line_blank {
1191 let previous_row = row - 1;
1192 return Point::new(
1193 previous_row,
1194 map.buffer_snapshot.line_len(MultiBufferRow(previous_row)),
1195 )
1196 .to_display_point(map);
1197 }
1198 }
1199
1200 map.max_point()
1201}
1202
1203fn surrounding_markers(
1204 map: &DisplaySnapshot,
1205 relative_to: DisplayPoint,
1206 around: bool,
1207 search_across_lines: bool,
1208 open_marker: char,
1209 close_marker: char,
1210) -> Option<Range<DisplayPoint>> {
1211 let point = relative_to.to_offset(map, Bias::Left);
1212
1213 let mut matched_closes = 0;
1214 let mut opening = None;
1215
1216 let mut before_ch = match movement::chars_before(map, point).next() {
1217 Some((ch, _)) => ch,
1218 _ => '\0',
1219 };
1220 if let Some((ch, range)) = movement::chars_after(map, point).next() {
1221 if ch == open_marker && before_ch != '\\' {
1222 if open_marker == close_marker {
1223 let mut total = 0;
1224 for ((ch, _), (before_ch, _)) in movement::chars_before(map, point).tuple_windows()
1225 {
1226 if ch == '\n' {
1227 break;
1228 }
1229 if ch == open_marker && before_ch != '\\' {
1230 total += 1;
1231 }
1232 }
1233 if total % 2 == 0 {
1234 opening = Some(range)
1235 }
1236 } else {
1237 opening = Some(range)
1238 }
1239 }
1240 }
1241
1242 if opening.is_none() {
1243 let mut chars_before = movement::chars_before(map, point).peekable();
1244 while let Some((ch, range)) = chars_before.next() {
1245 if ch == '\n' && !search_across_lines {
1246 break;
1247 }
1248
1249 if let Some((before_ch, _)) = chars_before.peek() {
1250 if *before_ch == '\\' {
1251 continue;
1252 }
1253 }
1254
1255 if ch == open_marker {
1256 if matched_closes == 0 {
1257 opening = Some(range);
1258 break;
1259 }
1260 matched_closes -= 1;
1261 } else if ch == close_marker {
1262 matched_closes += 1
1263 }
1264 }
1265 }
1266 if opening.is_none() {
1267 for (ch, range) in movement::chars_after(map, point) {
1268 if before_ch != '\\' {
1269 if ch == open_marker {
1270 opening = Some(range);
1271 break;
1272 } else if ch == close_marker {
1273 break;
1274 }
1275 }
1276
1277 before_ch = ch;
1278 }
1279 }
1280
1281 let mut opening = opening?;
1282
1283 let mut matched_opens = 0;
1284 let mut closing = None;
1285 before_ch = match movement::chars_before(map, opening.end).next() {
1286 Some((ch, _)) => ch,
1287 _ => '\0',
1288 };
1289 for (ch, range) in movement::chars_after(map, opening.end) {
1290 if ch == '\n' && !search_across_lines {
1291 break;
1292 }
1293
1294 if before_ch != '\\' {
1295 if ch == close_marker {
1296 if matched_opens == 0 {
1297 closing = Some(range);
1298 break;
1299 }
1300 matched_opens -= 1;
1301 } else if ch == open_marker {
1302 matched_opens += 1;
1303 }
1304 }
1305
1306 before_ch = ch;
1307 }
1308
1309 let mut closing = closing?;
1310
1311 if around && !search_across_lines {
1312 let mut found = false;
1313
1314 for (ch, range) in movement::chars_after(map, closing.end) {
1315 if ch.is_whitespace() && ch != '\n' {
1316 found = true;
1317 closing.end = range.end;
1318 } else {
1319 break;
1320 }
1321 }
1322
1323 if !found {
1324 for (ch, range) in movement::chars_before(map, opening.start) {
1325 if ch.is_whitespace() && ch != '\n' {
1326 opening.start = range.start
1327 } else {
1328 break;
1329 }
1330 }
1331 }
1332 }
1333
1334 if !around && search_across_lines {
1335 if let Some((ch, range)) = movement::chars_after(map, opening.end).next() {
1336 if ch == '\n' {
1337 opening.end = range.end
1338 }
1339 }
1340
1341 for (ch, range) in movement::chars_before(map, closing.start) {
1342 if !ch.is_whitespace() {
1343 break;
1344 }
1345 if ch != '\n' {
1346 closing.start = range.start
1347 }
1348 }
1349 }
1350
1351 let result = if around {
1352 opening.start..closing.end
1353 } else {
1354 opening.end..closing.start
1355 };
1356
1357 Some(
1358 map.clip_point(result.start.to_display_point(map), Bias::Left)
1359 ..map.clip_point(result.end.to_display_point(map), Bias::Right),
1360 )
1361}
1362
1363#[cfg(test)]
1364mod test {
1365 use gpui::KeyBinding;
1366 use indoc::indoc;
1367
1368 use crate::{
1369 object::AnyBrackets,
1370 state::Mode,
1371 test::{NeovimBackedTestContext, VimTestContext},
1372 };
1373
1374 const WORD_LOCATIONS: &str = indoc! {"
1375 The quick ˇbrowˇnˇ•••
1376 fox ˇjuˇmpsˇ over
1377 the lazy dogˇ••
1378 ˇ
1379 ˇ
1380 ˇ
1381 Thˇeˇ-ˇquˇickˇ ˇbrownˇ•
1382 ˇ••
1383 ˇ••
1384 ˇ fox-jumpˇs over
1385 the lazy dogˇ•
1386 ˇ
1387 "
1388 };
1389
1390 #[gpui::test]
1391 async fn test_change_word_object(cx: &mut gpui::TestAppContext) {
1392 let mut cx = NeovimBackedTestContext::new(cx).await;
1393
1394 cx.simulate_at_each_offset("c i w", WORD_LOCATIONS)
1395 .await
1396 .assert_matches();
1397 cx.simulate_at_each_offset("c i shift-w", WORD_LOCATIONS)
1398 .await
1399 .assert_matches();
1400 cx.simulate_at_each_offset("c a w", WORD_LOCATIONS)
1401 .await
1402 .assert_matches();
1403 cx.simulate_at_each_offset("c a shift-w", WORD_LOCATIONS)
1404 .await
1405 .assert_matches();
1406 }
1407
1408 #[gpui::test]
1409 async fn test_delete_word_object(cx: &mut gpui::TestAppContext) {
1410 let mut cx = NeovimBackedTestContext::new(cx).await;
1411
1412 cx.simulate_at_each_offset("d i w", WORD_LOCATIONS)
1413 .await
1414 .assert_matches();
1415 cx.simulate_at_each_offset("d i shift-w", WORD_LOCATIONS)
1416 .await
1417 .assert_matches();
1418 cx.simulate_at_each_offset("d a w", WORD_LOCATIONS)
1419 .await
1420 .assert_matches();
1421 cx.simulate_at_each_offset("d a shift-w", WORD_LOCATIONS)
1422 .await
1423 .assert_matches();
1424 }
1425
1426 #[gpui::test]
1427 async fn test_visual_word_object(cx: &mut gpui::TestAppContext) {
1428 let mut cx = NeovimBackedTestContext::new(cx).await;
1429
1430 /*
1431 cx.set_shared_state("The quick ˇbrown\nfox").await;
1432 cx.simulate_shared_keystrokes(["v"]).await;
1433 cx.assert_shared_state("The quick «bˇ»rown\nfox").await;
1434 cx.simulate_shared_keystrokes(["i", "w"]).await;
1435 cx.assert_shared_state("The quick «brownˇ»\nfox").await;
1436 */
1437 cx.set_shared_state("The quick brown\nˇ\nfox").await;
1438 cx.simulate_shared_keystrokes("v").await;
1439 cx.shared_state()
1440 .await
1441 .assert_eq("The quick brown\n«\nˇ»fox");
1442 cx.simulate_shared_keystrokes("i w").await;
1443 cx.shared_state()
1444 .await
1445 .assert_eq("The quick brown\n«\nˇ»fox");
1446
1447 cx.simulate_at_each_offset("v i w", WORD_LOCATIONS)
1448 .await
1449 .assert_matches();
1450 cx.simulate_at_each_offset("v i shift-w", WORD_LOCATIONS)
1451 .await
1452 .assert_matches();
1453 }
1454
1455 const PARAGRAPH_EXAMPLES: &[&str] = &[
1456 // Single line
1457 "ˇThe quick brown fox jumpˇs over the lazy dogˇ.ˇ",
1458 // Multiple lines without empty lines
1459 indoc! {"
1460 ˇThe quick brownˇ
1461 ˇfox jumps overˇ
1462 the lazy dog.ˇ
1463 "},
1464 // Heading blank paragraph and trailing normal paragraph
1465 indoc! {"
1466 ˇ
1467 ˇ
1468 ˇThe quick brown fox jumps
1469 ˇover the lazy dog.
1470 ˇ
1471 ˇ
1472 ˇThe quick brown fox jumpsˇ
1473 ˇover the lazy dog.ˇ
1474 "},
1475 // Inserted blank paragraph and trailing blank paragraph
1476 indoc! {"
1477 ˇThe quick brown fox jumps
1478 ˇover the lazy dog.
1479 ˇ
1480 ˇ
1481 ˇ
1482 ˇThe quick brown fox jumpsˇ
1483 ˇover the lazy dog.ˇ
1484 ˇ
1485 ˇ
1486 ˇ
1487 "},
1488 // "Blank" paragraph with whitespace characters
1489 indoc! {"
1490 ˇThe quick brown fox jumps
1491 over the lazy dog.
1492
1493 ˇ \t
1494
1495 ˇThe quick brown fox jumps
1496 over the lazy dog.ˇ
1497 ˇ
1498 ˇ \t
1499 \t \t
1500 "},
1501 // Single line "paragraphs", where selection size might be zero.
1502 indoc! {"
1503 ˇThe quick brown fox jumps over the lazy dog.
1504 ˇ
1505 ˇThe quick brown fox jumpˇs over the lazy dog.ˇ
1506 ˇ
1507 "},
1508 ];
1509
1510 #[gpui::test]
1511 async fn test_change_paragraph_object(cx: &mut gpui::TestAppContext) {
1512 let mut cx = NeovimBackedTestContext::new(cx).await;
1513
1514 for paragraph_example in PARAGRAPH_EXAMPLES {
1515 cx.simulate_at_each_offset("c i p", paragraph_example)
1516 .await
1517 .assert_matches();
1518 cx.simulate_at_each_offset("c a p", paragraph_example)
1519 .await
1520 .assert_matches();
1521 }
1522 }
1523
1524 #[gpui::test]
1525 async fn test_delete_paragraph_object(cx: &mut gpui::TestAppContext) {
1526 let mut cx = NeovimBackedTestContext::new(cx).await;
1527
1528 for paragraph_example in PARAGRAPH_EXAMPLES {
1529 cx.simulate_at_each_offset("d i p", paragraph_example)
1530 .await
1531 .assert_matches();
1532 cx.simulate_at_each_offset("d a p", paragraph_example)
1533 .await
1534 .assert_matches();
1535 }
1536 }
1537
1538 #[gpui::test]
1539 async fn test_visual_paragraph_object(cx: &mut gpui::TestAppContext) {
1540 let mut cx = NeovimBackedTestContext::new(cx).await;
1541
1542 const EXAMPLES: &[&str] = &[
1543 indoc! {"
1544 ˇThe quick brown
1545 fox jumps over
1546 the lazy dog.
1547 "},
1548 indoc! {"
1549 ˇ
1550
1551 ˇThe quick brown fox jumps
1552 over the lazy dog.
1553 ˇ
1554
1555 ˇThe quick brown fox jumps
1556 over the lazy dog.
1557 "},
1558 indoc! {"
1559 ˇThe quick brown fox jumps over the lazy dog.
1560 ˇ
1561 ˇThe quick brown fox jumps over the lazy dog.
1562
1563 "},
1564 ];
1565
1566 for paragraph_example in EXAMPLES {
1567 cx.simulate_at_each_offset("v i p", paragraph_example)
1568 .await
1569 .assert_matches();
1570 cx.simulate_at_each_offset("v a p", paragraph_example)
1571 .await
1572 .assert_matches();
1573 }
1574 }
1575
1576 // Test string with "`" for opening surrounders and "'" for closing surrounders
1577 const SURROUNDING_MARKER_STRING: &str = indoc! {"
1578 ˇTh'ˇe ˇ`ˇ'ˇquˇi`ˇck broˇ'wn`
1579 'ˇfox juˇmps ov`ˇer
1580 the ˇlazy d'o`ˇg"};
1581
1582 const SURROUNDING_OBJECTS: &[(char, char)] = &[
1583 ('"', '"'), // Double Quote
1584 ('(', ')'), // Parentheses
1585 ];
1586
1587 #[gpui::test]
1588 async fn test_change_surrounding_character_objects(cx: &mut gpui::TestAppContext) {
1589 let mut cx = NeovimBackedTestContext::new(cx).await;
1590
1591 for (start, end) in SURROUNDING_OBJECTS {
1592 let marked_string = SURROUNDING_MARKER_STRING
1593 .replace('`', &start.to_string())
1594 .replace('\'', &end.to_string());
1595
1596 cx.simulate_at_each_offset(&format!("c i {start}"), &marked_string)
1597 .await
1598 .assert_matches();
1599 cx.simulate_at_each_offset(&format!("c i {end}"), &marked_string)
1600 .await
1601 .assert_matches();
1602 cx.simulate_at_each_offset(&format!("c a {start}"), &marked_string)
1603 .await
1604 .assert_matches();
1605 cx.simulate_at_each_offset(&format!("c a {end}"), &marked_string)
1606 .await
1607 .assert_matches();
1608 }
1609 }
1610 #[gpui::test]
1611 async fn test_singleline_surrounding_character_objects(cx: &mut gpui::TestAppContext) {
1612 let mut cx = NeovimBackedTestContext::new(cx).await;
1613 cx.set_shared_wrap(12).await;
1614
1615 cx.set_shared_state(indoc! {
1616 "\"ˇhello world\"!"
1617 })
1618 .await;
1619 cx.simulate_shared_keystrokes("v i \"").await;
1620 cx.shared_state().await.assert_eq(indoc! {
1621 "\"«hello worldˇ»\"!"
1622 });
1623
1624 cx.set_shared_state(indoc! {
1625 "\"hˇello world\"!"
1626 })
1627 .await;
1628 cx.simulate_shared_keystrokes("v i \"").await;
1629 cx.shared_state().await.assert_eq(indoc! {
1630 "\"«hello worldˇ»\"!"
1631 });
1632
1633 cx.set_shared_state(indoc! {
1634 "helˇlo \"world\"!"
1635 })
1636 .await;
1637 cx.simulate_shared_keystrokes("v i \"").await;
1638 cx.shared_state().await.assert_eq(indoc! {
1639 "hello \"«worldˇ»\"!"
1640 });
1641
1642 cx.set_shared_state(indoc! {
1643 "hello \"wˇorld\"!"
1644 })
1645 .await;
1646 cx.simulate_shared_keystrokes("v i \"").await;
1647 cx.shared_state().await.assert_eq(indoc! {
1648 "hello \"«worldˇ»\"!"
1649 });
1650
1651 cx.set_shared_state(indoc! {
1652 "hello \"wˇorld\"!"
1653 })
1654 .await;
1655 cx.simulate_shared_keystrokes("v a \"").await;
1656 cx.shared_state().await.assert_eq(indoc! {
1657 "hello« \"world\"ˇ»!"
1658 });
1659
1660 cx.set_shared_state(indoc! {
1661 "hello \"wˇorld\" !"
1662 })
1663 .await;
1664 cx.simulate_shared_keystrokes("v a \"").await;
1665 cx.shared_state().await.assert_eq(indoc! {
1666 "hello «\"world\" ˇ»!"
1667 });
1668
1669 cx.set_shared_state(indoc! {
1670 "hello \"wˇorld\"•
1671 goodbye"
1672 })
1673 .await;
1674 cx.simulate_shared_keystrokes("v a \"").await;
1675 cx.shared_state().await.assert_eq(indoc! {
1676 "hello «\"world\" ˇ»
1677 goodbye"
1678 });
1679 }
1680
1681 #[gpui::test]
1682 async fn test_multiline_surrounding_character_objects(cx: &mut gpui::TestAppContext) {
1683 let mut cx = NeovimBackedTestContext::new(cx).await;
1684
1685 cx.set_shared_state(indoc! {
1686 "func empty(a string) bool {
1687 if a == \"\" {
1688 return true
1689 }
1690 ˇreturn false
1691 }"
1692 })
1693 .await;
1694 cx.simulate_shared_keystrokes("v i {").await;
1695 cx.shared_state().await.assert_eq(indoc! {"
1696 func empty(a string) bool {
1697 « if a == \"\" {
1698 return true
1699 }
1700 return false
1701 ˇ»}"});
1702 cx.set_shared_state(indoc! {
1703 "func empty(a string) bool {
1704 if a == \"\" {
1705 ˇreturn true
1706 }
1707 return false
1708 }"
1709 })
1710 .await;
1711 cx.simulate_shared_keystrokes("v i {").await;
1712 cx.shared_state().await.assert_eq(indoc! {"
1713 func empty(a string) bool {
1714 if a == \"\" {
1715 « return true
1716 ˇ» }
1717 return false
1718 }"});
1719
1720 cx.set_shared_state(indoc! {
1721 "func empty(a string) bool {
1722 if a == \"\" ˇ{
1723 return true
1724 }
1725 return false
1726 }"
1727 })
1728 .await;
1729 cx.simulate_shared_keystrokes("v i {").await;
1730 cx.shared_state().await.assert_eq(indoc! {"
1731 func empty(a string) bool {
1732 if a == \"\" {
1733 « return true
1734 ˇ» }
1735 return false
1736 }"});
1737 }
1738
1739 #[gpui::test]
1740 async fn test_singleline_surrounding_character_objects_with_escape(
1741 cx: &mut gpui::TestAppContext,
1742 ) {
1743 let mut cx = NeovimBackedTestContext::new(cx).await;
1744 cx.set_shared_state(indoc! {
1745 "h\"e\\\"lˇlo \\\"world\"!"
1746 })
1747 .await;
1748 cx.simulate_shared_keystrokes("v i \"").await;
1749 cx.shared_state().await.assert_eq(indoc! {
1750 "h\"«e\\\"llo \\\"worldˇ»\"!"
1751 });
1752
1753 cx.set_shared_state(indoc! {
1754 "hello \"teˇst \\\"inside\\\" world\""
1755 })
1756 .await;
1757 cx.simulate_shared_keystrokes("v i \"").await;
1758 cx.shared_state().await.assert_eq(indoc! {
1759 "hello \"«test \\\"inside\\\" worldˇ»\""
1760 });
1761 }
1762
1763 #[gpui::test]
1764 async fn test_vertical_bars(cx: &mut gpui::TestAppContext) {
1765 let mut cx = VimTestContext::new(cx, true).await;
1766 cx.set_state(
1767 indoc! {"
1768 fn boop() {
1769 baz(ˇ|a, b| { bar(|j, k| { })})
1770 }"
1771 },
1772 Mode::Normal,
1773 );
1774 cx.simulate_keystrokes("c i |");
1775 cx.assert_state(
1776 indoc! {"
1777 fn boop() {
1778 baz(|ˇ| { bar(|j, k| { })})
1779 }"
1780 },
1781 Mode::Insert,
1782 );
1783 cx.simulate_keystrokes("escape 1 8 |");
1784 cx.assert_state(
1785 indoc! {"
1786 fn boop() {
1787 baz(|| { bar(ˇ|j, k| { })})
1788 }"
1789 },
1790 Mode::Normal,
1791 );
1792
1793 cx.simulate_keystrokes("v a |");
1794 cx.assert_state(
1795 indoc! {"
1796 fn boop() {
1797 baz(|| { bar(«|j, k| ˇ»{ })})
1798 }"
1799 },
1800 Mode::Visual,
1801 );
1802 }
1803
1804 #[gpui::test]
1805 async fn test_argument_object(cx: &mut gpui::TestAppContext) {
1806 let mut cx = VimTestContext::new(cx, true).await;
1807
1808 // Generic arguments
1809 cx.set_state("fn boop<A: ˇDebug, B>() {}", Mode::Normal);
1810 cx.simulate_keystrokes("v i a");
1811 cx.assert_state("fn boop<«A: Debugˇ», B>() {}", Mode::Visual);
1812
1813 // Function arguments
1814 cx.set_state(
1815 "fn boop(ˇarg_a: (Tuple, Of, Types), arg_b: String) {}",
1816 Mode::Normal,
1817 );
1818 cx.simulate_keystrokes("d a a");
1819 cx.assert_state("fn boop(ˇarg_b: String) {}", Mode::Normal);
1820
1821 cx.set_state("std::namespace::test(\"strinˇg\", a.b.c())", Mode::Normal);
1822 cx.simulate_keystrokes("v a a");
1823 cx.assert_state("std::namespace::test(«\"string\", ˇ»a.b.c())", Mode::Visual);
1824
1825 // Tuple, vec, and array arguments
1826 cx.set_state(
1827 "fn boop(arg_a: (Tuple, Ofˇ, Types), arg_b: String) {}",
1828 Mode::Normal,
1829 );
1830 cx.simulate_keystrokes("c i a");
1831 cx.assert_state(
1832 "fn boop(arg_a: (Tuple, ˇ, Types), arg_b: String) {}",
1833 Mode::Insert,
1834 );
1835
1836 cx.set_state("let a = (test::call(), 'p', my_macro!{ˇ});", Mode::Normal);
1837 cx.simulate_keystrokes("c a a");
1838 cx.assert_state("let a = (test::call(), 'p'ˇ);", Mode::Insert);
1839
1840 cx.set_state("let a = [test::call(ˇ), 300];", Mode::Normal);
1841 cx.simulate_keystrokes("c i a");
1842 cx.assert_state("let a = [ˇ, 300];", Mode::Insert);
1843
1844 cx.set_state(
1845 "let a = vec![Vec::new(), vecˇ![test::call(), 300]];",
1846 Mode::Normal,
1847 );
1848 cx.simulate_keystrokes("c a a");
1849 cx.assert_state("let a = vec![Vec::new()ˇ];", Mode::Insert);
1850
1851 // Cursor immediately before / after brackets
1852 cx.set_state("let a = [test::call(first_arg)ˇ]", Mode::Normal);
1853 cx.simulate_keystrokes("v i a");
1854 cx.assert_state("let a = [«test::call(first_arg)ˇ»]", Mode::Visual);
1855
1856 cx.set_state("let a = [test::callˇ(first_arg)]", Mode::Normal);
1857 cx.simulate_keystrokes("v i a");
1858 cx.assert_state("let a = [«test::call(first_arg)ˇ»]", Mode::Visual);
1859 }
1860
1861 #[gpui::test]
1862 async fn test_indent_object(cx: &mut gpui::TestAppContext) {
1863 let mut cx = VimTestContext::new(cx, true).await;
1864
1865 // Base use case
1866 cx.set_state(
1867 indoc! {"
1868 fn boop() {
1869 // Comment
1870 baz();ˇ
1871
1872 loop {
1873 bar(1);
1874 bar(2);
1875 }
1876
1877 result
1878 }
1879 "},
1880 Mode::Normal,
1881 );
1882 cx.simulate_keystrokes("v i i");
1883 cx.assert_state(
1884 indoc! {"
1885 fn boop() {
1886 « // Comment
1887 baz();
1888
1889 loop {
1890 bar(1);
1891 bar(2);
1892 }
1893
1894 resultˇ»
1895 }
1896 "},
1897 Mode::Visual,
1898 );
1899
1900 // Around indent (include line above)
1901 cx.set_state(
1902 indoc! {"
1903 const ABOVE: str = true;
1904 fn boop() {
1905
1906 hello();
1907 worˇld()
1908 }
1909 "},
1910 Mode::Normal,
1911 );
1912 cx.simulate_keystrokes("v a i");
1913 cx.assert_state(
1914 indoc! {"
1915 const ABOVE: str = true;
1916 «fn boop() {
1917
1918 hello();
1919 world()ˇ»
1920 }
1921 "},
1922 Mode::Visual,
1923 );
1924
1925 // Around indent (include line above & below)
1926 cx.set_state(
1927 indoc! {"
1928 const ABOVE: str = true;
1929 fn boop() {
1930 hellˇo();
1931 world()
1932
1933 }
1934 const BELOW: str = true;
1935 "},
1936 Mode::Normal,
1937 );
1938 cx.simulate_keystrokes("c a shift-i");
1939 cx.assert_state(
1940 indoc! {"
1941 const ABOVE: str = true;
1942 ˇ
1943 const BELOW: str = true;
1944 "},
1945 Mode::Insert,
1946 );
1947 }
1948
1949 #[gpui::test]
1950 async fn test_delete_surrounding_character_objects(cx: &mut gpui::TestAppContext) {
1951 let mut cx = NeovimBackedTestContext::new(cx).await;
1952
1953 for (start, end) in SURROUNDING_OBJECTS {
1954 let marked_string = SURROUNDING_MARKER_STRING
1955 .replace('`', &start.to_string())
1956 .replace('\'', &end.to_string());
1957
1958 cx.simulate_at_each_offset(&format!("d i {start}"), &marked_string)
1959 .await
1960 .assert_matches();
1961 cx.simulate_at_each_offset(&format!("d i {end}"), &marked_string)
1962 .await
1963 .assert_matches();
1964 cx.simulate_at_each_offset(&format!("d a {start}"), &marked_string)
1965 .await
1966 .assert_matches();
1967 cx.simulate_at_each_offset(&format!("d a {end}"), &marked_string)
1968 .await
1969 .assert_matches();
1970 }
1971 }
1972
1973 #[gpui::test]
1974 async fn test_anyquotes_object(cx: &mut gpui::TestAppContext) {
1975 let mut cx = VimTestContext::new(cx, true).await;
1976
1977 const TEST_CASES: &[(&str, &str, &str, Mode)] = &[
1978 // Single quotes
1979 (
1980 "c i q",
1981 "Thisˇ is a 'quote' example.",
1982 "This is a 'ˇ' example.",
1983 Mode::Insert,
1984 ),
1985 (
1986 "c a q",
1987 "Thisˇ is a 'quote' example.",
1988 "This is a ˇexample.",
1989 Mode::Insert,
1990 ),
1991 (
1992 "c i q",
1993 "This is a \"simple 'qˇuote'\" example.",
1994 "This is a \"simple 'ˇ'\" example.",
1995 Mode::Insert,
1996 ),
1997 (
1998 "c a q",
1999 "This is a \"simple 'qˇuote'\" example.",
2000 "This is a \"simpleˇ\" example.",
2001 Mode::Insert,
2002 ),
2003 (
2004 "c i q",
2005 "This is a 'qˇuote' example.",
2006 "This is a 'ˇ' example.",
2007 Mode::Insert,
2008 ),
2009 (
2010 "c a q",
2011 "This is a 'qˇuote' example.",
2012 "This is a ˇexample.",
2013 Mode::Insert,
2014 ),
2015 (
2016 "d i q",
2017 "This is a 'qˇuote' example.",
2018 "This is a 'ˇ' example.",
2019 Mode::Normal,
2020 ),
2021 (
2022 "d a q",
2023 "This is a 'qˇuote' example.",
2024 "This is a ˇexample.",
2025 Mode::Normal,
2026 ),
2027 // Double quotes
2028 (
2029 "c i q",
2030 "This is a \"qˇuote\" example.",
2031 "This is a \"ˇ\" example.",
2032 Mode::Insert,
2033 ),
2034 (
2035 "c a q",
2036 "This is a \"qˇuote\" example.",
2037 "This is a ˇexample.",
2038 Mode::Insert,
2039 ),
2040 (
2041 "d i q",
2042 "This is a \"qˇuote\" example.",
2043 "This is a \"ˇ\" example.",
2044 Mode::Normal,
2045 ),
2046 (
2047 "d a q",
2048 "This is a \"qˇuote\" example.",
2049 "This is a ˇexample.",
2050 Mode::Normal,
2051 ),
2052 // Back quotes
2053 (
2054 "c i q",
2055 "This is a `qˇuote` example.",
2056 "This is a `ˇ` example.",
2057 Mode::Insert,
2058 ),
2059 (
2060 "c a q",
2061 "This is a `qˇuote` example.",
2062 "This is a ˇexample.",
2063 Mode::Insert,
2064 ),
2065 (
2066 "d i q",
2067 "This is a `qˇuote` example.",
2068 "This is a `ˇ` example.",
2069 Mode::Normal,
2070 ),
2071 (
2072 "d a q",
2073 "This is a `qˇuote` example.",
2074 "This is a ˇexample.",
2075 Mode::Normal,
2076 ),
2077 ];
2078
2079 for (keystrokes, initial_state, expected_state, expected_mode) in TEST_CASES {
2080 cx.set_state(initial_state, Mode::Normal);
2081
2082 cx.simulate_keystrokes(keystrokes);
2083
2084 cx.assert_state(expected_state, *expected_mode);
2085 }
2086
2087 const INVALID_CASES: &[(&str, &str, Mode)] = &[
2088 ("c i q", "this is a 'qˇuote example.", Mode::Normal), // Missing closing simple quote
2089 ("c a q", "this is a 'qˇuote example.", Mode::Normal), // Missing closing simple quote
2090 ("d i q", "this is a 'qˇuote example.", Mode::Normal), // Missing closing simple quote
2091 ("d a q", "this is a 'qˇuote example.", Mode::Normal), // Missing closing simple quote
2092 ("c i q", "this is a \"qˇuote example.", Mode::Normal), // Missing closing double quote
2093 ("c a q", "this is a \"qˇuote example.", Mode::Normal), // Missing closing double quote
2094 ("d i q", "this is a \"qˇuote example.", Mode::Normal), // Missing closing double quote
2095 ("d a q", "this is a \"qˇuote example.", Mode::Normal), // Missing closing back quote
2096 ("c i q", "this is a `qˇuote example.", Mode::Normal), // Missing closing back quote
2097 ("c a q", "this is a `qˇuote example.", Mode::Normal), // Missing closing back quote
2098 ("d i q", "this is a `qˇuote example.", Mode::Normal), // Missing closing back quote
2099 ("d a q", "this is a `qˇuote example.", Mode::Normal), // Missing closing back quote
2100 ];
2101
2102 for (keystrokes, initial_state, mode) in INVALID_CASES {
2103 cx.set_state(initial_state, Mode::Normal);
2104
2105 cx.simulate_keystrokes(keystrokes);
2106
2107 cx.assert_state(initial_state, *mode);
2108 }
2109 }
2110
2111 #[gpui::test]
2112 async fn test_anybrackets_object(cx: &mut gpui::TestAppContext) {
2113 let mut cx = VimTestContext::new(cx, true).await;
2114 cx.update(|_, cx| {
2115 cx.bind_keys([KeyBinding::new(
2116 "b",
2117 AnyBrackets,
2118 Some("vim_operator == a || vim_operator == i || vim_operator == cs"),
2119 )]);
2120 });
2121
2122 const TEST_CASES: &[(&str, &str, &str, Mode)] = &[
2123 // Bracket (Parentheses)
2124 (
2125 "c i b",
2126 "Thisˇ is a (simple [quote]) example.",
2127 "This is a (ˇ) example.",
2128 Mode::Insert,
2129 ),
2130 (
2131 "c i b",
2132 "This is a [simple (qˇuote)] example.",
2133 "This is a [simple (ˇ)] example.",
2134 Mode::Insert,
2135 ),
2136 (
2137 "c a b",
2138 "This is a [simple (qˇuote)] example.",
2139 "This is a [simple ˇ] example.",
2140 Mode::Insert,
2141 ),
2142 (
2143 "c a b",
2144 "Thisˇ is a (simple [quote]) example.",
2145 "This is a ˇ example.",
2146 Mode::Insert,
2147 ),
2148 (
2149 "c i b",
2150 "This is a (qˇuote) example.",
2151 "This is a (ˇ) example.",
2152 Mode::Insert,
2153 ),
2154 (
2155 "c a b",
2156 "This is a (qˇuote) example.",
2157 "This is a ˇ example.",
2158 Mode::Insert,
2159 ),
2160 (
2161 "d i b",
2162 "This is a (qˇuote) example.",
2163 "This is a (ˇ) example.",
2164 Mode::Normal,
2165 ),
2166 (
2167 "d a b",
2168 "This is a (qˇuote) example.",
2169 "This is a ˇ example.",
2170 Mode::Normal,
2171 ),
2172 // Square brackets
2173 (
2174 "c i b",
2175 "This is a [qˇuote] example.",
2176 "This is a [ˇ] example.",
2177 Mode::Insert,
2178 ),
2179 (
2180 "c a b",
2181 "This is a [qˇuote] example.",
2182 "This is a ˇ example.",
2183 Mode::Insert,
2184 ),
2185 (
2186 "d i b",
2187 "This is a [qˇuote] example.",
2188 "This is a [ˇ] example.",
2189 Mode::Normal,
2190 ),
2191 (
2192 "d a b",
2193 "This is a [qˇuote] example.",
2194 "This is a ˇ example.",
2195 Mode::Normal,
2196 ),
2197 // Curly brackets
2198 (
2199 "c i b",
2200 "This is a {qˇuote} example.",
2201 "This is a {ˇ} example.",
2202 Mode::Insert,
2203 ),
2204 (
2205 "c a b",
2206 "This is a {qˇuote} example.",
2207 "This is a ˇ example.",
2208 Mode::Insert,
2209 ),
2210 (
2211 "d i b",
2212 "This is a {qˇuote} example.",
2213 "This is a {ˇ} example.",
2214 Mode::Normal,
2215 ),
2216 (
2217 "d a b",
2218 "This is a {qˇuote} example.",
2219 "This is a ˇ example.",
2220 Mode::Normal,
2221 ),
2222 ];
2223
2224 for (keystrokes, initial_state, expected_state, expected_mode) in TEST_CASES {
2225 cx.set_state(initial_state, Mode::Normal);
2226
2227 cx.simulate_keystrokes(keystrokes);
2228
2229 cx.assert_state(expected_state, *expected_mode);
2230 }
2231
2232 const INVALID_CASES: &[(&str, &str, Mode)] = &[
2233 ("c i b", "this is a (qˇuote example.", Mode::Normal), // Missing closing bracket
2234 ("c a b", "this is a (qˇuote example.", Mode::Normal), // Missing closing bracket
2235 ("d i b", "this is a (qˇuote example.", Mode::Normal), // Missing closing bracket
2236 ("d a b", "this is a (qˇuote example.", Mode::Normal), // Missing closing bracket
2237 ("c i b", "this is a [qˇuote example.", Mode::Normal), // Missing closing square bracket
2238 ("c a b", "this is a [qˇuote example.", Mode::Normal), // Missing closing square bracket
2239 ("d i b", "this is a [qˇuote example.", Mode::Normal), // Missing closing square bracket
2240 ("d a b", "this is a [qˇuote example.", Mode::Normal), // Missing closing square bracket
2241 ("c i b", "this is a {qˇuote example.", Mode::Normal), // Missing closing curly bracket
2242 ("c a b", "this is a {qˇuote example.", Mode::Normal), // Missing closing curly bracket
2243 ("d i b", "this is a {qˇuote example.", Mode::Normal), // Missing closing curly bracket
2244 ("d a b", "this is a {qˇuote example.", Mode::Normal), // Missing closing curly bracket
2245 ];
2246
2247 for (keystrokes, initial_state, mode) in INVALID_CASES {
2248 cx.set_state(initial_state, Mode::Normal);
2249
2250 cx.simulate_keystrokes(keystrokes);
2251
2252 cx.assert_state(initial_state, *mode);
2253 }
2254 }
2255
2256 #[gpui::test]
2257 async fn test_tags(cx: &mut gpui::TestAppContext) {
2258 let mut cx = VimTestContext::new_html(cx).await;
2259
2260 cx.set_state("<html><head></head><body><b>hˇi!</b></body>", Mode::Normal);
2261 cx.simulate_keystrokes("v i t");
2262 cx.assert_state(
2263 "<html><head></head><body><b>«hi!ˇ»</b></body>",
2264 Mode::Visual,
2265 );
2266 cx.simulate_keystrokes("a t");
2267 cx.assert_state(
2268 "<html><head></head><body>«<b>hi!</b>ˇ»</body>",
2269 Mode::Visual,
2270 );
2271 cx.simulate_keystrokes("a t");
2272 cx.assert_state(
2273 "<html><head></head>«<body><b>hi!</b></body>ˇ»",
2274 Mode::Visual,
2275 );
2276
2277 // The cursor is before the tag
2278 cx.set_state(
2279 "<html><head></head><body> ˇ <b>hi!</b></body>",
2280 Mode::Normal,
2281 );
2282 cx.simulate_keystrokes("v i t");
2283 cx.assert_state(
2284 "<html><head></head><body> <b>«hi!ˇ»</b></body>",
2285 Mode::Visual,
2286 );
2287 cx.simulate_keystrokes("a t");
2288 cx.assert_state(
2289 "<html><head></head><body> «<b>hi!</b>ˇ»</body>",
2290 Mode::Visual,
2291 );
2292
2293 // The cursor is in the open tag
2294 cx.set_state(
2295 "<html><head></head><body><bˇ>hi!</b><b>hello!</b></body>",
2296 Mode::Normal,
2297 );
2298 cx.simulate_keystrokes("v a t");
2299 cx.assert_state(
2300 "<html><head></head><body>«<b>hi!</b>ˇ»<b>hello!</b></body>",
2301 Mode::Visual,
2302 );
2303 cx.simulate_keystrokes("i t");
2304 cx.assert_state(
2305 "<html><head></head><body>«<b>hi!</b><b>hello!</b>ˇ»</body>",
2306 Mode::Visual,
2307 );
2308
2309 // current selection length greater than 1
2310 cx.set_state(
2311 "<html><head></head><body><«b>hi!ˇ»</b></body>",
2312 Mode::Visual,
2313 );
2314 cx.simulate_keystrokes("i t");
2315 cx.assert_state(
2316 "<html><head></head><body><b>«hi!ˇ»</b></body>",
2317 Mode::Visual,
2318 );
2319 cx.simulate_keystrokes("a t");
2320 cx.assert_state(
2321 "<html><head></head><body>«<b>hi!</b>ˇ»</body>",
2322 Mode::Visual,
2323 );
2324
2325 cx.set_state(
2326 "<html><head></head><body><«b>hi!</ˇ»b></body>",
2327 Mode::Visual,
2328 );
2329 cx.simulate_keystrokes("a t");
2330 cx.assert_state(
2331 "<html><head></head>«<body><b>hi!</b></body>ˇ»",
2332 Mode::Visual,
2333 );
2334 }
2335}