1use std::io::{self, Write};
49use std::path::{Path, PathBuf};
50
51use serde::{Serialize, de::DeserializeOwned};
52
53#[derive(Debug, Clone, PartialEq, Eq, Hash)]
61pub struct CacheKey {
62 uri_hash: String,
63 content_hash: String,
64}
65
66impl CacheKey {
67 fn as_filename(&self) -> &str {
68 &self.uri_hash
69 }
70}
71
72#[derive(Debug, Clone)]
76pub struct WorkspaceCache {
77 dir: PathBuf,
78}
79
80pub const CACHE_SIZE_CAP: u64 = 512 * 1024 * 1024;
87
88impl WorkspaceCache {
89 pub fn new(root: &Path) -> Option<Self> {
101 let base = cache_base_dir()?;
102 let schema = schema_version();
103 let php_lsp_dir = base.join("php-lsp");
104 drop(prune_stale_schema_dirs(&php_lsp_dir, schema));
105 let workspace = workspace_hash(root);
106 let dir = php_lsp_dir.join(schema).join(workspace);
107 std::fs::create_dir_all(&dir).ok()?;
108 let cache = Self { dir };
109 if cache.size_bytes().unwrap_or(0) > CACHE_SIZE_CAP {
110 let _ = cache.clear();
111 }
112 Some(cache)
113 }
114
115 pub fn cache_dir(&self) -> &std::path::Path {
117 &self.dir
118 }
119
120 pub fn size_bytes(&self) -> io::Result<u64> {
125 dir_size(&self.dir)
126 }
127
128 pub fn with_dir(dir: PathBuf) -> Self {
132 Self { dir }
133 }
134
135 pub fn key_for(uri: &str, content: &str) -> CacheKey {
140 let uri_hash = blake3::hash(uri.as_bytes()).to_hex().as_str()[..32].to_string();
141 let content_hash = blake3::hash(content.as_bytes()).to_hex().as_str()[..32].to_string();
142 CacheKey {
143 uri_hash,
144 content_hash,
145 }
146 }
147
148 pub fn read<T: DeserializeOwned>(&self, key: &CacheKey) -> Option<T> {
154 let path = self.path_for(key);
155 let bytes = std::fs::read(&path).ok()?;
156 let config = bincode::config::standard();
157 let (stored_hash, value): (String, T) =
158 bincode::serde::decode_from_slice(&bytes, config).ok()?.0;
159 if stored_hash != key.content_hash {
160 return None;
161 }
162 Some(value)
163 }
164
165 pub fn write<T: Serialize>(&self, key: &CacheKey, value: &T) -> io::Result<()> {
177 let path = self.path_for(key);
178 let tmp = path.with_extension("tmp");
179 let config = bincode::config::standard();
180 let bytes = bincode::serde::encode_to_vec((&key.content_hash, value), config)
181 .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
182 {
183 let mut f = std::fs::File::create(&tmp)?;
184 f.write_all(&bytes)?;
185 }
186 std::fs::rename(&tmp, &path)?;
187 Ok(())
188 }
189
190 pub fn clear(&self) -> io::Result<()> {
195 if self.dir.exists() {
196 std::fs::remove_dir_all(&self.dir)?;
197 std::fs::create_dir_all(&self.dir)?;
198 }
199 Ok(())
200 }
201
202 fn path_for(&self, key: &CacheKey) -> PathBuf {
203 self.dir.join(format!("{}.bin", key.as_filename()))
204 }
205}
206
207fn dir_size(dir: &Path) -> io::Result<u64> {
212 let mut total = 0u64;
213 let entries = match std::fs::read_dir(dir) {
214 Ok(e) => e,
215 Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(0),
216 Err(e) => return Err(e),
217 };
218 for entry in entries.flatten() {
219 let meta = match entry.metadata() {
220 Ok(m) => m,
221 Err(_) => continue,
222 };
223 if meta.is_dir() {
224 total = total.saturating_add(dir_size(&entry.path())?);
225 } else if meta.is_file() {
226 total = total.saturating_add(meta.len());
227 }
228 }
229 Ok(total)
230}
231
232fn prune_stale_schema_dirs(
254 php_lsp_dir: &Path,
255 current_schema: &str,
256) -> Option<std::thread::JoinHandle<()>> {
257 let entries = std::fs::read_dir(php_lsp_dir).ok()?;
258 let stale: Vec<PathBuf> = entries
259 .flatten()
260 .filter(|entry| {
261 entry.file_name() != current_schema && entry.file_type().is_ok_and(|t| t.is_dir())
262 })
263 .map(|entry| entry.path())
264 .collect();
265 if stale.is_empty() {
266 return None;
267 }
268 Some(std::thread::spawn(move || {
269 for dir in stale {
270 let _ = std::fs::remove_dir_all(dir);
271 }
272 }))
273}
274
275pub(crate) fn cache_base_dir() -> Option<PathBuf> {
279 if let Some(xdg) = std::env::var_os("XDG_CACHE_HOME")
280 && !xdg.is_empty()
281 {
282 return Some(PathBuf::from(xdg));
283 }
284 if cfg!(windows) {
285 if let Some(local) = std::env::var_os("LOCALAPPDATA")
286 && !local.is_empty()
287 {
288 return Some(PathBuf::from(local));
289 }
290 } else if let Some(home) = std::env::var_os("HOME")
291 && !home.is_empty()
292 {
293 return Some(PathBuf::from(home).join(".cache"));
294 }
295 None
296}
297
298fn schema_version() -> &'static str {
306 concat!(env!("CARGO_PKG_VERSION"), "-fi-v5")
307}
308
309fn workspace_hash(root: &Path) -> String {
310 let canonical = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
311 let hex = blake3::hash(canonical.as_os_str().as_encoded_bytes()).to_hex();
312 hex.as_str()[..16].to_string()
313}
314
315#[cfg(test)]
316mod tests {
317 use super::*;
318 use tempfile::TempDir;
319
320 #[derive(Serialize, serde::Deserialize, PartialEq, Debug)]
321 struct SamplePayload {
322 name: String,
323 values: Vec<u32>,
324 }
325
326 #[test]
327 fn key_for_is_deterministic_per_uri_and_content() {
328 let k1 = WorkspaceCache::key_for("file:///a.php", "<?php echo 1;");
329 let k2 = WorkspaceCache::key_for("file:///a.php", "<?php echo 1;");
330 assert_eq!(k1, k2);
331 }
332
333 #[test]
334 fn key_for_differs_when_content_differs() {
335 let k1 = WorkspaceCache::key_for("file:///a.php", "<?php echo 1;");
336 let k2 = WorkspaceCache::key_for("file:///a.php", "<?php echo 2;");
337 assert_ne!(k1, k2);
338 }
339
340 #[test]
341 fn key_for_differs_when_uri_differs() {
342 let k1 = WorkspaceCache::key_for("file:///a.php", "<?php");
343 let k2 = WorkspaceCache::key_for("file:///b.php", "<?php");
344 assert_ne!(k1, k2);
345 }
346
347 #[test]
348 fn write_overwrites_slot_instead_of_orphaning_old_revision() {
349 let dir = TempDir::new().unwrap();
350 let cache = WorkspaceCache::with_dir(dir.path().to_path_buf());
351 let uri = "file:///churn.php";
352
353 let key_v1 = WorkspaceCache::key_for(uri, "<?php echo 1;");
354 cache
355 .write(
356 &key_v1,
357 &SamplePayload {
358 name: "v1".into(),
359 values: vec![1],
360 },
361 )
362 .unwrap();
363
364 let key_v2 = WorkspaceCache::key_for(uri, "<?php echo 2;");
365 cache
366 .write(
367 &key_v2,
368 &SamplePayload {
369 name: "v2".into(),
370 values: vec![2],
371 },
372 )
373 .unwrap();
374
375 let bin_files: Vec<_> = std::fs::read_dir(dir.path())
378 .unwrap()
379 .filter_map(|e| e.ok())
380 .filter(|e| e.path().extension().is_some_and(|ext| ext == "bin"))
381 .collect();
382 assert_eq!(
383 bin_files.len(),
384 1,
385 "editing a file must not orphan its previous cache entry"
386 );
387
388 let stale: Option<SamplePayload> = cache.read(&key_v1);
390 assert!(stale.is_none());
391
392 let current: SamplePayload = cache.read(&key_v2).unwrap();
393 assert_eq!(current.name, "v2");
394 }
395
396 #[test]
397 fn write_then_read_round_trips() {
398 let dir = TempDir::new().unwrap();
399 let cache = WorkspaceCache::with_dir(dir.path().to_path_buf());
400 let key = WorkspaceCache::key_for("file:///x.php", "<?php");
401 let payload = SamplePayload {
402 name: "x".into(),
403 values: vec![1, 2, 3],
404 };
405 cache.write(&key, &payload).unwrap();
406 let decoded: SamplePayload = cache.read(&key).unwrap();
407 assert_eq!(decoded, payload);
408 }
409
410 #[test]
411 fn read_returns_none_for_missing_key() {
412 let dir = TempDir::new().unwrap();
413 let cache = WorkspaceCache::with_dir(dir.path().to_path_buf());
414 let missing = WorkspaceCache::key_for("file:///nope.php", "");
415 let decoded: Option<SamplePayload> = cache.read(&missing);
416 assert!(decoded.is_none());
417 }
418
419 #[test]
420 fn read_returns_none_for_corrupted_entry() {
421 let dir = TempDir::new().unwrap();
422 let cache = WorkspaceCache::with_dir(dir.path().to_path_buf());
423 let key = WorkspaceCache::key_for("file:///c.php", "<?php");
424 std::fs::write(cache.path_for(&key), b"not valid bincode").unwrap();
426 let decoded: Option<SamplePayload> = cache.read(&key);
427 assert!(
428 decoded.is_none(),
429 "corrupted entry must look missing, not panic"
430 );
431 }
432
433 #[test]
434 fn write_is_atomic_via_rename() {
435 let dir = TempDir::new().unwrap();
440 let cache = WorkspaceCache::with_dir(dir.path().to_path_buf());
441 let key = WorkspaceCache::key_for("file:///atomic.php", "<?php");
442 let payload = SamplePayload {
443 name: "a".into(),
444 values: vec![],
445 };
446 cache.write(&key, &payload).unwrap();
447 let tmp = cache.path_for(&key).with_extension("tmp");
448 assert!(!tmp.exists(), "tmp file should be removed by rename");
449 }
450
451 #[test]
452 fn clear_drops_all_entries() {
453 let dir = TempDir::new().unwrap();
454 let cache = WorkspaceCache::with_dir(dir.path().to_path_buf());
455 for i in 0..3 {
456 let k = WorkspaceCache::key_for(&format!("file:///c{i}.php"), "");
457 cache
458 .write(
459 &k,
460 &SamplePayload {
461 name: i.to_string(),
462 values: vec![],
463 },
464 )
465 .unwrap();
466 }
467 cache.clear().unwrap();
468 for i in 0..3 {
469 let k = WorkspaceCache::key_for(&format!("file:///c{i}.php"), "");
470 let decoded: Option<SamplePayload> = cache.read(&k);
471 assert!(decoded.is_none());
472 }
473 }
474
475 #[test]
476 fn size_bytes_sums_flat_bin_files() {
477 let dir = TempDir::new().unwrap();
478 let cache = WorkspaceCache::with_dir(dir.path().to_path_buf());
479 assert_eq!(cache.size_bytes().unwrap(), 0);
480
481 let key1 = WorkspaceCache::key_for("file:///s1.php", "<?php");
482 cache
483 .write(
484 &key1,
485 &SamplePayload {
486 name: "s1".into(),
487 values: vec![0u32; 16],
488 },
489 )
490 .unwrap();
491 let key2 = WorkspaceCache::key_for("file:///s2.php", "<?php");
492 cache
493 .write(
494 &key2,
495 &SamplePayload {
496 name: "s2".into(),
497 values: vec![0u32; 16],
498 },
499 )
500 .unwrap();
501
502 let total = cache.size_bytes().unwrap();
503 let expected1 = cache.path_for(&key1).metadata().unwrap().len();
504 let expected2 = cache.path_for(&key2).metadata().unwrap().len();
505 assert_eq!(total, expected1 + expected2);
506 }
507
508 #[test]
509 fn size_bytes_recurses_into_nested_session_dir() {
510 let dir = TempDir::new().unwrap();
513 let cache = WorkspaceCache::with_dir(dir.path().to_path_buf());
514
515 let key = WorkspaceCache::key_for("file:///s.php", "<?php");
516 cache
517 .write(
518 &key,
519 &SamplePayload {
520 name: "s".into(),
521 values: vec![0u32; 16],
522 },
523 )
524 .unwrap();
525
526 let session_dir = dir.path().join("session");
527 std::fs::create_dir_all(&session_dir).unwrap();
528 std::fs::write(session_dir.join("cache.bin"), vec![0u8; 128]).unwrap();
529
530 let flat_len = cache.path_for(&key).metadata().unwrap().len();
531 assert_eq!(cache.size_bytes().unwrap(), flat_len + 128);
532 }
533
534 #[test]
535 fn file_index_round_trips() {
536 use crate::document::ast::ParsedDoc;
537 use crate::index::file_index::FileIndex;
538
539 let dir = TempDir::new().unwrap();
540 let cache = WorkspaceCache::with_dir(dir.path().to_path_buf());
541 let src = "<?php\nnamespace App;\nclass Foo { public function bar(): string {} }";
542 let key = WorkspaceCache::key_for("file:///Foo.php", src);
543
544 let doc = ParsedDoc::parse(src.to_string());
545 let index = FileIndex::extract(&doc);
546 cache.write(&key, &index).unwrap();
547
548 let decoded: FileIndex = cache.read(&key).unwrap();
549 assert_eq!(decoded.namespace.as_deref(), Some("App"));
550 assert_eq!(decoded.classes.len(), 1);
551 assert_eq!(decoded.classes[0].name.as_ref(), "Foo");
552 assert_eq!(decoded.classes[0].methods.len(), 1);
553 assert_eq!(decoded.classes[0].methods[0].name.as_ref(), "bar");
554 }
555
556 #[test]
557 fn prune_stale_schema_dirs_removes_old_schemas_keeps_current_and_flat_files() {
558 let dir = TempDir::new().unwrap();
559 let php_lsp_dir = dir.path();
560
561 let stale = php_lsp_dir.join("0.21.0-fi-v3");
562 std::fs::create_dir_all(stale.join("workspacehash")).unwrap();
563 std::fs::write(stale.join("workspacehash").join("a.bin"), b"old").unwrap();
564
565 let current = php_lsp_dir.join("0.22.0-fi-v4");
566 std::fs::create_dir_all(¤t).unwrap();
567
568 std::fs::write(php_lsp_dir.join("php-binary-version.json"), b"{}").unwrap();
570
571 prune_stale_schema_dirs(php_lsp_dir, "0.22.0-fi-v4")
572 .expect("a stale dir is present, so a cleanup thread must be spawned")
573 .join()
574 .unwrap();
575
576 assert!(!stale.exists(), "stale schema directory must be removed");
577 assert!(current.exists(), "current schema directory must survive");
578 assert!(
579 php_lsp_dir.join("php-binary-version.json").exists(),
580 "flat sibling files must not be touched"
581 );
582 }
583}