use std::{
path::{Path, PathBuf},
sync::{Arc, RwLock},
time::{Instant, SystemTime},
};
use crate::{
compat::HashMap,
compiled::{self, CompiledInlineTemplate, Segment},
error::TemplateError,
frontmatter::{self, Frontmatter},
types::VarDecl,
value::Value,
};
#[derive(Debug, Clone)]
pub(crate) struct CachedInclude {
pub segments: Arc<[Segment]>,
pub declarations: Arc<[VarDecl]>,
pub base_dir: PathBuf,
pub consts: HashMap<String, Value>,
pub imported_consts: HashMap<String, Value>,
}
pub(crate) fn hash_source(source: &str) -> u64 {
crate::__private::fnv1a_hash(source.as_bytes())
}
#[derive(Debug, Clone)]
struct CacheEntry {
source_hash: u64,
last_modified: SystemTime,
last_accessed: Instant,
segments: Arc<[Segment]>,
declarations: Arc<[VarDecl]>,
inline_templates: Arc<HashMap<String, CompiledInlineTemplate>>,
consts: Arc<HashMap<String, crate::value::Value>>,
imported_consts: Arc<HashMap<String, crate::value::Value>>,
frontmatter: Frontmatter,
}
trait HasLastAccessed {
fn last_accessed(&self) -> Instant;
}
impl HasLastAccessed for CacheEntry {
fn last_accessed(&self) -> Instant {
self.last_accessed
}
}
pub(crate) trait IncludeResolver: Send + Sync {
fn resolve_include(
&self,
path: &Path,
env: &[(String, Value)],
) -> Result<CachedInclude, TemplateError>;
}
#[derive(Clone)]
pub struct TemplateCache<S: std::hash::BuildHasher = std::collections::hash_map::RandomState> {
templates: Arc<RwLock<HashMap<PathBuf, CacheEntry>>>,
includes: Arc<RwLock<HashMap<PathBuf, IncludeCacheEntry>>>,
hasher: S,
max_entries: Option<usize>,
}
impl<S: std::hash::BuildHasher> std::fmt::Debug for TemplateCache<S> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TemplateCache")
.field("template_count", &self.template_count())
.field("include_count", &self.include_count())
.finish()
}
}
#[derive(Debug, Clone)]
struct IncludeCacheEntry {
source_hash: u64,
env_hash: u64,
last_modified: SystemTime,
last_accessed: Instant,
cached: CachedInclude,
}
impl HasLastAccessed for IncludeCacheEntry {
fn last_accessed(&self) -> Instant {
self.last_accessed
}
}
impl Default for TemplateCache {
fn default() -> Self {
Self::new()
}
}
impl TemplateCache {
#[must_use]
pub fn new() -> Self {
Self {
templates: Arc::new(RwLock::new(HashMap::new())),
includes: Arc::new(RwLock::new(HashMap::new())),
hasher: std::collections::hash_map::RandomState::new(),
max_entries: None,
}
}
}
impl<S: std::hash::BuildHasher> TemplateCache<S> {
#[must_use]
pub fn with_hasher(hasher: S) -> Self {
Self {
templates: Arc::new(RwLock::new(HashMap::new())),
includes: Arc::new(RwLock::new(HashMap::new())),
hasher,
max_entries: None,
}
}
#[must_use]
pub fn with_max_entries(mut self, max: usize) -> Self {
self.max_entries = Some(max);
self
}
fn hash_content(&self, source: &str) -> u64 {
self.hasher.hash_one(source)
}
fn hash_env(&self, env: &[(String, Value)]) -> u64 {
use std::fmt::Write as _;
let mut buf = String::new();
for (name, value) in env {
write!(buf, "{name}={value:?}\u{1f}").expect("writing to a String is infallible");
}
self.hash_content(&buf)
}
pub fn load(&self, path: &Path) -> Result<crate::Template, TemplateError> {
self.load_inner(path, false).map(|(tmpl, _fm)| tmpl)
}
pub fn load_with_frontmatter(
&self,
path: &Path,
) -> Result<(crate::Template, Frontmatter), TemplateError> {
let (tmpl, fm) = self.load_inner(path, true)?;
let fm = fm.ok_or_else(|| {
TemplateError::syntax("internal error: frontmatter not returned by load_inner")
})?;
Ok((tmpl, fm))
}
fn build_template_from_entry(
entry: &mut CacheEntry,
base_dir: Option<PathBuf>,
need_frontmatter: bool,
) -> (crate::Template, Option<Frontmatter>) {
entry.last_accessed = Instant::now();
let tmpl = crate::Template::from_cached(crate::template::CachedTemplateData {
segments: entry.segments.clone(),
declared_variables: entry.declarations.clone(),
base_dir,
inline_templates: entry.inline_templates.clone(),
source_hash: entry.source_hash,
consts: entry.consts.clone(),
imported_consts: entry.imported_consts.clone(),
name: entry.frontmatter.name.clone(),
description: entry.frontmatter.description.clone(),
});
let fm = if need_frontmatter {
Some(entry.frontmatter.clone())
} else {
None
};
(tmpl, fm)
}
fn load_inner(
&self,
path: &Path,
need_frontmatter: bool,
) -> Result<(crate::Template, Option<Frontmatter>), TemplateError> {
let canonical = std::fs::canonicalize(path)?;
let file_mtime = std::fs::metadata(path)?
.modified()
.unwrap_or(SystemTime::UNIX_EPOCH);
let base_dir = path.parent().map(Path::to_path_buf);
{
let mut cache = self
.templates
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(entry) = cache.get_mut(&canonical)
&& entry.last_modified == file_mtime
{
return Ok(Self::build_template_from_entry(
entry,
base_dir,
need_frontmatter,
));
}
}
let source = std::fs::read_to_string(path)?;
let source_hash = self.hash_content(&source);
{
let mut cache = self
.templates
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(entry) = cache.get_mut(&canonical)
&& entry.source_hash == source_hash
{
entry.last_modified = file_mtime;
return Ok(Self::build_template_from_entry(
entry,
base_dir,
need_frontmatter,
));
}
}
let (fm, body) = frontmatter::parse_frontmatter(&source)?;
let body_str = body.to_string();
let (segments, inline_templates) = compiled::compile(&body_str, &fm.type_aliases)?;
let consts: HashMap<String, crate::value::Value> = fm
.consts
.iter()
.filter_map(|d| d.default_value.clone().map(|v| (d.name.clone(), v)))
.collect();
let consts = Arc::new(consts);
let imported_consts = Arc::new(fm.imported_consts.clone());
let entry = CacheEntry {
source_hash,
last_modified: file_mtime,
last_accessed: Instant::now(),
segments: Arc::from(segments),
declarations: Arc::from(fm.declarations.clone()),
inline_templates: Arc::new(inline_templates),
consts: consts.clone(),
imported_consts: imported_consts.clone(),
frontmatter: fm.clone(),
};
{
let mut cache = self
.templates
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner);
Self::evict_lru(&mut cache, self.max_entries);
cache.insert(canonical, entry.clone());
}
let tmpl = crate::Template::from_cached(crate::template::CachedTemplateData {
segments: entry.segments,
declared_variables: entry.declarations,
base_dir,
inline_templates: entry.inline_templates,
source_hash,
consts: entry.consts,
imported_consts: entry.imported_consts,
name: entry.frontmatter.name.clone(),
description: entry.frontmatter.description.clone(),
});
Ok((tmpl, Some(fm)))
}
fn resolve_include_impl(
&self,
include_path: &Path,
env: &[(String, Value)],
) -> Result<CachedInclude, TemplateError> {
let canonical = std::fs::canonicalize(include_path).map_err(|err| {
TemplateError::IncludeNotFound(format!("{}: {err}", include_path.display()))
})?;
let file_mtime = std::fs::metadata(include_path)
.and_then(|m| m.modified())
.unwrap_or(SystemTime::UNIX_EPOCH);
let env_hash = self.hash_env(env);
{
let mut cache = self
.includes
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(entry) = cache.get_mut(&canonical)
&& entry.last_modified == file_mtime
&& entry.env_hash == env_hash
{
entry.last_accessed = Instant::now();
return Ok(entry.cached.clone());
}
}
let source = std::fs::read_to_string(include_path).map_err(|err| {
TemplateError::IncludeNotFound(format!("{}: {err}", include_path.display()))
})?;
let source_hash = self.hash_content(&source);
{
let mut cache = self
.includes
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(entry) = cache.get_mut(&canonical)
&& entry.source_hash == source_hash
&& entry.env_hash == env_hash
{
entry.last_modified = file_mtime;
entry.last_accessed = Instant::now();
return Ok(entry.cached.clone());
}
}
let base_dir = include_path
.parent()
.unwrap_or_else(|| Path::new("."))
.to_path_buf();
let env_pairs: Vec<(&str, Value)> =
env.iter().map(|(k, v)| (k.as_str(), v.clone())).collect();
let (fm, body) =
frontmatter::parse_frontmatter_with_base_dir(&source, &base_dir, &env_pairs)?;
let (segments, _inline_templates) = compiled::compile(body, &fm.type_aliases)?;
let mut include_consts = HashMap::new();
for d in &fm.consts {
if let Some(v) = d.default_value.clone() {
include_consts.insert(d.name.clone(), v);
}
}
for d in &fm.env {
if let Some(ref v) = d.default_value {
include_consts
.entry(d.name.clone())
.or_insert_with(|| v.clone());
}
}
let cached = CachedInclude {
segments: Arc::from(segments),
declarations: Arc::from(fm.declarations),
base_dir,
consts: include_consts,
imported_consts: fm.imported_consts,
};
{
let mut cache = self
.includes
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner);
Self::evict_lru(&mut cache, self.max_entries);
cache.insert(
canonical,
IncludeCacheEntry {
source_hash,
env_hash,
last_modified: file_mtime,
last_accessed: Instant::now(),
cached: cached.clone(),
},
);
}
Ok(cached)
}
pub fn clear(&self) {
self.templates
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clear();
self.includes
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clear();
}
#[must_use]
pub fn template_count(&self) -> usize {
self.templates
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.len()
}
#[must_use]
pub fn include_count(&self) -> usize {
self.includes
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.len()
}
fn evict_lru<V: HasLastAccessed>(cache: &mut HashMap<PathBuf, V>, max_entries: Option<usize>) {
let Some(max) = max_entries else { return };
if cache.len() < max {
return;
}
let keep = (max * 3 / 4).max(1);
let evict_count = cache.len().saturating_sub(keep);
if evict_count == 0 {
return;
}
let mut entries: Vec<_> = cache
.iter()
.map(|(k, v)| (k.clone(), v.last_accessed()))
.collect();
entries.sort_unstable_by_key(|(_, t)| *t);
for (key, _) in entries.into_iter().take(evict_count) {
cache.remove(&key);
}
}
}
impl<S: std::hash::BuildHasher + Send + Sync> IncludeResolver for TemplateCache<S> {
fn resolve_include(
&self,
path: &Path,
env: &[(String, Value)],
) -> Result<CachedInclude, TemplateError> {
self.resolve_include_impl(path, env)
}
}
#[cfg(test)]
mod tests;