1use 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};
32
33#[derive(Debug, Clone)]
35pub(crate) struct CachedInclude {
36 pub segments: Arc<[Segment]>,
38 pub declarations: Arc<[VarDecl]>,
40 pub base_dir: PathBuf,
42}
43
44pub(crate) fn hash_source(source: &str) -> u64 {
50 crate::__private::fnv1a_hash(source.as_bytes())
51}
52
53#[derive(Debug, Clone)]
55struct CacheEntry {
56 source_hash: u64,
58 last_modified: SystemTime,
60 last_accessed: Instant,
62 segments: Arc<[Segment]>,
64 declarations: Arc<[VarDecl]>,
66 inline_templates: Arc<HashMap<String, CompiledInlineTemplate>>,
68 consts: Arc<HashMap<String, crate::value::Value>>,
70 imported_consts: Arc<HashMap<String, crate::value::Value>>,
72 frontmatter: Frontmatter,
74}
75
76trait HasLastAccessed {
78 fn last_accessed(&self) -> Instant;
79}
80
81impl HasLastAccessed for CacheEntry {
82 fn last_accessed(&self) -> Instant {
83 self.last_accessed
84 }
85}
86
87pub(crate) trait IncludeResolver: Send + Sync {
121 fn resolve_include(&self, path: &Path) -> Result<CachedInclude, TemplateError>;
122}
123
124#[derive(Clone)]
156pub struct TemplateCache<S: std::hash::BuildHasher = std::collections::hash_map::RandomState> {
157 templates: Arc<RwLock<HashMap<PathBuf, CacheEntry>>>,
159 includes: Arc<RwLock<HashMap<PathBuf, IncludeCacheEntry>>>,
161 hasher: S,
163 max_entries: Option<usize>,
166}
167
168impl<S: std::hash::BuildHasher> std::fmt::Debug for TemplateCache<S> {
169 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
170 f.debug_struct("TemplateCache")
171 .field("template_count", &self.template_count())
172 .field("include_count", &self.include_count())
173 .finish()
174 }
175}
176
177#[derive(Debug, Clone)]
179struct IncludeCacheEntry {
180 source_hash: u64,
181 last_modified: SystemTime,
183 last_accessed: Instant,
185 cached: CachedInclude,
186}
187
188impl HasLastAccessed for IncludeCacheEntry {
189 fn last_accessed(&self) -> Instant {
190 self.last_accessed
191 }
192}
193
194impl Default for TemplateCache {
195 fn default() -> Self {
196 Self::new()
197 }
198}
199
200impl TemplateCache {
201 #[must_use]
203 pub fn new() -> Self {
204 Self {
205 templates: Arc::new(RwLock::new(HashMap::new())),
206 includes: Arc::new(RwLock::new(HashMap::new())),
207 hasher: std::collections::hash_map::RandomState::new(),
208 max_entries: None,
209 }
210 }
211}
212
213impl<S: std::hash::BuildHasher> TemplateCache<S> {
214 #[must_use]
246 pub fn with_hasher(hasher: S) -> Self {
247 Self {
248 templates: Arc::new(RwLock::new(HashMap::new())),
249 includes: Arc::new(RwLock::new(HashMap::new())),
250 hasher,
251 max_entries: None,
252 }
253 }
254
255 #[must_use]
269 pub fn with_max_entries(mut self, max: usize) -> Self {
270 self.max_entries = Some(max);
271 self
272 }
273
274 fn hash_content(&self, source: &str) -> u64 {
276 self.hasher.hash_one(source)
277 }
278
279 pub fn load(&self, path: &Path) -> Result<crate::Template, TemplateError> {
286 self.load_inner(path, false).map(|(tmpl, _fm)| tmpl)
287 }
288
289 pub fn load_with_frontmatter(
295 &self,
296 path: &Path,
297 ) -> Result<(crate::Template, Frontmatter), TemplateError> {
298 let (tmpl, fm) = self.load_inner(path, true)?;
299 let fm = fm.ok_or_else(|| {
301 TemplateError::syntax("internal error: frontmatter not returned by load_inner")
302 })?;
303 Ok((tmpl, fm))
304 }
305
306 fn build_template_from_entry(
307 entry: &mut CacheEntry,
308 base_dir: Option<PathBuf>,
309 need_frontmatter: bool,
310 ) -> (crate::Template, Option<Frontmatter>) {
311 entry.last_accessed = Instant::now();
312 let tmpl = crate::Template::from_cached(crate::template::CachedTemplateData {
313 segments: entry.segments.clone(),
314 declared_variables: entry.declarations.clone(),
315 base_dir,
316 inline_templates: entry.inline_templates.clone(),
317 source_hash: entry.source_hash,
318 consts: entry.consts.clone(),
319 imported_consts: entry.imported_consts.clone(),
320 name: entry.frontmatter.name.clone(),
321 description: entry.frontmatter.description.clone(),
322 });
323 let fm = if need_frontmatter {
324 Some(entry.frontmatter.clone())
325 } else {
326 None
327 };
328 (tmpl, fm)
329 }
330
331 fn load_inner(
335 &self,
336 path: &Path,
337 need_frontmatter: bool,
338 ) -> Result<(crate::Template, Option<Frontmatter>), TemplateError> {
339 let canonical = std::fs::canonicalize(path)?;
340 let file_mtime = std::fs::metadata(path)?
341 .modified()
342 .unwrap_or(SystemTime::UNIX_EPOCH);
343 let base_dir = path.parent().map(Path::to_path_buf);
344
345 {
347 let mut cache = self
348 .templates
349 .write()
350 .unwrap_or_else(std::sync::PoisonError::into_inner);
351 if let Some(entry) = cache.get_mut(&canonical)
352 && entry.last_modified == file_mtime
353 {
354 return Ok(Self::build_template_from_entry(
355 entry,
356 base_dir,
357 need_frontmatter,
358 ));
359 }
360 }
361
362 let source = std::fs::read_to_string(path)?;
364 let source_hash = self.hash_content(&source);
365
366 {
368 let mut cache = self
369 .templates
370 .write()
371 .unwrap_or_else(std::sync::PoisonError::into_inner);
372 if let Some(entry) = cache.get_mut(&canonical)
373 && entry.source_hash == source_hash
374 {
375 entry.last_modified = file_mtime;
376 return Ok(Self::build_template_from_entry(
377 entry,
378 base_dir,
379 need_frontmatter,
380 ));
381 }
382 }
383
384 let (fm, body) = frontmatter::parse_frontmatter(&source)?;
386 let body_str = body.to_string();
387 let (segments, inline_templates) = compiled::compile(&body_str, &fm.type_aliases)?;
388
389 let consts: HashMap<String, crate::value::Value> = fm
390 .consts
391 .iter()
392 .filter_map(|d| d.default_value.clone().map(|v| (d.name.clone(), v)))
393 .collect();
394 let consts = Arc::new(consts);
395 let imported_consts = Arc::new(fm.imported_consts.clone());
396
397 let entry = CacheEntry {
398 source_hash,
399 last_modified: file_mtime,
400 last_accessed: Instant::now(),
401 segments: Arc::from(segments),
402 declarations: Arc::from(fm.declarations.clone()),
403 inline_templates: Arc::new(inline_templates),
404 consts: consts.clone(),
405 imported_consts: imported_consts.clone(),
406 frontmatter: fm.clone(),
407 };
408
409 {
410 let mut cache = self
411 .templates
412 .write()
413 .unwrap_or_else(std::sync::PoisonError::into_inner);
414 Self::evict_lru(&mut cache, self.max_entries);
415 cache.insert(canonical, entry.clone());
416 }
417
418 let tmpl = crate::Template::from_cached(crate::template::CachedTemplateData {
419 segments: entry.segments,
420 declared_variables: entry.declarations,
421 base_dir,
422 inline_templates: entry.inline_templates,
423 source_hash,
424 consts: entry.consts,
425 imported_consts: entry.imported_consts,
426 name: entry.frontmatter.name.clone(),
427 description: entry.frontmatter.description.clone(),
428 });
429 Ok((tmpl, Some(fm)))
430 }
431
432 fn resolve_include_impl(&self, include_path: &Path) -> Result<CachedInclude, TemplateError> {
437 let canonical = std::fs::canonicalize(include_path).map_err(|err| {
438 TemplateError::IncludeNotFound(format!("{}: {err}", include_path.display()))
439 })?;
440
441 let file_mtime = std::fs::metadata(include_path)
442 .and_then(|m| m.modified())
443 .unwrap_or(SystemTime::UNIX_EPOCH);
444
445 {
447 let mut cache = self
448 .includes
449 .write()
450 .unwrap_or_else(std::sync::PoisonError::into_inner);
451 if let Some(entry) = cache.get_mut(&canonical)
452 && entry.last_modified == file_mtime
453 {
454 entry.last_accessed = Instant::now();
455 return Ok(entry.cached.clone());
456 }
457 }
458
459 let source = std::fs::read_to_string(include_path).map_err(|err| {
461 TemplateError::IncludeNotFound(format!("{}: {err}", include_path.display()))
462 })?;
463 let source_hash = self.hash_content(&source);
464
465 {
467 let mut cache = self
468 .includes
469 .write()
470 .unwrap_or_else(std::sync::PoisonError::into_inner);
471 if let Some(entry) = cache.get_mut(&canonical)
472 && entry.source_hash == source_hash
473 {
474 entry.last_modified = file_mtime;
475 entry.last_accessed = Instant::now();
476 return Ok(entry.cached.clone());
477 }
478 }
479
480 let (fm, body) = frontmatter::parse_frontmatter(&source)?;
482 let (segments, _inline_templates) = compiled::compile(body, &fm.type_aliases)?;
483 let base_dir = include_path
484 .parent()
485 .unwrap_or_else(|| Path::new("."))
486 .to_path_buf();
487
488 let cached = CachedInclude {
489 segments: Arc::from(segments),
490 declarations: Arc::from(fm.declarations),
491 base_dir,
492 };
493
494 {
495 let mut cache = self
496 .includes
497 .write()
498 .unwrap_or_else(std::sync::PoisonError::into_inner);
499 Self::evict_lru(&mut cache, self.max_entries);
500 cache.insert(
501 canonical,
502 IncludeCacheEntry {
503 source_hash,
504 last_modified: file_mtime,
505 last_accessed: Instant::now(),
506 cached: cached.clone(),
507 },
508 );
509 }
510
511 Ok(cached)
512 }
513
514 pub fn clear(&self) {
516 self.templates
517 .write()
518 .unwrap_or_else(std::sync::PoisonError::into_inner)
519 .clear();
520 self.includes
521 .write()
522 .unwrap_or_else(std::sync::PoisonError::into_inner)
523 .clear();
524 }
525
526 #[must_use]
528 pub fn template_count(&self) -> usize {
529 self.templates
530 .read()
531 .unwrap_or_else(std::sync::PoisonError::into_inner)
532 .len()
533 }
534
535 #[must_use]
537 pub fn include_count(&self) -> usize {
538 self.includes
539 .read()
540 .unwrap_or_else(std::sync::PoisonError::into_inner)
541 .len()
542 }
543
544 fn evict_lru<V: HasLastAccessed>(cache: &mut HashMap<PathBuf, V>, max_entries: Option<usize>) {
549 let Some(max) = max_entries else { return };
550 if cache.len() < max {
551 return;
552 }
553 let keep = (max * 3 / 4).max(1);
555 let evict_count = cache.len().saturating_sub(keep);
556 if evict_count == 0 {
557 return;
558 }
559 let mut entries: Vec<_> = cache
561 .iter()
562 .map(|(k, v)| (k.clone(), v.last_accessed()))
563 .collect();
564 entries.sort_unstable_by_key(|(_, t)| *t);
565 for (key, _) in entries.into_iter().take(evict_count) {
567 cache.remove(&key);
568 }
569 }
570}
571
572impl<S: std::hash::BuildHasher + Send + Sync> IncludeResolver for TemplateCache<S> {
573 fn resolve_include(&self, path: &Path) -> Result<CachedInclude, TemplateError> {
574 self.resolve_include_impl(path)
575 }
576}
577
578#[cfg(test)]
579mod tests {
580 use std::sync::atomic::AtomicUsize;
581
582 use super::*;
583
584 #[test]
585 fn cache_returns_same_template_for_unchanged_file() {
586 let dir = tempfile::tempdir().unwrap();
587 let path = dir.path().join("test.tmpl.md");
588 std::fs::write(
589 &path,
590 r"---
591params: [name = str]
592---
593Hello {{ name }}!",
594 )
595 .unwrap();
596
597 let cache = TemplateCache::new();
598 let t1 = cache.load(&path).unwrap();
599 let t2 = cache.load(&path).unwrap();
600
601 assert_eq!(t1.source_hash(), t2.source_hash());
602 assert_eq!(cache.template_count(), 1);
603 }
604
605 #[test]
606 fn cache_recompiles_on_file_change() {
607 let dir = tempfile::tempdir().unwrap();
608 let path = dir.path().join("test.tmpl.md");
609 std::fs::write(
610 &path,
611 r"---
612params: [name = str]
613---
614Hello {{ name }}!",
615 )
616 .unwrap();
617
618 let cache = TemplateCache::new();
619 let t1 = cache.load(&path).unwrap();
620
621 std::fs::write(
622 &path,
623 r"---
624params: [name = str]
625---
626Goodbye {{ name }}!",
627 )
628 .unwrap();
629 let t2 = cache.load(&path).unwrap();
630
631 assert_ne!(t1.source_hash(), t2.source_hash());
632 assert_eq!(cache.template_count(), 1); }
634
635 #[test]
636 fn cache_clear_invalidates_all() {
637 let dir = tempfile::tempdir().unwrap();
638 let path = dir.path().join("test.tmpl.md");
639 std::fs::write(
640 &path,
641 r"---
642params: []
643---
644Hi",
645 )
646 .unwrap();
647
648 let cache = TemplateCache::new();
649 cache.load(&path).unwrap();
650 assert_eq!(cache.template_count(), 1);
651
652 cache.clear();
653 assert_eq!(cache.template_count(), 0);
654 }
655
656 #[test]
657 fn include_cache_avoids_recompile() {
658 let dir = tempfile::tempdir().unwrap();
659 let path = dir.path().join("header.tmpl.md");
660 std::fs::write(
661 &path,
662 r"---
663name: header
664params: []
665---
666# Header",
667 )
668 .unwrap();
669
670 let cache = TemplateCache::new();
671 let c1 = cache.resolve_include(&path).unwrap();
672 let c2 = cache.resolve_include(&path).unwrap();
673
674 assert_eq!(c1.segments.len(), c2.segments.len());
675 assert_eq!(cache.include_count(), 1);
676 }
677
678 #[test]
679 fn load_with_frontmatter_caches() {
680 let dir = tempfile::tempdir().unwrap();
681 let path = dir.path().join("fm.tmpl.md");
682 std::fs::write(
683 &path,
684 r"---
685name: test
686params: [x = str]
687---
688{{ x }}",
689 )
690 .unwrap();
691
692 let cache = TemplateCache::new();
693 let (t1, fm1) = cache.load_with_frontmatter(&path).unwrap();
694 let (t2, fm2) = cache.load_with_frontmatter(&path).unwrap();
695
696 assert_eq!(t1.source_hash(), t2.source_hash());
697 assert_eq!(fm1.name, fm2.name);
698 assert_eq!(cache.template_count(), 1);
699 }
700
701 #[test]
702 fn render_cached_with_include() {
703 let dir = tempfile::tempdir().unwrap();
704
705 std::fs::write(
707 dir.path().join("header.tmpl.md"),
708 r"---
709name: header
710params: [title = str]
711---
712# {{ title }}",
713 )
714 .unwrap();
715 let main_path = dir.path().join("main.tmpl.md");
716 std::fs::write(
717 &main_path,
718 r"---
719params: [title = str]
720---
721> {% include [header](./header.tmpl.md) with title=title %}
722
723Body",
724 )
725 .unwrap();
726
727 let cache = TemplateCache::new();
728 let tmpl = cache.load(&main_path).unwrap();
729
730 let mut ctx = crate::Context::new();
731 ctx.set("title", "Hello");
732
733 let output1 = tmpl.render_ctx_cached(&ctx, &cache).unwrap();
735 assert!(output1.contains("# Hello"));
736 assert!(output1.contains("Body"));
737 assert_eq!(cache.include_count(), 1);
738
739 let output2 = tmpl.render_ctx_cached(&ctx, &cache).unwrap();
741 assert_eq!(output1, output2);
742 assert_eq!(cache.include_count(), 1); }
744
745 #[test]
746 fn with_hasher_custom_builder() {
747 use std::hash::BuildHasherDefault;
748
749 let cache = TemplateCache::with_hasher(BuildHasherDefault::<
751 std::collections::hash_map::DefaultHasher,
752 >::default());
753
754 let dir = tempfile::tempdir().unwrap();
755 let path = dir.path().join("custom.tmpl.md");
756 std::fs::write(
757 &path,
758 r"---
759params: [x = str]
760---
761{{ x }}",
762 )
763 .unwrap();
764
765 let tmpl = cache.load(&path).unwrap();
766 let mut ctx = crate::Context::new();
767 ctx.set("x", "works");
768 assert_eq!(tmpl.render_ctx(&ctx).unwrap(), "works");
769
770 let tmpl2 = cache.load(&path).unwrap();
772 assert_eq!(tmpl.source_hash(), tmpl2.source_hash());
773 }
774
775 #[test]
776 fn eviction_removes_lru_entry() {
777 let cache = TemplateCache::new().with_max_entries(2);
778 let dir = tempfile::tempdir().unwrap();
779
780 let path_a = dir.path().join("a.tmpl.md");
781 let path_b = dir.path().join("b.tmpl.md");
782 let path_c = dir.path().join("c.tmpl.md");
783 std::fs::write(
784 &path_a,
785 "\
786---
787
788params: []
789---
790A",
791 )
792 .unwrap();
793 std::fs::write(
794 &path_b,
795 "\
796---
797
798params: []
799---
800B",
801 )
802 .unwrap();
803 std::fs::write(
804 &path_c,
805 "\
806---
807
808params: []
809---
810C",
811 )
812 .unwrap();
813
814 cache.load(&path_a).unwrap();
815 cache.load(&path_b).unwrap();
816 assert_eq!(cache.template_count(), 2);
817
818 cache.load(&path_c).unwrap();
820 assert_eq!(cache.template_count(), 2);
821 }
822
823 #[test]
824 fn no_eviction_when_max_entries_is_none() {
825 let cache = TemplateCache::new();
826 let dir = tempfile::tempdir().unwrap();
827
828 for i in 0..10 {
829 let path = dir.path().join(format!("{i}.tmpl.md"));
830 std::fs::write(
831 &path,
832 format!(
833 "---
834params: []
835---
836{i}"
837 ),
838 )
839 .unwrap();
840 cache.load(&path).unwrap();
841 }
842 assert_eq!(cache.template_count(), 10);
843 }
844
845 fn run_loader_thread(
847 cache: &TemplateCache,
848 path: &std::path::Path,
849 successful_loads: &AtomicUsize,
850 ) {
851 use std::sync::atomic::Ordering;
852 if let Ok(tmpl) = cache.load(path) {
853 assert!(
855 !tmpl.declarations().is_empty(),
856 "loaded template must have declarations"
857 );
858 successful_loads.fetch_add(1, Ordering::Relaxed);
859 }
860 }
862
863 fn run_renderer_thread(
865 cache: &TemplateCache,
866 path: &std::path::Path,
867 expected_idx: usize,
868 successful_renders: &AtomicUsize,
869 ) {
870 use std::sync::atomic::Ordering;
871 if let Ok(tmpl) = cache.load(path) {
872 let mut ctx = crate::Context::new();
873 ctx.set("x", "hello");
874 if let Ok(output) = tmpl.render_ctx_cached(&ctx, cache) {
875 assert!(
876 output.contains("hello"),
877 "rendered output must contain 'hello', got: {output}"
878 );
879 assert!(
880 output.contains(&format!("template{expected_idx}")),
881 "rendered output must contain template index, got: {output}"
882 );
883 successful_renders.fetch_add(1, Ordering::Relaxed);
884 }
885 }
886 }
887
888 fn run_clear_thread(
890 cache: &TemplateCache,
891 path: &std::path::Path,
892 round: usize,
893 successful_loads: &AtomicUsize,
894 ) {
895 use std::sync::atomic::Ordering;
896 if round % 5 == 0 {
897 cache.clear();
898 }
899 if let Ok(tmpl) = cache.load(path) {
901 assert!(
902 !tmpl.declarations().is_empty(),
903 "reloaded template must have declarations"
904 );
905 successful_loads.fetch_add(1, Ordering::Relaxed);
906 }
907 }
908
909 fn run_reader_thread(
911 cache: &TemplateCache,
912 path: &std::path::Path,
913 paths_len: usize,
914 successful_loads: &AtomicUsize,
915 ) {
916 use std::sync::atomic::Ordering;
917 let tc = cache.template_count();
919 let ic = cache.include_count();
920 assert!(tc <= paths_len, "template count {tc} exceeds file count");
921 assert!(ic <= 100, "include count {ic} unexpectedly large");
922 if let Ok(tmpl) = cache.load(path) {
923 assert!(
924 !tmpl.declarations().is_empty(),
925 "loaded template must have declarations"
926 );
927 successful_loads.fetch_add(1, Ordering::Relaxed);
928 }
929 }
930
931 #[test]
941 fn concurrent_load_render_clear() {
942 use std::sync::{
943 Arc, Barrier,
944 atomic::{AtomicUsize, Ordering},
945 };
946
947 const NUM_THREADS: usize = 8;
948 const ROUNDS_PER_THREAD: usize = 50;
949
950 let dir = tempfile::tempdir().unwrap();
951
952 let mut paths = Vec::new();
954 for i in 0..4 {
955 let path = dir.path().join(format!("t{i}.tmpl.md"));
956 std::fs::write(
957 &path,
958 format!(
959 "---
960params: [x = str]
961---
962template{i}: {{{{ x }}}}"
963 ),
964 )
965 .unwrap();
966 paths.push(path);
967 }
968
969 let cache = Arc::new(TemplateCache::new());
970 let paths = Arc::new(paths);
971 let barrier = Arc::new(Barrier::new(NUM_THREADS));
972 let successful_loads = Arc::new(AtomicUsize::new(0));
973 let successful_renders = Arc::new(AtomicUsize::new(0));
974
975 let handles: Vec<_> = (0..NUM_THREADS)
976 .map(|thread_id| {
977 let cache = Arc::clone(&cache);
978 let paths = Arc::clone(&paths);
979 let barrier = Arc::clone(&barrier);
980 let successful_loads = Arc::clone(&successful_loads);
981 let successful_renders = Arc::clone(&successful_renders);
982 std::thread::spawn(move || {
983 barrier.wait();
985
986 for round in 0..ROUNDS_PER_THREAD {
987 let path = &paths[round % paths.len()];
988 let expected_idx = round % paths.len();
989
990 match thread_id % 4 {
991 0 => run_loader_thread(&cache, path, &successful_loads),
992 1 => {
993 run_renderer_thread(
994 &cache,
995 path,
996 expected_idx,
997 &successful_renders,
998 );
999 }
1000 2 => run_clear_thread(&cache, path, round, &successful_loads),
1001 _ => run_reader_thread(&cache, path, paths.len(), &successful_loads),
1002 }
1003 }
1004 })
1005 })
1006 .collect();
1007
1008 for handle in handles {
1010 handle.join().expect("thread must not panic");
1011 }
1012
1013 let loads = successful_loads.load(Ordering::Relaxed);
1015 let renders = successful_renders.load(Ordering::Relaxed);
1016 assert!(loads > 0, "no loads succeeded across {NUM_THREADS} threads");
1017 assert!(
1018 renders > 0,
1019 "no renders succeeded across {NUM_THREADS} threads"
1020 );
1021 }
1022}