use std::io::{Read, Write};
pub fn estimate_tokens(s: &str) -> usize {
if s.is_ascii() {
return s.len().div_ceil(4);
}
estimate_bytes(s.as_bytes())
}
fn estimate_bytes(b: &[u8]) -> usize {
let mut tokens = 0usize;
let mut ascii_run = 0usize;
for &c in b {
if c < 0x80 {
ascii_run += 1;
if ascii_run == 4 {
tokens += 1;
ascii_run = 0;
}
} else if c & 0xC0 != 0x80 {
if ascii_run > 0 {
tokens += ascii_run.div_ceil(4);
ascii_run = 0;
}
tokens += 1;
}
}
tokens + ascii_run.div_ceil(4)
}
fn head_byte_end(s: &str, n: usize) -> usize {
let mut chars = 0usize;
for (i, &b) in s.as_bytes().iter().enumerate() {
if b & 0xC0 != 0x80 {
chars += 1;
if chars > n {
return i;
}
}
}
s.len()
}
fn tail_byte_start(s: &str, n: usize) -> usize {
let mut chars = 0usize;
for (i, &b) in s.as_bytes().iter().enumerate().rev() {
if b & 0xC0 != 0x80 {
chars += 1;
if chars > n {
return i + utf8_len(b);
}
}
}
0
}
fn utf8_len(b: u8) -> usize {
if b < 0x80 {
1
} else if b & 0xE0 == 0xC0 {
2
} else if b & 0xF0 == 0xE0 {
3
} else {
4
}
}
pub fn prune_to_budget(s: &str, max_tokens: usize) -> String {
prune_with_total(s, max_tokens, estimate_tokens(s))
}
#[inline]
fn char_count(s: &str) -> usize {
s.as_bytes().iter().filter(|&&b| b & 0xC0 != 0x80).count()
}
fn prune_with_total(s: &str, max_tokens: usize, total: usize) -> String {
if total <= max_tokens {
return s.to_string();
}
if max_tokens == 0 {
return String::new();
}
let chars = char_count(s);
let marker = format!("\n\n...[rdsh pruned {total}->{max_tokens} tokens]...\n\n");
if estimate_tokens(&marker) >= max_tokens {
let mut low = 0;
let mut high = chars.min(max_tokens.saturating_mul(4));
while low < high {
let mid = low + (high - low).div_ceil(2);
if estimate_tokens(&s[..head_byte_end(s, mid)]) <= max_tokens {
low = mid;
} else {
high = mid - 1;
}
}
return s[..head_byte_end(s, low)].to_string();
}
let candidate = |keep: usize| {
let head_chars = keep * 2 / 3;
let tail_chars = keep - head_chars;
let head = &s[..head_byte_end(s, head_chars)];
let tail = if tail_chars == 0 {
""
} else {
&s[tail_byte_start(s, tail_chars)..]
};
format!("{head}{marker}{tail}")
};
let mut low = 0;
let mut high = chars.saturating_sub(1).min(max_tokens.saturating_mul(4));
while low < high {
let mid = low + (high - low).div_ceil(2);
if estimate_tokens(&candidate(mid)) <= max_tokens {
low = mid;
} else {
high = mid - 1;
}
}
candidate(low)
}
fn read_stdin() -> anyhow::Result<String> {
let mut buf = String::with_capacity(1 << 16);
std::io::stdin().read_to_string(&mut buf)?;
Ok(buf)
}
fn read_file(path: &str, locale: crate::locale::Locale) -> anyhow::Result<String> {
std::fs::read_to_string(path).map_err(|error| match locale {
crate::locale::Locale::English => error.into(),
crate::locale::Locale::Chinese | crate::locale::Locale::Japanese => anyhow::anyhow!(
"{} {path}: {error}",
crate::locale::native_message(locale, crate::locale::NativeMessage::CannotReadFile)
),
})
}
fn stdout_writer() -> std::io::BufWriter<std::io::StdoutLock<'static>> {
std::io::BufWriter::with_capacity(256 * 1024, std::io::stdout().lock())
}
pub fn cmd_tokens(
files: Vec<String>,
preview: usize,
locale: crate::locale::Locale,
) -> anyhow::Result<()> {
let mut out = stdout_writer();
if files.is_empty() {
let text = read_stdin()?;
writeln!(
out,
"{{\"tokens\": {}, \"chars\": {}}}",
estimate_tokens(&text),
text.chars().count()
)?;
out.flush()?;
return Ok(());
}
let mut total = 0usize;
for f in &files {
let text = read_file(f, locale)?;
let t = estimate_tokens(&text);
total += t;
writeln!(out, "{t:>8} {f}")?;
if preview > 0 {
let pv = text[..head_byte_end(&text, preview)].replace('\n', "\\n");
writeln!(
out,
" {}: {pv}",
crate::locale::native_message(locale, crate::locale::NativeMessage::Preview)
)?;
}
}
if files.len() > 1 {
writeln!(
out,
"{total:>8} ({})",
crate::locale::native_message(locale, crate::locale::NativeMessage::Total)
)?;
}
out.flush()?;
Ok(())
}
pub fn cmd_prune(
max_tokens: usize,
file: Option<String>,
locale: crate::locale::Locale,
) -> anyhow::Result<()> {
let text = match file {
Some(f) => read_file(&f, locale)?,
None => read_stdin()?,
};
let parsed: Option<serde_json::Value> = match text.trim_start().as_bytes().first() {
Some(b'{' | b'[' | b'"') => serde_json::from_str(&text).ok(),
_ => None,
};
let raw: &str = parsed
.as_ref()
.and_then(|v| v.get("content"))
.and_then(|c| c.as_str())
.filter(|c| !c.is_empty())
.unwrap_or(&text);
let before = estimate_tokens(raw);
let pruned = prune_with_total(raw, max_tokens, before);
let after = estimate_tokens(&pruned);
match locale {
crate::locale::Locale::English => {
eprintln!("[rdsh] tokens {before} -> {after} (budget {max_tokens})");
}
crate::locale::Locale::Chinese => {
eprintln!("[rdsh] 令牌数 {before} -> {after}(预算 {max_tokens})");
}
crate::locale::Locale::Japanese => {
eprintln!("[rdsh] トークン数 {before} -> {after}(予算 {max_tokens})");
}
}
let mut out = stdout_writer();
write!(out, "{pruned}")?;
out.flush()?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ascii_counts() {
assert_eq!(estimate_tokens(""), 0);
assert_eq!(estimate_tokens("a"), 1);
assert_eq!(estimate_tokens("abcd"), 1);
assert_eq!(estimate_tokens("abcde"), 2);
assert_eq!(estimate_tokens("hello world, token test"), 6);
}
#[test]
fn non_ascii_counts() {
assert_eq!(estimate_tokens("\u{e9}\u{e9}\u{e9}\u{e9}"), 4);
assert_eq!(estimate_tokens("\u{3042}"), 1);
assert_eq!(estimate_tokens("abc\u{3042}"), 2);
assert_eq!(estimate_tokens("\u{3042}abc"), 2);
assert_eq!(estimate_tokens("a"), 1);
assert_eq!(estimate_tokens("abcXdef"), 2);
}
#[test]
fn prune_keeps_small() {
let s = "short text";
assert_eq!(prune_to_budget(s, 4000), s);
}
#[test]
fn prune_marks_big() {
let s: String = "x".repeat(20000);
let p = prune_to_budget(&s, 100);
assert!(p.contains("rdsh pruned"));
assert!(estimate_tokens(&p) <= 100);
}
#[test]
fn prune_respects_small_and_unicode_budgets() {
for source in ["abcdef".repeat(1000), "日本語abc".repeat(1000)] {
for budget in [0, 1, 2, 4, 8, 16, 32, 100] {
let result = prune_to_budget(&source, budget);
assert!(estimate_tokens(&result) <= budget, "budget {budget}");
}
}
}
#[test]
fn inline_type_budget_for_tool_catalog() {
let small = r#"{"name":"search","input":{"q":"string"}}"#;
assert_eq!(prune_to_budget(small, 4000), small);
let big = r#"{"type":"object"}"#.repeat(2000);
let pruned = prune_to_budget(&big, 64);
assert!(estimate_tokens(&pruned) <= 64);
assert!(pruned.contains("rdsh pruned"));
}
#[test]
fn small_state_snapshot_kept_verbatim() {
let state = r#"{"cursor":"abc123","hits":[1,2,3]}"#;
let budget = estimate_tokens(state) + 10;
assert_eq!(prune_to_budget(state, budget), state);
}
#[test]
fn classifier_batch_fits_shared_budget() {
let choices = ["yes".to_string(), "no".to_string(), "はい".to_string()];
let total: usize = choices.iter().map(|c| estimate_tokens(c)).sum();
assert_eq!(total, 4); let joined = choices.join("\n").repeat(500);
let pruned = prune_to_budget(&joined, 48);
assert!(estimate_tokens(&pruned) <= 48);
}
#[test]
fn cjk_chars_count_one_each() {
assert_eq!(estimate_tokens("あ"), 1);
assert_eq!(estimate_tokens("日本語"), 3);
assert_eq!(estimate_tokens("abc日本"), 3);
}
#[test]
fn prune_keeps_head_and_tail_order() {
let s: String = (0..5000).map(|i| format!("L{i:04} ")).collect();
let p = prune_to_budget(&s, 50);
assert!(estimate_tokens(&p) <= 50);
let marker = p.find("rdsh pruned").expect("marker");
assert!(p[..marker].contains("L0000"));
assert!(p[marker..].contains("L4999"));
}
}