Skip to main content

php_lsp/document/
document_store.rs

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