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