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}