1use std::path::Path;
14use std::sync::atomic::{AtomicU64, Ordering};
15use std::sync::Mutex;
16use std::time::{SystemTime, UNIX_EPOCH};
17
18use kedge_compact::{CompactResult, Compactor};
19use rusqlite::{Connection, OptionalExtension};
20use sha2::{Digest, Sha256};
21use thiserror::Error;
22
23#[derive(Debug, Error)]
24pub enum CacheError {
25 #[error("sqlite error: {0}")]
26 Sqlite(#[from] rusqlite::Error),
27 #[error("compaction error: {0}")]
28 Compact(#[from] kedge_compact::CompactError),
29 #[error("cache lock poisoned")]
30 Poisoned,
31}
32
33pub type Result<T> = std::result::Result<T, CacheError>;
34
35const SCHEMA: &str = r#"
36CREATE TABLE IF NOT EXISTS ast_cache (
37 hash TEXT PRIMARY KEY, -- sha256 of the source bytes
38 lang TEXT NOT NULL,
39 text TEXT NOT NULL, -- the compacted skeleton
40 original_tokens INTEGER NOT NULL,
41 compacted_tokens INTEGER NOT NULL,
42 elided_bodies INTEGER NOT NULL,
43 created_ms INTEGER NOT NULL
44);
45"#;
46
47pub fn content_hash(source: &str) -> String {
49 let mut h = Sha256::new();
50 h.update(source.as_bytes());
51 format!("{:x}", h.finalize())
52}
53
54fn now_ms() -> i64 {
55 SystemTime::now()
56 .duration_since(UNIX_EPOCH)
57 .map(|d| d.as_millis() as i64)
58 .unwrap_or(0)
59}
60
61pub struct AstCache {
63 conn: Mutex<Connection>,
64 hits: AtomicU64,
65 misses: AtomicU64,
66}
67
68impl AstCache {
69 pub fn open(path: impl AsRef<Path>) -> Result<Self> {
71 Self::init(Connection::open(path)?)
72 }
73
74 pub fn in_memory() -> Result<Self> {
76 Self::init(Connection::open_in_memory()?)
77 }
78
79 fn init(conn: Connection) -> Result<Self> {
80 conn.execute_batch(SCHEMA)?;
81 Ok(AstCache {
82 conn: Mutex::new(conn),
83 hits: AtomicU64::new(0),
84 misses: AtomicU64::new(0),
85 })
86 }
87
88 pub fn outline_cached(&self, compactor: &mut Compactor, source: &str) -> Result<CompactResult> {
91 let hash = content_hash(source);
92 if let Some(hit) = self.load(&hash)? {
93 self.hits.fetch_add(1, Ordering::SeqCst);
94 return Ok(hit);
95 }
96 self.misses.fetch_add(1, Ordering::SeqCst);
97 let result = compactor.outline(source)?;
98 self.store(&hash, compactor.language().name(), &result)?;
99 Ok(result)
100 }
101
102 pub fn hits(&self) -> u64 {
104 self.hits.load(Ordering::SeqCst)
105 }
106
107 pub fn misses(&self) -> u64 {
109 self.misses.load(Ordering::SeqCst)
110 }
111
112 fn load(&self, hash: &str) -> Result<Option<CompactResult>> {
113 let conn = self.conn.lock().map_err(|_| CacheError::Poisoned)?;
114 let row = conn
115 .query_row(
116 "SELECT text, original_tokens, compacted_tokens, elided_bodies
117 FROM ast_cache WHERE hash = ?1",
118 [hash],
119 |r| {
120 Ok(CompactResult {
121 text: r.get(0)?,
122 original_tokens: r.get::<_, i64>(1)? as usize,
123 compacted_tokens: r.get::<_, i64>(2)? as usize,
124 elided_bodies: r.get::<_, i64>(3)? as usize,
125 })
126 },
127 )
128 .optional()?;
129 Ok(row)
130 }
131
132 fn store(&self, hash: &str, lang: &str, r: &CompactResult) -> Result<()> {
133 let conn = self.conn.lock().map_err(|_| CacheError::Poisoned)?;
134 conn.execute(
135 "INSERT OR REPLACE INTO ast_cache
136 (hash, lang, text, original_tokens, compacted_tokens, elided_bodies, created_ms)
137 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
138 rusqlite::params![
139 hash,
140 lang,
141 r.text,
142 r.original_tokens as i64,
143 r.compacted_tokens as i64,
144 r.elided_bodies as i64,
145 now_ms(),
146 ],
147 )?;
148 Ok(())
149 }
150}
151
152#[cfg(test)]
153mod tests {
154 use super::*;
155
156 const SRC: &str = r#"
157 /// A greeter.
158 pub fn hello(name: &str) -> String {
159 let mut s = String::new();
160 s.push_str("hi ");
161 s.push_str(name);
162 s
163 }
164 "#;
165
166 #[test]
167 fn hash_is_stable_and_content_sensitive() {
168 assert_eq!(content_hash("abc"), content_hash("abc"));
169 assert_ne!(content_hash("abc"), content_hash("abd"));
170 }
171
172 #[test]
173 fn miss_then_hit_skips_the_parser() {
174 let cache = AstCache::in_memory().unwrap();
175 let mut c = Compactor::rust().unwrap();
176
177 let first = cache.outline_cached(&mut c, SRC).unwrap();
179 assert_eq!(cache.misses(), 1);
180 assert_eq!(cache.hits(), 0);
181
182 let second = cache.outline_cached(&mut c, SRC).unwrap();
184 assert_eq!(cache.hits(), 1);
185 assert_eq!(cache.misses(), 1);
186 assert_eq!(first.text, second.text);
187 assert_eq!(first.compacted_tokens, second.compacted_tokens);
188
189 let changed = format!("{SRC}\n// touched");
191 cache.outline_cached(&mut c, &changed).unwrap();
192 assert_eq!(cache.misses(), 2);
193 }
194
195 #[test]
196 fn cache_survives_reopen_on_disk() {
197 let dir = tempfile::tempdir().unwrap();
198 let path = dir.path().join("ast.sqlite");
199 {
200 let cache = AstCache::open(&path).unwrap();
201 let mut c = Compactor::rust().unwrap();
202 cache.outline_cached(&mut c, SRC).unwrap();
203 assert_eq!(cache.misses(), 1);
204 }
205 let cache = AstCache::open(&path).unwrap();
207 let mut c = Compactor::rust().unwrap();
208 let r = cache.outline_cached(&mut c, SRC).unwrap();
209 assert_eq!(cache.hits(), 1);
210 assert_eq!(cache.misses(), 0);
211 assert!(r.text.contains("hello"));
212 }
213}