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