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