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