Skip to main content

code_moniker_workspace/
cache.rs

1// code-moniker: ignore-file[smell-harmonious-method-size]
2// TODO(smell): keep CacheKey as a narrow cache-identity value object; revisit this suppression if hashing, path metadata, or graph IO responsibilities grow further.
3use 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;
18// Bump when cached graph semantics change, even if the binary layout stays stable.
19const 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}