use once_cell::sync::Lazy;
use regex::Regex;
use unicode_segmentation::UnicodeSegmentation;
enum Token {
Word(String),
Punctuation(String),
Whitespace(String),
Space(String),
}
impl Token {
fn inner(self) -> String {
match self {
Token::Word(s) | Token::Punctuation(s) | Token::Whitespace(s) | Token::Space(s) => s,
}
}
}
pub trait Splitter {
fn split(&self, text: &str) -> Vec<String>;
fn split_with_limit(&self, text: &str, max_bytes_per_word: usize) -> Vec<Vec<u8>>;
}
pub struct HATSplitter;
impl Default for HATSplitter {
fn default() -> Self {
Self::new()
}
}
impl HATSplitter {
pub fn new() -> Self {
Self
}
fn unicode_word_split(input: &str) -> Vec<&str> {
input.split_word_bounds().collect::<Vec<&str>>()
}
fn split_at_matches<'a>(s: &'a str, re: &Regex) -> Vec<&'a str> {
let mut result = Vec::new();
let mut word_start = 0;
for regex_match in re.find_iter(s) {
let match_start = regex_match.start();
let word_end = match_start + s[match_start..].chars().next().unwrap().len_utf8();
result.push(&s[word_start..word_end]);
word_start = word_end;
}
if word_start < s.len() {
result.push(&s[word_start..s.len()]);
}
result
}
fn split_camel_case(s: &str) -> Vec<&str> {
static RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"(\p{Ll})(\p{Lu})").unwrap());
Self::split_at_matches(s, &RE)
}
fn split_punctuation(s: &str) -> Vec<&str> {
static RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"\p{P}").unwrap());
Self::split_at_matches(s, &RE)
}
fn combine_spaces(strings: Vec<&str>) -> Vec<String> {
strings.into_iter().fold(Vec::new(), |mut acc, s| {
if s == " " {
if let Some(last) = acc.last_mut() {
if last.chars().all(|c| c == ' ') {
last.push(' ');
return acc;
}
}
}
acc.push(s.to_string());
acc
})
}
fn split_long_words(strings: Vec<String>, max_bytes: usize) -> Vec<Vec<u8>> {
if max_bytes == 0 {
panic!("max_bytes must be greater than 0");
}
strings.into_iter().fold(Vec::new(), |mut result, string| {
let bytes = string.as_bytes();
if bytes.len() <= max_bytes {
result.push(bytes.to_vec());
return result;
}
let mut start_byte = 0;
while start_byte < bytes.len() {
let end_byte = std::cmp::min(start_byte + max_bytes, bytes.len());
let end = (start_byte + 1..=end_byte)
.rev()
.find(|&i| string.is_char_boundary(i))
.unwrap_or(end_byte);
result.push(bytes[start_byte..end].to_vec());
start_byte = end;
}
result
})
}
fn lex(s: &str) -> Vec<Token> {
static WHITESPACE_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"^\s+$").unwrap());
static PUNCTUATION_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"^\p{P}$").unwrap());
let words = Self::combine_spaces(
Self::unicode_word_split(s)
.iter()
.flat_map(|s| Self::split_punctuation(s))
.flat_map(|s| Self::split_camel_case(s))
.collect::<Vec<&str>>(),
);
words
.into_iter()
.map(|s| {
if s == " " {
Token::Space(s)
} else if WHITESPACE_RE.is_match(s.as_str()) {
Token::Whitespace(s)
} else if PUNCTUATION_RE.is_match(s.as_str()) {
Token::Punctuation(s)
} else {
Token::Word(s)
}
})
.collect()
}
fn parse(tokens: Vec<Token>) -> Vec<String> {
let groups = tokens
.into_iter()
.fold(Vec::<Vec<Token>>::new(), |mut groups, token| {
let should_append_to_last_group = |last_group: &Vec<Token>, token: &Token| {
matches!(
(last_group.last(), token),
(Some(Token::Space(_)), Token::Word(_))
| (
Some(Token::Space(_) | Token::Word(_) | Token::Punctuation(_)),
Token::Punctuation(_),
)
)
};
if let Some(last_group) = groups.last_mut() {
if should_append_to_last_group(last_group, &token) {
last_group.push(token);
return groups;
}
}
groups.push(vec![token]);
groups
});
groups
.into_iter()
.map(|group| group.into_iter().map(Token::inner).collect())
.collect()
}
}
impl Splitter for HATSplitter {
fn split(&self, input: &str) -> Vec<String> {
Self::parse(Self::lex(input))
}
fn split_with_limit(&self, input: &str, max_bytes: usize) -> Vec<Vec<u8>> {
Self::split_long_words(Self::parse(Self::lex(input)), max_bytes)
}
}
#[cfg(test)]
mod tests {
use super::*;
static STRANGE_STUFF: &str = "đâ¨đđđđđđžđđ˝đ Ř´ŮŘĄ ä˝ ĺĽ˝ĺ ăăăŤăĄăŻ ěë
íě¸ě đ¤˘đ¤đ¤¤ đ˝(Îťx.đĽÂ˛) đ¤đâ¨đ´, đ⊠đ đ˝âŚđâżĎâđĄ;đ§đ<đąđđ˘â>đ ď¸ŇĐŢÂą(Îđ§) äš( â˘_⢠)ă âż°ć¨ćĽđž";
#[test]
fn it_works() {
let result = HATSplitter::new().split("Hello, world!");
assert_eq!(result, vec!["Hello,", " world!"]);
}
#[test]
fn it_handles_empty_input() {
let result = HATSplitter::new().split("");
assert!(result.is_empty());
}
#[test]
fn it_splits_camel_case() {
let result = HATSplitter::new().split("howAreYou");
assert_eq!(result, vec!["how", "Are", "You"]);
}
#[test]
fn it_splits_snake_case() {
let result = HATSplitter::new().split("how_are_you");
assert_eq!(result, vec!["how_", "are_", "you"]);
}
#[test]
fn it_limits_word_size() {
let result = HATSplitter::new().split_with_limit("verylongword", 10);
assert_eq!(result, vec![b"verylongwo".to_vec(), b"rd".to_vec()]);
}
#[test]
fn it_splits_large_unicode_characters() {
let result = HATSplitter::new().split_with_limit("đ", 2);
assert_eq!(result.len(), 2);
}
#[test]
fn it_does_not_split_unicode_where_possible() {
let result = HATSplitter::new().split_with_limit("fĂźr", 2);
assert_eq!(
result,
vec![b"f".to_vec(), "Ăź".as_bytes().to_vec(), b"r".to_vec()]
);
}
#[test]
#[should_panic]
fn it_handles_zero_max_bytes() {
HATSplitter::new().split_with_limit("abc", 0);
}
#[test]
fn it_handles_strange_stuff() {
HATSplitter::new().split_with_limit(STRANGE_STUFF, 100);
}
#[test]
fn it_is_causal() {
let max_chunk_size = 1024;
let splitter = HATSplitter::new();
let full_split = splitter.split_with_limit(STRANGE_STUFF, max_chunk_size);
for (i, _) in STRANGE_STUFF.char_indices() {
let prefix = &STRANGE_STUFF[..i];
let partial_split = splitter.split_with_limit(prefix, max_chunk_size);
for (full_word, partial_word) in full_split.iter().zip(partial_split.iter()) {
assert_eq!(&full_word[..partial_word.len()], partial_word);
}
}
}
}