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