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 = 8;
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	load_or_extract_result_with(path, anchor, lang, cache_dir, ctx, read_source)
168}
169
170pub(crate) fn load_or_extract_workspace_result(
171	path: &Path,
172	anchor: &Path,
173	lang: Lang,
174	cache_dir: Option<&Path>,
175	ctx: &extract::Context,
176) -> io::Result<(CodeGraph, Option<String>)> {
177	load_or_extract_result_with(path, anchor, lang, cache_dir, ctx, read_source_lossy)
178}
179
180fn load_or_extract_result_with(
181	path: &Path,
182	anchor: &Path,
183	lang: Lang,
184	cache_dir: Option<&Path>,
185	ctx: &extract::Context,
186	read: fn(&Path) -> io::Result<String>,
187) -> io::Result<(CodeGraph, Option<String>)> {
188	if let Some(dir) = cache_dir
189		&& let Ok(key) = CacheKey::from_path_with_context(path, anchor, ctx)
190	{
191		if let Some(g) = load(dir, &key) {
192			return Ok((g, None));
193		}
194		let source = read(path)?;
195		let graph = extract::extract_with(lang, &source, anchor, ctx);
196		store(dir, &key, &graph);
197		return Ok((graph, Some(source)));
198	}
199	let source = read(path)?;
200	let graph = extract::extract_with(lang, &source, anchor, ctx);
201	Ok((graph, Some(source)))
202}
203
204pub(crate) fn read_source(path: &Path) -> io::Result<String> {
205	fs::read_to_string(path)
206}
207
208pub(crate) fn read_source_lossy(path: &Path) -> io::Result<String> {
209	fs::read(path).map(|bytes| String::from_utf8_lossy(&bytes).into_owned())
210}
211
212fn validate_header<'a>(bytes: &'a [u8], key: &CacheKey) -> Option<&'a [u8]> {
213	if bytes.len() < HEADER_FIXED {
214		return None;
215	}
216	let magic = u32::from_le_bytes(bytes[OFF_MAGIC..OFF_FORMAT].try_into().ok()?);
217	if magic != CACHE_MAGIC {
218		return None;
219	}
220	let version = u32::from_le_bytes(bytes[OFF_FORMAT..OFF_MTIME].try_into().ok()?);
221	if version != CACHE_FORMAT_VERSION {
222		return None;
223	}
224	let mtime = u64::from_le_bytes(bytes[OFF_MTIME..OFF_SIZE].try_into().ok()?);
225	let size = u64::from_le_bytes(bytes[OFF_SIZE..OFF_ANCHOR].try_into().ok()?);
226	let anchor_hash = u64::from_le_bytes(bytes[OFF_ANCHOR..OFF_CONTEXT].try_into().ok()?);
227	let context_hash = u64::from_le_bytes(bytes[OFF_CONTEXT..OFF_PATH_LEN].try_into().ok()?);
228	if mtime != key.mtime
229		|| size != key.size
230		|| anchor_hash != key.anchor_hash
231		|| context_hash != key.context_hash
232	{
233		return None;
234	}
235	let path_len = u32::from_le_bytes(bytes[OFF_PATH_LEN..HEADER_FIXED].try_into().ok()?) as usize;
236	if HEADER_FIXED + path_len > bytes.len() {
237		return None;
238	}
239	let stored_path = &bytes[HEADER_FIXED..HEADER_FIXED + path_len];
240	if stored_path != key.abs_path_bytes() {
241		return None;
242	}
243	Some(&bytes[HEADER_FIXED + path_len..])
244}
245
246#[cfg(unix)]
247fn path_bytes(p: &Path) -> &[u8] {
248	use std::os::unix::ffi::OsStrExt;
249	p.as_os_str().as_bytes()
250}
251
252#[cfg(not(unix))]
253fn path_bytes(p: &Path) -> &[u8] {
254	p.to_str().map(|s| s.as_bytes()).unwrap_or(&[])
255}
256
257fn hash_path(p: &Path) -> u64 {
258	let mut h = FxHasher::default();
259	path_bytes(p).hash(&mut h);
260	h.finish()
261}
262
263fn hash_context(ctx: &extract::Context) -> u64 {
264	let mut h = FxHasher::default();
265	ctx.project.hash(&mut h);
266	ctx.ts.aliases.len().hash(&mut h);
267	for alias in &ctx.ts.aliases {
268		alias.pattern.hash(&mut h);
269		alias.substitution.hash(&mut h);
270	}
271	h.finish()
272}
273
274#[cfg(test)]
275mod tests {
276	use super::*;
277	use code_moniker_core::core::moniker::MonikerBuilder;
278
279	fn graph_with_one_def() -> CodeGraph {
280		let root = MonikerBuilder::new()
281			.project(b"app")
282			.segment(b"path", b"root")
283			.build();
284		let mut g = CodeGraph::new(root.clone(), b"module");
285		let child = MonikerBuilder::new()
286			.project(b"app")
287			.segment(b"path", b"root")
288			.segment(b"class", b"Foo")
289			.build();
290		g.add_def(child, b"class", &root, Some((0, 10))).unwrap();
291		g
292	}
293
294	#[test]
295	fn store_then_load_roundtrips() {
296		let tmp = tempfile::tempdir().unwrap();
297		let src = tmp.path().join("src.ts");
298		std::fs::write(&src, b"export class Foo {}\n").unwrap();
299		let anchor = tmp.path().join("anchor");
300		let key = CacheKey::from_path(&src, &anchor).unwrap();
301		let g = graph_with_one_def();
302
303		store(tmp.path(), &key, &g);
304		let back = load(tmp.path(), &key).expect("should hit");
305		assert_eq!(back.def_count(), g.def_count());
306	}
307
308	#[test]
309	fn workspace_load_or_extract_accepts_non_utf8_source_bytes() {
310		let tmp = tempfile::tempdir().unwrap();
311		let src = tmp.path().join("legacy.c");
312		std::fs::write(&src, b"int value; /* legacy: \x96 */\n").unwrap();
313		let anchor = tmp.path().join("anchor");
314
315		let (graph, source) = load_or_extract_workspace_result(
316			&src,
317			&anchor,
318			Lang::C,
319			None,
320			&extract::Context::default(),
321		)
322		.expect("legacy source should be indexed lossily");
323
324		assert!(source.expect("source text").contains('\u{fffd}'));
325		assert!(graph.defs().any(|definition| {
326			definition
327				.moniker
328				.as_view()
329				.segments()
330				.last()
331				.is_some_and(|segment| segment.name == b"value")
332		}));
333	}
334
335	#[test]
336	fn load_misses_when_mtime_changes() {
337		let tmp = tempfile::tempdir().unwrap();
338		let src = tmp.path().join("src.ts");
339		std::fs::write(&src, b"a").unwrap();
340		let anchor = tmp.path().join("anchor");
341		let key = CacheKey::from_path(&src, &anchor).unwrap();
342		store(tmp.path(), &key, &graph_with_one_def());
343
344		std::thread::sleep(std::time::Duration::from_millis(10));
345		std::fs::write(&src, b"ab").unwrap();
346		let key2 = CacheKey::from_path(&src, &anchor).unwrap();
347		assert!(key2.mtime != key.mtime || key2.size != key.size);
348		assert!(load(tmp.path(), &key2).is_none());
349	}
350
351	#[test]
352	fn load_misses_when_anchor_changes() {
353		let tmp = tempfile::tempdir().unwrap();
354		let src = tmp.path().join("src.ts");
355		std::fs::write(&src, b"a").unwrap();
356		let anchor1 = tmp.path().join("anchor1");
357		let anchor2 = tmp.path().join("anchor2");
358		let key1 = CacheKey::from_path(&src, &anchor1).unwrap();
359		let key2 = CacheKey::from_path(&src, &anchor2).unwrap();
360		store(tmp.path(), &key1, &graph_with_one_def());
361		assert!(load(tmp.path(), &key1).is_some());
362		assert!(load(tmp.path(), &key2).is_none());
363	}
364
365	#[test]
366	fn load_misses_when_context_changes() {
367		let tmp = tempfile::tempdir().unwrap();
368		let src = tmp.path().join("src.ts");
369		std::fs::write(&src, b"export class Foo {}\n").unwrap();
370		let anchor = tmp.path().join("anchor");
371		let ctx_one = extract::Context {
372			project: Some("one".into()),
373			..extract::Context::default()
374		};
375		let ctx_two = extract::Context {
376			project: Some("two".into()),
377			..extract::Context::default()
378		};
379		let key1 = CacheKey::from_path_with_context(&src, &anchor, &ctx_one).unwrap();
380		let key2 = CacheKey::from_path_with_context(&src, &anchor, &ctx_two).unwrap();
381
382		store(tmp.path(), &key1, &graph_with_one_def());
383
384		assert!(load(tmp.path(), &key1).is_some());
385		assert!(load(tmp.path(), &key2).is_none());
386		assert_ne!(key1.full_path(tmp.path()), key2.full_path(tmp.path()));
387	}
388
389	#[test]
390	fn load_rejects_previous_semantic_format_version() {
391		let tmp = tempfile::tempdir().unwrap();
392		let src = tmp.path().join("src.ts");
393		std::fs::write(&src, b"export class Foo {}\n").unwrap();
394		let key = CacheKey::from_path(&src, tmp.path()).unwrap();
395		store(tmp.path(), &key, &graph_with_one_def());
396
397		let path = key.full_path(tmp.path());
398		let mut bytes = std::fs::read(&path).unwrap();
399		bytes[OFF_FORMAT..OFF_MTIME]
400			.copy_from_slice(&CACHE_FORMAT_VERSION.saturating_sub(1).to_le_bytes());
401		std::fs::write(path, bytes).unwrap();
402
403		assert!(
404			load(tmp.path(), &key).is_none(),
405			"cache entries from the pre-SDK semantic format must be rejected"
406		);
407	}
408
409	#[test]
410	fn load_returns_none_on_empty_dir() {
411		let tmp = tempfile::tempdir().unwrap();
412		let src = tmp.path().join("src.ts");
413		std::fs::write(&src, b"a").unwrap();
414		let key = CacheKey::from_path(&src, tmp.path()).unwrap();
415		assert!(load(tmp.path(), &key).is_none());
416	}
417
418	#[test]
419	fn cache_path_is_versioned_and_sharded() {
420		let tmp = tempfile::tempdir().unwrap();
421		let src = tmp.path().join("src.ts");
422		std::fs::write(&src, b"a").unwrap();
423		let key = CacheKey::from_path(&src, tmp.path()).unwrap();
424		let full = key.full_path(tmp.path());
425		let s = full.to_string_lossy();
426		assert!(s.contains(&format!("v{LAYOUT_VERSION}_{CACHE_FORMAT_VERSION}")));
427		assert!(full.parent().unwrap().file_name().unwrap().len() == 2);
428	}
429}