object.rs

  1use std::ops::Range;
  2
  3use editor::{char_kind, display_map::DisplaySnapshot, movement, Bias, CharKind, DisplayPoint};
  4use gpui::{actions, impl_actions, AppContext, WindowContext};
  5use language::Selection;
  6use serde::Deserialize;
  7use workspace::Workspace;
  8
  9use crate::{motion::right, normal::normal_object, state::Mode, visual::visual_object, Vim};
 10
 11#[derive(Copy, Clone, Debug, PartialEq)]
 12pub enum Object {
 13    Word { ignore_punctuation: bool },
 14    Sentence,
 15    Quotes,
 16    BackQuotes,
 17    DoubleQuotes,
 18    Parentheses,
 19    SquareBrackets,
 20    CurlyBrackets,
 21    AngleBrackets,
 22}
 23
 24#[derive(Clone, Deserialize, PartialEq)]
 25#[serde(rename_all = "camelCase")]
 26struct Word {
 27    #[serde(default)]
 28    ignore_punctuation: bool,
 29}
 30
 31actions!(
 32    vim,
 33    [
 34        Sentence,
 35        Quotes,
 36        BackQuotes,
 37        DoubleQuotes,
 38        Parentheses,
 39        SquareBrackets,
 40        CurlyBrackets,
 41        AngleBrackets
 42    ]
 43);
 44impl_actions!(vim, [Word]);
 45
 46pub fn init(cx: &mut AppContext) {
 47    cx.add_action(
 48        |_: &mut Workspace, &Word { ignore_punctuation }: &Word, cx: _| {
 49            object(Object::Word { ignore_punctuation }, cx)
 50        },
 51    );
 52    cx.add_action(|_: &mut Workspace, _: &Sentence, cx: _| object(Object::Sentence, cx));
 53    cx.add_action(|_: &mut Workspace, _: &Quotes, cx: _| object(Object::Quotes, cx));
 54    cx.add_action(|_: &mut Workspace, _: &BackQuotes, cx: _| object(Object::BackQuotes, cx));
 55    cx.add_action(|_: &mut Workspace, _: &DoubleQuotes, cx: _| object(Object::DoubleQuotes, cx));
 56    cx.add_action(|_: &mut Workspace, _: &Parentheses, cx: _| object(Object::Parentheses, cx));
 57    cx.add_action(|_: &mut Workspace, _: &SquareBrackets, cx: _| {
 58        object(Object::SquareBrackets, cx)
 59    });
 60    cx.add_action(|_: &mut Workspace, _: &CurlyBrackets, cx: _| object(Object::CurlyBrackets, cx));
 61    cx.add_action(|_: &mut Workspace, _: &AngleBrackets, cx: _| object(Object::AngleBrackets, cx));
 62}
 63
 64fn object(object: Object, cx: &mut WindowContext) {
 65    match Vim::read(cx).state().mode {
 66        Mode::Normal => normal_object(object, cx),
 67        Mode::Visual | Mode::VisualLine | Mode::VisualBlock => visual_object(object, cx),
 68        Mode::Insert => {
 69            // Shouldn't execute a text object in insert mode. Ignoring
 70        }
 71    }
 72}
 73
 74impl Object {
 75    pub fn is_multiline(self) -> bool {
 76        match self {
 77            Object::Word { .. } | Object::Quotes | Object::BackQuotes | Object::DoubleQuotes => {
 78                false
 79            }
 80            Object::Sentence
 81            | Object::Parentheses
 82            | Object::AngleBrackets
 83            | Object::CurlyBrackets
 84            | Object::SquareBrackets => true,
 85        }
 86    }
 87    pub fn range(
 88        self,
 89        map: &DisplaySnapshot,
 90        relative_to: DisplayPoint,
 91        around: bool,
 92    ) -> Option<Range<DisplayPoint>> {
 93        match self {
 94            Object::Word { ignore_punctuation } => {
 95                if around {
 96                    around_word(map, relative_to, ignore_punctuation)
 97                } else {
 98                    in_word(map, relative_to, ignore_punctuation)
 99                }
100            }
101            Object::Sentence => sentence(map, relative_to, around),
102            Object::Quotes => {
103                surrounding_markers(map, relative_to, around, self.is_multiline(), '\'', '\'')
104            }
105            Object::BackQuotes => {
106                surrounding_markers(map, relative_to, around, self.is_multiline(), '`', '`')
107            }
108            Object::DoubleQuotes => {
109                surrounding_markers(map, relative_to, around, self.is_multiline(), '"', '"')
110            }
111            Object::Parentheses => {
112                surrounding_markers(map, relative_to, around, self.is_multiline(), '(', ')')
113            }
114            Object::SquareBrackets => {
115                surrounding_markers(map, relative_to, around, self.is_multiline(), '[', ']')
116            }
117            Object::CurlyBrackets => {
118                surrounding_markers(map, relative_to, around, self.is_multiline(), '{', '}')
119            }
120            Object::AngleBrackets => {
121                surrounding_markers(map, relative_to, around, self.is_multiline(), '<', '>')
122            }
123        }
124    }
125
126    pub fn expand_selection(
127        self,
128        map: &DisplaySnapshot,
129        selection: &mut Selection<DisplayPoint>,
130        around: bool,
131    ) -> bool {
132        if let Some(range) = self.range(map, selection.head(), around) {
133            selection.start = range.start;
134            selection.end = range.end;
135            true
136        } else {
137            false
138        }
139    }
140}
141
142/// Return a range that surrounds the word relative_to is in
143/// If relative_to is at the start of a word, return the word.
144/// If relative_to is between words, return the space between
145fn in_word(
146    map: &DisplaySnapshot,
147    relative_to: DisplayPoint,
148    ignore_punctuation: bool,
149) -> Option<Range<DisplayPoint>> {
150    // Use motion::right so that we consider the character under the cursor when looking for the start
151    let start = movement::find_preceding_boundary_in_line(
152        map,
153        right(map, relative_to, 1),
154        |left, right| {
155            char_kind(left).coerce_punctuation(ignore_punctuation)
156                != char_kind(right).coerce_punctuation(ignore_punctuation)
157        },
158    );
159    let end = movement::find_boundary_in_line(map, relative_to, |left, right| {
160        char_kind(left).coerce_punctuation(ignore_punctuation)
161            != char_kind(right).coerce_punctuation(ignore_punctuation)
162    });
163
164    Some(start..end)
165}
166
167/// Return a range that surrounds the word and following whitespace
168/// relative_to is in.
169/// If relative_to is at the start of a word, return the word and following whitespace.
170/// If relative_to is between words, return the whitespace back and the following word
171
172/// if in word
173///   delete that word
174///   if there is whitespace following the word, delete that as well
175///   otherwise, delete any preceding whitespace
176/// otherwise
177///   delete whitespace around cursor
178///   delete word following the cursor
179fn around_word(
180    map: &DisplaySnapshot,
181    relative_to: DisplayPoint,
182    ignore_punctuation: bool,
183) -> Option<Range<DisplayPoint>> {
184    let in_word = map
185        .chars_at(relative_to)
186        .next()
187        .map(|(c, _)| char_kind(c) != CharKind::Whitespace)
188        .unwrap_or(false);
189
190    if in_word {
191        around_containing_word(map, relative_to, ignore_punctuation)
192    } else {
193        around_next_word(map, relative_to, ignore_punctuation)
194    }
195}
196
197fn around_containing_word(
198    map: &DisplaySnapshot,
199    relative_to: DisplayPoint,
200    ignore_punctuation: bool,
201) -> Option<Range<DisplayPoint>> {
202    in_word(map, relative_to, ignore_punctuation)
203        .map(|range| expand_to_include_whitespace(map, range, true))
204}
205
206fn around_next_word(
207    map: &DisplaySnapshot,
208    relative_to: DisplayPoint,
209    ignore_punctuation: bool,
210) -> Option<Range<DisplayPoint>> {
211    // Get the start of the word
212    let start = movement::find_preceding_boundary_in_line(
213        map,
214        right(map, relative_to, 1),
215        |left, right| {
216            char_kind(left).coerce_punctuation(ignore_punctuation)
217                != char_kind(right).coerce_punctuation(ignore_punctuation)
218        },
219    );
220
221    let mut word_found = false;
222    let end = movement::find_boundary(map, relative_to, |left, right| {
223        let left_kind = char_kind(left).coerce_punctuation(ignore_punctuation);
224        let right_kind = char_kind(right).coerce_punctuation(ignore_punctuation);
225
226        let found = (word_found && left_kind != right_kind) || right == '\n' && left == '\n';
227
228        if right_kind != CharKind::Whitespace {
229            word_found = true;
230        }
231
232        found
233    });
234
235    Some(start..end)
236}
237
238fn sentence(
239    map: &DisplaySnapshot,
240    relative_to: DisplayPoint,
241    around: bool,
242) -> Option<Range<DisplayPoint>> {
243    let mut start = None;
244    let mut previous_end = relative_to;
245
246    let mut chars = map.chars_at(relative_to).peekable();
247
248    // Search backwards for the previous sentence end or current sentence start. Include the character under relative_to
249    for (char, point) in chars
250        .peek()
251        .cloned()
252        .into_iter()
253        .chain(map.reverse_chars_at(relative_to))
254    {
255        if is_sentence_end(map, point) {
256            break;
257        }
258
259        if is_possible_sentence_start(char) {
260            start = Some(point);
261        }
262
263        previous_end = point;
264    }
265
266    // Search forward for the end of the current sentence or if we are between sentences, the start of the next one
267    let mut end = relative_to;
268    for (char, point) in chars {
269        if start.is_none() && is_possible_sentence_start(char) {
270            if around {
271                start = Some(point);
272                continue;
273            } else {
274                end = point;
275                break;
276            }
277        }
278
279        end = point;
280        *end.column_mut() += char.len_utf8() as u32;
281        end = map.clip_point(end, Bias::Left);
282
283        if is_sentence_end(map, end) {
284            break;
285        }
286    }
287
288    let mut range = start.unwrap_or(previous_end)..end;
289    if around {
290        range = expand_to_include_whitespace(map, range, false);
291    }
292
293    Some(range)
294}
295
296fn is_possible_sentence_start(character: char) -> bool {
297    !character.is_whitespace() && character != '.'
298}
299
300const SENTENCE_END_PUNCTUATION: &[char] = &['.', '!', '?'];
301const SENTENCE_END_FILLERS: &[char] = &[')', ']', '"', '\''];
302const SENTENCE_END_WHITESPACE: &[char] = &[' ', '\t', '\n'];
303fn is_sentence_end(map: &DisplaySnapshot, point: DisplayPoint) -> bool {
304    let mut next_chars = map.chars_at(point).peekable();
305    if let Some((char, _)) = next_chars.next() {
306        // We are at a double newline. This position is a sentence end.
307        if char == '\n' && next_chars.peek().map(|(c, _)| c == &'\n').unwrap_or(false) {
308            return true;
309        }
310
311        // The next text is not a valid whitespace. This is not a sentence end
312        if !SENTENCE_END_WHITESPACE.contains(&char) {
313            return false;
314        }
315    }
316
317    for (char, _) in map.reverse_chars_at(point) {
318        if SENTENCE_END_PUNCTUATION.contains(&char) {
319            return true;
320        }
321
322        if !SENTENCE_END_FILLERS.contains(&char) {
323            return false;
324        }
325    }
326
327    return false;
328}
329
330/// Expands the passed range to include whitespace on one side or the other in a line. Attempts to add the
331/// whitespace to the end first and falls back to the start if there was none.
332fn expand_to_include_whitespace(
333    map: &DisplaySnapshot,
334    mut range: Range<DisplayPoint>,
335    stop_at_newline: bool,
336) -> Range<DisplayPoint> {
337    let mut whitespace_included = false;
338
339    let mut chars = map.chars_at(range.end).peekable();
340    while let Some((char, point)) = chars.next() {
341        if char == '\n' && stop_at_newline {
342            break;
343        }
344
345        if char.is_whitespace() {
346            // Set end to the next display_point or the character position after the current display_point
347            range.end = chars.peek().map(|(_, point)| *point).unwrap_or_else(|| {
348                let mut end = point;
349                *end.column_mut() += char.len_utf8() as u32;
350                map.clip_point(end, Bias::Left)
351            });
352
353            if char != '\n' {
354                whitespace_included = true;
355            }
356        } else {
357            // Found non whitespace. Quit out.
358            break;
359        }
360    }
361
362    if !whitespace_included {
363        for (char, point) in map.reverse_chars_at(range.start) {
364            if char == '\n' && stop_at_newline {
365                break;
366            }
367
368            if !char.is_whitespace() {
369                break;
370            }
371
372            range.start = point;
373        }
374    }
375
376    range
377}
378
379fn surrounding_markers(
380    map: &DisplaySnapshot,
381    relative_to: DisplayPoint,
382    around: bool,
383    search_across_lines: bool,
384    start_marker: char,
385    end_marker: char,
386) -> Option<Range<DisplayPoint>> {
387    let mut matched_ends = 0;
388    let mut start = None;
389    for (char, mut point) in map.reverse_chars_at(relative_to) {
390        if char == start_marker {
391            if matched_ends > 0 {
392                matched_ends -= 1;
393            } else {
394                if around {
395                    start = Some(point)
396                } else {
397                    *point.column_mut() += char.len_utf8() as u32;
398                    start = Some(point)
399                }
400                break;
401            }
402        } else if char == end_marker {
403            matched_ends += 1;
404        } else if char == '\n' && !search_across_lines {
405            break;
406        }
407    }
408
409    let mut matched_starts = 0;
410    let mut end = None;
411    for (char, mut point) in map.chars_at(relative_to) {
412        if char == end_marker {
413            if start.is_none() {
414                break;
415            }
416
417            if matched_starts > 0 {
418                matched_starts -= 1;
419            } else {
420                if around {
421                    *point.column_mut() += char.len_utf8() as u32;
422                    end = Some(point);
423                } else {
424                    end = Some(point);
425                }
426
427                break;
428            }
429        }
430
431        if char == start_marker {
432            if start.is_none() {
433                if around {
434                    start = Some(point);
435                } else {
436                    *point.column_mut() += char.len_utf8() as u32;
437                    start = Some(point);
438                }
439            } else {
440                matched_starts += 1;
441            }
442        }
443
444        if char == '\n' && !search_across_lines {
445            break;
446        }
447    }
448
449    let (Some(mut start), Some(mut end)) = (start, end) else {
450        return None;
451    };
452
453    if !around {
454        // if a block starts with a newline, move the start to after the newline.
455        let mut was_newline = false;
456        for (char, point) in map.chars_at(start) {
457            if was_newline {
458                start = point;
459            } else if char == '\n' {
460                was_newline = true;
461                continue;
462            }
463            break;
464        }
465        // if a block ends with a newline, then whitespace, then the delimeter,
466        // move the end to after the newline.
467        let mut new_end = end;
468        for (char, point) in map.reverse_chars_at(end) {
469            if char == '\n' {
470                end = new_end;
471                break;
472            }
473            if !char.is_whitespace() {
474                break;
475            }
476            new_end = point
477        }
478    }
479
480    Some(start..end)
481}
482
483#[cfg(test)]
484mod test {
485    use indoc::indoc;
486
487    use crate::test::{ExemptionFeatures, NeovimBackedTestContext};
488
489    const WORD_LOCATIONS: &'static str = indoc! {"
490        The quick ˇbrowˇnˇ•••
491        fox ˇjuˇmpsˇ over
492        the lazy dogˇ••
493        ˇ
494        ˇ
495        ˇ
496        Thˇeˇ-ˇquˇickˇ ˇbrownˇ•
497        ˇ••
498        ˇ••
499        ˇ  fox-jumpˇs over
500        the lazy dogˇ•
501        ˇ
502        "
503    };
504
505    #[gpui::test]
506    async fn test_change_word_object(cx: &mut gpui::TestAppContext) {
507        let mut cx = NeovimBackedTestContext::new(cx).await;
508
509        cx.assert_binding_matches_all(["c", "i", "w"], WORD_LOCATIONS)
510            .await;
511        cx.assert_binding_matches_all(["c", "i", "shift-w"], WORD_LOCATIONS)
512            .await;
513        cx.assert_binding_matches_all(["c", "a", "w"], WORD_LOCATIONS)
514            .await;
515        cx.assert_binding_matches_all(["c", "a", "shift-w"], WORD_LOCATIONS)
516            .await;
517    }
518
519    #[gpui::test]
520    async fn test_delete_word_object(cx: &mut gpui::TestAppContext) {
521        let mut cx = NeovimBackedTestContext::new(cx).await;
522
523        cx.assert_binding_matches_all(["d", "i", "w"], WORD_LOCATIONS)
524            .await;
525        cx.assert_binding_matches_all(["d", "i", "shift-w"], WORD_LOCATIONS)
526            .await;
527        cx.assert_binding_matches_all(["d", "a", "w"], WORD_LOCATIONS)
528            .await;
529        cx.assert_binding_matches_all(["d", "a", "shift-w"], WORD_LOCATIONS)
530            .await;
531    }
532
533    #[gpui::test]
534    async fn test_visual_word_object(cx: &mut gpui::TestAppContext) {
535        let mut cx = NeovimBackedTestContext::new(cx).await;
536
537        cx.set_shared_state("The quick ˇbrown\nfox").await;
538        cx.simulate_shared_keystrokes(["v"]).await;
539        cx.assert_shared_state("The quick «bˇ»rown\nfox").await;
540        cx.simulate_shared_keystrokes(["i", "w"]).await;
541        cx.assert_shared_state("The quick «brownˇ»\nfox").await;
542
543        cx.assert_binding_matches_all(["v", "i", "w"], WORD_LOCATIONS)
544            .await;
545        cx.assert_binding_matches_all_exempted(
546            ["v", "h", "i", "w"],
547            WORD_LOCATIONS,
548            ExemptionFeatures::NonEmptyVisualTextObjects,
549        )
550        .await;
551        cx.assert_binding_matches_all_exempted(
552            ["v", "l", "i", "w"],
553            WORD_LOCATIONS,
554            ExemptionFeatures::NonEmptyVisualTextObjects,
555        )
556        .await;
557        cx.assert_binding_matches_all(["v", "i", "shift-w"], WORD_LOCATIONS)
558            .await;
559
560        cx.assert_binding_matches_all_exempted(
561            ["v", "i", "h", "shift-w"],
562            WORD_LOCATIONS,
563            ExemptionFeatures::NonEmptyVisualTextObjects,
564        )
565        .await;
566        cx.assert_binding_matches_all_exempted(
567            ["v", "i", "l", "shift-w"],
568            WORD_LOCATIONS,
569            ExemptionFeatures::NonEmptyVisualTextObjects,
570        )
571        .await;
572
573        cx.assert_binding_matches_all_exempted(
574            ["v", "a", "w"],
575            WORD_LOCATIONS,
576            ExemptionFeatures::AroundObjectLeavesWhitespaceAtEndOfLine,
577        )
578        .await;
579        cx.assert_binding_matches_all_exempted(
580            ["v", "a", "shift-w"],
581            WORD_LOCATIONS,
582            ExemptionFeatures::AroundObjectLeavesWhitespaceAtEndOfLine,
583        )
584        .await;
585    }
586
587    const SENTENCE_EXAMPLES: &[&'static str] = &[
588        "ˇThe quick ˇbrownˇ?ˇ ˇFox Jˇumpsˇ!ˇ Ovˇer theˇ lazyˇ.",
589        indoc! {"
590            ˇThe quick ˇbrownˇ
591            fox jumps over
592            the lazy doˇgˇ.ˇ ˇThe quick ˇ
593            brown fox jumps over
594        "},
595        indoc! {"
596            The quick brown fox jumps.
597            Over the lazy dog
598            ˇ
599            ˇ
600            ˇ  fox-jumpˇs over
601            the lazy dog.ˇ
602            ˇ
603        "},
604        r#"ˇThe ˇquick brownˇ.)ˇ]ˇ'ˇ" Brown ˇfox jumpsˇ.ˇ "#,
605    ];
606
607    #[gpui::test]
608    async fn test_change_sentence_object(cx: &mut gpui::TestAppContext) {
609        let mut cx = NeovimBackedTestContext::new(cx)
610            .await
611            .binding(["c", "i", "s"]);
612        cx.add_initial_state_exemptions(
613            "The quick brown fox jumps.\nOver the lazy dog\nˇ\nˇ\n  fox-jumps over\nthe lazy dog.\n\n",
614            ExemptionFeatures::SentenceOnEmptyLines);
615        cx.add_initial_state_exemptions(
616            "The quick brown fox jumps.\nOver the lazy dog\n\n\nˇ  foxˇ-ˇjumpˇs over\nthe lazy dog.\n\n",
617            ExemptionFeatures::SentenceAtStartOfLineWithWhitespace);
618        cx.add_initial_state_exemptions(
619            "The quick brown fox jumps.\nOver the lazy dog\n\n\n  fox-jumps over\nthe lazy dog.ˇ\nˇ\n",
620            ExemptionFeatures::SentenceAfterPunctuationAtEndOfFile);
621        for sentence_example in SENTENCE_EXAMPLES {
622            cx.assert_all(sentence_example).await;
623        }
624
625        let mut cx = cx.binding(["c", "a", "s"]);
626        cx.add_initial_state_exemptions(
627            "The quick brown?ˇ Fox Jumps! Over the lazy.",
628            ExemptionFeatures::IncorrectLandingPosition,
629        );
630        cx.add_initial_state_exemptions(
631            "The quick brown.)]\'\" Brown fox jumps.ˇ ",
632            ExemptionFeatures::AroundObjectLeavesWhitespaceAtEndOfLine,
633        );
634
635        for sentence_example in SENTENCE_EXAMPLES {
636            cx.assert_all(sentence_example).await;
637        }
638    }
639
640    #[gpui::test]
641    async fn test_delete_sentence_object(cx: &mut gpui::TestAppContext) {
642        let mut cx = NeovimBackedTestContext::new(cx)
643            .await
644            .binding(["d", "i", "s"]);
645        cx.add_initial_state_exemptions(
646            "The quick brown fox jumps.\nOver the lazy dog\nˇ\nˇ\n  fox-jumps over\nthe lazy dog.\n\n",
647            ExemptionFeatures::SentenceOnEmptyLines);
648        cx.add_initial_state_exemptions(
649            "The quick brown fox jumps.\nOver the lazy dog\n\n\nˇ  foxˇ-ˇjumpˇs over\nthe lazy dog.\n\n",
650            ExemptionFeatures::SentenceAtStartOfLineWithWhitespace);
651        cx.add_initial_state_exemptions(
652            "The quick brown fox jumps.\nOver the lazy dog\n\n\n  fox-jumps over\nthe lazy dog.ˇ\nˇ\n",
653            ExemptionFeatures::SentenceAfterPunctuationAtEndOfFile);
654
655        for sentence_example in SENTENCE_EXAMPLES {
656            cx.assert_all(sentence_example).await;
657        }
658
659        let mut cx = cx.binding(["d", "a", "s"]);
660        cx.add_initial_state_exemptions(
661            "The quick brown?ˇ Fox Jumps! Over the lazy.",
662            ExemptionFeatures::IncorrectLandingPosition,
663        );
664        cx.add_initial_state_exemptions(
665            "The quick brown.)]\'\" Brown fox jumps.ˇ ",
666            ExemptionFeatures::AroundObjectLeavesWhitespaceAtEndOfLine,
667        );
668
669        for sentence_example in SENTENCE_EXAMPLES {
670            cx.assert_all(sentence_example).await;
671        }
672    }
673
674    #[gpui::test]
675    async fn test_visual_sentence_object(cx: &mut gpui::TestAppContext) {
676        let mut cx = NeovimBackedTestContext::new(cx)
677            .await
678            .binding(["v", "i", "s"]);
679        for sentence_example in SENTENCE_EXAMPLES {
680            cx.assert_all_exempted(sentence_example, ExemptionFeatures::SentenceOnEmptyLines)
681                .await;
682        }
683
684        let mut cx = cx.binding(["v", "a", "s"]);
685        for sentence_example in SENTENCE_EXAMPLES {
686            cx.assert_all_exempted(
687                sentence_example,
688                ExemptionFeatures::AroundSentenceStartingBetweenIncludesWrongWhitespace,
689            )
690            .await;
691        }
692    }
693
694    // Test string with "`" for opening surrounders and "'" for closing surrounders
695    const SURROUNDING_MARKER_STRING: &str = indoc! {"
696        ˇTh'ˇe ˇ`ˇ'ˇquˇi`ˇck broˇ'wn`
697        'ˇfox juˇmps ovˇ`ˇer
698        the ˇlazy dˇ'ˇoˇ`ˇg"};
699
700    const SURROUNDING_OBJECTS: &[(char, char)] = &[
701        ('\'', '\''), // Quote
702        ('`', '`'),   // Back Quote
703        ('"', '"'),   // Double Quote
704        ('(', ')'),   // Parentheses
705        ('[', ']'),   // SquareBrackets
706        ('{', '}'),   // CurlyBrackets
707        ('<', '>'),   // AngleBrackets
708    ];
709
710    #[gpui::test]
711    async fn test_change_surrounding_character_objects(cx: &mut gpui::TestAppContext) {
712        let mut cx = NeovimBackedTestContext::new(cx).await;
713
714        for (start, end) in SURROUNDING_OBJECTS {
715            if ((start == &'\'' || start == &'`' || start == &'"')
716                && !ExemptionFeatures::QuotesSeekForward.supported())
717                || (start == &'<' && !ExemptionFeatures::AngleBracketsFreezeNeovim.supported())
718            {
719                continue;
720            }
721
722            let marked_string = SURROUNDING_MARKER_STRING
723                .replace('`', &start.to_string())
724                .replace('\'', &end.to_string());
725
726            cx.assert_binding_matches_all(["c", "i", &start.to_string()], &marked_string)
727                .await;
728            cx.assert_binding_matches_all(["c", "i", &end.to_string()], &marked_string)
729                .await;
730            cx.assert_binding_matches_all(["c", "a", &start.to_string()], &marked_string)
731                .await;
732            cx.assert_binding_matches_all(["c", "a", &end.to_string()], &marked_string)
733                .await;
734        }
735    }
736
737    #[gpui::test]
738    async fn test_multiline_surrounding_character_objects(cx: &mut gpui::TestAppContext) {
739        let mut cx = NeovimBackedTestContext::new(cx).await;
740
741        cx.set_shared_state(indoc! {
742            "func empty(a string) bool {
743               if a == \"\" {
744                  return true
745               }
746               ˇreturn false
747            }"
748        })
749        .await;
750        cx.simulate_shared_keystrokes(["v", "i", "{"]).await;
751        cx.assert_shared_state(indoc! {"
752            func empty(a string) bool {
753            «   if a == \"\" {
754                  return true
755               }
756               return false
757            ˇ»}"})
758            .await;
759        cx.set_shared_state(indoc! {
760            "func empty(a string) bool {
761                 if a == \"\" {
762                     ˇreturn true
763                 }
764                 return false
765            }"
766        })
767        .await;
768        cx.simulate_shared_keystrokes(["v", "i", "{"]).await;
769        cx.assert_shared_state(indoc! {"
770            func empty(a string) bool {
771                 if a == \"\" {
772            «         return true
773            ˇ»     }
774                 return false
775            }"})
776            .await;
777    }
778
779    #[gpui::test]
780    async fn test_delete_surrounding_character_objects(cx: &mut gpui::TestAppContext) {
781        let mut cx = NeovimBackedTestContext::new(cx).await;
782
783        for (start, end) in SURROUNDING_OBJECTS {
784            if ((start == &'\'' || start == &'`' || start == &'"')
785                && !ExemptionFeatures::QuotesSeekForward.supported())
786                || (start == &'<' && !ExemptionFeatures::AngleBracketsFreezeNeovim.supported())
787            {
788                continue;
789            }
790            let marked_string = SURROUNDING_MARKER_STRING
791                .replace('`', &start.to_string())
792                .replace('\'', &end.to_string());
793
794            cx.assert_binding_matches_all(["d", "i", &start.to_string()], &marked_string)
795                .await;
796            cx.assert_binding_matches_all(["d", "i", &end.to_string()], &marked_string)
797                .await;
798            cx.assert_binding_matches_all(["d", "a", &start.to_string()], &marked_string)
799                .await;
800            cx.assert_binding_matches_all(["d", "a", &end.to_string()], &marked_string)
801                .await;
802        }
803    }
804}