diff options
author | PabloMansanet | 2021-06-11 12:57:07 +0000 |
---|---|---|
committer | GitHub | 2021-06-11 12:57:07 +0000 |
commit | 86af55c379c531df2d5dbc72841e28a10fc7938e (patch) | |
tree | d2946657e2ca0102c7ddf291b0ffb9819ab001d9 /helix-core | |
parent | 0c2b99327a60d478ff6a4e4a2a15f69e61857569 (diff) |
Movement fixes, refactor and unit test suite (#217)
* Add convenience/clarity wrapper for Range initialization
* Test horizontal moves
* Add column jumping tests
* Add failing movement conditions for multi-word moves
* Refactor skip_over_next
* Add complex forward movement unit tests
* Add strict whitespace checks and edge case tests
* Restore formatting
* Remove unused function
* Add empty test case for deletion and fix nth_prev_word_boundary
* Add tests for backward motion
* Refactor word movement
* Address review comments and finish refactoring backwards move
* Finish unit test suite
* Fmt pass
* Fix lint erors
* Clean up diff restoring bad 'cargo fmt' actions
* Simplify movement closures (thanks Pickfire)
* Fmt pass
* Replace index-based movement with iterator based movement, ensuring that each move incurs a single call to the RopeSlice API
* Break down tuple function
* Extract common logic to all movement functions
* Split iterator helpers away into their own module
* WIP reducing clones
* Operate on spans
* WIP simplifying iterators
* Simplify motion helpers
* Fix iterator
* Fix all unit tests
* Refactor and simplify
* Simplify fold
Diffstat (limited to 'helix-core')
-rw-r--r-- | helix-core/src/auto_pairs.rs | 4 | ||||
-rw-r--r-- | helix-core/src/lib.rs | 1 | ||||
-rw-r--r-- | helix-core/src/movement.rs | 775 | ||||
-rw-r--r-- | helix-core/src/selection.rs | 4 | ||||
-rw-r--r-- | helix-core/src/words.rs | 68 |
5 files changed, 635 insertions, 217 deletions
diff --git a/helix-core/src/auto_pairs.rs b/helix-core/src/auto_pairs.rs index a04b0d3e..74e25ac9 100644 --- a/helix-core/src/auto_pairs.rs +++ b/helix-core/src/auto_pairs.rs @@ -67,7 +67,7 @@ fn handle_open( let mut offs = 0; - let mut transaction = Transaction::change_by_selection(doc, selection, |range| { + let transaction = Transaction::change_by_selection(doc, selection, |range| { let pos = range.head; let next = next_char(doc, pos); @@ -109,7 +109,7 @@ fn handle_close(doc: &Rope, selection: &Selection, _open: char, close: char) -> let mut offs = 0; - let mut transaction = Transaction::change_by_selection(doc, selection, |range| { + let transaction = Transaction::change_by_selection(doc, selection, |range| { let pos = range.head; let next = next_char(doc, pos); diff --git a/helix-core/src/lib.rs b/helix-core/src/lib.rs index f5e7de52..8f4239b1 100644 --- a/helix-core/src/lib.rs +++ b/helix-core/src/lib.rs @@ -16,7 +16,6 @@ pub mod selection; mod state; pub mod syntax; mod transaction; -pub mod words; pub fn find_first_non_whitespace_char(line: RopeSlice) -> Option<usize> { line.chars().position(|ch| !ch.is_whitespace()) diff --git a/helix-core/src/movement.rs b/helix-core/src/movement.rs index 32dfcae3..8b1e802f 100644 --- a/helix-core/src/movement.rs +++ b/helix-core/src/movement.rs @@ -1,5 +1,12 @@ -use crate::graphemes::{nth_next_grapheme_boundary, nth_prev_grapheme_boundary, RopeGraphemes}; -use crate::{coords_at_pos, pos_at_coords, ChangeSet, Position, Range, Rope, RopeSlice, Selection}; +use std::iter::{self, from_fn, Peekable, SkipWhile}; + +use ropey::iter::Chars; + +use crate::{ + coords_at_pos, + graphemes::{nth_next_grapheme_boundary, nth_prev_grapheme_boundary}, + pos_at_coords, Position, Range, RopeSlice, +}; #[derive(Debug, Copy, Clone, PartialEq, Eq)] pub enum Direction { @@ -7,39 +14,49 @@ pub enum Direction { Backward, } +#[derive(Copy, Clone, PartialEq, Eq)] +pub enum Movement { + Extend, + Move, +} + pub fn move_horizontally( - text: RopeSlice, + slice: RopeSlice, range: Range, dir: Direction, count: usize, - extend: bool, + behaviour: Movement, ) -> Range { let pos = range.head; - let line = text.char_to_line(pos); + let line = slice.char_to_line(pos); // TODO: we can optimize clamping by passing in RopeSlice limited to current line. that way // we stop calculating past start/end of line. let pos = match dir { Direction::Backward => { - let start = text.line_to_char(line); - nth_prev_grapheme_boundary(text, pos, count).max(start) + let start = slice.line_to_char(line); + nth_prev_grapheme_boundary(slice, pos, count).max(start) } Direction::Forward => { // Line end is pos at the start of next line - 1 - let end = text.line_to_char(line + 1).saturating_sub(1); - nth_next_grapheme_boundary(text, pos, count).min(end) + let end = slice.line_to_char(line + 1).saturating_sub(1); + nth_next_grapheme_boundary(slice, pos, count).min(end) } }; - Range::new(if extend { range.anchor } else { pos }, pos) + let anchor = match behaviour { + Movement::Extend => range.anchor, + Movement::Move => pos, + }; + Range::new(anchor, pos) } pub fn move_vertically( - text: RopeSlice, + slice: RopeSlice, range: Range, dir: Direction, count: usize, - extend: bool, + behaviour: Movement, ) -> Range { - let Position { row, col } = coords_at_pos(text, range.head); + let Position { row, col } = coords_at_pos(slice, range.head); let horiz = range.horiz.unwrap_or(col as u32); @@ -47,139 +64,63 @@ pub fn move_vertically( Direction::Backward => row.saturating_sub(count), Direction::Forward => std::cmp::min( row.saturating_add(count), - text.len_lines().saturating_sub(2), + slice.len_lines().saturating_sub(2), ), }; // convert to 0-indexed, subtract another 1 because len_chars() counts \n - let new_line_len = text.line(new_line).len_chars().saturating_sub(2); + let new_line_len = slice.line(new_line).len_chars().saturating_sub(2); let new_col = std::cmp::min(horiz as usize, new_line_len); - let pos = pos_at_coords(text, Position::new(new_line, new_col)); + let pos = pos_at_coords(slice, Position::new(new_line, new_col)); - let mut range = Range::new(if extend { range.anchor } else { pos }, pos); + let anchor = match behaviour { + Movement::Extend => range.anchor, + Movement::Move => pos, + }; + + let mut range = Range::new(anchor, pos); range.horiz = Some(horiz); range } -pub fn move_next_word_start(slice: RopeSlice, mut begin: usize, count: usize) -> Option<Range> { - let mut end = begin; - - for _ in 0..count { - if begin + 1 == slice.len_chars() { - return None; - } - - let mut ch = slice.char(begin); - let next = slice.char(begin + 1); - - // if we're at the end of a word, or on whitespce right before new one - if categorize(ch) != categorize(next) { - begin += 1; - } - - if !skip_over_next(slice, &mut begin, |ch| ch == '\n') { - return None; - }; - ch = slice.char(begin); - - end = begin + 1; - - if is_word(ch) { - skip_over_next(slice, &mut end, is_word); - } else if is_punctuation(ch) { - skip_over_next(slice, &mut end, is_punctuation); - } - - skip_over_next(slice, &mut end, char::is_whitespace); - } - - Some(Range::new(begin, end - 1)) +pub fn move_next_word_start(slice: RopeSlice, range: Range, count: usize) -> Range { + word_move(slice, range, count, WordMotionTarget::NextWordStart) } -pub fn move_prev_word_start(slice: RopeSlice, mut begin: usize, count: usize) -> Option<Range> { - let mut with_end = false; - let mut end = begin; - - for _ in 0..count { - if begin == 0 { - return None; - } - - let ch = slice.char(begin); - let prev = slice.char(begin - 1); - - if categorize(ch) != categorize(prev) { - begin -= 1; - } - - // return if not skip while? - skip_over_prev(slice, &mut begin, |ch| ch == '\n'); - - end = begin; - - with_end = skip_over_prev(slice, &mut end, char::is_whitespace); - - // refetch - let ch = slice.char(end); - - if is_word(ch) { - with_end = skip_over_prev(slice, &mut end, is_word); - } else if is_punctuation(ch) { - with_end = skip_over_prev(slice, &mut end, is_punctuation); - } - } - - Some(Range::new(begin, if with_end { end } else { end + 1 })) +pub fn move_next_word_end(slice: RopeSlice, range: Range, count: usize) -> Range { + word_move(slice, range, count, WordMotionTarget::NextWordEnd) } -pub fn move_next_word_end(slice: RopeSlice, mut begin: usize, count: usize) -> Option<Range> { - let mut end = begin; - - for _ in 0..count { - if begin + 2 >= slice.len_chars() { - return None; - } - - let ch = slice.char(begin); - let next = slice.char(begin + 1); - - if categorize(ch) != categorize(next) { - begin += 1; - } - - if !skip_over_next(slice, &mut begin, |ch| ch == '\n') { - return None; - }; - - end = begin; - - skip_over_next(slice, &mut end, char::is_whitespace); - - // refetch - let ch = slice.char(end); - - if is_word(ch) { - skip_over_next(slice, &mut end, is_word); - } else if is_punctuation(ch) { - skip_over_next(slice, &mut end, is_punctuation); - } - } +pub fn move_prev_word_start(slice: RopeSlice, range: Range, count: usize) -> Range { + word_move(slice, range, count, WordMotionTarget::PrevWordStart) +} - Some(Range::new(begin, end - 1)) +fn word_move(slice: RopeSlice, mut range: Range, count: usize, target: WordMotionTarget) -> Range { + (0..count).fold(range, |range, _| { + slice.chars_at(range.head).range_to_target(target, range) + }) } // ---- util ------------ - -// used for by-word movement - #[inline] pub(crate) fn is_word(ch: char) -> bool { ch.is_alphanumeric() || ch == '_' } #[inline] +pub(crate) fn is_end_of_line(ch: char) -> bool { + ch == '\n' +} + +#[inline] +// Whitespace, but not end of line +pub(crate) fn is_strict_whitespace(ch: char) -> bool { + ch.is_whitespace() && !is_end_of_line(ch) +} + +#[inline] pub(crate) fn is_punctuation(ch: char) -> bool { use unicode_general_category::{get_general_category, GeneralCategory}; @@ -199,7 +140,7 @@ pub(crate) fn is_punctuation(ch: char) -> bool { } #[derive(Debug, Eq, PartialEq)] -pub(crate) enum Category { +pub enum Category { Whitespace, Eol, Word, @@ -209,7 +150,7 @@ pub(crate) enum Category { #[inline] pub(crate) fn categorize(ch: char) -> Category { - if ch == '\n' { + if is_end_of_line(ch) { Category::Eol } else if ch.is_whitespace() { Category::Whitespace @@ -223,46 +164,160 @@ pub(crate) fn categorize(ch: char) -> Category { } #[inline] -/// Returns true if there are more characters left after the new position. -pub fn skip_over_next<F>(slice: RopeSlice, pos: &mut usize, fun: F) -> bool +/// Returns first index that doesn't satisfy a given predicate when +/// advancing the character index. +/// +/// Returns none if all characters satisfy the predicate. +pub fn skip_while<F>(slice: RopeSlice, pos: usize, fun: F) -> Option<usize> where F: Fn(char) -> bool, { - let mut chars = slice.chars_at(*pos); - - #[allow(clippy::while_let_on_iterator)] - while let Some(ch) = chars.next() { - if !fun(ch) { - break; - } - *pos += 1; - } - chars.next().is_some() + let mut chars = slice.chars_at(pos).enumerate(); + chars.find_map(|(i, c)| if !fun(c) { Some(pos + i) } else { None }) } #[inline] -/// Returns true if the final pos matches the predicate. -pub fn skip_over_prev<F>(slice: RopeSlice, pos: &mut usize, fun: F) -> bool +/// Returns first index that doesn't satisfy a given predicate when +/// retreating the character index, saturating if all elements satisfy +/// the condition. +pub fn backwards_skip_while<F>(slice: RopeSlice, pos: usize, fun: F) -> Option<usize> where F: Fn(char) -> bool, { - // need to +1 so that prev() includes current char - let mut chars = slice.chars_at(*pos + 1); + let mut chars_starting_from_next = slice.chars_at(pos + 1); + let mut backwards = iter::from_fn(|| chars_starting_from_next.prev()).enumerate(); + backwards.find_map(|(i, c)| { + if !fun(c) { + Some(pos.saturating_sub(i)) + } else { + None + } + }) +} - #[allow(clippy::while_let_on_iterator)] - while let Some(ch) = chars.prev() { - if !fun(ch) { - break; +/// Possible targets of a word motion +#[derive(Copy, Clone, Debug)] +pub enum WordMotionTarget { + NextWordStart, + NextWordEnd, + PrevWordStart, +} + +pub trait CharHelpers { + fn range_to_target(&mut self, target: WordMotionTarget, origin: Range) -> Range; +} + +enum WordMotionPhase { + Start, + SkipNewlines, + ReachTarget, +} + +impl CharHelpers for Chars<'_> { + fn range_to_target(&mut self, target: WordMotionTarget, origin: Range) -> Range { + let range = origin; + // Characters are iterated forward or backwards depending on the motion direction. + let characters: Box<dyn Iterator<Item = char>> = match target { + WordMotionTarget::PrevWordStart => { + self.next(); + Box::new(from_fn(|| self.prev())) + } + _ => Box::new(self), + }; + + // Index advancement also depends on the direction. + let advance: &dyn Fn(&mut usize) = match target { + WordMotionTarget::PrevWordStart => &|u| *u = u.saturating_sub(1), + _ => &|u| *u += 1, + }; + + let mut characters = characters.peekable(); + let mut phase = WordMotionPhase::Start; + let mut head = origin.head; + let mut anchor: Option<usize> = None; + let is_boundary = |a: char, b: Option<char>| categorize(a) != categorize(b.unwrap_or(a)); + while let Some(peek) = characters.peek().copied() { + phase = match phase { + WordMotionPhase::Start => { + characters.next(); + if characters.peek().is_none() { + break; // We're at the end, so there's nothing to do. + } + // Anchor may remain here if the head wasn't at a boundary + if !is_boundary(peek, characters.peek().copied()) && !is_end_of_line(peek) { + anchor = Some(head); + } + // First character is always skipped by the head + advance(&mut head); + WordMotionPhase::SkipNewlines + } + WordMotionPhase::SkipNewlines => { + if is_end_of_line(peek) { + characters.next(); + if characters.peek().is_some() { + advance(&mut head); + } + WordMotionPhase::SkipNewlines + } else { + WordMotionPhase::ReachTarget + } + } + WordMotionPhase::ReachTarget => { + characters.next(); + anchor = anchor.or(Some(head)); + if reached_target(target, peek, characters.peek()) { + break; + } else { + advance(&mut head); + } + WordMotionPhase::ReachTarget + } + } + } + Range::new(anchor.unwrap_or(origin.anchor), head) + } +} + +fn reached_target(target: WordMotionTarget, peek: char, next_peek: Option<&char>) -> bool { + let next_peek = match next_peek { + Some(next_peek) => next_peek, + None => return true, + }; + + match target { + WordMotionTarget::NextWordStart => { + ((categorize(peek) != categorize(*next_peek)) + && (is_end_of_line(*next_peek) || !next_peek.is_whitespace())) + } + WordMotionTarget::NextWordEnd | WordMotionTarget::PrevWordStart => { + ((categorize(peek) != categorize(*next_peek)) + && (!peek.is_whitespace() || is_end_of_line(*next_peek))) } - *pos = pos.saturating_sub(1); } - fun(slice.char(*pos)) } #[cfg(test)] mod test { + use std::array::{self, IntoIter}; + + use ropey::Rope; + use super::*; + const SINGLE_LINE_SAMPLE: &str = "This is a simple alphabetic line"; + const MULTILINE_SAMPLE: &str = "\ + Multiline\n\ + text sample\n\ + which\n\ + is merely alphabetic\n\ + and whitespaced\n\ + "; + + const MULTIBYTE_CHARACTER_SAMPLE: &str = "\ + パーティーへ行かないか\n\ + The text above is Japanese\n\ + "; + #[test] fn test_vertical_move() { let text = Rope::from("abcd\nefg\nwrs"); @@ -273,17 +328,445 @@ mod test { assert_eq!( coords_at_pos( slice, - move_vertically(slice, range, Direction::Forward, 1, false).head + move_vertically(slice, range, Direction::Forward, 1, Movement::Move).head ), (1, 2).into() ); } #[test] + fn horizontal_moves_through_single_line_in_single_line_text() { + let text = Rope::from(SINGLE_LINE_SAMPLE); + let slice = text.slice(..); + let position = pos_at_coords(slice, (0, 0).into()); + + let mut range = Range::point(position); + + let moves_and_expected_coordinates = [ + ((Direction::Forward, 1usize), (0, 1)), + ((Direction::Forward, 2usize), (0, 3)), + ((Direction::Forward, 0usize), (0, 3)), + ((Direction::Forward, 999usize), (0, 31)), + ((Direction::Forward, 999usize), (0, 31)), + ((Direction::Backward, 999usize), (0, 0)), + ]; + + for ((direction, amount), coordinates) in IntoIter::new(moves_and_expected_coordinates) { + range = move_horizontally(slice, range, direction, amount, Movement::Move); + assert_eq!(coords_at_pos(slice, range.head), coordinates.into()) + } + } + + #[test] + fn horizontal_moves_through_single_line_in_multiline_text() { + let text = Rope::from(MULTILINE_SAMPLE); + let slice = text.slice(..); + let position = pos_at_coords(slice, (0, 0).into()); + + let mut range = Range::point(position); + + let moves_and_expected_coordinates = IntoIter::new([ + ((Direction::Forward, 1usize), (0, 1)), // M_ltiline + ((Direction::Forward, 2usize), (0, 3)), // Mul_iline + ((Direction::Backward, 6usize), (0, 0)), // _ultiline + ((Direction::Backward, 999usize), (0, 0)), // _ultiline + ((Direction::Forward, 3usize), (0, 3)), // Mul_iline + ((Direction::Forward, 0usize), (0, 3)), // Mul_iline + ((Direction::Backward, 0usize), (0, 3)), // Mul_iline + ((Direction::Forward, 999usize), (0, 9)), // Multilin_ + ((Direction::Forward, 999usize), (0, 9)), // Multilin_ + ]); + + for ((direction, amount), coordinates) in moves_and_expected_coordinates { + range = move_horizontally(slice, range, direction, amount, Movement::Move); + assert_eq!(coords_at_pos(slice, range.head), coordinates.into()); + assert_eq!(range.head, range.anchor); + } + } + + #[test] + fn selection_extending_moves_in_single_line_text() { + let text = Rope::from(SINGLE_LINE_SAMPLE); + let slice = text.slice(..); + let position = pos_at_coords(slice, (0, 0).into()); + + let mut range = Range::point(position); + let original_anchor = range.anchor; + + let moves = IntoIter::new([ + (Direction::Forward, 1usize), + (Direction::Forward, 5usize), + (Direction::Backward, 3usize), + ]); + + for (direction, amount) in moves { + range = move_horizontally(slice, range, direction, amount, Movement::Extend); + assert_eq!(range.anchor, original_anchor); + } + } + + #[test] + fn vertical_moves_in_single_column() { + let text = Rope::from(MULTILINE_SAMPLE); + let slice = dbg!(&text).slice(..); + let position = pos_at_coords(slice, (0, 0).into()); + let mut range = Range::point(position); + let moves_and_expected_coordinates = IntoIter::new([ + ((Direction::Forward, 1usize), (1, 0)), + ((Direction::Forward, 2usize), (3, 0)), + ((Direction::Backward, 999usize), (0, 0)), + ((Direction::Forward, 3usize), (3, 0)), + ((Direction::Forward, 0usize), (3, 0)), + ((Direction::Backward, 0usize), (3, 0)), + ((Direction::Forward, 5), (4, 0)), + ((Direction::Forward, 999usize), (4, 0)), + ]); + + for ((direction, amount), coordinates) in moves_and_expected_coordinates { + range = move_vertically(slice, range, direction, amount, Movement::Move); + assert_eq!(coords_at_pos(slice, range.head), coordinates.into()); + assert_eq!(range.head, range.anchor); + } + } + + #[test] + fn vertical_moves_jumping_column() { + let text = Rope::from(MULTILINE_SAMPLE); + let slice = text.slice(..); + let position = pos_at_coords(slice, (0, 0).into()); + let mut range = Range::point(position); + + enum Axis { + H, + V, + } + let moves_and_expected_coordinates = IntoIter::new([ + // Places cursor at the end of line + ((Axis::H, Direction::Forward, 8usize), (0, 8)), + // First descent preserves column as the target line is wider + ((Axis::V, Direction::Forward, 1usize), (1, 8)), + // Second descent clamps column as the target line is shorter + ((Axis::V, Direction::Forward, 1usize), (2, 4)), + // Third descent restores the original column + ((Axis::V, Direction::Forward, 1usize), (3, 8)), + // Behaviour is preserved even through long jumps + ((Axis::V, Direction::Backward, 999usize), (0, 8)), + ((Axis::V, Direction::Forward, 999usize), (4, 8)), + ]); + + for ((axis, direction, amount), coordinates) in moves_and_expected_coordinates { + range = match axis { + Axis::H => move_horizontally(slice, range, direction, amount, Movement::Move), + Axis::V => move_vertically(slice, range, direction, amount, Movement::Move), + }; + assert_eq!(coords_at_pos(slice, range.head), coordinates.into()); + assert_eq!(range.head, range.anchor); + } + } + + #[test] + fn multibyte_character_column_jumps() { + let text = Rope::from(MULTIBYTE_CHARACTER_SAMPLE); + let slice = text.slice(..); + let position = pos_at_coords(slice, (0, 0).into()); + let mut range = Range::point(position); + + // FIXME: The behaviour captured in this test diverges from both Kakoune and Vim. These + // will attempt to preserve the horizontal position of the cursor, rather than + // placing it at the same character index. + enum Axis { + H, + V, + } + let moves_and_expected_coordinates = IntoIter::new([ + // Places cursor at the fourth kana + ((Axis::H, Direction::Forward, 4), (0, 4)), + // Descent places cursor at the fourth character. + ((Axis::V, Direction::Forward, 1usize), (1, 4)), + ]); + + for ((axis, direction, amount), coordinates) in moves_and_expected_coordinates { + range = match axis { + Axis::H => move_horizontally(slice, range, direction, amount, Movement::Move), + Axis::V => move_vertically(slice, range, direction, amount, Movement::Move), + }; + assert_eq!(coords_at_pos(slice, range.head), coordinates.into()); + assert_eq!(range.head, range.anchor); + } + } + + #[test] + #[should_panic] + fn nonsensical_ranges_panic_on_forward_movement_attempt_in_debug_mode() { + move_next_word_start(Rope::from("Sample").slice(..), Range::point(99999999), 1); + } + + #[test] + #[should_panic] + fn nonsensical_ranges_panic_on_forward_to_end_movement_attempt_in_debug_mode() { + move_next_word_end(Rope::from("Sample").slice(..), Range::point(99999999), 1); + } + + #[test] + #[should_panic] + fn nonsensical_ranges_panic_on_backwards_movement_attempt_in_debug_mode() { + move_prev_word_start(Rope::from("Sample").slice(..), Range::point(99999999), 1); + } + + #[test] + fn test_behaviour_when_moving_to_start_of_next_words() { + let tests = array::IntoIter::new([ + ("Basic forward motion stops at the first space", + vec![(1, Range::new(0, 0), Range::new(0, 5))]), + (" Starting from a boundary advances the anchor", + vec![(1, Range::new(0, 0), Range::new(1, 9))]), + ("Long whitespace gap is bridged by the head", + vec![(1, Range::new(0, 0), Range::new(0, 10))]), + ("Previous anchor is irrelevant for forward motions", + vec![(1, Range::new(12, 0), Range::new(0, 8))]), + (" Starting from whitespace moves to last space in sequence", + vec![(1, Range::new(0, 0), Range::new(0, 3))]), + ("Starting from mid-word leaves anchor at start position and moves head", + vec![(1, Range::new(3, 3), Range::new(3, 8))]), + ("Identifiers_with_underscores are considered a single word", + vec![(1, Range::new(0, 0), Range::new(0, 28))]), + ("Jumping\n into starting whitespace selects the spaces before 'into'", + vec![(1, Range::new(0, 6), Range::new(8, 11))]), + ("alphanumeric.!,and.?=punctuation are considered 'words' for the purposes of word motion", + vec![ + (1, Range::new(0, 0), Range::new(0, 11)), + (1, Range::new(0, 11), Range::new(12, 14)), + (1, Range::new(12, 14), Range::new(15, 17)) + ]), + ("... ... punctuation and spaces behave as expected", + vec![ + (1, Range::new(0, 0), Range::new(0, 5)), + (1, Range::new(0, 5), Range::new(6, 9)), + ]), + (".._.._ punctuation is not joined by underscores into a single block", + vec![(1, Range::new(0, 0), Range::new(0, 1))]), + ("Newlines\n\nare bridged seamlessly.", + vec![ + (1, Range::new(0, 0), Range::new(0, 7)), + (1, Range::new(0, 7), Range::new(10, 13)), + ]), + ("Jumping\n\n\n\n\n\n from newlines to whitespace selects whitespace.", + vec![ + (1, Range::new(0, 8), Range::new(13, 15)), + ]), + ("A failed motion does not modify the range", + vec![ + (3, Range::new(37, 41), Range::new(37, 41)), + ]), + ("oh oh oh two character words!", + vec![ + (1, Range::new(0, 0), Range::new(0, 2)), + (1, Range::new(0, 2), Range::new(3, 5)), + (1, Range::new(0, 1), Range::new(2, 2)), + ]), + ("Multiple motions at once resolve correctly", + vec![ + (3, Range::new(0, 0), Range::new(17, 19)), + ]), + ("Excessive motions are performed partially", + vec![ + (999, Range::new(0, 0), Range::new(32, 40)), + ]), + ("", // Edge case of moving forward in empty string + vec![ + (1, Range::new(0, 0), Range::new(0, 0)), + ]), + ("\n\n\n\n\n", // Edge case of moving forward in all newlines + vec![ + (1, Range::new(0, 0), Range::new(0, 4)), + ]), + ("\n \n \n Jumping through alternated space blocks and newlines selects the space blocks", + vec![ + (1, Range::new(0, 0), Range::new(1, 3)), + (1, Range::new(1, 3), Range::new(5, 7)), + ]), + ("ヒーリクス multibyte characters behave as normal characters", + vec![ + (1, Range::new(0, 0), Range::new(0, 5)), + ]), + ]); + + for (sample, scenario) in tests { + for (count, begin, expected_end) in scenario.into_iter() { + let range = move_next_word_start(Rope::from(sample).slice(..), begin, count); + assert_eq!(range, expected_end, "Case failed: [{}]", sample); + } + } + } + + #[test] + fn test_behaviour_when_moving_to_start_of_previous_words() { + let tests = array::IntoIter::new([ + ("Basic backward motion from the middle of a word", + vec![(1, Range::new(3, 3), Range::new(3, 0))]), + ("Starting from after boundary retreats the anchor", + vec![(1, Range::new(0, 8), Range::new(7, 0))]), + (" Jump to start of a word preceded by whitespace", + vec![(1, Range::new(5, 5), Range::new(5, 4))]), + (" Jump to start of line from start of word preceded by whitespace", + vec![(1, Range::new(4, 4), Range::new(3, 0))]), + ("Previous anchor is irrelevant for backward motions", + vec![(1, Range::new(12, 5), Range::new(5, 0))]), + (" Starting from whitespace moves to first space in sequence", + vec![(1, Range::new(0, 3), Range::new(3, 0))]), + ("Identifiers_with_underscores are considered a single word", + vec![(1, Range::new(0, 20), Range::new(20, 0))]), + ("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 word", + vec![(1, Range::new(6, 6), Range::new(6, 0))]), + ("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, 6)), + (1, Range::new(9, 6), Range::new(5, 0)), + ]), + (".._.._ 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, 0)), + ]), + ("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(18, 18), Range::new(8, 0)), + ]), + ("Excessive motions are performed partially", + vec![ + (999, Range::new(40, 40), Range::new(9, 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)), + ]), + ("ヒーリクス multibyte characters behave as normal characters", + vec![ + (1, Range::new(0, 5), Range::new(4, 0)), + ]), + ]); + + for (sample, scenario) in tests { + for (count, begin, expected_end) in scenario.into_iter() { + let range = move_prev_word_start(Rope::from(sample).slice(..), begin, count); + assert_eq!(range, expected_end, "Case failed: [{}]", sample); + } + } + } + + #[test] + fn test_behaviour_when_moving_to_end_of_next_words() { + let tests = array::IntoIter::new([ + ("Basic forward motion from the start of a word to the end of it", + vec![(1, Range::new(0, 0), Range::new(0, 4))]), + ("Basic forward motion from the end of a word to the end of the next", + vec![(1, Range::new(0, 4), Range::new(5, 12))]), + ("Basic forward motion from the middle of a word to the end of it", + vec![(1, Range::new(2, 2), Range::new(2, 4))]), + (" Jumping to end of a word preceded by whitespace", + vec![(1, Range::new(0, 0), Range::new(0, 10))]), + (" Starting from a boundary advances the anchor", + vec![(1, Range::new(0, 0), Range::new(1, 8))]), + ("Previous anchor is irrelevant for end of word motion", + vec![(1, Range::new(12, 2), Range::new(2, 7))]), + ("Identifiers_with_underscores are considered a single word", + vec![(1, Range::new(0, 0), Range::new(0, 27))]), + ("Jumping\n into starting whitespace selects up to the end of next word", + vec![(1, Range::new(0, 6), Range::new(8, 15))]), + ("alphanumeric.!,and.?=punctuation are considered 'words' for the purposes of word motion", + vec![ + (1, Range::new(0, 0), Range::new(0, 11)), + (1, Range::new(0, 11), Range::new(12, 14)), + (1, Range::new(12, 14), Range::new(15, 17)) + ]), + ("... ... punctuation and spaces behave as expected", + vec![ + (1, Range::new(0, 0), Range::new(0, 2)), + (1, Range::new(0, 2), Range::new(3, 8)), + ]), + (".._.._ punctuation is not joined by underscores into a single block", + vec![(1, Range::new(0, 0), Range::new(0, 1))]), + ("Newlines\n\nare bridged seamlessly.", + vec![ + (1, Range::new(0, 0), Range::new(0, 7)), + (1, Range::new(0, 7), Range::new(10, 12)), + ]), + ("Jumping\n\n\n\n\n\n from newlines to whitespace selects to end of next word.", + vec![ + (1, Range::new(0, 8), Range::new(13, 19)), + ]), + ("A failed motion does not modify the range", + vec![ + (3, Range::new(37, 41), Range::new(37, 41)), + ]), + ("Multiple motions at once resolve correctly", + vec![ + (3, Range::new(0, 0), Range::new(16, 18)), + ]), + ("Excessive motions are performed partially", + vec![ + (999, Range::new(0, 0), Range::new(31, 40)), + ]), + ("", // Edge case of moving forward in empty string + vec![ + (1, Range::new(0, 0), Range::new(0, 0)), + ]), + ("\n\n\n\n\n", // Edge case of moving forward in all newlines + vec![ + (1, Range::new(0, 0), Range::new(0, 4)), + ]), + ("\n \n \n Jumping through alternated space blocks and newlines selects the space blocks", + vec![ + (1, Range::new(0, 0), Range::new(1, 3)), + (1, Range::new(1, 3), Range::new(5, 7)), + ]), + ("ヒーリクス multibyte characters behave as normal characters", + vec![ + (1, Range::new(0, 0), Range::new(0, 4)), + ]), + ]); + + for (sample, scenario) in tests { + for (count, begin, expected_end) in scenario.into_iter() { + let range = move_next_word_end(Rope::from(sample).slice(..), begin, count); + assert_eq!(range, expected_end, "Case failed: [{}]", sample); + } + } + } + + #[test] fn test_categorize() { const WORD_TEST_CASE: &'static str = "_hello_world_あいうえおー12345678901234567890"; - const PUNCTUATION_TEST_CASE: &'static str = "!\"#$%&\'()*+,-./:;<=>?@[\\]^`{|}~!”#$%&’()*+、。:;<=>?@「」^`{|}~"; + const PUNCTUATION_TEST_CASE: &'static str = + "!\"#$%&\'()*+,-./:;<=>?@[\\]^`{|}~!”#$%&’()*+、。:;<=>?@「」^`{|}~"; const WHITESPACE_TEST_CASE: &'static str = " "; assert_eq!(Category::Eol, categorize('\n')); diff --git a/helix-core/src/selection.rs b/helix-core/src/selection.rs index 7dafc97a..e452c2e2 100644 --- a/helix-core/src/selection.rs +++ b/helix-core/src/selection.rs @@ -35,6 +35,10 @@ impl Range { } } + pub fn point(head: usize) -> Self { + Self::new(head, head) + } + /// Start of the range. #[inline] #[must_use] diff --git a/helix-core/src/words.rs b/helix-core/src/words.rs deleted file mode 100644 index 7ecdacba..00000000 --- a/helix-core/src/words.rs +++ /dev/null @@ -1,68 +0,0 @@ -use crate::movement::{categorize, is_punctuation, is_word, skip_over_prev}; -use ropey::RopeSlice; - -#[must_use] -pub fn nth_prev_word_boundary(slice: RopeSlice, mut char_idx: usize, count: usize) -> usize { - let mut with_end = false; - - for _ in 0..count { - if char_idx == 0 { - break; - } - - // return if not skip while? - skip_over_prev(slice, &mut char_idx, |ch| ch == '\n'); - - with_end = skip_over_prev(slice, &mut char_idx, char::is_whitespace); - - // refetch - let ch = slice.char(char_idx); - - if is_word(ch) { - with_end = skip_over_prev(slice, &mut char_idx, is_word); - } else if is_punctuation(ch) { - with_end = skip_over_prev(slice, &mut char_idx, is_punctuation); - } - } - - if with_end || char_idx == 0 { - char_idx - } else { - char_idx + 1 - } -} - -#[test] -fn different_prev_word_boundary() { - use ropey::Rope; - let t = |x, y| { - let text = Rope::from(x); - let out = nth_prev_word_boundary(text.slice(..), text.len_chars().saturating_sub(1), 1); - assert_eq!(text.slice(..out), y, r#"from "{}""#, x); - }; - t("abcd\nefg\nwrs", "abcd\nefg\n"); - t("abcd\nefg\n", "abcd\n"); - t("abcd\n", ""); - t("hello, world!", "hello, world"); - t("hello, world", "hello, "); - t("hello, ", "hello"); - t("hello", ""); - t(",", ""); - t("こんにちは、世界!", "こんにちは、世界"); - t("こんにちは、世界", "こんにちは、"); - t("こんにちは、", "こんにちは"); - t("こんにちは", ""); - t("この世界。", "この世界"); - t("この世界", ""); - t("お前はもう死んでいる", ""); - t("その300円です", ""); // TODO: should stop at 300 - t("唱k", ""); // TODO: should stop at 唱 - t(",", ""); - t("1 + 1 = 2", "1 + 1 = "); - t("1 + 1 =", "1 + 1 "); - t("1 + 1", "1 + "); - t("1 + ", "1 "); - t("1 ", ""); - t("1+1=2", "1+1="); - t("", ""); -} |