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