object.rs

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