1use std::io::{self, Write};
37use std::path::{Path, PathBuf};
38
39use serde::{Serialize, de::DeserializeOwned};
40
41#[derive(Debug, Clone, PartialEq, Eq, Hash)]
44pub struct CacheKey(String);
45
46impl CacheKey {
47 fn as_filename(&self) -> &str {
48 &self.0
49 }
50}
51
52#[derive(Debug, Clone)]
56pub struct WorkspaceCache {
57 dir: PathBuf,
58}
59
60pub const CACHE_SIZE_CAP: u64 = 512 * 1024 * 1024;
67
68impl WorkspaceCache {
69 pub fn new(root: &Path) -> Option<Self> {
81 let base = cache_base_dir()?;
82 let schema = schema_version();
83 let workspace = workspace_hash(root);
84 let dir = base.join("php-lsp").join(schema).join(workspace);
85 std::fs::create_dir_all(&dir).ok()?;
86 let cache = Self { dir };
87 if cache.size_bytes().unwrap_or(0) > CACHE_SIZE_CAP {
88 let _ = cache.clear();
89 }
90 Some(cache)
91 }
92
93 pub fn cache_dir(&self) -> &std::path::Path {
95 &self.dir
96 }
97
98 pub fn size_bytes(&self) -> io::Result<u64> {
102 let mut total = 0u64;
103 let entries = match std::fs::read_dir(&self.dir) {
104 Ok(e) => e,
105 Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(0),
106 Err(e) => return Err(e),
107 };
108 for entry in entries.flatten() {
109 let meta = match entry.metadata() {
110 Ok(m) => m,
111 Err(_) => continue,
112 };
113 if meta.is_file() {
114 total = total.saturating_add(meta.len());
115 }
116 }
117 Ok(total)
118 }
119
120 #[cfg(test)]
124 pub fn with_dir(dir: PathBuf) -> Self {
125 Self { dir }
126 }
127
128 pub fn key_for(uri: &str, content: &str) -> CacheKey {
132 let mut hasher = blake3::Hasher::new();
133 hasher.update(uri.as_bytes());
134 hasher.update(&[0u8]);
135 hasher.update(content.as_bytes());
136 let full = hasher.finalize().to_hex();
137 CacheKey(full.as_str()[..32].to_string())
138 }
139
140 pub fn key_for_stat(uri: &str, mtime_secs: u64, size: u64) -> CacheKey {
151 let mut hasher = blake3::Hasher::new();
152 hasher.update(uri.as_bytes());
153 hasher.update(&[1u8]); hasher.update(&mtime_secs.to_le_bytes());
155 hasher.update(&size.to_le_bytes());
156 let full = hasher.finalize().to_hex();
157 CacheKey(full.as_str()[..32].to_string())
158 }
159
160 pub fn read<T: DeserializeOwned>(&self, key: &CacheKey) -> Option<T> {
164 let path = self.path_for(key);
165 let bytes = std::fs::read(&path).ok()?;
166 let config = bincode::config::standard();
167 bincode::serde::decode_from_slice(&bytes, config)
168 .ok()
169 .map(|(v, _len)| v)
170 }
171
172 pub fn write<T: Serialize>(&self, key: &CacheKey, value: &T) -> io::Result<()> {
182 let path = self.path_for(key);
183 let tmp = path.with_extension("tmp");
184 let config = bincode::config::standard();
185 let bytes = bincode::serde::encode_to_vec(value, config)
186 .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
187 {
188 let mut f = std::fs::File::create(&tmp)?;
189 f.write_all(&bytes)?;
190 }
191 std::fs::rename(&tmp, &path)?;
192 Ok(())
193 }
194
195 pub fn clear(&self) -> io::Result<()> {
200 if self.dir.exists() {
201 std::fs::remove_dir_all(&self.dir)?;
202 std::fs::create_dir_all(&self.dir)?;
203 }
204 Ok(())
205 }
206
207 fn path_for(&self, key: &CacheKey) -> PathBuf {
208 self.dir.join(format!("{}.bin", key.as_filename()))
209 }
210}
211
212fn cache_base_dir() -> Option<PathBuf> {
216 if let Some(xdg) = std::env::var_os("XDG_CACHE_HOME")
217 && !xdg.is_empty()
218 {
219 return Some(PathBuf::from(xdg));
220 }
221 if cfg!(windows) {
222 if let Some(local) = std::env::var_os("LOCALAPPDATA")
223 && !local.is_empty()
224 {
225 return Some(PathBuf::from(local));
226 }
227 } else if let Some(home) = std::env::var_os("HOME")
228 && !home.is_empty()
229 {
230 return Some(PathBuf::from(home).join(".cache"));
231 }
232 None
233}
234
235pub const FILE_INDEX_SCHEMA: &str = "fi-v1";
240
241fn schema_version() -> &'static str {
250 concat!(env!("CARGO_PKG_VERSION"), "-mir-0.7-fi-v1")
251}
252
253fn workspace_hash(root: &Path) -> String {
254 let canonical = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
255 let hex = blake3::hash(canonical.as_os_str().as_encoded_bytes()).to_hex();
256 hex.as_str()[..16].to_string()
257}
258
259#[cfg(test)]
260mod tests {
261 use super::*;
262 use tempfile::TempDir;
263
264 #[derive(Serialize, serde::Deserialize, PartialEq, Debug)]
265 struct SamplePayload {
266 name: String,
267 values: Vec<u32>,
268 }
269
270 #[test]
271 fn key_for_is_deterministic_per_uri_and_content() {
272 let k1 = WorkspaceCache::key_for("file:///a.php", "<?php echo 1;");
273 let k2 = WorkspaceCache::key_for("file:///a.php", "<?php echo 1;");
274 assert_eq!(k1, k2);
275 }
276
277 #[test]
278 fn key_for_differs_when_content_differs() {
279 let k1 = WorkspaceCache::key_for("file:///a.php", "<?php echo 1;");
280 let k2 = WorkspaceCache::key_for("file:///a.php", "<?php echo 2;");
281 assert_ne!(k1, k2);
282 }
283
284 #[test]
285 fn key_for_differs_when_uri_differs() {
286 let k1 = WorkspaceCache::key_for("file:///a.php", "<?php");
289 let k2 = WorkspaceCache::key_for("file:///b.php", "<?php");
290 assert_ne!(k1, k2);
291 }
292
293 #[test]
294 fn write_then_read_round_trips() {
295 let dir = TempDir::new().unwrap();
296 let cache = WorkspaceCache::with_dir(dir.path().to_path_buf());
297 let key = WorkspaceCache::key_for("file:///x.php", "<?php");
298 let payload = SamplePayload {
299 name: "x".into(),
300 values: vec![1, 2, 3],
301 };
302 cache.write(&key, &payload).unwrap();
303 let decoded: SamplePayload = cache.read(&key).unwrap();
304 assert_eq!(decoded, payload);
305 }
306
307 #[test]
308 fn read_returns_none_for_missing_key() {
309 let dir = TempDir::new().unwrap();
310 let cache = WorkspaceCache::with_dir(dir.path().to_path_buf());
311 let missing = WorkspaceCache::key_for("file:///nope.php", "");
312 let decoded: Option<SamplePayload> = cache.read(&missing);
313 assert!(decoded.is_none());
314 }
315
316 #[test]
317 fn read_returns_none_for_corrupted_entry() {
318 let dir = TempDir::new().unwrap();
319 let cache = WorkspaceCache::with_dir(dir.path().to_path_buf());
320 let key = WorkspaceCache::key_for("file:///c.php", "<?php");
321 std::fs::write(cache.path_for(&key), b"not valid bincode").unwrap();
323 let decoded: Option<SamplePayload> = cache.read(&key);
324 assert!(
325 decoded.is_none(),
326 "corrupted entry must look missing, not panic"
327 );
328 }
329
330 #[test]
331 fn write_is_atomic_via_rename() {
332 let dir = TempDir::new().unwrap();
337 let cache = WorkspaceCache::with_dir(dir.path().to_path_buf());
338 let key = WorkspaceCache::key_for("file:///atomic.php", "<?php");
339 let payload = SamplePayload {
340 name: "a".into(),
341 values: vec![],
342 };
343 cache.write(&key, &payload).unwrap();
344 let tmp = cache.path_for(&key).with_extension("tmp");
345 assert!(!tmp.exists(), "tmp file should be removed by rename");
346 }
347
348 #[test]
349 fn clear_drops_all_entries() {
350 let dir = TempDir::new().unwrap();
351 let cache = WorkspaceCache::with_dir(dir.path().to_path_buf());
352 for i in 0..3 {
353 let k = WorkspaceCache::key_for(&format!("file:///c{i}.php"), "");
354 cache
355 .write(
356 &k,
357 &SamplePayload {
358 name: i.to_string(),
359 values: vec![],
360 },
361 )
362 .unwrap();
363 }
364 cache.clear().unwrap();
365 for i in 0..3 {
366 let k = WorkspaceCache::key_for(&format!("file:///c{i}.php"), "");
367 let decoded: Option<SamplePayload> = cache.read(&k);
368 assert!(decoded.is_none());
369 }
370 }
371
372 #[test]
373 fn size_bytes_sums_flat_bin_files() {
374 let dir = TempDir::new().unwrap();
375 let cache = WorkspaceCache::with_dir(dir.path().to_path_buf());
376 assert_eq!(cache.size_bytes().unwrap(), 0);
377
378 let key1 = WorkspaceCache::key_for("file:///s1.php", "<?php");
379 cache
380 .write(
381 &key1,
382 &SamplePayload {
383 name: "s1".into(),
384 values: vec![0u32; 16],
385 },
386 )
387 .unwrap();
388 let key2 = WorkspaceCache::key_for("file:///s2.php", "<?php");
389 cache
390 .write(
391 &key2,
392 &SamplePayload {
393 name: "s2".into(),
394 values: vec![0u32; 16],
395 },
396 )
397 .unwrap();
398
399 let total = cache.size_bytes().unwrap();
400 let expected1 = cache.path_for(&key1).metadata().unwrap().len();
401 let expected2 = cache.path_for(&key2).metadata().unwrap().len();
402 assert_eq!(total, expected1 + expected2);
403 }
404
405 #[test]
406 fn file_index_round_trips() {
407 use crate::ast::ParsedDoc;
408 use crate::file_index::FileIndex;
409
410 let dir = TempDir::new().unwrap();
411 let cache = WorkspaceCache::with_dir(dir.path().to_path_buf());
412 let src = "<?php\nnamespace App;\nclass Foo { public function bar(): string {} }";
413 let key = WorkspaceCache::key_for("file:///Foo.php", src);
414
415 let doc = ParsedDoc::parse(src.to_string());
416 let index = FileIndex::extract(&doc);
417 cache.write(&key, &index).unwrap();
418
419 let decoded: FileIndex = cache.read(&key).unwrap();
420 assert_eq!(decoded.namespace.as_deref(), Some("App"));
421 assert_eq!(decoded.classes.len(), 1);
422 assert_eq!(decoded.classes[0].name.as_ref(), "Foo");
423 assert_eq!(decoded.classes[0].methods.len(), 1);
424 assert_eq!(decoded.classes[0].methods[0].name.as_ref(), "bar");
425 }
426}