diff --git a/crates/vim/src/motion.rs b/crates/vim/src/motion.rs index 9f871b6795291f2d6f3b5a831d8804fb0bad6686..d17281e6bee0b3bb9ffdd812da982153840d6f7d 100644 --- a/crates/vim/src/motion.rs +++ b/crates/vim/src/motion.rs @@ -1840,6 +1840,20 @@ fn previous_word_end( movement::saturating_left(map, point.to_display_point(map)) } +/// Checks if there's a subword boundary start between `left` and `right` characters. +/// This detects transitions like `_b` (separator to non-separator) or `aB` (lowercase to uppercase). +pub(crate) fn is_subword_start(left: char, right: char, separators: &str) -> bool { + let is_separator = |c: char| separators.contains(c); + (is_separator(left) && !is_separator(right)) || (left.is_lowercase() && right.is_uppercase()) +} + +/// Checks if there's a subword boundary end between `left` and `right` characters. +/// This detects transitions like `a_` (non-separator to separator) or `aB` (lowercase to uppercase). +pub(crate) fn is_subword_end(left: char, right: char, separators: &str) -> bool { + let is_separator = |c: char| separators.contains(c); + (!is_separator(left) && is_separator(right)) || (left.is_lowercase() && right.is_uppercase()) +} + fn next_subword_start( map: &DisplaySnapshot, mut point: DisplayPoint, @@ -1857,11 +1871,11 @@ fn next_subword_start( let right_kind = classifier.kind(right); let at_newline = right == '\n'; - let is_word_start = (left_kind != right_kind) && !left.is_alphanumeric(); - let is_subword_start = - left == '_' && right != '_' || left.is_lowercase() && right.is_uppercase(); - - let found = (!right.is_whitespace() && (is_word_start || is_subword_start)) + let is_stopping_punct = |c: char| "\"'{}[]()<>".contains(c); + let is_word_start = (left_kind != right_kind) + && (!right.is_ascii_punctuation() || is_stopping_punct(right)); + let found_subword_start = is_subword_start(left, right, "._-"); + let found = (!right.is_whitespace() && (is_word_start || found_subword_start)) || at_newline && crossed_newline || at_newline && left == '\n'; // Prevents skipping repeated empty lines @@ -1902,13 +1916,15 @@ pub(crate) fn next_subword_end( return true; } - let is_word_end = (left_kind != right_kind) && !right.is_alphanumeric(); - let is_subword_end = - left != '_' && right == '_' || left.is_lowercase() && right.is_uppercase(); + let is_stopping_punct = |c: char| ".\"'{}[]()<>".contains(c); + let is_word_end = (left_kind != right_kind) + && (!left.is_ascii_punctuation() || is_stopping_punct(left)); + let found_subword_end = is_subword_end(left, right, "_-"); - let found = !left.is_whitespace() && !at_newline && (is_word_end || is_subword_end); + let found = + !left.is_whitespace() && !at_newline && (is_word_end || found_subword_end); - if found && (is_word_end || is_subword_end) { + if found { need_backtrack = true; } @@ -1951,11 +1967,12 @@ fn previous_subword_start( let right_kind = classifier.kind(right); let at_newline = right == '\n'; - let is_word_start = (left_kind != right_kind) && !left.is_alphanumeric(); - let is_subword_start = - left == '_' && right != '_' || left.is_lowercase() && right.is_uppercase(); + let is_stopping_punct = |c: char| ".\"'{}[]()<>".contains(c); + let is_word_start = (left_kind != right_kind) + && (is_stopping_punct(right) || !right.is_ascii_punctuation()); + let found_subword_start = is_subword_start(left, right, "._-"); - let found = (!right.is_whitespace() && (is_word_start || is_subword_start)) + let found = (!right.is_whitespace() && (is_word_start || found_subword_start)) || at_newline && crossed_newline || at_newline && left == '\n'; // Prevents skipping repeated empty lines @@ -1998,16 +2015,17 @@ fn previous_subword_end( let left_kind = classifier.kind(left); let right_kind = classifier.kind(right); - let is_subword_end = - left != '_' && right == '_' || left.is_lowercase() && right.is_uppercase(); + let is_stopping_punct = |c: char| ".;\"'{}[]()<>".contains(c); + let found_subword_end = is_subword_end(left, right, "_-"); - if is_subword_end { + if found_subword_end { return true; } match (left_kind, right_kind) { (CharKind::Word, CharKind::Whitespace) | (CharKind::Word, CharKind::Punctuation) => true, + (CharKind::Punctuation, _) if is_stopping_punct(left) => true, (CharKind::Whitespace, CharKind::Whitespace) => left == '\n' && right == '\n', _ => false, } @@ -4771,4 +4789,212 @@ mod test { the quick brown foˇd over the lazy dog"}); assert!(!cx.cx.forced_motion()); } + + #[gpui::test] + async fn test_next_subword_start(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + + // Setup custom keybindings for subword motions so we can use the bindings + // in `simulate_keystrokes`. + cx.update(|_window, cx| { + cx.bind_keys([KeyBinding::new( + "w", + super::NextSubwordStart { + ignore_punctuation: false, + }, + None, + )]); + }); + + cx.set_state("ˇfoo.bar", Mode::Normal); + cx.simulate_keystrokes("w"); + cx.assert_state("foo.ˇbar", Mode::Normal); + + cx.set_state("ˇfoo(bar)", Mode::Normal); + cx.simulate_keystrokes("w"); + cx.assert_state("fooˇ(bar)", Mode::Normal); + cx.simulate_keystrokes("w"); + cx.assert_state("foo(ˇbar)", Mode::Normal); + cx.simulate_keystrokes("w"); + cx.assert_state("foo(barˇ)", Mode::Normal); + + cx.set_state("ˇfoo_bar_baz", Mode::Normal); + cx.simulate_keystrokes("w"); + cx.assert_state("foo_ˇbar_baz", Mode::Normal); + cx.simulate_keystrokes("w"); + cx.assert_state("foo_bar_ˇbaz", Mode::Normal); + + cx.set_state("ˇfooBarBaz", Mode::Normal); + cx.simulate_keystrokes("w"); + cx.assert_state("fooˇBarBaz", Mode::Normal); + cx.simulate_keystrokes("w"); + cx.assert_state("fooBarˇBaz", Mode::Normal); + + cx.set_state("ˇfoo;bar", Mode::Normal); + cx.simulate_keystrokes("w"); + cx.assert_state("foo;ˇbar", Mode::Normal); + } + + #[gpui::test] + async fn test_next_subword_end(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + + // Setup custom keybindings for subword motions so we can use the bindings + // in `simulate_keystrokes`. + cx.update(|_window, cx| { + cx.bind_keys([KeyBinding::new( + "e", + super::NextSubwordEnd { + ignore_punctuation: false, + }, + None, + )]); + }); + + cx.set_state("ˇfoo.bar", Mode::Normal); + cx.simulate_keystrokes("e"); + cx.assert_state("foˇo.bar", Mode::Normal); + cx.simulate_keystrokes("e"); + cx.assert_state("fooˇ.bar", Mode::Normal); + cx.simulate_keystrokes("e"); + cx.assert_state("foo.baˇr", Mode::Normal); + + cx.set_state("ˇfoo(bar)", Mode::Normal); + cx.simulate_keystrokes("e"); + cx.assert_state("foˇo(bar)", Mode::Normal); + cx.simulate_keystrokes("e"); + cx.assert_state("fooˇ(bar)", Mode::Normal); + cx.simulate_keystrokes("e"); + cx.assert_state("foo(baˇr)", Mode::Normal); + cx.simulate_keystrokes("e"); + cx.assert_state("foo(barˇ)", Mode::Normal); + + cx.set_state("ˇfoo_bar_baz", Mode::Normal); + cx.simulate_keystrokes("e"); + cx.assert_state("foˇo_bar_baz", Mode::Normal); + cx.simulate_keystrokes("e"); + cx.assert_state("foo_baˇr_baz", Mode::Normal); + cx.simulate_keystrokes("e"); + cx.assert_state("foo_bar_baˇz", Mode::Normal); + + cx.set_state("ˇfooBarBaz", Mode::Normal); + cx.simulate_keystrokes("e"); + cx.set_state("foˇoBarBaz", Mode::Normal); + cx.simulate_keystrokes("e"); + cx.set_state("fooBaˇrBaz", Mode::Normal); + cx.simulate_keystrokes("e"); + cx.set_state("fooBarBaˇz", Mode::Normal); + + cx.set_state("ˇfoo;bar", Mode::Normal); + cx.simulate_keystrokes("e"); + cx.set_state("foˇo;bar", Mode::Normal); + cx.simulate_keystrokes("e"); + cx.set_state("fooˇ;bar", Mode::Normal); + cx.simulate_keystrokes("e"); + cx.set_state("foo;baˇr", Mode::Normal); + } + + #[gpui::test] + async fn test_previous_subword_start(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + + // Setup custom keybindings for subword motions so we can use the bindings + // in `simulate_keystrokes`. + cx.update(|_window, cx| { + cx.bind_keys([KeyBinding::new( + "b", + super::PreviousSubwordStart { + ignore_punctuation: false, + }, + None, + )]); + }); + + cx.set_state("foo.barˇ", Mode::Normal); + cx.simulate_keystrokes("b"); + cx.assert_state("foo.ˇbar", Mode::Normal); + cx.simulate_keystrokes("b"); + cx.assert_state("fooˇ.bar", Mode::Normal); + cx.simulate_keystrokes("b"); + cx.assert_state("ˇfoo.bar", Mode::Normal); + + cx.set_state("foo(barˇ)", Mode::Normal); + cx.simulate_keystrokes("b"); + cx.assert_state("foo(ˇbar)", Mode::Normal); + cx.simulate_keystrokes("b"); + cx.assert_state("fooˇ(bar)", Mode::Normal); + cx.simulate_keystrokes("b"); + cx.assert_state("ˇfoo(bar)", Mode::Normal); + + cx.set_state("foo_bar_bazˇ", Mode::Normal); + cx.simulate_keystrokes("b"); + cx.assert_state("foo_bar_ˇbaz", Mode::Normal); + cx.simulate_keystrokes("b"); + cx.assert_state("foo_ˇbar_baz", Mode::Normal); + cx.simulate_keystrokes("b"); + cx.assert_state("ˇfoo_bar_baz", Mode::Normal); + + cx.set_state("fooBarBazˇ", Mode::Normal); + cx.simulate_keystrokes("b"); + cx.assert_state("fooBarˇBaz", Mode::Normal); + cx.simulate_keystrokes("b"); + cx.assert_state("fooˇBarBaz", Mode::Normal); + cx.simulate_keystrokes("b"); + cx.assert_state("ˇfooBarBaz", Mode::Normal); + + cx.set_state("foo;barˇ", Mode::Normal); + cx.simulate_keystrokes("b"); + cx.assert_state("foo;ˇbar", Mode::Normal); + cx.simulate_keystrokes("b"); + cx.assert_state("ˇfoo;bar", Mode::Normal); + } + + #[gpui::test] + async fn test_previous_subword_end(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + + // Setup custom keybindings for subword motions so we can use the bindings + // in `simulate_keystrokes`. + cx.update(|_window, cx| { + cx.bind_keys([KeyBinding::new( + "g e", + super::PreviousSubwordEnd { + ignore_punctuation: false, + }, + None, + )]); + }); + + cx.set_state("foo.baˇr", Mode::Normal); + cx.simulate_keystrokes("g e"); + cx.assert_state("fooˇ.bar", Mode::Normal); + cx.simulate_keystrokes("g e"); + cx.assert_state("foˇo.bar", Mode::Normal); + + cx.set_state("foo(barˇ)", Mode::Normal); + cx.simulate_keystrokes("g e"); + cx.assert_state("foo(baˇr)", Mode::Normal); + cx.simulate_keystrokes("g e"); + cx.assert_state("fooˇ(bar)", Mode::Normal); + cx.simulate_keystrokes("g e"); + cx.assert_state("foˇo(bar)", Mode::Normal); + + cx.set_state("foo_bar_baˇz", Mode::Normal); + cx.simulate_keystrokes("g e"); + cx.assert_state("foo_baˇr_baz", Mode::Normal); + cx.simulate_keystrokes("g e"); + cx.assert_state("foˇo_bar_baz", Mode::Normal); + + cx.set_state("fooBarBaˇz", Mode::Normal); + cx.simulate_keystrokes("g e"); + cx.assert_state("fooBaˇrBaz", Mode::Normal); + cx.simulate_keystrokes("g e"); + cx.assert_state("foˇoBarBaz", Mode::Normal); + + cx.set_state("foo;baˇr", Mode::Normal); + cx.simulate_keystrokes("g e"); + cx.assert_state("fooˇ;bar", Mode::Normal); + cx.simulate_keystrokes("g e"); + cx.assert_state("foˇo;bar", Mode::Normal); + } } diff --git a/crates/vim/src/object.rs b/crates/vim/src/object.rs index 64718e4ae6a7cd1befb819811f4ae9ef701719b9..70785b4e94c4ac9d5544bce315f8d8801be4fe57 100644 --- a/crates/vim/src/object.rs +++ b/crates/vim/src/object.rs @@ -2,7 +2,7 @@ use std::ops::Range; use crate::{ Vim, - motion::right, + motion::{is_subword_end, is_subword_start, right}, state::{Mode, Operator}, }; use editor::{ @@ -862,11 +862,8 @@ fn in_subword( .buffer_chars_at(offset) .next() .map(|(c, _)| { - if classifier.is_word('-') { - !classifier.is_whitespace(c) && c != '_' && c != '-' - } else { - !classifier.is_whitespace(c) && c != '_' - } + let is_separator = "._-".contains(c); + !classifier.is_whitespace(c) && !is_separator }) .unwrap_or(false); @@ -877,28 +874,19 @@ fn in_subword( movement::FindRange::SingleLine, |left, right| { let is_word_start = classifier.kind(left) != classifier.kind(right); - let is_subword_start = classifier.is_word('-') && left == '-' && right != '-' - || left == '_' && right != '_' - || left.is_lowercase() && right.is_uppercase(); - is_word_start || is_subword_start + is_word_start || is_subword_start(left, right, "._-") }, ) } else { movement::find_boundary(map, relative_to, FindRange::SingleLine, |left, right| { let is_word_start = classifier.kind(left) != classifier.kind(right); - let is_subword_start = classifier.is_word('-') && left == '-' && right != '-' - || left == '_' && right != '_' - || left.is_lowercase() && right.is_uppercase(); - is_word_start || is_subword_start + is_word_start || is_subword_start(left, right, "._-") }) }; let end = movement::find_boundary(map, relative_to, FindRange::SingleLine, |left, right| { let is_word_end = classifier.kind(left) != classifier.kind(right); - let is_subword_end = classifier.is_word('-') && left != '-' && right == '-' - || left != '_' && right == '_' - || left.is_lowercase() && right.is_uppercase(); - is_word_end || is_subword_end + is_word_end || is_subword_end(left, right, "._-") }); Some(start..end) @@ -1039,20 +1027,17 @@ fn around_subword( right(map, relative_to, 1), movement::FindRange::SingleLine, |left, right| { - let is_word_start = classifier.kind(left) != classifier.kind(right); - let is_subword_start = classifier.is_word('-') && left != '-' && right == '-' - || left != '_' && right == '_' - || left.is_lowercase() && right.is_uppercase(); - is_word_start || is_subword_start + let is_separator = |c: char| "._-".contains(c); + let is_word_start = + classifier.kind(left) != classifier.kind(right) && !is_separator(left); + is_word_start || is_subword_start(left, right, "._-") }, ); let end = movement::find_boundary(map, relative_to, FindRange::SingleLine, |left, right| { - let is_word_end = classifier.kind(left) != classifier.kind(right); - let is_subword_end = classifier.is_word('-') && left != '-' && right == '-' - || left != '_' && right == '_' - || left.is_lowercase() && right.is_uppercase(); - is_word_end || is_subword_end + let is_separator = |c: char| "._-".contains(c); + let is_word_end = classifier.kind(left) != classifier.kind(right) && !is_separator(right); + is_word_end || is_subword_end(left, right, "._-") }); Some(start..end).map(|range| expand_to_include_whitespace(map, range, true)) @@ -3909,4 +3894,73 @@ mod test { Mode::VisualLine, ); } + + #[gpui::test] + async fn test_subword_object(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + + // Setup custom keybindings for subword object so we can use the + // bindings in `simulate_keystrokes`. + cx.update(|_window, cx| { + cx.bind_keys([KeyBinding::new( + "w", + super::Subword { + ignore_punctuation: false, + }, + Some("vim_operator"), + )]); + }); + + cx.set_state("foo_ˇbar_baz", Mode::Normal); + cx.simulate_keystrokes("c i w"); + cx.assert_state("foo_ˇ_baz", Mode::Insert); + + cx.set_state("ˇfoo_bar_baz", Mode::Normal); + cx.simulate_keystrokes("c i w"); + cx.assert_state("ˇ_bar_baz", Mode::Insert); + + cx.set_state("foo_bar_baˇz", Mode::Normal); + cx.simulate_keystrokes("c i w"); + cx.assert_state("foo_bar_ˇ", Mode::Insert); + + cx.set_state("fooˇBarBaz", Mode::Normal); + cx.simulate_keystrokes("c i w"); + cx.assert_state("fooˇBaz", Mode::Insert); + + cx.set_state("ˇfooBarBaz", Mode::Normal); + cx.simulate_keystrokes("c i w"); + cx.assert_state("ˇBarBaz", Mode::Insert); + + cx.set_state("fooBarBaˇz", Mode::Normal); + cx.simulate_keystrokes("c i w"); + cx.assert_state("fooBarˇ", Mode::Insert); + + cx.set_state("foo.ˇbar.baz", Mode::Normal); + cx.simulate_keystrokes("c i w"); + cx.assert_state("foo.ˇ.baz", Mode::Insert); + + cx.set_state("foo_ˇbar_baz", Mode::Normal); + cx.simulate_keystrokes("d i w"); + cx.assert_state("foo_ˇ_baz", Mode::Normal); + + cx.set_state("fooˇBarBaz", Mode::Normal); + cx.simulate_keystrokes("d i w"); + cx.assert_state("fooˇBaz", Mode::Normal); + + cx.set_state("foo_ˇbar_baz", Mode::Normal); + cx.simulate_keystrokes("c a w"); + cx.assert_state("foo_ˇ_baz", Mode::Insert); + + cx.set_state("fooˇBarBaz", Mode::Normal); + cx.simulate_keystrokes("c a w"); + cx.assert_state("fooˇBaz", Mode::Insert); + + cx.set_state("foo_ˇbar_baz", Mode::Normal); + cx.simulate_keystrokes("d a w"); + cx.assert_state("foo_ˇ_baz", Mode::Normal); + + cx.set_state("fooˇBarBaz", Mode::Normal); + cx.simulate_keystrokes("d a w"); + cx.assert_state("fooˇBaz", Mode::Normal); + } }