1use std::ops::Range;
9
10#[derive(Clone, Copy, PartialEq, Eq, Debug)]
11enum Class {
12 Blank,
13 Word,
14 Punct,
15}
16
17fn class(c: char, big: bool) -> Class {
18 if c.is_whitespace() {
19 Class::Blank
20 } else if !(big || c.is_alphanumeric() || c == '_') {
21 Class::Punct
22 } else {
23 Class::Word
24 }
25}
26
27#[derive(Clone, Copy, Debug, PartialEq, Eq)]
29pub enum Direction {
30 Forward,
31 Backward,
32}
33
34impl Direction {
35 pub fn flip(self) -> Self {
36 match self {
37 Direction::Forward => Direction::Backward,
38 Direction::Backward => Direction::Forward,
39 }
40 }
41}
42
43#[derive(Clone, Copy, Debug, PartialEq, Eq)]
45pub enum TextObjectKind {
46 Inner,
47 Around,
48}
49
50fn chars_from(content: &str, offset: usize) -> impl Iterator<Item = (usize, char)> + '_ {
51 content[offset..]
52 .char_indices()
53 .map(move |(i, c)| (offset + i, c))
54}
55
56fn char_len_at(content: &str, offset: usize) -> usize {
57 content[offset..].chars().next().map_or(0, char::len_utf8)
58}
59
60fn prev_char_offset(content: &str, offset: usize) -> usize {
61 content[..offset]
62 .char_indices()
63 .next_back()
64 .map_or(offset, |(i, _)| i)
65}
66
67pub fn line_start(content: &str, offset: usize) -> usize {
68 content[..offset].rfind('\n').map_or(0, |i| i + 1)
69}
70
71pub fn line_end_exclusive(content: &str, offset: usize) -> usize {
72 content[offset..]
73 .find('\n')
74 .map_or(content.len(), |i| offset + i)
75}
76
77pub fn line_end(content: &str, offset: usize) -> usize {
78 let start = line_start(content, offset);
79 let end = line_end_exclusive(content, offset);
80 content[start..end]
81 .char_indices()
82 .next_back()
83 .map_or(start, |(i, _)| start + i)
84}
85
86pub fn first_nonblank(content: &str, offset: usize) -> usize {
87 let start = line_start(content, offset);
88 let end = line_end_exclusive(content, offset);
89 content[start..end]
90 .char_indices()
91 .find(|&(_, c)| !c.is_whitespace())
92 .map_or(start, |(i, _)| start + i)
93}
94
95pub fn word_forward(content: &str, offset: usize, big: bool) -> usize {
96 let mut chars = chars_from(content, offset).peekable();
97 let Some(&(_, first)) = chars.peek() else {
98 return offset;
99 };
100
101 let start = class(first, big);
102 if start != Class::Blank {
103 while let Some(&(_, c)) = chars.peek() {
104 if class(c, big) == start && c != '\n' {
105 chars.next();
106 } else {
107 break;
108 }
109 }
110 }
111
112 while let Some(&(_, c)) = chars.peek() {
114 if c == '\n' {
115 chars.next();
116 if let Some(&(pos, '\n')) = chars.peek() {
117 return pos;
118 }
119 } else if c.is_whitespace() {
120 chars.next();
121 } else {
122 break;
123 }
124 }
125
126 chars.peek().map_or(content.len(), |&(pos, _)| pos)
127}
128
129pub fn word_backward(content: &str, offset: usize, big: bool) -> usize {
130 let mut chars = content[..offset].char_indices().rev().peekable();
131
132 while let Some(&(_, c)) = chars.peek() {
133 if c.is_whitespace() {
134 chars.next();
135 } else {
136 break;
137 }
138 }
139
140 let Some(&(mut start, c)) = chars.peek() else {
141 return 0;
142 };
143 let target = class(c, big);
144 while let Some(&(i, c)) = chars.peek() {
145 if class(c, big) == target && c != '\n' {
146 start = i;
147 chars.next();
148 } else {
149 break;
150 }
151 }
152 start
153}
154
155pub fn word_end(content: &str, offset: usize, big: bool) -> usize {
156 let mut chars = chars_from(content, offset).skip(1).peekable();
157
158 while let Some(&(_, c)) = chars.peek() {
159 if c.is_whitespace() {
160 chars.next();
161 } else {
162 break;
163 }
164 }
165
166 let Some(&(mut end, c)) = chars.peek() else {
167 return prev_char_offset(content, content.len()).max(offset);
168 };
169 let target = class(c, big);
170 while let Some(&(i, c)) = chars.peek() {
171 if class(c, big) == target && c != '\n' {
172 end = i;
173 chars.next();
174 } else {
175 break;
176 }
177 }
178 end
179}
180
181pub fn find_char(
182 content: &str,
183 offset: usize,
184 target: char,
185 direction: Direction,
186 till: bool,
187) -> Option<usize> {
188 match direction {
189 Direction::Forward => {
190 let start = offset + char_len_at(content, offset);
191 let end = line_end_exclusive(content, offset);
192 let found = content
193 .get(start..end)?
194 .char_indices()
195 .find(|&(_, c)| c == target)
196 .map(|(i, _)| start + i)?;
197 Some(if till {
198 prev_char_offset(content, found)
199 } else {
200 found
201 })
202 }
203 Direction::Backward => {
204 let start = line_start(content, offset);
205 let found = content
206 .get(start..offset)?
207 .char_indices()
208 .rev()
209 .find(|&(_, c)| c == target)
210 .map(|(i, _)| start + i)?;
211 Some(if till {
212 found + char_len_at(content, found)
213 } else {
214 found
215 })
216 }
217 }
218}
219
220fn is_empty_line(content: &str, line_start: usize) -> bool {
221 line_start >= content.len() || content[line_start..].starts_with('\n')
222}
223
224fn next_line_start(content: &str, offset: usize) -> usize {
225 content[offset..]
226 .find('\n')
227 .map_or(content.len(), |i| offset + i + 1)
228}
229
230fn prev_line_start(content: &str, line_start: usize) -> usize {
231 if line_start == 0 {
232 return 0;
233 }
234 content[..line_start - 1].rfind('\n').map_or(0, |i| i + 1)
235}
236
237pub fn paragraph_forward(content: &str, offset: usize) -> usize {
238 let mut line = next_line_start(content, offset);
239 while line < content.len() {
240 if is_empty_line(content, line) {
241 return line;
242 }
243 line = next_line_start(content, line);
244 }
245 content.len()
246}
247
248pub fn paragraph_backward(content: &str, offset: usize) -> usize {
249 let mut line = line_start(content, offset);
250 while line > 0 {
251 line = prev_line_start(content, line);
252 if is_empty_line(content, line) {
253 return line;
254 }
255 }
256 0
257}
258
259const PAIRS: [(char, char); 3] = [('(', ')'), ('[', ']'), ('{', '}')];
260
261pub fn matching_pair(content: &str, offset: usize) -> Option<usize> {
262 let end = line_end_exclusive(content, offset);
263 let (bracket_offset, bracket) = content
264 .get(offset..end)?
265 .char_indices()
266 .map(|(i, c)| (offset + i, c))
267 .find(|&(_, c)| "()[]{}".contains(c))?;
268
269 let (open, close, forward) = PAIRS.iter().find_map(|&(open, close)| {
270 if bracket == open {
271 Some((open, close, true))
272 } else if bracket == close {
273 Some((open, close, false))
274 } else {
275 None
276 }
277 })?;
278
279 let mut depth = 0i32;
280 if forward {
281 for (i, c) in chars_from(content, bracket_offset) {
282 depth += (c == open) as i32 - (c == close) as i32;
283 if depth == 0 {
284 return Some(i);
285 }
286 }
287 } else {
288 for (i, c) in content[..bracket_offset + 1].char_indices().rev() {
289 depth += (c == close) as i32 - (c == open) as i32;
290 if depth == 0 {
291 return Some(i);
292 }
293 }
294 }
295 None
296}
297
298fn char_at(content: &str, offset: usize) -> Option<char> {
299 content[offset..].chars().next()
300}
301
302pub fn text_object(
303 content: &str,
304 offset: usize,
305 object: char,
306 kind: TextObjectKind,
307) -> Option<Range<usize>> {
308 let around = matches!(kind, TextObjectKind::Around);
309 match object {
310 'w' => Some(word_object(content, offset, false, around)),
311 'W' => Some(word_object(content, offset, true, around)),
312 '"' | '\'' | '`' => quote_object(content, offset, object, around),
313 '(' | ')' | 'b' => pair_object(content, offset, '(', ')', around),
314 '[' | ']' => pair_object(content, offset, '[', ']', around),
315 '{' | '}' | 'B' => pair_object(content, offset, '{', '}', around),
316 '<' | '>' => pair_object(content, offset, '<', '>', around),
317 _ => None,
318 }
319}
320
321fn word_object(content: &str, offset: usize, big: bool, around: bool) -> Range<usize> {
322 let Some(cursor) = char_at(content, offset) else {
323 return offset..offset;
324 };
325 let target = class(cursor, big);
326
327 let mut start = offset;
328 loop {
329 let prev = prev_char_offset(content, start);
330 match char_at(content, prev) {
331 Some(c) if prev != start && c != '\n' && class(c, big) == target => start = prev,
332 _ => break,
333 }
334 }
335
336 let mut end = offset;
337 while let Some(c) = char_at(content, end) {
338 if c == '\n' || class(c, big) != target {
339 break;
340 }
341 end += c.len_utf8();
342 }
343
344 if !around || target == Class::Blank {
345 return start..end;
346 }
347
348 let mut around_end = end;
349 while let Some(c) = char_at(content, around_end).filter(|&c| c == ' ' || c == '\t') {
350 around_end += c.len_utf8();
351 }
352 if around_end > end {
353 return start..around_end;
354 }
355
356 let mut around_start = start;
357 loop {
358 let prev = prev_char_offset(content, around_start);
359 match char_at(content, prev) {
360 Some(c) if prev != around_start && (c == ' ' || c == '\t') => around_start = prev,
361 _ => break,
362 }
363 }
364 around_start..end
365}
366
367fn quote_object(content: &str, offset: usize, quote: char, around: bool) -> Option<Range<usize>> {
368 let start = line_start(content, offset);
369 let end = line_end_exclusive(content, offset);
370 let quotes: Vec<usize> = content[start..end]
371 .char_indices()
372 .filter(|&(_, c)| c == quote)
373 .map(|(i, _)| start + i)
374 .collect();
375
376 let (open, close) = quotes
377 .chunks_exact(2)
378 .map(|pair| (pair[0], pair[1]))
379 .find(|&(_, close)| offset <= close)?;
380
381 if around {
382 Some(open..close + quote.len_utf8())
383 } else {
384 Some(open + quote.len_utf8()..close)
385 }
386}
387
388fn pair_object(
389 content: &str,
390 offset: usize,
391 open: char,
392 close: char,
393 around: bool,
394) -> Option<Range<usize>> {
395 let open_pos = enclosing_open(content, offset, open, close)?;
396 let close_pos = matching_close(content, open_pos, open, close)?;
397 if around {
398 Some(open_pos..close_pos + close.len_utf8())
399 } else {
400 Some(open_pos + open.len_utf8()..close_pos)
401 }
402}
403
404fn enclosing_open(content: &str, offset: usize, open: char, close: char) -> Option<usize> {
405 let end = offset + char_len_at(content, offset);
406 let mut depth = 0i32;
407 for (i, c) in content[..end].char_indices().rev() {
408 if c == close && i != offset {
409 depth += 1;
410 } else if c == open {
411 if depth == 0 {
412 return Some(i);
413 }
414 depth -= 1;
415 }
416 }
417 None
418}
419
420fn matching_close(content: &str, open_pos: usize, open: char, close: char) -> Option<usize> {
421 let mut depth = 0i32;
422 for (i, c) in chars_from(content, open_pos) {
423 depth += (c == open) as i32 - (c == close) as i32;
424 if depth == 0 {
425 return Some(i);
426 }
427 }
428 None
429}
430
431pub fn doc_start(content: &str) -> usize {
432 first_nonblank(content, 0)
433}
434
435pub fn nth_char_right(content: &str, offset: usize, n: usize) -> usize {
436 let end = line_end_exclusive(content, offset);
437 (0..n).fold(offset, |pos, _| {
438 if pos >= end {
439 pos
440 } else {
441 pos + char_len_at(content, pos)
442 }
443 })
444}
445
446pub fn nth_char_left(content: &str, offset: usize, n: usize) -> usize {
447 let start = line_start(content, offset);
448 (0..n).fold(offset, |pos, _| {
449 if pos <= start {
450 pos
451 } else {
452 prev_char_offset(content, pos)
453 }
454 })
455}
456
457pub fn line_down(content: &str, offset: usize, n: usize) -> usize {
458 let mut start = line_start(content, offset);
459 for _ in 0..n {
460 match content[start..].find('\n') {
461 Some(index) if start + index + 1 < content.len() => start += index + 1,
462 _ => break,
463 }
464 }
465 start
466}
467
468pub fn line_up(content: &str, offset: usize, n: usize) -> usize {
469 (0..n).fold(line_start(content, offset), |start, _| {
470 if start == 0 {
471 0
472 } else {
473 content[..start - 1].rfind('\n').map_or(0, |i| i + 1)
474 }
475 })
476}
477
478pub fn doc_end(content: &str) -> usize {
479 let trimmed = content.trim_end_matches('\n');
480 let last_line = line_start(content, trimmed.len());
481 first_nonblank(content, last_line)
482}
483
484pub fn goto_line(content: &str, line: usize) -> usize {
485 let mut start = 0;
486 for _ in 1..line.max(1) {
487 match content[start..].find('\n') {
488 Some(index) if start + index + 1 < content.len() => start += index + 1,
489 _ => break,
490 }
491 }
492 first_nonblank(content, start)
493}
494
495#[cfg(test)]
496mod tests {
497 use super::*;
498
499 #[test]
500 fn word_forward_word_and_punct_classes() {
501 let text = "foo.bar baz";
503 assert_eq!(word_forward(text, 0, false), 3); assert_eq!(word_forward(text, 3, false), 4); assert_eq!(word_forward(text, 4, false), 8); assert_eq!(word_forward(text, 0, true), 8); }
509
510 #[test]
511 fn word_forward_stops_on_empty_line() {
512 let text = "ab\n\ncd";
513 assert_eq!(word_forward(text, 0, false), 3); assert_eq!(word_forward(text, 3, false), 4); }
516
517 #[test]
518 fn word_forward_over_emoji() {
519 let text = "a😀 b";
522 assert_eq!(word_forward(text, 0, false), 1); assert_eq!(word_forward(text, 1, false), 6); }
525
526 #[test]
527 fn word_backward_basics() {
528 let text = "foo bar";
529 assert_eq!(word_backward(text, 7, false), 4); assert_eq!(word_backward(text, 4, false), 0); assert_eq!(word_backward(text, 0, false), 0);
532 }
533
534 #[test]
535 fn word_end_basics() {
536 let text = "foo bar";
537 assert_eq!(word_end(text, 0, false), 2); assert_eq!(word_end(text, 2, false), 6); }
540
541 #[test]
542 fn line_motions() {
543 let text = " ab\ncd\n";
544 assert_eq!(line_start(text, 3), 0);
545 assert_eq!(first_nonblank(text, 3), 2); assert_eq!(line_end(text, 0), 3); assert_eq!(line_start(text, 6), 5);
548 assert_eq!(line_end(text, 6), 6); }
550
551 #[test]
552 fn line_end_on_empty_line() {
553 let text = "a\n\nb";
554 assert_eq!(line_end(text, 2), 2); assert_eq!(first_nonblank(text, 2), 2);
556 }
557
558 #[test]
559 fn find_char_forward_and_till() {
560 let text = "abcxdef";
561 assert_eq!(find_char(text, 0, 'x', Direction::Forward, false), Some(3));
562 assert_eq!(find_char(text, 0, 'x', Direction::Forward, true), Some(2)); assert_eq!(find_char(text, 0, 'z', Direction::Forward, false), None);
564 assert_eq!(find_char("ab\nxc", 0, 'x', Direction::Forward, false), None);
566 }
567
568 #[test]
569 fn find_char_backward_and_till() {
570 let text = "abcxdef";
571 assert_eq!(find_char(text, 6, 'x', Direction::Backward, false), Some(3));
572 assert_eq!(find_char(text, 6, 'x', Direction::Backward, true), Some(4)); assert_eq!(find_char(text, 6, 'z', Direction::Backward, false), None);
574 }
575
576 #[test]
577 fn paragraph_motions() {
578 let text = "a\n\nb\n\nc";
579 assert_eq!(paragraph_forward(text, 0), 2); assert_eq!(paragraph_forward(text, 3), 5); assert_eq!(paragraph_forward(text, 6), text.len());
582 assert_eq!(paragraph_backward(text, 6), 5);
583 assert_eq!(paragraph_backward(text, 3), 2);
584 assert_eq!(paragraph_backward(text, 0), 0);
585 }
586
587 #[test]
588 fn matching_pair_all_kinds() {
589 assert_eq!(matching_pair("(a[b]c)", 0), Some(6));
590 assert_eq!(matching_pair("(a[b]c)", 6), Some(0));
591 assert_eq!(matching_pair("(a[b]c)", 2), Some(4)); assert_eq!(matching_pair("{ }", 0), Some(2));
593 assert_eq!(matching_pair("xy(z)", 0), Some(4));
595 assert_eq!(matching_pair("no brackets", 0), None);
596 }
597
598 #[test]
599 fn doc_motions() {
600 let text = " first\nmid\n last\n";
601 assert_eq!(doc_start(text), 2); assert_eq!(doc_end(text), 14); }
604
605 #[test]
606 fn text_object_quotes() {
607 let text = "say \"hello world\" now";
608 let inner = text_object(text, 7, '"', TextObjectKind::Inner).unwrap();
609 assert_eq!(&text[inner], "hello world");
610 let around = text_object(text, 7, '"', TextObjectKind::Around).unwrap();
611 assert_eq!(&text[around], "\"hello world\"");
612 let inner = text_object(text, 0, '"', TextObjectKind::Inner).unwrap();
614 assert_eq!(&text[inner], "hello world");
615 }
616
617 #[test]
618 fn text_object_nested_parens() {
619 let text = "a(b(c)d)e";
620 assert_eq!(
621 &text[text_object(text, 4, '(', TextObjectKind::Inner).unwrap()],
622 "c"
623 );
624 assert_eq!(
625 &text[text_object(text, 2, '(', TextObjectKind::Inner).unwrap()],
626 "b(c)d"
627 );
628 assert_eq!(
629 &text[text_object(text, 2, '(', TextObjectKind::Around).unwrap()],
630 "(b(c)d)"
631 );
632 }
633
634 #[test]
635 fn text_object_inner_and_around_word() {
636 let text = "foo bar baz";
637 assert_eq!(
638 &text[text_object(text, 5, 'w', TextObjectKind::Inner).unwrap()],
639 "bar"
640 );
641 assert_eq!(
642 &text[text_object(text, 5, 'w', TextObjectKind::Around).unwrap()],
643 "bar "
644 );
645 }
646
647 #[test]
648 fn text_object_missing_pair_is_none() {
649 assert!(text_object("no quotes here", 0, '"', TextObjectKind::Inner).is_none());
650 }
651
652 #[test]
653 fn goto_line_clamps() {
654 let text = "one\ntwo\nthree\n";
655 assert_eq!(goto_line(text, 1), 0); assert_eq!(goto_line(text, 2), 4); assert_eq!(goto_line(text, 3), 8); assert_eq!(goto_line(text, 99), 8); }
660
661 #[test]
662 fn nth_char_moves_and_clamps_on_the_line() {
663 let text = "abc\ndef";
664 assert_eq!(nth_char_right(text, 0, 2), 2); assert_eq!(nth_char_right(text, 0, 9), 3); assert_eq!(nth_char_left(text, 2, 1), 1); assert_eq!(nth_char_left(text, 2, 9), 0); }
669
670 #[test]
671 fn nth_char_steps_over_multibyte() {
672 let text = "aéb"; assert_eq!(nth_char_right(text, 0, 1), 1); assert_eq!(nth_char_right(text, 0, 2), 3); assert_eq!(nth_char_left(text, 3, 1), 1); }
677
678 #[test]
679 fn line_down_and_up_clamp() {
680 let text = "one\ntwo\nthree";
681 assert_eq!(line_down(text, 0, 1), 4); assert_eq!(line_down(text, 0, 2), 8); assert_eq!(line_down(text, 0, 9), 8); assert_eq!(line_up(text, 8, 1), 4); assert_eq!(line_up(text, 8, 9), 0); }
687}