use crate::ast::{Node, Template};
use crate::context::{Context, Scope};
use crate::parser;
use crate::render::Renderer;
use rustlavel_core::{Error, Result};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{Arc, RwLock};
pub const DEFAULT_ROOT: &str = "resources/views";
pub const EXTENSION: &str = "rl.html";
const MAX_LAYOUT_DEPTH: usize = 16;
pub struct Engine {
root: PathBuf,
reload: bool,
cache: RwLock<HashMap<String, Arc<Template>>>,
}
impl Engine {
pub fn new(root: impl Into<PathBuf>) -> Self {
Engine { root: root.into(), reload: false, cache: RwLock::new(HashMap::new()) }
}
pub fn with_reload(mut self, reload: bool) -> Self {
self.reload = reload;
self
}
pub fn root(&self) -> &Path {
&self.root
}
pub fn reloads(&self) -> bool {
self.reload
}
pub fn exists(&self, name: &str) -> bool {
self.path_of(name).is_ok_and(|path| path.is_file())
}
pub fn clear_cache(&self) {
if let Ok(mut cache) = self.cache.write() {
cache.clear();
}
}
pub fn render(&self, name: &str, context: &Context) -> Result<String> {
self.render_template(self.load(name)?, context)
}
pub fn render_source(&self, name: &str, source: &str, context: &Context) -> Result<String> {
self.render_template(Arc::new(parser::parse(name, source)?), context)
}
pub(crate) fn load(&self, name: &str) -> Result<Arc<Template>> {
if !self.reload
&& let Ok(cache) = self.cache.read()
&& let Some(template) = cache.get(name)
{
return Ok(Arc::clone(template));
}
let path = self.path_of(name)?;
let source = std::fs::read_to_string(&path).map_err(|error| {
if error.kind() == std::io::ErrorKind::NotFound {
Error::msg(format!("view `{name}` not found — expected `{}`", path.display()))
} else {
Error::Io(error)
}
})?;
let template = Arc::new(parser::parse(name, &source)?);
if !self.reload
&& let Ok(mut cache) = self.cache.write()
{
cache.insert(name.to_string(), Arc::clone(&template));
}
Ok(template)
}
fn path_of(&self, name: &str) -> Result<PathBuf> {
let looks_wrong = name.is_empty()
|| name.contains('\\')
|| name.split(['.', '/']).any(|segment| segment.is_empty() || segment == "..");
if looks_wrong {
return Err(Error::msg(format!(
"`{name}` is not a valid view name — use dots or slashes, like `pages.home`"
)));
}
Ok(self.root.join(format!("{}.{EXTENSION}", name.replace('.', "/"))))
}
fn render_template(&self, template: Arc<Template>, context: &Context) -> Result<String> {
let chain = self.layout_chain(template)?;
let mut sections: HashMap<&str, &[Node]> = HashMap::new();
for template in &chain {
for (name, nodes) in &template.sections {
sections.entry(name.as_str()).or_insert(nodes.as_slice());
}
}
let layout = chain.last().expect("a chain always holds the template it started from");
let mut out = String::new();
let mut scope = Scope::new(context);
Renderer::new(self, sections).nodes(&layout.nodes, &mut scope, &mut out, 0)?;
Ok(out)
}
fn layout_chain(&self, template: Arc<Template>) -> Result<Vec<Arc<Template>>> {
let mut chain = vec![template];
while let Some(parent) = chain[chain.len() - 1].extends.clone() {
if chain.iter().any(|template| template.name == parent.name) {
return Err(parent.error(format!(
"`{}` is already in this `@extends` chain — layouts cannot loop",
parent.name
)));
}
if chain.len() >= MAX_LAYOUT_DEPTH {
return Err(parent.error(format!(
"layouts are stacked more than {MAX_LAYOUT_DEPTH} deep — flatten them"
)));
}
let layout = self.load(&parent.name).map_err(|error| match error {
Error::Template { .. } => error,
other => parent.error(other.to_string()),
})?;
chain.push(layout);
}
Ok(chain)
}
}
impl Default for Engine {
fn default() -> Self {
Engine::new(DEFAULT_ROOT)
}
}
#[cfg(test)]
mod tests {
use super::*;
use rustlavel_core::Json;
fn fixture(test: &str, views: &[(&str, &str)]) -> PathBuf {
let root = std::env::temp_dir().join(format!("rustlavel-view-{test}"));
let _ = std::fs::remove_dir_all(&root);
for (name, source) in views {
let path = root.join(format!("{}.{EXTENSION}", name.replace('.', "/")));
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, source).unwrap();
}
root
}
#[test]
fn renders_a_view_from_disk() {
let engine = Engine::new(fixture("disk", &[("pages.home", "<h1>{{ title }}</h1>")]));
let context = Context::new().with("title", "Home & away");
assert_eq!(
engine.render("pages.home", &context).unwrap(),
"<h1>Home & away</h1>"
);
assert_eq!(
engine.render("pages/home", &context).unwrap(),
"<h1>Home & away</h1>"
);
}
#[test]
fn a_layout_receives_the_sections_a_page_defines() {
let root = fixture(
"layout",
&[
(
"layouts.app",
"<title>@yield(\"title\", \"Rustlavel\")</title>\n<main>@yield(\"body\")</main>\n",
),
(
"pages.about",
"@extends(\"layouts.app\")\n\
@section(\"title\", heading)\n\
@section(\"body\")\n\
<p>{{ heading }}</p>\n\
@endsection\n",
),
],
);
let engine = Engine::new(root);
let context = Context::new().with("heading", "About");
assert_eq!(
engine.render("pages.about", &context).unwrap(),
"<title>About</title>\n<main><p>About</p>\n</main>\n"
);
}
#[test]
fn a_yield_with_no_section_falls_back_or_stays_empty() {
let root = fixture(
"yield-default",
&[
("layouts.app", "[@yield(\"title\", \"Untitled\")][@yield(\"body\")]"),
("pages.bare", "@extends(\"layouts.app\")"),
],
);
assert_eq!(
Engine::new(root).render("pages.bare", &Context::new()).unwrap(),
"[Untitled][]"
);
}
#[test]
fn layouts_can_be_stacked_and_the_innermost_page_wins() {
let root = fixture(
"nested-layout",
&[
("layouts.base", "<html>@yield(\"body\")</html>"),
(
"layouts.admin",
"@extends(\"layouts.base\")@section(\"body\")<nav></nav>@yield(\"panel\")@endsection",
),
(
"pages.dashboard",
"@extends(\"layouts.admin\")@section(\"panel\")<p>hi</p>@endsection",
),
],
);
assert_eq!(
Engine::new(root).render("pages.dashboard", &Context::new()).unwrap(),
"<html><nav></nav><p>hi</p></html>"
);
}
#[test]
fn an_include_renders_a_partial_with_the_surrounding_scope() {
let root = fixture(
"include",
&[
("pages.list", "@foreach(users as user)@include(\"parts.user\")@endforeach"),
("parts.user", "<li>{{ user.name }} ({{ loop.iteration }})</li>"),
],
);
let context = Context::new().with(
"users",
Json::Array(vec![
Json::object([("name", Json::from("Ada"))]),
Json::object([("name", Json::from("Grace"))]),
]),
);
assert_eq!(
Engine::new(root).render("pages.list", &context).unwrap(),
"<li>Ada (1)</li><li>Grace (2)</li>"
);
}
#[test]
fn a_missing_view_is_reported_by_name() {
let engine = Engine::new(fixture("missing", &[]));
let message = engine.render("pages.nope", &Context::new()).unwrap_err().to_string();
assert!(message.contains("pages.nope"), "{message}");
assert!(message.contains("pages/nope.rl.html"), "{message}");
}
#[test]
fn a_missing_partial_is_blamed_on_the_line_that_included_it() {
let root = fixture("missing-partial", &[("pages.home", "<p>ok</p>\n@include(\"gone\")\n")]);
match Engine::new(root).render("pages.home", &Context::new()).unwrap_err() {
Error::Template { file, line, message, .. } => {
assert_eq!((file.as_str(), line), ("pages.home", 2));
assert!(message.contains("gone"), "{message}");
}
other => panic!("expected a template error, got {other:?}"),
}
}
#[test]
fn a_syntax_error_names_the_view_and_the_line_it_is_on() {
let root = fixture(
"syntax",
&[("pages.broken", "<ul>\n <li>ok</li>\n @foreach(items as item)\n</ul>\n")],
);
match Engine::new(root).render("pages.broken", &Context::new()).unwrap_err() {
Error::Template { file, line, column, message } => {
assert_eq!((file.as_str(), line, column), ("pages.broken", 3, 3));
assert!(message.contains("@endforeach"), "{message}");
}
other => panic!("expected a template error, got {other:?}"),
}
}
#[test]
fn a_view_name_cannot_escape_the_view_root() {
let engine = Engine::new(fixture("traversal", &[]));
for name in ["../secrets", "pages..home", "", "pages\\home"] {
assert!(engine.render(name, &Context::new()).is_err(), "`{name}` should not resolve");
}
}
#[test]
fn a_cached_template_survives_the_file_changing_until_reload_is_on() {
let root = fixture("cache", &[("pages.home", "first")]);
let path = root.join(format!("pages/home.{EXTENSION}"));
let cached = Engine::new(&root);
assert_eq!(cached.render("pages.home", &Context::new()).unwrap(), "first");
std::fs::write(&path, "second").unwrap();
assert_eq!(cached.render("pages.home", &Context::new()).unwrap(), "first");
cached.clear_cache();
assert_eq!(cached.render("pages.home", &Context::new()).unwrap(), "second");
let live = Engine::new(&root).with_reload(true);
assert_eq!(live.render("pages.home", &Context::new()).unwrap(), "second");
std::fs::write(&path, "third").unwrap();
assert_eq!(live.render("pages.home", &Context::new()).unwrap(), "third");
}
#[test]
fn a_layout_loop_is_caught_instead_of_hanging() {
let root = fixture(
"loop",
&[
("a", "@extends(\"b\")"),
("b", "@extends(\"a\")"),
],
);
let message = Engine::new(root).render("a", &Context::new()).unwrap_err().to_string();
assert!(message.contains("cannot loop"), "{message}");
}
#[test]
fn exists_reports_what_is_on_disk() {
let engine = Engine::new(fixture("exists", &[("pages.home", "hi")]));
assert!(engine.exists("pages.home"));
assert!(!engine.exists("pages.gone"));
assert!(!engine.exists("../etc/passwd"));
}
}