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 = 9;
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, Copy, Debug, Eq, PartialEq)]
32pub(crate) enum WorkspaceCacheOutcome {
33 Hit,
34 Miss,
35 Disabled,
36}
37
38impl WorkspaceCacheOutcome {
39 pub(crate) fn as_str(self) -> &'static str {
40 match self {
41 Self::Hit => "hit",
42 Self::Miss => "miss",
43 Self::Disabled => "disabled",
44 }
45 }
46}
47
48#[derive(Clone, Debug)]
49pub struct CacheKey {
50 pub abs_path: PathBuf,
51 pub mtime: u64,
52 pub size: u64,
53 pub anchor_hash: u64,
54 pub context_hash: u64,
55}
56
57impl CacheKey {
58 #[cfg(test)]
59 pub fn from_path(path: &Path, anchor: &Path) -> io::Result<Self> {
60 Self::from_path_with_context(path, anchor, &extract::Context::default())
61 }
62
63 pub fn from_path_with_context(
64 path: &Path,
65 anchor: &Path,
66 ctx: &extract::Context,
67 ) -> io::Result<Self> {
68 let abs_path = path.canonicalize()?;
69 let meta = fs::metadata(&abs_path)?;
70 let mtime = meta
71 .modified()?
72 .duration_since(UNIX_EPOCH)
73 .map(|d| d.as_nanos() as u64)
74 .unwrap_or(0);
75 let context_hash = hash_context(ctx, &abs_path);
76 Ok(Self {
77 abs_path,
78 mtime,
79 size: meta.len(),
80 anchor_hash: hash_path(anchor),
81 context_hash,
82 })
83 }
84
85 fn path_hash(&self) -> u64 {
86 hash_path(&self.abs_path)
87 }
88
89 fn full_path(&self, root: &Path) -> PathBuf {
90 root.join(format!("v{LAYOUT_VERSION}_{CACHE_FORMAT_VERSION}"))
91 .join(self.shard())
92 .join(self.filename())
93 }
94
95 fn filename(&self) -> String {
96 format!(
97 "{:016x}_{:016x}_{:016x}.bin",
98 self.path_hash(),
99 self.anchor_hash,
100 self.context_hash,
101 )
102 }
103
104 fn shard(&self) -> String {
105 format!("{:02x}", (self.path_hash() & 0xff) as u8)
106 }
107
108 fn abs_path_bytes(&self) -> &[u8] {
109 path_bytes(&self.abs_path)
110 }
111}
112
113pub fn load(cache_dir: &Path, key: &CacheKey) -> Option<CodeGraph> {
114 let path = key.full_path(cache_dir);
115 let bytes = fs::read(&path).ok()?;
116 let body = validate_header(&bytes, key)?;
117 match encoding::decode(body) {
118 Ok(g) => Some(g),
119 Err(e) => {
120 eprintln!(
121 "code-moniker: cache decode failed at {} ({e}); ignoring",
122 path.display(),
123 );
124 None
125 }
126 }
127}
128
129pub fn store(cache_dir: &Path, key: &CacheKey, graph: &CodeGraph) {
130 let _ = try_store(cache_dir, key, graph);
131}
132
133fn try_store(cache_dir: &Path, key: &CacheKey, graph: &CodeGraph) -> io::Result<()> {
134 let path = key.full_path(cache_dir);
135 if let Some(parent) = path.parent() {
136 fs::create_dir_all(parent)?;
137 }
138 let body = encoding::encode(graph)
139 .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string()))?;
140 let path_bytes = key.abs_path_bytes();
141 let mut buf = Vec::with_capacity(HEADER_FIXED + path_bytes.len() + body.len());
142 buf.extend_from_slice(&CACHE_MAGIC.to_le_bytes());
143 buf.extend_from_slice(&CACHE_FORMAT_VERSION.to_le_bytes());
144 buf.extend_from_slice(&key.mtime.to_le_bytes());
145 buf.extend_from_slice(&key.size.to_le_bytes());
146 buf.extend_from_slice(&key.anchor_hash.to_le_bytes());
147 buf.extend_from_slice(&key.context_hash.to_le_bytes());
148 buf.extend_from_slice(&(path_bytes.len() as u32).to_le_bytes());
149 buf.extend_from_slice(path_bytes);
150 buf.extend_from_slice(&body);
151
152 let nonce = TMP_NONCE.fetch_add(1, Ordering::Relaxed);
153 let tmp = path.with_extension(format!("tmp.{}.{nonce}", std::process::id()));
154 let write_result = (|| -> io::Result<()> {
155 let mut f = fs::File::create(&tmp)?;
156 f.write_all(&buf)?;
157 f.sync_data()?;
158 Ok(())
159 })();
160 if let Err(e) = write_result {
161 let _ = fs::remove_file(&tmp);
162 return Err(e);
163 }
164 fs::rename(&tmp, &path)
165}
166
167#[allow(dead_code)]
168pub fn load_or_extract(
169 path: &Path,
170 anchor: &Path,
171 lang: Lang,
172 cache_dir: Option<&Path>,
173 ctx: &extract::Context,
174) -> Option<(CodeGraph, Option<String>)> {
175 load_or_extract_result(path, anchor, lang, cache_dir, ctx).ok()
176}
177
178pub fn load_or_extract_result(
179 path: &Path,
180 anchor: &Path,
181 lang: Lang,
182 cache_dir: Option<&Path>,
183 ctx: &extract::Context,
184) -> io::Result<(CodeGraph, Option<String>)> {
185 load_or_extract_result_with(path, anchor, lang, cache_dir, ctx, read_source)
186}
187
188pub(crate) fn load_or_extract_workspace_result(
189 path: &Path,
190 anchor: &Path,
191 lang: Lang,
192 cache_dir: Option<&Path>,
193 ctx: &extract::Context,
194) -> io::Result<(CodeGraph, Option<String>, WorkspaceCacheOutcome)> {
195 load_or_extract_result_with_outcome(path, anchor, lang, cache_dir, ctx, read_source_lossy)
196}
197
198fn load_or_extract_result_with(
199 path: &Path,
200 anchor: &Path,
201 lang: Lang,
202 cache_dir: Option<&Path>,
203 ctx: &extract::Context,
204 read: fn(&Path) -> io::Result<String>,
205) -> io::Result<(CodeGraph, Option<String>)> {
206 load_or_extract_result_with_outcome(path, anchor, lang, cache_dir, ctx, read)
207 .map(|(graph, source, _)| (graph, source))
208}
209
210fn load_or_extract_result_with_outcome(
211 path: &Path,
212 anchor: &Path,
213 lang: Lang,
214 cache_dir: Option<&Path>,
215 ctx: &extract::Context,
216 read: fn(&Path) -> io::Result<String>,
217) -> io::Result<(CodeGraph, Option<String>, WorkspaceCacheOutcome)> {
218 if let Some(dir) = cache_dir
219 && let Ok(key) = CacheKey::from_path_with_context(path, anchor, ctx)
220 {
221 if let Some(graph) = load(dir, &key) {
222 return Ok((graph, None, WorkspaceCacheOutcome::Hit));
223 }
224 let source = read(path)?;
225 let graph = extract::extract_with(lang, &source, anchor, ctx);
226 store(dir, &key, &graph);
227 return Ok((graph, Some(source), WorkspaceCacheOutcome::Miss));
228 }
229 let source = read(path)?;
230 let graph = extract::extract_with(lang, &source, anchor, ctx);
231 Ok((graph, Some(source), WorkspaceCacheOutcome::Disabled))
232}
233
234pub(crate) fn read_source(path: &Path) -> io::Result<String> {
235 fs::read_to_string(path)
236}
237
238pub(crate) fn read_source_lossy(path: &Path) -> io::Result<String> {
239 fs::read(path).map(|bytes| String::from_utf8_lossy(&bytes).into_owned())
240}
241
242fn validate_header<'a>(bytes: &'a [u8], key: &CacheKey) -> Option<&'a [u8]> {
243 if bytes.len() < HEADER_FIXED {
244 return None;
245 }
246 let magic = u32::from_le_bytes(bytes[OFF_MAGIC..OFF_FORMAT].try_into().ok()?);
247 if magic != CACHE_MAGIC {
248 return None;
249 }
250 let version = u32::from_le_bytes(bytes[OFF_FORMAT..OFF_MTIME].try_into().ok()?);
251 if version != CACHE_FORMAT_VERSION {
252 return None;
253 }
254 let mtime = u64::from_le_bytes(bytes[OFF_MTIME..OFF_SIZE].try_into().ok()?);
255 let size = u64::from_le_bytes(bytes[OFF_SIZE..OFF_ANCHOR].try_into().ok()?);
256 let anchor_hash = u64::from_le_bytes(bytes[OFF_ANCHOR..OFF_CONTEXT].try_into().ok()?);
257 let context_hash = u64::from_le_bytes(bytes[OFF_CONTEXT..OFF_PATH_LEN].try_into().ok()?);
258 if mtime != key.mtime
259 || size != key.size
260 || anchor_hash != key.anchor_hash
261 || context_hash != key.context_hash
262 {
263 return None;
264 }
265 let path_len = u32::from_le_bytes(bytes[OFF_PATH_LEN..HEADER_FIXED].try_into().ok()?) as usize;
266 if HEADER_FIXED + path_len > bytes.len() {
267 return None;
268 }
269 let stored_path = &bytes[HEADER_FIXED..HEADER_FIXED + path_len];
270 if stored_path != key.abs_path_bytes() {
271 return None;
272 }
273 Some(&bytes[HEADER_FIXED + path_len..])
274}
275
276#[cfg(unix)]
277fn path_bytes(p: &Path) -> &[u8] {
278 use std::os::unix::ffi::OsStrExt;
279 p.as_os_str().as_bytes()
280}
281
282#[cfg(not(unix))]
283fn path_bytes(p: &Path) -> &[u8] {
284 p.to_str().map(|s| s.as_bytes()).unwrap_or(&[])
285}
286
287fn hash_path(p: &Path) -> u64 {
288 let mut h = FxHasher::default();
289 path_bytes(p).hash(&mut h);
290 h.finish()
291}
292
293fn hash_context(ctx: &extract::Context, path: &Path) -> u64 {
294 let mut h = FxHasher::default();
295 ctx.project.hash(&mut h);
296 ctx.srcset.hash(&mut h);
297 ctx.ts.aliases.len().hash(&mut h);
298 for alias in &ctx.ts.aliases {
299 alias.pattern.hash(&mut h);
300 alias.substitution.hash(&mut h);
301 }
302 let sdk_profile = ctx.ts.sdk_profile_for(path);
303 code_moniker_core::lang::ts::TsSdkProfile::catalog_digest().hash(&mut h);
304 sdk_profile.libraries().hash(&mut h);
305 h.finish()
306}
307
308#[cfg(test)]
309mod tests {
310 use super::*;
311 use code_moniker_core::core::moniker::MonikerBuilder;
312
313 fn graph_with_one_def() -> CodeGraph {
314 let root = MonikerBuilder::new()
315 .project(b"app")
316 .segment(b"path", b"root")
317 .build();
318 let mut g = CodeGraph::new(root.clone(), b"module");
319 let child = MonikerBuilder::new()
320 .project(b"app")
321 .segment(b"path", b"root")
322 .segment(b"class", b"Foo")
323 .build();
324 g.add_def(child, b"class", &root, Some((0, 10))).unwrap();
325 g
326 }
327
328 #[test]
329 fn store_then_load_roundtrips() {
330 let tmp = tempfile::tempdir().unwrap();
331 let src = tmp.path().join("src.ts");
332 std::fs::write(&src, b"export class Foo {}\n").unwrap();
333 let anchor = tmp.path().join("anchor");
334 let key = CacheKey::from_path(&src, &anchor).unwrap();
335 let g = graph_with_one_def();
336
337 store(tmp.path(), &key, &g);
338 let back = load(tmp.path(), &key).expect("should hit");
339 assert_eq!(back.def_count(), g.def_count());
340 }
341
342 #[test]
343 fn workspace_load_or_extract_accepts_non_utf8_source_bytes() {
344 let tmp = tempfile::tempdir().unwrap();
345 let src = tmp.path().join("legacy.c");
346 std::fs::write(&src, b"int value; /* legacy: \x96 */\n").unwrap();
347 let anchor = tmp.path().join("anchor");
348
349 let (graph, source, outcome) = load_or_extract_workspace_result(
350 &src,
351 &anchor,
352 Lang::C,
353 None,
354 &extract::Context::default(),
355 )
356 .expect("legacy source should be indexed lossily");
357
358 assert_eq!(outcome, WorkspaceCacheOutcome::Disabled);
359 assert!(source.expect("source text").contains('\u{fffd}'));
360 assert!(graph.defs().any(|definition| {
361 definition
362 .moniker
363 .as_view()
364 .segments()
365 .last()
366 .is_some_and(|segment| segment.name == b"value")
367 }));
368 }
369
370 #[test]
371 fn load_misses_when_mtime_changes() {
372 let tmp = tempfile::tempdir().unwrap();
373 let src = tmp.path().join("src.ts");
374 std::fs::write(&src, b"a").unwrap();
375 let anchor = tmp.path().join("anchor");
376 let key = CacheKey::from_path(&src, &anchor).unwrap();
377 store(tmp.path(), &key, &graph_with_one_def());
378
379 std::thread::sleep(std::time::Duration::from_millis(10));
380 std::fs::write(&src, b"ab").unwrap();
381 let key2 = CacheKey::from_path(&src, &anchor).unwrap();
382 assert!(key2.mtime != key.mtime || key2.size != key.size);
383 assert!(load(tmp.path(), &key2).is_none());
384 }
385
386 #[test]
387 fn load_misses_when_anchor_changes() {
388 let tmp = tempfile::tempdir().unwrap();
389 let src = tmp.path().join("src.ts");
390 std::fs::write(&src, b"a").unwrap();
391 let anchor1 = tmp.path().join("anchor1");
392 let anchor2 = tmp.path().join("anchor2");
393 let key1 = CacheKey::from_path(&src, &anchor1).unwrap();
394 let key2 = CacheKey::from_path(&src, &anchor2).unwrap();
395 store(tmp.path(), &key1, &graph_with_one_def());
396 assert!(load(tmp.path(), &key1).is_some());
397 assert!(load(tmp.path(), &key2).is_none());
398 }
399
400 #[test]
401 fn load_misses_when_context_changes() {
402 let tmp = tempfile::tempdir().unwrap();
403 let src = tmp.path().join("src.ts");
404 std::fs::write(&src, b"export class Foo {}\n").unwrap();
405 let anchor = tmp.path().join("anchor");
406 let ctx_one = extract::Context {
407 project: Some("one".into()),
408 ..extract::Context::default()
409 };
410 let ctx_two = extract::Context {
411 project: Some("two".into()),
412 ..extract::Context::default()
413 };
414 let key1 = CacheKey::from_path_with_context(&src, &anchor, &ctx_one).unwrap();
415 let key2 = CacheKey::from_path_with_context(&src, &anchor, &ctx_two).unwrap();
416
417 store(tmp.path(), &key1, &graph_with_one_def());
418
419 assert!(load(tmp.path(), &key1).is_some());
420 assert!(load(tmp.path(), &key2).is_none());
421 assert_ne!(key1.full_path(tmp.path()), key2.full_path(tmp.path()));
422 }
423
424 #[test]
425 fn load_misses_when_selected_typescript_sdk_profile_changes() {
426 let tmp = tempfile::tempdir().unwrap();
427 let src = tmp.path().join("src.ts");
428 std::fs::write(&src, b"document.createElement('div');\n").unwrap();
429 let config = tmp.path().join("tsconfig.json");
430 let anchor = tmp.path().join("anchor");
431 std::fs::write(&config, r#"{"compilerOptions":{"lib":["ES2022","DOM"]}}"#).unwrap();
432 let dom_context = extract::Context {
433 ts: crate::tsconfig::load(tmp.path()),
434 ..extract::Context::default()
435 };
436 let dom_key = CacheKey::from_path_with_context(&src, &anchor, &dom_context).unwrap();
437 store(tmp.path(), &dom_key, &graph_with_one_def());
438
439 std::fs::write(&config, r#"{"compilerOptions":{"lib":["ES2022"]}}"#).unwrap();
440 let node_context = extract::Context {
441 ts: crate::tsconfig::load(tmp.path()),
442 ..extract::Context::default()
443 };
444 let node_key = CacheKey::from_path_with_context(&src, &anchor, &node_context).unwrap();
445
446 assert_ne!(dom_key.context_hash, node_key.context_hash);
447 assert!(load(tmp.path(), &node_key).is_none());
448 assert_ne!(
449 dom_key.full_path(tmp.path()),
450 node_key.full_path(tmp.path())
451 );
452 }
453
454 #[test]
455 fn load_rejects_previous_semantic_format_version() {
456 let tmp = tempfile::tempdir().unwrap();
457 let src = tmp.path().join("src.ts");
458 std::fs::write(&src, b"export class Foo {}\n").unwrap();
459 let key = CacheKey::from_path(&src, tmp.path()).unwrap();
460 store(tmp.path(), &key, &graph_with_one_def());
461
462 let path = key.full_path(tmp.path());
463 let mut bytes = std::fs::read(&path).unwrap();
464 bytes[OFF_FORMAT..OFF_MTIME]
465 .copy_from_slice(&CACHE_FORMAT_VERSION.saturating_sub(1).to_le_bytes());
466 std::fs::write(path, bytes).unwrap();
467
468 assert!(
469 load(tmp.path(), &key).is_none(),
470 "cache entries from the pre-SDK semantic format must be rejected"
471 );
472 }
473
474 #[test]
475 fn load_returns_none_on_empty_dir() {
476 let tmp = tempfile::tempdir().unwrap();
477 let src = tmp.path().join("src.ts");
478 std::fs::write(&src, b"a").unwrap();
479 let key = CacheKey::from_path(&src, tmp.path()).unwrap();
480 assert!(load(tmp.path(), &key).is_none());
481 }
482
483 #[test]
484 fn cache_path_is_versioned_and_sharded() {
485 let tmp = tempfile::tempdir().unwrap();
486 let src = tmp.path().join("src.ts");
487 std::fs::write(&src, b"a").unwrap();
488 let key = CacheKey::from_path(&src, tmp.path()).unwrap();
489 let full = key.full_path(tmp.path());
490 let s = full.to_string_lossy();
491 assert!(s.contains(&format!("v{LAYOUT_VERSION}_{CACHE_FORMAT_VERSION}")));
492 assert!(full.parent().unwrap().file_name().unwrap().len() == 2);
493 }
494}