php_lsp/document/document_store.rs
1use std::sync::atomic::{AtomicBool, 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 /// On-demand `FileIndex` store for vendor files loaded lazily via PSR-4
63 /// navigation. Vendor is excluded from the eager workspace scan, so files
64 /// ingested by `psr4_method_goto` are not in the salsa workspace_index;
65 /// this map fills that gap for hierarchy traversal. Populated by
66 /// `cache_vendor_index`; reads via `get_vendor_index`.
67 vendor_index_cache: DashMap<Url, Arc<FileIndex>>,
68 /// Set once the workspace scan's reference-index phase finishes, i.e. the
69 /// `subtypes_of` map in `workspace_index` is complete. Visibility-derived
70 /// scope narrowing that relies on the full subtype set (protected methods)
71 /// only applies once this is `true`; before then it falls back to the full
72 /// workspace scope so references are never under-reported.
73 index_ready: AtomicBool,
74}
75
76impl Default for DocumentStore {
77 fn default() -> Self {
78 Self::new()
79 }
80}
81
82impl DocumentStore {
83 pub fn new() -> Self {
84 DocumentStore {
85 caches: CacheRegistry::new(),
86 lsp_ws_files: DashMap::new(),
87 deleted_uris: DashSet::new(),
88 workspace_files_dirty: AtomicBool::new(true),
89 lsp_workspace: Mutex::new(None),
90 php_version: Mutex::new(mir_analyzer::PhpVersion::LATEST),
91 psr4: Arc::new(ArcSwap::from_pointee(Psr4Map::empty())),
92 analysis_session: Mutex::new(None),
93 session_cache_dir: OnceLock::new(),
94 autoload_uris: std::sync::RwLock::new(Vec::new()),
95 vendor_index_cache: DashMap::new(),
96 index_ready: AtomicBool::new(false),
97 }
98 }
99
100 /// Mark the workspace reference index as fully built. Called by the scan
101 /// when its final phase completes (alongside `$/php-lsp/indexReady`).
102 pub fn mark_index_ready(&self) {
103 self.index_ready.store(true, Ordering::Release);
104 }
105
106 /// Whether the workspace reference index has finished building.
107 pub fn is_index_ready(&self) -> bool {
108 self.index_ready.load(Ordering::Acquire)
109 }
110
111 /// Set the directory used to persist stub-parse and analysis results across
112 /// server restarts. Must be called before the first `analysis_session` use;
113 /// subsequent calls are silently ignored (`OnceLock` semantics).
114 pub fn set_session_cache_dir(&self, dir: std::path::PathBuf) {
115 let _ = self.session_cache_dir.set(dir);
116 }
117
118 /// Register URIs discovered from composer.json `autoload.files` entries.
119 /// These PHP files define global helper functions (e.g. `tap()` in Laravel)
120 /// that are not class-resolvable via PSR-4. Clears `analysis_cache` so the
121 /// next per-file analysis pre-ingests them into the AnalysisSession before
122 /// running mir's FileAnalyzer.
123 pub fn set_autoload_uris(&self, uris: Vec<Url>) {
124 *self.autoload_uris.write().unwrap() = uris;
125 self.caches.evict_analysis_all();
126 }
127
128 /// Get or build the `AnalysisSession` for the given PHP version, **without**
129 /// loading PHP stubs. Stub loading is heavy (parses every built-in stub
130 /// file) and is only needed for semantic analysis, not for parse / index /
131 /// symbol-map queries. The parse path uses this; semantic callers use
132 /// [`Self::analysis_session`], which loads stubs idempotently on top.
133 fn get_or_build_session(
134 &self,
135 php_version: mir_analyzer::PhpVersion,
136 ) -> Arc<mir_analyzer::AnalysisSession> {
137 let mut guard = self.analysis_session.lock().unwrap();
138 if let Some((cached_ver, session)) = guard.as_ref()
139 && *cached_ver == php_version
140 {
141 return Arc::clone(session);
142 }
143 // Build a fresh session. Hand it the shared PSR-4 map so it can
144 // lazy-resolve `UndefinedClass` candidates without us having to mirror
145 // every vendor file upfront.
146 let resolver: Arc<dyn mir_analyzer::ClassResolver> = self.psr4.load_full();
147 let mut builder =
148 mir_analyzer::AnalysisSession::new(php_version).with_class_resolver(resolver);
149 if let Some(dir) = self.session_cache_dir.get() {
150 builder = builder.with_cache_dir(dir);
151 }
152 let session = Arc::new(builder);
153 *guard = Some((php_version, Arc::clone(&session)));
154 session
155 }
156
157 /// The `AnalysisSession` for the given PHP version with PHP stubs loaded.
158 /// Rebuilds when the version changes (e.g. user flipped config). The
159 /// session owns the shared salsa db and AnalysisCache; lazy-loads vendor
160 /// files via the shared PSR-4 map. `ensure_all_stubs` is idempotent — cheap
161 /// after the first call on a given session.
162 pub fn analysis_session(
163 &self,
164 php_version: mir_analyzer::PhpVersion,
165 ) -> Arc<mir_analyzer::AnalysisSession> {
166 let session = self.get_or_build_session(php_version);
167 session.ensure_all_stubs();
168 session
169 }
170
171 /// Current PHP version tracked by the workspace input.
172 pub fn workspace_php_version(&self) -> mir_analyzer::PhpVersion {
173 *self.php_version.lock().unwrap()
174 }
175
176 /// File URIs of all direct and transitive subclasses of `class_fqn`,
177 /// resolved via mir's inheritance graph. Returns an empty vec when the mir
178 /// session hasn't ingested the class yet (cold start, excluded paths).
179 ///
180 /// Used by `goto_implementation` and `subtypes` to scope their lookups to
181 /// the correct files, fixing aliased `extends` and FQN-qualified forms that
182 /// the raw-name `subtypes_of` map misses.
183 pub fn class_subtype_urls(&self, class_fqn: &str) -> Vec<tower_lsp::lsp_types::Url> {
184 let session = self.analysis_session(self.workspace_php_version());
185 session
186 .subtype_files(class_fqn)
187 .into_iter()
188 .filter_map(|p| tower_lsp::lsp_types::Url::parse(&p).ok())
189 .collect()
190 }
191
192 /// Return the `Arc<ArcSwap<Psr4Map>>` so callers can share it.
193 /// `Backend` clones this arc at construction time so writes
194 /// (e.g. loading composer.json on `initialized`) are immediately visible
195 /// to PSR-4 resolution during analysis without extra plumbing.
196 pub fn psr4_arc(&self) -> Arc<ArcSwap<Psr4Map>> {
197 Arc::clone(&self.psr4)
198 }
199
200 /// Durability for a file's salsa input: vendor files never change within a
201 /// session, so salsa can skip re-validating their queries on user edits.
202 fn input_durability(uri: &Url) -> salsa::Durability {
203 if uri.as_str().contains("/vendor/") {
204 salsa::Durability::HIGH
205 } else {
206 salsa::Durability::LOW
207 }
208 }
209
210 /// The `LspWsFile` handle for `uri`, if it is mirrored and not deleted.
211 fn lsp_ws_file(&self, uri: &Url) -> Option<LspWsFile> {
212 if self.deleted_uris.contains(uri) {
213 return None;
214 }
215 self.lsp_ws_files.get(uri).map(|e| *e)
216 }
217
218 /// Take a cheap snapshot of the shared mir db and run `f` on it, retrying
219 /// once on `salsa::Cancelled` (raised when a concurrent writer bumps the
220 /// revision). Mirrors [`Self::snapshot_query`] for the converged db.
221 fn snapshot_mir_query<R>(&self, f: impl Fn(&mir_analyzer::db::MirDbStorage) -> R) -> R {
222 use std::panic::AssertUnwindSafe;
223 let session = self.get_or_build_session(self.workspace_php_version());
224 // Each iteration's snapshot clone MUST drop before the next snapshot:
225 // a concurrent writer's salsa `set` holds the mir write lock and waits
226 // for outstanding db handles to drop, while the next `snapshot_db` needs
227 // the read lock — keeping the clone alive across the retry deadlocks.
228 loop {
229 let db = session.snapshot_db();
230 match salsa::Cancelled::catch(AssertUnwindSafe(|| f(&db))) {
231 Ok(r) => return r,
232 Err(_) => drop(db),
233 }
234 }
235 }
236
237 /// Mirror a file's current text into the salsa layer. Creates the
238 /// `FileText` input on first sight, otherwise updates `text` on the
239 /// existing input (bumping the salsa revision so downstream queries
240 /// invalidate).
241 pub fn mirror_text(&self, uri: &Url, text: &str) {
242 // G2 fast path: compare against the lock-free text cache. When the
243 // new text byte-matches what we already mirrored, skip the host
244 // mutex entirely. Common during workspace scan + `did_open` for
245 // unchanged files, where most threads would otherwise serialise on
246 // `host.lock()` just to confirm a no-op.
247 if let Some(cached) = self.caches.text_cache.get(uri)
248 && **cached == *text
249 && !self.deleted_uris.contains(uri)
250 && self.lsp_ws_files.contains_key(uri)
251 {
252 return;
253 }
254 self.mirror_text_arc(uri, Arc::from(text))
255 }
256
257 /// Like [`mirror_text`] but takes an already-allocated `Arc<str>`.
258 ///
259 /// Callers that already hold an `Arc<str>` (e.g. `ingest_from_doc` reusing
260 /// `ParsedDoc::source_arc()`) use this to avoid a second allocation and to
261 /// ensure `text_cache` and `parsed_cache` hold the same Arc pointer —
262 /// enabling `Arc::ptr_eq` validation in `get_parsed_cached`.
263 pub fn mirror_text_arc(&self, uri: &Url, text_arc: Arc<str>) {
264 let dur = Self::input_durability(uri);
265 let path: Arc<str> = Arc::from(uri.as_str());
266 let session = self.get_or_build_session(self.workspace_php_version());
267 if let Some(wf) = self.lsp_ws_files.get(uri).map(|e| *e) {
268 self.deleted_uris.remove(uri);
269 // Fast path: byte-identical text already mirrored — skip the write
270 // lock and the revision bump entirely.
271 if let Some(cached) = self.caches.text_cache.get(uri)
272 && **cached == *text_arc
273 {
274 return;
275 }
276 session.with_db_mut(|db| {
277 let sf = wf.source(db);
278 sf.set_text(db).with_durability(dur).to(text_arc.clone());
279 // Any text change invalidates a previously-seeded cached index.
280 // Only set when present to avoid a spurious second revision bump.
281 if wf.cached_index(db).is_some() {
282 wf.set_cached_index(db).to(None);
283 }
284 });
285 self.caches.text_cache.insert(uri.clone(), text_arc);
286 // Evict only this file's analysis; cross-file invalidation is handled
287 // lazily in `cached_analysis` via the declaration fingerprint.
288 self.caches.evict_analysis(uri);
289 } else {
290 let wf = session.with_db_mut(|db| {
291 let sf = db.upsert_source_file_with_durability(path, text_arc.clone(), dur);
292 LspWsFile::new(db, sf, None)
293 });
294 self.lsp_ws_files.insert(uri.clone(), wf);
295 self.caches.text_cache.insert(uri.clone(), text_arc);
296 self.workspace_files_dirty.store(true, Ordering::Release);
297 }
298 }
299
300 /// Return the `LspWsFile` handle for a URL, if active (not deleted).
301 #[cfg(test)]
302 pub fn source_file(&self, uri: &Url) -> Option<LspWsFile> {
303 if self.deleted_uris.contains(uri) {
304 return None;
305 }
306 self.lsp_ws_files.get(uri).map(|e| *e)
307 }
308
309 /// Phase K2: pre-seed a `FileIndex` loaded from the on-disk cache onto
310 /// the `FileText` input for `uri`. The next `file_index` call for that
311 /// file returns the cached index directly, skipping parse + extract.
312 ///
313 /// Must be called **before** any `file_index(db, sf)` call for this file —
314 /// otherwise salsa has already memoized the fresh-parse result and setting
315 /// `cached_index` now would only bump the revision without using the cache.
316 /// In practice the workspace-scan path seeds immediately after `mirror_text`
317 /// and before any query runs.
318 ///
319 /// Returns `false` when `uri` was not mirrored (caller should mirror
320 /// first); returns `true` on success.
321 pub fn seed_cached_index(&self, uri: &Url, index: Arc<FileIndex>) -> bool {
322 let Some(wf) = self.lsp_ws_file(uri) else {
323 return false;
324 };
325 let session = self.get_or_build_session(self.workspace_php_version());
326 session.with_db_mut(|db| wf.set_cached_index(db).to(Some(index)));
327 true
328 }
329
330 /// Evict the semantic-tokens cache for `uri`. Called by Backend when a
331 /// file is closed; diff-based tokens computed against the old revision
332 /// are no longer meaningful.
333 pub fn evict_token_cache(&self, uri: &Url) {
334 self.caches.evict_tokens(uri);
335 }
336
337 /// Return the `FileIndex` for `uri` by running `file_index` on a salsa
338 /// snapshot. Returns `None` when `uri` has not been mirrored.
339 ///
340 /// Test-only — production code uses the salsa query directly via
341 /// `snapshot_query`.
342 #[cfg(test)]
343 pub fn source_files_len(&self) -> usize {
344 self.lsp_ws_files.len()
345 }
346
347 #[cfg(test)]
348 pub fn snapshot_query_file_index(
349 &self,
350 uri: &Url,
351 ) -> Option<crate::index::file_index::FileIndex> {
352 let wf = self.lsp_ws_file(uri)?;
353 Some(
354 self.snapshot_mir_query(move |db| {
355 (*crate::db::mir_queries::file_index(db, wf).0).clone()
356 }),
357 )
358 }
359
360 /// Register a file in the salsa layer without marking it open.
361 ///
362 /// Salsa's `parsed_doc` query parses lazily on first read; diagnostics
363 /// are populated by `did_open` when the editor actually opens the file.
364 pub fn ingest(&self, uri: Url, text: &str) {
365 self.mirror_text(&uri, text);
366 }
367
368 /// Index a file using an already-parsed `ParsedDoc`, avoiding a second parse.
369 ///
370 /// Prefer this over [`ingest`] when the caller already has a `ParsedDoc` (e.g.
371 /// after running `DefinitionCollector` during workspace scan). Reuses the
372 /// `Arc<str>` already owned by `doc` so that `text_cache` and `SourceFile::text`
373 /// share the same pointer — enabling the `Arc::ptr_eq` fast path in
374 /// `get_parsed_cached` on the first subsequent salsa query, without an extra
375 /// `Arc::from(source)` allocation.
376 pub fn ingest_from_doc(&self, uri: Url, doc: &ParsedDoc) {
377 self.mirror_text_arc(&uri, doc.source_arc());
378 }
379
380 pub fn remove(&self, uri: &Url) {
381 self.caches.evict(uri);
382 // Mark the URI as deleted but keep the `source_files` entry so the
383 // salsa `SourceFile` handle remains alive. Re-opening the file reuses
384 // the same handle instead of calling `SourceFile::new()` again, which
385 // would create a new orphaned salsa input on every delete-reopen cycle.
386 self.deleted_uris.insert(uri.clone());
387 self.workspace_files_dirty.store(true, Ordering::Release);
388 // Sync workspace files so the deleted file is removed from the salsa
389 // `Workspace::files` list and won't appear in workspace symbols etc.
390 self.sync_workspace_files();
391 // Also evict the file from the `AnalysisSession`'s internal state so
392 // workspace symbol queries don't keep returning the deleted file's
393 // declarations. Cheap when the session hasn't ingested this file.
394 let guard = self.analysis_session.lock().unwrap();
395 if let Some((_, session)) = guard.as_ref() {
396 session.invalidate_file(uri.as_str());
397 }
398 }
399
400 // ── Salsa-backed accessors ─────────────────────────────────────────────
401 //
402 // Reads run the memoized `parsed_doc` / `file_index` queries, parsing
403 // only on first access per revision. These are the production accessors
404 // used by every handler.
405
406 /// Salsa-backed parsed document.
407 ///
408 /// Salsa-backed parsed document for any mirrored file (open or
409 /// background-indexed). Returns `None` only when the file is not known
410 /// to the store. Callers that want "only if open" should gate on
411 /// `Backend::open_files` at the call site (see `Backend::get_doc`).
412 pub fn get_doc_salsa(&self, uri: &Url) -> Option<Arc<ParsedDoc>> {
413 self.get_parsed_cached(uri)
414 }
415
416 /// Salsa-backed compact symbol index.
417 pub fn get_index_salsa(&self, uri: &Url) -> Option<Arc<FileIndex>> {
418 let wf = self.lsp_ws_file(uri)?;
419 Some(
420 self.snapshot_mir_query(move |db| crate::db::mir_queries::file_index(db, wf).0.clone()),
421 )
422 }
423
424 /// Salsa-backed pre-computed symbol map (name → Vec<SymbolEntry>).
425 /// Memoized per revision: stable files serve from cache in O(1).
426 pub fn get_symbol_map_salsa(
427 &self,
428 uri: &Url,
429 ) -> Option<Arc<crate::types::symbol_map::SymbolMap>> {
430 // Symbol map runs on the shared mir db, sharing its memoized `parsed_doc`.
431 let wf = self.lsp_ws_file(uri)?;
432 Some(self.snapshot_mir_query(move |db| {
433 let sf = wf.source(db);
434 crate::db::mir_queries::symbol_map(db, sf).0.clone()
435 }))
436 }
437
438 /// Pre-computed symbol maps for every entry in `open_urls` except `uri`.
439 pub fn other_symbol_maps(
440 &self,
441 uri: &Url,
442 open_urls: &[Url],
443 ) -> Vec<(Url, Arc<crate::types::symbol_map::SymbolMap>)> {
444 open_urls
445 .iter()
446 .filter(|u| *u != uri)
447 .filter_map(|u| self.get_symbol_map_salsa(u).map(|m| (u.clone(), m)))
448 .collect()
449 }
450
451 /// G3: shared implementation for `get_doc_salsa`.
452 /// Tries the `parsed_cache` (lock-free) first; validates via
453 /// `Arc::ptr_eq` against the G2 `text_cache` so a concurrent writer
454 /// that has already committed a new text input cannot be masked by a
455 /// stale cache entry. On miss, captures the text Arc and ParsedDoc
456 /// together inside a single `snapshot_query`, then publishes both.
457 fn get_parsed_cached(&self, uri: &Url) -> Option<Arc<ParsedDoc>> {
458 if let Some(current_text) = self.caches.text_cache.get(uri)
459 && let Some(entry) = self.caches.parsed_cache.get(uri)
460 && Arc::ptr_eq(&*current_text, &entry.0)
461 {
462 return Some(entry.1.clone());
463 }
464
465 // Parse runs on the shared mir db.
466 let wf = self.lsp_ws_file(uri)?;
467 self.caches.bump_parse_count();
468 let (text, doc) = self.snapshot_mir_query(move |db| {
469 let sf = wf.source(db);
470 let text = sf.text(db);
471 let doc = crate::db::mir_queries::parsed_doc(db, sf).0.clone();
472 (text, doc)
473 });
474 self.caches.insert_parsed(uri.clone(), text, doc.clone());
475 Some(doc)
476 }
477
478 /// Refresh `workspace.files` to mirror the current active file set.
479 ///
480 /// Skips all work when `workspace_files_dirty` is `false` (the common
481 /// case after the workspace scan completes — file-set changes are rare).
482 pub fn sync_workspace_files(&self) {
483 // Atomically clear the flag. If it was already false the file set
484 // hasn't changed since the last sync; nothing to do.
485 if !self.workspace_files_dirty.swap(false, Ordering::AcqRel) {
486 return;
487 }
488
489 // Collect active (non-deleted) files, sorted by URI for stable ordering.
490 let mut entries: Vec<(Arc<str>, LspWsFile)> = self
491 .lsp_ws_files
492 .iter()
493 .filter(|e| !self.deleted_uris.contains(e.key()))
494 .map(|e| (Arc::<str>::from(e.key().as_str()), *e.value()))
495 .collect();
496 entries.sort_unstable_by(|(a, _), (b, _)| a.cmp(b));
497 let files: Arc<[LspWsFile]> = entries.iter().map(|(_, wf)| *wf).collect();
498
499 let session = self.get_or_build_session(self.workspace_php_version());
500 let mut guard = self.lsp_workspace.lock().unwrap();
501 session.with_db_mut(|db| match *guard {
502 Some(ws) => {
503 ws.set_files(db).to(files);
504 }
505 None => *guard = Some(LspWorkspace::new(db, files)),
506 });
507 }
508
509 /// Mark the workspace file set as dirty so the next `sync_workspace_files`
510 /// call re-runs the collect/sort/compare path. Exposed for benchmarks that
511 /// need to measure the dirty-path cost in isolation.
512 pub fn mark_workspace_files_dirty(&self) {
513 self.workspace_files_dirty.store(true, Ordering::Release);
514 }
515
516 /// Update the PHP version tracked by the workspace. Salsa will invalidate
517 /// all `semantic_issues` queries so diagnostics are re-evaluated.
518 /// Skips the setter when the version hasn't changed to avoid spurious
519 /// query invalidation.
520 pub fn set_php_version(&self, version: mir_analyzer::PhpVersion) {
521 {
522 let mut guard = self.php_version.lock().unwrap();
523 if *guard == version {
524 return;
525 }
526 *guard = version;
527 }
528 // Changing the version selects a different `AnalysisSession` (and thus a
529 // different db). Drop the inputs created on the old session's db; the
530 // workspace scan re-mirrors files onto the new session. In practice the
531 // version is set once at init, before any file is mirrored.
532 self.lsp_ws_files.clear();
533 *self.lsp_workspace.lock().unwrap() = None;
534 self.workspace_files_dirty.store(true, Ordering::Release);
535 // Stale FileAnalysis from the old version would survive unchanged files.
536 self.caches.evict_analysis_all();
537 }
538
539 /// Session-backed reference lookup scoped to `files`. Returns `(file, line,
540 /// col)` locations for every occurrence of `symbol` originating in those
541 /// files, computed from the memoized `analyze_file` query — no ingest, no
542 /// shared-index mutation, so repeated lookups don't degrade across a
543 /// session. `files` should be the text-pre-filtered candidate set.
544 ///
545 /// Returns LSP-style 0-based line/column.
546 pub fn session_references_to(
547 &self,
548 symbol: &mir_analyzer::Name,
549 files: &[Arc<str>],
550 ) -> Vec<(Arc<str>, u32, u32, u32)> {
551 let php_version = self.workspace_php_version();
552 // Retry: concurrent db writes (background indexing) cancel snapshot
553 // queries via resume_unwind; without the loop the panic propagates out
554 // of the caller's spawn_blocking and the request silently returns empty.
555 let raw = loop {
556 let session = self.analysis_session(php_version);
557 if let Ok(refs) = salsa::Cancelled::catch(std::panic::AssertUnwindSafe(|| {
558 session.references_to_in_files(symbol, files)
559 })) {
560 break refs;
561 }
562 };
563 raw.into_iter()
564 .map(|(file, range)| {
565 // mir uses 1-based lines; 0-based columns (since mir 0.42.0).
566 let line = range.start.line.saturating_sub(1);
567 let col_start = range.start.column;
568 let col_end = range.end.column;
569 (file, line, col_start, col_end)
570 })
571 .collect()
572 }
573
574 /// The complete set of files a `private` method's references can occur in,
575 /// or `None` when the search must stay workspace-wide.
576 ///
577 /// `owner_fqn` is the enclosing class at the cursor; narrowing applies only
578 /// when that class *directly declares* `method` (so its `MethodDef`
579 /// visibility is authoritative — inherited/call-site lookups fall through to
580 /// `None`). A reference to `Class::method` is recorded only where
581 /// `analyze_file` resolves an expression to that key, and `analyze_file` is
582 /// per-source-file, so a `private` member — resolvable only inside the
583 /// declaring class body, which lives in one file — is fully scoped to the
584 /// declaring file at any indexing stage.
585 ///
586 /// A `protected` member is reachable from the declaring class and its
587 /// transitive subclasses (`$this->m()`, `parent::m()`, `self::`/`static::`),
588 /// all of which live in the declaring file or a subtype file — never outside
589 /// the hierarchy in valid PHP. Its scope is therefore the declaring file plus
590 /// the transitive subtype files. The complete subtype set is only known once
591 /// the workspace reference index finishes, so this narrowing applies only
592 /// when [`Self::is_index_ready`]; before then it falls back to `None` (full
593 /// workspace scope) so references are never under-reported.
594 ///
595 /// Classes that compose traits or mixins are excluded — those inline
596 /// external resolution context, so a reference could surface in another file.
597 pub fn method_reference_scope(&self, owner_fqn: &str, method: &str) -> Option<Vec<Url>> {
598 use crate::index::file_index::{ClassKind, Visibility};
599
600 let ws = self.get_workspace_index_salsa();
601 let owner_fqn = owner_fqn.trim_start_matches('\\');
602 let owner_short = crate::text::fqn_short_name(owner_fqn);
603
604 let (decl_uri, decl_class) = ws
605 .classes_by_name
606 .get(owner_short)?
607 .iter()
608 .filter_map(|&r| ws.at(r))
609 .find(|(_, cls)| cls.fqn.trim_start_matches('\\') == owner_fqn)?;
610
611 // Trait/interface methods, and methods on classes that compose traits or
612 // mixins, can be referenced from other files — keep the full scope.
613 if !matches!(decl_class.kind, ClassKind::Class | ClassKind::Enum)
614 || !decl_class.traits.is_empty()
615 || !decl_class.mixins.is_empty()
616 {
617 return None;
618 }
619
620 let vis = decl_class
621 .methods
622 .iter()
623 .find(|m| m.name.eq_ignore_ascii_case(method))?
624 .visibility;
625
626 match vis {
627 Visibility::Private => Some(vec![decl_uri.clone()]),
628 Visibility::Protected => {
629 if !self.is_index_ready() {
630 return None;
631 }
632 // Subtype set comes from mir's resolved inheritance graph — it
633 // matches subclasses by FQCN, so `extends \Ns\Base` and aliased
634 // `use ... as` forms are all found. Falls back to full scope if
635 // mir can't resolve the owner.
636 let session = self.analysis_session(self.workspace_php_version());
637 let mut files: std::collections::HashSet<Url> = session
638 .subtype_files(owner_fqn)
639 .into_iter()
640 .filter_map(|p| Url::parse(&p).ok())
641 .collect();
642 files.insert(decl_uri.clone());
643 Some(files.into_iter().collect())
644 }
645 Visibility::Public => None,
646 }
647 }
648
649 /// Phase J: salsa-memoized aggregate workspace index.
650 ///
651 /// Returns the shared `Arc<WorkspaceIndexData>` with flat
652 /// `(Url, Arc<FileIndex>)` list plus pre-built `classes_by_name` and
653 /// `subtypes_of` reverse maps. Used by workspace_symbols,
654 /// prepare_type_hierarchy, supertypes_of, subtypes_of, and
655 /// find_implementations so they don't each rebuild the aggregate per
656 /// request. Invalidates automatically when any file's `file_index`
657 /// changes.
658 pub fn get_workspace_index_salsa(&self) -> Arc<crate::db::workspace_index::WorkspaceIndexData> {
659 self.sync_workspace_files();
660 let ws = *self.lsp_workspace.lock().unwrap();
661 let Some(ws) = ws else {
662 return Arc::new(crate::db::workspace_index::WorkspaceIndexData {
663 files: Vec::new(),
664 classes_by_name: std::collections::HashMap::new(),
665 subtypes_of: std::collections::HashMap::new(),
666 decls_by_name: std::collections::HashMap::new(),
667 });
668 };
669 self.snapshot_mir_query(move |db| crate::db::mir_queries::workspace_index(db, ws).0.clone())
670 }
671
672 /// Total number of real `ParsedDoc` parses served so far (cache misses).
673 /// Surfaced via `$/php-lsp/debugStats` so tests can assert the references
674 /// read path doesn't parse the whole workspace.
675 pub fn parse_count(&self) -> u64 {
676 self.caches.parse_count()
677 }
678
679 /// Return the raw source text for `uri` if it has been mirrored into the
680 /// salsa workspace. Used by the references handler to pre-filter session
681 /// results by checking whether a file mentions the owning class name.
682 pub fn source_text(&self, uri: &Url) -> Option<Arc<str>> {
683 self.caches.text_cache.get(uri).map(|e| Arc::clone(&e))
684 }
685
686 /// Cache the semantic tokens computed for a delta response.
687 /// `result_id` is an opaque string (a hash of the token data) returned to the client.
688 pub fn store_token_cache(&self, uri: &Url, result_id: String, tokens: Arc<Vec<SemanticToken>>) {
689 self.caches.store_token(uri, result_id, tokens);
690 }
691
692 /// Return the cached tokens if `result_id` matches the stored one.
693 pub fn get_token_cache(&self, uri: &Url, result_id: &str) -> Option<Arc<Vec<SemanticToken>>> {
694 self.caches.get_token(uri, result_id)
695 }
696
697 /// Raw semantic issues for a file, computed via mir's session-based
698 /// `FileAnalyzer`. The session lazy-loads dependencies via PSR-4 so the
699 /// LSP no longer needs to mirror vendor up-front. Callers apply their
700 /// own `DiagnosticsConfig` filter via
701 /// [`crate::semantic_diagnostics::issues_to_diagnostics`].
702 #[tracing::instrument(skip_all)]
703 pub fn get_semantic_issues_salsa(&self, uri: &Url) -> Option<Arc<[mir_issues::Issue]>> {
704 let analysis = self.cached_analysis(uri)?;
705 let file: Arc<str> = Arc::from(uri.as_str());
706 // Workspace-level class issues for this file (circular inheritance,
707 // override violations, abstract-method gaps). These are session-wide
708 // (a dependency edit changes them without changing this file's bytes),
709 // so they are recomputed live rather than cached alongside the
710 // per-file body analysis.
711 let class_issues = {
712 let _s = tracing::debug_span!("session.class_issues_for").entered();
713 // Retry: concurrent db writes cancel snapshot queries via resume_unwind.
714 loop {
715 let session = self.analysis_session(self.workspace_php_version());
716 if let Ok(issues) = salsa::Cancelled::catch(std::panic::AssertUnwindSafe(|| {
717 session.class_issues(std::slice::from_ref(&file))
718 })) {
719 break issues;
720 }
721 }
722 };
723 let combined: Vec<mir_issues::Issue> = analysis
724 .issues
725 .iter()
726 .cloned()
727 .chain(class_issues)
728 .filter(|i| !i.suppressed)
729 .collect();
730 Some(Arc::from(combined))
731 }
732
733 /// Run (or reuse) mir's per-file body analysis, retaining the full
734 /// [`mir_analyzer::FileAnalysis`] — issues **and** resolved symbols — across
735 /// requests. Diagnostics read `.issues`; position features call
736 /// `.symbol_at(offset)` for the resolved type at a cursor.
737 ///
738 /// Cache hit when the entry's captured source `Arc` is pointer-equal to the
739 /// file's current `doc.source_arc()`. A miss recomputes and overwrites, so
740 /// the entry self-evicts on any content edit.
741 /// Build (or reuse) the whole-doc completion [`crate::types::type_map::TypeMap`]
742 /// for `uri`. Cache hit when the entry's captured source `Arc` is
743 /// pointer-equal to `doc.source_arc()` and the PHPStorm-meta pointer is
744 /// unchanged (meta lives behind `ArcSwap`, so its address is stable until
745 /// `.phpstorm.meta.php` is reloaded). A miss rebuilds and overwrites, so
746 /// the entry self-evicts on any content edit.
747 pub fn cached_type_map(
748 &self,
749 uri: &Url,
750 doc: &crate::document::ast::ParsedDoc,
751 meta: Option<&crate::lang::phpstorm_meta::PhpStormMeta>,
752 ) -> Arc<crate::types::type_map::TypeMap> {
753 let source = doc.source_arc();
754 let meta_key = meta.map_or(0usize, |m| std::ptr::from_ref(m) as usize);
755 if let Some(entry) = self.caches.type_map_cache.get(uri)
756 && Arc::ptr_eq(&entry.0, &source)
757 && entry.1 == meta_key
758 {
759 return Arc::clone(&entry.2);
760 }
761 let map = Arc::new(crate::types::type_map::TypeMap::from_doc_with_meta(
762 doc, meta,
763 ));
764 self.caches
765 .type_map_cache
766 .insert(uri.clone(), (source, meta_key, Arc::clone(&map)));
767 map
768 }
769
770 /// Cache-hit-only variant of [`Self::cached_analysis`]: returns the cached
771 /// analysis when the entry is current for the file's text, never computes.
772 /// Lets async handlers take the warm path synchronously and reserve
773 /// `spawn_blocking` for the cold path (mir Pass 1 + Pass 2 can take
774 /// hundreds of ms on large files).
775 pub fn cached_analysis_if_fresh(&self, uri: &Url) -> Option<Arc<mir_analyzer::FileAnalysis>> {
776 let doc = self.get_doc_salsa(uri)?;
777 let source = doc.source_arc();
778 let entry = self.caches.analysis_cache.get(uri)?;
779 let cur_ver = self.caches.decl_version();
780 (Arc::ptr_eq(&entry.0, &source) && entry.1 == cur_ver).then(|| Arc::clone(&entry.2))
781 }
782
783 #[tracing::instrument(skip_all)]
784 pub fn cached_analysis(&self, uri: &Url) -> Option<Arc<mir_analyzer::FileAnalysis>> {
785 // Need the parsed doc both for the analyzer and as the cache key.
786 let doc = self.get_doc_salsa(uri)?;
787 let source = doc.source_arc();
788
789 let cur_ver = self.caches.decl_version();
790 if let Some(entry) = self.caches.analysis_cache.get(uri)
791 && Arc::ptr_eq(&entry.0, &source)
792 && entry.1 == cur_ver
793 {
794 return Some(Arc::clone(&entry.2));
795 }
796
797 let php_version = self.workspace_php_version();
798 let session = self.analysis_session(php_version);
799 let file: Arc<str> = Arc::from(uri.as_str());
800
801 let source_map = php_rs_parser::source_map::SourceMap::new(doc.source());
802 let owned_program = if let Some(cached) = self.caches.owned_program_cache.get(uri)
803 && Arc::ptr_eq(&cached.0, &source)
804 {
805 Arc::clone(&cached.1)
806 } else {
807 let prog = Arc::new(php_ast::owned::to_owned_program(doc.program()));
808 self.caches
809 .owned_program_cache
810 .insert(uri.clone(), (Arc::clone(&source), Arc::clone(&prog)));
811 prog
812 };
813
814 // autoload.files helpers (e.g. Laravel's tap()) must be ingested so mir sees their functions.
815 let autoload_texts: Vec<(Arc<str>, Arc<str>)> = {
816 let autoload_uris = self.autoload_uris.read().unwrap().clone();
817 autoload_uris
818 .iter()
819 .filter_map(|auri| {
820 self.caches
821 .text_cache
822 .get(auri)
823 .map(|t| (Arc::from(auri.as_str()), Arc::clone(&*t)))
824 })
825 .collect()
826 };
827 // Bare same-namespace / use-imported class refs aren't resolved by mir's priority_index_for_ast; preload them.
828 let class_fqns = crate::references::collect_referenced_class_fqns(&doc);
829
830 // ingest_file/load_class/analyze take internal salsa snapshots; a concurrent db write cancels them via resume_unwind. Retry the idempotent sequence.
831 let analysis = loop {
832 let attempt = salsa::Cancelled::catch(std::panic::AssertUnwindSafe(|| {
833 session.ingest_file(file.clone(), source.clone());
834 for (afile, atext) in &autoload_texts {
835 session.ingest_file(afile.clone(), atext.clone());
836 }
837 for fqcn in &class_fqns {
838 let _ = session.load_class(fqcn);
839 }
840 let analyzer = mir_analyzer::FileAnalyzer::new(&session);
841 analyzer.analyze(file.clone(), doc.source(), &owned_program, &source_map)
842 }));
843 if let Ok(a) = attempt {
844 break Arc::new(a);
845 }
846 };
847 // Compare the new FileIndex against the stored fingerprint. If
848 // declarations changed (or this is the first analysis), bump
849 // `decl_version` so other files' cache entries become stale. Body-only
850 // edits leave the counter unchanged, allowing sibling files to be
851 // served from cache on the next request.
852 let new_index = self.get_index_salsa(uri);
853 let old_fp = self
854 .caches
855 .decl_fingerprints
856 .get(uri)
857 .map(|e| Arc::clone(&*e));
858 let decl_changed = match (&old_fp, &new_index) {
859 (Some(old), Some(new)) => **old != **new,
860 (None, Some(_)) => true,
861 _ => false,
862 };
863 if decl_changed {
864 if let Some(idx) = new_index {
865 self.caches.decl_fingerprints.insert(uri.clone(), idx);
866 }
867 self.caches.bump_decl_version();
868 }
869 let ver = self.caches.decl_version();
870 self.caches
871 .analysis_cache
872 .insert(uri.clone(), (source, ver, Arc::clone(&analysis)));
873 Some(analysis)
874 }
875
876 /// Returns `(uri, doc)` for files currently open in the editor.
877 ///
878 /// Resolve `open_urls` (from `Backend::open_urls()`) to parsed docs.
879 /// Files not mirrored in the salsa layer are filtered out silently.
880 pub fn docs_for(&self, open_urls: &[Url]) -> Vec<(Url, Arc<ParsedDoc>)> {
881 open_urls
882 .iter()
883 .filter_map(|u| self.get_doc_salsa(u).map(|d| (u.clone(), d)))
884 .collect()
885 }
886
887 /// `(primary, doc)` first, then every other open file's parsed doc.
888 /// The `open_urls` slice should include `uri` — this helper filters it out.
889 pub fn doc_with_others(
890 &self,
891 uri: &Url,
892 doc: Arc<ParsedDoc>,
893 open_urls: &[Url],
894 ) -> Vec<(Url, Arc<ParsedDoc>)> {
895 let mut result = vec![(uri.clone(), doc)];
896 result.extend(self.other_docs(uri, open_urls));
897 result
898 }
899
900 /// Parsed docs for every entry in `open_urls` except `uri`.
901 pub fn other_docs(&self, uri: &Url, open_urls: &[Url]) -> Vec<(Url, Arc<ParsedDoc>)> {
902 open_urls
903 .iter()
904 .filter(|u| *u != uri)
905 .filter_map(|u| self.get_doc_salsa(u).map(|d| (u.clone(), d)))
906 .collect()
907 }
908
909 /// Compact symbol index for every mirrored file.
910 pub fn all_indexes(&self) -> Vec<(Url, Arc<FileIndex>)> {
911 self.get_workspace_index_salsa().files.clone()
912 }
913
914 /// Store a lazily-loaded vendor `FileIndex` in the session cache.
915 /// Only call this for files that are not part of the normal workspace scan
916 /// (i.e. vendor files loaded on-demand by PSR-4 navigation).
917 pub fn cache_vendor_index(&self, uri: Url, index: Arc<FileIndex>) {
918 self.vendor_index_cache.insert(uri, index);
919 }
920
921 /// Retrieve a previously cached vendor `FileIndex`.
922 pub fn get_vendor_index(&self, uri: &Url) -> Option<Arc<FileIndex>> {
923 self.vendor_index_cache.get(uri).map(|e| Arc::clone(&*e))
924 }
925
926 /// Same as `all_indexes` but excludes `uri`.
927 pub fn other_indexes(&self, uri: &Url) -> Vec<(Url, Arc<FileIndex>)> {
928 self.get_workspace_index_salsa()
929 .files
930 .iter()
931 .filter(|(u, _)| u != uri)
932 .cloned()
933 .collect()
934 }
935
936 /// Parsed documents for every mirrored file (open or background-indexed).
937 /// Suitable for full-scan operations: find-references, rename,
938 /// call_hierarchy, code_lens.
939 pub fn all_docs_for_scan(&self) -> Vec<(Url, Arc<ParsedDoc>)> {
940 let urls: Vec<Url> = self
941 .lsp_ws_files
942 .iter()
943 .filter(|e| !self.deleted_uris.contains(e.key()))
944 .map(|e| e.key().clone())
945 .collect();
946 urls.into_iter()
947 .filter_map(|u| self.get_doc_salsa(&u).map(|d| (u, d)))
948 .collect()
949 }
950
951 /// URLs of workspace files whose raw source text mentions `word` as a whole
952 /// identifier — **no parsing**. Uses a precompiled `memmem` finder (the same
953 /// needle is scanned across the whole workspace) plus a word-boundary check,
954 /// so `save` does not match inside `saveAll`/`unsaved`; the scan runs in
955 /// parallel over the file set. The references read path narrows this list by
956 /// symbol visibility and hands it to mir's per-file `analyze_file`, so a
957 /// method lookup never materializes a `ParsedDoc` for the workspace.
958 ///
959 /// Files whose text is not yet in `text_cache` are included conservatively
960 /// (safe superset — never produces false negatives).
961 pub fn candidate_urls_for(&self, word: &str) -> Vec<Url> {
962 use rayon::prelude::*;
963 let urls: Vec<Url> = self
964 .lsp_ws_files
965 .iter()
966 .filter(|e| !self.deleted_uris.contains(e.key()))
967 .map(|e| e.key().clone())
968 .collect();
969 let finder = memchr::memmem::Finder::new(word.as_bytes());
970 let wlen = word.len();
971 urls.into_par_iter()
972 .filter(|u| match self.caches.text_cache.get(u) {
973 Some(src) => mentions_identifier(src.as_bytes(), &finder, wlen),
974 None => true,
975 })
976 .collect()
977 }
978
979 /// Parsed documents limited to files whose raw source text contains `word`.
980 ///
981 /// [`Self::candidate_urls_for`] + a salsa parse per survivor. Used by the
982 /// AST-walker reference paths (constructor refs, non-method kinds) that
983 /// genuinely need the parsed AST. The method reference path uses
984 /// `candidate_urls_for` directly and avoids these parses entirely.
985 ///
986 /// Files whose text is not yet in `text_cache` are included conservatively
987 /// (safe superset — never produces false negatives).
988 pub fn candidate_docs_for(&self, word: &str) -> Vec<(Url, Arc<ParsedDoc>)> {
989 self.candidate_urls_for(word)
990 .into_iter()
991 .filter_map(|u| self.get_doc_salsa(&u).map(|d| (u, d)))
992 .collect()
993 }
994}
995
996/// Whether `hay` contains `finder`'s needle (length `wlen`) as a whole
997/// identifier — i.e. not immediately preceded or followed by an ASCII
998/// identifier byte (`[A-Za-z0-9_]`). Only ASCII bytes count as identifier
999/// boundaries, so the check can over-accept near multibyte text but never
1000/// rejects a real whole-word match.
1001fn mentions_identifier(hay: &[u8], finder: &memchr::memmem::Finder, wlen: usize) -> bool {
1002 let is_ident = |b: u8| b.is_ascii_alphanumeric() || b == b'_';
1003 let mut from = 0;
1004 while let Some(rel) = finder.find(&hay[from..]) {
1005 let idx = from + rel;
1006 let before_ok = idx == 0 || !is_ident(hay[idx - 1]);
1007 let end = idx + wlen;
1008 let after_ok = end >= hay.len() || !is_ident(hay[end]);
1009 if before_ok && after_ok {
1010 return true;
1011 }
1012 from = idx + 1;
1013 }
1014 false
1015}
1016
1017#[cfg(test)]
1018mod tests {
1019 use super::*;
1020
1021 fn uri(path: &str) -> Url {
1022 Url::parse(&format!("file://{path}")).unwrap()
1023 }
1024
1025 #[test]
1026 fn mentions_identifier_respects_word_boundaries() {
1027 let m = |hay: &str, word: &str| {
1028 let f = memchr::memmem::Finder::new(word.as_bytes());
1029 mentions_identifier(hay.as_bytes(), &f, word.len())
1030 };
1031 assert!(m("$this->save();", "save"));
1032 assert!(m("function save() {}", "save"));
1033 assert!(m("save", "save"));
1034 // substrings inside a larger identifier must not match
1035 assert!(!m("$this->saveAll();", "save"));
1036 assert!(!m("return $unsaved;", "save"));
1037 assert!(!m("class Saver {}", "save"));
1038 assert!(!m("no occurrence here", "save"));
1039 }
1040
1041 /// Phase E4: open-file state lives on `Backend`, not `DocumentStore`.
1042 /// Tests that need to simulate "file is open" just mirror the text into
1043 /// the salsa input — the open/closed distinction is enforced by the
1044 /// caller (Backend) in production.
1045 fn open(store: &DocumentStore, u: Url, text: String) {
1046 store.mirror_text(&u, &text);
1047 }
1048
1049 // Removed `salsa_codebase_aggregates_all_files`: the salsa-side codebase
1050 // aggregation was deleted with the mir 0.22 migration. Equivalent
1051 // behaviour is now covered by mir-analyzer's own session tests.
1052
1053 #[test]
1054 fn index_registers_file_in_salsa() {
1055 let store = DocumentStore::new();
1056 store.ingest(uri("/lib.php"), "<?php\nfunction lib_fn() {}");
1057 let idx = store.get_index_salsa(&uri("/lib.php")).unwrap();
1058 assert_eq!(idx.functions.len(), 1);
1059 assert_eq!(idx.functions[0].name, "lib_fn".into());
1060 }
1061
1062 #[test]
1063 fn remove_hides_file_from_index() {
1064 let store = DocumentStore::new();
1065 let u = uri("/lib.php");
1066 store.ingest(u.clone(), "<?php");
1067 store.remove(&u);
1068 assert!(store.get_index_salsa(&u).is_none());
1069 }
1070
1071 #[test]
1072 fn remove_and_reopen_reuses_source_file_handle() {
1073 let store = DocumentStore::new();
1074 let u = uri("/lib.php");
1075 store.ingest(u.clone(), "<?php");
1076 let ft_before = store.source_file(&u).unwrap();
1077 store.remove(&u);
1078 assert!(
1079 store.source_file(&u).is_none(),
1080 "deleted file should be hidden"
1081 );
1082 store.mirror_text(&u, "<?php");
1083 let ft_after = store.source_file(&u).unwrap();
1084 assert!(
1085 ft_before == ft_after,
1086 "reopen must reuse the same FileText handle"
1087 );
1088 }
1089
1090 #[test]
1091 fn delete_reopen_churn_does_not_amplify_salsa_inputs() {
1092 let store = DocumentStore::new();
1093 let uris: Vec<Url> = (0..20).map(|i| uri(&format!("/churn/f{i}.php"))).collect();
1094 for u in &uris {
1095 store.ingest(u.clone(), "<?php class A {}");
1096 }
1097 let count_before = store.source_files_len();
1098 for _ in 0..10 {
1099 for u in &uris {
1100 store.remove(u);
1101 }
1102 for u in &uris {
1103 store.ingest(u.clone(), "<?php class A {}");
1104 }
1105 }
1106 assert_eq!(
1107 store.source_files_len(),
1108 count_before,
1109 "delete-reopen cycles must not create new salsa inputs (L1-B regression guard)"
1110 );
1111 }
1112
1113 #[test]
1114 fn all_indexes_includes_every_mirrored_file() {
1115 let store = DocumentStore::new();
1116 open(&store, uri("/a.php"), "<?php\nfunction a() {}".to_string());
1117 store.ingest(uri("/b.php"), "<?php\nfunction b() {}");
1118 assert_eq!(store.all_indexes().len(), 2);
1119 }
1120
1121 #[test]
1122 fn other_indexes_excludes_current_uri() {
1123 let store = DocumentStore::new();
1124 open(&store, uri("/a.php"), "<?php\nfunction a() {}".to_string());
1125 open(&store, uri("/b.php"), "<?php\nfunction b() {}".to_string());
1126 assert_eq!(store.other_indexes(&uri("/a.php")).len(), 1);
1127 }
1128
1129 #[test]
1130 fn other_docs_excludes_current_uri() {
1131 let store = DocumentStore::new();
1132 let ua = uri("/a.php");
1133 let ub = uri("/b.php");
1134 open(&store, ua.clone(), "<?php\nfunction a() {}".to_string());
1135 open(&store, ub.clone(), "<?php\nfunction b() {}".to_string());
1136 let open_urls = vec![ua.clone(), ub];
1137 assert_eq!(store.other_docs(&ua, &open_urls).len(), 1);
1138 }
1139
1140 #[test]
1141 fn evict_token_cache_removes_entry() {
1142 let store = DocumentStore::new();
1143 let u = uri("/a.php");
1144 open(&store, u.clone(), "<?php".to_string());
1145 store.store_token_cache(&u, "id1".to_string(), Arc::new(vec![]));
1146 assert!(store.get_token_cache(&u, "id1").is_some());
1147 store.evict_token_cache(&u);
1148 assert!(store.get_token_cache(&u, "id1").is_none());
1149 }
1150
1151 #[test]
1152 fn index_populates_file_index_with_symbols() {
1153 let store = DocumentStore::new();
1154 store.ingest(uri("/a.php"), "<?php\nfunction hello() {}");
1155 let idx = store.get_index_salsa(&uri("/a.php")).unwrap();
1156 assert_eq!(idx.functions.len(), 1);
1157 assert_eq!(idx.functions[0].name, "hello".into());
1158 }
1159
1160 #[test]
1161 fn open_populates_file_index_with_symbols() {
1162 let store = DocumentStore::new();
1163 open(&store, uri("/a.php"), "<?php\nclass Foo {}".to_string());
1164 let idx = store.get_index_salsa(&uri("/a.php")).unwrap();
1165 assert_eq!(idx.classes.len(), 1);
1166 assert_eq!(idx.classes[0].name, "Foo".into());
1167 }
1168
1169 // ── Mirror invariants ────────────────────────────────────────────────
1170 //
1171 // Every mutation path that changes file text must keep the salsa layer
1172 // consistent. These tests walk a set-edit-reopen cycle and assert that
1173 // the salsa-derived `FileIndex` reflects the latest text at each step.
1174
1175 fn names_of(idx: &FileIndex) -> Vec<String> {
1176 let mut out: Vec<String> = idx.classes.iter().map(|c| c.name.to_string()).collect();
1177 out.extend(idx.functions.iter().map(|f| f.name.to_string()));
1178 out.sort();
1179 out
1180 }
1181
1182 fn salsa_index_names(store: &DocumentStore, url: &Url) -> Vec<String> {
1183 store
1184 .snapshot_query_file_index(url)
1185 .map(|idx| names_of(&idx))
1186 .unwrap_or_default()
1187 }
1188
1189 #[test]
1190 fn mirror_tracks_repeated_edits() {
1191 let store = DocumentStore::new();
1192 let u = uri("/mirror.php");
1193
1194 open(&store, u.clone(), "<?php\nclass A {}".to_string());
1195 assert_eq!(salsa_index_names(&store, &u), vec!["A".to_string()]);
1196
1197 open(
1198 &store,
1199 u.clone(),
1200 "<?php\nclass A {}\nclass B {}".to_string(),
1201 );
1202 assert_eq!(
1203 salsa_index_names(&store, &u),
1204 vec!["A".to_string(), "B".to_string()]
1205 );
1206
1207 open(&store, u.clone(), "<?php\nfunction greet() {}".to_string());
1208 assert_eq!(salsa_index_names(&store, &u), vec!["greet".to_string()]);
1209 }
1210
1211 #[test]
1212 fn mirror_tracks_ingest_and_ingest_from_doc() {
1213 let store = DocumentStore::new();
1214
1215 // Background `index(url, text)` path.
1216 let u1 = uri("/bg1.php");
1217 store.ingest(u1.clone(), "<?php\nclass Bg1 {}");
1218 assert_eq!(salsa_index_names(&store, &u1), vec!["Bg1".to_string()]);
1219
1220 // `ingest_from_doc(url, &doc)` path (workspace-scan Phase 2).
1221 let u2 = uri("/bg2.php");
1222 let doc = crate::analysis::diagnostics::parse_document_no_diags(
1223 "<?php\nclass Bg2 {}\nfunction f() {}",
1224 );
1225 store.ingest_from_doc(u2.clone(), &doc);
1226 assert_eq!(
1227 salsa_index_names(&store, &u2),
1228 vec!["Bg2".to_string(), "f".to_string()]
1229 );
1230 }
1231
1232 /// G3: confirms the `parsed_cache` actually hits — two consecutive
1233 /// `get_doc_salsa` calls on unchanged text return the same `Arc`
1234 /// (pointer equality), and an edit forces a miss that produces a
1235 /// different `Arc`.
1236 /// parsed_cache must stay bounded — inserting more than
1237 /// `PARSED_CACHE_CAP` unique URLs must not cause unbounded growth.
1238 /// Eviction is probabilistic, so we only assert the bound, not which
1239 /// Seeding a cached index for a URL that was never mirrored is a no-op
1240 /// (returns `false`) — avoids silently allocating SourceFiles outside
1241 /// `mirror_text`'s control.
1242 #[test]
1243 fn seed_cached_index_noops_for_unknown_uri() {
1244 let store = DocumentStore::new();
1245 let u = uri("/never_mirrored.php");
1246 let index = Arc::new(crate::index::file_index::FileIndex::default());
1247 assert!(!store.seed_cached_index(&u, index));
1248 }
1249
1250 /// entries survive.
1251 #[test]
1252 fn parsed_cache_stays_bounded_under_many_inserts() {
1253 let store = DocumentStore::new();
1254 use crate::document::cache_registry::PARSED_CACHE_CAP;
1255 let overflow = PARSED_CACHE_CAP + 100;
1256 for i in 0..overflow {
1257 let u = uri(&format!("/cap/file{i}.php"));
1258 store.ingest(u.clone(), "<?php\nclass A {}");
1259 // Force a parsed_cache insert via get_doc_salsa.
1260 let _ = store.get_doc_salsa(&u);
1261 }
1262 assert!(
1263 store.caches.parsed_cache.len() <= PARSED_CACHE_CAP,
1264 "parsed_cache grew to {} entries (cap {})",
1265 store.caches.parsed_cache.len(),
1266 PARSED_CACHE_CAP
1267 );
1268 }
1269
1270 #[test]
1271 fn get_doc_salsa_cache_hits_across_calls() {
1272 let store = DocumentStore::new();
1273 let u = uri("/g3_cache.php");
1274 open(&store, u.clone(), "<?php\nclass G3 {}".to_string());
1275
1276 let a = store.get_doc_salsa(&u).unwrap();
1277 let b = store.get_doc_salsa(&u).unwrap();
1278 assert!(
1279 Arc::ptr_eq(&a, &b),
1280 "parsed_cache hit should yield the same Arc across calls"
1281 );
1282
1283 open(&store, u.clone(), "<?php\nclass G3b {}".to_string());
1284 let c = store.get_doc_salsa(&u).unwrap();
1285 assert!(
1286 !Arc::ptr_eq(&a, &c),
1287 "edit should invalidate the parsed_cache entry"
1288 );
1289 }
1290
1291 #[test]
1292 fn get_doc_salsa_returns_some_for_mirrored_files() {
1293 // Phase E4: `get_doc_salsa` no longer gates on open-state. The
1294 // open/closed distinction now lives on `Backend::get_doc`.
1295 let store = DocumentStore::new();
1296 let u = uri("/e4_doc.php");
1297 store.ingest(u.clone(), "<?php\nclass P {}");
1298 assert!(store.get_doc_salsa(&u).is_some());
1299 }
1300
1301 #[test]
1302 fn get_salsa_accessors_return_none_for_unknown_uri() {
1303 let store = DocumentStore::new();
1304 let u = uri("/never-seen.php");
1305 assert!(store.get_doc_salsa(&u).is_none());
1306 assert!(store.get_index_salsa(&u).is_none());
1307 }
1308
1309 /// Phase E1: concurrent readers and writers must not deadlock, panic, or
1310 /// return stale data. Writers briefly bump inputs while readers are
1311 /// running on cloned snapshots; any `salsa::Cancelled` raised on the
1312 /// reader side must be caught and retried by `snapshot_query`.
1313 ///
1314 /// The salsa surface (`get_doc_salsa`, `get_index_salsa`) is protected by
1315 /// `snapshot_query`'s last-resort host-lock fallback.
1316 #[test]
1317 fn concurrent_reads_and_writes_do_not_panic() {
1318 use std::sync::Arc;
1319 use std::thread;
1320 use std::time::{Duration, Instant};
1321
1322 let store = Arc::new(DocumentStore::new());
1323 let urls: Vec<Url> = (0..8).map(|i| uri(&format!("/f{i}.php"))).collect();
1324 for (i, u) in urls.iter().enumerate() {
1325 open(&store, u.clone(), format!("<?php\nclass C{i} {{}}"));
1326 }
1327
1328 let deadline = Instant::now() + Duration::from_millis(400);
1329 let mut handles = Vec::new();
1330
1331 // Writer thread: keep bumping every file's text.
1332 {
1333 let store = Arc::clone(&store);
1334 let urls = urls.clone();
1335 handles.push(thread::spawn(move || {
1336 let mut rev = 0u32;
1337 while Instant::now() < deadline {
1338 for u in &urls {
1339 let text = format!("<?php\nclass C{{}}\n// rev {rev}");
1340 store.mirror_text(u, &text);
1341 }
1342 rev += 1;
1343 }
1344 }));
1345 }
1346
1347 // Reader threads: hammer the salsa accessors.
1348 for _ in 0..4 {
1349 let store = Arc::clone(&store);
1350 let urls = urls.clone();
1351 handles.push(thread::spawn(move || {
1352 while Instant::now() < deadline {
1353 for u in &urls {
1354 let _ = store.get_doc_salsa(u);
1355 let _ = store.get_index_salsa(u);
1356 }
1357 // Post mir 0.22: codebase + refs live in the session,
1358 // not salsa. Concurrent-read smoke is limited to the
1359 // remaining salsa surface (parsed_doc, file_index).
1360 }
1361 }));
1362 }
1363
1364 for h in handles {
1365 h.join().expect("no panic under concurrent read/write");
1366 }
1367 }
1368
1369 /// PSR-4 lazy-loading: `get_semantic_issues_salsa` must not emit
1370 /// `UndefinedClass` for a class that is PSR-4-resolvable on disk, even
1371 /// when the dependency file is not yet in `source_files`.
1372 #[test]
1373 fn psr4_lazy_load_suppresses_undefined_class() {
1374 let tmp = tempfile::tempdir().unwrap();
1375
1376 // Write Entity.php to disk (not mirrored into the store).
1377 std::fs::create_dir_all(tmp.path().join("src/Model")).unwrap();
1378 std::fs::write(
1379 tmp.path().join("src/Model/Entity.php"),
1380 "<?php\nnamespace App\\Model;\nclass Entity {}\n",
1381 )
1382 .unwrap();
1383
1384 // Write composer.json so Psr4Map::load can build the map.
1385 std::fs::write(
1386 tmp.path().join("composer.json"),
1387 r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
1388 )
1389 .unwrap();
1390
1391 let store = DocumentStore::new();
1392
1393 // Inject a PSR-4 map pointing at the tmp dir.
1394 store
1395 .psr4
1396 .store(Arc::new(crate::lang::autoload::Psr4Map::load(tmp.path())));
1397
1398 // Mirror the consuming file (Entity not yet in source_files).
1399 // Uses Entity as a parameter type hint — the analyzer resolves these
1400 // through use statements, so this exercises the full PSR-4 lazy-load path.
1401 let handler_url = Url::from_file_path(tmp.path().join("src/Service/Handler.php")).unwrap();
1402 store.mirror_text(
1403 &handler_url,
1404 "<?php\nnamespace App\\Service;\nuse App\\Model\\Entity;\nfunction handle(Entity $e): Entity { return $e; }\n",
1405 );
1406
1407 let issues = store.get_semantic_issues_salsa(&handler_url).unwrap();
1408 let undef: Vec<_> = issues
1409 .iter()
1410 .filter(|i| matches!(i.kind, mir_issues::IssueKind::UndefinedClass { .. }))
1411 .collect();
1412 assert!(
1413 undef.is_empty(),
1414 "PSR-4 lazy-loading must prevent UndefinedClass for App\\Model\\Entity; got: {undef:?}"
1415 );
1416 }
1417
1418 /// Issue #191 regression: workspace-wide scans (find-references, rename,
1419 /// call-hierarchy) must not re-parse closed/indexed files on repeated
1420 /// invocations. Once a file's `ParsedDoc` has been produced, subsequent
1421 /// `all_docs_for_scan()` calls must hit the cache and return the same
1422 /// `Arc<ParsedDoc>` (pointer equality), proving no re-parse occurred.
1423 ///
1424 /// The cache layers protecting this are:
1425 /// 1. `parsed_cache` (cap [`PARSED_CACHE_CAP`]) — read-through, validated
1426 /// via `Arc::ptr_eq` on the text Arc.
1427 /// 2. salsa `parsed_doc` memo (`lru = 2048`) — second line of defense
1428 /// when `parsed_cache` evicts.
1429 ///
1430 /// Together they keep every workspace-scan op O(N) memo lookups, never
1431 /// O(N) parses, for any workspace whose file count fits the cap.
1432 #[test]
1433 fn all_docs_for_scan_does_not_reparse_indexed_files() {
1434 let store = DocumentStore::new();
1435 const N: usize = 50;
1436 for i in 0..N {
1437 let u = uri(&format!("/scan/file{i}.php"));
1438 store.ingest(u, &format!("<?php\nclass C{i} {{}}\nfunction f{i}() {{}}"));
1439 }
1440
1441 let first: Vec<_> = store.all_docs_for_scan();
1442 let second: Vec<_> = store.all_docs_for_scan();
1443 assert_eq!(first.len(), N);
1444 assert_eq!(second.len(), N);
1445
1446 let by_url_first: std::collections::HashMap<Url, Arc<ParsedDoc>> =
1447 first.into_iter().collect();
1448 for (u, doc2) in second {
1449 let doc1 = by_url_first
1450 .get(&u)
1451 .expect("second scan returned a URL the first didn't");
1452 assert!(
1453 Arc::ptr_eq(doc1, &doc2),
1454 "{u} re-parsed across all_docs_for_scan calls — \
1455 cache (parsed_cache + salsa parsed_doc memo) failed to hit"
1456 );
1457 }
1458
1459 // Editing one file's text must invalidate just that file's entry,
1460 // not the rest. This locks in self-eviction via Arc::ptr_eq on text.
1461 let edited_url = uri("/scan/file0.php");
1462 let pre_edit = store.get_doc_salsa(&edited_url).unwrap();
1463 store.ingest(edited_url.clone(), "<?php\nclass C0Edited {}");
1464 let post_edit = store.get_doc_salsa(&edited_url).unwrap();
1465 assert!(
1466 !Arc::ptr_eq(&pre_edit, &post_edit),
1467 "edited file must produce a fresh ParsedDoc"
1468 );
1469 for i in 1..N {
1470 let u = uri(&format!("/scan/file{i}.php"));
1471 let original = by_url_first.get(&u).unwrap();
1472 let after = store.get_doc_salsa(&u).unwrap();
1473 assert!(
1474 Arc::ptr_eq(original, &after),
1475 "{u} should not have re-parsed because of an unrelated edit"
1476 );
1477 }
1478 }
1479
1480 /// Incremental analysis cache: a body-only edit to file A (no declaration
1481 /// changes) must not bump `decl_version`, so file B's cached analysis
1482 /// survives. A declaration edit MUST bump the version so B's entry goes
1483 /// stale.
1484 #[test]
1485 fn body_only_edit_does_not_invalidate_sibling_analysis_cache() {
1486 let store = DocumentStore::new();
1487 let ua = uri("/ic_a.php");
1488 let ub = uri("/ic_b.php");
1489
1490 // Analyze both files to establish their fingerprints.
1491 open(
1492 &store,
1493 ua.clone(),
1494 "<?php\nfunction a() { return 1; }".to_string(),
1495 );
1496 open(
1497 &store,
1498 ub.clone(),
1499 "<?php\nfunction b() { return 2; }".to_string(),
1500 );
1501 let _ = store.cached_analysis(&ua).unwrap();
1502 let analysis_b_first = store.cached_analysis(&ub).unwrap();
1503 let ver_after_warm = store.caches.decl_version();
1504
1505 // Body-only edit to A: same function name, different body → FileIndex unchanged.
1506 store.mirror_text(&ua, "<?php\nfunction a() { return 999; }");
1507 let _ = store.cached_analysis(&ua);
1508 let ver_after_body_edit = store.caches.decl_version();
1509 assert_eq!(
1510 ver_after_warm, ver_after_body_edit,
1511 "body-only edit must not bump decl_version"
1512 );
1513
1514 // B's cached entry should still be valid (ptr-eq source AND same version).
1515 let analysis_b_second = store.cached_analysis_if_fresh(&ub);
1516 assert!(
1517 analysis_b_second.is_some(),
1518 "B's analysis should hit cache after body-only edit to A"
1519 );
1520 assert!(
1521 Arc::ptr_eq(&analysis_b_first, &analysis_b_second.unwrap()),
1522 "B's analysis should be the identical Arc (no re-analysis)"
1523 );
1524
1525 // Declaration edit to A: rename the function → FileIndex changes.
1526 store.mirror_text(&ua, "<?php\nfunction a_renamed() { return 999; }");
1527 let _ = store.cached_analysis(&ua);
1528 let ver_after_decl_edit = store.caches.decl_version();
1529 assert!(
1530 ver_after_decl_edit > ver_after_body_edit,
1531 "declaration edit must bump decl_version (was {ver_after_body_edit}, now {ver_after_decl_edit})"
1532 );
1533
1534 // B's entry is now stale — cached_analysis_if_fresh must return None.
1535 let analysis_b_stale = store.cached_analysis_if_fresh(&ub);
1536 assert!(
1537 analysis_b_stale.is_none(),
1538 "B's analysis should be stale after A's declaration changed"
1539 );
1540 }
1541
1542 /// snapshot_query must complete without panic when a concurrent writer
1543 /// races the snapshot. The single-retry-then-lock logic should handle this
1544 /// correctly: the lock-held fallback guarantees progress.
1545 #[test]
1546 fn snapshot_query_survives_concurrent_writes() {
1547 use std::sync::Arc;
1548 use std::thread;
1549 use std::time::{Duration, Instant};
1550
1551 let store = Arc::new(DocumentStore::new());
1552 let u = uri("/sq_test.php");
1553 open(
1554 &store,
1555 u.clone(),
1556 "<?php\nfunction f(): int { return 1; }".to_string(),
1557 );
1558
1559 let deadline = Instant::now() + Duration::from_millis(200);
1560 let mut handles = Vec::new();
1561
1562 // Writer: keep bumping the file text to trigger salsa::Cancelled.
1563 {
1564 let store = Arc::clone(&store);
1565 let u = u.clone();
1566 handles.push(thread::spawn(move || {
1567 let mut rev = 0u32;
1568 while Instant::now() < deadline {
1569 store.mirror_text(&u, &format!("<?php\nfunction f(): int {{ return {rev}; }}"));
1570 rev += 1;
1571 }
1572 }));
1573 }
1574
1575 // Reader: hammer snapshot_query via get_doc_salsa.
1576 for _ in 0..4 {
1577 let store = Arc::clone(&store);
1578 let u = u.clone();
1579 handles.push(thread::spawn(move || {
1580 while Instant::now() < deadline {
1581 let _ = store.get_doc_salsa(&u);
1582 let _ = store.get_index_salsa(&u);
1583 }
1584 }));
1585 }
1586
1587 for h in handles {
1588 h.join()
1589 .expect("no panic in snapshot_query under concurrent writes");
1590 }
1591 }
1592
1593 /// When a sibling file's declaration changes (bumping decl_version), the
1594 /// owned_program_cache entry for the unchanged file B should be reused
1595 /// rather than deep-cloned again. We verify this via Arc pointer equality.
1596 #[test]
1597 fn owned_program_cache_reused_after_sibling_declaration_change() {
1598 let store = DocumentStore::new();
1599 let ua = uri("/prog_a.php");
1600 let ub = uri("/prog_b.php");
1601
1602 open(
1603 &store,
1604 ua.clone(),
1605 "<?php\nfunction alpha(): void {}".to_string(),
1606 );
1607 open(
1608 &store,
1609 ub.clone(),
1610 "<?php\nfunction beta(): void {}".to_string(),
1611 );
1612
1613 // Warm both analysis caches and populate owned_program_cache for both files.
1614 let _ = store.cached_analysis(&ua);
1615 let _ = store.cached_analysis(&ub);
1616
1617 // Capture B's owned_program Arc before the sibling edit.
1618 let prog_b_first = store
1619 .caches
1620 .owned_program_cache
1621 .get(&ub)
1622 .map(|e| Arc::clone(&e.1))
1623 .expect("B's owned_program should be cached after first analysis");
1624
1625 // Declaration change to A: bumps decl_version, invalidating all cached_analysis entries.
1626 store.mirror_text(&ua, "<?php\nfunction alpha_renamed(): void {}");
1627 // Re-analyze A to trigger the decl_version bump.
1628 let _ = store.cached_analysis(&ua);
1629
1630 // Now re-analyze B. Its source is unchanged, so owned_program_cache must hit.
1631 let _ = store.cached_analysis(&ub);
1632
1633 let prog_b_second = store
1634 .caches
1635 .owned_program_cache
1636 .get(&ub)
1637 .map(|e| Arc::clone(&e.1))
1638 .expect("B's owned_program should still be cached after sibling edit");
1639
1640 assert!(
1641 Arc::ptr_eq(&prog_b_first, &prog_b_second),
1642 "B's owned_program Arc should be identical (cache hit) after sibling declaration change"
1643 );
1644 }
1645}