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 pub fn with_dir(dir: PathBuf) -> Self {
124 Self { dir }
125 }
126
127 pub fn key_for(uri: &str, content: &str) -> CacheKey {
131 let mut hasher = blake3::Hasher::new();
132 hasher.update(uri.as_bytes());
133 hasher.update(&[0u8]);
134 hasher.update(content.as_bytes());
135 let full = hasher.finalize().to_hex();
136 CacheKey(full.as_str()[..32].to_string())
137 }
138
139 pub fn read<T: DeserializeOwned>(&self, key: &CacheKey) -> Option<T> {
143 let path = self.path_for(key);
144 let bytes = std::fs::read(&path).ok()?;
145 let config = bincode::config::standard();
146 bincode::serde::decode_from_slice(&bytes, config)
147 .ok()
148 .map(|(v, _len)| v)
149 }
150
151 pub fn write<T: Serialize>(&self, key: &CacheKey, value: &T) -> io::Result<()> {
161 let path = self.path_for(key);
162 let tmp = path.with_extension("tmp");
163 let config = bincode::config::standard();
164 let bytes = bincode::serde::encode_to_vec(value, config)
165 .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
166 {
167 let mut f = std::fs::File::create(&tmp)?;
168 f.write_all(&bytes)?;
169 }
170 std::fs::rename(&tmp, &path)?;
171 Ok(())
172 }
173
174 pub fn clear(&self) -> io::Result<()> {
179 if self.dir.exists() {
180 std::fs::remove_dir_all(&self.dir)?;
181 std::fs::create_dir_all(&self.dir)?;
182 }
183 Ok(())
184 }
185
186 fn path_for(&self, key: &CacheKey) -> PathBuf {
187 self.dir.join(format!("{}.bin", key.as_filename()))
188 }
189}
190
191pub(crate) fn cache_base_dir() -> Option<PathBuf> {
195 if let Some(xdg) = std::env::var_os("XDG_CACHE_HOME")
196 && !xdg.is_empty()
197 {
198 return Some(PathBuf::from(xdg));
199 }
200 if cfg!(windows) {
201 if let Some(local) = std::env::var_os("LOCALAPPDATA")
202 && !local.is_empty()
203 {
204 return Some(PathBuf::from(local));
205 }
206 } else if let Some(home) = std::env::var_os("HOME")
207 && !home.is_empty()
208 {
209 return Some(PathBuf::from(home).join(".cache"));
210 }
211 None
212}
213
214fn schema_version() -> &'static str {
221 concat!(env!("CARGO_PKG_VERSION"), "-mir-0.7-fi-v4")
222}
223
224fn workspace_hash(root: &Path) -> String {
225 let canonical = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
226 let hex = blake3::hash(canonical.as_os_str().as_encoded_bytes()).to_hex();
227 hex.as_str()[..16].to_string()
228}
229
230#[cfg(test)]
231mod tests {
232 use super::*;
233 use tempfile::TempDir;
234
235 #[derive(Serialize, serde::Deserialize, PartialEq, Debug)]
236 struct SamplePayload {
237 name: String,
238 values: Vec<u32>,
239 }
240
241 #[test]
242 fn key_for_is_deterministic_per_uri_and_content() {
243 let k1 = WorkspaceCache::key_for("file:///a.php", "<?php echo 1;");
244 let k2 = WorkspaceCache::key_for("file:///a.php", "<?php echo 1;");
245 assert_eq!(k1, k2);
246 }
247
248 #[test]
249 fn key_for_differs_when_content_differs() {
250 let k1 = WorkspaceCache::key_for("file:///a.php", "<?php echo 1;");
251 let k2 = WorkspaceCache::key_for("file:///a.php", "<?php echo 2;");
252 assert_ne!(k1, k2);
253 }
254
255 #[test]
256 fn key_for_differs_when_uri_differs() {
257 let k1 = WorkspaceCache::key_for("file:///a.php", "<?php");
260 let k2 = WorkspaceCache::key_for("file:///b.php", "<?php");
261 assert_ne!(k1, k2);
262 }
263
264 #[test]
265 fn write_then_read_round_trips() {
266 let dir = TempDir::new().unwrap();
267 let cache = WorkspaceCache::with_dir(dir.path().to_path_buf());
268 let key = WorkspaceCache::key_for("file:///x.php", "<?php");
269 let payload = SamplePayload {
270 name: "x".into(),
271 values: vec![1, 2, 3],
272 };
273 cache.write(&key, &payload).unwrap();
274 let decoded: SamplePayload = cache.read(&key).unwrap();
275 assert_eq!(decoded, payload);
276 }
277
278 #[test]
279 fn read_returns_none_for_missing_key() {
280 let dir = TempDir::new().unwrap();
281 let cache = WorkspaceCache::with_dir(dir.path().to_path_buf());
282 let missing = WorkspaceCache::key_for("file:///nope.php", "");
283 let decoded: Option<SamplePayload> = cache.read(&missing);
284 assert!(decoded.is_none());
285 }
286
287 #[test]
288 fn read_returns_none_for_corrupted_entry() {
289 let dir = TempDir::new().unwrap();
290 let cache = WorkspaceCache::with_dir(dir.path().to_path_buf());
291 let key = WorkspaceCache::key_for("file:///c.php", "<?php");
292 std::fs::write(cache.path_for(&key), b"not valid bincode").unwrap();
294 let decoded: Option<SamplePayload> = cache.read(&key);
295 assert!(
296 decoded.is_none(),
297 "corrupted entry must look missing, not panic"
298 );
299 }
300
301 #[test]
302 fn write_is_atomic_via_rename() {
303 let dir = TempDir::new().unwrap();
308 let cache = WorkspaceCache::with_dir(dir.path().to_path_buf());
309 let key = WorkspaceCache::key_for("file:///atomic.php", "<?php");
310 let payload = SamplePayload {
311 name: "a".into(),
312 values: vec![],
313 };
314 cache.write(&key, &payload).unwrap();
315 let tmp = cache.path_for(&key).with_extension("tmp");
316 assert!(!tmp.exists(), "tmp file should be removed by rename");
317 }
318
319 #[test]
320 fn clear_drops_all_entries() {
321 let dir = TempDir::new().unwrap();
322 let cache = WorkspaceCache::with_dir(dir.path().to_path_buf());
323 for i in 0..3 {
324 let k = WorkspaceCache::key_for(&format!("file:///c{i}.php"), "");
325 cache
326 .write(
327 &k,
328 &SamplePayload {
329 name: i.to_string(),
330 values: vec![],
331 },
332 )
333 .unwrap();
334 }
335 cache.clear().unwrap();
336 for i in 0..3 {
337 let k = WorkspaceCache::key_for(&format!("file:///c{i}.php"), "");
338 let decoded: Option<SamplePayload> = cache.read(&k);
339 assert!(decoded.is_none());
340 }
341 }
342
343 #[test]
344 fn size_bytes_sums_flat_bin_files() {
345 let dir = TempDir::new().unwrap();
346 let cache = WorkspaceCache::with_dir(dir.path().to_path_buf());
347 assert_eq!(cache.size_bytes().unwrap(), 0);
348
349 let key1 = WorkspaceCache::key_for("file:///s1.php", "<?php");
350 cache
351 .write(
352 &key1,
353 &SamplePayload {
354 name: "s1".into(),
355 values: vec![0u32; 16],
356 },
357 )
358 .unwrap();
359 let key2 = WorkspaceCache::key_for("file:///s2.php", "<?php");
360 cache
361 .write(
362 &key2,
363 &SamplePayload {
364 name: "s2".into(),
365 values: vec![0u32; 16],
366 },
367 )
368 .unwrap();
369
370 let total = cache.size_bytes().unwrap();
371 let expected1 = cache.path_for(&key1).metadata().unwrap().len();
372 let expected2 = cache.path_for(&key2).metadata().unwrap().len();
373 assert_eq!(total, expected1 + expected2);
374 }
375
376 #[test]
377 fn file_index_round_trips() {
378 use crate::document::ast::ParsedDoc;
379 use crate::index::file_index::FileIndex;
380
381 let dir = TempDir::new().unwrap();
382 let cache = WorkspaceCache::with_dir(dir.path().to_path_buf());
383 let src = "<?php\nnamespace App;\nclass Foo { public function bar(): string {} }";
384 let key = WorkspaceCache::key_for("file:///Foo.php", src);
385
386 let doc = ParsedDoc::parse(src.to_string());
387 let index = FileIndex::extract(&doc);
388 cache.write(&key, &index).unwrap();
389
390 let decoded: FileIndex = cache.read(&key).unwrap();
391 assert_eq!(decoded.namespace.as_deref(), Some("App"));
392 assert_eq!(decoded.classes.len(), 1);
393 assert_eq!(decoded.classes[0].name.as_ref(), "Foo");
394 assert_eq!(decoded.classes[0].methods.len(), 1);
395 assert_eq!(decoded.classes[0].methods[0].name.as_ref(), "bar");
396 }
397}