@@ -1,5 +1,12 @@
1−use crate::graphemes::{nth_next_grapheme_boundary, nth_prev_grapheme_boundary, RopeGraphemes};
2−use crate::{coords_at_pos, pos_at_coords, ChangeSet, Position, Range, Rope, RopeSlice, Selection};
1+use std::iter::{self, from_fn, Peekable, SkipWhile};
2+
3+use ropey::iter::Chars;
4+
5+use crate::{
6+ coords_at_pos,
7+ graphemes::{nth_next_grapheme_boundary, nth_prev_grapheme_boundary},
8+ pos_at_coords, Position, Range, RopeSlice,
9+};
310
411#[derive(Debug, Copy, Clone, PartialEq, Eq)]
512pub enum Direction {
@@ -7,39 +14,49 @@ pub enum Direction {
714 Backward,
815}
916
17+#[derive(Copy, Clone, PartialEq, Eq)]
18+pub enum Movement {
19+ Extend,
20+ Move,
21+}
22+
1023pub fn move_horizontally(
11− text: RopeSlice,
24+ slice: RopeSlice,
1225 range: Range,
1326 dir: Direction,
1427 count: usize,
15− extend: bool,
28+ behaviour: Movement,
1629) -> Range {
1730 let pos = range.head;
18− let line = text.char_to_line(pos);
31+ let line = slice.char_to_line(pos);
1932 // TODO: we can optimize clamping by passing in RopeSlice limited to current line. that way
2033 // we stop calculating past start/end of line.
2134 let pos = match dir {
2235 Direction::Backward => {
23− let start = text.line_to_char(line);
24− nth_prev_grapheme_boundary(text, pos, count).max(start)
36+ let start = slice.line_to_char(line);
37+ nth_prev_grapheme_boundary(slice, pos, count).max(start)
2538 }
2639 Direction::Forward => {
2740 // Line end is pos at the start of next line - 1
28− let end = text.line_to_char(line + 1).saturating_sub(1);
29− nth_next_grapheme_boundary(text, pos, count).min(end)
41+ let end = slice.line_to_char(line + 1).saturating_sub(1);
42+ nth_next_grapheme_boundary(slice, pos, count).min(end)
3043 }
3144 };
32− Range::new(if extend { range.anchor } else { pos }, pos)
45+ let anchor = match behaviour {
46+ Movement::Extend => range.anchor,
47+ Movement::Move => pos,
48+ };
49+ Range::new(anchor, pos)
3350}
3451
3552pub fn move_vertically(
36− text: RopeSlice,
53+ slice: RopeSlice,
3754 range: Range,
3855 dir: Direction,
3956 count: usize,
40− extend: bool,
57+ behaviour: Movement,
4158) -> Range {
42− let Position { row, col } = coords_at_pos(text, range.head);
59+ let Position { row, col } = coords_at_pos(slice, range.head);
4360
4461 let horiz = range.horiz.unwrap_or(col as u32);
4562
@@ -47,138 +64,62 @@ pub fn move_vertically(
4764 Direction::Backward => row.saturating_sub(count),
4865 Direction::Forward => std::cmp::min(
4966 row.saturating_add(count),
50− text.len_lines().saturating_sub(2),
67+ slice.len_lines().saturating_sub(2),
5168 ),
5269 };
5370
5471 // convert to 0-indexed, subtract another 1 because len_chars() counts \n
55− let new_line_len = text.line(new_line).len_chars().saturating_sub(2);
72+ let new_line_len = slice.line(new_line).len_chars().saturating_sub(2);
5673
5774 let new_col = std::cmp::min(horiz as usize, new_line_len);
5875
59− let pos = pos_at_coords(text, Position::new(new_line, new_col));
76+ let pos = pos_at_coords(slice, Position::new(new_line, new_col));
6077
61− let mut range = Range::new(if extend { range.anchor } else { pos }, pos);
78+ let anchor = match behaviour {
79+ Movement::Extend => range.anchor,
80+ Movement::Move => pos,
81+ };
82+
83+ let mut range = Range::new(anchor, pos);
6284 range.horiz = Some(horiz);
6385 range
6486}
6587
66−pub fn move_next_word_start(slice: RopeSlice, mut begin: usize, count: usize) -> Option<Range> {
67− let mut end = begin;
68−
69− for _ in 0..count {
70− if begin + 1 == slice.len_chars() {
71− return None;
72− }
73−
74− let mut ch = slice.char(begin);
75− let next = slice.char(begin + 1);
76−
77− // if we're at the end of a word, or on whitespce right before new one
78− if categorize(ch) != categorize(next) {
79− begin += 1;
80− }
81−
82− if !skip_over_next(slice, &mut begin, |ch| ch == '\n') {
83− return None;
84− };
85− ch = slice.char(begin);
86−
87− end = begin + 1;
88−
89− if is_word(ch) {
90− skip_over_next(slice, &mut end, is_word);
91− } else if is_punctuation(ch) {
92− skip_over_next(slice, &mut end, is_punctuation);
93− }
94−
95− skip_over_next(slice, &mut end, char::is_whitespace);
96− }
97−
98− Some(Range::new(begin, end - 1))
88+pub fn move_next_word_start(slice: RopeSlice, range: Range, count: usize) -> Range {
89+ word_move(slice, range, count, WordMotionTarget::NextWordStart)
9990}
10091
101−pub fn move_prev_word_start(slice: RopeSlice, mut begin: usize, count: usize) -> Option<Range> {
102− let mut with_end = false;
103− let mut end = begin;
104−
105− for _ in 0..count {
106− if begin == 0 {
107− return None;
108− }
109−
110− let ch = slice.char(begin);
111− let prev = slice.char(begin - 1);
112−
113− if categorize(ch) != categorize(prev) {
114− begin -= 1;
115− }
116−
117− // return if not skip while?
118− skip_over_prev(slice, &mut begin, |ch| ch == '\n');
119−
120− end = begin;
121−
122− with_end = skip_over_prev(slice, &mut end, char::is_whitespace);
123−
124− // refetch
125− let ch = slice.char(end);
126−
127− if is_word(ch) {
128− with_end = skip_over_prev(slice, &mut end, is_word);
129− } else if is_punctuation(ch) {
130− with_end = skip_over_prev(slice, &mut end, is_punctuation);
131− }
132− }
133−
134− Some(Range::new(begin, if with_end { end } else { end + 1 }))
92+pub fn move_next_word_end(slice: RopeSlice, range: Range, count: usize) -> Range {
93+ word_move(slice, range, count, WordMotionTarget::NextWordEnd)
13594}
13695
137−pub fn move_next_word_end(slice: RopeSlice, mut begin: usize, count: usize) -> Option<Range> {
138− let mut end = begin;
139−
140− for _ in 0..count {
141− if begin + 2 >= slice.len_chars() {
142− return None;
143− }
144−
145− let ch = slice.char(begin);
146− let next = slice.char(begin + 1);
147−
148− if categorize(ch) != categorize(next) {
149− begin += 1;
150− }
151−
152− if !skip_over_next(slice, &mut begin, |ch| ch == '\n') {
153− return None;
154− };
155−
156− end = begin;
157−
158− skip_over_next(slice, &mut end, char::is_whitespace);
159−
160− // refetch
161− let ch = slice.char(end);
162−
163− if is_word(ch) {
164− skip_over_next(slice, &mut end, is_word);
165− } else if is_punctuation(ch) {
166− skip_over_next(slice, &mut end, is_punctuation);
167− }
168− }
96+pub fn move_prev_word_start(slice: RopeSlice, range: Range, count: usize) -> Range {
97+ word_move(slice, range, count, WordMotionTarget::PrevWordStart)
98+}
16999
170− Some(Range::new(begin, end - 1))
100+fn word_move(slice: RopeSlice, mut range: Range, count: usize, target: WordMotionTarget) -> Range {
101+ (0..count).fold(range, |range, _| {
102+ slice.chars_at(range.head).range_to_target(target, range)
103+ })
171104}
172105
173106// ---- util ------------
174−
175−// used for by-word movement
176−
177107#[inline]
178108pub(crate) fn is_word(ch: char) -> bool {
179109 ch.is_alphanumeric() || ch == '_'
180110}
181111
112+#[inline]
113+pub(crate) fn is_end_of_line(ch: char) -> bool {
114+ ch == '\n'
115+}
116+
117+#[inline]
118+// Whitespace, but not end of line
119+pub(crate) fn is_strict_whitespace(ch: char) -> bool {
120+ ch.is_whitespace() && !is_end_of_line(ch)
121+}
122+
182123#[inline]
183124pub(crate) fn is_punctuation(ch: char) -> bool {
184125 use unicode_general_category::{get_general_category, GeneralCategory};
@@ -199,7 +140,7 @@ pub(crate) fn is_punctuation(ch: char) -> bool {
199140}
200141
201142#[derive(Debug, Eq, PartialEq)]
202−pub(crate) enum Category {
143+pub enum Category {
203144 Whitespace,
204145 Eol,
205146 Word,
@@ -209,7 +150,7 @@ pub(crate) enum Category {
209150
210151#[inline]
211152pub(crate) fn categorize(ch: char) -> Category {
212− if ch == '\n' {
153+ if is_end_of_line(ch) {
213154 Category::Eol
214155 } else if ch.is_whitespace() {
215156 Category::Whitespace
@@ -223,46 +164,160 @@ pub(crate) fn categorize(ch: char) -> Category {
223164}
224165
225166#[inline]
226−/// Returns true if there are more characters left after the new position.
227−pub fn skip_over_next<F>(slice: RopeSlice, pos: &mut usize, fun: F) -> bool
167+/// Returns first index that doesn't satisfy a given predicate when
168+/// advancing the character index.
169+///
170+/// Returns none if all characters satisfy the predicate.
171+pub fn skip_while<F>(slice: RopeSlice, pos: usize, fun: F) -> Option<usize>
228172where
229173 F: Fn(char) -> bool,
230174{
231− let mut chars = slice.chars_at(*pos);
232−
233− #[allow(clippy::while_let_on_iterator)]
234− while let Some(ch) = chars.next() {
235− if !fun(ch) {
236− break;
237− }
238− *pos += 1;
239− }
240− chars.next().is_some()
175+ let mut chars = slice.chars_at(pos).enumerate();
176+ chars.find_map(|(i, c)| if !fun(c) { Some(pos + i) } else { None })
241177}
242178
243179#[inline]
244−/// Returns true if the final pos matches the predicate.
245−pub fn skip_over_prev<F>(slice: RopeSlice, pos: &mut usize, fun: F) -> bool
180+/// Returns first index that doesn't satisfy a given predicate when
181+/// retreating the character index, saturating if all elements satisfy
182+/// the condition.
183+pub fn backwards_skip_while<F>(slice: RopeSlice, pos: usize, fun: F) -> Option<usize>
246184where
247185 F: Fn(char) -> bool,
248186{
249− // need to +1 so that prev() includes current char
250− let mut chars = slice.chars_at(*pos + 1);
187+ let mut chars_starting_from_next = slice.chars_at(pos + 1);
188+ let mut backwards = iter::from_fn(|| chars_starting_from_next.prev()).enumerate();
189+ backwards.find_map(|(i, c)| {
190+ if !fun(c) {
191+ Some(pos.saturating_sub(i))
192+ } else {
193+ None
194+ }
195+ })
196+}
251197
252− #[allow(clippy::while_let_on_iterator)]
253− while let Some(ch) = chars.prev() {
254− if !fun(ch) {
255− break;
198+/// Possible targets of a word motion
199+#[derive(Copy, Clone, Debug)]
200+pub enum WordMotionTarget {
201+ NextWordStart,
202+ NextWordEnd,
203+ PrevWordStart,
204+}
205+
206+pub trait CharHelpers {
207+ fn range_to_target(&mut self, target: WordMotionTarget, origin: Range) -> Range;
208+}
209+
210+enum WordMotionPhase {
211+ Start,
212+ SkipNewlines,
213+ ReachTarget,
214+}
215+
216+impl CharHelpers for Chars<'_> {
217+ fn range_to_target(&mut self, target: WordMotionTarget, origin: Range) -> Range {
218+ let range = origin;
219+ // Characters are iterated forward or backwards depending on the motion direction.
220+ let characters: Box<dyn Iterator<Item = char>> = match target {
221+ WordMotionTarget::PrevWordStart => {
222+ self.next();
223+ Box::new(from_fn(|| self.prev()))
224+ }
225+ _ => Box::new(self),
226+ };
227+
228+ // Index advancement also depends on the direction.
229+ let advance: &dyn Fn(&mut usize) = match target {
230+ WordMotionTarget::PrevWordStart => &|u| *u = u.saturating_sub(1),
231+ _ => &|u| *u += 1,
232+ };
233+
234+ let mut characters = characters.peekable();
235+ let mut phase = WordMotionPhase::Start;
236+ let mut head = origin.head;
237+ let mut anchor: Option<usize> = None;
238+ let is_boundary = |a: char, b: Option<char>| categorize(a) != categorize(b.unwrap_or(a));
239+ while let Some(peek) = characters.peek().copied() {
240+ phase = match phase {
241+ WordMotionPhase::Start => {
242+ characters.next();
243+ if characters.peek().is_none() {
244+ break; // We're at the end, so there's nothing to do.
245+ }
246+ // Anchor may remain here if the head wasn't at a boundary
247+ if !is_boundary(peek, characters.peek().copied()) && !is_end_of_line(peek) {
248+ anchor = Some(head);
249+ }
250+ // First character is always skipped by the head
251+ advance(&mut head);
252+ WordMotionPhase::SkipNewlines
253+ }
254+ WordMotionPhase::SkipNewlines => {
255+ if is_end_of_line(peek) {
256+ characters.next();
257+ if characters.peek().is_some() {
258+ advance(&mut head);
259+ }
260+ WordMotionPhase::SkipNewlines
261+ } else {
262+ WordMotionPhase::ReachTarget
263+ }
264+ }
265+ WordMotionPhase::ReachTarget => {
266+ characters.next();
267+ anchor = anchor.or(Some(head));
268+ if reached_target(target, peek, characters.peek()) {
269+ break;
270+ } else {
271+ advance(&mut head);
272+ }
273+ WordMotionPhase::ReachTarget
274+ }
275+ }
276+ }
277+ Range::new(anchor.unwrap_or(origin.anchor), head)
278+ }
279+}
280+
281+fn reached_target(target: WordMotionTarget, peek: char, next_peek: Option<&char>) -> bool {
282+ let next_peek = match next_peek {
283+ Some(next_peek) => next_peek,
284+ None => return true,
285+ };
286+
287+ match target {
288+ WordMotionTarget::NextWordStart => {
289+ ((categorize(peek) != categorize(*next_peek))
290+ && (is_end_of_line(*next_peek) || !next_peek.is_whitespace()))
291+ }
292+ WordMotionTarget::NextWordEnd | WordMotionTarget::PrevWordStart => {
293+ ((categorize(peek) != categorize(*next_peek))
294+ && (!peek.is_whitespace() || is_end_of_line(*next_peek)))
256295 }
257− *pos = pos.saturating_sub(1);
258296 }
259− fun(slice.char(*pos))
260297}
261298
262299#[cfg(test)]
263300mod test {
301+ use std::array::{self, IntoIter};
302+
303+ use ropey::Rope;
304+
264305 use super::*;
265306
307+ const SINGLE_LINE_SAMPLE: &str = "This is a simple alphabetic line";
308+ const MULTILINE_SAMPLE: &str = "\
309+ Multiline\n\
310+ text sample\n\
311+ which\n\
312+ is merely alphabetic\n\
313+ and whitespaced\n\
314+ ";
315+
316+ const MULTIBYTE_CHARACTER_SAMPLE: &str = "\
317+ パーティーへ行かないか\n\
318+ The text above is Japanese\n\
319+ ";
320+
266321 #[test]
267322 fn test_vertical_move() {
268323 let text = Rope::from("abcd\nefg\nwrs");
@@ -273,17 +328,445 @@ mod test {
273328 assert_eq!(
274329 coords_at_pos(
275330 slice,
276− move_vertically(slice, range, Direction::Forward, 1, false).head
331+ move_vertically(slice, range, Direction::Forward, 1, Movement::Move).head
277332 ),
278333 (1, 2).into()
279334 );
280335 }
281336
337+ #[test]
338+ fn horizontal_moves_through_single_line_in_single_line_text() {
339+ let text = Rope::from(SINGLE_LINE_SAMPLE);
340+ let slice = text.slice(..);
341+ let position = pos_at_coords(slice, (0, 0).into());
342+
343+ let mut range = Range::point(position);
344+
345+ let moves_and_expected_coordinates = [
346+ ((Direction::Forward, 1usize), (0, 1)),
347+ ((Direction::Forward, 2usize), (0, 3)),
348+ ((Direction::Forward, 0usize), (0, 3)),
349+ ((Direction::Forward, 999usize), (0, 31)),
350+ ((Direction::Forward, 999usize), (0, 31)),
351+ ((Direction::Backward, 999usize), (0, 0)),
352+ ];
353+
354+ for ((direction, amount), coordinates) in IntoIter::new(moves_and_expected_coordinates) {
355+ range = move_horizontally(slice, range, direction, amount, Movement::Move);
356+ assert_eq!(coords_at_pos(slice, range.head), coordinates.into())
357+ }
358+ }
359+
360+ #[test]
361+ fn horizontal_moves_through_single_line_in_multiline_text() {
362+ let text = Rope::from(MULTILINE_SAMPLE);
363+ let slice = text.slice(..);
364+ let position = pos_at_coords(slice, (0, 0).into());
365+
366+ let mut range = Range::point(position);
367+
368+ let moves_and_expected_coordinates = IntoIter::new([
369+ ((Direction::Forward, 1usize), (0, 1)), // M_ltiline
370+ ((Direction::Forward, 2usize), (0, 3)), // Mul_iline
371+ ((Direction::Backward, 6usize), (0, 0)), // _ultiline
372+ ((Direction::Backward, 999usize), (0, 0)), // _ultiline
373+ ((Direction::Forward, 3usize), (0, 3)), // Mul_iline
374+ ((Direction::Forward, 0usize), (0, 3)), // Mul_iline
375+ ((Direction::Backward, 0usize), (0, 3)), // Mul_iline
376+ ((Direction::Forward, 999usize), (0, 9)), // Multilin_
377+ ((Direction::Forward, 999usize), (0, 9)), // Multilin_
378+ ]);
379+
380+ for ((direction, amount), coordinates) in moves_and_expected_coordinates {
381+ range = move_horizontally(slice, range, direction, amount, Movement::Move);
382+ assert_eq!(coords_at_pos(slice, range.head), coordinates.into());
383+ assert_eq!(range.head, range.anchor);
384+ }
385+ }
386+
387+ #[test]
388+ fn selection_extending_moves_in_single_line_text() {
389+ let text = Rope::from(SINGLE_LINE_SAMPLE);
390+ let slice = text.slice(..);
391+ let position = pos_at_coords(slice, (0, 0).into());
392+
393+ let mut range = Range::point(position);
394+ let original_anchor = range.anchor;
395+
396+ let moves = IntoIter::new([
397+ (Direction::Forward, 1usize),
398+ (Direction::Forward, 5usize),
399+ (Direction::Backward, 3usize),
400+ ]);
401+
402+ for (direction, amount) in moves {
403+ range = move_horizontally(slice, range, direction, amount, Movement::Extend);
404+ assert_eq!(range.anchor, original_anchor);
405+ }
406+ }
407+
408+ #[test]
409+ fn vertical_moves_in_single_column() {
410+ let text = Rope::from(MULTILINE_SAMPLE);
411+ let slice = dbg!(&text).slice(..);
412+ let position = pos_at_coords(slice, (0, 0).into());
413+ let mut range = Range::point(position);
414+ let moves_and_expected_coordinates = IntoIter::new([
415+ ((Direction::Forward, 1usize), (1, 0)),
416+ ((Direction::Forward, 2usize), (3, 0)),
417+ ((Direction::Backward, 999usize), (0, 0)),
418+ ((Direction::Forward, 3usize), (3, 0)),
419+ ((Direction::Forward, 0usize), (3, 0)),
420+ ((Direction::Backward, 0usize), (3, 0)),
421+ ((Direction::Forward, 5), (4, 0)),
422+ ((Direction::Forward, 999usize), (4, 0)),
423+ ]);
424+
425+ for ((direction, amount), coordinates) in moves_and_expected_coordinates {
426+ range = move_vertically(slice, range, direction, amount, Movement::Move);
427+ assert_eq!(coords_at_pos(slice, range.head), coordinates.into());
428+ assert_eq!(range.head, range.anchor);
429+ }
430+ }
431+
432+ #[test]
433+ fn vertical_moves_jumping_column() {
434+ let text = Rope::from(MULTILINE_SAMPLE);
435+ let slice = text.slice(..);
436+ let position = pos_at_coords(slice, (0, 0).into());
437+ let mut range = Range::point(position);
438+
439+ enum Axis {
440+ H,
441+ V,
442+ }
443+ let moves_and_expected_coordinates = IntoIter::new([
444+ // Places cursor at the end of line
445+ ((Axis::H, Direction::Forward, 8usize), (0, 8)),
446+ // First descent preserves column as the target line is wider
447+ ((Axis::V, Direction::Forward, 1usize), (1, 8)),
448+ // Second descent clamps column as the target line is shorter
449+ ((Axis::V, Direction::Forward, 1usize), (2, 4)),
450+ // Third descent restores the original column
451+ ((Axis::V, Direction::Forward, 1usize), (3, 8)),
452+ // Behaviour is preserved even through long jumps
453+ ((Axis::V, Direction::Backward, 999usize), (0, 8)),
454+ ((Axis::V, Direction::Forward, 999usize), (4, 8)),
455+ ]);
456+
457+ for ((axis, direction, amount), coordinates) in moves_and_expected_coordinates {
458+ range = match axis {
459+ Axis::H => move_horizontally(slice, range, direction, amount, Movement::Move),
460+ Axis::V => move_vertically(slice, range, direction, amount, Movement::Move),
461+ };
462+ assert_eq!(coords_at_pos(slice, range.head), coordinates.into());
463+ assert_eq!(range.head, range.anchor);
464+ }
465+ }
466+
467+ #[test]
468+ fn multibyte_character_column_jumps() {
469+ let text = Rope::from(MULTIBYTE_CHARACTER_SAMPLE);
470+ let slice = text.slice(..);
471+ let position = pos_at_coords(slice, (0, 0).into());
472+ let mut range = Range::point(position);
473+
474+ // FIXME: The behaviour captured in this test diverges from both Kakoune and Vim. These
475+ // will attempt to preserve the horizontal position of the cursor, rather than
476+ // placing it at the same character index.
477+ enum Axis {
478+ H,
479+ V,
480+ }
481+ let moves_and_expected_coordinates = IntoIter::new([
482+ // Places cursor at the fourth kana
483+ ((Axis::H, Direction::Forward, 4), (0, 4)),
484+ // Descent places cursor at the fourth character.
485+ ((Axis::V, Direction::Forward, 1usize), (1, 4)),
486+ ]);
487+
488+ for ((axis, direction, amount), coordinates) in moves_and_expected_coordinates {
489+ range = match axis {
490+ Axis::H => move_horizontally(slice, range, direction, amount, Movement::Move),
491+ Axis::V => move_vertically(slice, range, direction, amount, Movement::Move),
492+ };
493+ assert_eq!(coords_at_pos(slice, range.head), coordinates.into());
494+ assert_eq!(range.head, range.anchor);
495+ }
496+ }
497+
498+ #[test]
499+ #[should_panic]
500+ fn nonsensical_ranges_panic_on_forward_movement_attempt_in_debug_mode() {
501+ move_next_word_start(Rope::from("Sample").slice(..), Range::point(99999999), 1);
502+ }
503+
504+ #[test]
505+ #[should_panic]
506+ fn nonsensical_ranges_panic_on_forward_to_end_movement_attempt_in_debug_mode() {
507+ move_next_word_end(Rope::from("Sample").slice(..), Range::point(99999999), 1);
508+ }
509+
510+ #[test]
511+ #[should_panic]
512+ fn nonsensical_ranges_panic_on_backwards_movement_attempt_in_debug_mode() {
513+ move_prev_word_start(Rope::from("Sample").slice(..), Range::point(99999999), 1);
514+ }
515+
516+ #[test]
517+ fn test_behaviour_when_moving_to_start_of_next_words() {
518+ let tests = array::IntoIter::new([
519+ ("Basic forward motion stops at the first space",
520+ vec![(1, Range::new(0, 0), Range::new(0, 5))]),
521+ (" Starting from a boundary advances the anchor",
522+ vec![(1, Range::new(0, 0), Range::new(1, 9))]),
523+ ("Long whitespace gap is bridged by the head",
524+ vec![(1, Range::new(0, 0), Range::new(0, 10))]),
525+ ("Previous anchor is irrelevant for forward motions",
526+ vec![(1, Range::new(12, 0), Range::new(0, 8))]),
527+ (" Starting from whitespace moves to last space in sequence",
528+ vec![(1, Range::new(0, 0), Range::new(0, 3))]),
529+ ("Starting from mid-word leaves anchor at start position and moves head",
530+ vec![(1, Range::new(3, 3), Range::new(3, 8))]),
531+ ("Identifiers_with_underscores are considered a single word",
532+ vec![(1, Range::new(0, 0), Range::new(0, 28))]),
533+ ("Jumping\n into starting whitespace selects the spaces before 'into'",
534+ vec![(1, Range::new(0, 6), Range::new(8, 11))]),
535+ ("alphanumeric.!,and.?=punctuation are considered 'words' for the purposes of word motion",
536+ vec![
537+ (1, Range::new(0, 0), Range::new(0, 11)),
538+ (1, Range::new(0, 11), Range::new(12, 14)),
539+ (1, Range::new(12, 14), Range::new(15, 17))
540+ ]),
541+ ("... ... punctuation and spaces behave as expected",
542+ vec![
543+ (1, Range::new(0, 0), Range::new(0, 5)),
544+ (1, Range::new(0, 5), Range::new(6, 9)),
545+ ]),
546+ (".._.._ punctuation is not joined by underscores into a single block",
547+ vec![(1, Range::new(0, 0), Range::new(0, 1))]),
548+ ("Newlines\n\nare bridged seamlessly.",
549+ vec![
550+ (1, Range::new(0, 0), Range::new(0, 7)),
551+ (1, Range::new(0, 7), Range::new(10, 13)),
552+ ]),
553+ ("Jumping\n\n\n\n\n\n from newlines to whitespace selects whitespace.",
554+ vec![
555+ (1, Range::new(0, 8), Range::new(13, 15)),
556+ ]),
557+ ("A failed motion does not modify the range",
558+ vec![
559+ (3, Range::new(37, 41), Range::new(37, 41)),
560+ ]),
561+ ("oh oh oh two character words!",
562+ vec![
563+ (1, Range::new(0, 0), Range::new(0, 2)),
564+ (1, Range::new(0, 2), Range::new(3, 5)),
565+ (1, Range::new(0, 1), Range::new(2, 2)),
566+ ]),
567+ ("Multiple motions at once resolve correctly",
568+ vec![
569+ (3, Range::new(0, 0), Range::new(17, 19)),
570+ ]),
571+ ("Excessive motions are performed partially",
572+ vec![
573+ (999, Range::new(0, 0), Range::new(32, 40)),
574+ ]),
575+ ("", // Edge case of moving forward in empty string
576+ vec![
577+ (1, Range::new(0, 0), Range::new(0, 0)),
578+ ]),
579+ ("\n\n\n\n\n", // Edge case of moving forward in all newlines
580+ vec![
581+ (1, Range::new(0, 0), Range::new(0, 4)),
582+ ]),
583+ ("\n \n \n Jumping through alternated space blocks and newlines selects the space blocks",
584+ vec![
585+ (1, Range::new(0, 0), Range::new(1, 3)),
586+ (1, Range::new(1, 3), Range::new(5, 7)),
587+ ]),
588+ ("ヒーリクス multibyte characters behave as normal characters",
589+ vec![
590+ (1, Range::new(0, 0), Range::new(0, 5)),
591+ ]),
592+ ]);
593+
594+ for (sample, scenario) in tests {
595+ for (count, begin, expected_end) in scenario.into_iter() {
596+ let range = move_next_word_start(Rope::from(sample).slice(..), begin, count);
597+ assert_eq!(range, expected_end, "Case failed: [{}]", sample);
598+ }
599+ }
600+ }
601+
602+ #[test]
603+ fn test_behaviour_when_moving_to_start_of_previous_words() {
604+ let tests = array::IntoIter::new([
605+ ("Basic backward motion from the middle of a word",
606+ vec![(1, Range::new(3, 3), Range::new(3, 0))]),
607+ ("Starting from after boundary retreats the anchor",
608+ vec![(1, Range::new(0, 8), Range::new(7, 0))]),
609+ (" Jump to start of a word preceded by whitespace",
610+ vec![(1, Range::new(5, 5), Range::new(5, 4))]),
611+ (" Jump to start of line from start of word preceded by whitespace",
612+ vec![(1, Range::new(4, 4), Range::new(3, 0))]),
613+ ("Previous anchor is irrelevant for backward motions",
614+ vec![(1, Range::new(12, 5), Range::new(5, 0))]),
615+ (" Starting from whitespace moves to first space in sequence",
616+ vec![(1, Range::new(0, 3), Range::new(3, 0))]),
617+ ("Identifiers_with_underscores are considered a single word",
618+ vec![(1, Range::new(0, 20), Range::new(20, 0))]),
619+ ("Jumping\n \nback through a newline selects whitespace",
620+ vec![(1, Range::new(0, 13), Range::new(11, 8))]),
621+ ("Jumping to start of word from the end selects the word",
622+ vec![(1, Range::new(6, 6), Range::new(6, 0))]),
623+ ("alphanumeric.!,and.?=punctuation are considered 'words' for the purposes of word motion",
624+ vec![
625+ (1, Range::new(30, 30), Range::new(30, 21)),
626+ (1, Range::new(30, 21), Range::new(20, 18)),
627+ (1, Range::new(20, 18), Range::new(17, 15))
628+ ]),
629+
630+ ("... ... punctuation and spaces behave as expected",
631+ vec![
632+ (1, Range::new(0, 10), Range::new(9, 6)),
633+ (1, Range::new(9, 6), Range::new(5, 0)),
634+ ]),
635+ (".._.._ punctuation is not joined by underscores into a single block",
636+ vec![(1, Range::new(0, 5), Range::new(4, 3))]),
637+ ("Newlines\n\nare bridged seamlessly.",
638+ vec![
639+ (1, Range::new(0, 10), Range::new(7, 0)),
640+ ]),
641+ ("Jumping \n\n\n\n\nback from within a newline group selects previous block",
642+ vec![
643+ (1, Range::new(0, 13), Range::new(10, 0)),
644+ ]),
645+ ("Failed motions do not modify the range",
646+ vec![
647+ (0, Range::new(3, 0), Range::new(3, 0)),
648+ ]),
649+ ("Multiple motions at once resolve correctly",
650+ vec![
651+ (3, Range::new(18, 18), Range::new(8, 0)),
652+ ]),
653+ ("Excessive motions are performed partially",
654+ vec![
655+ (999, Range::new(40, 40), Range::new(9, 0)),
656+ ]),
657+ ("", // Edge case of moving backwards in empty string
658+ vec![
659+ (1, Range::new(0, 0), Range::new(0, 0)),
660+ ]),
661+ ("\n\n\n\n\n", // Edge case of moving backwards in all newlines
662+ vec![
663+ (1, Range::new(0, 0), Range::new(0, 0)),
664+ ]),
665+ (" \n \nJumping back through alternated space blocks and newlines selects the space blocks",
666+ vec![
667+ (1, Range::new(0, 7), Range::new(6, 4)),
668+ (1, Range::new(6, 4), Range::new(2, 0)),
669+ ]),
670+ ("ヒーリクス multibyte characters behave as normal characters",
671+ vec![
672+ (1, Range::new(0, 5), Range::new(4, 0)),
673+ ]),
674+ ]);
675+
676+ for (sample, scenario) in tests {
677+ for (count, begin, expected_end) in scenario.into_iter() {
678+ let range = move_prev_word_start(Rope::from(sample).slice(..), begin, count);
679+ assert_eq!(range, expected_end, "Case failed: [{}]", sample);
680+ }
681+ }
682+ }
683+
684+ #[test]
685+ fn test_behaviour_when_moving_to_end_of_next_words() {
686+ let tests = array::IntoIter::new([
687+ ("Basic forward motion from the start of a word to the end of it",
688+ vec![(1, Range::new(0, 0), Range::new(0, 4))]),
689+ ("Basic forward motion from the end of a word to the end of the next",
690+ vec![(1, Range::new(0, 4), Range::new(5, 12))]),
691+ ("Basic forward motion from the middle of a word to the end of it",
692+ vec![(1, Range::new(2, 2), Range::new(2, 4))]),
693+ (" Jumping to end of a word preceded by whitespace",
694+ vec![(1, Range::new(0, 0), Range::new(0, 10))]),
695+ (" Starting from a boundary advances the anchor",
696+ vec![(1, Range::new(0, 0), Range::new(1, 8))]),
697+ ("Previous anchor is irrelevant for end of word motion",
698+ vec![(1, Range::new(12, 2), Range::new(2, 7))]),
699+ ("Identifiers_with_underscores are considered a single word",
700+ vec![(1, Range::new(0, 0), Range::new(0, 27))]),
701+ ("Jumping\n into starting whitespace selects up to the end of next word",
702+ vec![(1, Range::new(0, 6), Range::new(8, 15))]),
703+ ("alphanumeric.!,and.?=punctuation are considered 'words' for the purposes of word motion",
704+ vec![
705+ (1, Range::new(0, 0), Range::new(0, 11)),
706+ (1, Range::new(0, 11), Range::new(12, 14)),
707+ (1, Range::new(12, 14), Range::new(15, 17))
708+ ]),
709+ ("... ... punctuation and spaces behave as expected",
710+ vec![
711+ (1, Range::new(0, 0), Range::new(0, 2)),
712+ (1, Range::new(0, 2), Range::new(3, 8)),
713+ ]),
714+ (".._.._ punctuation is not joined by underscores into a single block",
715+ vec![(1, Range::new(0, 0), Range::new(0, 1))]),
716+ ("Newlines\n\nare bridged seamlessly.",
717+ vec![
718+ (1, Range::new(0, 0), Range::new(0, 7)),
719+ (1, Range::new(0, 7), Range::new(10, 12)),
720+ ]),
721+ ("Jumping\n\n\n\n\n\n from newlines to whitespace selects to end of next word.",
722+ vec![
723+ (1, Range::new(0, 8), Range::new(13, 19)),
724+ ]),
725+ ("A failed motion does not modify the range",
726+ vec![
727+ (3, Range::new(37, 41), Range::new(37, 41)),
728+ ]),
729+ ("Multiple motions at once resolve correctly",
730+ vec![
731+ (3, Range::new(0, 0), Range::new(16, 18)),
732+ ]),
733+ ("Excessive motions are performed partially",
734+ vec![
735+ (999, Range::new(0, 0), Range::new(31, 40)),
736+ ]),
737+ ("", // Edge case of moving forward in empty string
738+ vec![
739+ (1, Range::new(0, 0), Range::new(0, 0)),
740+ ]),
741+ ("\n\n\n\n\n", // Edge case of moving forward in all newlines
742+ vec![
743+ (1, Range::new(0, 0), Range::new(0, 4)),
744+ ]),
745+ ("\n \n \n Jumping through alternated space blocks and newlines selects the space blocks",
746+ vec![
747+ (1, Range::new(0, 0), Range::new(1, 3)),
748+ (1, Range::new(1, 3), Range::new(5, 7)),
749+ ]),
750+ ("ヒーリクス multibyte characters behave as normal characters",
751+ vec![
752+ (1, Range::new(0, 0), Range::new(0, 4)),
753+ ]),
754+ ]);
755+
756+ for (sample, scenario) in tests {
757+ for (count, begin, expected_end) in scenario.into_iter() {
758+ let range = move_next_word_end(Rope::from(sample).slice(..), begin, count);
759+ assert_eq!(range, expected_end, "Case failed: [{}]", sample);
760+ }
761+ }
762+ }
763+
282764 #[test]
283765 fn test_categorize() {
284766 const WORD_TEST_CASE: &'static str =
285767 "_hello_world_あいうえおー12345678901234567890";
286− const PUNCTUATION_TEST_CASE: &'static str = "!\"#$%&\'()*+,-./:;<=>?@[\\]^`{|}~!”#$%&’()*+、。:;<=>?@「」^`{|}~";
768+ const PUNCTUATION_TEST_CASE: &'static str =
769+ "!\"#$%&\'()*+,-./:;<=>?@[\\]^`{|}~!”#$%&’()*+、。:;<=>?@「」^`{|}~";
287770 const WHITESPACE_TEST_CASE: &'static str = " ";
288771
289772 assert_eq!(Category::Eol, categorize('\n'));