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>/<entry-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//! - `<workspace-hash>` — blake3 of the canonicalized absolute path of the
17//!   first workspace root, truncated to 16 hex chars. Two separate projects
18//!   get isolated caches; two checkouts of the same project at the same
19//!   absolute path share one.
20//! - `<entry-hash>` — blake3 of the bytes `uri || 0x00 || content`, truncated
21//!   to 32 hex chars. Editing a file changes the content → new key → cache
22//!   miss; a different file at the same URI also gets a different key.
23//!
24//! ## Format
25//!
26//! `bincode` v2 (binary, fast, schema-stable via serde derives on
27//! `FileIndex` et al). Files are written atomically via a temp-file rename
28//! to avoid half-written entries on an interrupted shutdown.
29//!
30//! ## Invalidation
31//!
32//! Rotating the schema version invalidates everything; rotating the content
33//! invalidates one file. There's no LRU or cleanup yet — Step 2 will add a
34//! size cap + orphan sweep.
35
36use std::io::{self, Write};
37use std::path::{Path, PathBuf};
38
39use serde::{Serialize, de::DeserializeOwned};
40
41/// Identifies a single cache entry. Opaque — callers produce it via
42/// [`WorkspaceCache::key_for`] and pass it straight back to read/write.
43#[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/// Handle to the cache directory for a single workspace. Construction is
53/// cheap (creates directories on demand); the same handle can be shared
54/// across threads via `Arc` — it holds no mutable state.
55#[derive(Debug, Clone)]
56pub struct WorkspaceCache {
57    dir: PathBuf,
58}
59
60/// Size cap (bytes) for a single workspace's cache directory. At
61/// startup, if the directory exceeds this, we reset it — simpler than
62/// LRU eviction and the rebuild cost is bounded (it's just the next
63/// workspace scan running as if cold). 512 MiB fits a mega-workspace
64/// (50 k files × ~10 KB average `FileIndex`) with headroom and is
65/// small enough that no reasonable disk will choke on it.
66pub const CACHE_SIZE_CAP: u64 = 512 * 1024 * 1024;
67
68impl WorkspaceCache {
69    /// Create (or re-open) the cache directory for a workspace rooted at
70    /// `root`. Returns `None` when the system has no usable home/cache
71    /// directory — callers should treat that as "cache disabled" and
72    /// proceed without persistence.
73    ///
74    /// If the existing cache directory exceeds [`CACHE_SIZE_CAP`], it is
75    /// cleared before the handle is returned. That's a coarse knob —
76    /// K3 could refine to LRU-by-mtime — but crossing 512 MiB at
77    /// startup indicates the workspace has churned through many
78    /// content hashes and the rebuild cost is bounded to one full
79    /// re-scan.
80    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    /// The filesystem path of this workspace's cache directory.
94    pub fn cache_dir(&self) -> &std::path::Path {
95        &self.dir
96    }
97
98    /// Total bytes consumed by `.bin` entries in this workspace's cache
99    /// directory. Cheap (one `read_dir` pass, no recursion into
100    /// subdirectories because the layout is flat).
101    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    /// Override the root directory directly. The directory is used verbatim
121    /// (no schema / workspace subdirectories are appended). Use for tests or
122    /// when the caller provides an explicit `cachePath` initializationOption.
123    pub fn with_dir(dir: PathBuf) -> Self {
124        Self { dir }
125    }
126
127    /// Build a cache key from file content. Combines `uri` and `content`
128    /// so that two files with identical content but different URIs get
129    /// different keys.
130    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    /// Deserialize a previously-cached value. Returns `None` on any I/O
140    /// or decode failure — a corrupted entry should look identical to a
141    /// missing one so callers fall through to the recompute path.
142    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    /// Atomically publish an entry to the cache. Writes to a sibling
152    /// temp file then renames, so readers never see a half-written
153    /// payload even if the process dies mid-write.
154    ///
155    /// No fsync: the cache is advisory-only — a crash that loses a write
156    /// just produces a cache miss on the next startup, which safely falls
157    /// back to re-parsing. Skipping sync_all() avoids 5–15 ms per file on
158    /// macOS, which on a 1,500-file project accounts for most of the cold
159    /// indexing time.
160    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    /// Drop every entry in this workspace's cache. Safe to call while
175    /// other threads are reading — individual `read` calls that race
176    /// with a `clear` will see `None` rather than garbage, and the next
177    /// `write` recreates the entry.
178    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
191/// Platform cache directory: `$XDG_CACHE_HOME` or `$HOME/.cache` on Unix,
192/// `%LOCALAPPDATA%` on Windows. Deliberately doesn't depend on the `dirs`
193/// crate — keeps the footprint small and the behaviour predictable.
194pub(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
214/// Schema marker: bumping `php-lsp` crate version, `mir-codebase` version, or
215/// the trailing `fi-vN` file-index marker invalidates every cached entry. The
216/// hardcoded mir version is a trade-off: keeping it in source means we don't
217/// depend on `build.rs` introspection, at the cost of needing to remember to
218/// update it alongside `Cargo.toml`. Bump `fi-vN` whenever `FileIndex` or any
219/// type it contains gains, loses, or renames a field.
220fn 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        // Same content, different URI — the separator byte prevents
258        // (uri_a || content_b) from colliding with (uri_a+b || content).
259        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        // Write garbage bytes directly into the slot the cache would use.
293        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        // If the write path didn't go through a temp file, a crash
304        // mid-`write_all` could leave a half-written `.bin`. We can't
305        // easily simulate a crash, but we can at least assert the
306        // temp-file doesn't linger on success.
307        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}