1use serde::Deserialize;
13use std::collections::{HashMap, HashSet};
14use std::path::Path;
15use std::sync::OnceLock;
16use unicode_normalization::UnicodeNormalization;
17
18const DEFAULT_SPLIT: &str = r"(?i:'s|'t|'re|'ve|'m|'ll|'d)|[^\r\n\p{L}\p{N}]?\p{L}+|\p{N}| ?[^\s\p{L}\p{N}]+[\r\n]*|\s*[\r\n]+|\s+(?!\S)|\s+";
21
22pub const TOOL_MARKUP_TOKENS: &[&str] = &[
34 "<tool_call>",
35 "</tool_call>",
36 "<function",
37 "</function>",
38 "<param",
39 "</param>",
40];
41
42pub struct Tokenizer {
44 vocab: HashMap<String, u32>,
46 id_to_token: Vec<String>,
48 ranks: HashMap<(String, String), u32>,
50 sp_ids: OnceLock<Option<SpMerges>>,
54 added: Vec<(String, u32)>,
56 added_ids: HashSet<u32>,
58 special_ids: HashSet<u32>,
60 split_res: Vec<fancy_regex::Regex>,
64 split_drop_gap: Vec<bool>,
70 eow_suffix: Option<String>,
75 lowercase: bool,
78 collapse_ws: bool,
81 sp_prepend: bool,
84 sp_prepend_first: bool,
90 metaspace: bool,
93 nfc: bool,
96 byte_to_char: [char; 256],
98 char_to_byte: HashMap<char, u8>,
100 pub bos_token_id: Option<u32>,
102 pub eos_token_id: Option<u32>,
103 pub pad_token_id: Option<u32>,
104 pub im_start_id: Option<u32>,
106 pub im_end_id: Option<u32>,
107 pub chat_template: Option<String>,
110 pub extra_eos: HashSet<u32>,
112 pub add_bos: bool,
114}
115
116impl std::fmt::Debug for Tokenizer {
117 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
118 f.debug_struct("Tokenizer")
119 .field("vocab", &self.vocab.len())
120 .field("merges", &self.ranks.len())
121 .field("added", &self.added.len())
122 .finish()
123 }
124}
125
126fn bytes_to_unicode() -> ([char; 256], HashMap<char, u8>) {
129 let mut b2c = ['\0'; 256];
130 let mut c2b = HashMap::with_capacity(256);
131 let mut n = 0u32;
132 for b in 0..=255u16 {
133 let printable =
134 (0x21..=0x7E).contains(&b) || (0xA1..=0xAC).contains(&b) || (0xAE..=0xFF).contains(&b);
135 let c = if printable {
136 char::from_u32(b as u32).unwrap()
137 } else {
138 let c = char::from_u32(256 + n).unwrap();
139 n += 1;
140 c
141 };
142 b2c[b as usize] = c;
143 c2b.insert(c, b as u8);
144 }
145 (b2c, c2b)
146}
147
148#[derive(Deserialize)]
150struct HfTokenizerJson {
151 model: HfModel,
152 #[serde(default)]
153 added_tokens: Vec<HfAddedToken>,
154 #[serde(default)]
155 pre_tokenizer: Option<serde_json::Value>,
156 #[serde(default)]
157 normalizer: Option<serde_json::Value>,
158 #[serde(default)]
159 post_processor: Option<serde_json::Value>,
160}
161
162#[derive(Deserialize)]
163struct HfModel {
164 vocab: HashMap<String, u32>,
165 #[serde(default)]
166 merges: Vec<HfMerge>,
167 #[serde(default)]
168 byte_fallback: bool,
169 #[serde(default)]
171 end_of_word_suffix: Option<String>,
172}
173
174#[derive(Deserialize)]
177#[serde(untagged)]
178enum HfMerge {
179 Pair([String; 2]),
180 Text(String),
181}
182
183#[derive(Deserialize)]
184struct HfAddedToken {
185 id: u32,
186 content: String,
187 special: bool,
188}
189
190fn collect_split_patterns(pt: &serde_json::Value, out: &mut Vec<String>) {
199 if pt.get("type").and_then(|t| t.as_str()) == Some("Split") {
200 if let Some(r) = pt
201 .get("pattern")
202 .and_then(|p| p.get("Regex"))
203 .and_then(|r| r.as_str())
204 {
205 out.push(r.to_string());
206 }
207 return;
208 }
209 if let Some(list) = pt.get("pretokenizers").and_then(|l| l.as_array()) {
210 for p in list {
211 collect_split_patterns(p, out);
212 }
213 }
214}
215
216fn collect_split_drop_gaps(pt: &serde_json::Value, out: &mut Vec<bool>) {
220 if pt.get("type").and_then(|t| t.as_str()) == Some("Split") {
221 let behavior = pt.get("behavior").and_then(|b| b.as_str()).unwrap_or("");
222 let invert = pt.get("invert").and_then(|i| i.as_bool()).unwrap_or(false);
223 out.push(behavior == "Removed" && invert);
224 return;
225 }
226 if let Some(list) = pt.get("pretokenizers").and_then(|l| l.as_array()) {
227 for p in list {
228 collect_split_drop_gaps(p, out);
229 }
230 }
231}
232
233fn find_prepend_scheme(pt: &serde_json::Value) -> Option<String> {
236 if pt.get("type").and_then(|t| t.as_str()) == Some("Metaspace") {
237 return pt
238 .get("prepend_scheme")
239 .and_then(|p| p.as_str())
240 .map(String::from);
241 }
242 if let Some(list) = pt.get("pretokenizers").and_then(|l| l.as_array()) {
243 return list.iter().find_map(find_prepend_scheme);
244 }
245 None
246}
247
248pub(crate) fn strip_generation_tags(tpl: &str) -> std::borrow::Cow<'_, str> {
264 if !tpl.contains("generation") {
265 return std::borrow::Cow::Borrowed(tpl);
266 }
267 let mut out = String::with_capacity(tpl.len());
268 let mut rest = tpl;
269 let mut touched = false;
270 while let Some(open) = rest.find("{%") {
271 let Some(close_rel) = rest[open..].find("%}") else {
272 break;
273 };
274 let close = open + close_rel + 2;
275 let tag = &rest[open..close];
276 let inner = tag[2..tag.len() - 2].trim();
277 let lead = inner.starts_with('-');
278 let trail = inner.ends_with('-');
279 let name = inner.trim_matches('-').trim();
280 out.push_str(&rest[..open]);
281 if name == "generation" || name == "endgeneration" {
282 out.push_str(if lead { "{%-" } else { "{%" });
283 out.push_str(" set _generation_span = true ");
284 out.push_str(if trail { "-%}" } else { "%}" });
285 touched = true;
286 } else {
287 out.push_str(tag);
288 }
289 rest = &rest[close..];
290 }
291 if !touched {
292 return std::borrow::Cow::Borrowed(tpl);
293 }
294 out.push_str(rest);
295 std::borrow::Cow::Owned(out)
296}
297
298pub fn template_reads_reasoning_effort(tpl: &str) -> bool {
303 tpl.contains("reasoning_effort")
304}
305
306fn close_direct_think(rendered: String, enable_thinking: Option<bool>) -> String {
315 if enable_thinking != Some(false) || rendered.contains("</think>") {
316 return rendered;
317 }
318 if let Some(pos) = rendered.rfind("<|assistant|>") {
319 let marker_end = pos + "<|assistant|>".len();
320 let after = &rendered[marker_end..];
321 let mut out = String::with_capacity(rendered.len() + 24);
322 out.push_str(&rendered[..marker_end]);
323 if let Some(rest) = after.strip_prefix("<think>") {
324 out.push_str("<think></think>\n\n");
325 out.push_str(rest);
326 } else {
327 out.push_str(after);
328 if !out.ends_with('\n') {
329 out.push('\n');
330 }
331 out.push_str("<think>\n\n</think>\n\n");
332 }
333 return out;
334 }
335 if let Some(pos) = rendered.rfind("<|im_start|>assistant") {
338 let mut insert_at = pos + "<|im_start|>assistant".len();
339 if let Some(idx) = rendered[insert_at..].find('\n') {
340 insert_at += idx + 1;
341 }
342 let mut out = String::with_capacity(rendered.len() + 24);
343 out.push_str(&rendered[..insert_at]);
344 if !out.ends_with('\n') {
345 out.push('\n');
346 }
347 out.push_str("<think>\n\n</think>\n\n");
348 out.push_str(&rendered[insert_at..]);
349 return out;
350 }
351 if let Some(pos) = rendered.rfind("\nassistant") {
355 let mut insert_at = pos + 1 + "assistant".len();
356 if let Some(idx) = rendered[insert_at..].find('\n') {
357 insert_at += idx + 1;
358 }
359 let mut out = String::with_capacity(rendered.len() + 24);
360 out.push_str(&rendered[..insert_at]);
361 if !out.ends_with('\n') {
362 out.push('\n');
363 }
364 out.push_str("<think>\n\n</think>\n\n");
365 out.push_str(&rendered[insert_at..]);
366 return out;
367 }
368 rendered
369}
370
371impl Tokenizer {
372 pub fn from_file(path: impl AsRef<Path>) -> Result<Self, TokenizerError> {
374 let data = std::fs::read_to_string(path.as_ref())
375 .map_err(|e| TokenizerError::Io(e.to_string()))?;
376 Self::from_json(&data)
377 }
378
379 pub fn from_bytes(bytes: &[u8]) -> Result<Self, TokenizerError> {
381 let s = std::str::from_utf8(bytes)
382 .map_err(|e| TokenizerError::Parse(format!("vocab is not UTF-8: {e}")))?;
383 Self::from_json(s)
384 }
385
386 pub fn from_json(json: &str) -> Result<Self, TokenizerError> {
388 let hf: HfTokenizerJson =
389 serde_json::from_str(json).map_err(|e| TokenizerError::Parse(e.to_string()))?;
390
391 let mut vocab = hf.model.vocab;
392 let mut ranks = HashMap::new();
393 for (rank, m) in hf.model.merges.into_iter().enumerate() {
394 let (a, b) = match m {
395 HfMerge::Pair([a, b]) => (a, b),
396 HfMerge::Text(s) => {
397 let mut it = s.splitn(2, ' ');
398 match (it.next(), it.next()) {
399 (Some(a), Some(b)) => (a.to_string(), b.to_string()),
400 _ => continue,
401 }
402 }
403 };
404 ranks.insert((a, b), rank as u32);
405 }
406
407 let mut saw_gemma_bos = false;
412 let add_bos_detected = hf
413 .post_processor
414 .as_ref()
415 .map(|p| {
416 let pp = p.to_string();
417 pp.contains("\"<s>\"") || pp.contains("\"<bos>\"")
418 })
419 .unwrap_or(false);
420 let nfc = hf
421 .normalizer
422 .as_ref()
423 .map(|n| n.to_string().contains("NFC"))
424 .unwrap_or(false);
425 let metaspace = hf.model.byte_fallback
426 || hf
427 .normalizer
428 .as_ref()
429 .map(|n| n.to_string().contains("\u{2581}") || n.to_string().contains("▁"))
430 .unwrap_or(false);
431 let sp_prepend = hf
432 .normalizer
433 .as_ref()
434 .map(|n| n.to_string().contains("Prepend"))
435 .unwrap_or(false);
436 let (sp_prepend, sp_prepend_first) = if sp_prepend {
440 (true, false)
441 } else {
442 match hf.pre_tokenizer.as_ref().and_then(find_prepend_scheme) {
443 Some(s) if s == "always" => (true, false),
444 Some(s) if s == "first" => (false, true),
445 _ => (false, false),
446 }
447 };
448 let (split_res, split_drop_gap) = if metaspace {
449 (Vec::new(), Vec::new())
450 } else {
451 let mut pats = Vec::new();
452 if let Some(pt) = hf.pre_tokenizer.as_ref() {
453 collect_split_patterns(pt, &mut pats);
454 }
455 if pats.is_empty() {
456 pats.push(DEFAULT_SPLIT.to_string());
457 }
458 let mut split_drop_gap = Vec::new();
459 if let Some(pt) = hf.pre_tokenizer.as_ref() {
460 collect_split_drop_gaps(pt, &mut split_drop_gap);
461 }
462 while split_drop_gap.len() < pats.len() {
463 split_drop_gap.push(false);
464 }
465 split_drop_gap.truncate(pats.len());
466 (
467 pats.iter()
468 .map(|p| {
469 fancy_regex::Regex::new(p)
470 .map_err(|e| TokenizerError::Parse(format!("pre-tokenizer regex: {e}")))
471 })
472 .collect::<Result<Vec<_>, _>>()?,
473 split_drop_gap,
474 )
475 };
476 let norm_s = hf
478 .normalizer
479 .as_ref()
480 .map(|n| n.to_string())
481 .unwrap_or_default();
482 let collapse_ws = norm_s.contains("\"Replace\"") && norm_s.contains("\\\\s+");
483 let lowercase = norm_s.contains("\"Lowercase\"");
484
485 let mut bos_token_id = None;
487 let mut eos_token_id = None;
488 let mut pad_token_id = None;
489 let mut im_start_id = None;
490 let mut im_end_id = None;
491 let mut special_ids = HashSet::new();
492 let mut added_ids = HashSet::new();
493 let mut added = Vec::new();
494
495 for at in &hf.added_tokens {
496 vocab.insert(at.content.clone(), at.id);
497 added.push((at.content.clone(), at.id));
498 added_ids.insert(at.id);
499 if at.special && !TOOL_MARKUP_TOKENS.contains(&at.content.as_str()) {
500 special_ids.insert(at.id);
501 }
502 match at.content.as_str() {
503 "<|endoftext|>" | "</s>" | "[EOS]" => eos_token_id = Some(at.id),
504 "<|im_start|>" => im_start_id = Some(at.id),
505 "<|im_end|>" => im_end_id = Some(at.id),
506 "<s>" | "[BOS]" | "<|startoftext|>" => bos_token_id = Some(at.id),
507 "<bos>" => {
511 bos_token_id = Some(at.id);
512 saw_gemma_bos = true;
513 }
514 "<pad>" => pad_token_id = Some(at.id),
515 _ => {}
516 }
517 }
518
519 const DSV41_SPECIALS: &[&str] = &[
524 "<|begin▁of▁sentence|>",
525 "<|end▁of▁sentence|>",
526 "<|User|>",
527 "<|Assistant|>",
528 "<|System|>",
529 "<|latest_reminder|>",
530 "<|deepseek_image|>",
531 "<|action|>",
532 "<|query|>",
533 "<|authority|>",
534 "<|domain|>",
535 "<|title|>",
536 "<|read_url|>",
537 "<think>",
538 "</think>",
539 "|DSML|",
540 ];
541 for token in DSV41_SPECIALS {
542 if let Some(&id) = vocab.get(*token) {
543 if !added.iter().any(|(content, _)| content.as_str() == *token) {
544 added.push(((*token).to_string(), id));
545 }
546 added_ids.insert(id);
547 match *token {
548 "<|begin▁of▁sentence|>" => bos_token_id = Some(id),
549 "<|end▁of▁sentence|>" => eos_token_id = Some(id),
550 _ => {}
551 }
552 }
553 }
554 added.sort_by_key(|(c, _)| std::cmp::Reverse(c.len()));
555
556 let gemma_family = saw_gemma_bos
563 || vocab.contains_key("<start_of_turn>")
564 || added.iter().any(|(c, _)| c == "<start_of_turn>");
565
566 if let Some(pp) = hf.post_processor.as_ref() {
570 let pp = pp.to_string();
571 for name in ["<bos>", "<s>"] {
572 if pp.contains(&format!("\"{name}\"")) {
573 if let Some(&id) = vocab.get(name) {
574 bos_token_id = Some(id);
575 }
576 break;
577 }
578 }
579 }
580
581 let max_id = vocab.values().copied().max().unwrap_or(0) as usize;
583 let mut id_to_token = vec![String::new(); max_id + 1];
584 for (token, &id) in &vocab {
585 if (id as usize) < id_to_token.len() {
586 id_to_token[id as usize] = token.clone();
587 }
588 }
589
590 let (byte_to_char, char_to_byte) = bytes_to_unicode();
591
592 tracing::info!(
593 "Tokenizer loaded: {} vocab, {} merges, {} added, eos={:?}",
594 vocab.len(),
595 ranks.len(),
596 added.len(),
597 eos_token_id
598 );
599
600 Ok(Self {
601 vocab,
602 id_to_token,
603 ranks,
604 sp_ids: OnceLock::new(),
605 added,
606 added_ids,
607 special_ids,
608 split_res,
609 split_drop_gap,
610 eow_suffix: hf.model.end_of_word_suffix,
611 lowercase,
612 collapse_ws,
613 metaspace,
614 sp_prepend,
615 sp_prepend_first,
616 nfc,
617 byte_to_char,
618 char_to_byte,
619 bos_token_id,
620 eos_token_id,
621 pad_token_id,
622 im_start_id,
623 im_end_id,
624 chat_template: None,
625 extra_eos: HashSet::new(),
626 add_bos: add_bos_detected || gemma_family,
627 })
628 }
629
630 pub fn byte_level() -> Self {
632 let mut vocab = HashMap::new();
633 let mut id_to_token = Vec::with_capacity(256);
634 for i in 0..256u32 {
635 let tok = format!("<0x{:02X}>", i);
636 vocab.insert(tok.clone(), i);
637 id_to_token.push(tok);
638 }
639 let (byte_to_char, char_to_byte) = bytes_to_unicode();
640 Self {
641 vocab,
642 id_to_token,
643 ranks: HashMap::new(),
644 sp_ids: OnceLock::new(),
645 added: Vec::new(),
646 added_ids: HashSet::new(),
647 special_ids: HashSet::new(),
648 split_res: Vec::new(),
649 split_drop_gap: Vec::new(),
650 eow_suffix: None,
651 lowercase: false,
652 collapse_ws: false,
653 metaspace: false,
654 sp_prepend: false,
655 sp_prepend_first: false,
656 nfc: false,
657 byte_to_char,
658 char_to_byte,
659 bos_token_id: None,
660 eos_token_id: None,
661 pad_token_id: None,
662 im_start_id: None,
663 im_end_id: None,
664 chat_template: None,
665 extra_eos: HashSet::new(),
666 add_bos: false,
667 }
668 }
669
670 pub fn encode(&self, text: &str) -> Vec<u32> {
672 let mut ids = Vec::new();
673 let mut at = 0;
675 let mut next: Vec<Option<usize>> =
680 self.added.iter().map(|(content, _)| text.find(content.as_str())).collect();
681 let mut head = true;
686 'outer: while at < text.len() {
687 let mut best: Option<(usize, usize, u32)> = None; for (i, (content, id)) in self.added.iter().enumerate() {
689 if next[i].is_some_and(|p| p < at) {
690 next[i] = text[at..].find(content.as_str()).map(|p| at + p);
691 }
692 if let Some(pos) = next[i] {
693 let better = match best {
694 None => true,
695 Some((bp, bl, _)) => pos < bp || (pos == bp && content.len() > bl),
696 };
697 if better {
698 best = Some((pos, content.len(), *id));
699 }
700 if pos == at {
701 break; }
703 }
704 }
705 match best {
706 Some((pos, len, id)) => {
707 self.encode_segment_at(&text[at..pos], head, &mut ids);
708 ids.push(id);
709 at = pos + len;
710 head = false;
711 }
712 None => {
713 self.encode_segment_at(&text[at..], head, &mut ids);
714 break 'outer;
715 }
716 }
717 }
718 ids
719 }
720
721 pub fn encode_plain(&self, text: &str) -> Vec<u32> {
727 let mut ids = Vec::new();
728 self.encode_segment_at(text, true, &mut ids);
729 ids
730 }
731
732 fn encode_segment_at(&self, segment: &str, head: bool, out: &mut Vec<u32>) {
737 if segment.is_empty() {
738 return;
739 }
740 let mut norm: String = if self.nfc {
741 segment.nfc().collect()
742 } else {
743 segment.to_string()
744 };
745 if self.collapse_ws {
746 let mut collapsed = String::with_capacity(norm.len());
748 let mut in_ws = false;
749 for ch in norm.chars() {
750 if ch.is_whitespace() {
751 if !in_ws {
752 collapsed.push(' ');
753 in_ws = true;
754 }
755 } else {
756 collapsed.push(ch);
757 in_ws = false;
758 }
759 }
760 norm = collapsed;
761 }
762 if self.lowercase {
763 norm = norm.to_lowercase();
764 }
765 if self.metaspace {
766 let sp = if self.sp_prepend {
770 format!("\u{2581}{}", norm).replace(' ', "\u{2581}")
773 } else {
774 let replaced = norm.replace(' ', "\u{2581}");
778 if self.sp_prepend_first && head && !replaced.starts_with('\u{2581}') {
779 format!("\u{2581}{replaced}")
780 } else {
781 replaced
782 }
783 };
784 self.bpe_piece_sp(&sp, out);
785 return;
786 }
787 if !self.split_res.is_empty() {
788 let mut pieces: Vec<(usize, usize)> = vec![(0, norm.len())];
793 for (stage, re) in self.split_res.iter().enumerate() {
794 let drop = self.split_drop_gap.get(stage).copied().unwrap_or(false);
795 let mut next: Vec<(usize, usize)> = Vec::with_capacity(pieces.len() * 2);
796 for (ps, pe) in pieces {
797 let seg = &norm[ps..pe];
798 let mut last = 0usize;
799 for m in re.find_iter(seg) {
800 let m = match m {
801 Ok(m) => m,
802 Err(e) => {
803 tracing::error!("pre-tokenizer regex failed: {e}");
804 break;
805 }
806 };
807 if m.start() > last && !drop {
808 next.push((ps + last, ps + m.start()));
809 }
810 if m.end() > m.start() {
811 next.push((ps + m.start(), ps + m.end()));
812 }
813 last = m.end();
814 }
815 if last < seg.len() && !drop {
816 next.push((ps + last, pe));
817 }
818 }
819 pieces = next;
820 }
821 for (ps, pe) in pieces {
822 self.bpe_piece(&norm[ps..pe], out);
823 }
824 } else {
825 {
826 for b in norm.bytes() {
828 let tok = format!("<0x{:02X}>", b);
829 if let Some(&id) = self.vocab.get(&tok) {
830 out.push(id);
831 }
832 }
833 }
834 }
835 }
836
837 fn sp_merges(&self) -> Option<&SpMerges> {
839 self.sp_ids
840 .get_or_init(|| SpMerges::build(&self.vocab, &self.ranks))
841 .as_ref()
842 }
843
844 fn bpe_piece_sp(&self, piece: &str, out: &mut Vec<u32>) {
853 if piece.is_empty() {
854 return;
855 }
856 if !SP_STRINGS.with(|f| f.get()) {
857 if let Some(m) = self.sp_merges() {
858 m.encode(&self.vocab, piece, out);
859 return;
860 }
861 }
862 self.bpe_piece_sp_strings(piece, out);
863 }
864
865 #[doc(hidden)]
868 pub fn encode_reference(&self, text: &str) -> Vec<u32> {
869 SP_STRINGS.with(|f| f.set(true));
870 let ids = self.encode(text);
871 SP_STRINGS.with(|f| f.set(false));
872 ids
873 }
874
875 fn bpe_piece_sp_strings(&self, piece: &str, out: &mut Vec<u32>) {
876 let mut sym: Vec<String> = piece.chars().map(|c| c.to_string()).collect();
877 loop {
878 let mut best: Option<(u32, usize)> = None;
879 for i in 0..sym.len().saturating_sub(1) {
880 if let Some(&r) = self.ranks.get(&(sym[i].clone(), sym[i + 1].clone())) {
881 if best.map(|(br, _)| r < br).unwrap_or(true) {
882 best = Some((r, i));
883 }
884 }
885 }
886 let Some((_, i)) = best else { break };
887 let merged = format!("{}{}", sym[i], sym[i + 1]);
888 let (left, right) = (sym[i].clone(), sym[i + 1].clone());
889 let mut j = 0;
890 while j + 1 < sym.len() {
891 if sym[j] == left && sym[j + 1] == right {
892 sym[j] = merged.clone();
893 sym.remove(j + 1);
894 }
895 j += 1;
896 }
897 }
898 for t in &sym {
899 if let Some(&id) = self.vocab.get(t) {
900 out.push(id);
901 } else {
902 let mut ok = true;
903 for byte in t.bytes() {
904 let tok = format!("<0x{:02X}>", byte);
905 match self.vocab.get(&tok) {
906 Some(&id) => out.push(id),
907 None => {
908 ok = false;
909 break;
910 }
911 }
912 }
913 if !ok {
914 tracing::error!("tokenizer: no id for SP symbol {t:?} — dropped");
915 }
916 }
917 }
918 }
919
920 fn bpe_piece(&self, piece: &str, out: &mut Vec<u32>) {
922 if piece.is_empty() {
923 return;
924 }
925 let mapped: Vec<String> = piece
926 .bytes()
927 .map(|b| self.byte_to_char[b as usize].to_string())
928 .collect();
929 let mut sym = mapped;
930 if let Some(sfx) = &self.eow_suffix {
934 if let Some(last) = sym.last_mut() {
935 last.push_str(sfx);
936 }
937 }
938
939 loop {
941 let mut best: Option<(u32, usize)> = None;
942 for i in 0..sym.len().saturating_sub(1) {
943 if let Some(&r) = self.ranks.get(&(sym[i].clone(), sym[i + 1].clone())) {
944 if best.map(|(br, _)| r < br).unwrap_or(true) {
945 best = Some((r, i));
946 }
947 }
948 }
949 let Some((_, i)) = best else { break };
950 let merged = format!("{}{}", sym[i], sym[i + 1]);
951 let (left, right) = (sym[i].clone(), sym[i + 1].clone());
953 let mut j = 0;
954 while j + 1 < sym.len() {
955 if sym[j] == left && sym[j + 1] == right {
956 sym[j] = merged.clone();
957 sym.remove(j + 1);
958 }
959 j += 1;
960 }
961 }
962
963 for s in &sym {
964 if let Some(&id) = self.vocab.get(s) {
965 out.push(id);
966 } else {
967 let mut ok = true;
969 for ch in s.chars() {
970 let Some(&b) = self.char_to_byte.get(&ch) else {
971 ok = false;
972 break;
973 };
974 let tok = format!("<0x{:02X}>", b);
975 if let Some(&id) = self.vocab.get(&tok) {
976 out.push(id);
977 } else {
978 ok = false;
979 break;
980 }
981 }
982 if !ok {
983 tracing::error!("tokenizer: no id for symbol {s:?} — dropped");
984 }
985 }
986 }
987 }
988
989 pub fn decode(&self, ids: &[u32]) -> String {
992 let mut bytes: Vec<u8> = Vec::new();
993 for &id in ids {
994 if self.special_ids.contains(&id) {
995 continue;
996 }
997 let idx = id as usize;
998 if idx >= self.id_to_token.len() {
999 continue;
1000 }
1001 let tok = &self.id_to_token[idx];
1002 if self.added_ids.contains(&id) {
1003 if self.metaspace && tok.contains('\u{2581}') {
1006 bytes.extend_from_slice(tok.replace('\u{2581}', " ").as_bytes());
1007 } else {
1008 bytes.extend_from_slice(tok.as_bytes());
1009 }
1010 continue;
1011 }
1012 if tok.starts_with("<0x") && tok.ends_with('>') && tok.len() == 6 {
1014 if let Ok(b) = u8::from_str_radix(&tok[3..5], 16) {
1015 bytes.push(b);
1016 continue;
1017 }
1018 }
1019 if self.metaspace {
1020 for ch in tok.chars() {
1022 if ch == '\u{2581}' {
1023 bytes.push(b' ');
1024 } else {
1025 let mut buf = [0u8; 4];
1026 bytes.extend_from_slice(ch.encode_utf8(&mut buf).as_bytes());
1027 }
1028 }
1029 continue;
1030 }
1031 for ch in tok.chars() {
1032 match self.char_to_byte.get(&ch) {
1033 Some(&b) => bytes.push(b),
1034 None => {
1037 let mut buf = [0u8; 4];
1038 bytes.extend_from_slice(ch.encode_utf8(&mut buf).as_bytes());
1039 }
1040 }
1041 }
1042 }
1043 let text = String::from_utf8_lossy(&bytes).into_owned();
1044 if self.metaspace && (self.sp_prepend || self.sp_prepend_first) {
1045 if let Some(stripped) = text.strip_prefix(' ') {
1047 return stripped.to_string();
1048 }
1049 }
1050 text
1051 }
1052
1053 pub fn decode_token(&self, id: u32) -> String {
1056 if self.special_ids.contains(&id) {
1057 return String::new();
1058 }
1059 let idx = id as usize;
1060 if idx >= self.id_to_token.len() {
1061 return String::new();
1062 }
1063 let tok = &self.id_to_token[idx];
1064 if self.added_ids.contains(&id) {
1065 if self.metaspace && tok.contains('\u{2581}') {
1066 return tok.replace('\u{2581}', " ");
1067 }
1068 return tok.clone();
1069 }
1070 if tok.starts_with("<0x") && tok.ends_with('>') && tok.len() == 6 {
1071 if let Ok(b) = u8::from_str_radix(&tok[3..5], 16) {
1072 return String::from_utf8_lossy(&[b]).into_owned();
1073 }
1074 }
1075 if self.metaspace {
1076 return tok.replace('\u{2581}', " ");
1077 }
1078 let mut bytes = Vec::new();
1079 for ch in tok.chars() {
1080 match self.char_to_byte.get(&ch) {
1081 Some(&b) => bytes.push(b),
1082 None => {
1083 let mut buf = [0u8; 4];
1084 bytes.extend_from_slice(ch.encode_utf8(&mut buf).as_bytes());
1085 }
1086 }
1087 }
1088 String::from_utf8_lossy(&bytes).into_owned()
1089 }
1090
1091 pub fn decode_token_stream(&self, id: u32, pending: &mut Vec<u8>) -> String {
1098 if self.special_ids.contains(&id) {
1099 return String::new();
1100 }
1101 if self.added_ids.contains(&id) {
1102 pending.extend_from_slice(self.decode_token(id).as_bytes());
1103 } else {
1104 pending.extend_from_slice(&self.token_bytes(id));
1105 }
1106 match std::str::from_utf8(pending) {
1107 Ok(s) => {
1108 let out = s.to_string();
1109 pending.clear();
1110 out
1111 }
1112 Err(e) if e.error_len().is_none() => {
1114 let valid = e.valid_up_to();
1115 let out = std::str::from_utf8(&pending[..valid]).unwrap_or_default().to_string();
1116 pending.drain(..valid);
1117 out
1118 }
1119 Err(_) => {
1120 let out = String::from_utf8_lossy(pending).into_owned();
1121 pending.clear();
1122 out
1123 }
1124 }
1125 }
1126
1127 pub fn token_bytes(&self, id: u32) -> Vec<u8> {
1130 let idx = id as usize;
1131 let Some(tok) = self.id_to_token.get(idx) else {
1132 return Vec::new();
1133 };
1134 if self.added_ids.contains(&id) {
1135 return tok.as_bytes().to_vec();
1136 }
1137 if tok.starts_with("<0x") && tok.ends_with('>') && tok.len() == 6 {
1138 if let Ok(b) = u8::from_str_radix(&tok[3..5], 16) {
1139 return vec![b];
1140 }
1141 }
1142 if self.metaspace {
1143 return tok.replace('\u{2581}', " ").into_bytes();
1144 }
1145 let mut bytes = Vec::new();
1146 for ch in tok.chars() {
1147 match self.char_to_byte.get(&ch) {
1148 Some(&b) => bytes.push(b),
1149 None => {
1150 let mut buf = [0u8; 4];
1151 bytes.extend_from_slice(ch.encode_utf8(&mut buf).as_bytes());
1152 }
1153 }
1154 }
1155 bytes
1156 }
1157
1158 pub fn special_texts(&self) -> Vec<String> {
1160 self.added
1161 .iter()
1162 .filter(|(_, id)| self.special_ids.contains(id))
1163 .map(|(text, _)| text.clone())
1164 .collect()
1165 }
1166
1167 pub fn decode_token_for_hash(&self, id: u32) -> String {
1170 let idx = id as usize;
1171 if idx >= self.id_to_token.len() {
1172 return String::new();
1173 }
1174 if self.special_ids.contains(&id) {
1175 return self.id_to_token[idx].clone();
1176 }
1177 self.decode_token(id)
1178 }
1179
1180 pub fn decode_for_protocol(&self, ids: &[u32]) -> String {
1183 let mut out = String::new();
1184 for &id in ids {
1185 let idx = id as usize;
1186 if self.special_ids.contains(&id) {
1187 if let Some(token) = self.id_to_token.get(idx) {
1188 out.push_str(token);
1189 }
1190 } else {
1191 out.push_str(&self.decode_token(id));
1192 }
1193 }
1194 out
1195 }
1196
1197 pub fn raw_token_for_hash(&self, id: u32) -> String {
1199 self.id_to_token
1200 .get(id as usize)
1201 .cloned()
1202 .unwrap_or_default()
1203 }
1204
1205 pub fn apply_chat_template(&self, messages: &[(String, String)]) -> Vec<u32> {
1209 self.apply_chat_template_opts(messages, None)
1210 }
1211
1212 pub fn apply_chat_template_json(
1229 &self,
1230 messages: &[serde_json::Value],
1231 tools: Option<&[serde_json::Value]>,
1232 enable_thinking: Option<bool>,
1233 ) -> Vec<u32> {
1234 self.apply_chat_template_json_with_reasoning_effort(messages, tools, enable_thinking, None)
1235 }
1236
1237 pub fn apply_chat_template_json_with_reasoning_effort(
1240 &self,
1241 messages: &[serde_json::Value],
1242 tools: Option<&[serde_json::Value]>,
1243 enable_thinking: Option<bool>,
1244 reasoning_effort: Option<&str>,
1245 ) -> Vec<u32> {
1246 match self.try_apply_chat_template_json_with_reasoning_effort(
1247 messages,
1248 tools,
1249 enable_thinking,
1250 reasoning_effort,
1251 ) {
1252 Ok(ids) => ids,
1253 Err(e) => {
1254 tracing::error!("chat template render failed ({e}); ChatML fallback");
1255 self.chatml_json_fallback(messages, enable_thinking)
1256 }
1257 }
1258 }
1259
1260 pub fn try_apply_chat_template_json(
1271 &self,
1272 messages: &[serde_json::Value],
1273 tools: Option<&[serde_json::Value]>,
1274 enable_thinking: Option<bool>,
1275 ) -> Result<Vec<u32>, String> {
1276 self.try_apply_chat_template_json_with_reasoning_effort(
1277 messages,
1278 tools,
1279 enable_thinking,
1280 None,
1281 )
1282 }
1283
1284 pub fn try_apply_chat_template_json_with_reasoning_effort(
1288 &self,
1289 messages: &[serde_json::Value],
1290 tools: Option<&[serde_json::Value]>,
1291 enable_thinking: Option<bool>,
1292 reasoning_effort: Option<&str>,
1293 ) -> Result<Vec<u32>, String> {
1294 if let Some(tpl) = &self.chat_template {
1295 return self
1296 .render_template_json(tpl, messages, tools, enable_thinking, reasoning_effort)
1297 .map(|text| self.with_bos(self.encode(&text)))
1298 .map_err(|e| format!("{e:#}"));
1299 }
1300 Ok(self.chatml_json_fallback(messages, enable_thinking))
1301 }
1302
1303 fn chatml_json_fallback(
1304 &self,
1305 messages: &[serde_json::Value],
1306 enable_thinking: Option<bool>,
1307 ) -> Vec<u32> {
1308 let pairs: Vec<(String, String)> = messages
1309 .iter()
1310 .map(|m| {
1311 (
1312 m.get("role")
1313 .and_then(|v| v.as_str())
1314 .unwrap_or("user")
1315 .to_string(),
1316 m.get("content")
1317 .and_then(|v| v.as_str())
1318 .unwrap_or("")
1319 .to_string(),
1320 )
1321 })
1322 .collect();
1323 self.with_bos(self.chatml_fallback_opts(&pairs, enable_thinking))
1324 }
1325
1326 pub fn render_chat_json(
1328 &self,
1329 messages: &[serde_json::Value],
1330 tools: Option<&[serde_json::Value]>,
1331 enable_thinking: Option<bool>,
1332 ) -> Option<String> {
1333 self.render_chat_json_with_reasoning_effort(messages, tools, enable_thinking, None)
1334 }
1335
1336 pub fn render_chat_json_with_reasoning_effort(
1339 &self,
1340 messages: &[serde_json::Value],
1341 tools: Option<&[serde_json::Value]>,
1342 enable_thinking: Option<bool>,
1343 reasoning_effort: Option<&str>,
1344 ) -> Option<String> {
1345 let tpl = self.chat_template.as_ref()?;
1346 match self.render_template_json(tpl, messages, tools, enable_thinking, reasoning_effort) {
1347 Ok(t) => Some(t),
1348 Err(e) => {
1349 tracing::error!("chat template render (json): {e:#}");
1350 eprintln!("chat template render (json): {e:#}");
1351 None
1352 }
1353 }
1354 }
1355
1356 pub fn template_has_default_system(&self) -> bool {
1363 let Some(tpl) = self.chat_template.as_ref() else {
1364 return false;
1365 };
1366 let user = serde_json::json!({"role": "user", "content": "x"});
1367 let system = serde_json::json!({"role": "system", "content": ""});
1368 let alone = self.render_template_json(tpl, std::slice::from_ref(&user), None, None, None);
1369 let behind = self.render_template_json(tpl, &[system, user], None, None, None);
1370 matches!((alone, behind), (Ok(a), Ok(b)) if a.len() > b.len())
1371 }
1372
1373 fn render_template_json(
1374 &self,
1375 tpl: &str,
1376 messages: &[serde_json::Value],
1377 tools: Option<&[serde_json::Value]>,
1378 enable_thinking: Option<bool>,
1379 reasoning_effort: Option<&str>,
1380 ) -> Result<String, minijinja::Error> {
1381 let mut env = crate::chat_template::environment();
1382 self.add_special_globals(&mut env);
1383 let tpl_src = strip_generation_tags(tpl);
1384 env.add_template("chat", &tpl_src)?;
1385 let msgs: Vec<minijinja::Value> = messages
1386 .iter()
1387 .map(minijinja::Value::from_serialize)
1388 .collect();
1389 let tools_v: Option<Vec<minijinja::Value>> =
1390 tools.map(|ts| ts.iter().map(minijinja::Value::from_serialize).collect());
1391 let tpl = env.get_template("chat")?;
1392 let rendered = match (tools_v, enable_thinking, reasoning_effort) {
1399 (Some(ts), Some(v), Some(effort)) => tpl.render(minijinja::context! {
1400 messages => msgs, tools => ts, add_generation_prompt => true, enable_thinking => v,
1401 reasoning_effort => effort,
1402 tool_call_format => "json",
1403 })?,
1404 (Some(ts), Some(v), None) => tpl.render(minijinja::context! {
1405 messages => msgs, tools => ts, add_generation_prompt => true, enable_thinking => v,
1406 reasoning_effort => if !v { Some("low") } else { None::<&str> },
1411 tool_call_format => "json",
1412 })?,
1413 (Some(ts), None, Some(effort)) => tpl.render(minijinja::context! {
1414 messages => msgs, tools => ts, add_generation_prompt => true,
1415 reasoning_effort => effort,
1416 tool_call_format => "json",
1417 })?,
1418 (Some(ts), None, None) => tpl.render(minijinja::context! {
1419 messages => msgs, tools => ts, add_generation_prompt => true,
1420 tool_call_format => "json",
1421 })?,
1422 (None, Some(v), Some(effort)) => tpl.render(minijinja::context! {
1423 messages => msgs, add_generation_prompt => true, enable_thinking => v,
1424 reasoning_effort => effort,
1425 tool_call_format => "json",
1426 })?,
1427 (None, Some(v), None) => tpl.render(minijinja::context! {
1428 messages => msgs, add_generation_prompt => true, enable_thinking => v,
1429 reasoning_effort => if !v { Some("low") } else { None::<&str> },
1431 tool_call_format => "json",
1432 })?,
1433 (None, None, Some(effort)) => tpl.render(minijinja::context! {
1434 messages => msgs, add_generation_prompt => true,
1435 reasoning_effort => effort,
1436 tool_call_format => "json",
1437 })?,
1438 (None, None, None) => tpl.render(minijinja::context! {
1439 messages => msgs, add_generation_prompt => true,
1440 tool_call_format => "json",
1441 })?,
1442 };
1443 Ok(close_direct_think(rendered, enable_thinking))
1444 }
1445
1446 pub fn apply_chat_template_opts(
1447 &self,
1448 messages: &[(String, String)],
1449 enable_thinking: Option<bool>,
1450 ) -> Vec<u32> {
1451 if let Some(tpl) = &self.chat_template {
1452 match self.render_template(tpl, messages, enable_thinking) {
1453 Ok(text) => return self.with_bos(self.encode(&text)),
1454 Err(e) => {
1455 tracing::error!("chat template render failed ({e}); ChatML fallback");
1456 }
1457 }
1458 }
1459 self.with_bos(self.chatml_fallback_opts(messages, enable_thinking))
1460 }
1461
1462 pub fn with_bos(&self, mut ids: Vec<u32>) -> Vec<u32> {
1464 if self.add_bos {
1465 if let Some(b) = self.bos_token_id {
1466 if ids.first() != Some(&b) {
1467 ids.insert(0, b);
1468 }
1469 }
1470 }
1471 ids
1472 }
1473
1474 pub fn render_chat(&self, messages: &[(String, String)]) -> Option<String> {
1476 self.render_chat_opts(messages, None)
1477 }
1478
1479 pub fn render_chat_opts(
1481 &self,
1482 messages: &[(String, String)],
1483 enable_thinking: Option<bool>,
1484 ) -> Option<String> {
1485 let tpl = self.chat_template.as_ref()?;
1486 match self.render_template(tpl, messages, enable_thinking) {
1487 Ok(t) => Some(t),
1488 Err(e) => {
1489 tracing::error!("chat template render: {e:#}");
1490 None
1491 }
1492 }
1493 }
1494
1495 fn render_template(
1496 &self,
1497 tpl: &str,
1498 messages: &[(String, String)],
1499 enable_thinking: Option<bool>,
1500 ) -> Result<String, minijinja::Error> {
1501 let mut env = crate::chat_template::environment();
1502 self.add_special_globals(&mut env);
1503 let tpl_src = strip_generation_tags(tpl);
1504 env.add_template("chat", &tpl_src)?;
1505 let msgs: Vec<minijinja::Value> = messages
1506 .iter()
1507 .map(|(role, content)| {
1508 minijinja::context! { role => role, content => content }
1509 })
1510 .collect();
1511 let rendered = match enable_thinking {
1514 Some(v) => env.get_template("chat")?.render(minijinja::context! {
1515 messages => msgs,
1516 add_generation_prompt => true,
1517 enable_thinking => v,
1518 reasoning_effort => if !v { Some("low") } else { None::<&str> },
1521 })?,
1522 None => env.get_template("chat")?.render(minijinja::context! {
1523 messages => msgs,
1524 add_generation_prompt => true,
1525 })?,
1526 };
1527 Ok(close_direct_think(rendered, enable_thinking))
1528 }
1529
1530 fn add_special_globals(&self, env: &mut minijinja::Environment<'_>) {
1536 let bos = self.bos_token_id.and_then(|id| {
1537 self.added
1538 .iter()
1539 .find(|(_, i)| *i == id)
1540 .map(|(text, _)| text.clone())
1541 });
1542 if let Some(text) = bos {
1543 env.add_global("bos_token", text);
1544 }
1545 }
1546
1547 fn chatml_fallback(&self, messages: &[(String, String)]) -> Vec<u32> {
1549 self.chatml_fallback_opts(messages, None)
1550 }
1551
1552 fn chatml_fallback_opts(
1554 &self,
1555 messages: &[(String, String)],
1556 enable_thinking: Option<bool>,
1557 ) -> Vec<u32> {
1558 let mut tokens = Vec::new();
1559
1560 for (role, content) in messages {
1561 if let Some(start_id) = self.im_start_id {
1563 tokens.push(start_id);
1564 }
1565 tokens.extend(self.encode(&format!("{}\n{}", role, content)));
1566 if let Some(end_id) = self.im_end_id {
1567 tokens.push(end_id);
1568 }
1569 tokens.extend(self.encode("\n"));
1570 }
1571
1572 if let Some(start_id) = self.im_start_id {
1574 tokens.push(start_id);
1575 }
1576 tokens.extend(self.encode("assistant\n"));
1577 if enable_thinking == Some(false) {
1578 tokens.extend(self.encode("<think>\n\n</think>\n\n"));
1579 }
1580
1581 tokens
1582 }
1583
1584 pub fn vocab_size(&self) -> usize {
1586 self.id_to_token.len()
1587 }
1588
1589 pub fn token_to_id(&self, token: &str) -> Option<u32> {
1593 self.vocab.get(token).copied()
1594 }
1595
1596 pub fn convert_tokens_to_ids(&self, token: &str) -> Option<u32> {
1599 self.token_to_id(token)
1600 }
1601
1602 pub fn is_eos(&self, id: u32) -> bool {
1604 self.eos_token_id == Some(id) || self.im_end_id == Some(id) || self.extra_eos.contains(&id)
1605 }
1606}
1607
1608#[derive(Debug, thiserror::Error)]
1609pub enum TokenizerError {
1610 #[error("IO error: {0}")]
1611 Io(String),
1612 #[error("Parse error: {0}")]
1613 Parse(String),
1614}
1615
1616struct SpMerges {
1618 pairs: HashMap<u64, (u32, u32)>,
1619 bytes: [u32; 256],
1621}
1622
1623const SP_NONE: u32 = u32::MAX;
1624
1625thread_local! {
1626 static SP_STRINGS: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
1628}
1629
1630impl SpMerges {
1631 fn build(vocab: &HashMap<String, u32>, ranks: &HashMap<(String, String), u32>) -> Option<Self> {
1632 if ranks.is_empty() {
1633 return None;
1634 }
1635 let mut pairs = HashMap::with_capacity(ranks.len());
1636 let mut cat = String::new();
1637 for ((a, b), &r) in ranks {
1638 let (&ia, &ib) = (vocab.get(a)?, vocab.get(b)?);
1639 cat.clear();
1640 cat.push_str(a);
1641 cat.push_str(b);
1642 let &m = vocab.get(&cat)?;
1643 pairs.insert(((ia as u64) << 32) | ib as u64, (r, m));
1644 }
1645 let mut bytes = [SP_NONE; 256];
1646 for (b, slot) in bytes.iter_mut().enumerate() {
1647 if let Some(&id) = vocab.get(&format!("<0x{b:02X}>")) {
1648 *slot = id;
1649 }
1650 }
1651 Some(SpMerges { pairs, bytes })
1652 }
1653
1654 #[inline]
1655 fn rank(&self, a: u32, b: u32) -> Option<(u32, u32)> {
1656 if a == SP_NONE || b == SP_NONE {
1657 return None;
1658 }
1659 self.pairs.get(&(((a as u64) << 32) | b as u64)).copied()
1660 }
1661
1662 fn encode(&self, vocab: &HashMap<String, u32>, piece: &str, out: &mut Vec<u32>) {
1667 use std::cmp::Reverse;
1668 use std::collections::BinaryHeap;
1669 let spans: Vec<(usize, usize)> = piece
1670 .char_indices()
1671 .map(|(i, c)| (i, i + c.len_utf8()))
1672 .collect();
1673 let n = spans.len();
1674 let mut buf = [0u8; 4];
1675 let mut id: Vec<u32> = piece
1676 .chars()
1677 .map(|c| *vocab.get(c.encode_utf8(&mut buf) as &str).unwrap_or(&SP_NONE))
1678 .collect();
1679 let mut next: Vec<usize> = (1..=n).collect();
1680 let mut prev: Vec<usize> = (0..n).map(|i| i.wrapping_sub(1)).collect();
1681 let mut alive = vec![true; n];
1682 let mut heap: BinaryHeap<Reverse<(u32, usize)>> = BinaryHeap::with_capacity(n);
1683 for i in 0..n.saturating_sub(1) {
1684 if let Some((r, _)) = self.rank(id[i], id[i + 1]) {
1685 heap.push(Reverse((r, i)));
1686 }
1687 }
1688 let mut batch: Vec<usize> = Vec::new();
1689 let mut merged: Vec<usize> = Vec::new();
1690 while let Some(&Reverse((r, _))) = heap.peek() {
1691 batch.clear();
1692 while let Some(&Reverse((r2, pos))) = heap.peek() {
1693 if r2 != r {
1694 break;
1695 }
1696 heap.pop();
1697 batch.push(pos);
1698 }
1699 batch.sort_unstable();
1700 batch.dedup();
1701 merged.clear();
1702 for &pos in &batch {
1703 if !alive[pos] || next[pos] >= n {
1704 continue;
1705 }
1706 let j = next[pos];
1707 match self.rank(id[pos], id[j]) {
1708 Some((rr, m)) if rr == r => {
1709 id[pos] = m;
1710 alive[j] = false;
1711 next[pos] = next[j];
1712 if next[j] < n {
1713 prev[next[j]] = pos;
1714 }
1715 merged.push(pos);
1716 }
1717 _ => {}
1718 }
1719 }
1720 for &pos in &merged {
1721 if !alive[pos] {
1722 continue;
1723 }
1724 let pv = prev[pos];
1725 if pv < n && alive[pv] {
1726 if let Some((r2, _)) = self.rank(id[pv], id[pos]) {
1727 heap.push(Reverse((r2, pv)));
1728 }
1729 }
1730 let nx = next[pos];
1731 if nx < n {
1732 if let Some((r2, _)) = self.rank(id[pos], id[nx]) {
1733 heap.push(Reverse((r2, pos)));
1734 }
1735 }
1736 }
1737 }
1738 let mut i = 0usize;
1739 while i < n {
1740 if id[i] != SP_NONE {
1741 out.push(id[i]);
1742 } else {
1743 let (a, b) = spans[i];
1745 let mut ok = true;
1746 for &byte in piece[a..b].as_bytes() {
1747 match self.bytes[byte as usize] {
1748 SP_NONE => {
1749 ok = false;
1750 break;
1751 }
1752 t => out.push(t),
1753 }
1754 }
1755 if !ok {
1756 tracing::error!("tokenizer: no id for SP symbol {:?} — dropped", &piece[a..b]);
1757 }
1758 }
1759 i = next[i];
1760 }
1761 }
1762}
1763
1764#[cfg(test)]
1765mod tests {
1766 use super::*;
1767
1768 #[test]
1769 fn direct_answer_closes_think_after_a_chatml_assistant_line() {
1770 let r = "<|im_start|>user\nhi<|im_end|>\n<|im_start|>assistant\n".to_string();
1771 assert_eq!(
1772 close_direct_think(r.clone(), Some(false)),
1773 "<|im_start|>user\nhi<|im_end|>\n<|im_start|>assistant\n<think>\n\n</think>\n\n"
1774 );
1775 assert_eq!(close_direct_think(r.clone(), None), r);
1776 let done = format!("{r}<think>\n\n</think>\n\n");
1778 assert_eq!(close_direct_think(done.clone(), Some(false)), done);
1779 }
1780
1781 #[test]
1782 fn byte_unicode_bijection() {
1783 let (b2c, c2b) = bytes_to_unicode();
1784 for b in 0..=255u8 {
1785 assert_eq!(c2b[&b2c[b as usize]], b);
1786 }
1787 assert_eq!(b2c[b' ' as usize], 'Ġ');
1789 assert_eq!(b2c[b'\n' as usize], 'Ċ');
1790 }
1791
1792 #[test]
1793 fn byte_level_roundtrip_utf8() {
1794 let tok = Tokenizer::byte_level();
1795 let text = "hello 🌍 hi\n";
1796 let ids = tok.encode(text);
1797 assert_eq!(ids.len(), text.len()); assert_eq!(tok.decode(&ids), text);
1799 }
1800
1801 fn mini_json() -> String {
1804 let vocab: Vec<(&str, u32)> = vec![
1806 ("h", 0),
1807 ("e", 1),
1808 ("l", 2),
1809 ("o", 3),
1810 ("Ġ", 4),
1811 ("w", 5),
1812 ("r", 6),
1813 ("d", 7),
1814 ("he", 8),
1815 ("Ġw", 9),
1816 ];
1817 let vocab_json: String = vocab
1818 .iter()
1819 .map(|(t, i)| format!("\"{t}\": {i}"))
1820 .collect::<Vec<_>>()
1821 .join(", ");
1822 format!(
1823 r#"{{
1824 "model": {{
1825 "type": "BPE",
1826 "vocab": {{ {vocab_json} }},
1827 "merges": [["h", "e"], ["Ġ", "w"]]
1828 }},
1829 "added_tokens": [
1830 {{"id": 10, "content": "<|eot|>", "special": true}}
1831 ]
1832 }}"#
1833 )
1834 }
1835
1836 #[test]
1842 fn sp_heap_bpe_matches_the_string_form() {
1843 let toks = [
1844 "a", "b", "c", "▁", "bc", "abc", "ab", "aa", "aab", "ca", "▁a", "aabc",
1845 "aaa", "<0x64>", "<0xC3>", "▁▁",
1846 ];
1847 let vocab: Vec<String> = toks
1848 .iter()
1849 .enumerate()
1850 .map(|(i, t)| format!("\"{t}\": {i}"))
1851 .collect();
1852 let merges = [
1853 ("aab", "c"),
1854 ("b", "c"),
1855 ("a", "bc"),
1856 ("a", "b"),
1857 ("ab", "c"),
1858 ("a", "a"),
1859 ("aa", "b"),
1860 ("c", "a"),
1861 ("▁", "a"),
1862 ("aa", "a"),
1863 ("▁", "▁"),
1864 ];
1865 let merges: Vec<String> = merges
1866 .iter()
1867 .map(|(a, b)| format!("[\"{a}\", \"{b}\"]"))
1868 .collect();
1869 let json = format!(
1870 r#"{{"model": {{"type": "BPE", "byte_fallback": true, "vocab": {{ {} }}, "merges": [{}]}},
1871 "normalizer": {{"type": "Replace", "pattern": {{"String": " "}}, "content": "\u2581"}},
1872 "added_tokens": []}}"#,
1873 vocab.join(", "),
1874 merges.join(", ")
1875 );
1876 let t = Tokenizer::from_json(&json).unwrap();
1877 assert!(t.metaspace, "a byte_fallback vocabulary takes the SentencePiece path");
1878 assert!(t.sp_merges().is_some());
1879 let alphabet = ['a', 'b', 'c', '\u{2581}', 'd', 'é', 'a', 'a', 'b'];
1880 let mut state = 0x2545_f491_4f6c_dd1du64;
1881 for len in 0..40 {
1882 for _ in 0..60 {
1883 let piece: String = (0..len)
1884 .map(|_| {
1885 state ^= state << 13;
1886 state ^= state >> 7;
1887 state ^= state << 17;
1888 alphabet[(state % alphabet.len() as u64) as usize]
1889 })
1890 .collect();
1891 let (mut fast, mut slow) = (Vec::new(), Vec::new());
1892 t.sp_merges().unwrap().encode(&t.vocab, &piece, &mut fast);
1893 t.bpe_piece_sp_strings(&piece, &mut slow);
1894 assert_eq!(fast, slow, "{piece:?}");
1895 }
1896 }
1897 }
1898
1899 #[test]
1903 fn streaming_keeps_a_character_split_over_tokens() {
1904 let (b2u, _) = bytes_to_unicode();
1905 let piece = |bytes: &[u8]| bytes.iter().map(|&b| b2u[b as usize]).collect::<String>();
1906 let vocab = serde_json::json!({
1907 piece(" ".as_bytes()): 0,
1908 piece(&[0xE5, 0x9C]): 1,
1909 piece(&[0xB0]): 2,
1910 piece("球".as_bytes()): 3,
1911 });
1912 let json = serde_json::json!({
1913 "model": {"type": "BPE", "vocab": vocab, "merges": []},
1914 "pre_tokenizer": {"type": "ByteLevel"},
1915 "decoder": {"type": "ByteLevel"},
1916 "added_tokens": [{"id": 4, "content": "<|im_end|>", "special": true}],
1917 })
1918 .to_string();
1919 let t = Tokenizer::from_json(&json).unwrap();
1920 let ids = [0u32, 1, 2, 3, 4];
1921 assert_eq!(t.decode(&ids), " 地球");
1922 let mut pending = Vec::new();
1923 let pieces: Vec<String> = ids.iter().map(|&i| t.decode_token_stream(i, &mut pending)).collect();
1924 assert_eq!(pieces, [" ", "", "地", "球", ""]);
1925 assert!(pending.is_empty());
1926 assert_eq!(t.decode_token_stream(2, &mut pending), "\u{FFFD}");
1928 assert!(pending.is_empty());
1929 }
1930
1931 #[test]
1935 fn tool_markup_decodes_even_when_special() {
1936 let json = r#"{
1937 "model": {"type": "BPE", "vocab": {"h": 0, "e": 1, "l": 2, "o": 3}, "merges": []},
1938 "added_tokens": [
1939 {"id": 10, "content": "<|im_end|>", "special": true},
1940 {"id": 11, "content": "<function", "special": true},
1941 {"id": 12, "content": "</function>", "special": true},
1942 {"id": 13, "content": "<param", "special": true},
1943 {"id": 14, "content": "</param>", "special": true},
1944 {"id": 15, "content": "<tool_call>", "special": true}
1945 ]
1946 }"#;
1947 let t = Tokenizer::from_json(json).unwrap();
1948 let ids = [11, 0, 1, 13, 2, 14, 12, 15, 10];
1949 assert_eq!(
1950 t.decode(&ids),
1951 "<functionhe<paraml</param></function><tool_call>"
1952 );
1953 let streamed: String = ids.iter().map(|&i| t.decode_token(i)).collect();
1954 assert_eq!(streamed, t.decode(&ids), "streaming must agree with decode");
1955 assert!(
1956 !t.decode(&[10]).contains("im_end"),
1957 "control tokens stay hidden"
1958 );
1959 }
1960
1961 #[test]
1964 fn real_tokenizer_parity_when_available() {
1965 let Ok(path) = std::env::var("CMF_TOK_PARITY") else {
1966 return;
1967 };
1968 let t = Tokenizer::from_file(&path).expect("load");
1969 for (text, want) in [
1970 (
1971 "The capital of France is",
1972 vec![671u32, 6102, 294, 8760, 344],
1973 ),
1974 ("2 + 2 =", vec![20, 940, 223, 20, 438]),
1975 ] {
1976 let got = t.encode(text);
1977 assert_eq!(got, want, "«{text}»");
1978 }
1979 }
1980
1981 #[test]
1986 fn granite_42_chat_template_when_available() {
1987 let Ok(path) = std::env::var("CMF_GRANITE_CHAT_TEMPLATE") else {
1988 return;
1989 };
1990 let mut tok = Tokenizer::byte_level();
1991 tok.chat_template = Some(std::fs::read_to_string(path).expect("read Granite template"));
1992 let messages = vec![("user".to_string(), "Hello".to_string())];
1993
1994 let thinking = tok
1995 .render_chat_opts(&messages, Some(true))
1996 .expect("render Granite thinking prompt");
1997 assert_eq!(
1998 thinking,
1999 "<|im_start|>system\n<|im_end|>\n<|im_start|>user\nHello<|im_end|>\n<|im_start|>assistant\n<think>\n"
2000 );
2001
2002 let direct = tok
2003 .render_chat_opts(&messages, Some(false))
2004 .expect("render Granite direct prompt");
2005 assert_eq!(
2006 direct,
2007 "<|im_start|>system\n<|im_end|>\n<|im_start|>user\nHello<|im_end|>\n<|im_start|>assistant\n<think></think>"
2008 );
2009 }
2010
2011 #[test]
2012 fn json_template_passes_explicit_reasoning_effort_without_overriding_it() {
2013 let mut tok = Tokenizer::byte_level();
2014 tok.chat_template = Some(
2015 "{% if reasoning_effort is defined %}<effort>{{ reasoning_effort }}</effort>{% else %}<default-effort>{% endif %}|{{ enable_thinking }}|{{ messages[0].reasoning_content }}"
2016 .to_string(),
2017 );
2018 let messages = vec![serde_json::json!({
2019 "role": "assistant",
2020 "content": "answer",
2021 "reasoning_content": "prior chain"
2022 })];
2023 let explicit = tok
2024 .render_chat_json_with_reasoning_effort(&messages, None, Some(false), Some("high"))
2025 .expect("render explicit effort");
2026 assert_eq!(explicit, "<effort>high</effort>|False|prior chain");
2027
2028 let native_default = tok
2029 .render_chat_json_with_reasoning_effort(&messages, None, None, None)
2030 .expect("render native default");
2031 assert_eq!(native_default, "<default-effort>||prior chain");
2032 }
2033
2034 #[test]
2039 fn minico_effort_dial_resolves_like_transformers() {
2040 let mut tok = Tokenizer::byte_level();
2041 tok.chat_template = Some(
2042 r#"{%- set effort = (reasoning_effort | default("medium", true) | string | lower | trim) -%}
2043{%- set effort_aliases = {"none": "off", "xhigh": "max", "med": "medium"} -%}
2044{%- if effort in effort_aliases -%}
2045 {%- set effort = effort_aliases[effort] -%}
2046{%- endif -%}
2047{%- if effort not in ["off", "low", "medium", "high", "max"] -%}
2048 {%- set effort = "medium" -%}
2049{%- endif -%}
2050{%- if enable_thinking is defined and enable_thinking is not none and not enable_thinking -%}
2051 {%- set effort = "off" -%}
2052{%- endif -%}
2053{{- "Reasoning effort: " + effort -}}
2054{%- if effort == "off" -%}{{- "<think>\n\n</think>" -}}{%- endif -%}"#
2055 .to_string(),
2056 );
2057 let msgs = vec![serde_json::json!({"role": "user", "content": "hi"})];
2058 let render = |thinking: Option<bool>, effort: Option<&str>| {
2059 tok.render_chat_json_with_reasoning_effort(&msgs, None, thinking, effort)
2060 .expect("render the effort dial")
2061 };
2062 assert_eq!(render(None, None), "Reasoning effort: medium");
2063 assert_eq!(render(Some(true), None), "Reasoning effort: medium");
2064 assert_eq!(render(None, Some("high")), "Reasoning effort: high");
2065 assert_eq!(render(None, Some(" XHigh ")), "Reasoning effort: max");
2066 assert_eq!(render(None, Some("bogus")), "Reasoning effort: medium");
2067 assert_eq!(render(None, Some("off")), "Reasoning effort: off<think>\n\n</think>");
2068 assert_eq!(render(Some(false), None), "Reasoning effort: off<think>\n\n</think>");
2069 assert_eq!(render(Some(false), Some("max")), "Reasoning effort: off<think>\n\n</think>");
2070 assert!(template_reads_reasoning_effort(tok.chat_template.as_deref().unwrap()));
2071 }
2072
2073 #[test]
2074 fn template_default_system_is_detected() {
2075 let mut tok = Tokenizer::byte_level();
2076 tok.chat_template = Some(
2077 "{% if messages[0].role != 'system' %}<|im_start|>system\nYou are CherryPit.<|im_end|>\n{% endif %}\
2078 {% for m in messages %}<|im_start|>{{ m.role }}\n{{ m.content }}<|im_end|>\n{% endfor %}"
2079 .to_string(),
2080 );
2081 assert!(tok.template_has_default_system());
2082
2083 tok.chat_template = Some(
2084 "{% for m in messages %}<|im_start|>{{ m.role }}\n{{ m.content }}<|im_end|>\n{% endfor %}"
2085 .to_string(),
2086 );
2087 assert!(!tok.template_has_default_system());
2088
2089 tok.chat_template = Some("{% for message in messages %}".to_string());
2090 assert!(!tok.template_has_default_system());
2091 tok.chat_template = None;
2092 assert!(!tok.template_has_default_system());
2093 }
2094
2095 #[test]
2096 fn fallible_json_template_render_does_not_use_chatml_on_jinja_error() {
2097 let mut tok = Tokenizer::byte_level();
2098 tok.chat_template = Some("{% for message in messages %}".to_string());
2099 let messages = vec![serde_json::json!({"role": "user", "content": "hello"})];
2100 let error = tok
2101 .try_apply_chat_template_json_with_reasoning_effort(
2102 &messages,
2103 None,
2104 None,
2105 Some("high"),
2106 )
2107 .expect_err("unclosed Jinja must fail instead of producing ChatML");
2108 assert!(!error.is_empty());
2109 }
2110
2111 #[test]
2117 fn minimax_m3_chat_template_when_available() {
2118 let Ok(path) = std::env::var("CMF_MINIMAX_M3_CHAT_TEMPLATE") else {
2119 return;
2120 };
2121 let mut tok = Tokenizer::byte_level();
2122 tok.chat_template = Some(std::fs::read_to_string(path).expect("read MiniMax M3 template"));
2123 let messages = vec![
2124 serde_json::json!({"role": "system", "content": "Keep answers concise."}),
2125 serde_json::json!({"role": "user", "content": "What is 2 + 2?"}),
2126 ];
2127 let tools = vec![serde_json::json!({
2128 "type": "function",
2129 "function": {
2130 "name": "get_weather",
2131 "description": "Return the weather for a city.",
2132 "parameters": {
2133 "type": "object",
2134 "properties": {"city": {"type": "string"}},
2135 "required": ["city"]
2136 }
2137 }
2138 })];
2139 let rendered = tok
2140 .render_chat_json(&messages, Some(&tools), Some(false))
2141 .expect("render MiniMax M3 native prompt");
2142 assert!(rendered.starts_with("]~!b[]~b]system\n"), "{rendered:?}");
2143 assert!(rendered.contains("<effort>low</effort>"), "{rendered:?}");
2144 assert!(
2145 rendered.contains("]<]minimax[>[<tool_call>"),
2146 "{rendered:?}"
2147 );
2148 assert!(rendered.contains("get_weather"), "{rendered:?}");
2149 assert!(rendered.ends_with("]~b]ai\n"), "{rendered:?}");
2150 }
2151
2152 #[test]
2156 fn glm_direct_template_keeps_assistant_special_token_intact() {
2157 let mut tok = Tokenizer::byte_level();
2158 tok.chat_template = Some(
2159 "[gMASK]<sop>{%- set effort = reasoning_effort if reasoning_effort is defined and reasoning_effort in ['low', 'high'] else 'max' -%}<|system|>Reasoning Effort: {{ effort | capitalize }}{%- for m in messages -%}<|user|>{{ m.content }}{%- endfor -%}<|assistant|><think>"
2160 .to_string(),
2161 );
2162 let messages = vec![("user".to_string(), "2+2?".to_string())];
2163 let rendered = tok
2164 .render_chat_opts(&messages, Some(false))
2165 .expect("render GLM direct prompt");
2166 assert!(rendered.contains("Reasoning Effort: Low"));
2167 assert!(rendered.contains("<|assistant|><think></think>"));
2168 assert!(!rendered.contains("<|assistant\n"));
2169
2170 let thinking = tok
2173 .render_chat_opts(&messages, Some(true))
2174 .expect("render GLM thinking prompt");
2175 assert!(thinking.contains("Reasoning Effort: Max"));
2176
2177 let json_messages = vec![serde_json::json!({
2178 "role": "user",
2179 "content": "2+2?"
2180 })];
2181 let rendered_json = tok
2182 .render_chat_json(&json_messages, None, Some(false))
2183 .expect("render GLM JSON direct prompt");
2184 assert!(rendered_json.contains("Reasoning Effort: Low"));
2185 assert!(rendered_json.contains("<|assistant|><think></think>"));
2186 assert!(!rendered_json.contains("<|assistant\n"));
2187 }
2188
2189 #[test]
2195 fn every_split_in_a_sequence_is_applied() {
2196 let pt = serde_json::json!({
2197 "type": "Sequence",
2198 "pretokenizers": [
2199 {"type": "Split", "behavior": "Isolated",
2200 "pattern": {"Regex": r"\p{N}{1,3}"}},
2201 {"type": "Split", "behavior": "Isolated",
2202 "pattern": {"Regex": r" ?[\p{L}]+"}},
2203 {"type": "ByteLevel", "add_prefix_space": false, "use_regex": false}
2204 ]
2205 });
2206 let mut pats = Vec::new();
2207 collect_split_patterns(&pt, &mut pats);
2208 assert_eq!(
2209 pats.len(),
2210 2,
2211 "both Split stages must be collected: {pats:?}"
2212 );
2213 assert!(pats[0].contains("p{N}"), "digit rule first");
2214 assert!(pats[1].contains("p{L}"), "word rule second");
2215
2216 let re: Vec<fancy_regex::Regex> = pats
2220 .iter()
2221 .map(|p| fancy_regex::Regex::new(p).unwrap())
2222 .collect();
2223 let norm = "ab cd12";
2224 let mut pieces: Vec<(usize, usize)> = vec![(0, norm.len())];
2225 for r in &re {
2226 let mut next = Vec::new();
2227 for (ps, pe) in pieces {
2228 let seg = &norm[ps..pe];
2229 let mut last = 0;
2230 for m in r.find_iter(seg).flatten() {
2231 if m.start() > last {
2232 next.push((ps + last, ps + m.start()));
2233 }
2234 if m.end() > m.start() {
2235 next.push((ps + m.start(), ps + m.end()));
2236 }
2237 last = m.end();
2238 }
2239 if last < seg.len() {
2240 next.push((ps + last, pe));
2241 }
2242 }
2243 pieces = next;
2244 }
2245 let got: Vec<&str> = pieces.iter().map(|(a, b)| &norm[*a..*b]).collect();
2246 assert_eq!(
2247 got,
2248 vec!["ab", " cd", "12"],
2249 "staged split produced {got:?}"
2250 );
2251 }
2252
2253 #[test]
2254 fn full_pipeline_merges_and_added_tokens() {
2255 let tok = Tokenizer::from_json(&mini_json()).unwrap();
2256 let ids = tok.encode("hello world");
2258 assert_eq!(ids, vec![8, 2, 2, 3, 9, 3, 6, 2, 7]);
2259 assert_eq!(tok.decode(&ids), "hello world");
2260 let ids2 = tok.encode("he<|eot|>he");
2262 assert_eq!(ids2, vec![8, 10, 8]);
2263 assert_eq!(tok.decode(&ids2), "hehe");
2264 }
2265
2266 #[test]
2269 fn added_token_scan_matches_a_fresh_search() {
2270 let mut tok = Tokenizer::from_json(&mini_json()).unwrap();
2271 tok.added = vec![
2273 ("<|eot|>".to_string(), 10),
2274 ("absent!".to_string(), 11),
2275 ("<|".to_string(), 12),
2276 ("he".to_string(), 13),
2277 ];
2278 let fresh = |text: &str| {
2279 let (mut ids, mut rest, mut head) = (Vec::new(), text, true);
2280 while !rest.is_empty() {
2281 let mut best: Option<(usize, usize, u32)> = None;
2282 for (content, id) in &tok.added {
2283 if let Some(pos) = rest.find(content.as_str()) {
2284 if best.is_none_or(|(bp, bl, _)| pos < bp || (pos == bp && content.len() > bl)) {
2285 best = Some((pos, content.len(), *id));
2286 }
2287 }
2288 }
2289 let Some((pos, len, id)) = best else {
2290 tok.encode_segment_at(rest, head, &mut ids);
2291 break;
2292 };
2293 tok.encode_segment_at(&rest[..pos], head, &mut ids);
2294 ids.push(id);
2295 rest = &rest[pos + len..];
2296 head = false;
2297 }
2298 ids
2299 };
2300 for text in [
2301 "",
2302 "he",
2303 "world",
2304 "<|",
2305 "<|eot|>",
2306 "<|eot|><|eot|>he<|",
2307 "hello <|eot|> world <| he <|eot|>he",
2308 "<|<|eot|<|eot|>hehe wor<|ld",
2309 "world he<|eot|> hello he he <|",
2310 ] {
2311 assert_eq!(tok.encode(text), fresh(text), "{text:?}");
2312 }
2313 }
2314
2315 #[test]
2320 fn chat_template_sees_bos_token_once() {
2321 let mut tok = Tokenizer::from_json(&mini_json()).unwrap();
2322 tok.bos_token_id = Some(10);
2323 tok.chat_template = Some(
2324 "{{- bos_token -}}{%- for m in messages -%}{{ m.content }}{%- endfor -%}".to_string(),
2325 );
2326 let messages = vec![("user".to_string(), "he".to_string())];
2327 assert_eq!(tok.apply_chat_template(&messages), vec![10, 8]);
2328 let json = vec![serde_json::json!({"role": "user", "content": "he"})];
2329 assert_eq!(tok.apply_chat_template_json(&json, None, None), vec![10, 8]);
2330 tok.add_bos = true;
2331 assert_eq!(tok.apply_chat_template(&messages), vec![10, 8]);
2332 }
2333
2334 #[test]
2335 fn non_ascii_is_never_silently_dropped() {
2336 let tok = Tokenizer::from_json(&mini_json()).unwrap();
2337 let ids = tok.encode("hello");
2340 assert!(!ids.is_empty());
2341 }
2342}
2343
2344#[cfg(test)]
2345mod generation_tag_tests {
2346 use super::strip_generation_tags;
2347
2348 #[test]
2353 fn a_generation_block_becomes_a_no_op_keeping_its_whitespace_control() {
2354 let tpl = "a{%- generation -%}b{%- endgeneration -%}c";
2355 let out = strip_generation_tags(tpl);
2356 assert!(!out.contains("{%- generation"));
2357 assert!(!out.contains("endgeneration"));
2358 assert_eq!(out.matches("{%-").count(), 2);
2360 assert_eq!(out.matches("-%}").count(), 2);
2361 assert!(out.starts_with('a') && out.ends_with('c'));
2362 }
2363
2364 #[test]
2367 fn each_side_keeps_its_own_dash() {
2368 let out = strip_generation_tags("{% generation %}x{%- endgeneration %}");
2369 assert!(out.starts_with("{% set"), "no dash added on the left");
2370 assert!(out.contains("{%- set"), "the right tag keeps its dash");
2371 assert!(!out.contains("-%}"), "no trailing dash invented");
2372 }
2373
2374 #[test]
2377 fn everything_else_is_left_alone() {
2378 let plain = "{%- if x -%}{{ y }}{%- endif -%}";
2379 assert_eq!(strip_generation_tags(plain), plain);
2380 let prose = "{{ 'the generation of tokens' }}";
2382 assert_eq!(strip_generation_tags(prose), prose);
2383 }
2384}