use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use serde::{Serialize, de::DeserializeOwned};
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct CacheKey {
uri_hash: String,
content_hash: String,
}
impl CacheKey {
fn as_filename(&self) -> &str {
&self.uri_hash
}
}
#[derive(Debug, Clone)]
pub struct WorkspaceCache {
dir: PathBuf,
}
pub const CACHE_SIZE_CAP: u64 = 512 * 1024 * 1024;
pub const TOTAL_CACHE_SIZE_CAP: u64 = 2 * 1024 * 1024 * 1024;
const WORKSPACE_MAX_IDLE: Duration = Duration::from_secs(30 * 24 * 60 * 60);
const NO_META_GRACE: Duration = Duration::from_secs(24 * 60 * 60);
const META_FILE: &str = "workspace.json";
impl WorkspaceCache {
pub fn new(root: &Path) -> Option<Self> {
let base = cache_base_dir()?;
let schema = schema_version();
let php_lsp_dir = base.join("php-lsp");
let workspace = workspace_hash(root);
let dir = php_lsp_dir.join(schema).join(&workspace);
std::fs::create_dir_all(&dir).ok()?;
let _ = write_workspace_meta(&dir, root);
drop(prune_stale_dirs(php_lsp_dir, schema.to_string(), workspace));
let cache = Self { dir };
if cache.size_bytes().unwrap_or(0) > CACHE_SIZE_CAP {
let _ = cache.clear();
}
Some(cache)
}
pub fn cache_dir(&self) -> &std::path::Path {
&self.dir
}
pub fn size_bytes(&self) -> io::Result<u64> {
dir_size(&self.dir)
}
pub fn with_dir(dir: PathBuf) -> Self {
Self { dir }
}
pub fn key_for(uri: &str, content: &str) -> CacheKey {
let uri_hash = blake3::hash(uri.as_bytes()).to_hex().as_str()[..32].to_string();
let content_hash = blake3::hash(content.as_bytes()).to_hex().as_str()[..32].to_string();
CacheKey {
uri_hash,
content_hash,
}
}
pub fn read<T: DeserializeOwned>(&self, key: &CacheKey) -> Option<T> {
let path = self.path_for(key);
let bytes = std::fs::read(&path).ok()?;
let config = bincode::config::standard();
let (stored_hash, value): (String, T) =
bincode::serde::decode_from_slice(&bytes, config).ok()?.0;
if stored_hash != key.content_hash {
return None;
}
Some(value)
}
pub fn write<T: Serialize>(&self, key: &CacheKey, value: &T) -> io::Result<()> {
let path = self.path_for(key);
let tmp = path.with_extension("tmp");
let config = bincode::config::standard();
let bytes = bincode::serde::encode_to_vec((&key.content_hash, value), config)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
{
let mut f = std::fs::File::create(&tmp)?;
f.write_all(&bytes)?;
}
std::fs::rename(&tmp, &path)?;
Ok(())
}
pub fn clear(&self) -> io::Result<()> {
if self.dir.exists() {
std::fs::remove_dir_all(&self.dir)?;
std::fs::create_dir_all(&self.dir)?;
}
Ok(())
}
fn path_for(&self, key: &CacheKey) -> PathBuf {
self.dir.join(format!("{}.bin", key.as_filename()))
}
}
fn dir_size(dir: &Path) -> io::Result<u64> {
let mut total = 0u64;
let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(0),
Err(e) => return Err(e),
};
for entry in entries.flatten() {
let meta = match entry.metadata() {
Ok(m) => m,
Err(_) => continue,
};
if meta.is_dir() {
total = total.saturating_add(dir_size(&entry.path())?);
} else if meta.is_file() {
total = total.saturating_add(meta.len());
}
}
Ok(total)
}
fn prune_stale_dirs(
php_lsp_dir: PathBuf,
current_schema: String,
current_workspace: String,
) -> std::thread::JoinHandle<()> {
std::thread::spawn(move || {
let now = SystemTime::now();
for dir in subdirs(&php_lsp_dir) {
if dir.file_name() != Some(current_schema.as_ref()) {
let _ = std::fs::remove_dir_all(dir);
}
}
let schema_dir = php_lsp_dir.join(¤t_schema);
for dir in subdirs(&schema_dir) {
if dir.file_name() != Some(current_workspace.as_ref())
&& is_stale_workspace_dir(&dir, now)
{
let _ = std::fs::remove_dir_all(dir);
}
}
evict_lru_workspaces(&schema_dir, ¤t_workspace, TOTAL_CACHE_SIZE_CAP);
})
}
fn evict_lru_workspaces(schema_dir: &Path, current_workspace: &str, cap: u64) {
let mut total = 0u64;
let mut candidates = Vec::new();
for dir in subdirs(schema_dir) {
let size = dir_size(&dir).unwrap_or(0);
total = total.saturating_add(size);
if dir.file_name() != Some(current_workspace.as_ref()) {
let used = last_used(&dir, read_workspace_meta(&dir).as_ref());
candidates.push((used, size, dir));
}
}
candidates.sort_by_key(|(used, _, _)| *used);
for (_, size, dir) in candidates {
if total <= cap {
break;
}
if std::fs::remove_dir_all(&dir).is_ok() {
total -= size;
}
}
}
fn subdirs(dir: &Path) -> Vec<PathBuf> {
let Ok(entries) = std::fs::read_dir(dir) else {
return Vec::new();
};
entries
.flatten()
.filter(|entry| entry.file_type().is_ok_and(|t| t.is_dir()))
.map(|entry| entry.path())
.collect()
}
#[derive(Serialize, serde::Deserialize)]
struct WorkspaceMeta {
root: PathBuf,
last_used_secs: u64,
}
fn write_workspace_meta(dir: &Path, root: &Path) -> io::Result<()> {
let meta = WorkspaceMeta {
root: root.canonicalize().unwrap_or_else(|_| root.to_path_buf()),
last_used_secs: SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_secs()),
};
let path = dir.join(META_FILE);
let tmp = path.with_extension("json.tmp");
std::fs::write(&tmp, serde_json::to_vec(&meta).map_err(io::Error::other)?)?;
std::fs::rename(&tmp, &path)
}
fn is_stale_workspace_dir(dir: &Path, now: SystemTime) -> bool {
let idle_since = |t: SystemTime| now.duration_since(t).unwrap_or(Duration::ZERO);
let Some(meta) = read_workspace_meta(dir) else {
return idle_since(last_used(dir, None)) > NO_META_GRACE;
};
if !meta.root.exists() {
return true;
}
idle_since(last_used(dir, Some(&meta))) > WORKSPACE_MAX_IDLE
}
fn read_workspace_meta(dir: &Path) -> Option<WorkspaceMeta> {
let bytes = std::fs::read(dir.join(META_FILE)).ok()?;
serde_json::from_slice(&bytes).ok()
}
fn last_used(dir: &Path, meta: Option<&WorkspaceMeta>) -> SystemTime {
let mtime = std::fs::metadata(dir)
.and_then(|m| m.modified())
.unwrap_or_else(|_| SystemTime::now());
let stamped = meta.map_or(UNIX_EPOCH, |m| {
UNIX_EPOCH + Duration::from_secs(m.last_used_secs)
});
stamped.max(mtime)
}
pub(crate) fn cache_base_dir() -> Option<PathBuf> {
if let Some(xdg) = std::env::var_os("XDG_CACHE_HOME")
&& !xdg.is_empty()
{
return Some(PathBuf::from(xdg));
}
if cfg!(windows) {
if let Some(local) = std::env::var_os("LOCALAPPDATA")
&& !local.is_empty()
{
return Some(PathBuf::from(local));
}
} else if let Some(home) = std::env::var_os("HOME")
&& !home.is_empty()
{
return Some(PathBuf::from(home).join(".cache"));
}
None
}
fn schema_version() -> &'static str {
concat!(env!("CARGO_PKG_VERSION"), "-fi-v6")
}
fn workspace_hash(root: &Path) -> String {
let canonical = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
let hex = blake3::hash(canonical.as_os_str().as_encoded_bytes()).to_hex();
hex.as_str()[..16].to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[derive(Serialize, serde::Deserialize, PartialEq, Debug)]
struct SamplePayload {
name: String,
values: Vec<u32>,
}
#[test]
fn key_for_is_deterministic_per_uri_and_content() {
let k1 = WorkspaceCache::key_for("file:///a.php", "<?php echo 1;");
let k2 = WorkspaceCache::key_for("file:///a.php", "<?php echo 1;");
assert_eq!(k1, k2);
}
#[test]
fn key_for_differs_when_content_differs() {
let k1 = WorkspaceCache::key_for("file:///a.php", "<?php echo 1;");
let k2 = WorkspaceCache::key_for("file:///a.php", "<?php echo 2;");
assert_ne!(k1, k2);
}
#[test]
fn key_for_differs_when_uri_differs() {
let k1 = WorkspaceCache::key_for("file:///a.php", "<?php");
let k2 = WorkspaceCache::key_for("file:///b.php", "<?php");
assert_ne!(k1, k2);
}
#[test]
fn write_overwrites_slot_instead_of_orphaning_old_revision() {
let dir = TempDir::new().unwrap();
let cache = WorkspaceCache::with_dir(dir.path().to_path_buf());
let uri = "file:///churn.php";
let key_v1 = WorkspaceCache::key_for(uri, "<?php echo 1;");
cache
.write(
&key_v1,
&SamplePayload {
name: "v1".into(),
values: vec![1],
},
)
.unwrap();
let key_v2 = WorkspaceCache::key_for(uri, "<?php echo 2;");
cache
.write(
&key_v2,
&SamplePayload {
name: "v2".into(),
values: vec![2],
},
)
.unwrap();
let bin_files: Vec<_> = std::fs::read_dir(dir.path())
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| e.path().extension().is_some_and(|ext| ext == "bin"))
.collect();
assert_eq!(
bin_files.len(),
1,
"editing a file must not orphan its previous cache entry"
);
let stale: Option<SamplePayload> = cache.read(&key_v1);
assert!(stale.is_none());
let current: SamplePayload = cache.read(&key_v2).unwrap();
assert_eq!(current.name, "v2");
}
#[test]
fn write_then_read_round_trips() {
let dir = TempDir::new().unwrap();
let cache = WorkspaceCache::with_dir(dir.path().to_path_buf());
let key = WorkspaceCache::key_for("file:///x.php", "<?php");
let payload = SamplePayload {
name: "x".into(),
values: vec![1, 2, 3],
};
cache.write(&key, &payload).unwrap();
let decoded: SamplePayload = cache.read(&key).unwrap();
assert_eq!(decoded, payload);
}
#[test]
fn read_returns_none_for_missing_key() {
let dir = TempDir::new().unwrap();
let cache = WorkspaceCache::with_dir(dir.path().to_path_buf());
let missing = WorkspaceCache::key_for("file:///nope.php", "");
let decoded: Option<SamplePayload> = cache.read(&missing);
assert!(decoded.is_none());
}
#[test]
fn read_returns_none_for_corrupted_entry() {
let dir = TempDir::new().unwrap();
let cache = WorkspaceCache::with_dir(dir.path().to_path_buf());
let key = WorkspaceCache::key_for("file:///c.php", "<?php");
std::fs::write(cache.path_for(&key), b"not valid bincode").unwrap();
let decoded: Option<SamplePayload> = cache.read(&key);
assert!(
decoded.is_none(),
"corrupted entry must look missing, not panic"
);
}
#[test]
fn write_is_atomic_via_rename() {
let dir = TempDir::new().unwrap();
let cache = WorkspaceCache::with_dir(dir.path().to_path_buf());
let key = WorkspaceCache::key_for("file:///atomic.php", "<?php");
let payload = SamplePayload {
name: "a".into(),
values: vec![],
};
cache.write(&key, &payload).unwrap();
let tmp = cache.path_for(&key).with_extension("tmp");
assert!(!tmp.exists(), "tmp file should be removed by rename");
}
#[test]
fn clear_drops_all_entries() {
let dir = TempDir::new().unwrap();
let cache = WorkspaceCache::with_dir(dir.path().to_path_buf());
for i in 0..3 {
let k = WorkspaceCache::key_for(&format!("file:///c{i}.php"), "");
cache
.write(
&k,
&SamplePayload {
name: i.to_string(),
values: vec![],
},
)
.unwrap();
}
cache.clear().unwrap();
for i in 0..3 {
let k = WorkspaceCache::key_for(&format!("file:///c{i}.php"), "");
let decoded: Option<SamplePayload> = cache.read(&k);
assert!(decoded.is_none());
}
}
#[test]
fn size_bytes_sums_flat_bin_files() {
let dir = TempDir::new().unwrap();
let cache = WorkspaceCache::with_dir(dir.path().to_path_buf());
assert_eq!(cache.size_bytes().unwrap(), 0);
let key1 = WorkspaceCache::key_for("file:///s1.php", "<?php");
cache
.write(
&key1,
&SamplePayload {
name: "s1".into(),
values: vec![0u32; 16],
},
)
.unwrap();
let key2 = WorkspaceCache::key_for("file:///s2.php", "<?php");
cache
.write(
&key2,
&SamplePayload {
name: "s2".into(),
values: vec![0u32; 16],
},
)
.unwrap();
let total = cache.size_bytes().unwrap();
let expected1 = cache.path_for(&key1).metadata().unwrap().len();
let expected2 = cache.path_for(&key2).metadata().unwrap().len();
assert_eq!(total, expected1 + expected2);
}
#[test]
fn size_bytes_recurses_into_nested_session_dir() {
let dir = TempDir::new().unwrap();
let cache = WorkspaceCache::with_dir(dir.path().to_path_buf());
let key = WorkspaceCache::key_for("file:///s.php", "<?php");
cache
.write(
&key,
&SamplePayload {
name: "s".into(),
values: vec![0u32; 16],
},
)
.unwrap();
let session_dir = dir.path().join("session");
std::fs::create_dir_all(&session_dir).unwrap();
std::fs::write(session_dir.join("cache.bin"), vec![0u8; 128]).unwrap();
let flat_len = cache.path_for(&key).metadata().unwrap().len();
assert_eq!(cache.size_bytes().unwrap(), flat_len + 128);
}
#[test]
fn file_index_round_trips() {
use crate::document::ast::ParsedDoc;
use crate::index::file_index::FileIndex;
let dir = TempDir::new().unwrap();
let cache = WorkspaceCache::with_dir(dir.path().to_path_buf());
let src = "<?php\nnamespace App;\nclass Foo { public function bar(): string {} }";
let key = WorkspaceCache::key_for("file:///Foo.php", src);
let doc = ParsedDoc::parse(src.to_string());
let index = FileIndex::extract(&doc);
cache.write(&key, &index).unwrap();
let decoded: FileIndex = cache.read(&key).unwrap();
assert_eq!(decoded.namespace.as_deref(), Some("App"));
assert_eq!(decoded.classes.len(), 1);
assert_eq!(decoded.classes[0].name.as_ref(), "Foo");
assert_eq!(decoded.classes[0].methods.len(), 1);
assert_eq!(decoded.classes[0].methods[0].name.as_ref(), "bar");
}
#[test]
fn prune_stale_dirs_removes_old_schemas_keeps_current_and_flat_files() {
let dir = TempDir::new().unwrap();
let php_lsp_dir = dir.path();
let stale = php_lsp_dir.join("0.21.0-fi-v3");
std::fs::create_dir_all(stale.join("workspacehash")).unwrap();
std::fs::write(stale.join("workspacehash").join("a.bin"), b"old").unwrap();
let current = php_lsp_dir.join("0.22.0-fi-v4");
std::fs::create_dir_all(¤t).unwrap();
std::fs::write(php_lsp_dir.join("php-binary-version.json"), b"{}").unwrap();
prune_stale_dirs(
php_lsp_dir.to_path_buf(),
"0.22.0-fi-v4".into(),
"current".into(),
)
.join()
.unwrap();
assert!(!stale.exists(), "stale schema directory must be removed");
assert!(current.exists(), "current schema directory must survive");
assert!(
php_lsp_dir.join("php-binary-version.json").exists(),
"flat sibling files must not be touched"
);
}
fn workspace_dir_with_meta(parent: &Path, root: &Path) -> PathBuf {
let dir = parent.join(workspace_hash(root));
std::fs::create_dir_all(&dir).unwrap();
write_workspace_meta(&dir, root).unwrap();
dir
}
#[test]
fn workspace_dir_is_stale_once_its_root_is_deleted() {
let cache = TempDir::new().unwrap();
let root = TempDir::new().unwrap();
let dir = workspace_dir_with_meta(cache.path(), root.path());
assert!(!is_stale_workspace_dir(&dir, SystemTime::now()));
drop(root);
assert!(is_stale_workspace_dir(&dir, SystemTime::now()));
}
#[test]
fn workspace_dir_is_stale_after_max_idle() {
let cache = TempDir::new().unwrap();
let root = TempDir::new().unwrap();
let dir = workspace_dir_with_meta(cache.path(), root.path());
let now = SystemTime::now();
assert!(!is_stale_workspace_dir(&dir, now + WORKSPACE_MAX_IDLE / 2));
assert!(is_stale_workspace_dir(
&dir,
now + WORKSPACE_MAX_IDLE + Duration::from_secs(60)
));
}
#[test]
fn workspace_dir_without_meta_survives_only_the_grace_period() {
let cache = TempDir::new().unwrap();
let dir = cache.path().join("legacy");
std::fs::create_dir_all(&dir).unwrap();
let now = SystemTime::now();
assert!(!is_stale_workspace_dir(&dir, now));
assert!(is_stale_workspace_dir(
&dir,
now + NO_META_GRACE + Duration::from_secs(60)
));
}
#[test]
fn prune_stale_dirs_removes_orphaned_workspaces_keeps_current_and_live() {
let php_lsp_dir = TempDir::new().unwrap();
let schema_dir = php_lsp_dir.path().join("0.22.0-fi-v4");
let live_root = TempDir::new().unwrap();
let live = workspace_dir_with_meta(&schema_dir, live_root.path());
let gone_root = TempDir::new().unwrap();
let orphaned = workspace_dir_with_meta(&schema_dir, gone_root.path());
drop(gone_root);
let current_root = TempDir::new().unwrap();
let current = workspace_dir_with_meta(&schema_dir, current_root.path());
drop(current_root);
prune_stale_dirs(
php_lsp_dir.path().to_path_buf(),
"0.22.0-fi-v4".into(),
current.file_name().unwrap().to_string_lossy().into_owned(),
)
.join()
.unwrap();
assert!(live.exists());
assert!(current.exists());
assert!(!orphaned.exists());
}
#[test]
fn evict_lru_workspaces_drops_oldest_until_under_cap() {
let schema_dir = TempDir::new().unwrap();
let root = TempDir::new().unwrap();
let stamps = [u64::from(u32::MAX), 0, 0];
let dirs: Vec<PathBuf> = stamps
.iter()
.enumerate()
.map(|(i, &last_used_secs)| {
let dir = schema_dir.path().join(format!("ws{i}"));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("a.bin"), vec![0u8; 1000]).unwrap();
let meta = WorkspaceMeta {
root: root.path().to_path_buf(),
last_used_secs,
};
std::fs::write(dir.join(META_FILE), serde_json::to_vec(&meta).unwrap()).unwrap();
dir
})
.collect();
let per_dir = dir_size(&dirs[0]).unwrap();
evict_lru_workspaces(schema_dir.path(), "ws2", 2 * per_dir + per_dir / 2);
assert!(dirs[0].exists());
assert!(!dirs[1].exists());
assert!(dirs[2].exists());
}
}