object.rs

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