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