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