Skip to main content

rustlavel_view/
engine.rs

1//! Loading, caching, and rendering the templates in `resources/views`.
2
3use crate::ast::{Node, Template};
4use crate::context::{Context, Scope};
5use crate::parser;
6use crate::render::Renderer;
7use rustlavel_core::{Error, Result};
8use std::collections::HashMap;
9use std::path::{Path, PathBuf};
10use std::sync::{Arc, RwLock};
11
12/// Where views live by default, mirroring Laravel's layout.
13pub const DEFAULT_ROOT: &str = "resources/views";
14
15/// The file extension a view carries.
16///
17/// The double extension is deliberate: `home.rl.html` is still an `.html` file
18/// to an editor, so a designer keeps syntax highlighting, formatting, and
19/// emmet while working on a template.
20pub const EXTENSION: &str = "rl.html";
21
22/// How many layouts an `@extends` chain may stack.
23const MAX_LAYOUT_DEPTH: usize = 16;
24
25/// Loads templates from a directory and renders them.
26///
27/// One engine is built at boot and shared; `render` takes `&self` so handlers
28/// can render concurrently.
29pub struct Engine {
30    root: PathBuf,
31    reload: bool,
32    /// Parsed templates, keyed by view name.
33    cache: RwLock<HashMap<String, Arc<Template>>>,
34}
35
36impl Engine {
37    pub fn new(root: impl Into<PathBuf>) -> Self {
38        Engine { root: root.into(), reload: false, cache: RwLock::new(HashMap::new()) }
39    }
40
41    /// Re-read every template from disk on every render.
42    ///
43    /// This is the whole reason the engine interprets an AST instead of
44    /// generating Rust: with reload on, a designer edits a template, hits
45    /// refresh, and sees the change — no recompile, no restart. Leave it off in
46    /// production, where the parse should happen once.
47    pub fn with_reload(mut self, reload: bool) -> Self {
48        self.reload = reload;
49        self
50    }
51
52    pub fn root(&self) -> &Path {
53        &self.root
54    }
55
56    pub fn reloads(&self) -> bool {
57        self.reload
58    }
59
60    /// Whether a view exists on disk.
61    pub fn exists(&self, name: &str) -> bool {
62        self.path_of(name).is_ok_and(|path| path.is_file())
63    }
64
65    /// Forget every parsed template.
66    pub fn clear_cache(&self) {
67        if let Ok(mut cache) = self.cache.write() {
68            cache.clear();
69        }
70    }
71
72    /// Render the view `name` — `"pages.home"` or `"pages/home"` — with
73    /// `context` as its data.
74    pub fn render(&self, name: &str, context: &Context) -> Result<String> {
75        self.render_template(self.load(name)?, context)
76    }
77
78    /// Render a template held in memory, still resolving `@include` and
79    /// `@extends` through this engine's root.
80    ///
81    /// Useful for a mail body assembled in code, and for tests that have no
82    /// reason to touch the disk.
83    pub fn render_source(&self, name: &str, source: &str, context: &Context) -> Result<String> {
84        self.render_template(Arc::new(parser::parse(name, source)?), context)
85    }
86
87    /// Parse a view, going through the cache unless reload mode is on.
88    pub(crate) fn load(&self, name: &str) -> Result<Arc<Template>> {
89        // A poisoned lock is not worth failing a page over: parse again.
90        if !self.reload
91            && let Ok(cache) = self.cache.read()
92            && let Some(template) = cache.get(name)
93        {
94            return Ok(Arc::clone(template));
95        }
96
97        let path = self.path_of(name)?;
98        let source = std::fs::read_to_string(&path).map_err(|error| {
99            if error.kind() == std::io::ErrorKind::NotFound {
100                Error::msg(format!("view `{name}` not found — expected `{}`", path.display()))
101            } else {
102                Error::Io(error)
103            }
104        })?;
105
106        let template = Arc::new(parser::parse(name, &source)?);
107        if !self.reload
108            && let Ok(mut cache) = self.cache.write()
109        {
110            cache.insert(name.to_string(), Arc::clone(&template));
111        }
112        Ok(template)
113    }
114
115    /// Map a view name onto a file, refusing anything that could leave the
116    /// view root — a name can reach this from a route parameter.
117    fn path_of(&self, name: &str) -> Result<PathBuf> {
118        let looks_wrong = name.is_empty()
119            || name.contains('\\')
120            || name.split(['.', '/']).any(|segment| segment.is_empty() || segment == "..");
121        if looks_wrong {
122            return Err(Error::msg(format!(
123                "`{name}` is not a valid view name — use dots or slashes, like `pages.home`"
124            )));
125        }
126        Ok(self.root.join(format!("{}.{EXTENSION}", name.replace('.', "/"))))
127    }
128
129    /// Render `template`, pulling its sections up through its layouts.
130    fn render_template(&self, template: Arc<Template>, context: &Context) -> Result<String> {
131        let chain = self.layout_chain(template)?;
132
133        // The child is walked first so its sections win; a section defined in a
134        // layout is only a default for the pages that do not override it.
135        let mut sections: HashMap<&str, &[Node]> = HashMap::new();
136        for template in &chain {
137            for (name, nodes) in &template.sections {
138                sections.entry(name.as_str()).or_insert(nodes.as_slice());
139            }
140        }
141
142        // Only the outermost layout is rendered: everything a child contributes
143        // reaches the page through `@yield`.
144        let layout = chain.last().expect("a chain always holds the template it started from");
145        let mut out = String::new();
146        let mut scope = Scope::new(context);
147        Renderer::new(self, sections).nodes(&layout.nodes, &mut scope, &mut out, 0)?;
148        Ok(out)
149    }
150
151    /// Follow `@extends` from a template up to its outermost layout.
152    fn layout_chain(&self, template: Arc<Template>) -> Result<Vec<Arc<Template>>> {
153        let mut chain = vec![template];
154
155        while let Some(parent) = chain[chain.len() - 1].extends.clone() {
156            if chain.iter().any(|template| template.name == parent.name) {
157                return Err(parent.error(format!(
158                    "`{}` is already in this `@extends` chain — layouts cannot loop",
159                    parent.name
160                )));
161            }
162            if chain.len() >= MAX_LAYOUT_DEPTH {
163                return Err(parent.error(format!(
164                    "layouts are stacked more than {MAX_LAYOUT_DEPTH} deep — flatten them"
165                )));
166            }
167            let layout = self.load(&parent.name).map_err(|error| match error {
168                Error::Template { .. } => error,
169                other => parent.error(other.to_string()),
170            })?;
171            chain.push(layout);
172        }
173
174        Ok(chain)
175    }
176}
177
178impl Default for Engine {
179    fn default() -> Self {
180        Engine::new(DEFAULT_ROOT)
181    }
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187    use rustlavel_core::Json;
188
189    /// Each test writes its own directory: tests run concurrently, and a shared
190    /// fixture would be rewritten underneath a test that is reading it.
191    fn fixture(test: &str, views: &[(&str, &str)]) -> PathBuf {
192        let root = std::env::temp_dir().join(format!("rustlavel-view-{test}"));
193        let _ = std::fs::remove_dir_all(&root);
194        for (name, source) in views {
195            let path = root.join(format!("{}.{EXTENSION}", name.replace('.', "/")));
196            std::fs::create_dir_all(path.parent().unwrap()).unwrap();
197            std::fs::write(path, source).unwrap();
198        }
199        root
200    }
201
202    #[test]
203    fn renders_a_view_from_disk() {
204        let engine = Engine::new(fixture("disk", &[("pages.home", "<h1>{{ title }}</h1>")]));
205        let context = Context::new().with("title", "Home & away");
206
207        assert_eq!(
208            engine.render("pages.home", &context).unwrap(),
209            "<h1>Home &amp; away</h1>"
210        );
211        // Slashes and dots name the same file.
212        assert_eq!(
213            engine.render("pages/home", &context).unwrap(),
214            "<h1>Home &amp; away</h1>"
215        );
216    }
217
218    #[test]
219    fn a_layout_receives_the_sections_a_page_defines() {
220        let root = fixture(
221            "layout",
222            &[
223                (
224                    "layouts.app",
225                    "<title>@yield(\"title\", \"Rustlavel\")</title>\n<main>@yield(\"body\")</main>\n",
226                ),
227                (
228                    "pages.about",
229                    "@extends(\"layouts.app\")\n\
230                     @section(\"title\", heading)\n\
231                     @section(\"body\")\n\
232                     <p>{{ heading }}</p>\n\
233                     @endsection\n",
234                ),
235            ],
236        );
237        let engine = Engine::new(root);
238        let context = Context::new().with("heading", "About");
239
240        assert_eq!(
241            engine.render("pages.about", &context).unwrap(),
242            "<title>About</title>\n<main><p>About</p>\n</main>\n"
243        );
244    }
245
246    #[test]
247    fn a_yield_with_no_section_falls_back_or_stays_empty() {
248        let root = fixture(
249            "yield-default",
250            &[
251                ("layouts.app", "[@yield(\"title\", \"Untitled\")][@yield(\"body\")]"),
252                ("pages.bare", "@extends(\"layouts.app\")"),
253            ],
254        );
255
256        assert_eq!(
257            Engine::new(root).render("pages.bare", &Context::new()).unwrap(),
258            "[Untitled][]"
259        );
260    }
261
262    #[test]
263    fn layouts_can_be_stacked_and_the_innermost_page_wins() {
264        let root = fixture(
265            "nested-layout",
266            &[
267                ("layouts.base", "<html>@yield(\"body\")</html>"),
268                (
269                    "layouts.admin",
270                    "@extends(\"layouts.base\")@section(\"body\")<nav></nav>@yield(\"panel\")@endsection",
271                ),
272                (
273                    "pages.dashboard",
274                    "@extends(\"layouts.admin\")@section(\"panel\")<p>hi</p>@endsection",
275                ),
276            ],
277        );
278
279        assert_eq!(
280            Engine::new(root).render("pages.dashboard", &Context::new()).unwrap(),
281            "<html><nav></nav><p>hi</p></html>"
282        );
283    }
284
285    #[test]
286    fn an_include_renders_a_partial_with_the_surrounding_scope() {
287        let root = fixture(
288            "include",
289            &[
290                ("pages.list", "@foreach(users as user)@include(\"parts.user\")@endforeach"),
291                ("parts.user", "<li>{{ user.name }} ({{ loop.iteration }})</li>"),
292            ],
293        );
294        let context = Context::new().with(
295            "users",
296            Json::Array(vec![
297                Json::object([("name", Json::from("Ada"))]),
298                Json::object([("name", Json::from("Grace"))]),
299            ]),
300        );
301
302        assert_eq!(
303            Engine::new(root).render("pages.list", &context).unwrap(),
304            "<li>Ada (1)</li><li>Grace (2)</li>"
305        );
306    }
307
308    #[test]
309    fn a_missing_view_is_reported_by_name() {
310        let engine = Engine::new(fixture("missing", &[]));
311        let message = engine.render("pages.nope", &Context::new()).unwrap_err().to_string();
312
313        assert!(message.contains("pages.nope"), "{message}");
314        assert!(message.contains("pages/nope.rl.html"), "{message}");
315    }
316
317    #[test]
318    fn a_missing_partial_is_blamed_on_the_line_that_included_it() {
319        let root = fixture("missing-partial", &[("pages.home", "<p>ok</p>\n@include(\"gone\")\n")]);
320
321        match Engine::new(root).render("pages.home", &Context::new()).unwrap_err() {
322            Error::Template { file, line, message, .. } => {
323                assert_eq!((file.as_str(), line), ("pages.home", 2));
324                assert!(message.contains("gone"), "{message}");
325            }
326            other => panic!("expected a template error, got {other:?}"),
327        }
328    }
329
330    #[test]
331    fn a_syntax_error_names_the_view_and_the_line_it_is_on() {
332        let root = fixture(
333            "syntax",
334            &[("pages.broken", "<ul>\n  <li>ok</li>\n  @foreach(items as item)\n</ul>\n")],
335        );
336
337        match Engine::new(root).render("pages.broken", &Context::new()).unwrap_err() {
338            Error::Template { file, line, column, message } => {
339                assert_eq!((file.as_str(), line, column), ("pages.broken", 3, 3));
340                assert!(message.contains("@endforeach"), "{message}");
341            }
342            other => panic!("expected a template error, got {other:?}"),
343        }
344    }
345
346    #[test]
347    fn a_view_name_cannot_escape_the_view_root() {
348        let engine = Engine::new(fixture("traversal", &[]));
349
350        for name in ["../secrets", "pages..home", "", "pages\\home"] {
351            assert!(engine.render(name, &Context::new()).is_err(), "`{name}` should not resolve");
352        }
353    }
354
355    #[test]
356    fn a_cached_template_survives_the_file_changing_until_reload_is_on() {
357        let root = fixture("cache", &[("pages.home", "first")]);
358        let path = root.join(format!("pages/home.{EXTENSION}"));
359
360        let cached = Engine::new(&root);
361        assert_eq!(cached.render("pages.home", &Context::new()).unwrap(), "first");
362        std::fs::write(&path, "second").unwrap();
363        assert_eq!(cached.render("pages.home", &Context::new()).unwrap(), "first");
364        cached.clear_cache();
365        assert_eq!(cached.render("pages.home", &Context::new()).unwrap(), "second");
366
367        // With reload on, the edit shows up on the next request — no restart,
368        // which is the point of the mode.
369        let live = Engine::new(&root).with_reload(true);
370        assert_eq!(live.render("pages.home", &Context::new()).unwrap(), "second");
371        std::fs::write(&path, "third").unwrap();
372        assert_eq!(live.render("pages.home", &Context::new()).unwrap(), "third");
373    }
374
375    #[test]
376    fn a_layout_loop_is_caught_instead_of_hanging() {
377        let root = fixture(
378            "loop",
379            &[
380                ("a", "@extends(\"b\")"),
381                ("b", "@extends(\"a\")"),
382            ],
383        );
384
385        let message = Engine::new(root).render("a", &Context::new()).unwrap_err().to_string();
386        assert!(message.contains("cannot loop"), "{message}");
387    }
388
389    #[test]
390    fn exists_reports_what_is_on_disk() {
391        let engine = Engine::new(fixture("exists", &[("pages.home", "hi")]));
392
393        assert!(engine.exists("pages.home"));
394        assert!(!engine.exists("pages.gone"));
395        assert!(!engine.exists("../etc/passwd"));
396    }
397}