Skip to main content

template/
engine.rs

1use alloc::{
2    collections::BTreeMap,
3    format,
4    string::{String, ToString},
5    sync::Arc,
6    vec,
7    vec::Vec,
8};
9use core::fmt;
10
11use crate::{
12    ast::{Node, NodeList},
13    context::{Context, IntoContext},
14    error::Error,
15    parser::TemplateParser,
16    value::Value,
17    vm::Renderer,
18};
19
20const MAX_EXTEND_DEPTH: usize = 128;
21
22/// Default maximum number of items a `range()` call may produce.
23///
24/// This bounds the memory a template can allocate through `range()`. It can be
25/// changed per engine with [`Engine::set_range_limit`].
26pub const DEFAULT_RANGE_LIMIT: usize = 32_000;
27
28/// Controls auto-escaping behavior.
29///
30/// - `Html` — auto-escapes `{{ ... }}` output (`&`, `<`, `>`, `"`, `'`)
31/// - `Text` — no escaping, raw output
32#[derive(Clone, Debug)]
33pub enum EscapeMode {
34    Html,
35    Text,
36}
37
38pub(crate) struct ParsedTemplate {
39    pub(crate) nodes: NodeList,
40}
41
42/// A template engine that compiles templates into an AST and renders them.
43///
44/// Templates are added by name and can reference each other via `{% include %}`
45/// and `{% extends %}` / `{% block %}`.
46///
47/// # Trust model
48///
49/// Templates are **trusted code**, exactly like Jinja2 or MiniJinja: a template
50/// can read any variable in the rendering context and can consume CPU/memory
51/// within the configured limits. Never feed untrusted template *source* to
52/// [`add_template`](Engine::add_template). User data belongs in the *context*,
53/// which is escaped automatically in [`EscapeMode::Html`].
54pub struct Engine {
55    pub(crate) mode: EscapeMode,
56    pub(crate) templates: BTreeMap<String, ParsedTemplate>,
57    pub(crate) range_limit: usize,
58}
59
60impl Engine {
61    /// Create a new engine with the given escaping mode.
62    pub fn new(mode: EscapeMode) -> Self {
63        Self {
64            mode,
65            templates: BTreeMap::new(),
66            range_limit: DEFAULT_RANGE_LIMIT,
67        }
68    }
69
70    /// Set the maximum number of items a `range()` call may produce.
71    ///
72    /// Defaults to [`DEFAULT_RANGE_LIMIT`]. Calls that would exceed the limit
73    /// return a render error instead of allocating an unbounded amount of memory.
74    pub fn set_range_limit(&mut self, limit: usize) {
75        self.range_limit = limit;
76    }
77
78    /// Return the current `range()` item limit.
79    pub fn range_limit(&self) -> usize {
80        self.range_limit
81    }
82
83    /// Compile and register a template by name.
84    ///
85    /// Returns an error if the template source contains invalid syntax
86    /// (e.g. unclosed `{{ }}`, mismatched `{% if %}` / `{% endif %}`),
87    /// exceeded nesting limits, statically-detectable type errors, or if a
88    /// template with the same name already exists.
89    pub fn add_template(&mut self, name: &str, source: &str) -> Result<(), Error> {
90        if self.templates.contains_key(name) {
91            return Err(Error::parse(format!("template `{name}` already exists")));
92        }
93        let mut parser = TemplateParser::new(source);
94        let nodes = parser.parse()?;
95
96        self.templates.insert(
97            name.to_string(),
98            ParsedTemplate {
99                nodes,
100            },
101        );
102        Ok(())
103    }
104
105    /// Render a named template with the given context variables.
106    ///
107    /// Pass a [`Context`] built with the [`context!`] macro, or a reference to
108    /// any type that implements [`Serialize`](serde::Serialize) (requires the
109    /// `std` feature). Non-map values such as plain strings or numbers produce
110    /// a render error.
111    ///
112    /// ```rust
113    /// use template::{Engine, EscapeMode, context};
114    ///
115    /// let mut engine = Engine::new(EscapeMode::Text);
116    /// engine.add_template("t", "Hello, {{ name }}!").unwrap();
117    /// let result = engine.render("t", context! { name: "World" }).unwrap();
118    /// assert_eq!(result, "Hello, World!");
119    /// ```
120    ///
121    /// Returns an error if the template name is not registered or if an
122    /// expression fails during rendering.
123    pub fn render(&self, name: &str, context: impl IntoContext) -> Result<String, Error> {
124        let mut output = String::new();
125        self.render_to(name, context, &mut output)?;
126        Ok(output)
127    }
128
129    /// Render a named template directly into any [`fmt::Write`] sink.
130    ///
131    /// This is the allocation-friendly counterpart of [`Engine::render`]: output
132    /// can be streamed into an existing `String`, a `Vec<u8>` wrapper, or any
133    /// other `fmt::Write` implementation without building an intermediate buffer.
134    pub fn render_to(&self, name: &str, context: impl IntoContext, out: &mut impl fmt::Write) -> Result<(), Error> {
135        let template = self
136            .templates
137            .get(name)
138            .ok_or_else(|| Error::undefined_template(name))?;
139
140        let context: Context = context.into_context().map_err(|e| Error::parse(e.0))?;
141
142        self.render_with_extends(&template.nodes, &context.0, out, None, 0)
143    }
144
145    fn render_with_extends(
146        &self,
147        nodes: &NodeList,
148        variables: &Value,
149        output: &mut dyn fmt::Write,
150        parent_blocks: Option<&BTreeMap<String, Vec<Arc<NodeList>>>>,
151        extend_depth: usize,
152    ) -> Result<(), Error> {
153        if extend_depth >= MAX_EXTEND_DEPTH {
154            return Err(Error::render(format!("extend depth limit ({MAX_EXTEND_DEPTH}) exceeded")));
155        }
156        // Check if this template extends another
157        let extends_name = nodes.iter().find_map(|n| {
158            if let Node::Extends(name) = n {
159                Some(name.clone())
160            } else {
161                None
162            }
163        });
164
165        if let Some(parent_name) = extends_name {
166            // Collect blocks from this template (cheap `Arc` clones, no deep copy).
167            let mut blocks: BTreeMap<String, Arc<NodeList>> = BTreeMap::new();
168            for node in nodes {
169                if let Node::Block(block) = node {
170                    blocks.insert(block.name.clone(), Arc::clone(&block.body));
171                }
172            }
173
174            // Get parent template
175            let parent = self
176                .templates
177                .get(parent_name.as_str())
178                .ok_or_else(|| Error::undefined_template(&parent_name))?;
179
180            // Merge current blocks into the parent block chain.
181            // Current blocks that shadow existing chain entries push onto the chain;
182            // new blocks start a fresh chain.
183            let mut merged_chain: BTreeMap<String, Vec<Arc<NodeList>>> = parent_blocks.cloned().unwrap_or_default();
184            for (name, body) in &blocks {
185                match merged_chain.get_mut(name) {
186                    Some(chain) => chain.push(Arc::clone(body)),
187                    None => {
188                        merged_chain.insert(name.clone(), vec![Arc::clone(body)]);
189                    }
190                }
191            }
192
193            // Render parent with merged blocks
194            self.render_with_extends(&parent.nodes, variables, output, Some(&merged_chain), extend_depth + 1)?;
195        } else {
196            // No extends - render normally with block overrides
197            let mut renderer = Renderer::new(self, output, variables.clone());
198
199            if let Some(blocks) = parent_blocks {
200                for (name, chain) in blocks {
201                    let mut full_chain = chain.clone();
202                    if let Some(current_block) = nodes.iter().find_map(|n| {
203                        if let Node::Block(b) = n {
204                            if b.name == *name {
205                                Some(Arc::clone(&b.body))
206                            } else {
207                                None
208                            }
209                        } else {
210                            None
211                        }
212                    }) {
213                        full_chain.push(current_block);
214                    }
215                    renderer.block_overrides.insert(name.clone(), full_chain);
216                }
217            }
218
219            renderer.render_nodes(nodes)?;
220        }
221
222        Ok(())
223    }
224}
225
226#[cfg(test)]
227mod tests {
228    use serde::Serialize;
229
230    use super::*;
231    use crate::context;
232
233    #[test]
234    fn test_simple_template() {
235        let mut engine = Engine::new(EscapeMode::Text);
236        engine.add_template("hello", "Hello, {{ name }}!").unwrap();
237        let result = engine.render("hello", context! { name: "World" }).unwrap();
238        assert_eq!(result, "Hello, World!");
239    }
240
241    #[test]
242    fn test_html_escape() {
243        let mut engine = Engine::new(EscapeMode::Html);
244        engine.add_template("t", "<p>{{ content }}</p>").unwrap();
245        let result = engine
246            .render("t", context! { content: "<script>alert('xss')</script>" })
247            .unwrap();
248        assert_eq!(result, "<p>&lt;script&gt;alert(&#39;xss&#39;)&lt;/script&gt;</p>");
249    }
250
251    #[test]
252    fn test_text_no_escape() {
253        let mut engine = Engine::new(EscapeMode::Text);
254        engine.add_template("t", "{{ content }}").unwrap();
255        let result = engine
256            .render("t", context! { content: "<script>alert('xss')</script>" })
257            .unwrap();
258        assert_eq!(result, "<script>alert('xss')</script>");
259    }
260
261    #[test]
262    fn test_if_true() {
263        let mut engine = Engine::new(EscapeMode::Text);
264        engine.add_template("t", "{% if show %}visible{% endif %}").unwrap();
265        let result = engine.render("t", context! { show: true }).unwrap();
266        assert_eq!(result, "visible");
267    }
268
269    #[test]
270    fn test_if_false() {
271        let mut engine = Engine::new(EscapeMode::Text);
272        engine.add_template("t", "{% if show %}visible{% endif %}").unwrap();
273        let result = engine.render("t", context! { show: false }).unwrap();
274        assert_eq!(result, "");
275    }
276
277    #[test]
278    fn test_if_else() {
279        let mut engine = Engine::new(EscapeMode::Text);
280        engine
281            .add_template("t", "{% if show %}yes{% else %}no{% endif %}")
282            .unwrap();
283        let result = engine.render("t", context! { show: false }).unwrap();
284        assert_eq!(result, "no");
285    }
286
287    #[test]
288    fn test_if_elif_else() {
289        let mut engine = Engine::new(EscapeMode::Text);
290        engine
291            .add_template("t", "{% if x == 1 %}one{% elif x == 2 %}two{% else %}other{% endif %}")
292            .unwrap();
293        assert_eq!(engine.render("t", context! { x: 1 }).unwrap(), "one");
294        assert_eq!(engine.render("t", context! { x: 2 }).unwrap(), "two");
295        assert_eq!(engine.render("t", context! { x: 3 }).unwrap(), "other");
296    }
297
298    #[test]
299    fn test_for_loop() {
300        let mut engine = Engine::new(EscapeMode::Text);
301        engine
302            .add_template("t", "{% for item in items %}{{ item }},{% endfor %}")
303            .unwrap();
304        let result = engine.render("t", context! { items: vec!["a", "b", "c"] }).unwrap();
305        assert_eq!(result, "a,b,c,");
306    }
307
308    #[test]
309    fn test_dotted_access() {
310        let mut engine = Engine::new(EscapeMode::Text);
311        engine.add_template("t", "{{ user.name }}").unwrap();
312        let result = engine
313            .render("t", context! { user: context! { name: "Alice" } })
314            .unwrap();
315        assert_eq!(result, "Alice");
316    }
317
318    #[test]
319    fn test_filter_upper() {
320        let mut engine = Engine::new(EscapeMode::Text);
321        engine.add_template("t", "{{ name | upper }}").unwrap();
322        let result = engine.render("t", context! { name: "hello" }).unwrap();
323        assert_eq!(result, "HELLO");
324    }
325
326    #[test]
327    fn test_filter_chain() {
328        let mut engine = Engine::new(EscapeMode::Text);
329        engine.add_template("t", "{{ name | upper | reverse }}").unwrap();
330        let result = engine.render("t", context! { name: "abc" }).unwrap();
331        assert_eq!(result, "CBA");
332    }
333
334    #[test]
335    fn test_set_variable() {
336        let mut engine = Engine::new(EscapeMode::Text);
337        engine.add_template("t", "{% set x = 42 %}{{ x }}").unwrap();
338        let result = engine.render("t", context! {}).unwrap();
339        assert_eq!(result, "42");
340    }
341
342    #[test]
343    fn test_raw_block() {
344        let mut engine = Engine::new(EscapeMode::Text);
345        engine
346            .add_template("t", "before{% raw %}{{ not processed }}{% endraw %}after")
347            .unwrap();
348        let result = engine.render("t", context! {}).unwrap();
349        assert_eq!(result, "before{{ not processed }}after");
350    }
351
352    #[test]
353    fn test_include() {
354        let mut engine = Engine::new(EscapeMode::Text);
355        engine.add_template("header", "Header").unwrap();
356        engine.add_template("page", "{% include \"header\" %}Body").unwrap();
357        let result = engine.render("page", context! {}).unwrap();
358        assert_eq!(result, "HeaderBody");
359    }
360
361    #[test]
362    fn test_comparisons() {
363        let mut engine = Engine::new(EscapeMode::Text);
364        engine.add_template("t", "{% if x == 1 %}eq{% endif %}").unwrap();
365        assert_eq!(engine.render("t", context! { x: 1 }).unwrap(), "eq");
366        assert_eq!(engine.render("t", context! { x: 2 }).unwrap(), "");
367    }
368
369    #[test]
370    fn test_not_operator() {
371        let mut engine = Engine::new(EscapeMode::Text);
372        engine.add_template("t", "{% if not x %}empty{% endif %}").unwrap();
373        let result = engine.render("t", context! { x: false }).unwrap();
374        assert_eq!(result, "empty");
375    }
376
377    #[test]
378    fn test_in_operator() {
379        let mut engine = Engine::new(EscapeMode::Text);
380        engine
381            .add_template("t", "{% if \"a\" in items %}found{% endif %}")
382            .unwrap();
383        let result = engine.render("t", context! { items: vec!["a", "b", "c"] }).unwrap();
384        assert_eq!(result, "found");
385    }
386
387    #[test]
388    fn test_nested_if_for() {
389        let mut engine = Engine::new(EscapeMode::Text);
390        engine
391            .add_template(
392                "t",
393                "{% for item in items %}{% if item.active %}{{ item.name }}{% endif %}{% endfor %}",
394            )
395            .unwrap();
396        let result = engine
397            .render(
398                "t",
399                context! {
400                    items: vec![
401                        context! { name: "a", active: true },
402                        context! { name: "b", active: false },
403                        context! { name: "c", active: true },
404                    ]
405                },
406            )
407            .unwrap();
408        assert_eq!(result, "ac");
409    }
410
411    #[test]
412    fn test_extends_and_blocks() {
413        let mut engine = Engine::new(EscapeMode::Text);
414        engine
415            .add_template("base", "before{% block content %}default{% endblock %}after")
416            .unwrap();
417        engine
418            .add_template("child", "{% extends \"base\" %}{% block content %}child content{% endblock %}")
419            .unwrap();
420        let result = engine.render("child", context! {}).unwrap();
421        assert_eq!(result, "beforechild contentafter");
422    }
423
424    #[test]
425    fn test_super_in_block() {
426        let mut engine = Engine::new(EscapeMode::Text);
427        engine
428            .add_template("base", "{% block content %}parent{% endblock %}")
429            .unwrap();
430        engine
431            .add_template(
432                "child",
433                "{% extends \"base\" %}{% block content %}{{ super() }} + child{% endblock %}",
434            )
435            .unwrap();
436        let result = engine.render("child", context! {}).unwrap();
437        assert_eq!(result, "parent + child");
438    }
439
440    #[test]
441    fn test_comment_is_ignored() {
442        let mut engine = Engine::new(EscapeMode::Text);
443        engine.add_template("t", "before{# comment #}after").unwrap();
444        let result = engine.render("t", context! {}).unwrap();
445        assert_eq!(result, "beforeafter");
446    }
447
448    #[test]
449    fn test_empty_variable() {
450        let mut engine = Engine::new(EscapeMode::Text);
451        engine.add_template("t", "{{ missing }}").unwrap();
452        let result = engine.render("t", context! {}).unwrap();
453        assert_eq!(result, "");
454    }
455
456    #[cfg(feature = "std")]
457    #[test]
458    fn test_struct_variables() {
459        #[derive(Serialize)]
460        struct User {
461            name: String,
462            age: i32,
463        }
464
465        let mut engine = Engine::new(EscapeMode::Text);
466        engine.add_template("t", "{{ name }} is {{ age }}").unwrap();
467        let user = User {
468            name: "Bob".into(),
469            age: 30,
470        };
471        let result = engine.render("t", &user).unwrap();
472        assert_eq!(result, "Bob is 30");
473    }
474
475    #[test]
476    fn test_default_filter() {
477        let mut engine = Engine::new(EscapeMode::Text);
478        engine.add_template("t", "{{ name | default(\"unknown\") }}").unwrap();
479
480        // With variable set
481        let result = engine.render("t", context! { name: "Alice" }).unwrap();
482        assert_eq!(result, "Alice");
483
484        // Without variable
485        let result = engine.render("t", context! {}).unwrap();
486        assert_eq!(result, "unknown");
487    }
488
489    #[test]
490    fn test_length_filter() {
491        let mut engine = Engine::new(EscapeMode::Text);
492        engine.add_template("t", "{{ items | length }}").unwrap();
493        let result = engine.render("t", context! { items: vec![1, 2, 3] }).unwrap();
494        assert_eq!(result, "3");
495    }
496
497    #[test]
498    fn test_arithmetic() {
499        let mut engine = Engine::new(EscapeMode::Text);
500        engine.add_template("t", "{{ 1 + 2 * 3 }}").unwrap();
501        let result = engine.render("t", context! {}).unwrap();
502        assert_eq!(result, "7");
503    }
504
505    #[test]
506    fn test_float_arithmetic() {
507        let mut engine = Engine::new(EscapeMode::Text);
508        engine.add_template("t", "{{ 3.5 + 2.5 }}").unwrap();
509        let result = engine.render("t", context! {}).unwrap();
510        assert_eq!(result, "6");
511    }
512
513    #[test]
514    fn test_float_division() {
515        let mut engine = Engine::new(EscapeMode::Text);
516        engine.add_template("t", "{{ 10.0 / 3 }}").unwrap();
517        let result = engine.render("t", context! {}).unwrap();
518        assert!(
519            result == "3.3333333333333335" || result == "3.333333333333333",
520            "unexpected float division result: {result}"
521        );
522    }
523
524    #[test]
525    fn test_super_in_html_mode() {
526        let mut engine = Engine::new(EscapeMode::Html);
527        engine
528            .add_template("base", "{% block content %}<b>parent</b>{% endblock %}")
529            .unwrap();
530        engine
531            .add_template(
532                "child",
533                "{% extends \"base\" %}{% block content %}{{ super() }}<i>child</i>{% endblock %}",
534            )
535            .unwrap();
536        let result = engine.render("child", context! {}).unwrap();
537        assert_eq!(result, "<b>parent</b><i>child</i>");
538    }
539
540    #[test]
541    fn test_multi_level_extends() {
542        let mut engine = Engine::new(EscapeMode::Text);
543        engine
544            .add_template("base", "{% block content %}base{% endblock %}")
545            .unwrap();
546        engine
547            .add_template(
548                "child",
549                "{% extends \"base\" %}{% block content %}child {{ super() }}{% endblock %}",
550            )
551            .unwrap();
552        engine
553            .add_template(
554                "grandchild",
555                "{% extends \"child\" %}{% block content %}grandchild {{ super() }}{% endblock %}",
556            )
557            .unwrap();
558        let result = engine.render("grandchild", context! {}).unwrap();
559        assert_eq!(result, "grandchild child base");
560    }
561
562    #[test]
563    fn test_safe_filter_html_mode() {
564        let mut engine = Engine::new(EscapeMode::Html);
565        engine.add_template("t", "{{ content | safe }}").unwrap();
566        let result = engine.render("t", context! { content: "<b>bold</b>" }).unwrap();
567        assert_eq!(result, "<b>bold</b>");
568    }
569
570    #[test]
571    fn test_escape_filter_html_mode() {
572        let mut engine = Engine::new(EscapeMode::Html);
573        engine
574            .add_template("t", "{{ content }} and {{ content | escape }}")
575            .unwrap();
576        let result = engine.render("t", context! { content: "<br>" }).unwrap();
577        // Already auto-escaped in HTML mode, |escape should not double-escape
578        assert_eq!(result, "&lt;br&gt; and &lt;br&gt;");
579    }
580
581    #[test]
582    fn test_for_loop_over_string() {
583        let mut engine = Engine::new(EscapeMode::Text);
584        engine
585            .add_template("t", "{% for c in s %}{{ c }}|{% endfor %}")
586            .unwrap();
587        let result = engine.render("t", context! { s: "ab" }).unwrap();
588        assert_eq!(result, "a|b|");
589    }
590
591    #[test]
592    fn test_index_access() {
593        let mut engine = Engine::new(EscapeMode::Text);
594        engine.add_template("t", "{{ items[0] }},{{ items[1] }}").unwrap();
595        let result = engine.render("t", context! { items: vec!["a", "b"] }).unwrap();
596        assert_eq!(result, "a,b");
597    }
598
599    #[test]
600    fn test_in_operator_string() {
601        let mut engine = Engine::new(EscapeMode::Text);
602        engine
603            .add_template("t", "{% if \"world\" in \"hello world\" %}found{% endif %}")
604            .unwrap();
605        let result = engine.render("t", context! {}).unwrap();
606        assert_eq!(result, "found");
607    }
608
609    #[test]
610    fn test_missing_endif_errors() {
611        let mut engine = Engine::new(EscapeMode::Text);
612        let result = engine.add_template("t", "{% if true %}hello");
613        assert!(result.is_err());
614    }
615
616    #[test]
617    fn test_length_filter_map() {
618        let mut engine = Engine::new(EscapeMode::Text);
619        engine.add_template("t", "{{ obj | length }}").unwrap();
620        let result = engine.render("t", context! { obj: context! { a: 1, b: 2 } }).unwrap();
621        assert_eq!(result, "2");
622    }
623
624    #[test]
625    fn test_undefined_template_error() {
626        let engine = Engine::new(EscapeMode::Text);
627        let result = engine.render("nonexistent", context! {});
628        assert!(result.is_err());
629    }
630
631    #[test]
632    fn test_division_by_zero() {
633        let mut engine = Engine::new(EscapeMode::Text);
634        engine.add_template("t", "{{ 1 / 0 }}").unwrap();
635        let result = engine.render("t", context! {});
636        assert!(result.is_err());
637    }
638
639    #[test]
640    fn test_modulo_by_zero() {
641        let mut engine = Engine::new(EscapeMode::Text);
642        engine.add_template("t", "{{ 10 % 0 }}").unwrap();
643        let result = engine.render("t", context! {});
644        assert!(result.is_err());
645    }
646
647    #[test]
648    fn test_circular_include() {
649        let mut engine = Engine::new(EscapeMode::Text);
650        engine.add_template("a", "{% include \"b\" %}").unwrap();
651        engine.add_template("b", "{% include \"a\" %}").unwrap();
652        let result = engine.render("a", context! {});
653        assert!(result.is_err());
654    }
655
656    #[test]
657    fn test_circular_extends() {
658        let mut engine = Engine::new(EscapeMode::Text);
659        engine
660            .add_template("a", "{% extends \"b\" %}{% block x %}a{% endblock %}")
661            .unwrap();
662        engine
663            .add_template("b", "{% extends \"a\" %}{% block x %}b{% endblock %}")
664            .unwrap();
665        let result = engine.render("a", context! {});
666        assert!(result.is_err());
667    }
668
669    #[test]
670    fn test_unknown_function() {
671        let mut engine = Engine::new(EscapeMode::Text);
672        engine.add_template("t", "{{ foobar() }}").unwrap();
673        let result = engine.render("t", context! {});
674        assert!(result.is_err());
675    }
676
677    #[test]
678    fn test_range_with_float() {
679        let mut engine = Engine::new(EscapeMode::Text);
680        engine
681            .add_template("t", "{% for i in range(5.5) %}{{ i }}{% endfor %}")
682            .unwrap();
683        let result = engine.render("t", context! {});
684        assert!(result.is_err());
685    }
686
687    #[test]
688    fn test_trailing_tokens_in_expr() {
689        let mut engine = Engine::new(EscapeMode::Text);
690        let result = engine.add_template("t", "{{ true false }}");
691        assert!(result.is_err());
692    }
693
694    #[test]
695    fn test_trailing_tokens_after_filter() {
696        let mut engine = Engine::new(EscapeMode::Text);
697        let result = engine.add_template("t", "{{ name | upper + 1 }}");
698        assert!(result.is_err());
699    }
700
701    #[test]
702    fn test_add_template_overwrite_error() {
703        let mut engine = Engine::new(EscapeMode::Text);
704        engine.add_template("t", "hello").unwrap();
705        let result = engine.add_template("t", "world");
706        assert!(result.is_err());
707    }
708
709    #[test]
710    fn test_raw_block_preserves_inner_tags() {
711        let mut engine = Engine::new(EscapeMode::Text);
712        engine
713            .add_template("t", "before{% raw %}{% inner %}{% endraw %}after")
714            .unwrap();
715        let result = engine.render("t", context! {}).unwrap();
716        assert_eq!(result, "before{% inner %}after");
717    }
718
719    #[test]
720    fn test_raw_block_preserves_trailing_whitespace() {
721        let mut engine = Engine::new(EscapeMode::Text);
722        engine
723            .add_template("t", "before{% raw %}hello   {% endraw %}after")
724            .unwrap();
725        let result = engine.render("t", context! {}).unwrap();
726        assert_eq!(result, "beforehello   after");
727    }
728
729    #[test]
730    fn test_range_with_integer() {
731        let mut engine = Engine::new(EscapeMode::Text);
732        engine
733            .add_template("t", "{% for i in range(3) %}{{ i }}{% endfor %}")
734            .unwrap();
735        let result = engine.render("t", context! {}).unwrap();
736        assert_eq!(result, "012");
737    }
738
739    #[test]
740    fn test_length_filter_in_html_mode_on_safe() {
741        let mut engine = Engine::new(EscapeMode::Html);
742        engine.add_template("t", "{{ \"hello\" | escape | length }}").unwrap();
743        let result = engine.render("t", context! {}).unwrap();
744        // "hello" escaped is "hello" (no HTML chars), and length is 5 chars
745        assert_eq!(result, "5");
746    }
747
748    #[test]
749    fn test_first_on_safe_string() {
750        let mut engine = Engine::new(EscapeMode::Text);
751        engine.add_template("t", "{{ \"abc\" | safe | first }}").unwrap();
752        let result = engine.render("t", context! {}).unwrap();
753        assert_eq!(result, "a");
754    }
755
756    #[test]
757    fn test_last_on_safe_string() {
758        let mut engine = Engine::new(EscapeMode::Text);
759        engine.add_template("t", "{{ \"abc\" | safe | last }}").unwrap();
760        let result = engine.render("t", context! {}).unwrap();
761        assert_eq!(result, "c");
762    }
763
764    #[test]
765    fn test_reverse_on_safe_string() {
766        let mut engine = Engine::new(EscapeMode::Text);
767        engine.add_template("t", "{{ \"abc\" | safe | reverse }}").unwrap();
768        let result = engine.render("t", context! {}).unwrap();
769        assert_eq!(result, "cba");
770    }
771
772    #[test]
773    fn test_length_on_safe_string() {
774        let mut engine = Engine::new(EscapeMode::Text);
775        engine.add_template("t", "{{ \"hello\" | safe | length }}").unwrap();
776        let result = engine.render("t", context! {}).unwrap();
777        assert_eq!(result, "5");
778    }
779
780    #[test]
781    fn test_short_circuit_and() {
782        let mut engine = Engine::new(EscapeMode::Text);
783        engine.add_template("t", "{% if false and 1/0 %}ok{% endif %}").unwrap();
784        let result = engine.render("t", context! {}).unwrap();
785        assert_eq!(result, "");
786    }
787
788    #[test]
789    fn test_short_circuit_or() {
790        let mut engine = Engine::new(EscapeMode::Text);
791        engine.add_template("t", "{% if true or 1/0 %}ok{% endif %}").unwrap();
792        let result = engine.render("t", context! {}).unwrap();
793        assert_eq!(result, "ok");
794    }
795
796    #[test]
797    fn test_range_no_args() {
798        let mut engine = Engine::new(EscapeMode::Text);
799        engine.add_template("t", "{{ range() }}").unwrap();
800        let result = engine.render("t", context! {});
801        assert!(result.is_err());
802    }
803
804    #[test]
805    fn test_range_extra_args() {
806        let mut engine = Engine::new(EscapeMode::Text);
807        engine
808            .add_template("t", "{% for i in range(1,2,3) %}{{ i }}{% endfor %}")
809            .unwrap();
810        let result = engine.render("t", context! {});
811        assert!(result.is_err());
812    }
813
814    #[test]
815    fn test_for_empty_string() {
816        let mut engine = Engine::new(EscapeMode::Text);
817        engine
818            .add_template("t", "{% for c in \"\" %}{{ c }}{% endfor %}")
819            .unwrap();
820        let result = engine.render("t", context! {}).unwrap();
821        assert_eq!(result, "");
822    }
823
824    #[test]
825    fn test_for_empty_array() {
826        let mut engine = Engine::new(EscapeMode::Text);
827        engine
828            .add_template("t", "{% for i in items %}{{ i }}{% endfor %}")
829            .unwrap();
830        let result = engine.render("t", context! { items: Vec::<i32>::new() }).unwrap();
831        assert_eq!(result, "");
832    }
833
834    #[test]
835    fn test_for_over_number() {
836        let mut engine = Engine::new(EscapeMode::Text);
837        engine
838            .add_template("t", "{% for i in 42 %}{{ i }}{% endfor %}")
839            .unwrap();
840        let result = engine.render("t", context! {}).unwrap();
841        assert_eq!(result, "");
842    }
843
844    #[test]
845    fn test_for_over_map() {
846        let mut engine = Engine::new(EscapeMode::Text);
847        engine.add_template("t", "{% for i in m %}{{ i }}{% endfor %}").unwrap();
848        let result = engine.render("t", context! { m: context! { a: 1 } }).unwrap();
849        assert_eq!(result, "");
850    }
851
852    #[test]
853    fn test_missing_include() {
854        let mut engine = Engine::new(EscapeMode::Text);
855        engine.add_template("t", "{% include \"missing\" %}").unwrap();
856        let result = engine.render("t", context! {});
857        assert!(result.is_err());
858    }
859
860    #[test]
861    fn test_missing_extends() {
862        let mut engine = Engine::new(EscapeMode::Text);
863        engine.add_template("t", "{% extends \"missing\" %}").unwrap();
864        let result = engine.render("t", context! {});
865        assert!(result.is_err());
866    }
867
868    #[test]
869    fn test_super_without_parent() {
870        let mut engine = Engine::new(EscapeMode::Text);
871        engine.add_template("t", "{{ super() }}").unwrap();
872        let result = engine.render("t", context! {}).unwrap();
873        assert_eq!(result, "");
874    }
875
876    #[test]
877    fn test_set_overwrite() {
878        let mut engine = Engine::new(EscapeMode::Text);
879        engine
880            .add_template("t", "{% set x = 1 %}{% set x = 2 %}{{ x }}")
881            .unwrap();
882        let result = engine.render("t", context! {}).unwrap();
883        assert_eq!(result, "2");
884    }
885
886    #[test]
887    fn test_nested_missing() {
888        let mut engine = Engine::new(EscapeMode::Text);
889        engine.add_template("t", "{{ a.b.c }}").unwrap();
890        let result = engine.render("t", context! {}).unwrap();
891        assert_eq!(result, "");
892    }
893
894    #[test]
895    fn test_negative_index_runtime() {
896        let mut engine = Engine::new(EscapeMode::Text);
897        engine.add_template("t", "{{ items[i] }}").unwrap();
898        let result = engine.render("t", context! { items: vec![1, 2], i: -1 });
899        assert!(result.is_err());
900    }
901
902    #[test]
903    fn test_float_index_runtime() {
904        let mut engine = Engine::new(EscapeMode::Text);
905        engine.add_template("t", "{{ items[i] }}").unwrap();
906        let result = engine.render("t", context! { items: vec![1, 2], i: 1.5 });
907        assert!(result.is_err());
908    }
909
910    #[test]
911    fn test_empty_map_index() {
912        let mut engine = Engine::new(EscapeMode::Text);
913        engine.add_template("t", "{{ m.key }}").unwrap();
914        let result = engine.render("t", context! {}).unwrap();
915        assert_eq!(result, "");
916    }
917
918    #[test]
919    fn test_float_div_by_zero_inf() {
920        let mut engine = Engine::new(EscapeMode::Text);
921        engine.add_template("t", "{{ 1.0 / 0.0 }}").unwrap();
922        let result = engine.render("t", context! {}).unwrap();
923        assert_eq!(result, "inf");
924    }
925
926    #[test]
927    fn test_neg_float_div_by_zero_neg_inf() {
928        let mut engine = Engine::new(EscapeMode::Text);
929        engine.add_template("t", "{{ -1.0 / 0.0 }}").unwrap();
930        let result = engine.render("t", context! {}).unwrap();
931        assert_eq!(result, "-inf");
932    }
933
934    #[test]
935    fn test_float_zero_div_by_zero_nan() {
936        let mut engine = Engine::new(EscapeMode::Text);
937        engine.add_template("t", "{{ 0.0 / 0.0 }}").unwrap();
938        let result = engine.render("t", context! {}).unwrap();
939        assert_eq!(result, "NaN");
940    }
941
942    #[test]
943    fn test_elif_after_else_error() {
944        let mut engine = Engine::new(EscapeMode::Text);
945        let result = engine.add_template("t", "{% if a %}b{% else %}c{% elif d %}e{% endif %}");
946        assert!(result.is_err());
947    }
948
949    #[test]
950    fn test_multiple_else_error() {
951        let mut engine = Engine::new(EscapeMode::Text);
952        let result = engine.add_template("t", "{% if a %}b{% else %}c{% else %}d{% endif %}");
953        assert!(result.is_err());
954    }
955
956    #[test]
957    fn test_empty_if_condition_error() {
958        let mut engine = Engine::new(EscapeMode::Text);
959        let result = engine.add_template("t", "{% if %}a{% endif %}");
960        assert!(result.is_err());
961    }
962
963    // ---- strict boolean semantics ----
964
965    #[test]
966    fn test_static_non_bool_or_is_parse_error() {
967        let mut engine = Engine::new(EscapeMode::Text);
968        assert!(engine.add_template("t", "{{ 0 or \"x\" }}").is_err());
969        assert!(engine.add_template("t", "{{ \"x\" or true }}").is_err());
970        assert!(engine.add_template("t", "{{ 1 and true }}").is_err());
971        assert!(engine.add_template("t", "{{ not 0 }}").is_err());
972        assert!(engine.add_template("t", "{% if 1 %}a{% endif %}").is_err());
973        assert!(engine.add_template("t", "{% if \"a\" %}a{% endif %}").is_err());
974    }
975
976    #[test]
977    fn test_or_and_return_values() {
978        let mut engine = Engine::new(EscapeMode::Text);
979        engine
980            .add_template(
981                "t",
982                "{{ false or \"x\" }}|{{ true or \"x\" }}|{{ true and \"y\" }}|{{ false and \"y\" }}",
983            )
984            .unwrap();
985        // decision operand must be boolean; the fallback value is returned verbatim
986        assert_eq!(engine.render("t", context! {}).unwrap(), "x|true|y|false");
987    }
988
989    #[test]
990    fn test_or_chain_flattens() {
991        let mut engine = Engine::new(EscapeMode::Text);
992        engine.add_template("t", "{{ false or false or \"last\" }}").unwrap();
993        assert_eq!(engine.render("t", context! {}).unwrap(), "last");
994    }
995
996    #[test]
997    fn test_dynamic_non_bool_or_is_render_error() {
998        let mut engine = Engine::new(EscapeMode::Text);
999        engine.add_template("t", "{{ x or \"fallback\" }}").unwrap();
1000        assert!(engine.render("t", context! { x: 5 }).is_err());
1001        assert_eq!(engine.render("t", context! { x: false }).unwrap(), "fallback");
1002        assert_eq!(engine.render("t", context! { x: true }).unwrap(), "true");
1003    }
1004
1005    #[test]
1006    fn test_strict_if_condition_rejects_non_bool() {
1007        let mut engine = Engine::new(EscapeMode::Text);
1008        engine.add_template("t", "{% if x %}y{% endif %}").unwrap();
1009        assert!(engine.render("t", context! { x: 0 }).is_err());
1010        assert!(engine.render("t", context! { x: 1 }).is_err());
1011        assert!(engine.render("t", context! { x: "s" }).is_err());
1012        assert!(engine.render("t", context! { x: vec![1] }).is_err());
1013        // missing => null, also not a boolean
1014        assert!(engine.render("t", context! {}).is_err());
1015        assert_eq!(engine.render("t", context! { x: true }).unwrap(), "y");
1016        assert_eq!(engine.render("t", context! { x: false }).unwrap(), "");
1017    }
1018
1019    #[test]
1020    fn test_is_set() {
1021        let mut engine = Engine::new(EscapeMode::Text);
1022        engine.add_template("t", "{{ is_set(a.b.c) }}|{{ is_set(x) }}").unwrap();
1023        // Missing nested field must be false, never an error.
1024        assert_eq!(engine.render("t", context! {}).unwrap(), "false|false");
1025        assert_eq!(
1026            engine
1027                .render("t", context! { a: context! { b: context! { c: 1 } }, x: 0 })
1028                .unwrap(),
1029            "true|true"
1030        );
1031        // b exists but is not a map, so a.b.c is missing.
1032        assert_eq!(engine.render("t", context! { a: context! { b: 1 } }).unwrap(), "false|false");
1033    }
1034
1035    #[test]
1036    fn test_is_set_wrong_arity() {
1037        let mut engine = Engine::new(EscapeMode::Text);
1038        engine.add_template("t", "{{ is_set() }}").unwrap();
1039        assert!(engine.render("t", context! {}).is_err());
1040    }
1041
1042    // ---- arithmetic overflow ----
1043
1044    #[test]
1045    fn test_integer_overflow_is_error_not_panic() {
1046        let mut engine = Engine::new(EscapeMode::Text);
1047        engine.add_template("t", "{{ x + y }}").unwrap();
1048        assert!(engine.render("t", context! { x: i64::MAX, y: 1i64 }).is_err());
1049        engine.add_template("u", "{{ x * y }}").unwrap();
1050        assert!(engine.render("u", context! { x: i64::MAX, y: 2i64 }).is_err());
1051        engine.add_template("v", "{{ x - y }}").unwrap();
1052        assert!(engine.render("v", context! { x: i64::MIN, y: 1i64 }).is_err());
1053        engine.add_template("w", "{{ -x }}").unwrap();
1054        assert!(engine.render("w", context! { x: i64::MIN }).is_err());
1055    }
1056
1057    #[test]
1058    fn test_min_div_minus_one_is_error() {
1059        // These panic in plain Rust even in release mode; the engine must not.
1060        let mut engine = Engine::new(EscapeMode::Text);
1061        engine.add_template("d", "{{ x / -1 }}").unwrap();
1062        engine.add_template("m", "{{ x % -1 }}").unwrap();
1063        assert!(engine.render("d", context! { x: i64::MIN }).is_err());
1064        assert!(engine.render("m", context! { x: i64::MIN }).is_err());
1065    }
1066
1067    // ---- range limit ----
1068
1069    #[test]
1070    fn test_range_limit_default() {
1071        let mut engine = Engine::new(EscapeMode::Text);
1072        engine.add_template("t", "{{ range(n) | length }}").unwrap();
1073        assert_eq!(engine.render("t", context! { n: 32_000i64 }).unwrap(), "32000");
1074        assert!(engine.render("t", context! { n: 32_001i64 }).is_err());
1075        // A range that would overflow i64 must not panic.
1076        assert!(engine.render("t", context! { n: i64::MAX }).is_err());
1077    }
1078
1079    #[test]
1080    fn test_range_limit_configurable() {
1081        let mut engine = Engine::new(EscapeMode::Text);
1082        engine.set_range_limit(3);
1083        assert_eq!(engine.range_limit(), 3);
1084        engine.add_template("t", "{{ range(n) | join(\",\") }}").unwrap();
1085        assert_eq!(engine.render("t", context! { n: 3i64 }).unwrap(), "0,1,2");
1086        assert!(engine.render("t", context! { n: 4i64 }).is_err());
1087    }
1088
1089    #[test]
1090    fn test_range_empty_and_reversed() {
1091        let mut engine = Engine::new(EscapeMode::Text);
1092        engine.add_template("t", "{{ range(a, b) | join(\",\") }}").unwrap();
1093        assert_eq!(engine.render("t", context! { a: 5i64, b: 3i64 }).unwrap(), "");
1094        assert_eq!(engine.render("t", context! { a: -2i64, b: 2i64 }).unwrap(), "-2,-1,0,1");
1095    }
1096
1097    // ---- recursion limits ----
1098
1099    #[test]
1100    fn test_template_nesting_limit_returns_error() {
1101        let mut engine = Engine::new(EscapeMode::Text);
1102        let ok = "{% if true %}".repeat(100) + &"{% endif %}".repeat(100);
1103        engine.add_template("ok", &ok).unwrap();
1104        let too_deep = "{% if true %}".repeat(200) + &"{% endif %}".repeat(200);
1105        assert!(engine.add_template("deep", &too_deep).is_err());
1106    }
1107
1108    #[test]
1109    fn test_expression_nesting_limit_returns_error() {
1110        let mut engine = Engine::new(EscapeMode::Text);
1111        let ok = "{{ ".to_string() + &"(".repeat(50) + "1" + &")".repeat(50) + " }}";
1112        engine.add_template("ok", &ok).unwrap();
1113        let too_deep = "{{ ".to_string() + &"(".repeat(200) + "1" + &")".repeat(200) + " }}";
1114        assert!(engine.add_template("deep", &too_deep).is_err());
1115    }
1116
1117    #[test]
1118    fn test_huge_expression_rejected() {
1119        let mut engine = Engine::new(EscapeMode::Text);
1120        // A long filter chain must be rejected instead of overflowing the stack.
1121        let t = format!("{{{{ x{} }}}}", "|upper".repeat(5000));
1122        assert!(engine.add_template("t", &t).is_err());
1123    }
1124
1125    // ---- include / extends ----
1126
1127    #[test]
1128    fn test_extends_must_be_top_level() {
1129        let mut engine = Engine::new(EscapeMode::Text);
1130        engine.add_template("base", "{% block b %}d{% endblock %}").unwrap();
1131        assert!(
1132            engine
1133                .add_template("c", "{% if true %}{% extends \"base\" %}{% endif %}")
1134                .is_err()
1135        );
1136    }
1137
1138    #[test]
1139    fn test_include_with_extends_is_error() {
1140        let mut engine = Engine::new(EscapeMode::Text);
1141        engine.add_template("base", "BASE{% block b %}d{% endblock %}").unwrap();
1142        engine
1143            .add_template("sub", "{% extends \"base\" %}{% block b %}S{% endblock %}")
1144            .unwrap();
1145        engine.add_template("page", "{% include \"sub\" %}").unwrap();
1146        assert!(engine.render("page", context! {}).is_err());
1147    }
1148
1149    // ---- parser correctness ----
1150
1151    #[test]
1152    fn test_delimiters_inside_string_literals() {
1153        let mut engine = Engine::new(EscapeMode::Text);
1154        engine.add_template("t", "{{ \"a}}b\" }}").unwrap();
1155        assert_eq!(engine.render("t", context! {}).unwrap(), "a}}b");
1156        engine
1157            .add_template("u", "{% if x == \"%}\" %}Y{% else %}N{% endif %}")
1158            .unwrap();
1159        assert_eq!(engine.render("u", context! { x: "%}" }).unwrap(), "Y");
1160        assert_eq!(engine.render("u", context! { x: "nope" }).unwrap(), "N");
1161    }
1162
1163    #[test]
1164    fn test_unterminated_string_is_parse_error() {
1165        let mut engine = Engine::new(EscapeMode::Text);
1166        assert!(engine.add_template("t", "{{ \"abc }}").is_err());
1167        assert!(engine.add_template("u", "{% if x == \"abc %}y{% endif %}").is_err());
1168    }
1169
1170    #[test]
1171    fn test_unknown_filter_is_parse_error() {
1172        let mut engine = Engine::new(EscapeMode::Text);
1173        assert!(engine.add_template("t", "{{ x | nope }}").is_err());
1174    }
1175
1176    #[test]
1177    fn test_wrong_filter_arity_is_parse_error() {
1178        let mut engine = Engine::new(EscapeMode::Text);
1179        assert!(engine.add_template("t", "{{ x | upper(\"junk\") }}").is_err());
1180        assert!(engine.add_template("u", "{{ x | join(\",\", \"extra\") }}").is_err());
1181        // Valid arities still work.
1182        assert!(engine.add_template("v", "{{ x | default(1) }}").is_ok());
1183        assert!(engine.add_template("w", "{{ x | upper }}").is_ok());
1184    }
1185
1186    #[test]
1187    fn test_default_is_null_only() {
1188        let mut engine = Engine::new(EscapeMode::Text);
1189        engine
1190            .add_template(
1191                "t",
1192                "{{ z | default(9) }}|{{ f | default(9) }}|{{ s | default(9) }}|{{ m | default(9) }}",
1193            )
1194            .unwrap();
1195        let out = engine.render("t", context! { z: 0, f: false, s: "" }).unwrap();
1196        assert_eq!(out, "0|false||9");
1197    }
1198
1199    // ---- Safe propagation ----
1200
1201    #[test]
1202    fn test_safe_survives_pure_filters() {
1203        let mut engine = Engine::new(EscapeMode::Html);
1204        engine.add_template("t", "{{ x | safe | reverse }}").unwrap();
1205        // Safe status is preserved, so the result is not escaped again.
1206        assert_eq!(engine.render("t", context! { x: "<b>" }).unwrap(), ">b<");
1207    }
1208
1209    #[test]
1210    fn test_safe_compares_equal_to_str() {
1211        let mut engine = Engine::new(EscapeMode::Text);
1212        engine
1213            .add_template("t", "{{ (x | safe) == x }}|{{ (x | safe) < \"z\" }}")
1214            .unwrap();
1215        assert_eq!(engine.render("t", context! { x: "a" }).unwrap(), "true|true");
1216    }
1217
1218    #[test]
1219    fn test_join_leaves_unsafe_element_escaped() {
1220        let mut engine = Engine::new(EscapeMode::Html);
1221        engine.add_template("t", "{{ items | join(\",\") }}").unwrap();
1222        let out = engine.render("t", context! { items: vec!["<a>", "<b>"] }).unwrap();
1223        assert_eq!(out, "&lt;a&gt;,&lt;b&gt;");
1224    }
1225
1226    // ---- injection ----
1227
1228    #[test]
1229    fn test_xss_payloads_are_escaped() {
1230        let mut engine = Engine::new(EscapeMode::Html);
1231        engine.add_template("t", "{{ x }}").unwrap();
1232        for payload in [
1233            "<script>alert(1)</script>",
1234            "\" onmouseover=alert(1)",
1235            "' onclick='alert(1)",
1236            "`backtick`",
1237            "&amp;",
1238            "javascript:alert(1)",
1239        ] {
1240            let out = engine.render("t", context! { x: payload }).unwrap();
1241            assert!(!out.contains('<'), "raw `<` leaked for {payload:?}");
1242            assert!(!out.contains('>'), "raw `>` leaked for {payload:?}");
1243            assert!(!out.contains('"'), "raw `\"` leaked for {payload:?}");
1244            assert!(!out.contains('\''), "raw `'` leaked for {payload:?}");
1245        }
1246    }
1247
1248    #[test]
1249    fn test_escape_filter_does_not_double_escape() {
1250        let mut engine = Engine::new(EscapeMode::Html);
1251        engine.add_template("t", "{{ x | escape }}").unwrap();
1252        assert_eq!(engine.render("t", context! { x: "<br>" }).unwrap(), "&lt;br&gt;");
1253    }
1254
1255    // ---- render_to ----
1256
1257    #[test]
1258    fn test_render_to_writer() {
1259        use core::fmt;
1260
1261        struct Sink(String);
1262        impl fmt::Write for Sink {
1263            fn write_str(&mut self, s: &str) -> fmt::Result {
1264                self.0.push_str(s);
1265                Ok(())
1266            }
1267        }
1268
1269        let mut engine = Engine::new(EscapeMode::Html);
1270        engine.add_template("t", "Hi {{ name }}").unwrap();
1271        let mut sink = Sink(String::new());
1272        engine.render_to("t", context! { name: "<b>" }, &mut sink).unwrap();
1273        assert_eq!(sink.0, "Hi &lt;b&gt;");
1274    }
1275
1276    // ---- error positions ----
1277
1278    #[test]
1279    fn test_error_position_is_template_absolute() {
1280        let mut engine = Engine::new(EscapeMode::Text);
1281        // `-1` sits at column 30 of the template, not column 8 of the expression.
1282        let err = engine
1283            .add_template("t", "aaaaaaaaaaaaaaaaaaaa{{ items[-1] }}")
1284            .unwrap_err();
1285        assert!(
1286            err.to_string().contains("1:30"),
1287            "expected template-absolute position 1:30, got: {err}"
1288        );
1289    }
1290
1291    #[test]
1292    fn test_error_position_on_later_line() {
1293        let mut engine = Engine::new(EscapeMode::Text);
1294        let src = "a\n{{ items[-1] }}";
1295        let err = engine.add_template("t", src).unwrap_err();
1296        assert!(err.to_string().contains("2:10"), "expected position 2:10, got: {err}");
1297    }
1298
1299    #[test]
1300    fn test_unknown_filter_position() {
1301        let mut engine = Engine::new(EscapeMode::Text);
1302        let err = engine.add_template("t", "prefix {{ x | nope }}").unwrap_err();
1303        // `nope` starts at column 15 (`prefix {{ x | ` is 14 chars).
1304        assert!(err.to_string().contains("1:15"), "expected position 1:15, got: {err}");
1305    }
1306
1307    #[test]
1308    fn test_multiline_tag_error_position() {
1309        let mut engine = Engine::new(EscapeMode::Text);
1310        // The tag content spans two lines; the negative index is on line 2.
1311        let err = engine
1312            .add_template("t", "{% if items[\n-1] %}x{% endif %}")
1313            .unwrap_err();
1314        assert!(err.to_string().contains("2:1"), "expected position 2:1, got: {err}");
1315    }
1316
1317    // ---- endblock labels ----
1318
1319    #[test]
1320    fn test_endblock_with_matching_label() {
1321        let mut engine = Engine::new(EscapeMode::Text);
1322        engine
1323            .add_template("t", "{% block content %}hi{% endblock content %}")
1324            .unwrap();
1325        assert_eq!(engine.render("t", context! {}).unwrap(), "hi");
1326    }
1327
1328    #[test]
1329    fn test_endblock_without_label() {
1330        let mut engine = Engine::new(EscapeMode::Text);
1331        engine.add_template("t", "{% block content %}hi{% endblock %}").unwrap();
1332        assert_eq!(engine.render("t", context! {}).unwrap(), "hi");
1333    }
1334
1335    #[test]
1336    fn test_endblock_with_mismatched_label_is_error() {
1337        let mut engine = Engine::new(EscapeMode::Text);
1338        let err = engine
1339            .add_template("t", "{% block content %}hi{% endblock footer %}")
1340            .unwrap_err();
1341        assert!(
1342            err.to_string().contains("endblock content"),
1343            "expected mismatch message, got: {err}"
1344        );
1345    }
1346
1347    #[test]
1348    fn test_endfor_label_still_rejected() {
1349        let mut engine = Engine::new(EscapeMode::Text);
1350        assert!(engine.add_template("t", "{% for i in x %}{% endfor i %}").is_err());
1351    }
1352
1353    #[test]
1354    fn test_non_ascii_identifier_rejected() {
1355        let mut engine = Engine::new(EscapeMode::Text);
1356        assert!(engine.add_template("t", "{{ café }}").is_err());
1357    }
1358}