1use 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
12pub const DEFAULT_ROOT: &str = "resources/views";
14
15pub const EXTENSION: &str = "rl.html";
21
22const MAX_LAYOUT_DEPTH: usize = 16;
24
25pub struct Engine {
30 root: PathBuf,
31 reload: bool,
32 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 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 pub fn exists(&self, name: &str) -> bool {
62 self.path_of(name).is_ok_and(|path| path.is_file())
63 }
64
65 pub fn clear_cache(&self) {
67 if let Ok(mut cache) = self.cache.write() {
68 cache.clear();
69 }
70 }
71
72 pub fn render(&self, name: &str, context: &Context) -> Result<String> {
75 self.render_template(self.load(name)?, context)
76 }
77
78 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 pub(crate) fn load(&self, name: &str) -> Result<Arc<Template>> {
89 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 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 fn render_template(&self, template: Arc<Template>, context: &Context) -> Result<String> {
131 let chain = self.layout_chain(template)?;
132
133 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 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 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 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 & away</h1>"
210 );
211 assert_eq!(
213 engine.render("pages/home", &context).unwrap(),
214 "<h1>Home & 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 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}