1use 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
13pub const DEFAULT_ROOT: &str = "resources/views";
15
16pub const EXTENSION: &str = "rl.html";
22
23const MAX_LAYOUT_DEPTH: usize = 16;
25
26pub struct Engine {
31 root: PathBuf,
32 reload: bool,
33 cache: RwLock<HashMap<String, Arc<Template>>>,
35 translator: Option<Arc<dyn Translate>>,
38 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 pub fn with_reload(mut self, reload: bool) -> Self {
63 self.reload = reload;
64 self
65 }
66
67 pub fn with_translator(mut self, translator: Arc<dyn Translate>) -> Self {
73 self.translator = Some(translator);
74 self
75 }
76
77 pub fn routes_cell(&self) -> Arc<std::sync::OnceLock<Arc<dyn crate::routes::Routes>>> {
80 Arc::clone(&self.routes)
81 }
82
83 pub(crate) fn routes(&self) -> Option<&dyn crate::routes::Routes> {
85 self.routes.get().map(|table| table.as_ref())
86 }
87
88 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 pub fn exists(&self, name: &str) -> bool {
103 self.path_of(name).is_ok_and(|path| path.is_file())
104 }
105
106 pub fn clear_cache(&self) {
108 if let Ok(mut cache) = self.cache.write() {
109 cache.clear();
110 }
111 }
112
113 pub fn render(&self, name: &str, context: &Context) -> Result<String> {
116 self.render_template(self.load(name)?, context)
117 }
118
119 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 pub(crate) fn load(&self, name: &str) -> Result<Arc<Template>> {
130 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 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 fn render_template(&self, template: Arc<Template>, context: &Context) -> Result<String> {
172 let chain = self.layout_chain(template)?;
173
174 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 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 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 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 & away</h1>"
259 );
260 assert_eq!(
262 engine.render("pages/home", &context).unwrap(),
263 "<h1>Home & 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 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}