md_tmpl_core/cache.rs
1//! Template compilation cache for fast hot-reload.
2//!
3//! [`TemplateCache`] stores compiled templates keyed by `(path, content_hash)`.
4//! On reload it:
5//!
6//! 1. Stats the file to check its modification time (cheap syscall).
7//! 2. If the mtime matches the cached entry, returns the cached template —
8//! **zero file I/O beyond a stat**.
9//! 3. If the mtime changed, reads the file and hashes the source.
10//! 4. If the hash still matches (e.g. whitespace-only save), returns cached.
11//! 5. Otherwise, compiles the new source, stores it, and returns it.
12//!
13//! The cache also stores compiled **include** segments so that included
14//! templates are not re-read and re-compiled on every render.
15//!
16//! An optional LRU eviction limit ([`TemplateCache::with_max_entries`])
17//! prevents unbounded memory growth in long-running processes.
18
19use std::{
20 path::{Path, PathBuf},
21 sync::{Arc, RwLock},
22 time::{Instant, SystemTime},
23};
24
25use crate::{
26 compat::HashMap,
27 compiled::{self, CompiledInlineTemplate, Segment},
28 error::TemplateError,
29 frontmatter::{self, Frontmatter},
30 types::VarDecl,
31 value::Value,
32};
33
34/// A compiled include entry, ready for rendering without re-parsing.
35#[derive(Debug, Clone)]
36pub(crate) struct CachedInclude {
37 /// Pre-compiled segment instructions.
38 pub segments: Arc<[Segment]>,
39 /// Declared variables from the included template's frontmatter.
40 pub declarations: Arc<[VarDecl]>,
41 /// Base directory for resolving nested includes.
42 pub base_dir: PathBuf,
43 /// Local constants from the included template's frontmatter.
44 pub consts: HashMap<String, Value>,
45 /// Imported constants (e.g. from `imports:` in frontmatter).
46 pub imported_consts: HashMap<String, Value>,
47}
48
49/// Content-hash of a source string.
50///
51/// Uses the shared FNV-1a implementation for deterministic, cross-version
52/// stable hashing. Same source → same hash, different source → (very
53/// likely) different hash.
54pub(crate) fn hash_source(source: &str) -> u64 {
55 crate::__private::fnv1a_hash(source.as_bytes())
56}
57
58/// A cache entry for a compiled template.
59#[derive(Debug, Clone)]
60struct CacheEntry {
61 /// Hash of the raw source (including frontmatter).
62 source_hash: u64,
63 /// File modification time at the point the source was read.
64 last_modified: SystemTime,
65 /// Last time this entry was accessed (for LRU eviction).
66 last_accessed: Instant,
67 /// Pre-compiled segment tree.
68 segments: Arc<[Segment]>,
69 /// Frontmatter declarations.
70 declarations: Arc<[VarDecl]>,
71 /// Inline template definitions.
72 inline_templates: Arc<HashMap<String, CompiledInlineTemplate>>,
73 /// Local constants.
74 consts: Arc<HashMap<String, crate::value::Value>>,
75 /// Imported constants.
76 imported_consts: Arc<HashMap<String, crate::value::Value>>,
77 /// Full parsed frontmatter.
78 frontmatter: Frontmatter,
79}
80
81/// Internal trait for generic LRU eviction across different cache entry types.
82trait HasLastAccessed {
83 fn last_accessed(&self) -> Instant;
84}
85
86impl HasLastAccessed for CacheEntry {
87 fn last_accessed(&self) -> Instant {
88 self.last_accessed
89 }
90}
91
92/// Thread-safe template compilation cache.
93///
94/// Caches compiled templates and includes by path + content hash to avoid
95/// redundant parsing during hot-reload and rendering.
96///
97/// # Usage
98///
99/// ```rust
100/// use md_tmpl_core::TemplateCache;
101///
102/// let dir = tempfile::tempdir().unwrap();
103/// let path = dir.path().join("greeting.tmpl.md");
104/// std::fs::write(
105/// &path,
106/// r#"---
107/// params:
108/// - name = str
109/// ---
110/// Hi {{ name }}!"#,
111/// )
112/// .unwrap();
113///
114/// let cache = TemplateCache::new();
115///
116/// // First load — compiles from disk.
117/// let tmpl = cache.load(&path).unwrap();
118///
119/// // Second load of same unchanged file — returns cached, zero re-parsing.
120/// let tmpl2 = cache.load(&path).unwrap();
121/// assert_eq!(tmpl.source_hash(), tmpl2.source_hash());
122/// ```
123/// Internal trait for include resolution — erases the `BuildHasher`
124/// generic so that [`Scope`](crate::scope::Scope) doesn't need to carry it.
125pub(crate) trait IncludeResolver: Send + Sync {
126 /// Resolve (and cache) an included template file.
127 ///
128 /// `env` carries the compile-time environment values propagated from the
129 /// including template so that the include's `env:` frontmatter resolves to
130 /// the same values it would on the uncached render path. Because those
131 /// values are baked into the cached result (as injected constants), they
132 /// participate in cache invalidation.
133 fn resolve_include(
134 &self,
135 path: &Path,
136 env: &[(String, Value)],
137 ) -> Result<CachedInclude, TemplateError>;
138}
139
140/// A template compilation cache parameterised over a [`BuildHasher`](std::hash::BuildHasher)
141/// for content-addressed invalidation.
142///
143/// The default hasher is [`RandomState`](std::collections::hash_map::RandomState) (SipHash-1-3). Supply a
144/// different `BuildHasher` via [`with_hasher`](Self::with_hasher) if
145/// you need a faster or more collision-resistant hash.
146///
147/// # Examples
148///
149/// ```
150/// use md_tmpl_core::TemplateCache;
151///
152/// let dir = tempfile::tempdir().unwrap();
153/// let path = dir.path().join("greeting.tmpl.md");
154/// std::fs::write(
155/// &path,
156/// r#"---
157/// params:
158/// - name = str
159/// ---
160/// Hi {{ name }}!"#,
161/// )
162/// .unwrap();
163///
164/// let cache = TemplateCache::new();
165/// let tmpl = cache.load(&path).unwrap();
166///
167/// // Second load of same unchanged file — returns cached, zero re-parsing.
168/// let tmpl2 = cache.load(&path).unwrap();
169/// assert_eq!(tmpl.source_hash(), tmpl2.source_hash());
170/// ```
171#[derive(Clone)]
172pub struct TemplateCache<S: std::hash::BuildHasher = std::collections::hash_map::RandomState> {
173 /// Main template cache: canonical path → entry.
174 templates: Arc<RwLock<HashMap<PathBuf, CacheEntry>>>,
175 /// Include cache: canonical path → compiled include.
176 includes: Arc<RwLock<HashMap<PathBuf, IncludeCacheEntry>>>,
177 /// Hasher builder for content-addressed cache invalidation.
178 hasher: S,
179 /// Optional maximum number of entries per cache map. When set and
180 /// exceeded on insert, the least-recently-used entry is evicted.
181 max_entries: Option<usize>,
182}
183
184impl<S: std::hash::BuildHasher> std::fmt::Debug for TemplateCache<S> {
185 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
186 f.debug_struct("TemplateCache")
187 .field("template_count", &self.template_count())
188 .field("include_count", &self.include_count())
189 .finish()
190 }
191}
192
193/// Cache entry for an included template file.
194#[derive(Debug, Clone)]
195struct IncludeCacheEntry {
196 source_hash: u64,
197 /// Hash of the compile-time env values baked into `cached`. Two renders
198 /// with different env must not share a cached result, so this participates
199 /// in invalidation alongside `source_hash`/`last_modified`.
200 env_hash: u64,
201 /// File modification time at the point the source was read.
202 last_modified: SystemTime,
203 /// Last time this entry was accessed (for LRU eviction).
204 last_accessed: Instant,
205 cached: CachedInclude,
206}
207
208impl HasLastAccessed for IncludeCacheEntry {
209 fn last_accessed(&self) -> Instant {
210 self.last_accessed
211 }
212}
213
214impl Default for TemplateCache {
215 fn default() -> Self {
216 Self::new()
217 }
218}
219
220impl TemplateCache {
221 /// Create a new empty cache using the default content hasher (`SipHash-1-3`).
222 #[must_use]
223 pub fn new() -> Self {
224 Self {
225 templates: Arc::new(RwLock::new(HashMap::new())),
226 includes: Arc::new(RwLock::new(HashMap::new())),
227 hasher: std::collections::hash_map::RandomState::new(),
228 max_entries: None,
229 }
230 }
231}
232
233impl<S: std::hash::BuildHasher> TemplateCache<S> {
234 /// Create a new empty cache with a custom [`BuildHasher`](std::hash::BuildHasher).
235 ///
236 /// The default uses `RandomState` (SipHash-1-3). Supply a
237 /// different `BuildHasher` if you need stronger collision resistance
238 /// or faster hashing (e.g. `xxHash`, `FxHash`, `AHasher`).
239 ///
240 /// # Examples
241 ///
242 /// ```
243 /// use std::{collections::hash_map::DefaultHasher, hash::BuildHasherDefault};
244 ///
245 /// use md_tmpl_core::TemplateCache;
246 ///
247 /// let cache = TemplateCache::with_hasher(BuildHasherDefault::<DefaultHasher>::default());
248 ///
249 /// let dir = tempfile::tempdir().unwrap();
250 /// let path = dir.path().join("test.tmpl.md");
251 /// std::fs::write(
252 /// &path,
253 /// r#"---
254 /// params: [x = str]
255 /// ---
256 /// {{ x }}"#,
257 /// )
258 /// .unwrap();
259 ///
260 /// let tmpl = cache.load(&path).unwrap();
261 /// let mut ctx = md_tmpl_core::Context::new();
262 /// ctx.set("x", "works");
263 /// assert_eq!(tmpl.render_ctx(&ctx).unwrap(), "works");
264 /// ```
265 #[must_use]
266 pub fn with_hasher(hasher: S) -> Self {
267 Self {
268 templates: Arc::new(RwLock::new(HashMap::new())),
269 includes: Arc::new(RwLock::new(HashMap::new())),
270 hasher,
271 max_entries: None,
272 }
273 }
274
275 /// Set the maximum number of entries per cache map.
276 ///
277 /// When a new entry is inserted and the cache exceeds this limit,
278 /// the least-recently-used entry is evicted. `None` (the default)
279 /// disables eviction.
280 ///
281 /// # Examples
282 ///
283 /// ```
284 /// use md_tmpl_core::TemplateCache;
285 ///
286 /// let cache = TemplateCache::new().with_max_entries(128);
287 /// ```
288 #[must_use]
289 pub fn with_max_entries(mut self, max: usize) -> Self {
290 self.max_entries = Some(max);
291 self
292 }
293
294 /// Hash a source string using this cache's hasher.
295 fn hash_content(&self, source: &str) -> u64 {
296 self.hasher.hash_one(source)
297 }
298
299 /// Hash the compile-time env values for include-cache invalidation.
300 ///
301 /// [`Value`] intentionally does not implement [`Hash`] (it can contain
302 /// floats), so we hash a deterministic textual rendering instead. The env
303 /// is tiny (a handful of scalar entries) and only hashed on include
304 /// resolution, so the formatting cost is negligible.
305 fn hash_env(&self, env: &[(String, Value)]) -> u64 {
306 use std::fmt::Write as _;
307 let mut buf = String::new();
308 for (name, value) in env {
309 // Writing to a `String` via `fmt::Write` cannot fail; surface the
310 // impossible error rather than silently discarding the Result.
311 write!(buf, "{name}={value:?}\u{1f}").expect("writing to a String is infallible");
312 }
313 self.hash_content(&buf)
314 }
315
316 /// Load a template from file, using the cache if the source is unchanged.
317 ///
318 /// # Errors
319 ///
320 /// Returns [`TemplateError::Io`] if the file cannot be read, or
321 /// [`TemplateError::Syntax`] if compilation fails.
322 pub fn load(&self, path: &Path) -> Result<crate::Template, TemplateError> {
323 self.load_inner(path, false).map(|(tmpl, _fm)| tmpl)
324 }
325
326 /// Load a template and return frontmatter too, using the cache.
327 ///
328 /// # Errors
329 ///
330 /// Returns [`TemplateError`] on I/O or syntax errors.
331 pub fn load_with_frontmatter(
332 &self,
333 path: &Path,
334 ) -> Result<(crate::Template, Frontmatter), TemplateError> {
335 let (tmpl, fm) = self.load_inner(path, true)?;
336 // `load_inner(_, true)` always returns `Some(fm)`.
337 let fm = fm.ok_or_else(|| {
338 TemplateError::syntax("internal error: frontmatter not returned by load_inner")
339 })?;
340 Ok((tmpl, fm))
341 }
342
343 fn build_template_from_entry(
344 entry: &mut CacheEntry,
345 base_dir: Option<PathBuf>,
346 need_frontmatter: bool,
347 ) -> (crate::Template, Option<Frontmatter>) {
348 entry.last_accessed = Instant::now();
349 let tmpl = crate::Template::from_cached(crate::template::CachedTemplateData {
350 segments: entry.segments.clone(),
351 declared_variables: entry.declarations.clone(),
352 base_dir,
353 inline_templates: entry.inline_templates.clone(),
354 source_hash: entry.source_hash,
355 consts: entry.consts.clone(),
356 imported_consts: entry.imported_consts.clone(),
357 name: entry.frontmatter.name.clone(),
358 description: entry.frontmatter.description.clone(),
359 });
360 let fm = if need_frontmatter {
361 Some(entry.frontmatter.clone())
362 } else {
363 None
364 };
365 (tmpl, fm)
366 }
367
368 /// Shared implementation: stat → mtime check → (read → hash) → cache → compile → store.
369 ///
370 /// When `need_frontmatter` is false, avoids cloning the cached frontmatter.
371 fn load_inner(
372 &self,
373 path: &Path,
374 need_frontmatter: bool,
375 ) -> Result<(crate::Template, Option<Frontmatter>), TemplateError> {
376 let canonical = std::fs::canonicalize(path)?;
377 let file_mtime = std::fs::metadata(path)?
378 .modified()
379 .unwrap_or(SystemTime::UNIX_EPOCH);
380 let base_dir = path.parent().map(Path::to_path_buf);
381
382 // Fast path: if mtime matches the cached entry, skip reading the file entirely.
383 {
384 let mut cache = self
385 .templates
386 .write()
387 .unwrap_or_else(std::sync::PoisonError::into_inner);
388 if let Some(entry) = cache.get_mut(&canonical)
389 && entry.last_modified == file_mtime
390 {
391 return Ok(Self::build_template_from_entry(
392 entry,
393 base_dir,
394 need_frontmatter,
395 ));
396 }
397 }
398
399 // Mtime changed (or first load) — read file and hash.
400 let source = std::fs::read_to_string(path)?;
401 let source_hash = self.hash_content(&source);
402
403 // Check if content hash still matches despite mtime change (e.g. whitespace-only save).
404 {
405 let mut cache = self
406 .templates
407 .write()
408 .unwrap_or_else(std::sync::PoisonError::into_inner);
409 if let Some(entry) = cache.get_mut(&canonical)
410 && entry.source_hash == source_hash
411 {
412 entry.last_modified = file_mtime;
413 return Ok(Self::build_template_from_entry(
414 entry,
415 base_dir,
416 need_frontmatter,
417 ));
418 }
419 }
420
421 // Cache miss — compile.
422 let (fm, body) = frontmatter::parse_frontmatter(&source)?;
423 let body_str = body.to_string();
424 let (segments, inline_templates) = compiled::compile(&body_str, &fm.type_aliases)?;
425
426 let consts: HashMap<String, crate::value::Value> = fm
427 .consts
428 .iter()
429 .filter_map(|d| d.default_value.clone().map(|v| (d.name.clone(), v)))
430 .collect();
431 let consts = Arc::new(consts);
432 let imported_consts = Arc::new(fm.imported_consts.clone());
433
434 let entry = CacheEntry {
435 source_hash,
436 last_modified: file_mtime,
437 last_accessed: Instant::now(),
438 segments: Arc::from(segments),
439 declarations: Arc::from(fm.declarations.clone()),
440 inline_templates: Arc::new(inline_templates),
441 consts: consts.clone(),
442 imported_consts: imported_consts.clone(),
443 frontmatter: fm.clone(),
444 };
445
446 {
447 let mut cache = self
448 .templates
449 .write()
450 .unwrap_or_else(std::sync::PoisonError::into_inner);
451 Self::evict_lru(&mut cache, self.max_entries);
452 cache.insert(canonical, entry.clone());
453 }
454
455 let tmpl = crate::Template::from_cached(crate::template::CachedTemplateData {
456 segments: entry.segments,
457 declared_variables: entry.declarations,
458 base_dir,
459 inline_templates: entry.inline_templates,
460 source_hash,
461 consts: entry.consts,
462 imported_consts: entry.imported_consts,
463 name: entry.frontmatter.name.clone(),
464 description: entry.frontmatter.description.clone(),
465 });
466 Ok((tmpl, Some(fm)))
467 }
468
469 /// Resolve an include from cache or compile it from disk.
470 ///
471 /// Called during rendering — avoids re-reading and re-compiling
472 /// included template files that haven't changed.
473 fn resolve_include_impl(
474 &self,
475 include_path: &Path,
476 env: &[(String, Value)],
477 ) -> Result<CachedInclude, TemplateError> {
478 let canonical = std::fs::canonicalize(include_path).map_err(|err| {
479 TemplateError::IncludeNotFound(format!("{}: {err}", include_path.display()))
480 })?;
481
482 let file_mtime = std::fs::metadata(include_path)
483 .and_then(|m| m.modified())
484 .unwrap_or(SystemTime::UNIX_EPOCH);
485
486 // Env values are baked into the cached result, so a change in env must
487 // invalidate the entry even when the file itself is untouched.
488 let env_hash = self.hash_env(env);
489
490 // Fast path: mtime + env match → skip reading the file.
491 {
492 let mut cache = self
493 .includes
494 .write()
495 .unwrap_or_else(std::sync::PoisonError::into_inner);
496 if let Some(entry) = cache.get_mut(&canonical)
497 && entry.last_modified == file_mtime
498 && entry.env_hash == env_hash
499 {
500 entry.last_accessed = Instant::now();
501 return Ok(entry.cached.clone());
502 }
503 }
504
505 // Mtime changed — read and hash.
506 let source = std::fs::read_to_string(include_path).map_err(|err| {
507 TemplateError::IncludeNotFound(format!("{}: {err}", include_path.display()))
508 })?;
509 let source_hash = self.hash_content(&source);
510
511 // Content hash still matches (and same env) despite mtime change?
512 {
513 let mut cache = self
514 .includes
515 .write()
516 .unwrap_or_else(std::sync::PoisonError::into_inner);
517 if let Some(entry) = cache.get_mut(&canonical)
518 && entry.source_hash == source_hash
519 && entry.env_hash == env_hash
520 {
521 entry.last_modified = file_mtime;
522 entry.last_accessed = Instant::now();
523 return Ok(entry.cached.clone());
524 }
525 }
526
527 // Cache miss — compile.
528 let base_dir = include_path
529 .parent()
530 .unwrap_or_else(|| Path::new("."))
531 .to_path_buf();
532 // Propagate compile-time env so the include's `env:` frontmatter
533 // resolves to the same values as on the uncached render path.
534 let env_pairs: Vec<(&str, Value)> =
535 env.iter().map(|(k, v)| (k.as_str(), v.clone())).collect();
536 let (fm, body) =
537 frontmatter::parse_frontmatter_with_base_dir(&source, &base_dir, &env_pairs)?;
538 let (segments, _inline_templates) = compiled::compile(body, &fm.type_aliases)?;
539
540 let mut include_consts = HashMap::new();
541 for d in &fm.consts {
542 if let Some(v) = d.default_value.clone() {
543 include_consts.insert(d.name.clone(), v);
544 }
545 }
546 // Inject resolved env values as constants.
547 for d in &fm.env {
548 if let Some(ref v) = d.default_value {
549 include_consts
550 .entry(d.name.clone())
551 .or_insert_with(|| v.clone());
552 }
553 }
554
555 let cached = CachedInclude {
556 segments: Arc::from(segments),
557 declarations: Arc::from(fm.declarations),
558 base_dir,
559 consts: include_consts,
560 imported_consts: fm.imported_consts,
561 };
562
563 {
564 let mut cache = self
565 .includes
566 .write()
567 .unwrap_or_else(std::sync::PoisonError::into_inner);
568 Self::evict_lru(&mut cache, self.max_entries);
569 cache.insert(
570 canonical,
571 IncludeCacheEntry {
572 source_hash,
573 env_hash,
574 last_modified: file_mtime,
575 last_accessed: Instant::now(),
576 cached: cached.clone(),
577 },
578 );
579 }
580
581 Ok(cached)
582 }
583
584 /// Invalidate all cached entries (e.g. after a bulk file update).
585 pub fn clear(&self) {
586 self.templates
587 .write()
588 .unwrap_or_else(std::sync::PoisonError::into_inner)
589 .clear();
590 self.includes
591 .write()
592 .unwrap_or_else(std::sync::PoisonError::into_inner)
593 .clear();
594 }
595
596 /// Number of cached main templates.
597 #[must_use]
598 pub fn template_count(&self) -> usize {
599 self.templates
600 .read()
601 .unwrap_or_else(std::sync::PoisonError::into_inner)
602 .len()
603 }
604
605 /// Number of cached include templates.
606 #[must_use]
607 pub fn include_count(&self) -> usize {
608 self.includes
609 .read()
610 .unwrap_or_else(std::sync::PoisonError::into_inner)
611 .len()
612 }
613
614 /// Evict the oldest entries when the cache exceeds `max_entries`.
615 ///
616 /// Amortised: evicts down to 75% capacity in a single pass, so the
617 /// O(n·log n) sort runs infrequently rather than on every insert.
618 fn evict_lru<V: HasLastAccessed>(cache: &mut HashMap<PathBuf, V>, max_entries: Option<usize>) {
619 let Some(max) = max_entries else { return };
620 if cache.len() < max {
621 return;
622 }
623 // Target: keep 75% of max (at least 1).
624 let keep = (max * 3 / 4).max(1);
625 let evict_count = cache.len().saturating_sub(keep);
626 if evict_count == 0 {
627 return;
628 }
629 // Collect and sort by last_accessed (oldest first).
630 let mut entries: Vec<_> = cache
631 .iter()
632 .map(|(k, v)| (k.clone(), v.last_accessed()))
633 .collect();
634 entries.sort_unstable_by_key(|(_, t)| *t);
635 // Remove the oldest `evict_count` entries.
636 for (key, _) in entries.into_iter().take(evict_count) {
637 cache.remove(&key);
638 }
639 }
640}
641
642impl<S: std::hash::BuildHasher + Send + Sync> IncludeResolver for TemplateCache<S> {
643 fn resolve_include(
644 &self,
645 path: &Path,
646 env: &[(String, Value)],
647 ) -> Result<CachedInclude, TemplateError> {
648 self.resolve_include_impl(path, env)
649 }
650}
651
652#[cfg(test)]
653mod tests;