1use unicode_segmentation::UnicodeSegmentation;
27
28#[derive(Clone, Copy, Debug, PartialEq, Eq)]
29enum Class {
30 Space,
31 Tab,
32 Break,
33 Word,
34 Ideo,
35 Punct,
36 Symbol,
37}
38
39#[derive(Clone, Copy, Debug)]
41struct Cluster {
42 start: usize,
43 class: Class,
44}
45
46pub fn is_space(c: char) -> bool {
48 matches!(
49 c,
50 ' ' | '\u{a0}' | '\u{1680}' | '\u{2000}'
51 ..='\u{200a}' | '\u{202f}' | '\u{205f}' | '\u{3000}'
52 )
53}
54
55fn is_ideo(c: char) -> bool {
57 matches!(c,
58 '\u{1100}'..='\u{11ff}'
59 | '\u{2e80}'..='\u{2fdf}'
60 | '\u{3005}'..='\u{3007}'
61 | '\u{3021}'..='\u{3029}'
62 | '\u{3031}'..='\u{3035}'
63 | '\u{3038}'..='\u{303c}'
64 | '\u{3040}'..='\u{309f}'
65 | '\u{30a0}'..='\u{30ff}'
66 | '\u{3100}'..='\u{318f}'
67 | '\u{31a0}'..='\u{31ff}'
68 | '\u{3400}'..='\u{4dbf}'
69 | '\u{4e00}'..='\u{9fff}'
70 | '\u{a960}'..='\u{a97f}'
71 | '\u{ac00}'..='\u{d7ff}'
72 | '\u{f900}'..='\u{faff}'
73 | '\u{ff66}'..='\u{ffdc}'
74 | '\u{20000}'..='\u{3134f}')
75}
76
77fn is_symbol(c: char) -> bool {
79 matches!(c,
80 '\u{a9}' | '\u{ae}' | '\u{2122}'
81 | '\u{2190}'..='\u{21ff}'
82 | '\u{2300}'..='\u{23ff}'
83 | '\u{25a0}'..='\u{27bf}'
84 | '\u{2b00}'..='\u{2bff}'
85 | '\u{1f000}'..='\u{1faff}')
86}
87
88fn base_class(c: char) -> Class {
89 match c {
90 '\u{b}' | '\n' | '\r' | '\u{2028}' | '\u{2029}' => Class::Break,
91 '\t' => Class::Tab,
92 c if is_space(c) => Class::Space,
93 c if is_ideo(c) => Class::Ideo,
94 c if c.is_alphanumeric() => Class::Word,
95 c if is_symbol(c) => Class::Symbol,
96 '\u{300}'..='\u{36f}'
98 | '\u{1ab0}'..='\u{1aff}'
99 | '\u{1dc0}'..='\u{1dff}'
100 | '\u{20d0}'..='\u{20ff}'
101 | '\u{fe20}'..='\u{fe2f}' => Class::Word,
102 _ => Class::Punct,
103 }
104}
105
106fn clusters(text: &str) -> Vec<Cluster> {
107 let mut out: Vec<Cluster> = Vec::new();
108 let mut firsts: Vec<char> = Vec::new();
109 let mut pos = 0;
110 for g in text.graphemes(true) {
111 let n = g.chars().count();
112 let first = g.chars().next().unwrap_or(' ');
113 out.push(Cluster {
115 start: pos,
116 class: base_class(first),
117 });
118 firsts.push(first);
119 pos += n;
120 }
121 let classes: Vec<Class> = out.iter().map(|c| c.class).collect();
123 for (i, cl) in out.iter_mut().enumerate() {
124 let Some(&c) = firsts.get(i) else { continue };
125 let prev = i.checked_sub(1).and_then(|j| firsts.get(j)).copied();
126 let next = firsts.get(i + 1).copied();
127 let joins = match c {
128 '\'' | '\u{2019}' => next.is_some_and(char::is_alphanumeric),
129 '-' => prev.is_some_and(char::is_alphanumeric),
130 '.' | ',' | ':' => {
131 prev.is_some_and(char::is_numeric) && next.is_some_and(char::is_numeric)
132 }
133 _ => false,
134 };
135 if joins && classes.get(i) == Some(&Class::Punct) {
136 cl.class = Class::Word;
137 }
138 }
139 out
140}
141
142fn starts_token(prev: Class, cur: Class) -> bool {
144 if cur == Class::Space {
145 return matches!(prev, Class::Tab | Class::Break);
147 }
148 prev == Class::Space || prev != cur || matches!(cur, Class::Ideo | Class::Tab | Class::Break)
149}
150
151pub fn stops(text: &str) -> Vec<usize> {
154 let cs = clusters(text);
155 let mut out = vec![0];
156 for w in cs.windows(2) {
157 if let [a, b] = w
158 && starts_token(a.class, b.class)
159 {
160 out.push(b.start);
161 }
162 }
163 let n = text.chars().count();
164 if out.last() != Some(&n) {
165 out.push(n);
166 }
167 out
168}
169
170pub fn back_stops(text: &str) -> Vec<usize> {
174 let cs = clusters(text);
175 let chars: Vec<char> = text.chars().collect();
176 let mut out = vec![0];
177 for w in cs.windows(2) {
178 if let [a, b] = w {
179 let symbol_punct = matches!(
180 (a.class, b.class),
181 (Class::Symbol, Class::Punct) | (Class::Punct, Class::Symbol)
182 );
183 if starts_token(a.class, b.class) && !symbol_punct {
184 out.push(b.start);
185 }
186 }
187 }
188 for (i, c) in chars.iter().enumerate() {
189 if *c != ' ' && is_space(*c) && chars.get(i + 1) != Some(&' ') && i > 0 {
190 out.push(i);
191 }
192 }
193 out.sort_unstable();
194 out.dedup();
195 let n = chars.len();
196 if out.last() != Some(&n) {
197 out.push(n);
198 }
199 out
200}
201
202pub fn next_stop(text: &str, ch: usize) -> Option<usize> {
204 stops(text).into_iter().find(|s| *s > ch)
205}
206
207pub fn prev_stop(text: &str, ch: usize) -> Option<usize> {
209 back_stops(text).into_iter().rev().find(|s| *s < ch)
210}
211
212fn space_at(chars: &[char], i: usize) -> bool {
214 chars.get(i).is_some_and(|c| is_space(*c))
215}
216
217fn content_end(text: &str, ch: usize) -> usize {
219 let chars: Vec<char> = text.chars().collect();
220 let next = next_stop(text, ch).unwrap_or(chars.len());
221 let mut e = ch;
222 while e < next && !space_at(&chars, e) {
223 e += 1;
224 }
225 if e == ch && space_at(&chars, ch) {
227 next
228 } else {
229 e
230 }
231}
232
233pub fn word_at(text: &str, ch: usize) -> (usize, usize) {
236 let chars: Vec<char> = text.chars().collect();
237 let n = chars.len();
238 if n == 0 {
239 return (0, 0);
240 }
241 let ch = ch.min(n);
242 let on_space = ch >= n || space_at(&chars, ch);
243 let after_token = ch > 0 && !space_at(&chars, ch - 1);
244 let at = if on_space && after_token {
245 ch - 1
246 } else {
247 ch.min(n - 1)
248 };
249 if space_at(&chars, at) {
250 let mut s = at;
251 while s > 0 && space_at(&chars, s - 1) {
252 s -= 1;
253 }
254 let mut e = at;
255 while e < n && space_at(&chars, e) {
256 e += 1;
257 }
258 return (s, e);
259 }
260 let s = stops(text)
261 .into_iter()
262 .rev()
263 .find(|s| *s <= at)
264 .unwrap_or(0);
265 (s, content_end(text, s).max(at + 1).min(n))
266}
267
268pub fn delete_forward_end(text: &str, ch: usize) -> Option<usize> {
272 let chars: Vec<char> = text.chars().collect();
273 let n = chars.len();
274 if ch >= n {
275 return None;
276 }
277 let st = stops(text);
278 let at_stop = st.contains(&ch);
279 if !at_stop && !space_at(&chars, ch) {
280 return Some(content_end(text, ch));
281 }
282 let prev_is_text = ch > 0
283 && chars
284 .get(ch - 1)
285 .is_some_and(|c| !is_space(*c) && !matches!(base_class(*c), Class::Tab | Class::Break));
286 if space_at(&chars, ch) && prev_is_text {
287 let mut j = ch;
288 while j < n && space_at(&chars, j) {
289 j += 1;
290 }
291 if j >= n {
292 return Some(n);
293 }
294 return Some(content_end(text, j));
295 }
296 st.into_iter().find(|s| *s > ch)
297}
298
299pub fn is_closing_punct(c: char) -> bool {
302 matches!(
303 c,
304 '.' | ','
305 | ';'
306 | ':'
307 | '!'
308 | '?'
309 | ')'
310 | ']'
311 | '}'
312 | '%'
313 | '\u{2026}'
314 | '\u{201d}'
315 | '\u{2019}'
316 | '\u{bb}'
317 )
318}
319
320pub fn is_sentence_punct(c: char) -> bool {
323 matches!(c, '.' | ',' | ';' | ':' | '!' | '?' | '\u{2026}')
324}
325
326#[cfg(test)]
327mod tests {
328 use super::*;
329
330 fn marked(text: &str, st: &[usize]) -> String {
332 let mut out = String::new();
333 for (i, c) in text.chars().enumerate() {
334 if st.contains(&i) {
335 out.push('|');
336 }
337 out.push(match c {
338 '\u{b}' => 'โต',
339 '\t' => 'โ',
340 c => c,
341 });
342 }
343 if st.contains(&text.chars().count()) {
344 out.push('|');
345 }
346 out
347 }
348 fn fwd(text: &str) -> String {
349 marked(text, &stops(text))
350 }
351 fn back(text: &str) -> String {
352 marked(text, &back_stops(text))
353 }
354
355 #[test]
357 fn punctuation_and_spaces() {
358 let t = "Hello, world! Foo...bar baz-qux it's 3.14 e.g. a_b x@y.com (end) \"q\" end.";
359 let want = "|Hello|, |world|! |Foo|...|bar |baz-qux |it's |3.14 |e|.|g|. |a|_|b |x|@|y|.|com |(|end|) |\"|q|\" |end|.|";
360 assert_eq!(fwd(t), want);
361 assert_eq!(back(t), want);
362 }
363
364 #[test]
365 fn hyphens_apostrophes_numbers() {
366 let t = "a-b -b a--b a - b a- b dogs' x 'q' 1,000.5 3:45 v1.2.3 $5 50% a/b x&y #tag a+b c++ end";
367 assert_eq!(
368 fwd(t),
369 "|a-b |-|b |a-|-|b |a |- |b |a- |b |dogs|' |x |'q|' |1,000.5 |3:45 |v1.2.3 |$|5 |50|% |a|/|b |x|&|y |#|tag |a|+|b |c|++ |end|"
370 );
371 let t = "itโs x a?!b a.)b a),b 1.a a1.b 1.5.x 1-2 a1-b A-1 โx xโ a's 'tis o'clock โqโ end";
372 assert_eq!(
373 fwd(t),
374 "|itโs |x |a|?!|b |a|.)|b |a|),|b |1|.|a |a1|.|b |1.5|.|x |1-2 |a1-b |A-1 |โ|x |x|โ |a's |'tis |o'clock |โ|q|โ |end|"
375 );
376 }
377
378 #[test]
379 fn marks_emoji_symbols() {
380 let t = "cafรฉ e\u{301}te ๐๐ฝ x ๐จ\u{200d}๐ฉ\u{200d}๐ง y ยฝ ยฎz ยซqยป โwโ ยฟno? okโem dashโen x";
381 let want = "|cafรฉ |e\u{301}te |๐๐ฝ |x |๐จ\u{200d}๐ฉ\u{200d}๐ง |y |ยฝ |ยฎ|z |ยซ|q|ยป |โ|w|โ |ยฟ|no|? |ok|โ|em |dash|โ|en |x|";
382 assert_eq!(fwd(t), want);
383 assert_eq!(back(t), want);
384 assert_eq!(fwd("๐๐ x ๐? x๐y"), "|๐๐ |x |๐|? |x|๐|y|");
386 assert_eq!(back("๐๐ x ๐? x๐y"), "|๐๐ |x |๐? |x|๐|y|");
387 }
388
389 #[test]
390 fn cjk_each_character() {
391 let t = "ๆฅๆฌ่ชใฎใใญในใใงใใใซใฟใซใๆผขๅญ abc ไธญๆๅญ็ฌฆ๏ผๆ ็นใํ๊ตญ์ด ๋ฌธ์ฅ aใใb";
392 assert_eq!(
393 fwd(t),
394 "|ๆฅ|ๆฌ|่ช|ใฎ|ใ|ใญ|ใน|ใ|ใง|ใ|ใ|ใซ|ใฟ|ใซ|ใ|ๆผข|ๅญ |abc |ไธญ|ๆ|ๅญ|็ฌฆ|๏ผ|ๆ |็น|ใ|ํ|๊ตญ|์ด |๋ฌธ|์ฅ |a|ใใ|b|"
395 );
396 assert_eq!(back(t), fwd(t));
397 }
398
399 #[test]
400 fn rtl_words_in_logical_order() {
401 let t = "Hello ู
ุฑุญุจุง ุจุงูุนุงูู
world ืฉืืื ืขืืื end";
402 assert_eq!(fwd(t), "|Hello |ู
ุฑุญุจุง |ุจุงูุนุงูู
|world |ืฉืืื |ืขืืื |end|");
403 }
404
405 #[test]
406 fn tabs_breaks_and_other_spaces() {
407 assert_eq!(fwd(" lead x mid "), "| |lead |x |mid |");
408 assert_eq!(
409 fwd("Five\u{b}six seven\tTab x"),
410 "|Five|โต|six |seven|โ|Tab |x|"
411 );
412 assert_eq!(
413 fwd("a\t b x \u{b} y q\u{b}\u{b}z"),
414 "|a|โ| |b |x |โต| |y |q|โต|โต|z|"
415 );
416 let t = "a\u{a0}\u{a0}b a\u{3000}b a\u{2003}b a \u{a0}b a\t\tb a \tb a\u{a0} b end";
417 assert_eq!(
418 fwd(t),
419 "|a\u{a0}\u{a0}|b |a\u{3000}|b |a\u{2003}|b |a \u{a0}|b |a|โ|โ|b |a |โ|b |a\u{a0} |b |end|"
420 );
421 assert_eq!(
422 back(t),
423 "|a|\u{a0}|\u{a0}|b |a|\u{3000}|b |a|\u{2003}|b |a |\u{a0}|b |a|โ|โ|b |a |โ|b |a\u{a0} |b |end|"
424 );
425 }
426
427 #[test]
428 fn next_and_prev() {
429 let t = " lead x mid ";
430 assert_eq!(next_stop(t, 1), Some(2));
431 assert_eq!(next_stop(t, 9), Some(11));
432 assert_eq!(next_stop(t, 16), None);
433 assert_eq!(prev_stop(t, 1), Some(0));
434 assert_eq!(prev_stop(t, 9), Some(7));
435 assert_eq!(prev_stop(t, 15), Some(11));
436 assert_eq!(prev_stop(t, 0), None);
437 assert_eq!(next_stop("", 0), None);
438 assert_eq!(prev_stop("", 0), None);
439 }
440
441 #[test]
442 fn double_click_word() {
443 let t = "Hello world, foo bar";
444 assert_eq!(word_at(t, 7), (6, 11));
445 assert_eq!(word_at(t, 15), (14, 17));
446 assert_eq!(word_at(t, 11), (11, 12));
447 assert_eq!(word_at(t, 12), (11, 12));
448 assert_eq!(word_at(t, 13), (12, 14));
449 assert_eq!(word_at(t, 21), (18, 21));
450 assert_eq!(word_at("ๆฅๆฌ่ชใฎใใญในใ abc", 5), (5, 6));
451 assert_eq!(word_at("", 3), (0, 0));
452 assert_eq!(word_at("x๐จ\u{200d}๐ฉ\u{200d}๐งy", 2), (1, 6));
453 }
454
455 #[test]
456 fn delete_forward_extent() {
457 let t = "ab cd...ef gh";
458 let ends: Vec<Option<usize>> = (0..=t.len()).map(|c| delete_forward_end(t, c)).collect();
459 assert_eq!(
461 ends,
462 vec![
463 Some(5),
464 Some(2),
465 Some(7),
466 Some(5),
467 Some(5),
468 Some(7),
469 Some(7),
470 Some(10),
471 Some(10),
472 Some(10),
473 Some(13),
474 Some(12),
475 Some(15),
476 Some(15),
477 Some(15),
478 None
479 ]
480 );
481 assert_eq!(delete_forward_end("ab\t cd", 2), Some(3));
482 assert_eq!(delete_forward_end("ab ", 2), Some(4));
483 }
484}