1use std::fs;
4use std::hash::{Hash, Hasher};
5use std::io::{self, Write};
6use std::path::{Path, PathBuf};
7use std::sync::atomic::{AtomicU64, Ordering};
8use std::time::UNIX_EPOCH;
9
10use code_moniker_core::core::code_graph::CodeGraph;
11use code_moniker_core::core::code_graph::encoding::{self, LAYOUT_VERSION};
12use rustc_hash::FxHasher;
13
14use crate::extract;
15use code_moniker_core::lang::Lang;
16
17const CACHE_MAGIC: u32 = 0xC0DE_2106;
18const CACHE_FORMAT_VERSION: u32 = 7;
20const OFF_MAGIC: usize = 0;
21const OFF_FORMAT: usize = 4;
22const OFF_MTIME: usize = 8;
23const OFF_SIZE: usize = 16;
24const OFF_ANCHOR: usize = 24;
25const OFF_CONTEXT: usize = 32;
26const OFF_PATH_LEN: usize = 40;
27const HEADER_FIXED: usize = OFF_PATH_LEN + 4;
28
29static TMP_NONCE: AtomicU64 = AtomicU64::new(0);
30
31#[derive(Clone, Debug)]
32pub struct CacheKey {
33 pub abs_path: PathBuf,
34 pub mtime: u64,
35 pub size: u64,
36 pub anchor_hash: u64,
37 pub context_hash: u64,
38}
39
40impl CacheKey {
41 #[cfg(test)]
42 pub fn from_path(path: &Path, anchor: &Path) -> io::Result<Self> {
43 Self::from_path_with_context(path, anchor, &extract::Context::default())
44 }
45
46 pub fn from_path_with_context(
47 path: &Path,
48 anchor: &Path,
49 ctx: &extract::Context,
50 ) -> io::Result<Self> {
51 let abs_path = path.canonicalize()?;
52 let meta = fs::metadata(&abs_path)?;
53 let mtime = meta
54 .modified()?
55 .duration_since(UNIX_EPOCH)
56 .map(|d| d.as_nanos() as u64)
57 .unwrap_or(0);
58 Ok(Self {
59 abs_path,
60 mtime,
61 size: meta.len(),
62 anchor_hash: hash_path(anchor),
63 context_hash: hash_context(ctx),
64 })
65 }
66
67 fn path_hash(&self) -> u64 {
68 hash_path(&self.abs_path)
69 }
70
71 fn full_path(&self, root: &Path) -> PathBuf {
72 root.join(format!("v{LAYOUT_VERSION}_{CACHE_FORMAT_VERSION}"))
73 .join(self.shard())
74 .join(self.filename())
75 }
76
77 fn filename(&self) -> String {
78 format!(
79 "{:016x}_{:016x}_{:016x}.bin",
80 self.path_hash(),
81 self.anchor_hash,
82 self.context_hash,
83 )
84 }
85
86 fn shard(&self) -> String {
87 format!("{:02x}", (self.path_hash() & 0xff) as u8)
88 }
89
90 fn abs_path_bytes(&self) -> &[u8] {
91 path_bytes(&self.abs_path)
92 }
93}
94
95pub fn load(cache_dir: &Path, key: &CacheKey) -> Option<CodeGraph> {
96 let path = key.full_path(cache_dir);
97 let bytes = fs::read(&path).ok()?;
98 let body = validate_header(&bytes, key)?;
99 match encoding::decode(body) {
100 Ok(g) => Some(g),
101 Err(e) => {
102 eprintln!(
103 "code-moniker: cache decode failed at {} ({e}); ignoring",
104 path.display(),
105 );
106 None
107 }
108 }
109}
110
111pub fn store(cache_dir: &Path, key: &CacheKey, graph: &CodeGraph) {
112 let _ = try_store(cache_dir, key, graph);
113}
114
115fn try_store(cache_dir: &Path, key: &CacheKey, graph: &CodeGraph) -> io::Result<()> {
116 let path = key.full_path(cache_dir);
117 if let Some(parent) = path.parent() {
118 fs::create_dir_all(parent)?;
119 }
120 let body = encoding::encode(graph)
121 .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string()))?;
122 let path_bytes = key.abs_path_bytes();
123 let mut buf = Vec::with_capacity(HEADER_FIXED + path_bytes.len() + body.len());
124 buf.extend_from_slice(&CACHE_MAGIC.to_le_bytes());
125 buf.extend_from_slice(&CACHE_FORMAT_VERSION.to_le_bytes());
126 buf.extend_from_slice(&key.mtime.to_le_bytes());
127 buf.extend_from_slice(&key.size.to_le_bytes());
128 buf.extend_from_slice(&key.anchor_hash.to_le_bytes());
129 buf.extend_from_slice(&key.context_hash.to_le_bytes());
130 buf.extend_from_slice(&(path_bytes.len() as u32).to_le_bytes());
131 buf.extend_from_slice(path_bytes);
132 buf.extend_from_slice(&body);
133
134 let nonce = TMP_NONCE.fetch_add(1, Ordering::Relaxed);
135 let tmp = path.with_extension(format!("tmp.{}.{nonce}", std::process::id()));
136 let write_result = (|| -> io::Result<()> {
137 let mut f = fs::File::create(&tmp)?;
138 f.write_all(&buf)?;
139 f.sync_data()?;
140 Ok(())
141 })();
142 if let Err(e) = write_result {
143 let _ = fs::remove_file(&tmp);
144 return Err(e);
145 }
146 fs::rename(&tmp, &path)
147}
148
149#[allow(dead_code)]
150pub fn load_or_extract(
151 path: &Path,
152 anchor: &Path,
153 lang: Lang,
154 cache_dir: Option<&Path>,
155 ctx: &extract::Context,
156) -> Option<(CodeGraph, Option<String>)> {
157 load_or_extract_result(path, anchor, lang, cache_dir, ctx).ok()
158}
159
160pub fn load_or_extract_result(
161 path: &Path,
162 anchor: &Path,
163 lang: Lang,
164 cache_dir: Option<&Path>,
165 ctx: &extract::Context,
166) -> io::Result<(CodeGraph, Option<String>)> {
167 if let Some(dir) = cache_dir
168 && let Ok(key) = CacheKey::from_path_with_context(path, anchor, ctx)
169 {
170 if let Some(g) = load(dir, &key) {
171 return Ok((g, None));
172 }
173 let source = fs::read_to_string(path)?;
174 let graph = extract::extract_with(lang, &source, anchor, ctx);
175 store(dir, &key, &graph);
176 return Ok((graph, Some(source)));
177 }
178 let source = fs::read_to_string(path)?;
179 let graph = extract::extract_with(lang, &source, anchor, ctx);
180 Ok((graph, Some(source)))
181}
182
183fn validate_header<'a>(bytes: &'a [u8], key: &CacheKey) -> Option<&'a [u8]> {
184 if bytes.len() < HEADER_FIXED {
185 return None;
186 }
187 let magic = u32::from_le_bytes(bytes[OFF_MAGIC..OFF_FORMAT].try_into().ok()?);
188 if magic != CACHE_MAGIC {
189 return None;
190 }
191 let version = u32::from_le_bytes(bytes[OFF_FORMAT..OFF_MTIME].try_into().ok()?);
192 if version != CACHE_FORMAT_VERSION {
193 return None;
194 }
195 let mtime = u64::from_le_bytes(bytes[OFF_MTIME..OFF_SIZE].try_into().ok()?);
196 let size = u64::from_le_bytes(bytes[OFF_SIZE..OFF_ANCHOR].try_into().ok()?);
197 let anchor_hash = u64::from_le_bytes(bytes[OFF_ANCHOR..OFF_CONTEXT].try_into().ok()?);
198 let context_hash = u64::from_le_bytes(bytes[OFF_CONTEXT..OFF_PATH_LEN].try_into().ok()?);
199 if mtime != key.mtime
200 || size != key.size
201 || anchor_hash != key.anchor_hash
202 || context_hash != key.context_hash
203 {
204 return None;
205 }
206 let path_len = u32::from_le_bytes(bytes[OFF_PATH_LEN..HEADER_FIXED].try_into().ok()?) as usize;
207 if HEADER_FIXED + path_len > bytes.len() {
208 return None;
209 }
210 let stored_path = &bytes[HEADER_FIXED..HEADER_FIXED + path_len];
211 if stored_path != key.abs_path_bytes() {
212 return None;
213 }
214 Some(&bytes[HEADER_FIXED + path_len..])
215}
216
217#[cfg(unix)]
218fn path_bytes(p: &Path) -> &[u8] {
219 use std::os::unix::ffi::OsStrExt;
220 p.as_os_str().as_bytes()
221}
222
223#[cfg(not(unix))]
224fn path_bytes(p: &Path) -> &[u8] {
225 p.to_str().map(|s| s.as_bytes()).unwrap_or(&[])
226}
227
228fn hash_path(p: &Path) -> u64 {
229 let mut h = FxHasher::default();
230 path_bytes(p).hash(&mut h);
231 h.finish()
232}
233
234fn hash_context(ctx: &extract::Context) -> u64 {
235 let mut h = FxHasher::default();
236 ctx.project.hash(&mut h);
237 ctx.ts.aliases.len().hash(&mut h);
238 for alias in &ctx.ts.aliases {
239 alias.pattern.hash(&mut h);
240 alias.substitution.hash(&mut h);
241 }
242 h.finish()
243}
244
245#[cfg(test)]
246mod tests {
247 use super::*;
248 use code_moniker_core::core::moniker::MonikerBuilder;
249
250 fn graph_with_one_def() -> CodeGraph {
251 let root = MonikerBuilder::new()
252 .project(b"app")
253 .segment(b"path", b"root")
254 .build();
255 let mut g = CodeGraph::new(root.clone(), b"module");
256 let child = MonikerBuilder::new()
257 .project(b"app")
258 .segment(b"path", b"root")
259 .segment(b"class", b"Foo")
260 .build();
261 g.add_def(child, b"class", &root, Some((0, 10))).unwrap();
262 g
263 }
264
265 #[test]
266 fn store_then_load_roundtrips() {
267 let tmp = tempfile::tempdir().unwrap();
268 let src = tmp.path().join("src.ts");
269 std::fs::write(&src, b"export class Foo {}\n").unwrap();
270 let anchor = tmp.path().join("anchor");
271 let key = CacheKey::from_path(&src, &anchor).unwrap();
272 let g = graph_with_one_def();
273
274 store(tmp.path(), &key, &g);
275 let back = load(tmp.path(), &key).expect("should hit");
276 assert_eq!(back.def_count(), g.def_count());
277 }
278
279 #[test]
280 fn load_misses_when_mtime_changes() {
281 let tmp = tempfile::tempdir().unwrap();
282 let src = tmp.path().join("src.ts");
283 std::fs::write(&src, b"a").unwrap();
284 let anchor = tmp.path().join("anchor");
285 let key = CacheKey::from_path(&src, &anchor).unwrap();
286 store(tmp.path(), &key, &graph_with_one_def());
287
288 std::thread::sleep(std::time::Duration::from_millis(10));
289 std::fs::write(&src, b"ab").unwrap();
290 let key2 = CacheKey::from_path(&src, &anchor).unwrap();
291 assert!(key2.mtime != key.mtime || key2.size != key.size);
292 assert!(load(tmp.path(), &key2).is_none());
293 }
294
295 #[test]
296 fn load_misses_when_anchor_changes() {
297 let tmp = tempfile::tempdir().unwrap();
298 let src = tmp.path().join("src.ts");
299 std::fs::write(&src, b"a").unwrap();
300 let anchor1 = tmp.path().join("anchor1");
301 let anchor2 = tmp.path().join("anchor2");
302 let key1 = CacheKey::from_path(&src, &anchor1).unwrap();
303 let key2 = CacheKey::from_path(&src, &anchor2).unwrap();
304 store(tmp.path(), &key1, &graph_with_one_def());
305 assert!(load(tmp.path(), &key1).is_some());
306 assert!(load(tmp.path(), &key2).is_none());
307 }
308
309 #[test]
310 fn load_misses_when_context_changes() {
311 let tmp = tempfile::tempdir().unwrap();
312 let src = tmp.path().join("src.ts");
313 std::fs::write(&src, b"export class Foo {}\n").unwrap();
314 let anchor = tmp.path().join("anchor");
315 let ctx_one = extract::Context {
316 project: Some("one".into()),
317 ..extract::Context::default()
318 };
319 let ctx_two = extract::Context {
320 project: Some("two".into()),
321 ..extract::Context::default()
322 };
323 let key1 = CacheKey::from_path_with_context(&src, &anchor, &ctx_one).unwrap();
324 let key2 = CacheKey::from_path_with_context(&src, &anchor, &ctx_two).unwrap();
325
326 store(tmp.path(), &key1, &graph_with_one_def());
327
328 assert!(load(tmp.path(), &key1).is_some());
329 assert!(load(tmp.path(), &key2).is_none());
330 assert_ne!(key1.full_path(tmp.path()), key2.full_path(tmp.path()));
331 }
332
333 #[test]
334 fn load_returns_none_on_empty_dir() {
335 let tmp = tempfile::tempdir().unwrap();
336 let src = tmp.path().join("src.ts");
337 std::fs::write(&src, b"a").unwrap();
338 let key = CacheKey::from_path(&src, tmp.path()).unwrap();
339 assert!(load(tmp.path(), &key).is_none());
340 }
341
342 #[test]
343 fn cache_path_is_versioned_and_sharded() {
344 let tmp = tempfile::tempdir().unwrap();
345 let src = tmp.path().join("src.ts");
346 std::fs::write(&src, b"a").unwrap();
347 let key = CacheKey::from_path(&src, tmp.path()).unwrap();
348 let full = key.full_path(tmp.path());
349 let s = full.to_string_lossy();
350 assert!(s.contains(&format!("v{LAYOUT_VERSION}_{CACHE_FORMAT_VERSION}")));
351 assert!(full.parent().unwrap().file_name().unwrap().len() == 2);
352 }
353}