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