object.rs

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