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;
6
7use dashmap::{DashMap, DashSet};
8use salsa::Setter;
9use tower_lsp::lsp_types::{SemanticToken, Url};
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 /// `Url -> 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<Url, 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<Url>,
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 /// `LspWorkspace` salsa input on the shared mir db: the project-file scoping
39 /// set aggregated by `workspace_index`. Created lazily on first sync (the db
40 /// is owned by the lazily-built `AnalysisSession`).
41 lsp_workspace: Mutex<Option<LspWorkspace>>,
42 /// Target PHP version (selects the `AnalysisSession`). Stored here since the
43 /// converged db has no php-lsp `Workspace` input to carry it.
44 php_version: Mutex<mir_analyzer::PhpVersion>,
45 /// Shared PSR-4 namespace-to-path map. Shared with `Backend` via `Arc`
46 /// so updates from `initialized` (when composer.json is loaded) are
47 /// visible here without any additional wiring. `ArcSwap` makes reads
48 /// lock-free — a poisoned guard can no longer crash a request handler.
49 psr4: Arc<ArcSwap<Psr4Map>>,
50 /// mir-analyzer's `AnalysisSession` — owns the workspace MirDb, runs
51 /// Pass-2 analysis, and lazy-loads dependencies via PSR-4. Built lazily
52 /// on first use; rebuilt when PHP version changes.
53 analysis_session: Mutex<Option<(mir_analyzer::PhpVersion, Arc<mir_analyzer::AnalysisSession>)>>,
54 /// Cache directory shared with the workspace file-index cache. When set,
55 /// new `AnalysisSession`s are built with `with_cache_dir` so that stub
56 /// parsing results survive server restarts.
57 session_cache_dir: OnceLock<std::path::PathBuf>,
58 /// URIs of autoload.files entries from composer.json. These define global
59 /// helper functions (e.g. tap, class_uses_recursive in Laravel) that are
60 /// not discoverable by namespace walk. Pre-ingested into the AnalysisSession
61 /// before each file analysis so mir doesn't emit false UndefinedFunction.
62 autoload_uris: std::sync::RwLock<Vec<Url>>,
63 /// Set once the workspace scan's reference-index phase finishes, i.e. the
64 /// `subtypes_of` map in `workspace_index` is complete. Visibility-derived
65 /// scope narrowing that relies on the full subtype set (protected methods)
66 /// only applies once this is `true`; before then it falls back to the full
67 /// workspace scope so references are never under-reported.
68 index_ready: AtomicBool,
69 /// Monotonically increasing counter bumped on every actual text write
70 /// (including file deletions). Long-running read operations (e.g.
71 /// `session_references_to`) capture this value before starting and
72 /// cancel themselves if it advances — avoiding stale results and
73 /// unbounded retry loops after concurrent edits.
74 write_revision: AtomicU64,
75 /// Cancel token for the in-flight dependent-diagnostics sweep. A newer
76 /// edit's sweep cancels the previous one via [`Self::begin_reanalyze`], so
77 /// fast typing preempts stale workspace re-analysis rather than queueing
78 /// behind it.
79 reanalyze_cancel: Mutex<mir_analyzer::IndexCancel>,
80 /// Cancel token for the in-flight analysis warm sweep
81 /// ([`Self::warm_analysis_sweep`]); a newer sweep cancels the previous one
82 /// via [`Self::begin_warm_sweep`].
83 warm_sweep_cancel: Mutex<mir_analyzer::IndexCancel>,
84 /// Warm sweeps that ran to completion (not cancelled). Observability only,
85 /// surfaced via `$/php-lsp/debugStats` so benches/tests can await warmth.
86 warm_sweeps_completed: AtomicU64,
87 /// Count of in-flight interactive reads (requests the user is waiting on).
88 /// The workspace scan yields at file boundaries while this is non-zero, so
89 /// its per-file salsa writes can't starve a request's snapshot into an
90 /// endless `Cancelled` retry loop. Advisory only — relaxed ordering.
91 interactive_reads: AtomicU64,
92}
93
94/// RAII handle marking an interactive read in flight; see
95/// [`DocumentStore::interactive_read_guard`].
96pub struct InteractiveReadGuard<'a>(&'a AtomicU64);
97
98impl Drop for InteractiveReadGuard<'_> {
99 fn drop(&mut self) {
100 self.0.fetch_sub(1, Ordering::Relaxed);
101 }
102}
103
104impl Default for DocumentStore {
105 fn default() -> Self {
106 Self::new()
107 }
108}
109
110impl DocumentStore {
111 pub fn new() -> Self {
112 // Deep type-inference recursion on pathological files can overflow
113 // rayon's default worker stack (observed as a crash on framework
114 // fixtures under load — and the warm sweep analyzes *every* workspace
115 // file, so one such file must not take the server down). Size the
116 // global pool's stacks once before any parallel analysis runs;
117 // build_global fails harmlessly if a pool already exists.
118 static RAYON_STACK: OnceLock<()> = OnceLock::new();
119 RAYON_STACK.get_or_init(|| {
120 let _ = rayon::ThreadPoolBuilder::new()
121 .stack_size(16 * 1024 * 1024)
122 .build_global();
123 });
124 DocumentStore {
125 caches: CacheRegistry::new(),
126 lsp_ws_files: DashMap::new(),
127 deleted_uris: DashSet::new(),
128 workspace_files_dirty: AtomicBool::new(true),
129 lsp_workspace: Mutex::new(None),
130 php_version: Mutex::new(mir_analyzer::PhpVersion::LATEST),
131 psr4: Arc::new(ArcSwap::from_pointee(Psr4Map::empty())),
132 analysis_session: Mutex::new(None),
133 session_cache_dir: OnceLock::new(),
134 autoload_uris: std::sync::RwLock::new(Vec::new()),
135 index_ready: AtomicBool::new(false),
136 write_revision: AtomicU64::new(0),
137 reanalyze_cancel: Mutex::new(mir_analyzer::IndexCancel::new()),
138 warm_sweep_cancel: Mutex::new(mir_analyzer::IndexCancel::new()),
139 warm_sweeps_completed: AtomicU64::new(0),
140 interactive_reads: AtomicU64::new(0),
141 }
142 }
143
144 /// Mark an interactive read (a request the user is waiting on) as in
145 /// flight for the guard's lifetime. Background bulk writers poll
146 /// [`Self::yield_to_interactive_reads`] between files and pause while any
147 /// guard is live, giving the read a write-free window to complete.
148 pub fn interactive_read_guard(&self) -> InteractiveReadGuard<'_> {
149 self.interactive_reads.fetch_add(1, Ordering::Relaxed);
150 InteractiveReadGuard(&self.interactive_reads)
151 }
152
153 /// Take an interactive-read guard, wait (bounded ~50 ms) for in-flight
154 /// background writes to go quiet, and return the settled write revision.
155 ///
156 /// For user-facing sweeps that abort when [`Self::write_rev`] advances:
157 /// snapshotting the revision this way ensures only genuine user edits —
158 /// not the background scan the guard just paused — void the sweep.
159 /// Sleeps; call from a blocking thread only.
160 pub fn settled_write_rev_guard(&self) -> (InteractiveReadGuard<'_>, u64) {
161 let guard = self.interactive_read_guard();
162 // Post-scan there is no background write storm to settle; skip the
163 // sleep so steady-state requests pay only the guard's atomic inc.
164 if !self.is_index_ready() {
165 let deadline = std::time::Instant::now() + std::time::Duration::from_millis(50);
166 let mut rev = self.write_rev();
167 loop {
168 std::thread::sleep(std::time::Duration::from_millis(2));
169 let now = self.write_rev();
170 let quiet = now == rev;
171 rev = now;
172 if quiet || std::time::Instant::now() >= deadline {
173 break;
174 }
175 }
176 }
177 (guard, self.write_rev())
178 }
179
180 /// Pause the calling (background-writer) thread while interactive reads
181 /// are in flight, up to 500 ms per call. Bounded so a wedged reader can
182 /// only slow the scan, never stop it; callers invoke this per file, so
183 /// sustained interactive traffic keeps yielding at each boundary.
184 pub fn yield_to_interactive_reads(&self) {
185 let mut waited = std::time::Duration::ZERO;
186 const STEP: std::time::Duration = std::time::Duration::from_millis(2);
187 const MAX: std::time::Duration = std::time::Duration::from_millis(500);
188 while self.interactive_reads.load(Ordering::Relaxed) > 0 && waited < MAX {
189 std::thread::sleep(STEP);
190 waited += STEP;
191 }
192 }
193
194 /// Install a fresh cancel token for a dependent-diagnostics sweep,
195 /// cancelling whichever sweep was previously in flight. Pass the returned
196 /// token to `reanalyze_dependents_cancellable`; when a newer edit calls
197 /// this again the old token flips and that older sweep stops at its next
198 /// file boundary instead of racing this one to publish stale diagnostics.
199 pub fn begin_reanalyze(&self) -> mir_analyzer::IndexCancel {
200 let fresh = mir_analyzer::IndexCancel::new();
201 let mut guard = self.reanalyze_cancel.lock().unwrap();
202 guard.cancel();
203 *guard = fresh.clone();
204 fresh
205 }
206
207 /// Install a fresh cancel token for an analysis warm sweep, cancelling any
208 /// sweep previously in flight. Mirrors [`Self::begin_reanalyze`] but on a
209 /// separate slot: edit-triggered diagnostic sweeps and background warming
210 /// must not cancel each other.
211 pub fn begin_warm_sweep(&self) -> mir_analyzer::IndexCancel {
212 let fresh = mir_analyzer::IndexCancel::new();
213 let mut guard = self.warm_sweep_cancel.lock().unwrap();
214 guard.cancel();
215 *guard = fresh.clone();
216 fresh
217 }
218
219 /// Drive every workspace file through mir's memoized `analyze_file` query
220 /// so later reference/rename requests are memo hits instead of cold
221 /// analyses (~20 ms per file on real code — a few hundred candidate files
222 /// makes an interactive request take seconds).
223 ///
224 /// `priority` files (and the files declaring the classes they reference —
225 /// the user's working set) are warmed first, so requests against open
226 /// files go warm within the sweep's first seconds even on workspaces
227 /// where the full sweep takes much longer.
228 ///
229 /// Background-priority: processes files in small chunks, yielding to
230 /// interactive reads at each boundary, and stops at the next boundary once
231 /// `cancel` flips. Re-running after edits is cheap — unaffected files
232 /// revalidate via salsa without re-analysis. Blocking; call from
233 /// `spawn_blocking`.
234 pub fn warm_analysis_sweep(&self, priority: &[Url], cancel: &mir_analyzer::IndexCancel) {
235 // Dedicated thread with a generous stack: the serial prepare phase and
236 // priority resolution recurse over real-world ASTs whose depth can
237 // exceed the default 2 MiB thread stack (debug builds especially).
238 // One pathological file must not abort the whole server process.
239 std::thread::scope(|s| {
240 let _ = std::thread::Builder::new()
241 .name("php-lsp-warm-sweep".into())
242 .stack_size(32 * 1024 * 1024)
243 .spawn_scoped(s, || self.warm_analysis_sweep_inner(priority, cancel))
244 .map(|h| h.join());
245 });
246 }
247
248 fn warm_analysis_sweep_inner(&self, priority: &[Url], cancel: &mir_analyzer::IndexCancel) {
249 const CHUNK: usize = 32;
250 let front: Vec<Arc<str>> = self.sweep_priority_files(priority);
251 let front_set: HashSet<&str> = front.iter().map(|f| f.as_ref()).collect();
252 let files: Vec<Arc<str>> = front
253 .iter()
254 .cloned()
255 .chain(
256 self.lsp_ws_files
257 .iter()
258 .filter(|e| !self.deleted_uris.contains(e.key()))
259 .map(|e| Arc::<str>::from(e.key().as_str()))
260 .filter(|f| !front_set.contains(f.as_ref())),
261 )
262 .collect();
263 drop(front_set);
264 let session = self.analysis_session(self.workspace_php_version());
265 let mut all_chunks_settled = true;
266 for chunk in files.chunks(CHUNK) {
267 if cancel.is_cancelled() {
268 return;
269 }
270 self.yield_to_interactive_reads();
271 // A concurrent write (e.g. another file being ingested) can land
272 // mid-chunk and cancel the analysis snapshot. Retry the same
273 // chunk immediately — `cancel` (not just the transient snapshot
274 // cancellation) is the authoritative stop signal, so a retry loop
275 // here only spins while an unrelated writer keeps landing, and
276 // exits promptly once `cancel` itself flips (a real edit
277 // superseding this sweep). Without the retry, a chunk silently
278 // skipped here has no guaranteed follow-up: `warm_sweeps_completed`
279 // must not count this sweep as covering files it never actually
280 // analyzed, or a caller polling that counter (e.g. a test waiting
281 // for the reference index to be fully warm) can observe "done"
282 // while some files' postings were never written.
283 loop {
284 if cancel.is_cancelled() {
285 all_chunks_settled = false;
286 break;
287 }
288 if salsa::Cancelled::catch(std::panic::AssertUnwindSafe(|| {
289 session.reanalyze_files_cancellable(chunk, cancel)
290 }))
291 .is_ok()
292 {
293 break;
294 }
295 }
296 }
297 if !cancel.is_cancelled() && all_chunks_settled {
298 // The sweep staged each analyzed file's reference postings into
299 // mir's AnalysisCache; persist them so the next launch's
300 // `warm_start_indexes` replays references index-warm. Flush
301 // before publishing completion — observers of the counter may
302 // rely on the postings being on disk.
303 session.flush_analysis_cache();
304 self.warm_sweeps_completed.fetch_add(1, Ordering::Relaxed);
305 }
306 }
307
308 /// Persist mir's staged analysis-cache entries (reference postings) to
309 /// disk. No-op when nothing changed since the last flush.
310 pub fn flush_analysis_cache(&self) {
311 self.analysis_session(self.workspace_php_version())
312 .flush_analysis_cache();
313 }
314
315 /// Warm sweeps that ran to completion. See `$/php-lsp/debugStats`.
316 pub fn warm_sweeps_completed(&self) -> u64 {
317 self.warm_sweeps_completed.load(Ordering::Relaxed)
318 }
319
320 /// The sweep's front of the queue: `priority` files themselves plus the
321 /// files declaring the classes they reference (type hints, `use` imports,
322 /// `new`, `extends`, …) — the set a request against an open file most
323 /// likely touches. Resolution goes through the memoized workspace index,
324 /// matching by FQN with a short-name fallback.
325 fn sweep_priority_files(&self, priority: &[Url]) -> Vec<Arc<str>> {
326 if priority.is_empty() {
327 return Vec::new();
328 }
329 let ws = self.get_workspace_index_salsa();
330 let mut out: Vec<Arc<str>> = Vec::new();
331 let mut seen: HashSet<Arc<str>> = HashSet::new();
332 let push = |uri: &Url, out: &mut Vec<Arc<str>>, seen: &mut HashSet<Arc<str>>| {
333 let f: Arc<str> = Arc::from(uri.as_str());
334 if seen.insert(Arc::clone(&f)) {
335 out.push(f);
336 }
337 };
338 for uri in priority {
339 push(uri, &mut out, &mut seen);
340 }
341 for uri in priority {
342 let Some(doc) = self.get_doc_salsa(uri) else {
343 continue;
344 };
345 for fqn in crate::navigation::references::collect_referenced_class_fqns(&doc) {
346 let short = crate::text::fqn_short_name(&fqn);
347 let Some(refs) = ws.classes_by_name.get(short) else {
348 continue;
349 };
350 for &r in refs {
351 if let Some((decl_uri, cls)) = ws.at(r)
352 && cls.fqn.trim_start_matches('\\') == fqn
353 {
354 push(decl_uri, &mut out, &mut seen);
355 }
356 }
357 }
358 }
359 out
360 }
361
362 /// Mark the workspace reference index as fully built. Called by the scan
363 /// when its final phase completes (alongside `$/php-lsp/indexReady`).
364 pub fn mark_index_ready(&self) {
365 self.index_ready.store(true, Ordering::Release);
366 }
367
368 /// Whether the workspace reference index has finished building.
369 pub fn is_index_ready(&self) -> bool {
370 self.index_ready.load(Ordering::Acquire)
371 }
372
373 /// Snapshot of the write-revision counter. Long-running reads should
374 /// capture this before starting and pass it to cancellable operations;
375 /// if the counter advances, those operations abort and return empty rather
376 /// than looping indefinitely against a newly-invalidated database.
377 pub fn write_rev(&self) -> u64 {
378 self.write_revision.load(Ordering::Acquire)
379 }
380
381 /// Set the directory used to persist stub-parse and analysis results across
382 /// server restarts. Must be called before the first `analysis_session` use;
383 /// subsequent calls are silently ignored (`OnceLock` semantics).
384 pub fn set_session_cache_dir(&self, dir: std::path::PathBuf) {
385 let _ = self.session_cache_dir.set(dir);
386 }
387
388 /// Register URIs discovered from composer.json `autoload.files` entries.
389 /// These PHP files define global helper functions (e.g. `tap()` in Laravel)
390 /// that are not class-resolvable via PSR-4. Clears `analysis_cache` so the
391 /// next per-file analysis pre-ingests them into the AnalysisSession before
392 /// running mir's FileAnalyzer.
393 pub fn set_autoload_uris(&self, uris: Vec<Url>) {
394 *self.autoload_uris.write().unwrap() = uris;
395 self.caches.evict_analysis_all();
396 }
397
398 /// Get or build the `AnalysisSession` for the given PHP version. Rebuilds
399 /// when the version changes (e.g. user flipped config). The session owns
400 /// the shared salsa db and AnalysisCache; lazy-loads vendor files via the
401 /// shared PSR-4 map. Built-in stubs are *not* pre-loaded: mir's
402 /// `prepare_ast_for_analysis` ingests the stubs each analyzed file
403 /// references, and [`crate::types::stub_members`] faults in single stub
404 /// files for builtin member/hover lookups.
405 pub fn analysis_session(
406 &self,
407 php_version: mir_analyzer::PhpVersion,
408 ) -> Arc<mir_analyzer::AnalysisSession> {
409 let mut guard = self.analysis_session.lock().unwrap();
410 if let Some((cached_ver, session)) = guard.as_ref()
411 && *cached_ver == php_version
412 {
413 return Arc::clone(session);
414 }
415 // Build a fresh session. Hand it the shared PSR-4 map so it can
416 // lazy-resolve `UndefinedClass` candidates without us having to mirror
417 // every vendor file upfront.
418 let resolver: Arc<dyn mir_analyzer::ClassResolver> = self.psr4.load_full();
419 // References/implementations are answered from mir's delta-maintained
420 // inverted indexes (posting lists + subtype edges), which the session
421 // keeps in step with every edit path.
422 let mut builder =
423 mir_analyzer::AnalysisSession::new(php_version).with_class_resolver(resolver);
424 if let Some(dir) = self.session_cache_dir.get() {
425 builder = builder.with_cache_dir(dir);
426 }
427 let session = Arc::new(builder);
428 *guard = Some((php_version, Arc::clone(&session)));
429 session
430 }
431
432 /// Current PHP version tracked by the workspace input.
433 pub fn workspace_php_version(&self) -> mir_analyzer::PhpVersion {
434 *self.php_version.lock().unwrap()
435 }
436
437 /// File URIs of all direct and transitive subclasses of `class_fqn`,
438 /// resolved via mir's inheritance graph. Returns an empty vec when the mir
439 /// session hasn't ingested the class yet (cold start, excluded paths).
440 ///
441 /// Used by `goto_implementation` and `subtypes` to scope their lookups to
442 /// the correct files, fixing aliased `extends` and FQN-qualified forms that
443 /// the raw-name `subtypes_of` map misses.
444 pub fn class_subtype_urls(&self, class_fqn: &str) -> Vec<tower_lsp::lsp_types::Url> {
445 let session = self.analysis_session(self.workspace_php_version());
446 session
447 .subtype_files(class_fqn)
448 .into_iter()
449 .filter_map(|p| tower_lsp::lsp_types::Url::parse(&p).ok())
450 .collect()
451 }
452
453 /// Return the `Arc<ArcSwap<Psr4Map>>` so callers can share it.
454 /// `Backend` clones this arc at construction time so writes
455 /// (e.g. loading composer.json on `initialized`) are immediately visible
456 /// to PSR-4 resolution during analysis without extra plumbing.
457 pub fn psr4_arc(&self) -> Arc<ArcSwap<Psr4Map>> {
458 Arc::clone(&self.psr4)
459 }
460
461 /// Durability for a file's salsa input: vendor files never change within a
462 /// session, so salsa can skip re-validating their queries on user edits.
463 fn input_durability(uri: &Url) -> salsa::Durability {
464 if uri.as_str().contains("/vendor/") {
465 salsa::Durability::HIGH
466 } else {
467 salsa::Durability::LOW
468 }
469 }
470
471 /// The `LspWsFile` handle for `uri`, if it is mirrored and not deleted.
472 fn lsp_ws_file(&self, uri: &Url) -> Option<LspWsFile> {
473 if self.deleted_uris.contains(uri) {
474 return None;
475 }
476 self.lsp_ws_files.get(uri).map(|e| *e)
477 }
478
479 /// Take a cheap snapshot of the shared mir db and run `f` on it, retrying
480 /// on `salsa::Cancelled` (raised when a concurrent writer bumps the
481 /// revision). Mirrors [`Self::snapshot_query`] for the converged db.
482 ///
483 /// The loop terminates as soon as the writer pauses long enough for one
484 /// query to complete. Handlers that want an early exit under sustained
485 /// write pressure should pass a write-rev closure (see `code_lenses`) and
486 /// check it at coarser granularity rather than here.
487 fn snapshot_mir_query<R>(&self, f: impl Fn(&mir_analyzer::db::MirDbStorage) -> R) -> R {
488 use std::panic::AssertUnwindSafe;
489 let _interactive = self.interactive_read_guard();
490 let session = self.analysis_session(self.workspace_php_version());
491 // Each iteration's snapshot clone MUST drop before the next snapshot:
492 // a concurrent writer's salsa `set` holds the mir write lock and waits
493 // for outstanding db handles to drop, while the next `snapshot_db` needs
494 // the read lock — keeping the clone alive across the retry deadlocks.
495 loop {
496 let db = session.snapshot_db();
497 match salsa::Cancelled::catch(AssertUnwindSafe(|| f(&db))) {
498 Ok(r) => return r,
499 Err(_) => drop(db),
500 }
501 }
502 }
503
504 /// Bounded variant of [`Self::snapshot_mir_query`] for cosmetic,
505 /// cursor-triggered reads that must not spin under sustained write pressure.
506 /// Returns `None` if a concurrent writer cancels the query `attempts` times
507 /// in a row (the caller then falls back to a stale result). Same
508 /// drop-before-retry discipline as `snapshot_mir_query`.
509 fn try_snapshot_mir_query<R>(
510 &self,
511 attempts: usize,
512 f: impl Fn(&mir_analyzer::db::MirDbStorage) -> R,
513 ) -> Option<R> {
514 use std::panic::AssertUnwindSafe;
515 let _interactive = self.interactive_read_guard();
516 let session = self.analysis_session(self.workspace_php_version());
517 for _ in 0..attempts {
518 let db = session.snapshot_db();
519 match salsa::Cancelled::catch(AssertUnwindSafe(|| f(&db))) {
520 Ok(r) => return Some(r),
521 Err(_) => drop(db),
522 }
523 }
524 None
525 }
526
527 /// Mirror a file's current text into the salsa layer. Creates the
528 /// `FileText` input on first sight, otherwise updates `text` on the
529 /// existing input (bumping the salsa revision so downstream queries
530 /// invalidate).
531 pub fn mirror_text(&self, uri: &Url, text: &str) {
532 // G2 fast path: compare against the lock-free text cache. When the
533 // new text byte-matches what we already mirrored, skip the host
534 // mutex entirely. Common during workspace scan + `did_open` for
535 // unchanged files, where most threads would otherwise serialise on
536 // `host.lock()` just to confirm a no-op.
537 if let Some(cached) = self.caches.text_cache.get(uri)
538 && **cached == *text
539 && !self.deleted_uris.contains(uri)
540 && self.lsp_ws_files.contains_key(uri)
541 {
542 return;
543 }
544 self.mirror_text_arc(uri, Arc::from(text))
545 }
546
547 /// Like [`mirror_text`] but takes an already-allocated `Arc<str>`.
548 ///
549 /// Callers that already hold an `Arc<str>` (e.g. `ingest_from_doc` reusing
550 /// `ParsedDoc::source_arc()`) use this to avoid a second allocation and to
551 /// ensure `text_cache` and `parsed_cache` hold the same Arc pointer —
552 /// enabling `Arc::ptr_eq` validation in `get_parsed_cached`.
553 pub fn mirror_text_arc(&self, uri: &Url, text_arc: Arc<str>) {
554 let dur = Self::input_durability(uri);
555 let path: Arc<str> = Arc::from(uri.as_str());
556 let session = self.analysis_session(self.workspace_php_version());
557 if let Some(wf) = self.lsp_ws_files.get(uri).map(|e| *e) {
558 self.deleted_uris.remove(uri);
559 // Fast path: byte-identical text already mirrored — skip the write
560 // lock and the revision bump entirely.
561 if let Some(cached) = self.caches.text_cache.get(uri)
562 && **cached == *text_arc
563 {
564 return;
565 }
566 session.with_db_mut(|db| {
567 let sf = wf.source(db);
568 sf.set_text(db).with_durability(dur).to(text_arc.clone());
569 // Any text change invalidates a previously-seeded cached index.
570 // Only set when present to avoid a spurious second revision bump.
571 if wf.cached_index(db).is_some() {
572 wf.set_cached_index(db).to(None);
573 }
574 });
575 self.caches.text_cache.insert(uri.clone(), text_arc);
576 // Evict only this file's analysis; cross-file invalidation is handled
577 // lazily in `cached_analysis` via the declaration fingerprint.
578 self.caches.evict_analysis(uri);
579 self.write_revision.fetch_add(1, Ordering::Release);
580 } else {
581 let wf = session.with_db_mut(|db| {
582 let sf = db.upsert_source_file_with_durability(path, text_arc.clone(), dur);
583 LspWsFile::new(db, sf, None)
584 });
585 self.lsp_ws_files.insert(uri.clone(), wf);
586 self.caches.text_cache.insert(uri.clone(), text_arc);
587 self.workspace_files_dirty.store(true, Ordering::Release);
588 self.write_revision.fetch_add(1, Ordering::Release);
589 }
590 }
591
592 /// Return the `LspWsFile` handle for a URL, if active (not deleted).
593 #[cfg(test)]
594 pub fn source_file(&self, uri: &Url) -> Option<LspWsFile> {
595 if self.deleted_uris.contains(uri) {
596 return None;
597 }
598 self.lsp_ws_files.get(uri).map(|e| *e)
599 }
600
601 /// Phase K2: pre-seed a `FileIndex` loaded from the on-disk cache onto
602 /// the `FileText` input for `uri`. The next `file_index` call for that
603 /// file returns the cached index directly, skipping parse + extract.
604 ///
605 /// Must be called **before** any `file_index(db, sf)` call for this file —
606 /// otherwise salsa has already memoized the fresh-parse result and setting
607 /// `cached_index` now would only bump the revision without using the cache.
608 /// In practice the workspace-scan path seeds immediately after `mirror_text`
609 /// and before any query runs.
610 ///
611 /// Returns `false` when `uri` was not mirrored (caller should mirror
612 /// first); returns `true` on success.
613 pub fn seed_cached_index(&self, uri: &Url, index: Arc<FileIndex>) -> bool {
614 let Some(wf) = self.lsp_ws_file(uri) else {
615 return false;
616 };
617 let session = self.analysis_session(self.workspace_php_version());
618 session.with_db_mut(|db| wf.set_cached_index(db).to(Some(index)));
619 true
620 }
621
622 /// Evict the semantic-tokens cache for `uri`. Called by Backend when a
623 /// file is closed; diff-based tokens computed against the old revision
624 /// are no longer meaningful.
625 pub fn evict_token_cache(&self, uri: &Url) {
626 self.caches.evict_tokens(uri);
627 }
628
629 /// Return the `FileIndex` for `uri` by running `file_index` on a salsa
630 /// snapshot. Returns `None` when `uri` has not been mirrored.
631 ///
632 /// Test-only — production code uses the salsa query directly via
633 /// `snapshot_query`.
634 #[cfg(test)]
635 pub fn source_files_len(&self) -> usize {
636 self.lsp_ws_files.len()
637 }
638
639 #[cfg(test)]
640 pub fn snapshot_query_file_index(
641 &self,
642 uri: &Url,
643 ) -> Option<crate::index::file_index::FileIndex> {
644 let wf = self.lsp_ws_file(uri)?;
645 Some(
646 self.snapshot_mir_query(move |db| {
647 (*crate::db::mir_queries::file_index(db, wf).0).clone()
648 }),
649 )
650 }
651
652 /// Register a file in the salsa layer without marking it open.
653 ///
654 /// Salsa's `parsed_doc` query parses lazily on first read; diagnostics
655 /// are populated by `did_open` when the editor actually opens the file.
656 pub fn ingest(&self, uri: Url, text: &str) {
657 self.mirror_text(&uri, text);
658 }
659
660 /// Index a file using an already-parsed `ParsedDoc`, avoiding a second parse.
661 ///
662 /// Prefer this over [`ingest`] when the caller already has a `ParsedDoc` (e.g.
663 /// after running `DefinitionCollector` during workspace scan). Reuses the
664 /// `Arc<str>` already owned by `doc` so that `text_cache` and `SourceFile::text`
665 /// share the same pointer — enabling the `Arc::ptr_eq` fast path in
666 /// `get_parsed_cached` on the first subsequent salsa query, without an extra
667 /// `Arc::from(source)` allocation.
668 pub fn ingest_from_doc(&self, uri: Url, doc: &ParsedDoc) {
669 self.mirror_text_arc(&uri, doc.source_arc());
670 }
671
672 pub fn remove(&self, uri: &Url) {
673 self.caches.evict(uri);
674 // Mark the URI as deleted but keep the `source_files` entry so the
675 // salsa `SourceFile` handle remains alive. Re-opening the file reuses
676 // the same handle instead of calling `SourceFile::new()` again, which
677 // would create a new orphaned salsa input on every delete-reopen cycle.
678 self.deleted_uris.insert(uri.clone());
679 self.workspace_files_dirty.store(true, Ordering::Release);
680 // Sync workspace files so the deleted file is removed from the salsa
681 // `Workspace::files` list and won't appear in workspace symbols etc.
682 self.sync_workspace_files();
683 // Also evict the file from the `AnalysisSession`'s internal state so
684 // workspace symbol queries don't keep returning the deleted file's
685 // declarations. Cheap when the session hasn't ingested this file.
686 let guard = self.analysis_session.lock().unwrap();
687 if let Some((_, session)) = guard.as_ref() {
688 session.invalidate_file(uri.as_str());
689 }
690 self.write_revision.fetch_add(1, Ordering::Release);
691 }
692
693 // ── Salsa-backed accessors ─────────────────────────────────────────────
694 //
695 // Reads run the memoized `parsed_doc` / `file_index` queries, parsing
696 // only on first access per revision. These are the production accessors
697 // used by every handler.
698
699 /// Salsa-backed parsed document.
700 ///
701 /// Salsa-backed parsed document for any mirrored file (open or
702 /// background-indexed). Returns `None` only when the file is not known
703 /// to the store. Callers that want "only if open" should gate on
704 /// `Backend::open_files` at the call site (see `Backend::get_doc`).
705 pub fn get_doc_salsa(&self, uri: &Url) -> Option<Arc<ParsedDoc>> {
706 self.get_parsed_cached(uri)
707 }
708
709 /// Salsa-backed compact symbol index.
710 pub fn get_index_salsa(&self, uri: &Url) -> Option<Arc<FileIndex>> {
711 let wf = self.lsp_ws_file(uri)?;
712 Some(
713 self.snapshot_mir_query(move |db| crate::db::mir_queries::file_index(db, wf).0.clone()),
714 )
715 }
716
717 /// Salsa-backed pre-computed symbol map (name → Vec<SymbolEntry>).
718 /// Memoized per revision: stable files serve from cache in O(1).
719 pub fn get_symbol_map_salsa(
720 &self,
721 uri: &Url,
722 ) -> Option<Arc<crate::types::symbol_map::SymbolMap>> {
723 // Symbol map runs on the shared mir db, sharing its memoized `parsed_doc`.
724 let wf = self.lsp_ws_file(uri)?;
725 Some(self.snapshot_mir_query(move |db| {
726 let sf = *wf.source(db);
727 crate::db::mir_queries::symbol_map(db, sf).0.clone()
728 }))
729 }
730
731 /// Pre-computed symbol maps for every entry in `open_urls` except `uri`.
732 pub fn other_symbol_maps(
733 &self,
734 uri: &Url,
735 open_urls: &[Url],
736 ) -> Vec<(Url, Arc<crate::types::symbol_map::SymbolMap>)> {
737 open_urls
738 .iter()
739 .filter(|u| *u != uri)
740 .filter_map(|u| self.get_symbol_map_salsa(u).map(|m| (u.clone(), m)))
741 .collect()
742 }
743
744 /// G3: shared implementation for `get_doc_salsa`.
745 /// Tries the `parsed_cache` (lock-free) first; validates via
746 /// `Arc::ptr_eq` against the G2 `text_cache` so a concurrent writer
747 /// that has already committed a new text input cannot be masked by a
748 /// stale cache entry. On miss, captures the text Arc and ParsedDoc
749 /// together inside a single `snapshot_query`, then publishes both.
750 fn get_parsed_cached(&self, uri: &Url) -> Option<Arc<ParsedDoc>> {
751 if let Some(current_text) = self.caches.text_cache.get(uri)
752 && let Some(entry) = self.caches.parsed_cache.get(uri)
753 && Arc::ptr_eq(&*current_text, &entry.0)
754 {
755 let doc = entry.1.clone();
756 drop(entry);
757 self.caches.touch(uri);
758 return Some(doc);
759 }
760
761 // Parse runs on the shared mir db.
762 let wf = self.lsp_ws_file(uri)?;
763 self.caches.bump_parse_count();
764 let (text, doc) = self.snapshot_mir_query(move |db| {
765 let sf = *wf.source(db);
766 let text = sf.text(db).clone();
767 let doc = crate::db::mir_queries::parsed_doc(db, sf).0.clone();
768 (text, doc)
769 });
770 self.caches.insert_parsed(uri.clone(), text, doc.clone());
771 Some(doc)
772 }
773
774 /// Parsed doc for a cosmetic, cursor-triggered read (e.g. `documentHighlight`)
775 /// that must stay responsive while the user types. Unlike [`Self::get_doc_salsa`]
776 /// it never spins on the `Cancelled` retry loop: it tries the lock-free
777 /// `parsed_cache`, then a bounded snapshot parse, and if a concurrent write
778 /// stream keeps cancelling it, returns the last-good cached `ParsedDoc`
779 /// (possibly one edit stale — invisible for a highlight, unlike a hang).
780 /// `None` only when the file has never been parsed.
781 pub fn get_doc_snapshot_or_stale(&self, uri: &Url) -> Option<Arc<ParsedDoc>> {
782 if let Some(current_text) = self.caches.text_cache.get(uri)
783 && let Some(entry) = self.caches.parsed_cache.get(uri)
784 && Arc::ptr_eq(&*current_text, &entry.0)
785 {
786 let doc = entry.1.clone();
787 drop(entry);
788 self.caches.touch(uri);
789 return Some(doc);
790 }
791
792 if let Some(wf) = self.lsp_ws_file(uri)
793 && let Some((text, doc)) = self.try_snapshot_mir_query(3, move |db| {
794 let sf = *wf.source(db);
795 let text = sf.text(db).clone();
796 let doc = crate::db::mir_queries::parsed_doc(db, sf).0.clone();
797 (text, doc)
798 })
799 {
800 self.caches.bump_parse_count();
801 self.caches.insert_parsed(uri.clone(), text, doc.clone());
802 return Some(doc);
803 }
804
805 self.caches.parsed_cache.get(uri).map(|e| e.1.clone())
806 }
807
808 /// Refresh `workspace.files` to mirror the current active file set.
809 ///
810 /// Skips all work when `workspace_files_dirty` is `false` (the common
811 /// case after the workspace scan completes — file-set changes are rare).
812 pub fn sync_workspace_files(&self) {
813 // Atomically clear the flag. If it was already false the file set
814 // hasn't changed since the last sync; nothing to do.
815 if !self.workspace_files_dirty.swap(false, Ordering::AcqRel) {
816 return;
817 }
818
819 // Collect active (non-deleted) files, sorted by URI for stable ordering.
820 let mut entries: Vec<(Arc<str>, LspWsFile)> = self
821 .lsp_ws_files
822 .iter()
823 .filter(|e| !self.deleted_uris.contains(e.key()))
824 .map(|e| (Arc::<str>::from(e.key().as_str()), *e.value()))
825 .collect();
826 entries.sort_unstable_by(|(a, _), (b, _)| a.cmp(b));
827 let files: Arc<[LspWsFile]> = entries.iter().map(|(_, wf)| *wf).collect();
828
829 let session = self.analysis_session(self.workspace_php_version());
830 let mut guard = self.lsp_workspace.lock().unwrap();
831 session.with_db_mut(|db| match *guard {
832 Some(ws) => {
833 ws.set_files(db).to(files);
834 }
835 None => *guard = Some(LspWorkspace::new(db, files)),
836 });
837 }
838
839 /// Mark the workspace file set as dirty so the next `sync_workspace_files`
840 /// call re-runs the collect/sort/compare path. Exposed for benchmarks that
841 /// need to measure the dirty-path cost in isolation.
842 pub fn mark_workspace_files_dirty(&self) {
843 self.workspace_files_dirty.store(true, Ordering::Release);
844 }
845
846 /// Update the PHP version tracked by the workspace. Salsa will invalidate
847 /// all `semantic_issues` queries so diagnostics are re-evaluated.
848 /// Skips the setter when the version hasn't changed to avoid spurious
849 /// query invalidation.
850 pub fn set_php_version(&self, version: mir_analyzer::PhpVersion) {
851 {
852 let mut guard = self.php_version.lock().unwrap();
853 if *guard == version {
854 return;
855 }
856 *guard = version;
857 }
858 // Changing the version selects a different `AnalysisSession` (and thus a
859 // different db). Drop the inputs created on the old session's db; the
860 // workspace scan re-mirrors files onto the new session. In practice the
861 // version is set once at init, before any file is mirrored.
862 self.lsp_ws_files.clear();
863 *self.lsp_workspace.lock().unwrap() = None;
864 self.workspace_files_dirty.store(true, Ordering::Release);
865 // Stale FileAnalysis from the old version would survive unchanged files.
866 self.caches.evict_analysis_all();
867 }
868
869 /// Inverted-index reference lookup scoped to `files` (the text-pre-filtered
870 /// candidate set): a posting-list read for committed-fresh files plus an
871 /// on-demand analyze+commit for stale/uncommitted candidates, so warm
872 /// requests are O(results) instead of O(candidates).
873 ///
874 /// Returns LSP-style 0-based line/column.
875 ///
876 /// `cancel_rev`: when `Some(rev)`, the loop throws `salsa::Cancelled` and
877 /// returns empty if a concurrent write advances the `write_revision` counter
878 /// past `rev` — preventing unbounded retries against a newly-invalidated db.
879 /// Pass `None` to retain the original indefinite-retry behaviour (fast ops
880 /// like single-file reads where a stale result is not a concern).
881 pub fn indexed_references(
882 &self,
883 symbol: &mir_analyzer::Name,
884 files: &[Arc<str>],
885 include_declaration: bool,
886 cancel_rev: Option<u64>,
887 ) -> Vec<(Arc<str>, u32, u32, u32)> {
888 let php_version = self.workspace_php_version();
889 // Staleness probe threaded into mir: polled at phase boundaries and
890 // between cancellation retries, so a request invalidated by a
891 // concurrent edit aborts *inside* mir's retry loop instead of spinning
892 // there indefinitely (mir catches `Cancelled` internally, so an outer
893 // catch alone never fires for the parallel phase).
894 let stale =
895 || cancel_rev.is_some_and(|rev| self.write_revision.load(Ordering::Acquire) != rev);
896 // Retry: concurrent db writes (background indexing) cancel snapshot
897 // queries via resume_unwind; without the loop the panic propagates out
898 // of the caller's spawn_blocking and the request silently returns empty.
899 let raw = loop {
900 let session = self.analysis_session(php_version);
901 match salsa::Cancelled::catch(std::panic::AssertUnwindSafe(|| {
902 session.indexed_references_to(symbol, files, include_declaration, &stale)
903 })) {
904 Ok(Some(refs)) => break refs,
905 // mir aborted via the staleness probe — or a Phase-1 unwind
906 // hit an already-stale request. Propagate as salsa::Cancelled
907 // so spawn_blocking callers see a JoinError::Panicked and
908 // return empty via unwrap_or_default.
909 Ok(None) => {
910 std::panic::resume_unwind(Box::new(salsa::Cancelled::PendingWrite));
911 }
912 Err(_) if stale() => {
913 std::panic::resume_unwind(Box::new(salsa::Cancelled::PendingWrite));
914 }
915 // Phase-1 unwind from a write that doesn't invalidate this
916 // request (mir-internal load_class, scan writes with no
917 // cancel_rev) — retry.
918 Err(_) => {}
919 }
920 };
921 raw.into_iter()
922 .map(|(file, range)| {
923 // mir uses 1-based lines; 0-based columns (since mir 0.42.0).
924 let line = range.start.line.saturating_sub(1);
925 let col_start = range.start.column;
926 let col_end = range.end.column;
927 (file, line, col_start, col_end)
928 })
929 .collect()
930 }
931
932 /// `use`-import lines referencing `symbol`, from mir's `use:` postings.
933 /// Read-only lookup with no freshness pass — run [`Self::indexed_references`]
934 /// over the same `files` first so uncommitted candidates are analyzed.
935 /// Returns `(file_uri, line, col_start, col_end)` tuples like
936 /// [`Self::indexed_references`]; the range covers the whole import item.
937 pub fn indexed_use_imports(
938 &self,
939 symbol: &mir_analyzer::Name,
940 files: &[Arc<str>],
941 ) -> Vec<(Arc<str>, u32, u32, u32)> {
942 let session = self.analysis_session(self.workspace_php_version());
943 let _interactive = self.interactive_read_guard();
944 let raw = loop {
945 if let Ok(locs) = salsa::Cancelled::catch(std::panic::AssertUnwindSafe(|| {
946 session.indexed_use_import_locations(symbol, files)
947 })) {
948 break locs;
949 }
950 };
951 raw.into_iter()
952 .map(|(file, range)| {
953 // mir uses 1-based lines; 0-based columns.
954 let line = range.start.line.saturating_sub(1);
955 (file, line, range.start.column, range.end.column)
956 })
957 .collect()
958 }
959
960 /// Replay disk-cached reference postings and subtype edges for the whole
961 /// mirrored workspace (mir's `warm_start_files`) — a no-op per file
962 /// without a content-hash-matching cache entry from a previous run. Call
963 /// once after the scan mirrors texts, before the analysis warm sweep, so
964 /// a returning session starts index-warm.
965 pub fn warm_start_indexes(&self) {
966 let files: Vec<(Arc<str>, Arc<str>)> = self
967 .lsp_ws_files
968 .iter()
969 .filter(|e| !self.deleted_uris.contains(e.key()))
970 .filter_map(|e| {
971 let text = self.caches.text_cache.get(e.key())?;
972 Some((Arc::<str>::from(e.key().as_str()), Arc::clone(&text)))
973 })
974 .collect();
975 if files.is_empty() {
976 return;
977 }
978 let session = self.analysis_session(self.workspace_php_version());
979 session.warm_start_files(&files);
980 }
981
982 /// Candidate file scope for a posting lookup on `symbol` (whose cursor /
983 /// declaration word is `word`). Private/protected methods narrow to their
984 /// visibility scope. After the analysis warm sweep has committed every
985 /// file's postings the freshness pass is a cheap per-file check, so the
986 /// whole workspace is passed directly; before that, the text pre-filter
987 /// bounds the on-demand analysis a cold query would otherwise pay across
988 /// the full workspace.
989 pub(crate) fn reference_candidate_files(
990 &self,
991 symbol: &mir_analyzer::Name,
992 word: &str,
993 ) -> Vec<Arc<str>> {
994 if let mir_analyzer::Name::Method { class, name } = symbol
995 && let Some(urls) = self.method_reference_scope(class, name)
996 {
997 return urls.iter().map(|u| Arc::from(u.as_str())).collect();
998 }
999 if self.warm_sweeps_completed() > 0 {
1000 self.workspace_file_paths()
1001 } else {
1002 self.candidate_urls_for(word)
1003 .iter()
1004 .map(|u| Arc::from(u.as_str()))
1005 .collect()
1006 }
1007 }
1008
1009 /// Every active workspace file as a mir path (`Arc<str>` of the URI).
1010 /// The candidate scope handed to mir's subtype queries.
1011 pub(crate) fn workspace_file_paths(&self) -> Vec<Arc<str>> {
1012 self.lsp_ws_files
1013 .iter()
1014 .filter(|e| !self.deleted_uris.contains(e.key()))
1015 .map(|e| Arc::<str>::from(e.key().as_str()))
1016 .collect()
1017 }
1018
1019 /// Transitive subtypes of `class_fqn` from mir's maintained subtype edge
1020 /// index, with declaration sites. `include_trait_users` also counts
1021 /// `use Trait;` composition as a subtype edge (trait-usage lenses); leave
1022 /// it off for goto-implementation semantics (extends/implements only).
1023 pub fn indexed_subtype_classes(
1024 &self,
1025 class_fqn: &str,
1026 include_trait_users: bool,
1027 ) -> Vec<mir_analyzer::SubtypeClassSite> {
1028 let files = self.workspace_file_paths();
1029 let session = self.analysis_session(self.workspace_php_version());
1030 let _interactive = self.interactive_read_guard();
1031 loop {
1032 if let Ok(sites) = salsa::Cancelled::catch(std::panic::AssertUnwindSafe(|| {
1033 session.indexed_subtype_classes(class_fqn, &files, include_trait_users)
1034 })) {
1035 break sites;
1036 }
1037 }
1038 }
1039
1040 /// Concrete implementations of `class_fqn::method` across its subtypes:
1041 /// `(subtype fqcn, file, name range)` from mir's subtype edge index.
1042 pub fn indexed_method_implementations(
1043 &self,
1044 class_fqn: &str,
1045 method: &str,
1046 ) -> Vec<(Arc<str>, Arc<str>, mir_analyzer::Range)> {
1047 let files = self.workspace_file_paths();
1048 let session = self.analysis_session(self.workspace_php_version());
1049 let _interactive = self.interactive_read_guard();
1050 loop {
1051 if let Ok(sites) = salsa::Cancelled::catch(std::panic::AssertUnwindSafe(|| {
1052 session.indexed_method_implementations(class_fqn, method, &files)
1053 })) {
1054 break sites;
1055 }
1056 }
1057 }
1058
1059 /// The complete set of files a `private` method's references can occur in,
1060 /// or `None` when the search must stay workspace-wide.
1061 ///
1062 /// `owner_fqn` is the enclosing class at the cursor; narrowing applies only
1063 /// when that class *directly declares* `method` (so its `MethodDef`
1064 /// visibility is authoritative — inherited/call-site lookups fall through to
1065 /// `None`). A reference to `Class::method` is recorded only where
1066 /// `analyze_file` resolves an expression to that key, and `analyze_file` is
1067 /// per-source-file, so a `private` member — resolvable only inside the
1068 /// declaring class body, which lives in one file — is fully scoped to the
1069 /// declaring file at any indexing stage.
1070 ///
1071 /// A `protected` member is reachable from the declaring class and its
1072 /// transitive subclasses (`$this->m()`, `parent::m()`, `self::`/`static::`),
1073 /// all of which live in the declaring file or a subtype file — never outside
1074 /// the hierarchy in valid PHP. Its scope is therefore the declaring file plus
1075 /// the transitive subtype files. The complete subtype set is only known once
1076 /// the workspace reference index finishes, so this narrowing applies only
1077 /// when [`Self::is_index_ready`]; before then it falls back to `None` (full
1078 /// workspace scope) so references are never under-reported.
1079 ///
1080 /// Classes that compose traits or mixins are excluded — those inline
1081 /// external resolution context, so a reference could surface in another file.
1082 pub fn method_reference_scope(&self, owner_fqn: &str, method: &str) -> Option<Vec<Url>> {
1083 use crate::index::file_index::{ClassKind, Visibility};
1084
1085 let ws = self.get_workspace_index_salsa();
1086 let owner_fqn = owner_fqn.trim_start_matches('\\');
1087 let owner_short = crate::text::fqn_short_name(owner_fqn);
1088
1089 let (decl_uri, decl_class) = ws
1090 .classes_by_name
1091 .get(owner_short)?
1092 .iter()
1093 .filter_map(|&r| ws.at(r))
1094 .find(|(_, cls)| cls.fqn.trim_start_matches('\\') == owner_fqn)?;
1095
1096 // Trait/interface methods, and methods on classes that compose traits or
1097 // mixins, can be referenced from other files — keep the full scope.
1098 if !matches!(decl_class.kind, ClassKind::Class | ClassKind::Enum)
1099 || !decl_class.traits.is_empty()
1100 || !decl_class.mixins.is_empty()
1101 {
1102 return None;
1103 }
1104
1105 let vis = decl_class
1106 .methods
1107 .iter()
1108 .find(|m| m.name.eq_ignore_ascii_case(method))?
1109 .visibility;
1110
1111 match vis {
1112 Visibility::Private => Some(vec![decl_uri.clone()]),
1113 Visibility::Protected => {
1114 if !self.is_index_ready() {
1115 return None;
1116 }
1117 // Subtype set comes from mir's resolved inheritance graph — it
1118 // matches subclasses by FQCN, so `extends \Ns\Base` and aliased
1119 // `use ... as` forms are all found. Falls back to full scope if
1120 // mir can't resolve the owner.
1121 let session = self.analysis_session(self.workspace_php_version());
1122 let mut files: std::collections::HashSet<Url> = session
1123 .subtype_files(owner_fqn)
1124 .into_iter()
1125 .filter_map(|p| Url::parse(&p).ok())
1126 .collect();
1127 files.insert(decl_uri.clone());
1128 Some(files.into_iter().collect())
1129 }
1130 Visibility::Public => None,
1131 }
1132 }
1133
1134 /// Phase J: salsa-memoized aggregate workspace index.
1135 ///
1136 /// Returns the shared `Arc<WorkspaceIndexData>` with flat
1137 /// `(Url, Arc<FileIndex>)` list plus pre-built `classes_by_name` and
1138 /// `subtypes_of` reverse maps. Used by workspace_symbols,
1139 /// prepare_type_hierarchy, supertypes_of, subtypes_of, and
1140 /// find_implementations so they don't each rebuild the aggregate per
1141 /// request. Invalidates automatically when any file's `file_index`
1142 /// changes.
1143 pub fn get_workspace_index_salsa(&self) -> Arc<crate::db::workspace_index::WorkspaceIndexData> {
1144 self.sync_workspace_files();
1145 let ws = *self.lsp_workspace.lock().unwrap();
1146 let Some(ws) = ws else {
1147 return Arc::new(crate::db::workspace_index::WorkspaceIndexData {
1148 files: Vec::new(),
1149 classes_by_name: std::collections::HashMap::new(),
1150 subtypes_of: std::collections::HashMap::new(),
1151 decls_by_name: std::collections::HashMap::new(),
1152 classes_by_lowercase_name: Vec::new(),
1153 });
1154 };
1155 self.snapshot_mir_query(move |db| crate::db::mir_queries::workspace_index(db, ws).0.clone())
1156 }
1157
1158 /// Total number of real `ParsedDoc` parses served so far (cache misses).
1159 /// Surfaced via `$/php-lsp/debugStats` so tests can assert the references
1160 /// read path doesn't parse the whole workspace.
1161 pub fn parse_count(&self) -> u64 {
1162 self.caches.parse_count()
1163 }
1164
1165 /// Times mir's `RefIndex` was locked on the current session. Reads are
1166 /// per-key posting lookups and edits commit one file's postings, so this
1167 /// grows by a small bounded amount per operation — never per candidate.
1168 /// Surfaced via `$/php-lsp/debugStats` for the stress-test guard.
1169 pub fn ref_index_lock_count(&self) -> u64 {
1170 self.analysis_session(self.workspace_php_version())
1171 .ref_index_lock_count()
1172 }
1173
1174 /// Return the raw source text for `uri` if it has been mirrored into the
1175 /// salsa workspace. Used by the references handler to pre-filter session
1176 /// results by checking whether a file mentions the owning class name.
1177 pub fn source_text(&self, uri: &Url) -> Option<Arc<str>> {
1178 self.caches.text_cache.get(uri).map(|e| Arc::clone(&e))
1179 }
1180
1181 /// Cache the semantic tokens computed for a delta response.
1182 /// `result_id` is an opaque string (a hash of the token data) returned to the client.
1183 pub fn store_token_cache(&self, uri: &Url, result_id: String, tokens: Arc<Vec<SemanticToken>>) {
1184 self.caches.store_token(uri, result_id, tokens);
1185 }
1186
1187 /// Return the cached tokens if `result_id` matches the stored one.
1188 pub fn get_token_cache(&self, uri: &Url, result_id: &str) -> Option<Arc<Vec<SemanticToken>>> {
1189 self.caches.get_token(uri, result_id)
1190 }
1191
1192 /// Raw semantic issues for a file, computed via mir's session-based
1193 /// `FileAnalyzer`. The session lazy-loads dependencies via PSR-4 so the
1194 /// LSP no longer needs to mirror vendor up-front. Callers apply their
1195 /// own `DiagnosticsConfig` filter via
1196 /// [`crate::semantic_diagnostics::issues_to_diagnostics`].
1197 #[tracing::instrument(skip_all)]
1198 pub fn get_semantic_issues_salsa(&self, uri: &Url) -> Option<Arc<[mir_issues::Issue]>> {
1199 let analysis = self.cached_analysis(uri)?;
1200 let file: Arc<str> = Arc::from(uri.as_str());
1201 // Workspace-level class issues for this file (circular inheritance,
1202 // override violations, abstract-method gaps). These are session-wide
1203 // (a dependency edit changes them without changing this file's bytes),
1204 // so they are recomputed live rather than cached alongside the
1205 // per-file body analysis.
1206 let class_issues = {
1207 let _s = tracing::debug_span!("session.class_issues_for").entered();
1208 // Retry: concurrent db writes cancel snapshot queries via resume_unwind.
1209 loop {
1210 let session = self.analysis_session(self.workspace_php_version());
1211 if let Ok(issues) = salsa::Cancelled::catch(std::panic::AssertUnwindSafe(|| {
1212 session.class_issues(std::slice::from_ref(&file))
1213 })) {
1214 break issues;
1215 }
1216 }
1217 };
1218 let combined: Vec<mir_issues::Issue> = analysis
1219 .issues
1220 .iter()
1221 .cloned()
1222 .chain(class_issues)
1223 .filter(|i| !i.suppressed)
1224 .collect();
1225 Some(Arc::from(combined))
1226 }
1227
1228 /// Run (or reuse) mir's per-file body analysis, retaining the full
1229 /// [`mir_analyzer::FileAnalysis`] — issues **and** resolved symbols — across
1230 /// requests. Diagnostics read `.issues`; position features call
1231 /// `.symbol_at(offset)` for the resolved type at a cursor.
1232 ///
1233 /// Cache hit when the entry's captured source `Arc` is pointer-equal to the
1234 /// file's current `doc.source_arc()`. A miss recomputes and overwrites, so
1235 /// the entry self-evicts on any content edit.
1236 /// Build (or reuse) the whole-doc completion [`crate::types::type_map::TypeMap`]
1237 /// for `uri`. Cache hit when the entry's captured source `Arc` is
1238 /// pointer-equal to `doc.source_arc()` and the PHPStorm-meta pointer is
1239 /// unchanged (meta lives behind `ArcSwap`, so its address is stable until
1240 /// `.phpstorm.meta.php` is reloaded). A miss rebuilds and overwrites, so
1241 /// the entry self-evicts on any content edit.
1242 pub fn cached_type_map(
1243 &self,
1244 uri: &Url,
1245 doc: &crate::document::ast::ParsedDoc,
1246 meta: Option<&crate::lang::phpstorm_meta::PhpStormMeta>,
1247 ) -> Arc<crate::types::type_map::TypeMap> {
1248 let source = doc.source_arc();
1249 let meta_key = meta.map_or(0usize, |m| std::ptr::from_ref(m) as usize);
1250 if let Some(entry) = self.caches.type_map_cache.get(uri)
1251 && Arc::ptr_eq(&entry.0, &source)
1252 && entry.1 == meta_key
1253 {
1254 let map = Arc::clone(&entry.2);
1255 drop(entry);
1256 self.caches.touch(uri);
1257 return map;
1258 }
1259 let map = Arc::new(crate::types::type_map::TypeMap::from_doc_with_meta(
1260 doc, meta,
1261 ));
1262 self.caches.shed_stale(
1263 &self.caches.type_map_cache,
1264 crate::document::cache_registry::TYPE_MAP_CACHE_CAP,
1265 );
1266 self.caches.touch(uri);
1267 self.caches
1268 .type_map_cache
1269 .insert(uri.clone(), (source, meta_key, Arc::clone(&map)));
1270 map
1271 }
1272
1273 /// Cache-hit-only variant of [`Self::cached_analysis`]: returns the cached
1274 /// analysis when the entry is current for the file's text, never computes.
1275 /// Lets async handlers take the warm path synchronously and reserve
1276 /// `spawn_blocking` for the cold path (mir Pass 1 + Pass 2 can take
1277 /// hundreds of ms on large files).
1278 pub fn cached_analysis_if_fresh(&self, uri: &Url) -> Option<Arc<mir_analyzer::FileAnalysis>> {
1279 let doc = self.get_doc_salsa(uri)?;
1280 let source = doc.source_arc();
1281 let cur_ver = self.caches.decl_version();
1282 let analysis = {
1283 let entry = self.caches.analysis_cache.get(uri)?;
1284 (Arc::ptr_eq(&entry.0, &source) && entry.1 == cur_ver).then(|| Arc::clone(&entry.2))?
1285 };
1286 self.caches.touch(uri);
1287 Some(analysis)
1288 }
1289
1290 pub fn cached_analysis(&self, uri: &Url) -> Option<Arc<mir_analyzer::FileAnalysis>> {
1291 self.cached_analysis_cancellable(uri, &|| false)
1292 }
1293
1294 /// [`Self::cached_analysis`] with an early exit: `should_cancel` is
1295 /// polled whenever a concurrent salsa write cancels the analysis attempt.
1296 /// A handler passes its write-revision staleness probe so a request made
1297 /// obsolete by newer typing stops burning a blocking-pool thread instead
1298 /// of retrying until the writer pauses — the editor re-requests anyway.
1299 #[tracing::instrument(skip_all)]
1300 pub fn cached_analysis_cancellable(
1301 &self,
1302 uri: &Url,
1303 should_cancel: &(dyn Fn() -> bool + Sync),
1304 ) -> Option<Arc<mir_analyzer::FileAnalysis>> {
1305 // Need the parsed doc both for the analyzer and as the cache key.
1306 let doc = self.get_doc_salsa(uri)?;
1307 let source = doc.source_arc();
1308
1309 let cur_ver = self.caches.decl_version();
1310 if let Some(entry) = self.caches.analysis_cache.get(uri)
1311 && Arc::ptr_eq(&entry.0, &source)
1312 && entry.1 == cur_ver
1313 {
1314 let analysis = Arc::clone(&entry.2);
1315 drop(entry);
1316 self.caches.touch(uri);
1317 return Some(analysis);
1318 }
1319
1320 let php_version = self.workspace_php_version();
1321 let session = self.analysis_session(php_version);
1322 let file: Arc<str> = Arc::from(uri.as_str());
1323
1324 let source_map = php_rs_parser::source_map::SourceMap::new(doc.source());
1325 let owned_program = if let Some(cached) = self.caches.owned_program_cache.get(uri)
1326 && Arc::ptr_eq(&cached.0, &source)
1327 {
1328 Arc::clone(&cached.1)
1329 } else {
1330 let prog = Arc::new(php_ast::owned::to_owned_program(doc.program()));
1331 self.caches.shed_stale(
1332 &self.caches.owned_program_cache,
1333 crate::document::cache_registry::OWNED_PROGRAM_CACHE_CAP,
1334 );
1335 self.caches
1336 .owned_program_cache
1337 .insert(uri.clone(), (Arc::clone(&source), Arc::clone(&prog)));
1338 prog
1339 };
1340
1341 // autoload.files helpers (e.g. Laravel's tap()) must be ingested so mir sees their functions.
1342 let autoload_texts: Vec<(Arc<str>, Arc<str>)> = {
1343 let autoload_uris = self.autoload_uris.read().unwrap().clone();
1344 autoload_uris
1345 .iter()
1346 .filter_map(|auri| {
1347 self.caches
1348 .text_cache
1349 .get(auri)
1350 .map(|t| (Arc::from(auri.as_str()), Arc::clone(&*t)))
1351 })
1352 .collect()
1353 };
1354 // Bare same-namespace / use-imported class refs aren't resolved by mir's priority_index_for_ast; preload them.
1355 let class_fqns = crate::references::collect_referenced_class_fqns(&doc);
1356
1357 // ingest_file/load_class/analyze take internal salsa snapshots; a concurrent db write cancels them via resume_unwind. Retry the idempotent sequence.
1358 let _interactive = self.interactive_read_guard();
1359 let analysis = loop {
1360 let attempt = salsa::Cancelled::catch(std::panic::AssertUnwindSafe(|| {
1361 session.ingest_file(file.clone(), source.clone());
1362 for (afile, atext) in &autoload_texts {
1363 session.ingest_file(afile.clone(), atext.clone());
1364 }
1365 for fqcn in &class_fqns {
1366 let _ = session.load_class(fqcn);
1367 }
1368 let analyzer = mir_analyzer::FileAnalyzer::new(&session);
1369 analyzer.analyze(file.clone(), doc.source(), &owned_program, &source_map)
1370 }));
1371 match attempt {
1372 Ok(a) => break Arc::new(a),
1373 Err(_) => {
1374 // A write cancelled the attempt. If it replaced THIS
1375 // file's text the result is already obsolete (the cache
1376 // key is the source Arc) and the editor re-requests after
1377 // its didChange — stop burning the blocking thread.
1378 // Writes elsewhere just retry as before.
1379 let text_changed = self
1380 .caches
1381 .text_cache
1382 .get(uri)
1383 .is_none_or(|t| !Arc::ptr_eq(&t, &source));
1384 if text_changed || should_cancel() {
1385 return None;
1386 }
1387 }
1388 }
1389 };
1390 // Compare the new FileIndex against the stored fingerprint. If
1391 // declarations changed (or this is the first analysis), bump
1392 // `decl_version` so other files' cache entries become stale. Body-only
1393 // edits leave the counter unchanged, allowing sibling files to be
1394 // served from cache on the next request.
1395 let new_index = self.get_index_salsa(uri);
1396 let old_fp = self
1397 .caches
1398 .decl_fingerprints
1399 .get(uri)
1400 .map(|e| Arc::clone(&*e));
1401 let decl_changed = match (&old_fp, &new_index) {
1402 (Some(old), Some(new)) => **old != **new,
1403 // First analysis: only a file that actually declares something can
1404 // affect other files. Opening a plain script must not invalidate
1405 // every open file's analysis cache and mir's warm-up marks.
1406 (None, Some(new)) => !new.declares_nothing(),
1407 _ => false,
1408 };
1409 if decl_changed {
1410 if let Some(idx) = new_index {
1411 self.caches.decl_fingerprints.insert(uri.clone(), idx);
1412 }
1413 self.caches.bump_decl_version();
1414 // Text reaches mir via direct salsa `set_text` writes, so mir can't
1415 // see declaration deletions itself. A deleted declaration may
1416 // unshadow a lazy-loadable symbol — invalidate mir's warm-up skip
1417 // set so reference queries re-run their prepare pass once.
1418 session.bump_prepare_generation();
1419 }
1420 // Tag with the version observed before the analysis ran, plus this
1421 // file's own bump. Reading `decl_version()` here instead would absorb
1422 // a concurrent bump from another file's mid-compute declaration change
1423 // into the tag, marking a possibly-stale result as fresh. The
1424 // conservative tag errs toward one extra recompute, never staleness.
1425 let ver = cur_ver + u64::from(decl_changed);
1426 self.caches.shed_stale(
1427 &self.caches.analysis_cache,
1428 crate::document::cache_registry::ANALYSIS_CACHE_CAP,
1429 );
1430 self.caches.touch(uri);
1431 self.caches
1432 .analysis_cache
1433 .insert(uri.clone(), (source, ver, Arc::clone(&analysis)));
1434 Some(analysis)
1435 }
1436
1437 /// Returns `(uri, doc)` for files currently open in the editor.
1438 ///
1439 /// Resolve `open_urls` (from `Backend::open_urls()`) to parsed docs.
1440 /// Files not mirrored in the salsa layer are filtered out silently.
1441 pub fn docs_for(&self, open_urls: &[Url]) -> Vec<(Url, Arc<ParsedDoc>)> {
1442 open_urls
1443 .iter()
1444 .filter_map(|u| self.get_doc_salsa(u).map(|d| (u.clone(), d)))
1445 .collect()
1446 }
1447
1448 /// `(primary, doc)` first, then every other open file's parsed doc.
1449 /// The `open_urls` slice should include `uri` — this helper filters it out.
1450 pub fn doc_with_others(
1451 &self,
1452 uri: &Url,
1453 doc: Arc<ParsedDoc>,
1454 open_urls: &[Url],
1455 ) -> Vec<(Url, Arc<ParsedDoc>)> {
1456 let mut result = vec![(uri.clone(), doc)];
1457 result.extend(self.other_docs(uri, open_urls));
1458 result
1459 }
1460
1461 /// Parsed docs for every entry in `open_urls` except `uri`.
1462 pub fn other_docs(&self, uri: &Url, open_urls: &[Url]) -> Vec<(Url, Arc<ParsedDoc>)> {
1463 open_urls
1464 .iter()
1465 .filter(|u| *u != uri)
1466 .filter_map(|u| self.get_doc_salsa(u).map(|d| (u.clone(), d)))
1467 .collect()
1468 }
1469
1470 /// Compact symbol index for every mirrored file.
1471 pub fn all_indexes(&self) -> Vec<(Url, Arc<FileIndex>)> {
1472 self.get_workspace_index_salsa().files.clone()
1473 }
1474
1475 /// Store a lazily-loaded vendor `FileIndex` in the session cache.
1476 /// Only call this for files that are not part of the normal workspace scan
1477 /// (i.e. vendor files loaded on-demand by PSR-4 navigation).
1478 pub fn cache_vendor_index(&self, uri: Url, index: Arc<FileIndex>) {
1479 self.caches.vendor_index_cache.insert(uri, index);
1480 }
1481
1482 /// Retrieve a previously cached vendor `FileIndex`.
1483 pub fn get_vendor_index(&self, uri: &Url) -> Option<Arc<FileIndex>> {
1484 self.caches
1485 .vendor_index_cache
1486 .get(uri)
1487 .map(|e| Arc::clone(&*e))
1488 }
1489
1490 /// Same as `all_indexes` but excludes `uri`.
1491 pub fn other_indexes(&self, uri: &Url) -> Vec<(Url, Arc<FileIndex>)> {
1492 self.get_workspace_index_salsa()
1493 .files
1494 .iter()
1495 .filter(|(u, _)| u != uri)
1496 .cloned()
1497 .collect()
1498 }
1499
1500 /// Parsed documents for every mirrored file (open or background-indexed).
1501 /// Suitable for full-scan operations: find-references, rename,
1502 /// call_hierarchy, code_lens.
1503 pub fn all_docs_for_scan(&self) -> Vec<(Url, Arc<ParsedDoc>)> {
1504 let urls: Vec<Url> = self
1505 .lsp_ws_files
1506 .iter()
1507 .filter(|e| !self.deleted_uris.contains(e.key()))
1508 .map(|e| e.key().clone())
1509 .collect();
1510 urls.into_iter()
1511 .filter_map(|u| self.get_doc_salsa(&u).map(|d| (u, d)))
1512 .collect()
1513 }
1514
1515 /// URLs of workspace files whose raw source text mentions `word` as a whole
1516 /// identifier — **no parsing**. Uses a precompiled `memmem` finder (the same
1517 /// needle is scanned across the whole workspace) plus a word-boundary check,
1518 /// so `save` does not match inside `saveAll`/`unsaved`; the scan runs in
1519 /// parallel over the file set. The references read path narrows this list by
1520 /// symbol visibility and hands it to mir's per-file `analyze_file`, so a
1521 /// method lookup never materializes a `ParsedDoc` for the workspace.
1522 ///
1523 /// Files whose text is not yet in `text_cache` are included conservatively
1524 /// (safe superset — never produces false negatives).
1525 pub fn candidate_urls_for(&self, word: &str) -> Vec<Url> {
1526 use rayon::prelude::*;
1527 let urls: Vec<Url> = self
1528 .lsp_ws_files
1529 .iter()
1530 .filter(|e| !self.deleted_uris.contains(e.key()))
1531 .map(|e| e.key().clone())
1532 .collect();
1533 let finder = memchr::memmem::Finder::new(word.as_bytes());
1534 let wlen = word.len();
1535 urls.into_par_iter()
1536 .filter(|u| match self.caches.text_cache.get(u) {
1537 Some(src) => mentions_identifier(src.as_bytes(), &finder, wlen),
1538 None => true,
1539 })
1540 .collect()
1541 }
1542
1543 /// Parsed documents limited to files whose raw source text contains `word`.
1544 ///
1545 /// [`Self::candidate_urls_for`] + a salsa parse per survivor. Used by the
1546 /// AST-walker reference paths (constructor refs, non-method kinds) that
1547 /// genuinely need the parsed AST. The method reference path uses
1548 /// `candidate_urls_for` directly and avoids these parses entirely.
1549 ///
1550 /// Files whose text is not yet in `text_cache` are included conservatively
1551 /// (safe superset — never produces false negatives).
1552 pub fn candidate_docs_for(&self, word: &str) -> Vec<(Url, Arc<ParsedDoc>)> {
1553 self.candidate_urls_for(word)
1554 .into_iter()
1555 .filter_map(|u| self.get_doc_salsa(&u).map(|d| (u, d)))
1556 .collect()
1557 }
1558}
1559
1560/// Whether `hay` contains `finder`'s needle (length `wlen`) as a whole
1561/// identifier — i.e. not immediately preceded or followed by an ASCII
1562/// identifier byte (`[A-Za-z0-9_]`). Only ASCII bytes count as identifier
1563/// boundaries, so the check can over-accept near multibyte text but never
1564/// rejects a real whole-word match.
1565fn mentions_identifier(hay: &[u8], finder: &memchr::memmem::Finder, wlen: usize) -> bool {
1566 let is_ident = |b: u8| b.is_ascii_alphanumeric() || b == b'_';
1567 let mut from = 0;
1568 while let Some(rel) = finder.find(&hay[from..]) {
1569 let idx = from + rel;
1570 let before_ok = idx == 0 || !is_ident(hay[idx - 1]);
1571 let end = idx + wlen;
1572 let after_ok = end >= hay.len() || !is_ident(hay[end]);
1573 if before_ok && after_ok {
1574 return true;
1575 }
1576 from = idx + 1;
1577 }
1578 false
1579}
1580
1581#[cfg(test)]
1582mod tests {
1583 use super::*;
1584
1585 fn uri(path: &str) -> Url {
1586 Url::parse(&format!("file://{path}")).unwrap()
1587 }
1588
1589 #[test]
1590 fn mentions_identifier_respects_word_boundaries() {
1591 let m = |hay: &str, word: &str| {
1592 let f = memchr::memmem::Finder::new(word.as_bytes());
1593 mentions_identifier(hay.as_bytes(), &f, word.len())
1594 };
1595 assert!(m("$this->save();", "save"));
1596 assert!(m("function save() {}", "save"));
1597 assert!(m("save", "save"));
1598 // substrings inside a larger identifier must not match
1599 assert!(!m("$this->saveAll();", "save"));
1600 assert!(!m("return $unsaved;", "save"));
1601 assert!(!m("class Saver {}", "save"));
1602 assert!(!m("no occurrence here", "save"));
1603 }
1604
1605 /// Phase E4: open-file state lives on `Backend`, not `DocumentStore`.
1606 /// Tests that need to simulate "file is open" just mirror the text into
1607 /// the salsa input — the open/closed distinction is enforced by the
1608 /// caller (Backend) in production.
1609 fn open(store: &DocumentStore, u: Url, text: String) {
1610 store.mirror_text(&u, &text);
1611 }
1612
1613 // Removed `salsa_codebase_aggregates_all_files`: the salsa-side codebase
1614 // aggregation was deleted with the mir 0.22 migration. Equivalent
1615 // behaviour is now covered by mir-analyzer's own session tests.
1616
1617 // Spawn a thread that calls `yield_to_interactive_reads` and hands back
1618 // the elapsed time it spent waiting. It signals `ready` right before
1619 // entering the call, so the caller can block on that signal instead of
1620 // guessing how long thread start-up takes — a fixed sleep here would
1621 // race the thread scheduler, and that race is exactly what made this
1622 // test flaky under load (e.g. Windows CI runners have much coarser and
1623 // less predictable thread-spawn latency than Linux/macOS).
1624 fn spawn_yield_waiter(
1625 store: &Arc<DocumentStore>,
1626 ) -> (
1627 std::sync::mpsc::Receiver<()>,
1628 std::thread::JoinHandle<std::time::Duration>,
1629 ) {
1630 let (ready_tx, ready_rx) = std::sync::mpsc::channel();
1631 let store = Arc::clone(store);
1632 let handle = std::thread::spawn(move || {
1633 ready_tx.send(()).unwrap();
1634 let t = std::time::Instant::now();
1635 store.yield_to_interactive_reads();
1636 t.elapsed()
1637 });
1638 (ready_rx, handle)
1639 }
1640
1641 #[test]
1642 fn scan_writers_yield_while_interactive_reads_are_in_flight() {
1643 let store = Arc::new(DocumentStore::new());
1644
1645 // No guard: returns immediately.
1646 let t = std::time::Instant::now();
1647 store.yield_to_interactive_reads();
1648 assert!(t.elapsed() < std::time::Duration::from_millis(200));
1649
1650 // Guard held on another thread: the writer waits until it drops.
1651 // We only start the hold timer once the waiter has confirmed it's
1652 // about to poll, so the guard can never be dropped before the
1653 // writer starts waiting on it.
1654 let guard = store.interactive_read_guard();
1655 let (ready_rx, waiter) = spawn_yield_waiter(&store);
1656 ready_rx.recv().unwrap();
1657 const HOLD: std::time::Duration = std::time::Duration::from_millis(60);
1658 std::thread::sleep(HOLD);
1659 drop(guard);
1660 let waited = waiter.join().unwrap();
1661 assert!(
1662 waited >= HOLD,
1663 "writer should have paused at least as long as the read guard was held (waited {waited:?})"
1664 );
1665
1666 // Guard fully released: nested guards count correctly.
1667 let g1 = store.interactive_read_guard();
1668 let g2 = store.interactive_read_guard();
1669 drop(g1);
1670 let (ready_rx, waiter) = spawn_yield_waiter(&store);
1671 ready_rx.recv().unwrap();
1672 const HOLD2: std::time::Duration = std::time::Duration::from_millis(30);
1673 std::thread::sleep(HOLD2);
1674 drop(g2);
1675 let waited = waiter.join().unwrap();
1676 assert!(
1677 waited >= HOLD2,
1678 "writer should have paused while the nested read guard was held (waited {waited:?})"
1679 );
1680 store.yield_to_interactive_reads(); // all guards dropped — no wait
1681 }
1682
1683 #[test]
1684 fn index_registers_file_in_salsa() {
1685 let store = DocumentStore::new();
1686 store.ingest(uri("/lib.php"), "<?php\nfunction lib_fn() {}");
1687 let idx = store.get_index_salsa(&uri("/lib.php")).unwrap();
1688 assert_eq!(idx.functions.len(), 1);
1689 assert_eq!(&*idx.functions[0].name, "lib_fn");
1690 }
1691
1692 #[test]
1693 fn remove_hides_file_from_index() {
1694 let store = DocumentStore::new();
1695 let u = uri("/lib.php");
1696 store.ingest(u.clone(), "<?php");
1697 store.remove(&u);
1698 assert!(store.get_index_salsa(&u).is_none());
1699 }
1700
1701 #[test]
1702 fn remove_and_reopen_reuses_source_file_handle() {
1703 let store = DocumentStore::new();
1704 let u = uri("/lib.php");
1705 store.ingest(u.clone(), "<?php");
1706 let ft_before = store.source_file(&u).unwrap();
1707 store.remove(&u);
1708 assert!(
1709 store.source_file(&u).is_none(),
1710 "deleted file should be hidden"
1711 );
1712 store.mirror_text(&u, "<?php");
1713 let ft_after = store.source_file(&u).unwrap();
1714 assert!(
1715 ft_before == ft_after,
1716 "reopen must reuse the same FileText handle"
1717 );
1718 }
1719
1720 #[test]
1721 fn delete_reopen_churn_does_not_amplify_salsa_inputs() {
1722 let store = DocumentStore::new();
1723 let uris: Vec<Url> = (0..20).map(|i| uri(&format!("/churn/f{i}.php"))).collect();
1724 for u in &uris {
1725 store.ingest(u.clone(), "<?php class A {}");
1726 }
1727 let count_before = store.source_files_len();
1728 for _ in 0..10 {
1729 for u in &uris {
1730 store.remove(u);
1731 }
1732 for u in &uris {
1733 store.ingest(u.clone(), "<?php class A {}");
1734 }
1735 }
1736 assert_eq!(
1737 store.source_files_len(),
1738 count_before,
1739 "delete-reopen cycles must not create new salsa inputs (L1-B regression guard)"
1740 );
1741 }
1742
1743 #[test]
1744 fn warm_analysis_sweep_preserves_reference_results() {
1745 let store = DocumentStore::new();
1746 store.ingest(
1747 uri("/svc.php"),
1748 "<?php\nnamespace App;\nclass Svc { public function run(): void {} }",
1749 );
1750 store.ingest(
1751 uri("/caller.php"),
1752 "<?php\nnamespace App;\nclass Caller {\n private Svc $s;\n public function go(): void { $this->s->run(); }\n}",
1753 );
1754 let sym = mir_analyzer::Name::method("App\\Svc", "run");
1755 let files: Vec<Arc<str>> = [uri("/svc.php"), uri("/caller.php")]
1756 .iter()
1757 .map(|u| Arc::from(u.as_str()))
1758 .collect();
1759 let cold = store.indexed_references(&sym, &files, false, None);
1760 assert_eq!(cold.len(), 1, "caller.php references Svc::run once");
1761
1762 let cancel = store.begin_warm_sweep();
1763 store.warm_analysis_sweep(&[], &cancel);
1764 let warm = store.indexed_references(&sym, &files, false, None);
1765 assert_eq!(cold, warm, "sweep must not change reference results");
1766
1767 // An edit after the sweep is still picked up (memos revalidate).
1768 store.ingest(
1769 uri("/caller.php"),
1770 "<?php\nnamespace App;\nclass Caller {\n private Svc $s;\n public function go(): void { $this->s->run(); $this->s->run(); }\n}",
1771 );
1772 let cancel = store.begin_warm_sweep();
1773 store.warm_analysis_sweep(&[], &cancel);
1774 let after_edit = store.indexed_references(&sym, &files, false, None);
1775 assert_eq!(after_edit.len(), 2, "re-sweep must see the new reference");
1776 }
1777
1778 #[test]
1779 fn sweep_priority_puts_open_files_and_their_dependencies_first() {
1780 let store = DocumentStore::new();
1781 store.ingest(
1782 uri("/a.php"),
1783 "<?php\nnamespace App;\nclass A { public function go(B $b): void {} }",
1784 );
1785 store.ingest(uri("/b.php"), "<?php\nnamespace App;\nclass B {}");
1786 store.ingest(uri("/unrelated.php"), "<?php\nnamespace App;\nclass C {}");
1787 store.mark_index_ready();
1788
1789 let front = store.sweep_priority_files(&[uri("/a.php")]);
1790 let front: Vec<&str> = front.iter().map(|f| f.as_ref()).collect();
1791 assert_eq!(
1792 front,
1793 vec![uri("/a.php").as_str(), uri("/b.php").as_str()],
1794 "open file first, then the files declaring its referenced classes"
1795 );
1796 }
1797
1798 #[test]
1799 fn warm_analysis_sweep_stops_on_cancel() {
1800 let store = DocumentStore::new();
1801 for i in 0..40 {
1802 store.ingest(
1803 uri(&format!("/f{i}.php")),
1804 "<?php\nclass A { public function m(): int { return 1; } }",
1805 );
1806 }
1807 let cancel = store.begin_warm_sweep();
1808 cancel.cancel();
1809 // Pre-cancelled token: returns without analyzing (must not hang/panic).
1810 store.warm_analysis_sweep(&[], &cancel);
1811 // A newer sweep's token supersedes the old one.
1812 let old = store.begin_warm_sweep();
1813 let _new = store.begin_warm_sweep();
1814 assert!(old.is_cancelled());
1815 }
1816
1817 #[test]
1818 fn all_indexes_includes_every_mirrored_file() {
1819 let store = DocumentStore::new();
1820 open(&store, uri("/a.php"), "<?php\nfunction a() {}".to_string());
1821 store.ingest(uri("/b.php"), "<?php\nfunction b() {}");
1822 assert_eq!(store.all_indexes().len(), 2);
1823 }
1824
1825 #[test]
1826 fn other_indexes_excludes_current_uri() {
1827 let store = DocumentStore::new();
1828 open(&store, uri("/a.php"), "<?php\nfunction a() {}".to_string());
1829 open(&store, uri("/b.php"), "<?php\nfunction b() {}".to_string());
1830 assert_eq!(store.other_indexes(&uri("/a.php")).len(), 1);
1831 }
1832
1833 #[test]
1834 fn other_docs_excludes_current_uri() {
1835 let store = DocumentStore::new();
1836 let ua = uri("/a.php");
1837 let ub = uri("/b.php");
1838 open(&store, ua.clone(), "<?php\nfunction a() {}".to_string());
1839 open(&store, ub.clone(), "<?php\nfunction b() {}".to_string());
1840 let open_urls = vec![ua.clone(), ub];
1841 assert_eq!(store.other_docs(&ua, &open_urls).len(), 1);
1842 }
1843
1844 #[test]
1845 fn evict_token_cache_removes_entry() {
1846 let store = DocumentStore::new();
1847 let u = uri("/a.php");
1848 open(&store, u.clone(), "<?php".to_string());
1849 store.store_token_cache(&u, "id1".to_string(), Arc::new(vec![]));
1850 assert!(store.get_token_cache(&u, "id1").is_some());
1851 store.evict_token_cache(&u);
1852 assert!(store.get_token_cache(&u, "id1").is_none());
1853 }
1854
1855 #[test]
1856 fn vendor_index_cache_evicted_on_remove() {
1857 let store = DocumentStore::new();
1858 let u = uri("/vendor/acme/lib.php");
1859 store.ingest(u.clone(), "<?php\nclass Lib {}");
1860 let idx = store.get_index_salsa(&u).unwrap();
1861 store.cache_vendor_index(u.clone(), idx.clone());
1862 assert!(store.get_vendor_index(&u).is_some());
1863 store.remove(&u);
1864 assert!(store.get_vendor_index(&u).is_none());
1865 }
1866
1867 #[test]
1868 fn index_populates_file_index_with_symbols() {
1869 let store = DocumentStore::new();
1870 store.ingest(uri("/a.php"), "<?php\nfunction hello() {}");
1871 let idx = store.get_index_salsa(&uri("/a.php")).unwrap();
1872 assert_eq!(idx.functions.len(), 1);
1873 assert_eq!(&*idx.functions[0].name, "hello");
1874 }
1875
1876 #[test]
1877 fn open_populates_file_index_with_symbols() {
1878 let store = DocumentStore::new();
1879 open(&store, uri("/a.php"), "<?php\nclass Foo {}".to_string());
1880 let idx = store.get_index_salsa(&uri("/a.php")).unwrap();
1881 assert_eq!(idx.classes.len(), 1);
1882 assert_eq!(&*idx.classes[0].name, "Foo");
1883 }
1884
1885 // ── Mirror invariants ────────────────────────────────────────────────
1886 //
1887 // Every mutation path that changes file text must keep the salsa layer
1888 // consistent. These tests walk a set-edit-reopen cycle and assert that
1889 // the salsa-derived `FileIndex` reflects the latest text at each step.
1890
1891 fn names_of(idx: &FileIndex) -> Vec<String> {
1892 let mut out: Vec<String> = idx.classes.iter().map(|c| c.name.to_string()).collect();
1893 out.extend(idx.functions.iter().map(|f| f.name.to_string()));
1894 out.sort();
1895 out
1896 }
1897
1898 fn salsa_index_names(store: &DocumentStore, url: &Url) -> Vec<String> {
1899 store
1900 .snapshot_query_file_index(url)
1901 .map(|idx| names_of(&idx))
1902 .unwrap_or_default()
1903 }
1904
1905 #[test]
1906 fn mirror_tracks_repeated_edits() {
1907 let store = DocumentStore::new();
1908 let u = uri("/mirror.php");
1909
1910 open(&store, u.clone(), "<?php\nclass A {}".to_string());
1911 assert_eq!(salsa_index_names(&store, &u), vec!["A".to_string()]);
1912
1913 open(
1914 &store,
1915 u.clone(),
1916 "<?php\nclass A {}\nclass B {}".to_string(),
1917 );
1918 assert_eq!(
1919 salsa_index_names(&store, &u),
1920 vec!["A".to_string(), "B".to_string()]
1921 );
1922
1923 open(&store, u.clone(), "<?php\nfunction greet() {}".to_string());
1924 assert_eq!(salsa_index_names(&store, &u), vec!["greet".to_string()]);
1925 }
1926
1927 #[test]
1928 fn mirror_tracks_ingest_and_ingest_from_doc() {
1929 let store = DocumentStore::new();
1930
1931 // Background `index(url, text)` path.
1932 let u1 = uri("/bg1.php");
1933 store.ingest(u1.clone(), "<?php\nclass Bg1 {}");
1934 assert_eq!(salsa_index_names(&store, &u1), vec!["Bg1".to_string()]);
1935
1936 // `ingest_from_doc(url, &doc)` path (workspace-scan Phase 2).
1937 let u2 = uri("/bg2.php");
1938 let doc = crate::analysis::diagnostics::parse_document_no_diags(
1939 "<?php\nclass Bg2 {}\nfunction f() {}",
1940 );
1941 store.ingest_from_doc(u2.clone(), &doc);
1942 assert_eq!(
1943 salsa_index_names(&store, &u2),
1944 vec!["Bg2".to_string(), "f".to_string()]
1945 );
1946 }
1947
1948 /// G3: confirms the `parsed_cache` actually hits — two consecutive
1949 /// `get_doc_salsa` calls on unchanged text return the same `Arc`
1950 /// (pointer equality), and an edit forces a miss that produces a
1951 /// different `Arc`.
1952 /// parsed_cache must stay bounded — inserting more than
1953 /// `PARSED_CACHE_CAP` unique URLs must not cause unbounded growth.
1954 /// Eviction is probabilistic, so we only assert the bound, not which
1955 /// Seeding a cached index for a URL that was never mirrored is a no-op
1956 /// (returns `false`) — avoids silently allocating SourceFiles outside
1957 /// `mirror_text`'s control.
1958 #[test]
1959 fn seed_cached_index_noops_for_unknown_uri() {
1960 let store = DocumentStore::new();
1961 let u = uri("/never_mirrored.php");
1962 let index = Arc::new(crate::index::file_index::FileIndex::default());
1963 assert!(!store.seed_cached_index(&u, index));
1964 }
1965
1966 /// entries survive.
1967 #[test]
1968 fn parsed_cache_stays_bounded_under_many_inserts() {
1969 let store = DocumentStore::new();
1970 use crate::document::cache_registry::PARSED_CACHE_CAP;
1971 let overflow = PARSED_CACHE_CAP + 100;
1972 for i in 0..overflow {
1973 let u = uri(&format!("/cap/file{i}.php"));
1974 store.ingest(u.clone(), "<?php\nclass A {}");
1975 // Force a parsed_cache insert via get_doc_salsa.
1976 let _ = store.get_doc_salsa(&u);
1977 }
1978 assert!(
1979 store.caches.parsed_cache.len() <= PARSED_CACHE_CAP,
1980 "parsed_cache grew to {} entries (cap {})",
1981 store.caches.parsed_cache.len(),
1982 PARSED_CACHE_CAP
1983 );
1984 }
1985
1986 /// The shed is recency-based: an entry touched right before overflow (an
1987 /// open file being edited) must survive a sweep of cold inserts, while
1988 /// untouched cold entries are the ones dropped.
1989 #[test]
1990 fn parsed_cache_shed_keeps_recently_used_entries() {
1991 let store = DocumentStore::new();
1992 use crate::document::cache_registry::PARSED_CACHE_CAP;
1993
1994 let hot = uri("/cap/hot.php");
1995 store.ingest(hot.clone(), "<?php\nclass Hot {}");
1996 let _ = store.get_doc_salsa(&hot);
1997
1998 // Fill to just below the cap with cold entries, then re-touch the hot
1999 // file so it is the most recently used entry at shed time.
2000 for i in 0..(PARSED_CACHE_CAP - 2) {
2001 let u = uri(&format!("/cap/cold{i}.php"));
2002 store.ingest(u.clone(), "<?php\nclass A {}");
2003 let _ = store.get_doc_salsa(&u);
2004 }
2005 let _ = store.get_doc_salsa(&hot);
2006
2007 // Overflow: this insert triggers the shed of the LRU half.
2008 let trigger = uri("/cap/trigger.php");
2009 store.ingest(trigger.clone(), "<?php\nclass T {}");
2010 let _ = store.get_doc_salsa(&trigger);
2011
2012 assert!(
2013 store.caches.parsed_cache.contains_key(&hot),
2014 "recently-touched entry must survive the recency shed"
2015 );
2016 assert!(
2017 store.caches.parsed_cache.len() <= PARSED_CACHE_CAP,
2018 "cache must stay bounded after the shed"
2019 );
2020 }
2021
2022 /// `analysis_cache` must stay bounded when many distinct files are
2023 /// analyzed across a session (multi-hour usage previously grew it
2024 /// without limit).
2025 #[test]
2026 fn analysis_cache_stays_bounded_under_many_files() {
2027 use crate::document::cache_registry::ANALYSIS_CACHE_CAP;
2028 let store = DocumentStore::new();
2029 let overflow = ANALYSIS_CACHE_CAP + 16;
2030 for i in 0..overflow {
2031 let u = uri(&format!("/acap/file{i}.php"));
2032 store.ingest(u.clone(), "<?php\nfunction f() { return 1; }");
2033 let _ = store.cached_analysis(&u);
2034 }
2035 assert!(
2036 store.caches.analysis_cache.len() <= ANALYSIS_CACHE_CAP,
2037 "analysis_cache grew to {} entries (cap {})",
2038 store.caches.analysis_cache.len(),
2039 ANALYSIS_CACHE_CAP
2040 );
2041 }
2042
2043 #[test]
2044 fn get_doc_salsa_cache_hits_across_calls() {
2045 let store = DocumentStore::new();
2046 let u = uri("/g3_cache.php");
2047 open(&store, u.clone(), "<?php\nclass G3 {}".to_string());
2048
2049 let a = store.get_doc_salsa(&u).unwrap();
2050 let b = store.get_doc_salsa(&u).unwrap();
2051 assert!(
2052 Arc::ptr_eq(&a, &b),
2053 "parsed_cache hit should yield the same Arc across calls"
2054 );
2055
2056 open(&store, u.clone(), "<?php\nclass G3b {}".to_string());
2057 let c = store.get_doc_salsa(&u).unwrap();
2058 assert!(
2059 !Arc::ptr_eq(&a, &c),
2060 "edit should invalidate the parsed_cache entry"
2061 );
2062 }
2063
2064 #[test]
2065 fn get_doc_salsa_returns_some_for_mirrored_files() {
2066 // Phase E4: `get_doc_salsa` no longer gates on open-state. The
2067 // open/closed distinction now lives on `Backend::get_doc`.
2068 let store = DocumentStore::new();
2069 let u = uri("/e4_doc.php");
2070 store.ingest(u.clone(), "<?php\nclass P {}");
2071 assert!(store.get_doc_salsa(&u).is_some());
2072 }
2073
2074 #[test]
2075 fn get_doc_snapshot_or_stale_matches_salsa_on_settled_buffer() {
2076 let store = DocumentStore::new();
2077 let u = uri("/stale_settled.php");
2078 open(&store, u.clone(), "<?php\nclass S {}".to_string());
2079 let fresh = store.get_doc_salsa(&u).unwrap();
2080 let stale = store.get_doc_snapshot_or_stale(&u).unwrap();
2081 assert!(
2082 Arc::ptr_eq(&fresh, &stale),
2083 "on a settled buffer the stale-tolerant accessor must serve the cached parse"
2084 );
2085 }
2086
2087 /// WS1: the cursor-triggered highlight accessor must stay responsive under a
2088 /// sustained write stream — never spin (the deadline join hangs if it does)
2089 /// and always yield a fresh or last-good parse for an open file.
2090 #[test]
2091 fn get_doc_snapshot_or_stale_stays_responsive_under_writes() {
2092 use std::sync::Arc;
2093 use std::thread;
2094 use std::time::{Duration, Instant};
2095
2096 let store = Arc::new(DocumentStore::new());
2097 let urls: Vec<Url> = (0..8).map(|i| uri(&format!("/hl{i}.php"))).collect();
2098 for (i, u) in urls.iter().enumerate() {
2099 open(&store, u.clone(), format!("<?php\nclass H{i} {{}}"));
2100 // Warm the parse cache so the stale fallback always has a last-good doc.
2101 assert!(store.get_doc_salsa(u).is_some());
2102 }
2103
2104 let deadline = Instant::now() + Duration::from_millis(400);
2105 let mut handles = Vec::new();
2106
2107 {
2108 let store = Arc::clone(&store);
2109 let urls = urls.clone();
2110 handles.push(thread::spawn(move || {
2111 let mut rev = 0u32;
2112 while Instant::now() < deadline {
2113 for u in &urls {
2114 store.mirror_text(u, &format!("<?php\nclass H {{}}\n// rev {rev}"));
2115 }
2116 rev += 1;
2117 }
2118 }));
2119 }
2120
2121 for _ in 0..4 {
2122 let store = Arc::clone(&store);
2123 let urls = urls.clone();
2124 handles.push(thread::spawn(move || {
2125 while Instant::now() < deadline {
2126 for u in &urls {
2127 assert!(
2128 store.get_doc_snapshot_or_stale(u).is_some(),
2129 "open file must resolve to a fresh or stale parse, never None"
2130 );
2131 }
2132 }
2133 }));
2134 }
2135
2136 for h in handles {
2137 h.join()
2138 .expect("stale accessor must not panic or spin under concurrent writes");
2139 }
2140 }
2141
2142 /// WS1 A/B measurement (not a pass/fail guard): compares the old unbounded
2143 /// `get_doc_salsa` against the new bounded `get_doc_snapshot_or_stale` under
2144 /// an identical continuous write stream. Run explicitly:
2145 /// `cargo test --lib ws1_ab_latency -- --ignored --nocapture`.
2146 #[test]
2147 #[ignore = "timing measurement; run with --ignored --nocapture"]
2148 fn ws1_ab_latency_under_write_pressure() {
2149 use std::sync::Arc;
2150 use std::sync::atomic::{AtomicBool, Ordering};
2151 use std::thread;
2152 use std::time::{Duration, Instant};
2153
2154 // A large file: each parse is long enough that a concurrent write can
2155 // cancel it mid-flight — the condition under which the unbounded retry
2156 // actually spins (a tiny file parses faster than the writer's gap).
2157 fn big_source(rev: u32) -> String {
2158 let mut s = String::from("<?php\nclass Big {\n");
2159 for i in 0..3000 {
2160 s.push_str(&format!(
2161 " public function m{i}(int $a, string $b): int {{ return $a + {i}; }}\n"
2162 ));
2163 }
2164 s.push_str(&format!("}}\n// rev {rev}\n"));
2165 s
2166 }
2167
2168 fn measure(
2169 store: &Arc<DocumentStore>,
2170 url: &Url,
2171 writers: usize,
2172 label: &str,
2173 call: impl Fn(&DocumentStore, &Url) -> bool,
2174 ) {
2175 let stop = Arc::new(AtomicBool::new(false));
2176 let mut writer_handles = Vec::new();
2177 for w in 0..writers {
2178 let store = Arc::clone(store);
2179 let url = url.clone();
2180 let stop = Arc::clone(&stop);
2181 writer_handles.push(thread::spawn(move || {
2182 let mut rev = (w as u32) * 1_000_000;
2183 while !stop.load(Ordering::Relaxed) {
2184 store.mirror_text(&url, &big_source(rev));
2185 rev = rev.wrapping_add(1);
2186 }
2187 }));
2188 }
2189 thread::sleep(Duration::from_millis(30)); // let the writers get hot
2190
2191 let n = 100usize;
2192 let mut max = Duration::ZERO;
2193 let mut total = Duration::ZERO;
2194 for _ in 0..n {
2195 let t = Instant::now();
2196 let ok = call(store, url);
2197 let dt = t.elapsed();
2198 assert!(ok, "{label}: accessor returned None");
2199 max = max.max(dt);
2200 total += dt;
2201 }
2202 stop.store(true, Ordering::Relaxed);
2203 for h in writer_handles {
2204 h.join().unwrap();
2205 }
2206 println!(
2207 "{label}: {n} calls, {writers} writers — max {:>10.3?}, mean {:>10.3?}",
2208 max,
2209 total / n as u32
2210 );
2211 }
2212
2213 let store = Arc::new(DocumentStore::new());
2214 let url = uri("/perf_big.php");
2215 open(&store, url.clone(), big_source(0));
2216 assert!(store.get_doc_salsa(&url).is_some()); // warm the cache
2217
2218 for writers in [2usize, 4] {
2219 measure(
2220 &store,
2221 &url,
2222 writers,
2223 "NEW get_doc_snapshot_or_stale",
2224 |s, u| s.get_doc_snapshot_or_stale(u).is_some(),
2225 );
2226 measure(
2227 &store,
2228 &url,
2229 writers,
2230 "OLD get_doc_salsa (unbounded) ",
2231 |s, u| s.get_doc_salsa(u).is_some(),
2232 );
2233 }
2234 }
2235
2236 #[test]
2237 fn get_salsa_accessors_return_none_for_unknown_uri() {
2238 let store = DocumentStore::new();
2239 let u = uri("/never-seen.php");
2240 assert!(store.get_doc_salsa(&u).is_none());
2241 assert!(store.get_index_salsa(&u).is_none());
2242 }
2243
2244 /// Phase E1: concurrent readers and writers must not deadlock, panic, or
2245 /// return stale data. Writers briefly bump inputs while readers are
2246 /// running on cloned snapshots; any `salsa::Cancelled` raised on the
2247 /// reader side must be caught and retried by `snapshot_query`.
2248 ///
2249 /// The salsa surface (`get_doc_salsa`, `get_index_salsa`) is protected by
2250 /// `snapshot_query`'s last-resort host-lock fallback.
2251 #[test]
2252 fn concurrent_reads_and_writes_do_not_panic() {
2253 use std::sync::Arc;
2254 use std::thread;
2255 use std::time::{Duration, Instant};
2256
2257 let store = Arc::new(DocumentStore::new());
2258 let urls: Vec<Url> = (0..8).map(|i| uri(&format!("/f{i}.php"))).collect();
2259 for (i, u) in urls.iter().enumerate() {
2260 open(&store, u.clone(), format!("<?php\nclass C{i} {{}}"));
2261 }
2262
2263 let deadline = Instant::now() + Duration::from_millis(400);
2264 let mut handles = Vec::new();
2265
2266 // Writer thread: keep bumping every file's text.
2267 {
2268 let store = Arc::clone(&store);
2269 let urls = urls.clone();
2270 handles.push(thread::spawn(move || {
2271 let mut rev = 0u32;
2272 while Instant::now() < deadline {
2273 for u in &urls {
2274 let text = format!("<?php\nclass C{{}}\n// rev {rev}");
2275 store.mirror_text(u, &text);
2276 }
2277 rev += 1;
2278 }
2279 }));
2280 }
2281
2282 // Reader threads: hammer the salsa accessors.
2283 for _ in 0..4 {
2284 let store = Arc::clone(&store);
2285 let urls = urls.clone();
2286 handles.push(thread::spawn(move || {
2287 while Instant::now() < deadline {
2288 for u in &urls {
2289 let _ = store.get_doc_salsa(u);
2290 let _ = store.get_index_salsa(u);
2291 }
2292 // Post mir 0.22: codebase + refs live in the session,
2293 // not salsa. Concurrent-read smoke is limited to the
2294 // remaining salsa surface (parsed_doc, file_index).
2295 }
2296 }));
2297 }
2298
2299 for h in handles {
2300 h.join().expect("no panic under concurrent read/write");
2301 }
2302 }
2303
2304 /// PSR-4 lazy-loading: `get_semantic_issues_salsa` must not emit
2305 /// `UndefinedClass` for a class that is PSR-4-resolvable on disk, even
2306 /// when the dependency file is not yet in `source_files`.
2307 #[test]
2308 fn psr4_lazy_load_suppresses_undefined_class() {
2309 let tmp = tempfile::tempdir().unwrap();
2310
2311 // Write Entity.php to disk (not mirrored into the store).
2312 std::fs::create_dir_all(tmp.path().join("src/Model")).unwrap();
2313 std::fs::write(
2314 tmp.path().join("src/Model/Entity.php"),
2315 "<?php\nnamespace App\\Model;\nclass Entity {}\n",
2316 )
2317 .unwrap();
2318
2319 // Write composer.json so Psr4Map::load can build the map.
2320 std::fs::write(
2321 tmp.path().join("composer.json"),
2322 r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
2323 )
2324 .unwrap();
2325
2326 let store = DocumentStore::new();
2327
2328 // Inject a PSR-4 map pointing at the tmp dir.
2329 store
2330 .psr4
2331 .store(Arc::new(crate::lang::autoload::Psr4Map::load(tmp.path())));
2332
2333 // Mirror the consuming file (Entity not yet in source_files).
2334 // Uses Entity as a parameter type hint — the analyzer resolves these
2335 // through use statements, so this exercises the full PSR-4 lazy-load path.
2336 let handler_url = Url::from_file_path(tmp.path().join("src/Service/Handler.php")).unwrap();
2337 store.mirror_text(
2338 &handler_url,
2339 "<?php\nnamespace App\\Service;\nuse App\\Model\\Entity;\nfunction handle(Entity $e): Entity { return $e; }\n",
2340 );
2341
2342 let issues = store.get_semantic_issues_salsa(&handler_url).unwrap();
2343 let undef: Vec<_> = issues
2344 .iter()
2345 .filter(|i| matches!(i.kind, mir_issues::IssueKind::UndefinedClass { .. }))
2346 .collect();
2347 assert!(
2348 undef.is_empty(),
2349 "PSR-4 lazy-loading must prevent UndefinedClass for App\\Model\\Entity; got: {undef:?}"
2350 );
2351 }
2352
2353 /// Issue #191 regression: workspace-wide scans (find-references, rename,
2354 /// call-hierarchy) must not re-parse closed/indexed files on repeated
2355 /// invocations. Once a file's `ParsedDoc` has been produced, subsequent
2356 /// `all_docs_for_scan()` calls must hit the cache and return the same
2357 /// `Arc<ParsedDoc>` (pointer equality), proving no re-parse occurred.
2358 ///
2359 /// The cache layers protecting this are:
2360 /// 1. `parsed_cache` (cap [`PARSED_CACHE_CAP`]) — read-through, validated
2361 /// via `Arc::ptr_eq` on the text Arc.
2362 /// 2. salsa `parsed_doc` memo (`lru = 2048`) — second line of defense
2363 /// when `parsed_cache` evicts.
2364 ///
2365 /// Together they keep every workspace-scan op O(N) memo lookups, never
2366 /// O(N) parses, for any workspace whose file count fits the cap.
2367 #[test]
2368 fn all_docs_for_scan_does_not_reparse_indexed_files() {
2369 let store = DocumentStore::new();
2370 const N: usize = 50;
2371 for i in 0..N {
2372 let u = uri(&format!("/scan/file{i}.php"));
2373 store.ingest(u, &format!("<?php\nclass C{i} {{}}\nfunction f{i}() {{}}"));
2374 }
2375
2376 let first: Vec<_> = store.all_docs_for_scan();
2377 let second: Vec<_> = store.all_docs_for_scan();
2378 assert_eq!(first.len(), N);
2379 assert_eq!(second.len(), N);
2380
2381 let by_url_first: std::collections::HashMap<Url, Arc<ParsedDoc>> =
2382 first.into_iter().collect();
2383 for (u, doc2) in second {
2384 let doc1 = by_url_first
2385 .get(&u)
2386 .expect("second scan returned a URL the first didn't");
2387 assert!(
2388 Arc::ptr_eq(doc1, &doc2),
2389 "{u} re-parsed across all_docs_for_scan calls — \
2390 cache (parsed_cache + salsa parsed_doc memo) failed to hit"
2391 );
2392 }
2393
2394 // Editing one file's text must invalidate just that file's entry,
2395 // not the rest. This locks in self-eviction via Arc::ptr_eq on text.
2396 let edited_url = uri("/scan/file0.php");
2397 let pre_edit = store.get_doc_salsa(&edited_url).unwrap();
2398 store.ingest(edited_url.clone(), "<?php\nclass C0Edited {}");
2399 let post_edit = store.get_doc_salsa(&edited_url).unwrap();
2400 assert!(
2401 !Arc::ptr_eq(&pre_edit, &post_edit),
2402 "edited file must produce a fresh ParsedDoc"
2403 );
2404 for i in 1..N {
2405 let u = uri(&format!("/scan/file{i}.php"));
2406 let original = by_url_first.get(&u).unwrap();
2407 let after = store.get_doc_salsa(&u).unwrap();
2408 assert!(
2409 Arc::ptr_eq(original, &after),
2410 "{u} should not have re-parsed because of an unrelated edit"
2411 );
2412 }
2413 }
2414
2415 /// Incremental analysis cache: a body-only edit to file A (no declaration
2416 /// changes) must not bump `decl_version`, so file B's cached analysis
2417 /// survives. A declaration edit MUST bump the version so B's entry goes
2418 /// stale.
2419 #[test]
2420 fn body_only_edit_does_not_invalidate_sibling_analysis_cache() {
2421 let store = DocumentStore::new();
2422 let ua = uri("/ic_a.php");
2423 let ub = uri("/ic_b.php");
2424
2425 // Analyze both files to establish their fingerprints.
2426 open(
2427 &store,
2428 ua.clone(),
2429 "<?php\nfunction a() { return 1; }".to_string(),
2430 );
2431 open(
2432 &store,
2433 ub.clone(),
2434 "<?php\nfunction b() { return 2; }".to_string(),
2435 );
2436 let _ = store.cached_analysis(&ua).unwrap();
2437 let analysis_b_first = store.cached_analysis(&ub).unwrap();
2438 let ver_after_warm = store.caches.decl_version();
2439
2440 // Body-only edit to A: same function name, different body → FileIndex unchanged.
2441 store.mirror_text(&ua, "<?php\nfunction a() { return 999; }");
2442 let _ = store.cached_analysis(&ua);
2443 let ver_after_body_edit = store.caches.decl_version();
2444 assert_eq!(
2445 ver_after_warm, ver_after_body_edit,
2446 "body-only edit must not bump decl_version"
2447 );
2448
2449 // B's cached entry should still be valid (ptr-eq source AND same version).
2450 let analysis_b_second = store.cached_analysis_if_fresh(&ub);
2451 assert!(
2452 analysis_b_second.is_some(),
2453 "B's analysis should hit cache after body-only edit to A"
2454 );
2455 assert!(
2456 Arc::ptr_eq(&analysis_b_first, &analysis_b_second.unwrap()),
2457 "B's analysis should be the identical Arc (no re-analysis)"
2458 );
2459
2460 // Declaration edit to A: rename the function → FileIndex changes.
2461 store.mirror_text(&ua, "<?php\nfunction a_renamed() { return 999; }");
2462 let _ = store.cached_analysis(&ua);
2463 let ver_after_decl_edit = store.caches.decl_version();
2464 assert!(
2465 ver_after_decl_edit > ver_after_body_edit,
2466 "declaration edit must bump decl_version (was {ver_after_body_edit}, now {ver_after_decl_edit})"
2467 );
2468
2469 // B's entry is now stale — cached_analysis_if_fresh must return None.
2470 let analysis_b_stale = store.cached_analysis_if_fresh(&ub);
2471 assert!(
2472 analysis_b_stale.is_none(),
2473 "B's analysis should be stale after A's declaration changed"
2474 );
2475 }
2476
2477 /// snapshot_query must complete without panic when a concurrent writer
2478 /// races the snapshot. The single-retry-then-lock logic should handle this
2479 /// correctly: the lock-held fallback guarantees progress.
2480 #[test]
2481 fn snapshot_query_survives_concurrent_writes() {
2482 use std::sync::Arc;
2483 use std::thread;
2484 use std::time::{Duration, Instant};
2485
2486 let store = Arc::new(DocumentStore::new());
2487 let u = uri("/sq_test.php");
2488 open(
2489 &store,
2490 u.clone(),
2491 "<?php\nfunction f(): int { return 1; }".to_string(),
2492 );
2493
2494 let deadline = Instant::now() + Duration::from_millis(200);
2495 let mut handles = Vec::new();
2496
2497 // Writer: keep bumping the file text to trigger salsa::Cancelled.
2498 {
2499 let store = Arc::clone(&store);
2500 let u = u.clone();
2501 handles.push(thread::spawn(move || {
2502 let mut rev = 0u32;
2503 while Instant::now() < deadline {
2504 store.mirror_text(&u, &format!("<?php\nfunction f(): int {{ return {rev}; }}"));
2505 rev += 1;
2506 }
2507 }));
2508 }
2509
2510 // Reader: hammer snapshot_query via get_doc_salsa.
2511 for _ in 0..4 {
2512 let store = Arc::clone(&store);
2513 let u = u.clone();
2514 handles.push(thread::spawn(move || {
2515 while Instant::now() < deadline {
2516 let _ = store.get_doc_salsa(&u);
2517 let _ = store.get_index_salsa(&u);
2518 }
2519 }));
2520 }
2521
2522 for h in handles {
2523 h.join()
2524 .expect("no panic in snapshot_query under concurrent writes");
2525 }
2526 }
2527
2528 /// When a sibling file's declaration changes (bumping decl_version), the
2529 /// owned_program_cache entry for the unchanged file B should be reused
2530 /// rather than deep-cloned again. We verify this via Arc pointer equality.
2531 #[test]
2532 fn owned_program_cache_reused_after_sibling_declaration_change() {
2533 let store = DocumentStore::new();
2534 let ua = uri("/prog_a.php");
2535 let ub = uri("/prog_b.php");
2536
2537 open(
2538 &store,
2539 ua.clone(),
2540 "<?php\nfunction alpha(): void {}".to_string(),
2541 );
2542 open(
2543 &store,
2544 ub.clone(),
2545 "<?php\nfunction beta(): void {}".to_string(),
2546 );
2547
2548 // Warm both analysis caches and populate owned_program_cache for both files.
2549 let _ = store.cached_analysis(&ua);
2550 let _ = store.cached_analysis(&ub);
2551
2552 // Capture B's owned_program Arc before the sibling edit.
2553 let prog_b_first = store
2554 .caches
2555 .owned_program_cache
2556 .get(&ub)
2557 .map(|e| Arc::clone(&e.1))
2558 .expect("B's owned_program should be cached after first analysis");
2559
2560 // Declaration change to A: bumps decl_version, invalidating all cached_analysis entries.
2561 store.mirror_text(&ua, "<?php\nfunction alpha_renamed(): void {}");
2562 // Re-analyze A to trigger the decl_version bump.
2563 let _ = store.cached_analysis(&ua);
2564
2565 // Now re-analyze B. Its source is unchanged, so owned_program_cache must hit.
2566 let _ = store.cached_analysis(&ub);
2567
2568 let prog_b_second = store
2569 .caches
2570 .owned_program_cache
2571 .get(&ub)
2572 .map(|e| Arc::clone(&e.1))
2573 .expect("B's owned_program should still be cached after sibling edit");
2574
2575 assert!(
2576 Arc::ptr_eq(&prog_b_first, &prog_b_second),
2577 "B's owned_program Arc should be identical (cache hit) after sibling declaration change"
2578 );
2579 }
2580
2581 /// write_rev() increments on each real text write (identical text is a no-op).
2582 #[test]
2583 fn write_rev_increments_on_write() {
2584 let store = DocumentStore::new();
2585 let u = uri("/rev_test.php");
2586 let before = store.write_rev();
2587 store.mirror_text(&u, "<?php echo 1;");
2588 let after_first = store.write_rev();
2589 assert!(after_first > before, "first write must bump the revision");
2590 // Identical text must NOT bump the revision (fast path).
2591 store.mirror_text(&u, "<?php echo 1;");
2592 assert_eq!(
2593 store.write_rev(),
2594 after_first,
2595 "identical text must not bump the revision"
2596 );
2597 // Different text must bump again.
2598 store.mirror_text(&u, "<?php echo 2;");
2599 assert!(
2600 store.write_rev() > after_first,
2601 "changed text must bump the revision"
2602 );
2603 }
2604
2605 /// begin_reanalyze() cancels the previously-issued sweep token so a newer
2606 /// edit preempts an in-flight dependent walk, and hands back a fresh token.
2607 #[test]
2608 fn begin_reanalyze_cancels_previous_token() {
2609 let store = DocumentStore::new();
2610 let first = store.begin_reanalyze();
2611 assert!(!first.is_cancelled(), "a fresh token starts un-cancelled");
2612 let second = store.begin_reanalyze();
2613 assert!(
2614 first.is_cancelled(),
2615 "issuing a new token must cancel the previous in-flight sweep"
2616 );
2617 assert!(
2618 !second.is_cancelled(),
2619 "the newly-issued token must itself be un-cancelled"
2620 );
2621 }
2622
2623 /// write_rev() increments when a file is removed.
2624 #[test]
2625 fn write_rev_increments_on_remove() {
2626 let store = DocumentStore::new();
2627 let u = uri("/rev_remove.php");
2628 store.mirror_text(&u, "<?php class A {}");
2629 let before = store.write_rev();
2630 store.remove(&u);
2631 assert!(store.write_rev() > before, "remove must bump the revision");
2632 }
2633}