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 COMPACT_ALGO_VERSION: &str = "1";
40
41const SCHEMA: &str = r#"
47DROP TABLE IF EXISTS ast_cache;
48CREATE TABLE IF NOT EXISTS ast_cache_v2 (
49 hash TEXT NOT NULL, -- sha256 of the source bytes
50 lang TEXT NOT NULL,
51 version TEXT NOT NULL, -- COMPACT_ALGO_VERSION
52 text TEXT NOT NULL, -- the compacted skeleton
53 original_tokens INTEGER NOT NULL,
54 compacted_tokens INTEGER NOT NULL,
55 elided_bodies INTEGER NOT NULL,
56 created_ms INTEGER NOT NULL,
57 PRIMARY KEY (hash, lang, version)
58);
59"#;
60
61pub fn content_hash(source: &str) -> String {
63 let mut h = Sha256::new();
64 h.update(source.as_bytes());
65 format!("{:x}", h.finalize())
66}
67
68fn now_ms() -> i64 {
69 SystemTime::now()
70 .duration_since(UNIX_EPOCH)
71 .map(|d| d.as_millis() as i64)
72 .unwrap_or(0)
73}
74
75pub struct AstCache {
77 conn: Mutex<Connection>,
78 hits: AtomicU64,
79 misses: AtomicU64,
80}
81
82impl AstCache {
83 pub fn open(path: impl AsRef<Path>) -> Result<Self> {
85 Self::init(Connection::open(path)?)
86 }
87
88 pub fn in_memory() -> Result<Self> {
90 Self::init(Connection::open_in_memory()?)
91 }
92
93 fn init(conn: Connection) -> Result<Self> {
94 conn.execute_batch(SCHEMA)?;
95 Ok(AstCache {
96 conn: Mutex::new(conn),
97 hits: AtomicU64::new(0),
98 misses: AtomicU64::new(0),
99 })
100 }
101
102 pub fn outline_cached(&self, compactor: &mut Compactor, source: &str) -> Result<CompactResult> {
105 let hash = content_hash(source);
106 let lang = compactor.language().name();
107 if let Some(hit) = self.load(&hash, lang)? {
108 self.hits.fetch_add(1, Ordering::SeqCst);
109 return Ok(hit);
110 }
111 self.misses.fetch_add(1, Ordering::SeqCst);
112 let result = compactor.outline(source)?;
113 self.store(&hash, lang, &result)?;
114 Ok(result)
115 }
116
117 pub fn hits(&self) -> u64 {
119 self.hits.load(Ordering::SeqCst)
120 }
121
122 pub fn misses(&self) -> u64 {
124 self.misses.load(Ordering::SeqCst)
125 }
126
127 fn load(&self, hash: &str, lang: &str) -> Result<Option<CompactResult>> {
128 let conn = self.conn.lock().map_err(|_| CacheError::Poisoned)?;
129 let row = conn
130 .query_row(
131 "SELECT text, original_tokens, compacted_tokens, elided_bodies
132 FROM ast_cache_v2 WHERE hash = ?1 AND lang = ?2 AND version = ?3",
133 rusqlite::params![hash, lang, COMPACT_ALGO_VERSION],
134 |r| {
135 Ok(CompactResult {
136 text: r.get(0)?,
137 original_tokens: r.get::<_, i64>(1)? as usize,
138 compacted_tokens: r.get::<_, i64>(2)? as usize,
139 elided_bodies: r.get::<_, i64>(3)? as usize,
140 })
141 },
142 )
143 .optional()?;
144 Ok(row)
145 }
146
147 fn store(&self, hash: &str, lang: &str, r: &CompactResult) -> Result<()> {
148 let conn = self.conn.lock().map_err(|_| CacheError::Poisoned)?;
149 conn.execute(
150 "INSERT OR REPLACE INTO ast_cache_v2
151 (hash, lang, version, text, original_tokens, compacted_tokens, elided_bodies, created_ms)
152 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
153 rusqlite::params![
154 hash,
155 lang,
156 COMPACT_ALGO_VERSION,
157 r.text,
158 r.original_tokens as i64,
159 r.compacted_tokens as i64,
160 r.elided_bodies as i64,
161 now_ms(),
162 ],
163 )?;
164 Ok(())
165 }
166}
167
168#[cfg(test)]
169mod tests {
170 use super::*;
171
172 const SRC: &str = r#"
173 /// A greeter.
174 pub fn hello(name: &str) -> String {
175 let mut s = String::new();
176 s.push_str("hi ");
177 s.push_str(name);
178 s
179 }
180 "#;
181
182 #[test]
183 fn hash_is_stable_and_content_sensitive() {
184 assert_eq!(content_hash("abc"), content_hash("abc"));
185 assert_ne!(content_hash("abc"), content_hash("abd"));
186 }
187
188 #[test]
189 fn miss_then_hit_skips_the_parser() {
190 let cache = AstCache::in_memory().unwrap();
191 let mut c = Compactor::rust().unwrap();
192
193 let first = cache.outline_cached(&mut c, SRC).unwrap();
195 assert_eq!(cache.misses(), 1);
196 assert_eq!(cache.hits(), 0);
197
198 let second = cache.outline_cached(&mut c, SRC).unwrap();
200 assert_eq!(cache.hits(), 1);
201 assert_eq!(cache.misses(), 1);
202 assert_eq!(first.text, second.text);
203 assert_eq!(first.compacted_tokens, second.compacted_tokens);
204
205 let changed = format!("{SRC}\n// touched");
207 cache.outline_cached(&mut c, &changed).unwrap();
208 assert_eq!(cache.misses(), 2);
209 }
210
211 #[test]
212 fn same_bytes_different_language_is_not_a_hit() {
213 let cache = AstCache::in_memory().unwrap();
214 let mut rust = Compactor::rust().unwrap();
215 let mut py = Compactor::new(kedge_compact::Language::Python).unwrap();
216 cache.outline_cached(&mut rust, SRC).unwrap();
218 assert_eq!(cache.misses(), 1);
219 cache.outline_cached(&mut py, SRC).unwrap();
222 assert_eq!(
223 cache.misses(),
224 2,
225 "same bytes under a different language must not be a cross-language hit"
226 );
227 assert_eq!(cache.hits(), 0);
228 }
229
230 #[test]
231 fn cache_survives_reopen_on_disk() {
232 let dir = tempfile::tempdir().unwrap();
233 let path = dir.path().join("ast.sqlite");
234 {
235 let cache = AstCache::open(&path).unwrap();
236 let mut c = Compactor::rust().unwrap();
237 cache.outline_cached(&mut c, SRC).unwrap();
238 assert_eq!(cache.misses(), 1);
239 }
240 let cache = AstCache::open(&path).unwrap();
242 let mut c = Compactor::rust().unwrap();
243 let r = cache.outline_cached(&mut c, SRC).unwrap();
244 assert_eq!(cache.hits(), 1);
245 assert_eq!(cache.misses(), 0);
246 assert!(r.text.contains("hello"));
247 }
248}