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