Skip to main content

php_lsp/document/
document_store.rs

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