Skip to main content

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;