Skip to main content

rainier_view/
engine.rs

1//! Rendering — the [`ViewEngine`] port, the [`View`] value, and the
2//! [`TemplateEngine`] implementation.
3
4use std::collections::HashMap;
5use std::path::{Path, PathBuf};
6use std::sync::RwLock;
7
8use rainier_support::{Error, Result};
9use serde::Serialize;
10use serde_json::{Map, Value};
11
12use crate::template::{parse, CompareOp, Condition, Expr, Literal, Node, Template};
13
14/// A view name plus the data to render it with.
15///
16/// Returned from a controller and rendered by the response layer, so an action
17/// can name a template without reaching for the engine.
18#[derive(Debug, Clone, PartialEq)]
19pub struct View {
20    name: String,
21    data: Value,
22}
23
24impl View {
25    /// A view with no data.
26    pub fn new(name: impl Into<String>) -> Self {
27        Self { name: name.into(), data: Value::Object(Map::new()) }
28    }
29
30    /// A view with `data` — any serialisable value that produces an object.
31    pub fn with(name: impl Into<String>, data: impl Serialize) -> Result<Self> {
32        let data = serde_json::to_value(data)?;
33        if !data.is_object() {
34            return Err(Error::internal(
35                "view data must be an object — a template reads values by name",
36            ));
37        }
38        Ok(Self { name: name.into(), data })
39    }
40
41    /// Add one value.
42    pub fn add(mut self, key: impl Into<String>, value: impl Serialize) -> Result<Self> {
43        let value = serde_json::to_value(value)?;
44        self.data.as_object_mut().expect("view data is always an object").insert(key.into(), value);
45        Ok(self)
46    }
47
48    /// The template's name.
49    pub fn name(&self) -> &str {
50        &self.name
51    }
52
53    /// The data.
54    pub fn data(&self) -> &Value {
55        &self.data
56    }
57}
58
59/// Renders a named template.
60pub trait ViewEngine: Send + Sync + 'static {
61    /// Render `name` with `data`.
62    fn render(&self, name: &str, data: &Value) -> Result<String>;
63
64    /// Render a [`View`].
65    fn render_view(&self, view: &View) -> Result<String> {
66        self.render(view.name(), view.data())
67    }
68
69    /// Whether `name` resolves to a template.
70    fn exists(&self, name: &str) -> bool;
71}
72
73/// A template engine over a directory of templates.
74///
75/// A view name is dotted and maps to a path: `posts.show` →
76/// `<root>/posts/show.view.html`.
77pub struct TemplateEngine {
78    root: PathBuf,
79    extension: String,
80    /// Parsed templates, keyed by name.
81    cache: RwLock<HashMap<String, Template>>,
82    /// Whether to keep parsed templates. Off in development so an edit shows
83    /// up without a restart; on in production so a template is parsed once.
84    caching: bool,
85}
86
87impl TemplateEngine {
88    /// Load templates from `root`, caching parsed output.
89    pub fn new(root: impl Into<PathBuf>) -> Self {
90        Self {
91            root: root.into(),
92            extension: "view.html".to_string(),
93            cache: RwLock::new(HashMap::new()),
94            caching: true,
95        }
96    }
97
98    /// Re-read and re-parse templates on every render.
99    pub fn without_cache(mut self) -> Self {
100        self.caching = false;
101        self
102    }
103
104    /// Use a different file extension.
105    pub fn with_extension(mut self, extension: impl Into<String>) -> Self {
106        self.extension = extension.into();
107        self
108    }
109
110    /// Where templates are read from.
111    pub fn root(&self) -> &Path {
112        &self.root
113    }
114
115    /// The file a view name maps to.
116    ///
117    /// Every segment is sanitised and the result is always **inside** the
118    /// template root. That matters because a view name can reach the engine
119    /// from a template (`@include('…')`) and, in a careless application, from
120    /// user input. Naively replacing dots with separators is not enough: a
121    /// name like `..secrets` produces a leading separator, and `Path::join`
122    /// with a rooted path *discards the base* — silently reading from the
123    /// filesystem root.
124    pub fn path_for(&self, name: &str) -> PathBuf {
125        let segments: Vec<String> = name
126            .split('.')
127            .map(|segment| segment.trim().replace(['/', '\\'], ""))
128            .filter(|segment| !segment.is_empty() && segment != "." && segment != "..")
129            .collect();
130
131        let Some((file, directories)) = segments.split_last() else {
132            // Nothing usable was left; return a path that cannot exist rather
133            // than the template root itself.
134            return self.root.join(format!("_invalid_.{}", self.extension));
135        };
136
137        let mut path = self.root.clone();
138        for directory in directories {
139            path.push(directory);
140        }
141        path.push(format!("{file}.{}", self.extension));
142        path
143    }
144
145    /// Discard every cached template.
146    pub fn flush(&self) {
147        self.cache.write().expect("view cache poisoned").clear();
148    }
149
150    fn load(&self, name: &str) -> Result<Template> {
151        if self.caching {
152            if let Some(cached) = self.cache.read().expect("view cache poisoned").get(name) {
153                return Ok(cached.clone());
154            }
155        }
156
157        let path = self.path_for(name);
158        let source = std::fs::read_to_string(&path).map_err(|e| {
159            Error::internal(format!("view `{name}` not found at {}: {e}", path.display()))
160        })?;
161
162        let template = parse(&source)
163            .map_err(|e| Error::internal(format!("view `{name}` failed to parse: {e}")))?;
164
165        if self.caching {
166            self.cache
167                .write()
168                .expect("view cache poisoned")
169                .insert(name.to_string(), template.clone());
170        }
171        Ok(template)
172    }
173}
174
175impl ViewEngine for TemplateEngine {
176    fn render(&self, name: &str, data: &Value) -> Result<String> {
177        let mut renderer = Renderer { engine: self, depth: 0 };
178        renderer.render_named(name, data)
179    }
180
181    fn exists(&self, name: &str) -> bool {
182        self.path_for(name).is_file()
183    }
184}
185
186impl std::fmt::Debug for TemplateEngine {
187    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
188        f.debug_struct("TemplateEngine")
189            .field("root", &self.root)
190            .field("caching", &self.caching)
191            .finish()
192    }
193}
194
195/// How deep `@include` / `@extends` may nest before we assume a cycle.
196const MAX_DEPTH: usize = 32;
197
198struct Renderer<'a> {
199    engine: &'a TemplateEngine,
200    depth: usize,
201}
202
203impl Renderer<'_> {
204    fn render_named(&mut self, name: &str, data: &Value) -> Result<String> {
205        self.depth += 1;
206        if self.depth > MAX_DEPTH {
207            return Err(Error::internal(format!(
208                "view `{name}` nests more than {MAX_DEPTH} levels deep — this is almost \
209                 certainly a template including itself"
210            )));
211        }
212
213        let template = self.engine.load(name)?;
214
215        // A child template contributes *sections*, not output: the layout
216        // decides where they land. So render the child only to collect them,
217        // then render the parent with those in hand.
218        if let Some(layout) = &template.extends {
219            let layout = unquote(layout);
220            let mut sections = HashMap::new();
221            self.collect_sections(&template.nodes, data, &mut sections)?;
222
223            let parent = self.engine.load(&layout)?;
224            let mut out = String::new();
225            self.render_nodes(&parent.nodes, data, &sections, &mut out)?;
226            self.depth -= 1;
227            return Ok(out);
228        }
229
230        let mut out = String::new();
231        self.render_nodes(&template.nodes, data, &HashMap::new(), &mut out)?;
232        self.depth -= 1;
233        Ok(out)
234    }
235
236    fn collect_sections(
237        &mut self,
238        nodes: &[Node],
239        data: &Value,
240        sections: &mut HashMap<String, String>,
241    ) -> Result<()> {
242        for node in nodes {
243            if let Node::Section { name, body } = node {
244                let mut rendered = String::new();
245                self.render_nodes(body, data, &HashMap::new(), &mut rendered)?;
246                sections.insert(name.clone(), rendered);
247            }
248        }
249        Ok(())
250    }
251
252    fn render_nodes(
253        &mut self,
254        nodes: &[Node],
255        data: &Value,
256        sections: &HashMap<String, String>,
257        out: &mut String,
258    ) -> Result<()> {
259        for node in nodes {
260            match node {
261                Node::Text(text) => out.push_str(text),
262
263                Node::Echo { expr, raw } => {
264                    let value = lookup(data, expr);
265                    let rendered = stringify(value);
266                    if *raw {
267                        out.push_str(&rendered);
268                    } else {
269                        out.push_str(&escape_html(&rendered));
270                    }
271                }
272
273                Node::If { branches, otherwise } => {
274                    let mut taken = false;
275                    for (condition, body) in branches {
276                        if evaluate(data, condition) {
277                            self.render_nodes(body, data, sections, out)?;
278                            taken = true;
279                            break;
280                        }
281                    }
282                    if !taken {
283                        if let Some(body) = otherwise {
284                            self.render_nodes(body, data, sections, out)?;
285                        }
286                    }
287                }
288
289                Node::Foreach { collection, key, value, body } => {
290                    let items = lookup(data, collection);
291                    let pairs: Vec<(Value, Value)> = match items {
292                        Some(Value::Array(items)) => items
293                            .iter()
294                            .enumerate()
295                            .map(|(index, item)| (Value::from(index), item.clone()))
296                            .collect(),
297                        Some(Value::Object(map)) => {
298                            map.iter().map(|(k, v)| (Value::String(k.clone()), v.clone())).collect()
299                        }
300                        // Anything else — including a missing key — is an
301                        // empty loop rather than an error, so an optional
302                        // collection needs no `@if` around it.
303                        _ => Vec::new(),
304                    };
305
306                    for (index, item) in pairs {
307                        // The loop variables shadow the outer scope for the
308                        // body only; the parent data is otherwise intact.
309                        let mut scope = data.as_object().cloned().unwrap_or_default();
310                        scope.insert(value.clone(), item);
311                        if let Some(key) = key {
312                            scope.insert(key.clone(), index);
313                        }
314                        self.render_nodes(body, &Value::Object(scope), sections, out)?;
315                    }
316                }
317
318                Node::Include(name) => {
319                    out.push_str(&self.render_named(name, data)?);
320                }
321
322                Node::Yield(name) => {
323                    if let Some(content) = sections.get(name) {
324                        out.push_str(content);
325                    }
326                    // A yield with no matching section renders nothing, which
327                    // is what a layout with an optional slot wants.
328                }
329
330                // A section outside a layout renders in place, so a template
331                // can be viewed on its own.
332                Node::Section { body, .. } => self.render_nodes(body, data, sections, out)?,
333            }
334        }
335        Ok(())
336    }
337}
338
339fn unquote(value: &str) -> String {
340    let trimmed = value.trim();
341    trimmed
342        .strip_prefix('\'')
343        .and_then(|v| v.strip_suffix('\''))
344        .or_else(|| trimmed.strip_prefix('"').and_then(|v| v.strip_suffix('"')))
345        .unwrap_or(trimmed)
346        .to_string()
347}
348
349/// Read a dotted path out of the view data.
350fn lookup<'a>(data: &'a Value, expr: &Expr) -> Option<&'a Value> {
351    let mut cursor = data;
352    for segment in &expr.path {
353        cursor = match cursor {
354            Value::Object(map) => map.get(segment)?,
355            Value::Array(items) => items.get(segment.parse::<usize>().ok()?)?,
356            _ => return None,
357        };
358    }
359    Some(cursor)
360}
361
362/// Render a value for output. A missing value is empty rather than an error —
363/// a template should not 500 because an optional field is absent.
364fn stringify(value: Option<&Value>) -> String {
365    match value {
366        None | Some(Value::Null) => String::new(),
367        Some(Value::String(text)) => text.clone(),
368        Some(Value::Bool(b)) => b.to_string(),
369        Some(Value::Number(n)) => n.to_string(),
370        Some(other) => other.to_string(),
371    }
372}
373
374/// Escape the five characters that can break out of HTML text or an attribute.
375///
376/// This is why `{{ }}` is the default and `{!! !!}` has to be asked for: the
377/// safe form should be the one you reach for without thinking.
378pub fn escape_html(value: &str) -> String {
379    let mut out = String::with_capacity(value.len());
380    for character in value.chars() {
381        match character {
382            '&' => out.push_str("&amp;"),
383            '<' => out.push_str("&lt;"),
384            '>' => out.push_str("&gt;"),
385            '"' => out.push_str("&quot;"),
386            '\'' => out.push_str("&#39;"),
387            other => out.push(other),
388        }
389    }
390    out
391}
392
393/// Whether a value counts as true in an `@if`.
394fn truthy(value: Option<&Value>) -> bool {
395    match value {
396        None | Some(Value::Null) => false,
397        Some(Value::Bool(b)) => *b,
398        Some(Value::String(text)) => !text.is_empty(),
399        Some(Value::Number(n)) => n.as_f64().is_some_and(|n| n != 0.0),
400        Some(Value::Array(items)) => !items.is_empty(),
401        Some(Value::Object(map)) => !map.is_empty(),
402    }
403}
404
405fn evaluate(data: &Value, condition: &Condition) -> bool {
406    match condition {
407        Condition::Truthy(expr) => truthy(lookup(data, expr)),
408        Condition::Falsy(expr) => !truthy(lookup(data, expr)),
409        Condition::Compare { left, op, right } => compare(lookup(data, left), *op, right),
410    }
411}
412
413fn compare(left: Option<&Value>, op: CompareOp, right: &Literal) -> bool {
414    // Equality works for every type; ordering only for numbers, since
415    // ordering strings by byte value is rarely what a template meant.
416    let ordering = match (left, right) {
417        (Some(Value::Number(a)), Literal::Number(b)) => a.as_f64().and_then(|a| a.partial_cmp(b)),
418        (Some(Value::String(a)), Literal::Number(b)) => {
419            a.parse::<f64>().ok().and_then(|a| a.partial_cmp(b))
420        }
421        _ => None,
422    };
423
424    let equal = match (left, right) {
425        (None | Some(Value::Null), Literal::Null) => true,
426        (Some(Value::String(a)), Literal::Text(b)) => a == b,
427        (Some(Value::Bool(a)), Literal::Bool(b)) => a == b,
428        (Some(Value::Number(a)), Literal::Number(b)) => {
429            a.as_f64().is_some_and(|a| (a - b).abs() < f64::EPSILON)
430        }
431        // A form value arrives as a string; comparing it to a number should
432        // still work.
433        (Some(Value::String(a)), Literal::Number(b)) => {
434            a.parse::<f64>().is_ok_and(|a| (a - b).abs() < f64::EPSILON)
435        }
436        (Some(Value::Number(a)), Literal::Text(b)) => a.to_string() == *b,
437        _ => false,
438    };
439
440    match op {
441        CompareOp::Eq => equal,
442        CompareOp::Ne => !equal,
443        CompareOp::Gt => ordering == Some(std::cmp::Ordering::Greater),
444        CompareOp::Gte => {
445            matches!(ordering, Some(std::cmp::Ordering::Greater) | Some(std::cmp::Ordering::Equal))
446        }
447        CompareOp::Lt => ordering == Some(std::cmp::Ordering::Less),
448        CompareOp::Lte => {
449            matches!(ordering, Some(std::cmp::Ordering::Less) | Some(std::cmp::Ordering::Equal))
450        }
451    }
452}
453
454/// A [`ViewEngine`] over templates held in memory, for tests and for a service
455/// with a handful of built-in templates.
456#[derive(Debug, Default)]
457pub struct MemoryEngine {
458    templates: RwLock<HashMap<String, String>>,
459}
460
461impl MemoryEngine {
462    /// No templates.
463    pub fn new() -> Self {
464        Self::default()
465    }
466
467    /// Add a template.
468    pub fn with(self, name: impl Into<String>, source: impl Into<String>) -> Self {
469        self.templates.write().expect("template lock poisoned").insert(name.into(), source.into());
470        self
471    }
472}
473
474impl ViewEngine for MemoryEngine {
475    fn render(&self, name: &str, data: &Value) -> Result<String> {
476        let source = self
477            .templates
478            .read()
479            .expect("template lock poisoned")
480            .get(name)
481            .cloned()
482            .ok_or_else(|| Error::internal(format!("view `{name}` is not registered")))?;
483
484        let template = parse(&source)?;
485        // Layouts and includes need the engine's loader; an in-memory engine
486        // renders one template at a time, which is all a test needs.
487        let mut out = String::new();
488        let engine = TemplateEngine::new(".");
489        let mut renderer = Renderer { engine: &engine, depth: 0 };
490        renderer.render_nodes(&template.nodes, data, &HashMap::new(), &mut out)?;
491        Ok(out)
492    }
493
494    fn exists(&self, name: &str) -> bool {
495        self.templates.read().expect("template lock poisoned").contains_key(name)
496    }
497}
498
499#[cfg(test)]
500mod tests {
501    use super::*;
502    use serde_json::json;
503
504    fn render(source: &str, data: Value) -> String {
505        MemoryEngine::new().with("t", source).render("t", &data).expect("should render")
506    }
507
508    #[test]
509    fn interpolates_values() {
510        assert_eq!(render("Hi {{ name }}!", json!({ "name": "Ada" })), "Hi Ada!");
511        assert_eq!(render("{{ n }}", json!({ "n": 42 })), "42");
512        assert_eq!(render("{{ b }}", json!({ "b": true })), "true");
513    }
514
515    #[test]
516    fn reads_dotted_paths_and_array_indices() {
517        let data = json!({ "user": { "name": "Ada" }, "tags": ["a", "b"] });
518        assert_eq!(render("{{ user.name }}", data.clone()), "Ada");
519        assert_eq!(render("{{ tags.1 }}", data), "b");
520    }
521
522    #[test]
523    fn a_missing_value_renders_empty_rather_than_failing() {
524        // A 500 because an optional field is absent would be a bad trade.
525        assert_eq!(render("[{{ nope }}]", json!({})), "[]");
526        assert_eq!(render("[{{ a.b.c }}]", json!({ "a": 1 })), "[]");
527        assert_eq!(render("[{{ n }}]", json!({ "n": null })), "[]");
528    }
529
530    #[test]
531    fn interpolation_escapes_html_by_default() {
532        let data = json!({ "body": "<script>alert('xss')</script>" });
533        let rendered = render("{{ body }}", data);
534
535        assert!(!rendered.contains("<script>"), "{rendered}");
536        assert_eq!(rendered, "&lt;script&gt;alert(&#39;xss&#39;)&lt;/script&gt;");
537    }
538
539    #[test]
540    fn escaping_covers_attribute_breakouts() {
541        assert_eq!(escape_html(r#"" onload="x"#), "&quot; onload=&quot;x");
542        assert_eq!(escape_html("a & b"), "a &amp; b");
543    }
544
545    #[test]
546    fn raw_interpolation_is_opt_in() {
547        let data = json!({ "body": "<b>bold</b>" });
548        assert_eq!(render("{!! body !!}", data), "<b>bold</b>");
549    }
550
551    #[test]
552    fn conditionals_pick_a_branch() {
553        let template =
554            "@if(role == \"admin\")admin@elseif(role == \"editor\")editor@else guest@endif";
555
556        assert_eq!(render(template, json!({ "role": "admin" })), "admin");
557        assert_eq!(render(template, json!({ "role": "editor" })), "editor");
558        assert_eq!(render(template, json!({ "role": "other" })), " guest");
559        assert_eq!(render(template, json!({})), " guest");
560    }
561
562    #[test]
563    fn truthiness_follows_the_obvious_rules() {
564        let template = "@if(v)yes@else no@endif";
565
566        for truthy in [json!(true), json!(1), json!("x"), json!(["a"]), json!({ "a": 1 })] {
567            assert_eq!(render(template, json!({ "v": truthy })), "yes");
568        }
569        for falsy in [json!(false), json!(0), json!(""), json!([]), json!({}), json!(null)] {
570            assert_eq!(render(template, json!({ "v": falsy })), " no");
571        }
572        assert_eq!(render(template, json!({})), " no", "an absent key is falsy");
573    }
574
575    #[test]
576    fn negation_inverts() {
577        assert_eq!(render("@if(!v)empty@endif", json!({ "v": false })), "empty");
578        assert_eq!(render("@if(!v)empty@endif", json!({ "v": true })), "");
579    }
580
581    #[test]
582    fn numeric_comparisons() {
583        assert_eq!(render("@if(n > 5)big@endif", json!({ "n": 10 })), "big");
584        assert_eq!(render("@if(n > 5)big@endif", json!({ "n": 3 })), "");
585        assert_eq!(render("@if(n >= 5)ok@endif", json!({ "n": 5 })), "ok");
586        assert_eq!(render("@if(n <= 5)ok@endif", json!({ "n": 5 })), "ok");
587        assert_eq!(render("@if(n < 5)small@endif", json!({ "n": 1 })), "small");
588    }
589
590    #[test]
591    fn a_numeric_string_compares_as_a_number() {
592        // Form input arrives as a string; the template should not care.
593        assert_eq!(render("@if(n > 5)big@endif", json!({ "n": "10" })), "big");
594        assert_eq!(render("@if(n == 10)ten@endif", json!({ "n": "10" })), "ten");
595    }
596
597    #[test]
598    fn ordering_a_non_number_is_false_rather_than_arbitrary() {
599        assert_eq!(render("@if(s > 5)?@endif", json!({ "s": "abc" })), "");
600        assert_eq!(render("@if(s < 5)?@endif", json!({ "s": "abc" })), "");
601    }
602
603    #[test]
604    fn loops_over_an_array() {
605        let data = json!({ "posts": [{ "title": "A" }, { "title": "B" }] });
606        assert_eq!(render("@foreach(posts as post)[{{ post.title }}]@endforeach", data), "[A][B]");
607    }
608
609    #[test]
610    fn loops_expose_the_index_or_key() {
611        let list = json!({ "items": ["a", "b"] });
612        assert_eq!(
613            render("@foreach(items as i => v){{ i }}={{ v }};@endforeach", list),
614            "0=a;1=b;"
615        );
616
617        let map = json!({ "meta": { "x": "1" } });
618        assert_eq!(render("@foreach(meta as k => v){{ k }}:{{ v }}@endforeach", map), "x:1");
619    }
620
621    #[test]
622    fn looping_over_nothing_renders_nothing() {
623        assert_eq!(render("[@foreach(items as i)x@endforeach]", json!({})), "[]");
624        assert_eq!(render("[@foreach(items as i)x@endforeach]", json!({ "items": [] })), "[]");
625        assert_eq!(
626            render("[@foreach(items as i)x@endforeach]", json!({ "items": "not a list" })),
627            "[]"
628        );
629    }
630
631    #[test]
632    fn the_outer_scope_is_visible_inside_a_loop() {
633        let data = json!({ "prefix": "-", "items": ["a"] });
634        assert_eq!(render("@foreach(items as i){{ prefix }}{{ i }}@endforeach", data), "-a");
635    }
636
637    #[test]
638    fn loops_and_conditionals_nest() {
639        let data =
640            json!({ "posts": [{ "published": true, "t": "A" }, { "published": false, "t": "B" }] });
641        assert_eq!(
642            render("@foreach(posts as p)@if(p.published){{ p.t }}@endif@endforeach", data),
643            "A"
644        );
645    }
646
647    // --- file-backed engine ------------------------------------------------
648
649    fn temp_root(name: &str) -> PathBuf {
650        let root = std::env::temp_dir().join(format!("rainier-view-{name}"));
651        let _ = std::fs::remove_dir_all(&root);
652        std::fs::create_dir_all(&root).expect("temp dir");
653        root
654    }
655
656    fn write(root: &Path, name: &str, source: &str) {
657        let path = root.join(format!("{name}.view.html"));
658        if let Some(parent) = path.parent() {
659            std::fs::create_dir_all(parent).expect("parent dir");
660        }
661        std::fs::write(path, source).expect("write template");
662    }
663
664    #[test]
665    fn renders_a_template_from_disk() {
666        let root = temp_root("basic");
667        write(&root, "greeting", "Hi {{ name }}");
668
669        let engine = TemplateEngine::new(&root);
670        assert!(engine.exists("greeting"));
671        assert_eq!(engine.render("greeting", &json!({ "name": "Ada" })).unwrap(), "Hi Ada");
672    }
673
674    #[test]
675    fn a_missing_template_names_itself_and_its_path() {
676        let engine = TemplateEngine::new(temp_root("missing"));
677        let err = engine.render("nope", &json!({})).err().expect("should fail");
678
679        assert!(err.message().contains("`nope`"), "{}", err.message());
680        assert!(err.message().contains("nope.view.html"), "{}", err.message());
681        assert!(!engine.exists("nope"));
682    }
683
684    #[test]
685    fn a_dotted_name_maps_to_a_subdirectory() {
686        let root = temp_root("nested");
687        std::fs::create_dir_all(root.join("posts")).unwrap();
688        write(&root, "posts/show", "post");
689
690        let engine = TemplateEngine::new(&root);
691        assert_eq!(engine.render("posts.show", &json!({})).unwrap(), "post");
692    }
693
694    #[test]
695    fn a_view_name_cannot_escape_the_template_root() {
696        // Regression guard: `..secrets` used to produce a leading separator,
697        // and `Path::join` with a rooted path discards the base entirely.
698        let root = temp_root("traversal");
699        let engine = TemplateEngine::new(&root);
700
701        for hostile in ["..secrets", "../../etc/passwd", ".", "..", "a/../../b", ""] {
702            let path = engine.path_for(hostile);
703            assert!(path.starts_with(&root), "`{hostile}` escaped to {path:?}");
704            assert!(!path.to_string_lossy().contains(".."), "`{hostile}` produced {path:?}");
705        }
706    }
707
708    #[test]
709    fn a_normal_name_still_maps_the_obvious_way() {
710        let engine = TemplateEngine::new("views");
711        assert_eq!(
712            engine.path_for("posts.show"),
713            PathBuf::from("views").join("posts").join("show.view.html")
714        );
715    }
716
717    #[test]
718    fn includes_are_rendered_with_the_same_data() {
719        let root = temp_root("include");
720        write(&root, "page", "<main>@include('parts.nav')</main>");
721        write(&root, "parts/nav", "<nav>{{ title }}</nav>");
722
723        let engine = TemplateEngine::new(&root);
724        assert_eq!(
725            engine.render("page", &json!({ "title": "Home" })).unwrap(),
726            "<main><nav>Home</nav></main>"
727        );
728    }
729
730    #[test]
731    fn a_layout_yields_the_childs_sections() {
732        let root = temp_root("layout");
733        write(
734            &root,
735            "layout",
736            "<html><title>@yield('title')</title><body>@yield('body')</body></html>",
737        );
738        write(
739            &root,
740            "page",
741            "@extends('layout')@section('title')Hi@endsection@section('body')<p>{{ name }}</p>@endsection",
742        );
743
744        let engine = TemplateEngine::new(&root);
745        assert_eq!(
746            engine.render("page", &json!({ "name": "Ada" })).unwrap(),
747            "<html><title>Hi</title><body><p>Ada</p></body></html>"
748        );
749    }
750
751    #[test]
752    fn a_yield_with_no_section_renders_nothing() {
753        let root = temp_root("optional-yield");
754        write(&root, "layout", "[@yield('sidebar')]");
755        write(&root, "page", "@extends('layout')");
756
757        let engine = TemplateEngine::new(&root);
758        assert_eq!(engine.render("page", &json!({})).unwrap(), "[]");
759    }
760
761    #[test]
762    fn a_self_including_template_is_caught_rather_than_overflowing_the_stack() {
763        let root = temp_root("cycle");
764        write(&root, "loop", "@include('loop')");
765
766        let engine = TemplateEngine::new(&root);
767        let err = engine.render("loop", &json!({})).err().expect("should fail");
768        assert!(err.message().contains("nests more than"), "{}", err.message());
769    }
770
771    #[test]
772    fn caching_can_be_turned_off_for_development() {
773        let root = temp_root("cache");
774        write(&root, "page", "one");
775
776        let cached = TemplateEngine::new(&root);
777        assert_eq!(cached.render("page", &json!({})).unwrap(), "one");
778        write(&root, "page", "two");
779        assert_eq!(cached.render("page", &json!({})).unwrap(), "one", "still cached");
780
781        cached.flush();
782        assert_eq!(cached.render("page", &json!({})).unwrap(), "two");
783
784        let live = TemplateEngine::new(&root).without_cache();
785        write(&root, "page", "three");
786        assert_eq!(live.render("page", &json!({})).unwrap(), "three");
787    }
788
789    #[test]
790    fn a_view_carries_its_name_and_data() {
791        let view = View::new("posts.show").add("id", 7).unwrap();
792        assert_eq!(view.name(), "posts.show");
793        assert_eq!(view.data()["id"], 7);
794    }
795
796    #[test]
797    fn view_data_must_be_an_object() {
798        assert!(View::with("x", serde_json::json!({ "a": 1 })).is_ok());
799        let err = View::with("x", "a bare string").err().expect("should fail");
800        assert!(err.message().contains("must be an object"), "{}", err.message());
801    }
802
803    #[test]
804    fn render_view_goes_through_the_engine() {
805        let engine = MemoryEngine::new().with("t", "Hi {{ name }}");
806        let view = View::new("t").add("name", "Ada").unwrap();
807        assert_eq!(engine.render_view(&view).unwrap(), "Hi Ada");
808    }
809}