1use std::io::{self, Write};
38use std::path::{Path, PathBuf};
39
40use serde::{Serialize, de::DeserializeOwned};
41
42#[derive(Debug, Clone, PartialEq, Eq, Hash)]
45pub struct CacheKey(String);
46
47impl CacheKey {
48 fn as_filename(&self) -> &str {
49 &self.0
50 }
51}
52
53#[derive(Debug, Clone)]
57pub struct WorkspaceCache {
58 dir: PathBuf,
59}
60
61pub const CACHE_SIZE_CAP: u64 = 512 * 1024 * 1024;
68
69impl WorkspaceCache {
70 pub fn new(root: &Path) -> Option<Self> {
82 let base = cache_base_dir()?;
83 let schema = schema_version();
84 let workspace = workspace_hash(root);
85 let dir = base.join("php-lsp").join(schema).join(workspace);
86 std::fs::create_dir_all(&dir).ok()?;
87 let cache = Self { dir };
88 if cache.size_bytes().unwrap_or(0) > CACHE_SIZE_CAP {
89 let _ = cache.clear();
90 }
91 Some(cache)
92 }
93
94 pub fn size_bytes(&self) -> io::Result<u64> {
98 let mut total = 0u64;
99 let entries = match std::fs::read_dir(&self.dir) {
100 Ok(e) => e,
101 Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(0),
102 Err(e) => return Err(e),
103 };
104 for entry in entries.flatten() {
105 let meta = match entry.metadata() {
106 Ok(m) => m,
107 Err(_) => continue,
108 };
109 if meta.is_file() {
110 total = total.saturating_add(meta.len());
111 }
112 }
113 Ok(total)
114 }
115
116 #[cfg(test)]
120 pub fn with_dir(dir: PathBuf) -> Self {
121 Self { dir }
122 }
123
124 pub fn key_for(uri: &str, content: &str) -> CacheKey {
128 let mut hasher = blake3::Hasher::new();
129 hasher.update(uri.as_bytes());
130 hasher.update(&[0u8]);
131 hasher.update(content.as_bytes());
132 let full = hasher.finalize().to_hex();
133 CacheKey(full.as_str()[..32].to_string())
136 }
137
138 pub fn read<T: DeserializeOwned>(&self, key: &CacheKey) -> Option<T> {
142 let path = self.path_for(key);
143 let bytes = std::fs::read(&path).ok()?;
144 let config = bincode::config::standard();
145 bincode::serde::decode_from_slice(&bytes, config)
146 .ok()
147 .map(|(v, _len)| v)
148 }
149
150 pub fn write<T: Serialize>(&self, key: &CacheKey, value: &T) -> io::Result<()> {
154 let path = self.path_for(key);
155 let tmp = path.with_extension("tmp");
156 let config = bincode::config::standard();
157 let bytes = bincode::serde::encode_to_vec(value, config)
158 .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
159 {
160 let mut f = std::fs::File::create(&tmp)?;
161 f.write_all(&bytes)?;
162 f.sync_all()?;
163 }
164 std::fs::rename(&tmp, &path)?;
165 Ok(())
166 }
167
168 pub fn clear(&self) -> io::Result<()> {
173 if self.dir.exists() {
174 std::fs::remove_dir_all(&self.dir)?;
175 std::fs::create_dir_all(&self.dir)?;
176 }
177 Ok(())
178 }
179
180 fn path_for(&self, key: &CacheKey) -> PathBuf {
181 self.dir.join(format!("{}.bin", key.as_filename()))
182 }
183}
184
185fn cache_base_dir() -> Option<PathBuf> {
189 if let Some(xdg) = std::env::var_os("XDG_CACHE_HOME")
190 && !xdg.is_empty()
191 {
192 return Some(PathBuf::from(xdg));
193 }
194 if cfg!(windows) {
195 if let Some(local) = std::env::var_os("LOCALAPPDATA")
196 && !local.is_empty()
197 {
198 return Some(PathBuf::from(local));
199 }
200 } else if let Some(home) = std::env::var_os("HOME")
201 && !home.is_empty()
202 {
203 return Some(PathBuf::from(home).join(".cache"));
204 }
205 None
206}
207
208fn schema_version() -> &'static str {
215 concat!(env!("CARGO_PKG_VERSION"), "-mir-0.7")
216}
217
218fn workspace_hash(root: &Path) -> String {
219 let canonical = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
220 let hex = blake3::hash(canonical.as_os_str().as_encoded_bytes()).to_hex();
221 hex.as_str()[..16].to_string()
222}
223
224#[cfg(test)]
225mod tests {
226 use super::*;
227 use tempfile::TempDir;
228
229 #[derive(Serialize, serde::Deserialize, PartialEq, Debug)]
230 struct SamplePayload {
231 name: String,
232 values: Vec<u32>,
233 }
234
235 #[test]
236 fn key_for_is_deterministic_per_uri_and_content() {
237 let k1 = WorkspaceCache::key_for("file:///a.php", "<?php echo 1;");
238 let k2 = WorkspaceCache::key_for("file:///a.php", "<?php echo 1;");
239 assert_eq!(k1, k2);
240 }
241
242 #[test]
243 fn key_for_differs_when_content_differs() {
244 let k1 = WorkspaceCache::key_for("file:///a.php", "<?php echo 1;");
245 let k2 = WorkspaceCache::key_for("file:///a.php", "<?php echo 2;");
246 assert_ne!(k1, k2);
247 }
248
249 #[test]
250 fn key_for_differs_when_uri_differs() {
251 let k1 = WorkspaceCache::key_for("file:///a.php", "<?php");
254 let k2 = WorkspaceCache::key_for("file:///b.php", "<?php");
255 assert_ne!(k1, k2);
256 }
257
258 #[test]
259 fn write_then_read_round_trips() {
260 let dir = TempDir::new().unwrap();
261 let cache = WorkspaceCache::with_dir(dir.path().to_path_buf());
262 let key = WorkspaceCache::key_for("file:///x.php", "<?php");
263 let payload = SamplePayload {
264 name: "x".into(),
265 values: vec![1, 2, 3],
266 };
267 cache.write(&key, &payload).unwrap();
268 let decoded: SamplePayload = cache.read(&key).unwrap();
269 assert_eq!(decoded, payload);
270 }
271
272 #[test]
273 fn read_returns_none_for_missing_key() {
274 let dir = TempDir::new().unwrap();
275 let cache = WorkspaceCache::with_dir(dir.path().to_path_buf());
276 let missing = WorkspaceCache::key_for("file:///nope.php", "");
277 let decoded: Option<SamplePayload> = cache.read(&missing);
278 assert!(decoded.is_none());
279 }
280
281 #[test]
282 fn read_returns_none_for_corrupted_entry() {
283 let dir = TempDir::new().unwrap();
284 let cache = WorkspaceCache::with_dir(dir.path().to_path_buf());
285 let key = WorkspaceCache::key_for("file:///c.php", "<?php");
286 std::fs::write(cache.path_for(&key), b"not valid bincode").unwrap();
288 let decoded: Option<SamplePayload> = cache.read(&key);
289 assert!(
290 decoded.is_none(),
291 "corrupted entry must look missing, not panic"
292 );
293 }
294
295 #[test]
296 fn write_is_atomic_via_rename() {
297 let dir = TempDir::new().unwrap();
302 let cache = WorkspaceCache::with_dir(dir.path().to_path_buf());
303 let key = WorkspaceCache::key_for("file:///atomic.php", "<?php");
304 let payload = SamplePayload {
305 name: "a".into(),
306 values: vec![],
307 };
308 cache.write(&key, &payload).unwrap();
309 let tmp = cache.path_for(&key).with_extension("tmp");
310 assert!(!tmp.exists(), "tmp file should be removed by rename");
311 }
312
313 #[test]
314 fn clear_drops_all_entries() {
315 let dir = TempDir::new().unwrap();
316 let cache = WorkspaceCache::with_dir(dir.path().to_path_buf());
317 for i in 0..3 {
318 let k = WorkspaceCache::key_for(&format!("file:///c{i}.php"), "");
319 cache
320 .write(
321 &k,
322 &SamplePayload {
323 name: i.to_string(),
324 values: vec![],
325 },
326 )
327 .unwrap();
328 }
329 cache.clear().unwrap();
330 for i in 0..3 {
331 let k = WorkspaceCache::key_for(&format!("file:///c{i}.php"), "");
332 let decoded: Option<SamplePayload> = cache.read(&k);
333 assert!(decoded.is_none());
334 }
335 }
336
337 #[test]
338 fn size_bytes_sums_flat_bin_files() {
339 let dir = TempDir::new().unwrap();
340 let cache = WorkspaceCache::with_dir(dir.path().to_path_buf());
341 assert_eq!(cache.size_bytes().unwrap(), 0);
342
343 let key1 = WorkspaceCache::key_for("file:///s1.php", "<?php");
344 cache
345 .write(
346 &key1,
347 &SamplePayload {
348 name: "s1".into(),
349 values: vec![0u32; 16],
350 },
351 )
352 .unwrap();
353 let key2 = WorkspaceCache::key_for("file:///s2.php", "<?php");
354 cache
355 .write(
356 &key2,
357 &SamplePayload {
358 name: "s2".into(),
359 values: vec![0u32; 16],
360 },
361 )
362 .unwrap();
363
364 let total = cache.size_bytes().unwrap();
365 let expected1 = cache.path_for(&key1).metadata().unwrap().len();
366 let expected2 = cache.path_for(&key2).metadata().unwrap().len();
367 assert_eq!(total, expected1 + expected2);
368 }
369
370 #[test]
371 fn stub_slice_round_trips() {
372 let dir = TempDir::new().unwrap();
375 let cache = WorkspaceCache::with_dir(dir.path().to_path_buf());
376 let key = WorkspaceCache::key_for("file:///stub.php", "<?php class Foo {}");
377 let slice = mir_codebase::storage::StubSlice::default();
378 cache.write(&key, &slice).unwrap();
379 let decoded: mir_codebase::storage::StubSlice = cache.read(&key).unwrap();
380 assert_eq!(decoded.classes.len(), slice.classes.len());
383 }
384}