Skip to main content

php_lsp/document/
document_store.rs

1use std::sync::atomic::{AtomicBool, 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    /// On-demand `FileIndex` store for vendor files loaded lazily via PSR-4
63    /// navigation. Vendor is excluded from the eager workspace scan, so files
64    /// ingested by `psr4_method_goto` are not in the salsa workspace_index;
65    /// this map fills that gap for hierarchy traversal. Populated by
66    /// `cache_vendor_index`; reads via `get_vendor_index`.
67    vendor_index_cache: DashMap<Url, Arc<FileIndex>>,
68    /// Set once the workspace scan's reference-index phase finishes, i.e. the
69    /// `subtypes_of` map in `workspace_index` is complete. Visibility-derived
70    /// scope narrowing that relies on the full subtype set (protected methods)
71    /// only applies once this is `true`; before then it falls back to the full
72    /// workspace scope so references are never under-reported.
73    index_ready: AtomicBool,
74}
75
76impl Default for DocumentStore {
77    fn default() -> Self {
78        Self::new()
79    }
80}
81
82impl DocumentStore {
83    pub fn new() -> Self {
84        DocumentStore {
85            caches: CacheRegistry::new(),
86            lsp_ws_files: DashMap::new(),
87            deleted_uris: DashSet::new(),
88            workspace_files_dirty: AtomicBool::new(true),
89            lsp_workspace: Mutex::new(None),
90            php_version: Mutex::new(mir_analyzer::PhpVersion::LATEST),
91            psr4: Arc::new(ArcSwap::from_pointee(Psr4Map::empty())),
92            analysis_session: Mutex::new(None),
93            session_cache_dir: OnceLock::new(),
94            autoload_uris: std::sync::RwLock::new(Vec::new()),
95            vendor_index_cache: DashMap::new(),
96            index_ready: AtomicBool::new(false),
97        }
98    }
99
100    /// Mark the workspace reference index as fully built. Called by the scan
101    /// when its final phase completes (alongside `$/php-lsp/indexReady`).
102    pub fn mark_index_ready(&self) {
103        self.index_ready.store(true, Ordering::Release);
104    }
105
106    /// Whether the workspace reference index has finished building.
107    pub fn is_index_ready(&self) -> bool {
108        self.index_ready.load(Ordering::Acquire)
109    }
110
111    /// Set the directory used to persist stub-parse and analysis results across
112    /// server restarts.  Must be called before the first `analysis_session` use;
113    /// subsequent calls are silently ignored (`OnceLock` semantics).
114    pub fn set_session_cache_dir(&self, dir: std::path::PathBuf) {
115        let _ = self.session_cache_dir.set(dir);
116    }
117
118    /// Register URIs discovered from composer.json `autoload.files` entries.
119    /// These PHP files define global helper functions (e.g. `tap()` in Laravel)
120    /// that are not class-resolvable via PSR-4. Clears `analysis_cache` so the
121    /// next per-file analysis pre-ingests them into the AnalysisSession before
122    /// running mir's FileAnalyzer.
123    pub fn set_autoload_uris(&self, uris: Vec<Url>) {
124        *self.autoload_uris.write().unwrap() = uris;
125        self.caches.evict_analysis_all();
126    }
127
128    /// Get or build the `AnalysisSession` for the given PHP version, **without**
129    /// loading PHP stubs. Stub loading is heavy (parses every built-in stub
130    /// file) and is only needed for semantic analysis, not for parse / index /
131    /// symbol-map queries. The parse path uses this; semantic callers use
132    /// [`Self::analysis_session`], which loads stubs idempotently on top.
133    fn get_or_build_session(
134        &self,
135        php_version: mir_analyzer::PhpVersion,
136    ) -> Arc<mir_analyzer::AnalysisSession> {
137        let mut guard = self.analysis_session.lock().unwrap();
138        if let Some((cached_ver, session)) = guard.as_ref()
139            && *cached_ver == php_version
140        {
141            return Arc::clone(session);
142        }
143        // Build a fresh session. Hand it the shared PSR-4 map so it can
144        // lazy-resolve `UndefinedClass` candidates without us having to mirror
145        // every vendor file upfront.
146        let resolver: Arc<dyn mir_analyzer::ClassResolver> = self.psr4.load_full();
147        let mut builder =
148            mir_analyzer::AnalysisSession::new(php_version).with_class_resolver(resolver);
149        if let Some(dir) = self.session_cache_dir.get() {
150            builder = builder.with_cache_dir(dir);
151        }
152        let session = Arc::new(builder);
153        *guard = Some((php_version, Arc::clone(&session)));
154        session
155    }
156
157    /// The `AnalysisSession` for the given PHP version with PHP stubs loaded.
158    /// Rebuilds when the version changes (e.g. user flipped config). The
159    /// session owns the shared salsa db and AnalysisCache; lazy-loads vendor
160    /// files via the shared PSR-4 map. `ensure_all_stubs` is idempotent — cheap
161    /// after the first call on a given session.
162    pub fn analysis_session(
163        &self,
164        php_version: mir_analyzer::PhpVersion,
165    ) -> Arc<mir_analyzer::AnalysisSession> {
166        let session = self.get_or_build_session(php_version);
167        session.ensure_all_stubs();
168        session
169    }
170
171    /// Current PHP version tracked by the workspace input.
172    pub fn workspace_php_version(&self) -> mir_analyzer::PhpVersion {
173        *self.php_version.lock().unwrap()
174    }
175
176    /// File URIs of all direct and transitive subclasses of `class_fqn`,
177    /// resolved via mir's inheritance graph. Returns an empty vec when the mir
178    /// session hasn't ingested the class yet (cold start, excluded paths).
179    ///
180    /// Used by `goto_implementation` and `subtypes` to scope their lookups to
181    /// the correct files, fixing aliased `extends` and FQN-qualified forms that
182    /// the raw-name `subtypes_of` map misses.
183    pub fn class_subtype_urls(&self, class_fqn: &str) -> Vec<tower_lsp::lsp_types::Url> {
184        let session = self.analysis_session(self.workspace_php_version());
185        session
186            .subtype_files(class_fqn)
187            .into_iter()
188            .filter_map(|p| tower_lsp::lsp_types::Url::parse(&p).ok())
189            .collect()
190    }
191
192    /// Return the `Arc<ArcSwap<Psr4Map>>` so callers can share it.
193    /// `Backend` clones this arc at construction time so writes
194    /// (e.g. loading composer.json on `initialized`) are immediately visible
195    /// to PSR-4 resolution during analysis without extra plumbing.
196    pub fn psr4_arc(&self) -> Arc<ArcSwap<Psr4Map>> {
197        Arc::clone(&self.psr4)
198    }
199
200    /// Durability for a file's salsa input: vendor files never change within a
201    /// session, so salsa can skip re-validating their queries on user edits.
202    fn input_durability(uri: &Url) -> salsa::Durability {
203        if uri.as_str().contains("/vendor/") {
204            salsa::Durability::HIGH
205        } else {
206            salsa::Durability::LOW
207        }
208    }
209
210    /// The `LspWsFile` handle for `uri`, if it is mirrored and not deleted.
211    fn lsp_ws_file(&self, uri: &Url) -> Option<LspWsFile> {
212        if self.deleted_uris.contains(uri) {
213            return None;
214        }
215        self.lsp_ws_files.get(uri).map(|e| *e)
216    }
217
218    /// Take a cheap snapshot of the shared mir db and run `f` on it, retrying
219    /// once on `salsa::Cancelled` (raised when a concurrent writer bumps the
220    /// revision). Mirrors [`Self::snapshot_query`] for the converged db.
221    fn snapshot_mir_query<R>(&self, f: impl Fn(&mir_analyzer::db::MirDbStorage) -> R) -> R {
222        use std::panic::AssertUnwindSafe;
223        let session = self.get_or_build_session(self.workspace_php_version());
224        // Each iteration's snapshot clone MUST drop before the next snapshot:
225        // a concurrent writer's salsa `set` holds the mir write lock and waits
226        // for outstanding db handles to drop, while the next `snapshot_db` needs
227        // the read lock — keeping the clone alive across the retry deadlocks.
228        loop {
229            let db = session.snapshot_db();
230            match salsa::Cancelled::catch(AssertUnwindSafe(|| f(&db))) {
231                Ok(r) => return r,
232                Err(_) => drop(db),
233            }
234        }
235    }
236
237    /// Mirror a file's current text into the salsa layer. Creates the
238    /// `FileText` input on first sight, otherwise updates `text` on the
239    /// existing input (bumping the salsa revision so downstream queries
240    /// invalidate).
241    pub fn mirror_text(&self, uri: &Url, text: &str) {
242        // G2 fast path: compare against the lock-free text cache. When the
243        // new text byte-matches what we already mirrored, skip the host
244        // mutex entirely. Common during workspace scan + `did_open` for
245        // unchanged files, where most threads would otherwise serialise on
246        // `host.lock()` just to confirm a no-op.
247        if let Some(cached) = self.caches.text_cache.get(uri)
248            && **cached == *text
249            && !self.deleted_uris.contains(uri)
250            && self.lsp_ws_files.contains_key(uri)
251        {
252            return;
253        }
254        self.mirror_text_arc(uri, Arc::from(text))
255    }
256
257    /// Like [`mirror_text`] but takes an already-allocated `Arc<str>`.
258    ///
259    /// Callers that already hold an `Arc<str>` (e.g. `ingest_from_doc` reusing
260    /// `ParsedDoc::source_arc()`) use this to avoid a second allocation and to
261    /// ensure `text_cache` and `parsed_cache` hold the same Arc pointer —
262    /// enabling `Arc::ptr_eq` validation in `get_parsed_cached`.
263    pub fn mirror_text_arc(&self, uri: &Url, text_arc: Arc<str>) {
264        let dur = Self::input_durability(uri);
265        let path: Arc<str> = Arc::from(uri.as_str());
266        let session = self.get_or_build_session(self.workspace_php_version());
267        if let Some(wf) = self.lsp_ws_files.get(uri).map(|e| *e) {
268            self.deleted_uris.remove(uri);
269            // Fast path: byte-identical text already mirrored — skip the write
270            // lock and the revision bump entirely.
271            if let Some(cached) = self.caches.text_cache.get(uri)
272                && **cached == *text_arc
273            {
274                return;
275            }
276            session.with_db_mut(|db| {
277                let sf = wf.source(db);
278                sf.set_text(db).with_durability(dur).to(text_arc.clone());
279                // Any text change invalidates a previously-seeded cached index.
280                // Only set when present to avoid a spurious second revision bump.
281                if wf.cached_index(db).is_some() {
282                    wf.set_cached_index(db).to(None);
283                }
284            });
285            self.caches.text_cache.insert(uri.clone(), text_arc);
286            // Evict only this file's analysis; cross-file invalidation is handled
287            // lazily in `cached_analysis` via the declaration fingerprint.
288            self.caches.evict_analysis(uri);
289        } else {
290            let wf = session.with_db_mut(|db| {
291                let sf = db.upsert_source_file_with_durability(path, text_arc.clone(), dur);
292                LspWsFile::new(db, sf, None)
293            });
294            self.lsp_ws_files.insert(uri.clone(), wf);
295            self.caches.text_cache.insert(uri.clone(), text_arc);
296            self.workspace_files_dirty.store(true, Ordering::Release);
297        }
298    }
299
300    /// Return the `LspWsFile` handle for a URL, if active (not deleted).
301    #[cfg(test)]
302    pub fn source_file(&self, uri: &Url) -> Option<LspWsFile> {
303        if self.deleted_uris.contains(uri) {
304            return None;
305        }
306        self.lsp_ws_files.get(uri).map(|e| *e)
307    }
308
309    /// Phase K2: pre-seed a `FileIndex` loaded from the on-disk cache onto
310    /// the `FileText` input for `uri`. The next `file_index` call for that
311    /// file returns the cached index directly, skipping parse + extract.
312    ///
313    /// Must be called **before** any `file_index(db, sf)` call for this file —
314    /// otherwise salsa has already memoized the fresh-parse result and setting
315    /// `cached_index` now would only bump the revision without using the cache.
316    /// In practice the workspace-scan path seeds immediately after `mirror_text`
317    /// and before any query runs.
318    ///
319    /// Returns `false` when `uri` was not mirrored (caller should mirror
320    /// first); returns `true` on success.
321    pub fn seed_cached_index(&self, uri: &Url, index: Arc<FileIndex>) -> bool {
322        let Some(wf) = self.lsp_ws_file(uri) else {
323            return false;
324        };
325        let session = self.get_or_build_session(self.workspace_php_version());
326        session.with_db_mut(|db| wf.set_cached_index(db).to(Some(index)));
327        true
328    }
329
330    /// Evict the semantic-tokens cache for `uri`. Called by Backend when a
331    /// file is closed; diff-based tokens computed against the old revision
332    /// are no longer meaningful.
333    pub fn evict_token_cache(&self, uri: &Url) {
334        self.caches.evict_tokens(uri);
335    }
336
337    /// Return the `FileIndex` for `uri` by running `file_index` on a salsa
338    /// snapshot.  Returns `None` when `uri` has not been mirrored.
339    ///
340    /// Test-only — production code uses the salsa query directly via
341    /// `snapshot_query`.
342    #[cfg(test)]
343    pub fn source_files_len(&self) -> usize {
344        self.lsp_ws_files.len()
345    }
346
347    #[cfg(test)]
348    pub fn snapshot_query_file_index(
349        &self,
350        uri: &Url,
351    ) -> Option<crate::index::file_index::FileIndex> {
352        let wf = self.lsp_ws_file(uri)?;
353        Some(
354            self.snapshot_mir_query(move |db| {
355                (*crate::db::mir_queries::file_index(db, wf).0).clone()
356            }),
357        )
358    }
359
360    /// Register a file in the salsa layer without marking it open.
361    ///
362    /// Salsa's `parsed_doc` query parses lazily on first read; diagnostics
363    /// are populated by `did_open` when the editor actually opens the file.
364    pub fn ingest(&self, uri: Url, text: &str) {
365        self.mirror_text(&uri, text);
366    }
367
368    /// Index a file using an already-parsed `ParsedDoc`, avoiding a second parse.
369    ///
370    /// Prefer this over [`ingest`] when the caller already has a `ParsedDoc` (e.g.
371    /// after running `DefinitionCollector` during workspace scan). Reuses the
372    /// `Arc<str>` already owned by `doc` so that `text_cache` and `SourceFile::text`
373    /// share the same pointer — enabling the `Arc::ptr_eq` fast path in
374    /// `get_parsed_cached` on the first subsequent salsa query, without an extra
375    /// `Arc::from(source)` allocation.
376    pub fn ingest_from_doc(&self, uri: Url, doc: &ParsedDoc) {
377        self.mirror_text_arc(&uri, doc.source_arc());
378    }
379
380    pub fn remove(&self, uri: &Url) {
381        self.caches.evict(uri);
382        // Mark the URI as deleted but keep the `source_files` entry so the
383        // salsa `SourceFile` handle remains alive. Re-opening the file reuses
384        // the same handle instead of calling `SourceFile::new()` again, which
385        // would create a new orphaned salsa input on every delete-reopen cycle.
386        self.deleted_uris.insert(uri.clone());
387        self.workspace_files_dirty.store(true, Ordering::Release);
388        // Sync workspace files so the deleted file is removed from the salsa
389        // `Workspace::files` list and won't appear in workspace symbols etc.
390        self.sync_workspace_files();
391        // Also evict the file from the `AnalysisSession`'s internal state so
392        // workspace symbol queries don't keep returning the deleted file's
393        // declarations. Cheap when the session hasn't ingested this file.
394        let guard = self.analysis_session.lock().unwrap();
395        if let Some((_, session)) = guard.as_ref() {
396            session.invalidate_file(uri.as_str());
397        }
398    }
399
400    // ── Salsa-backed accessors ─────────────────────────────────────────────
401    //
402    // Reads run the memoized `parsed_doc` / `file_index` queries, parsing
403    // only on first access per revision. These are the production accessors
404    // used by every handler.
405
406    /// Salsa-backed parsed document.
407    ///
408    /// Salsa-backed parsed document for any mirrored file (open or
409    /// background-indexed). Returns `None` only when the file is not known
410    /// to the store. Callers that want "only if open" should gate on
411    /// `Backend::open_files` at the call site (see `Backend::get_doc`).
412    pub fn get_doc_salsa(&self, uri: &Url) -> Option<Arc<ParsedDoc>> {
413        self.get_parsed_cached(uri)
414    }
415
416    /// Salsa-backed compact symbol index.
417    pub fn get_index_salsa(&self, uri: &Url) -> Option<Arc<FileIndex>> {
418        let wf = self.lsp_ws_file(uri)?;
419        Some(
420            self.snapshot_mir_query(move |db| crate::db::mir_queries::file_index(db, wf).0.clone()),
421        )
422    }
423
424    /// Salsa-backed pre-computed symbol map (name → Vec<SymbolEntry>).
425    /// Memoized per revision: stable files serve from cache in O(1).
426    pub fn get_symbol_map_salsa(
427        &self,
428        uri: &Url,
429    ) -> Option<Arc<crate::types::symbol_map::SymbolMap>> {
430        // Symbol map runs on the shared mir db, sharing its memoized `parsed_doc`.
431        let wf = self.lsp_ws_file(uri)?;
432        Some(self.snapshot_mir_query(move |db| {
433            let sf = wf.source(db);
434            crate::db::mir_queries::symbol_map(db, sf).0.clone()
435        }))
436    }
437
438    /// Pre-computed symbol maps for every entry in `open_urls` except `uri`.
439    pub fn other_symbol_maps(
440        &self,
441        uri: &Url,
442        open_urls: &[Url],
443    ) -> Vec<(Url, Arc<crate::types::symbol_map::SymbolMap>)> {
444        open_urls
445            .iter()
446            .filter(|u| *u != uri)
447            .filter_map(|u| self.get_symbol_map_salsa(u).map(|m| (u.clone(), m)))
448            .collect()
449    }
450
451    /// G3: shared implementation for `get_doc_salsa`.
452    /// Tries the `parsed_cache` (lock-free) first; validates via
453    /// `Arc::ptr_eq` against the G2 `text_cache` so a concurrent writer
454    /// that has already committed a new text input cannot be masked by a
455    /// stale cache entry. On miss, captures the text Arc and ParsedDoc
456    /// together inside a single `snapshot_query`, then publishes both.
457    fn get_parsed_cached(&self, uri: &Url) -> Option<Arc<ParsedDoc>> {
458        if let Some(current_text) = self.caches.text_cache.get(uri)
459            && let Some(entry) = self.caches.parsed_cache.get(uri)
460            && Arc::ptr_eq(&*current_text, &entry.0)
461        {
462            return Some(entry.1.clone());
463        }
464
465        // Parse runs on the shared mir db.
466        let wf = self.lsp_ws_file(uri)?;
467        self.caches.bump_parse_count();
468        let (text, doc) = self.snapshot_mir_query(move |db| {
469            let sf = wf.source(db);
470            let text = sf.text(db);
471            let doc = crate::db::mir_queries::parsed_doc(db, sf).0.clone();
472            (text, doc)
473        });
474        self.caches.insert_parsed(uri.clone(), text, doc.clone());
475        Some(doc)
476    }
477
478    /// Refresh `workspace.files` to mirror the current active file set.
479    ///
480    /// Skips all work when `workspace_files_dirty` is `false` (the common
481    /// case after the workspace scan completes — file-set changes are rare).
482    pub fn sync_workspace_files(&self) {
483        // Atomically clear the flag.  If it was already false the file set
484        // hasn't changed since the last sync; nothing to do.
485        if !self.workspace_files_dirty.swap(false, Ordering::AcqRel) {
486            return;
487        }
488
489        // Collect active (non-deleted) files, sorted by URI for stable ordering.
490        let mut entries: Vec<(Arc<str>, LspWsFile)> = self
491            .lsp_ws_files
492            .iter()
493            .filter(|e| !self.deleted_uris.contains(e.key()))
494            .map(|e| (Arc::<str>::from(e.key().as_str()), *e.value()))
495            .collect();
496        entries.sort_unstable_by(|(a, _), (b, _)| a.cmp(b));
497        let files: Arc<[LspWsFile]> = entries.iter().map(|(_, wf)| *wf).collect();
498
499        let session = self.get_or_build_session(self.workspace_php_version());
500        let mut guard = self.lsp_workspace.lock().unwrap();
501        session.with_db_mut(|db| match *guard {
502            Some(ws) => {
503                ws.set_files(db).to(files);
504            }
505            None => *guard = Some(LspWorkspace::new(db, files)),
506        });
507    }
508
509    /// Mark the workspace file set as dirty so the next `sync_workspace_files`
510    /// call re-runs the collect/sort/compare path.  Exposed for benchmarks that
511    /// need to measure the dirty-path cost in isolation.
512    pub fn mark_workspace_files_dirty(&self) {
513        self.workspace_files_dirty.store(true, Ordering::Release);
514    }
515
516    /// Update the PHP version tracked by the workspace. Salsa will invalidate
517    /// all `semantic_issues` queries so diagnostics are re-evaluated.
518    /// Skips the setter when the version hasn't changed to avoid spurious
519    /// query invalidation.
520    pub fn set_php_version(&self, version: mir_analyzer::PhpVersion) {
521        {
522            let mut guard = self.php_version.lock().unwrap();
523            if *guard == version {
524                return;
525            }
526            *guard = version;
527        }
528        // Changing the version selects a different `AnalysisSession` (and thus a
529        // different db). Drop the inputs created on the old session's db; the
530        // workspace scan re-mirrors files onto the new session. In practice the
531        // version is set once at init, before any file is mirrored.
532        self.lsp_ws_files.clear();
533        *self.lsp_workspace.lock().unwrap() = None;
534        self.workspace_files_dirty.store(true, Ordering::Release);
535        // Stale FileAnalysis from the old version would survive unchanged files.
536        self.caches.evict_analysis_all();
537    }
538
539    /// Session-backed reference lookup scoped to `files`. Returns `(file, line,
540    /// col)` locations for every occurrence of `symbol` originating in those
541    /// files, computed from the memoized `analyze_file` query — no ingest, no
542    /// shared-index mutation, so repeated lookups don't degrade across a
543    /// session. `files` should be the text-pre-filtered candidate set.
544    ///
545    /// Returns LSP-style 0-based line/column.
546    pub fn session_references_to(
547        &self,
548        symbol: &mir_analyzer::Name,
549        files: &[Arc<str>],
550    ) -> Vec<(Arc<str>, u32, u32, u32)> {
551        let php_version = self.workspace_php_version();
552        // Retry: concurrent db writes (background indexing) cancel snapshot
553        // queries via resume_unwind; without the loop the panic propagates out
554        // of the caller's spawn_blocking and the request silently returns empty.
555        let raw = loop {
556            let session = self.analysis_session(php_version);
557            if let Ok(refs) = salsa::Cancelled::catch(std::panic::AssertUnwindSafe(|| {
558                session.references_to_in_files(symbol, files)
559            })) {
560                break refs;
561            }
562        };
563        raw.into_iter()
564            .map(|(file, range)| {
565                // mir uses 1-based lines; 0-based columns (since mir 0.42.0).
566                let line = range.start.line.saturating_sub(1);
567                let col_start = range.start.column;
568                let col_end = range.end.column;
569                (file, line, col_start, col_end)
570            })
571            .collect()
572    }
573
574    /// The complete set of files a `private` method's references can occur in,
575    /// or `None` when the search must stay workspace-wide.
576    ///
577    /// `owner_fqn` is the enclosing class at the cursor; narrowing applies only
578    /// when that class *directly declares* `method` (so its `MethodDef`
579    /// visibility is authoritative — inherited/call-site lookups fall through to
580    /// `None`). A reference to `Class::method` is recorded only where
581    /// `analyze_file` resolves an expression to that key, and `analyze_file` is
582    /// per-source-file, so a `private` member — resolvable only inside the
583    /// declaring class body, which lives in one file — is fully scoped to the
584    /// declaring file at any indexing stage.
585    ///
586    /// A `protected` member is reachable from the declaring class and its
587    /// transitive subclasses (`$this->m()`, `parent::m()`, `self::`/`static::`),
588    /// all of which live in the declaring file or a subtype file — never outside
589    /// the hierarchy in valid PHP. Its scope is therefore the declaring file plus
590    /// the transitive subtype files. The complete subtype set is only known once
591    /// the workspace reference index finishes, so this narrowing applies only
592    /// when [`Self::is_index_ready`]; before then it falls back to `None` (full
593    /// workspace scope) so references are never under-reported.
594    ///
595    /// Classes that compose traits or mixins are excluded — those inline
596    /// external resolution context, so a reference could surface in another file.
597    pub fn method_reference_scope(&self, owner_fqn: &str, method: &str) -> Option<Vec<Url>> {
598        use crate::index::file_index::{ClassKind, Visibility};
599
600        let ws = self.get_workspace_index_salsa();
601        let owner_fqn = owner_fqn.trim_start_matches('\\');
602        let owner_short = crate::text::fqn_short_name(owner_fqn);
603
604        let (decl_uri, decl_class) = ws
605            .classes_by_name
606            .get(owner_short)?
607            .iter()
608            .filter_map(|&r| ws.at(r))
609            .find(|(_, cls)| cls.fqn.trim_start_matches('\\') == owner_fqn)?;
610
611        // Trait/interface methods, and methods on classes that compose traits or
612        // mixins, can be referenced from other files — keep the full scope.
613        if !matches!(decl_class.kind, ClassKind::Class | ClassKind::Enum)
614            || !decl_class.traits.is_empty()
615            || !decl_class.mixins.is_empty()
616        {
617            return None;
618        }
619
620        let vis = decl_class
621            .methods
622            .iter()
623            .find(|m| m.name.eq_ignore_ascii_case(method))?
624            .visibility;
625
626        match vis {
627            Visibility::Private => Some(vec![decl_uri.clone()]),
628            Visibility::Protected => {
629                if !self.is_index_ready() {
630                    return None;
631                }
632                // Subtype set comes from mir's resolved inheritance graph — it
633                // matches subclasses by FQCN, so `extends \Ns\Base` and aliased
634                // `use ... as` forms are all found. Falls back to full scope if
635                // mir can't resolve the owner.
636                let session = self.analysis_session(self.workspace_php_version());
637                let mut files: std::collections::HashSet<Url> = session
638                    .subtype_files(owner_fqn)
639                    .into_iter()
640                    .filter_map(|p| Url::parse(&p).ok())
641                    .collect();
642                files.insert(decl_uri.clone());
643                Some(files.into_iter().collect())
644            }
645            Visibility::Public => None,
646        }
647    }
648
649    /// Phase J: salsa-memoized aggregate workspace index.
650    ///
651    /// Returns the shared `Arc<WorkspaceIndexData>` with flat
652    /// `(Url, Arc<FileIndex>)` list plus pre-built `classes_by_name` and
653    /// `subtypes_of` reverse maps. Used by workspace_symbols,
654    /// prepare_type_hierarchy, supertypes_of, subtypes_of, and
655    /// find_implementations so they don't each rebuild the aggregate per
656    /// request. Invalidates automatically when any file's `file_index`
657    /// changes.
658    pub fn get_workspace_index_salsa(&self) -> Arc<crate::db::workspace_index::WorkspaceIndexData> {
659        self.sync_workspace_files();
660        let ws = *self.lsp_workspace.lock().unwrap();
661        let Some(ws) = ws else {
662            return Arc::new(crate::db::workspace_index::WorkspaceIndexData {
663                files: Vec::new(),
664                classes_by_name: std::collections::HashMap::new(),
665                subtypes_of: std::collections::HashMap::new(),
666                decls_by_name: std::collections::HashMap::new(),
667            });
668        };
669        self.snapshot_mir_query(move |db| crate::db::mir_queries::workspace_index(db, ws).0.clone())
670    }
671
672    /// Total number of real `ParsedDoc` parses served so far (cache misses).
673    /// Surfaced via `$/php-lsp/debugStats` so tests can assert the references
674    /// read path doesn't parse the whole workspace.
675    pub fn parse_count(&self) -> u64 {
676        self.caches.parse_count()
677    }
678
679    /// Return the raw source text for `uri` if it has been mirrored into the
680    /// salsa workspace. Used by the references handler to pre-filter session
681    /// results by checking whether a file mentions the owning class name.
682    pub fn source_text(&self, uri: &Url) -> Option<Arc<str>> {
683        self.caches.text_cache.get(uri).map(|e| Arc::clone(&e))
684    }
685
686    /// Cache the semantic tokens computed for a delta response.
687    /// `result_id` is an opaque string (a hash of the token data) returned to the client.
688    pub fn store_token_cache(&self, uri: &Url, result_id: String, tokens: Arc<Vec<SemanticToken>>) {
689        self.caches.store_token(uri, result_id, tokens);
690    }
691
692    /// Return the cached tokens if `result_id` matches the stored one.
693    pub fn get_token_cache(&self, uri: &Url, result_id: &str) -> Option<Arc<Vec<SemanticToken>>> {
694        self.caches.get_token(uri, result_id)
695    }
696
697    /// Raw semantic issues for a file, computed via mir's session-based
698    /// `FileAnalyzer`. The session lazy-loads dependencies via PSR-4 so the
699    /// LSP no longer needs to mirror vendor up-front. Callers apply their
700    /// own `DiagnosticsConfig` filter via
701    /// [`crate::semantic_diagnostics::issues_to_diagnostics`].
702    #[tracing::instrument(skip_all)]
703    pub fn get_semantic_issues_salsa(&self, uri: &Url) -> Option<Arc<[mir_issues::Issue]>> {
704        let analysis = self.cached_analysis(uri)?;
705        let file: Arc<str> = Arc::from(uri.as_str());
706        // Workspace-level class issues for this file (circular inheritance,
707        // override violations, abstract-method gaps). These are session-wide
708        // (a dependency edit changes them without changing this file's bytes),
709        // so they are recomputed live rather than cached alongside the
710        // per-file body analysis.
711        let class_issues = {
712            let _s = tracing::debug_span!("session.class_issues_for").entered();
713            // Retry: concurrent db writes cancel snapshot queries via resume_unwind.
714            loop {
715                let session = self.analysis_session(self.workspace_php_version());
716                if let Ok(issues) = salsa::Cancelled::catch(std::panic::AssertUnwindSafe(|| {
717                    session.class_issues(std::slice::from_ref(&file))
718                })) {
719                    break issues;
720                }
721            }
722        };
723        let combined: Vec<mir_issues::Issue> = analysis
724            .issues
725            .iter()
726            .cloned()
727            .chain(class_issues)
728            .filter(|i| !i.suppressed)
729            .collect();
730        Some(Arc::from(combined))
731    }
732
733    /// Run (or reuse) mir's per-file body analysis, retaining the full
734    /// [`mir_analyzer::FileAnalysis`] — issues **and** resolved symbols — across
735    /// requests. Diagnostics read `.issues`; position features call
736    /// `.symbol_at(offset)` for the resolved type at a cursor.
737    ///
738    /// Cache hit when the entry's captured source `Arc` is pointer-equal to the
739    /// file's current `doc.source_arc()`. A miss recomputes and overwrites, so
740    /// the entry self-evicts on any content edit.
741    /// Build (or reuse) the whole-doc completion [`crate::types::type_map::TypeMap`]
742    /// for `uri`. Cache hit when the entry's captured source `Arc` is
743    /// pointer-equal to `doc.source_arc()` and the PHPStorm-meta pointer is
744    /// unchanged (meta lives behind `ArcSwap`, so its address is stable until
745    /// `.phpstorm.meta.php` is reloaded). A miss rebuilds and overwrites, so
746    /// the entry self-evicts on any content edit.
747    pub fn cached_type_map(
748        &self,
749        uri: &Url,
750        doc: &crate::document::ast::ParsedDoc,
751        meta: Option<&crate::lang::phpstorm_meta::PhpStormMeta>,
752    ) -> Arc<crate::types::type_map::TypeMap> {
753        let source = doc.source_arc();
754        let meta_key = meta.map_or(0usize, |m| std::ptr::from_ref(m) as usize);
755        if let Some(entry) = self.caches.type_map_cache.get(uri)
756            && Arc::ptr_eq(&entry.0, &source)
757            && entry.1 == meta_key
758        {
759            return Arc::clone(&entry.2);
760        }
761        let map = Arc::new(crate::types::type_map::TypeMap::from_doc_with_meta(
762            doc, meta,
763        ));
764        self.caches
765            .type_map_cache
766            .insert(uri.clone(), (source, meta_key, Arc::clone(&map)));
767        map
768    }
769
770    /// Cache-hit-only variant of [`Self::cached_analysis`]: returns the cached
771    /// analysis when the entry is current for the file's text, never computes.
772    /// Lets async handlers take the warm path synchronously and reserve
773    /// `spawn_blocking` for the cold path (mir Pass 1 + Pass 2 can take
774    /// hundreds of ms on large files).
775    pub fn cached_analysis_if_fresh(&self, uri: &Url) -> Option<Arc<mir_analyzer::FileAnalysis>> {
776        let doc = self.get_doc_salsa(uri)?;
777        let source = doc.source_arc();
778        let entry = self.caches.analysis_cache.get(uri)?;
779        let cur_ver = self.caches.decl_version();
780        (Arc::ptr_eq(&entry.0, &source) && entry.1 == cur_ver).then(|| Arc::clone(&entry.2))
781    }
782
783    #[tracing::instrument(skip_all)]
784    pub fn cached_analysis(&self, uri: &Url) -> Option<Arc<mir_analyzer::FileAnalysis>> {
785        // Need the parsed doc both for the analyzer and as the cache key.
786        let doc = self.get_doc_salsa(uri)?;
787        let source = doc.source_arc();
788
789        let cur_ver = self.caches.decl_version();
790        if let Some(entry) = self.caches.analysis_cache.get(uri)
791            && Arc::ptr_eq(&entry.0, &source)
792            && entry.1 == cur_ver
793        {
794            return Some(Arc::clone(&entry.2));
795        }
796
797        let php_version = self.workspace_php_version();
798        let session = self.analysis_session(php_version);
799        let file: Arc<str> = Arc::from(uri.as_str());
800
801        let source_map = php_rs_parser::source_map::SourceMap::new(doc.source());
802        let owned_program = if let Some(cached) = self.caches.owned_program_cache.get(uri)
803            && Arc::ptr_eq(&cached.0, &source)
804        {
805            Arc::clone(&cached.1)
806        } else {
807            let prog = Arc::new(php_ast::owned::to_owned_program(doc.program()));
808            self.caches
809                .owned_program_cache
810                .insert(uri.clone(), (Arc::clone(&source), Arc::clone(&prog)));
811            prog
812        };
813
814        // autoload.files helpers (e.g. Laravel's tap()) must be ingested so mir sees their functions.
815        let autoload_texts: Vec<(Arc<str>, Arc<str>)> = {
816            let autoload_uris = self.autoload_uris.read().unwrap().clone();
817            autoload_uris
818                .iter()
819                .filter_map(|auri| {
820                    self.caches
821                        .text_cache
822                        .get(auri)
823                        .map(|t| (Arc::from(auri.as_str()), Arc::clone(&*t)))
824                })
825                .collect()
826        };
827        // Bare same-namespace / use-imported class refs aren't resolved by mir's priority_index_for_ast; preload them.
828        let class_fqns = crate::references::collect_referenced_class_fqns(&doc);
829
830        // ingest_file/load_class/analyze take internal salsa snapshots; a concurrent db write cancels them via resume_unwind. Retry the idempotent sequence.
831        let analysis = loop {
832            let attempt = salsa::Cancelled::catch(std::panic::AssertUnwindSafe(|| {
833                session.ingest_file(file.clone(), source.clone());
834                for (afile, atext) in &autoload_texts {
835                    session.ingest_file(afile.clone(), atext.clone());
836                }
837                for fqcn in &class_fqns {
838                    let _ = session.load_class(fqcn);
839                }
840                let analyzer = mir_analyzer::FileAnalyzer::new(&session);
841                analyzer.analyze(file.clone(), doc.source(), &owned_program, &source_map)
842            }));
843            if let Ok(a) = attempt {
844                break Arc::new(a);
845            }
846        };
847        // Compare the new FileIndex against the stored fingerprint. If
848        // declarations changed (or this is the first analysis), bump
849        // `decl_version` so other files' cache entries become stale. Body-only
850        // edits leave the counter unchanged, allowing sibling files to be
851        // served from cache on the next request.
852        let new_index = self.get_index_salsa(uri);
853        let old_fp = self
854            .caches
855            .decl_fingerprints
856            .get(uri)
857            .map(|e| Arc::clone(&*e));
858        let decl_changed = match (&old_fp, &new_index) {
859            (Some(old), Some(new)) => **old != **new,
860            (None, Some(_)) => true,
861            _ => false,
862        };
863        if decl_changed {
864            if let Some(idx) = new_index {
865                self.caches.decl_fingerprints.insert(uri.clone(), idx);
866            }
867            self.caches.bump_decl_version();
868        }
869        let ver = self.caches.decl_version();
870        self.caches
871            .analysis_cache
872            .insert(uri.clone(), (source, ver, Arc::clone(&analysis)));
873        Some(analysis)
874    }
875
876    /// Returns `(uri, doc)` for files currently open in the editor.
877    ///
878    /// Resolve `open_urls` (from `Backend::open_urls()`) to parsed docs.
879    /// Files not mirrored in the salsa layer are filtered out silently.
880    pub fn docs_for(&self, open_urls: &[Url]) -> Vec<(Url, Arc<ParsedDoc>)> {
881        open_urls
882            .iter()
883            .filter_map(|u| self.get_doc_salsa(u).map(|d| (u.clone(), d)))
884            .collect()
885    }
886
887    /// `(primary, doc)` first, then every other open file's parsed doc.
888    /// The `open_urls` slice should include `uri` — this helper filters it out.
889    pub fn doc_with_others(
890        &self,
891        uri: &Url,
892        doc: Arc<ParsedDoc>,
893        open_urls: &[Url],
894    ) -> Vec<(Url, Arc<ParsedDoc>)> {
895        let mut result = vec![(uri.clone(), doc)];
896        result.extend(self.other_docs(uri, open_urls));
897        result
898    }
899
900    /// Parsed docs for every entry in `open_urls` except `uri`.
901    pub fn other_docs(&self, uri: &Url, open_urls: &[Url]) -> Vec<(Url, Arc<ParsedDoc>)> {
902        open_urls
903            .iter()
904            .filter(|u| *u != uri)
905            .filter_map(|u| self.get_doc_salsa(u).map(|d| (u.clone(), d)))
906            .collect()
907    }
908
909    /// Compact symbol index for every mirrored file.
910    pub fn all_indexes(&self) -> Vec<(Url, Arc<FileIndex>)> {
911        self.get_workspace_index_salsa().files.clone()
912    }
913
914    /// Store a lazily-loaded vendor `FileIndex` in the session cache.
915    /// Only call this for files that are not part of the normal workspace scan
916    /// (i.e. vendor files loaded on-demand by PSR-4 navigation).
917    pub fn cache_vendor_index(&self, uri: Url, index: Arc<FileIndex>) {
918        self.vendor_index_cache.insert(uri, index);
919    }
920
921    /// Retrieve a previously cached vendor `FileIndex`.
922    pub fn get_vendor_index(&self, uri: &Url) -> Option<Arc<FileIndex>> {
923        self.vendor_index_cache.get(uri).map(|e| Arc::clone(&*e))
924    }
925
926    /// Same as `all_indexes` but excludes `uri`.
927    pub fn other_indexes(&self, uri: &Url) -> Vec<(Url, Arc<FileIndex>)> {
928        self.get_workspace_index_salsa()
929            .files
930            .iter()
931            .filter(|(u, _)| u != uri)
932            .cloned()
933            .collect()
934    }
935
936    /// Parsed documents for every mirrored file (open or background-indexed).
937    /// Suitable for full-scan operations: find-references, rename,
938    /// call_hierarchy, code_lens.
939    pub fn all_docs_for_scan(&self) -> Vec<(Url, Arc<ParsedDoc>)> {
940        let urls: Vec<Url> = self
941            .lsp_ws_files
942            .iter()
943            .filter(|e| !self.deleted_uris.contains(e.key()))
944            .map(|e| e.key().clone())
945            .collect();
946        urls.into_iter()
947            .filter_map(|u| self.get_doc_salsa(&u).map(|d| (u, d)))
948            .collect()
949    }
950
951    /// URLs of workspace files whose raw source text mentions `word` as a whole
952    /// identifier — **no parsing**. Uses a precompiled `memmem` finder (the same
953    /// needle is scanned across the whole workspace) plus a word-boundary check,
954    /// so `save` does not match inside `saveAll`/`unsaved`; the scan runs in
955    /// parallel over the file set. The references read path narrows this list by
956    /// symbol visibility and hands it to mir's per-file `analyze_file`, so a
957    /// method lookup never materializes a `ParsedDoc` for the workspace.
958    ///
959    /// Files whose text is not yet in `text_cache` are included conservatively
960    /// (safe superset — never produces false negatives).
961    pub fn candidate_urls_for(&self, word: &str) -> Vec<Url> {
962        use rayon::prelude::*;
963        let urls: Vec<Url> = self
964            .lsp_ws_files
965            .iter()
966            .filter(|e| !self.deleted_uris.contains(e.key()))
967            .map(|e| e.key().clone())
968            .collect();
969        let finder = memchr::memmem::Finder::new(word.as_bytes());
970        let wlen = word.len();
971        urls.into_par_iter()
972            .filter(|u| match self.caches.text_cache.get(u) {
973                Some(src) => mentions_identifier(src.as_bytes(), &finder, wlen),
974                None => true,
975            })
976            .collect()
977    }
978
979    /// Parsed documents limited to files whose raw source text contains `word`.
980    ///
981    /// [`Self::candidate_urls_for`] + a salsa parse per survivor. Used by the
982    /// AST-walker reference paths (constructor refs, non-method kinds) that
983    /// genuinely need the parsed AST. The method reference path uses
984    /// `candidate_urls_for` directly and avoids these parses entirely.
985    ///
986    /// Files whose text is not yet in `text_cache` are included conservatively
987    /// (safe superset — never produces false negatives).
988    pub fn candidate_docs_for(&self, word: &str) -> Vec<(Url, Arc<ParsedDoc>)> {
989        self.candidate_urls_for(word)
990            .into_iter()
991            .filter_map(|u| self.get_doc_salsa(&u).map(|d| (u, d)))
992            .collect()
993    }
994}
995
996/// Whether `hay` contains `finder`'s needle (length `wlen`) as a whole
997/// identifier — i.e. not immediately preceded or followed by an ASCII
998/// identifier byte (`[A-Za-z0-9_]`). Only ASCII bytes count as identifier
999/// boundaries, so the check can over-accept near multibyte text but never
1000/// rejects a real whole-word match.
1001fn mentions_identifier(hay: &[u8], finder: &memchr::memmem::Finder, wlen: usize) -> bool {
1002    let is_ident = |b: u8| b.is_ascii_alphanumeric() || b == b'_';
1003    let mut from = 0;
1004    while let Some(rel) = finder.find(&hay[from..]) {
1005        let idx = from + rel;
1006        let before_ok = idx == 0 || !is_ident(hay[idx - 1]);
1007        let end = idx + wlen;
1008        let after_ok = end >= hay.len() || !is_ident(hay[end]);
1009        if before_ok && after_ok {
1010            return true;
1011        }
1012        from = idx + 1;
1013    }
1014    false
1015}
1016
1017#[cfg(test)]
1018mod tests {
1019    use super::*;
1020
1021    fn uri(path: &str) -> Url {
1022        Url::parse(&format!("file://{path}")).unwrap()
1023    }
1024
1025    #[test]
1026    fn mentions_identifier_respects_word_boundaries() {
1027        let m = |hay: &str, word: &str| {
1028            let f = memchr::memmem::Finder::new(word.as_bytes());
1029            mentions_identifier(hay.as_bytes(), &f, word.len())
1030        };
1031        assert!(m("$this->save();", "save"));
1032        assert!(m("function save() {}", "save"));
1033        assert!(m("save", "save"));
1034        // substrings inside a larger identifier must not match
1035        assert!(!m("$this->saveAll();", "save"));
1036        assert!(!m("return $unsaved;", "save"));
1037        assert!(!m("class Saver {}", "save"));
1038        assert!(!m("no occurrence here", "save"));
1039    }
1040
1041    /// Phase E4: open-file state lives on `Backend`, not `DocumentStore`.
1042    /// Tests that need to simulate "file is open" just mirror the text into
1043    /// the salsa input — the open/closed distinction is enforced by the
1044    /// caller (Backend) in production.
1045    fn open(store: &DocumentStore, u: Url, text: String) {
1046        store.mirror_text(&u, &text);
1047    }
1048
1049    // Removed `salsa_codebase_aggregates_all_files`: the salsa-side codebase
1050    // aggregation was deleted with the mir 0.22 migration. Equivalent
1051    // behaviour is now covered by mir-analyzer's own session tests.
1052
1053    #[test]
1054    fn index_registers_file_in_salsa() {
1055        let store = DocumentStore::new();
1056        store.ingest(uri("/lib.php"), "<?php\nfunction lib_fn() {}");
1057        let idx = store.get_index_salsa(&uri("/lib.php")).unwrap();
1058        assert_eq!(idx.functions.len(), 1);
1059        assert_eq!(idx.functions[0].name, "lib_fn".into());
1060    }
1061
1062    #[test]
1063    fn remove_hides_file_from_index() {
1064        let store = DocumentStore::new();
1065        let u = uri("/lib.php");
1066        store.ingest(u.clone(), "<?php");
1067        store.remove(&u);
1068        assert!(store.get_index_salsa(&u).is_none());
1069    }
1070
1071    #[test]
1072    fn remove_and_reopen_reuses_source_file_handle() {
1073        let store = DocumentStore::new();
1074        let u = uri("/lib.php");
1075        store.ingest(u.clone(), "<?php");
1076        let ft_before = store.source_file(&u).unwrap();
1077        store.remove(&u);
1078        assert!(
1079            store.source_file(&u).is_none(),
1080            "deleted file should be hidden"
1081        );
1082        store.mirror_text(&u, "<?php");
1083        let ft_after = store.source_file(&u).unwrap();
1084        assert!(
1085            ft_before == ft_after,
1086            "reopen must reuse the same FileText handle"
1087        );
1088    }
1089
1090    #[test]
1091    fn delete_reopen_churn_does_not_amplify_salsa_inputs() {
1092        let store = DocumentStore::new();
1093        let uris: Vec<Url> = (0..20).map(|i| uri(&format!("/churn/f{i}.php"))).collect();
1094        for u in &uris {
1095            store.ingest(u.clone(), "<?php class A {}");
1096        }
1097        let count_before = store.source_files_len();
1098        for _ in 0..10 {
1099            for u in &uris {
1100                store.remove(u);
1101            }
1102            for u in &uris {
1103                store.ingest(u.clone(), "<?php class A {}");
1104            }
1105        }
1106        assert_eq!(
1107            store.source_files_len(),
1108            count_before,
1109            "delete-reopen cycles must not create new salsa inputs (L1-B regression guard)"
1110        );
1111    }
1112
1113    #[test]
1114    fn all_indexes_includes_every_mirrored_file() {
1115        let store = DocumentStore::new();
1116        open(&store, uri("/a.php"), "<?php\nfunction a() {}".to_string());
1117        store.ingest(uri("/b.php"), "<?php\nfunction b() {}");
1118        assert_eq!(store.all_indexes().len(), 2);
1119    }
1120
1121    #[test]
1122    fn other_indexes_excludes_current_uri() {
1123        let store = DocumentStore::new();
1124        open(&store, uri("/a.php"), "<?php\nfunction a() {}".to_string());
1125        open(&store, uri("/b.php"), "<?php\nfunction b() {}".to_string());
1126        assert_eq!(store.other_indexes(&uri("/a.php")).len(), 1);
1127    }
1128
1129    #[test]
1130    fn other_docs_excludes_current_uri() {
1131        let store = DocumentStore::new();
1132        let ua = uri("/a.php");
1133        let ub = uri("/b.php");
1134        open(&store, ua.clone(), "<?php\nfunction a() {}".to_string());
1135        open(&store, ub.clone(), "<?php\nfunction b() {}".to_string());
1136        let open_urls = vec![ua.clone(), ub];
1137        assert_eq!(store.other_docs(&ua, &open_urls).len(), 1);
1138    }
1139
1140    #[test]
1141    fn evict_token_cache_removes_entry() {
1142        let store = DocumentStore::new();
1143        let u = uri("/a.php");
1144        open(&store, u.clone(), "<?php".to_string());
1145        store.store_token_cache(&u, "id1".to_string(), Arc::new(vec![]));
1146        assert!(store.get_token_cache(&u, "id1").is_some());
1147        store.evict_token_cache(&u);
1148        assert!(store.get_token_cache(&u, "id1").is_none());
1149    }
1150
1151    #[test]
1152    fn index_populates_file_index_with_symbols() {
1153        let store = DocumentStore::new();
1154        store.ingest(uri("/a.php"), "<?php\nfunction hello() {}");
1155        let idx = store.get_index_salsa(&uri("/a.php")).unwrap();
1156        assert_eq!(idx.functions.len(), 1);
1157        assert_eq!(idx.functions[0].name, "hello".into());
1158    }
1159
1160    #[test]
1161    fn open_populates_file_index_with_symbols() {
1162        let store = DocumentStore::new();
1163        open(&store, uri("/a.php"), "<?php\nclass Foo {}".to_string());
1164        let idx = store.get_index_salsa(&uri("/a.php")).unwrap();
1165        assert_eq!(idx.classes.len(), 1);
1166        assert_eq!(idx.classes[0].name, "Foo".into());
1167    }
1168
1169    // ── Mirror invariants ────────────────────────────────────────────────
1170    //
1171    // Every mutation path that changes file text must keep the salsa layer
1172    // consistent. These tests walk a set-edit-reopen cycle and assert that
1173    // the salsa-derived `FileIndex` reflects the latest text at each step.
1174
1175    fn names_of(idx: &FileIndex) -> Vec<String> {
1176        let mut out: Vec<String> = idx.classes.iter().map(|c| c.name.to_string()).collect();
1177        out.extend(idx.functions.iter().map(|f| f.name.to_string()));
1178        out.sort();
1179        out
1180    }
1181
1182    fn salsa_index_names(store: &DocumentStore, url: &Url) -> Vec<String> {
1183        store
1184            .snapshot_query_file_index(url)
1185            .map(|idx| names_of(&idx))
1186            .unwrap_or_default()
1187    }
1188
1189    #[test]
1190    fn mirror_tracks_repeated_edits() {
1191        let store = DocumentStore::new();
1192        let u = uri("/mirror.php");
1193
1194        open(&store, u.clone(), "<?php\nclass A {}".to_string());
1195        assert_eq!(salsa_index_names(&store, &u), vec!["A".to_string()]);
1196
1197        open(
1198            &store,
1199            u.clone(),
1200            "<?php\nclass A {}\nclass B {}".to_string(),
1201        );
1202        assert_eq!(
1203            salsa_index_names(&store, &u),
1204            vec!["A".to_string(), "B".to_string()]
1205        );
1206
1207        open(&store, u.clone(), "<?php\nfunction greet() {}".to_string());
1208        assert_eq!(salsa_index_names(&store, &u), vec!["greet".to_string()]);
1209    }
1210
1211    #[test]
1212    fn mirror_tracks_ingest_and_ingest_from_doc() {
1213        let store = DocumentStore::new();
1214
1215        // Background `index(url, text)` path.
1216        let u1 = uri("/bg1.php");
1217        store.ingest(u1.clone(), "<?php\nclass Bg1 {}");
1218        assert_eq!(salsa_index_names(&store, &u1), vec!["Bg1".to_string()]);
1219
1220        // `ingest_from_doc(url, &doc)` path (workspace-scan Phase 2).
1221        let u2 = uri("/bg2.php");
1222        let doc = crate::analysis::diagnostics::parse_document_no_diags(
1223            "<?php\nclass Bg2 {}\nfunction f() {}",
1224        );
1225        store.ingest_from_doc(u2.clone(), &doc);
1226        assert_eq!(
1227            salsa_index_names(&store, &u2),
1228            vec!["Bg2".to_string(), "f".to_string()]
1229        );
1230    }
1231
1232    /// G3: confirms the `parsed_cache` actually hits — two consecutive
1233    /// `get_doc_salsa` calls on unchanged text return the same `Arc`
1234    /// (pointer equality), and an edit forces a miss that produces a
1235    /// different `Arc`.
1236    /// parsed_cache must stay bounded — inserting more than
1237    /// `PARSED_CACHE_CAP` unique URLs must not cause unbounded growth.
1238    /// Eviction is probabilistic, so we only assert the bound, not which
1239    /// Seeding a cached index for a URL that was never mirrored is a no-op
1240    /// (returns `false`) — avoids silently allocating SourceFiles outside
1241    /// `mirror_text`'s control.
1242    #[test]
1243    fn seed_cached_index_noops_for_unknown_uri() {
1244        let store = DocumentStore::new();
1245        let u = uri("/never_mirrored.php");
1246        let index = Arc::new(crate::index::file_index::FileIndex::default());
1247        assert!(!store.seed_cached_index(&u, index));
1248    }
1249
1250    /// entries survive.
1251    #[test]
1252    fn parsed_cache_stays_bounded_under_many_inserts() {
1253        let store = DocumentStore::new();
1254        use crate::document::cache_registry::PARSED_CACHE_CAP;
1255        let overflow = PARSED_CACHE_CAP + 100;
1256        for i in 0..overflow {
1257            let u = uri(&format!("/cap/file{i}.php"));
1258            store.ingest(u.clone(), "<?php\nclass A {}");
1259            // Force a parsed_cache insert via get_doc_salsa.
1260            let _ = store.get_doc_salsa(&u);
1261        }
1262        assert!(
1263            store.caches.parsed_cache.len() <= PARSED_CACHE_CAP,
1264            "parsed_cache grew to {} entries (cap {})",
1265            store.caches.parsed_cache.len(),
1266            PARSED_CACHE_CAP
1267        );
1268    }
1269
1270    #[test]
1271    fn get_doc_salsa_cache_hits_across_calls() {
1272        let store = DocumentStore::new();
1273        let u = uri("/g3_cache.php");
1274        open(&store, u.clone(), "<?php\nclass G3 {}".to_string());
1275
1276        let a = store.get_doc_salsa(&u).unwrap();
1277        let b = store.get_doc_salsa(&u).unwrap();
1278        assert!(
1279            Arc::ptr_eq(&a, &b),
1280            "parsed_cache hit should yield the same Arc across calls"
1281        );
1282
1283        open(&store, u.clone(), "<?php\nclass G3b {}".to_string());
1284        let c = store.get_doc_salsa(&u).unwrap();
1285        assert!(
1286            !Arc::ptr_eq(&a, &c),
1287            "edit should invalidate the parsed_cache entry"
1288        );
1289    }
1290
1291    #[test]
1292    fn get_doc_salsa_returns_some_for_mirrored_files() {
1293        // Phase E4: `get_doc_salsa` no longer gates on open-state. The
1294        // open/closed distinction now lives on `Backend::get_doc`.
1295        let store = DocumentStore::new();
1296        let u = uri("/e4_doc.php");
1297        store.ingest(u.clone(), "<?php\nclass P {}");
1298        assert!(store.get_doc_salsa(&u).is_some());
1299    }
1300
1301    #[test]
1302    fn get_salsa_accessors_return_none_for_unknown_uri() {
1303        let store = DocumentStore::new();
1304        let u = uri("/never-seen.php");
1305        assert!(store.get_doc_salsa(&u).is_none());
1306        assert!(store.get_index_salsa(&u).is_none());
1307    }
1308
1309    /// Phase E1: concurrent readers and writers must not deadlock, panic, or
1310    /// return stale data. Writers briefly bump inputs while readers are
1311    /// running on cloned snapshots; any `salsa::Cancelled` raised on the
1312    /// reader side must be caught and retried by `snapshot_query`.
1313    ///
1314    /// The salsa surface (`get_doc_salsa`, `get_index_salsa`) is protected by
1315    /// `snapshot_query`'s last-resort host-lock fallback.
1316    #[test]
1317    fn concurrent_reads_and_writes_do_not_panic() {
1318        use std::sync::Arc;
1319        use std::thread;
1320        use std::time::{Duration, Instant};
1321
1322        let store = Arc::new(DocumentStore::new());
1323        let urls: Vec<Url> = (0..8).map(|i| uri(&format!("/f{i}.php"))).collect();
1324        for (i, u) in urls.iter().enumerate() {
1325            open(&store, u.clone(), format!("<?php\nclass C{i} {{}}"));
1326        }
1327
1328        let deadline = Instant::now() + Duration::from_millis(400);
1329        let mut handles = Vec::new();
1330
1331        // Writer thread: keep bumping every file's text.
1332        {
1333            let store = Arc::clone(&store);
1334            let urls = urls.clone();
1335            handles.push(thread::spawn(move || {
1336                let mut rev = 0u32;
1337                while Instant::now() < deadline {
1338                    for u in &urls {
1339                        let text = format!("<?php\nclass C{{}}\n// rev {rev}");
1340                        store.mirror_text(u, &text);
1341                    }
1342                    rev += 1;
1343                }
1344            }));
1345        }
1346
1347        // Reader threads: hammer the salsa accessors.
1348        for _ in 0..4 {
1349            let store = Arc::clone(&store);
1350            let urls = urls.clone();
1351            handles.push(thread::spawn(move || {
1352                while Instant::now() < deadline {
1353                    for u in &urls {
1354                        let _ = store.get_doc_salsa(u);
1355                        let _ = store.get_index_salsa(u);
1356                    }
1357                    // Post mir 0.22: codebase + refs live in the session,
1358                    // not salsa. Concurrent-read smoke is limited to the
1359                    // remaining salsa surface (parsed_doc, file_index).
1360                }
1361            }));
1362        }
1363
1364        for h in handles {
1365            h.join().expect("no panic under concurrent read/write");
1366        }
1367    }
1368
1369    /// PSR-4 lazy-loading: `get_semantic_issues_salsa` must not emit
1370    /// `UndefinedClass` for a class that is PSR-4-resolvable on disk, even
1371    /// when the dependency file is not yet in `source_files`.
1372    #[test]
1373    fn psr4_lazy_load_suppresses_undefined_class() {
1374        let tmp = tempfile::tempdir().unwrap();
1375
1376        // Write Entity.php to disk (not mirrored into the store).
1377        std::fs::create_dir_all(tmp.path().join("src/Model")).unwrap();
1378        std::fs::write(
1379            tmp.path().join("src/Model/Entity.php"),
1380            "<?php\nnamespace App\\Model;\nclass Entity {}\n",
1381        )
1382        .unwrap();
1383
1384        // Write composer.json so Psr4Map::load can build the map.
1385        std::fs::write(
1386            tmp.path().join("composer.json"),
1387            r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
1388        )
1389        .unwrap();
1390
1391        let store = DocumentStore::new();
1392
1393        // Inject a PSR-4 map pointing at the tmp dir.
1394        store
1395            .psr4
1396            .store(Arc::new(crate::lang::autoload::Psr4Map::load(tmp.path())));
1397
1398        // Mirror the consuming file (Entity not yet in source_files).
1399        // Uses Entity as a parameter type hint — the analyzer resolves these
1400        // through use statements, so this exercises the full PSR-4 lazy-load path.
1401        let handler_url = Url::from_file_path(tmp.path().join("src/Service/Handler.php")).unwrap();
1402        store.mirror_text(
1403            &handler_url,
1404            "<?php\nnamespace App\\Service;\nuse App\\Model\\Entity;\nfunction handle(Entity $e): Entity { return $e; }\n",
1405        );
1406
1407        let issues = store.get_semantic_issues_salsa(&handler_url).unwrap();
1408        let undef: Vec<_> = issues
1409            .iter()
1410            .filter(|i| matches!(i.kind, mir_issues::IssueKind::UndefinedClass { .. }))
1411            .collect();
1412        assert!(
1413            undef.is_empty(),
1414            "PSR-4 lazy-loading must prevent UndefinedClass for App\\Model\\Entity; got: {undef:?}"
1415        );
1416    }
1417
1418    /// Issue #191 regression: workspace-wide scans (find-references, rename,
1419    /// call-hierarchy) must not re-parse closed/indexed files on repeated
1420    /// invocations. Once a file's `ParsedDoc` has been produced, subsequent
1421    /// `all_docs_for_scan()` calls must hit the cache and return the same
1422    /// `Arc<ParsedDoc>` (pointer equality), proving no re-parse occurred.
1423    ///
1424    /// The cache layers protecting this are:
1425    ///   1. `parsed_cache` (cap [`PARSED_CACHE_CAP`]) — read-through, validated
1426    ///      via `Arc::ptr_eq` on the text Arc.
1427    ///   2. salsa `parsed_doc` memo (`lru = 2048`) — second line of defense
1428    ///      when `parsed_cache` evicts.
1429    ///
1430    /// Together they keep every workspace-scan op O(N) memo lookups, never
1431    /// O(N) parses, for any workspace whose file count fits the cap.
1432    #[test]
1433    fn all_docs_for_scan_does_not_reparse_indexed_files() {
1434        let store = DocumentStore::new();
1435        const N: usize = 50;
1436        for i in 0..N {
1437            let u = uri(&format!("/scan/file{i}.php"));
1438            store.ingest(u, &format!("<?php\nclass C{i} {{}}\nfunction f{i}() {{}}"));
1439        }
1440
1441        let first: Vec<_> = store.all_docs_for_scan();
1442        let second: Vec<_> = store.all_docs_for_scan();
1443        assert_eq!(first.len(), N);
1444        assert_eq!(second.len(), N);
1445
1446        let by_url_first: std::collections::HashMap<Url, Arc<ParsedDoc>> =
1447            first.into_iter().collect();
1448        for (u, doc2) in second {
1449            let doc1 = by_url_first
1450                .get(&u)
1451                .expect("second scan returned a URL the first didn't");
1452            assert!(
1453                Arc::ptr_eq(doc1, &doc2),
1454                "{u} re-parsed across all_docs_for_scan calls — \
1455                 cache (parsed_cache + salsa parsed_doc memo) failed to hit"
1456            );
1457        }
1458
1459        // Editing one file's text must invalidate just that file's entry,
1460        // not the rest. This locks in self-eviction via Arc::ptr_eq on text.
1461        let edited_url = uri("/scan/file0.php");
1462        let pre_edit = store.get_doc_salsa(&edited_url).unwrap();
1463        store.ingest(edited_url.clone(), "<?php\nclass C0Edited {}");
1464        let post_edit = store.get_doc_salsa(&edited_url).unwrap();
1465        assert!(
1466            !Arc::ptr_eq(&pre_edit, &post_edit),
1467            "edited file must produce a fresh ParsedDoc"
1468        );
1469        for i in 1..N {
1470            let u = uri(&format!("/scan/file{i}.php"));
1471            let original = by_url_first.get(&u).unwrap();
1472            let after = store.get_doc_salsa(&u).unwrap();
1473            assert!(
1474                Arc::ptr_eq(original, &after),
1475                "{u} should not have re-parsed because of an unrelated edit"
1476            );
1477        }
1478    }
1479
1480    /// Incremental analysis cache: a body-only edit to file A (no declaration
1481    /// changes) must not bump `decl_version`, so file B's cached analysis
1482    /// survives. A declaration edit MUST bump the version so B's entry goes
1483    /// stale.
1484    #[test]
1485    fn body_only_edit_does_not_invalidate_sibling_analysis_cache() {
1486        let store = DocumentStore::new();
1487        let ua = uri("/ic_a.php");
1488        let ub = uri("/ic_b.php");
1489
1490        // Analyze both files to establish their fingerprints.
1491        open(
1492            &store,
1493            ua.clone(),
1494            "<?php\nfunction a() { return 1; }".to_string(),
1495        );
1496        open(
1497            &store,
1498            ub.clone(),
1499            "<?php\nfunction b() { return 2; }".to_string(),
1500        );
1501        let _ = store.cached_analysis(&ua).unwrap();
1502        let analysis_b_first = store.cached_analysis(&ub).unwrap();
1503        let ver_after_warm = store.caches.decl_version();
1504
1505        // Body-only edit to A: same function name, different body → FileIndex unchanged.
1506        store.mirror_text(&ua, "<?php\nfunction a() { return 999; }");
1507        let _ = store.cached_analysis(&ua);
1508        let ver_after_body_edit = store.caches.decl_version();
1509        assert_eq!(
1510            ver_after_warm, ver_after_body_edit,
1511            "body-only edit must not bump decl_version"
1512        );
1513
1514        // B's cached entry should still be valid (ptr-eq source AND same version).
1515        let analysis_b_second = store.cached_analysis_if_fresh(&ub);
1516        assert!(
1517            analysis_b_second.is_some(),
1518            "B's analysis should hit cache after body-only edit to A"
1519        );
1520        assert!(
1521            Arc::ptr_eq(&analysis_b_first, &analysis_b_second.unwrap()),
1522            "B's analysis should be the identical Arc (no re-analysis)"
1523        );
1524
1525        // Declaration edit to A: rename the function → FileIndex changes.
1526        store.mirror_text(&ua, "<?php\nfunction a_renamed() { return 999; }");
1527        let _ = store.cached_analysis(&ua);
1528        let ver_after_decl_edit = store.caches.decl_version();
1529        assert!(
1530            ver_after_decl_edit > ver_after_body_edit,
1531            "declaration edit must bump decl_version (was {ver_after_body_edit}, now {ver_after_decl_edit})"
1532        );
1533
1534        // B's entry is now stale — cached_analysis_if_fresh must return None.
1535        let analysis_b_stale = store.cached_analysis_if_fresh(&ub);
1536        assert!(
1537            analysis_b_stale.is_none(),
1538            "B's analysis should be stale after A's declaration changed"
1539        );
1540    }
1541
1542    /// snapshot_query must complete without panic when a concurrent writer
1543    /// races the snapshot. The single-retry-then-lock logic should handle this
1544    /// correctly: the lock-held fallback guarantees progress.
1545    #[test]
1546    fn snapshot_query_survives_concurrent_writes() {
1547        use std::sync::Arc;
1548        use std::thread;
1549        use std::time::{Duration, Instant};
1550
1551        let store = Arc::new(DocumentStore::new());
1552        let u = uri("/sq_test.php");
1553        open(
1554            &store,
1555            u.clone(),
1556            "<?php\nfunction f(): int { return 1; }".to_string(),
1557        );
1558
1559        let deadline = Instant::now() + Duration::from_millis(200);
1560        let mut handles = Vec::new();
1561
1562        // Writer: keep bumping the file text to trigger salsa::Cancelled.
1563        {
1564            let store = Arc::clone(&store);
1565            let u = u.clone();
1566            handles.push(thread::spawn(move || {
1567                let mut rev = 0u32;
1568                while Instant::now() < deadline {
1569                    store.mirror_text(&u, &format!("<?php\nfunction f(): int {{ return {rev}; }}"));
1570                    rev += 1;
1571                }
1572            }));
1573        }
1574
1575        // Reader: hammer snapshot_query via get_doc_salsa.
1576        for _ in 0..4 {
1577            let store = Arc::clone(&store);
1578            let u = u.clone();
1579            handles.push(thread::spawn(move || {
1580                while Instant::now() < deadline {
1581                    let _ = store.get_doc_salsa(&u);
1582                    let _ = store.get_index_salsa(&u);
1583                }
1584            }));
1585        }
1586
1587        for h in handles {
1588            h.join()
1589                .expect("no panic in snapshot_query under concurrent writes");
1590        }
1591    }
1592
1593    /// When a sibling file's declaration changes (bumping decl_version), the
1594    /// owned_program_cache entry for the unchanged file B should be reused
1595    /// rather than deep-cloned again. We verify this via Arc pointer equality.
1596    #[test]
1597    fn owned_program_cache_reused_after_sibling_declaration_change() {
1598        let store = DocumentStore::new();
1599        let ua = uri("/prog_a.php");
1600        let ub = uri("/prog_b.php");
1601
1602        open(
1603            &store,
1604            ua.clone(),
1605            "<?php\nfunction alpha(): void {}".to_string(),
1606        );
1607        open(
1608            &store,
1609            ub.clone(),
1610            "<?php\nfunction beta(): void {}".to_string(),
1611        );
1612
1613        // Warm both analysis caches and populate owned_program_cache for both files.
1614        let _ = store.cached_analysis(&ua);
1615        let _ = store.cached_analysis(&ub);
1616
1617        // Capture B's owned_program Arc before the sibling edit.
1618        let prog_b_first = store
1619            .caches
1620            .owned_program_cache
1621            .get(&ub)
1622            .map(|e| Arc::clone(&e.1))
1623            .expect("B's owned_program should be cached after first analysis");
1624
1625        // Declaration change to A: bumps decl_version, invalidating all cached_analysis entries.
1626        store.mirror_text(&ua, "<?php\nfunction alpha_renamed(): void {}");
1627        // Re-analyze A to trigger the decl_version bump.
1628        let _ = store.cached_analysis(&ua);
1629
1630        // Now re-analyze B. Its source is unchanged, so owned_program_cache must hit.
1631        let _ = store.cached_analysis(&ub);
1632
1633        let prog_b_second = store
1634            .caches
1635            .owned_program_cache
1636            .get(&ub)
1637            .map(|e| Arc::clone(&e.1))
1638            .expect("B's owned_program should still be cached after sibling edit");
1639
1640        assert!(
1641            Arc::ptr_eq(&prog_b_first, &prog_b_second),
1642            "B's owned_program Arc should be identical (cache hit) after sibling declaration change"
1643        );
1644    }
1645}