1use std::io::{self, Write};
52use std::path::{Path, PathBuf};
53use std::time::{Duration, SystemTime, UNIX_EPOCH};
54
55use serde::{Serialize, de::DeserializeOwned};
56
57#[derive(Debug, Clone, PartialEq, Eq, Hash)]
65pub struct CacheKey {
66 uri_hash: String,
67 content_hash: String,
68}
69
70impl CacheKey {
71 fn as_filename(&self) -> &str {
72 &self.uri_hash
73 }
74}
75
76#[derive(Debug, Clone)]
80pub struct WorkspaceCache {
81 dir: PathBuf,
82}
83
84pub const CACHE_SIZE_CAP: u64 = 512 * 1024 * 1024;
91
92pub const TOTAL_CACHE_SIZE_CAP: u64 = 2 * 1024 * 1024 * 1024;
96
97const WORKSPACE_MAX_IDLE: Duration = Duration::from_secs(30 * 24 * 60 * 60);
98
99const NO_META_GRACE: Duration = Duration::from_secs(24 * 60 * 60);
102
103const META_FILE: &str = "workspace.json";
104
105impl WorkspaceCache {
106 pub fn new(root: &Path) -> Option<Self> {
118 let base = cache_base_dir()?;
119 let schema = schema_version();
120 let php_lsp_dir = base.join("php-lsp");
121 let workspace = workspace_hash(root);
122 let dir = php_lsp_dir.join(schema).join(&workspace);
123 std::fs::create_dir_all(&dir).ok()?;
124 let _ = write_workspace_meta(&dir, root);
125 drop(prune_stale_dirs(php_lsp_dir, schema.to_string(), workspace));
126 let cache = Self { dir };
127 if cache.size_bytes().unwrap_or(0) > CACHE_SIZE_CAP {
128 let _ = cache.clear();
129 }
130 Some(cache)
131 }
132
133 pub fn cache_dir(&self) -> &std::path::Path {
135 &self.dir
136 }
137
138 pub fn size_bytes(&self) -> io::Result<u64> {
143 dir_size(&self.dir)
144 }
145
146 pub fn with_dir(dir: PathBuf) -> Self {
150 Self { dir }
151 }
152
153 pub fn key_for(uri: &str, content: &str) -> CacheKey {
158 let uri_hash = blake3::hash(uri.as_bytes()).to_hex().as_str()[..32].to_string();
159 let content_hash = blake3::hash(content.as_bytes()).to_hex().as_str()[..32].to_string();
160 CacheKey {
161 uri_hash,
162 content_hash,
163 }
164 }
165
166 pub fn read<T: DeserializeOwned>(&self, key: &CacheKey) -> Option<T> {
172 let path = self.path_for(key);
173 let bytes = std::fs::read(&path).ok()?;
174 let config = bincode::config::standard();
175 let (stored_hash, value): (String, T) =
176 bincode::serde::decode_from_slice(&bytes, config).ok()?.0;
177 if stored_hash != key.content_hash {
178 return None;
179 }
180 Some(value)
181 }
182
183 pub fn write<T: Serialize>(&self, key: &CacheKey, value: &T) -> io::Result<()> {
195 let path = self.path_for(key);
196 let tmp = path.with_extension("tmp");
197 let config = bincode::config::standard();
198 let bytes = bincode::serde::encode_to_vec((&key.content_hash, value), config)
199 .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
200 {
201 let mut f = std::fs::File::create(&tmp)?;
202 f.write_all(&bytes)?;
203 }
204 std::fs::rename(&tmp, &path)?;
205 Ok(())
206 }
207
208 pub fn clear(&self) -> io::Result<()> {
213 if self.dir.exists() {
214 std::fs::remove_dir_all(&self.dir)?;
215 std::fs::create_dir_all(&self.dir)?;
216 }
217 Ok(())
218 }
219
220 fn path_for(&self, key: &CacheKey) -> PathBuf {
221 self.dir.join(format!("{}.bin", key.as_filename()))
222 }
223}
224
225fn dir_size(dir: &Path) -> io::Result<u64> {
230 let mut total = 0u64;
231 let entries = match std::fs::read_dir(dir) {
232 Ok(e) => e,
233 Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(0),
234 Err(e) => return Err(e),
235 };
236 for entry in entries.flatten() {
237 let meta = match entry.metadata() {
238 Ok(m) => m,
239 Err(_) => continue,
240 };
241 if meta.is_dir() {
242 total = total.saturating_add(dir_size(&entry.path())?);
243 } else if meta.is_file() {
244 total = total.saturating_add(meta.len());
245 }
246 }
247 Ok(total)
248}
249
250fn prune_stale_dirs(
268 php_lsp_dir: PathBuf,
269 current_schema: String,
270 current_workspace: String,
271) -> std::thread::JoinHandle<()> {
272 std::thread::spawn(move || {
273 let now = SystemTime::now();
274 for dir in subdirs(&php_lsp_dir) {
275 if dir.file_name() != Some(current_schema.as_ref()) {
276 let _ = std::fs::remove_dir_all(dir);
277 }
278 }
279 let schema_dir = php_lsp_dir.join(¤t_schema);
280 for dir in subdirs(&schema_dir) {
281 if dir.file_name() != Some(current_workspace.as_ref())
282 && is_stale_workspace_dir(&dir, now)
283 {
284 let _ = std::fs::remove_dir_all(dir);
285 }
286 }
287 evict_lru_workspaces(&schema_dir, ¤t_workspace, TOTAL_CACHE_SIZE_CAP);
288 })
289}
290
291fn evict_lru_workspaces(schema_dir: &Path, current_workspace: &str, cap: u64) {
295 let mut total = 0u64;
296 let mut candidates = Vec::new();
297 for dir in subdirs(schema_dir) {
298 let size = dir_size(&dir).unwrap_or(0);
299 total = total.saturating_add(size);
300 if dir.file_name() != Some(current_workspace.as_ref()) {
301 let used = last_used(&dir, read_workspace_meta(&dir).as_ref());
302 candidates.push((used, size, dir));
303 }
304 }
305 candidates.sort_by_key(|(used, _, _)| *used);
306 for (_, size, dir) in candidates {
307 if total <= cap {
308 break;
309 }
310 if std::fs::remove_dir_all(&dir).is_ok() {
311 total -= size;
312 }
313 }
314}
315
316fn subdirs(dir: &Path) -> Vec<PathBuf> {
317 let Ok(entries) = std::fs::read_dir(dir) else {
318 return Vec::new();
319 };
320 entries
321 .flatten()
322 .filter(|entry| entry.file_type().is_ok_and(|t| t.is_dir()))
323 .map(|entry| entry.path())
324 .collect()
325}
326
327#[derive(Serialize, serde::Deserialize)]
328struct WorkspaceMeta {
329 root: PathBuf,
330 last_used_secs: u64,
331}
332
333fn write_workspace_meta(dir: &Path, root: &Path) -> io::Result<()> {
334 let meta = WorkspaceMeta {
335 root: root.canonicalize().unwrap_or_else(|_| root.to_path_buf()),
336 last_used_secs: SystemTime::now()
337 .duration_since(UNIX_EPOCH)
338 .map_or(0, |d| d.as_secs()),
339 };
340 let path = dir.join(META_FILE);
341 let tmp = path.with_extension("json.tmp");
342 std::fs::write(&tmp, serde_json::to_vec(&meta).map_err(io::Error::other)?)?;
343 std::fs::rename(&tmp, &path)
344}
345
346fn is_stale_workspace_dir(dir: &Path, now: SystemTime) -> bool {
352 let idle_since = |t: SystemTime| now.duration_since(t).unwrap_or(Duration::ZERO);
353 let Some(meta) = read_workspace_meta(dir) else {
354 return idle_since(last_used(dir, None)) > NO_META_GRACE;
355 };
356 if !meta.root.exists() {
357 return true;
358 }
359 idle_since(last_used(dir, Some(&meta))) > WORKSPACE_MAX_IDLE
360}
361
362fn read_workspace_meta(dir: &Path) -> Option<WorkspaceMeta> {
363 let bytes = std::fs::read(dir.join(META_FILE)).ok()?;
364 serde_json::from_slice(&bytes).ok()
365}
366
367fn last_used(dir: &Path, meta: Option<&WorkspaceMeta>) -> SystemTime {
370 let mtime = std::fs::metadata(dir)
371 .and_then(|m| m.modified())
372 .unwrap_or_else(|_| SystemTime::now());
373 let stamped = meta.map_or(UNIX_EPOCH, |m| {
374 UNIX_EPOCH + Duration::from_secs(m.last_used_secs)
375 });
376 stamped.max(mtime)
377}
378
379pub(crate) fn cache_base_dir() -> Option<PathBuf> {
383 if let Some(xdg) = std::env::var_os("XDG_CACHE_HOME")
384 && !xdg.is_empty()
385 {
386 return Some(PathBuf::from(xdg));
387 }
388 if cfg!(windows) {
389 if let Some(local) = std::env::var_os("LOCALAPPDATA")
390 && !local.is_empty()
391 {
392 return Some(PathBuf::from(local));
393 }
394 } else if let Some(home) = std::env::var_os("HOME")
395 && !home.is_empty()
396 {
397 return Some(PathBuf::from(home).join(".cache"));
398 }
399 None
400}
401
402fn schema_version() -> &'static str {
410 concat!(env!("CARGO_PKG_VERSION"), "-fi-v6")
411}
412
413fn workspace_hash(root: &Path) -> String {
414 let canonical = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
415 let hex = blake3::hash(canonical.as_os_str().as_encoded_bytes()).to_hex();
416 hex.as_str()[..16].to_string()
417}
418
419#[cfg(test)]
420mod tests {
421 use super::*;
422 use tempfile::TempDir;
423
424 #[derive(Serialize, serde::Deserialize, PartialEq, Debug)]
425 struct SamplePayload {
426 name: String,
427 values: Vec<u32>,
428 }
429
430 #[test]
431 fn key_for_is_deterministic_per_uri_and_content() {
432 let k1 = WorkspaceCache::key_for("file:///a.php", "<?php echo 1;");
433 let k2 = WorkspaceCache::key_for("file:///a.php", "<?php echo 1;");
434 assert_eq!(k1, k2);
435 }
436
437 #[test]
438 fn key_for_differs_when_content_differs() {
439 let k1 = WorkspaceCache::key_for("file:///a.php", "<?php echo 1;");
440 let k2 = WorkspaceCache::key_for("file:///a.php", "<?php echo 2;");
441 assert_ne!(k1, k2);
442 }
443
444 #[test]
445 fn key_for_differs_when_uri_differs() {
446 let k1 = WorkspaceCache::key_for("file:///a.php", "<?php");
447 let k2 = WorkspaceCache::key_for("file:///b.php", "<?php");
448 assert_ne!(k1, k2);
449 }
450
451 #[test]
452 fn write_overwrites_slot_instead_of_orphaning_old_revision() {
453 let dir = TempDir::new().unwrap();
454 let cache = WorkspaceCache::with_dir(dir.path().to_path_buf());
455 let uri = "file:///churn.php";
456
457 let key_v1 = WorkspaceCache::key_for(uri, "<?php echo 1;");
458 cache
459 .write(
460 &key_v1,
461 &SamplePayload {
462 name: "v1".into(),
463 values: vec![1],
464 },
465 )
466 .unwrap();
467
468 let key_v2 = WorkspaceCache::key_for(uri, "<?php echo 2;");
469 cache
470 .write(
471 &key_v2,
472 &SamplePayload {
473 name: "v2".into(),
474 values: vec![2],
475 },
476 )
477 .unwrap();
478
479 let bin_files: Vec<_> = std::fs::read_dir(dir.path())
482 .unwrap()
483 .filter_map(|e| e.ok())
484 .filter(|e| e.path().extension().is_some_and(|ext| ext == "bin"))
485 .collect();
486 assert_eq!(
487 bin_files.len(),
488 1,
489 "editing a file must not orphan its previous cache entry"
490 );
491
492 let stale: Option<SamplePayload> = cache.read(&key_v1);
494 assert!(stale.is_none());
495
496 let current: SamplePayload = cache.read(&key_v2).unwrap();
497 assert_eq!(current.name, "v2");
498 }
499
500 #[test]
501 fn write_then_read_round_trips() {
502 let dir = TempDir::new().unwrap();
503 let cache = WorkspaceCache::with_dir(dir.path().to_path_buf());
504 let key = WorkspaceCache::key_for("file:///x.php", "<?php");
505 let payload = SamplePayload {
506 name: "x".into(),
507 values: vec![1, 2, 3],
508 };
509 cache.write(&key, &payload).unwrap();
510 let decoded: SamplePayload = cache.read(&key).unwrap();
511 assert_eq!(decoded, payload);
512 }
513
514 #[test]
515 fn read_returns_none_for_missing_key() {
516 let dir = TempDir::new().unwrap();
517 let cache = WorkspaceCache::with_dir(dir.path().to_path_buf());
518 let missing = WorkspaceCache::key_for("file:///nope.php", "");
519 let decoded: Option<SamplePayload> = cache.read(&missing);
520 assert!(decoded.is_none());
521 }
522
523 #[test]
524 fn read_returns_none_for_corrupted_entry() {
525 let dir = TempDir::new().unwrap();
526 let cache = WorkspaceCache::with_dir(dir.path().to_path_buf());
527 let key = WorkspaceCache::key_for("file:///c.php", "<?php");
528 std::fs::write(cache.path_for(&key), b"not valid bincode").unwrap();
530 let decoded: Option<SamplePayload> = cache.read(&key);
531 assert!(
532 decoded.is_none(),
533 "corrupted entry must look missing, not panic"
534 );
535 }
536
537 #[test]
538 fn write_is_atomic_via_rename() {
539 let dir = TempDir::new().unwrap();
544 let cache = WorkspaceCache::with_dir(dir.path().to_path_buf());
545 let key = WorkspaceCache::key_for("file:///atomic.php", "<?php");
546 let payload = SamplePayload {
547 name: "a".into(),
548 values: vec![],
549 };
550 cache.write(&key, &payload).unwrap();
551 let tmp = cache.path_for(&key).with_extension("tmp");
552 assert!(!tmp.exists(), "tmp file should be removed by rename");
553 }
554
555 #[test]
556 fn clear_drops_all_entries() {
557 let dir = TempDir::new().unwrap();
558 let cache = WorkspaceCache::with_dir(dir.path().to_path_buf());
559 for i in 0..3 {
560 let k = WorkspaceCache::key_for(&format!("file:///c{i}.php"), "");
561 cache
562 .write(
563 &k,
564 &SamplePayload {
565 name: i.to_string(),
566 values: vec![],
567 },
568 )
569 .unwrap();
570 }
571 cache.clear().unwrap();
572 for i in 0..3 {
573 let k = WorkspaceCache::key_for(&format!("file:///c{i}.php"), "");
574 let decoded: Option<SamplePayload> = cache.read(&k);
575 assert!(decoded.is_none());
576 }
577 }
578
579 #[test]
580 fn size_bytes_sums_flat_bin_files() {
581 let dir = TempDir::new().unwrap();
582 let cache = WorkspaceCache::with_dir(dir.path().to_path_buf());
583 assert_eq!(cache.size_bytes().unwrap(), 0);
584
585 let key1 = WorkspaceCache::key_for("file:///s1.php", "<?php");
586 cache
587 .write(
588 &key1,
589 &SamplePayload {
590 name: "s1".into(),
591 values: vec![0u32; 16],
592 },
593 )
594 .unwrap();
595 let key2 = WorkspaceCache::key_for("file:///s2.php", "<?php");
596 cache
597 .write(
598 &key2,
599 &SamplePayload {
600 name: "s2".into(),
601 values: vec![0u32; 16],
602 },
603 )
604 .unwrap();
605
606 let total = cache.size_bytes().unwrap();
607 let expected1 = cache.path_for(&key1).metadata().unwrap().len();
608 let expected2 = cache.path_for(&key2).metadata().unwrap().len();
609 assert_eq!(total, expected1 + expected2);
610 }
611
612 #[test]
613 fn size_bytes_recurses_into_nested_session_dir() {
614 let dir = TempDir::new().unwrap();
617 let cache = WorkspaceCache::with_dir(dir.path().to_path_buf());
618
619 let key = WorkspaceCache::key_for("file:///s.php", "<?php");
620 cache
621 .write(
622 &key,
623 &SamplePayload {
624 name: "s".into(),
625 values: vec![0u32; 16],
626 },
627 )
628 .unwrap();
629
630 let session_dir = dir.path().join("session");
631 std::fs::create_dir_all(&session_dir).unwrap();
632 std::fs::write(session_dir.join("cache.bin"), vec![0u8; 128]).unwrap();
633
634 let flat_len = cache.path_for(&key).metadata().unwrap().len();
635 assert_eq!(cache.size_bytes().unwrap(), flat_len + 128);
636 }
637
638 #[test]
639 fn file_index_round_trips() {
640 use crate::document::ast::ParsedDoc;
641 use crate::index::file_index::FileIndex;
642
643 let dir = TempDir::new().unwrap();
644 let cache = WorkspaceCache::with_dir(dir.path().to_path_buf());
645 let src = "<?php\nnamespace App;\nclass Foo { public function bar(): string {} }";
646 let key = WorkspaceCache::key_for("file:///Foo.php", src);
647
648 let doc = ParsedDoc::parse(src.to_string());
649 let index = FileIndex::extract(&doc);
650 cache.write(&key, &index).unwrap();
651
652 let decoded: FileIndex = cache.read(&key).unwrap();
653 assert_eq!(decoded.namespace.as_deref(), Some("App"));
654 assert_eq!(decoded.classes.len(), 1);
655 assert_eq!(decoded.classes[0].name.as_ref(), "Foo");
656 assert_eq!(decoded.classes[0].methods.len(), 1);
657 assert_eq!(decoded.classes[0].methods[0].name.as_ref(), "bar");
658 }
659
660 #[test]
661 fn prune_stale_dirs_removes_old_schemas_keeps_current_and_flat_files() {
662 let dir = TempDir::new().unwrap();
663 let php_lsp_dir = dir.path();
664
665 let stale = php_lsp_dir.join("0.21.0-fi-v3");
666 std::fs::create_dir_all(stale.join("workspacehash")).unwrap();
667 std::fs::write(stale.join("workspacehash").join("a.bin"), b"old").unwrap();
668
669 let current = php_lsp_dir.join("0.22.0-fi-v4");
670 std::fs::create_dir_all(¤t).unwrap();
671
672 std::fs::write(php_lsp_dir.join("php-binary-version.json"), b"{}").unwrap();
674
675 prune_stale_dirs(
676 php_lsp_dir.to_path_buf(),
677 "0.22.0-fi-v4".into(),
678 "current".into(),
679 )
680 .join()
681 .unwrap();
682
683 assert!(!stale.exists(), "stale schema directory must be removed");
684 assert!(current.exists(), "current schema directory must survive");
685 assert!(
686 php_lsp_dir.join("php-binary-version.json").exists(),
687 "flat sibling files must not be touched"
688 );
689 }
690
691 fn workspace_dir_with_meta(parent: &Path, root: &Path) -> PathBuf {
692 let dir = parent.join(workspace_hash(root));
693 std::fs::create_dir_all(&dir).unwrap();
694 write_workspace_meta(&dir, root).unwrap();
695 dir
696 }
697
698 #[test]
699 fn workspace_dir_is_stale_once_its_root_is_deleted() {
700 let cache = TempDir::new().unwrap();
701 let root = TempDir::new().unwrap();
702 let dir = workspace_dir_with_meta(cache.path(), root.path());
703 assert!(!is_stale_workspace_dir(&dir, SystemTime::now()));
704
705 drop(root);
706 assert!(is_stale_workspace_dir(&dir, SystemTime::now()));
707 }
708
709 #[test]
710 fn workspace_dir_is_stale_after_max_idle() {
711 let cache = TempDir::new().unwrap();
712 let root = TempDir::new().unwrap();
713 let dir = workspace_dir_with_meta(cache.path(), root.path());
714 let now = SystemTime::now();
715 assert!(!is_stale_workspace_dir(&dir, now + WORKSPACE_MAX_IDLE / 2));
716 assert!(is_stale_workspace_dir(
717 &dir,
718 now + WORKSPACE_MAX_IDLE + Duration::from_secs(60)
719 ));
720 }
721
722 #[test]
723 fn workspace_dir_without_meta_survives_only_the_grace_period() {
724 let cache = TempDir::new().unwrap();
725 let dir = cache.path().join("legacy");
726 std::fs::create_dir_all(&dir).unwrap();
727 let now = SystemTime::now();
728 assert!(!is_stale_workspace_dir(&dir, now));
729 assert!(is_stale_workspace_dir(
730 &dir,
731 now + NO_META_GRACE + Duration::from_secs(60)
732 ));
733 }
734
735 #[test]
736 fn prune_stale_dirs_removes_orphaned_workspaces_keeps_current_and_live() {
737 let php_lsp_dir = TempDir::new().unwrap();
738 let schema_dir = php_lsp_dir.path().join("0.22.0-fi-v4");
739
740 let live_root = TempDir::new().unwrap();
741 let live = workspace_dir_with_meta(&schema_dir, live_root.path());
742
743 let gone_root = TempDir::new().unwrap();
744 let orphaned = workspace_dir_with_meta(&schema_dir, gone_root.path());
745 drop(gone_root);
746
747 let current_root = TempDir::new().unwrap();
749 let current = workspace_dir_with_meta(&schema_dir, current_root.path());
750 drop(current_root);
751
752 prune_stale_dirs(
753 php_lsp_dir.path().to_path_buf(),
754 "0.22.0-fi-v4".into(),
755 current.file_name().unwrap().to_string_lossy().into_owned(),
756 )
757 .join()
758 .unwrap();
759
760 assert!(live.exists());
761 assert!(current.exists());
762 assert!(!orphaned.exists());
763 }
764
765 #[test]
766 fn evict_lru_workspaces_drops_oldest_until_under_cap() {
767 let schema_dir = TempDir::new().unwrap();
768 let root = TempDir::new().unwrap();
769 let stamps = [u64::from(u32::MAX), 0, 0];
771 let dirs: Vec<PathBuf> = stamps
772 .iter()
773 .enumerate()
774 .map(|(i, &last_used_secs)| {
775 let dir = schema_dir.path().join(format!("ws{i}"));
776 std::fs::create_dir_all(&dir).unwrap();
777 std::fs::write(dir.join("a.bin"), vec![0u8; 1000]).unwrap();
778 let meta = WorkspaceMeta {
779 root: root.path().to_path_buf(),
780 last_used_secs,
781 };
782 std::fs::write(dir.join(META_FILE), serde_json::to_vec(&meta).unwrap()).unwrap();
783 dir
784 })
785 .collect();
786
787 let per_dir = dir_size(&dirs[0]).unwrap();
789 evict_lru_workspaces(schema_dir.path(), "ws2", 2 * per_dir + per_dir / 2);
790
791 assert!(dirs[0].exists());
792 assert!(!dirs[1].exists());
793 assert!(dirs[2].exists());
794 }
795}