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