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 if len > 1 {
(len as f64 / 2.0).ceil() as u64
} else {
1
};
}
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 re = patterns::split_pattern();
let mut out = Vec::new();
let mut last = 0;
for m in re.find_iter(text) {
if m.start() > last {
out.push(&text[last..m.start()]);
}
out.push(m.as_str());
last = m.end();
}
if last < text.len() {
out.push(&text[last..]);
}
out.retain(|s| !s.is_empty());
out
}
#[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", " "]);
}
}