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