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}
39
40impl Engine {
41 pub fn new(root: impl Into<PathBuf>) -> Self {
42 Engine {
43 root: root.into(),
44 reload: false,
45 cache: RwLock::new(HashMap::new()),
46 translator: None,
47 }
48 }
49
50 pub fn with_reload(mut self, reload: bool) -> Self {
57 self.reload = reload;
58 self
59 }
60
61 pub fn with_translator(mut self, translator: Arc<dyn Translate>) -> Self {
67 self.translator = Some(translator);
68 self
69 }
70
71 pub(crate) fn translator(&self) -> Option<&dyn Translate> {
73 self.translator.as_deref()
74 }
75
76 pub fn root(&self) -> &Path {
77 &self.root
78 }
79
80 pub fn reloads(&self) -> bool {
81 self.reload
82 }
83
84 pub fn exists(&self, name: &str) -> bool {
86 self.path_of(name).is_ok_and(|path| path.is_file())
87 }
88
89 pub fn clear_cache(&self) {
91 if let Ok(mut cache) = self.cache.write() {
92 cache.clear();
93 }
94 }
95
96 pub fn render(&self, name: &str, context: &Context) -> Result<String> {
99 self.render_template(self.load(name)?, context)
100 }
101
102 pub fn render_source(&self, name: &str, source: &str, context: &Context) -> Result<String> {
108 self.render_template(Arc::new(parser::parse(name, source)?), context)
109 }
110
111 pub(crate) fn load(&self, name: &str) -> Result<Arc<Template>> {
113 if !self.reload
115 && let Ok(cache) = self.cache.read()
116 && let Some(template) = cache.get(name)
117 {
118 return Ok(Arc::clone(template));
119 }
120
121 let path = self.path_of(name)?;
122 let source = std::fs::read_to_string(&path).map_err(|error| {
123 if error.kind() == std::io::ErrorKind::NotFound {
124 Error::msg(format!("view `{name}` not found — expected `{}`", path.display()))
125 } else {
126 Error::Io(error)
127 }
128 })?;
129
130 let template = Arc::new(parser::parse(name, &source)?);
131 if !self.reload
132 && let Ok(mut cache) = self.cache.write()
133 {
134 cache.insert(name.to_string(), Arc::clone(&template));
135 }
136 Ok(template)
137 }
138
139 fn path_of(&self, name: &str) -> Result<PathBuf> {
142 let looks_wrong = name.is_empty()
143 || name.contains('\\')
144 || name.split(['.', '/']).any(|segment| segment.is_empty() || segment == "..");
145 if looks_wrong {
146 return Err(Error::msg(format!(
147 "`{name}` is not a valid view name — use dots or slashes, like `pages.home`"
148 )));
149 }
150 Ok(self.root.join(format!("{}.{EXTENSION}", name.replace('.', "/"))))
151 }
152
153 fn render_template(&self, template: Arc<Template>, context: &Context) -> Result<String> {
155 let chain = self.layout_chain(template)?;
156
157 let mut sections: HashMap<&str, &[Node]> = HashMap::new();
160 for template in &chain {
161 for (name, nodes) in &template.sections {
162 sections.entry(name.as_str()).or_insert(nodes.as_slice());
163 }
164 }
165
166 let layout = chain.last().expect("a chain always holds the template it started from");
169 let mut out = String::new();
170 let mut scope = Scope::new(context);
171 Renderer::new(self, sections).nodes(&layout.nodes, &mut scope, &mut out, 0)?;
172 Ok(out)
173 }
174
175 fn layout_chain(&self, template: Arc<Template>) -> Result<Vec<Arc<Template>>> {
177 let mut chain = vec![template];
178
179 while let Some(parent) = chain[chain.len() - 1].extends.clone() {
180 if chain.iter().any(|template| template.name == parent.name) {
181 return Err(parent.error(format!(
182 "`{}` is already in this `@extends` chain — layouts cannot loop",
183 parent.name
184 )));
185 }
186 if chain.len() >= MAX_LAYOUT_DEPTH {
187 return Err(parent.error(format!(
188 "layouts are stacked more than {MAX_LAYOUT_DEPTH} deep — flatten them"
189 )));
190 }
191 let layout = self.load(&parent.name).map_err(|error| match error {
192 Error::Template { .. } => error,
193 other => parent.error(other.to_string()),
194 })?;
195 chain.push(layout);
196 }
197
198 Ok(chain)
199 }
200}
201
202impl Default for Engine {
203 fn default() -> Self {
204 Engine::new(DEFAULT_ROOT)
205 }
206}
207
208#[cfg(test)]
209mod tests {
210 use super::*;
211 use rustlavel_core::Json;
212
213 fn fixture(test: &str, views: &[(&str, &str)]) -> PathBuf {
216 let root = std::env::temp_dir().join(format!("rustlavel-view-{test}"));
217 let _ = std::fs::remove_dir_all(&root);
218 for (name, source) in views {
219 let path = root.join(format!("{}.{EXTENSION}", name.replace('.', "/")));
220 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
221 std::fs::write(path, source).unwrap();
222 }
223 root
224 }
225
226 #[test]
227 fn renders_a_view_from_disk() {
228 let engine = Engine::new(fixture("disk", &[("pages.home", "<h1>{{ title }}</h1>")]));
229 let context = Context::new().with("title", "Home & away");
230
231 assert_eq!(
232 engine.render("pages.home", &context).unwrap(),
233 "<h1>Home & away</h1>"
234 );
235 assert_eq!(
237 engine.render("pages/home", &context).unwrap(),
238 "<h1>Home & away</h1>"
239 );
240 }
241
242 #[test]
243 fn a_layout_receives_the_sections_a_page_defines() {
244 let root = fixture(
245 "layout",
246 &[
247 (
248 "layouts.app",
249 "<title>@yield(\"title\", \"Rustlavel\")</title>\n<main>@yield(\"body\")</main>\n",
250 ),
251 (
252 "pages.about",
253 "@extends(\"layouts.app\")\n\
254 @section(\"title\", heading)\n\
255 @section(\"body\")\n\
256 <p>{{ heading }}</p>\n\
257 @endsection\n",
258 ),
259 ],
260 );
261 let engine = Engine::new(root);
262 let context = Context::new().with("heading", "About");
263
264 assert_eq!(
265 engine.render("pages.about", &context).unwrap(),
266 "<title>About</title>\n<main><p>About</p>\n</main>\n"
267 );
268 }
269
270 #[test]
271 fn a_yield_with_no_section_falls_back_or_stays_empty() {
272 let root = fixture(
273 "yield-default",
274 &[
275 ("layouts.app", "[@yield(\"title\", \"Untitled\")][@yield(\"body\")]"),
276 ("pages.bare", "@extends(\"layouts.app\")"),
277 ],
278 );
279
280 assert_eq!(
281 Engine::new(root).render("pages.bare", &Context::new()).unwrap(),
282 "[Untitled][]"
283 );
284 }
285
286 #[test]
287 fn layouts_can_be_stacked_and_the_innermost_page_wins() {
288 let root = fixture(
289 "nested-layout",
290 &[
291 ("layouts.base", "<html>@yield(\"body\")</html>"),
292 (
293 "layouts.admin",
294 "@extends(\"layouts.base\")@section(\"body\")<nav></nav>@yield(\"panel\")@endsection",
295 ),
296 (
297 "pages.dashboard",
298 "@extends(\"layouts.admin\")@section(\"panel\")<p>hi</p>@endsection",
299 ),
300 ],
301 );
302
303 assert_eq!(
304 Engine::new(root).render("pages.dashboard", &Context::new()).unwrap(),
305 "<html><nav></nav><p>hi</p></html>"
306 );
307 }
308
309 #[test]
310 fn an_include_renders_a_partial_with_the_surrounding_scope() {
311 let root = fixture(
312 "include",
313 &[
314 ("pages.list", "@foreach(users as user)@include(\"parts.user\")@endforeach"),
315 ("parts.user", "<li>{{ user.name }} ({{ loop.iteration }})</li>"),
316 ],
317 );
318 let context = Context::new().with(
319 "users",
320 Json::Array(vec![
321 Json::object([("name", Json::from("Ada"))]),
322 Json::object([("name", Json::from("Grace"))]),
323 ]),
324 );
325
326 assert_eq!(
327 Engine::new(root).render("pages.list", &context).unwrap(),
328 "<li>Ada (1)</li><li>Grace (2)</li>"
329 );
330 }
331
332 #[test]
333 fn a_missing_view_is_reported_by_name() {
334 let engine = Engine::new(fixture("missing", &[]));
335 let message = engine.render("pages.nope", &Context::new()).unwrap_err().to_string();
336
337 assert!(message.contains("pages.nope"), "{message}");
338 assert!(message.contains("pages/nope.rl.html"), "{message}");
339 }
340
341 #[test]
342 fn a_missing_partial_is_blamed_on_the_line_that_included_it() {
343 let root = fixture("missing-partial", &[("pages.home", "<p>ok</p>\n@include(\"gone\")\n")]);
344
345 match Engine::new(root).render("pages.home", &Context::new()).unwrap_err() {
346 Error::Template { file, line, message, .. } => {
347 assert_eq!((file.as_str(), line), ("pages.home", 2));
348 assert!(message.contains("gone"), "{message}");
349 }
350 other => panic!("expected a template error, got {other:?}"),
351 }
352 }
353
354 #[test]
355 fn a_syntax_error_names_the_view_and_the_line_it_is_on() {
356 let root = fixture(
357 "syntax",
358 &[("pages.broken", "<ul>\n <li>ok</li>\n @foreach(items as item)\n</ul>\n")],
359 );
360
361 match Engine::new(root).render("pages.broken", &Context::new()).unwrap_err() {
362 Error::Template { file, line, column, message } => {
363 assert_eq!((file.as_str(), line, column), ("pages.broken", 3, 3));
364 assert!(message.contains("@endforeach"), "{message}");
365 }
366 other => panic!("expected a template error, got {other:?}"),
367 }
368 }
369
370 #[test]
371 fn a_view_name_cannot_escape_the_view_root() {
372 let engine = Engine::new(fixture("traversal", &[]));
373
374 for name in ["../secrets", "pages..home", "", "pages\\home"] {
375 assert!(engine.render(name, &Context::new()).is_err(), "`{name}` should not resolve");
376 }
377 }
378
379 #[test]
380 fn a_cached_template_survives_the_file_changing_until_reload_is_on() {
381 let root = fixture("cache", &[("pages.home", "first")]);
382 let path = root.join(format!("pages/home.{EXTENSION}"));
383
384 let cached = Engine::new(&root);
385 assert_eq!(cached.render("pages.home", &Context::new()).unwrap(), "first");
386 std::fs::write(&path, "second").unwrap();
387 assert_eq!(cached.render("pages.home", &Context::new()).unwrap(), "first");
388 cached.clear_cache();
389 assert_eq!(cached.render("pages.home", &Context::new()).unwrap(), "second");
390
391 let live = Engine::new(&root).with_reload(true);
394 assert_eq!(live.render("pages.home", &Context::new()).unwrap(), "second");
395 std::fs::write(&path, "third").unwrap();
396 assert_eq!(live.render("pages.home", &Context::new()).unwrap(), "third");
397 }
398
399 #[test]
400 fn a_layout_loop_is_caught_instead_of_hanging() {
401 let root = fixture(
402 "loop",
403 &[
404 ("a", "@extends(\"b\")"),
405 ("b", "@extends(\"a\")"),
406 ],
407 );
408
409 let message = Engine::new(root).render("a", &Context::new()).unwrap_err().to_string();
410 assert!(message.contains("cannot loop"), "{message}");
411 }
412
413 #[test]
414 fn exists_reports_what_is_on_disk() {
415 let engine = Engine::new(fixture("exists", &[("pages.home", "hi")]));
416
417 assert!(engine.exists("pages.home"));
418 assert!(!engine.exists("pages.gone"));
419 assert!(!engine.exists("../etc/passwd"));
420 }
421}