Skip to main content

cortiq_engine/
chat_template.rs

1//! The Jinja environment chat templates render in — transformers'
2//! semantics, not minijinja's defaults.
3//!
4//! `transformers` renders `chat_template` in a jinja2 sandbox with
5//! `trim_blocks` + `lstrip_blocks`, loop controls, and a handful of
6//! helpers it injects. The one that matters most is `tojson`: it is NOT
7//! jinja2's built-in (which HTML-escapes), but
8//!
9//! ```python
10//! def tojson(x, ensure_ascii=False, indent=None, separators=None, sort_keys=False):
11//!     return json.dumps(x, ensure_ascii=ensure_ascii, indent=indent,
12//!                       separators=separators, sort_keys=sort_keys)
13//! ```
14//!
15//! minijinja's own `tojson` differs on every axis a tool prompt touches:
16//! compact separators (`{"a":1}` against `{"a": 1}`), `<`/`>`/`&`/`'`
17//! escaped as `\u003c`…, no `ensure_ascii`/`separators`/`sort_keys`
18//! keywords (MiniCPM5's `tojson(ensure_ascii=False)` was a hard render
19//! error), and — without minijinja's `preserve_order` — map keys sorted.
20//! Every tool declaration therefore reached the model in a shape it was
21//! never trained on, or not at all. [`py_json_dumps`] reproduces
22//! `json.dumps` byte for byte, including float `repr` and the
23//! `ensure_ascii` surrogate-pair escapes.
24
25use minijinja::value::{Kwargs, Rest, Value, ValueKind};
26use minijinja::{Environment, Error, ErrorKind};
27
28/// Options of Python's `json.dumps` that transformers' `tojson` exposes.
29#[derive(Debug, Clone, Default)]
30pub struct DumpsOptions {
31    pub ensure_ascii: bool,
32    /// `None` = single line. `Some(s)` = newline + `s` per level (Python
33    /// turns an int `n` into `n` spaces; a string is used verbatim).
34    pub indent: Option<String>,
35    /// `(item_separator, key_separator)`; `None` = Python's default,
36    /// which depends on `indent`: `(", ", ": ")` single-line,
37    /// `(",", ": ")` indented.
38    pub separators: Option<(String, String)>,
39    pub sort_keys: bool,
40}
41
42/// Build the environment every chat template renders in.
43pub(crate) fn environment<'a>() -> Environment<'a> {
44    let mut env = Environment::new();
45    env.set_trim_blocks(true);
46    env.set_lstrip_blocks(true);
47    // HF templates use python string/dict methods (.startswith, .get …).
48    env.set_unknown_method_callback(minijinja_contrib::pycompat::unknown_method_callback);
49    env.add_filter("tojson", tojson_filter);
50    // `{{ x }}` and `x | string` print the way Python's `str()` does:
51    // `True`/`False`/`None`, float `repr`, and containers as Python
52    // literals (`['a', 1]`, `{'k': True}`). minijinja's own spelling
53    // (`true`, `none`, `["a", 1]`) reached tool-call HISTORY: MiniCPM5
54    // renders a non-string argument with `{{ param_value }}`, Qwen3.5 /
55    // Qwen3-coder with `args_value | string`, and a boolean argument came
56    // back as a token sequence the model never saw in training. Strings,
57    // integers and every other kind keep minijinja's default path.
58    env.set_formatter(|out, state, value| {
59        if needs_py_str(value) {
60            write!(out, "{}", py_str(value))
61                .map_err(|_| Error::new(ErrorKind::WriteFailure, "formatter write failed"))
62        } else {
63            minijinja::escape_formatter(out, state, value)
64        }
65    });
66    env.add_filter("string", |value: Value| -> Value {
67        if value.kind() == ValueKind::String {
68            value
69        } else if needs_py_str(&value) {
70            Value::from(py_str(&value))
71        } else {
72            Value::from(value.to_string())
73        }
74    });
75    // `raise_exception` is another transformers helper: templates call it
76    // to reject a malformed conversation. Unregistered, the render still
77    // fails, but with "unknown function" instead of the template's reason.
78    env.add_function("raise_exception", |msg: String| -> Result<Value, Error> {
79        Err(Error::new(ErrorKind::InvalidOperation, msg))
80    });
81    // `visible_text` is a helper transformers injects into its template
82    // env (it flattens multimodal content to its text). Nanbeige's
83    // template calls it unconditionally in the tools branch; without it
84    // the render errors and the fallback quietly serves a TOOLLESS prompt.
85    env.add_function("visible_text", |v: Value| -> String {
86        if let Some(s) = v.as_str() {
87            return s.to_string();
88        }
89        if let Ok(iter) = v.try_iter() {
90            let mut out = Vec::new();
91            for item in iter {
92                if let Some(s) = item.as_str() {
93                    out.push(s.to_string());
94                } else if let Ok(t) = item.get_attr("text") {
95                    if let Some(s) = t.as_str() {
96                        out.push(s.to_string());
97                    }
98                }
99            }
100            return out.join("\n");
101        }
102        String::new()
103    });
104    env
105}
106
107/// `x | tojson(ensure_ascii=False, indent=None, separators=None, sort_keys=False)`.
108///
109/// Positional arguments bind in that order, exactly as in the Python
110/// signature — so `tojson(2)` sets `ensure_ascii`, not an indent, the
111/// same as it does under transformers.
112fn tojson_filter(value: &Value, args: Rest<Value>) -> Result<Value, Error> {
113    // minijinja passes keyword arguments as a trailing kwargs value.
114    let mut positional: Vec<Value> = args.0;
115    let kwargs = match positional.last() {
116        Some(last) if last.is_kwargs() => Kwargs::try_from(positional.pop().unwrap())?,
117        _ => Kwargs::from_iter(std::iter::empty::<(String, Value)>()),
118    };
119    if positional.len() > 4 {
120        return Err(Error::new(
121            ErrorKind::TooManyArguments,
122            "tojson() takes at most 4 positional arguments",
123        ));
124    }
125    let mut positional = positional.into_iter();
126    let ensure_ascii = positional.next();
127    let indent = positional.next();
128    let separators = positional.next();
129    let sort_keys = positional.next();
130    let pick = |pos: Option<Value>, name: &str| -> Result<Option<Value>, Error> {
131        let kw: Option<Value> = kwargs.get(name)?;
132        match (pos, kw) {
133            (Some(_), Some(_)) => Err(Error::new(
134                ErrorKind::InvalidOperation,
135                format!("tojson() got multiple values for argument '{name}'"),
136            )),
137            (p, k) => Ok(p.or(k).filter(|v| !v.is_none() && !v.is_undefined())),
138        }
139    };
140    let ensure_ascii = pick(ensure_ascii, "ensure_ascii")?;
141    let indent = pick(indent, "indent")?;
142    let separators = pick(separators, "separators")?;
143    let sort_keys = pick(sort_keys, "sort_keys")?;
144    kwargs.assert_all_used()?;
145
146    let indent = match indent {
147        None => None,
148        Some(v) if v.kind() == ValueKind::String => Some(v.as_str().unwrap_or("").to_string()),
149        Some(v) if v.kind() == ValueKind::Bool => Some(" ".repeat(v.is_true() as usize)),
150        Some(v) => {
151            let n = v.as_i64().ok_or_else(|| {
152                Error::new(
153                    ErrorKind::InvalidOperation,
154                    format!("tojson(): indent must be an int or a string, got {v}"),
155                )
156            })?;
157            Some(" ".repeat(n.max(0) as usize))
158        }
159    };
160    let separators = match separators {
161        None => None,
162        Some(v) => {
163            let items: Vec<Value> = v.try_iter().map(|it| it.collect()).map_err(|_| {
164                Error::new(
165                    ErrorKind::InvalidOperation,
166                    "tojson(): separators must be a (item, key) pair",
167                )
168            })?;
169            match items.as_slice() {
170                [a, b] if a.as_str().is_some() && b.as_str().is_some() => Some((
171                    a.as_str().unwrap().to_string(),
172                    b.as_str().unwrap().to_string(),
173                )),
174                _ => {
175                    return Err(Error::new(
176                        ErrorKind::InvalidOperation,
177                        "tojson(): separators must be a pair of strings",
178                    ));
179                }
180            }
181        }
182    };
183    let opts = DumpsOptions {
184        ensure_ascii: ensure_ascii.is_some_and(|v| v.is_true()),
185        indent,
186        separators,
187        sort_keys: sort_keys.is_some_and(|v| v.is_true()),
188    };
189    // Not HTML-escaped (transformers returns a plain str) and marked safe
190    // so an autoescaping template would not escape it again either.
191    py_json_dumps(value, &opts).map(Value::from_safe_string)
192}
193
194/// Kinds whose Python `str()` differs from minijinja's `Display`.
195fn needs_py_str(v: &Value) -> bool {
196    match v.kind() {
197        ValueKind::None | ValueKind::Bool | ValueKind::Seq | ValueKind::Map => true,
198        ValueKind::Number => !v.is_integer(),
199        _ => false,
200    }
201}
202
203/// Python `str(x)` for template values (strings verbatim, the rest `repr`).
204pub fn py_str(v: &Value) -> String {
205    match v.kind() {
206        ValueKind::String => v.as_str().unwrap_or("").to_string(),
207        ValueKind::Undefined => String::new(),
208        _ => py_repr(v),
209    }
210}
211
212/// Python `repr(x)` for template values.
213pub fn py_repr(v: &Value) -> String {
214    match v.kind() {
215        ValueKind::Undefined | ValueKind::None => "None".into(),
216        ValueKind::Bool => (if v.is_true() { "True" } else { "False" }).into(),
217        ValueKind::Number if v.is_integer() => v.to_string(),
218        ValueKind::Number => {
219            let f = f64::try_from(v.clone()).unwrap_or(f64::NAN);
220            if f.is_nan() {
221                "nan".into()
222            } else if f.is_infinite() {
223                (if f > 0.0 { "inf" } else { "-inf" }).into()
224            } else {
225                py_float_repr(f)
226            }
227        }
228        ValueKind::String => py_str_repr(v.as_str().unwrap_or("")),
229        ValueKind::Seq => {
230            let items: Vec<String> = v
231                .try_iter()
232                .map(|it| it.map(|x| py_repr(&x)).collect())
233                .unwrap_or_default();
234            format!("[{}]", items.join(", "))
235        }
236        ValueKind::Map => {
237            let mut items = Vec::new();
238            if let Ok(keys) = v.try_iter() {
239                for k in keys {
240                    let item = v.get_item(&k).unwrap_or(Value::UNDEFINED);
241                    items.push(format!("{}: {}", py_repr(&k), py_repr(&item)));
242                }
243            }
244            format!("{{{}}}", items.join(", "))
245        }
246        _ => v.to_string(),
247    }
248}
249
250/// Python `repr(str)`: single quotes unless the text holds a `'` and no
251/// `"`; backslash escapes for that quote, the backslash, newline, CR,
252/// tab and other control characters.
253fn py_str_repr(s: &str) -> String {
254    let quote = if s.contains('\'') && !s.contains('"') {
255        '"'
256    } else {
257        '\''
258    };
259    let mut out = String::with_capacity(s.len() + 2);
260    out.push(quote);
261    for c in s.chars() {
262        match c {
263            '\\' => out.push_str("\\\\"),
264            '\n' => out.push_str("\\n"),
265            '\r' => out.push_str("\\r"),
266            '\t' => out.push_str("\\t"),
267            c if c == quote => {
268                out.push('\\');
269                out.push(c);
270            }
271            c if (c as u32) < 0x20 || (0x7f..0xa0).contains(&(c as u32)) => {
272                out.push_str(&format!("\\x{:02x}", c as u32));
273            }
274            c => out.push(c),
275        }
276    }
277    out.push(quote);
278    out
279}
280
281/// Python `json.dumps(value, **opts)` over a template value.
282pub fn py_json_dumps(value: &Value, opts: &DumpsOptions) -> Result<String, Error> {
283    let (item_sep, key_sep) = match &opts.separators {
284        Some((i, k)) => (i.as_str(), k.as_str()),
285        None if opts.indent.is_some() => (",", ": "),
286        None => (", ", ": "),
287    };
288    let mut out = String::new();
289    let mut d = Dumper {
290        opts,
291        item_sep,
292        key_sep,
293        out: &mut out,
294    };
295    d.value(value, 0)?;
296    Ok(out)
297}
298
299struct Dumper<'o> {
300    opts: &'o DumpsOptions,
301    item_sep: &'o str,
302    key_sep: &'o str,
303    out: &'o mut String,
304}
305
306impl Dumper<'_> {
307    fn newline(&mut self, level: usize) {
308        if let Some(ind) = &self.opts.indent {
309            self.out.push('\n');
310            for _ in 0..level {
311                self.out.push_str(ind);
312            }
313        }
314    }
315
316    fn value(&mut self, v: &Value, level: usize) -> Result<(), Error> {
317        match v.kind() {
318            // json.dumps(None) → null. An undefined value cannot reach
319            // Python's json.dumps at all (jinja2 raises); null is the
320            // forgiving spelling rather than a failed render.
321            ValueKind::Undefined | ValueKind::None => self.out.push_str("null"),
322            ValueKind::Bool => self
323                .out
324                .push_str(if v.is_true() { "true" } else { "false" }),
325            ValueKind::Number => {
326                if v.is_integer() {
327                    self.out.push_str(&v.to_string());
328                } else {
329                    let f = f64::try_from(v.clone()).map_err(|_| {
330                        Error::new(ErrorKind::InvalidOperation, "tojson(): bad number")
331                    })?;
332                    self.out.push_str(&py_float_repr(f));
333                }
334            }
335            ValueKind::String => self.string(v.as_str().unwrap_or("")),
336            ValueKind::Seq | ValueKind::Iterable => {
337                let items: Vec<Value> = v.try_iter()?.collect();
338                if items.is_empty() {
339                    self.out.push_str("[]");
340                    return Ok(());
341                }
342                self.out.push('[');
343                for (i, item) in items.iter().enumerate() {
344                    if i > 0 {
345                        self.out.push_str(self.item_sep);
346                    }
347                    self.newline(level + 1);
348                    self.value(item, level + 1)?;
349                }
350                self.newline(level);
351                self.out.push(']');
352            }
353            ValueKind::Map | ValueKind::Plain => {
354                let mut entries: Vec<(String, Value)> = Vec::new();
355                for key in v.try_iter()? {
356                    let item = v.get_item(&key)?;
357                    entries.push((self.key_text(&key)?, item));
358                }
359                if self.opts.sort_keys {
360                    entries.sort_by(|a, b| a.0.cmp(&b.0));
361                }
362                if entries.is_empty() {
363                    self.out.push_str("{}");
364                    return Ok(());
365                }
366                self.out.push('{');
367                for (i, (k, item)) in entries.iter().enumerate() {
368                    if i > 0 {
369                        self.out.push_str(self.item_sep);
370                    }
371                    self.newline(level + 1);
372                    self.string(k);
373                    self.out.push_str(self.key_sep);
374                    self.value(item, level + 1)?;
375                }
376                self.newline(level);
377                self.out.push('}');
378            }
379            other => {
380                return Err(Error::new(
381                    ErrorKind::InvalidOperation,
382                    format!("tojson(): object of type {other} is not JSON serializable"),
383                ));
384            }
385        }
386        Ok(())
387    }
388
389    /// Python coerces non-string keys: int → "1", float → repr, bool →
390    /// "true", None → "null".
391    fn key_text(&self, k: &Value) -> Result<String, Error> {
392        Ok(match k.kind() {
393            ValueKind::String => k.as_str().unwrap_or("").to_string(),
394            ValueKind::None | ValueKind::Undefined => "null".into(),
395            ValueKind::Bool => (if k.is_true() { "true" } else { "false" }).into(),
396            ValueKind::Number if k.is_integer() => k.to_string(),
397            ValueKind::Number => py_float_repr(f64::try_from(k.clone()).unwrap_or(f64::NAN)),
398            other => {
399                return Err(Error::new(
400                    ErrorKind::InvalidOperation,
401                    format!("tojson(): keys must be str, int, float, bool or None, not {other}"),
402                ));
403            }
404        })
405    }
406
407    fn string(&mut self, s: &str) {
408        self.out.push('"');
409        for c in s.chars() {
410            match c {
411                '"' => self.out.push_str("\\\""),
412                '\\' => self.out.push_str("\\\\"),
413                '\n' => self.out.push_str("\\n"),
414                '\r' => self.out.push_str("\\r"),
415                '\t' => self.out.push_str("\\t"),
416                '\u{08}' => self.out.push_str("\\b"),
417                '\u{0c}' => self.out.push_str("\\f"),
418                c if (c as u32) < 0x20 => {
419                    self.out.push_str(&format!("\\u{:04x}", c as u32));
420                }
421                // ensure_ascii escapes everything outside printable ASCII
422                // (space..~), DEL included; astral chars as a UTF-16
423                // surrogate pair, lower-case hex — Python's spelling.
424                c if self.opts.ensure_ascii && !(' '..='~').contains(&c) => {
425                    let mut buf = [0u16; 2];
426                    for unit in c.encode_utf16(&mut buf) {
427                        self.out.push_str(&format!("\\u{:04x}", unit));
428                    }
429                }
430                c => self.out.push(c),
431            }
432        }
433        self.out.push('"');
434    }
435}
436
437/// Python's `float.__repr__`: the shortest round-trip digits, fixed
438/// notation for decimal exponents in (-4, 16], otherwise `d.ddde±XX`
439/// with at least two exponent digits; `json.dumps` spells the
440/// non-finite values `NaN` / `Infinity` / `-Infinity`.
441pub fn py_float_repr(f: f64) -> String {
442    if f.is_nan() {
443        return "NaN".into();
444    }
445    if f.is_infinite() {
446        return if f > 0.0 { "Infinity" } else { "-Infinity" }.into();
447    }
448    if f == 0.0 {
449        return if f.is_sign_negative() { "-0.0" } else { "0.0" }.into();
450    }
451    // Rust's `{:e}` is the shortest round-trip representation too.
452    let e = format!("{:e}", f.abs());
453    let (mant, exp) = e.split_once('e').expect("LowerExp has an exponent");
454    let exp: i32 = exp.parse().expect("LowerExp exponent");
455    let digits: String = mant.chars().filter(|c| c.is_ascii_digit()).collect();
456    let decpt = exp + 1; // value = 0.DIGITS × 10^decpt
457    let mut s = String::new();
458    if f < 0.0 {
459        s.push('-');
460    }
461    if -4 < decpt && decpt <= 16 {
462        let n = digits.len() as i32;
463        if decpt <= 0 {
464            s.push_str("0.");
465            for _ in 0..(-decpt) {
466                s.push('0');
467            }
468            s.push_str(&digits);
469        } else if decpt >= n {
470            s.push_str(&digits);
471            for _ in 0..(decpt - n) {
472                s.push('0');
473            }
474            s.push_str(".0");
475        } else {
476            s.push_str(&digits[..decpt as usize]);
477            s.push('.');
478            s.push_str(&digits[decpt as usize..]);
479        }
480    } else {
481        s.push_str(&digits[..1]);
482        if digits.len() > 1 {
483            s.push('.');
484            s.push_str(&digits[1..]);
485        }
486        s.push('e');
487        s.push(if exp < 0 { '-' } else { '+' });
488        s.push_str(&format!("{:02}", exp.abs()));
489    }
490    s
491}
492
493#[cfg(test)]
494mod tests {
495    use super::*;
496
497    fn render(tpl: &str, ctx: serde_json::Value) -> Result<String, Error> {
498        let mut env = environment();
499        env.add_template("t", tpl)?;
500        env.get_template("t")?.render(Value::from_serialize(&ctx))
501    }
502
503    fn tool() -> serde_json::Value {
504        // Deliberately NOT alphabetical: insertion order must survive.
505        serde_json::json!({
506            "type": "function",
507            "function": {
508                "name": "get_weather",
509                "description": "Погода <city> & \"quotes\" 🌧",
510                "parameters": {
511                    "type": "object",
512                    "properties": {"unit": {"type": "string"}, "city": {"type": "string"}},
513                    "required": ["city"]
514                }
515            }
516        })
517    }
518
519    /// Default call: Python's `", "` / `": "` separators, insertion
520    /// order, non-ASCII verbatim, and NO HTML escaping of `<`, `&`, `'`.
521    /// Expected strings are `json.dumps(tool)` from CPython 3.12.
522    #[test]
523    fn default_matches_python_json_dumps() {
524        let got = render("{{ t | tojson }}", serde_json::json!({"t": tool()})).unwrap();
525        assert_eq!(
526            got,
527            r#"{"type": "function", "function": {"name": "get_weather", "description": "Погода <city> & \"quotes\" 🌧", "parameters": {"type": "object", "properties": {"unit": {"type": "string"}, "city": {"type": "string"}}, "required": ["city"]}}}"#
528        );
529        // MiniCPM5 spells the default out — it was a hard error before.
530        let explicit = render(
531            "{{ t | tojson(ensure_ascii=False) }}",
532            serde_json::json!({"t": tool()}),
533        )
534        .unwrap();
535        assert_eq!(explicit, got);
536    }
537
538    #[test]
539    fn ensure_ascii_escapes_like_python() {
540        let got = render(
541            "{{ s | tojson(ensure_ascii=True) }}",
542            serde_json::json!({"s": "é\u{7f}🌧\u{1}\t'"}),
543        )
544        .unwrap();
545        // json.dumps("é\x7f🌧\x01\t'", ensure_ascii=True)
546        assert_eq!(got, r#""\u00e9\u007f\ud83c\udf27\u0001\t'""#);
547    }
548
549    #[test]
550    fn sort_keys_indent_and_separators() {
551        let v = serde_json::json!({"b": [1, 2], "a": {}, "c": []});
552        let ctx = serde_json::json!({"v": v});
553        assert_eq!(
554            render("{{ v | tojson(sort_keys=True) }}", ctx.clone()).unwrap(),
555            r#"{"a": {}, "b": [1, 2], "c": []}"#
556        );
557        // json.dumps(v, indent=2): item separator loses its space.
558        assert_eq!(
559            render("{{ v | tojson(indent=2) }}", ctx.clone()).unwrap(),
560            "{\n  \"b\": [\n    1,\n    2\n  ],\n  \"a\": {},\n  \"c\": []\n}"
561        );
562        assert_eq!(
563            render(
564                "{{ v | tojson(indent='\\t', sort_keys=true) }}",
565                ctx.clone()
566            )
567            .unwrap(),
568            "{\n\t\"a\": {},\n\t\"b\": [\n\t\t1,\n\t\t2\n\t],\n\t\"c\": []\n}"
569        );
570        assert_eq!(
571            render("{{ v | tojson(separators=(',', ':')) }}", ctx.clone()).unwrap(),
572            r#"{"b":[1,2],"a":{},"c":[]}"#
573        );
574        // indent=0: newlines, no indentation.
575        assert_eq!(
576            render("{{ [1] | tojson(indent=0) }}", ctx.clone()).unwrap(),
577            "[\n1\n]"
578        );
579        // Positional arguments follow the Python signature: the first
580        // one is ensure_ascii, not an indent.
581        assert_eq!(
582            render("{{ 'é' | tojson(true) }}", ctx.clone()).unwrap(),
583            r#""\u00e9""#
584        );
585    }
586
587    #[test]
588    fn unknown_keyword_is_an_error() {
589        assert!(render("{{ 1 | tojson(bogus=1) }}", serde_json::json!({})).is_err());
590    }
591
592    #[test]
593    fn scalars_follow_python() {
594        let got = render(
595            "{{ v | tojson }}",
596            serde_json::json!({"v": [1, -7, 1.5, 18.0, 1e16, 1.0e-5, 0.0001, 123456789012345678u64, true, null]}),
597        )
598        .unwrap();
599        // json.dumps([1, -7, 1.5, 18.0, 1e16, 1e-05, 0.0001, 123456789012345678, True, None])
600        assert_eq!(
601            got,
602            "[1, -7, 1.5, 18.0, 1e+16, 1e-05, 0.0001, 123456789012345678, true, null]"
603        );
604    }
605
606    #[test]
607    fn float_repr_table() {
608        for (f, want) in [
609            (0.1, "0.1"),
610            (1.0 / 3.0, "0.3333333333333333"),
611            (1234567890123456.0, "1234567890123456.0"),
612            (12345678901234567.0, "1.2345678901234568e+16"),
613            (-2.5e-7, "-2.5e-07"),
614            (1e100, "1e+100"),
615            (-0.0, "-0.0"),
616            (f64::INFINITY, "Infinity"),
617        ] {
618            assert_eq!(py_float_repr(f), want, "{f:e}");
619        }
620    }
621
622    /// Template-level iteration keeps request order too (Python dicts
623    /// are insertion-ordered; minijinja sorted them before
624    /// `preserve_order`).
625    #[test]
626    fn dict_items_keep_insertion_order() {
627        let got = render(
628            "{% for k, v in d.items() %}{{ k }}={{ v }};{% endfor %}",
629            serde_json::json!({"d": {"zeta": 1, "alpha": 2, "mid": 3}}),
630        )
631        .unwrap();
632        assert_eq!(got, "zeta=1;alpha=2;mid=3;");
633    }
634
635    /// `{{ x }}` / `x | string` print Python's `str()`; the expected
636    /// strings are CPython's output for the same values.
637    #[test]
638    fn printing_follows_python_str() {
639        let ctx = serde_json::json!({
640            "b": true, "n": null, "f": 1.5e-7, "i": 42, "s": "plain",
641            "l": ["a", "it's", 2, false, null], "d": {"k": true, "z": [1.0]}
642        });
643        let got = render(
644            "{{ b }}|{{ n }}|{{ f }}|{{ i }}|{{ s }}|{{ l }}|{{ d }}|{{ b | string }}|{{ 'x' ~ i }}",
645            ctx,
646        )
647        .unwrap();
648        assert_eq!(
649            got,
650            r#"True|None|1.5e-07|42|plain|['a', "it's", 2, False, None]|{'k': True, 'z': [1.0]}|True|x42"#
651        );
652        // repr("a\nb'\"\\") == 'a\nb\'"\\'
653        assert_eq!(py_str_repr("a\nb'\"\\"), r#"'a\nb\'"\\'"#);
654    }
655
656    #[test]
657    fn raise_exception_carries_the_template_message() {
658        let err = render(
659            "{{ raise_exception('roles must alternate') }}",
660            serde_json::json!({}),
661        )
662        .unwrap_err();
663        assert!(
664            format!("{err:#}").contains("roles must alternate"),
665            "{err:#}"
666        );
667    }
668}