use unicode_segmentation::UnicodeSegmentation;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Class {
Space,
Tab,
Break,
Word,
Ideo,
Punct,
Symbol,
}
#[derive(Clone, Copy, Debug)]
struct Cluster {
start: usize,
class: Class,
}
pub fn is_space(c: char) -> bool {
matches!(
c,
' ' | '\u{a0}' | '\u{1680}' | '\u{2000}'
..='\u{200a}' | '\u{202f}' | '\u{205f}' | '\u{3000}'
)
}
fn is_ideo(c: char) -> bool {
matches!(c,
'\u{1100}'..='\u{11ff}'
| '\u{2e80}'..='\u{2fdf}'
| '\u{3005}'..='\u{3007}'
| '\u{3021}'..='\u{3029}'
| '\u{3031}'..='\u{3035}'
| '\u{3038}'..='\u{303c}'
| '\u{3040}'..='\u{309f}'
| '\u{30a0}'..='\u{30ff}'
| '\u{3100}'..='\u{318f}'
| '\u{31a0}'..='\u{31ff}'
| '\u{3400}'..='\u{4dbf}'
| '\u{4e00}'..='\u{9fff}'
| '\u{a960}'..='\u{a97f}'
| '\u{ac00}'..='\u{d7ff}'
| '\u{f900}'..='\u{faff}'
| '\u{ff66}'..='\u{ffdc}'
| '\u{20000}'..='\u{3134f}')
}
fn is_symbol(c: char) -> bool {
matches!(c,
'\u{a9}' | '\u{ae}' | '\u{2122}'
| '\u{2190}'..='\u{21ff}'
| '\u{2300}'..='\u{23ff}'
| '\u{25a0}'..='\u{27bf}'
| '\u{2b00}'..='\u{2bff}'
| '\u{1f000}'..='\u{1faff}')
}
fn base_class(c: char) -> Class {
match c {
'\u{b}' | '\n' | '\r' | '\u{2028}' | '\u{2029}' => Class::Break,
'\t' => Class::Tab,
c if is_space(c) => Class::Space,
c if is_ideo(c) => Class::Ideo,
c if c.is_alphanumeric() => Class::Word,
c if is_symbol(c) => Class::Symbol,
'\u{300}'..='\u{36f}'
| '\u{1ab0}'..='\u{1aff}'
| '\u{1dc0}'..='\u{1dff}'
| '\u{20d0}'..='\u{20ff}'
| '\u{fe20}'..='\u{fe2f}' => Class::Word,
_ => Class::Punct,
}
}
fn clusters(text: &str) -> Vec<Cluster> {
let mut out: Vec<Cluster> = Vec::new();
let mut firsts: Vec<char> = Vec::new();
let mut pos = 0;
for g in text.graphemes(true) {
let n = g.chars().count();
let first = g.chars().next().unwrap_or(' ');
out.push(Cluster {
start: pos,
class: base_class(first),
});
firsts.push(first);
pos += n;
}
let classes: Vec<Class> = out.iter().map(|c| c.class).collect();
for (i, cl) in out.iter_mut().enumerate() {
let Some(&c) = firsts.get(i) else { continue };
let prev = i.checked_sub(1).and_then(|j| firsts.get(j)).copied();
let next = firsts.get(i + 1).copied();
let joins = match c {
'\'' | '\u{2019}' => next.is_some_and(char::is_alphanumeric),
'-' => prev.is_some_and(char::is_alphanumeric),
'.' | ',' | ':' => {
prev.is_some_and(char::is_numeric) && next.is_some_and(char::is_numeric)
}
_ => false,
};
if joins && classes.get(i) == Some(&Class::Punct) {
cl.class = Class::Word;
}
}
out
}
fn starts_token(prev: Class, cur: Class) -> bool {
if cur == Class::Space {
return matches!(prev, Class::Tab | Class::Break);
}
prev == Class::Space || prev != cur || matches!(cur, Class::Ideo | Class::Tab | Class::Break)
}
pub fn stops(text: &str) -> Vec<usize> {
let cs = clusters(text);
let mut out = vec![0];
for w in cs.windows(2) {
if let [a, b] = w
&& starts_token(a.class, b.class)
{
out.push(b.start);
}
}
let n = text.chars().count();
if out.last() != Some(&n) {
out.push(n);
}
out
}
pub fn back_stops(text: &str) -> Vec<usize> {
let cs = clusters(text);
let chars: Vec<char> = text.chars().collect();
let mut out = vec![0];
for w in cs.windows(2) {
if let [a, b] = w {
let symbol_punct = matches!(
(a.class, b.class),
(Class::Symbol, Class::Punct) | (Class::Punct, Class::Symbol)
);
if starts_token(a.class, b.class) && !symbol_punct {
out.push(b.start);
}
}
}
for (i, c) in chars.iter().enumerate() {
if *c != ' ' && is_space(*c) && chars.get(i + 1) != Some(&' ') && i > 0 {
out.push(i);
}
}
out.sort_unstable();
out.dedup();
let n = chars.len();
if out.last() != Some(&n) {
out.push(n);
}
out
}
pub fn next_stop(text: &str, ch: usize) -> Option<usize> {
stops(text).into_iter().find(|s| *s > ch)
}
pub fn prev_stop(text: &str, ch: usize) -> Option<usize> {
back_stops(text).into_iter().rev().find(|s| *s < ch)
}
fn space_at(chars: &[char], i: usize) -> bool {
chars.get(i).is_some_and(|c| is_space(*c))
}
fn content_end(text: &str, ch: usize) -> usize {
let chars: Vec<char> = text.chars().collect();
let next = next_stop(text, ch).unwrap_or(chars.len());
let mut e = ch;
while e < next && !space_at(&chars, e) {
e += 1;
}
if e == ch && space_at(&chars, ch) {
next
} else {
e
}
}
pub fn word_at(text: &str, ch: usize) -> (usize, usize) {
let chars: Vec<char> = text.chars().collect();
let n = chars.len();
if n == 0 {
return (0, 0);
}
let ch = ch.min(n);
let on_space = ch >= n || space_at(&chars, ch);
let after_token = ch > 0 && !space_at(&chars, ch - 1);
let at = if on_space && after_token {
ch - 1
} else {
ch.min(n - 1)
};
if space_at(&chars, at) {
let mut s = at;
while s > 0 && space_at(&chars, s - 1) {
s -= 1;
}
let mut e = at;
while e < n && space_at(&chars, e) {
e += 1;
}
return (s, e);
}
let s = stops(text)
.into_iter()
.rev()
.find(|s| *s <= at)
.unwrap_or(0);
(s, content_end(text, s).max(at + 1).min(n))
}
pub fn delete_forward_end(text: &str, ch: usize) -> Option<usize> {
let chars: Vec<char> = text.chars().collect();
let n = chars.len();
if ch >= n {
return None;
}
let st = stops(text);
let at_stop = st.contains(&ch);
if !at_stop && !space_at(&chars, ch) {
return Some(content_end(text, ch));
}
let prev_is_text = ch > 0
&& chars
.get(ch - 1)
.is_some_and(|c| !is_space(*c) && !matches!(base_class(*c), Class::Tab | Class::Break));
if space_at(&chars, ch) && prev_is_text {
let mut j = ch;
while j < n && space_at(&chars, j) {
j += 1;
}
if j >= n {
return Some(n);
}
return Some(content_end(text, j));
}
st.into_iter().find(|s| *s > ch)
}
pub fn is_closing_punct(c: char) -> bool {
matches!(
c,
'.' | ','
| ';'
| ':'
| '!'
| '?'
| ')'
| ']'
| '}'
| '%'
| '\u{2026}'
| '\u{201d}'
| '\u{2019}'
| '\u{bb}'
)
}
pub fn is_sentence_punct(c: char) -> bool {
matches!(c, '.' | ',' | ';' | ':' | '!' | '?' | '\u{2026}')
}
#[cfg(test)]
mod tests {
use super::*;
fn marked(text: &str, st: &[usize]) -> String {
let mut out = String::new();
for (i, c) in text.chars().enumerate() {
if st.contains(&i) {
out.push('|');
}
out.push(match c {
'\u{b}' => 'โต',
'\t' => 'โ',
c => c,
});
}
if st.contains(&text.chars().count()) {
out.push('|');
}
out
}
fn fwd(text: &str) -> String {
marked(text, &stops(text))
}
fn back(text: &str) -> String {
marked(text, &back_stops(text))
}
#[test]
fn punctuation_and_spaces() {
let t = "Hello, world! Foo...bar baz-qux it's 3.14 e.g. a_b x@y.com (end) \"q\" end.";
let want = "|Hello|, |world|! |Foo|...|bar |baz-qux |it's |3.14 |e|.|g|. |a|_|b |x|@|y|.|com |(|end|) |\"|q|\" |end|.|";
assert_eq!(fwd(t), want);
assert_eq!(back(t), want);
}
#[test]
fn hyphens_apostrophes_numbers() {
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";
assert_eq!(
fwd(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|"
);
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";
assert_eq!(
fwd(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|"
);
}
#[test]
fn marks_emoji_symbols() {
let t = "cafรฉ e\u{301}te ๐๐ฝ x ๐จ\u{200d}๐ฉ\u{200d}๐ง y ยฝ ยฎz ยซqยป โwโ ยฟno? okโem dashโen x";
let want = "|cafรฉ |e\u{301}te |๐๐ฝ |x |๐จ\u{200d}๐ฉ\u{200d}๐ง |y |ยฝ |ยฎ|z |ยซ|q|ยป |โ|w|โ |ยฟ|no|? |ok|โ|em |dash|โ|en |x|";
assert_eq!(fwd(t), want);
assert_eq!(back(t), want);
assert_eq!(fwd("๐๐ x ๐? x๐y"), "|๐๐ |x |๐|? |x|๐|y|");
assert_eq!(back("๐๐ x ๐? x๐y"), "|๐๐ |x |๐? |x|๐|y|");
}
#[test]
fn cjk_each_character() {
let t = "ๆฅๆฌ่ชใฎใใญในใใงใใใซใฟใซใๆผขๅญ abc ไธญๆๅญ็ฌฆ๏ผๆ ็นใํ๊ตญ์ด ๋ฌธ์ฅ aใใb";
assert_eq!(
fwd(t),
"|ๆฅ|ๆฌ|่ช|ใฎ|ใ|ใญ|ใน|ใ|ใง|ใ|ใ|ใซ|ใฟ|ใซ|ใ|ๆผข|ๅญ |abc |ไธญ|ๆ|ๅญ|็ฌฆ|๏ผ|ๆ |็น|ใ|ํ|๊ตญ|์ด |๋ฌธ|์ฅ |a|ใใ|b|"
);
assert_eq!(back(t), fwd(t));
}
#[test]
fn rtl_words_in_logical_order() {
let t = "Hello ู
ุฑุญุจุง ุจุงูุนุงูู
world ืฉืืื ืขืืื end";
assert_eq!(fwd(t), "|Hello |ู
ุฑุญุจุง |ุจุงูุนุงูู
|world |ืฉืืื |ืขืืื |end|");
}
#[test]
fn tabs_breaks_and_other_spaces() {
assert_eq!(fwd(" lead x mid "), "| |lead |x |mid |");
assert_eq!(
fwd("Five\u{b}six seven\tTab x"),
"|Five|โต|six |seven|โ|Tab |x|"
);
assert_eq!(
fwd("a\t b x \u{b} y q\u{b}\u{b}z"),
"|a|โ| |b |x |โต| |y |q|โต|โต|z|"
);
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";
assert_eq!(
fwd(t),
"|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|"
);
assert_eq!(
back(t),
"|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|"
);
}
#[test]
fn next_and_prev() {
let t = " lead x mid ";
assert_eq!(next_stop(t, 1), Some(2));
assert_eq!(next_stop(t, 9), Some(11));
assert_eq!(next_stop(t, 16), None);
assert_eq!(prev_stop(t, 1), Some(0));
assert_eq!(prev_stop(t, 9), Some(7));
assert_eq!(prev_stop(t, 15), Some(11));
assert_eq!(prev_stop(t, 0), None);
assert_eq!(next_stop("", 0), None);
assert_eq!(prev_stop("", 0), None);
}
#[test]
fn double_click_word() {
let t = "Hello world, foo bar";
assert_eq!(word_at(t, 7), (6, 11));
assert_eq!(word_at(t, 15), (14, 17));
assert_eq!(word_at(t, 11), (11, 12));
assert_eq!(word_at(t, 12), (11, 12));
assert_eq!(word_at(t, 13), (12, 14));
assert_eq!(word_at(t, 21), (18, 21));
assert_eq!(word_at("ๆฅๆฌ่ชใฎใใญในใ abc", 5), (5, 6));
assert_eq!(word_at("", 3), (0, 0));
assert_eq!(word_at("x๐จ\u{200d}๐ฉ\u{200d}๐งy", 2), (1, 6));
}
#[test]
fn delete_forward_extent() {
let t = "ab cd...ef gh";
let ends: Vec<Option<usize>> = (0..=t.len()).map(|c| delete_forward_end(t, c)).collect();
assert_eq!(
ends,
vec![
Some(5),
Some(2),
Some(7),
Some(5),
Some(5),
Some(7),
Some(7),
Some(10),
Some(10),
Some(10),
Some(13),
Some(12),
Some(15),
Some(15),
Some(15),
None
]
);
assert_eq!(delete_forward_end("ab\t cd", 2), Some(3));
assert_eq!(delete_forward_end("ab ", 2), Some(4));
}
}