Skip to main content

cortiq_engine/
tokenizer.rs

1//! Byte-level BPE tokenizer — HF tokenizer.json parity.
2//!
3//! Faithful pipeline (matches `tokenizers` for Qwen-style files):
4//!   added-token split (raw text) → NFC → pre-tokenizer regex
5//!   (GPT-2 style, needs lookahead) → byte-level mapping → ranked BPE
6//!   merges → vocab ids. Decode reverses through the byte-level map,
7//!   assembling UTF-8 across token boundaries.
8//!
9//! No silent corruption: a symbol that cannot be encoded is reported
10//! (tracing::error), never dropped without a trace.
11
12use serde::Deserialize;
13use std::collections::{HashMap, HashSet};
14use std::path::Path;
15use unicode_normalization::UnicodeNormalization;
16
17/// GPT-2 pre-tokenizer pattern — used when tokenizer.json carries no
18/// explicit Split regex (Qwen files carry their own; see `from_json`).
19const 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+";
20
21/// A loaded BPE tokenizer.
22pub struct Tokenizer {
23    /// Token string → ID
24    vocab: HashMap<String, u32>,
25    /// ID → Token string
26    id_to_token: Vec<String>,
27    /// BPE merge ranks: (left, right) → rank (lower merges first)
28    ranks: HashMap<(String, String), u32>,
29    /// All added tokens (split during encode; emitted raw at decode)
30    added: Vec<(String, u32)>,
31    /// IDs of added tokens (decode: emit content raw, no byte-map)
32    added_ids: HashSet<u32>,
33    /// IDs of special tokens (skipped by `decode`)
34    special_ids: HashSet<u32>,
35    /// Pre-tokenizer split pattern (None = whitespace fallback for the
36    /// synthetic `byte_level()` tokenizer)
37    /// Applied in order; each subdivides the previous stage's pieces.
38    split_res: Vec<fancy_regex::Regex>,
39    /// SentencePiece Prepend("▁") normalizer present (llama family).
40    /// Gemma replaces spaces with ▁ but does NOT prepend one.
41    sp_prepend: bool,
42    /// Metaspace `prepend_scheme: "first"` in the PRE-tokenizer (no
43    /// Prepend normalizer): the ▁ goes on the very first section of the
44    /// input only, so text after an added token gets none. Nanbeige 4.2
45    /// is this shape — reading only the normalizer left every raw prompt
46    /// short one leading ▁ ("Hello" instead of "▁Hello").
47    sp_prepend_first: bool,
48    /// SentencePiece family (TinyLlama/Llama-2/Mistral): metaspace ▁
49    /// normalization + byte_fallback, no byte-level alphabet.
50    metaspace: bool,
51    /// NFC only when the file's normalizer declares it (Qwen does,
52    /// TinyLlama does not — forcing it broke combining-accent parity).
53    nfc: bool,
54    /// byte → byte-level char (GPT-2 visible-alphabet mapping)
55    byte_to_char: [char; 256],
56    /// byte-level char → byte
57    char_to_byte: HashMap<char, u8>,
58    /// Special tokens
59    pub bos_token_id: Option<u32>,
60    pub eos_token_id: Option<u32>,
61    pub pad_token_id: Option<u32>,
62    /// Chat template special tokens
63    pub im_start_id: Option<u32>,
64    pub im_end_id: Option<u32>,
65    /// Jinja chat template carried by the container (spec §6.1);
66    /// None → hardcoded ChatML fallback.
67    pub chat_template: Option<String>,
68    /// Extra stop ids from the container's generation config.
69    pub extra_eos: HashSet<u32>,
70    /// Generation prepends BOS (llama post_processor semantics).
71    pub add_bos: bool,
72}
73
74impl std::fmt::Debug for Tokenizer {
75    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
76        f.debug_struct("Tokenizer")
77            .field("vocab", &self.vocab.len())
78            .field("merges", &self.ranks.len())
79            .field("added", &self.added.len())
80            .finish()
81    }
82}
83
84/// GPT-2 byte↔unicode bijection: printable bytes map to themselves,
85/// the rest get consecutive codepoints from U+0100 up.
86fn bytes_to_unicode() -> ([char; 256], HashMap<char, u8>) {
87    let mut b2c = ['\0'; 256];
88    let mut c2b = HashMap::with_capacity(256);
89    let mut n = 0u32;
90    for b in 0..=255u16 {
91        let printable =
92            (0x21..=0x7E).contains(&b) || (0xA1..=0xAC).contains(&b) || (0xAE..=0xFF).contains(&b);
93        let c = if printable {
94            char::from_u32(b as u32).unwrap()
95        } else {
96            let c = char::from_u32(256 + n).unwrap();
97            n += 1;
98            c
99        };
100        b2c[b as usize] = c;
101        c2b.insert(c, b as u8);
102    }
103    (b2c, c2b)
104}
105
106/// HuggingFace tokenizer.json schema (the parts we execute).
107#[derive(Deserialize)]
108struct HfTokenizerJson {
109    model: HfModel,
110    #[serde(default)]
111    added_tokens: Vec<HfAddedToken>,
112    #[serde(default)]
113    pre_tokenizer: Option<serde_json::Value>,
114    #[serde(default)]
115    normalizer: Option<serde_json::Value>,
116    #[serde(default)]
117    post_processor: Option<serde_json::Value>,
118}
119
120#[derive(Deserialize)]
121struct HfModel {
122    vocab: HashMap<String, u32>,
123    #[serde(default)]
124    merges: Vec<HfMerge>,
125    #[serde(default)]
126    byte_fallback: bool,
127}
128
129/// Merge rules come in two HF flavours: legacy `"a b"` strings and
130/// modern `["a", "b"]` pairs (Qwen3.5 tokenizer.json uses pairs).
131#[derive(Deserialize)]
132#[serde(untagged)]
133enum HfMerge {
134    Pair([String; 2]),
135    Text(String),
136}
137
138#[derive(Deserialize)]
139struct HfAddedToken {
140    id: u32,
141    content: String,
142    special: bool,
143}
144
145/// Collect EVERY Split regex from a pre_tokenizer subtree, in order.
146///
147/// A Sequence applies its splits one after another, each subdividing what
148/// the previous one produced — taking only the first is not an
149/// approximation, it is a different tokenizer. DeepSeek-V4 puts the digit
150/// rule first and the word rule third, so reading one pattern sent whole
151/// sentences into BPE as a single piece and produced ids the model has
152/// never seen.
153fn collect_split_patterns(pt: &serde_json::Value, out: &mut Vec<String>) {
154    if pt.get("type").and_then(|t| t.as_str()) == Some("Split") {
155        if let Some(r) = pt
156            .get("pattern")
157            .and_then(|p| p.get("Regex"))
158            .and_then(|r| r.as_str())
159        {
160            out.push(r.to_string());
161        }
162        return;
163    }
164    if let Some(list) = pt.get("pretokenizers").and_then(|l| l.as_array()) {
165        for p in list {
166            collect_split_patterns(p, out);
167        }
168    }
169}
170
171/// `prepend_scheme` of a Metaspace pre-tokenizer ("always" | "first" |
172/// "never"), searched through a Sequence.
173fn find_prepend_scheme(pt: &serde_json::Value) -> Option<String> {
174    if pt.get("type").and_then(|t| t.as_str()) == Some("Metaspace") {
175        return pt
176            .get("prepend_scheme")
177            .and_then(|p| p.as_str())
178            .map(String::from);
179    }
180    if let Some(list) = pt.get("pretokenizers").and_then(|l| l.as_array()) {
181        return list.iter().find_map(find_prepend_scheme);
182    }
183    None
184}
185
186/// Neutralise `{% generation %}` / `{% endgeneration %}`.
187///
188/// Transformers uses that pair to mark which span of the render is the
189/// assistant's own tokens, so a trainer can build a loss mask. For
190/// INFERENCE it is transparent — the body renders either way — but
191/// minijinja does not know the statement and fails the whole template,
192/// after which the caller quietly serves a ChatML approximation and the
193/// model answers a differently-shaped prompt than the one it was tuned
194/// on. LiquidAI's LFM2.5 templates use it.
195///
196/// Deleting the tag is not enough: `{%- generation -%}` also carries
197/// whitespace control, and dropping it would leave the newline and the
198/// indentation around it in the output. Each tag becomes an assignment
199/// that does nothing, carrying the SAME dashes, so minijinja trims
200/// exactly what the original would have.
201pub(crate) fn strip_generation_tags(tpl: &str) -> std::borrow::Cow<'_, str> {
202    if !tpl.contains("generation") {
203        return std::borrow::Cow::Borrowed(tpl);
204    }
205    let mut out = String::with_capacity(tpl.len());
206    let mut rest = tpl;
207    let mut touched = false;
208    while let Some(open) = rest.find("{%") {
209        let Some(close_rel) = rest[open..].find("%}") else {
210            break;
211        };
212        let close = open + close_rel + 2;
213        let tag = &rest[open..close];
214        let inner = tag[2..tag.len() - 2].trim();
215        let lead = inner.starts_with('-');
216        let trail = inner.ends_with('-');
217        let name = inner.trim_matches('-').trim();
218        out.push_str(&rest[..open]);
219        if name == "generation" || name == "endgeneration" {
220            out.push_str(if lead { "{%-" } else { "{%" });
221            out.push_str(" set _generation_span = true ");
222            out.push_str(if trail { "-%}" } else { "%}" });
223            touched = true;
224        } else {
225            out.push_str(tag);
226        }
227        rest = &rest[close..];
228    }
229    if !touched {
230        return std::borrow::Cow::Borrowed(tpl);
231    }
232    out.push_str(rest);
233    std::borrow::Cow::Owned(out)
234}
235
236impl Tokenizer {
237    /// Load tokenizer from HuggingFace tokenizer.json file.
238    pub fn from_file(path: impl AsRef<Path>) -> Result<Self, TokenizerError> {
239        let data = std::fs::read_to_string(path.as_ref())
240            .map_err(|e| TokenizerError::Io(e.to_string()))?;
241        Self::from_json(&data)
242    }
243
244    /// Load tokenizer from raw tokenizer.json bytes (CMF VOCAB section).
245    pub fn from_bytes(bytes: &[u8]) -> Result<Self, TokenizerError> {
246        let s = std::str::from_utf8(bytes)
247            .map_err(|e| TokenizerError::Parse(format!("vocab is not UTF-8: {e}")))?;
248        Self::from_json(s)
249    }
250
251    /// Load tokenizer from JSON string.
252    pub fn from_json(json: &str) -> Result<Self, TokenizerError> {
253        let hf: HfTokenizerJson =
254            serde_json::from_str(json).map_err(|e| TokenizerError::Parse(e.to_string()))?;
255
256        let mut vocab = hf.model.vocab;
257        let mut ranks = HashMap::new();
258        for (rank, m) in hf.model.merges.into_iter().enumerate() {
259            let (a, b) = match m {
260                HfMerge::Pair([a, b]) => (a, b),
261                HfMerge::Text(s) => {
262                    let mut it = s.splitn(2, ' ');
263                    match (it.next(), it.next()) {
264                        (Some(a), Some(b)) => (a.to_string(), b.to_string()),
265                        _ => continue,
266                    }
267                }
268            };
269            ranks.insert((a, b), rank as u32);
270        }
271
272        // Family detection: SentencePiece carries byte_fallback and/or a
273        // Prepend("▁") normalizer; byte-level BPE carries a Split regex.
274        // Llama-family post_processor prepends <s> at add_special_tokens
275        // time; generation must honor it (word salad without BOS).
276        let mut saw_gemma_bos = false;
277        let add_bos_detected = hf
278            .post_processor
279            .as_ref()
280            .map(|p| {
281                let pp = p.to_string();
282                pp.contains("\"<s>\"") || pp.contains("\"<bos>\"")
283            })
284            .unwrap_or(false);
285        let nfc = hf
286            .normalizer
287            .as_ref()
288            .map(|n| n.to_string().contains("NFC"))
289            .unwrap_or(false);
290        let metaspace = hf.model.byte_fallback
291            || hf
292                .normalizer
293                .as_ref()
294                .map(|n| n.to_string().contains("\u{2581}") || n.to_string().contains("▁"))
295                .unwrap_or(false);
296        let sp_prepend = hf
297            .normalizer
298            .as_ref()
299            .map(|n| n.to_string().contains("Prepend"))
300            .unwrap_or(false);
301        // Metaspace can also live in the pre-tokenizer, carrying its own
302        // prepend_scheme: "always" behaves like the llama normalizer,
303        // "first" only marks the head of the input (see `sp_prepend_first`).
304        let (sp_prepend, sp_prepend_first) = if sp_prepend {
305            (true, false)
306        } else {
307            match hf.pre_tokenizer.as_ref().and_then(find_prepend_scheme) {
308                Some(s) if s == "always" => (true, false),
309                Some(s) if s == "first" => (false, true),
310                _ => (false, false),
311            }
312        };
313        let split_res = if metaspace {
314            Vec::new()
315        } else {
316            let mut pats = Vec::new();
317            if let Some(pt) = hf.pre_tokenizer.as_ref() {
318                collect_split_patterns(pt, &mut pats);
319            }
320            if pats.is_empty() {
321                pats.push(DEFAULT_SPLIT.to_string());
322            }
323            pats.iter()
324                .map(|p| {
325                    fancy_regex::Regex::new(p)
326                        .map_err(|e| TokenizerError::Parse(format!("pre-tokenizer regex: {e}")))
327                })
328                .collect::<Result<Vec<_>, _>>()?
329        };
330
331        // Added tokens: longest-first so overlapping contents match right.
332        let mut bos_token_id = None;
333        let mut eos_token_id = None;
334        let mut pad_token_id = None;
335        let mut im_start_id = None;
336        let mut im_end_id = None;
337        let mut special_ids = HashSet::new();
338        let mut added_ids = HashSet::new();
339        let mut added = Vec::new();
340
341        for at in &hf.added_tokens {
342            vocab.insert(at.content.clone(), at.id);
343            added.push((at.content.clone(), at.id));
344            added_ids.insert(at.id);
345            if at.special {
346                special_ids.insert(at.id);
347            }
348            match at.content.as_str() {
349                "<|endoftext|>" | "</s>" | "[EOS]" => eos_token_id = Some(at.id),
350                "<|im_start|>" => im_start_id = Some(at.id),
351                "<|im_end|>" => im_end_id = Some(at.id),
352                "<s>" | "[BOS]" => bos_token_id = Some(at.id),
353                // Gemma spells BOS as literal "<bos>" — the family
354                // REQUIRES it on every sequence, and newer tokenizers
355                // (gemma-4) no longer say so in a post_processor.
356                "<bos>" => {
357                    bos_token_id = Some(at.id);
358                    saw_gemma_bos = true;
359                }
360                "<pad>" => pad_token_id = Some(at.id),
361                _ => {}
362            }
363        }
364        added.sort_by_key(|(c, _)| std::cmp::Reverse(c.len()));
365
366        // Gemma REQUIRES a leading <bos> on every sequence, but newer
367        // tokenizers (gemma-4, 262k vocab) no longer spell it in a
368        // post_processor template — the family marker <start_of_turn>
369        // is the reliable tell. Without this, raw-text scoring runs
370        // unanchored and the first ~30 positions read worse than
371        // uniform (the chat path masked it: the template carries <bos>).
372        let gemma_family = saw_gemma_bos
373            || vocab.contains_key("<start_of_turn>")
374            || added.iter().any(|(c, _)| c == "<start_of_turn>");
375
376        // The post_processor template names the exact BOS content
377        // (llama "<s>", gemma "<bos>" — gemma's vocab carries BOTH, so
378        // added-token scan order must not decide).
379        if let Some(pp) = hf.post_processor.as_ref() {
380            let pp = pp.to_string();
381            for name in ["<bos>", "<s>"] {
382                if pp.contains(&format!("\"{name}\"")) {
383                    if let Some(&id) = vocab.get(name) {
384                        bos_token_id = Some(id);
385                    }
386                    break;
387                }
388            }
389        }
390
391        // Build reverse map
392        let max_id = vocab.values().copied().max().unwrap_or(0) as usize;
393        let mut id_to_token = vec![String::new(); max_id + 1];
394        for (token, &id) in &vocab {
395            if (id as usize) < id_to_token.len() {
396                id_to_token[id as usize] = token.clone();
397            }
398        }
399
400        let (byte_to_char, char_to_byte) = bytes_to_unicode();
401
402        tracing::info!(
403            "Tokenizer loaded: {} vocab, {} merges, {} added, eos={:?}",
404            vocab.len(),
405            ranks.len(),
406            added.len(),
407            eos_token_id
408        );
409
410        Ok(Self {
411            vocab,
412            id_to_token,
413            ranks,
414            added,
415            added_ids,
416            special_ids,
417            split_res,
418            metaspace,
419            sp_prepend,
420            sp_prepend_first,
421            nfc,
422            byte_to_char,
423            char_to_byte,
424            bos_token_id,
425            eos_token_id,
426            pad_token_id,
427            im_start_id,
428            im_end_id,
429            chat_template: None,
430            extra_eos: HashSet::new(),
431            add_bos: add_bos_detected || gemma_family,
432        })
433    }
434
435    /// Create a minimal tokenizer for testing (byte tokens, no merges).
436    pub fn byte_level() -> Self {
437        let mut vocab = HashMap::new();
438        let mut id_to_token = Vec::with_capacity(256);
439        for i in 0..256u32 {
440            let tok = format!("<0x{:02X}>", i);
441            vocab.insert(tok.clone(), i);
442            id_to_token.push(tok);
443        }
444        let (byte_to_char, char_to_byte) = bytes_to_unicode();
445        Self {
446            vocab,
447            id_to_token,
448            ranks: HashMap::new(),
449            added: Vec::new(),
450            added_ids: HashSet::new(),
451            special_ids: HashSet::new(),
452            split_res: Vec::new(),
453            metaspace: false,
454            sp_prepend: false,
455            sp_prepend_first: false,
456            nfc: false,
457            byte_to_char,
458            char_to_byte,
459            bos_token_id: None,
460            eos_token_id: None,
461            pad_token_id: None,
462            im_start_id: None,
463            im_end_id: None,
464            chat_template: None,
465            extra_eos: HashSet::new(),
466            add_bos: false,
467        }
468    }
469
470    /// Encode text to token IDs.
471    pub fn encode(&self, text: &str) -> Vec<u32> {
472        let mut ids = Vec::new();
473        // Added tokens match on raw text (normalized: false), longest first.
474        let mut rest = text;
475        // `prepend_scheme: "first"` marks only the section that starts at
476        // offset 0 — HF drops the ▁ for everything after an added token,
477        // which is why a chat prompt opening with <|im_start|> tokenizes
478        // the same either way and only raw prompts were wrong.
479        let mut head = true;
480        'outer: while !rest.is_empty() {
481            let mut best: Option<(usize, usize, u32)> = None; // (pos, len, id)
482            for (content, id) in &self.added {
483                if let Some(pos) = rest.find(content.as_str()) {
484                    let better = match best {
485                        None => true,
486                        Some((bp, bl, _)) => pos < bp || (pos == bp && content.len() > bl),
487                    };
488                    if better {
489                        best = Some((pos, content.len(), *id));
490                    }
491                    if pos == 0 {
492                        break; // earliest possible; added is longest-first
493                    }
494                }
495            }
496            match best {
497                Some((pos, len, id)) => {
498                    self.encode_segment_at(&rest[..pos], head, &mut ids);
499                    ids.push(id);
500                    rest = &rest[pos + len..];
501                    head = false;
502                }
503                None => {
504                    self.encode_segment_at(rest, head, &mut ids);
505                    break 'outer;
506                }
507            }
508        }
509        ids
510    }
511
512    /// Encode one added-token-free segment: NFC → split → byte-map →
513    /// BPE. `head` says whether this section starts at offset 0 of the
514    /// input — only that one takes a `prepend_scheme: "first"` ▁.
515    fn encode_segment_at(&self, segment: &str, head: bool, out: &mut Vec<u32>) {
516        if segment.is_empty() {
517            return;
518        }
519        let norm: String = if self.nfc {
520            segment.nfc().collect()
521        } else {
522            segment.to_string()
523        };
524        if self.metaspace {
525            // SentencePiece: [Prepend("▁") +] Replace(" "→"▁"), BPE over
526            // chars of the whole span (no pre-tokenizer, no byte map).
527            // Gemma's normalizer replaces only — no dummy prefix.
528            let sp = if self.sp_prepend {
529                // Normalizer order: Prepend THEN Replace, unguarded — so
530                // " hello" really does become ▁▁hello on llama.
531                format!("\u{2581}{}", norm).replace(' ', "\u{2581}")
532            } else {
533                // Pre-tokenizer Metaspace: Replace, then prepend only if
534                // the span does not already start with ▁ (HF's guard, so
535                // " hello" stays one ▁, not two).
536                let replaced = norm.replace(' ', "\u{2581}");
537                if self.sp_prepend_first && head && !replaced.starts_with('\u{2581}') {
538                    format!("\u{2581}{replaced}")
539                } else {
540                    replaced
541                }
542            };
543            self.bpe_piece_sp(&sp, out);
544            return;
545        }
546        if !self.split_res.is_empty() {
547            // Each stage subdivides the pieces the previous one left, with
548            // Isolated behaviour: both the matches and the gaps survive.
549            let mut pieces: Vec<(usize, usize)> = vec![(0, norm.len())];
550            for re in &self.split_res {
551                let mut next: Vec<(usize, usize)> = Vec::with_capacity(pieces.len() * 2);
552                for (ps, pe) in pieces {
553                    let seg = &norm[ps..pe];
554                    let mut last = 0usize;
555                    for m in re.find_iter(seg) {
556                        let m = match m {
557                            Ok(m) => m,
558                            Err(e) => {
559                                tracing::error!("pre-tokenizer regex failed: {e}");
560                                break;
561                            }
562                        };
563                        if m.start() > last {
564                            next.push((ps + last, ps + m.start()));
565                        }
566                        if m.end() > m.start() {
567                            next.push((ps + m.start(), ps + m.end()));
568                        }
569                        last = m.end();
570                    }
571                    if last < seg.len() {
572                        next.push((ps + last, pe));
573                    }
574                }
575                pieces = next;
576            }
577            for (ps, pe) in pieces {
578                self.bpe_piece(&norm[ps..pe], out);
579            }
580        } else {
581            {
582                // Synthetic byte_level() tokenizer: raw byte tokens.
583                for b in norm.bytes() {
584                    let tok = format!("<0x{:02X}>", b);
585                    if let Some(&id) = self.vocab.get(&tok) {
586                        out.push(id);
587                    }
588                }
589            }
590        }
591    }
592
593    /// SentencePiece BPE: symbols are chars (no byte-level alphabet);
594    /// unknown symbols fall back to <0xNN> tokens per UTF-8 byte.
595    fn bpe_piece_sp(&self, piece: &str, out: &mut Vec<u32>) {
596        if piece.is_empty() {
597            return;
598        }
599        let mut sym: Vec<String> = piece.chars().map(|c| c.to_string()).collect();
600        loop {
601            let mut best: Option<(u32, usize)> = None;
602            for i in 0..sym.len().saturating_sub(1) {
603                if let Some(&r) = self.ranks.get(&(sym[i].clone(), sym[i + 1].clone())) {
604                    if best.map(|(br, _)| r < br).unwrap_or(true) {
605                        best = Some((r, i));
606                    }
607                }
608            }
609            let Some((_, i)) = best else { break };
610            let merged = format!("{}{}", sym[i], sym[i + 1]);
611            let (left, right) = (sym[i].clone(), sym[i + 1].clone());
612            let mut j = 0;
613            while j + 1 < sym.len() {
614                if sym[j] == left && sym[j + 1] == right {
615                    sym[j] = merged.clone();
616                    sym.remove(j + 1);
617                }
618                j += 1;
619            }
620        }
621        for t in &sym {
622            if let Some(&id) = self.vocab.get(t) {
623                out.push(id);
624            } else {
625                let mut ok = true;
626                for byte in t.bytes() {
627                    let tok = format!("<0x{:02X}>", byte);
628                    match self.vocab.get(&tok) {
629                        Some(&id) => out.push(id),
630                        None => {
631                            ok = false;
632                            break;
633                        }
634                    }
635                }
636                if !ok {
637                    tracing::error!("tokenizer: no id for SP symbol {t:?} — dropped");
638                }
639            }
640        }
641    }
642
643    /// Byte-level map one pre-token piece, then ranked BPE merges.
644    fn bpe_piece(&self, piece: &str, out: &mut Vec<u32>) {
645        if piece.is_empty() {
646            return;
647        }
648        let mapped: Vec<String> = piece
649            .bytes()
650            .map(|b| self.byte_to_char[b as usize].to_string())
651            .collect();
652        let mut sym = mapped;
653
654        // Classic BPE: repeatedly merge the lowest-rank adjacent pair.
655        loop {
656            let mut best: Option<(u32, usize)> = None;
657            for i in 0..sym.len().saturating_sub(1) {
658                if let Some(&r) = self.ranks.get(&(sym[i].clone(), sym[i + 1].clone())) {
659                    if best.map(|(br, _)| r < br).unwrap_or(true) {
660                        best = Some((r, i));
661                    }
662                }
663            }
664            let Some((_, i)) = best else { break };
665            let merged = format!("{}{}", sym[i], sym[i + 1]);
666            // Merge ALL occurrences of this exact pair, left to right.
667            let (left, right) = (sym[i].clone(), sym[i + 1].clone());
668            let mut j = 0;
669            while j + 1 < sym.len() {
670                if sym[j] == left && sym[j + 1] == right {
671                    sym[j] = merged.clone();
672                    sym.remove(j + 1);
673                }
674                j += 1;
675            }
676        }
677
678        for s in &sym {
679            if let Some(&id) = self.vocab.get(s) {
680                out.push(id);
681            } else {
682                // Byte-fallback (synthetic vocabs); never drop silently.
683                let mut ok = true;
684                for ch in s.chars() {
685                    let Some(&b) = self.char_to_byte.get(&ch) else {
686                        ok = false;
687                        break;
688                    };
689                    let tok = format!("<0x{:02X}>", b);
690                    if let Some(&id) = self.vocab.get(&tok) {
691                        out.push(id);
692                    } else {
693                        ok = false;
694                        break;
695                    }
696                }
697                if !ok {
698                    tracing::error!("tokenizer: no id for symbol {s:?} — dropped");
699                }
700            }
701        }
702    }
703
704    /// Decode token IDs back to text. Special tokens are skipped; added
705    /// tokens are raw text; everything else reverses the byte-level map.
706    pub fn decode(&self, ids: &[u32]) -> String {
707        let mut bytes: Vec<u8> = Vec::new();
708        for &id in ids {
709            if self.special_ids.contains(&id) {
710                continue;
711            }
712            let idx = id as usize;
713            if idx >= self.id_to_token.len() {
714                continue;
715            }
716            let tok = &self.id_to_token[idx];
717            if self.added_ids.contains(&id) {
718                // Gemma-3n declares its multi-space ▁-runs as ADDED
719                // tokens — verbatim passthrough leaked ▁ into output.
720                if self.metaspace && tok.contains('\u{2581}') {
721                    bytes.extend_from_slice(tok.replace('\u{2581}', " ").as_bytes());
722                } else {
723                    bytes.extend_from_slice(tok.as_bytes());
724                }
725                continue;
726            }
727            // Byte-fallback / legacy byte tokens
728            if tok.starts_with("<0x") && tok.ends_with('>') && tok.len() == 6 {
729                if let Ok(b) = u8::from_str_radix(&tok[3..5], 16) {
730                    bytes.push(b);
731                    continue;
732                }
733            }
734            if self.metaspace {
735                // SP decoder: Replace(▁→" "); UTF-8 chars pass through.
736                for ch in tok.chars() {
737                    if ch == '\u{2581}' {
738                        bytes.push(b' ');
739                    } else {
740                        let mut buf = [0u8; 4];
741                        bytes.extend_from_slice(ch.encode_utf8(&mut buf).as_bytes());
742                    }
743                }
744                continue;
745            }
746            for ch in tok.chars() {
747                match self.char_to_byte.get(&ch) {
748                    Some(&b) => bytes.push(b),
749                    // Not a byte-level char (shouldn't happen for real
750                    // vocabs) — pass the char through as UTF-8.
751                    None => {
752                        let mut buf = [0u8; 4];
753                        bytes.extend_from_slice(ch.encode_utf8(&mut buf).as_bytes());
754                    }
755                }
756            }
757        }
758        let text = String::from_utf8_lossy(&bytes).into_owned();
759        if self.metaspace && (self.sp_prepend || self.sp_prepend_first) {
760            // SP decoder Strip(start=1): one leading space from Prepend.
761            if let Some(stripped) = text.strip_prefix(' ') {
762                return stripped.to_string();
763            }
764        }
765        text
766    }
767
768    /// Streaming decode of ONE token: no sequence-level Strip — a
769    /// per-token strip would eat the ▁-spaces of every SP word.
770    pub fn decode_token(&self, id: u32) -> String {
771        if self.special_ids.contains(&id) {
772            return String::new();
773        }
774        let idx = id as usize;
775        if idx >= self.id_to_token.len() {
776            return String::new();
777        }
778        let tok = &self.id_to_token[idx];
779        if self.added_ids.contains(&id) {
780            if self.metaspace && tok.contains('\u{2581}') {
781                return tok.replace('\u{2581}', " ");
782            }
783            return tok.clone();
784        }
785        if tok.starts_with("<0x") && tok.ends_with('>') && tok.len() == 6 {
786            if let Ok(b) = u8::from_str_radix(&tok[3..5], 16) {
787                return String::from_utf8_lossy(&[b]).into_owned();
788            }
789        }
790        if self.metaspace {
791            return tok.replace('\u{2581}', " ");
792        }
793        let mut bytes = Vec::new();
794        for ch in tok.chars() {
795            match self.char_to_byte.get(&ch) {
796                Some(&b) => bytes.push(b),
797                None => {
798                    let mut buf = [0u8; 4];
799                    bytes.extend_from_slice(ch.encode_utf8(&mut buf).as_bytes());
800                }
801            }
802        }
803        String::from_utf8_lossy(&bytes).into_owned()
804    }
805
806    /// Render the container's Jinja chat template (HF semantics:
807    /// trim_blocks + lstrip_blocks + loop controls) and encode it.
808    /// Falls back to hardcoded ChatML when the file carries none.
809    pub fn apply_chat_template(&self, messages: &[(String, String)]) -> Vec<u32> {
810        self.apply_chat_template_opts(messages, None)
811    }
812
813    /// Like `apply_chat_template`, with an explicit `enable_thinking` value for
814    /// reasoning-model templates (Qwen3/3.5 emit an empty <think> block when it
815    /// is false, so the model answers directly). `None` leaves the variable
816    /// undefined — the template's own default applies.
817
818    /// Chat template with the FULL message shape and a tool list.
819    ///
820    /// The pair-based API below flattens every message to (role, text),
821    /// which silently drops exactly what agentic use needs: the `tools`
822    /// array, `role: "tool"` results, and `tool_calls` on assistant
823    /// turns. The templates this format embeds — Qwen-family, Nanbeige —
824    /// have carried a `{%- if tools %}` branch all along; this is the
825    /// call that finally feeds it. Messages arrive as JSON objects in
826    /// the OpenAI shape and pass through to minijinja unflattened, so a
827    /// template sees the same fields a Python `apply_chat_template`
828    /// would.
829    pub fn apply_chat_template_json(
830        &self,
831        messages: &[serde_json::Value],
832        tools: Option<&[serde_json::Value]>,
833        enable_thinking: Option<bool>,
834    ) -> Vec<u32> {
835        if let Some(tpl) = &self.chat_template {
836            match self.render_template_json(tpl, messages, tools, enable_thinking) {
837                Ok(text) => return self.with_bos(self.encode(&text)),
838                Err(e) => {
839                    tracing::error!("chat template render failed ({e}); ChatML fallback");
840                }
841            }
842        }
843        let pairs: Vec<(String, String)> = messages
844            .iter()
845            .map(|m| {
846                (
847                    m.get("role")
848                        .and_then(|v| v.as_str())
849                        .unwrap_or("user")
850                        .to_string(),
851                    m.get("content")
852                        .and_then(|v| v.as_str())
853                        .unwrap_or("")
854                        .to_string(),
855                )
856            })
857            .collect();
858        self.with_bos(self.chatml_fallback_opts(&pairs, enable_thinking))
859    }
860
861    /// Render the template against JSON-shaped messages (parity surface).
862    pub fn render_chat_json(
863        &self,
864        messages: &[serde_json::Value],
865        tools: Option<&[serde_json::Value]>,
866        enable_thinking: Option<bool>,
867    ) -> Option<String> {
868        let tpl = self.chat_template.as_ref()?;
869        match self.render_template_json(tpl, messages, tools, enable_thinking) {
870            Ok(t) => Some(t),
871            Err(e) => {
872                tracing::error!("chat template render (json): {e:#}");
873                eprintln!("chat template render (json): {e:#}");
874                None
875            }
876        }
877    }
878
879    fn render_template_json(
880        &self,
881        tpl: &str,
882        messages: &[serde_json::Value],
883        tools: Option<&[serde_json::Value]>,
884        enable_thinking: Option<bool>,
885    ) -> Result<String, minijinja::Error> {
886        let mut env = minijinja::Environment::new();
887        env.set_trim_blocks(true);
888        env.set_lstrip_blocks(true);
889        env.set_unknown_method_callback(minijinja_contrib::pycompat::unknown_method_callback);
890        // `visible_text` is a helper transformers injects into its
891        // template env (it flattens multimodal content to its text).
892        // Nanbeige's template calls it unconditionally in the tools
893        // branch; without it the render errors and the fallback quietly
894        // serves a TOOLLESS prompt.
895        env.add_function("visible_text", |v: minijinja::Value| -> String {
896            if let Some(s) = v.as_str() {
897                return s.to_string();
898            }
899            if let Ok(iter) = v.try_iter() {
900                let mut out = Vec::new();
901                for item in iter {
902                    if let Some(s) = item.as_str() {
903                        out.push(s.to_string());
904                    } else if let Ok(t) = item.get_attr("text") {
905                        if let Some(s) = t.as_str() {
906                            out.push(s.to_string());
907                        }
908                    }
909                }
910                return out.join("\n");
911            }
912            String::new()
913        });
914        let tpl_src = strip_generation_tags(tpl);
915        env.add_template("chat", &tpl_src)?;
916        let msgs: Vec<minijinja::Value> = messages
917            .iter()
918            .map(minijinja::Value::from_serialize)
919            .collect();
920        let tools_v: Option<Vec<minijinja::Value>> =
921            tools.map(|ts| ts.iter().map(minijinja::Value::from_serialize).collect());
922        let tpl = env.get_template("chat")?;
923        // Three axes, each present only when meaningful: templates guard
924        // with `is defined`, and an explicit null flips those guards.
925        // `tool_call_format` picks the grammar in two-mode templates
926        // (Nanbeige): undefined falls into their XML branch. The JSON
927        // grammar is the one every parser downstream speaks, so choose
928        // it explicitly; templates without the knob never read it.
929        let rendered = match (tools_v, enable_thinking) {
930            (Some(ts), Some(v)) => tpl.render(minijinja::context! {
931                messages => msgs, tools => ts, add_generation_prompt => true, enable_thinking => v,
932                tool_call_format => "json",
933            })?,
934            (Some(ts), None) => tpl.render(minijinja::context! {
935                messages => msgs, tools => ts, add_generation_prompt => true,
936                tool_call_format => "json",
937            })?,
938            (None, Some(v)) => tpl.render(minijinja::context! {
939                messages => msgs, add_generation_prompt => true, enable_thinking => v,
940                tool_call_format => "json",
941            })?,
942            (None, None) => tpl.render(minijinja::context! {
943                messages => msgs, add_generation_prompt => true,
944                tool_call_format => "json",
945            })?,
946        };
947        Ok(rendered)
948    }
949
950    pub fn apply_chat_template_opts(
951        &self,
952        messages: &[(String, String)],
953        enable_thinking: Option<bool>,
954    ) -> Vec<u32> {
955        if let Some(tpl) = &self.chat_template {
956            match self.render_template(tpl, messages, enable_thinking) {
957                Ok(text) => return self.with_bos(self.encode(&text)),
958                Err(e) => {
959                    tracing::error!("chat template render failed ({e}); ChatML fallback");
960                }
961            }
962        }
963        self.with_bos(self.chatml_fallback_opts(messages, enable_thinking))
964    }
965
966    /// Prepend BOS when the tokenizer declares it (llama family).
967    pub fn with_bos(&self, mut ids: Vec<u32>) -> Vec<u32> {
968        if self.add_bos {
969            if let Some(b) = self.bos_token_id {
970                if ids.first() != Some(&b) {
971                    ids.insert(0, b);
972                }
973            }
974        }
975        ids
976    }
977
978    /// Render the carried template to text (parity-testable surface).
979    pub fn render_chat(&self, messages: &[(String, String)]) -> Option<String> {
980        self.render_chat_opts(messages, None)
981    }
982
983    /// Render the carried template to text with explicit thinking mode.
984    pub fn render_chat_opts(
985        &self,
986        messages: &[(String, String)],
987        enable_thinking: Option<bool>,
988    ) -> Option<String> {
989        let tpl = self.chat_template.as_ref()?;
990        match self.render_template(tpl, messages, enable_thinking) {
991            Ok(t) => Some(t),
992            Err(e) => {
993                tracing::error!("chat template render: {e:#}");
994                None
995            }
996        }
997    }
998
999    fn render_template(
1000        &self,
1001        tpl: &str,
1002        messages: &[(String, String)],
1003        enable_thinking: Option<bool>,
1004    ) -> Result<String, minijinja::Error> {
1005        let mut env = minijinja::Environment::new();
1006        env.set_trim_blocks(true);
1007        env.set_lstrip_blocks(true);
1008        // HF templates use python string methods (.startswith, .strip…).
1009        env.set_unknown_method_callback(minijinja_contrib::pycompat::unknown_method_callback);
1010        let tpl_src = strip_generation_tags(tpl);
1011        env.add_template("chat", &tpl_src)?;
1012        let msgs: Vec<minijinja::Value> = messages
1013            .iter()
1014            .map(|(role, content)| {
1015                minijinja::context! { role => role, content => content }
1016            })
1017            .collect();
1018        // `enable_thinking` stays UNDEFINED when None — reasoning templates
1019        // check `enable_thinking is defined` and fall back to their default.
1020        let rendered = match enable_thinking {
1021            Some(v) => env.get_template("chat")?.render(minijinja::context! {
1022                messages => msgs,
1023                add_generation_prompt => true,
1024                enable_thinking => v,
1025            })?,
1026            None => env.get_template("chat")?.render(minijinja::context! {
1027                messages => msgs,
1028                add_generation_prompt => true,
1029            })?,
1030        };
1031        // Templates that ignore `enable_thinking` (e.g. Nanbeige/Qwen-legacy)
1032        // always emit a generation prompt. When thinking is explicitly disabled,
1033        // prefill an empty <think>…</think> block so the model answers directly.
1034        if enable_thinking == Some(false) && !rendered.contains("</think>") {
1035            if let Some(pos) = rendered.rfind("assistant") {
1036                let mut insert_at = pos + "assistant".len();
1037                if let Some(idx) = rendered[insert_at..].find('\n') {
1038                    insert_at += idx + 1;
1039                }
1040                let mut out = String::with_capacity(rendered.len() + 24);
1041                out.push_str(&rendered[..insert_at]);
1042                if !out.ends_with('\n') {
1043                    out.push('\n');
1044                }
1045                out.push_str("<think>\n\n</think>\n\n");
1046                out.push_str(&rendered[insert_at..]);
1047                return Ok(out);
1048            }
1049        }
1050        Ok(rendered)
1051    }
1052
1053    /// Hardcoded Qwen ChatML (pre-§6.1 files).
1054    fn chatml_fallback(&self, messages: &[(String, String)]) -> Vec<u32> {
1055        self.chatml_fallback_opts(messages, None)
1056    }
1057
1058    /// Hardcoded Qwen ChatML (pre-§6.1 files) with optional thinking suppression.
1059    fn chatml_fallback_opts(
1060        &self,
1061        messages: &[(String, String)],
1062        enable_thinking: Option<bool>,
1063    ) -> Vec<u32> {
1064        let mut tokens = Vec::new();
1065
1066        for (role, content) in messages {
1067            // <|im_start|>role\ncontent<|im_end|>\n
1068            if let Some(start_id) = self.im_start_id {
1069                tokens.push(start_id);
1070            }
1071            tokens.extend(self.encode(&format!("{}\n{}", role, content)));
1072            if let Some(end_id) = self.im_end_id {
1073                tokens.push(end_id);
1074            }
1075            tokens.extend(self.encode("\n"));
1076        }
1077
1078        // Add assistant prefix
1079        if let Some(start_id) = self.im_start_id {
1080            tokens.push(start_id);
1081        }
1082        tokens.extend(self.encode("assistant\n"));
1083        if enable_thinking == Some(false) {
1084            tokens.extend(self.encode("<think>\n\n</think>\n\n"));
1085        }
1086
1087        tokens
1088    }
1089
1090    /// Vocabulary size.
1091    pub fn vocab_size(&self) -> usize {
1092        self.id_to_token.len()
1093    }
1094
1095    /// Check if token ID is EOS.
1096    pub fn is_eos(&self, id: u32) -> bool {
1097        self.eos_token_id == Some(id) || self.im_end_id == Some(id) || self.extra_eos.contains(&id)
1098    }
1099}
1100
1101#[derive(Debug, thiserror::Error)]
1102pub enum TokenizerError {
1103    #[error("IO error: {0}")]
1104    Io(String),
1105    #[error("Parse error: {0}")]
1106    Parse(String),
1107}
1108
1109#[cfg(test)]
1110mod tests {
1111    use super::*;
1112
1113    #[test]
1114    fn byte_unicode_bijection() {
1115        let (b2c, c2b) = bytes_to_unicode();
1116        for b in 0..=255u8 {
1117            assert_eq!(c2b[&b2c[b as usize]], b);
1118        }
1119        // GPT-2 well-known mappings: space → Ġ, newline → Ċ
1120        assert_eq!(b2c[b' ' as usize], 'Ġ');
1121        assert_eq!(b2c[b'\n' as usize], 'Ċ');
1122    }
1123
1124    #[test]
1125    fn byte_level_roundtrip_utf8() {
1126        let tok = Tokenizer::byte_level();
1127        let text = "hello 🌍 hi\n";
1128        let ids = tok.encode(text);
1129        assert_eq!(ids.len(), text.len()); // one id per byte
1130        assert_eq!(tok.decode(&ids), text);
1131    }
1132
1133    /// A tiny real-format tokenizer.json exercising the full pipeline:
1134    /// GPT-2 regex, byte-level alphabet, one merge, an added token.
1135    fn mini_json() -> String {
1136        // vocab: byte-level chars for h,e,l,o,Ġ,w,r,d + merged "he"
1137        let vocab: Vec<(&str, u32)> = vec![
1138            ("h", 0),
1139            ("e", 1),
1140            ("l", 2),
1141            ("o", 3),
1142            ("Ġ", 4),
1143            ("w", 5),
1144            ("r", 6),
1145            ("d", 7),
1146            ("he", 8),
1147            ("Ġw", 9),
1148        ];
1149        let vocab_json: String = vocab
1150            .iter()
1151            .map(|(t, i)| format!("\"{t}\": {i}"))
1152            .collect::<Vec<_>>()
1153            .join(", ");
1154        format!(
1155            r#"{{
1156              "model": {{
1157                "type": "BPE",
1158                "vocab": {{ {vocab_json} }},
1159                "merges": [["h", "e"], ["Ġ", "w"]]
1160              }},
1161              "added_tokens": [
1162                {{"id": 10, "content": "<|eot|>", "special": true}}
1163              ]
1164            }}"#
1165        )
1166    }
1167
1168    /// Parity against HuggingFace on a real tokenizer, run only when the
1169    /// file is present (CMF_TOK_PARITY=/path/to/tokenizer.json).
1170    #[test]
1171    fn real_tokenizer_parity_when_available() {
1172        let Ok(path) = std::env::var("CMF_TOK_PARITY") else {
1173            return;
1174        };
1175        let t = Tokenizer::from_file(&path).expect("load");
1176        for (text, want) in [
1177            (
1178                "The capital of France is",
1179                vec![671u32, 6102, 294, 8760, 344],
1180            ),
1181            ("2 + 2 =", vec![20, 940, 223, 20, 438]),
1182        ] {
1183            let got = t.encode(text);
1184            assert_eq!(got, want, "«{text}»");
1185        }
1186    }
1187
1188    /// A Sequence of Splits applies ALL of them, in order. Reading only the
1189    /// first is not a near-miss: DeepSeek-V4 puts a digit rule first and the
1190    /// word rule third, so one-pattern behaviour hands BPE a whole sentence
1191    /// as a single piece and the ids that come back are ones the model was
1192    /// never trained on.
1193    #[test]
1194    fn every_split_in_a_sequence_is_applied() {
1195        let pt = serde_json::json!({
1196            "type": "Sequence",
1197            "pretokenizers": [
1198                {"type": "Split", "behavior": "Isolated",
1199                 "pattern": {"Regex": r"\p{N}{1,3}"}},
1200                {"type": "Split", "behavior": "Isolated",
1201                 "pattern": {"Regex": r" ?[\p{L}]+"}},
1202                {"type": "ByteLevel", "add_prefix_space": false, "use_regex": false}
1203            ]
1204        });
1205        let mut pats = Vec::new();
1206        collect_split_patterns(&pt, &mut pats);
1207        assert_eq!(
1208            pats.len(),
1209            2,
1210            "both Split stages must be collected: {pats:?}"
1211        );
1212        assert!(pats[0].contains("p{N}"), "digit rule first");
1213        assert!(pats[1].contains("p{L}"), "word rule second");
1214
1215        // And the staged subdivision reaches the word boundaries. Building a
1216        // byte-level tokenizer over this pre_tokenizer, "ab cd" has to become
1217        // two pieces rather than one.
1218        let re: Vec<fancy_regex::Regex> = pats
1219            .iter()
1220            .map(|p| fancy_regex::Regex::new(p).unwrap())
1221            .collect();
1222        let norm = "ab cd12";
1223        let mut pieces: Vec<(usize, usize)> = vec![(0, norm.len())];
1224        for r in &re {
1225            let mut next = Vec::new();
1226            for (ps, pe) in pieces {
1227                let seg = &norm[ps..pe];
1228                let mut last = 0;
1229                for m in r.find_iter(seg).flatten() {
1230                    if m.start() > last {
1231                        next.push((ps + last, ps + m.start()));
1232                    }
1233                    if m.end() > m.start() {
1234                        next.push((ps + m.start(), ps + m.end()));
1235                    }
1236                    last = m.end();
1237                }
1238                if last < seg.len() {
1239                    next.push((ps + last, pe));
1240                }
1241            }
1242            pieces = next;
1243        }
1244        let got: Vec<&str> = pieces.iter().map(|(a, b)| &norm[*a..*b]).collect();
1245        assert_eq!(
1246            got,
1247            vec!["ab", " cd", "12"],
1248            "staged split produced {got:?}"
1249        );
1250    }
1251
1252    #[test]
1253    fn full_pipeline_merges_and_added_tokens() {
1254        let tok = Tokenizer::from_json(&mini_json()).unwrap();
1255        // "hello world" → [he,l,l,o, Ġw,o,r,l,d]
1256        let ids = tok.encode("hello world");
1257        assert_eq!(ids, vec![8, 2, 2, 3, 9, 3, 6, 2, 7]);
1258        assert_eq!(tok.decode(&ids), "hello world");
1259        // Added token splits and is skipped at decode (special).
1260        let ids2 = tok.encode("he<|eot|>he");
1261        assert_eq!(ids2, vec![8, 10, 8]);
1262        assert_eq!(tok.decode(&ids2), "hehe");
1263    }
1264
1265    #[test]
1266    fn non_ascii_is_never_silently_dropped() {
1267        let tok = Tokenizer::from_json(&mini_json()).unwrap();
1268        // A non-ASCII char is not encodable by the mini vocab (no byte tokens either):
1269        // the id list may be empty, but ASCII around it must survive.
1270        let ids = tok.encode("hello");
1271        assert!(!ids.is_empty());
1272    }
1273}
1274
1275#[cfg(test)]
1276mod generation_tag_tests {
1277    use super::strip_generation_tags;
1278
1279    /// LFM2.5's template wraps the assistant branch in `{%- generation -%}`.
1280    /// minijinja does not know the statement, the whole template failed,
1281    /// and the caller served a ChatML approximation instead — the model
1282    /// then answers a differently-shaped prompt than it was tuned on.
1283    #[test]
1284    fn a_generation_block_becomes_a_no_op_keeping_its_whitespace_control() {
1285        let tpl = "a{%- generation -%}b{%- endgeneration -%}c";
1286        let out = strip_generation_tags(tpl);
1287        assert!(!out.contains("{%- generation"));
1288        assert!(!out.contains("endgeneration"));
1289        // Both dashes survive on both tags: the trimming must not change.
1290        assert_eq!(out.matches("{%-").count(), 2);
1291        assert_eq!(out.matches("-%}").count(), 2);
1292        assert!(out.starts_with('a') && out.ends_with('c'));
1293    }
1294
1295    /// Whitespace control is per-side, and a tag without dashes must not
1296    /// grow any.
1297    #[test]
1298    fn each_side_keeps_its_own_dash() {
1299        let out = strip_generation_tags("{% generation %}x{%- endgeneration %}");
1300        assert!(out.starts_with("{% set"), "no dash added on the left");
1301        assert!(out.contains("{%- set"), "the right tag keeps its dash");
1302        assert!(!out.contains("-%}"), "no trailing dash invented");
1303    }
1304
1305    /// Templates that never use it are returned untouched, and other
1306    /// statements are never rewritten.
1307    #[test]
1308    fn everything_else_is_left_alone() {
1309        let plain = "{%- if x -%}{{ y }}{%- endif -%}";
1310        assert_eq!(strip_generation_tags(plain), plain);
1311        // The word appearing in TEXT is not a statement.
1312        let prose = "{{ 'the generation of tokens' }}";
1313        assert_eq!(strip_generation_tags(prose), prose);
1314    }
1315}