You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
helix-plus/helix-core/src/textobject.rs

319 lines
11 KiB
Rust

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),
(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),
],
),
(
"(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
);
}
}
}
}