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