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