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