#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TokenKind {
Word,
Number,
Url,
Email,
Punct,
Whitespace,
Other,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Token {
pub kind: TokenKind,
pub text: String,
pub start: usize,
pub end: usize,
}
#[derive(Debug, Clone, Default)]
pub struct TokenStream {
pub tokens: Vec<Token>,
}
impl TokenStream {
pub fn tokenize(input: &str) -> TokenStream {
let chars: Vec<(usize, char)> = input.char_indices().collect();
let mut tokens = Vec::new();
let mut i = 0usize;
while i < chars.len() {
let (start, c) = chars[i];
let (kind, end) = if c.is_whitespace() {
let end = run_while(&chars, i, |c| c.is_whitespace());
(TokenKind::Whitespace, end)
} else if c.is_alphabetic() {
let end = link_end(&chars, i);
match classify_link(&chars, i, end) {
LinkKind::Url => (TokenKind::Url, end),
LinkKind::Email => (TokenKind::Email, end),
LinkKind::None => {
let end = run_while(&chars, i, |c| c.is_alphabetic());
(TokenKind::Word, end)
}
}
} else if c.is_numeric() {
(TokenKind::Number, number_end(&chars, i))
} else if c.is_ascii_punctuation() {
(TokenKind::Punct, i + 1)
} else {
let end = run_while(&chars, i, |c| {
!c.is_whitespace() && !c.is_alphanumeric() && !c.is_ascii_punctuation()
});
(TokenKind::Other, end)
};
let end_idx = if end >= chars.len() {
input.len()
} else {
chars[end].0
};
tokens.push(Token {
kind,
text: input[start..end_idx].to_string(),
start,
end: end_idx,
});
i = end;
}
TokenStream { tokens }
}
pub fn render(&self) -> String {
let mut out = String::new();
for t in &self.tokens {
out.push_str(&t.text);
}
out
}
pub fn prev_significant(&self, i: usize) -> Option<&Token> {
self.tokens[..i]
.iter()
.rev()
.find(|t| t.kind != TokenKind::Whitespace)
}
pub fn next_significant(&self, i: usize) -> Option<&Token> {
self.tokens[i + 1..]
.iter()
.find(|t| t.kind != TokenKind::Whitespace)
}
}
fn run_while(chars: &[(usize, char)], mut i: usize, pred: impl Fn(char) -> bool) -> usize {
while i < chars.len() && pred(chars[i].1) {
i += 1;
}
i
}
fn run_until_whitespace(chars: &[(usize, char)], i: usize) -> usize {
run_while(chars, i, |c| !c.is_whitespace())
}
fn number_end(chars: &[(usize, char)], start: usize) -> usize {
let mut i = start;
let mut consumed_digit = false;
while i < chars.len() {
let c = chars[i].1;
if c.is_numeric() {
consumed_digit = true;
i += 1;
} else if consumed_digit
&& matches!(c, '.' | ',' | ':' | '/' | '-')
&& chars.get(i + 1).is_some_and(|(_, n)| n.is_numeric())
{
i += 1;
} else if consumed_digit
&& c == 'h'
&& chars.get(i + 1).is_some_and(|(_, n)| n.is_numeric())
{
i += 1;
} else {
break;
}
}
i
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum LinkKind {
None,
Url,
Email,
}
fn link_end(chars: &[(usize, char)], i: usize) -> usize {
let mut end = run_until_whitespace(chars, i);
while end > i && matches!(chars[end - 1].1, ',' | '.' | '!' | '?' | ';' | ':' | ')' | ']' | '}') {
end -= 1;
}
end
}
fn classify_link(chars: &[(usize, char)], i: usize, end: usize) -> LinkKind {
let run: String = chars[i..end].iter().map(|(_, c)| *c).collect();
if run.starts_with("http://") || run.starts_with("https://") || run.starts_with("www.") {
return LinkKind::Url;
}
if let Some(at) = run.find('@') {
let has_dot_after_at = run[at + 1..].contains('.');
let alnum_before = run[..at].chars().any(|c| c.is_alphanumeric());
if has_dot_after_at && alnum_before {
return LinkKind::Email;
}
return LinkKind::None;
}
if let Some(dot) = run.rfind('.') {
let rest = &run[dot + 1..];
let tld = rest.split('/').next().unwrap_or("");
let tld_ok = (2..=20).contains(&tld.len()) && tld.chars().all(|c| c.is_ascii_alphabetic());
let has_before = run[..dot].chars().any(|c| c.is_alphanumeric());
let no_junk = !run.contains([',', '?', '!', ';', ':', '(', ')', '—']);
if has_before && tld_ok && no_junk {
return LinkKind::Url;
}
}
LinkKind::None
}
#[cfg(test)]
mod tests {
use super::*;
fn kinds(input: &str) -> Vec<(TokenKind, String)> {
TokenStream::tokenize(input)
.tokens
.iter()
.map(|t| (t.kind, t.text.clone()))
.collect()
}
fn k(kind: TokenKind, text: &str) -> (TokenKind, String) {
(kind, text.to_string())
}
#[test]
fn tokenizes_words_punct_and_spaces() {
assert_eq!(
kinds("tipo, o Sam"),
vec![
k(TokenKind::Word, "tipo"),
k(TokenKind::Punct, ","),
k(TokenKind::Whitespace, " "),
k(TokenKind::Word, "o"),
k(TokenKind::Whitespace, " "),
k(TokenKind::Word, "Sam"),
]
);
}
#[test]
fn tokenizes_pt_br_numbers_with_separators() {
assert_eq!(
kinds("às 14h30 e 1.000,50 e 3-5 e 15,"),
vec![
k(TokenKind::Word, "às"),
k(TokenKind::Whitespace, " "),
k(TokenKind::Number, "14h30"),
k(TokenKind::Whitespace, " "),
k(TokenKind::Word, "e"),
k(TokenKind::Whitespace, " "),
k(TokenKind::Number, "1.000,50"),
k(TokenKind::Whitespace, " "),
k(TokenKind::Word, "e"),
k(TokenKind::Whitespace, " "),
k(TokenKind::Number, "3-5"),
k(TokenKind::Whitespace, " "),
k(TokenKind::Word, "e"),
k(TokenKind::Whitespace, " "),
k(TokenKind::Number, "15"),
k(TokenKind::Punct, ","),
]
);
}
#[test]
fn tokenizes_urls_and_domains_as_single_tokens() {
assert_eq!(
kinds("https://tipo.com e www.sabe.com.br e tipo.com"),
vec![
k(TokenKind::Url, "https://tipo.com"),
k(TokenKind::Whitespace, " "),
k(TokenKind::Word, "e"),
k(TokenKind::Whitespace, " "),
k(TokenKind::Url, "www.sabe.com.br"),
k(TokenKind::Whitespace, " "),
k(TokenKind::Word, "e"),
k(TokenKind::Whitespace, " "),
k(TokenKind::Url, "tipo.com"),
]
);
}
#[test]
fn tokenizes_long_tld_domains() {
assert_eq!(
kinds("acesse semantic.technology e o site novo.consulting"),
vec![
k(TokenKind::Word, "acesse"),
k(TokenKind::Whitespace, " "),
k(TokenKind::Url, "semantic.technology"),
k(TokenKind::Whitespace, " "),
k(TokenKind::Word, "e"),
k(TokenKind::Whitespace, " "),
k(TokenKind::Word, "o"),
k(TokenKind::Whitespace, " "),
k(TokenKind::Word, "site"),
k(TokenKind::Whitespace, " "),
k(TokenKind::Url, "novo.consulting"),
]
);
}
#[test]
fn tokenizes_emails() {
assert_eq!(
kinds("fale com fulano@sabe.com hoje"),
vec![
k(TokenKind::Word, "fale"),
k(TokenKind::Whitespace, " "),
k(TokenKind::Word, "com"),
k(TokenKind::Whitespace, " "),
k(TokenKind::Email, "fulano@sabe.com"),
k(TokenKind::Whitespace, " "),
k(TokenKind::Word, "hoje"),
]
);
}
#[test]
fn sentence_period_is_not_a_domain() {
assert_eq!(
kinds("terminei. Hoje"),
vec![
k(TokenKind::Word, "terminei"),
k(TokenKind::Punct, "."),
k(TokenKind::Whitespace, " "),
k(TokenKind::Word, "Hoje"),
]
);
}
#[test]
fn render_is_lossless() {
let samples = [
"tipo, o Sam Altman usa o Claude Code né?",
"às 14h30, 1.000,50 e 3-5",
"https://tipo.com e fulano@sabe.com",
"oi,tudo bem — tudo sim!",
" espaços e quebras ",
"café com pão, mas sem açúcar!",
];
for s in samples {
assert_eq!(TokenStream::tokenize(s).render(), *s);
}
}
#[test]
fn prev_next_significant_skip_spaces() {
let ts = TokenStream::tokenize("tipo, o Sam");
assert!(ts.prev_significant(0).is_none());
assert_eq!(ts.next_significant(0).unwrap().text, ",");
assert_eq!(ts.prev_significant(3).unwrap().text, ",");
assert_eq!(ts.next_significant(3).unwrap().text, "Sam");
assert!(ts.next_significant(5).is_none());
}
#[test]
fn unicode_and_emoji_do_not_panic() {
let ts = TokenStream::tokenize("İstanbul ẞ ǰ café 🎉 combinado\u{301} a\u{301}");
assert_eq!(ts.render(), "İstanbul ẞ ǰ café 🎉 combinado\u{301} a\u{301}");
}
}