use crate::patterns;
use regex::{Regex, RegexBuilder};
use std::sync::OnceLock;
#[derive(Clone, Debug)]
pub struct LanguageConfig {
pub pattern: Regex,
pub average_chars_per_token: f64,
}
impl LanguageConfig {
pub fn new(pattern: &str, average_chars_per_token: f64) -> Result<Self, regex::Error> {
Ok(Self {
pattern: Regex::new(pattern)?,
average_chars_per_token,
})
}
pub fn case_insensitive(
pattern: &str,
average_chars_per_token: f64,
) -> Result<Self, regex::Error> {
Ok(Self {
pattern: RegexBuilder::new(pattern).case_insensitive(true).build()?,
average_chars_per_token,
})
}
}
#[derive(Clone, Debug, Default)]
pub struct TokenEstimationOptions {
pub default_chars_per_token: Option<f64>,
pub language_configs: Option<Vec<LanguageConfig>>,
}
#[derive(Clone, Debug, Default)]
pub struct SplitByTokensOptions {
pub default_chars_per_token: Option<f64>,
pub language_configs: Option<Vec<LanguageConfig>>,
pub overlap: Option<u64>,
}
pub fn default_language_configs() -> &'static [LanguageConfig] {
static CONFIGS: OnceLock<Vec<LanguageConfig>> = OnceLock::new();
CONFIGS
.get_or_init(|| {
vec![
LanguageConfig::case_insensitive("[äöüßẞ]", 3.0).unwrap(),
LanguageConfig::case_insensitive("[éèêëàâîïôûùüÿçœæáíóúñ]", 3.0).unwrap(),
LanguageConfig::case_insensitive("[ąćęłńóśźżěščřžýůúďťň]", 3.5).unwrap(),
]
})
.as_slice()
}
pub fn utf16_len(s: &str) -> usize {
s.encode_utf16().count()
}
pub fn char_count(s: &str) -> usize {
s.chars().count()
}
pub fn estimate_segment_tokens(
segment: &str,
language_configs: &[LanguageConfig],
default_chars_per_token: f64,
) -> u64 {
if patterns::whitespace().is_match(segment) {
return 0;
}
if patterns::cjk().is_match(segment) {
return char_count(segment) as u64;
}
if patterns::numeric().is_match(segment) {
return 1;
}
let len = utf16_len(segment);
if len <= patterns::SHORT_TOKEN_THRESHOLD {
return 1;
}
if patterns::punctuation().is_match(segment) {
return (len as f64 / 2.0).ceil() as u64;
}
let chars_per_token = language_specific_chars_per_token(segment, language_configs)
.unwrap_or(default_chars_per_token);
(len as f64 / chars_per_token).ceil() as u64
}
fn language_specific_chars_per_token(
segment: &str,
language_configs: &[LanguageConfig],
) -> Option<f64> {
language_configs
.iter()
.find(|c| c.pattern.is_match(segment))
.map(|c| c.average_chars_per_token)
}
pub fn split_segments(text: &str) -> Vec<&str> {
let mut out = Vec::new();
let mut start = 0;
let mut i = 0;
while i < text.len() {
if i == start
&& text.as_bytes()[i].is_ascii_digit()
&& scan_boundary_number(text, &mut start, &mut i, &mut out)
{
continue;
}
if is_delimiter_at(text, i) {
if i > start {
out.push(&text[start..i]);
}
let delimiter_start = i;
let punctuation = is_punctuation(text.as_bytes()[i]);
i += delimiter_len_at(text, i);
while i < text.len()
&& ((punctuation && is_punctuation(text.as_bytes()[i]))
|| (!punctuation && is_whitespace_at(text, i)))
{
i += delimiter_len_at(text, i);
}
out.push(&text[delimiter_start..i]);
start = i;
} else {
i += char_len_at(text, i);
}
}
if start < text.len() {
out.push(&text[start..]);
}
out
}
fn scan_boundary_number<'a>(
text: &'a str,
start: &mut usize,
i: &mut usize,
out: &mut Vec<&'a str>,
) -> bool {
let original_start = *start;
let original_len = out.len();
let bytes = text.as_bytes();
loop {
let digit_start = *i;
consume_ascii_digits(bytes, i);
if *i < bytes.len()
&& matches!(bytes[*i], b'.' | b',')
&& *i + 1 < bytes.len()
&& bytes[*i + 1].is_ascii_digit()
{
out.push(&text[digit_start..*i]);
out.push(&text[*i..*i + 1]);
*i += 1;
continue;
}
let standalone_end = *i == text.len() || is_delimiter_at(text, *i);
if standalone_end && patterns::numeric().is_match(&text[original_start..*i]) {
out.truncate(original_len);
out.push(&text[original_start..*i]);
*start = *i;
} else if out.len() == original_len {
*start = original_start;
} else {
*start = digit_start;
}
return true;
}
}
fn consume_ascii_digits(bytes: &[u8], i: &mut usize) {
while *i < bytes.len() && bytes[*i].is_ascii_digit() {
*i += 1;
}
}
fn is_delimiter_at(text: &str, i: usize) -> bool {
is_punctuation(text.as_bytes()[i]) || is_whitespace_at(text, i)
}
fn is_whitespace_at(text: &str, i: usize) -> bool {
is_ecmascript_whitespace(text[i..].chars().next().unwrap())
}
fn delimiter_len_at(text: &str, i: usize) -> usize {
if is_punctuation(text.as_bytes()[i]) {
1
} else {
char_len_at(text, i)
}
}
fn char_len_at(text: &str, i: usize) -> usize {
text[i..].chars().next().unwrap().len_utf8()
}
fn is_punctuation(byte: u8) -> bool {
matches!(
byte,
b'.' | b','
| b'!'
| b'?'
| b';'
| b'('
| b')'
| b'{'
| b'}'
| b'['
| b']'
| b'<'
| b'>'
| b':'
| b'/'
| b'\\'
| b'|'
| b'@'
| b'#'
| b'$'
| b'%'
| b'^'
| b'&'
| b'*'
| b'+'
| b'='
| b'`'
| b'~'
| b'_'
| b'-'
)
}
fn is_ecmascript_whitespace(ch: char) -> bool {
if ('\u{2000}'..='\u{200A}').contains(&ch) {
return true;
}
matches!(
ch,
'\u{0009}'
| '\u{000A}'
| '\u{000B}'
| '\u{000C}'
| '\u{000D}'
| '\u{0020}'
| '\u{00A0}'
| '\u{1680}'
| '\u{2028}'
| '\u{2029}'
| '\u{202F}'
| '\u{205F}'
| '\u{3000}'
| '\u{FEFF}'
)
}
#[cfg(test)]
mod tests {
use super::*;
fn est(segment: &str) -> u64 {
estimate_segment_tokens(
segment,
default_language_configs(),
patterns::DEFAULT_CHARS_PER_TOKEN,
)
}
#[test]
fn segment_math_table() {
let cases: &[(&str, u64)] = &[
("is", 1), ("a", 1), (",", 1), ("!!!", 1), ("!!!!", 2), ("::::", 2), ("12345", 1), ("3.14", 1), ("1,000.50", 1), ("Hello", 1), ("sentence", 2), ("道德經", 3), ("中😀文", 3), ("中\u{20000}文", 3), ("pünktlich", 3), ("français", 3), ("señor", 2), ("źdźbło", 2), ("příliš", 2), ];
for (input, want) in cases {
assert_eq!(est(input), *want, "segment {input:?}");
}
}
#[test]
fn whitespace_segment_is_zero() {
assert_eq!(est(" "), 0);
assert_eq!(est("\t\n "), 0);
}
#[test]
fn uppercase_accents_match_language_rules() {
assert_eq!(est("ÜBERGROSS"), (9.0_f64 / 3.0).ceil() as u64);
}
#[test]
fn split_keeps_delimiters() {
let segs = split_segments("Hello, world!");
assert_eq!(segs, vec!["Hello", ",", " ", "world", "!"]);
}
#[test]
fn split_drops_empty_pieces() {
let segs = split_segments(" a ");
assert_eq!(segs, vec![" ", "a", " "]);
}
}