From c68fe1f2a3a40c37969c1f5d18e3134320a0c773 Mon Sep 17 00:00:00 2001 From: Gokul Soumya Date: Sat, 3 Jul 2021 06:37:49 +0530 Subject: Add object selection (textobjects) (#385) * Add textobjects for word * Add textobjects for surround characters * Apply clippy lints * Remove ThisWordPrevBound in favor of PrevWordEnd It's the same as PrevWordEnd except for taking the current char into account, so use a "flag" to capture that usecase * Add tests for PrevWordEnd movement * Remove ThisWord* movements They did not preserve anchor positions and were only used for textobject boundary search anyway so replace them with simple position finding functions * Rewrite tests of word textobject * Add tests for surround textobject * Add textobject docs * Refactor textobject word position functions * Apply clippy lints on textobject * Fix overflow error with textobjects--- helix-core/src/textobject.rs | 319 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 319 insertions(+) create mode 100644 helix-core/src/textobject.rs (limited to 'helix-core/src/textobject.rs') diff --git a/helix-core/src/textobject.rs b/helix-core/src/textobject.rs new file mode 100644 index 00000000..d29eb03c --- /dev/null +++ b/helix-core/src/textobject.rs @@ -0,0 +1,319 @@ +use ropey::RopeSlice; + +use crate::chars::{categorize_char, char_is_line_ending, char_is_whitespace, CharCategory}; +use crate::movement::{self, Direction}; +use crate::surround; +use crate::Range; + +fn this_word_end_pos(slice: RopeSlice, pos: usize) -> usize { + this_word_bound_pos(slice, pos, Direction::Forward) +} + +fn this_word_start_pos(slice: RopeSlice, pos: usize) -> usize { + this_word_bound_pos(slice, pos, Direction::Backward) +} + +fn this_word_bound_pos(slice: RopeSlice, mut pos: usize, direction: Direction) -> usize { + let iter = match direction { + Direction::Forward => slice.chars_at(pos + 1), + Direction::Backward => { + let mut iter = slice.chars_at(pos); + iter.reverse(); + iter + } + }; + + match categorize_char(slice.char(pos)) { + CharCategory::Eol | CharCategory::Whitespace => pos, + category => { + for peek in iter { + let curr_category = categorize_char(peek); + if curr_category != category + || curr_category == CharCategory::Eol + || curr_category == CharCategory::Whitespace + { + return pos; + } + pos = match direction { + Direction::Forward => pos + 1, + Direction::Backward => pos.saturating_sub(1), + } + } + pos + } + } +} + +#[derive(Copy, Clone, PartialEq, Eq, Debug)] +pub enum TextObject { + Around, + Inside, +} + +// count doesn't do anything yet +pub fn textobject_word( + slice: RopeSlice, + range: Range, + textobject: TextObject, + count: usize, +) -> Range { + let this_word_start = this_word_start_pos(slice, range.head); + let this_word_end = this_word_end_pos(slice, range.head); + + let (anchor, head); + match textobject { + TextObject::Inside => { + anchor = this_word_start; + head = this_word_end; + } + TextObject::Around => { + if slice + .get_char(this_word_end + 1) + .map_or(true, char_is_line_ending) + { + head = this_word_end; + if slice + .get_char(this_word_start.saturating_sub(1)) + .map_or(true, char_is_line_ending) + { + // single word on a line + anchor = this_word_start; + } else { + // last word on a line, select the whitespace before it too + anchor = movement::move_prev_word_end(slice, range, count).head; + } + } else if char_is_whitespace(slice.char(range.head)) { + // select whole whitespace and next word + head = movement::move_next_word_end(slice, range, count).head; + anchor = movement::backwards_skip_while(slice, range.head, |c| c.is_whitespace()) + .map(|p| p + 1) // p is first *non* whitespace char, so +1 to get whitespace pos + .unwrap_or(0); + } else { + head = movement::move_next_word_start(slice, range, count).head; + anchor = this_word_start; + } + } + }; + Range::new(anchor, head) +} + +pub fn textobject_surround( + slice: RopeSlice, + range: Range, + textobject: TextObject, + ch: char, + count: usize, +) -> Range { + surround::find_nth_pairs_pos(slice, ch, range.head, count) + .map(|(anchor, head)| match textobject { + TextObject::Inside => Range::new(anchor + 1, head.saturating_sub(1)), + TextObject::Around => Range::new(anchor, head), + }) + .unwrap_or(range) +} + +#[cfg(test)] +mod test { + use super::TextObject::*; + use super::*; + + use crate::Range; + use ropey::Rope; + + #[test] + fn test_textobject_word() { + // (text, [(cursor position, textobject, final range), ...]) + let tests = &[ + ( + "cursor at beginning of doc", + vec![(0, Inside, (0, 5)), (0, Around, (0, 6))], + ), + ( + "cursor at middle of word", + vec![ + (13, Inside, (10, 15)), + (10, Inside, (10, 15)), + (15, Inside, (10, 15)), + (13, Around, (10, 16)), + (10, Around, (10, 16)), + (15, Around, (10, 16)), + ], + ), + ( + "cursor between word whitespace", + vec![(6, Inside, (6, 6)), (6, Around, (6, 13))], + ), + ( + "cursor on word before newline\n", + vec![ + (22, Inside, (22, 28)), + (28, Inside, (22, 28)), + (25, Inside, (22, 28)), + (22, Around, (21, 28)), + (28, Around, (21, 28)), + (25, Around, (21, 28)), + ], + ), + ( + "cursor on newline\nnext line", + vec![(17, Inside, (17, 17)), (17, Around, (17, 22))], + ), + ( + "cursor on word after newline\nnext line", + vec![ + (29, Inside, (29, 32)), + (30, Inside, (29, 32)), + (32, Inside, (29, 32)), + (29, Around, (29, 33)), + (30, Around, (29, 33)), + (32, Around, (29, 33)), + ], + ), + ( + "cursor on #$%:;* punctuation", + vec![ + (13, Inside, (10, 15)), + (10, Inside, (10, 15)), + (15, Inside, (10, 15)), + (13, Around, (10, 16)), + (10, Around, (10, 16)), + (15, Around, (10, 16)), + ], + ), + ( + "cursor on punc%^#$:;.tuation", + vec![ + (14, Inside, (14, 20)), + (20, Inside, (14, 20)), + (17, Inside, (14, 20)), + (14, Around, (14, 20)), + // FIXME: edge case + // (20, Around, (14, 20)), + (17, Around, (14, 20)), + ], + ), + ( + "cursor in extra whitespace", + vec![ + (9, Inside, (9, 9)), + (10, Inside, (10, 10)), + (11, Inside, (11, 11)), + (9, Around, (9, 16)), + (10, Around, (9, 16)), + (11, Around, (9, 16)), + ], + ), + ( + "cursor at end of doc", + vec![(19, Inside, (17, 19)), (19, Around, (16, 19))], + ), + ]; + + for (sample, scenario) in tests { + let doc = Rope::from(*sample); + let slice = doc.slice(..); + for &case in scenario { + let (pos, objtype, expected_range) = case; + let result = textobject_word(slice, Range::point(pos), objtype, 1); + assert_eq!( + result, + expected_range.into(), + "\nCase failed: {:?} - {:?}", + sample, + case + ); + } + } + } + + #[test] + fn test_textobject_surround() { + // (text, [(cursor position, textobject, final range, count), ...]) + let tests = &[ + ( + "simple (single) surround pairs", + vec![ + (3, Inside, (3, 3), '(', 1), + (7, Inside, (8, 13), ')', 1), + (10, Inside, (8, 13), '(', 1), + (14, Inside, (8, 13), ')', 1), + (3, Around, (3, 3), '(', 1), + (7, Around, (7, 14), ')', 1), + (10, Around, (7, 14), '(', 1), + (14, Around, (7, 14), ')', 1), + ], + ), + ( + "samexx 'single' surround pairs", + vec![ + (3, Inside, (3, 3), '\'', 1), + // FIXME: surround doesn't work when *on* same chars pair + // (7, Inner, (8, 13), '\'', 1), + (10, Inside, (8, 13), '\'', 1), + // (14, Inner, (8, 13), '\'', 1), + (3, Around, (3, 3), '\'', 1), + // (7, Around, (7, 14), '\'', 1), + (10, Around, (7, 14), '\'', 1), + // (14, Around, (7, 14), '\'', 1), + ], + ), + ( + "(nested (surround (pairs)) 3 levels)", + vec![ + (0, Inside, (1, 34), '(', 1), + (6, Inside, (1, 34), ')', 1), + (8, Inside, (9, 24), '(', 1), + (8, Inside, (9, 34), ')', 2), + (20, Inside, (9, 24), '(', 2), + (20, Inside, (1, 34), ')', 3), + (0, Around, (0, 35), '(', 1), + (6, Around, (0, 35), ')', 1), + (8, Around, (8, 25), '(', 1), + (8, Around, (8, 35), ')', 2), + (20, Around, (8, 25), '(', 2), + (20, Around, (0, 35), ')', 3), + ], + ), + ( + "(mixed {surround [pair] same} line)", + vec![ + (2, Inside, (1, 33), '(', 1), + (9, Inside, (8, 27), '{', 1), + (18, Inside, (18, 21), '[', 1), + (2, Around, (0, 34), '(', 1), + (9, Around, (7, 28), '{', 1), + (18, Around, (17, 22), '[', 1), + ], + ), + ( + "(stepped (surround) pairs (should) skip)", + vec![(22, Inside, (1, 38), '(', 1), (22, Around, (0, 39), '(', 1)], + ), + ( + "[surround pairs{\non different]\nlines}", + vec![ + (7, Inside, (1, 28), '[', 1), + (15, Inside, (16, 35), '{', 1), + (7, Around, (0, 29), '[', 1), + (15, Around, (15, 36), '{', 1), + ], + ), + ]; + + for (sample, scenario) in tests { + let doc = Rope::from(*sample); + let slice = doc.slice(..); + for &case in scenario { + let (pos, objtype, expected_range, ch, count) = case; + let result = textobject_surround(slice, Range::point(pos), objtype, ch, count); + assert_eq!( + result, + expected_range.into(), + "\nCase failed: {:?} - {:?}", + sample, + case + ); + } + } + } +} -- cgit v1.2.3-70-g09d2 From 351c1e7e5533a05a8e4da20d1e5227c356098b7b Mon Sep 17 00:00:00 2001 From: Gokul Soumya Date: Sat, 3 Jul 2021 15:46:56 +0530 Subject: Fix surround bug when cursor on same pair For example when the cursor is _on_ the `'` in `'word'`, the cursor wouldn't move because the search for a matching pair started _from_ the position of the cursor and simply found itself. --- helix-core/src/surround.rs | 18 +++++++++++++----- helix-core/src/textobject.rs | 9 ++++----- 2 files changed, 17 insertions(+), 10 deletions(-) (limited to 'helix-core/src/textobject.rs') diff --git a/helix-core/src/surround.rs b/helix-core/src/surround.rs index 61981d6e..841288c2 100644 --- a/helix-core/src/surround.rs +++ b/helix-core/src/surround.rs @@ -41,11 +41,14 @@ pub fn find_nth_pairs_pos( let (open, close) = get_pair(ch); let (open_pos, close_pos) = if open == close { - // find_nth* do not consider current character; +1/-1 to include them - ( - search::find_nth_prev(text, open, pos + 1, n, true)?, - search::find_nth_next(text, close, pos - 1, n, true)?, - ) + let prev = search::find_nth_prev(text, open, pos, n, true); + let next = search::find_nth_next(text, close, pos, n, true); + if text.char(pos) == open { + // curosr is *on* a pair + next.map(|n| (pos, n)).or_else(|| prev.map(|p| (p, pos)))? + } else { + (prev?, next?) + } } else { ( find_nth_open_pair(text, open, close, pos, n)?, @@ -198,6 +201,11 @@ mod test { assert_eq!(find_nth_pairs_pos(slice, '\'', 13, 1), Some((10, 15))); assert_eq!(find_nth_pairs_pos(slice, '\'', 13, 2), Some((4, 21))); assert_eq!(find_nth_pairs_pos(slice, '\'', 13, 3), Some((0, 27))); + // cursor on the quotes + assert_eq!(find_nth_pairs_pos(slice, '\'', 10, 1), Some((10, 15))); + // this is the best we can do since opening and closing pairs are same + assert_eq!(find_nth_pairs_pos(slice, '\'', 0, 1), Some((0, 4))); + assert_eq!(find_nth_pairs_pos(slice, '\'', 27, 1), Some((21, 27))); } #[test] diff --git a/helix-core/src/textobject.rs b/helix-core/src/textobject.rs index d29eb03c..fbf66256 100644 --- a/helix-core/src/textobject.rs +++ b/helix-core/src/textobject.rs @@ -247,14 +247,13 @@ mod test { "samexx 'single' surround pairs", vec![ (3, Inside, (3, 3), '\'', 1), - // FIXME: surround doesn't work when *on* same chars pair - // (7, Inner, (8, 13), '\'', 1), + (7, Inside, (8, 13), '\'', 1), (10, Inside, (8, 13), '\'', 1), - // (14, Inner, (8, 13), '\'', 1), + (14, Inside, (8, 13), '\'', 1), (3, Around, (3, 3), '\'', 1), - // (7, Around, (7, 14), '\'', 1), + (7, Around, (7, 14), '\'', 1), (10, Around, (7, 14), '\'', 1), - // (14, Around, (7, 14), '\'', 1), + (14, Around, (7, 14), '\'', 1), ], ), ( -- cgit v1.2.3-70-g09d2