Skip to main content

basalt_tui/note_editor/
motion.rs

1//! Pure vim-style motions over document text.
2//!
3//! Every function takes a byte `offset` into `content` and returns a new byte
4//! offset; results stay within `0..=content.len()` and always land on a char
5//! boundary, so a returned offset is safe to slice at. Functions that can fail
6//! to find a target return `Option` instead of clamping.
7
8use 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/// Direction a find (`f`/`F`/`t`/`T`) scans.
28#[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/// Whether a text object spans its delimiters (`a`) or just the inside (`i`).
44#[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    // An empty line counts as a word.
113    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        // foo.bar baz -> stops at '.', then 'bar', then 'baz'
502        let text = "foo.bar baz";
503        assert_eq!(word_forward(text, 0, false), 3); // '.'
504        assert_eq!(word_forward(text, 3, false), 4); // 'bar'
505        assert_eq!(word_forward(text, 4, false), 8); // 'baz'
506                                                     // WORD mode skips the punctuation as part of the word.
507        assert_eq!(word_forward(text, 0, true), 8); // 'baz'
508    }
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); // the empty line
514        assert_eq!(word_forward(text, 3, false), 4); // 'cd'
515    }
516
517    #[test]
518    fn word_forward_over_emoji() {
519        // "a😀 b": a=0, emoji=1..5, space=5, b=6. The emoji is its own
520        // (punctuation) word; stepping past it must land on the right byte.
521        let text = "a😀 b";
522        assert_eq!(word_forward(text, 0, false), 1); // onto the emoji
523        assert_eq!(word_forward(text, 1, false), 6); // past the 4-byte emoji, onto 'b'
524    }
525
526    #[test]
527    fn word_backward_basics() {
528        let text = "foo bar";
529        assert_eq!(word_backward(text, 7, false), 4); // start of 'bar'
530        assert_eq!(word_backward(text, 4, false), 0); // start of 'foo'
531        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); // 'o' end of foo
538        assert_eq!(word_end(text, 2, false), 6); // 'r' end of bar
539    }
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); // 'a'
546        assert_eq!(line_end(text, 0), 3); // 'b'
547        assert_eq!(line_start(text, 6), 5);
548        assert_eq!(line_end(text, 6), 6); // 'd'
549    }
550
551    #[test]
552    fn line_end_on_empty_line() {
553        let text = "a\n\nb";
554        assert_eq!(line_end(text, 2), 2); // stays on the empty line
555        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)); // 't' stops before
563        assert_eq!(find_char(text, 0, 'z', Direction::Forward, false), None);
564        // does not cross the newline
565        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)); // 'T' stops after
573        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); // empty line after 'a'
580        assert_eq!(paragraph_forward(text, 3), 5); // empty line after 'b'
581        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)); // '[' -> ']'
592        assert_eq!(matching_pair("{ }", 0), Some(2));
593        // finds the first bracket at or after the cursor on the line
594        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); // 'f'
602        assert_eq!(doc_end(text), 14); // 'l' of "last"
603    }
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        // works from before the quotes too (vim searches forward on the line)
613        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); // 'one'
656        assert_eq!(goto_line(text, 2), 4); // 'two'
657        assert_eq!(goto_line(text, 3), 8); // 'three'
658        assert_eq!(goto_line(text, 99), 8); // clamps to the last line
659    }
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); // a -> c
665        assert_eq!(nth_char_right(text, 0, 9), 3); // clamps just past the last char
666        assert_eq!(nth_char_left(text, 2, 1), 1); // c -> b
667        assert_eq!(nth_char_left(text, 2, 9), 0); // clamps at the line start
668    }
669
670    #[test]
671    fn nth_char_steps_over_multibyte() {
672        let text = "aéb"; // é is two bytes
673        assert_eq!(nth_char_right(text, 0, 1), 1); // onto 'é'
674        assert_eq!(nth_char_right(text, 0, 2), 3); // past 'é' onto 'b'
675        assert_eq!(nth_char_left(text, 3, 1), 1); // 'b' back onto 'é'
676    }
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); // start of 'two'
682        assert_eq!(line_down(text, 0, 2), 8); // start of 'three'
683        assert_eq!(line_down(text, 0, 9), 8); // clamps at the last line
684        assert_eq!(line_up(text, 8, 1), 4); // back to 'two'
685        assert_eq!(line_up(text, 8, 9), 0); // clamps at the first line
686    }
687}