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 `source_files` entry so the
675        // salsa `SourceFile` handle remains alive. Re-opening the file reuses
676        // the same handle instead of calling `SourceFile::new()` again, which
677        // would create a new orphaned salsa input on every delete-reopen cycle.
678        self.deleted_uris.insert(uri.clone());
679        self.workspace_files_dirty.store(true, Ordering::Release);
680        // Sync workspace files so the deleted file is removed from the salsa
681        // `Workspace::files` list and won't appear in workspace symbols etc.
682        self.sync_workspace_files();
683        // Also evict the file from the `AnalysisSession`'s internal state so
684        // workspace symbol queries don't keep returning the deleted file's
685        // declarations. Cheap when the session hasn't ingested this file.
686        let guard = self.analysis_session.lock().unwrap();
687        if let Some((_, session)) = guard.as_ref() {
688            session.invalidate_file(uri.as_str());
689        }
690        self.write_revision.fetch_add(1, Ordering::Release);
691    }
692
693    // ── Salsa-backed accessors ─────────────────────────────────────────────
694    //
695    // Reads run the memoized `parsed_doc` / `file_index` queries, parsing
696    // only on first access per revision. These are the production accessors
697    // used by every handler.
698
699    /// Salsa-backed parsed document.
700    ///
701    /// Salsa-backed parsed document for any mirrored file (open or
702    /// background-indexed). Returns `None` only when the file is not known
703    /// to the store. Callers that want "only if open" should gate on
704    /// `Backend::open_files` at the call site (see `Backend::get_doc`).
705    pub fn get_doc_salsa(&self, uri: &Url) -> Option<Arc<ParsedDoc>> {
706        self.get_parsed_cached(uri)
707    }
708
709    /// Salsa-backed compact symbol index.
710    pub fn get_index_salsa(&self, uri: &Url) -> Option<Arc<FileIndex>> {
711        let wf = self.lsp_ws_file(uri)?;
712        Some(
713            self.snapshot_mir_query(move |db| crate::db::mir_queries::file_index(db, wf).0.clone()),
714        )
715    }
716
717    /// Salsa-backed pre-computed symbol map (name → Vec<SymbolEntry>).
718    /// Memoized per revision: stable files serve from cache in O(1).
719    pub fn get_symbol_map_salsa(
720        &self,
721        uri: &Url,
722    ) -> Option<Arc<crate::types::symbol_map::SymbolMap>> {
723        // Symbol map runs on the shared mir db, sharing its memoized `parsed_doc`.
724        let wf = self.lsp_ws_file(uri)?;
725        Some(self.snapshot_mir_query(move |db| {
726            let sf = *wf.source(db);
727            crate::db::mir_queries::symbol_map(db, sf).0.clone()
728        }))
729    }
730
731    /// Pre-computed symbol maps for every entry in `open_urls` except `uri`.
732    pub fn other_symbol_maps(
733        &self,
734        uri: &Url,
735        open_urls: &[Url],
736    ) -> Vec<(Url, Arc<crate::types::symbol_map::SymbolMap>)> {
737        open_urls
738            .iter()
739            .filter(|u| *u != uri)
740            .filter_map(|u| self.get_symbol_map_salsa(u).map(|m| (u.clone(), m)))
741            .collect()
742    }
743
744    /// G3: shared implementation for `get_doc_salsa`.
745    /// Tries the `parsed_cache` (lock-free) first; validates via
746    /// `Arc::ptr_eq` against the G2 `text_cache` so a concurrent writer
747    /// that has already committed a new text input cannot be masked by a
748    /// stale cache entry. On miss, captures the text Arc and ParsedDoc
749    /// together inside a single `snapshot_query`, then publishes both.
750    fn get_parsed_cached(&self, uri: &Url) -> Option<Arc<ParsedDoc>> {
751        if let Some(current_text) = self.caches.text_cache.get(uri)
752            && let Some(entry) = self.caches.parsed_cache.get(uri)
753            && Arc::ptr_eq(&*current_text, &entry.0)
754        {
755            let doc = entry.1.clone();
756            drop(entry);
757            self.caches.touch(uri);
758            return Some(doc);
759        }
760
761        // Parse runs on the shared mir db.
762        let wf = self.lsp_ws_file(uri)?;
763        self.caches.bump_parse_count();
764        let (text, doc) = self.snapshot_mir_query(move |db| {
765            let sf = *wf.source(db);
766            let text = sf.text(db).clone();
767            let doc = crate::db::mir_queries::parsed_doc(db, sf).0.clone();
768            (text, doc)
769        });
770        self.caches.insert_parsed(uri.clone(), text, doc.clone());
771        Some(doc)
772    }
773
774    /// Parsed doc for a cosmetic, cursor-triggered read (e.g. `documentHighlight`)
775    /// that must stay responsive while the user types. Unlike [`Self::get_doc_salsa`]
776    /// it never spins on the `Cancelled` retry loop: it tries the lock-free
777    /// `parsed_cache`, then a bounded snapshot parse, and if a concurrent write
778    /// stream keeps cancelling it, returns the last-good cached `ParsedDoc`
779    /// (possibly one edit stale — invisible for a highlight, unlike a hang).
780    /// `None` only when the file has never been parsed.
781    pub fn get_doc_snapshot_or_stale(&self, uri: &Url) -> Option<Arc<ParsedDoc>> {
782        if let Some(current_text) = self.caches.text_cache.get(uri)
783            && let Some(entry) = self.caches.parsed_cache.get(uri)
784            && Arc::ptr_eq(&*current_text, &entry.0)
785        {
786            let doc = entry.1.clone();
787            drop(entry);
788            self.caches.touch(uri);
789            return Some(doc);
790        }
791
792        if let Some(wf) = self.lsp_ws_file(uri)
793            && let Some((text, doc)) = self.try_snapshot_mir_query(3, move |db| {
794                let sf = *wf.source(db);
795                let text = sf.text(db).clone();
796                let doc = crate::db::mir_queries::parsed_doc(db, sf).0.clone();
797                (text, doc)
798            })
799        {
800            self.caches.bump_parse_count();
801            self.caches.insert_parsed(uri.clone(), text, doc.clone());
802            return Some(doc);
803        }
804
805        self.caches.parsed_cache.get(uri).map(|e| e.1.clone())
806    }
807
808    /// Refresh `workspace.files` to mirror the current active file set.
809    ///
810    /// Skips all work when `workspace_files_dirty` is `false` (the common
811    /// case after the workspace scan completes — file-set changes are rare).
812    pub fn sync_workspace_files(&self) {
813        // Atomically clear the flag.  If it was already false the file set
814        // hasn't changed since the last sync; nothing to do.
815        if !self.workspace_files_dirty.swap(false, Ordering::AcqRel) {
816            return;
817        }
818
819        // Collect active (non-deleted) files, sorted by URI for stable ordering.
820        let mut entries: Vec<(Arc<str>, LspWsFile)> = self
821            .lsp_ws_files
822            .iter()
823            .filter(|e| !self.deleted_uris.contains(e.key()))
824            .map(|e| (Arc::<str>::from(e.key().as_str()), *e.value()))
825            .collect();
826        entries.sort_unstable_by(|(a, _), (b, _)| a.cmp(b));
827        let files: Arc<[LspWsFile]> = entries.iter().map(|(_, wf)| *wf).collect();
828
829        let session = self.analysis_session(self.workspace_php_version());
830        let mut guard = self.lsp_workspace.lock().unwrap();
831        session.with_db_mut(|db| match *guard {
832            Some(ws) => {
833                ws.set_files(db).to(files);
834            }
835            None => *guard = Some(LspWorkspace::new(db, files)),
836        });
837    }
838
839    /// Mark the workspace file set as dirty so the next `sync_workspace_files`
840    /// call re-runs the collect/sort/compare path.  Exposed for benchmarks that
841    /// need to measure the dirty-path cost in isolation.
842    pub fn mark_workspace_files_dirty(&self) {
843        self.workspace_files_dirty.store(true, Ordering::Release);
844    }
845
846    /// Update the PHP version tracked by the workspace. Salsa will invalidate
847    /// all `semantic_issues` queries so diagnostics are re-evaluated.
848    /// Skips the setter when the version hasn't changed to avoid spurious
849    /// query invalidation.
850    pub fn set_php_version(&self, version: mir_analyzer::PhpVersion) {
851        {
852            let mut guard = self.php_version.lock().unwrap();
853            if *guard == version {
854                return;
855            }
856            *guard = version;
857        }
858        // Changing the version selects a different `AnalysisSession` (and thus a
859        // different db). Drop the inputs created on the old session's db; the
860        // workspace scan re-mirrors files onto the new session. In practice the
861        // version is set once at init, before any file is mirrored.
862        self.lsp_ws_files.clear();
863        *self.lsp_workspace.lock().unwrap() = None;
864        self.workspace_files_dirty.store(true, Ordering::Release);
865        // Stale FileAnalysis from the old version would survive unchanged files.
866        self.caches.evict_analysis_all();
867    }
868
869    /// Inverted-index reference lookup scoped to `files` (the text-pre-filtered
870    /// candidate set): a posting-list read for committed-fresh files plus an
871    /// on-demand analyze+commit for stale/uncommitted candidates, so warm
872    /// requests are O(results) instead of O(candidates).
873    ///
874    /// Returns LSP-style 0-based line/column.
875    ///
876    /// `cancel_rev`: when `Some(rev)`, the loop throws `salsa::Cancelled` and
877    /// returns empty if a concurrent write advances the `write_revision` counter
878    /// past `rev` — preventing unbounded retries against a newly-invalidated db.
879    /// Pass `None` to retain the original indefinite-retry behaviour (fast ops
880    /// like single-file reads where a stale result is not a concern).
881    pub fn indexed_references(
882        &self,
883        symbol: &mir_analyzer::Name,
884        files: &[Arc<str>],
885        include_declaration: bool,
886        cancel_rev: Option<u64>,
887    ) -> Vec<(Arc<str>, u32, u32, u32)> {
888        let php_version = self.workspace_php_version();
889        // Staleness probe threaded into mir: polled at phase boundaries and
890        // between cancellation retries, so a request invalidated by a
891        // concurrent edit aborts *inside* mir's retry loop instead of spinning
892        // there indefinitely (mir catches `Cancelled` internally, so an outer
893        // catch alone never fires for the parallel phase).
894        let stale =
895            || cancel_rev.is_some_and(|rev| self.write_revision.load(Ordering::Acquire) != rev);
896        // Retry: concurrent db writes (background indexing) cancel snapshot
897        // queries via resume_unwind; without the loop the panic propagates out
898        // of the caller's spawn_blocking and the request silently returns empty.
899        let raw = loop {
900            let session = self.analysis_session(php_version);
901            match salsa::Cancelled::catch(std::panic::AssertUnwindSafe(|| {
902                session.indexed_references_to(symbol, files, include_declaration, &stale)
903            })) {
904                Ok(Some(refs)) => break refs,
905                // mir aborted via the staleness probe — or a Phase-1 unwind
906                // hit an already-stale request. Propagate as salsa::Cancelled
907                // so spawn_blocking callers see a JoinError::Panicked and
908                // return empty via unwrap_or_default.
909                Ok(None) => {
910                    std::panic::resume_unwind(Box::new(salsa::Cancelled::PendingWrite));
911                }
912                Err(_) if stale() => {
913                    std::panic::resume_unwind(Box::new(salsa::Cancelled::PendingWrite));
914                }
915                // Phase-1 unwind from a write that doesn't invalidate this
916                // request (mir-internal load_class, scan writes with no
917                // cancel_rev) — retry.
918                Err(_) => {}
919            }
920        };
921        raw.into_iter()
922            .map(|(file, range)| {
923                // mir uses 1-based lines; 0-based columns (since mir 0.42.0).
924                let line = range.start.line.saturating_sub(1);
925                let col_start = range.start.column;
926                let col_end = range.end.column;
927                (file, line, col_start, col_end)
928            })
929            .collect()
930    }
931
932    /// `use`-import lines referencing `symbol`, from mir's `use:` postings.
933    /// Read-only lookup with no freshness pass — run [`Self::indexed_references`]
934    /// over the same `files` first so uncommitted candidates are analyzed.
935    /// Returns `(file_uri, line, col_start, col_end)` tuples like
936    /// [`Self::indexed_references`]; the range covers the whole import item.
937    pub fn indexed_use_imports(
938        &self,
939        symbol: &mir_analyzer::Name,
940        files: &[Arc<str>],
941    ) -> Vec<(Arc<str>, u32, u32, u32)> {
942        let session = self.analysis_session(self.workspace_php_version());
943        let _interactive = self.interactive_read_guard();
944        let raw = loop {
945            if let Ok(locs) = salsa::Cancelled::catch(std::panic::AssertUnwindSafe(|| {
946                session.indexed_use_import_locations(symbol, files)
947            })) {
948                break locs;
949            }
950        };
951        raw.into_iter()
952            .map(|(file, range)| {
953                // mir uses 1-based lines; 0-based columns.
954                let line = range.start.line.saturating_sub(1);
955                (file, line, range.start.column, range.end.column)
956            })
957            .collect()
958    }
959
960    /// Replay disk-cached reference postings and subtype edges for the whole
961    /// mirrored workspace (mir's `warm_start_files`) — a no-op per file
962    /// without a content-hash-matching cache entry from a previous run. Call
963    /// once after the scan mirrors texts, before the analysis warm sweep, so
964    /// a returning session starts index-warm.
965    pub fn warm_start_indexes(&self) {
966        let files: Vec<(Arc<str>, Arc<str>)> = self
967            .lsp_ws_files
968            .iter()
969            .filter(|e| !self.deleted_uris.contains(e.key()))
970            .filter_map(|e| {
971                let text = self.caches.text_cache.get(e.key())?;
972                Some((Arc::<str>::from(e.key().as_str()), Arc::clone(&text)))
973            })
974            .collect();
975        if files.is_empty() {
976            return;
977        }
978        let session = self.analysis_session(self.workspace_php_version());
979        session.warm_start_files(&files);
980    }
981
982    /// Candidate file scope for a posting lookup on `symbol` (whose cursor /
983    /// declaration word is `word`). Private/protected methods narrow to their
984    /// visibility scope. After the analysis warm sweep has committed every
985    /// file's postings the freshness pass is a cheap per-file check, so the
986    /// whole workspace is passed directly; before that, the text pre-filter
987    /// bounds the on-demand analysis a cold query would otherwise pay across
988    /// the full workspace.
989    pub(crate) fn reference_candidate_files(
990        &self,
991        symbol: &mir_analyzer::Name,
992        word: &str,
993    ) -> Vec<Arc<str>> {
994        if let mir_analyzer::Name::Method { class, name } = symbol
995            && let Some(urls) = self.method_reference_scope(class, name)
996        {
997            return urls.iter().map(|u| Arc::from(u.as_str())).collect();
998        }
999        if self.warm_sweeps_completed() > 0 {
1000            self.workspace_file_paths()
1001        } else {
1002            self.candidate_urls_for(word)
1003                .iter()
1004                .map(|u| Arc::from(u.as_str()))
1005                .collect()
1006        }
1007    }
1008
1009    /// Every active workspace file as a mir path (`Arc<str>` of the URI).
1010    /// The candidate scope handed to mir's subtype queries.
1011    pub(crate) fn workspace_file_paths(&self) -> Vec<Arc<str>> {
1012        self.lsp_ws_files
1013            .iter()
1014            .filter(|e| !self.deleted_uris.contains(e.key()))
1015            .map(|e| Arc::<str>::from(e.key().as_str()))
1016            .collect()
1017    }
1018
1019    /// Transitive subtypes of `class_fqn` from mir's maintained subtype edge
1020    /// index, with declaration sites. `include_trait_users` also counts
1021    /// `use Trait;` composition as a subtype edge (trait-usage lenses); leave
1022    /// it off for goto-implementation semantics (extends/implements only).
1023    pub fn indexed_subtype_classes(
1024        &self,
1025        class_fqn: &str,
1026        include_trait_users: bool,
1027    ) -> Vec<mir_analyzer::SubtypeClassSite> {
1028        let files = self.workspace_file_paths();
1029        let session = self.analysis_session(self.workspace_php_version());
1030        let _interactive = self.interactive_read_guard();
1031        loop {
1032            if let Ok(sites) = salsa::Cancelled::catch(std::panic::AssertUnwindSafe(|| {
1033                session.indexed_subtype_classes(class_fqn, &files, include_trait_users)
1034            })) {
1035                break sites;
1036            }
1037        }
1038    }
1039
1040    /// Concrete implementations of `class_fqn::method` across its subtypes:
1041    /// `(subtype fqcn, file, name range)` from mir's subtype edge index.
1042    pub fn indexed_method_implementations(
1043        &self,
1044        class_fqn: &str,
1045        method: &str,
1046    ) -> Vec<(Arc<str>, Arc<str>, mir_analyzer::Range)> {
1047        let files = self.workspace_file_paths();
1048        let session = self.analysis_session(self.workspace_php_version());
1049        let _interactive = self.interactive_read_guard();
1050        loop {
1051            if let Ok(sites) = salsa::Cancelled::catch(std::panic::AssertUnwindSafe(|| {
1052                session.indexed_method_implementations(class_fqn, method, &files)
1053            })) {
1054                break sites;
1055            }
1056        }
1057    }
1058
1059    /// The complete set of files a `private` method's references can occur in,
1060    /// or `None` when the search must stay workspace-wide.
1061    ///
1062    /// `owner_fqn` is the enclosing class at the cursor; narrowing applies only
1063    /// when that class *directly declares* `method` (so its `MethodDef`
1064    /// visibility is authoritative — inherited/call-site lookups fall through to
1065    /// `None`). A reference to `Class::method` is recorded only where
1066    /// `analyze_file` resolves an expression to that key, and `analyze_file` is
1067    /// per-source-file, so a `private` member — resolvable only inside the
1068    /// declaring class body, which lives in one file — is fully scoped to the
1069    /// declaring file at any indexing stage.
1070    ///
1071    /// A `protected` member is reachable from the declaring class and its
1072    /// transitive subclasses (`$this->m()`, `parent::m()`, `self::`/`static::`),
1073    /// all of which live in the declaring file or a subtype file — never outside
1074    /// the hierarchy in valid PHP. Its scope is therefore the declaring file plus
1075    /// the transitive subtype files. The complete subtype set is only known once
1076    /// the workspace reference index finishes, so this narrowing applies only
1077    /// when [`Self::is_index_ready`]; before then it falls back to `None` (full
1078    /// workspace scope) so references are never under-reported.
1079    ///
1080    /// Classes that compose traits or mixins are excluded — those inline
1081    /// external resolution context, so a reference could surface in another file.
1082    pub fn method_reference_scope(&self, owner_fqn: &str, method: &str) -> Option<Vec<Url>> {
1083        use crate::index::file_index::{ClassKind, Visibility};
1084
1085        let ws = self.get_workspace_index_salsa();
1086        let owner_fqn = owner_fqn.trim_start_matches('\\');
1087        let owner_short = crate::text::fqn_short_name(owner_fqn);
1088
1089        let (decl_uri, decl_class) = ws
1090            .classes_by_name
1091            .get(owner_short)?
1092            .iter()
1093            .filter_map(|&r| ws.at(r))
1094            .find(|(_, cls)| cls.fqn.trim_start_matches('\\') == owner_fqn)?;
1095
1096        // Trait/interface methods, and methods on classes that compose traits or
1097        // mixins, can be referenced from other files — keep the full scope.
1098        if !matches!(decl_class.kind, ClassKind::Class | ClassKind::Enum)
1099            || !decl_class.traits.is_empty()
1100            || !decl_class.mixins.is_empty()
1101        {
1102            return None;
1103        }
1104
1105        let vis = decl_class
1106            .methods
1107            .iter()
1108            .find(|m| m.name.eq_ignore_ascii_case(method))?
1109            .visibility;
1110
1111        match vis {
1112            Visibility::Private => Some(vec![decl_uri.clone()]),
1113            Visibility::Protected => {
1114                if !self.is_index_ready() {
1115                    return None;
1116                }
1117                // Subtype set comes from mir's resolved inheritance graph — it
1118                // matches subclasses by FQCN, so `extends \Ns\Base` and aliased
1119                // `use ... as` forms are all found. Falls back to full scope if
1120                // mir can't resolve the owner.
1121                let session = self.analysis_session(self.workspace_php_version());
1122                let mut files: std::collections::HashSet<Url> = session
1123                    .subtype_files(owner_fqn)
1124                    .into_iter()
1125                    .filter_map(|p| Url::parse(&p).ok())
1126                    .collect();
1127                files.insert(decl_uri.clone());
1128                Some(files.into_iter().collect())
1129            }
1130            Visibility::Public => None,
1131        }
1132    }
1133
1134    /// Phase J: salsa-memoized aggregate workspace index.
1135    ///
1136    /// Returns the shared `Arc<WorkspaceIndexData>` with flat
1137    /// `(Url, Arc<FileIndex>)` list plus pre-built `classes_by_name` and
1138    /// `subtypes_of` reverse maps. Used by workspace_symbols,
1139    /// prepare_type_hierarchy, supertypes_of, subtypes_of, and
1140    /// find_implementations so they don't each rebuild the aggregate per
1141    /// request. Invalidates automatically when any file's `file_index`
1142    /// changes.
1143    pub fn get_workspace_index_salsa(&self) -> Arc<crate::db::workspace_index::WorkspaceIndexData> {
1144        self.sync_workspace_files();
1145        let ws = *self.lsp_workspace.lock().unwrap();
1146        let Some(ws) = ws else {
1147            return Arc::new(crate::db::workspace_index::WorkspaceIndexData {
1148                files: Vec::new(),
1149                classes_by_name: std::collections::HashMap::new(),
1150                subtypes_of: std::collections::HashMap::new(),
1151                decls_by_name: std::collections::HashMap::new(),
1152                classes_by_lowercase_name: Vec::new(),
1153            });
1154        };
1155        self.snapshot_mir_query(move |db| crate::db::mir_queries::workspace_index(db, ws).0.clone())
1156    }
1157
1158    /// Total number of real `ParsedDoc` parses served so far (cache misses).
1159    /// Surfaced via `$/php-lsp/debugStats` so tests can assert the references
1160    /// read path doesn't parse the whole workspace.
1161    pub fn parse_count(&self) -> u64 {
1162        self.caches.parse_count()
1163    }
1164
1165    /// Times mir's `RefIndex` was locked on the current session. Reads are
1166    /// per-key posting lookups and edits commit one file's postings, so this
1167    /// grows by a small bounded amount per operation — never per candidate.
1168    /// Surfaced via `$/php-lsp/debugStats` for the stress-test guard.
1169    pub fn ref_index_lock_count(&self) -> u64 {
1170        self.analysis_session(self.workspace_php_version())
1171            .ref_index_lock_count()
1172    }
1173
1174    /// Return the raw source text for `uri` if it has been mirrored into the
1175    /// salsa workspace. Used by the references handler to pre-filter session
1176    /// results by checking whether a file mentions the owning class name.
1177    pub fn source_text(&self, uri: &Url) -> Option<Arc<str>> {
1178        self.caches.text_cache.get(uri).map(|e| Arc::clone(&e))
1179    }
1180
1181    /// Cache the semantic tokens computed for a delta response.
1182    /// `result_id` is an opaque string (a hash of the token data) returned to the client.
1183    pub fn store_token_cache(&self, uri: &Url, result_id: String, tokens: Arc<Vec<SemanticToken>>) {
1184        self.caches.store_token(uri, result_id, tokens);
1185    }
1186
1187    /// Return the cached tokens if `result_id` matches the stored one.
1188    pub fn get_token_cache(&self, uri: &Url, result_id: &str) -> Option<Arc<Vec<SemanticToken>>> {
1189        self.caches.get_token(uri, result_id)
1190    }
1191
1192    /// Raw semantic issues for a file, computed via mir's session-based
1193    /// `FileAnalyzer`. The session lazy-loads dependencies via PSR-4 so the
1194    /// LSP no longer needs to mirror vendor up-front. Callers apply their
1195    /// own `DiagnosticsConfig` filter via
1196    /// [`crate::semantic_diagnostics::issues_to_diagnostics`].
1197    #[tracing::instrument(skip_all)]
1198    pub fn get_semantic_issues_salsa(&self, uri: &Url) -> Option<Arc<[mir_issues::Issue]>> {
1199        let analysis = self.cached_analysis(uri)?;
1200        let file: Arc<str> = Arc::from(uri.as_str());
1201        // Workspace-level class issues for this file (circular inheritance,
1202        // override violations, abstract-method gaps). These are session-wide
1203        // (a dependency edit changes them without changing this file's bytes),
1204        // so they are recomputed live rather than cached alongside the
1205        // per-file body analysis.
1206        let class_issues = {
1207            let _s = tracing::debug_span!("session.class_issues_for").entered();
1208            // Retry: concurrent db writes cancel snapshot queries via resume_unwind.
1209            loop {
1210                let session = self.analysis_session(self.workspace_php_version());
1211                if let Ok(issues) = salsa::Cancelled::catch(std::panic::AssertUnwindSafe(|| {
1212                    session.class_issues(std::slice::from_ref(&file))
1213                })) {
1214                    break issues;
1215                }
1216            }
1217        };
1218        let combined: Vec<mir_issues::Issue> = analysis
1219            .issues
1220            .iter()
1221            .cloned()
1222            .chain(class_issues)
1223            .filter(|i| !i.suppressed)
1224            .collect();
1225        Some(Arc::from(combined))
1226    }
1227
1228    /// Run (or reuse) mir's per-file body analysis, retaining the full
1229    /// [`mir_analyzer::FileAnalysis`] — issues **and** resolved symbols — across
1230    /// requests. Diagnostics read `.issues`; position features call
1231    /// `.symbol_at(offset)` for the resolved type at a cursor.
1232    ///
1233    /// Cache hit when the entry's captured source `Arc` is pointer-equal to the
1234    /// file's current `doc.source_arc()`. A miss recomputes and overwrites, so
1235    /// the entry self-evicts on any content edit.
1236    /// Build (or reuse) the whole-doc completion [`crate::types::type_map::TypeMap`]
1237    /// for `uri`. Cache hit when the entry's captured source `Arc` is
1238    /// pointer-equal to `doc.source_arc()` and the PHPStorm-meta pointer is
1239    /// unchanged (meta lives behind `ArcSwap`, so its address is stable until
1240    /// `.phpstorm.meta.php` is reloaded). A miss rebuilds and overwrites, so
1241    /// the entry self-evicts on any content edit.
1242    pub fn cached_type_map(
1243        &self,
1244        uri: &Url,
1245        doc: &crate::document::ast::ParsedDoc,
1246        meta: Option<&crate::lang::phpstorm_meta::PhpStormMeta>,
1247    ) -> Arc<crate::types::type_map::TypeMap> {
1248        let source = doc.source_arc();
1249        let meta_key = meta.map_or(0usize, |m| std::ptr::from_ref(m) as usize);
1250        if let Some(entry) = self.caches.type_map_cache.get(uri)
1251            && Arc::ptr_eq(&entry.0, &source)
1252            && entry.1 == meta_key
1253        {
1254            let map = Arc::clone(&entry.2);
1255            drop(entry);
1256            self.caches.touch(uri);
1257            return map;
1258        }
1259        let map = Arc::new(crate::types::type_map::TypeMap::from_doc_with_meta(
1260            doc, meta,
1261        ));
1262        self.caches.shed_stale(
1263            &self.caches.type_map_cache,
1264            crate::document::cache_registry::TYPE_MAP_CACHE_CAP,
1265        );
1266        self.caches.touch(uri);
1267        self.caches
1268            .type_map_cache
1269            .insert(uri.clone(), (source, meta_key, Arc::clone(&map)));
1270        map
1271    }
1272
1273    /// Cache-hit-only variant of [`Self::cached_analysis`]: returns the cached
1274    /// analysis when the entry is current for the file's text, never computes.
1275    /// Lets async handlers take the warm path synchronously and reserve
1276    /// `spawn_blocking` for the cold path (mir Pass 1 + Pass 2 can take
1277    /// hundreds of ms on large files).
1278    pub fn cached_analysis_if_fresh(&self, uri: &Url) -> Option<Arc<mir_analyzer::FileAnalysis>> {
1279        let doc = self.get_doc_salsa(uri)?;
1280        let source = doc.source_arc();
1281        let cur_ver = self.caches.decl_version();
1282        let analysis = {
1283            let entry = self.caches.analysis_cache.get(uri)?;
1284            (Arc::ptr_eq(&entry.0, &source) && entry.1 == cur_ver).then(|| Arc::clone(&entry.2))?
1285        };
1286        self.caches.touch(uri);
1287        Some(analysis)
1288    }
1289
1290    pub fn cached_analysis(&self, uri: &Url) -> Option<Arc<mir_analyzer::FileAnalysis>> {
1291        self.cached_analysis_cancellable(uri, &|| false)
1292    }
1293
1294    /// [`Self::cached_analysis`] with an early exit: `should_cancel` is
1295    /// polled whenever a concurrent salsa write cancels the analysis attempt.
1296    /// A handler passes its write-revision staleness probe so a request made
1297    /// obsolete by newer typing stops burning a blocking-pool thread instead
1298    /// of retrying until the writer pauses — the editor re-requests anyway.
1299    #[tracing::instrument(skip_all)]
1300    pub fn cached_analysis_cancellable(
1301        &self,
1302        uri: &Url,
1303        should_cancel: &(dyn Fn() -> bool + Sync),
1304    ) -> Option<Arc<mir_analyzer::FileAnalysis>> {
1305        // Need the parsed doc both for the analyzer and as the cache key.
1306        let doc = self.get_doc_salsa(uri)?;
1307        let source = doc.source_arc();
1308
1309        let cur_ver = self.caches.decl_version();
1310        if let Some(entry) = self.caches.analysis_cache.get(uri)
1311            && Arc::ptr_eq(&entry.0, &source)
1312            && entry.1 == cur_ver
1313        {
1314            let analysis = Arc::clone(&entry.2);
1315            drop(entry);
1316            self.caches.touch(uri);
1317            return Some(analysis);
1318        }
1319
1320        let php_version = self.workspace_php_version();
1321        let session = self.analysis_session(php_version);
1322        let file: Arc<str> = Arc::from(uri.as_str());
1323
1324        let source_map = php_rs_parser::source_map::SourceMap::new(doc.source());
1325        let owned_program = if let Some(cached) = self.caches.owned_program_cache.get(uri)
1326            && Arc::ptr_eq(&cached.0, &source)
1327        {
1328            Arc::clone(&cached.1)
1329        } else {
1330            let prog = Arc::new(php_ast::owned::to_owned_program(doc.program()));
1331            self.caches.shed_stale(
1332                &self.caches.owned_program_cache,
1333                crate::document::cache_registry::OWNED_PROGRAM_CACHE_CAP,
1334            );
1335            self.caches
1336                .owned_program_cache
1337                .insert(uri.clone(), (Arc::clone(&source), Arc::clone(&prog)));
1338            prog
1339        };
1340
1341        // autoload.files helpers (e.g. Laravel's tap()) must be ingested so mir sees their functions.
1342        let autoload_texts: Vec<(Arc<str>, Arc<str>)> = {
1343            let autoload_uris = self.autoload_uris.read().unwrap().clone();
1344            autoload_uris
1345                .iter()
1346                .filter_map(|auri| {
1347                    self.caches
1348                        .text_cache
1349                        .get(auri)
1350                        .map(|t| (Arc::from(auri.as_str()), Arc::clone(&*t)))
1351                })
1352                .collect()
1353        };
1354        // Bare same-namespace / use-imported class refs aren't resolved by mir's priority_index_for_ast; preload them.
1355        let class_fqns = crate::references::collect_referenced_class_fqns(&doc);
1356
1357        // ingest_file/load_class/analyze take internal salsa snapshots; a concurrent db write cancels them via resume_unwind. Retry the idempotent sequence.
1358        let _interactive = self.interactive_read_guard();
1359        let analysis = loop {
1360            let attempt = salsa::Cancelled::catch(std::panic::AssertUnwindSafe(|| {
1361                session.ingest_file(file.clone(), source.clone());
1362                for (afile, atext) in &autoload_texts {
1363                    session.ingest_file(afile.clone(), atext.clone());
1364                }
1365                for fqcn in &class_fqns {
1366                    let _ = session.load_class(fqcn);
1367                }
1368                let analyzer = mir_analyzer::FileAnalyzer::new(&session);
1369                analyzer.analyze(file.clone(), doc.source(), &owned_program, &source_map)
1370            }));
1371            match attempt {
1372                Ok(a) => break Arc::new(a),
1373                Err(_) => {
1374                    // A write cancelled the attempt. If it replaced THIS
1375                    // file's text the result is already obsolete (the cache
1376                    // key is the source Arc) and the editor re-requests after
1377                    // its didChange — stop burning the blocking thread.
1378                    // Writes elsewhere just retry as before.
1379                    let text_changed = self
1380                        .caches
1381                        .text_cache
1382                        .get(uri)
1383                        .is_none_or(|t| !Arc::ptr_eq(&t, &source));
1384                    if text_changed || should_cancel() {
1385                        return None;
1386                    }
1387                }
1388            }
1389        };
1390        // Compare the new FileIndex against the stored fingerprint. If
1391        // declarations changed (or this is the first analysis), bump
1392        // `decl_version` so other files' cache entries become stale. Body-only
1393        // edits leave the counter unchanged, allowing sibling files to be
1394        // served from cache on the next request.
1395        let new_index = self.get_index_salsa(uri);
1396        let old_fp = self
1397            .caches
1398            .decl_fingerprints
1399            .get(uri)
1400            .map(|e| Arc::clone(&*e));
1401        let decl_changed = match (&old_fp, &new_index) {
1402            (Some(old), Some(new)) => **old != **new,
1403            // First analysis: only a file that actually declares something can
1404            // affect other files. Opening a plain script must not invalidate
1405            // every open file's analysis cache and mir's warm-up marks.
1406            (None, Some(new)) => !new.declares_nothing(),
1407            _ => false,
1408        };
1409        if decl_changed {
1410            if let Some(idx) = new_index {
1411                self.caches.decl_fingerprints.insert(uri.clone(), idx);
1412            }
1413            self.caches.bump_decl_version();
1414            // Text reaches mir via direct salsa `set_text` writes, so mir can't
1415            // see declaration deletions itself. A deleted declaration may
1416            // unshadow a lazy-loadable symbol — invalidate mir's warm-up skip
1417            // set so reference queries re-run their prepare pass once.
1418            session.bump_prepare_generation();
1419        }
1420        // Tag with the version observed before the analysis ran, plus this
1421        // file's own bump. Reading `decl_version()` here instead would absorb
1422        // a concurrent bump from another file's mid-compute declaration change
1423        // into the tag, marking a possibly-stale result as fresh. The
1424        // conservative tag errs toward one extra recompute, never staleness.
1425        let ver = cur_ver + u64::from(decl_changed);
1426        self.caches.shed_stale(
1427            &self.caches.analysis_cache,
1428            crate::document::cache_registry::ANALYSIS_CACHE_CAP,
1429        );
1430        self.caches.touch(uri);
1431        self.caches
1432            .analysis_cache
1433            .insert(uri.clone(), (source, ver, Arc::clone(&analysis)));
1434        Some(analysis)
1435    }
1436
1437    /// Returns `(uri, doc)` for files currently open in the editor.
1438    ///
1439    /// Resolve `open_urls` (from `Backend::open_urls()`) to parsed docs.
1440    /// Files not mirrored in the salsa layer are filtered out silently.
1441    pub fn docs_for(&self, open_urls: &[Url]) -> Vec<(Url, Arc<ParsedDoc>)> {
1442        open_urls
1443            .iter()
1444            .filter_map(|u| self.get_doc_salsa(u).map(|d| (u.clone(), d)))
1445            .collect()
1446    }
1447
1448    /// `(primary, doc)` first, then every other open file's parsed doc.
1449    /// The `open_urls` slice should include `uri` — this helper filters it out.
1450    pub fn doc_with_others(
1451        &self,
1452        uri: &Url,
1453        doc: Arc<ParsedDoc>,
1454        open_urls: &[Url],
1455    ) -> Vec<(Url, Arc<ParsedDoc>)> {
1456        let mut result = vec![(uri.clone(), doc)];
1457        result.extend(self.other_docs(uri, open_urls));
1458        result
1459    }
1460
1461    /// Parsed docs for every entry in `open_urls` except `uri`.
1462    pub fn other_docs(&self, uri: &Url, open_urls: &[Url]) -> Vec<(Url, Arc<ParsedDoc>)> {
1463        open_urls
1464            .iter()
1465            .filter(|u| *u != uri)
1466            .filter_map(|u| self.get_doc_salsa(u).map(|d| (u.clone(), d)))
1467            .collect()
1468    }
1469
1470    /// Compact symbol index for every mirrored file.
1471    pub fn all_indexes(&self) -> Vec<(Url, Arc<FileIndex>)> {
1472        self.get_workspace_index_salsa().files.clone()
1473    }
1474
1475    /// Store a lazily-loaded vendor `FileIndex` in the session cache.
1476    /// Only call this for files that are not part of the normal workspace scan
1477    /// (i.e. vendor files loaded on-demand by PSR-4 navigation).
1478    pub fn cache_vendor_index(&self, uri: Url, index: Arc<FileIndex>) {
1479        self.caches.vendor_index_cache.insert(uri, index);
1480    }
1481
1482    /// Retrieve a previously cached vendor `FileIndex`.
1483    pub fn get_vendor_index(&self, uri: &Url) -> Option<Arc<FileIndex>> {
1484        self.caches
1485            .vendor_index_cache
1486            .get(uri)
1487            .map(|e| Arc::clone(&*e))
1488    }
1489
1490    /// Same as `all_indexes` but excludes `uri`.
1491    pub fn other_indexes(&self, uri: &Url) -> Vec<(Url, Arc<FileIndex>)> {
1492        self.get_workspace_index_salsa()
1493            .files
1494            .iter()
1495            .filter(|(u, _)| u != uri)
1496            .cloned()
1497            .collect()
1498    }
1499
1500    /// Parsed documents for every mirrored file (open or background-indexed).
1501    /// Suitable for full-scan operations: find-references, rename,
1502    /// call_hierarchy, code_lens.
1503    pub fn all_docs_for_scan(&self) -> Vec<(Url, Arc<ParsedDoc>)> {
1504        let urls: Vec<Url> = self
1505            .lsp_ws_files
1506            .iter()
1507            .filter(|e| !self.deleted_uris.contains(e.key()))
1508            .map(|e| e.key().clone())
1509            .collect();
1510        urls.into_iter()
1511            .filter_map(|u| self.get_doc_salsa(&u).map(|d| (u, d)))
1512            .collect()
1513    }
1514
1515    /// URLs of workspace files whose raw source text mentions `word` as a whole
1516    /// identifier — **no parsing**. Uses a precompiled `memmem` finder (the same
1517    /// needle is scanned across the whole workspace) plus a word-boundary check,
1518    /// so `save` does not match inside `saveAll`/`unsaved`; the scan runs in
1519    /// parallel over the file set. The references read path narrows this list by
1520    /// symbol visibility and hands it to mir's per-file `analyze_file`, so a
1521    /// method lookup never materializes a `ParsedDoc` for the workspace.
1522    ///
1523    /// Files whose text is not yet in `text_cache` are included conservatively
1524    /// (safe superset — never produces false negatives).
1525    pub fn candidate_urls_for(&self, word: &str) -> Vec<Url> {
1526        use rayon::prelude::*;
1527        let urls: Vec<Url> = self
1528            .lsp_ws_files
1529            .iter()
1530            .filter(|e| !self.deleted_uris.contains(e.key()))
1531            .map(|e| e.key().clone())
1532            .collect();
1533        let finder = memchr::memmem::Finder::new(word.as_bytes());
1534        let wlen = word.len();
1535        urls.into_par_iter()
1536            .filter(|u| match self.caches.text_cache.get(u) {
1537                Some(src) => mentions_identifier(src.as_bytes(), &finder, wlen),
1538                None => true,
1539            })
1540            .collect()
1541    }
1542
1543    /// Parsed documents limited to files whose raw source text contains `word`.
1544    ///
1545    /// [`Self::candidate_urls_for`] + a salsa parse per survivor. Used by the
1546    /// AST-walker reference paths (constructor refs, non-method kinds) that
1547    /// genuinely need the parsed AST. The method reference path uses
1548    /// `candidate_urls_for` directly and avoids these parses entirely.
1549    ///
1550    /// Files whose text is not yet in `text_cache` are included conservatively
1551    /// (safe superset — never produces false negatives).
1552    pub fn candidate_docs_for(&self, word: &str) -> Vec<(Url, Arc<ParsedDoc>)> {
1553        self.candidate_urls_for(word)
1554            .into_iter()
1555            .filter_map(|u| self.get_doc_salsa(&u).map(|d| (u, d)))
1556            .collect()
1557    }
1558}
1559
1560/// Whether `hay` contains `finder`'s needle (length `wlen`) as a whole
1561/// identifier — i.e. not immediately preceded or followed by an ASCII
1562/// identifier byte (`[A-Za-z0-9_]`). Only ASCII bytes count as identifier
1563/// boundaries, so the check can over-accept near multibyte text but never
1564/// rejects a real whole-word match.
1565fn mentions_identifier(hay: &[u8], finder: &memchr::memmem::Finder, wlen: usize) -> bool {
1566    let is_ident = |b: u8| b.is_ascii_alphanumeric() || b == b'_';
1567    let mut from = 0;
1568    while let Some(rel) = finder.find(&hay[from..]) {
1569        let idx = from + rel;
1570        let before_ok = idx == 0 || !is_ident(hay[idx - 1]);
1571        let end = idx + wlen;
1572        let after_ok = end >= hay.len() || !is_ident(hay[end]);
1573        if before_ok && after_ok {
1574            return true;
1575        }
1576        from = idx + 1;
1577    }
1578    false
1579}
1580
1581#[cfg(test)]
1582mod tests {
1583    use super::*;
1584
1585    fn uri(path: &str) -> Url {
1586        Url::parse(&format!("file://{path}")).unwrap()
1587    }
1588
1589    #[test]
1590    fn mentions_identifier_respects_word_boundaries() {
1591        let m = |hay: &str, word: &str| {
1592            let f = memchr::memmem::Finder::new(word.as_bytes());
1593            mentions_identifier(hay.as_bytes(), &f, word.len())
1594        };
1595        assert!(m("$this->save();", "save"));
1596        assert!(m("function save() {}", "save"));
1597        assert!(m("save", "save"));
1598        // substrings inside a larger identifier must not match
1599        assert!(!m("$this->saveAll();", "save"));
1600        assert!(!m("return $unsaved;", "save"));
1601        assert!(!m("class Saver {}", "save"));
1602        assert!(!m("no occurrence here", "save"));
1603    }
1604
1605    /// Phase E4: open-file state lives on `Backend`, not `DocumentStore`.
1606    /// Tests that need to simulate "file is open" just mirror the text into
1607    /// the salsa input — the open/closed distinction is enforced by the
1608    /// caller (Backend) in production.
1609    fn open(store: &DocumentStore, u: Url, text: String) {
1610        store.mirror_text(&u, &text);
1611    }
1612
1613    // Removed `salsa_codebase_aggregates_all_files`: the salsa-side codebase
1614    // aggregation was deleted with the mir 0.22 migration. Equivalent
1615    // behaviour is now covered by mir-analyzer's own session tests.
1616
1617    // Spawn a thread that calls `yield_to_interactive_reads` and hands back
1618    // the elapsed time it spent waiting. It signals `ready` right before
1619    // entering the call, so the caller can block on that signal instead of
1620    // guessing how long thread start-up takes — a fixed sleep here would
1621    // race the thread scheduler, and that race is exactly what made this
1622    // test flaky under load (e.g. Windows CI runners have much coarser and
1623    // less predictable thread-spawn latency than Linux/macOS).
1624    fn spawn_yield_waiter(
1625        store: &Arc<DocumentStore>,
1626    ) -> (
1627        std::sync::mpsc::Receiver<()>,
1628        std::thread::JoinHandle<std::time::Duration>,
1629    ) {
1630        let (ready_tx, ready_rx) = std::sync::mpsc::channel();
1631        let store = Arc::clone(store);
1632        let handle = std::thread::spawn(move || {
1633            ready_tx.send(()).unwrap();
1634            let t = std::time::Instant::now();
1635            store.yield_to_interactive_reads();
1636            t.elapsed()
1637        });
1638        (ready_rx, handle)
1639    }
1640
1641    #[test]
1642    fn scan_writers_yield_while_interactive_reads_are_in_flight() {
1643        let store = Arc::new(DocumentStore::new());
1644
1645        // No guard: returns immediately.
1646        let t = std::time::Instant::now();
1647        store.yield_to_interactive_reads();
1648        assert!(t.elapsed() < std::time::Duration::from_millis(200));
1649
1650        // Guard held on another thread: the writer waits until it drops.
1651        // We only start the hold timer once the waiter has confirmed it's
1652        // about to poll, so the guard can never be dropped before the
1653        // writer starts waiting on it.
1654        let guard = store.interactive_read_guard();
1655        let (ready_rx, waiter) = spawn_yield_waiter(&store);
1656        ready_rx.recv().unwrap();
1657        const HOLD: std::time::Duration = std::time::Duration::from_millis(60);
1658        std::thread::sleep(HOLD);
1659        drop(guard);
1660        let waited = waiter.join().unwrap();
1661        assert!(
1662            waited >= HOLD,
1663            "writer should have paused at least as long as the read guard was held (waited {waited:?})"
1664        );
1665
1666        // Guard fully released: nested guards count correctly.
1667        let g1 = store.interactive_read_guard();
1668        let g2 = store.interactive_read_guard();
1669        drop(g1);
1670        let (ready_rx, waiter) = spawn_yield_waiter(&store);
1671        ready_rx.recv().unwrap();
1672        const HOLD2: std::time::Duration = std::time::Duration::from_millis(30);
1673        std::thread::sleep(HOLD2);
1674        drop(g2);
1675        let waited = waiter.join().unwrap();
1676        assert!(
1677            waited >= HOLD2,
1678            "writer should have paused while the nested read guard was held (waited {waited:?})"
1679        );
1680        store.yield_to_interactive_reads(); // all guards dropped — no wait
1681    }
1682
1683    #[test]
1684    fn index_registers_file_in_salsa() {
1685        let store = DocumentStore::new();
1686        store.ingest(uri("/lib.php"), "<?php\nfunction lib_fn() {}");
1687        let idx = store.get_index_salsa(&uri("/lib.php")).unwrap();
1688        assert_eq!(idx.functions.len(), 1);
1689        assert_eq!(&*idx.functions[0].name, "lib_fn");
1690    }
1691
1692    #[test]
1693    fn remove_hides_file_from_index() {
1694        let store = DocumentStore::new();
1695        let u = uri("/lib.php");
1696        store.ingest(u.clone(), "<?php");
1697        store.remove(&u);
1698        assert!(store.get_index_salsa(&u).is_none());
1699    }
1700
1701    #[test]
1702    fn remove_and_reopen_reuses_source_file_handle() {
1703        let store = DocumentStore::new();
1704        let u = uri("/lib.php");
1705        store.ingest(u.clone(), "<?php");
1706        let ft_before = store.source_file(&u).unwrap();
1707        store.remove(&u);
1708        assert!(
1709            store.source_file(&u).is_none(),
1710            "deleted file should be hidden"
1711        );
1712        store.mirror_text(&u, "<?php");
1713        let ft_after = store.source_file(&u).unwrap();
1714        assert!(
1715            ft_before == ft_after,
1716            "reopen must reuse the same FileText handle"
1717        );
1718    }
1719
1720    #[test]
1721    fn delete_reopen_churn_does_not_amplify_salsa_inputs() {
1722        let store = DocumentStore::new();
1723        let uris: Vec<Url> = (0..20).map(|i| uri(&format!("/churn/f{i}.php"))).collect();
1724        for u in &uris {
1725            store.ingest(u.clone(), "<?php class A {}");
1726        }
1727        let count_before = store.source_files_len();
1728        for _ in 0..10 {
1729            for u in &uris {
1730                store.remove(u);
1731            }
1732            for u in &uris {
1733                store.ingest(u.clone(), "<?php class A {}");
1734            }
1735        }
1736        assert_eq!(
1737            store.source_files_len(),
1738            count_before,
1739            "delete-reopen cycles must not create new salsa inputs (L1-B regression guard)"
1740        );
1741    }
1742
1743    #[test]
1744    fn warm_analysis_sweep_preserves_reference_results() {
1745        let store = DocumentStore::new();
1746        store.ingest(
1747            uri("/svc.php"),
1748            "<?php\nnamespace App;\nclass Svc { public function run(): void {} }",
1749        );
1750        store.ingest(
1751            uri("/caller.php"),
1752            "<?php\nnamespace App;\nclass Caller {\n    private Svc $s;\n    public function go(): void { $this->s->run(); }\n}",
1753        );
1754        let sym = mir_analyzer::Name::method("App\\Svc", "run");
1755        let files: Vec<Arc<str>> = [uri("/svc.php"), uri("/caller.php")]
1756            .iter()
1757            .map(|u| Arc::from(u.as_str()))
1758            .collect();
1759        let cold = store.indexed_references(&sym, &files, false, None);
1760        assert_eq!(cold.len(), 1, "caller.php references Svc::run once");
1761
1762        let cancel = store.begin_warm_sweep();
1763        store.warm_analysis_sweep(&[], &cancel);
1764        let warm = store.indexed_references(&sym, &files, false, None);
1765        assert_eq!(cold, warm, "sweep must not change reference results");
1766
1767        // An edit after the sweep is still picked up (memos revalidate).
1768        store.ingest(
1769            uri("/caller.php"),
1770            "<?php\nnamespace App;\nclass Caller {\n    private Svc $s;\n    public function go(): void { $this->s->run(); $this->s->run(); }\n}",
1771        );
1772        let cancel = store.begin_warm_sweep();
1773        store.warm_analysis_sweep(&[], &cancel);
1774        let after_edit = store.indexed_references(&sym, &files, false, None);
1775        assert_eq!(after_edit.len(), 2, "re-sweep must see the new reference");
1776    }
1777
1778    #[test]
1779    fn sweep_priority_puts_open_files_and_their_dependencies_first() {
1780        let store = DocumentStore::new();
1781        store.ingest(
1782            uri("/a.php"),
1783            "<?php\nnamespace App;\nclass A { public function go(B $b): void {} }",
1784        );
1785        store.ingest(uri("/b.php"), "<?php\nnamespace App;\nclass B {}");
1786        store.ingest(uri("/unrelated.php"), "<?php\nnamespace App;\nclass C {}");
1787        store.mark_index_ready();
1788
1789        let front = store.sweep_priority_files(&[uri("/a.php")]);
1790        let front: Vec<&str> = front.iter().map(|f| f.as_ref()).collect();
1791        assert_eq!(
1792            front,
1793            vec![uri("/a.php").as_str(), uri("/b.php").as_str()],
1794            "open file first, then the files declaring its referenced classes"
1795        );
1796    }
1797
1798    #[test]
1799    fn warm_analysis_sweep_stops_on_cancel() {
1800        let store = DocumentStore::new();
1801        for i in 0..40 {
1802            store.ingest(
1803                uri(&format!("/f{i}.php")),
1804                "<?php\nclass A { public function m(): int { return 1; } }",
1805            );
1806        }
1807        let cancel = store.begin_warm_sweep();
1808        cancel.cancel();
1809        // Pre-cancelled token: returns without analyzing (must not hang/panic).
1810        store.warm_analysis_sweep(&[], &cancel);
1811        // A newer sweep's token supersedes the old one.
1812        let old = store.begin_warm_sweep();
1813        let _new = store.begin_warm_sweep();
1814        assert!(old.is_cancelled());
1815    }
1816
1817    #[test]
1818    fn all_indexes_includes_every_mirrored_file() {
1819        let store = DocumentStore::new();
1820        open(&store, uri("/a.php"), "<?php\nfunction a() {}".to_string());
1821        store.ingest(uri("/b.php"), "<?php\nfunction b() {}");
1822        assert_eq!(store.all_indexes().len(), 2);
1823    }
1824
1825    #[test]
1826    fn other_indexes_excludes_current_uri() {
1827        let store = DocumentStore::new();
1828        open(&store, uri("/a.php"), "<?php\nfunction a() {}".to_string());
1829        open(&store, uri("/b.php"), "<?php\nfunction b() {}".to_string());
1830        assert_eq!(store.other_indexes(&uri("/a.php")).len(), 1);
1831    }
1832
1833    #[test]
1834    fn other_docs_excludes_current_uri() {
1835        let store = DocumentStore::new();
1836        let ua = uri("/a.php");
1837        let ub = uri("/b.php");
1838        open(&store, ua.clone(), "<?php\nfunction a() {}".to_string());
1839        open(&store, ub.clone(), "<?php\nfunction b() {}".to_string());
1840        let open_urls = vec![ua.clone(), ub];
1841        assert_eq!(store.other_docs(&ua, &open_urls).len(), 1);
1842    }
1843
1844    #[test]
1845    fn evict_token_cache_removes_entry() {
1846        let store = DocumentStore::new();
1847        let u = uri("/a.php");
1848        open(&store, u.clone(), "<?php".to_string());
1849        store.store_token_cache(&u, "id1".to_string(), Arc::new(vec![]));
1850        assert!(store.get_token_cache(&u, "id1").is_some());
1851        store.evict_token_cache(&u);
1852        assert!(store.get_token_cache(&u, "id1").is_none());
1853    }
1854
1855    #[test]
1856    fn vendor_index_cache_evicted_on_remove() {
1857        let store = DocumentStore::new();
1858        let u = uri("/vendor/acme/lib.php");
1859        store.ingest(u.clone(), "<?php\nclass Lib {}");
1860        let idx = store.get_index_salsa(&u).unwrap();
1861        store.cache_vendor_index(u.clone(), idx.clone());
1862        assert!(store.get_vendor_index(&u).is_some());
1863        store.remove(&u);
1864        assert!(store.get_vendor_index(&u).is_none());
1865    }
1866
1867    #[test]
1868    fn index_populates_file_index_with_symbols() {
1869        let store = DocumentStore::new();
1870        store.ingest(uri("/a.php"), "<?php\nfunction hello() {}");
1871        let idx = store.get_index_salsa(&uri("/a.php")).unwrap();
1872        assert_eq!(idx.functions.len(), 1);
1873        assert_eq!(&*idx.functions[0].name, "hello");
1874    }
1875
1876    #[test]
1877    fn open_populates_file_index_with_symbols() {
1878        let store = DocumentStore::new();
1879        open(&store, uri("/a.php"), "<?php\nclass Foo {}".to_string());
1880        let idx = store.get_index_salsa(&uri("/a.php")).unwrap();
1881        assert_eq!(idx.classes.len(), 1);
1882        assert_eq!(&*idx.classes[0].name, "Foo");
1883    }
1884
1885    // ── Mirror invariants ────────────────────────────────────────────────
1886    //
1887    // Every mutation path that changes file text must keep the salsa layer
1888    // consistent. These tests walk a set-edit-reopen cycle and assert that
1889    // the salsa-derived `FileIndex` reflects the latest text at each step.
1890
1891    fn names_of(idx: &FileIndex) -> Vec<String> {
1892        let mut out: Vec<String> = idx.classes.iter().map(|c| c.name.to_string()).collect();
1893        out.extend(idx.functions.iter().map(|f| f.name.to_string()));
1894        out.sort();
1895        out
1896    }
1897
1898    fn salsa_index_names(store: &DocumentStore, url: &Url) -> Vec<String> {
1899        store
1900            .snapshot_query_file_index(url)
1901            .map(|idx| names_of(&idx))
1902            .unwrap_or_default()
1903    }
1904
1905    #[test]
1906    fn mirror_tracks_repeated_edits() {
1907        let store = DocumentStore::new();
1908        let u = uri("/mirror.php");
1909
1910        open(&store, u.clone(), "<?php\nclass A {}".to_string());
1911        assert_eq!(salsa_index_names(&store, &u), vec!["A".to_string()]);
1912
1913        open(
1914            &store,
1915            u.clone(),
1916            "<?php\nclass A {}\nclass B {}".to_string(),
1917        );
1918        assert_eq!(
1919            salsa_index_names(&store, &u),
1920            vec!["A".to_string(), "B".to_string()]
1921        );
1922
1923        open(&store, u.clone(), "<?php\nfunction greet() {}".to_string());
1924        assert_eq!(salsa_index_names(&store, &u), vec!["greet".to_string()]);
1925    }
1926
1927    #[test]
1928    fn mirror_tracks_ingest_and_ingest_from_doc() {
1929        let store = DocumentStore::new();
1930
1931        // Background `index(url, text)` path.
1932        let u1 = uri("/bg1.php");
1933        store.ingest(u1.clone(), "<?php\nclass Bg1 {}");
1934        assert_eq!(salsa_index_names(&store, &u1), vec!["Bg1".to_string()]);
1935
1936        // `ingest_from_doc(url, &doc)` path (workspace-scan Phase 2).
1937        let u2 = uri("/bg2.php");
1938        let doc = crate::analysis::diagnostics::parse_document_no_diags(
1939            "<?php\nclass Bg2 {}\nfunction f() {}",
1940        );
1941        store.ingest_from_doc(u2.clone(), &doc);
1942        assert_eq!(
1943            salsa_index_names(&store, &u2),
1944            vec!["Bg2".to_string(), "f".to_string()]
1945        );
1946    }
1947
1948    /// G3: confirms the `parsed_cache` actually hits — two consecutive
1949    /// `get_doc_salsa` calls on unchanged text return the same `Arc`
1950    /// (pointer equality), and an edit forces a miss that produces a
1951    /// different `Arc`.
1952    /// parsed_cache must stay bounded — inserting more than
1953    /// `PARSED_CACHE_CAP` unique URLs must not cause unbounded growth.
1954    /// Eviction is probabilistic, so we only assert the bound, not which
1955    /// Seeding a cached index for a URL that was never mirrored is a no-op
1956    /// (returns `false`) — avoids silently allocating SourceFiles outside
1957    /// `mirror_text`'s control.
1958    #[test]
1959    fn seed_cached_index_noops_for_unknown_uri() {
1960        let store = DocumentStore::new();
1961        let u = uri("/never_mirrored.php");
1962        let index = Arc::new(crate::index::file_index::FileIndex::default());
1963        assert!(!store.seed_cached_index(&u, index));
1964    }
1965
1966    /// entries survive.
1967    #[test]
1968    fn parsed_cache_stays_bounded_under_many_inserts() {
1969        let store = DocumentStore::new();
1970        use crate::document::cache_registry::PARSED_CACHE_CAP;
1971        let overflow = PARSED_CACHE_CAP + 100;
1972        for i in 0..overflow {
1973            let u = uri(&format!("/cap/file{i}.php"));
1974            store.ingest(u.clone(), "<?php\nclass A {}");
1975            // Force a parsed_cache insert via get_doc_salsa.
1976            let _ = store.get_doc_salsa(&u);
1977        }
1978        assert!(
1979            store.caches.parsed_cache.len() <= PARSED_CACHE_CAP,
1980            "parsed_cache grew to {} entries (cap {})",
1981            store.caches.parsed_cache.len(),
1982            PARSED_CACHE_CAP
1983        );
1984    }
1985
1986    /// The shed is recency-based: an entry touched right before overflow (an
1987    /// open file being edited) must survive a sweep of cold inserts, while
1988    /// untouched cold entries are the ones dropped.
1989    #[test]
1990    fn parsed_cache_shed_keeps_recently_used_entries() {
1991        let store = DocumentStore::new();
1992        use crate::document::cache_registry::PARSED_CACHE_CAP;
1993
1994        let hot = uri("/cap/hot.php");
1995        store.ingest(hot.clone(), "<?php\nclass Hot {}");
1996        let _ = store.get_doc_salsa(&hot);
1997
1998        // Fill to just below the cap with cold entries, then re-touch the hot
1999        // file so it is the most recently used entry at shed time.
2000        for i in 0..(PARSED_CACHE_CAP - 2) {
2001            let u = uri(&format!("/cap/cold{i}.php"));
2002            store.ingest(u.clone(), "<?php\nclass A {}");
2003            let _ = store.get_doc_salsa(&u);
2004        }
2005        let _ = store.get_doc_salsa(&hot);
2006
2007        // Overflow: this insert triggers the shed of the LRU half.
2008        let trigger = uri("/cap/trigger.php");
2009        store.ingest(trigger.clone(), "<?php\nclass T {}");
2010        let _ = store.get_doc_salsa(&trigger);
2011
2012        assert!(
2013            store.caches.parsed_cache.contains_key(&hot),
2014            "recently-touched entry must survive the recency shed"
2015        );
2016        assert!(
2017            store.caches.parsed_cache.len() <= PARSED_CACHE_CAP,
2018            "cache must stay bounded after the shed"
2019        );
2020    }
2021
2022    /// `analysis_cache` must stay bounded when many distinct files are
2023    /// analyzed across a session (multi-hour usage previously grew it
2024    /// without limit).
2025    #[test]
2026    fn analysis_cache_stays_bounded_under_many_files() {
2027        use crate::document::cache_registry::ANALYSIS_CACHE_CAP;
2028        let store = DocumentStore::new();
2029        let overflow = ANALYSIS_CACHE_CAP + 16;
2030        for i in 0..overflow {
2031            let u = uri(&format!("/acap/file{i}.php"));
2032            store.ingest(u.clone(), "<?php\nfunction f() { return 1; }");
2033            let _ = store.cached_analysis(&u);
2034        }
2035        assert!(
2036            store.caches.analysis_cache.len() <= ANALYSIS_CACHE_CAP,
2037            "analysis_cache grew to {} entries (cap {})",
2038            store.caches.analysis_cache.len(),
2039            ANALYSIS_CACHE_CAP
2040        );
2041    }
2042
2043    #[test]
2044    fn get_doc_salsa_cache_hits_across_calls() {
2045        let store = DocumentStore::new();
2046        let u = uri("/g3_cache.php");
2047        open(&store, u.clone(), "<?php\nclass G3 {}".to_string());
2048
2049        let a = store.get_doc_salsa(&u).unwrap();
2050        let b = store.get_doc_salsa(&u).unwrap();
2051        assert!(
2052            Arc::ptr_eq(&a, &b),
2053            "parsed_cache hit should yield the same Arc across calls"
2054        );
2055
2056        open(&store, u.clone(), "<?php\nclass G3b {}".to_string());
2057        let c = store.get_doc_salsa(&u).unwrap();
2058        assert!(
2059            !Arc::ptr_eq(&a, &c),
2060            "edit should invalidate the parsed_cache entry"
2061        );
2062    }
2063
2064    #[test]
2065    fn get_doc_salsa_returns_some_for_mirrored_files() {
2066        // Phase E4: `get_doc_salsa` no longer gates on open-state. The
2067        // open/closed distinction now lives on `Backend::get_doc`.
2068        let store = DocumentStore::new();
2069        let u = uri("/e4_doc.php");
2070        store.ingest(u.clone(), "<?php\nclass P {}");
2071        assert!(store.get_doc_salsa(&u).is_some());
2072    }
2073
2074    #[test]
2075    fn get_doc_snapshot_or_stale_matches_salsa_on_settled_buffer() {
2076        let store = DocumentStore::new();
2077        let u = uri("/stale_settled.php");
2078        open(&store, u.clone(), "<?php\nclass S {}".to_string());
2079        let fresh = store.get_doc_salsa(&u).unwrap();
2080        let stale = store.get_doc_snapshot_or_stale(&u).unwrap();
2081        assert!(
2082            Arc::ptr_eq(&fresh, &stale),
2083            "on a settled buffer the stale-tolerant accessor must serve the cached parse"
2084        );
2085    }
2086
2087    /// WS1: the cursor-triggered highlight accessor must stay responsive under a
2088    /// sustained write stream — never spin (the deadline join hangs if it does)
2089    /// and always yield a fresh or last-good parse for an open file.
2090    #[test]
2091    fn get_doc_snapshot_or_stale_stays_responsive_under_writes() {
2092        use std::sync::Arc;
2093        use std::thread;
2094        use std::time::{Duration, Instant};
2095
2096        let store = Arc::new(DocumentStore::new());
2097        let urls: Vec<Url> = (0..8).map(|i| uri(&format!("/hl{i}.php"))).collect();
2098        for (i, u) in urls.iter().enumerate() {
2099            open(&store, u.clone(), format!("<?php\nclass H{i} {{}}"));
2100            // Warm the parse cache so the stale fallback always has a last-good doc.
2101            assert!(store.get_doc_salsa(u).is_some());
2102        }
2103
2104        let deadline = Instant::now() + Duration::from_millis(400);
2105        let mut handles = Vec::new();
2106
2107        {
2108            let store = Arc::clone(&store);
2109            let urls = urls.clone();
2110            handles.push(thread::spawn(move || {
2111                let mut rev = 0u32;
2112                while Instant::now() < deadline {
2113                    for u in &urls {
2114                        store.mirror_text(u, &format!("<?php\nclass H {{}}\n// rev {rev}"));
2115                    }
2116                    rev += 1;
2117                }
2118            }));
2119        }
2120
2121        for _ in 0..4 {
2122            let store = Arc::clone(&store);
2123            let urls = urls.clone();
2124            handles.push(thread::spawn(move || {
2125                while Instant::now() < deadline {
2126                    for u in &urls {
2127                        assert!(
2128                            store.get_doc_snapshot_or_stale(u).is_some(),
2129                            "open file must resolve to a fresh or stale parse, never None"
2130                        );
2131                    }
2132                }
2133            }));
2134        }
2135
2136        for h in handles {
2137            h.join()
2138                .expect("stale accessor must not panic or spin under concurrent writes");
2139        }
2140    }
2141
2142    /// WS1 A/B measurement (not a pass/fail guard): compares the old unbounded
2143    /// `get_doc_salsa` against the new bounded `get_doc_snapshot_or_stale` under
2144    /// an identical continuous write stream. Run explicitly:
2145    /// `cargo test --lib ws1_ab_latency -- --ignored --nocapture`.
2146    #[test]
2147    #[ignore = "timing measurement; run with --ignored --nocapture"]
2148    fn ws1_ab_latency_under_write_pressure() {
2149        use std::sync::Arc;
2150        use std::sync::atomic::{AtomicBool, Ordering};
2151        use std::thread;
2152        use std::time::{Duration, Instant};
2153
2154        // A large file: each parse is long enough that a concurrent write can
2155        // cancel it mid-flight — the condition under which the unbounded retry
2156        // actually spins (a tiny file parses faster than the writer's gap).
2157        fn big_source(rev: u32) -> String {
2158            let mut s = String::from("<?php\nclass Big {\n");
2159            for i in 0..3000 {
2160                s.push_str(&format!(
2161                    "    public function m{i}(int $a, string $b): int {{ return $a + {i}; }}\n"
2162                ));
2163            }
2164            s.push_str(&format!("}}\n// rev {rev}\n"));
2165            s
2166        }
2167
2168        fn measure(
2169            store: &Arc<DocumentStore>,
2170            url: &Url,
2171            writers: usize,
2172            label: &str,
2173            call: impl Fn(&DocumentStore, &Url) -> bool,
2174        ) {
2175            let stop = Arc::new(AtomicBool::new(false));
2176            let mut writer_handles = Vec::new();
2177            for w in 0..writers {
2178                let store = Arc::clone(store);
2179                let url = url.clone();
2180                let stop = Arc::clone(&stop);
2181                writer_handles.push(thread::spawn(move || {
2182                    let mut rev = (w as u32) * 1_000_000;
2183                    while !stop.load(Ordering::Relaxed) {
2184                        store.mirror_text(&url, &big_source(rev));
2185                        rev = rev.wrapping_add(1);
2186                    }
2187                }));
2188            }
2189            thread::sleep(Duration::from_millis(30)); // let the writers get hot
2190
2191            let n = 100usize;
2192            let mut max = Duration::ZERO;
2193            let mut total = Duration::ZERO;
2194            for _ in 0..n {
2195                let t = Instant::now();
2196                let ok = call(store, url);
2197                let dt = t.elapsed();
2198                assert!(ok, "{label}: accessor returned None");
2199                max = max.max(dt);
2200                total += dt;
2201            }
2202            stop.store(true, Ordering::Relaxed);
2203            for h in writer_handles {
2204                h.join().unwrap();
2205            }
2206            println!(
2207                "{label}: {n} calls, {writers} writers — max {:>10.3?}, mean {:>10.3?}",
2208                max,
2209                total / n as u32
2210            );
2211        }
2212
2213        let store = Arc::new(DocumentStore::new());
2214        let url = uri("/perf_big.php");
2215        open(&store, url.clone(), big_source(0));
2216        assert!(store.get_doc_salsa(&url).is_some()); // warm the cache
2217
2218        for writers in [2usize, 4] {
2219            measure(
2220                &store,
2221                &url,
2222                writers,
2223                "NEW get_doc_snapshot_or_stale",
2224                |s, u| s.get_doc_snapshot_or_stale(u).is_some(),
2225            );
2226            measure(
2227                &store,
2228                &url,
2229                writers,
2230                "OLD get_doc_salsa (unbounded)  ",
2231                |s, u| s.get_doc_salsa(u).is_some(),
2232            );
2233        }
2234    }
2235
2236    #[test]
2237    fn get_salsa_accessors_return_none_for_unknown_uri() {
2238        let store = DocumentStore::new();
2239        let u = uri("/never-seen.php");
2240        assert!(store.get_doc_salsa(&u).is_none());
2241        assert!(store.get_index_salsa(&u).is_none());
2242    }
2243
2244    /// Phase E1: concurrent readers and writers must not deadlock, panic, or
2245    /// return stale data. Writers briefly bump inputs while readers are
2246    /// running on cloned snapshots; any `salsa::Cancelled` raised on the
2247    /// reader side must be caught and retried by `snapshot_query`.
2248    ///
2249    /// The salsa surface (`get_doc_salsa`, `get_index_salsa`) is protected by
2250    /// `snapshot_query`'s last-resort host-lock fallback.
2251    #[test]
2252    fn concurrent_reads_and_writes_do_not_panic() {
2253        use std::sync::Arc;
2254        use std::thread;
2255        use std::time::{Duration, Instant};
2256
2257        let store = Arc::new(DocumentStore::new());
2258        let urls: Vec<Url> = (0..8).map(|i| uri(&format!("/f{i}.php"))).collect();
2259        for (i, u) in urls.iter().enumerate() {
2260            open(&store, u.clone(), format!("<?php\nclass C{i} {{}}"));
2261        }
2262
2263        let deadline = Instant::now() + Duration::from_millis(400);
2264        let mut handles = Vec::new();
2265
2266        // Writer thread: keep bumping every file's text.
2267        {
2268            let store = Arc::clone(&store);
2269            let urls = urls.clone();
2270            handles.push(thread::spawn(move || {
2271                let mut rev = 0u32;
2272                while Instant::now() < deadline {
2273                    for u in &urls {
2274                        let text = format!("<?php\nclass C{{}}\n// rev {rev}");
2275                        store.mirror_text(u, &text);
2276                    }
2277                    rev += 1;
2278                }
2279            }));
2280        }
2281
2282        // Reader threads: hammer the salsa accessors.
2283        for _ in 0..4 {
2284            let store = Arc::clone(&store);
2285            let urls = urls.clone();
2286            handles.push(thread::spawn(move || {
2287                while Instant::now() < deadline {
2288                    for u in &urls {
2289                        let _ = store.get_doc_salsa(u);
2290                        let _ = store.get_index_salsa(u);
2291                    }
2292                    // Post mir 0.22: codebase + refs live in the session,
2293                    // not salsa. Concurrent-read smoke is limited to the
2294                    // remaining salsa surface (parsed_doc, file_index).
2295                }
2296            }));
2297        }
2298
2299        for h in handles {
2300            h.join().expect("no panic under concurrent read/write");
2301        }
2302    }
2303
2304    /// PSR-4 lazy-loading: `get_semantic_issues_salsa` must not emit
2305    /// `UndefinedClass` for a class that is PSR-4-resolvable on disk, even
2306    /// when the dependency file is not yet in `source_files`.
2307    #[test]
2308    fn psr4_lazy_load_suppresses_undefined_class() {
2309        let tmp = tempfile::tempdir().unwrap();
2310
2311        // Write Entity.php to disk (not mirrored into the store).
2312        std::fs::create_dir_all(tmp.path().join("src/Model")).unwrap();
2313        std::fs::write(
2314            tmp.path().join("src/Model/Entity.php"),
2315            "<?php\nnamespace App\\Model;\nclass Entity {}\n",
2316        )
2317        .unwrap();
2318
2319        // Write composer.json so Psr4Map::load can build the map.
2320        std::fs::write(
2321            tmp.path().join("composer.json"),
2322            r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
2323        )
2324        .unwrap();
2325
2326        let store = DocumentStore::new();
2327
2328        // Inject a PSR-4 map pointing at the tmp dir.
2329        store
2330            .psr4
2331            .store(Arc::new(crate::lang::autoload::Psr4Map::load(tmp.path())));
2332
2333        // Mirror the consuming file (Entity not yet in source_files).
2334        // Uses Entity as a parameter type hint — the analyzer resolves these
2335        // through use statements, so this exercises the full PSR-4 lazy-load path.
2336        let handler_url = Url::from_file_path(tmp.path().join("src/Service/Handler.php")).unwrap();
2337        store.mirror_text(
2338            &handler_url,
2339            "<?php\nnamespace App\\Service;\nuse App\\Model\\Entity;\nfunction handle(Entity $e): Entity { return $e; }\n",
2340        );
2341
2342        let issues = store.get_semantic_issues_salsa(&handler_url).unwrap();
2343        let undef: Vec<_> = issues
2344            .iter()
2345            .filter(|i| matches!(i.kind, mir_issues::IssueKind::UndefinedClass { .. }))
2346            .collect();
2347        assert!(
2348            undef.is_empty(),
2349            "PSR-4 lazy-loading must prevent UndefinedClass for App\\Model\\Entity; got: {undef:?}"
2350        );
2351    }
2352
2353    /// Issue #191 regression: workspace-wide scans (find-references, rename,
2354    /// call-hierarchy) must not re-parse closed/indexed files on repeated
2355    /// invocations. Once a file's `ParsedDoc` has been produced, subsequent
2356    /// `all_docs_for_scan()` calls must hit the cache and return the same
2357    /// `Arc<ParsedDoc>` (pointer equality), proving no re-parse occurred.
2358    ///
2359    /// The cache layers protecting this are:
2360    ///   1. `parsed_cache` (cap [`PARSED_CACHE_CAP`]) — read-through, validated
2361    ///      via `Arc::ptr_eq` on the text Arc.
2362    ///   2. salsa `parsed_doc` memo (`lru = 2048`) — second line of defense
2363    ///      when `parsed_cache` evicts.
2364    ///
2365    /// Together they keep every workspace-scan op O(N) memo lookups, never
2366    /// O(N) parses, for any workspace whose file count fits the cap.
2367    #[test]
2368    fn all_docs_for_scan_does_not_reparse_indexed_files() {
2369        let store = DocumentStore::new();
2370        const N: usize = 50;
2371        for i in 0..N {
2372            let u = uri(&format!("/scan/file{i}.php"));
2373            store.ingest(u, &format!("<?php\nclass C{i} {{}}\nfunction f{i}() {{}}"));
2374        }
2375
2376        let first: Vec<_> = store.all_docs_for_scan();
2377        let second: Vec<_> = store.all_docs_for_scan();
2378        assert_eq!(first.len(), N);
2379        assert_eq!(second.len(), N);
2380
2381        let by_url_first: std::collections::HashMap<Url, Arc<ParsedDoc>> =
2382            first.into_iter().collect();
2383        for (u, doc2) in second {
2384            let doc1 = by_url_first
2385                .get(&u)
2386                .expect("second scan returned a URL the first didn't");
2387            assert!(
2388                Arc::ptr_eq(doc1, &doc2),
2389                "{u} re-parsed across all_docs_for_scan calls — \
2390                 cache (parsed_cache + salsa parsed_doc memo) failed to hit"
2391            );
2392        }
2393
2394        // Editing one file's text must invalidate just that file's entry,
2395        // not the rest. This locks in self-eviction via Arc::ptr_eq on text.
2396        let edited_url = uri("/scan/file0.php");
2397        let pre_edit = store.get_doc_salsa(&edited_url).unwrap();
2398        store.ingest(edited_url.clone(), "<?php\nclass C0Edited {}");
2399        let post_edit = store.get_doc_salsa(&edited_url).unwrap();
2400        assert!(
2401            !Arc::ptr_eq(&pre_edit, &post_edit),
2402            "edited file must produce a fresh ParsedDoc"
2403        );
2404        for i in 1..N {
2405            let u = uri(&format!("/scan/file{i}.php"));
2406            let original = by_url_first.get(&u).unwrap();
2407            let after = store.get_doc_salsa(&u).unwrap();
2408            assert!(
2409                Arc::ptr_eq(original, &after),
2410                "{u} should not have re-parsed because of an unrelated edit"
2411            );
2412        }
2413    }
2414
2415    /// Incremental analysis cache: a body-only edit to file A (no declaration
2416    /// changes) must not bump `decl_version`, so file B's cached analysis
2417    /// survives. A declaration edit MUST bump the version so B's entry goes
2418    /// stale.
2419    #[test]
2420    fn body_only_edit_does_not_invalidate_sibling_analysis_cache() {
2421        let store = DocumentStore::new();
2422        let ua = uri("/ic_a.php");
2423        let ub = uri("/ic_b.php");
2424
2425        // Analyze both files to establish their fingerprints.
2426        open(
2427            &store,
2428            ua.clone(),
2429            "<?php\nfunction a() { return 1; }".to_string(),
2430        );
2431        open(
2432            &store,
2433            ub.clone(),
2434            "<?php\nfunction b() { return 2; }".to_string(),
2435        );
2436        let _ = store.cached_analysis(&ua).unwrap();
2437        let analysis_b_first = store.cached_analysis(&ub).unwrap();
2438        let ver_after_warm = store.caches.decl_version();
2439
2440        // Body-only edit to A: same function name, different body → FileIndex unchanged.
2441        store.mirror_text(&ua, "<?php\nfunction a() { return 999; }");
2442        let _ = store.cached_analysis(&ua);
2443        let ver_after_body_edit = store.caches.decl_version();
2444        assert_eq!(
2445            ver_after_warm, ver_after_body_edit,
2446            "body-only edit must not bump decl_version"
2447        );
2448
2449        // B's cached entry should still be valid (ptr-eq source AND same version).
2450        let analysis_b_second = store.cached_analysis_if_fresh(&ub);
2451        assert!(
2452            analysis_b_second.is_some(),
2453            "B's analysis should hit cache after body-only edit to A"
2454        );
2455        assert!(
2456            Arc::ptr_eq(&analysis_b_first, &analysis_b_second.unwrap()),
2457            "B's analysis should be the identical Arc (no re-analysis)"
2458        );
2459
2460        // Declaration edit to A: rename the function → FileIndex changes.
2461        store.mirror_text(&ua, "<?php\nfunction a_renamed() { return 999; }");
2462        let _ = store.cached_analysis(&ua);
2463        let ver_after_decl_edit = store.caches.decl_version();
2464        assert!(
2465            ver_after_decl_edit > ver_after_body_edit,
2466            "declaration edit must bump decl_version (was {ver_after_body_edit}, now {ver_after_decl_edit})"
2467        );
2468
2469        // B's entry is now stale — cached_analysis_if_fresh must return None.
2470        let analysis_b_stale = store.cached_analysis_if_fresh(&ub);
2471        assert!(
2472            analysis_b_stale.is_none(),
2473            "B's analysis should be stale after A's declaration changed"
2474        );
2475    }
2476
2477    /// snapshot_query must complete without panic when a concurrent writer
2478    /// races the snapshot. The single-retry-then-lock logic should handle this
2479    /// correctly: the lock-held fallback guarantees progress.
2480    #[test]
2481    fn snapshot_query_survives_concurrent_writes() {
2482        use std::sync::Arc;
2483        use std::thread;
2484        use std::time::{Duration, Instant};
2485
2486        let store = Arc::new(DocumentStore::new());
2487        let u = uri("/sq_test.php");
2488        open(
2489            &store,
2490            u.clone(),
2491            "<?php\nfunction f(): int { return 1; }".to_string(),
2492        );
2493
2494        let deadline = Instant::now() + Duration::from_millis(200);
2495        let mut handles = Vec::new();
2496
2497        // Writer: keep bumping the file text to trigger salsa::Cancelled.
2498        {
2499            let store = Arc::clone(&store);
2500            let u = u.clone();
2501            handles.push(thread::spawn(move || {
2502                let mut rev = 0u32;
2503                while Instant::now() < deadline {
2504                    store.mirror_text(&u, &format!("<?php\nfunction f(): int {{ return {rev}; }}"));
2505                    rev += 1;
2506                }
2507            }));
2508        }
2509
2510        // Reader: hammer snapshot_query via get_doc_salsa.
2511        for _ in 0..4 {
2512            let store = Arc::clone(&store);
2513            let u = u.clone();
2514            handles.push(thread::spawn(move || {
2515                while Instant::now() < deadline {
2516                    let _ = store.get_doc_salsa(&u);
2517                    let _ = store.get_index_salsa(&u);
2518                }
2519            }));
2520        }
2521
2522        for h in handles {
2523            h.join()
2524                .expect("no panic in snapshot_query under concurrent writes");
2525        }
2526    }
2527
2528    /// When a sibling file's declaration changes (bumping decl_version), the
2529    /// owned_program_cache entry for the unchanged file B should be reused
2530    /// rather than deep-cloned again. We verify this via Arc pointer equality.
2531    #[test]
2532    fn owned_program_cache_reused_after_sibling_declaration_change() {
2533        let store = DocumentStore::new();
2534        let ua = uri("/prog_a.php");
2535        let ub = uri("/prog_b.php");
2536
2537        open(
2538            &store,
2539            ua.clone(),
2540            "<?php\nfunction alpha(): void {}".to_string(),
2541        );
2542        open(
2543            &store,
2544            ub.clone(),
2545            "<?php\nfunction beta(): void {}".to_string(),
2546        );
2547
2548        // Warm both analysis caches and populate owned_program_cache for both files.
2549        let _ = store.cached_analysis(&ua);
2550        let _ = store.cached_analysis(&ub);
2551
2552        // Capture B's owned_program Arc before the sibling edit.
2553        let prog_b_first = store
2554            .caches
2555            .owned_program_cache
2556            .get(&ub)
2557            .map(|e| Arc::clone(&e.1))
2558            .expect("B's owned_program should be cached after first analysis");
2559
2560        // Declaration change to A: bumps decl_version, invalidating all cached_analysis entries.
2561        store.mirror_text(&ua, "<?php\nfunction alpha_renamed(): void {}");
2562        // Re-analyze A to trigger the decl_version bump.
2563        let _ = store.cached_analysis(&ua);
2564
2565        // Now re-analyze B. Its source is unchanged, so owned_program_cache must hit.
2566        let _ = store.cached_analysis(&ub);
2567
2568        let prog_b_second = store
2569            .caches
2570            .owned_program_cache
2571            .get(&ub)
2572            .map(|e| Arc::clone(&e.1))
2573            .expect("B's owned_program should still be cached after sibling edit");
2574
2575        assert!(
2576            Arc::ptr_eq(&prog_b_first, &prog_b_second),
2577            "B's owned_program Arc should be identical (cache hit) after sibling declaration change"
2578        );
2579    }
2580
2581    /// write_rev() increments on each real text write (identical text is a no-op).
2582    #[test]
2583    fn write_rev_increments_on_write() {
2584        let store = DocumentStore::new();
2585        let u = uri("/rev_test.php");
2586        let before = store.write_rev();
2587        store.mirror_text(&u, "<?php echo 1;");
2588        let after_first = store.write_rev();
2589        assert!(after_first > before, "first write must bump the revision");
2590        // Identical text must NOT bump the revision (fast path).
2591        store.mirror_text(&u, "<?php echo 1;");
2592        assert_eq!(
2593            store.write_rev(),
2594            after_first,
2595            "identical text must not bump the revision"
2596        );
2597        // Different text must bump again.
2598        store.mirror_text(&u, "<?php echo 2;");
2599        assert!(
2600            store.write_rev() > after_first,
2601            "changed text must bump the revision"
2602        );
2603    }
2604
2605    /// begin_reanalyze() cancels the previously-issued sweep token so a newer
2606    /// edit preempts an in-flight dependent walk, and hands back a fresh token.
2607    #[test]
2608    fn begin_reanalyze_cancels_previous_token() {
2609        let store = DocumentStore::new();
2610        let first = store.begin_reanalyze();
2611        assert!(!first.is_cancelled(), "a fresh token starts un-cancelled");
2612        let second = store.begin_reanalyze();
2613        assert!(
2614            first.is_cancelled(),
2615            "issuing a new token must cancel the previous in-flight sweep"
2616        );
2617        assert!(
2618            !second.is_cancelled(),
2619            "the newly-issued token must itself be un-cancelled"
2620        );
2621    }
2622
2623    /// write_rev() increments when a file is removed.
2624    #[test]
2625    fn write_rev_increments_on_remove() {
2626        let store = DocumentStore::new();
2627        let u = uri("/rev_remove.php");
2628        store.mirror_text(&u, "<?php class A {}");
2629        let before = store.write_rev();
2630        store.remove(&u);
2631        assert!(store.write_rev() > before, "remove must bump the revision");
2632    }
2633}