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