Skip to main content

php_lsp/document/
document_store.rs

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