Skip to main content

damascene_markdown/
cache.rs

1//! Keyed render cache for repeated [`md`](crate::md) calls — the
2//! streaming-chat pattern.
3//!
4//! A conversation view re-renders its whole backlog every frame; calling
5//! [`md`](crate::md) per message re-parses kilobytes of markdown that
6//! hasn't changed since the last frame, and during streaming the cost is
7//! paid per *delta*. [`MdCache`] memoizes the rendered `El` by source
8//! text: stable messages hit the cache (a cheap tree clone), and only
9//! the message currently growing re-parses. LRU-bounded so scrolled-away
10//! backlogs don't accumulate forever — pair the capacity with your
11//! visible window (`BuildCx::visible_range` tells you what's realized).
12
13use std::collections::HashMap;
14
15use damascene_core::El;
16
17use crate::{MarkdownOptions, md_with_options};
18
19/// Default entry cap — generous for a screenful of chat plus
20/// scroll-back margin.
21const DEFAULT_CAPACITY: usize = 256;
22
23/// A bounded memo of `source text → rendered El` for one
24/// [`MarkdownOptions`] configuration.
25///
26/// ```ignore
27/// struct ChatApp {
28///     md_cache: RefCell<MdCache>, // interior-mutable: written during build
29///     // …
30/// }
31///
32/// // per visible message, every frame:
33/// let rendered = self.md_cache.borrow_mut().get(&message.text);
34/// ```
35///
36/// Keys are the full source text (no hash-collision risk); values are
37/// `El` subtrees cloned out on hit. A streaming message changes text
38/// each delta, so it naturally misses (re-parses) while growing and
39/// starts hitting once stable; superseded partial entries age out via
40/// LRU.
41pub struct MdCache {
42    options: MarkdownOptions,
43    capacity: usize,
44    entries: HashMap<Box<str>, Entry>,
45    /// Monotonic access counter for LRU stamps.
46    tick: u64,
47    /// Total cache misses (= real parses) — exposed for tests and
48    /// perf-diagnostics overlays.
49    parses: u64,
50}
51
52struct Entry {
53    rendered: El,
54    last_used: u64,
55}
56
57impl MdCache {
58    /// A cache rendering with `options`, holding up to
59    /// [`DEFAULT_CAPACITY`] entries.
60    pub fn new(options: MarkdownOptions) -> Self {
61        Self::with_capacity(options, DEFAULT_CAPACITY)
62    }
63
64    /// A cache with an explicit entry cap. Size it to your visible
65    /// window plus scroll-back margin; each entry holds the source
66    /// text and the rendered subtree.
67    pub fn with_capacity(options: MarkdownOptions, capacity: usize) -> Self {
68        Self {
69            options,
70            capacity: capacity.max(1),
71            entries: HashMap::new(),
72            tick: 0,
73            parses: 0,
74        }
75    }
76
77    /// The rendered tree for `text` — a clone of the cached subtree on
78    /// hit, a fresh parse (then cached) on miss.
79    pub fn get(&mut self, text: &str) -> El {
80        self.tick += 1;
81        let tick = self.tick;
82        if let Some(entry) = self.entries.get_mut(text) {
83            entry.last_used = tick;
84            return entry.rendered.clone();
85        }
86        self.parses += 1;
87        let rendered = md_with_options(text, self.options);
88        if self.entries.len() >= self.capacity {
89            self.evict_lru();
90        }
91        self.entries.insert(
92            Box::from(text),
93            Entry {
94                rendered: rendered.clone(),
95                last_used: tick,
96            },
97        );
98        rendered
99    }
100
101    /// Number of cache misses so far — each one was a real markdown
102    /// parse.
103    pub fn parses(&self) -> u64 {
104        self.parses
105    }
106
107    /// Entries currently held.
108    pub fn len(&self) -> usize {
109        self.entries.len()
110    }
111
112    /// True when nothing is cached yet.
113    pub fn is_empty(&self) -> bool {
114        self.entries.is_empty()
115    }
116
117    /// Drop everything (e.g. on theme change if your message trees bake
118    /// theme-derived values — stock `md` output is theme-neutral and
119    /// does not need this).
120    pub fn clear(&mut self) {
121        self.entries.clear();
122    }
123
124    fn evict_lru(&mut self) {
125        if let Some(oldest) = self
126            .entries
127            .iter()
128            .min_by_key(|(_, e)| e.last_used)
129            .map(|(k, _)| k.clone())
130        {
131            self.entries.remove(&oldest);
132        }
133    }
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139
140    #[test]
141    fn hits_skip_the_parse() {
142        let mut cache = MdCache::new(MarkdownOptions::default());
143        let _ = cache.get("# stable message");
144        let _ = cache.get("# stable message");
145        let _ = cache.get("# stable message");
146        assert_eq!(cache.parses(), 1);
147        assert_eq!(cache.len(), 1);
148
149        // A streaming tail misses per delta — each is a real parse.
150        let _ = cache.get("partial");
151        let _ = cache.get("partial text");
152        assert_eq!(cache.parses(), 3);
153    }
154
155    #[test]
156    fn lru_eviction_keeps_recent_entries() {
157        let mut cache = MdCache::with_capacity(MarkdownOptions::default(), 2);
158        let _ = cache.get("one");
159        let _ = cache.get("two");
160        let _ = cache.get("one"); // refresh "one"
161        let _ = cache.get("three"); // evicts "two"
162        assert_eq!(cache.len(), 2);
163        let parses = cache.parses();
164        let _ = cache.get("one"); // still cached
165        assert_eq!(cache.parses(), parses);
166        let _ = cache.get("two"); // evicted → re-parse
167        assert_eq!(cache.parses(), parses + 1);
168    }
169}