Skip to main content

twrite_core/
movement.rs

1use std::ops::Range;
2
3use ropey::Rope;
4
5/// Character classification used for word-boundary detection.
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub enum CharKind {
8    /// Whitespace characters (spaces, tabs, newlines).
9    Whitespace,
10    /// Alphanumeric characters and underscore (`_`).
11    Word,
12    /// Punctuation and symbols (`.`, `,`, `(`, `)`, `;`, `+`, etc.).
13    Punctuation,
14}
15
16/// Classifies a character into [`CharKind`].
17pub fn classify_char(c: char) -> CharKind {
18    if c.is_whitespace() {
19        CharKind::Whitespace
20    } else if c.is_alphanumeric() || c == '_' {
21        CharKind::Word
22    } else {
23        CharKind::Punctuation
24    }
25}
26
27/// Finds the start offset of the previous word (or punctuation token) moving backward from `cursor_byte`.
28///
29/// Behavior matches classic editor `Ctrl + Left`:
30/// 1. If preceded by whitespace (non-newline), skips backward across the whitespace.
31/// 2. If preceded by a newline, stops at that line boundary.
32/// 3. Identifies whether the preceding token is a word or punctuation sequence.
33/// 4. Moves backward across consecutive characters of that same kind.
34/// 5. Returns the starting byte offset of that token.
35pub fn find_prev_word_start(text: &Rope, cursor_byte: usize) -> usize {
36    let cursor_byte = cursor_byte.min(text.len_bytes());
37    if cursor_byte == 0 {
38        return 0;
39    }
40
41    let mut char_idx = text.byte_to_char(cursor_byte);
42    if char_idx == 0 {
43        return 0;
44    }
45
46    let prev_c = text.char(char_idx - 1);
47    if prev_c == '\n' {
48        if char_idx >= 2 && text.char(char_idx - 2) == '\r' {
49            return text.char_to_byte(char_idx - 2);
50        }
51        return text.char_to_byte(char_idx - 1);
52    }
53
54    while char_idx > 0 {
55        let c = text.char(char_idx - 1);
56        if c == '\n' || c == '\r' {
57            return text.char_to_byte(char_idx);
58        }
59        if classify_char(c) != CharKind::Whitespace {
60            break;
61        }
62        char_idx -= 1;
63    }
64
65    if char_idx == 0 {
66        return 0;
67    }
68
69    let target_kind = classify_char(text.char(char_idx - 1));
70
71    while char_idx > 0 {
72        let c = text.char(char_idx - 1);
73        if c == '\n' || c == '\r' || classify_char(c) != target_kind {
74            break;
75        }
76        char_idx -= 1;
77    }
78
79    text.char_to_byte(char_idx)
80}
81
82/// Finds the end offset of the current word or next word moving forward from `cursor_byte`.
83///
84/// Behavior matches classic editor `Ctrl + Right`:
85/// 1. If currently at a newline, advances past the newline.
86/// 2. If currently at non-newline whitespace, skips forward across the whitespace.
87/// 3. Identifies whether the current token is a word or punctuation sequence.
88/// 4. Moves forward across consecutive characters of that same kind.
89/// 5. Returns the ending byte offset of that token.
90pub fn find_next_word_end(text: &Rope, cursor_byte: usize) -> usize {
91    let total_bytes = text.len_bytes();
92    let cursor_byte = cursor_byte.min(total_bytes);
93    if cursor_byte >= total_bytes {
94        return total_bytes;
95    }
96
97    let total_chars = text.len_chars();
98    let mut char_idx = text.byte_to_char(cursor_byte);
99    if char_idx >= total_chars {
100        return total_bytes;
101    }
102
103    let c = text.char(char_idx);
104    if c == '\r' {
105        if char_idx + 1 < total_chars && text.char(char_idx + 1) == '\n' {
106            return text.char_to_byte(char_idx + 2);
107        }
108        return text.char_to_byte(char_idx + 1);
109    }
110    if c == '\n' {
111        return text.char_to_byte(char_idx + 1);
112    }
113
114    while char_idx < total_chars {
115        let c = text.char(char_idx);
116        if c == '\n' || c == '\r' {
117            return text.char_to_byte(char_idx);
118        }
119        if classify_char(c) != CharKind::Whitespace {
120            break;
121        }
122        char_idx += 1;
123    }
124
125    if char_idx >= total_chars {
126        return total_bytes;
127    }
128
129    let target_kind = classify_char(text.char(char_idx));
130
131    while char_idx < total_chars {
132        let c = text.char(char_idx);
133        if c == '\n' || c == '\r' || classify_char(c) != target_kind {
134            break;
135        }
136        char_idx += 1;
137    }
138
139    text.char_to_byte(char_idx)
140}
141
142/// Returns the byte offset of the beginning of the line containing `cursor_byte`.
143pub fn find_line_start(text: &Rope, cursor_byte: usize) -> usize {
144    let cursor_byte = cursor_byte.min(text.len_bytes());
145    let char_idx = text.byte_to_char(cursor_byte);
146    let line_idx = text.char_to_line(char_idx);
147    let line_start_char = text.line_to_char(line_idx);
148    text.char_to_byte(line_start_char)
149}
150
151/// Returns the byte offset of the end of the line containing `cursor_byte`,
152/// excluding trailing newline characters (`\r\n` or `\n`).
153pub fn find_line_end(text: &Rope, cursor_byte: usize) -> usize {
154    let cursor_byte = cursor_byte.min(text.len_bytes());
155    let char_idx = text.byte_to_char(cursor_byte);
156    let line_idx = text.char_to_line(char_idx);
157    let line = text.line(line_idx);
158    let mut line_len_chars = line.len_chars();
159
160    if line_len_chars > 0 && line.char(line_len_chars - 1) == '\n' {
161        line_len_chars -= 1;
162        if line_len_chars > 0 && line.char(line_len_chars - 1) == '\r' {
163            line_len_chars -= 1;
164        }
165    }
166
167    let line_start_char = text.line_to_char(line_idx);
168    text.char_to_byte(line_start_char + line_len_chars)
169}
170
171/// Finds the byte range of the word, punctuation token, or whitespace run containing `cursor_byte`.
172///
173/// If `cursor_byte` points to whitespace or a line break immediately following a word or
174/// punctuation token on the same line, the preceding token is selected. Otherwise, the
175/// continuous token of the same [`CharKind`] spanning `cursor_byte` is returned, never
176/// crossing line boundaries.
177pub fn find_word_range_at(text: &Rope, cursor_byte: usize) -> Range<usize> {
178    let total_bytes = text.len_bytes();
179    let cursor_byte = cursor_byte.min(total_bytes);
180    if total_bytes == 0 {
181        return 0..0;
182    }
183
184    let total_chars = text.len_chars();
185    let char_idx = text.byte_to_char(cursor_byte);
186
187    let target_idx = if char_idx >= total_chars {
188        if char_idx > 0 {
189            let prev = text.char(char_idx - 1);
190            if prev != '\n' && prev != '\r' {
191                char_idx - 1
192            } else {
193                return cursor_byte..cursor_byte;
194            }
195        } else {
196            return cursor_byte..cursor_byte;
197        }
198    } else {
199        let curr = text.char(char_idx);
200        if curr == '\n' || curr == '\r' {
201            if char_idx > 0 {
202                let prev = text.char(char_idx - 1);
203                if prev != '\n' && prev != '\r' {
204                    char_idx - 1
205                } else {
206                    return cursor_byte..cursor_byte;
207                }
208            } else {
209                return cursor_byte..cursor_byte;
210            }
211        } else if classify_char(curr) == CharKind::Whitespace && char_idx > 0 {
212            let prev = text.char(char_idx - 1);
213            if prev != '\n' && prev != '\r' && classify_char(prev) != CharKind::Whitespace {
214                char_idx - 1
215            } else {
216                char_idx
217            }
218        } else {
219            char_idx
220        }
221    };
222
223    let target_char = text.char(target_idx);
224    let target_kind = classify_char(target_char);
225
226    let mut start_idx = target_idx;
227    while start_idx > 0 {
228        let prev = text.char(start_idx - 1);
229        if prev == '\n' || prev == '\r' || classify_char(prev) != target_kind {
230            break;
231        }
232        start_idx -= 1;
233    }
234
235    let mut end_idx = target_idx + 1;
236    while end_idx < total_chars {
237        let next = text.char(end_idx);
238        if next == '\n' || next == '\r' || classify_char(next) != target_kind {
239            break;
240        }
241        end_idx += 1;
242    }
243
244    let start_byte = text.char_to_byte(start_idx);
245    let end_byte = text.char_to_byte(end_idx);
246    start_byte..end_byte
247}
248
249/// Returns the byte range of the full line containing `cursor_byte`, including
250/// any trailing line terminator (`\n` or `\r\n`).
251pub fn find_line_range_at(text: &Rope, cursor_byte: usize) -> Range<usize> {
252    let total_bytes = text.len_bytes();
253    let cursor_byte = cursor_byte.min(total_bytes);
254    if total_bytes == 0 {
255        return 0..0;
256    }
257
258    let char_idx = text.byte_to_char(cursor_byte);
259    let line_idx = text.char_to_line(char_idx);
260    let line_start_char = text.line_to_char(line_idx);
261    let start = text.char_to_byte(line_start_char);
262
263    let end = if line_idx + 1 < text.len_lines() {
264        text.line_to_byte(line_idx + 1)
265    } else {
266        total_bytes
267    };
268
269    start..end
270}
271
272#[cfg(test)]
273mod tests {
274    use super::*;
275
276    #[test]
277    fn test_classify_char() {
278        assert_eq!(classify_char('a'), CharKind::Word);
279        assert_eq!(classify_char('Z'), CharKind::Word);
280        assert_eq!(classify_char('0'), CharKind::Word);
281        assert_eq!(classify_char('_'), CharKind::Word);
282        assert_eq!(classify_char(' '), CharKind::Whitespace);
283        assert_eq!(classify_char('\t'), CharKind::Whitespace);
284        assert_eq!(classify_char('\n'), CharKind::Whitespace);
285        assert_eq!(classify_char('.'), CharKind::Punctuation);
286        assert_eq!(classify_char('('), CharKind::Punctuation);
287        assert_eq!(classify_char(';'), CharKind::Punctuation);
288    }
289
290    #[test]
291    fn test_find_prev_word_start() {
292        let text = Rope::from_str("hello world, foo.bar();");
293
294        assert_eq!(find_prev_word_start(&text, 23), 20);
295        assert_eq!(find_prev_word_start(&text, 20), 17);
296        assert_eq!(find_prev_word_start(&text, 17), 16);
297        assert_eq!(find_prev_word_start(&text, 16), 13);
298        assert_eq!(find_prev_word_start(&text, 13), 11);
299        assert_eq!(find_prev_word_start(&text, 11), 6);
300        assert_eq!(find_prev_word_start(&text, 6), 0);
301        assert_eq!(find_prev_word_start(&text, 0), 0);
302    }
303
304    #[test]
305    fn test_find_prev_word_multiple_spaces() {
306        let text = Rope::from_str("hello    world");
307        assert_eq!(find_prev_word_start(&text, 14), 9);
308        assert_eq!(find_prev_word_start(&text, 9), 0);
309    }
310
311    #[test]
312    fn test_find_prev_word_across_lines() {
313        let text = Rope::from_str("hello\nworld");
314        assert_eq!(find_prev_word_start(&text, 11), 6);
315        assert_eq!(find_prev_word_start(&text, 6), 5);
316        assert_eq!(find_prev_word_start(&text, 5), 0);
317    }
318
319    #[test]
320    fn test_find_next_word_end() {
321        let text = Rope::from_str("hello world, foo.bar();");
322
323        assert_eq!(find_next_word_end(&text, 0), 5);
324        assert_eq!(find_next_word_end(&text, 5), 11);
325        assert_eq!(find_next_word_end(&text, 11), 12);
326        assert_eq!(find_next_word_end(&text, 12), 16);
327        assert_eq!(find_next_word_end(&text, 16), 17);
328        assert_eq!(find_next_word_end(&text, 17), 20);
329        assert_eq!(find_next_word_end(&text, 20), 23);
330        assert_eq!(find_next_word_end(&text, 23), 23);
331    }
332
333    #[test]
334    fn test_find_next_word_multiple_spaces() {
335        let text = Rope::from_str("hello    world");
336        assert_eq!(find_next_word_end(&text, 0), 5);
337        assert_eq!(find_next_word_end(&text, 5), 14);
338    }
339
340    #[test]
341    fn test_find_line_boundaries() {
342        let text = Rope::from_str("first line\nsecond line\nthird");
343
344        assert_eq!(find_line_start(&text, 5), 0);
345        assert_eq!(find_line_end(&text, 5), 10);
346
347        assert_eq!(find_line_start(&text, 15), 11);
348        assert_eq!(find_line_end(&text, 15), 22);
349
350        assert_eq!(find_line_start(&text, 25), 23);
351        assert_eq!(find_line_end(&text, 25), 28);
352    }
353
354    #[test]
355    fn test_find_word_range_at() {
356        let text = Rope::from_str("hello world, foo.bar();\nsecond line");
357
358        assert_eq!(find_word_range_at(&text, 0), 0..5);
359        assert_eq!(find_word_range_at(&text, 2), 0..5);
360        assert_eq!(find_word_range_at(&text, 5), 0..5);
361
362        assert_eq!(find_word_range_at(&text, 6), 6..11);
363        assert_eq!(find_word_range_at(&text, 10), 6..11);
364
365        assert_eq!(find_word_range_at(&text, 11), 11..12);
366
367        assert_eq!(find_word_range_at(&text, 12), 11..12);
368
369        assert_eq!(find_word_range_at(&text, 13), 13..16);
370        assert_eq!(find_word_range_at(&text, 16), 16..17);
371        assert_eq!(find_word_range_at(&text, 17), 17..20);
372        assert_eq!(find_word_range_at(&text, 20), 20..23);
373        assert_eq!(find_word_range_at(&text, 21), 20..23);
374        assert_eq!(find_word_range_at(&text, 22), 20..23);
375
376        assert_eq!(find_word_range_at(&text, 23), 20..23);
377
378        assert_eq!(find_word_range_at(&text, 24), 24..30);
379
380        let empty = Rope::from_str("");
381        assert_eq!(find_word_range_at(&empty, 0), 0..0);
382
383        let unicode = Rope::from_str("مرحبا بالعالم");
384        assert_eq!(find_word_range_at(&unicode, 0), 0..10);
385    }
386
387    #[test]
388    fn test_find_word_range_multiple_spaces() {
389        let text = Rope::from_str("hello   world");
390        assert_eq!(find_word_range_at(&text, 5), 0..5);
391        assert_eq!(find_word_range_at(&text, 6), 5..8);
392        assert_eq!(find_word_range_at(&text, 7), 5..8);
393        assert_eq!(find_word_range_at(&text, 8), 8..13);
394    }
395
396    #[test]
397    fn test_find_line_range_at() {
398        let text = Rope::from_str("first line\nsecond line\nthird");
399
400        assert_eq!(find_line_range_at(&text, 0), 0..11);
401        assert_eq!(find_line_range_at(&text, 5), 0..11);
402        assert_eq!(find_line_range_at(&text, 10), 0..11);
403
404        assert_eq!(find_line_range_at(&text, 11), 11..23);
405        assert_eq!(find_line_range_at(&text, 15), 11..23);
406
407        assert_eq!(find_line_range_at(&text, 23), 23..28);
408        assert_eq!(find_line_range_at(&text, 27), 23..28);
409        assert_eq!(find_line_range_at(&text, 28), 23..28);
410
411        let crlf = Rope::from_str("first\r\nsecond\r\n");
412        assert_eq!(find_line_range_at(&crlf, 2), 0..7);
413        assert_eq!(find_line_range_at(&crlf, 8), 7..15);
414
415        let empty = Rope::from_str("");
416        assert_eq!(find_line_range_at(&empty, 0), 0..0);
417    }
418}