Skip to main content

typ_buffer/
word.rs

1//! Word-wise motion.
2//!
3//! Everything here indexes graphemes, never bytes or chars, so `Ctrl+Left`
4//! through CJK or emoji moves in the same units the cursor does.
5
6use unicode_segmentation::UnicodeSegmentation;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9enum Class {
10    Whitespace,
11    Word,
12    Punctuation,
13}
14
15/// Punctuation is its own class rather than being lumped in with words, so
16/// `foo::bar` is four stops instead of one — which is what makes word motion
17/// useful in code rather than only in prose.
18fn class(grapheme: &str) -> Class {
19    let Some(c) = grapheme.chars().next() else {
20        return Class::Whitespace;
21    };
22    if c.is_whitespace() {
23        Class::Whitespace
24    } else if c.is_alphanumeric() || c == '_' {
25        Class::Word
26    } else {
27        Class::Punctuation
28    }
29}
30
31fn classes(line: &str) -> Vec<Class> {
32    line.graphemes(true).map(class).collect()
33}
34
35/// The next boundary at or after `col`: skip whitespace, then consume one run
36/// of like-classed graphemes.
37pub fn next_word_boundary(line: &str, col: usize) -> usize {
38    let classes = classes(line);
39    let len = classes.len();
40    let mut i = col.min(len);
41
42    while i < len && classes[i] == Class::Whitespace {
43        i += 1;
44    }
45    if i >= len {
46        return len;
47    }
48    let run = classes[i];
49    while i < len && classes[i] == run {
50        i += 1;
51    }
52    i
53}
54
55/// The previous boundary at or before `col`, mirroring `next_word_boundary`.
56pub fn previous_word_boundary(line: &str, col: usize) -> usize {
57    let classes = classes(line);
58    let mut i = col.min(classes.len());
59
60    while i > 0 && classes[i - 1] == Class::Whitespace {
61        i -= 1;
62    }
63    if i == 0 {
64        return 0;
65    }
66    let run = classes[i - 1];
67    while i > 0 && classes[i - 1] == run {
68        i -= 1;
69    }
70    i
71}
72
73/// The run containing `col`, as `(start, end)` grapheme indices.
74///
75/// A cursor sitting immediately after a word counts as being on it, which is
76/// what makes double-click-at-the-end select what the user meant.
77pub fn word_at(line: &str, col: usize) -> Option<(usize, usize)> {
78    let classes = classes(line);
79    let len = classes.len();
80    if len == 0 {
81        return None;
82    }
83    let probe = if col < len { col } else { len - 1 };
84    let target = classes[probe];
85    if target == Class::Whitespace {
86        return None;
87    }
88
89    let mut start = probe;
90    while start > 0 && classes[start - 1] == target {
91        start -= 1;
92    }
93    let mut end = probe;
94    while end < len && classes[end] == target {
95        end += 1;
96    }
97    Some((start, end))
98}