Skip to main content

php_lsp/document/
document_store.rs

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