mirror of https://github.com/helix-editor/helix
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.
577 lines
19 KiB
Rust
577 lines
19 KiB
Rust
use std::fmt::Display;
|
|
|
|
use ropey::RopeSlice;
|
|
use tree_sitter::{Node, QueryCursor};
|
|
|
|
use crate::chars::{categorize_char, char_is_whitespace, CharCategory};
|
|
use crate::graphemes::{next_grapheme_boundary, prev_grapheme_boundary};
|
|
use crate::line_ending::rope_is_line_ending;
|
|
use crate::movement::Direction;
|
|
use crate::surround;
|
|
use crate::syntax::LanguageConfiguration;
|
|
use crate::Range;
|
|
|
|
fn find_word_boundary(slice: RopeSlice, mut pos: usize, direction: Direction, long: bool) -> usize {
|
|
use CharCategory::{Eol, Whitespace};
|
|
|
|
let iter = match direction {
|
|
Direction::Forward => slice.chars_at(pos),
|
|
Direction::Backward => {
|
|
let mut iter = slice.chars_at(pos);
|
|
iter.reverse();
|
|
iter
|
|
}
|
|
};
|
|
|
|
let mut prev_category = match direction {
|
|
Direction::Forward if pos == 0 => Whitespace,
|
|
Direction::Forward => categorize_char(slice.char(pos - 1)),
|
|
Direction::Backward if pos == slice.len_chars() => Whitespace,
|
|
Direction::Backward => categorize_char(slice.char(pos)),
|
|
};
|
|
|
|
for ch in iter {
|
|
match categorize_char(ch) {
|
|
Eol | Whitespace => return pos,
|
|
category => {
|
|
if !long && category != prev_category && pos != 0 && pos != slice.len_chars() {
|
|
return pos;
|
|
} else {
|
|
match direction {
|
|
Direction::Forward => pos += 1,
|
|
Direction::Backward => pos = pos.saturating_sub(1),
|
|
}
|
|
prev_category = category;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
pos
|
|
}
|
|
|
|
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
|
|
pub enum TextObject {
|
|
Around,
|
|
Inside,
|
|
/// Used for moving between objects.
|
|
Movement,
|
|
}
|
|
|
|
impl Display for TextObject {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
f.write_str(match self {
|
|
Self::Around => "around",
|
|
Self::Inside => "inside",
|
|
Self::Movement => "movement",
|
|
})
|
|
}
|
|
}
|
|
|
|
// count doesn't do anything yet
|
|
pub fn textobject_word(
|
|
slice: RopeSlice,
|
|
range: Range,
|
|
textobject: TextObject,
|
|
_count: usize,
|
|
long: bool,
|
|
) -> Range {
|
|
let pos = range.cursor(slice);
|
|
|
|
let word_start = find_word_boundary(slice, pos, Direction::Backward, long);
|
|
let word_end = match slice.get_char(pos).map(categorize_char) {
|
|
None | Some(CharCategory::Whitespace | CharCategory::Eol) => pos,
|
|
_ => find_word_boundary(slice, pos + 1, Direction::Forward, long),
|
|
};
|
|
|
|
// Special case.
|
|
if word_start == word_end {
|
|
return Range::new(word_start, word_end);
|
|
}
|
|
|
|
match textobject {
|
|
TextObject::Inside => Range::new(word_start, word_end),
|
|
TextObject::Around => {
|
|
let whitespace_count_right = slice
|
|
.chars_at(word_end)
|
|
.take_while(|c| char_is_whitespace(*c))
|
|
.count();
|
|
|
|
if whitespace_count_right > 0 {
|
|
Range::new(word_start, word_end + whitespace_count_right)
|
|
} else {
|
|
let whitespace_count_left = {
|
|
let mut iter = slice.chars_at(word_start);
|
|
iter.reverse();
|
|
iter.take_while(|c| char_is_whitespace(*c)).count()
|
|
};
|
|
Range::new(word_start - whitespace_count_left, word_end)
|
|
}
|
|
}
|
|
TextObject::Movement => unreachable!(),
|
|
}
|
|
}
|
|
|
|
pub fn textobject_paragraph(
|
|
slice: RopeSlice,
|
|
range: Range,
|
|
textobject: TextObject,
|
|
count: usize,
|
|
) -> Range {
|
|
let mut line = range.cursor_line(slice);
|
|
let prev_line_empty = rope_is_line_ending(slice.line(line.saturating_sub(1)));
|
|
let curr_line_empty = rope_is_line_ending(slice.line(line));
|
|
let next_line_empty = rope_is_line_ending(slice.line(line.saturating_sub(1)));
|
|
let last_char =
|
|
prev_grapheme_boundary(slice, slice.line_to_char(line + 1)) == range.cursor(slice);
|
|
let prev_empty_to_line = prev_line_empty && !curr_line_empty;
|
|
let curr_empty_to_line = curr_line_empty && !next_line_empty;
|
|
|
|
// skip character before paragraph boundary
|
|
let mut line_back = line; // line but backwards
|
|
if prev_empty_to_line || curr_empty_to_line {
|
|
line_back += 1;
|
|
}
|
|
// do not include current paragraph on paragraph end (include next)
|
|
if !(curr_empty_to_line && last_char) {
|
|
let mut lines = slice.lines_at(line_back);
|
|
lines.reverse();
|
|
let mut lines = lines.map(rope_is_line_ending).peekable();
|
|
while lines.next_if(|&e| e).is_some() {
|
|
line_back -= 1;
|
|
}
|
|
while lines.next_if(|&e| !e).is_some() {
|
|
line_back -= 1;
|
|
}
|
|
}
|
|
|
|
// skip character after paragraph boundary
|
|
if curr_empty_to_line && last_char {
|
|
line += 1;
|
|
}
|
|
let mut lines = slice.lines_at(line).map(rope_is_line_ending).peekable();
|
|
let mut count_done = 0; // count how many non-whitespace paragraphs done
|
|
for _ in 0..count {
|
|
let mut done = false;
|
|
while lines.next_if(|&e| !e).is_some() {
|
|
line += 1;
|
|
done = true;
|
|
}
|
|
while lines.next_if(|&e| e).is_some() {
|
|
line += 1;
|
|
}
|
|
count_done += done as usize;
|
|
}
|
|
|
|
// search one paragraph backwards for last paragraph
|
|
// makes `map` at the end of the paragraph with trailing newlines useful
|
|
let last_paragraph = count_done != count && lines.peek().is_none();
|
|
if last_paragraph {
|
|
let mut lines = slice.lines_at(line_back);
|
|
lines.reverse();
|
|
let mut lines = lines.map(rope_is_line_ending).peekable();
|
|
while lines.next_if(|&e| e).is_some() {
|
|
line_back -= 1;
|
|
}
|
|
while lines.next_if(|&e| !e).is_some() {
|
|
line_back -= 1;
|
|
}
|
|
}
|
|
|
|
// handle last whitespaces part separately depending on textobject
|
|
match textobject {
|
|
TextObject::Around => {}
|
|
TextObject::Inside => {
|
|
// remove last whitespace paragraph
|
|
let mut lines = slice.lines_at(line);
|
|
lines.reverse();
|
|
let mut lines = lines.map(rope_is_line_ending).peekable();
|
|
while lines.next_if(|&e| e).is_some() {
|
|
line -= 1;
|
|
}
|
|
}
|
|
TextObject::Movement => unreachable!(),
|
|
}
|
|
|
|
let anchor = slice.line_to_char(line_back);
|
|
let head = slice.line_to_char(line);
|
|
Range::new(anchor, head)
|
|
}
|
|
|
|
pub fn textobject_pair_surround(
|
|
slice: RopeSlice,
|
|
range: Range,
|
|
textobject: TextObject,
|
|
ch: char,
|
|
count: usize,
|
|
) -> Range {
|
|
textobject_pair_surround_impl(slice, range, textobject, Some(ch), count)
|
|
}
|
|
|
|
pub fn textobject_pair_surround_closest(
|
|
slice: RopeSlice,
|
|
range: Range,
|
|
textobject: TextObject,
|
|
count: usize,
|
|
) -> Range {
|
|
textobject_pair_surround_impl(slice, range, textobject, None, count)
|
|
}
|
|
|
|
fn textobject_pair_surround_impl(
|
|
slice: RopeSlice,
|
|
range: Range,
|
|
textobject: TextObject,
|
|
ch: Option<char>,
|
|
count: usize,
|
|
) -> Range {
|
|
let pair_pos = match ch {
|
|
Some(ch) => surround::find_nth_pairs_pos(slice, ch, range, count),
|
|
// Automatically find the closest surround pairs
|
|
None => surround::find_nth_closest_pairs_pos(slice, range, count),
|
|
};
|
|
pair_pos
|
|
.map(|(anchor, head)| match textobject {
|
|
TextObject::Inside => Range::new(next_grapheme_boundary(slice, anchor), head),
|
|
TextObject::Around => Range::new(anchor, next_grapheme_boundary(slice, head)),
|
|
TextObject::Movement => unreachable!(),
|
|
})
|
|
.unwrap_or(range)
|
|
}
|
|
|
|
/// Transform the given range to select text objects based on tree-sitter.
|
|
/// `object_name` is a query capture base name like "function", "class", etc.
|
|
/// `slice_tree` is the tree-sitter node corresponding to given text slice.
|
|
pub fn textobject_treesitter(
|
|
slice: RopeSlice,
|
|
range: Range,
|
|
textobject: TextObject,
|
|
object_name: &str,
|
|
slice_tree: Node,
|
|
lang_config: &LanguageConfiguration,
|
|
_count: usize,
|
|
) -> Range {
|
|
let get_range = move || -> Option<Range> {
|
|
let byte_pos = slice.char_to_byte(range.cursor(slice));
|
|
|
|
let capture_name = format!("{}.{}", object_name, textobject); // eg. function.inner
|
|
let mut cursor = QueryCursor::new();
|
|
let node = lang_config
|
|
.textobject_query()?
|
|
.capture_nodes(&capture_name, slice_tree, slice, &mut cursor)?
|
|
.filter(|node| node.byte_range().contains(&byte_pos))
|
|
.min_by_key(|node| node.byte_range().len())?;
|
|
|
|
let len = slice.len_bytes();
|
|
let start_byte = node.start_byte();
|
|
let end_byte = node.end_byte();
|
|
if start_byte >= len || end_byte >= len {
|
|
return None;
|
|
}
|
|
|
|
let start_char = slice.byte_to_char(start_byte);
|
|
let end_char = slice.byte_to_char(end_byte);
|
|
|
|
Some(Range::new(start_char, end_char))
|
|
};
|
|
get_range().unwrap_or(range)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod test {
|
|
use super::TextObject::*;
|
|
use super::*;
|
|
|
|
use crate::Range;
|
|
use ropey::Rope;
|
|
|
|
#[test]
|
|
fn test_textobject_word() {
|
|
// (text, [(char position, textobject, final range), ...])
|
|
let tests = &[
|
|
(
|
|
"cursor at beginning of doc",
|
|
vec![(0, Inside, (0, 6)), (0, Around, (0, 7))],
|
|
),
|
|
(
|
|
"cursor at middle of word",
|
|
vec![
|
|
(13, Inside, (10, 16)),
|
|
(10, Inside, (10, 16)),
|
|
(15, Inside, (10, 16)),
|
|
(13, Around, (10, 17)),
|
|
(10, Around, (10, 17)),
|
|
(15, Around, (10, 17)),
|
|
],
|
|
),
|
|
(
|
|
"cursor between word whitespace",
|
|
vec![(6, Inside, (6, 6)), (6, Around, (6, 6))],
|
|
),
|
|
(
|
|
"cursor on word before newline\n",
|
|
vec![
|
|
(22, Inside, (22, 29)),
|
|
(28, Inside, (22, 29)),
|
|
(25, Inside, (22, 29)),
|
|
(22, Around, (21, 29)),
|
|
(28, Around, (21, 29)),
|
|
(25, Around, (21, 29)),
|
|
],
|
|
),
|
|
(
|
|
"cursor on newline\nnext line",
|
|
vec![(17, Inside, (17, 17)), (17, Around, (17, 17))],
|
|
),
|
|
(
|
|
"cursor on word after newline\nnext line",
|
|
vec![
|
|
(29, Inside, (29, 33)),
|
|
(30, Inside, (29, 33)),
|
|
(32, Inside, (29, 33)),
|
|
(29, Around, (29, 34)),
|
|
(30, Around, (29, 34)),
|
|
(32, Around, (29, 34)),
|
|
],
|
|
),
|
|
(
|
|
"cursor on #$%:;* punctuation",
|
|
vec![
|
|
(13, Inside, (10, 16)),
|
|
(10, Inside, (10, 16)),
|
|
(15, Inside, (10, 16)),
|
|
(13, Around, (10, 17)),
|
|
(10, Around, (10, 17)),
|
|
(15, Around, (10, 17)),
|
|
],
|
|
),
|
|
(
|
|
"cursor on punc%^#$:;.tuation",
|
|
vec![
|
|
(14, Inside, (14, 21)),
|
|
(20, Inside, (14, 21)),
|
|
(17, Inside, (14, 21)),
|
|
(14, Around, (14, 21)),
|
|
(20, Around, (14, 21)),
|
|
(17, Around, (14, 21)),
|
|
],
|
|
),
|
|
(
|
|
"cursor in extra whitespace",
|
|
vec![
|
|
(9, Inside, (9, 9)),
|
|
(10, Inside, (10, 10)),
|
|
(11, Inside, (11, 11)),
|
|
(9, Around, (9, 9)),
|
|
(10, Around, (10, 10)),
|
|
(11, Around, (11, 11)),
|
|
],
|
|
),
|
|
(
|
|
"cursor on word with extra whitespace",
|
|
vec![(11, Inside, (10, 14)), (11, Around, (10, 17))],
|
|
),
|
|
(
|
|
"cursor at end with extra whitespace",
|
|
vec![(28, Inside, (27, 37)), (28, Around, (24, 37))],
|
|
),
|
|
(
|
|
"cursor at end of doc",
|
|
vec![(19, Inside, (17, 20)), (19, Around, (16, 20))],
|
|
),
|
|
];
|
|
|
|
for (sample, scenario) in tests {
|
|
let doc = Rope::from(*sample);
|
|
let slice = doc.slice(..);
|
|
for &case in scenario {
|
|
let (pos, objtype, expected_range) = case;
|
|
// cursor is a single width selection
|
|
let range = Range::new(pos, pos + 1);
|
|
let result = textobject_word(slice, range, objtype, 1, false);
|
|
assert_eq!(
|
|
result,
|
|
expected_range.into(),
|
|
"\nCase failed: {:?} - {:?}",
|
|
sample,
|
|
case
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_textobject_paragraph_inside_single() {
|
|
let tests = [
|
|
("#[|]#", "#[|]#"),
|
|
("firs#[t|]#\n\nparagraph\n\n", "#[first\n|]#\nparagraph\n\n"),
|
|
(
|
|
"second\n\npa#[r|]#agraph\n\n",
|
|
"second\n\n#[paragraph\n|]#\n",
|
|
),
|
|
("#[f|]#irst char\n\n", "#[first char\n|]#\n"),
|
|
("last char\n#[\n|]#", "#[last char\n|]#\n"),
|
|
(
|
|
"empty to line\n#[\n|]#paragraph boundary\n\n",
|
|
"empty to line\n\n#[paragraph boundary\n|]#\n",
|
|
),
|
|
(
|
|
"line to empty\n\n#[p|]#aragraph boundary\n\n",
|
|
"line to empty\n\n#[paragraph boundary\n|]#\n",
|
|
),
|
|
];
|
|
|
|
for (before, expected) in tests {
|
|
let (s, selection) = crate::test::print(before);
|
|
let text = Rope::from(s.as_str());
|
|
let selection = selection
|
|
.transform(|r| textobject_paragraph(text.slice(..), r, TextObject::Inside, 1));
|
|
let actual = crate::test::plain(&s, selection);
|
|
assert_eq!(actual, expected, "\nbefore: `{:?}`", before);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_textobject_paragraph_inside_double() {
|
|
let tests = [
|
|
(
|
|
"last two\n\n#[p|]#aragraph\n\nwithout whitespaces\n\n",
|
|
"last two\n\n#[paragraph\n\nwithout whitespaces\n|]#\n",
|
|
),
|
|
(
|
|
"last two\n#[\n|]#paragraph\n\nwithout whitespaces\n\n",
|
|
"last two\n\n#[paragraph\n\nwithout whitespaces\n|]#\n",
|
|
),
|
|
];
|
|
|
|
for (before, expected) in tests {
|
|
let (s, selection) = crate::test::print(before);
|
|
let text = Rope::from(s.as_str());
|
|
let selection = selection
|
|
.transform(|r| textobject_paragraph(text.slice(..), r, TextObject::Inside, 2));
|
|
let actual = crate::test::plain(&s, selection);
|
|
assert_eq!(actual, expected, "\nbefore: `{:?}`", before);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_textobject_paragraph_around_single() {
|
|
let tests = [
|
|
("#[|]#", "#[|]#"),
|
|
("firs#[t|]#\n\nparagraph\n\n", "#[first\n\n|]#paragraph\n\n"),
|
|
(
|
|
"second\n\npa#[r|]#agraph\n\n",
|
|
"second\n\n#[paragraph\n\n|]#",
|
|
),
|
|
("#[f|]#irst char\n\n", "#[first char\n\n|]#"),
|
|
("last char\n#[\n|]#", "#[last char\n\n|]#"),
|
|
(
|
|
"empty to line\n#[\n|]#paragraph boundary\n\n",
|
|
"empty to line\n\n#[paragraph boundary\n\n|]#",
|
|
),
|
|
(
|
|
"line to empty\n\n#[p|]#aragraph boundary\n\n",
|
|
"line to empty\n\n#[paragraph boundary\n\n|]#",
|
|
),
|
|
];
|
|
|
|
for (before, expected) in tests {
|
|
let (s, selection) = crate::test::print(before);
|
|
let text = Rope::from(s.as_str());
|
|
let selection = selection
|
|
.transform(|r| textobject_paragraph(text.slice(..), r, TextObject::Around, 1));
|
|
let actual = crate::test::plain(&s, selection);
|
|
assert_eq!(actual, expected, "\nbefore: `{:?}`", before);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_textobject_surround() {
|
|
// (text, [(cursor position, textobject, final range, surround char, count), ...])
|
|
let tests = &[
|
|
(
|
|
"simple (single) surround pairs",
|
|
vec![
|
|
(3, Inside, (3, 3), '(', 1),
|
|
(7, Inside, (8, 14), ')', 1),
|
|
(10, Inside, (8, 14), '(', 1),
|
|
(14, Inside, (8, 14), ')', 1),
|
|
(3, Around, (3, 3), '(', 1),
|
|
(7, Around, (7, 15), ')', 1),
|
|
(10, Around, (7, 15), '(', 1),
|
|
(14, Around, (7, 15), ')', 1),
|
|
],
|
|
),
|
|
(
|
|
"samexx 'single' surround pairs",
|
|
vec![
|
|
(3, Inside, (3, 3), '\'', 1),
|
|
(7, Inside, (7, 7), '\'', 1),
|
|
(10, Inside, (8, 14), '\'', 1),
|
|
(14, Inside, (14, 14), '\'', 1),
|
|
(3, Around, (3, 3), '\'', 1),
|
|
(7, Around, (7, 7), '\'', 1),
|
|
(10, Around, (7, 15), '\'', 1),
|
|
(14, Around, (14, 14), '\'', 1),
|
|
],
|
|
),
|
|
(
|
|
"(nested (surround (pairs)) 3 levels)",
|
|
vec![
|
|
(0, Inside, (1, 35), '(', 1),
|
|
(6, Inside, (1, 35), ')', 1),
|
|
(8, Inside, (9, 25), '(', 1),
|
|
(8, Inside, (9, 35), ')', 2),
|
|
(20, Inside, (9, 25), '(', 2),
|
|
(20, Inside, (1, 35), ')', 3),
|
|
(0, Around, (0, 36), '(', 1),
|
|
(6, Around, (0, 36), ')', 1),
|
|
(8, Around, (8, 26), '(', 1),
|
|
(8, Around, (8, 36), ')', 2),
|
|
(20, Around, (8, 26), '(', 2),
|
|
(20, Around, (0, 36), ')', 3),
|
|
],
|
|
),
|
|
(
|
|
"(mixed {surround [pair] same} line)",
|
|
vec![
|
|
(2, Inside, (1, 34), '(', 1),
|
|
(9, Inside, (8, 28), '{', 1),
|
|
(18, Inside, (18, 22), '[', 1),
|
|
(2, Around, (0, 35), '(', 1),
|
|
(9, Around, (7, 29), '{', 1),
|
|
(18, Around, (17, 23), '[', 1),
|
|
],
|
|
),
|
|
(
|
|
"(stepped (surround) pairs (should) skip)",
|
|
vec![(22, Inside, (1, 39), '(', 1), (22, Around, (0, 40), '(', 1)],
|
|
),
|
|
(
|
|
"[surround pairs{\non different]\nlines}",
|
|
vec![
|
|
(7, Inside, (1, 29), '[', 1),
|
|
(15, Inside, (16, 36), '{', 1),
|
|
(7, Around, (0, 30), '[', 1),
|
|
(15, Around, (15, 37), '{', 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_pair_surround(slice, Range::point(pos), objtype, ch, count);
|
|
assert_eq!(
|
|
result,
|
|
expected_range.into(),
|
|
"\nCase failed: {:?} - {:?}",
|
|
sample,
|
|
case
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|