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