Skip to main content

php_lsp/document/
document_store.rs

1use std::collections::HashSet;
2use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
3use std::sync::{Arc, Mutex, OnceLock};
4
5use arc_swap::{ArcSwap, ArcSwapOption};
6
7use dashmap::{DashMap, DashSet};
8use salsa::Setter;
9use tower_lsp_server::ls_types::{SemanticToken, Uri};
10
11use crate::db::mir_queries::{LspWorkspace, LspWsFile};
12use crate::document::ast::ParsedDoc;
13use crate::document::cache_registry::CacheRegistry;
14use crate::index::file_index::FileIndex;
15use crate::lang::autoload::Psr4Map;
16
17pub struct DocumentStore {
18    /// Per-file caches with unified eviction logic. See [`CacheRegistry`].
19    caches: CacheRegistry,
20
21    // ── Salsa-input storage ────────────────────────────────────────────────
22    // Phase E4: `DocumentStore` is now a pure salsa-input wrapper. Open-file
23    // state (live text, version token, parse-diagnostics cache) lives on
24    // `Backend` in its `open_files` map; the set of files tracked by salsa
25    // is exactly `source_files.keys()`.
26    /// `Uri -> LspWsFile` lookup on the shared mir db. Each `LspWsFile` pairs
27    /// mir's `SourceFile` (the shared text input) with the optional warm-start
28    /// `cached_index`. Created/updated through the `AnalysisSession`'s
29    /// `with_db_mut` under the db write lock; reads run on cheap snapshot clones.
30    lsp_ws_files: DashMap<Uri, LspWsFile>,
31    /// URIs that have been removed. Re-opening a deleted URI un-deletes it here
32    /// and reuses the existing `LspWsFile` handle.
33    deleted_uris: DashSet<Uri>,
34    /// Set to `true` when the set of tracked files changes (add or remove).
35    /// `sync_workspace_files` skips the collect/sort/compare path when this
36    /// is `false`, avoiding a lock acquisition on every LSP request.
37    workspace_files_dirty: AtomicBool,
38    /// Cached result of `workspace_file_paths()`. `None` means stale/never
39    /// built — cleared by `mark_workspace_files_dirty` rather than shared
40    /// with `workspace_files_dirty` itself, since `sync_workspace_files`
41    /// destructively consumes that flag (`swap(false, ..)`) and a second
42    /// consumer reading it would race for the same signal.
43    workspace_file_paths_cache: ArcSwapOption<Vec<Arc<str>>>,
44    /// Diagnostic counter: incremented once per call to
45    /// `resolve_reachability_queries`, i.e. once per actual workspace scan
46    /// pass — regardless of how many queries are batched into that pass.
47    /// Lets tests assert that batching N symbols' reachability narrowing
48    /// (e.g. all of one file's code lenses) pays one scan, not N.
49    reachability_scan_passes: AtomicU64,
50    /// Per-URI lock so concurrent callers computing the same uncached file's
51    /// analysis (`cached_analysis_cancellable`) serialize onto one
52    /// computation instead of each redoing the full mir pass — e.g. `did_open`'s
53    /// diagnostics trigger racing a fast-following hover on a large,
54    /// just-opened file. Entries are never removed: bounded by the number of
55    /// distinct files ever analyzed, the same order of magnitude as
56    /// `lsp_ws_files`.
57    analysis_inflight: DashMap<Uri, Arc<Mutex<()>>>,
58    /// Diagnostic counter: incremented once per real `FileAnalyzer::analyze`
59    /// run in `cached_analysis_cancellable` (never on a cache hit). Lets
60    /// tests assert that concurrent callers racing for the same uncached
61    /// file's analysis (see `analysis_inflight`) compute it once, not once
62    /// per caller.
63    analysis_compute_count: AtomicU64,
64    /// `LspWorkspace` salsa input on the shared mir db: the project-file scoping
65    /// set aggregated by `workspace_index`. Created lazily on first sync (the db
66    /// is owned by the lazily-built `AnalysisSession`).
67    lsp_workspace: Mutex<Option<LspWorkspace>>,
68    /// Shared PSR-4 namespace-to-path map. Shared with `Backend` via `Arc`
69    /// so updates from `initialized` (when composer.json is loaded) are
70    /// visible here without any additional wiring. `ArcSwap` makes reads
71    /// lock-free — a poisoned guard can no longer crash a request handler.
72    psr4: Arc<ArcSwap<Psr4Map>>,
73    /// `(target PHP version, cached AnalysisSession built for that version)`.
74    /// One lock for both — `workspace_php_version()` used to read a separate
75    /// `Mutex<PhpVersion>`, so fetching the version and then building/fetching
76    /// the session used to take two locks per call; `current_analysis_session()`
77    /// takes one. `None` only before the first build, or right after
78    /// `set_php_version` invalidates it.
79    /// mir-analyzer's `AnalysisSession` owns the workspace MirDb, runs Pass-2
80    /// analysis, and lazy-loads dependencies via PSR-4.
81    analysis_session: Mutex<(
82        mir_analyzer::PhpVersion,
83        Option<Arc<mir_analyzer::AnalysisSession>>,
84    )>,
85    /// Cache directory shared with the workspace file-index cache. When set,
86    /// new `AnalysisSession`s are built with `with_cache_dir` so that stub
87    /// parsing results survive server restarts.
88    session_cache_dir: OnceLock<std::path::PathBuf>,
89    /// User-supplied stub directories (`initializationOptions.stubDirs` /
90    /// `.php-lsp.json`). When set, new `AnalysisSession`s are built with
91    /// `with_user_stubs` so their `.php` files are registered as the
92    /// highest-precedence symbol source alongside the bundled built-ins.
93    user_stub_dirs: OnceLock<Vec<std::path::PathBuf>>,
94    /// URIs of autoload.files entries from composer.json. These define global
95    /// helper functions (e.g. tap, class_uses_recursive in Laravel) that are
96    /// not discoverable by namespace walk. Pre-ingested into the AnalysisSession
97    /// before each file analysis so mir doesn't emit false UndefinedFunction.
98    autoload_uris: std::sync::RwLock<Vec<Uri>>,
99    /// Set once the workspace scan's reference-index phase finishes, i.e. the
100    /// `subtypes_of` map in `workspace_index` is complete. Visibility-derived
101    /// scope narrowing that relies on the full subtype set (protected methods)
102    /// only applies once this is `true`; before then it falls back to the full
103    /// workspace scope so references are never under-reported.
104    index_ready: AtomicBool,
105    /// Monotonically increasing counter bumped on every actual text write
106    /// (including file deletions). Long-running read operations (e.g.
107    /// `session_references_to`) capture this value before starting and
108    /// cancel themselves if it advances — avoiding stale results and
109    /// unbounded retry loops after concurrent edits.
110    write_revision: AtomicU64,
111    /// Cancel token for the in-flight dependent-diagnostics sweep. A newer
112    /// edit's sweep cancels the previous one via [`Self::begin_reanalyze`], so
113    /// fast typing preempts stale workspace re-analysis rather than queueing
114    /// behind it.
115    reanalyze_cancel: Mutex<mir_analyzer::IndexCancel>,
116    /// Cancel token for the in-flight analysis warm sweep
117    /// ([`Self::warm_analysis_sweep`]); a newer sweep cancels the previous one
118    /// via [`Self::begin_warm_sweep`].
119    warm_sweep_cancel: Mutex<mir_analyzer::IndexCancel>,
120    /// Warm sweeps that ran to completion (not cancelled). Observability only,
121    /// surfaced via `$/php-lsp/debugStats` so benches/tests can await warmth.
122    warm_sweeps_completed: AtomicU64,
123    /// Calls to [`Self::warm_start_indexes`] that actually replayed at least
124    /// one file (not the empty-workspace no-op). Observability only, surfaced
125    /// via `$/php-lsp/debugStats` so tests can await a runtime-added folder's
126    /// warm-start replay instead of guessing a fixed delay.
127    warm_start_replays_completed: AtomicU64,
128    /// Count of in-flight interactive reads (requests the user is waiting on).
129    /// The workspace scan yields at file boundaries while this is non-zero, so
130    /// its per-file salsa writes can't starve a request's snapshot into an
131    /// endless `Cancelled` retry loop. Advisory only — relaxed ordering.
132    interactive_reads: AtomicU64,
133}
134
135/// RAII handle marking an interactive read in flight; see
136/// [`DocumentStore::interactive_read_guard`].
137pub struct InteractiveReadGuard<'a>(&'a AtomicU64);
138
139impl Drop for InteractiveReadGuard<'_> {
140    fn drop(&mut self) {
141        self.0.fetch_sub(1, Ordering::Relaxed);
142    }
143}
144
145impl Default for DocumentStore {
146    fn default() -> Self {
147        Self::new()
148    }
149}
150
151/// Analysis threads need big stacks: deep type-inference recursion on
152/// pathological files overflows rayon's 2 MB default, and salsa 0.28's
153/// `DependencyGraph::update_transferred_edges` recurses per transferred
154/// dependent under contended parallel queries — observed blowing 16 MB on
155/// the Laravel fixture (64 MB measured clean over repeated runs; the pages
156/// are reserved, not committed). Size the global pool before any parallel
157/// analysis runs; build_global fails harmlessly if a pool already exists.
158/// Benches/tests driving `AnalysisSession` without a `DocumentStore` must
159/// call this themselves — rayon otherwise lazily builds the pool with
160/// default stacks on mir's first `par_iter`.
161pub fn ensure_rayon_worker_stacks() {
162    static RAYON_STACK: OnceLock<()> = OnceLock::new();
163    RAYON_STACK.get_or_init(|| {
164        let _ = rayon::ThreadPoolBuilder::new()
165            .stack_size(64 * 1024 * 1024)
166            .build_global();
167    });
168}
169
170impl DocumentStore {
171    pub fn new() -> Self {
172        ensure_rayon_worker_stacks();
173        DocumentStore {
174            caches: CacheRegistry::new(),
175            lsp_ws_files: DashMap::new(),
176            deleted_uris: DashSet::new(),
177            workspace_files_dirty: AtomicBool::new(true),
178            workspace_file_paths_cache: ArcSwapOption::from(None),
179            reachability_scan_passes: AtomicU64::new(0),
180            analysis_inflight: DashMap::new(),
181            analysis_compute_count: AtomicU64::new(0),
182            lsp_workspace: Mutex::new(None),
183            psr4: Arc::new(ArcSwap::from_pointee(Psr4Map::empty())),
184            analysis_session: Mutex::new((mir_analyzer::PhpVersion::LATEST, None)),
185            session_cache_dir: OnceLock::new(),
186            user_stub_dirs: OnceLock::new(),
187            autoload_uris: std::sync::RwLock::new(Vec::new()),
188            index_ready: AtomicBool::new(false),
189            write_revision: AtomicU64::new(0),
190            reanalyze_cancel: Mutex::new(mir_analyzer::IndexCancel::new()),
191            warm_sweep_cancel: Mutex::new(mir_analyzer::IndexCancel::new()),
192            warm_sweeps_completed: AtomicU64::new(0),
193            warm_start_replays_completed: AtomicU64::new(0),
194            interactive_reads: AtomicU64::new(0),
195        }
196    }
197
198    /// Mark an interactive read (a request the user is waiting on) as in
199    /// flight for the guard's lifetime. Background bulk writers poll
200    /// [`Self::yield_to_interactive_reads`] between files and pause while any
201    /// guard is live, giving the read a write-free window to complete.
202    pub fn interactive_read_guard(&self) -> InteractiveReadGuard<'_> {
203        self.interactive_reads.fetch_add(1, Ordering::Relaxed);
204        InteractiveReadGuard(&self.interactive_reads)
205    }
206
207    /// Take an interactive-read guard, wait (bounded ~50 ms) for in-flight
208    /// background writes to go quiet, and return the settled write revision.
209    ///
210    /// For user-facing sweeps that abort when [`Self::write_rev`] advances:
211    /// snapshotting the revision this way ensures only genuine user edits —
212    /// not the background scan the guard just paused — void the sweep.
213    /// Sleeps; call from a blocking thread only.
214    pub fn settled_write_rev_guard(&self) -> (InteractiveReadGuard<'_>, u64) {
215        let guard = self.interactive_read_guard();
216        // Post-scan there is no background write storm to settle; skip the
217        // sleep so steady-state requests pay only the guard's atomic inc.
218        if !self.is_index_ready() {
219            let deadline = std::time::Instant::now() + std::time::Duration::from_millis(50);
220            let mut rev = self.write_rev();
221            loop {
222                std::thread::sleep(std::time::Duration::from_millis(2));
223                let now = self.write_rev();
224                let quiet = now == rev;
225                rev = now;
226                if quiet || std::time::Instant::now() >= deadline {
227                    break;
228                }
229            }
230        }
231        (guard, self.write_rev())
232    }
233
234    /// Pause the calling (background-writer) thread while interactive reads
235    /// are in flight, up to 500 ms per call. Bounded so a wedged reader can
236    /// only slow the scan, never stop it; callers invoke this per file, so
237    /// sustained interactive traffic keeps yielding at each boundary.
238    pub fn yield_to_interactive_reads(&self) {
239        let mut waited = std::time::Duration::ZERO;
240        const STEP: std::time::Duration = std::time::Duration::from_millis(2);
241        const MAX: std::time::Duration = std::time::Duration::from_millis(500);
242        while self.interactive_reads.load(Ordering::Relaxed) > 0 && waited < MAX {
243            std::thread::sleep(STEP);
244            waited += STEP;
245        }
246    }
247
248    /// Install a fresh cancel token for a dependent-diagnostics sweep,
249    /// cancelling whichever sweep was previously in flight. Pass the returned
250    /// token to `reanalyze_dependents_cancellable`; when a newer edit calls
251    /// this again the old token flips and that older sweep stops at its next
252    /// file boundary instead of racing this one to publish stale diagnostics.
253    pub fn begin_reanalyze(&self) -> mir_analyzer::IndexCancel {
254        let fresh = mir_analyzer::IndexCancel::new();
255        let mut guard = self.reanalyze_cancel.lock().unwrap();
256        guard.cancel();
257        *guard = fresh.clone();
258        fresh
259    }
260
261    /// Install a fresh cancel token for an analysis warm sweep, cancelling any
262    /// sweep previously in flight. Mirrors [`Self::begin_reanalyze`] but on a
263    /// separate slot: edit-triggered diagnostic sweeps and background warming
264    /// must not cancel each other.
265    pub fn begin_warm_sweep(&self) -> mir_analyzer::IndexCancel {
266        let fresh = mir_analyzer::IndexCancel::new();
267        let mut guard = self.warm_sweep_cancel.lock().unwrap();
268        guard.cancel();
269        *guard = fresh.clone();
270        fresh
271    }
272
273    /// Drive every workspace file through mir's memoized `analyze_file` query
274    /// so later reference/rename requests are memo hits instead of cold
275    /// analyses (~20 ms per file on real code — a few hundred candidate files
276    /// makes an interactive request take seconds).
277    ///
278    /// `priority` files (and the files declaring the classes they reference —
279    /// the user's working set) are warmed first, so requests against open
280    /// files go warm within the sweep's first seconds even on workspaces
281    /// where the full sweep takes much longer.
282    ///
283    /// Background-priority: processes files in small chunks, yielding to
284    /// interactive reads at each boundary, and stops at the next boundary once
285    /// `cancel` flips. Re-running after edits is cheap — unaffected files
286    /// revalidate via salsa without re-analysis. Blocking; call from
287    /// `spawn_blocking`.
288    pub fn warm_analysis_sweep(&self, priority: &[Uri], cancel: &mir_analyzer::IndexCancel) {
289        // Dedicated thread with a generous stack: the serial prepare phase and
290        // priority resolution recurse over real-world ASTs whose depth can
291        // exceed the default 2 MiB thread stack (debug builds especially).
292        // One pathological file must not abort the whole server process.
293        std::thread::scope(|s| {
294            let _ = std::thread::Builder::new()
295                .name("php-lsp-warm-sweep".into())
296                .stack_size(64 * 1024 * 1024)
297                .spawn_scoped(s, || self.warm_analysis_sweep_inner(priority, cancel))
298                .map(|h| h.join());
299        });
300    }
301
302    /// Build the sweep's ordered file list: `priority` files and the files
303    /// declaring the classes they reference (see [`Self::sweep_priority_files`])
304    /// first, then every other tracked file — except vendor files, which the
305    /// ambient sweep skips regardless of `indexVendor`. A vendor file that
306    /// ends up in `priority` (opened directly, or referenced by an open
307    /// project file) is unaffected: the exclusion only applies to the
308    /// untargeted bulk-sweep tail, not the front of the queue.
309    fn sweep_candidate_files(&self, priority: &[Uri]) -> Vec<Arc<str>> {
310        let front: Vec<Arc<str>> = self.sweep_priority_files(priority);
311        let front_set: HashSet<&str> = front.iter().map(|f| f.as_ref()).collect();
312        front
313            .iter()
314            .cloned()
315            .chain(
316                self.lsp_ws_files
317                    .iter()
318                    .filter(|e| !self.deleted_uris.contains(e.key()))
319                    .filter(|e| !is_vendor_uri(e.key()))
320                    .map(|e| Arc::<str>::from(e.key().as_str()))
321                    .filter(|f| !front_set.contains(f.as_ref())),
322            )
323            .collect()
324    }
325
326    fn warm_analysis_sweep_inner(&self, priority: &[Uri], cancel: &mir_analyzer::IndexCancel) {
327        const CHUNK: usize = 32;
328        let files = self.sweep_candidate_files(priority);
329        let session = self.current_analysis_session();
330        let mut all_chunks_settled = true;
331        for chunk in files.chunks(CHUNK) {
332            if cancel.is_cancelled() {
333                return;
334            }
335            self.yield_to_interactive_reads();
336            // A concurrent write (e.g. another file being ingested) can land
337            // mid-chunk and cancel the analysis snapshot. Retry the same
338            // chunk immediately — `cancel` (not just the transient snapshot
339            // cancellation) is the authoritative stop signal, so a retry loop
340            // here only spins while an unrelated writer keeps landing, and
341            // exits promptly once `cancel` itself flips (a real edit
342            // superseding this sweep). Without the retry, a chunk silently
343            // skipped here has no guaranteed follow-up: `warm_sweeps_completed`
344            // must not count this sweep as covering files it never actually
345            // analyzed, or a caller polling that counter (e.g. a test waiting
346            // for the reference index to be fully warm) can observe "done"
347            // while some files' postings were never written.
348            loop {
349                if cancel.is_cancelled() {
350                    all_chunks_settled = false;
351                    break;
352                }
353                if salsa::Cancelled::catch(std::panic::AssertUnwindSafe(|| {
354                    session.reanalyze_files_cancellable(chunk, cancel)
355                }))
356                .is_ok()
357                {
358                    break;
359                }
360            }
361        }
362        if !cancel.is_cancelled() && all_chunks_settled {
363            // The sweep staged each analyzed file's reference postings into
364            // mir's AnalysisCache; persist them so the next launch's
365            // `warm_start_indexes` replays references index-warm. Flush
366            // before publishing completion — observers of the counter may
367            // rely on the postings being on disk.
368            session.flush_analysis_cache();
369            self.warm_sweeps_completed.fetch_add(1, Ordering::Relaxed);
370        }
371    }
372
373    /// Persist mir's staged analysis-cache entries (reference postings) to
374    /// disk. No-op when nothing changed since the last flush.
375    pub fn flush_analysis_cache(&self) {
376        self.current_analysis_session().flush_analysis_cache();
377    }
378
379    /// Warm sweeps that ran to completion. See `$/php-lsp/debugStats`.
380    pub fn warm_sweeps_completed(&self) -> u64 {
381        self.warm_sweeps_completed.load(Ordering::Relaxed)
382    }
383
384    pub fn warm_start_replays_completed(&self) -> u64 {
385        self.warm_start_replays_completed.load(Ordering::Relaxed)
386    }
387
388    /// The sweep's front of the queue: `priority` files themselves plus the
389    /// files declaring the classes they reference (type hints, `use` imports,
390    /// `new`, `extends`, …) — the set a request against an open file most
391    /// likely touches. Resolution goes through the memoized workspace index,
392    /// matching by FQN with a short-name fallback.
393    fn sweep_priority_files(&self, priority: &[Uri]) -> Vec<Arc<str>> {
394        if priority.is_empty() {
395            return Vec::new();
396        }
397        let ws = self.get_workspace_index_salsa();
398        let mut out: Vec<Arc<str>> = Vec::new();
399        let mut seen: HashSet<Arc<str>> = HashSet::new();
400        let push = |uri: &Uri, out: &mut Vec<Arc<str>>, seen: &mut HashSet<Arc<str>>| {
401            let f: Arc<str> = Arc::from(uri.as_str());
402            if seen.insert(Arc::clone(&f)) {
403                out.push(f);
404            }
405        };
406        for uri in priority {
407            push(uri, &mut out, &mut seen);
408        }
409        for uri in priority {
410            let Some(doc) = self.get_doc_salsa(uri) else {
411                continue;
412            };
413            for fqn in crate::navigation::references::collect_referenced_class_fqns(&doc) {
414                let short = crate::text::fqn_short_name(&fqn);
415                let Some(refs) = ws.classes_by_name.get(short) else {
416                    continue;
417                };
418                for &r in refs {
419                    if let Some((decl_uri, cls)) = ws.at(r)
420                        && cls.fqn.trim_start_matches('\\') == fqn
421                    {
422                        push(decl_uri, &mut out, &mut seen);
423                    }
424                }
425            }
426        }
427        out
428    }
429
430    /// Mark the workspace reference index as fully built. Called by the scan
431    /// when its final phase completes (alongside `$/php-lsp/indexReady`).
432    pub fn mark_index_ready(&self) {
433        self.index_ready.store(true, Ordering::Release);
434    }
435
436    /// Whether the workspace reference index has finished building.
437    pub fn is_index_ready(&self) -> bool {
438        self.index_ready.load(Ordering::Acquire)
439    }
440
441    /// Snapshot of the write-revision counter. Long-running reads should
442    /// capture this before starting and pass it to cancellable operations;
443    /// if the counter advances, those operations abort and return empty rather
444    /// than looping indefinitely against a newly-invalidated database.
445    pub fn write_rev(&self) -> u64 {
446        self.write_revision.load(Ordering::Acquire)
447    }
448
449    /// Set the directory used to persist stub-parse and analysis results across
450    /// server restarts. Subsequent calls are silently ignored (`OnceLock`
451    /// semantics).
452    ///
453    /// If a session was already built — an early request (e.g. the editor's
454    /// restored-buffer `didOpen` racing `initialized`) builds one on demand —
455    /// it was pinned in-memory-only: nothing it computes is ever flushed, and
456    /// the next launch replays nothing, silently re-paying the whole cold
457    /// cost every session. Drop it so the next `analysis_session()` rebuilds
458    /// with the cache attached.
459    ///
460    /// Returns `true` when a session was dropped — the caller must then
461    /// re-mirror every currently open buffer (see `drop_session_scoped_state`):
462    /// the workspace scan won't do it, since it explicitly skips files that
463    /// are already open.
464    pub fn set_session_cache_dir(&self, dir: std::path::PathBuf) -> bool {
465        if self.session_cache_dir.set(dir).is_err() {
466            return false;
467        }
468        let dropped = self.analysis_session.lock().unwrap().1.take().is_some();
469        if dropped {
470            self.drop_session_scoped_state();
471        }
472        dropped
473    }
474
475    /// Set the user-supplied stub directories (`initializationOptions.stubDirs`).
476    /// Subsequent calls are silently ignored (`OnceLock` semantics).
477    ///
478    /// Same early-session race as [`Self::set_session_cache_dir`]: if a
479    /// session was already built without these directories, drop it so the
480    /// next `analysis_session()` rebuilds with `with_user_stubs` attached.
481    ///
482    /// Returns `true` when a session was dropped — same re-mirroring
483    /// obligation as `set_session_cache_dir`.
484    pub fn set_user_stub_dirs(&self, dirs: Vec<std::path::PathBuf>) -> bool {
485        if self.user_stub_dirs.set(dirs).is_err() {
486            return false;
487        }
488        let dropped = self.analysis_session.lock().unwrap().1.take().is_some();
489        if dropped {
490            self.drop_session_scoped_state();
491        }
492        dropped
493    }
494
495    /// Register URIs discovered from composer.json `autoload.files` entries.
496    /// These PHP files define global helper functions (e.g. `tap()` in Laravel)
497    /// that are not class-resolvable via PSR-4. Clears `analysis_cache` so the
498    /// next per-file analysis pre-ingests them into the AnalysisSession before
499    /// running mir's FileAnalyzer.
500    pub fn set_autoload_uris(&self, uris: Vec<Uri>) {
501        *self.autoload_uris.write().unwrap() = uris;
502        self.caches.evict_analysis_all();
503    }
504
505    /// Get or build the `AnalysisSession` for the given PHP version. Rebuilds
506    /// when the version changes (e.g. user flipped config). The session owns
507    /// the shared salsa db and AnalysisCache; lazy-loads vendor files via the
508    /// shared PSR-4 map. Built-in stubs are *not* pre-loaded: mir's
509    /// `prepare_ast_for_analysis` ingests the stubs each analyzed file
510    /// references, and [`crate::types::stub_members`] faults in single stub
511    /// files for builtin member/hover lookups. User stub directories (see
512    /// [`Self::set_user_stub_dirs`]) are configured on the builder here; mir's
513    /// `ingest_file` — called for every mirrored document — is what actually
514    /// registers them (and the built-in stubs) as `SourceFile` inputs.
515    pub fn analysis_session(
516        &self,
517        php_version: mir_analyzer::PhpVersion,
518    ) -> Arc<mir_analyzer::AnalysisSession> {
519        let mut guard = self.analysis_session.lock().unwrap();
520        if guard.0 == php_version
521            && let Some(session) = guard.1.as_ref()
522        {
523            return Arc::clone(session);
524        }
525        // Build a fresh session. Hand it the shared PSR-4 map so it can
526        // lazy-resolve `UndefinedClass` candidates without us having to mirror
527        // every vendor file upfront.
528        let resolver: Arc<dyn mir_analyzer::ClassResolver> = self.psr4.load_full();
529        // References/implementations are answered from mir's delta-maintained
530        // inverted indexes (posting lists + subtype edges), which the session
531        // keeps in step with every edit path.
532        let mut builder =
533            mir_analyzer::AnalysisSession::new(php_version).with_class_resolver(resolver);
534        // Must run before `with_cache_dir`: the cache epoch folds in a
535        // fingerprint of the user stub set, which `with_cache_dir` only picks
536        // up if it's already been configured.
537        if let Some(dirs) = self.user_stub_dirs.get()
538            && !dirs.is_empty()
539        {
540            builder = builder.with_user_stubs(Vec::new(), dirs.clone());
541        }
542        if let Some(dir) = self.session_cache_dir.get() {
543            builder = builder.with_cache_dir(dir);
544        }
545        let session = Arc::new(builder);
546        *guard = (php_version, Some(Arc::clone(&session)));
547        session
548    }
549
550    /// Get-or-build the `AnalysisSession` for the *current* workspace PHP
551    /// version — a single lock acquisition on the common (already-built)
552    /// path. Replaces `self.analysis_session(self.workspace_php_version())`,
553    /// which took two separate locks per call.
554    pub fn current_analysis_session(&self) -> Arc<mir_analyzer::AnalysisSession> {
555        let guard = self.analysis_session.lock().unwrap();
556        if let Some(session) = guard.1.as_ref() {
557            return Arc::clone(session);
558        }
559        let php_version = guard.0;
560        drop(guard);
561        self.analysis_session(php_version)
562    }
563
564    /// Current PHP version tracked by the workspace input.
565    pub fn workspace_php_version(&self) -> mir_analyzer::PhpVersion {
566        self.analysis_session.lock().unwrap().0
567    }
568
569    /// File URIs of all direct and transitive subclasses of `class_fqn`,
570    /// resolved via mir's inheritance graph. Returns an empty vec when the mir
571    /// session hasn't ingested the class yet (cold start, excluded paths).
572    ///
573    /// Used by `goto_implementation` and `subtypes` to scope their lookups to
574    /// the correct files, fixing aliased `extends` and FQN-qualified forms that
575    /// the raw-name `subtypes_of` map misses.
576    pub fn class_subtype_urls(&self, class_fqn: &str) -> Vec<tower_lsp_server::ls_types::Uri> {
577        let session = self.current_analysis_session();
578        session
579            .subtype_files(class_fqn)
580            .into_iter()
581            .filter_map(|p| p.parse::<Uri>().ok())
582            .collect()
583    }
584
585    /// Return the `Arc<ArcSwap<Psr4Map>>` so callers can share it.
586    /// `Backend` clones this arc at construction time so writes
587    /// (e.g. loading composer.json on `initialized`) are immediately visible
588    /// to PSR-4 resolution during analysis without extra plumbing.
589    pub fn psr4_arc(&self) -> Arc<ArcSwap<Psr4Map>> {
590        Arc::clone(&self.psr4)
591    }
592
593    /// Durability for a file's salsa input: vendor files never change within a
594    /// session, so salsa can skip re-validating their queries on user edits.
595    fn input_durability(uri: &Uri) -> salsa::Durability {
596        if uri.as_str().contains("/vendor/") {
597            salsa::Durability::HIGH
598        } else {
599            salsa::Durability::LOW
600        }
601    }
602
603    /// The `LspWsFile` handle for `uri`, if it is mirrored and not deleted.
604    fn lsp_ws_file(&self, uri: &Uri) -> Option<LspWsFile> {
605        if self.deleted_uris.contains(uri) {
606            return None;
607        }
608        self.lsp_ws_files.get(uri).map(|e| *e)
609    }
610
611    /// Take a cheap snapshot of the shared mir db and run `f` on it, retrying
612    /// on `salsa::Cancelled` (raised when a concurrent writer bumps the
613    /// revision). Mirrors [`Self::snapshot_query`] for the converged db.
614    ///
615    /// The loop terminates as soon as the writer pauses long enough for one
616    /// query to complete. Handlers that want an early exit under sustained
617    /// write pressure should pass a write-rev closure (see `code_lenses`) and
618    /// check it at coarser granularity rather than here.
619    fn snapshot_mir_query<R>(&self, f: impl Fn(&mir_analyzer::db::MirDbStorage) -> R) -> R {
620        use std::panic::AssertUnwindSafe;
621        let _interactive = self.interactive_read_guard();
622        let session = self.current_analysis_session();
623        // Each iteration's snapshot clone MUST drop before the next snapshot:
624        // a concurrent writer's salsa `set` holds the mir write lock and waits
625        // for outstanding db handles to drop, while the next `snapshot_db` needs
626        // the read lock — keeping the clone alive across the retry deadlocks.
627        loop {
628            let db = session.snapshot_db();
629            match salsa::Cancelled::catch(AssertUnwindSafe(|| f(&db))) {
630                Ok(r) => return r,
631                Err(_) => drop(db),
632            }
633        }
634    }
635
636    /// Bounded variant of [`Self::snapshot_mir_query`] for cosmetic,
637    /// cursor-triggered reads that must not spin under sustained write pressure.
638    /// Returns `None` if a concurrent writer cancels the query `attempts` times
639    /// in a row (the caller then falls back to a stale result). Same
640    /// drop-before-retry discipline as `snapshot_mir_query`.
641    fn try_snapshot_mir_query<R>(
642        &self,
643        attempts: usize,
644        f: impl Fn(&mir_analyzer::db::MirDbStorage) -> R,
645    ) -> Option<R> {
646        use std::panic::AssertUnwindSafe;
647        let _interactive = self.interactive_read_guard();
648        let session = self.current_analysis_session();
649        for _ in 0..attempts {
650            let db = session.snapshot_db();
651            match salsa::Cancelled::catch(AssertUnwindSafe(|| f(&db))) {
652                Ok(r) => return Some(r),
653                Err(_) => drop(db),
654            }
655        }
656        None
657    }
658
659    /// Mirror a file's current text into the salsa layer. Creates the
660    /// `FileText` input on first sight, otherwise updates `text` on the
661    /// existing input (bumping the salsa revision so downstream queries
662    /// invalidate).
663    pub fn mirror_text(&self, uri: &Uri, text: &str) {
664        // G2 fast path: compare against the lock-free text cache. When the
665        // new text byte-matches what we already mirrored, skip the host
666        // mutex entirely. Common during workspace scan + `did_open` for
667        // unchanged files, where most threads would otherwise serialise on
668        // `host.lock()` just to confirm a no-op.
669        if let Some(cached) = self.caches.text_cache.get(uri)
670            && **cached == *text
671            && !self.deleted_uris.contains(uri)
672            && self.lsp_ws_files.contains_key(uri)
673        {
674            return;
675        }
676        self.mirror_text_arc(uri, Arc::from(text))
677    }
678
679    /// Like [`mirror_text`] but takes an already-allocated `Arc<str>`.
680    ///
681    /// Callers that already hold an `Arc<str>` (e.g. `ingest_from_doc` reusing
682    /// `ParsedDoc::source_arc()`) use this to avoid a second allocation and to
683    /// ensure `text_cache` and `parsed_cache` hold the same Arc pointer —
684    /// enabling `Arc::ptr_eq` validation in `get_parsed_cached`.
685    pub fn mirror_text_arc(&self, uri: &Uri, text_arc: Arc<str>) {
686        let dur = Self::input_durability(uri);
687        let path: Arc<str> = Arc::from(uri.as_str());
688        let session = self.current_analysis_session();
689        if let Some(wf) = self.lsp_ws_files.get(uri).map(|e| *e) {
690            // A resurrected (previously-deleted) file changes
691            // `workspace_file_paths()`'s result set — invalidate its cache.
692            // The common case (an edit to an already-live file) hits `None`
693            // here and pays nothing extra.
694            if self.deleted_uris.remove(uri).is_some() {
695                self.workspace_file_paths_cache.store(None);
696            }
697            // Fast path: byte-identical text already mirrored — skip the write
698            // lock and the revision bump entirely.
699            if let Some(cached) = self.caches.text_cache.get(uri)
700                && **cached == *text_arc
701            {
702                return;
703            }
704            session.with_db_mut(|db| {
705                let sf = wf.source(db);
706                sf.set_text(db).with_durability(dur).to(text_arc.clone());
707                // Any text change invalidates a previously-seeded cached index.
708                // Only set when present to avoid a spurious second revision bump.
709                if wf.cached_index(db).is_some() {
710                    wf.set_cached_index(db).to(None);
711                }
712            });
713            self.caches.text_cache.insert(uri.clone(), text_arc);
714            // Evict only this file's analysis; cross-file invalidation is handled
715            // lazily in `cached_analysis` via the declaration fingerprint.
716            self.caches.evict_analysis(uri);
717            self.write_revision.fetch_add(1, Ordering::Release);
718        } else {
719            let wf = session.with_db_mut(|db| {
720                let sf = db.upsert_source_file_with_durability(path, text_arc.clone(), dur);
721                LspWsFile::new(db, sf, None)
722            });
723            self.lsp_ws_files.insert(uri.clone(), wf);
724            self.caches.text_cache.insert(uri.clone(), text_arc);
725            self.mark_workspace_files_dirty();
726            self.write_revision.fetch_add(1, Ordering::Release);
727        }
728    }
729
730    /// Return the `LspWsFile` handle for a URL, if active (not deleted).
731    #[cfg(test)]
732    pub fn source_file(&self, uri: &Uri) -> Option<LspWsFile> {
733        if self.deleted_uris.contains(uri) {
734            return None;
735        }
736        self.lsp_ws_files.get(uri).map(|e| *e)
737    }
738
739    /// Phase K2: pre-seed a `FileIndex` loaded from the on-disk cache onto
740    /// the `FileText` input for `uri`. The next `file_index` call for that
741    /// file returns the cached index directly, skipping parse + extract.
742    ///
743    /// Must be called **before** any `file_index(db, sf)` call for this file —
744    /// otherwise salsa has already memoized the fresh-parse result and setting
745    /// `cached_index` now would only bump the revision without using the cache.
746    /// In practice the workspace-scan path seeds immediately after `mirror_text`
747    /// and before any query runs.
748    ///
749    /// Returns `false` when `uri` was not mirrored (caller should mirror
750    /// first); returns `true` on success.
751    pub fn seed_cached_index(&self, uri: &Uri, index: Arc<FileIndex>) -> bool {
752        let Some(wf) = self.lsp_ws_file(uri) else {
753            return false;
754        };
755        let session = self.current_analysis_session();
756        session.with_db_mut(|db| wf.set_cached_index(db).to(Some(index)));
757        true
758    }
759
760    /// Evict the semantic-tokens cache for `uri`. Called by Backend when a
761    /// file is closed; diff-based tokens computed against the old revision
762    /// are no longer meaningful.
763    pub fn evict_token_cache(&self, uri: &Uri) {
764        self.caches.evict_tokens(uri);
765    }
766
767    /// Return the `FileIndex` for `uri` by running `file_index` on a salsa
768    /// snapshot.  Returns `None` when `uri` has not been mirrored.
769    ///
770    /// Test-only — production code uses the salsa query directly via
771    /// `snapshot_query`.
772    #[cfg(test)]
773    pub fn source_files_len(&self) -> usize {
774        self.lsp_ws_files.len()
775    }
776
777    #[cfg(test)]
778    pub fn snapshot_query_file_index(
779        &self,
780        uri: &Uri,
781    ) -> Option<crate::index::file_index::FileIndex> {
782        let wf = self.lsp_ws_file(uri)?;
783        Some(
784            self.snapshot_mir_query(move |db| {
785                (*crate::db::mir_queries::file_index(db, wf).0).clone()
786            }),
787        )
788    }
789
790    /// Register a file in the salsa layer without marking it open.
791    ///
792    /// Salsa's `parsed_doc` query parses lazily on first read; diagnostics
793    /// are populated by `did_open` when the editor actually opens the file.
794    pub fn ingest(&self, uri: Uri, text: &str) {
795        self.mirror_text(&uri, text);
796    }
797
798    /// Index a file using an already-parsed `ParsedDoc`, avoiding a second parse.
799    ///
800    /// Prefer this over [`ingest`] when the caller already has a `ParsedDoc` (e.g.
801    /// after running `DefinitionCollector` during workspace scan). Reuses the
802    /// `Arc<str>` already owned by `doc` so that `text_cache` and `SourceFile::text`
803    /// share the same pointer — enabling the `Arc::ptr_eq` fast path in
804    /// `get_parsed_cached` on the first subsequent salsa query, without an extra
805    /// `Arc::from(source)` allocation.
806    pub fn ingest_from_doc(&self, uri: Uri, doc: &ParsedDoc) {
807        self.mirror_text_arc(&uri, doc.source_arc());
808    }
809
810    pub fn remove(&self, uri: &Uri) {
811        self.caches.evict(uri);
812        // Mark the URI as deleted but keep the `lsp_ws_files` entry so the
813        // salsa `LspWsFile`/`SourceFile` handles remain alive. Re-opening the
814        // file reuses the same handle instead of calling `LspWsFile::new()`
815        // again, which would create a new orphaned salsa input on every
816        // delete-reopen cycle. `session.invalidate_file` below already frees
817        // the (potentially large) text this handle holds via mir's own
818        // `remove_source_file`, so keeping the handle costs only its own
819        // small footprint, not the file's content.
820        self.deleted_uris.insert(uri.clone());
821        self.mark_workspace_files_dirty();
822        // Sync workspace files so the deleted file is removed from the salsa
823        // `Workspace::files` list and won't appear in workspace symbols etc.
824        self.sync_workspace_files();
825        // Also evict the file from the `AnalysisSession`'s internal state so
826        // workspace symbol queries don't keep returning the deleted file's
827        // declarations. Cheap when the session hasn't ingested this file.
828        let guard = self.analysis_session.lock().unwrap();
829        if let Some(session) = guard.1.as_ref() {
830            session.invalidate_file(uri.as_str());
831            // `file_index` has no LRU cap (unlike `parsed_doc`/`symbol_map`),
832            // so without this it would keep holding this file's pre-deletion
833            // `FileIndex` (and any seeded on-disk `cached_index`) in memory
834            // for the rest of the process's life. Clear the seed and force
835            // one recompute against the now-emptied text (`invalidate_file`
836            // above already cleared it via mir's `remove_source_file`) to
837            // shrink the memo to near-nothing immediately.
838            if let Some(wf) = self.lsp_ws_files.get(uri).map(|e| *e) {
839                session.with_db_mut(|db| {
840                    if wf.cached_index(db).is_some() {
841                        wf.set_cached_index(db).to(None);
842                    }
843                });
844                let db = session.snapshot_db();
845                let _ = crate::db::mir_queries::file_index(&db, wf);
846            }
847        }
848        self.write_revision.fetch_add(1, Ordering::Release);
849    }
850
851    // ── Salsa-backed accessors ─────────────────────────────────────────────
852    //
853    // Reads run the memoized `parsed_doc` / `file_index` queries, parsing
854    // only on first access per revision. These are the production accessors
855    // used by every handler.
856
857    /// Salsa-backed parsed document.
858    ///
859    /// Salsa-backed parsed document for any mirrored file (open or
860    /// background-indexed). Returns `None` only when the file is not known
861    /// to the store. Callers that want "only if open" should gate on
862    /// `Backend::open_files` at the call site (see `Backend::get_doc`).
863    pub fn get_doc_salsa(&self, uri: &Uri) -> Option<Arc<ParsedDoc>> {
864        self.get_parsed_cached(uri)
865    }
866
867    /// Salsa-backed compact symbol index.
868    pub fn get_index_salsa(&self, uri: &Uri) -> Option<Arc<FileIndex>> {
869        let wf = self.lsp_ws_file(uri)?;
870        Some(
871            self.snapshot_mir_query(move |db| crate::db::mir_queries::file_index(db, wf).0.clone()),
872        )
873    }
874
875    /// Salsa-backed pre-computed symbol map (name → Vec<SymbolEntry>).
876    /// Memoized per revision: stable files serve from cache in O(1).
877    pub fn get_symbol_map_salsa(
878        &self,
879        uri: &Uri,
880    ) -> Option<Arc<crate::types::symbol_map::SymbolMap>> {
881        // Symbol map runs on the shared mir db, sharing its memoized `parsed_doc`.
882        let wf = self.lsp_ws_file(uri)?;
883        Some(self.snapshot_mir_query(move |db| {
884            let sf = *wf.source(db);
885            crate::db::mir_queries::symbol_map(db, sf).0.clone()
886        }))
887    }
888
889    /// Pre-computed symbol maps for every entry in `open_urls` except `uri`.
890    pub fn other_symbol_maps(
891        &self,
892        uri: &Uri,
893        open_urls: &[Uri],
894    ) -> Vec<(Uri, Arc<crate::types::symbol_map::SymbolMap>)> {
895        open_urls
896            .iter()
897            .filter(|u| *u != uri)
898            .filter_map(|u| self.get_symbol_map_salsa(u).map(|m| (u.clone(), m)))
899            .collect()
900    }
901
902    /// G3: shared implementation for `get_doc_salsa`.
903    /// Tries the `parsed_cache` (lock-free) first; validates via
904    /// `Arc::ptr_eq` against the G2 `text_cache` so a concurrent writer
905    /// that has already committed a new text input cannot be masked by a
906    /// stale cache entry. On miss, captures the text Arc and ParsedDoc
907    /// together inside a single `snapshot_query`, then publishes both.
908    fn get_parsed_cached(&self, uri: &Uri) -> Option<Arc<ParsedDoc>> {
909        if let Some(current_text) = self.caches.text_cache.get(uri)
910            && let Some(entry) = self.caches.parsed_cache.get(uri)
911            && Arc::ptr_eq(&*current_text, &entry.0)
912        {
913            let doc = entry.1.clone();
914            drop(entry);
915            self.caches.touch(uri);
916            return Some(doc);
917        }
918
919        // Parse runs on the shared mir db.
920        let wf = self.lsp_ws_file(uri)?;
921        self.caches.bump_parse_count();
922        let (text, doc) = self.snapshot_mir_query(move |db| {
923            let sf = *wf.source(db);
924            let text = sf.text(db).clone();
925            let doc = crate::db::mir_queries::parsed_doc(db, sf).0.clone();
926            (text, doc)
927        });
928        self.caches.insert_parsed(uri.clone(), text, doc.clone());
929        Some(doc)
930    }
931
932    /// Parsed doc for a cosmetic, cursor-triggered read (e.g. `documentHighlight`)
933    /// that must stay responsive while the user types. Unlike [`Self::get_doc_salsa`]
934    /// it never spins on the `Cancelled` retry loop: it tries the lock-free
935    /// `parsed_cache`, then a bounded snapshot parse, and if a concurrent write
936    /// stream keeps cancelling it, returns the last-good cached `ParsedDoc`
937    /// (possibly one edit stale — invisible for a highlight, unlike a hang).
938    /// `None` only when the file has never been parsed.
939    pub fn get_doc_snapshot_or_stale(&self, uri: &Uri) -> Option<Arc<ParsedDoc>> {
940        if let Some(current_text) = self.caches.text_cache.get(uri)
941            && let Some(entry) = self.caches.parsed_cache.get(uri)
942            && Arc::ptr_eq(&*current_text, &entry.0)
943        {
944            let doc = entry.1.clone();
945            drop(entry);
946            self.caches.touch(uri);
947            return Some(doc);
948        }
949
950        if let Some(wf) = self.lsp_ws_file(uri)
951            && let Some((text, doc)) = self.try_snapshot_mir_query(3, move |db| {
952                let sf = *wf.source(db);
953                let text = sf.text(db).clone();
954                let doc = crate::db::mir_queries::parsed_doc(db, sf).0.clone();
955                (text, doc)
956            })
957        {
958            self.caches.bump_parse_count();
959            self.caches.insert_parsed(uri.clone(), text, doc.clone());
960            return Some(doc);
961        }
962
963        self.caches.parsed_cache.get(uri).map(|e| e.1.clone())
964    }
965
966    /// Refresh `workspace.files` to mirror the current active file set.
967    ///
968    /// Skips all work when `workspace_files_dirty` is `false` (the common
969    /// case after the workspace scan completes — file-set changes are rare).
970    pub fn sync_workspace_files(&self) {
971        // Atomically clear the flag.  If it was already false the file set
972        // hasn't changed since the last sync; nothing to do.
973        if !self.workspace_files_dirty.swap(false, Ordering::AcqRel) {
974            return;
975        }
976
977        // Collect active (non-deleted) files, sorted by URI for stable ordering.
978        let mut entries: Vec<(Arc<str>, LspWsFile)> = self
979            .lsp_ws_files
980            .iter()
981            .filter(|e| !self.deleted_uris.contains(e.key()))
982            .map(|e| (Arc::<str>::from(e.key().as_str()), *e.value()))
983            .collect();
984        entries.sort_unstable_by(|(a, _), (b, _)| a.cmp(b));
985        let files: Arc<[LspWsFile]> = entries.iter().map(|(_, wf)| *wf).collect();
986
987        let session = self.current_analysis_session();
988        let mut guard = self.lsp_workspace.lock().unwrap();
989        session.with_db_mut(|db| match *guard {
990            Some(ws) => {
991                ws.set_files(db).to(files);
992            }
993            None => *guard = Some(LspWorkspace::new(db, files)),
994        });
995    }
996
997    /// Mark the workspace file set as dirty so the next `sync_workspace_files`
998    /// call re-runs the collect/sort/compare path, and invalidate the
999    /// `workspace_file_paths()` cache. Exposed for benchmarks that need to
1000    /// measure the dirty-path cost in isolation.
1001    pub fn mark_workspace_files_dirty(&self) {
1002        self.workspace_files_dirty.store(true, Ordering::Release);
1003        self.workspace_file_paths_cache.store(None);
1004    }
1005
1006    /// Update the PHP version tracked by the workspace. Salsa will invalidate
1007    /// all `semantic_issues` queries so diagnostics are re-evaluated.
1008    /// Skips the setter when the version hasn't changed to avoid spurious
1009    /// query invalidation. Returns `true` when the version actually changed —
1010    /// callers that scan the workspace at runtime (not just at init, before
1011    /// any file is mirrored) must re-scan every root when this is `true`,
1012    /// since `drop_session_scoped_state` below empties `lsp_ws_files` and
1013    /// nothing else repopulates it.
1014    pub fn set_php_version(&self, version: mir_analyzer::PhpVersion) -> bool {
1015        {
1016            let mut guard = self.analysis_session.lock().unwrap();
1017            if guard.0 == version {
1018                return false;
1019            }
1020            // Clear the cached session too: `current_analysis_session()` trusts
1021            // `guard.1` unconditionally when present, so leaving the old
1022            // version's session behind here would silently hand it out under
1023            // the new version until something calls `analysis_session(version)`
1024            // explicitly to force the mismatch-triggered rebuild.
1025            guard.0 = version;
1026            guard.1 = None;
1027        }
1028        // Changing the version selects a different `AnalysisSession` (and thus
1029        // a different db). In practice the version is set once at init, before
1030        // any file is mirrored — but a runtime change is possible too (the
1031        // caller must re-scan in that case, see the `bool` return above).
1032        self.drop_session_scoped_state();
1033        true
1034    }
1035
1036    /// Discard every piece of state scoped to the current `AnalysisSession`'s
1037    /// salsa db. MUST accompany anything that causes `analysis_session()` to
1038    /// hand out a different db (version change, cache-dir attach): the
1039    /// `LspWsFile` input handles in `lsp_ws_files` index into the *old* db's
1040    /// tables, and using one against a new db panics with a salsa slot-type
1041    /// mismatch. The workspace scan re-mirrors on-disk files onto the new
1042    /// session, but it explicitly skips anything already open in the editor
1043    /// (so it doesn't clobber live buffer edits with disk content) — callers
1044    /// whose drop can race an open buffer must re-mirror open files themselves.
1045    fn drop_session_scoped_state(&self) {
1046        self.lsp_ws_files.clear();
1047        *self.lsp_workspace.lock().unwrap() = None;
1048        self.mark_workspace_files_dirty();
1049        // Cached FileAnalysis values reference the old session's state.
1050        self.caches.evict_analysis_all();
1051    }
1052
1053    /// Inverted-index reference lookup scoped to `files` (the text-pre-filtered
1054    /// candidate set): a posting-list read for committed-fresh files plus an
1055    /// on-demand analyze+commit for stale/uncommitted candidates, so warm
1056    /// requests are O(results) instead of O(candidates).
1057    ///
1058    /// Returns LSP-style 0-based line/column.
1059    ///
1060    /// `cancel_rev`: when `Some(rev)`, the loop throws `salsa::Cancelled` and
1061    /// returns empty if a concurrent write advances the `write_revision` counter
1062    /// past `rev` — preventing unbounded retries against a newly-invalidated db.
1063    /// Pass `None` to retain the original indefinite-retry behaviour (fast ops
1064    /// like single-file reads where a stale result is not a concern).
1065    pub fn indexed_references(
1066        &self,
1067        symbol: &mir_analyzer::Name,
1068        files: &[Arc<str>],
1069        include_declaration: bool,
1070        cancel_rev: Option<u64>,
1071    ) -> Vec<(Arc<str>, u32, u32, u32)> {
1072        let php_version = self.workspace_php_version();
1073        // Staleness probe threaded into mir: polled at phase boundaries and
1074        // between cancellation retries, so a request invalidated by a
1075        // concurrent edit aborts *inside* mir's retry loop instead of spinning
1076        // there indefinitely (mir catches `Cancelled` internally, so an outer
1077        // catch alone never fires for the parallel phase).
1078        let stale =
1079            || cancel_rev.is_some_and(|rev| self.write_revision.load(Ordering::Acquire) != rev);
1080        // Retry: concurrent db writes (background indexing) cancel snapshot
1081        // queries via resume_unwind; without the loop the panic propagates out
1082        // of the caller's spawn_blocking and the request silently returns empty.
1083        let raw = loop {
1084            let session = self.analysis_session(php_version);
1085            match salsa::Cancelled::catch(std::panic::AssertUnwindSafe(|| {
1086                session.indexed_references_to(symbol, files, include_declaration, &stale)
1087            })) {
1088                Ok(Some(refs)) => break refs,
1089                // mir aborted via the staleness probe — or a Phase-1 unwind
1090                // hit an already-stale request. Propagate as salsa::Cancelled
1091                // so spawn_blocking callers see a JoinError::Panicked and
1092                // return empty via unwrap_or_default.
1093                Ok(None) => {
1094                    std::panic::resume_unwind(Box::new(salsa::Cancelled::PendingWrite));
1095                }
1096                Err(_) if stale() => {
1097                    std::panic::resume_unwind(Box::new(salsa::Cancelled::PendingWrite));
1098                }
1099                // Phase-1 unwind from a write that doesn't invalidate this
1100                // request (mir-internal load_class, scan writes with no
1101                // cancel_rev) — retry.
1102                Err(_) => {}
1103            }
1104        };
1105        raw.into_iter()
1106            .map(|(file, range)| {
1107                // mir uses 1-based lines; 0-based columns (since mir 0.42.0).
1108                let line = range.start.line.saturating_sub(1);
1109                let col_start = range.start.column;
1110                let col_end = range.end.column;
1111                (file, line, col_start, col_end)
1112            })
1113            .collect()
1114    }
1115
1116    /// `use`-import lines referencing `symbol`, from mir's `use:` postings.
1117    /// Read-only lookup with no freshness pass — run [`Self::indexed_references`]
1118    /// over the same `files` first so uncommitted candidates are analyzed.
1119    /// Returns `(file_uri, line, col_start, col_end)` tuples like
1120    /// [`Self::indexed_references`]; the range covers the whole import item.
1121    pub fn indexed_use_imports(
1122        &self,
1123        symbol: &mir_analyzer::Name,
1124        files: &[Arc<str>],
1125    ) -> Vec<(Arc<str>, u32, u32, u32)> {
1126        let session = self.current_analysis_session();
1127        let _interactive = self.interactive_read_guard();
1128        let raw = loop {
1129            if let Ok(locs) = salsa::Cancelled::catch(std::panic::AssertUnwindSafe(|| {
1130                session.indexed_use_import_locations(symbol, files)
1131            })) {
1132                break locs;
1133            }
1134        };
1135        raw.into_iter()
1136            .map(|(file, range)| {
1137                // mir uses 1-based lines; 0-based columns.
1138                let line = range.start.line.saturating_sub(1);
1139                (file, line, range.start.column, range.end.column)
1140            })
1141            .collect()
1142    }
1143
1144    /// Replay disk-cached reference postings and subtype edges for the whole
1145    /// mirrored workspace (mir's `warm_start_files`) — a no-op per file
1146    /// without a content-hash-matching cache entry from a previous run. Call
1147    /// once after the scan mirrors texts, before the analysis warm sweep, so
1148    /// a returning session starts index-warm.
1149    pub fn warm_start_indexes(&self) {
1150        let files: Vec<(Arc<str>, Arc<str>)> = self
1151            .lsp_ws_files
1152            .iter()
1153            .filter(|e| !self.deleted_uris.contains(e.key()))
1154            .filter_map(|e| {
1155                let text = self.caches.text_cache.get(e.key())?;
1156                Some((Arc::<str>::from(e.key().as_str()), Arc::clone(&text)))
1157            })
1158            .collect();
1159        if files.is_empty() {
1160            return;
1161        }
1162        let session = self.current_analysis_session();
1163        session.warm_start_files(&files);
1164        self.warm_start_replays_completed
1165            .fetch_add(1, Ordering::Relaxed);
1166    }
1167
1168    /// Candidate file scope for a posting lookup on `symbol`.
1169    /// Private/protected methods narrow to their visibility scope; everything
1170    /// else gets the whole workspace. mir gates never-committed candidates on
1171    /// a symbol-name text mention internally (with PHP's case-insensitive
1172    /// matching semantics), so a host-side text prefilter would only re-scan
1173    /// the same bytes with weaker semantics.
1174    pub(crate) fn reference_candidate_files(&self, symbol: &mir_analyzer::Name) -> Vec<Arc<str>> {
1175        match symbol {
1176            mir_analyzer::Name::Method { class, name } => {
1177                if let Some(urls) = self.method_reference_scope(class, name) {
1178                    return urls.iter().map(|u| Arc::from(u.as_str())).collect();
1179                }
1180            }
1181            // A class reference always textually resolves the name in the
1182            // referencing file (import, namespace, or qualified path) —
1183            // there's no instance-typed-receiver indirection here, so FQN
1184            // narrowing is always sound.
1185            mir_analyzer::Name::Class(fqcn) => {
1186                if let Some(files) = self.fqn_reachable_files(std::slice::from_ref(fqcn)) {
1187                    return files;
1188                }
1189            }
1190            // Functions/constants resolve like class names ONLY when
1191            // namespaced: an unqualified call to a *global* one (`env()`,
1192            // `PHP_EOL`) works from any namespace via PHP's global
1193            // fallback, so no file can be excluded for those.
1194            mir_analyzer::Name::Function(fqcn) | mir_analyzer::Name::GlobalConstant(fqcn) => {
1195                if fqcn.trim_start_matches('\\').contains('\\')
1196                    && let Some(files) = self.fqn_reachable_files(std::slice::from_ref(fqcn))
1197                {
1198                    return files;
1199                }
1200            }
1201            _ => {}
1202        }
1203        self.workspace_file_paths().to_vec()
1204    }
1205
1206    /// Every active workspace file as a mir path (`Arc<str>` of the URI).
1207    /// The candidate scope handed to mir's queries — mir gates uncommitted
1208    /// candidates on symbol-name mention internally, so this is always safe.
1209    ///
1210    /// Cached behind an `Arc` (invalidated by `mark_workspace_files_dirty`) so
1211    /// the ~15K-file walk + `Arc<str>` allocation per file only happens once
1212    /// per workspace-file-set change, not once per caller per request.
1213    pub fn workspace_file_paths(&self) -> Arc<Vec<Arc<str>>> {
1214        if let Some(cached) = self.workspace_file_paths_cache.load_full() {
1215            return cached;
1216        }
1217        let files: Arc<Vec<Arc<str>>> = Arc::new(
1218            self.lsp_ws_files
1219                .iter()
1220                .filter(|e| !self.deleted_uris.contains(e.key()))
1221                .map(|e| Arc::<str>::from(e.key().as_str()))
1222                .collect(),
1223        );
1224        self.workspace_file_paths_cache
1225            .store(Some(Arc::clone(&files)));
1226        files
1227    }
1228
1229    /// Transitive subtypes of `class_fqn` from mir's maintained subtype edge
1230    /// index, with declaration sites. `include_trait_users` also counts
1231    /// `use Trait;` composition as a subtype edge (trait-usage lenses); leave
1232    /// it off for goto-implementation semantics (extends/implements only).
1233    pub fn indexed_subtype_classes(
1234        &self,
1235        class_fqn: &str,
1236        include_trait_users: bool,
1237    ) -> Vec<mir_analyzer::SubtypeClassSite> {
1238        let files = self.workspace_file_paths();
1239        let session = self.current_analysis_session();
1240        let _interactive = self.interactive_read_guard();
1241        loop {
1242            if let Ok(sites) = salsa::Cancelled::catch(std::panic::AssertUnwindSafe(|| {
1243                session.indexed_subtype_classes(class_fqn, &files, include_trait_users)
1244            })) {
1245                break sites;
1246            }
1247        }
1248    }
1249
1250    /// Concrete implementations of `class_fqn::method` across its subtypes:
1251    /// `(subtype fqcn, file, name range)` from mir's subtype edge index.
1252    pub fn indexed_method_implementations(
1253        &self,
1254        class_fqn: &str,
1255        method: &str,
1256    ) -> Vec<(Arc<str>, Arc<str>, mir_analyzer::Range)> {
1257        let files = self.workspace_file_paths();
1258        let session = self.current_analysis_session();
1259        let _interactive = self.interactive_read_guard();
1260        loop {
1261            if let Ok(sites) = salsa::Cancelled::catch(std::panic::AssertUnwindSafe(|| {
1262                session.indexed_method_implementations(class_fqn, method, &files)
1263            })) {
1264                break sites;
1265            }
1266        }
1267    }
1268
1269    /// The complete set of files a `private` method's references can occur in,
1270    /// or `None` when the search must stay workspace-wide.
1271    ///
1272    /// `owner_fqn` is the enclosing class at the cursor; narrowing applies only
1273    /// when that class *directly declares* `method` (so its `MethodDef`
1274    /// visibility is authoritative — inherited/call-site lookups fall through to
1275    /// `None`). A reference to `Class::method` is recorded only where
1276    /// `analyze_file` resolves an expression to that key, and `analyze_file` is
1277    /// per-source-file, so a `private` member — resolvable only inside the
1278    /// declaring class body, which lives in one file — is fully scoped to the
1279    /// declaring file at any indexing stage.
1280    ///
1281    /// A `protected` member is reachable from the declaring class and its
1282    /// transitive subclasses (`$this->m()`, `parent::m()`, `self::`/`static::`),
1283    /// all of which live in the declaring file or a subtype file — never outside
1284    /// the hierarchy in valid PHP. Its scope is therefore the declaring file plus
1285    /// the transitive subtype files. The complete subtype set is only known once
1286    /// the workspace reference index finishes, so this narrowing applies only
1287    /// when [`Self::is_index_ready`]; before then it falls back to `None` (full
1288    /// workspace scope) so references are never under-reported.
1289    ///
1290    /// Classes that compose traits or mixins are excluded — those inline
1291    /// external resolution context, so a reference could surface in another file.
1292    pub fn method_reference_scope(&self, owner_fqn: &str, method: &str) -> Option<Vec<Uri>> {
1293        match self.method_reference_scope_plan(owner_fqn, method) {
1294            MethodScopePlan::Files(files) => Some(files),
1295            MethodScopePlan::NeedsScan { fqns, extra_needle } => {
1296                let files =
1297                    self.fqn_reachable_files_with_needles(&fqns, &[extra_needle.as_str()])?;
1298                Some(
1299                    files
1300                        .iter()
1301                        .filter_map(|f| (f).parse::<Uri>().ok())
1302                        .collect(),
1303                )
1304            }
1305            MethodScopePlan::FullWorkspace => None,
1306        }
1307    }
1308
1309    /// Whether [`Self::reference_candidate_files`] narrows `class::method`'s
1310    /// scope below the full workspace, without paying for the narrowing scan
1311    /// itself. A caller that further partitions the candidate set by a
1312    /// heuristic text mention (references' priority-result streaming) only
1313    /// has anything to gain when the scope is *not* already narrowed — a
1314    /// narrowed scope is already the minimal necessary set, so partitioning
1315    /// it further just re-scans those files' bytes for no benefit.
1316    pub(crate) fn method_scope_is_narrowed(&self, owner_fqn: &str, method: &str) -> bool {
1317        !matches!(
1318            self.method_reference_scope_plan(owner_fqn, method),
1319            MethodScopePlan::FullWorkspace
1320        )
1321    }
1322
1323    /// Same narrowing decision as [`Self::method_reference_scope`], but stops
1324    /// short of running the FQN/text-needle scan itself — that scan is the
1325    /// expensive part (a parallel pass over every workspace file), and a
1326    /// caller resolving many methods at once (code lens, one call per
1327    /// declaration in a file) needs to batch it across methods via
1328    /// [`Self::resolve_reachability_queries`] instead of paying it per call.
1329    fn method_reference_scope_plan(&self, owner_fqn: &str, method: &str) -> MethodScopePlan {
1330        use crate::index::file_index::{ClassKind, Visibility};
1331
1332        let ws = self.get_workspace_index_salsa();
1333        let owner_fqn = owner_fqn.trim_start_matches('\\');
1334        let owner_short = crate::text::fqn_short_name(owner_fqn);
1335
1336        let Some((decl_uri, decl_class)) = ws.classes_by_name.get(owner_short).and_then(|refs| {
1337            refs.iter()
1338                .filter_map(|&r| ws.at(r))
1339                .find(|(_, cls)| cls.fqn.trim_start_matches('\\') == owner_fqn)
1340        }) else {
1341            return MethodScopePlan::FullWorkspace;
1342        };
1343
1344        // Trait/interface methods, and methods on classes that compose traits or
1345        // mixins, can be referenced from other files — keep the full scope.
1346        if !matches!(decl_class.kind, ClassKind::Class | ClassKind::Enum)
1347            || !decl_class.traits.is_empty()
1348            || !decl_class.mixins.is_empty()
1349        {
1350            return MethodScopePlan::FullWorkspace;
1351        }
1352
1353        let Some(method_def) = decl_class
1354            .methods
1355            .iter()
1356            .find(|m| m.name.eq_ignore_ascii_case(method))
1357        else {
1358            return MethodScopePlan::FullWorkspace;
1359        };
1360
1361        match method_def.visibility {
1362            Visibility::Private => MethodScopePlan::Files(vec![decl_uri.clone()]),
1363            Visibility::Protected => {
1364                if !self.is_index_ready() {
1365                    return MethodScopePlan::FullWorkspace;
1366                }
1367                // Subtype set comes from mir's resolved inheritance graph — it
1368                // matches subclasses by FQCN, so `extends \Ns\Base` and aliased
1369                // `use ... as` forms are all found. Falls back to full scope if
1370                // mir can't resolve the owner.
1371                let session = self.current_analysis_session();
1372                let mut files: std::collections::HashSet<Uri> = session
1373                    .subtype_files(owner_fqn)
1374                    .into_iter()
1375                    .filter_map(|p| p.parse::<Uri>().ok())
1376                    .collect();
1377                files.insert(decl_uri.clone());
1378                MethodScopePlan::Files(files.into_iter().collect())
1379            }
1380            // A public *static* method's call sites come in exactly two
1381            // textual shapes: a resolved class name (`Owner::m()`, inherited
1382            // `Sub::m()`, `self::`/`static::`/`parent::m()` from inside the
1383            // hierarchy, aliased `use Owner as X; X::m()` — all covered by
1384            // owner+subtype FQN reachability) or an *instance* receiver
1385            // (`$obj::m()`, whose file may never name the class but always
1386            // contains the member token — covered by the member-name
1387            // needle). Dynamic member names (`Owner::$m()`) produce no
1388            // posting at all (verified), so the union is exhaustive. This
1389            // beats mir's own gate, which only sees bare short names and
1390            // can't tell this owner apart from an unrelated class sharing
1391            // its short name. Instance methods stay unnarrowed
1392            // (`FullWorkspace` below): a typed receiver can hide both the
1393            // class *and* any distinguishing token cheaper than the member
1394            // name itself.
1395            Visibility::Public
1396                if method_def.is_static || method.eq_ignore_ascii_case("__construct") =>
1397            {
1398                if !self.is_index_ready() {
1399                    return MethodScopePlan::FullWorkspace;
1400                }
1401                let mut fqns: Vec<Arc<str>> = vec![Arc::from(owner_fqn)];
1402                fqns.extend(
1403                    self.indexed_subtype_classes(owner_fqn, false)
1404                        .into_iter()
1405                        .map(|s| s.fqcn),
1406                );
1407                // `__construct`'s extra needle is the call-shaped
1408                // `->__construct` (explicit re-init on a typed receiver),
1409                // not the bare identifier — that would drag in every file
1410                // *declaring* a constructor. `new X` sites and
1411                // `parent::`/`self::`/`static::__construct()` calls need no
1412                // needle: the former textually resolve an owner/subtype
1413                // name, the latter live inside hierarchy files, which the
1414                // subtype closure's namespace rule already admits (unlike
1415                // mir's own gate, which lacks the closure and keeps a
1416                // `::__construct` token for the grandchild-`parent::` case).
1417                let extra_needle = if method.eq_ignore_ascii_case("__construct") {
1418                    "->__construct".to_string()
1419                } else {
1420                    method.to_string()
1421                };
1422                MethodScopePlan::NeedsScan { fqns, extra_needle }
1423            }
1424            Visibility::Public => MethodScopePlan::FullWorkspace,
1425        }
1426    }
1427
1428    /// Batched form of [`Self::reference_candidate_files`]: resolves the
1429    /// candidate scope for every symbol in `symbols` while sharing ONE pass
1430    /// over the workspace for every symbol whose scope needs FQN/text-needle
1431    /// narrowing (class references, public static methods, constructors),
1432    /// instead of one pass per symbol.
1433    ///
1434    /// Code lens is the motivating caller: a single file's declarations can
1435    /// produce dozens of reference-count lenses in one request, and each
1436    /// used to pay its own full `ws.files` scan via
1437    /// [`Self::fqn_reachable_files_with_needles`] — O(declarations × files)
1438    /// text scans instead of the one pass this performs.
1439    ///
1440    /// Order-preserving: `result[i]` is the scope for `symbols[i]`.
1441    pub(crate) fn batch_reference_candidate_files(
1442        &self,
1443        symbols: &[mir_analyzer::Name],
1444    ) -> Vec<Vec<Arc<str>>> {
1445        let mut results: Vec<Option<Vec<Arc<str>>>> = vec![None; symbols.len()];
1446        let mut queries: Vec<ReachabilityQuery> = Vec::new();
1447        // queries[k] resolves the scope for symbols[query_targets[k]].
1448        let mut query_targets: Vec<usize> = Vec::new();
1449
1450        for (i, symbol) in symbols.iter().enumerate() {
1451            match symbol {
1452                mir_analyzer::Name::Method { class, name } => {
1453                    match self.method_reference_scope_plan(class, name) {
1454                        MethodScopePlan::Files(urls) => {
1455                            results[i] = Some(urls.iter().map(|u| Arc::from(u.as_str())).collect());
1456                        }
1457                        MethodScopePlan::NeedsScan { fqns, extra_needle } => {
1458                            queries.push(ReachabilityQuery {
1459                                fqns,
1460                                extra_needles: vec![extra_needle],
1461                            });
1462                            query_targets.push(i);
1463                        }
1464                        MethodScopePlan::FullWorkspace => {}
1465                    }
1466                }
1467                mir_analyzer::Name::Class(fqcn) => {
1468                    queries.push(ReachabilityQuery {
1469                        fqns: vec![fqcn.clone()],
1470                        extra_needles: Vec::new(),
1471                    });
1472                    query_targets.push(i);
1473                }
1474                mir_analyzer::Name::Function(fqcn) | mir_analyzer::Name::GlobalConstant(fqcn)
1475                    if fqcn.trim_start_matches('\\').contains('\\') =>
1476                {
1477                    queries.push(ReachabilityQuery {
1478                        fqns: vec![fqcn.clone()],
1479                        extra_needles: Vec::new(),
1480                    });
1481                    query_targets.push(i);
1482                }
1483                _ => {}
1484            }
1485        }
1486
1487        if !queries.is_empty()
1488            && let Some(scanned) = self.resolve_reachability_queries(&queries)
1489        {
1490            for (files, target) in scanned.into_iter().zip(query_targets) {
1491                results[target] = Some(files);
1492            }
1493        }
1494
1495        let fallback = self.workspace_file_paths();
1496        results
1497            .into_iter()
1498            .map(|r| r.unwrap_or_else(|| fallback.as_ref().clone()))
1499            .collect()
1500    }
1501
1502    /// See the `reachability_scan_passes` field's docs. Exposed via
1503    /// `debugStats` so a protocol-level test can assert a request with many
1504    /// narrowing-requiring declarations (code lens) pays one scan pass, not
1505    /// one per declaration.
1506    pub(crate) fn reachability_scan_passes(&self) -> u64 {
1507        self.reachability_scan_passes.load(Ordering::Relaxed)
1508    }
1509
1510    /// See the `analysis_compute_count` field's docs.
1511    pub(crate) fn analysis_compute_count(&self) -> u64 {
1512        self.analysis_compute_count.load(Ordering::Relaxed)
1513    }
1514
1515    /// Files that could possibly reference any FQN in `fqns`, narrowed via
1516    /// PHP's own name-resolution rules rather than bare-word text mention:
1517    ///
1518    /// 1. **Namespace rule** — the file's namespace equals the target's, or
1519    ///    is a `\`-segment prefix of it: `namespace Foo;` reaches
1520    ///    `Foo\Bar\Baz` through the relative-qualified `Bar\Baz`.
1521    /// 2. **Import rule** — a `use` of the target itself, an alias of it, or
1522    ///    any namespace prefix of it (`use Foo\Bar; Bar\Baz::x()`), all
1523    ///    matched on the import's FQN so aliases come for free.
1524    /// 3. **Text rule** — the qualified path appears literally in the file
1525    ///    (`\App\Widget::class` config arrays with no `use` line, or a
1526    ///    leading-slash-free `App\Widget` from a no-namespace file, where
1527    ///    qualified names resolve from the root). Matched
1528    ///    ASCII-case-insensitively — PHP class names are case-insensitive.
1529    ///
1530    /// These are the *only* ways PHP resolves a class-like or namespaced
1531    /// name, so this is exact narrowing grounded in the language's own
1532    /// resolution order, not a heuristic — unlike mir's own gate, which
1533    /// only sees bare short names and can't distinguish this owner class
1534    /// from an unrelated class sharing its short name elsewhere in the
1535    /// workspace.
1536    ///
1537    /// Callers must NOT use this for global-namespace functions/constants
1538    /// (unqualified calls fall back to the global namespace from *any*
1539    /// namespace, so no file can be excluded) or for instance members (a
1540    /// typed receiver can hide the class entirely).
1541    ///
1542    /// Returns `None` (full workspace) when the boot scan hasn't finished —
1543    /// same conservative discipline as the `Protected` branch above — so
1544    /// references are never under-reported at cold start.
1545    pub fn fqn_reachable_files(&self, fqns: &[Arc<str>]) -> Option<Vec<Arc<str>>> {
1546        self.fqn_reachable_files_with_needles(fqns, &[])
1547    }
1548
1549    /// [`Self::fqn_reachable_files`] plus files whose text contains any of
1550    /// `extra_needles` (ASCII-case-insensitive substring, no word bounds).
1551    /// The union is what makes member-symbol narrowing sound: a static call
1552    /// through an *instance* receiver (`$obj::make()`) or an explicit
1553    /// re-init (`$obj->__construct()`) references a member without the file
1554    /// ever resolving the owner's name, but its text always contains the
1555    /// call token itself.
1556    ///
1557    /// A single-query call through [`Self::resolve_reachability_queries`] —
1558    /// see that method for the batched form multiple callers share.
1559    fn fqn_reachable_files_with_needles(
1560        &self,
1561        fqns: &[Arc<str>],
1562        extra_needles: &[&str],
1563    ) -> Option<Vec<Arc<str>>> {
1564        let query = ReachabilityQuery {
1565            fqns: fqns.to_vec(),
1566            extra_needles: extra_needles.iter().map(|n| n.to_string()).collect(),
1567        };
1568        self.resolve_reachability_queries(std::slice::from_ref(&query))
1569            .map(|mut per_query| per_query.pop().expect("one query in, one result out"))
1570    }
1571
1572    /// Resolves many [`Self::fqn_reachable_files_with_needles`]-shaped
1573    /// queries in a single pass over the workspace, so N callers share one
1574    /// scan instead of paying N.
1575    ///
1576    /// The namespace/import checks are cheap in-memory field comparisons and
1577    /// stay per-query, but the qualified-mention fallback scans each
1578    /// candidate's full text — that scan is what gets shared: every query's
1579    /// needles feed one combined Aho-Corasick automaton, so a file pays one
1580    /// text pass regardless of how many queries are outstanding, instead of
1581    /// one pass per query. Each match is attributed back to its owning
1582    /// query by pattern id.
1583    ///
1584    /// Returns `None` for every query when the boot scan hasn't finished —
1585    /// same conservative discipline as the single-query form — so
1586    /// references are never under-reported at cold start.
1587    fn resolve_reachability_queries(
1588        &self,
1589        queries: &[ReachabilityQuery],
1590    ) -> Option<Vec<Vec<Arc<str>>>> {
1591        use rayon::prelude::*;
1592
1593        if !self.is_index_ready() {
1594            return None;
1595        }
1596        if queries.is_empty() {
1597            return Some(Vec::new());
1598        }
1599        self.reachability_scan_passes
1600            .fetch_add(1, Ordering::Relaxed);
1601
1602        // Per-query targets/namespaces for the cheap checks, plus one needle
1603        // list tagging each pattern with the query it belongs to (a query's
1604        // FQN needles and extra needles can both contribute patterns).
1605        let mut per_query_targets: Vec<Vec<&str>> = Vec::with_capacity(queries.len());
1606        let mut per_query_namespaces: Vec<Vec<Option<&str>>> = Vec::with_capacity(queries.len());
1607        let mut all_needles: Vec<String> = Vec::new();
1608        let mut needle_query: Vec<usize> = Vec::new();
1609
1610        for (qi, q) in queries.iter().enumerate() {
1611            let targets: Vec<&str> = q.fqns.iter().map(|f| f.trim_start_matches('\\')).collect();
1612            let namespaces: Vec<Option<&str>> = targets
1613                .iter()
1614                .map(|t| t.rsplit_once('\\').map(|(ns, _)| ns))
1615                .collect();
1616            // Namespaced targets match with or without the leading `\`; a
1617            // bare global name keeps it (the short name alone would match
1618            // everything).
1619            for t in &targets {
1620                all_needles.push(if t.contains('\\') {
1621                    (*t).to_string()
1622                } else {
1623                    format!("\\{t}")
1624                });
1625                needle_query.push(qi);
1626            }
1627            for n in &q.extra_needles {
1628                all_needles.push(n.clone());
1629                needle_query.push(qi);
1630            }
1631            per_query_targets.push(targets);
1632            per_query_namespaces.push(namespaces);
1633        }
1634        let finder = aho_corasick::AhoCorasick::builder()
1635            .ascii_case_insensitive(true)
1636            .build(&all_needles)
1637            .ok()?;
1638
1639        let ws = self.get_workspace_index_salsa();
1640        let n = queries.len();
1641        // For a common short name most of the corpus fails the cheap
1642        // namespace/import checks and falls through to the text scan, so
1643        // this must run in parallel (mir's own equivalent gate scan does
1644        // the same) or a cold query on a 15K-file workspace pays a
1645        // multi-second sequential scan instead of the few-ms parallel one.
1646        let per_file_matches: Vec<(Arc<str>, Vec<bool>)> = ws
1647            .files
1648            .par_iter()
1649            .filter_map(|(url, idx)| {
1650                let file_ns = idx.namespace.as_deref().map(|s| s.trim_start_matches('\\'));
1651                let mut matched = vec![false; n];
1652                let mut unresolved = 0usize;
1653                for qi in 0..n {
1654                    let ns_match = per_query_namespaces[qi]
1655                        .iter()
1656                        .any(|ns| match (file_ns, ns) {
1657                            (Some(f), Some(t)) => fqn_segment_prefix(f, t),
1658                            (None, None) => true,
1659                            // Global-ns files reach namespaced targets only via
1660                            // the full path (text rule); namespaced files reach
1661                            // global classes only via `\Name` or an import.
1662                            _ => false,
1663                        });
1664                    if ns_match {
1665                        matched[qi] = true;
1666                        continue;
1667                    }
1668                    let import_match = idx.use_imports.iter().any(|(_, f)| {
1669                        let f = f.trim_start_matches('\\');
1670                        per_query_targets[qi]
1671                            .iter()
1672                            .any(|t| fqn_segment_prefix(f, t))
1673                    });
1674                    if import_match {
1675                        matched[qi] = true;
1676                        continue;
1677                    }
1678                    unresolved += 1;
1679                }
1680                if unresolved > 0
1681                    && let Some(text) = self.source_text(url)
1682                {
1683                    // Enumerate matches but stop as soon as every query still
1684                    // unresolved by the cheap checks has been hit once —
1685                    // bounds the scan to the number of distinct needles that
1686                    // actually occur, not every occurrence of a common one.
1687                    for m in finder.find_iter(text.as_ref()) {
1688                        let qi = needle_query[m.pattern().as_usize()];
1689                        if !matched[qi] {
1690                            matched[qi] = true;
1691                            unresolved -= 1;
1692                            if unresolved == 0 {
1693                                break;
1694                            }
1695                        }
1696                    }
1697                }
1698                matched
1699                    .iter()
1700                    .any(|&m| m)
1701                    .then(|| (Arc::<str>::from(url.as_str()), matched))
1702            })
1703            .collect();
1704
1705        let mut out: Vec<Vec<Arc<str>>> = vec![Vec::new(); n];
1706        for (file, matched) in per_file_matches {
1707            for (qi, out_files) in out.iter_mut().enumerate() {
1708                if matched[qi] {
1709                    out_files.push(file.clone());
1710                }
1711            }
1712        }
1713        Some(out)
1714    }
1715
1716    /// Phase J: salsa-memoized aggregate workspace index.
1717    ///
1718    /// Returns the shared `Arc<WorkspaceIndexData>` with flat
1719    /// `(Uri, Arc<FileIndex>)` list plus pre-built `classes_by_name` and
1720    /// `subtypes_of` reverse maps. Used by workspace_symbols,
1721    /// prepare_type_hierarchy, supertypes_of, subtypes_of, and
1722    /// find_implementations so they don't each rebuild the aggregate per
1723    /// request. Invalidates automatically when any file's `file_index`
1724    /// changes.
1725    pub fn get_workspace_index_salsa(&self) -> Arc<crate::db::workspace_index::WorkspaceIndexData> {
1726        self.sync_workspace_files();
1727        let ws = *self.lsp_workspace.lock().unwrap();
1728        let Some(ws) = ws else {
1729            return Arc::new(crate::db::workspace_index::WorkspaceIndexData::from_files(
1730                Vec::new(),
1731            ));
1732        };
1733        self.snapshot_mir_query(move |db| crate::db::mir_queries::workspace_index(db, ws).0.clone())
1734    }
1735
1736    /// Total number of real `ParsedDoc` parses served so far (cache misses).
1737    /// Surfaced via `$/php-lsp/debugStats` so tests can assert the references
1738    /// read path doesn't parse the whole workspace.
1739    pub fn parse_count(&self) -> u64 {
1740        self.caches.parse_count()
1741    }
1742
1743    /// Times mir's `RefIndex` was locked on the current session. Reads are
1744    /// per-key posting lookups and edits commit one file's postings, so this
1745    /// grows by a small bounded amount per operation — never per candidate.
1746    /// Surfaced via `$/php-lsp/debugStats` for the stress-test guard.
1747    pub fn ref_index_lock_count(&self) -> u64 {
1748        self.current_analysis_session().ref_index_lock_count()
1749    }
1750
1751    /// Whether mir's workspace symbol index singleton is populated (warm-start
1752    /// seed or first sweep). See `DebugStats::workspace_symbol_index_ready`.
1753    pub fn workspace_symbol_index_ready(&self) -> bool {
1754        self.analysis_session(self.workspace_php_version())
1755            .workspace_symbol_index_ready()
1756    }
1757
1758    /// Executions of mir's tracked O(all-files) symbol-index walk.
1759    pub fn workspace_index_walks(&self) -> u64 {
1760        self.analysis_session(self.workspace_php_version())
1761            .workspace_index_walks()
1762    }
1763
1764    /// Return the raw source text for `uri` if it has been mirrored into the
1765    /// salsa workspace. Used by the references handler to pre-filter session
1766    /// results by checking whether a file mentions the owning class name.
1767    pub fn source_text(&self, uri: &Uri) -> Option<Arc<str>> {
1768        self.caches.text_cache.get(uri).map(|e| Arc::clone(&e))
1769    }
1770
1771    /// Cache the semantic tokens computed for a delta response.
1772    /// `result_id` is an opaque string (a hash of the token data) returned to the client.
1773    pub fn store_token_cache(&self, uri: &Uri, result_id: String, tokens: Arc<Vec<SemanticToken>>) {
1774        self.caches.store_token(uri, result_id, tokens);
1775    }
1776
1777    /// Return the cached tokens if `result_id` matches the stored one.
1778    pub fn get_token_cache(&self, uri: &Uri, result_id: &str) -> Option<Arc<Vec<SemanticToken>>> {
1779        self.caches.get_token(uri, result_id)
1780    }
1781
1782    /// Raw semantic issues for a file, computed via mir's session-based
1783    /// `FileAnalyzer`. The session lazy-loads dependencies via PSR-4 so the
1784    /// LSP no longer needs to mirror vendor up-front. Callers apply their
1785    /// own `DiagnosticsConfig` filter via
1786    /// [`crate::semantic_diagnostics::issues_to_diagnostics`].
1787    #[tracing::instrument(skip_all)]
1788    pub fn get_semantic_issues_salsa(&self, uri: &Uri) -> Option<Arc<[mir_issues::Issue]>> {
1789        let analysis = self.cached_analysis(uri)?;
1790        let file: Arc<str> = Arc::from(uri.as_str());
1791        // Workspace-level class issues for this file (circular inheritance,
1792        // override violations, abstract-method gaps). These are session-wide
1793        // (a dependency edit changes them without changing this file's bytes),
1794        // so they are recomputed live rather than cached alongside the
1795        // per-file body analysis.
1796        let class_issues = {
1797            let _s = tracing::debug_span!("session.class_issues_for").entered();
1798            // Retry: concurrent db writes cancel snapshot queries via resume_unwind.
1799            loop {
1800                let session = self.current_analysis_session();
1801                if let Ok(issues) = salsa::Cancelled::catch(std::panic::AssertUnwindSafe(|| {
1802                    session.class_issues(std::slice::from_ref(&file))
1803                })) {
1804                    break issues;
1805                }
1806            }
1807        };
1808        // `ParseError` is excluded: php-lsp already surfaces raw parse errors
1809        // as `SyntaxError` diagnostics from its own parser pass (see
1810        // `crate::analysis::diagnostics::diagnostics_from_doc`), so including
1811        // mir's copy here would duplicate every syntax-error diagnostic.
1812        let collector_issues = {
1813            let session = self.current_analysis_session();
1814            session
1815                .collector_issues(std::slice::from_ref(&file))
1816                .into_iter()
1817                .filter(|i| !matches!(i.kind, mir_issues::IssueKind::ParseError { .. }))
1818        };
1819        let combined: Vec<mir_issues::Issue> = analysis
1820            .issues
1821            .iter()
1822            .cloned()
1823            .chain(class_issues)
1824            .chain(collector_issues)
1825            .filter(|i| !i.suppressed)
1826            .collect();
1827        Some(Arc::from(combined))
1828    }
1829
1830    /// Run (or reuse) mir's per-file body analysis, retaining the full
1831    /// [`mir_analyzer::FileAnalysis`] — issues **and** resolved symbols — across
1832    /// requests. Diagnostics read `.issues`; position features call
1833    /// `.symbol_at(offset)` for the resolved type at a cursor.
1834    ///
1835    /// Cache hit when the entry's captured source `Arc` is pointer-equal to the
1836    /// file's current `doc.source_arc()`. A miss recomputes and overwrites, so
1837    /// the entry self-evicts on any content edit.
1838    /// Cache-hit-only variant of [`Self::cached_analysis`]: returns the cached
1839    /// analysis when the entry is current for the file's text, never computes.
1840    /// Lets async handlers take the warm path synchronously and reserve
1841    /// `spawn_blocking` for the cold path (mir Pass 1 + Pass 2 can take
1842    /// hundreds of ms on large files).
1843    pub fn cached_analysis_if_fresh(&self, uri: &Uri) -> Option<Arc<mir_analyzer::FileAnalysis>> {
1844        let doc = self.get_doc_salsa(uri)?;
1845        let source = doc.source_arc();
1846        let cur_ver = self.caches.decl_version();
1847        let analysis = {
1848            let entry = self.caches.analysis_cache.get(uri)?;
1849            (Arc::ptr_eq(&entry.0, &source) && entry.1 == cur_ver).then(|| Arc::clone(&entry.2))?
1850        };
1851        self.caches.touch(uri);
1852        Some(analysis)
1853    }
1854
1855    pub fn cached_analysis(&self, uri: &Uri) -> Option<Arc<mir_analyzer::FileAnalysis>> {
1856        self.cached_analysis_cancellable(uri, &|| false)
1857    }
1858
1859    /// Compare `uri`'s current `FileIndex` against its stored declaration
1860    /// fingerprint, bumping `decl_version` (and `session`'s prepare
1861    /// generation) when declarations changed or this is the file's
1862    /// first-seen fingerprint. Body-only edits leave the counter unchanged so
1863    /// sibling files keep serving from cache. Returns whether it bumped.
1864    fn sync_decl_fingerprint(&self, uri: &Uri, session: &mir_analyzer::AnalysisSession) -> bool {
1865        let new_index = self.get_index_salsa(uri);
1866        let old_fp = self
1867            .caches
1868            .decl_fingerprints
1869            .get(uri)
1870            .map(|e| Arc::clone(&*e));
1871        let decl_changed = match (&old_fp, &new_index) {
1872            (Some(old), Some(new)) => **old != **new,
1873            // First analysis: only a file that actually declares something can
1874            // affect other files. Opening a plain script must not invalidate
1875            // every open file's analysis cache and mir's warm-up marks.
1876            (None, Some(new)) => !new.declares_nothing(),
1877            _ => false,
1878        };
1879        if decl_changed {
1880            if let Some(idx) = new_index {
1881                self.caches.decl_fingerprints.insert(uri.clone(), idx);
1882            }
1883            self.caches.bump_decl_version();
1884            // Text reaches mir via direct salsa `set_text` writes, so mir can't
1885            // see declaration deletions itself. A deleted declaration may
1886            // unshadow a lazy-loadable symbol — invalidate mir's warm-up skip
1887            // set so reference queries re-run their prepare pass once.
1888            session.bump_prepare_generation();
1889        }
1890        decl_changed
1891    }
1892
1893    /// Register a newly-discovered file's declarations so a consumer that was
1894    /// analyzed *before* this file existed doesn't keep a stale cached
1895    /// analysis forever.
1896    ///
1897    /// `mirror_text`/`ingest_from_doc` alone never bump `decl_version` —
1898    /// that only happens inside `cached_analysis_cancellable`'s own
1899    /// first-analysis check, which runs when a file undergoes its *own* full
1900    /// analysis. A file that is merely scanned or created as someone else's
1901    /// dependency (what a workspace scan or a `didChangeWatchedFiles`
1902    /// CREATED/CHANGED event does) never goes through that path, so without
1903    /// this call, a consumer analyzed earlier keeps returning a stale
1904    /// `UndefinedClass` (or similar) even after the dependency shows up.
1905    ///
1906    /// Call this after mirroring a file from one of those discovery paths —
1907    /// not from every `mirror_text` call, which would also fire on every
1908    /// keystroke edit to an already-open file and defeat the whole point of
1909    /// caching sibling files' analyses across those edits.
1910    pub fn note_new_file_declarations(&self, uri: &Uri) {
1911        let session = self.current_analysis_session();
1912        self.sync_decl_fingerprint(uri, &session);
1913    }
1914
1915    /// [`Self::cached_analysis`] with an early exit: `should_cancel` is
1916    /// polled whenever a concurrent salsa write cancels the analysis attempt.
1917    /// A handler passes its write-revision staleness probe so a request made
1918    /// obsolete by newer typing stops burning a blocking-pool thread instead
1919    /// of retrying until the writer pauses — the editor re-requests anyway.
1920    #[tracing::instrument(skip_all)]
1921    pub fn cached_analysis_cancellable(
1922        &self,
1923        uri: &Uri,
1924        should_cancel: &(dyn Fn() -> bool + Sync),
1925    ) -> Option<Arc<mir_analyzer::FileAnalysis>> {
1926        // Need the parsed doc both for the analyzer and as the cache key.
1927        let doc = self.get_doc_salsa(uri)?;
1928        let source = doc.source_arc();
1929
1930        if let Some(hit) = self.cached_analysis_if_fresh(uri) {
1931            return Some(hit);
1932        }
1933
1934        // Serialize concurrent callers analyzing the SAME uncached file onto
1935        // one computation instead of each redoing the full mir pass — e.g.
1936        // `did_open`'s diagnostics trigger racing a fast-following hover on a
1937        // large just-opened file (tower-lsp's default concurrency runs up to
1938        // 4 in-flight LSP messages at once, so this genuinely happens). Other
1939        // files never contend: the lock is per-URI.
1940        let inflight = Arc::clone(
1941            self.analysis_inflight
1942                .entry(uri.clone())
1943                .or_insert_with(|| Arc::new(Mutex::new(())))
1944                .value(),
1945        );
1946        let _inflight_guard = inflight.lock().unwrap();
1947
1948        // Re-check now that we hold the per-URI lock: whoever we waited on
1949        // may have just populated the cache. `cur_ver` becomes the freshness
1950        // tag on OUR insert below, so re-fetching it here (rather than reusing
1951        // a value captured before we waited) reflects what was actually true
1952        // right before we started computing, not before we started waiting.
1953        if let Some(hit) = self.cached_analysis_if_fresh(uri) {
1954            return Some(hit);
1955        }
1956        let cur_ver = self.caches.decl_version();
1957
1958        let session = self.current_analysis_session();
1959        let file: Arc<str> = Arc::from(uri.as_str());
1960
1961        let source_map = php_rs_parser::source_map::SourceMap::new(doc.source());
1962        let owned_program = if let Some(cached) = self.caches.owned_program_cache.get(uri)
1963            && Arc::ptr_eq(&cached.0, &source)
1964        {
1965            Arc::clone(&cached.1)
1966        } else {
1967            let prog = Arc::new(php_ast::owned::to_owned_program(doc.program()));
1968            self.caches.shed_stale(
1969                &self.caches.owned_program_cache,
1970                crate::document::cache_registry::OWNED_PROGRAM_CACHE_CAP,
1971            );
1972            self.caches
1973                .owned_program_cache
1974                .insert(uri.clone(), (Arc::clone(&source), Arc::clone(&prog)));
1975            prog
1976        };
1977
1978        // autoload.files helpers (e.g. Laravel's tap()) must be ingested so mir sees their functions.
1979        let autoload_texts: Vec<(Arc<str>, Arc<str>)> = {
1980            let autoload_uris = self.autoload_uris.read().unwrap().clone();
1981            autoload_uris
1982                .iter()
1983                .filter_map(|auri| {
1984                    self.caches
1985                        .text_cache
1986                        .get(auri)
1987                        .map(|t| (Arc::from(auri.as_str()), Arc::clone(&*t)))
1988                })
1989                .collect()
1990        };
1991        // Bare same-namespace / use-imported class refs aren't resolved by mir's priority_index_for_ast; preload them.
1992        let class_fqns = crate::navigation::references::collect_referenced_class_fqns(&doc);
1993
1994        // ingest_file/load_class/analyze take internal salsa snapshots; a concurrent db write cancels them via resume_unwind. Retry the idempotent sequence.
1995        let _interactive = self.interactive_read_guard();
1996        let analysis = loop {
1997            let attempt = salsa::Cancelled::catch(std::panic::AssertUnwindSafe(|| {
1998                session.ingest_file(file.clone(), source.clone());
1999                for (afile, atext) in &autoload_texts {
2000                    session.ingest_file(afile.clone(), atext.clone());
2001                }
2002                for fqcn in &class_fqns {
2003                    let _ = session.load_class(fqcn);
2004                }
2005                let analyzer = mir_analyzer::FileAnalyzer::new(&session);
2006                analyzer.analyze(file.clone(), doc.source(), &owned_program, &source_map)
2007            }));
2008            match attempt {
2009                Ok(a) => {
2010                    self.analysis_compute_count.fetch_add(1, Ordering::Relaxed);
2011                    break Arc::new(a);
2012                }
2013                Err(_) => {
2014                    // A write cancelled the attempt. If it replaced THIS
2015                    // file's text the result is already obsolete (the cache
2016                    // key is the source Arc) and the editor re-requests after
2017                    // its didChange — stop burning the blocking thread.
2018                    // Writes elsewhere just retry as before.
2019                    let text_changed = self
2020                        .caches
2021                        .text_cache
2022                        .get(uri)
2023                        .is_none_or(|t| !Arc::ptr_eq(&t, &source));
2024                    if text_changed || should_cancel() {
2025                        return None;
2026                    }
2027                }
2028            }
2029        };
2030        // Compare the new FileIndex against the stored fingerprint. If
2031        // declarations changed (or this is the first analysis), bump
2032        // `decl_version` so other files' cache entries become stale. Body-only
2033        // edits leave the counter unchanged, allowing sibling files to be
2034        // served from cache on the next request.
2035        let decl_changed = self.sync_decl_fingerprint(uri, &session);
2036        // Tag with the version observed before the analysis ran, plus this
2037        // file's own bump. Reading `decl_version()` here instead would absorb
2038        // a concurrent bump from another file's mid-compute declaration change
2039        // into the tag, marking a possibly-stale result as fresh. The
2040        // conservative tag errs toward one extra recompute, never staleness.
2041        let ver = cur_ver + u64::from(decl_changed);
2042        self.caches.shed_stale(
2043            &self.caches.analysis_cache,
2044            crate::document::cache_registry::ANALYSIS_CACHE_CAP,
2045        );
2046        self.caches.touch(uri);
2047        self.caches
2048            .analysis_cache
2049            .insert(uri.clone(), (source, ver, Arc::clone(&analysis)));
2050        Some(analysis)
2051    }
2052
2053    /// Returns `(uri, doc)` for files currently open in the editor.
2054    ///
2055    /// Resolve `open_urls` (from `Backend::open_urls()`) to parsed docs.
2056    /// Files not mirrored in the salsa layer are filtered out silently.
2057    pub fn docs_for(&self, open_urls: &[Uri]) -> Vec<(Uri, Arc<ParsedDoc>)> {
2058        open_urls
2059            .iter()
2060            .filter_map(|u| self.get_doc_salsa(u).map(|d| (u.clone(), d)))
2061            .collect()
2062    }
2063
2064    /// Parsed docs for every entry in `open_urls` except `uri`.
2065    pub fn other_docs(&self, uri: &Uri, open_urls: &[Uri]) -> Vec<(Uri, Arc<ParsedDoc>)> {
2066        open_urls
2067            .iter()
2068            .filter(|u| *u != uri)
2069            .filter_map(|u| self.get_doc_salsa(u).map(|d| (u.clone(), d)))
2070            .collect()
2071    }
2072
2073    /// Compact symbol index for every mirrored file.
2074    pub fn all_indexes(&self) -> Vec<(Uri, Arc<FileIndex>)> {
2075        self.get_workspace_index_salsa().files.clone()
2076    }
2077
2078    /// Borrow-scoped alternative to `all_indexes()` for callers that only
2079    /// need the slice for the duration of one synchronous call — avoids
2080    /// cloning every `Uri` in the aggregate (`get_workspace_index_salsa()`
2081    /// itself is a cheap `Arc` clone; `all_indexes()`'s `.files.clone()` is
2082    /// the expensive part). Use `all_indexes()` instead when the result must
2083    /// be moved across an `.await`/`spawn_blocking` boundary.
2084    pub fn with_all_indexes<R>(&self, f: impl FnOnce(&[(Uri, Arc<FileIndex>)]) -> R) -> R {
2085        f(&self.get_workspace_index_salsa().files)
2086    }
2087
2088    /// Store a lazily-loaded vendor `FileIndex` in the session cache.
2089    /// Only call this for files that are not part of the normal workspace scan
2090    /// (i.e. vendor files loaded on-demand by PSR-4 navigation).
2091    pub fn cache_vendor_index(&self, uri: Uri, index: Arc<FileIndex>) {
2092        self.caches.vendor_index_cache.insert(uri, index);
2093    }
2094
2095    /// Retrieve a previously cached vendor `FileIndex`.
2096    pub fn get_vendor_index(&self, uri: &Uri) -> Option<Arc<FileIndex>> {
2097        self.caches
2098            .vendor_index_cache
2099            .get(uri)
2100            .map(|e| Arc::clone(&*e))
2101    }
2102
2103    /// Same as `all_indexes` but excludes `uri`.
2104    pub fn other_indexes(&self, uri: &Uri) -> Vec<(Uri, Arc<FileIndex>)> {
2105        self.get_workspace_index_salsa()
2106            .files
2107            .iter()
2108            .filter(|(u, _)| u != uri)
2109            .cloned()
2110            .collect()
2111    }
2112
2113    /// Parsed documents for every mirrored file (open or background-indexed).
2114    /// Suitable for full-scan operations: find-references, rename,
2115    /// call_hierarchy, code_lens.
2116    pub fn all_docs_for_scan(&self) -> Vec<(Uri, Arc<ParsedDoc>)> {
2117        let urls: Vec<Uri> = self
2118            .lsp_ws_files
2119            .iter()
2120            .filter(|e| !self.deleted_uris.contains(e.key()))
2121            .map(|e| e.key().clone())
2122            .collect();
2123        urls.into_iter()
2124            .filter_map(|u| self.get_doc_salsa(&u).map(|d| (u, d)))
2125            .collect()
2126    }
2127
2128    /// Like [`Self::all_docs_for_scan`], but only parses files whose raw text
2129    /// contains at least one of `needles` (ASCII-case-insensitive — PHP class
2130    /// names are case-insensitive, unlike the Laravel string-key prefilter's
2131    /// exact-value match). Used by callers scanning for a class/interface
2132    /// *declaration* by name (e.g. "implement missing methods"), where the
2133    /// needle set is small and a miss guarantees the file declares none of
2134    /// them.
2135    pub fn docs_for_scan_mentioning(&self, needles: &[String]) -> Vec<(Uri, Arc<ParsedDoc>)> {
2136        let Ok(finder) = aho_corasick::AhoCorasick::builder()
2137            .ascii_case_insensitive(true)
2138            .build(needles)
2139        else {
2140            return Vec::new();
2141        };
2142        let urls: Vec<Uri> = self
2143            .lsp_ws_files
2144            .iter()
2145            .filter(|e| !self.deleted_uris.contains(e.key()))
2146            .filter(|e| {
2147                self.source_text(e.key())
2148                    .is_some_and(|text| finder.is_match(text.as_ref()))
2149            })
2150            .map(|e| e.key().clone())
2151            .collect();
2152        urls.into_iter()
2153            .filter_map(|u| self.get_doc_salsa(&u).map(|d| (u, d)))
2154            .collect()
2155    }
2156
2157    /// Files whose `use` imports include `fqn` (leading `\` and ASCII case
2158    /// ignored — PHP names are case-insensitive), from the workspace symbol
2159    /// index — no parsing, no text scan. The candidate scope for `use`-line
2160    /// rewrites on file rename/delete: only importers can carry such a line.
2161    pub fn files_importing(&self, fqn: &str) -> Vec<Uri> {
2162        let target = fqn.trim_start_matches('\\');
2163        self.get_workspace_index_salsa()
2164            .files
2165            .iter()
2166            .filter(|(_, idx)| {
2167                idx.use_imports
2168                    .iter()
2169                    .any(|(_, f)| f.trim_start_matches('\\').eq_ignore_ascii_case(target))
2170            })
2171            .map(|(u, _)| u.clone())
2172            .collect()
2173    }
2174}
2175
2176/// `prefix` equals `whole` or is a `\`-segment-aligned prefix of it,
2177/// ASCII-case-insensitively (PHP name semantics). `"Foo\Bar"` is a segment
2178/// prefix of `"Foo\Bar\Baz"` but `"Foo\Ba"` is not.
2179fn fqn_segment_prefix(prefix: &str, whole: &str) -> bool {
2180    let (p, w) = (prefix.as_bytes(), whole.as_bytes());
2181    w.len() >= p.len()
2182        && w[..p.len()].eq_ignore_ascii_case(p)
2183        && (w.len() == p.len() || w[p.len()] == b'\\')
2184}
2185
2186/// `uri`'s path has a `vendor` component, e.g. `file:///proj/vendor/acme/Lib.php`
2187/// or the nested-monorepo `file:///proj/packages/api/vendor/x/Y.php`. URI
2188/// paths are always `/`-separated regardless of platform, so a plain
2189/// component split is enough — no OS path handling needed.
2190fn is_vendor_uri(uri: &Uri) -> bool {
2191    uri.path().split('/').any(|seg| seg == "vendor")
2192}
2193
2194/// The narrowing decision for a method's reference scope, computed without
2195/// running the expensive part (the FQN/text-needle workspace scan) so a
2196/// batch caller can pool that scan across many methods.
2197enum MethodScopePlan {
2198    /// Fully resolved already — no workspace scan needed at all (private:
2199    /// the declaring file alone; protected: the declaring file plus its
2200    /// resolved subtype set).
2201    Files(Vec<Uri>),
2202    /// A public static method or constructor: resolvable to the declaring
2203    /// class's FQN-reachable files unioned with files matching one extra
2204    /// text needle. This is the case a batch caller pools.
2205    NeedsScan {
2206        fqns: Vec<Arc<str>>,
2207        extra_needle: String,
2208    },
2209    /// No narrowing possible (instance methods, an unresolvable owner, or
2210    /// the boot scan hasn't finished) — full workspace scope.
2211    FullWorkspace,
2212}
2213
2214/// One [`DocumentStore::resolve_reachability_queries`] request: the target
2215/// FQNs for the namespace/import rules, plus raw text needles matched the
2216/// same way (ASCII-case-insensitive substring).
2217struct ReachabilityQuery {
2218    fqns: Vec<Arc<str>>,
2219    extra_needles: Vec<String>,
2220}
2221
2222#[cfg(test)]
2223mod tests {
2224    use super::*;
2225
2226    fn uri(path: &str) -> Uri {
2227        format!("file://{path}").parse::<Uri>().unwrap()
2228    }
2229
2230    /// Phase E4: open-file state lives on `Backend`, not `DocumentStore`.
2231    /// Tests that need to simulate "file is open" just mirror the text into
2232    /// the salsa input — the open/closed distinction is enforced by the
2233    /// caller (Backend) in production.
2234    fn open(store: &DocumentStore, u: Uri, text: String) {
2235        store.mirror_text(&u, &text);
2236    }
2237
2238    // Removed `salsa_codebase_aggregates_all_files`: the salsa-side codebase
2239    // aggregation was deleted with the mir 0.22 migration. Equivalent
2240    // behaviour is now covered by mir-analyzer's own session tests.
2241
2242    // Spawn a thread that calls `yield_to_interactive_reads` and hands back
2243    // the elapsed time it spent waiting. It signals `ready` right before
2244    // entering the call, so the caller can block on that signal instead of
2245    // guessing how long thread start-up takes — a fixed sleep here would
2246    // race the thread scheduler, and that race is exactly what made this
2247    // test flaky under load (e.g. Windows CI runners have much coarser and
2248    // less predictable thread-spawn latency than Linux/macOS).
2249    fn spawn_yield_waiter(
2250        store: &Arc<DocumentStore>,
2251    ) -> (
2252        std::sync::mpsc::Receiver<()>,
2253        std::thread::JoinHandle<std::time::Duration>,
2254    ) {
2255        let (ready_tx, ready_rx) = std::sync::mpsc::channel();
2256        let store = Arc::clone(store);
2257        let handle = std::thread::spawn(move || {
2258            ready_tx.send(()).unwrap();
2259            let t = std::time::Instant::now();
2260            store.yield_to_interactive_reads();
2261            t.elapsed()
2262        });
2263        (ready_rx, handle)
2264    }
2265
2266    #[test]
2267    fn scan_writers_yield_while_interactive_reads_are_in_flight() {
2268        let store = Arc::new(DocumentStore::new());
2269
2270        // No guard: returns immediately.
2271        let t = std::time::Instant::now();
2272        store.yield_to_interactive_reads();
2273        assert!(t.elapsed() < std::time::Duration::from_millis(200));
2274
2275        // Guard held on another thread: the writer waits until it drops.
2276        // We only start the hold timer once the waiter has confirmed it's
2277        // about to poll, so the guard can never be dropped before the
2278        // writer starts waiting on it.
2279        let guard = store.interactive_read_guard();
2280        let (ready_rx, waiter) = spawn_yield_waiter(&store);
2281        ready_rx.recv().unwrap();
2282        const HOLD: std::time::Duration = std::time::Duration::from_millis(60);
2283        std::thread::sleep(HOLD);
2284        drop(guard);
2285        let waited = waiter.join().unwrap();
2286        assert!(
2287            waited >= HOLD,
2288            "writer should have paused at least as long as the read guard was held (waited {waited:?})"
2289        );
2290
2291        // Guard fully released: nested guards count correctly.
2292        let g1 = store.interactive_read_guard();
2293        let g2 = store.interactive_read_guard();
2294        drop(g1);
2295        let (ready_rx, waiter) = spawn_yield_waiter(&store);
2296        ready_rx.recv().unwrap();
2297        const HOLD2: std::time::Duration = std::time::Duration::from_millis(30);
2298        std::thread::sleep(HOLD2);
2299        drop(g2);
2300        let waited = waiter.join().unwrap();
2301        assert!(
2302            waited >= HOLD2,
2303            "writer should have paused while the nested read guard was held (waited {waited:?})"
2304        );
2305        store.yield_to_interactive_reads(); // all guards dropped — no wait
2306    }
2307
2308    #[test]
2309    fn index_registers_file_in_salsa() {
2310        let store = DocumentStore::new();
2311        store.ingest(uri("/lib.php"), "<?php\nfunction lib_fn() {}");
2312        let idx = store.get_index_salsa(&uri("/lib.php")).unwrap();
2313        assert_eq!(idx.functions.len(), 1);
2314        assert_eq!(&*idx.functions[0].name, "lib_fn");
2315    }
2316
2317    #[test]
2318    fn remove_hides_file_from_index() {
2319        let store = DocumentStore::new();
2320        let u = uri("/lib.php");
2321        store.ingest(u.clone(), "<?php");
2322        store.remove(&u);
2323        assert!(store.get_index_salsa(&u).is_none());
2324    }
2325
2326    #[test]
2327    fn remove_frees_mir_source_text() {
2328        let store = DocumentStore::new();
2329        let u = uri("/lib.php");
2330        let big_text = format!("<?php\n{}", "// pad\n".repeat(1000));
2331        store.ingest(u.clone(), &big_text);
2332
2333        let session = store.current_analysis_session();
2334        let sf = session.lookup_source_file(u.as_str()).unwrap();
2335        let len_before = store.snapshot_mir_query(|db| sf.text(db).len());
2336        assert!(len_before > 1000, "sanity: source text should be mirrored");
2337
2338        store.remove(&u);
2339
2340        let len_after = store.snapshot_mir_query(|db| sf.text(db).len());
2341        assert_eq!(
2342            len_after, 0,
2343            "remove() must free mir's SourceFile text, not just hide the file from indexes"
2344        );
2345    }
2346
2347    #[test]
2348    fn remove_shrinks_file_index_memo() {
2349        let store = DocumentStore::new();
2350        let u = uri("/lib.php");
2351        store.ingest(u.clone(), "<?php\nclass BigClassBeforeDelete {}\n");
2352
2353        let wf = store.lsp_ws_files.get(&u).map(|e| *e).unwrap();
2354        let idx_before =
2355            store.snapshot_mir_query(|db| crate::db::mir_queries::file_index(db, wf).0.clone());
2356        assert_eq!(
2357            idx_before.classes.len(),
2358            1,
2359            "sanity: class should be indexed"
2360        );
2361
2362        store.remove(&u);
2363
2364        let idx_after =
2365            store.snapshot_mir_query(|db| crate::db::mir_queries::file_index(db, wf).0.clone());
2366        assert!(
2367            idx_after.classes.is_empty(),
2368            "remove() must shrink file_index's memo (no LRU cap, unlike parsed_doc/symbol_map) \
2369             against the emptied text, not hold the pre-deletion FileIndex forever"
2370        );
2371    }
2372
2373    #[test]
2374    fn remove_and_reopen_reuses_source_file_handle() {
2375        let store = DocumentStore::new();
2376        let u = uri("/lib.php");
2377        store.ingest(u.clone(), "<?php");
2378        let ft_before = store.source_file(&u).unwrap();
2379        store.remove(&u);
2380        assert!(
2381            store.source_file(&u).is_none(),
2382            "deleted file should be hidden"
2383        );
2384        store.mirror_text(&u, "<?php");
2385        let ft_after = store.source_file(&u).unwrap();
2386        assert!(
2387            ft_before == ft_after,
2388            "reopen must reuse the same FileText handle"
2389        );
2390    }
2391
2392    #[test]
2393    fn delete_reopen_churn_does_not_amplify_salsa_inputs() {
2394        let store = DocumentStore::new();
2395        let uris: Vec<Uri> = (0..20).map(|i| uri(&format!("/churn/f{i}.php"))).collect();
2396        for u in &uris {
2397            store.ingest(u.clone(), "<?php class A {}");
2398        }
2399        let count_before = store.source_files_len();
2400        for _ in 0..10 {
2401            for u in &uris {
2402                store.remove(u);
2403            }
2404            for u in &uris {
2405                store.ingest(u.clone(), "<?php class A {}");
2406            }
2407        }
2408        assert_eq!(
2409            store.source_files_len(),
2410            count_before,
2411            "delete-reopen cycles must not create new salsa inputs (L1-B regression guard)"
2412        );
2413    }
2414
2415    #[test]
2416    fn warm_analysis_sweep_preserves_reference_results() {
2417        let store = DocumentStore::new();
2418        store.ingest(
2419            uri("/svc.php"),
2420            "<?php\nnamespace App;\nclass Svc { public function run(): void {} }",
2421        );
2422        store.ingest(
2423            uri("/caller.php"),
2424            "<?php\nnamespace App;\nclass Caller {\n    private Svc $s;\n    public function go(): void { $this->s->run(); }\n}",
2425        );
2426        let sym = mir_analyzer::Name::method("App\\Svc", "run");
2427        let files: Vec<Arc<str>> = [uri("/svc.php"), uri("/caller.php")]
2428            .iter()
2429            .map(|u| Arc::from(u.as_str()))
2430            .collect();
2431        let cold = store.indexed_references(&sym, &files, false, None);
2432        assert_eq!(cold.len(), 1, "caller.php references Svc::run once");
2433        expect_test::expect![[r#"
2434            [
2435                (
2436                    "file:///caller.php",
2437                    4,
2438                    43,
2439                    46,
2440                ),
2441            ]
2442        "#]]
2443        .assert_debug_eq(&cold);
2444
2445        let cancel = store.begin_warm_sweep();
2446        store.warm_analysis_sweep(&[], &cancel);
2447        let warm = store.indexed_references(&sym, &files, false, None);
2448        assert_eq!(cold, warm, "sweep must not change reference results");
2449
2450        // An edit after the sweep is still picked up (memos revalidate).
2451        store.ingest(
2452            uri("/caller.php"),
2453            "<?php\nnamespace App;\nclass Caller {\n    private Svc $s;\n    public function go(): void { $this->s->run(); $this->s->run(); }\n}",
2454        );
2455        let cancel = store.begin_warm_sweep();
2456        store.warm_analysis_sweep(&[], &cancel);
2457        let after_edit = store.indexed_references(&sym, &files, false, None);
2458        assert_eq!(after_edit.len(), 2, "re-sweep must see the new reference");
2459        expect_test::expect![[r#"
2460            [
2461                (
2462                    "file:///caller.php",
2463                    4,
2464                    43,
2465                    46,
2466                ),
2467                (
2468                    "file:///caller.php",
2469                    4,
2470                    60,
2471                    63,
2472                ),
2473            ]
2474        "#]]
2475        .assert_debug_eq(&after_edit);
2476    }
2477
2478    #[test]
2479    fn sweep_priority_puts_open_files_and_their_dependencies_first() {
2480        let store = DocumentStore::new();
2481        store.ingest(
2482            uri("/a.php"),
2483            "<?php\nnamespace App;\nclass A { public function go(B $b): void {} }",
2484        );
2485        store.ingest(uri("/b.php"), "<?php\nnamespace App;\nclass B {}");
2486        store.ingest(uri("/unrelated.php"), "<?php\nnamespace App;\nclass C {}");
2487        store.mark_index_ready();
2488
2489        let front = store.sweep_priority_files(&[uri("/a.php")]);
2490        let front: Vec<&str> = front.iter().map(|f| f.as_ref()).collect();
2491        assert_eq!(
2492            front,
2493            vec![uri("/a.php").as_str(), uri("/b.php").as_str()],
2494            "open file first, then the files declaring its referenced classes"
2495        );
2496    }
2497
2498    #[test]
2499    fn sweep_candidate_files_excludes_vendor_from_the_ambient_tail() {
2500        let store = DocumentStore::new();
2501        store.ingest(
2502            uri("/vendor/acme/Lib.php"),
2503            "<?php\nnamespace Acme;\nclass Lib {}",
2504        );
2505        store.ingest(uri("/src/Own.php"), "<?php\nnamespace App;\nclass Own {}");
2506        store.mark_index_ready();
2507
2508        let files = store.sweep_candidate_files(&[]);
2509        let files: Vec<&str> = files.iter().map(|f| f.as_ref()).collect();
2510        assert!(
2511            !files.contains(&uri("/vendor/acme/Lib.php").as_str()),
2512            "vendor file must not be in the ambient (non-priority) sweep list: {files:?}"
2513        );
2514        assert!(
2515            files.contains(&uri("/src/Own.php").as_str()),
2516            "non-vendor file must still be in the ambient sweep list: {files:?}"
2517        );
2518    }
2519
2520    #[test]
2521    fn sweep_candidate_files_still_includes_an_explicitly_prioritized_vendor_file() {
2522        let store = DocumentStore::new();
2523        store.ingest(
2524            uri("/vendor/acme/Lib.php"),
2525            "<?php\nnamespace Acme;\nclass Lib {}",
2526        );
2527        store.mark_index_ready();
2528
2529        // Opening the vendor file directly makes it a priority target, which
2530        // bypasses the ambient-sweep vendor exclusion.
2531        let files = store.sweep_candidate_files(&[uri("/vendor/acme/Lib.php")]);
2532        let files: Vec<&str> = files.iter().map(|f| f.as_ref()).collect();
2533        assert!(
2534            files.contains(&uri("/vendor/acme/Lib.php").as_str()),
2535            "an explicitly opened/prioritized vendor file must still be warmed: {files:?}"
2536        );
2537    }
2538
2539    #[test]
2540    fn is_vendor_uri_matches_top_level_and_nested_vendor_dirs_only() {
2541        assert!(is_vendor_uri(&uri("/proj/vendor/acme/Lib.php")));
2542        assert!(is_vendor_uri(&uri(
2543            "/proj/packages/api/vendor/acme/Lib.php"
2544        )));
2545        assert!(!is_vendor_uri(&uri("/proj/src/Vendored/Lib.php")));
2546        assert!(!is_vendor_uri(&uri("/proj/src/VendorLib.php")));
2547    }
2548
2549    #[test]
2550    fn warm_analysis_sweep_stops_on_cancel() {
2551        let store = DocumentStore::new();
2552        for i in 0..40 {
2553            store.ingest(
2554                uri(&format!("/f{i}.php")),
2555                "<?php\nclass A { public function m(): int { return 1; } }",
2556            );
2557        }
2558        let cancel = store.begin_warm_sweep();
2559        cancel.cancel();
2560        // Pre-cancelled token: returns without analyzing (must not hang/panic).
2561        store.warm_analysis_sweep(&[], &cancel);
2562        // A newer sweep's token supersedes the old one.
2563        let old = store.begin_warm_sweep();
2564        let _new = store.begin_warm_sweep();
2565        assert!(old.is_cancelled());
2566    }
2567
2568    #[test]
2569    fn all_indexes_includes_every_mirrored_file() {
2570        let store = DocumentStore::new();
2571        open(&store, uri("/a.php"), "<?php\nfunction a() {}".to_string());
2572        store.ingest(uri("/b.php"), "<?php\nfunction b() {}");
2573        let indexes = store.all_indexes();
2574        let mut uris: Vec<&str> = indexes.iter().map(|(u, _)| u.as_str()).collect();
2575        uris.sort();
2576        assert_eq!(uris, vec![uri("/a.php").as_str(), uri("/b.php").as_str()]);
2577    }
2578
2579    #[test]
2580    fn other_indexes_excludes_current_uri() {
2581        let store = DocumentStore::new();
2582        open(&store, uri("/a.php"), "<?php\nfunction a() {}".to_string());
2583        open(&store, uri("/b.php"), "<?php\nfunction b() {}".to_string());
2584        let others = store.other_indexes(&uri("/a.php"));
2585        assert_eq!(others.len(), 1);
2586        assert_eq!(others[0].0.as_str(), uri("/b.php").as_str());
2587    }
2588
2589    #[test]
2590    fn other_docs_excludes_current_uri() {
2591        let store = DocumentStore::new();
2592        let ua = uri("/a.php");
2593        let ub = uri("/b.php");
2594        open(&store, ua.clone(), "<?php\nfunction a() {}".to_string());
2595        open(&store, ub.clone(), "<?php\nfunction b() {}".to_string());
2596        let open_urls = vec![ua.clone(), ub.clone()];
2597        let others = store.other_docs(&ua, &open_urls);
2598        assert_eq!(others.len(), 1);
2599        assert_eq!(others[0].0.as_str(), ub.as_str());
2600    }
2601
2602    #[test]
2603    fn evict_token_cache_removes_entry() {
2604        let store = DocumentStore::new();
2605        let u = uri("/a.php");
2606        open(&store, u.clone(), "<?php".to_string());
2607        store.store_token_cache(&u, "id1".to_string(), Arc::new(vec![]));
2608        assert!(store.get_token_cache(&u, "id1").is_some());
2609        store.evict_token_cache(&u);
2610        assert!(store.get_token_cache(&u, "id1").is_none());
2611    }
2612
2613    #[test]
2614    fn vendor_index_cache_evicted_on_remove() {
2615        let store = DocumentStore::new();
2616        let u = uri("/vendor/acme/lib.php");
2617        store.ingest(u.clone(), "<?php\nclass Lib {}");
2618        let idx = store.get_index_salsa(&u).unwrap();
2619        store.cache_vendor_index(u.clone(), idx.clone());
2620        let cached = store.get_vendor_index(&u).unwrap();
2621        assert!(
2622            Arc::ptr_eq(&idx, &cached),
2623            "get_vendor_index must return the exact FileIndex that was cached"
2624        );
2625        store.remove(&u);
2626        assert!(store.get_vendor_index(&u).is_none());
2627    }
2628
2629    #[test]
2630    fn index_populates_file_index_with_symbols() {
2631        let store = DocumentStore::new();
2632        store.ingest(uri("/a.php"), "<?php\nfunction hello() {}");
2633        let idx = store.get_index_salsa(&uri("/a.php")).unwrap();
2634        assert_eq!(idx.functions.len(), 1);
2635        assert_eq!(&*idx.functions[0].name, "hello");
2636    }
2637
2638    #[test]
2639    fn open_populates_file_index_with_symbols() {
2640        let store = DocumentStore::new();
2641        open(&store, uri("/a.php"), "<?php\nclass Foo {}".to_string());
2642        let idx = store.get_index_salsa(&uri("/a.php")).unwrap();
2643        assert_eq!(idx.classes.len(), 1);
2644        assert_eq!(&*idx.classes[0].name, "Foo");
2645    }
2646
2647    // ── Mirror invariants ────────────────────────────────────────────────
2648    //
2649    // Every mutation path that changes file text must keep the salsa layer
2650    // consistent. These tests walk a set-edit-reopen cycle and assert that
2651    // the salsa-derived `FileIndex` reflects the latest text at each step.
2652
2653    fn names_of(idx: &FileIndex) -> Vec<String> {
2654        let mut out: Vec<String> = idx.classes.iter().map(|c| c.name.to_string()).collect();
2655        out.extend(idx.functions.iter().map(|f| f.name.to_string()));
2656        out.sort();
2657        out
2658    }
2659
2660    fn salsa_index_names(store: &DocumentStore, url: &Uri) -> Vec<String> {
2661        store
2662            .snapshot_query_file_index(url)
2663            .map(|idx| names_of(&idx))
2664            .unwrap_or_default()
2665    }
2666
2667    #[test]
2668    fn mirror_tracks_repeated_edits() {
2669        let store = DocumentStore::new();
2670        let u = uri("/mirror.php");
2671
2672        open(&store, u.clone(), "<?php\nclass A {}".to_string());
2673        assert_eq!(salsa_index_names(&store, &u), vec!["A".to_string()]);
2674
2675        open(
2676            &store,
2677            u.clone(),
2678            "<?php\nclass A {}\nclass B {}".to_string(),
2679        );
2680        assert_eq!(
2681            salsa_index_names(&store, &u),
2682            vec!["A".to_string(), "B".to_string()]
2683        );
2684
2685        open(&store, u.clone(), "<?php\nfunction greet() {}".to_string());
2686        assert_eq!(salsa_index_names(&store, &u), vec!["greet".to_string()]);
2687    }
2688
2689    #[test]
2690    fn mirror_tracks_ingest_and_ingest_from_doc() {
2691        let store = DocumentStore::new();
2692
2693        // Background `index(url, text)` path.
2694        let u1 = uri("/bg1.php");
2695        store.ingest(u1.clone(), "<?php\nclass Bg1 {}");
2696        assert_eq!(salsa_index_names(&store, &u1), vec!["Bg1".to_string()]);
2697
2698        // `ingest_from_doc(url, &doc)` path (workspace-scan Phase 2).
2699        let u2 = uri("/bg2.php");
2700        let doc = crate::analysis::diagnostics::parse_document_no_diags(
2701            "<?php\nclass Bg2 {}\nfunction f() {}",
2702        );
2703        store.ingest_from_doc(u2.clone(), &doc);
2704        assert_eq!(
2705            salsa_index_names(&store, &u2),
2706            vec!["Bg2".to_string(), "f".to_string()]
2707        );
2708    }
2709
2710    /// G3: confirms the `parsed_cache` actually hits — two consecutive
2711    /// `get_doc_salsa` calls on unchanged text return the same `Arc`
2712    /// (pointer equality), and an edit forces a miss that produces a
2713    /// different `Arc`.
2714    /// parsed_cache must stay bounded — inserting more than
2715    /// `PARSED_CACHE_CAP` unique URLs must not cause unbounded growth.
2716    /// Eviction is probabilistic, so we only assert the bound, not which
2717    /// Seeding a cached index for a URL that was never mirrored is a no-op
2718    /// (returns `false`) — avoids silently allocating SourceFiles outside
2719    /// `mirror_text`'s control.
2720    #[test]
2721    fn seed_cached_index_noops_for_unknown_uri() {
2722        let store = DocumentStore::new();
2723        let u = uri("/never_mirrored.php");
2724        let index = Arc::new(crate::index::file_index::FileIndex::default());
2725        assert!(!store.seed_cached_index(&u, index));
2726    }
2727
2728    /// entries survive.
2729    #[test]
2730    fn parsed_cache_stays_bounded_under_many_inserts() {
2731        let store = DocumentStore::new();
2732        use crate::document::cache_registry::PARSED_CACHE_CAP;
2733        let overflow = PARSED_CACHE_CAP + 100;
2734        for i in 0..overflow {
2735            let u = uri(&format!("/cap/file{i}.php"));
2736            store.ingest(u.clone(), "<?php\nclass A {}");
2737            // Force a parsed_cache insert via get_doc_salsa.
2738            let _ = store.get_doc_salsa(&u);
2739        }
2740        assert!(
2741            store.caches.parsed_cache.len() <= PARSED_CACHE_CAP,
2742            "parsed_cache grew to {} entries (cap {})",
2743            store.caches.parsed_cache.len(),
2744            PARSED_CACHE_CAP
2745        );
2746    }
2747
2748    /// The shed is recency-based: an entry touched right before overflow (an
2749    /// open file being edited) must survive a sweep of cold inserts, while
2750    /// untouched cold entries are the ones dropped.
2751    #[test]
2752    fn parsed_cache_shed_keeps_recently_used_entries() {
2753        let store = DocumentStore::new();
2754        use crate::document::cache_registry::PARSED_CACHE_CAP;
2755
2756        let hot = uri("/cap/hot.php");
2757        store.ingest(hot.clone(), "<?php\nclass Hot {}");
2758        let _ = store.get_doc_salsa(&hot);
2759
2760        // Fill to just below the cap with cold entries, then re-touch the hot
2761        // file so it is the most recently used entry at shed time.
2762        for i in 0..(PARSED_CACHE_CAP - 2) {
2763            let u = uri(&format!("/cap/cold{i}.php"));
2764            store.ingest(u.clone(), "<?php\nclass A {}");
2765            let _ = store.get_doc_salsa(&u);
2766        }
2767        let _ = store.get_doc_salsa(&hot);
2768
2769        // Overflow: this insert triggers the shed of the LRU half.
2770        let trigger = uri("/cap/trigger.php");
2771        store.ingest(trigger.clone(), "<?php\nclass T {}");
2772        let _ = store.get_doc_salsa(&trigger);
2773
2774        assert!(
2775            store.caches.parsed_cache.contains_key(&hot),
2776            "recently-touched entry must survive the recency shed"
2777        );
2778        assert!(
2779            store.caches.parsed_cache.len() <= PARSED_CACHE_CAP,
2780            "cache must stay bounded after the shed"
2781        );
2782    }
2783
2784    /// `analysis_cache` must stay bounded when many distinct files are
2785    /// analyzed across a session (multi-hour usage previously grew it
2786    /// without limit).
2787    #[test]
2788    fn analysis_cache_stays_bounded_under_many_files() {
2789        use crate::document::cache_registry::ANALYSIS_CACHE_CAP;
2790        let store = DocumentStore::new();
2791        let overflow = ANALYSIS_CACHE_CAP + 16;
2792        for i in 0..overflow {
2793            let u = uri(&format!("/acap/file{i}.php"));
2794            store.ingest(u.clone(), "<?php\nfunction f() { return 1; }");
2795            let _ = store.cached_analysis(&u);
2796        }
2797        assert!(
2798            store.caches.analysis_cache.len() <= ANALYSIS_CACHE_CAP,
2799            "analysis_cache grew to {} entries (cap {})",
2800            store.caches.analysis_cache.len(),
2801            ANALYSIS_CACHE_CAP
2802        );
2803    }
2804
2805    #[test]
2806    fn get_doc_salsa_cache_hits_across_calls() {
2807        let store = DocumentStore::new();
2808        let u = uri("/g3_cache.php");
2809        open(&store, u.clone(), "<?php\nclass G3 {}".to_string());
2810
2811        let a = store.get_doc_salsa(&u).unwrap();
2812        let b = store.get_doc_salsa(&u).unwrap();
2813        assert!(
2814            Arc::ptr_eq(&a, &b),
2815            "parsed_cache hit should yield the same Arc across calls"
2816        );
2817
2818        open(&store, u.clone(), "<?php\nclass G3b {}".to_string());
2819        let c = store.get_doc_salsa(&u).unwrap();
2820        assert!(
2821            !Arc::ptr_eq(&a, &c),
2822            "edit should invalidate the parsed_cache entry"
2823        );
2824    }
2825
2826    #[test]
2827    fn get_doc_salsa_returns_some_for_mirrored_files() {
2828        // Phase E4: `get_doc_salsa` no longer gates on open-state. The
2829        // open/closed distinction now lives on `Backend::get_doc`.
2830        let store = DocumentStore::new();
2831        let u = uri("/e4_doc.php");
2832        store.ingest(u.clone(), "<?php\nclass P {}");
2833        let doc = store.get_doc_salsa(&u).unwrap();
2834        match &doc.program().stmts[0].kind {
2835            php_ast::StmtKind::Class(c) => {
2836                assert_eq!(c.name.and_then(|n| n.as_str()), Some("P"))
2837            }
2838            other => panic!("expected a class declaration, got {other:?}"),
2839        }
2840    }
2841
2842    #[test]
2843    fn get_doc_snapshot_or_stale_matches_salsa_on_settled_buffer() {
2844        let store = DocumentStore::new();
2845        let u = uri("/stale_settled.php");
2846        open(&store, u.clone(), "<?php\nclass S {}".to_string());
2847        let fresh = store.get_doc_salsa(&u).unwrap();
2848        let stale = store.get_doc_snapshot_or_stale(&u).unwrap();
2849        assert!(
2850            Arc::ptr_eq(&fresh, &stale),
2851            "on a settled buffer the stale-tolerant accessor must serve the cached parse"
2852        );
2853    }
2854
2855    /// WS1: the cursor-triggered highlight accessor must stay responsive under a
2856    /// sustained write stream — never spin (the deadline join hangs if it does)
2857    /// and always yield a fresh or last-good parse for an open file.
2858    #[test]
2859    fn get_doc_snapshot_or_stale_stays_responsive_under_writes() {
2860        use std::sync::Arc;
2861        use std::thread;
2862        use std::time::{Duration, Instant};
2863
2864        let store = Arc::new(DocumentStore::new());
2865        let urls: Vec<Uri> = (0..8).map(|i| uri(&format!("/hl{i}.php"))).collect();
2866        for (i, u) in urls.iter().enumerate() {
2867            open(&store, u.clone(), format!("<?php\nclass H{i} {{}}"));
2868            // Warm the parse cache so the stale fallback always has a last-good doc.
2869            assert!(store.get_doc_salsa(u).is_some());
2870        }
2871
2872        let deadline = Instant::now() + Duration::from_millis(400);
2873        let mut handles = Vec::new();
2874
2875        {
2876            let store = Arc::clone(&store);
2877            let urls = urls.clone();
2878            handles.push(thread::spawn(move || {
2879                let mut rev = 0u32;
2880                while Instant::now() < deadline {
2881                    for u in &urls {
2882                        store.mirror_text(u, &format!("<?php\nclass H {{}}\n// rev {rev}"));
2883                    }
2884                    rev += 1;
2885                }
2886            }));
2887        }
2888
2889        for _ in 0..4 {
2890            let store = Arc::clone(&store);
2891            let urls = urls.clone();
2892            handles.push(thread::spawn(move || {
2893                while Instant::now() < deadline {
2894                    for u in &urls {
2895                        assert!(
2896                            store.get_doc_snapshot_or_stale(u).is_some(),
2897                            "open file must resolve to a fresh or stale parse, never None"
2898                        );
2899                    }
2900                }
2901            }));
2902        }
2903
2904        for h in handles {
2905            h.join()
2906                .expect("stale accessor must not panic or spin under concurrent writes");
2907        }
2908    }
2909
2910    /// WS1 A/B measurement (not a pass/fail guard): compares the old unbounded
2911    /// `get_doc_salsa` against the new bounded `get_doc_snapshot_or_stale` under
2912    /// an identical continuous write stream. Run explicitly:
2913    /// `cargo test --lib ws1_ab_latency -- --ignored --nocapture`.
2914    #[test]
2915    #[ignore = "timing measurement; run with --ignored --nocapture"]
2916    fn ws1_ab_latency_under_write_pressure() {
2917        use std::sync::Arc;
2918        use std::sync::atomic::{AtomicBool, Ordering};
2919        use std::thread;
2920        use std::time::{Duration, Instant};
2921
2922        // A large file: each parse is long enough that a concurrent write can
2923        // cancel it mid-flight — the condition under which the unbounded retry
2924        // actually spins (a tiny file parses faster than the writer's gap).
2925        fn big_source(rev: u32) -> String {
2926            let mut s = String::from("<?php\nclass Big {\n");
2927            for i in 0..3000 {
2928                s.push_str(&format!(
2929                    "    public function m{i}(int $a, string $b): int {{ return $a + {i}; }}\n"
2930                ));
2931            }
2932            s.push_str(&format!("}}\n// rev {rev}\n"));
2933            s
2934        }
2935
2936        fn measure(
2937            store: &Arc<DocumentStore>,
2938            url: &Uri,
2939            writers: usize,
2940            label: &str,
2941            call: impl Fn(&DocumentStore, &Uri) -> bool,
2942        ) {
2943            let stop = Arc::new(AtomicBool::new(false));
2944            let mut writer_handles = Vec::new();
2945            for w in 0..writers {
2946                let store = Arc::clone(store);
2947                let url = url.clone();
2948                let stop = Arc::clone(&stop);
2949                writer_handles.push(thread::spawn(move || {
2950                    let mut rev = (w as u32) * 1_000_000;
2951                    while !stop.load(Ordering::Relaxed) {
2952                        store.mirror_text(&url, &big_source(rev));
2953                        rev = rev.wrapping_add(1);
2954                    }
2955                }));
2956            }
2957            thread::sleep(Duration::from_millis(30)); // let the writers get hot
2958
2959            let n = 100usize;
2960            let mut max = Duration::ZERO;
2961            let mut total = Duration::ZERO;
2962            for _ in 0..n {
2963                let t = Instant::now();
2964                let ok = call(store, url);
2965                let dt = t.elapsed();
2966                assert!(ok, "{label}: accessor returned None");
2967                max = max.max(dt);
2968                total += dt;
2969            }
2970            stop.store(true, Ordering::Relaxed);
2971            for h in writer_handles {
2972                h.join().unwrap();
2973            }
2974            println!(
2975                "{label}: {n} calls, {writers} writers — max {:>10.3?}, mean {:>10.3?}",
2976                max,
2977                total / n as u32
2978            );
2979        }
2980
2981        let store = Arc::new(DocumentStore::new());
2982        let url = uri("/perf_big.php");
2983        open(&store, url.clone(), big_source(0));
2984        assert!(store.get_doc_salsa(&url).is_some()); // warm the cache
2985
2986        for writers in [2usize, 4] {
2987            measure(
2988                &store,
2989                &url,
2990                writers,
2991                "NEW get_doc_snapshot_or_stale",
2992                |s, u| s.get_doc_snapshot_or_stale(u).is_some(),
2993            );
2994            measure(
2995                &store,
2996                &url,
2997                writers,
2998                "OLD get_doc_salsa (unbounded)  ",
2999                |s, u| s.get_doc_salsa(u).is_some(),
3000            );
3001        }
3002    }
3003
3004    #[test]
3005    fn get_salsa_accessors_return_none_for_unknown_uri() {
3006        let store = DocumentStore::new();
3007        let u = uri("/never-seen.php");
3008        assert!(store.get_doc_salsa(&u).is_none());
3009        assert!(store.get_index_salsa(&u).is_none());
3010    }
3011
3012    /// Phase E1: concurrent readers and writers must not deadlock, panic, or
3013    /// return stale data. Writers briefly bump inputs while readers are
3014    /// running on cloned snapshots; any `salsa::Cancelled` raised on the
3015    /// reader side must be caught and retried by `snapshot_query`.
3016    ///
3017    /// The salsa surface (`get_doc_salsa`, `get_index_salsa`) is protected by
3018    /// `snapshot_query`'s last-resort host-lock fallback.
3019    #[test]
3020    fn concurrent_reads_and_writes_do_not_panic() {
3021        use std::sync::Arc;
3022        use std::thread;
3023        use std::time::{Duration, Instant};
3024
3025        let store = Arc::new(DocumentStore::new());
3026        let urls: Vec<Uri> = (0..8).map(|i| uri(&format!("/f{i}.php"))).collect();
3027        for (i, u) in urls.iter().enumerate() {
3028            open(&store, u.clone(), format!("<?php\nclass C{i} {{}}"));
3029        }
3030
3031        let deadline = Instant::now() + Duration::from_millis(400);
3032        let mut handles = Vec::new();
3033
3034        // Writer thread: keep bumping every file's text.
3035        {
3036            let store = Arc::clone(&store);
3037            let urls = urls.clone();
3038            handles.push(thread::spawn(move || {
3039                let mut rev = 0u32;
3040                while Instant::now() < deadline {
3041                    for u in &urls {
3042                        let text = format!("<?php\nclass C{{}}\n// rev {rev}");
3043                        store.mirror_text(u, &text);
3044                    }
3045                    rev += 1;
3046                }
3047            }));
3048        }
3049
3050        // Reader threads: hammer the salsa accessors.
3051        for _ in 0..4 {
3052            let store = Arc::clone(&store);
3053            let urls = urls.clone();
3054            handles.push(thread::spawn(move || {
3055                while Instant::now() < deadline {
3056                    for u in &urls {
3057                        let _ = store.get_doc_salsa(u);
3058                        let _ = store.get_index_salsa(u);
3059                    }
3060                    // Post mir 0.22: codebase + refs live in the session,
3061                    // not salsa. Concurrent-read smoke is limited to the
3062                    // remaining salsa surface (parsed_doc, file_index).
3063                }
3064            }));
3065        }
3066
3067        for h in handles {
3068            h.join().expect("no panic under concurrent read/write");
3069        }
3070    }
3071
3072    /// PSR-4 lazy-loading: `get_semantic_issues_salsa` must not emit
3073    /// `UndefinedClass` for a class that is PSR-4-resolvable on disk, even
3074    /// when the dependency file is not yet in `source_files`.
3075    #[test]
3076    fn psr4_lazy_load_suppresses_undefined_class() {
3077        let tmp = tempfile::tempdir().unwrap();
3078
3079        // Write Entity.php to disk (not mirrored into the store).
3080        std::fs::create_dir_all(tmp.path().join("src/Model")).unwrap();
3081        std::fs::write(
3082            tmp.path().join("src/Model/Entity.php"),
3083            "<?php\nnamespace App\\Model;\nclass Entity {}\n",
3084        )
3085        .unwrap();
3086
3087        // Write composer.json so Psr4Map::load can build the map.
3088        std::fs::write(
3089            tmp.path().join("composer.json"),
3090            r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
3091        )
3092        .unwrap();
3093
3094        let store = DocumentStore::new();
3095
3096        // Inject a PSR-4 map pointing at the tmp dir.
3097        store
3098            .psr4
3099            .store(Arc::new(crate::lang::autoload::Psr4Map::load(tmp.path())));
3100
3101        // Mirror the consuming file (Entity not yet in source_files).
3102        // Uses Entity as a parameter type hint — the analyzer resolves these
3103        // through use statements, so this exercises the full PSR-4 lazy-load path.
3104        let handler_url = Uri::from_file_path(tmp.path().join("src/Service/Handler.php")).unwrap();
3105        store.mirror_text(
3106            &handler_url,
3107            "<?php\nnamespace App\\Service;\nuse App\\Model\\Entity;\nfunction handle(Entity $e): Entity { return $e; }\n",
3108        );
3109
3110        let issues = store.get_semantic_issues_salsa(&handler_url).unwrap();
3111        let undef: Vec<_> = issues
3112            .iter()
3113            .filter(|i| matches!(i.kind, mir_issues::IssueKind::UndefinedClass { .. }))
3114            .collect();
3115        assert!(
3116            undef.is_empty(),
3117            "PSR-4 lazy-loading must prevent UndefinedClass for App\\Model\\Entity; got: {undef:?}"
3118        );
3119    }
3120
3121    /// Issue #243 repro: a PSR-0-autoloaded vendor class (PEAR-style, e.g.
3122    /// `Legacy_Service`) must lazy-load the same way PSR-4 classes do.
3123    #[test]
3124    fn psr0_lazy_load_suppresses_undefined_class_243_repro() {
3125        let tmp = tempfile::tempdir().unwrap();
3126
3127        std::fs::create_dir_all(tmp.path().join("vendor/legacy/src/Legacy")).unwrap();
3128        std::fs::write(
3129            tmp.path().join("vendor/legacy/src/Legacy/Service.php"),
3130            "<?php\n\nclass Legacy_Service\n{\n    public function name(): string\n    {\n        return 'legacy';\n    }\n}\n",
3131        )
3132        .unwrap();
3133
3134        std::fs::write(
3135            tmp.path().join("composer.json"),
3136            r#"{"autoload":{"psr-0":{"Legacy_":"vendor/legacy/src/"}}}"#,
3137        )
3138        .unwrap();
3139
3140        let store = DocumentStore::new();
3141        store
3142            .psr4
3143            .store(Arc::new(crate::lang::autoload::Psr4Map::load(tmp.path())));
3144
3145        let repro_url = Uri::from_file_path(tmp.path().join("repro.php")).unwrap();
3146        store.mirror_text(
3147            &repro_url,
3148            "<?php\n\n$service = new Legacy_Service();\necho $service->name();\n",
3149        );
3150
3151        let issues = store.get_semantic_issues_salsa(&repro_url).unwrap();
3152        let undef: Vec<_> = issues
3153            .iter()
3154            .filter(|i| matches!(i.kind, mir_issues::IssueKind::UndefinedClass { .. }))
3155            .collect();
3156        assert!(
3157            undef.is_empty(),
3158            "PSR-0 lazy-loading must prevent UndefinedClass for Legacy_Service; got: {undef:?}"
3159        );
3160    }
3161
3162    /// Regression (found while investigating issue #242, root cause is
3163    /// distinct from it — not fixed by the `is_index_ready` gate):
3164    /// `cached_analysis_if_fresh`'s staleness check is keyed on
3165    /// `decl_version`, which only bumps inside `cached_analysis_cancellable`
3166    /// when a file undergoes its *own* full analysis. Mirroring a brand new
3167    /// file into the store (`mirror_text`/`ingest_from_doc`, what a workspace
3168    /// scan or `didChangeWatchedFiles` CREATED event does) doesn't go through
3169    /// that path, so callers on that path must explicitly call
3170    /// `note_new_file_declarations` — exactly what `did_change_watched_files`
3171    /// now does — to invalidate consumers analyzed before the dependency
3172    /// existed.
3173    #[test]
3174    fn stale_cached_analysis_not_invalidated_by_new_dependency_file() {
3175        let store = DocumentStore::new();
3176        let consumer_uri = uri("/app.php");
3177        let dep_uri = uri("/Mage.php");
3178
3179        // Consumer references Mage before Mage exists anywhere in the store —
3180        // correctly flagged, and this analysis gets cached.
3181        store.mirror_text(&consumer_uri, "<?php\n\nnew Mage();\n");
3182        let issues = store.get_semantic_issues_salsa(&consumer_uri).unwrap();
3183        assert!(
3184            issues
3185                .iter()
3186                .any(|i| matches!(i.kind, mir_issues::IssueKind::UndefinedClass { .. })),
3187            "sanity check: Mage must be reported missing before it exists"
3188        );
3189
3190        // Mage now becomes known to the store — exactly what a workspace
3191        // scan or a `didChangeWatchedFiles` CREATED event does.
3192        store.mirror_text(&dep_uri, "<?php\nclass Mage {}\n");
3193        store.note_new_file_declarations(&dep_uri);
3194
3195        // Consumer's diagnostics should now resolve cleanly.
3196        let issues = store.get_semantic_issues_salsa(&consumer_uri).unwrap();
3197        assert!(
3198            !issues
3199                .iter()
3200                .any(|i| matches!(i.kind, mir_issues::IssueKind::UndefinedClass { .. })),
3201            "Mage now exists, but consumer.php's cached analysis was never \
3202             invalidated: {issues:?}"
3203        );
3204    }
3205
3206    /// Issue #191 regression: workspace-wide scans (find-references, rename,
3207    /// call-hierarchy) must not re-parse closed/indexed files on repeated
3208    /// invocations. Once a file's `ParsedDoc` has been produced, subsequent
3209    /// `all_docs_for_scan()` calls must hit the cache and return the same
3210    /// `Arc<ParsedDoc>` (pointer equality), proving no re-parse occurred.
3211    ///
3212    /// The cache layers protecting this are:
3213    ///   1. `parsed_cache` (cap [`PARSED_CACHE_CAP`]) — read-through, validated
3214    ///      via `Arc::ptr_eq` on the text Arc.
3215    ///   2. salsa `parsed_doc` memo (`lru = 2048`) — second line of defense
3216    ///      when `parsed_cache` evicts.
3217    ///
3218    /// Together they keep every workspace-scan op O(N) memo lookups, never
3219    /// O(N) parses, for any workspace whose file count fits the cap.
3220    #[test]
3221    fn all_docs_for_scan_does_not_reparse_indexed_files() {
3222        let store = DocumentStore::new();
3223        const N: usize = 50;
3224        for i in 0..N {
3225            let u = uri(&format!("/scan/file{i}.php"));
3226            store.ingest(u, &format!("<?php\nclass C{i} {{}}\nfunction f{i}() {{}}"));
3227        }
3228
3229        let first: Vec<_> = store.all_docs_for_scan();
3230        let second: Vec<_> = store.all_docs_for_scan();
3231        assert_eq!(first.len(), N);
3232        assert_eq!(second.len(), N);
3233
3234        let by_url_first: std::collections::HashMap<Uri, Arc<ParsedDoc>> =
3235            first.into_iter().collect();
3236        for (u, doc2) in second {
3237            let doc1 = by_url_first
3238                .get(&u)
3239                .expect("second scan returned a URL the first didn't");
3240            assert!(
3241                Arc::ptr_eq(doc1, &doc2),
3242                "{u:?} re-parsed across all_docs_for_scan calls — \
3243                 cache (parsed_cache + salsa parsed_doc memo) failed to hit"
3244            );
3245        }
3246
3247        // Editing one file's text must invalidate just that file's entry,
3248        // not the rest. This locks in self-eviction via Arc::ptr_eq on text.
3249        let edited_url = uri("/scan/file0.php");
3250        let pre_edit = store.get_doc_salsa(&edited_url).unwrap();
3251        store.ingest(edited_url.clone(), "<?php\nclass C0Edited {}");
3252        let post_edit = store.get_doc_salsa(&edited_url).unwrap();
3253        assert!(
3254            !Arc::ptr_eq(&pre_edit, &post_edit),
3255            "edited file must produce a fresh ParsedDoc"
3256        );
3257        for i in 1..N {
3258            let u = uri(&format!("/scan/file{i}.php"));
3259            let original = by_url_first.get(&u).unwrap();
3260            let after = store.get_doc_salsa(&u).unwrap();
3261            assert!(
3262                Arc::ptr_eq(original, &after),
3263                "{u:?} should not have re-parsed because of an unrelated edit"
3264            );
3265        }
3266    }
3267
3268    /// Incremental analysis cache: a body-only edit to file A (no declaration
3269    /// changes) must not bump `decl_version`, so file B's cached analysis
3270    /// survives. A declaration edit MUST bump the version so B's entry goes
3271    /// stale.
3272    #[test]
3273    fn body_only_edit_does_not_invalidate_sibling_analysis_cache() {
3274        let store = DocumentStore::new();
3275        let ua = uri("/ic_a.php");
3276        let ub = uri("/ic_b.php");
3277
3278        // Analyze both files to establish their fingerprints.
3279        open(
3280            &store,
3281            ua.clone(),
3282            "<?php\nfunction a() { return 1; }".to_string(),
3283        );
3284        open(
3285            &store,
3286            ub.clone(),
3287            "<?php\nfunction b() { return 2; }".to_string(),
3288        );
3289        let _ = store.cached_analysis(&ua).unwrap();
3290        let analysis_b_first = store.cached_analysis(&ub).unwrap();
3291        let ver_after_warm = store.caches.decl_version();
3292
3293        // Body-only edit to A: same function name, different body → FileIndex unchanged.
3294        store.mirror_text(&ua, "<?php\nfunction a() { return 999; }");
3295        let _ = store.cached_analysis(&ua);
3296        let ver_after_body_edit = store.caches.decl_version();
3297        assert_eq!(
3298            ver_after_warm, ver_after_body_edit,
3299            "body-only edit must not bump decl_version"
3300        );
3301
3302        // B's cached entry should still be valid (ptr-eq source AND same version).
3303        let analysis_b_second = store.cached_analysis_if_fresh(&ub);
3304        assert!(
3305            analysis_b_second.is_some(),
3306            "B's analysis should hit cache after body-only edit to A"
3307        );
3308        assert!(
3309            Arc::ptr_eq(&analysis_b_first, &analysis_b_second.unwrap()),
3310            "B's analysis should be the identical Arc (no re-analysis)"
3311        );
3312
3313        // Declaration edit to A: rename the function → FileIndex changes.
3314        store.mirror_text(&ua, "<?php\nfunction a_renamed() { return 999; }");
3315        let _ = store.cached_analysis(&ua);
3316        let ver_after_decl_edit = store.caches.decl_version();
3317        assert!(
3318            ver_after_decl_edit > ver_after_body_edit,
3319            "declaration edit must bump decl_version (was {ver_after_body_edit}, now {ver_after_decl_edit})"
3320        );
3321
3322        // B's entry is now stale — cached_analysis_if_fresh must return None.
3323        let analysis_b_stale = store.cached_analysis_if_fresh(&ub);
3324        assert!(
3325            analysis_b_stale.is_none(),
3326            "B's analysis should be stale after A's declaration changed"
3327        );
3328    }
3329
3330    /// snapshot_query must complete without panic when a concurrent writer
3331    /// races the snapshot. The single-retry-then-lock logic should handle this
3332    /// correctly: the lock-held fallback guarantees progress.
3333    #[test]
3334    fn snapshot_query_survives_concurrent_writes() {
3335        use std::sync::Arc;
3336        use std::thread;
3337        use std::time::{Duration, Instant};
3338
3339        let store = Arc::new(DocumentStore::new());
3340        let u = uri("/sq_test.php");
3341        open(
3342            &store,
3343            u.clone(),
3344            "<?php\nfunction f(): int { return 1; }".to_string(),
3345        );
3346
3347        let deadline = Instant::now() + Duration::from_millis(200);
3348        let mut handles = Vec::new();
3349
3350        // Writer: keep bumping the file text to trigger salsa::Cancelled.
3351        {
3352            let store = Arc::clone(&store);
3353            let u = u.clone();
3354            handles.push(thread::spawn(move || {
3355                let mut rev = 0u32;
3356                while Instant::now() < deadline {
3357                    store.mirror_text(&u, &format!("<?php\nfunction f(): int {{ return {rev}; }}"));
3358                    rev += 1;
3359                }
3360            }));
3361        }
3362
3363        // Reader: hammer snapshot_query via get_doc_salsa.
3364        for _ in 0..4 {
3365            let store = Arc::clone(&store);
3366            let u = u.clone();
3367            handles.push(thread::spawn(move || {
3368                while Instant::now() < deadline {
3369                    let _ = store.get_doc_salsa(&u);
3370                    let _ = store.get_index_salsa(&u);
3371                }
3372            }));
3373        }
3374
3375        for h in handles {
3376            h.join()
3377                .expect("no panic in snapshot_query under concurrent writes");
3378        }
3379    }
3380
3381    /// Concurrent callers racing to analyze the SAME uncached file (e.g.
3382    /// `did_open`'s diagnostics trigger vs. a fast-following hover, which
3383    /// tower-lsp's default concurrency genuinely allows) must serialize onto
3384    /// one `FileAnalyzer::analyze` run and all observe its identical result
3385    /// — not each redo the full analysis independently.
3386    #[test]
3387    fn concurrent_callers_share_one_analysis_computation() {
3388        use std::sync::{Arc, Barrier};
3389        use std::thread;
3390
3391        let store = Arc::new(DocumentStore::new());
3392        let u = uri("/inflight_test.php");
3393        open(
3394            &store,
3395            u.clone(),
3396            "<?php\nfunction f(): int { return 1; }".to_string(),
3397        );
3398
3399        const N: usize = 8;
3400        let barrier = Arc::new(Barrier::new(N));
3401        let handles: Vec<_> = (0..N)
3402            .map(|_| {
3403                let store = Arc::clone(&store);
3404                let u = u.clone();
3405                let barrier = Arc::clone(&barrier);
3406                thread::spawn(move || {
3407                    barrier.wait();
3408                    store.cached_analysis(&u)
3409                })
3410            })
3411            .collect();
3412        let results: Vec<_> = handles
3413            .into_iter()
3414            .map(|h| h.join().expect("no panic in cached_analysis"))
3415            .collect();
3416
3417        assert_eq!(
3418            store.analysis_compute_count(),
3419            1,
3420            "{N} concurrent callers analyzing the same uncached file must compute once, not {N} times"
3421        );
3422        let first = results[0].clone().expect("analysis must succeed");
3423        for r in &results {
3424            let r = r.as_ref().expect("analysis must succeed");
3425            assert!(
3426                Arc::ptr_eq(&first, r),
3427                "every caller must observe the identical analysis Arc, not its own copy"
3428            );
3429        }
3430    }
3431
3432    /// Manual perf diagnostic (`cargo test --release -- --ignored --nocapture
3433    /// diagnostic_coalescing_cpu_time_ms`): N threads race `cached_analysis`
3434    /// for the same uncached, moderately expensive file. `computations`
3435    /// (from `analysis_compute_count`) is the direct proof of the fix: it
3436    /// must stay at 1 regardless of N. `wall_ms` is secondary color — without
3437    /// the per-URI lock, N threads each independently running the full
3438    /// analyze pass genuinely in parallel can contend for the same cores/
3439    /// caches and degrade wall time as N grows past the core count; with the
3440    /// lock, added threads just wait on the one real computation.
3441    #[test]
3442    #[ignore]
3443    fn diagnostic_coalescing_cpu_time_ms() {
3444        use std::sync::{Arc, Barrier};
3445        use std::thread;
3446        use std::time::Instant;
3447
3448        let mut src = "<?php\nclass Big {\n".to_string();
3449        for m in 0..200 {
3450            src.push_str(&format!(
3451                "    public function m{m}(int $x): int {{ $y = $x * {m} + strlen((string)$x); return $y > 0 ? $y : -$y; }}\n"
3452            ));
3453        }
3454        src.push_str("}\n");
3455
3456        for &nthreads in &[1usize, 4, 8, 16, 32] {
3457            let store = Arc::new(DocumentStore::new());
3458            let u = uri("/coalesce_bench.php");
3459            open(&store, u.clone(), src.clone());
3460
3461            let barrier = Arc::new(Barrier::new(nthreads));
3462            let wall_start = Instant::now();
3463            let handles: Vec<_> = (0..nthreads)
3464                .map(|_| {
3465                    let store = Arc::clone(&store);
3466                    let u = u.clone();
3467                    let barrier = Arc::clone(&barrier);
3468                    thread::spawn(move || {
3469                        barrier.wait();
3470                        let _ = store.cached_analysis(&u);
3471                    })
3472                })
3473                .collect();
3474            for h in handles {
3475                h.join().unwrap();
3476            }
3477            let wall_ms = wall_start.elapsed().as_secs_f64() * 1000.0;
3478            eprintln!(
3479                "threads={nthreads:>3}  computations={:>3}  wall_ms={wall_ms:>8.3}",
3480                store.analysis_compute_count()
3481            );
3482        }
3483    }
3484
3485    /// Manual perf diagnostic (`cargo test --release -- --ignored --nocapture
3486    /// diagnostic_batch_vs_per_symbol_scan_ms`), not a CI-run assertion:
3487    /// isolates the pure scan-count effect of `batch_reference_candidate_files`
3488    /// vs M independent `reference_candidate_files` calls, excluding the
3489    /// surrounding `code_lenses()`/`indexed_references` cost entirely — this
3490    /// measures scope narrowing alone. See `benches/code_lens_scaling.rs` for
3491    /// the end-to-end request-level number.
3492    #[test]
3493    #[ignore]
3494    fn diagnostic_batch_vs_per_symbol_scan_ms() {
3495        use std::time::Instant;
3496        let num_methods = 100usize;
3497        for &n_noise in &[500usize, 1500, 3000, 8000] {
3498            let store = DocumentStore::new();
3499            let owner = "App\\Service";
3500            let mut src = "<?php\nnamespace App;\nclass Service {\n".to_string();
3501            for m in 0..num_methods {
3502                src.push_str(&format!(
3503                    "    public static function alpha{m}(): void {{}}\n"
3504                ));
3505            }
3506            src.push_str("}\n");
3507            store.ingest(("file:///synth/Service.php").parse::<Uri>().unwrap(), &src);
3508            for i in 0..n_noise {
3509                let u = format!("file:///synth/N{i}.php").parse::<Uri>().unwrap();
3510                let t = format!(
3511                    "<?php\nnamespace Other{i};\nclass N{i} {{ public function run(){{}} }}\n"
3512                );
3513                store.ingest(u, &t);
3514            }
3515            store.mark_index_ready();
3516
3517            let mut symbols = vec![mir_analyzer::Name::class(owner)];
3518            for m in 0..num_methods {
3519                symbols.push(mir_analyzer::Name::method(owner, &format!("alpha{m}")));
3520            }
3521
3522            let t = Instant::now();
3523            for s in &symbols {
3524                std::hint::black_box(store.reference_candidate_files(s));
3525            }
3526            let per_symbol_ms = t.elapsed().as_secs_f64() * 1000.0;
3527
3528            let t = Instant::now();
3529            std::hint::black_box(store.batch_reference_candidate_files(&symbols));
3530            let batch_ms = t.elapsed().as_secs_f64() * 1000.0;
3531
3532            eprintln!(
3533                "n_noise={n_noise:>6}  symbols={:>4}  per_symbol_ms={per_symbol_ms:>10.3}  batch_ms={batch_ms:>10.3}  speedup={:>6.2}x",
3534                symbols.len(),
3535                per_symbol_ms / batch_ms
3536            );
3537        }
3538    }
3539
3540    /// Manual perf diagnostic (`cargo test --release -- --ignored --nocapture
3541    /// diagnostic_priority_scan_avoided_cost_ms`): measures the cost of
3542    /// `handle_references`'s priority-partition owner-mention scan
3543    /// (`navigation.rs`) run over an already-narrowed candidate scope — the
3544    /// exact cost `method_scope_is_narrowed` now lets the handler skip
3545    /// entirely for private/protected/static methods. The scope size here
3546    /// models a protected method's subtype-closure narrowing (the case where
3547    /// a widely-subclassed base class makes the "narrowed" scope non-trivial).
3548    #[test]
3549    #[ignore]
3550    fn diagnostic_priority_scan_avoided_cost_ms() {
3551        use std::time::Instant;
3552
3553        let store = DocumentStore::new();
3554        let owner_short = "Owner";
3555        for &n in &[10usize, 100, 1000, 3000] {
3556            let mut files: Vec<Arc<str>> = Vec::with_capacity(n);
3557            for i in 0..n {
3558                let u = format!("file:///synth/S{i}.php").parse::<Uri>().unwrap();
3559                let t = format!(
3560                    "<?php\nclass S{i} extends Owner {{\n\
3561                     \x20   public function run(): void {{\n\
3562                     \x20       parent::process();\n\
3563                     \x20   }}\n\
3564                     }}\n"
3565                );
3566                store.ingest(u.clone(), &t);
3567                files.push(Arc::<str>::from(u.as_str()));
3568            }
3569
3570            let t0 = Instant::now();
3571            use rayon::prelude::*;
3572            let matched: Vec<Arc<str>> = files
3573                .par_iter()
3574                .filter(|f| {
3575                    (f.as_ref())
3576                        .parse::<Uri>()
3577                        .ok()
3578                        .and_then(|u| store.source_text(&u))
3579                        .is_some_and(|txt| {
3580                            crate::text::contains_ascii_case_insensitive(&txt, owner_short)
3581                        })
3582                })
3583                .cloned()
3584                .collect();
3585            let avoided_ms = t0.elapsed().as_secs_f64() * 1000.0;
3586            eprintln!(
3587                "narrowed_scope_files={n:>5}  avoided_scan_ms={avoided_ms:>9.4}  matched={}  (new code: 0.000ms — skipped entirely)",
3588                matched.len()
3589            );
3590        }
3591    }
3592
3593    /// Manual perf diagnostic (`cargo test --release -- --ignored --nocapture
3594    /// diagnostic_laravel_scan_blocking_vs_spawn_blocking`): demonstrates the
3595    /// actual effect of moving `handle_references`'s Laravel string-key scan
3596    /// (`navigation.rs`) into `spawn_blocking`. On a single-worker-thread
3597    /// runtime, a CPU-bound scan with no `.await` points inside it hogs that
3598    /// one worker until it completes — a concurrently-spawned trivial task
3599    /// can't run until the scan yields. `spawn_blocking` moves the scan to a
3600    /// separate thread, freeing the worker immediately.
3601    #[tokio::test(flavor = "multi_thread", worker_threads = 1)]
3602    #[ignore]
3603    async fn diagnostic_laravel_scan_blocking_vs_spawn_blocking() {
3604        use std::time::Instant;
3605
3606        fn run_scan_inline(store: &DocumentStore, n: usize) -> usize {
3607            let mut count = 0;
3608            for i in 0..n {
3609                let u = format!("file:///synth/F{i}.php").parse::<Uri>().unwrap();
3610                if store
3611                    .source_text(&u)
3612                    .is_some_and(|t| t.contains("APP_NAME_0"))
3613                    && let Some(doc) = store.get_doc_salsa(&u)
3614                {
3615                    let _ = crate::laravel::find_call_sites(&doc, &["env"], "APP_NAME_0");
3616                    count += 1;
3617                }
3618            }
3619            count
3620        }
3621
3622        let store = Arc::new(DocumentStore::new());
3623        let n = 30_000usize;
3624        for i in 0..n {
3625            let u = format!("file:///synth/F{i}.php").parse::<Uri>().unwrap();
3626            let t = format!("<?php\n$x = env('APP_NAME_{i}');\n");
3627            store.ingest(u, &t);
3628        }
3629
3630        // Both scenarios spawn the scan AND a trivial task as separate
3631        // `tokio::spawn`ed tasks — the same relationship real tower-lsp
3632        // request handlers have (each dispatched request is its own spawned
3633        // task on the shared worker pool), unlike driving the scan inline in
3634        // this test function's own body (which `#[tokio::test]` runs via
3635        // `block_on` on the harness thread, not a pool worker, and so
3636        // wouldn't actually contend with `tokio::spawn`ed tasks at all).
3637
3638        // OLD shape: scan task runs the work inline, no yield points inside.
3639        let spawn_time = Instant::now();
3640        let store_c = Arc::clone(&store);
3641        let scan_handle = tokio::spawn(async move { run_scan_inline(&store_c, n) });
3642        let (tx, rx) = tokio::sync::oneshot::channel();
3643        tokio::spawn(async move {
3644            let _ = tx.send(Instant::now());
3645        });
3646        let matched = scan_handle.await.unwrap();
3647        let scan_ms = spawn_time.elapsed().as_secs_f64() * 1000.0;
3648        let trivial_ran_at = rx.await.unwrap();
3649        let trivial_delay_ms = trivial_ran_at.duration_since(spawn_time).as_secs_f64() * 1000.0;
3650        eprintln!(
3651            "OLD (inline on the request's own task):  scan_ms={scan_ms:>8.3}  matched={matched:>4}  concurrent_trivial_task_delay_ms={trivial_delay_ms:>8.3}"
3652        );
3653
3654        // NEW shape: scan task hands the work to spawn_blocking and awaits it.
3655        let spawn_time = Instant::now();
3656        let store_c = Arc::clone(&store);
3657        let scan_handle = tokio::spawn(async move {
3658            tokio::task::spawn_blocking(move || run_scan_inline(&store_c, n))
3659                .await
3660                .unwrap()
3661        });
3662        let (tx, rx) = tokio::sync::oneshot::channel();
3663        tokio::spawn(async move {
3664            let _ = tx.send(Instant::now());
3665        });
3666        let matched = scan_handle.await.unwrap();
3667        let scan_ms = spawn_time.elapsed().as_secs_f64() * 1000.0;
3668        let trivial_ran_at = rx.await.unwrap();
3669        let trivial_delay_ms = trivial_ran_at.duration_since(spawn_time).as_secs_f64() * 1000.0;
3670        eprintln!(
3671            "NEW (via spawn_blocking):                scan_ms={scan_ms:>8.3}  matched={matched:>4}  concurrent_trivial_task_delay_ms={trivial_delay_ms:>8.3}"
3672        );
3673    }
3674
3675    /// When a sibling file's declaration changes (bumping decl_version), the
3676    /// owned_program_cache entry for the unchanged file B should be reused
3677    /// rather than deep-cloned again. We verify this via Arc pointer equality.
3678    #[test]
3679    fn owned_program_cache_reused_after_sibling_declaration_change() {
3680        let store = DocumentStore::new();
3681        let ua = uri("/prog_a.php");
3682        let ub = uri("/prog_b.php");
3683
3684        open(
3685            &store,
3686            ua.clone(),
3687            "<?php\nfunction alpha(): void {}".to_string(),
3688        );
3689        open(
3690            &store,
3691            ub.clone(),
3692            "<?php\nfunction beta(): void {}".to_string(),
3693        );
3694
3695        // Warm both analysis caches and populate owned_program_cache for both files.
3696        let _ = store.cached_analysis(&ua);
3697        let _ = store.cached_analysis(&ub);
3698
3699        // Capture B's owned_program Arc before the sibling edit.
3700        let prog_b_first = store
3701            .caches
3702            .owned_program_cache
3703            .get(&ub)
3704            .map(|e| Arc::clone(&e.1))
3705            .expect("B's owned_program should be cached after first analysis");
3706
3707        // Declaration change to A: bumps decl_version, invalidating all cached_analysis entries.
3708        store.mirror_text(&ua, "<?php\nfunction alpha_renamed(): void {}");
3709        // Re-analyze A to trigger the decl_version bump.
3710        let _ = store.cached_analysis(&ua);
3711
3712        // Now re-analyze B. Its source is unchanged, so owned_program_cache must hit.
3713        let _ = store.cached_analysis(&ub);
3714
3715        let prog_b_second = store
3716            .caches
3717            .owned_program_cache
3718            .get(&ub)
3719            .map(|e| Arc::clone(&e.1))
3720            .expect("B's owned_program should still be cached after sibling edit");
3721
3722        assert!(
3723            Arc::ptr_eq(&prog_b_first, &prog_b_second),
3724            "B's owned_program Arc should be identical (cache hit) after sibling declaration change"
3725        );
3726    }
3727
3728    /// write_rev() increments on each real text write (identical text is a no-op).
3729    #[test]
3730    fn write_rev_increments_on_write() {
3731        let store = DocumentStore::new();
3732        let u = uri("/rev_test.php");
3733        let before = store.write_rev();
3734        store.mirror_text(&u, "<?php echo 1;");
3735        let after_first = store.write_rev();
3736        assert!(after_first > before, "first write must bump the revision");
3737        // Identical text must NOT bump the revision (fast path).
3738        store.mirror_text(&u, "<?php echo 1;");
3739        assert_eq!(
3740            store.write_rev(),
3741            after_first,
3742            "identical text must not bump the revision"
3743        );
3744        // Different text must bump again.
3745        store.mirror_text(&u, "<?php echo 2;");
3746        assert!(
3747            store.write_rev() > after_first,
3748            "changed text must bump the revision"
3749        );
3750    }
3751
3752    /// begin_reanalyze() cancels the previously-issued sweep token so a newer
3753    /// edit preempts an in-flight dependent walk, and hands back a fresh token.
3754    #[test]
3755    fn begin_reanalyze_cancels_previous_token() {
3756        let store = DocumentStore::new();
3757        let first = store.begin_reanalyze();
3758        assert!(!first.is_cancelled(), "a fresh token starts un-cancelled");
3759        let second = store.begin_reanalyze();
3760        assert!(
3761            first.is_cancelled(),
3762            "issuing a new token must cancel the previous in-flight sweep"
3763        );
3764        assert!(
3765            !second.is_cancelled(),
3766            "the newly-issued token must itself be un-cancelled"
3767        );
3768    }
3769
3770    /// A session built before the cache dir is known (an editor's
3771    /// restored-buffer `didOpen` racing `initialized`) must be rebuilt with
3772    /// the cache attached when the dir arrives — not stay pinned
3773    /// in-memory-only for the server's lifetime.
3774    #[test]
3775    fn set_session_cache_dir_rebuilds_pinned_session() {
3776        let store = DocumentStore::new();
3777        let early = store.analysis_session(mir_analyzer::PhpVersion::LATEST);
3778        let dir = tempfile::tempdir().unwrap();
3779        store.set_session_cache_dir(dir.path().to_path_buf());
3780        let rebuilt = store.analysis_session(mir_analyzer::PhpVersion::LATEST);
3781        assert!(
3782            !Arc::ptr_eq(&early, &rebuilt),
3783            "the cache-less early session must be dropped and rebuilt"
3784        );
3785        assert!(
3786            dir.path().join("stubs").exists(),
3787            "the rebuilt session must have opened the on-disk stub cache"
3788        );
3789    }
3790
3791    /// Same rebuild-on-race guarantee as `set_session_cache_dir`, for
3792    /// `set_user_stub_dirs`.
3793    #[test]
3794    fn set_user_stub_dirs_rebuilds_pinned_session() {
3795        let store = DocumentStore::new();
3796        let early = store.analysis_session(mir_analyzer::PhpVersion::LATEST);
3797        let dir = tempfile::tempdir().unwrap();
3798        store.set_user_stub_dirs(vec![dir.path().to_path_buf()]);
3799        let rebuilt = store.analysis_session(mir_analyzer::PhpVersion::LATEST);
3800        assert!(
3801            !Arc::ptr_eq(&early, &rebuilt),
3802            "the stub-dir-less early session must be dropped and rebuilt"
3803        );
3804    }
3805
3806    /// A class defined only in a `stubDirs` directory must resolve — without
3807    /// `UndefinedClass` — from a *different* file than whichever one happened
3808    /// to trigger the session's lazy stub load, proving the stub is
3809    /// registered as a real, session-wide symbol rather than something
3810    /// scoped to the triggering file.
3811    #[test]
3812    fn user_stub_directory_class_resolves_across_files() {
3813        let stubs_dir = tempfile::tempdir().unwrap();
3814        std::fs::write(
3815            stubs_dir.path().join("Container.php"),
3816            "<?php\nclass Container {\n    public function get(string $id): mixed { return null; }\n}\n",
3817        )
3818        .unwrap();
3819
3820        let store = DocumentStore::new();
3821        store.set_user_stub_dirs(vec![stubs_dir.path().to_path_buf()]);
3822
3823        // First file has nothing to do with the stub; only primes the session.
3824        let a = uri("/stub_cross_file_a.php");
3825        store.mirror_text(&a, "<?php\necho 1;\n");
3826        let _ = store.get_semantic_issues_salsa(&a);
3827
3828        // Second, unrelated file references the stub-defined class.
3829        let b = uri("/stub_cross_file_b.php");
3830        store.mirror_text(&b, "<?php\n$c = new Container();\n$c->get('x');\n");
3831        let issues = store.get_semantic_issues_salsa(&b).unwrap();
3832        let undef: Vec<_> = issues
3833            .iter()
3834            .filter(|i| matches!(i.kind, mir_issues::IssueKind::UndefinedClass { .. }))
3835            .collect();
3836        assert!(
3837            undef.is_empty(),
3838            "Container must resolve from the stubDirs directory in a file that \
3839             didn't trigger the load itself; got: {undef:?}"
3840        );
3841    }
3842
3843    /// write_rev() increments when a file is removed.
3844    #[test]
3845    fn write_rev_increments_on_remove() {
3846        let store = DocumentStore::new();
3847        let u = uri("/rev_remove.php");
3848        store.mirror_text(&u, "<?php class A {}");
3849        let before = store.write_rev();
3850        store.remove(&u);
3851        assert!(store.write_rev() > before, "remove must bump the revision");
3852    }
3853}