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