Skip to main content

php_lsp/document/
document_store.rs

1use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
2use std::sync::{Arc, Mutex, OnceLock};
3
4use arc_swap::ArcSwap;
5
6use dashmap::{DashMap, DashSet};
7use salsa::Setter;
8use tower_lsp::lsp_types::{SemanticToken, Url};
9
10use crate::db::mir_queries::{LspWorkspace, LspWsFile};
11use crate::document::ast::ParsedDoc;
12use crate::document::cache_registry::CacheRegistry;
13use crate::index::file_index::FileIndex;
14use crate::lang::autoload::Psr4Map;
15
16pub struct DocumentStore {
17    /// Per-file caches with unified eviction logic. See [`CacheRegistry`].
18    caches: CacheRegistry,
19
20    // ── Salsa-input storage ────────────────────────────────────────────────
21    // Phase E4: `DocumentStore` is now a pure salsa-input wrapper. Open-file
22    // state (live text, version token, parse-diagnostics cache) lives on
23    // `Backend` in its `open_files` map; the set of files tracked by salsa
24    // is exactly `source_files.keys()`.
25    /// `Url -> LspWsFile` lookup on the shared mir db. Each `LspWsFile` pairs
26    /// mir's `SourceFile` (the shared text input) with the optional warm-start
27    /// `cached_index`. Created/updated through the `AnalysisSession`'s
28    /// `with_db_mut` under the db write lock; reads run on cheap snapshot clones.
29    lsp_ws_files: DashMap<Url, LspWsFile>,
30    /// URIs that have been removed. Re-opening a deleted URI un-deletes it here
31    /// and reuses the existing `LspWsFile` handle.
32    deleted_uris: DashSet<Url>,
33    /// Set to `true` when the set of tracked files changes (add or remove).
34    /// `sync_workspace_files` skips the collect/sort/compare path when this
35    /// is `false`, avoiding a lock acquisition on every LSP request.
36    workspace_files_dirty: AtomicBool,
37    /// `LspWorkspace` salsa input on the shared mir db: the project-file scoping
38    /// set aggregated by `workspace_index`. Created lazily on first sync (the db
39    /// is owned by the lazily-built `AnalysisSession`).
40    lsp_workspace: Mutex<Option<LspWorkspace>>,
41    /// Target PHP version (selects the `AnalysisSession`). Stored here since the
42    /// converged db has no php-lsp `Workspace` input to carry it.
43    php_version: Mutex<mir_analyzer::PhpVersion>,
44    /// Shared PSR-4 namespace-to-path map. Shared with `Backend` via `Arc`
45    /// so updates from `initialized` (when composer.json is loaded) are
46    /// visible here without any additional wiring. `ArcSwap` makes reads
47    /// lock-free — a poisoned guard can no longer crash a request handler.
48    psr4: Arc<ArcSwap<Psr4Map>>,
49    /// mir-analyzer's `AnalysisSession` — owns the workspace MirDb, runs
50    /// Pass-2 analysis, and lazy-loads dependencies via PSR-4. Built lazily
51    /// on first use; rebuilt when PHP version changes.
52    analysis_session: Mutex<Option<(mir_analyzer::PhpVersion, Arc<mir_analyzer::AnalysisSession>)>>,
53    /// Cache directory shared with the workspace file-index cache. When set,
54    /// new `AnalysisSession`s are built with `with_cache_dir` so that stub
55    /// parsing results survive server restarts.
56    session_cache_dir: OnceLock<std::path::PathBuf>,
57    /// URIs of autoload.files entries from composer.json. These define global
58    /// helper functions (e.g. tap, class_uses_recursive in Laravel) that are
59    /// not discoverable by namespace walk. Pre-ingested into the AnalysisSession
60    /// before each file analysis so mir doesn't emit false UndefinedFunction.
61    autoload_uris: std::sync::RwLock<Vec<Url>>,
62    /// Set once the workspace scan's reference-index phase finishes, i.e. the
63    /// `subtypes_of` map in `workspace_index` is complete. Visibility-derived
64    /// scope narrowing that relies on the full subtype set (protected methods)
65    /// only applies once this is `true`; before then it falls back to the full
66    /// workspace scope so references are never under-reported.
67    index_ready: AtomicBool,
68    /// Monotonically increasing counter bumped on every actual text write
69    /// (including file deletions). Long-running read operations (e.g.
70    /// `session_references_to`) capture this value before starting and
71    /// cancel themselves if it advances — avoiding stale results and
72    /// unbounded retry loops after concurrent edits.
73    write_revision: AtomicU64,
74    /// Cancel token for the in-flight dependent-diagnostics sweep. A newer
75    /// edit's sweep cancels the previous one via [`Self::begin_reanalyze`], so
76    /// fast typing preempts stale workspace re-analysis rather than queueing
77    /// behind it.
78    reanalyze_cancel: Mutex<mir_analyzer::IndexCancel>,
79}
80
81impl Default for DocumentStore {
82    fn default() -> Self {
83        Self::new()
84    }
85}
86
87impl DocumentStore {
88    pub fn new() -> Self {
89        DocumentStore {
90            caches: CacheRegistry::new(),
91            lsp_ws_files: DashMap::new(),
92            deleted_uris: DashSet::new(),
93            workspace_files_dirty: AtomicBool::new(true),
94            lsp_workspace: Mutex::new(None),
95            php_version: Mutex::new(mir_analyzer::PhpVersion::LATEST),
96            psr4: Arc::new(ArcSwap::from_pointee(Psr4Map::empty())),
97            analysis_session: Mutex::new(None),
98            session_cache_dir: OnceLock::new(),
99            autoload_uris: std::sync::RwLock::new(Vec::new()),
100            index_ready: AtomicBool::new(false),
101            write_revision: AtomicU64::new(0),
102            reanalyze_cancel: Mutex::new(mir_analyzer::IndexCancel::new()),
103        }
104    }
105
106    /// Install a fresh cancel token for a dependent-diagnostics sweep,
107    /// cancelling whichever sweep was previously in flight. Pass the returned
108    /// token to `reanalyze_dependents_cancellable`; when a newer edit calls
109    /// this again the old token flips and that older sweep stops at its next
110    /// file boundary instead of racing this one to publish stale diagnostics.
111    pub fn begin_reanalyze(&self) -> mir_analyzer::IndexCancel {
112        let fresh = mir_analyzer::IndexCancel::new();
113        let mut guard = self.reanalyze_cancel.lock().unwrap();
114        guard.cancel();
115        *guard = fresh.clone();
116        fresh
117    }
118
119    /// Mark the workspace reference index as fully built. Called by the scan
120    /// when its final phase completes (alongside `$/php-lsp/indexReady`).
121    pub fn mark_index_ready(&self) {
122        self.index_ready.store(true, Ordering::Release);
123    }
124
125    /// Whether the workspace reference index has finished building.
126    pub fn is_index_ready(&self) -> bool {
127        self.index_ready.load(Ordering::Acquire)
128    }
129
130    /// Snapshot of the write-revision counter. Long-running reads should
131    /// capture this before starting and pass it to cancellable operations;
132    /// if the counter advances, those operations abort and return empty rather
133    /// than looping indefinitely against a newly-invalidated database.
134    pub fn write_rev(&self) -> u64 {
135        self.write_revision.load(Ordering::Acquire)
136    }
137
138    /// Set the directory used to persist stub-parse and analysis results across
139    /// server restarts.  Must be called before the first `analysis_session` use;
140    /// subsequent calls are silently ignored (`OnceLock` semantics).
141    pub fn set_session_cache_dir(&self, dir: std::path::PathBuf) {
142        let _ = self.session_cache_dir.set(dir);
143    }
144
145    /// Register URIs discovered from composer.json `autoload.files` entries.
146    /// These PHP files define global helper functions (e.g. `tap()` in Laravel)
147    /// that are not class-resolvable via PSR-4. Clears `analysis_cache` so the
148    /// next per-file analysis pre-ingests them into the AnalysisSession before
149    /// running mir's FileAnalyzer.
150    pub fn set_autoload_uris(&self, uris: Vec<Url>) {
151        *self.autoload_uris.write().unwrap() = uris;
152        self.caches.evict_analysis_all();
153    }
154
155    /// Get or build the `AnalysisSession` for the given PHP version, **without**
156    /// loading PHP stubs. Stub loading is heavy (parses every built-in stub
157    /// file) and is only needed for semantic analysis, not for parse / index /
158    /// symbol-map queries. The parse path uses this; semantic callers use
159    /// [`Self::analysis_session`], which loads stubs idempotently on top.
160    fn get_or_build_session(
161        &self,
162        php_version: mir_analyzer::PhpVersion,
163    ) -> Arc<mir_analyzer::AnalysisSession> {
164        let mut guard = self.analysis_session.lock().unwrap();
165        if let Some((cached_ver, session)) = guard.as_ref()
166            && *cached_ver == php_version
167        {
168            return Arc::clone(session);
169        }
170        // Build a fresh session. Hand it the shared PSR-4 map so it can
171        // lazy-resolve `UndefinedClass` candidates without us having to mirror
172        // every vendor file upfront.
173        let resolver: Arc<dyn mir_analyzer::ClassResolver> = self.psr4.load_full();
174        let mut builder =
175            mir_analyzer::AnalysisSession::new(php_version).with_class_resolver(resolver);
176        if let Some(dir) = self.session_cache_dir.get() {
177            builder = builder.with_cache_dir(dir);
178        }
179        let session = Arc::new(builder);
180        *guard = Some((php_version, Arc::clone(&session)));
181        session
182    }
183
184    /// The `AnalysisSession` for the given PHP version with PHP stubs loaded.
185    /// Rebuilds when the version changes (e.g. user flipped config). The
186    /// session owns the shared salsa db and AnalysisCache; lazy-loads vendor
187    /// files via the shared PSR-4 map. `ensure_all_stubs` is idempotent — cheap
188    /// after the first call on a given session.
189    pub fn analysis_session(
190        &self,
191        php_version: mir_analyzer::PhpVersion,
192    ) -> Arc<mir_analyzer::AnalysisSession> {
193        let session = self.get_or_build_session(php_version);
194        session.ensure_all_stubs();
195        session
196    }
197
198    /// Current PHP version tracked by the workspace input.
199    pub fn workspace_php_version(&self) -> mir_analyzer::PhpVersion {
200        *self.php_version.lock().unwrap()
201    }
202
203    /// File URIs of all direct and transitive subclasses of `class_fqn`,
204    /// resolved via mir's inheritance graph. Returns an empty vec when the mir
205    /// session hasn't ingested the class yet (cold start, excluded paths).
206    ///
207    /// Used by `goto_implementation` and `subtypes` to scope their lookups to
208    /// the correct files, fixing aliased `extends` and FQN-qualified forms that
209    /// the raw-name `subtypes_of` map misses.
210    pub fn class_subtype_urls(&self, class_fqn: &str) -> Vec<tower_lsp::lsp_types::Url> {
211        let session = self.analysis_session(self.workspace_php_version());
212        session
213            .subtype_files(class_fqn)
214            .into_iter()
215            .filter_map(|p| tower_lsp::lsp_types::Url::parse(&p).ok())
216            .collect()
217    }
218
219    /// Return the `Arc<ArcSwap<Psr4Map>>` so callers can share it.
220    /// `Backend` clones this arc at construction time so writes
221    /// (e.g. loading composer.json on `initialized`) are immediately visible
222    /// to PSR-4 resolution during analysis without extra plumbing.
223    pub fn psr4_arc(&self) -> Arc<ArcSwap<Psr4Map>> {
224        Arc::clone(&self.psr4)
225    }
226
227    /// Durability for a file's salsa input: vendor files never change within a
228    /// session, so salsa can skip re-validating their queries on user edits.
229    fn input_durability(uri: &Url) -> salsa::Durability {
230        if uri.as_str().contains("/vendor/") {
231            salsa::Durability::HIGH
232        } else {
233            salsa::Durability::LOW
234        }
235    }
236
237    /// The `LspWsFile` handle for `uri`, if it is mirrored and not deleted.
238    fn lsp_ws_file(&self, uri: &Url) -> Option<LspWsFile> {
239        if self.deleted_uris.contains(uri) {
240            return None;
241        }
242        self.lsp_ws_files.get(uri).map(|e| *e)
243    }
244
245    /// Take a cheap snapshot of the shared mir db and run `f` on it, retrying
246    /// on `salsa::Cancelled` (raised when a concurrent writer bumps the
247    /// revision). Mirrors [`Self::snapshot_query`] for the converged db.
248    ///
249    /// The loop terminates as soon as the writer pauses long enough for one
250    /// query to complete. Handlers that want an early exit under sustained
251    /// write pressure should pass a write-rev closure (see `code_lenses`) and
252    /// check it at coarser granularity rather than here.
253    fn snapshot_mir_query<R>(&self, f: impl Fn(&mir_analyzer::db::MirDbStorage) -> R) -> R {
254        use std::panic::AssertUnwindSafe;
255        let session = self.get_or_build_session(self.workspace_php_version());
256        // Each iteration's snapshot clone MUST drop before the next snapshot:
257        // a concurrent writer's salsa `set` holds the mir write lock and waits
258        // for outstanding db handles to drop, while the next `snapshot_db` needs
259        // the read lock — keeping the clone alive across the retry deadlocks.
260        loop {
261            let db = session.snapshot_db();
262            match salsa::Cancelled::catch(AssertUnwindSafe(|| f(&db))) {
263                Ok(r) => return r,
264                Err(_) => drop(db),
265            }
266        }
267    }
268
269    /// Bounded variant of [`Self::snapshot_mir_query`] for cosmetic,
270    /// cursor-triggered reads that must not spin under sustained write pressure.
271    /// Returns `None` if a concurrent writer cancels the query `attempts` times
272    /// in a row (the caller then falls back to a stale result). Same
273    /// drop-before-retry discipline as `snapshot_mir_query`.
274    fn try_snapshot_mir_query<R>(
275        &self,
276        attempts: usize,
277        f: impl Fn(&mir_analyzer::db::MirDbStorage) -> R,
278    ) -> Option<R> {
279        use std::panic::AssertUnwindSafe;
280        let session = self.get_or_build_session(self.workspace_php_version());
281        for _ in 0..attempts {
282            let db = session.snapshot_db();
283            match salsa::Cancelled::catch(AssertUnwindSafe(|| f(&db))) {
284                Ok(r) => return Some(r),
285                Err(_) => drop(db),
286            }
287        }
288        None
289    }
290
291    /// Mirror a file's current text into the salsa layer. Creates the
292    /// `FileText` input on first sight, otherwise updates `text` on the
293    /// existing input (bumping the salsa revision so downstream queries
294    /// invalidate).
295    pub fn mirror_text(&self, uri: &Url, text: &str) {
296        // G2 fast path: compare against the lock-free text cache. When the
297        // new text byte-matches what we already mirrored, skip the host
298        // mutex entirely. Common during workspace scan + `did_open` for
299        // unchanged files, where most threads would otherwise serialise on
300        // `host.lock()` just to confirm a no-op.
301        if let Some(cached) = self.caches.text_cache.get(uri)
302            && **cached == *text
303            && !self.deleted_uris.contains(uri)
304            && self.lsp_ws_files.contains_key(uri)
305        {
306            return;
307        }
308        self.mirror_text_arc(uri, Arc::from(text))
309    }
310
311    /// Like [`mirror_text`] but takes an already-allocated `Arc<str>`.
312    ///
313    /// Callers that already hold an `Arc<str>` (e.g. `ingest_from_doc` reusing
314    /// `ParsedDoc::source_arc()`) use this to avoid a second allocation and to
315    /// ensure `text_cache` and `parsed_cache` hold the same Arc pointer —
316    /// enabling `Arc::ptr_eq` validation in `get_parsed_cached`.
317    pub fn mirror_text_arc(&self, uri: &Url, text_arc: Arc<str>) {
318        let dur = Self::input_durability(uri);
319        let path: Arc<str> = Arc::from(uri.as_str());
320        let session = self.get_or_build_session(self.workspace_php_version());
321        if let Some(wf) = self.lsp_ws_files.get(uri).map(|e| *e) {
322            self.deleted_uris.remove(uri);
323            // Fast path: byte-identical text already mirrored — skip the write
324            // lock and the revision bump entirely.
325            if let Some(cached) = self.caches.text_cache.get(uri)
326                && **cached == *text_arc
327            {
328                return;
329            }
330            session.with_db_mut(|db| {
331                let sf = wf.source(db);
332                sf.set_text(db).with_durability(dur).to(text_arc.clone());
333                // Any text change invalidates a previously-seeded cached index.
334                // Only set when present to avoid a spurious second revision bump.
335                if wf.cached_index(db).is_some() {
336                    wf.set_cached_index(db).to(None);
337                }
338            });
339            self.caches.text_cache.insert(uri.clone(), text_arc);
340            // Evict only this file's analysis; cross-file invalidation is handled
341            // lazily in `cached_analysis` via the declaration fingerprint.
342            self.caches.evict_analysis(uri);
343            self.write_revision.fetch_add(1, Ordering::Release);
344        } else {
345            let wf = session.with_db_mut(|db| {
346                let sf = db.upsert_source_file_with_durability(path, text_arc.clone(), dur);
347                LspWsFile::new(db, sf, None)
348            });
349            self.lsp_ws_files.insert(uri.clone(), wf);
350            self.caches.text_cache.insert(uri.clone(), text_arc);
351            self.workspace_files_dirty.store(true, Ordering::Release);
352            self.write_revision.fetch_add(1, Ordering::Release);
353        }
354    }
355
356    /// Return the `LspWsFile` handle for a URL, if active (not deleted).
357    #[cfg(test)]
358    pub fn source_file(&self, uri: &Url) -> Option<LspWsFile> {
359        if self.deleted_uris.contains(uri) {
360            return None;
361        }
362        self.lsp_ws_files.get(uri).map(|e| *e)
363    }
364
365    /// Phase K2: pre-seed a `FileIndex` loaded from the on-disk cache onto
366    /// the `FileText` input for `uri`. The next `file_index` call for that
367    /// file returns the cached index directly, skipping parse + extract.
368    ///
369    /// Must be called **before** any `file_index(db, sf)` call for this file —
370    /// otherwise salsa has already memoized the fresh-parse result and setting
371    /// `cached_index` now would only bump the revision without using the cache.
372    /// In practice the workspace-scan path seeds immediately after `mirror_text`
373    /// and before any query runs.
374    ///
375    /// Returns `false` when `uri` was not mirrored (caller should mirror
376    /// first); returns `true` on success.
377    pub fn seed_cached_index(&self, uri: &Url, index: Arc<FileIndex>) -> bool {
378        let Some(wf) = self.lsp_ws_file(uri) else {
379            return false;
380        };
381        let session = self.get_or_build_session(self.workspace_php_version());
382        session.with_db_mut(|db| wf.set_cached_index(db).to(Some(index)));
383        true
384    }
385
386    /// Evict the semantic-tokens cache for `uri`. Called by Backend when a
387    /// file is closed; diff-based tokens computed against the old revision
388    /// are no longer meaningful.
389    pub fn evict_token_cache(&self, uri: &Url) {
390        self.caches.evict_tokens(uri);
391    }
392
393    /// Return the `FileIndex` for `uri` by running `file_index` on a salsa
394    /// snapshot.  Returns `None` when `uri` has not been mirrored.
395    ///
396    /// Test-only — production code uses the salsa query directly via
397    /// `snapshot_query`.
398    #[cfg(test)]
399    pub fn source_files_len(&self) -> usize {
400        self.lsp_ws_files.len()
401    }
402
403    #[cfg(test)]
404    pub fn snapshot_query_file_index(
405        &self,
406        uri: &Url,
407    ) -> Option<crate::index::file_index::FileIndex> {
408        let wf = self.lsp_ws_file(uri)?;
409        Some(
410            self.snapshot_mir_query(move |db| {
411                (*crate::db::mir_queries::file_index(db, wf).0).clone()
412            }),
413        )
414    }
415
416    /// Register a file in the salsa layer without marking it open.
417    ///
418    /// Salsa's `parsed_doc` query parses lazily on first read; diagnostics
419    /// are populated by `did_open` when the editor actually opens the file.
420    pub fn ingest(&self, uri: Url, text: &str) {
421        self.mirror_text(&uri, text);
422    }
423
424    /// Index a file using an already-parsed `ParsedDoc`, avoiding a second parse.
425    ///
426    /// Prefer this over [`ingest`] when the caller already has a `ParsedDoc` (e.g.
427    /// after running `DefinitionCollector` during workspace scan). Reuses the
428    /// `Arc<str>` already owned by `doc` so that `text_cache` and `SourceFile::text`
429    /// share the same pointer — enabling the `Arc::ptr_eq` fast path in
430    /// `get_parsed_cached` on the first subsequent salsa query, without an extra
431    /// `Arc::from(source)` allocation.
432    pub fn ingest_from_doc(&self, uri: Url, doc: &ParsedDoc) {
433        self.mirror_text_arc(&uri, doc.source_arc());
434    }
435
436    pub fn remove(&self, uri: &Url) {
437        self.caches.evict(uri);
438        // Mark the URI as deleted but keep the `source_files` entry so the
439        // salsa `SourceFile` handle remains alive. Re-opening the file reuses
440        // the same handle instead of calling `SourceFile::new()` again, which
441        // would create a new orphaned salsa input on every delete-reopen cycle.
442        self.deleted_uris.insert(uri.clone());
443        self.workspace_files_dirty.store(true, Ordering::Release);
444        // Sync workspace files so the deleted file is removed from the salsa
445        // `Workspace::files` list and won't appear in workspace symbols etc.
446        self.sync_workspace_files();
447        // Also evict the file from the `AnalysisSession`'s internal state so
448        // workspace symbol queries don't keep returning the deleted file's
449        // declarations. Cheap when the session hasn't ingested this file.
450        let guard = self.analysis_session.lock().unwrap();
451        if let Some((_, session)) = guard.as_ref() {
452            session.invalidate_file(uri.as_str());
453        }
454        self.write_revision.fetch_add(1, Ordering::Release);
455    }
456
457    // ── Salsa-backed accessors ─────────────────────────────────────────────
458    //
459    // Reads run the memoized `parsed_doc` / `file_index` queries, parsing
460    // only on first access per revision. These are the production accessors
461    // used by every handler.
462
463    /// Salsa-backed parsed document.
464    ///
465    /// Salsa-backed parsed document for any mirrored file (open or
466    /// background-indexed). Returns `None` only when the file is not known
467    /// to the store. Callers that want "only if open" should gate on
468    /// `Backend::open_files` at the call site (see `Backend::get_doc`).
469    pub fn get_doc_salsa(&self, uri: &Url) -> Option<Arc<ParsedDoc>> {
470        self.get_parsed_cached(uri)
471    }
472
473    /// Salsa-backed compact symbol index.
474    pub fn get_index_salsa(&self, uri: &Url) -> Option<Arc<FileIndex>> {
475        let wf = self.lsp_ws_file(uri)?;
476        Some(
477            self.snapshot_mir_query(move |db| crate::db::mir_queries::file_index(db, wf).0.clone()),
478        )
479    }
480
481    /// Salsa-backed pre-computed symbol map (name → Vec<SymbolEntry>).
482    /// Memoized per revision: stable files serve from cache in O(1).
483    pub fn get_symbol_map_salsa(
484        &self,
485        uri: &Url,
486    ) -> Option<Arc<crate::types::symbol_map::SymbolMap>> {
487        // Symbol map runs on the shared mir db, sharing its memoized `parsed_doc`.
488        let wf = self.lsp_ws_file(uri)?;
489        Some(self.snapshot_mir_query(move |db| {
490            let sf = wf.source(db);
491            crate::db::mir_queries::symbol_map(db, sf).0.clone()
492        }))
493    }
494
495    /// Pre-computed symbol maps for every entry in `open_urls` except `uri`.
496    pub fn other_symbol_maps(
497        &self,
498        uri: &Url,
499        open_urls: &[Url],
500    ) -> Vec<(Url, Arc<crate::types::symbol_map::SymbolMap>)> {
501        open_urls
502            .iter()
503            .filter(|u| *u != uri)
504            .filter_map(|u| self.get_symbol_map_salsa(u).map(|m| (u.clone(), m)))
505            .collect()
506    }
507
508    /// G3: shared implementation for `get_doc_salsa`.
509    /// Tries the `parsed_cache` (lock-free) first; validates via
510    /// `Arc::ptr_eq` against the G2 `text_cache` so a concurrent writer
511    /// that has already committed a new text input cannot be masked by a
512    /// stale cache entry. On miss, captures the text Arc and ParsedDoc
513    /// together inside a single `snapshot_query`, then publishes both.
514    fn get_parsed_cached(&self, uri: &Url) -> Option<Arc<ParsedDoc>> {
515        if let Some(current_text) = self.caches.text_cache.get(uri)
516            && let Some(entry) = self.caches.parsed_cache.get(uri)
517            && Arc::ptr_eq(&*current_text, &entry.0)
518        {
519            let doc = entry.1.clone();
520            drop(entry);
521            self.caches.touch(uri);
522            return Some(doc);
523        }
524
525        // Parse runs on the shared mir db.
526        let wf = self.lsp_ws_file(uri)?;
527        self.caches.bump_parse_count();
528        let (text, doc) = self.snapshot_mir_query(move |db| {
529            let sf = wf.source(db);
530            let text = sf.text(db);
531            let doc = crate::db::mir_queries::parsed_doc(db, sf).0.clone();
532            (text, doc)
533        });
534        self.caches.insert_parsed(uri.clone(), text, doc.clone());
535        Some(doc)
536    }
537
538    /// Parsed doc for a cosmetic, cursor-triggered read (e.g. `documentHighlight`)
539    /// that must stay responsive while the user types. Unlike [`Self::get_doc_salsa`]
540    /// it never spins on the `Cancelled` retry loop: it tries the lock-free
541    /// `parsed_cache`, then a bounded snapshot parse, and if a concurrent write
542    /// stream keeps cancelling it, returns the last-good cached `ParsedDoc`
543    /// (possibly one edit stale — invisible for a highlight, unlike a hang).
544    /// `None` only when the file has never been parsed.
545    pub fn get_doc_snapshot_or_stale(&self, uri: &Url) -> Option<Arc<ParsedDoc>> {
546        if let Some(current_text) = self.caches.text_cache.get(uri)
547            && let Some(entry) = self.caches.parsed_cache.get(uri)
548            && Arc::ptr_eq(&*current_text, &entry.0)
549        {
550            let doc = entry.1.clone();
551            drop(entry);
552            self.caches.touch(uri);
553            return Some(doc);
554        }
555
556        if let Some(wf) = self.lsp_ws_file(uri)
557            && let Some((text, doc)) = self.try_snapshot_mir_query(3, move |db| {
558                let sf = wf.source(db);
559                let text = sf.text(db);
560                let doc = crate::db::mir_queries::parsed_doc(db, sf).0.clone();
561                (text, doc)
562            })
563        {
564            self.caches.bump_parse_count();
565            self.caches.insert_parsed(uri.clone(), text, doc.clone());
566            return Some(doc);
567        }
568
569        self.caches.parsed_cache.get(uri).map(|e| e.1.clone())
570    }
571
572    /// Refresh `workspace.files` to mirror the current active file set.
573    ///
574    /// Skips all work when `workspace_files_dirty` is `false` (the common
575    /// case after the workspace scan completes — file-set changes are rare).
576    pub fn sync_workspace_files(&self) {
577        // Atomically clear the flag.  If it was already false the file set
578        // hasn't changed since the last sync; nothing to do.
579        if !self.workspace_files_dirty.swap(false, Ordering::AcqRel) {
580            return;
581        }
582
583        // Collect active (non-deleted) files, sorted by URI for stable ordering.
584        let mut entries: Vec<(Arc<str>, LspWsFile)> = self
585            .lsp_ws_files
586            .iter()
587            .filter(|e| !self.deleted_uris.contains(e.key()))
588            .map(|e| (Arc::<str>::from(e.key().as_str()), *e.value()))
589            .collect();
590        entries.sort_unstable_by(|(a, _), (b, _)| a.cmp(b));
591        let files: Arc<[LspWsFile]> = entries.iter().map(|(_, wf)| *wf).collect();
592
593        let session = self.get_or_build_session(self.workspace_php_version());
594        let mut guard = self.lsp_workspace.lock().unwrap();
595        session.with_db_mut(|db| match *guard {
596            Some(ws) => {
597                ws.set_files(db).to(files);
598            }
599            None => *guard = Some(LspWorkspace::new(db, files)),
600        });
601    }
602
603    /// Mark the workspace file set as dirty so the next `sync_workspace_files`
604    /// call re-runs the collect/sort/compare path.  Exposed for benchmarks that
605    /// need to measure the dirty-path cost in isolation.
606    pub fn mark_workspace_files_dirty(&self) {
607        self.workspace_files_dirty.store(true, Ordering::Release);
608    }
609
610    /// Update the PHP version tracked by the workspace. Salsa will invalidate
611    /// all `semantic_issues` queries so diagnostics are re-evaluated.
612    /// Skips the setter when the version hasn't changed to avoid spurious
613    /// query invalidation.
614    pub fn set_php_version(&self, version: mir_analyzer::PhpVersion) {
615        {
616            let mut guard = self.php_version.lock().unwrap();
617            if *guard == version {
618                return;
619            }
620            *guard = version;
621        }
622        // Changing the version selects a different `AnalysisSession` (and thus a
623        // different db). Drop the inputs created on the old session's db; the
624        // workspace scan re-mirrors files onto the new session. In practice the
625        // version is set once at init, before any file is mirrored.
626        self.lsp_ws_files.clear();
627        *self.lsp_workspace.lock().unwrap() = None;
628        self.workspace_files_dirty.store(true, Ordering::Release);
629        // Stale FileAnalysis from the old version would survive unchanged files.
630        self.caches.evict_analysis_all();
631    }
632
633    /// Session-backed reference lookup scoped to `files`. Returns `(file, line,
634    /// col)` locations for every occurrence of `symbol` originating in those
635    /// files, computed from the memoized `analyze_file` query — no ingest, no
636    /// shared-index mutation, so repeated lookups don't degrade across a
637    /// session. `files` should be the text-pre-filtered candidate set.
638    ///
639    /// Returns LSP-style 0-based line/column.
640    ///
641    /// `cancel_rev`: when `Some(rev)`, the loop throws `salsa::Cancelled` and
642    /// returns empty if a concurrent write advances the `write_revision` counter
643    /// past `rev` — preventing unbounded retries against a newly-invalidated db.
644    /// Pass `None` to retain the original indefinite-retry behaviour (fast ops
645    /// like single-file reads where a stale result is not a concern).
646    pub fn session_references_to(
647        &self,
648        symbol: &mir_analyzer::Name,
649        files: &[Arc<str>],
650        cancel_rev: Option<u64>,
651    ) -> Vec<(Arc<str>, u32, u32, u32)> {
652        let php_version = self.workspace_php_version();
653        // Staleness probe threaded into mir: polled at Phase-1 file boundaries
654        // and between Phase-2 retry attempts, so a request invalidated by a
655        // concurrent edit aborts *inside* mir's retry loop instead of spinning
656        // there indefinitely (mir catches `Cancelled` internally, so an outer
657        // catch alone never fires for Phase 2).
658        let stale =
659            || cancel_rev.is_some_and(|rev| self.write_revision.load(Ordering::Acquire) != rev);
660        // Retry: concurrent db writes (background indexing) cancel snapshot
661        // queries via resume_unwind; without the loop the panic propagates out
662        // of the caller's spawn_blocking and the request silently returns empty.
663        let raw = loop {
664            let session = self.analysis_session(php_version);
665            match salsa::Cancelled::catch(std::panic::AssertUnwindSafe(|| {
666                session.references_to_in_files_cancellable(symbol, files, &stale)
667            })) {
668                Ok(Some(refs)) => break refs,
669                // mir aborted via the staleness probe — or a Phase-1 unwind
670                // hit an already-stale request. Propagate as salsa::Cancelled
671                // so spawn_blocking callers see a JoinError::Panicked and
672                // return empty via unwrap_or_default.
673                Ok(None) => {
674                    std::panic::resume_unwind(Box::new(salsa::Cancelled::PendingWrite));
675                }
676                Err(_) if stale() => {
677                    std::panic::resume_unwind(Box::new(salsa::Cancelled::PendingWrite));
678                }
679                // Phase-1 unwind from a write that doesn't invalidate this
680                // request (mir-internal load_class, scan writes with no
681                // cancel_rev) — retry.
682                Err(_) => {}
683            }
684        };
685        raw.into_iter()
686            .map(|(file, range)| {
687                // mir uses 1-based lines; 0-based columns (since mir 0.42.0).
688                let line = range.start.line.saturating_sub(1);
689                let col_start = range.start.column;
690                let col_end = range.end.column;
691                (file, line, col_start, col_end)
692            })
693            .collect()
694    }
695
696    /// The complete set of files a `private` method's references can occur in,
697    /// or `None` when the search must stay workspace-wide.
698    ///
699    /// `owner_fqn` is the enclosing class at the cursor; narrowing applies only
700    /// when that class *directly declares* `method` (so its `MethodDef`
701    /// visibility is authoritative — inherited/call-site lookups fall through to
702    /// `None`). A reference to `Class::method` is recorded only where
703    /// `analyze_file` resolves an expression to that key, and `analyze_file` is
704    /// per-source-file, so a `private` member — resolvable only inside the
705    /// declaring class body, which lives in one file — is fully scoped to the
706    /// declaring file at any indexing stage.
707    ///
708    /// A `protected` member is reachable from the declaring class and its
709    /// transitive subclasses (`$this->m()`, `parent::m()`, `self::`/`static::`),
710    /// all of which live in the declaring file or a subtype file — never outside
711    /// the hierarchy in valid PHP. Its scope is therefore the declaring file plus
712    /// the transitive subtype files. The complete subtype set is only known once
713    /// the workspace reference index finishes, so this narrowing applies only
714    /// when [`Self::is_index_ready`]; before then it falls back to `None` (full
715    /// workspace scope) so references are never under-reported.
716    ///
717    /// Classes that compose traits or mixins are excluded — those inline
718    /// external resolution context, so a reference could surface in another file.
719    pub fn method_reference_scope(&self, owner_fqn: &str, method: &str) -> Option<Vec<Url>> {
720        use crate::index::file_index::{ClassKind, Visibility};
721
722        let ws = self.get_workspace_index_salsa();
723        let owner_fqn = owner_fqn.trim_start_matches('\\');
724        let owner_short = crate::text::fqn_short_name(owner_fqn);
725
726        let (decl_uri, decl_class) = ws
727            .classes_by_name
728            .get(owner_short)?
729            .iter()
730            .filter_map(|&r| ws.at(r))
731            .find(|(_, cls)| cls.fqn.trim_start_matches('\\') == owner_fqn)?;
732
733        // Trait/interface methods, and methods on classes that compose traits or
734        // mixins, can be referenced from other files — keep the full scope.
735        if !matches!(decl_class.kind, ClassKind::Class | ClassKind::Enum)
736            || !decl_class.traits.is_empty()
737            || !decl_class.mixins.is_empty()
738        {
739            return None;
740        }
741
742        let vis = decl_class
743            .methods
744            .iter()
745            .find(|m| m.name.eq_ignore_ascii_case(method))?
746            .visibility;
747
748        match vis {
749            Visibility::Private => Some(vec![decl_uri.clone()]),
750            Visibility::Protected => {
751                if !self.is_index_ready() {
752                    return None;
753                }
754                // Subtype set comes from mir's resolved inheritance graph — it
755                // matches subclasses by FQCN, so `extends \Ns\Base` and aliased
756                // `use ... as` forms are all found. Falls back to full scope if
757                // mir can't resolve the owner.
758                let session = self.analysis_session(self.workspace_php_version());
759                let mut files: std::collections::HashSet<Url> = session
760                    .subtype_files(owner_fqn)
761                    .into_iter()
762                    .filter_map(|p| Url::parse(&p).ok())
763                    .collect();
764                files.insert(decl_uri.clone());
765                Some(files.into_iter().collect())
766            }
767            Visibility::Public => None,
768        }
769    }
770
771    /// Phase J: salsa-memoized aggregate workspace index.
772    ///
773    /// Returns the shared `Arc<WorkspaceIndexData>` with flat
774    /// `(Url, Arc<FileIndex>)` list plus pre-built `classes_by_name` and
775    /// `subtypes_of` reverse maps. Used by workspace_symbols,
776    /// prepare_type_hierarchy, supertypes_of, subtypes_of, and
777    /// find_implementations so they don't each rebuild the aggregate per
778    /// request. Invalidates automatically when any file's `file_index`
779    /// changes.
780    pub fn get_workspace_index_salsa(&self) -> Arc<crate::db::workspace_index::WorkspaceIndexData> {
781        self.sync_workspace_files();
782        let ws = *self.lsp_workspace.lock().unwrap();
783        let Some(ws) = ws else {
784            return Arc::new(crate::db::workspace_index::WorkspaceIndexData {
785                files: Vec::new(),
786                classes_by_name: std::collections::HashMap::new(),
787                subtypes_of: std::collections::HashMap::new(),
788                decls_by_name: std::collections::HashMap::new(),
789                classes_by_lowercase_name: Vec::new(),
790            });
791        };
792        self.snapshot_mir_query(move |db| crate::db::mir_queries::workspace_index(db, ws).0.clone())
793    }
794
795    /// Total number of real `ParsedDoc` parses served so far (cache misses).
796    /// Surfaced via `$/php-lsp/debugStats` so tests can assert the references
797    /// read path doesn't parse the whole workspace.
798    pub fn parse_count(&self) -> u64 {
799        self.caches.parse_count()
800    }
801
802    /// Return the raw source text for `uri` if it has been mirrored into the
803    /// salsa workspace. Used by the references handler to pre-filter session
804    /// results by checking whether a file mentions the owning class name.
805    pub fn source_text(&self, uri: &Url) -> Option<Arc<str>> {
806        self.caches.text_cache.get(uri).map(|e| Arc::clone(&e))
807    }
808
809    /// Cache the semantic tokens computed for a delta response.
810    /// `result_id` is an opaque string (a hash of the token data) returned to the client.
811    pub fn store_token_cache(&self, uri: &Url, result_id: String, tokens: Arc<Vec<SemanticToken>>) {
812        self.caches.store_token(uri, result_id, tokens);
813    }
814
815    /// Return the cached tokens if `result_id` matches the stored one.
816    pub fn get_token_cache(&self, uri: &Url, result_id: &str) -> Option<Arc<Vec<SemanticToken>>> {
817        self.caches.get_token(uri, result_id)
818    }
819
820    /// Raw semantic issues for a file, computed via mir's session-based
821    /// `FileAnalyzer`. The session lazy-loads dependencies via PSR-4 so the
822    /// LSP no longer needs to mirror vendor up-front. Callers apply their
823    /// own `DiagnosticsConfig` filter via
824    /// [`crate::semantic_diagnostics::issues_to_diagnostics`].
825    #[tracing::instrument(skip_all)]
826    pub fn get_semantic_issues_salsa(&self, uri: &Url) -> Option<Arc<[mir_issues::Issue]>> {
827        let analysis = self.cached_analysis(uri)?;
828        let file: Arc<str> = Arc::from(uri.as_str());
829        // Workspace-level class issues for this file (circular inheritance,
830        // override violations, abstract-method gaps). These are session-wide
831        // (a dependency edit changes them without changing this file's bytes),
832        // so they are recomputed live rather than cached alongside the
833        // per-file body analysis.
834        let class_issues = {
835            let _s = tracing::debug_span!("session.class_issues_for").entered();
836            // Retry: concurrent db writes cancel snapshot queries via resume_unwind.
837            loop {
838                let session = self.analysis_session(self.workspace_php_version());
839                if let Ok(issues) = salsa::Cancelled::catch(std::panic::AssertUnwindSafe(|| {
840                    session.class_issues(std::slice::from_ref(&file))
841                })) {
842                    break issues;
843                }
844            }
845        };
846        let combined: Vec<mir_issues::Issue> = analysis
847            .issues
848            .iter()
849            .cloned()
850            .chain(class_issues)
851            .filter(|i| !i.suppressed)
852            .collect();
853        Some(Arc::from(combined))
854    }
855
856    /// Run (or reuse) mir's per-file body analysis, retaining the full
857    /// [`mir_analyzer::FileAnalysis`] — issues **and** resolved symbols — across
858    /// requests. Diagnostics read `.issues`; position features call
859    /// `.symbol_at(offset)` for the resolved type at a cursor.
860    ///
861    /// Cache hit when the entry's captured source `Arc` is pointer-equal to the
862    /// file's current `doc.source_arc()`. A miss recomputes and overwrites, so
863    /// the entry self-evicts on any content edit.
864    /// Build (or reuse) the whole-doc completion [`crate::types::type_map::TypeMap`]
865    /// for `uri`. Cache hit when the entry's captured source `Arc` is
866    /// pointer-equal to `doc.source_arc()` and the PHPStorm-meta pointer is
867    /// unchanged (meta lives behind `ArcSwap`, so its address is stable until
868    /// `.phpstorm.meta.php` is reloaded). A miss rebuilds and overwrites, so
869    /// the entry self-evicts on any content edit.
870    pub fn cached_type_map(
871        &self,
872        uri: &Url,
873        doc: &crate::document::ast::ParsedDoc,
874        meta: Option<&crate::lang::phpstorm_meta::PhpStormMeta>,
875    ) -> Arc<crate::types::type_map::TypeMap> {
876        let source = doc.source_arc();
877        let meta_key = meta.map_or(0usize, |m| std::ptr::from_ref(m) as usize);
878        if let Some(entry) = self.caches.type_map_cache.get(uri)
879            && Arc::ptr_eq(&entry.0, &source)
880            && entry.1 == meta_key
881        {
882            let map = Arc::clone(&entry.2);
883            drop(entry);
884            self.caches.touch(uri);
885            return map;
886        }
887        let map = Arc::new(crate::types::type_map::TypeMap::from_doc_with_meta(
888            doc, meta,
889        ));
890        self.caches.shed_stale(
891            &self.caches.type_map_cache,
892            crate::document::cache_registry::TYPE_MAP_CACHE_CAP,
893        );
894        self.caches.touch(uri);
895        self.caches
896            .type_map_cache
897            .insert(uri.clone(), (source, meta_key, Arc::clone(&map)));
898        map
899    }
900
901    /// Cache-hit-only variant of [`Self::cached_analysis`]: returns the cached
902    /// analysis when the entry is current for the file's text, never computes.
903    /// Lets async handlers take the warm path synchronously and reserve
904    /// `spawn_blocking` for the cold path (mir Pass 1 + Pass 2 can take
905    /// hundreds of ms on large files).
906    pub fn cached_analysis_if_fresh(&self, uri: &Url) -> Option<Arc<mir_analyzer::FileAnalysis>> {
907        let doc = self.get_doc_salsa(uri)?;
908        let source = doc.source_arc();
909        let cur_ver = self.caches.decl_version();
910        let analysis = {
911            let entry = self.caches.analysis_cache.get(uri)?;
912            (Arc::ptr_eq(&entry.0, &source) && entry.1 == cur_ver).then(|| Arc::clone(&entry.2))?
913        };
914        self.caches.touch(uri);
915        Some(analysis)
916    }
917
918    #[tracing::instrument(skip_all)]
919    pub fn cached_analysis(&self, uri: &Url) -> Option<Arc<mir_analyzer::FileAnalysis>> {
920        // Need the parsed doc both for the analyzer and as the cache key.
921        let doc = self.get_doc_salsa(uri)?;
922        let source = doc.source_arc();
923
924        let cur_ver = self.caches.decl_version();
925        if let Some(entry) = self.caches.analysis_cache.get(uri)
926            && Arc::ptr_eq(&entry.0, &source)
927            && entry.1 == cur_ver
928        {
929            let analysis = Arc::clone(&entry.2);
930            drop(entry);
931            self.caches.touch(uri);
932            return Some(analysis);
933        }
934
935        let php_version = self.workspace_php_version();
936        let session = self.analysis_session(php_version);
937        let file: Arc<str> = Arc::from(uri.as_str());
938
939        let source_map = php_rs_parser::source_map::SourceMap::new(doc.source());
940        let owned_program = if let Some(cached) = self.caches.owned_program_cache.get(uri)
941            && Arc::ptr_eq(&cached.0, &source)
942        {
943            Arc::clone(&cached.1)
944        } else {
945            let prog = Arc::new(php_ast::owned::to_owned_program(doc.program()));
946            self.caches.shed_stale(
947                &self.caches.owned_program_cache,
948                crate::document::cache_registry::OWNED_PROGRAM_CACHE_CAP,
949            );
950            self.caches
951                .owned_program_cache
952                .insert(uri.clone(), (Arc::clone(&source), Arc::clone(&prog)));
953            prog
954        };
955
956        // autoload.files helpers (e.g. Laravel's tap()) must be ingested so mir sees their functions.
957        let autoload_texts: Vec<(Arc<str>, Arc<str>)> = {
958            let autoload_uris = self.autoload_uris.read().unwrap().clone();
959            autoload_uris
960                .iter()
961                .filter_map(|auri| {
962                    self.caches
963                        .text_cache
964                        .get(auri)
965                        .map(|t| (Arc::from(auri.as_str()), Arc::clone(&*t)))
966                })
967                .collect()
968        };
969        // Bare same-namespace / use-imported class refs aren't resolved by mir's priority_index_for_ast; preload them.
970        let class_fqns = crate::references::collect_referenced_class_fqns(&doc);
971
972        // ingest_file/load_class/analyze take internal salsa snapshots; a concurrent db write cancels them via resume_unwind. Retry the idempotent sequence.
973        let analysis = loop {
974            let attempt = salsa::Cancelled::catch(std::panic::AssertUnwindSafe(|| {
975                session.ingest_file(file.clone(), source.clone());
976                for (afile, atext) in &autoload_texts {
977                    session.ingest_file(afile.clone(), atext.clone());
978                }
979                for fqcn in &class_fqns {
980                    let _ = session.load_class(fqcn);
981                }
982                let analyzer = mir_analyzer::FileAnalyzer::new(&session);
983                analyzer.analyze(file.clone(), doc.source(), &owned_program, &source_map)
984            }));
985            if let Ok(a) = attempt {
986                break Arc::new(a);
987            }
988        };
989        // Compare the new FileIndex against the stored fingerprint. If
990        // declarations changed (or this is the first analysis), bump
991        // `decl_version` so other files' cache entries become stale. Body-only
992        // edits leave the counter unchanged, allowing sibling files to be
993        // served from cache on the next request.
994        let new_index = self.get_index_salsa(uri);
995        let old_fp = self
996            .caches
997            .decl_fingerprints
998            .get(uri)
999            .map(|e| Arc::clone(&*e));
1000        let decl_changed = match (&old_fp, &new_index) {
1001            (Some(old), Some(new)) => **old != **new,
1002            (None, Some(_)) => true,
1003            _ => false,
1004        };
1005        if decl_changed {
1006            if let Some(idx) = new_index {
1007                self.caches.decl_fingerprints.insert(uri.clone(), idx);
1008            }
1009            self.caches.bump_decl_version();
1010            // Text reaches mir via direct salsa `set_text` writes, so mir can't
1011            // see declaration deletions itself. A deleted declaration may
1012            // unshadow a lazy-loadable symbol — invalidate mir's warm-up skip
1013            // set so reference queries re-run their prepare pass once.
1014            session.bump_prepare_generation();
1015        }
1016        let ver = self.caches.decl_version();
1017        self.caches.shed_stale(
1018            &self.caches.analysis_cache,
1019            crate::document::cache_registry::ANALYSIS_CACHE_CAP,
1020        );
1021        self.caches.touch(uri);
1022        self.caches
1023            .analysis_cache
1024            .insert(uri.clone(), (source, ver, Arc::clone(&analysis)));
1025        Some(analysis)
1026    }
1027
1028    /// Returns `(uri, doc)` for files currently open in the editor.
1029    ///
1030    /// Resolve `open_urls` (from `Backend::open_urls()`) to parsed docs.
1031    /// Files not mirrored in the salsa layer are filtered out silently.
1032    pub fn docs_for(&self, open_urls: &[Url]) -> Vec<(Url, Arc<ParsedDoc>)> {
1033        open_urls
1034            .iter()
1035            .filter_map(|u| self.get_doc_salsa(u).map(|d| (u.clone(), d)))
1036            .collect()
1037    }
1038
1039    /// `(primary, doc)` first, then every other open file's parsed doc.
1040    /// The `open_urls` slice should include `uri` — this helper filters it out.
1041    pub fn doc_with_others(
1042        &self,
1043        uri: &Url,
1044        doc: Arc<ParsedDoc>,
1045        open_urls: &[Url],
1046    ) -> Vec<(Url, Arc<ParsedDoc>)> {
1047        let mut result = vec![(uri.clone(), doc)];
1048        result.extend(self.other_docs(uri, open_urls));
1049        result
1050    }
1051
1052    /// Parsed docs for every entry in `open_urls` except `uri`.
1053    pub fn other_docs(&self, uri: &Url, open_urls: &[Url]) -> Vec<(Url, Arc<ParsedDoc>)> {
1054        open_urls
1055            .iter()
1056            .filter(|u| *u != uri)
1057            .filter_map(|u| self.get_doc_salsa(u).map(|d| (u.clone(), d)))
1058            .collect()
1059    }
1060
1061    /// Compact symbol index for every mirrored file.
1062    pub fn all_indexes(&self) -> Vec<(Url, Arc<FileIndex>)> {
1063        self.get_workspace_index_salsa().files.clone()
1064    }
1065
1066    /// Store a lazily-loaded vendor `FileIndex` in the session cache.
1067    /// Only call this for files that are not part of the normal workspace scan
1068    /// (i.e. vendor files loaded on-demand by PSR-4 navigation).
1069    pub fn cache_vendor_index(&self, uri: Url, index: Arc<FileIndex>) {
1070        self.caches.vendor_index_cache.insert(uri, index);
1071    }
1072
1073    /// Retrieve a previously cached vendor `FileIndex`.
1074    pub fn get_vendor_index(&self, uri: &Url) -> Option<Arc<FileIndex>> {
1075        self.caches
1076            .vendor_index_cache
1077            .get(uri)
1078            .map(|e| Arc::clone(&*e))
1079    }
1080
1081    /// Same as `all_indexes` but excludes `uri`.
1082    pub fn other_indexes(&self, uri: &Url) -> Vec<(Url, Arc<FileIndex>)> {
1083        self.get_workspace_index_salsa()
1084            .files
1085            .iter()
1086            .filter(|(u, _)| u != uri)
1087            .cloned()
1088            .collect()
1089    }
1090
1091    /// Parsed documents for every mirrored file (open or background-indexed).
1092    /// Suitable for full-scan operations: find-references, rename,
1093    /// call_hierarchy, code_lens.
1094    pub fn all_docs_for_scan(&self) -> Vec<(Url, Arc<ParsedDoc>)> {
1095        let urls: Vec<Url> = self
1096            .lsp_ws_files
1097            .iter()
1098            .filter(|e| !self.deleted_uris.contains(e.key()))
1099            .map(|e| e.key().clone())
1100            .collect();
1101        urls.into_iter()
1102            .filter_map(|u| self.get_doc_salsa(&u).map(|d| (u, d)))
1103            .collect()
1104    }
1105
1106    /// URLs of workspace files whose raw source text mentions `word` as a whole
1107    /// identifier — **no parsing**. Uses a precompiled `memmem` finder (the same
1108    /// needle is scanned across the whole workspace) plus a word-boundary check,
1109    /// so `save` does not match inside `saveAll`/`unsaved`; the scan runs in
1110    /// parallel over the file set. The references read path narrows this list by
1111    /// symbol visibility and hands it to mir's per-file `analyze_file`, so a
1112    /// method lookup never materializes a `ParsedDoc` for the workspace.
1113    ///
1114    /// Files whose text is not yet in `text_cache` are included conservatively
1115    /// (safe superset — never produces false negatives).
1116    pub fn candidate_urls_for(&self, word: &str) -> Vec<Url> {
1117        use rayon::prelude::*;
1118        let urls: Vec<Url> = self
1119            .lsp_ws_files
1120            .iter()
1121            .filter(|e| !self.deleted_uris.contains(e.key()))
1122            .map(|e| e.key().clone())
1123            .collect();
1124        let finder = memchr::memmem::Finder::new(word.as_bytes());
1125        let wlen = word.len();
1126        urls.into_par_iter()
1127            .filter(|u| match self.caches.text_cache.get(u) {
1128                Some(src) => mentions_identifier(src.as_bytes(), &finder, wlen),
1129                None => true,
1130            })
1131            .collect()
1132    }
1133
1134    /// Parsed documents limited to files whose raw source text contains `word`.
1135    ///
1136    /// [`Self::candidate_urls_for`] + a salsa parse per survivor. Used by the
1137    /// AST-walker reference paths (constructor refs, non-method kinds) that
1138    /// genuinely need the parsed AST. The method reference path uses
1139    /// `candidate_urls_for` directly and avoids these parses entirely.
1140    ///
1141    /// Files whose text is not yet in `text_cache` are included conservatively
1142    /// (safe superset — never produces false negatives).
1143    pub fn candidate_docs_for(&self, word: &str) -> Vec<(Url, Arc<ParsedDoc>)> {
1144        self.candidate_urls_for(word)
1145            .into_iter()
1146            .filter_map(|u| self.get_doc_salsa(&u).map(|d| (u, d)))
1147            .collect()
1148    }
1149}
1150
1151/// Whether `hay` contains `finder`'s needle (length `wlen`) as a whole
1152/// identifier — i.e. not immediately preceded or followed by an ASCII
1153/// identifier byte (`[A-Za-z0-9_]`). Only ASCII bytes count as identifier
1154/// boundaries, so the check can over-accept near multibyte text but never
1155/// rejects a real whole-word match.
1156fn mentions_identifier(hay: &[u8], finder: &memchr::memmem::Finder, wlen: usize) -> bool {
1157    let is_ident = |b: u8| b.is_ascii_alphanumeric() || b == b'_';
1158    let mut from = 0;
1159    while let Some(rel) = finder.find(&hay[from..]) {
1160        let idx = from + rel;
1161        let before_ok = idx == 0 || !is_ident(hay[idx - 1]);
1162        let end = idx + wlen;
1163        let after_ok = end >= hay.len() || !is_ident(hay[end]);
1164        if before_ok && after_ok {
1165            return true;
1166        }
1167        from = idx + 1;
1168    }
1169    false
1170}
1171
1172#[cfg(test)]
1173mod tests {
1174    use super::*;
1175
1176    fn uri(path: &str) -> Url {
1177        Url::parse(&format!("file://{path}")).unwrap()
1178    }
1179
1180    #[test]
1181    fn mentions_identifier_respects_word_boundaries() {
1182        let m = |hay: &str, word: &str| {
1183            let f = memchr::memmem::Finder::new(word.as_bytes());
1184            mentions_identifier(hay.as_bytes(), &f, word.len())
1185        };
1186        assert!(m("$this->save();", "save"));
1187        assert!(m("function save() {}", "save"));
1188        assert!(m("save", "save"));
1189        // substrings inside a larger identifier must not match
1190        assert!(!m("$this->saveAll();", "save"));
1191        assert!(!m("return $unsaved;", "save"));
1192        assert!(!m("class Saver {}", "save"));
1193        assert!(!m("no occurrence here", "save"));
1194    }
1195
1196    /// Phase E4: open-file state lives on `Backend`, not `DocumentStore`.
1197    /// Tests that need to simulate "file is open" just mirror the text into
1198    /// the salsa input — the open/closed distinction is enforced by the
1199    /// caller (Backend) in production.
1200    fn open(store: &DocumentStore, u: Url, text: String) {
1201        store.mirror_text(&u, &text);
1202    }
1203
1204    // Removed `salsa_codebase_aggregates_all_files`: the salsa-side codebase
1205    // aggregation was deleted with the mir 0.22 migration. Equivalent
1206    // behaviour is now covered by mir-analyzer's own session tests.
1207
1208    #[test]
1209    fn index_registers_file_in_salsa() {
1210        let store = DocumentStore::new();
1211        store.ingest(uri("/lib.php"), "<?php\nfunction lib_fn() {}");
1212        let idx = store.get_index_salsa(&uri("/lib.php")).unwrap();
1213        assert_eq!(idx.functions.len(), 1);
1214        assert_eq!(&*idx.functions[0].name, "lib_fn");
1215    }
1216
1217    #[test]
1218    fn remove_hides_file_from_index() {
1219        let store = DocumentStore::new();
1220        let u = uri("/lib.php");
1221        store.ingest(u.clone(), "<?php");
1222        store.remove(&u);
1223        assert!(store.get_index_salsa(&u).is_none());
1224    }
1225
1226    #[test]
1227    fn remove_and_reopen_reuses_source_file_handle() {
1228        let store = DocumentStore::new();
1229        let u = uri("/lib.php");
1230        store.ingest(u.clone(), "<?php");
1231        let ft_before = store.source_file(&u).unwrap();
1232        store.remove(&u);
1233        assert!(
1234            store.source_file(&u).is_none(),
1235            "deleted file should be hidden"
1236        );
1237        store.mirror_text(&u, "<?php");
1238        let ft_after = store.source_file(&u).unwrap();
1239        assert!(
1240            ft_before == ft_after,
1241            "reopen must reuse the same FileText handle"
1242        );
1243    }
1244
1245    #[test]
1246    fn delete_reopen_churn_does_not_amplify_salsa_inputs() {
1247        let store = DocumentStore::new();
1248        let uris: Vec<Url> = (0..20).map(|i| uri(&format!("/churn/f{i}.php"))).collect();
1249        for u in &uris {
1250            store.ingest(u.clone(), "<?php class A {}");
1251        }
1252        let count_before = store.source_files_len();
1253        for _ in 0..10 {
1254            for u in &uris {
1255                store.remove(u);
1256            }
1257            for u in &uris {
1258                store.ingest(u.clone(), "<?php class A {}");
1259            }
1260        }
1261        assert_eq!(
1262            store.source_files_len(),
1263            count_before,
1264            "delete-reopen cycles must not create new salsa inputs (L1-B regression guard)"
1265        );
1266    }
1267
1268    #[test]
1269    fn all_indexes_includes_every_mirrored_file() {
1270        let store = DocumentStore::new();
1271        open(&store, uri("/a.php"), "<?php\nfunction a() {}".to_string());
1272        store.ingest(uri("/b.php"), "<?php\nfunction b() {}");
1273        assert_eq!(store.all_indexes().len(), 2);
1274    }
1275
1276    #[test]
1277    fn other_indexes_excludes_current_uri() {
1278        let store = DocumentStore::new();
1279        open(&store, uri("/a.php"), "<?php\nfunction a() {}".to_string());
1280        open(&store, uri("/b.php"), "<?php\nfunction b() {}".to_string());
1281        assert_eq!(store.other_indexes(&uri("/a.php")).len(), 1);
1282    }
1283
1284    #[test]
1285    fn other_docs_excludes_current_uri() {
1286        let store = DocumentStore::new();
1287        let ua = uri("/a.php");
1288        let ub = uri("/b.php");
1289        open(&store, ua.clone(), "<?php\nfunction a() {}".to_string());
1290        open(&store, ub.clone(), "<?php\nfunction b() {}".to_string());
1291        let open_urls = vec![ua.clone(), ub];
1292        assert_eq!(store.other_docs(&ua, &open_urls).len(), 1);
1293    }
1294
1295    #[test]
1296    fn evict_token_cache_removes_entry() {
1297        let store = DocumentStore::new();
1298        let u = uri("/a.php");
1299        open(&store, u.clone(), "<?php".to_string());
1300        store.store_token_cache(&u, "id1".to_string(), Arc::new(vec![]));
1301        assert!(store.get_token_cache(&u, "id1").is_some());
1302        store.evict_token_cache(&u);
1303        assert!(store.get_token_cache(&u, "id1").is_none());
1304    }
1305
1306    #[test]
1307    fn vendor_index_cache_evicted_on_remove() {
1308        let store = DocumentStore::new();
1309        let u = uri("/vendor/acme/lib.php");
1310        store.ingest(u.clone(), "<?php\nclass Lib {}");
1311        let idx = store.get_index_salsa(&u).unwrap();
1312        store.cache_vendor_index(u.clone(), idx.clone());
1313        assert!(store.get_vendor_index(&u).is_some());
1314        store.remove(&u);
1315        assert!(store.get_vendor_index(&u).is_none());
1316    }
1317
1318    #[test]
1319    fn index_populates_file_index_with_symbols() {
1320        let store = DocumentStore::new();
1321        store.ingest(uri("/a.php"), "<?php\nfunction hello() {}");
1322        let idx = store.get_index_salsa(&uri("/a.php")).unwrap();
1323        assert_eq!(idx.functions.len(), 1);
1324        assert_eq!(&*idx.functions[0].name, "hello");
1325    }
1326
1327    #[test]
1328    fn open_populates_file_index_with_symbols() {
1329        let store = DocumentStore::new();
1330        open(&store, uri("/a.php"), "<?php\nclass Foo {}".to_string());
1331        let idx = store.get_index_salsa(&uri("/a.php")).unwrap();
1332        assert_eq!(idx.classes.len(), 1);
1333        assert_eq!(&*idx.classes[0].name, "Foo");
1334    }
1335
1336    // ── Mirror invariants ────────────────────────────────────────────────
1337    //
1338    // Every mutation path that changes file text must keep the salsa layer
1339    // consistent. These tests walk a set-edit-reopen cycle and assert that
1340    // the salsa-derived `FileIndex` reflects the latest text at each step.
1341
1342    fn names_of(idx: &FileIndex) -> Vec<String> {
1343        let mut out: Vec<String> = idx.classes.iter().map(|c| c.name.to_string()).collect();
1344        out.extend(idx.functions.iter().map(|f| f.name.to_string()));
1345        out.sort();
1346        out
1347    }
1348
1349    fn salsa_index_names(store: &DocumentStore, url: &Url) -> Vec<String> {
1350        store
1351            .snapshot_query_file_index(url)
1352            .map(|idx| names_of(&idx))
1353            .unwrap_or_default()
1354    }
1355
1356    #[test]
1357    fn mirror_tracks_repeated_edits() {
1358        let store = DocumentStore::new();
1359        let u = uri("/mirror.php");
1360
1361        open(&store, u.clone(), "<?php\nclass A {}".to_string());
1362        assert_eq!(salsa_index_names(&store, &u), vec!["A".to_string()]);
1363
1364        open(
1365            &store,
1366            u.clone(),
1367            "<?php\nclass A {}\nclass B {}".to_string(),
1368        );
1369        assert_eq!(
1370            salsa_index_names(&store, &u),
1371            vec!["A".to_string(), "B".to_string()]
1372        );
1373
1374        open(&store, u.clone(), "<?php\nfunction greet() {}".to_string());
1375        assert_eq!(salsa_index_names(&store, &u), vec!["greet".to_string()]);
1376    }
1377
1378    #[test]
1379    fn mirror_tracks_ingest_and_ingest_from_doc() {
1380        let store = DocumentStore::new();
1381
1382        // Background `index(url, text)` path.
1383        let u1 = uri("/bg1.php");
1384        store.ingest(u1.clone(), "<?php\nclass Bg1 {}");
1385        assert_eq!(salsa_index_names(&store, &u1), vec!["Bg1".to_string()]);
1386
1387        // `ingest_from_doc(url, &doc)` path (workspace-scan Phase 2).
1388        let u2 = uri("/bg2.php");
1389        let doc = crate::analysis::diagnostics::parse_document_no_diags(
1390            "<?php\nclass Bg2 {}\nfunction f() {}",
1391        );
1392        store.ingest_from_doc(u2.clone(), &doc);
1393        assert_eq!(
1394            salsa_index_names(&store, &u2),
1395            vec!["Bg2".to_string(), "f".to_string()]
1396        );
1397    }
1398
1399    /// G3: confirms the `parsed_cache` actually hits — two consecutive
1400    /// `get_doc_salsa` calls on unchanged text return the same `Arc`
1401    /// (pointer equality), and an edit forces a miss that produces a
1402    /// different `Arc`.
1403    /// parsed_cache must stay bounded — inserting more than
1404    /// `PARSED_CACHE_CAP` unique URLs must not cause unbounded growth.
1405    /// Eviction is probabilistic, so we only assert the bound, not which
1406    /// Seeding a cached index for a URL that was never mirrored is a no-op
1407    /// (returns `false`) — avoids silently allocating SourceFiles outside
1408    /// `mirror_text`'s control.
1409    #[test]
1410    fn seed_cached_index_noops_for_unknown_uri() {
1411        let store = DocumentStore::new();
1412        let u = uri("/never_mirrored.php");
1413        let index = Arc::new(crate::index::file_index::FileIndex::default());
1414        assert!(!store.seed_cached_index(&u, index));
1415    }
1416
1417    /// entries survive.
1418    #[test]
1419    fn parsed_cache_stays_bounded_under_many_inserts() {
1420        let store = DocumentStore::new();
1421        use crate::document::cache_registry::PARSED_CACHE_CAP;
1422        let overflow = PARSED_CACHE_CAP + 100;
1423        for i in 0..overflow {
1424            let u = uri(&format!("/cap/file{i}.php"));
1425            store.ingest(u.clone(), "<?php\nclass A {}");
1426            // Force a parsed_cache insert via get_doc_salsa.
1427            let _ = store.get_doc_salsa(&u);
1428        }
1429        assert!(
1430            store.caches.parsed_cache.len() <= PARSED_CACHE_CAP,
1431            "parsed_cache grew to {} entries (cap {})",
1432            store.caches.parsed_cache.len(),
1433            PARSED_CACHE_CAP
1434        );
1435    }
1436
1437    /// The shed is recency-based: an entry touched right before overflow (an
1438    /// open file being edited) must survive a sweep of cold inserts, while
1439    /// untouched cold entries are the ones dropped.
1440    #[test]
1441    fn parsed_cache_shed_keeps_recently_used_entries() {
1442        let store = DocumentStore::new();
1443        use crate::document::cache_registry::PARSED_CACHE_CAP;
1444
1445        let hot = uri("/cap/hot.php");
1446        store.ingest(hot.clone(), "<?php\nclass Hot {}");
1447        let _ = store.get_doc_salsa(&hot);
1448
1449        // Fill to just below the cap with cold entries, then re-touch the hot
1450        // file so it is the most recently used entry at shed time.
1451        for i in 0..(PARSED_CACHE_CAP - 2) {
1452            let u = uri(&format!("/cap/cold{i}.php"));
1453            store.ingest(u.clone(), "<?php\nclass A {}");
1454            let _ = store.get_doc_salsa(&u);
1455        }
1456        let _ = store.get_doc_salsa(&hot);
1457
1458        // Overflow: this insert triggers the shed of the LRU half.
1459        let trigger = uri("/cap/trigger.php");
1460        store.ingest(trigger.clone(), "<?php\nclass T {}");
1461        let _ = store.get_doc_salsa(&trigger);
1462
1463        assert!(
1464            store.caches.parsed_cache.contains_key(&hot),
1465            "recently-touched entry must survive the recency shed"
1466        );
1467        assert!(
1468            store.caches.parsed_cache.len() <= PARSED_CACHE_CAP,
1469            "cache must stay bounded after the shed"
1470        );
1471    }
1472
1473    /// `analysis_cache` must stay bounded when many distinct files are
1474    /// analyzed across a session (multi-hour usage previously grew it
1475    /// without limit).
1476    #[test]
1477    fn analysis_cache_stays_bounded_under_many_files() {
1478        use crate::document::cache_registry::ANALYSIS_CACHE_CAP;
1479        let store = DocumentStore::new();
1480        let overflow = ANALYSIS_CACHE_CAP + 16;
1481        for i in 0..overflow {
1482            let u = uri(&format!("/acap/file{i}.php"));
1483            store.ingest(u.clone(), "<?php\nfunction f() { return 1; }");
1484            let _ = store.cached_analysis(&u);
1485        }
1486        assert!(
1487            store.caches.analysis_cache.len() <= ANALYSIS_CACHE_CAP,
1488            "analysis_cache grew to {} entries (cap {})",
1489            store.caches.analysis_cache.len(),
1490            ANALYSIS_CACHE_CAP
1491        );
1492    }
1493
1494    #[test]
1495    fn get_doc_salsa_cache_hits_across_calls() {
1496        let store = DocumentStore::new();
1497        let u = uri("/g3_cache.php");
1498        open(&store, u.clone(), "<?php\nclass G3 {}".to_string());
1499
1500        let a = store.get_doc_salsa(&u).unwrap();
1501        let b = store.get_doc_salsa(&u).unwrap();
1502        assert!(
1503            Arc::ptr_eq(&a, &b),
1504            "parsed_cache hit should yield the same Arc across calls"
1505        );
1506
1507        open(&store, u.clone(), "<?php\nclass G3b {}".to_string());
1508        let c = store.get_doc_salsa(&u).unwrap();
1509        assert!(
1510            !Arc::ptr_eq(&a, &c),
1511            "edit should invalidate the parsed_cache entry"
1512        );
1513    }
1514
1515    #[test]
1516    fn get_doc_salsa_returns_some_for_mirrored_files() {
1517        // Phase E4: `get_doc_salsa` no longer gates on open-state. The
1518        // open/closed distinction now lives on `Backend::get_doc`.
1519        let store = DocumentStore::new();
1520        let u = uri("/e4_doc.php");
1521        store.ingest(u.clone(), "<?php\nclass P {}");
1522        assert!(store.get_doc_salsa(&u).is_some());
1523    }
1524
1525    #[test]
1526    fn get_doc_snapshot_or_stale_matches_salsa_on_settled_buffer() {
1527        let store = DocumentStore::new();
1528        let u = uri("/stale_settled.php");
1529        open(&store, u.clone(), "<?php\nclass S {}".to_string());
1530        let fresh = store.get_doc_salsa(&u).unwrap();
1531        let stale = store.get_doc_snapshot_or_stale(&u).unwrap();
1532        assert!(
1533            Arc::ptr_eq(&fresh, &stale),
1534            "on a settled buffer the stale-tolerant accessor must serve the cached parse"
1535        );
1536    }
1537
1538    /// WS1: the cursor-triggered highlight accessor must stay responsive under a
1539    /// sustained write stream — never spin (the deadline join hangs if it does)
1540    /// and always yield a fresh or last-good parse for an open file.
1541    #[test]
1542    fn get_doc_snapshot_or_stale_stays_responsive_under_writes() {
1543        use std::sync::Arc;
1544        use std::thread;
1545        use std::time::{Duration, Instant};
1546
1547        let store = Arc::new(DocumentStore::new());
1548        let urls: Vec<Url> = (0..8).map(|i| uri(&format!("/hl{i}.php"))).collect();
1549        for (i, u) in urls.iter().enumerate() {
1550            open(&store, u.clone(), format!("<?php\nclass H{i} {{}}"));
1551            // Warm the parse cache so the stale fallback always has a last-good doc.
1552            assert!(store.get_doc_salsa(u).is_some());
1553        }
1554
1555        let deadline = Instant::now() + Duration::from_millis(400);
1556        let mut handles = Vec::new();
1557
1558        {
1559            let store = Arc::clone(&store);
1560            let urls = urls.clone();
1561            handles.push(thread::spawn(move || {
1562                let mut rev = 0u32;
1563                while Instant::now() < deadline {
1564                    for u in &urls {
1565                        store.mirror_text(u, &format!("<?php\nclass H {{}}\n// rev {rev}"));
1566                    }
1567                    rev += 1;
1568                }
1569            }));
1570        }
1571
1572        for _ in 0..4 {
1573            let store = Arc::clone(&store);
1574            let urls = urls.clone();
1575            handles.push(thread::spawn(move || {
1576                while Instant::now() < deadline {
1577                    for u in &urls {
1578                        assert!(
1579                            store.get_doc_snapshot_or_stale(u).is_some(),
1580                            "open file must resolve to a fresh or stale parse, never None"
1581                        );
1582                    }
1583                }
1584            }));
1585        }
1586
1587        for h in handles {
1588            h.join()
1589                .expect("stale accessor must not panic or spin under concurrent writes");
1590        }
1591    }
1592
1593    /// WS1 A/B measurement (not a pass/fail guard): compares the old unbounded
1594    /// `get_doc_salsa` against the new bounded `get_doc_snapshot_or_stale` under
1595    /// an identical continuous write stream. Run explicitly:
1596    /// `cargo test --lib ws1_ab_latency -- --ignored --nocapture`.
1597    #[test]
1598    #[ignore = "timing measurement; run with --ignored --nocapture"]
1599    fn ws1_ab_latency_under_write_pressure() {
1600        use std::sync::Arc;
1601        use std::sync::atomic::{AtomicBool, Ordering};
1602        use std::thread;
1603        use std::time::{Duration, Instant};
1604
1605        // A large file: each parse is long enough that a concurrent write can
1606        // cancel it mid-flight — the condition under which the unbounded retry
1607        // actually spins (a tiny file parses faster than the writer's gap).
1608        fn big_source(rev: u32) -> String {
1609            let mut s = String::from("<?php\nclass Big {\n");
1610            for i in 0..3000 {
1611                s.push_str(&format!(
1612                    "    public function m{i}(int $a, string $b): int {{ return $a + {i}; }}\n"
1613                ));
1614            }
1615            s.push_str(&format!("}}\n// rev {rev}\n"));
1616            s
1617        }
1618
1619        fn measure(
1620            store: &Arc<DocumentStore>,
1621            url: &Url,
1622            writers: usize,
1623            label: &str,
1624            call: impl Fn(&DocumentStore, &Url) -> bool,
1625        ) {
1626            let stop = Arc::new(AtomicBool::new(false));
1627            let mut writer_handles = Vec::new();
1628            for w in 0..writers {
1629                let store = Arc::clone(store);
1630                let url = url.clone();
1631                let stop = Arc::clone(&stop);
1632                writer_handles.push(thread::spawn(move || {
1633                    let mut rev = (w as u32) * 1_000_000;
1634                    while !stop.load(Ordering::Relaxed) {
1635                        store.mirror_text(&url, &big_source(rev));
1636                        rev = rev.wrapping_add(1);
1637                    }
1638                }));
1639            }
1640            thread::sleep(Duration::from_millis(30)); // let the writers get hot
1641
1642            let n = 100usize;
1643            let mut max = Duration::ZERO;
1644            let mut total = Duration::ZERO;
1645            for _ in 0..n {
1646                let t = Instant::now();
1647                let ok = call(store, url);
1648                let dt = t.elapsed();
1649                assert!(ok, "{label}: accessor returned None");
1650                max = max.max(dt);
1651                total += dt;
1652            }
1653            stop.store(true, Ordering::Relaxed);
1654            for h in writer_handles {
1655                h.join().unwrap();
1656            }
1657            println!(
1658                "{label}: {n} calls, {writers} writers — max {:>10.3?}, mean {:>10.3?}",
1659                max,
1660                total / n as u32
1661            );
1662        }
1663
1664        let store = Arc::new(DocumentStore::new());
1665        let url = uri("/perf_big.php");
1666        open(&store, url.clone(), big_source(0));
1667        assert!(store.get_doc_salsa(&url).is_some()); // warm the cache
1668
1669        for writers in [2usize, 4] {
1670            measure(
1671                &store,
1672                &url,
1673                writers,
1674                "NEW get_doc_snapshot_or_stale",
1675                |s, u| s.get_doc_snapshot_or_stale(u).is_some(),
1676            );
1677            measure(
1678                &store,
1679                &url,
1680                writers,
1681                "OLD get_doc_salsa (unbounded)  ",
1682                |s, u| s.get_doc_salsa(u).is_some(),
1683            );
1684        }
1685    }
1686
1687    #[test]
1688    fn get_salsa_accessors_return_none_for_unknown_uri() {
1689        let store = DocumentStore::new();
1690        let u = uri("/never-seen.php");
1691        assert!(store.get_doc_salsa(&u).is_none());
1692        assert!(store.get_index_salsa(&u).is_none());
1693    }
1694
1695    /// Phase E1: concurrent readers and writers must not deadlock, panic, or
1696    /// return stale data. Writers briefly bump inputs while readers are
1697    /// running on cloned snapshots; any `salsa::Cancelled` raised on the
1698    /// reader side must be caught and retried by `snapshot_query`.
1699    ///
1700    /// The salsa surface (`get_doc_salsa`, `get_index_salsa`) is protected by
1701    /// `snapshot_query`'s last-resort host-lock fallback.
1702    #[test]
1703    fn concurrent_reads_and_writes_do_not_panic() {
1704        use std::sync::Arc;
1705        use std::thread;
1706        use std::time::{Duration, Instant};
1707
1708        let store = Arc::new(DocumentStore::new());
1709        let urls: Vec<Url> = (0..8).map(|i| uri(&format!("/f{i}.php"))).collect();
1710        for (i, u) in urls.iter().enumerate() {
1711            open(&store, u.clone(), format!("<?php\nclass C{i} {{}}"));
1712        }
1713
1714        let deadline = Instant::now() + Duration::from_millis(400);
1715        let mut handles = Vec::new();
1716
1717        // Writer thread: keep bumping every file's text.
1718        {
1719            let store = Arc::clone(&store);
1720            let urls = urls.clone();
1721            handles.push(thread::spawn(move || {
1722                let mut rev = 0u32;
1723                while Instant::now() < deadline {
1724                    for u in &urls {
1725                        let text = format!("<?php\nclass C{{}}\n// rev {rev}");
1726                        store.mirror_text(u, &text);
1727                    }
1728                    rev += 1;
1729                }
1730            }));
1731        }
1732
1733        // Reader threads: hammer the salsa accessors.
1734        for _ in 0..4 {
1735            let store = Arc::clone(&store);
1736            let urls = urls.clone();
1737            handles.push(thread::spawn(move || {
1738                while Instant::now() < deadline {
1739                    for u in &urls {
1740                        let _ = store.get_doc_salsa(u);
1741                        let _ = store.get_index_salsa(u);
1742                    }
1743                    // Post mir 0.22: codebase + refs live in the session,
1744                    // not salsa. Concurrent-read smoke is limited to the
1745                    // remaining salsa surface (parsed_doc, file_index).
1746                }
1747            }));
1748        }
1749
1750        for h in handles {
1751            h.join().expect("no panic under concurrent read/write");
1752        }
1753    }
1754
1755    /// PSR-4 lazy-loading: `get_semantic_issues_salsa` must not emit
1756    /// `UndefinedClass` for a class that is PSR-4-resolvable on disk, even
1757    /// when the dependency file is not yet in `source_files`.
1758    #[test]
1759    fn psr4_lazy_load_suppresses_undefined_class() {
1760        let tmp = tempfile::tempdir().unwrap();
1761
1762        // Write Entity.php to disk (not mirrored into the store).
1763        std::fs::create_dir_all(tmp.path().join("src/Model")).unwrap();
1764        std::fs::write(
1765            tmp.path().join("src/Model/Entity.php"),
1766            "<?php\nnamespace App\\Model;\nclass Entity {}\n",
1767        )
1768        .unwrap();
1769
1770        // Write composer.json so Psr4Map::load can build the map.
1771        std::fs::write(
1772            tmp.path().join("composer.json"),
1773            r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
1774        )
1775        .unwrap();
1776
1777        let store = DocumentStore::new();
1778
1779        // Inject a PSR-4 map pointing at the tmp dir.
1780        store
1781            .psr4
1782            .store(Arc::new(crate::lang::autoload::Psr4Map::load(tmp.path())));
1783
1784        // Mirror the consuming file (Entity not yet in source_files).
1785        // Uses Entity as a parameter type hint — the analyzer resolves these
1786        // through use statements, so this exercises the full PSR-4 lazy-load path.
1787        let handler_url = Url::from_file_path(tmp.path().join("src/Service/Handler.php")).unwrap();
1788        store.mirror_text(
1789            &handler_url,
1790            "<?php\nnamespace App\\Service;\nuse App\\Model\\Entity;\nfunction handle(Entity $e): Entity { return $e; }\n",
1791        );
1792
1793        let issues = store.get_semantic_issues_salsa(&handler_url).unwrap();
1794        let undef: Vec<_> = issues
1795            .iter()
1796            .filter(|i| matches!(i.kind, mir_issues::IssueKind::UndefinedClass { .. }))
1797            .collect();
1798        assert!(
1799            undef.is_empty(),
1800            "PSR-4 lazy-loading must prevent UndefinedClass for App\\Model\\Entity; got: {undef:?}"
1801        );
1802    }
1803
1804    /// Issue #191 regression: workspace-wide scans (find-references, rename,
1805    /// call-hierarchy) must not re-parse closed/indexed files on repeated
1806    /// invocations. Once a file's `ParsedDoc` has been produced, subsequent
1807    /// `all_docs_for_scan()` calls must hit the cache and return the same
1808    /// `Arc<ParsedDoc>` (pointer equality), proving no re-parse occurred.
1809    ///
1810    /// The cache layers protecting this are:
1811    ///   1. `parsed_cache` (cap [`PARSED_CACHE_CAP`]) — read-through, validated
1812    ///      via `Arc::ptr_eq` on the text Arc.
1813    ///   2. salsa `parsed_doc` memo (`lru = 2048`) — second line of defense
1814    ///      when `parsed_cache` evicts.
1815    ///
1816    /// Together they keep every workspace-scan op O(N) memo lookups, never
1817    /// O(N) parses, for any workspace whose file count fits the cap.
1818    #[test]
1819    fn all_docs_for_scan_does_not_reparse_indexed_files() {
1820        let store = DocumentStore::new();
1821        const N: usize = 50;
1822        for i in 0..N {
1823            let u = uri(&format!("/scan/file{i}.php"));
1824            store.ingest(u, &format!("<?php\nclass C{i} {{}}\nfunction f{i}() {{}}"));
1825        }
1826
1827        let first: Vec<_> = store.all_docs_for_scan();
1828        let second: Vec<_> = store.all_docs_for_scan();
1829        assert_eq!(first.len(), N);
1830        assert_eq!(second.len(), N);
1831
1832        let by_url_first: std::collections::HashMap<Url, Arc<ParsedDoc>> =
1833            first.into_iter().collect();
1834        for (u, doc2) in second {
1835            let doc1 = by_url_first
1836                .get(&u)
1837                .expect("second scan returned a URL the first didn't");
1838            assert!(
1839                Arc::ptr_eq(doc1, &doc2),
1840                "{u} re-parsed across all_docs_for_scan calls — \
1841                 cache (parsed_cache + salsa parsed_doc memo) failed to hit"
1842            );
1843        }
1844
1845        // Editing one file's text must invalidate just that file's entry,
1846        // not the rest. This locks in self-eviction via Arc::ptr_eq on text.
1847        let edited_url = uri("/scan/file0.php");
1848        let pre_edit = store.get_doc_salsa(&edited_url).unwrap();
1849        store.ingest(edited_url.clone(), "<?php\nclass C0Edited {}");
1850        let post_edit = store.get_doc_salsa(&edited_url).unwrap();
1851        assert!(
1852            !Arc::ptr_eq(&pre_edit, &post_edit),
1853            "edited file must produce a fresh ParsedDoc"
1854        );
1855        for i in 1..N {
1856            let u = uri(&format!("/scan/file{i}.php"));
1857            let original = by_url_first.get(&u).unwrap();
1858            let after = store.get_doc_salsa(&u).unwrap();
1859            assert!(
1860                Arc::ptr_eq(original, &after),
1861                "{u} should not have re-parsed because of an unrelated edit"
1862            );
1863        }
1864    }
1865
1866    /// Incremental analysis cache: a body-only edit to file A (no declaration
1867    /// changes) must not bump `decl_version`, so file B's cached analysis
1868    /// survives. A declaration edit MUST bump the version so B's entry goes
1869    /// stale.
1870    #[test]
1871    fn body_only_edit_does_not_invalidate_sibling_analysis_cache() {
1872        let store = DocumentStore::new();
1873        let ua = uri("/ic_a.php");
1874        let ub = uri("/ic_b.php");
1875
1876        // Analyze both files to establish their fingerprints.
1877        open(
1878            &store,
1879            ua.clone(),
1880            "<?php\nfunction a() { return 1; }".to_string(),
1881        );
1882        open(
1883            &store,
1884            ub.clone(),
1885            "<?php\nfunction b() { return 2; }".to_string(),
1886        );
1887        let _ = store.cached_analysis(&ua).unwrap();
1888        let analysis_b_first = store.cached_analysis(&ub).unwrap();
1889        let ver_after_warm = store.caches.decl_version();
1890
1891        // Body-only edit to A: same function name, different body → FileIndex unchanged.
1892        store.mirror_text(&ua, "<?php\nfunction a() { return 999; }");
1893        let _ = store.cached_analysis(&ua);
1894        let ver_after_body_edit = store.caches.decl_version();
1895        assert_eq!(
1896            ver_after_warm, ver_after_body_edit,
1897            "body-only edit must not bump decl_version"
1898        );
1899
1900        // B's cached entry should still be valid (ptr-eq source AND same version).
1901        let analysis_b_second = store.cached_analysis_if_fresh(&ub);
1902        assert!(
1903            analysis_b_second.is_some(),
1904            "B's analysis should hit cache after body-only edit to A"
1905        );
1906        assert!(
1907            Arc::ptr_eq(&analysis_b_first, &analysis_b_second.unwrap()),
1908            "B's analysis should be the identical Arc (no re-analysis)"
1909        );
1910
1911        // Declaration edit to A: rename the function → FileIndex changes.
1912        store.mirror_text(&ua, "<?php\nfunction a_renamed() { return 999; }");
1913        let _ = store.cached_analysis(&ua);
1914        let ver_after_decl_edit = store.caches.decl_version();
1915        assert!(
1916            ver_after_decl_edit > ver_after_body_edit,
1917            "declaration edit must bump decl_version (was {ver_after_body_edit}, now {ver_after_decl_edit})"
1918        );
1919
1920        // B's entry is now stale — cached_analysis_if_fresh must return None.
1921        let analysis_b_stale = store.cached_analysis_if_fresh(&ub);
1922        assert!(
1923            analysis_b_stale.is_none(),
1924            "B's analysis should be stale after A's declaration changed"
1925        );
1926    }
1927
1928    /// snapshot_query must complete without panic when a concurrent writer
1929    /// races the snapshot. The single-retry-then-lock logic should handle this
1930    /// correctly: the lock-held fallback guarantees progress.
1931    #[test]
1932    fn snapshot_query_survives_concurrent_writes() {
1933        use std::sync::Arc;
1934        use std::thread;
1935        use std::time::{Duration, Instant};
1936
1937        let store = Arc::new(DocumentStore::new());
1938        let u = uri("/sq_test.php");
1939        open(
1940            &store,
1941            u.clone(),
1942            "<?php\nfunction f(): int { return 1; }".to_string(),
1943        );
1944
1945        let deadline = Instant::now() + Duration::from_millis(200);
1946        let mut handles = Vec::new();
1947
1948        // Writer: keep bumping the file text to trigger salsa::Cancelled.
1949        {
1950            let store = Arc::clone(&store);
1951            let u = u.clone();
1952            handles.push(thread::spawn(move || {
1953                let mut rev = 0u32;
1954                while Instant::now() < deadline {
1955                    store.mirror_text(&u, &format!("<?php\nfunction f(): int {{ return {rev}; }}"));
1956                    rev += 1;
1957                }
1958            }));
1959        }
1960
1961        // Reader: hammer snapshot_query via get_doc_salsa.
1962        for _ in 0..4 {
1963            let store = Arc::clone(&store);
1964            let u = u.clone();
1965            handles.push(thread::spawn(move || {
1966                while Instant::now() < deadline {
1967                    let _ = store.get_doc_salsa(&u);
1968                    let _ = store.get_index_salsa(&u);
1969                }
1970            }));
1971        }
1972
1973        for h in handles {
1974            h.join()
1975                .expect("no panic in snapshot_query under concurrent writes");
1976        }
1977    }
1978
1979    /// When a sibling file's declaration changes (bumping decl_version), the
1980    /// owned_program_cache entry for the unchanged file B should be reused
1981    /// rather than deep-cloned again. We verify this via Arc pointer equality.
1982    #[test]
1983    fn owned_program_cache_reused_after_sibling_declaration_change() {
1984        let store = DocumentStore::new();
1985        let ua = uri("/prog_a.php");
1986        let ub = uri("/prog_b.php");
1987
1988        open(
1989            &store,
1990            ua.clone(),
1991            "<?php\nfunction alpha(): void {}".to_string(),
1992        );
1993        open(
1994            &store,
1995            ub.clone(),
1996            "<?php\nfunction beta(): void {}".to_string(),
1997        );
1998
1999        // Warm both analysis caches and populate owned_program_cache for both files.
2000        let _ = store.cached_analysis(&ua);
2001        let _ = store.cached_analysis(&ub);
2002
2003        // Capture B's owned_program Arc before the sibling edit.
2004        let prog_b_first = store
2005            .caches
2006            .owned_program_cache
2007            .get(&ub)
2008            .map(|e| Arc::clone(&e.1))
2009            .expect("B's owned_program should be cached after first analysis");
2010
2011        // Declaration change to A: bumps decl_version, invalidating all cached_analysis entries.
2012        store.mirror_text(&ua, "<?php\nfunction alpha_renamed(): void {}");
2013        // Re-analyze A to trigger the decl_version bump.
2014        let _ = store.cached_analysis(&ua);
2015
2016        // Now re-analyze B. Its source is unchanged, so owned_program_cache must hit.
2017        let _ = store.cached_analysis(&ub);
2018
2019        let prog_b_second = store
2020            .caches
2021            .owned_program_cache
2022            .get(&ub)
2023            .map(|e| Arc::clone(&e.1))
2024            .expect("B's owned_program should still be cached after sibling edit");
2025
2026        assert!(
2027            Arc::ptr_eq(&prog_b_first, &prog_b_second),
2028            "B's owned_program Arc should be identical (cache hit) after sibling declaration change"
2029        );
2030    }
2031
2032    /// write_rev() increments on each real text write (identical text is a no-op).
2033    #[test]
2034    fn write_rev_increments_on_write() {
2035        let store = DocumentStore::new();
2036        let u = uri("/rev_test.php");
2037        let before = store.write_rev();
2038        store.mirror_text(&u, "<?php echo 1;");
2039        let after_first = store.write_rev();
2040        assert!(after_first > before, "first write must bump the revision");
2041        // Identical text must NOT bump the revision (fast path).
2042        store.mirror_text(&u, "<?php echo 1;");
2043        assert_eq!(
2044            store.write_rev(),
2045            after_first,
2046            "identical text must not bump the revision"
2047        );
2048        // Different text must bump again.
2049        store.mirror_text(&u, "<?php echo 2;");
2050        assert!(
2051            store.write_rev() > after_first,
2052            "changed text must bump the revision"
2053        );
2054    }
2055
2056    /// begin_reanalyze() cancels the previously-issued sweep token so a newer
2057    /// edit preempts an in-flight dependent walk, and hands back a fresh token.
2058    #[test]
2059    fn begin_reanalyze_cancels_previous_token() {
2060        let store = DocumentStore::new();
2061        let first = store.begin_reanalyze();
2062        assert!(!first.is_cancelled(), "a fresh token starts un-cancelled");
2063        let second = store.begin_reanalyze();
2064        assert!(
2065            first.is_cancelled(),
2066            "issuing a new token must cancel the previous in-flight sweep"
2067        );
2068        assert!(
2069            !second.is_cancelled(),
2070            "the newly-issued token must itself be un-cancelled"
2071        );
2072    }
2073
2074    /// write_rev() increments when a file is removed.
2075    #[test]
2076    fn write_rev_increments_on_remove() {
2077        let store = DocumentStore::new();
2078        let u = uri("/rev_remove.php");
2079        store.mirror_text(&u, "<?php class A {}");
2080        let before = store.write_rev();
2081        store.remove(&u);
2082        assert!(store.write_rev() > before, "remove must bump the revision");
2083    }
2084}