on 6c11708 help sign in
helix/helix-core/src/textobject.rs
375 lines 13 kB history raw
1use std::fmt::Display;
3use ropey::RopeSlice;
4use tree_sitter::{Node, QueryCursor};
6use crate::chars::{categorize_char, char_is_whitespace, CharCategory};
7use crate::graphemes::next_grapheme_boundary;
8use crate::movement::Direction;
9use crate::surround;
10use crate::syntax::LanguageConfiguration;
11use crate::Range;
13fn find_word_boundary(slice: RopeSlice, mut pos: usize, direction: Direction, long: bool) -> usize {
14 use CharCategory::{Eol, Whitespace};
16 let iter = match direction {
17 Direction::Forward => slice.chars_at(pos),
18 Direction::Backward => {
19 let mut iter = slice.chars_at(pos);
20 iter.reverse();
21 iter
22 }
23 };
25 let mut prev_category = match direction {
26 Direction::Forward if pos == 0 => Whitespace,
27 Direction::Forward => categorize_char(slice.char(pos - 1)),
28 Direction::Backward if pos == slice.len_chars() => Whitespace,
29 Direction::Backward => categorize_char(slice.char(pos)),
30 };
32 for ch in iter {
33 match categorize_char(ch) {
34 Eol | Whitespace => return pos,
35 category => {
36 if !long && category != prev_category && pos != 0 && pos != slice.len_chars() {
37 return pos;
38 } else {
39 match direction {
40 Direction::Forward => pos += 1,
41 Direction::Backward => pos = pos.saturating_sub(1),
42 }
43 prev_category = category;
44 }
45 }
46 }
47 }
49 pos
52#[derive(Copy, Clone, PartialEq, Eq, Debug)]
53pub enum TextObject {
54 Around,
55 Inside,
58impl Display for TextObject {
59 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
60 f.write_str(match self {
61 Self::Around => "around",
62 Self::Inside => "inside",
63 })
64 }
67// count doesn't do anything yet
68pub fn textobject_word(
69 slice: RopeSlice,
70 range: Range,
71 textobject: TextObject,
72 _count: usize,
73 long: bool,
74) -> Range {
75 let pos = range.cursor(slice);
77 let word_start = find_word_boundary(slice, pos, Direction::Backward, long);
78 let word_end = match slice.get_char(pos).map(categorize_char) {
79 None | Some(CharCategory::Whitespace | CharCategory::Eol) => pos,
80 _ => find_word_boundary(slice, pos + 1, Direction::Forward, long),
81 };
83 // Special case.
84 if word_start == word_end {
85 return Range::new(word_start, word_end);
86 }
88 match textobject {
89 TextObject::Inside => Range::new(word_start, word_end),
90 TextObject::Around => {
91 let whitespace_count_right = slice
92 .chars_at(word_end)
93 .take_while(|c| char_is_whitespace(*c))
94 .count();
96 if whitespace_count_right > 0 {
97 Range::new(word_start, word_end + whitespace_count_right)
98 } else {
99 let whitespace_count_left = {
100 let mut iter = slice.chars_at(word_start);
101 iter.reverse();
102 iter.take_while(|c| char_is_whitespace(*c)).count()
103 };
104 Range::new(word_start - whitespace_count_left, word_end)
105 }
106 }
107 }
110pub fn textobject_surround(
111 slice: RopeSlice,
112 range: Range,
113 textobject: TextObject,
114 ch: char,
115 count: usize,
116) -> Range {
117 surround::find_nth_pairs_pos(slice, ch, range, count)
118 .map(|(anchor, head)| match textobject {
119 TextObject::Inside => Range::new(next_grapheme_boundary(slice, anchor), head),
120 TextObject::Around => Range::new(anchor, next_grapheme_boundary(slice, head)),
121 })
122 .unwrap_or(range)
125/// Transform the given range to select text objects based on tree-sitter.
126/// `object_name` is a query capture base name like "function", "class", etc.
127/// `slice_tree` is the tree-sitter node corresponding to given text slice.
128pub fn textobject_treesitter(
129 slice: RopeSlice,
130 range: Range,
131 textobject: TextObject,
132 object_name: &str,
133 slice_tree: Node,
134 lang_config: &LanguageConfiguration,
135 _count: usize,
136) -> Range {
137 let get_range = move || -> Option<Range> {
138 let byte_pos = slice.char_to_byte(range.cursor(slice));
140 let capture_name = format!("{}.{}", object_name, textobject); // eg. function.inner
141 let mut cursor = QueryCursor::new();
142 let node = lang_config
143 .textobject_query()?
144 .capture_nodes(&capture_name, slice_tree, slice, &mut cursor)?
145 .filter(|node| node.byte_range().contains(&byte_pos))
146 .min_by_key(|node| node.byte_range().len())?;
148 let len = slice.len_bytes();
149 let start_byte = node.start_byte();
150 let end_byte = node.end_byte();
151 if start_byte >= len || end_byte >= len {
152 return None;
153 }
155 let start_char = slice.byte_to_char(start_byte);
156 let end_char = slice.byte_to_char(end_byte);
158 Some(Range::new(start_char, end_char))
159 };
160 get_range().unwrap_or(range)
163#[cfg(test)]
164mod test {
165 use super::TextObject::*;
166 use super::*;
168 use crate::Range;
169 use ropey::Rope;
171 #[test]
172 fn test_textobject_word() {
173 // (text, [(char position, textobject, final range), ...])
174 let tests = &[
175 (
176 "cursor at beginning of doc",
177 vec![(0, Inside, (0, 6)), (0, Around, (0, 7))],
178 ),
179 (
180 "cursor at middle of word",
181 vec![
182 (13, Inside, (10, 16)),
183 (10, Inside, (10, 16)),
184 (15, Inside, (10, 16)),
185 (13, Around, (10, 17)),
186 (10, Around, (10, 17)),
187 (15, Around, (10, 17)),
188 ],
189 ),
190 (
191 "cursor between word whitespace",
192 vec![(6, Inside, (6, 6)), (6, Around, (6, 6))],
193 ),
194 (
195 "cursor on word before newline\n",
196 vec![
197 (22, Inside, (22, 29)),
198 (28, Inside, (22, 29)),
199 (25, Inside, (22, 29)),
200 (22, Around, (21, 29)),
201 (28, Around, (21, 29)),
202 (25, Around, (21, 29)),
203 ],
204 ),
205 (
206 "cursor on newline\nnext line",
207 vec![(17, Inside, (17, 17)), (17, Around, (17, 17))],
208 ),
209 (
210 "cursor on word after newline\nnext line",
211 vec![
212 (29, Inside, (29, 33)),
213 (30, Inside, (29, 33)),
214 (32, Inside, (29, 33)),
215 (29, Around, (29, 34)),
216 (30, Around, (29, 34)),
217 (32, Around, (29, 34)),
218 ],
219 ),
220 (
221 "cursor on #$%:;* punctuation",
222 vec![
223 (13, Inside, (10, 16)),
224 (10, Inside, (10, 16)),
225 (15, Inside, (10, 16)),
226 (13, Around, (10, 17)),
227 (10, Around, (10, 17)),
228 (15, Around, (10, 17)),
229 ],
230 ),
231 (
232 "cursor on punc%^#$:;.tuation",
233 vec![
234 (14, Inside, (14, 21)),
235 (20, Inside, (14, 21)),
236 (17, Inside, (14, 21)),
237 (14, Around, (14, 21)),
238 (20, Around, (14, 21)),
239 (17, Around, (14, 21)),
240 ],
241 ),
242 (
243 "cursor in extra whitespace",
244 vec![
245 (9, Inside, (9, 9)),
246 (10, Inside, (10, 10)),
247 (11, Inside, (11, 11)),
248 (9, Around, (9, 9)),
249 (10, Around, (10, 10)),
250 (11, Around, (11, 11)),
251 ],
252 ),
253 (
254 "cursor on word with extra whitespace",
255 vec![(11, Inside, (10, 14)), (11, Around, (10, 17))],
256 ),
257 (
258 "cursor at end with extra whitespace",
259 vec![(28, Inside, (27, 37)), (28, Around, (24, 37))],
260 ),
261 (
262 "cursor at end of doc",
263 vec![(19, Inside, (17, 20)), (19, Around, (16, 20))],
264 ),
265 ];
267 for (sample, scenario) in tests {
268 let doc = Rope::from(*sample);
269 let slice = doc.slice(..);
270 for &case in scenario {
271 let (pos, objtype, expected_range) = case;
272 // cursor is a single width selection
273 let range = Range::new(pos, pos + 1);
274 let result = textobject_word(slice, range, objtype, 1, false);
275 assert_eq!(
276 result,
277 expected_range.into(),
278 "\nCase failed: {:?} - {:?}",
279 sample,
280 case
281 );
282 }
283 }
284 }
286 #[test]
287 fn test_textobject_surround() {
288 // (text, [(cursor position, textobject, final range, surround char, count), ...])
289 let tests = &[
290 (
291 "simple (single) surround pairs",
292 vec![
293 (3, Inside, (3, 3), '(', 1),
294 (7, Inside, (8, 14), ')', 1),
295 (10, Inside, (8, 14), '(', 1),
296 (14, Inside, (8, 14), ')', 1),
297 (3, Around, (3, 3), '(', 1),
298 (7, Around, (7, 15), ')', 1),
299 (10, Around, (7, 15), '(', 1),
300 (14, Around, (7, 15), ')', 1),
301 ],
302 ),
303 (
304 "samexx 'single' surround pairs",
305 vec![
306 (3, Inside, (3, 3), '\'', 1),
307 (7, Inside, (7, 7), '\'', 1),
308 (10, Inside, (8, 14), '\'', 1),
309 (14, Inside, (14, 14), '\'', 1),
310 (3, Around, (3, 3), '\'', 1),
311 (7, Around, (7, 7), '\'', 1),
312 (10, Around, (7, 15), '\'', 1),
313 (14, Around, (14, 14), '\'', 1),
314 ],
315 ),
316 (
317 "(nested (surround (pairs)) 3 levels)",
318 vec![
319 (0, Inside, (1, 35), '(', 1),
320 (6, Inside, (1, 35), ')', 1),
321 (8, Inside, (9, 25), '(', 1),
322 (8, Inside, (9, 35), ')', 2),
323 (20, Inside, (9, 25), '(', 2),
324 (20, Inside, (1, 35), ')', 3),
325 (0, Around, (0, 36), '(', 1),
326 (6, Around, (0, 36), ')', 1),
327 (8, Around, (8, 26), '(', 1),
328 (8, Around, (8, 36), ')', 2),
329 (20, Around, (8, 26), '(', 2),
330 (20, Around, (0, 36), ')', 3),
331 ],
332 ),
333 (
334 "(mixed {surround [pair] same} line)",
335 vec![
336 (2, Inside, (1, 34), '(', 1),
337 (9, Inside, (8, 28), '{', 1),
338 (18, Inside, (18, 22), '[', 1),
339 (2, Around, (0, 35), '(', 1),
340 (9, Around, (7, 29), '{', 1),
341 (18, Around, (17, 23), '[', 1),
342 ],
343 ),
344 (
345 "(stepped (surround) pairs (should) skip)",
346 vec![(22, Inside, (1, 39), '(', 1), (22, Around, (0, 40), '(', 1)],
347 ),
348 (
349 "[surround pairs{\non different]\nlines}",
350 vec![
351 (7, Inside, (1, 29), '[', 1),
352 (15, Inside, (16, 36), '{', 1),
353 (7, Around, (0, 30), '[', 1),
354 (15, Around, (15, 37), '{', 1),
355 ],
356 ),
357 ];
359 for (sample, scenario) in tests {
360 let doc = Rope::from(*sample);
361 let slice = doc.slice(..);
362 for &case in scenario {
363 let (pos, objtype, expected_range, ch, count) = case;
364 let result = textobject_surround(slice, Range::point(pos), objtype, ch, count);
365 assert_eq!(
366 result,
367 expected_range.into(),
368 "\nCase failed: {:?} - {:?}",
369 sample,
370 case
371 );
372 }
373 }
374 }