Skip to main content

php_lsp/index/
cache.rs

1//! Persistent on-disk cache for Phase K.
2//!
3//! The cache stores a serialized `FileIndex` per PHP file, keyed on
4//! `(uri, content)`.  On a warm start `scan_workspace` reads the cached index
5//! instead of parsing the file, shrinking cold-start I/O from O(parse) to
6//! O(read + bincode-decode) — roughly 10–50× faster per file.
7//!
8//! ## Layout
9//!
10//! ```text
11//! ~/.cache/php-lsp/<schema-version>/<workspace-hash>/<uri-hash>.bin
12//! ```
13//!
14//! - `<schema-version>` — `php-lsp` crate version; bumping it rotates the
15//!   entire cache so old entries are never decoded against a newer schema.
16//!   The previous version's directory is pruned by the next `WorkspaceCache::new`
17//!   call rather than left to accumulate release over release.
18//! - `<workspace-hash>` — blake3 of the canonicalized absolute path of the
19//!   first workspace root, truncated to 16 hex chars. Two separate projects
20//!   get isolated caches; two checkouts of the same project at the same
21//!   absolute path share one.
22//! - `<uri-hash>` — blake3 of the URI, truncated to 32 hex chars. One file
23//!   per URI, not per `(uri, content)` pair: a content hash is stored inside
24//!   the entry and checked on read, so re-indexing an edited file overwrites
25//!   its existing slot in place instead of leaving the previous revision's
26//!   entry to rot on disk.
27//!
28//! `<workspace-hash>` also holds a `session/` subdirectory: mir's own
29//! `AnalysisCache`, nested here (not under mir's default `.mir-cache/`) so
30//! both caches share schema/workspace rotation and cleanup. `size_bytes`
31//! recurses into it for the size cap.
32//!
33//! ## Format
34//!
35//! `bincode` v2 (binary, fast, schema-stable via serde derives on
36//! `FileIndex` et al), prefixed with the entry's content hash so a stale
37//! revision reads back as a miss. Files are written atomically via a
38//! temp-file rename to avoid half-written entries on an interrupted
39//! shutdown.
40//!
41//! ## Invalidation
42//!
43//! Rotating the schema version invalidates everything; rotating the content
44//! invalidates just that entry's slot (overwritten on next write). The size
45//! cap in [`WorkspaceCache::new`] is a backstop for workspaces that outgrow
46//! it, not the primary cleanup mechanism.
47
48use std::io::{self, Write};
49use std::path::{Path, PathBuf};
50
51use serde::{Serialize, de::DeserializeOwned};
52
53/// Identifies a single cache entry. Opaque — callers produce it via
54/// [`WorkspaceCache::key_for`] and pass it straight back to read/write.
55///
56/// `uri_hash` is the on-disk filename, so every revision of a given URI
57/// lands in the same slot. `content_hash` is stored inside the entry and
58/// checked on read, so an edit invalidates the slot without orphaning a
59/// separate file for the old content.
60#[derive(Debug, Clone, PartialEq, Eq, Hash)]
61pub struct CacheKey {
62    uri_hash: String,
63    content_hash: String,
64}
65
66impl CacheKey {
67    fn as_filename(&self) -> &str {
68        &self.uri_hash
69    }
70}
71
72/// Handle to the cache directory for a single workspace. Construction is
73/// cheap (creates directories on demand); the same handle can be shared
74/// across threads via `Arc` — it holds no mutable state.
75#[derive(Debug, Clone)]
76pub struct WorkspaceCache {
77    dir: PathBuf,
78}
79
80/// Size cap (bytes) for a single workspace's cache directory. At
81/// startup, if the directory exceeds this, we reset it — simpler than
82/// LRU eviction and the rebuild cost is bounded (it's just the next
83/// workspace scan running as if cold). 512 MiB fits a mega-workspace
84/// (50 k files × ~10 KB average `FileIndex`) with headroom and is
85/// small enough that no reasonable disk will choke on it.
86pub const CACHE_SIZE_CAP: u64 = 512 * 1024 * 1024;
87
88impl WorkspaceCache {
89    /// Create (or re-open) the cache directory for a workspace rooted at
90    /// `root`. Returns `None` when the system has no usable home/cache
91    /// directory — callers should treat that as "cache disabled" and
92    /// proceed without persistence.
93    ///
94    /// If the existing cache directory exceeds [`CACHE_SIZE_CAP`], it is
95    /// cleared before the handle is returned. That's a coarse knob —
96    /// K3 could refine to LRU-by-mtime — but crossing 512 MiB at
97    /// startup indicates the workspace itself has more files than the
98    /// cap fits, and the rebuild cost is bounded to one full
99    /// re-scan.
100    pub fn new(root: &Path) -> Option<Self> {
101        let base = cache_base_dir()?;
102        let schema = schema_version();
103        let php_lsp_dir = base.join("php-lsp");
104        drop(prune_stale_schema_dirs(&php_lsp_dir, schema));
105        let workspace = workspace_hash(root);
106        let dir = php_lsp_dir.join(schema).join(workspace);
107        std::fs::create_dir_all(&dir).ok()?;
108        let cache = Self { dir };
109        if cache.size_bytes().unwrap_or(0) > CACHE_SIZE_CAP {
110            let _ = cache.clear();
111        }
112        Some(cache)
113    }
114
115    /// The filesystem path of this workspace's cache directory.
116    pub fn cache_dir(&self) -> &std::path::Path {
117        &self.dir
118    }
119
120    /// Total bytes consumed by this workspace's cache directory, including
121    /// nested subdirectories — mir's `AnalysisCache` lives under a `session/`
122    /// subdirectory of this one (see `set_session_cache_dir`), so the layout
123    /// isn't flat even though this directory's own entries are.
124    pub fn size_bytes(&self) -> io::Result<u64> {
125        dir_size(&self.dir)
126    }
127
128    /// Override the root directory directly. The directory is used verbatim
129    /// (no schema / workspace subdirectories are appended). Use for tests or
130    /// when the caller provides an explicit `cachePath` initializationOption.
131    pub fn with_dir(dir: PathBuf) -> Self {
132        Self { dir }
133    }
134
135    /// Build a cache key for `(uri, content)`. The filename half (`uri_hash`)
136    /// stays fixed across edits to the same URI so a re-index overwrites its
137    /// existing slot; the validation half (`content_hash`) changes on every
138    /// edit so a read against stale content misses.
139    pub fn key_for(uri: &str, content: &str) -> CacheKey {
140        let uri_hash = blake3::hash(uri.as_bytes()).to_hex().as_str()[..32].to_string();
141        let content_hash = blake3::hash(content.as_bytes()).to_hex().as_str()[..32].to_string();
142        CacheKey {
143            uri_hash,
144            content_hash,
145        }
146    }
147
148    /// Deserialize a previously-cached value. Returns `None` on any I/O
149    /// or decode failure, or when the entry's stored content hash no
150    /// longer matches `key` (the slot holds a stale revision) — all of
151    /// which should look identical to a missing entry so callers fall
152    /// through to the recompute path.
153    pub fn read<T: DeserializeOwned>(&self, key: &CacheKey) -> Option<T> {
154        let path = self.path_for(key);
155        let bytes = std::fs::read(&path).ok()?;
156        let config = bincode::config::standard();
157        let (stored_hash, value): (String, T) =
158            bincode::serde::decode_from_slice(&bytes, config).ok()?.0;
159        if stored_hash != key.content_hash {
160            return None;
161        }
162        Some(value)
163    }
164
165    /// Atomically publish an entry to the cache. Writes to a sibling
166    /// temp file then renames, so readers never see a half-written
167    /// payload even if the process dies mid-write. Because the filename
168    /// is keyed on URI alone, this overwrites whatever revision (if any)
169    /// previously occupied the slot rather than leaving it behind.
170    ///
171    /// No fsync: the cache is advisory-only — a crash that loses a write
172    /// just produces a cache miss on the next startup, which safely falls
173    /// back to re-parsing. Skipping sync_all() avoids 5–15 ms per file on
174    /// macOS, which on a 1,500-file project accounts for most of the cold
175    /// indexing time.
176    pub fn write<T: Serialize>(&self, key: &CacheKey, value: &T) -> io::Result<()> {
177        let path = self.path_for(key);
178        let tmp = path.with_extension("tmp");
179        let config = bincode::config::standard();
180        let bytes = bincode::serde::encode_to_vec((&key.content_hash, value), config)
181            .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
182        {
183            let mut f = std::fs::File::create(&tmp)?;
184            f.write_all(&bytes)?;
185        }
186        std::fs::rename(&tmp, &path)?;
187        Ok(())
188    }
189
190    /// Drop every entry in this workspace's cache. Safe to call while
191    /// other threads are reading — individual `read` calls that race
192    /// with a `clear` will see `None` rather than garbage, and the next
193    /// `write` recreates the entry.
194    pub fn clear(&self) -> io::Result<()> {
195        if self.dir.exists() {
196            std::fs::remove_dir_all(&self.dir)?;
197            std::fs::create_dir_all(&self.dir)?;
198        }
199        Ok(())
200    }
201
202    fn path_for(&self, key: &CacheKey) -> PathBuf {
203        self.dir.join(format!("{}.bin", key.as_filename()))
204    }
205}
206
207/// Recursively sums file sizes under `dir`. Used for the size cap: the
208/// workspace cache directory isn't flat — mir's `AnalysisCache` lives in a
209/// `session/` subdirectory underneath it — so a top-level-only scan would
210/// never see it grow.
211fn dir_size(dir: &Path) -> io::Result<u64> {
212    let mut total = 0u64;
213    let entries = match std::fs::read_dir(dir) {
214        Ok(e) => e,
215        Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(0),
216        Err(e) => return Err(e),
217    };
218    for entry in entries.flatten() {
219        let meta = match entry.metadata() {
220            Ok(m) => m,
221            Err(_) => continue,
222        };
223        if meta.is_dir() {
224            total = total.saturating_add(dir_size(&entry.path())?);
225        } else if meta.is_file() {
226            total = total.saturating_add(meta.len());
227        }
228    }
229    Ok(total)
230}
231
232/// Removes sibling schema-version directories under `php_lsp_dir` other than
233/// `current_schema`. `schema_version()` embeds `CARGO_PKG_VERSION`, so every
234/// php-lsp release moves to a new schema directory; without this, every past
235/// release's entire cache directory (including its nested mir `session/`
236/// cache) sits there forever since nothing else ever reads or removes it.
237/// Only directories are touched — `php_lsp_dir` also holds flat sibling files
238/// (e.g. `php-binary-version.json`, see `autoload::detect_php_binary_version`)
239/// that must survive. Best-effort: errors are ignored, same advisory-cache
240/// posture as the rest of this module.
241///
242/// The actual deletion runs on a detached background thread rather than
243/// blocking the caller: on a long-lived dev machine a stale schema directory
244/// can hold many thousands of entries across many past workspaces (one
245/// `remove_dir_all` per schema version, each recursing into every workspace
246/// ever cached under it), and deleting that synchronously inside
247/// `WorkspaceCache::new()` — on the same path as the workspace scan — stalled
248/// startup for 20+ minutes on a real cache before `indexReady` ever fired.
249/// The listing/filtering above stays synchronous (cheap, one shallow
250/// `read_dir`); only the recursive removal is deferred. Returns the join
251/// handle so tests can wait for completion; normal callers drop it and let
252/// the cleanup finish in its own time.
253fn prune_stale_schema_dirs(
254    php_lsp_dir: &Path,
255    current_schema: &str,
256) -> Option<std::thread::JoinHandle<()>> {
257    let entries = std::fs::read_dir(php_lsp_dir).ok()?;
258    let stale: Vec<PathBuf> = entries
259        .flatten()
260        .filter(|entry| {
261            entry.file_name() != current_schema && entry.file_type().is_ok_and(|t| t.is_dir())
262        })
263        .map(|entry| entry.path())
264        .collect();
265    if stale.is_empty() {
266        return None;
267    }
268    Some(std::thread::spawn(move || {
269        for dir in stale {
270            let _ = std::fs::remove_dir_all(dir);
271        }
272    }))
273}
274
275/// Platform cache directory: `$XDG_CACHE_HOME` or `$HOME/.cache` on Unix,
276/// `%LOCALAPPDATA%` on Windows. Deliberately doesn't depend on the `dirs`
277/// crate — keeps the footprint small and the behaviour predictable.
278pub(crate) fn cache_base_dir() -> Option<PathBuf> {
279    if let Some(xdg) = std::env::var_os("XDG_CACHE_HOME")
280        && !xdg.is_empty()
281    {
282        return Some(PathBuf::from(xdg));
283    }
284    if cfg!(windows) {
285        if let Some(local) = std::env::var_os("LOCALAPPDATA")
286            && !local.is_empty()
287        {
288            return Some(PathBuf::from(local));
289        }
290    } else if let Some(home) = std::env::var_os("HOME")
291        && !home.is_empty()
292    {
293        return Some(PathBuf::from(home).join(".cache"));
294    }
295    None
296}
297
298/// Schema marker: bumping `php-lsp`'s crate version invalidates every cached
299/// entry (a new release moves to a new directory; see
300/// `prune_stale_schema_dirs` for cleanup of the old one). `fi-vN` is the one
301/// manual knob — bump it whenever `FileIndex` or any type it contains gains,
302/// loses, or renames a field, including a `php_ast`/parser upgrade that
303/// changes what gets extracted. `FileIndex` has no dependency on any mir
304/// type, so a mir version has no bearing on this cache's validity.
305fn schema_version() -> &'static str {
306    concat!(env!("CARGO_PKG_VERSION"), "-fi-v5")
307}
308
309fn workspace_hash(root: &Path) -> String {
310    let canonical = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
311    let hex = blake3::hash(canonical.as_os_str().as_encoded_bytes()).to_hex();
312    hex.as_str()[..16].to_string()
313}
314
315#[cfg(test)]
316mod tests {
317    use super::*;
318    use tempfile::TempDir;
319
320    #[derive(Serialize, serde::Deserialize, PartialEq, Debug)]
321    struct SamplePayload {
322        name: String,
323        values: Vec<u32>,
324    }
325
326    #[test]
327    fn key_for_is_deterministic_per_uri_and_content() {
328        let k1 = WorkspaceCache::key_for("file:///a.php", "<?php echo 1;");
329        let k2 = WorkspaceCache::key_for("file:///a.php", "<?php echo 1;");
330        assert_eq!(k1, k2);
331    }
332
333    #[test]
334    fn key_for_differs_when_content_differs() {
335        let k1 = WorkspaceCache::key_for("file:///a.php", "<?php echo 1;");
336        let k2 = WorkspaceCache::key_for("file:///a.php", "<?php echo 2;");
337        assert_ne!(k1, k2);
338    }
339
340    #[test]
341    fn key_for_differs_when_uri_differs() {
342        let k1 = WorkspaceCache::key_for("file:///a.php", "<?php");
343        let k2 = WorkspaceCache::key_for("file:///b.php", "<?php");
344        assert_ne!(k1, k2);
345    }
346
347    #[test]
348    fn write_overwrites_slot_instead_of_orphaning_old_revision() {
349        let dir = TempDir::new().unwrap();
350        let cache = WorkspaceCache::with_dir(dir.path().to_path_buf());
351        let uri = "file:///churn.php";
352
353        let key_v1 = WorkspaceCache::key_for(uri, "<?php echo 1;");
354        cache
355            .write(
356                &key_v1,
357                &SamplePayload {
358                    name: "v1".into(),
359                    values: vec![1],
360                },
361            )
362            .unwrap();
363
364        let key_v2 = WorkspaceCache::key_for(uri, "<?php echo 2;");
365        cache
366            .write(
367                &key_v2,
368                &SamplePayload {
369                    name: "v2".into(),
370                    values: vec![2],
371                },
372            )
373            .unwrap();
374
375        // Same URI → same on-disk slot, so the second write replaces the
376        // first rather than leaving a second, now-unreachable .bin file.
377        let bin_files: Vec<_> = std::fs::read_dir(dir.path())
378            .unwrap()
379            .filter_map(|e| e.ok())
380            .filter(|e| e.path().extension().is_some_and(|ext| ext == "bin"))
381            .collect();
382        assert_eq!(
383            bin_files.len(),
384            1,
385            "editing a file must not orphan its previous cache entry"
386        );
387
388        // The stale key (old content) now misses instead of returning v1.
389        let stale: Option<SamplePayload> = cache.read(&key_v1);
390        assert!(stale.is_none());
391
392        let current: SamplePayload = cache.read(&key_v2).unwrap();
393        assert_eq!(current.name, "v2");
394    }
395
396    #[test]
397    fn write_then_read_round_trips() {
398        let dir = TempDir::new().unwrap();
399        let cache = WorkspaceCache::with_dir(dir.path().to_path_buf());
400        let key = WorkspaceCache::key_for("file:///x.php", "<?php");
401        let payload = SamplePayload {
402            name: "x".into(),
403            values: vec![1, 2, 3],
404        };
405        cache.write(&key, &payload).unwrap();
406        let decoded: SamplePayload = cache.read(&key).unwrap();
407        assert_eq!(decoded, payload);
408    }
409
410    #[test]
411    fn read_returns_none_for_missing_key() {
412        let dir = TempDir::new().unwrap();
413        let cache = WorkspaceCache::with_dir(dir.path().to_path_buf());
414        let missing = WorkspaceCache::key_for("file:///nope.php", "");
415        let decoded: Option<SamplePayload> = cache.read(&missing);
416        assert!(decoded.is_none());
417    }
418
419    #[test]
420    fn read_returns_none_for_corrupted_entry() {
421        let dir = TempDir::new().unwrap();
422        let cache = WorkspaceCache::with_dir(dir.path().to_path_buf());
423        let key = WorkspaceCache::key_for("file:///c.php", "<?php");
424        // Write garbage bytes directly into the slot the cache would use.
425        std::fs::write(cache.path_for(&key), b"not valid bincode").unwrap();
426        let decoded: Option<SamplePayload> = cache.read(&key);
427        assert!(
428            decoded.is_none(),
429            "corrupted entry must look missing, not panic"
430        );
431    }
432
433    #[test]
434    fn write_is_atomic_via_rename() {
435        // If the write path didn't go through a temp file, a crash
436        // mid-`write_all` could leave a half-written `.bin`. We can't
437        // easily simulate a crash, but we can at least assert the
438        // temp-file doesn't linger on success.
439        let dir = TempDir::new().unwrap();
440        let cache = WorkspaceCache::with_dir(dir.path().to_path_buf());
441        let key = WorkspaceCache::key_for("file:///atomic.php", "<?php");
442        let payload = SamplePayload {
443            name: "a".into(),
444            values: vec![],
445        };
446        cache.write(&key, &payload).unwrap();
447        let tmp = cache.path_for(&key).with_extension("tmp");
448        assert!(!tmp.exists(), "tmp file should be removed by rename");
449    }
450
451    #[test]
452    fn clear_drops_all_entries() {
453        let dir = TempDir::new().unwrap();
454        let cache = WorkspaceCache::with_dir(dir.path().to_path_buf());
455        for i in 0..3 {
456            let k = WorkspaceCache::key_for(&format!("file:///c{i}.php"), "");
457            cache
458                .write(
459                    &k,
460                    &SamplePayload {
461                        name: i.to_string(),
462                        values: vec![],
463                    },
464                )
465                .unwrap();
466        }
467        cache.clear().unwrap();
468        for i in 0..3 {
469            let k = WorkspaceCache::key_for(&format!("file:///c{i}.php"), "");
470            let decoded: Option<SamplePayload> = cache.read(&k);
471            assert!(decoded.is_none());
472        }
473    }
474
475    #[test]
476    fn size_bytes_sums_flat_bin_files() {
477        let dir = TempDir::new().unwrap();
478        let cache = WorkspaceCache::with_dir(dir.path().to_path_buf());
479        assert_eq!(cache.size_bytes().unwrap(), 0);
480
481        let key1 = WorkspaceCache::key_for("file:///s1.php", "<?php");
482        cache
483            .write(
484                &key1,
485                &SamplePayload {
486                    name: "s1".into(),
487                    values: vec![0u32; 16],
488                },
489            )
490            .unwrap();
491        let key2 = WorkspaceCache::key_for("file:///s2.php", "<?php");
492        cache
493            .write(
494                &key2,
495                &SamplePayload {
496                    name: "s2".into(),
497                    values: vec![0u32; 16],
498                },
499            )
500            .unwrap();
501
502        let total = cache.size_bytes().unwrap();
503        let expected1 = cache.path_for(&key1).metadata().unwrap().len();
504        let expected2 = cache.path_for(&key2).metadata().unwrap().len();
505        assert_eq!(total, expected1 + expected2);
506    }
507
508    #[test]
509    fn size_bytes_recurses_into_nested_session_dir() {
510        // Simulates mir's AnalysisCache living under `<workspace-hash>/session/`:
511        // size_bytes must see it, not just this directory's own flat entries.
512        let dir = TempDir::new().unwrap();
513        let cache = WorkspaceCache::with_dir(dir.path().to_path_buf());
514
515        let key = WorkspaceCache::key_for("file:///s.php", "<?php");
516        cache
517            .write(
518                &key,
519                &SamplePayload {
520                    name: "s".into(),
521                    values: vec![0u32; 16],
522                },
523            )
524            .unwrap();
525
526        let session_dir = dir.path().join("session");
527        std::fs::create_dir_all(&session_dir).unwrap();
528        std::fs::write(session_dir.join("cache.bin"), vec![0u8; 128]).unwrap();
529
530        let flat_len = cache.path_for(&key).metadata().unwrap().len();
531        assert_eq!(cache.size_bytes().unwrap(), flat_len + 128);
532    }
533
534    #[test]
535    fn file_index_round_trips() {
536        use crate::document::ast::ParsedDoc;
537        use crate::index::file_index::FileIndex;
538
539        let dir = TempDir::new().unwrap();
540        let cache = WorkspaceCache::with_dir(dir.path().to_path_buf());
541        let src = "<?php\nnamespace App;\nclass Foo { public function bar(): string {} }";
542        let key = WorkspaceCache::key_for("file:///Foo.php", src);
543
544        let doc = ParsedDoc::parse(src.to_string());
545        let index = FileIndex::extract(&doc);
546        cache.write(&key, &index).unwrap();
547
548        let decoded: FileIndex = cache.read(&key).unwrap();
549        assert_eq!(decoded.namespace.as_deref(), Some("App"));
550        assert_eq!(decoded.classes.len(), 1);
551        assert_eq!(decoded.classes[0].name.as_ref(), "Foo");
552        assert_eq!(decoded.classes[0].methods.len(), 1);
553        assert_eq!(decoded.classes[0].methods[0].name.as_ref(), "bar");
554    }
555
556    #[test]
557    fn prune_stale_schema_dirs_removes_old_schemas_keeps_current_and_flat_files() {
558        let dir = TempDir::new().unwrap();
559        let php_lsp_dir = dir.path();
560
561        let stale = php_lsp_dir.join("0.21.0-fi-v3");
562        std::fs::create_dir_all(stale.join("workspacehash")).unwrap();
563        std::fs::write(stale.join("workspacehash").join("a.bin"), b"old").unwrap();
564
565        let current = php_lsp_dir.join("0.22.0-fi-v4");
566        std::fs::create_dir_all(&current).unwrap();
567
568        // Sibling flat file, like `php-binary-version.json` — must survive.
569        std::fs::write(php_lsp_dir.join("php-binary-version.json"), b"{}").unwrap();
570
571        prune_stale_schema_dirs(php_lsp_dir, "0.22.0-fi-v4")
572            .expect("a stale dir is present, so a cleanup thread must be spawned")
573            .join()
574            .unwrap();
575
576        assert!(!stale.exists(), "stale schema directory must be removed");
577        assert!(current.exists(), "current schema directory must survive");
578        assert!(
579            php_lsp_dir.join("php-binary-version.json").exists(),
580            "flat sibling files must not be touched"
581        );
582    }
583}