aboutsummaryrefslogtreecommitdiff
path: root/helix-core/src/movement.rs
diff options
context:
space:
mode:
Diffstat (limited to 'helix-core/src/movement.rs')
-rw-r--r--helix-core/src/movement.rs99
1 files changed, 94 insertions, 5 deletions
diff --git a/helix-core/src/movement.rs b/helix-core/src/movement.rs
index acc95e7e..f9e5deb4 100644
--- a/helix-core/src/movement.rs
+++ b/helix-core/src/movement.rs
@@ -113,6 +113,10 @@ pub fn move_prev_long_word_start(slice: RopeSlice, range: Range, count: usize) -
word_move(slice, range, count, WordMotionTarget::PrevLongWordStart)
}
+pub fn move_prev_word_end(slice: RopeSlice, range: Range, count: usize) -> Range {
+ word_move(slice, range, count, WordMotionTarget::PrevWordEnd)
+}
+
fn word_move(slice: RopeSlice, range: Range, count: usize, target: WordMotionTarget) -> Range {
(0..count).fold(range, |range, _| {
slice.chars_at(range.head).range_to_target(target, range)
@@ -159,6 +163,7 @@ pub enum WordMotionTarget {
NextWordStart,
NextWordEnd,
PrevWordStart,
+ PrevWordEnd,
// A "Long word" (also known as a WORD in vim/kakoune) is strictly
// delimited by whitespace, and can consist of punctuation as well
// as alphanumerics.
@@ -181,7 +186,9 @@ impl CharHelpers for Chars<'_> {
fn range_to_target(&mut self, target: WordMotionTarget, origin: Range) -> Range {
// Characters are iterated forward or backwards depending on the motion direction.
let characters: Box<dyn Iterator<Item = char>> = match target {
- WordMotionTarget::PrevWordStart | WordMotionTarget::PrevLongWordStart => {
+ WordMotionTarget::PrevWordStart
+ | WordMotionTarget::PrevLongWordStart
+ | WordMotionTarget::PrevWordEnd => {
self.next();
Box::new(from_fn(|| self.prev()))
}
@@ -190,9 +197,9 @@ impl CharHelpers for Chars<'_> {
// Index advancement also depends on the direction.
let advance: &dyn Fn(&mut usize) = match target {
- WordMotionTarget::PrevWordStart | WordMotionTarget::PrevLongWordStart => {
- &|u| *u = u.saturating_sub(1)
- }
+ WordMotionTarget::PrevWordStart
+ | WordMotionTarget::PrevLongWordStart
+ | WordMotionTarget::PrevWordEnd => &|u| *u = u.saturating_sub(1),
_ => &|u| *u += 1,
};
@@ -265,7 +272,7 @@ fn reached_target(target: WordMotionTarget, peek: char, next_peek: Option<&char>
};
match target {
- WordMotionTarget::NextWordStart => {
+ WordMotionTarget::NextWordStart | WordMotionTarget::PrevWordEnd => {
is_word_boundary(peek, *next_peek)
&& (char_is_line_ending(*next_peek) || !next_peek.is_whitespace())
}
@@ -914,6 +921,88 @@ mod test {
}
#[test]
+ fn test_behaviour_when_moving_to_end_of_previous_words() {
+ let tests = array::IntoIter::new([
+ ("Basic backward motion from the middle of a word",
+ vec![(1, Range::new(9, 9), Range::new(9, 5))]),
+ ("Starting from after boundary retreats the anchor",
+ vec![(1, Range::new(0, 13), Range::new(12, 8))]),
+ ("Jump to end of a word succeeded by whitespace",
+ vec![(1, Range::new(10, 10), Range::new(10, 4))]),
+ (" Jump to start of line from end of word preceded by whitespace",
+ vec![(1, Range::new(7, 7), Range::new(7, 0))]),
+ ("Previous anchor is irrelevant for backward motions",
+ vec![(1, Range::new(26, 12), Range::new(12, 8))]),
+ (" Starting from whitespace moves to first space in sequence",
+ vec![(1, Range::new(0, 3), Range::new(3, 0))]),
+ ("Test identifiers_with_underscores are considered a single word",
+ vec![(1, Range::new(0, 25), Range::new(25, 4))]),
+ ("Jumping\n \nback through a newline selects whitespace",
+ vec![(1, Range::new(0, 13), Range::new(11, 8))]),
+ ("Jumping to start of word from the end selects the whole word",
+ vec![(1, Range::new(15, 15), Range::new(15, 10))]),
+ ("alphanumeric.!,and.?=punctuation are considered 'words' for the purposes of word motion",
+ vec![
+ (1, Range::new(30, 30), Range::new(30, 21)),
+ (1, Range::new(30, 21), Range::new(20, 18)),
+ (1, Range::new(20, 18), Range::new(17, 15))
+ ]),
+
+ ("... ... punctuation and spaces behave as expected",
+ vec![
+ (1, Range::new(0, 10), Range::new(9, 9)),
+ (1, Range::new(9, 6), Range::new(5, 3)),
+ ]),
+ (".._.._ punctuation is not joined by underscores into a single block",
+ vec![(1, Range::new(0, 5), Range::new(4, 3))]),
+ ("Newlines\n\nare bridged seamlessly.",
+ vec![
+ (1, Range::new(0, 10), Range::new(7, 0)),
+ ]),
+ ("Jumping \n\n\n\n\nback from within a newline group selects previous block",
+ vec![
+ (1, Range::new(0, 13), Range::new(10, 7)),
+ ]),
+ ("Failed motions do not modify the range",
+ vec![
+ (0, Range::new(3, 0), Range::new(3, 0)),
+ ]),
+ ("Multiple motions at once resolve correctly",
+ vec![
+ (3, Range::new(23, 23), Range::new(15, 8)),
+ ]),
+ ("Excessive motions are performed partially",
+ vec![
+ (999, Range::new(40, 40), Range::new(8, 0)),
+ ]),
+ ("", // Edge case of moving backwards in empty string
+ vec![
+ (1, Range::new(0, 0), Range::new(0, 0)),
+ ]),
+ ("\n\n\n\n\n", // Edge case of moving backwards in all newlines
+ vec![
+ (1, Range::new(0, 0), Range::new(0, 0)),
+ ]),
+ (" \n \nJumping back through alternated space blocks and newlines selects the space blocks",
+ vec![
+ (1, Range::new(0, 7), Range::new(6, 4)),
+ (1, Range::new(6, 4), Range::new(2, 0)),
+ ]),
+ ("Test ヒーリクス multibyte characters behave as normal characters",
+ vec![
+ (1, Range::new(0, 9), Range::new(9, 4)),
+ ]),
+ ]);
+
+ for (sample, scenario) in tests {
+ for (count, begin, expected_end) in scenario.into_iter() {
+ let range = move_prev_word_end(Rope::from(sample).slice(..), begin, count);
+ assert_eq!(range, expected_end, "Case failed: [{}]", sample);
+ }
+ }
+ }
+
+ #[test]
fn test_behaviour_when_moving_to_end_of_next_long_words() {
let tests = array::IntoIter::new([
("Basic forward motion from the start of a word to the end of it",