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