Skip to main content

mir_analyzer/
session.rs

1//! Session-based analysis API for incremental, per-file analysis.
2//!
3//! [`AnalysisSession`] owns the salsa database and per-session caches for a
4//! long-running analysis context shared across many per-file analyses. Reads
5//! clone the database under a brief lock, then run lock-free; writes hold the
6//! lock briefly to mutate canonical state. `MirDbStorage::clone()` is cheap
7//! (Arc-wrapped registries), so this pattern gives parallel readers without
8//! blocking on concurrent writes for longer than the clone itself.
9//!
10//! See [`crate::file_analyzer::FileAnalyzer`] for the per-file analysis
11//! entry point that operates against a session.
12
13use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet};
14use std::path::PathBuf;
15use std::sync::Arc;
16
17use parking_lot::RwLock;
18
19use crate::analyzer_db::AnalyzerDb;
20use crate::cache::AnalysisCache;
21use crate::composer::Psr4Map;
22use crate::db::{MirDatabase, MirDbStorage, RefLoc};
23use crate::php_version::PhpVersion;
24
25/// Long-lived analysis context. Owns the salsa database and tracks which
26/// stubs have been loaded.
27///
28/// Cheap to clone the inner db for parallel reads; writes funnel through
29/// [`Self::ingest_file`], [`Self::invalidate_file`], and the crate-internal
30/// [`Self::with_db_mut`].
31#[derive(Clone)]
32pub struct AnalysisSession {
33    /// Shared database management (salsa, file registry, stub tracking).
34    pub(crate) db: Arc<AnalyzerDb>,
35    pub(crate) cache: Option<Arc<AnalysisCache>>,
36    /// PSR-4 / Composer autoload map. Retained alongside `resolver` so the
37    /// `psr4()` accessor can still return a typed `Psr4Map` for callers that
38    /// need Composer-specific data (project_files / vendor_files / etc.).
39    pub(crate) psr4: Option<Arc<Psr4Map>>,
40    /// Generic class resolver used for on-demand lazy loading. When `psr4`
41    /// is set via [`Self::with_psr4`], this is populated with the same map
42    /// re-typed as `dyn ClassResolver`. Consumers can also supply their own
43    /// resolver via [`Self::with_class_resolver`] without going through
44    /// Composer.
45    resolver: Option<Arc<dyn crate::ClassResolver>>,
46    pub(crate) php_version: PhpVersion,
47    pub(crate) user_stub_files: Vec<PathBuf>,
48    pub(crate) user_stub_dirs: Vec<PathBuf>,
49    /// Tracks symbols that were previously defined in a file but have since
50    /// been removed (deleted or renamed). When `ingest_file` detects that
51    /// a symbol disappears, it records it here so `dependency_graph()` can
52    /// still produce edges to files that reference the now-gone symbol.
53    ///
54    /// Keyed by the file that used to define the symbols. Symbols are removed
55    /// from the set when re-added to the same file on a subsequent ingest.
56    /// The set may contain symbols with no current referencers; those are
57    /// harmless — the `symbol_referencers_of` lookup returns empty.
58    stale_defined_symbols: Arc<RwLock<HashMap<String, HashSet<Arc<str>>>>>,
59    /// Negative cache: FQCNs that `load_class` already failed on.
60    /// The value is the resolver-mapped path (when known) so eviction on
61    /// `set_file_text` / `ingest_file` is a path equality check rather than
62    /// re-running the resolver per entry. `None` means the resolver itself
63    /// couldn't map the FQCN; those entries survive file edits (no source
64    /// change makes a never-resolvable name resolvable).
65    /// Bounded to `UNRESOLVABLE_CACHE_CAP`; clears on overflow.
66    unresolvable_fqcns: UnresolvableCache,
67    /// Pluggable source-text provider for lazy-load. Defaults to filesystem
68    /// reads ([`crate::FsSourceProvider`]); LSPs swap in a VFS-backed
69    /// implementation so unsaved buffers override on-disk content.
70    source_provider: Arc<dyn crate::SourceProvider>,
71}
72
73/// FQCN → optional resolver-mapped path. See the field doc on
74/// `AnalysisSession::unresolvable_fqcns`.
75type UnresolvableCache = Arc<RwLock<HashMap<Arc<str>, Option<Arc<str>>>>>;
76
77/// Cap on the negative-resolution cache. Sized to accommodate a large
78/// workspace's worth of genuinely-missing references without unbounded
79/// growth. On overflow the cache is cleared; the cost is a few extra
80/// resolver calls until it re-fills.
81const UNRESOLVABLE_CACHE_CAP: usize = 10_000;
82
83impl AnalysisSession {
84    /// Create a session targeting the given PHP language version.
85    pub fn new(php_version: PhpVersion) -> Self {
86        let db = Arc::new(AnalyzerDb::new());
87        db.salsa
88            .write()
89            .set_php_version(Arc::from(php_version.to_string().as_str()));
90        Self {
91            db,
92            cache: None,
93            psr4: None,
94            resolver: None,
95            php_version,
96            user_stub_files: Vec::new(),
97            user_stub_dirs: Vec::new(),
98            stale_defined_symbols: Arc::new(RwLock::new(HashMap::default())),
99            unresolvable_fqcns: Arc::new(RwLock::new(HashMap::default())),
100            source_provider: Arc::new(crate::FsSourceProvider),
101        }
102    }
103
104    /// Swap in a custom [`crate::SourceProvider`]. LSPs install a VFS-backed
105    /// provider here so the analyzer reads from unsaved editor buffers
106    /// instead of disk.
107    pub fn with_source_provider(mut self, provider: Arc<dyn crate::SourceProvider>) -> Self {
108        self.source_provider = provider;
109        self
110    }
111
112    /// Attach a pre-built [`AnalysisCache`] (the body-analysis issue cache) and
113    /// open a sibling definition [`StubSlice`] cache under the same root, so
114    /// callers using this builder get the same speedup as `with_cache_dir`.
115    ///
116    /// Rebuilds the shared database to attach the definition cache — call
117    /// **before** any file is ingested. A debug assertion catches misuse.
118    ///
119    /// [`StubSlice`]: mir_codebase::storage::StubSlice
120    pub fn with_cache(mut self, cache: Arc<AnalysisCache>) -> Self {
121        debug_assert_eq!(
122            self.db.source_file_count(),
123            0,
124            "AnalysisSession::with_cache must be called before any file is ingested"
125        );
126        let dir = cache.cache_dir().to_path_buf();
127        self.db = Arc::new(AnalyzerDb::new().with_cache_dir(&dir));
128        self.db
129            .salsa
130            .write()
131            .set_php_version(Arc::from(self.php_version.to_string().as_str()));
132        self.cache = Some(cache);
133        self
134    }
135
136    /// Convenience: open a disk-backed cache at `cache_dir` and attach it.
137    ///
138    /// Attaches both the body-analysis issue cache ([`AnalysisCache`]) and the
139    /// definition [`StubSlice`] cache to the shared database. Builds a fresh
140    /// [`AnalyzerDb`] internally — call **before** any file is ingested. A
141    /// debug assertion catches misuse.
142    ///
143    /// [`StubSlice`]: mir_codebase::storage::StubSlice
144    pub fn with_cache_dir(mut self, cache_dir: &std::path::Path) -> Self {
145        debug_assert_eq!(
146            self.db.source_file_count(),
147            0,
148            "AnalysisSession::with_cache_dir must be called before any file is ingested"
149        );
150        self.db = Arc::new(AnalyzerDb::new().with_cache_dir(cache_dir));
151        self.db
152            .salsa
153            .write()
154            .set_php_version(Arc::from(self.php_version.to_string().as_str()));
155        // Fold the user-stub fingerprint into the cache epoch. `with_user_stubs`
156        // must run before this for it to be picked up (it does in `build_session`);
157        // sessions without user stubs get 0, which is correct.
158        let user_stub_fp =
159            crate::stubs::user_stub_fingerprint(&self.user_stub_files, &self.user_stub_dirs);
160        self.cache = Some(Arc::new(AnalysisCache::open(
161            cache_dir,
162            self.php_version.cache_byte(),
163            user_stub_fp,
164        )));
165        self
166    }
167
168    /// Attach a Composer autoload map (PSR-4, PSR-0, classmap, files).
169    /// Sets the same map as the active [`crate::ClassResolver`] so
170    /// [`Self::load_class`] works out of the box.
171    pub fn with_psr4(mut self, map: Arc<Psr4Map>) -> Self {
172        let user_resolver: Arc<dyn crate::ClassResolver> = map.clone();
173        // Wrap with stub awareness so `find_class_like` / `resolve_fqcn_to_path`
174        // can map built-in PHP class FQCNs (`ArrayObject`, `Exception`, …)
175        // to their stub virtual paths.
176        let resolver: Arc<dyn crate::ClassResolver> = Arc::new(crate::ChainedClassResolver::new(
177            user_resolver,
178            Arc::new(crate::StubClassResolver),
179        ));
180        self.psr4 = Some(map);
181        self.resolver = Some(resolver.clone());
182        // Mirror into MirDbStorage so salsa-tracked resolver queries
183        // (`db::resolve_fqcn_to_path`) see the same resolver and are
184        // invalidated on swap.
185        self.db.salsa.write().set_resolver(Some(resolver));
186        self
187    }
188
189    /// Attach a generic class resolver for projects that don't use Composer
190    /// (WordPress, Drupal, custom autoloaders, workspace-walk indexes).
191    /// Replaces any previously-set Composer-backed resolver. Automatically
192    /// wrapped with stub awareness so PHP built-ins remain resolvable.
193    pub fn with_class_resolver(mut self, resolver: Arc<dyn crate::ClassResolver>) -> Self {
194        let wrapped: Arc<dyn crate::ClassResolver> = Arc::new(crate::ChainedClassResolver::new(
195            resolver,
196            Arc::new(crate::StubClassResolver),
197        ));
198        self.db.salsa.write().set_resolver(Some(wrapped.clone()));
199        self.resolver = Some(wrapped);
200        self
201    }
202
203    pub fn with_user_stubs(mut self, files: Vec<PathBuf>, dirs: Vec<PathBuf>) -> Self {
204        self.user_stub_files = files;
205        self.user_stub_dirs = dirs;
206        self
207    }
208
209    pub fn php_version(&self) -> PhpVersion {
210        self.php_version
211    }
212
213    pub fn cache(&self) -> Option<&AnalysisCache> {
214        self.cache.as_deref()
215    }
216
217    pub fn psr4(&self) -> Option<&Psr4Map> {
218        self.psr4.as_deref()
219    }
220
221    /// Deprecated — stub loading is now fully lazy per-AST.
222    ///
223    /// This is an alias for [`Self::ensure_all_stubs`] kept for API
224    /// compatibility. Internal analysis paths use [`Self::prepare_ast_for_analysis`]
225    /// which loads only the stubs referenced by the file under analysis.
226    #[deprecated(note = "use ensure_all_stubs() or ensure_stubs_for_ast() instead")]
227    pub fn ensure_essential_stubs(&self) {
228        self.ensure_all_stubs();
229    }
230
231    /// Load every embedded PHP stub plus any configured user stubs.
232    /// Use for batch tools (CLI, full project analysis) where comprehensive
233    /// symbol coverage matters more than cold-start latency.
234    pub fn ensure_all_stubs(&self) {
235        let paths: Vec<&'static str> = crate::stubs::stub_files().iter().map(|&(p, _)| p).collect();
236        self.db.ingest_stub_paths(&paths, self.php_version);
237        self.ensure_user_stubs_loaded();
238    }
239
240    /// Ensure the embedded stub that defines `name` (a function) is ingested.
241    /// Returns `true` when a matching stub exists (whether or not it was
242    /// already loaded), `false` when `name` isn't a known PHP built-in.
243    ///
244    /// Most callers should use [`Self::ensure_stubs_for_ast`] instead —
245    /// it auto-discovers needed stubs from a parsed file.
246    #[doc(hidden)]
247    pub fn ensure_stub_for_function(&self, name: &str) -> bool {
248        match crate::stubs::stub_path_for_function(name) {
249            Some(path) => {
250                self.db.ingest_stub_paths(&[path], self.php_version);
251                true
252            }
253            None => false,
254        }
255    }
256
257    /// Ensure the embedded stub that defines `fqcn` (a class / interface /
258    /// trait / enum) is ingested. Case-insensitive lookup with optional
259    /// leading backslash.
260    ///
261    /// Most callers should use [`Self::ensure_stubs_for_ast`] instead.
262    #[doc(hidden)]
263    pub fn ensure_stub_for_class(&self, fqcn: &str) -> bool {
264        match crate::stubs::stub_path_for_class(fqcn) {
265            Some(path) => {
266                self.db.ingest_stub_paths(&[path], self.php_version);
267                true
268            }
269            None => false,
270        }
271    }
272
273    /// Ensure the embedded stub that defines `name` (a constant) is ingested.
274    ///
275    /// Most callers should use [`Self::ensure_stubs_for_ast`] instead.
276    #[doc(hidden)]
277    pub fn ensure_stub_for_constant(&self, name: &str) -> bool {
278        match crate::stubs::stub_path_for_constant(name) {
279            Some(path) => {
280                self.db.ingest_stub_paths(&[path], self.php_version);
281                true
282            }
283            None => false,
284        }
285    }
286
287    /// Number of distinct embedded stubs currently ingested into the session.
288    /// Useful for diagnostics and bench reporting.
289    pub fn loaded_stub_count(&self) -> usize {
290        self.db.loaded_stubs.lock().len()
291    }
292
293    /// Auto-discover and ingest the embedded stubs needed to cover every
294    /// built-in PHP function / class / constant referenced by `source`.
295    ///
296    /// Used by [`crate::FileAnalyzer::analyze`] to keep essentials-only mode
297    /// correct without forcing callers to enumerate which stubs they need.
298    /// Idempotent — already-loaded stubs are skipped via [`Self::loaded_stubs`].
299    ///
300    /// The discovery scan is a coarse identifier sweep (see
301    /// [`crate::stubs::collect_referenced_builtin_paths`]) — it may pull in
302    /// a slightly larger set than the file strictly needs, but never misses
303    /// a referenced built-in. Cost is sub-millisecond per file.
304    ///
305    /// Fast path: if every embedded stub is already loaded (e.g. after a
306    /// batch tool called [`Self::ensure_all_stubs`]), the source scan
307    /// is skipped entirely.
308    pub fn ensure_stubs_for_source(&self, source: &str) {
309        // Cheap check first: skip the scan entirely when we already know we
310        // have everything. Avoids a ~50-500µs source walk on every analyze
311        // call in batch / warm-session scenarios.
312        {
313            let loaded = self.db.loaded_stubs.lock();
314            if loaded.len() >= crate::stubs::stub_files().len() {
315                return;
316            }
317        }
318        let paths = crate::stubs::collect_referenced_builtin_paths(source);
319        if paths.is_empty() {
320            return;
321        }
322        self.db.ingest_stub_paths(&paths, self.php_version);
323    }
324
325    /// Discover and ingest stubs by walking the parsed AST of a PHP file.
326    ///
327    /// Similar to [`Self::ensure_stubs_for_source`], but takes an already-parsed
328    /// AST instead of raw source text. Produces zero false positives since it
329    /// only extracts identifiers from actual AST nodes (not from strings or
330    /// comments). Preferred over `ensure_stubs_for_source` when the AST is
331    /// already available (e.g., in [`crate::FileAnalyzer`]).
332    ///
333    /// Idempotent and skips the scan if all stubs are already loaded.
334    pub fn ensure_stubs_for_ast(&self, program: &php_ast::owned::Program) {
335        {
336            let loaded = self.db.loaded_stubs.lock();
337            if loaded.len() >= crate::stubs::stub_files().len() {
338                return;
339            }
340        }
341        let paths = crate::stubs::collect_referenced_builtin_paths_from_ast(program);
342        if paths.is_empty() {
343            return;
344        }
345        self.db.ingest_stub_paths(&paths, self.php_version);
346    }
347
348    /// Returns true if this session has a configured class resolver
349    /// (typically a PSR-4 / classmap autoloader chained with the stub
350    /// resolver). Used by `FileAnalyzer` to skip the AST-scan preload
351    /// when no resolver is wired up.
352    pub fn has_resolver(&self) -> bool {
353        self.resolver.is_some()
354    }
355
356    /// Run both pre-passes (builtin-stub loading and PSR-4 class preloading)
357    /// in one call.  Replaces the two separate `ensure_stubs_for_ast` /
358    /// `preload_psr4_classes_for_ast` calls at every `FileAnalyzer::analyze`
359    /// site.
360    pub fn prepare_ast_for_analysis(&self, program: &php_ast::owned::Program, file: &str) {
361        self.ensure_stubs_for_ast(program);
362        self.priority_index_for_ast(program, file);
363    }
364
365    /// Priority-index the classes directly referenced by `file`'s AST.
366    ///
367    /// In the eager-static-input model the background indexer
368    /// ([`Self::index_batch`]) walks the whole vendor tree, but it may not have
369    /// reached every file the open buffer references yet. To avoid a transient
370    /// false `UndefinedClass` during the warm-up window, this **reorders** that
371    /// static work: it resolves the buffer's *direct* class references and
372    /// loads any not-yet-indexed ones immediately, jumping them to the front of
373    /// the queue.
374    ///
375    /// This is bounded by the number of distinct direct references in **one**
376    /// file — no transitive BFS, no depth/total budget, no pinning. Inheritance
377    /// ancestors and signature types of those classes are picked up by the
378    /// background walk (or, for navigation, by [`Self::hover`] /
379    /// [`Self::definition_of`]). Because `bump_workspace_revision` no longer
380    /// nulls the workspace index singleton, each [`Self::load_class`] here costs
381    /// only a resolver lookup + parse (or cache hit) + one tier-aware merge,
382    /// invalidating just the actively-analyzed file's memo once — not the whole
383    /// cache. Once background indexing completes this is a no-op (every
384    /// reference already resolves).
385    pub fn priority_index_for_ast(&self, program: &php_ast::owned::Program, file: &str) {
386        if self.resolver.is_none() {
387            return;
388        }
389        let refs = collect_class_refs_from_ast(program);
390        if refs.is_empty() {
391            return;
392        }
393        // Resolve names against the file's namespace/imports up front, then
394        // drop the snapshot before loading (which mutates inputs).
395        let resolved: Vec<String> = {
396            let db = self.snapshot_db();
397            refs.into_iter()
398                .map(|raw| crate::db::resolve_name(&db, file, &raw))
399                .collect()
400        };
401        for fqcn in resolved {
402            // load_class is a no-op when the class is already indexed (the
403            // common case once the background walk has passed this file).
404            self.load_class(&fqcn);
405        }
406    }
407
408    fn ensure_user_stubs_loaded(&self) {
409        self.db
410            .ingest_user_stubs(&self.user_stub_files, &self.user_stub_dirs);
411    }
412
413    /// Cheap clone of the salsa db for a read-only query. The lock is held
414    /// only for the duration of the clone, so concurrent readers never
415    /// serialize on each other or on writes for longer than the clone itself.
416    ///
417    /// **Internal API — exposes Salsa types.** Subject to change without
418    /// notice. Public consumers should use the typed query methods
419    /// ([`Self::definition_of`], [`Self::hover`], etc.) instead.
420    #[doc(hidden)]
421    pub fn snapshot_db(&self) -> MirDbStorage {
422        self.db.snapshot_db()
423    }
424
425    /// Commit a batch of reference locations from a db snapshot into the
426    /// session's shared maps.  Called by [`crate::FileAnalyzer`] and
427    /// [`crate::BatchFileAnalyzer`] after parallel body analysis to flush the pending
428    /// buffers that accumulate in worker db clones.
429    pub(crate) fn commit_ref_locs_batch(&self, locs: Vec<RefLoc>) {
430        if locs.is_empty() {
431            return;
432        }
433        let guard = self.db.salsa.read();
434        guard.commit_reference_locations_batch(locs);
435    }
436
437    /// Run a closure with read access to a database snapshot.
438    ///
439    /// **Internal API — exposes Salsa types.** Subject to change without
440    /// notice.
441    #[doc(hidden)]
442    pub fn read<R>(&self, f: impl FnOnce(&dyn MirDatabase) -> R) -> R {
443        let db = self.snapshot_db();
444        f(&db)
445    }
446
447    /// definition-collection ingestion. Updates the file's source text in the salsa db,
448    /// runs definition collection, and ingests the resulting stub slice.
449    /// Triggers stub loading on first call. Also updates the cache's reverse-
450    /// dependency graph for `file` so cross-file invalidation stays correct
451    /// across incremental edits — without rebuilding the graph from scratch.
452    ///
453    /// If `file` was previously ingested, its old definitions and reference
454    /// locations are removed first so renames / deletions don't leave stale
455    /// state in the codebase. (Without this, long-running sessions would
456    /// accumulate dead reference-location entries indefinitely.)
457    pub fn ingest_file(&self, file: Arc<str>, source: Arc<str>) {
458        self.ensure_all_stubs();
459
460        // Snapshot symbols defined before clearing — O(symbols_in_file) with forward index.
461        let old_symbols: HashSet<Arc<str>> = {
462            let guard = self.db.salsa.read();
463            guard.file_defined_symbols(file.as_ref())
464        };
465
466        {
467            let mut guard = self.db.salsa.write();
468            guard.remove_file_definitions(file.as_ref());
469        }
470        let _file_defs =
471            self.db
472                .collect_and_ingest_file(file.clone(), source.as_ref(), self.php_version);
473
474        // Snapshot symbols after ingesting — O(symbols_in_file).
475        let new_symbols: HashSet<Arc<str>> = {
476            let guard = self.db.salsa.read();
477            guard.file_defined_symbols(file.as_ref())
478        };
479
480        // Symbols removed from this file must be tracked so dependency_graph()
481        // can still produce edges to files referencing the now-gone symbols.
482        let deleted: Vec<Arc<str>> = old_symbols.difference(&new_symbols).cloned().collect();
483        let re_added: Vec<Arc<str>> = new_symbols.difference(&old_symbols).cloned().collect();
484        if !deleted.is_empty() || !re_added.is_empty() {
485            let mut stale = self.stale_defined_symbols.write();
486            let entry = stale.entry(file.as_ref().to_string()).or_default();
487            for sym in deleted {
488                entry.insert(sym);
489            }
490            for sym in &re_added {
491                entry.remove(sym);
492            }
493            if entry.is_empty() {
494                stale.remove(file.as_ref());
495            }
496        }
497
498        self.update_reverse_deps_for(&file);
499        // Evict cached analysis results for files that depend on this one so
500        // that the next re_analyze_file call re-analyses them rather than
501        // replaying a stale cache entry. Mirrors the eviction in
502        // `re_analyze_file` (batch.rs) but applies to the ingest path used by
503        // LSP servers that edit a single file without re-analysing it.
504        if let Some(cache) = self.cache.as_deref() {
505            cache.evict_with_dependents(&[file.to_string()]);
506        }
507        // Only evict cache entries whose resolver-mapped path equals this
508        // file. FQCNs the resolver can't map (psr4 miss) stay cached — no
509        // ingest could change their fate. Avoids the per-keystroke storm
510        // where wholesale clearing forces every unresolved FQCN to re-hit
511        // the resolver on the next FileAnalyzer iteration.
512        self.evict_unresolvable_for_file(&file);
513
514        // If the workspace symbol index singleton has already been built, keep
515        // it consistent with this edit *incrementally*: subtract the file's old
516        // declarations and add its new ones (tier-aware). Body-only edits are a
517        // no-op inside `update_workspace_index_for_file` (name-only
518        // FileDeclarations equality → no singleton write → the HIGH-durability
519        // dep does not invalidate body-analysis memos). Only the rare ambiguous
520        // case (a removed name still declared by another file, where this file
521        // owned the winning entry) falls back to a full O(N) rebuild.
522        {
523            let mut guard = self.db.salsa.write();
524            if guard.workspace_symbol_index_singleton().is_some() {
525                if let Some(sf) = guard.lookup_source_file(file.as_ref()) {
526                    if !guard.update_workspace_index_for_file(sf) {
527                        guard.rebuild_workspace_symbol_index();
528                    }
529                }
530            }
531        }
532    }
533
534    /// Register `source` as the text of `file` in the salsa input layer **without**
535    /// parsing or running definition collection.
536    ///
537    /// This is the LSP-friendly bulk-population entry point: after a workspace
538    /// scan, callers can feed every discovered file's text to the session
539    /// cheaply (an Arc clone plus a HashMap insert per file). Name resolution
540    /// then happens on demand via [`Self::load_class`], which reads
541    /// the file from disk through the configured [`crate::ClassResolver`] and
542    /// runs definition collection lazily when a class FQCN actually needs to resolve.
543    ///
544    /// Contrast with [`Self::ingest_file`], which eagerly parses, runs definition collection,
545    /// and populates the symbol index. Use `ingest_file` for files the user is
546    /// actively editing (where in-memory text diverges from disk); use
547    /// `set_file_text` for files known only through the workspace scan.
548    ///
549    /// Clears the negative cache: a previously-unresolvable FQCN may now
550    /// resolve if its defining file is among the newly-registered set.
551    pub fn set_file_text(&self, file: Arc<str>, source: Arc<str>) {
552        {
553            let mut guard = self.db.salsa.write();
554            guard.upsert_source_file(file.clone(), source);
555        }
556        self.evict_unresolvable_for_file(&file);
557    }
558
559    /// Bulk-register vendor / library files with HIGH salsa durability.
560    ///
561    /// HIGH-durability files are not expected to change during the session.
562    /// When a LOW-durability project file is edited, salsa can skip O(N)
563    /// dependency verification for every HIGH-durability file, reducing
564    /// `workspace_symbol_index` re-verification cost to O(project files only).
565    ///
566    /// Definition collection runs lazily on first symbol access; no parsing at call time.
567    pub fn set_vendor_files<I>(&self, files: I)
568    where
569        I: IntoIterator<Item = (Arc<str>, Arc<str>)>,
570    {
571        let mut guard = self.db.salsa.write();
572        for (file, source) in files {
573            guard.upsert_source_file_with_durability(file, source, salsa::Durability::HIGH);
574        }
575    }
576
577    /// Build or refresh the `WorkspaceSymbolIndexSingleton` from all currently
578    /// registered files.
579    ///
580    /// After this call, `find_class_like`, `find_function`, and
581    /// `find_global_constant` read `singleton.index(db)` — a single
582    /// `Durability::HIGH` tracked dep — instead of recomputing the full
583    /// O(N_files) dep list via `workspace_symbol_index`. On subsequent
584    /// LOW-durability (project-file) body edits the dep short-circuits in O(1).
585    ///
586    /// Call this once after all vendor + stub + project files have been
587    /// ingested (end of workspace warm-up). Also called automatically by
588    /// [`Self::ingest_file`] when a file's declared names change.
589    pub fn rebuild_workspace_symbol_index(&self) {
590        self.db.salsa.write().rebuild_workspace_symbol_index();
591    }
592
593    /// Bulk variant of [`Self::set_file_text`]. Acquires the salsa write lock
594    /// once for the entire batch instead of once per file.
595    ///
596    /// The intended LSP scan loop is:
597    /// ```text
598    /// let files: Vec<_> = walk_workspace()
599    ///     .map(|path| (path, fs::read(&path).unwrap()))
600    ///     .collect();
601    /// session.set_workspace_files(files);
602    /// ```
603    /// After this call, every file's source text is known to salsa. No
604    /// parsing has happened yet — Definition collection runs per file on the first
605    /// `load_class` that needs to consult it.
606    pub fn set_workspace_files<I>(&self, files: I)
607    where
608        I: IntoIterator<Item = (Arc<str>, Arc<str>)>,
609    {
610        let registered_paths: Vec<Arc<str>> = {
611            let mut guard = self.db.salsa.write();
612            files
613                .into_iter()
614                .map(|(file, source)| {
615                    guard.upsert_source_file(file.clone(), source);
616                    file
617                })
618                .collect()
619        };
620        if !registered_paths.is_empty() && self.resolver.is_some() {
621            self.evict_unresolvable_for_files(&registered_paths);
622        }
623    }
624
625    /// The workspace generation epoch — the rust-analyzer-style "are we up to
626    /// date" counter. Bumped whenever a file is added or removed. A consumer
627    /// records this alongside the diagnostics it publishes for a file; when the
628    /// value later advances (background indexing registered more files), those
629    /// files become candidates for re-analysis + re-publish.
630    pub fn index_generation(&self) -> u64 {
631        self.db.salsa.read().workspace_revision_value()
632    }
633
634    /// Index one bounded chunk of `(path, text)` files — the chunked background
635    /// indexing primitive.
636    ///
637    /// For each chunk this: (1) registers the files as `Durability::HIGH` salsa
638    /// inputs in one short write window, (2) parses them to prime the in-process
639    /// and on-disk declaration caches (in parallel when `parallelism ==
640    /// `[`IndexParallelism::Rayon`]; sequentially for wasm / single-thread
641    /// consumers), and (3) merges their declarations into the workspace symbol
642    /// index singleton **incrementally** (no full rebuild) so partially-indexed
643    /// symbols resolve immediately.
644    ///
645    /// The library spawns no thread: the consumer pumps chunks from its own
646    /// driver (LSP worker thread, or one chunk per wasm event-loop tick),
647    /// re-checking higher-priority work between calls. `cancel` is honoured at
648    /// chunk boundaries so an edit can abandon queued indexing cheaply.
649    ///
650    /// **Contract:** index the workspace *incrementally* through this method;
651    /// don't bulk-register the entire file set up front and then index — the
652    /// first call lazily seeds the singleton from the currently-registered set
653    /// (built-in stubs + this chunk), so keeping that initial set small keeps
654    /// the first call cheap. Call [`Self::finalize_index`] once after the last
655    /// chunk to reconcile authoritatively.
656    ///
657    /// **Responsiveness:** parsing / declaration collection happens off the
658    /// salsa write lock (on a snapshot); only the cheap symbol-map merge runs
659    /// under the lock, so the write window per chunk is short and an interactive
660    /// read on another thread blocks at most that long. Note that, per salsa's
661    /// snapshot model, a *cancellable query* in flight on another thread (e.g.
662    /// `hover`, `definition_of`, `FileAnalyzer::analyze`) when this batch takes
663    /// the write lock may unwind with `salsa::Cancelled`; a multi-threaded
664    /// consumer should catch that and retry the request (the rust-analyzer
665    /// pattern). A single-threaded consumer that interleaves requests *between*
666    /// `index_batch` calls never observes cancellation.
667    pub fn index_batch(
668        &self,
669        files: &[(Arc<str>, Arc<str>)],
670        parallelism: crate::IndexParallelism,
671        cancel: &crate::IndexCancel,
672    ) -> crate::IndexBatchOutcome {
673        if files.is_empty() || cancel.is_cancelled() {
674            return crate::IndexBatchOutcome {
675                registered: 0,
676                cancelled: cancel.is_cancelled(),
677                generation: self.index_generation(),
678            };
679        }
680        self.ensure_all_stubs();
681
682        // 1. Register the chunk as HIGH-durability inputs — one short write
683        //    window, then release the lock so interactive requests interleave.
684        let sources: Vec<crate::db::SourceFile> = {
685            let mut guard = self.db.salsa.write();
686            files
687                .iter()
688                .map(|(file, source)| {
689                    guard.upsert_source_file_with_durability(
690                        file.clone(),
691                        source.clone(),
692                        salsa::Durability::HIGH,
693                    )
694                })
695                .collect()
696        };
697        let registered = sources.len();
698
699        if cancel.is_cancelled() {
700            return crate::IndexBatchOutcome {
701                registered,
702                cancelled: true,
703                generation: self.index_generation(),
704            };
705        }
706
707        // Is this the seed chunk (no singleton yet)? If so we must collect decls
708        // for the whole currently-registered set (stubs + this chunk); otherwise
709        // just this chunk.
710        let seed = self
711            .db
712            .salsa
713            .read()
714            .workspace_symbol_index_singleton()
715            .is_none();
716        let snap = self.db.snapshot_db();
717        let to_collect: Vec<crate::db::SourceFile> = if seed {
718            snap.all_source_files()
719        } else {
720            sources.clone()
721        };
722
723        // 2. Collect per-file declarations OFF the write lock (on a snapshot).
724        //    This is where parsing happens — crucially NOT while holding the
725        //    write lock, so concurrent interactive reads are not blocked for the
726        //    parse duration. Also primes the shared parse/disk caches.
727        let collect_one = |db: &crate::db::MirDbStorage, sf: crate::db::SourceFile| {
728            (sf, crate::db::collect_file_declarations(db, sf))
729        };
730        let decls: Vec<(crate::db::SourceFile, crate::db::FileDeclarations)> =
731            if parallelism == crate::IndexParallelism::Rayon {
732                use rayon::prelude::*;
733                to_collect
734                    .par_iter()
735                    .map_with(snap.clone(), |db, &sf| collect_one(db, sf))
736                    .collect()
737            } else {
738                to_collect
739                    .iter()
740                    .map(|&sf| collect_one(&snap, sf))
741                    .collect()
742            };
743        drop(snap);
744
745        if cancel.is_cancelled() {
746            return crate::IndexBatchOutcome {
747                registered,
748                cancelled: true,
749                generation: self.index_generation(),
750            };
751        }
752
753        // 3. Apply to the singleton under a SHORT write window — only cheap map
754        //    construction / merge runs here (no parse).
755        {
756            let mut guard = self.db.salsa.write();
757            if guard.workspace_symbol_index_singleton().is_none() {
758                guard.build_workspace_index_from_decls(decls);
759            } else {
760                guard.merge_precomputed_into_workspace_index(&decls);
761            }
762        }
763
764        crate::IndexBatchOutcome {
765            registered,
766            cancelled: cancel.is_cancelled(),
767            generation: self.index_generation(),
768        }
769    }
770
771    /// Authoritative full rebuild of the workspace symbol index. Call once
772    /// after the consumer has pumped every [`Self::index_batch`] chunk (end of
773    /// warm-up) to reconcile the incrementally-merged index against the full
774    /// registered set. Cheap after indexing — every file's declarations are
775    /// already cached.
776    pub fn finalize_index(&self) {
777        self.db.salsa.write().rebuild_workspace_symbol_index();
778    }
779
780    /// Drop a file's contribution to the session: codebase definitions,
781    /// reference locations, salsa input handle, cache entry, and outgoing
782    /// reverse-dependency edges. Cache entries of *dependent* files are
783    /// also evicted (cross-file invalidation).
784    ///
785    /// Use this when a file is closed by the consumer, or before a re-ingest
786    /// of substantially changed content. (Plain re-ingest via
787    /// [`Self::ingest_file`] also drops old definitions, but does not
788    /// remove the salsa input handle — call this for full cleanup.)
789    pub fn invalidate_file(&self, file: &str) {
790        {
791            let mut guard = self.db.salsa.write();
792            guard.remove_file_definitions(file);
793            guard.remove_source_file(file);
794        }
795        // Outgoing structural edges disappear from the derived graph
796        // automatically: the file is no longer in `source_file_paths()`, so
797        // `dependency_graph()` stops iterating it.
798        // Clear stale symbol tracking for this file — it's fully gone.
799        self.stale_defined_symbols.write().remove(file);
800        if let Some(cache) = &self.cache {
801            cache.update_reverse_deps_for_file(file, &HashSet::default());
802            cache.evict_with_dependents(&[file.to_string()]);
803        }
804        // The file is gone; cache entries that previously mapped to it stay
805        // unresolvable until the file (or another with matching symbols) is
806        // ingested again. Selective evict mirrors the ingest path.
807        self.evict_unresolvable_for_file(file);
808        // Vendor files are static in the eager-index model — closing a project
809        // buffer never evicts them (no per-file pinning). Memory is bounded by
810        // the LRU on `collect_file_definitions` and the parse cache instead.
811    }
812
813    /// Number of files currently tracked in this session's salsa input set.
814    /// Stable across reads; useful for diagnostics and memory bounds checks.
815    pub fn tracked_file_count(&self) -> usize {
816        let guard = self.db.salsa.read();
817        guard.source_file_count()
818    }
819
820    // -----------------------------------------------------------------------
821    // Read-only codebase queries
822    //
823    // All take a brief lock to clone the db, then run the lookup against the
824    // owned snapshot — concurrent edits proceed without blocking.
825    // -----------------------------------------------------------------------
826
827    /// Resolve a top-level symbol (class or function) to its declaration
828    /// location. Powers go-to-definition.
829    ///
830    /// **Side effects:** if the symbol isn't yet known, this may invoke the
831    /// configured [`crate::SourceProvider`] to fault in additional files and
832    /// mutate the salsa input set. Use [`Self::definition_of_cached`] for a
833    /// pure variant that only consults already-loaded state.
834    ///
835    /// Returns:
836    /// - `Ok(Location)` — symbol found with a source location
837    /// - `Err(NotFound)` — no such symbol in the codebase
838    /// - `Err(NoSourceLocation)` — symbol exists but has no recorded span
839    ///   (e.g. some stub-only declarations)
840    pub fn definition_of(
841        &self,
842        symbol: &crate::Name,
843    ) -> Result<mir_types::Location, crate::SymbolLookupError> {
844        // Trigger any necessary lazy-load mutations before snapshotting.
845        match symbol {
846            crate::Name::Class(fqcn) => {
847                let _ = self.load_class(fqcn.as_ref());
848            }
849            crate::Name::Function(fqn) => {
850                let _ = self.load_class(fqn.as_ref());
851            }
852            crate::Name::Method { class, .. }
853            | crate::Name::Property { class, .. }
854            | crate::Name::ClassConstant { class, .. } => {
855                let _ = self.load_class(class.as_ref());
856            }
857            _ => {}
858        }
859        self.definition_of_cached(symbol)
860    }
861
862    /// Pure variant of [`Self::definition_of`]. Never invokes the
863    /// [`crate::SourceProvider`] and never mutates salsa inputs; resolves
864    /// only against state already loaded by `set_file_text` / `ingest_file`.
865    /// Returns `Err(NotFound)` when the symbol isn't in the loaded set, even
866    /// if a resolver could in principle map it.
867    pub fn definition_of_cached(
868        &self,
869        symbol: &crate::Name,
870    ) -> Result<mir_types::Location, crate::SymbolLookupError> {
871        let db = self.snapshot_db();
872        match symbol {
873            crate::Name::Class(fqcn) => {
874                let here = crate::db::Fqcn::from_str(&db, fqcn.as_ref());
875                let class = crate::db::find_class_like(&db, here)
876                    .ok_or(crate::SymbolLookupError::NotFound)?;
877                class
878                    .location()
879                    .cloned()
880                    .ok_or(crate::SymbolLookupError::NoSourceLocation)
881            }
882            crate::Name::Function(fqn) => {
883                let here = crate::db::Fqcn::from_str(&db, fqn.as_ref());
884                let f = crate::db::find_function(&db, here)
885                    .ok_or(crate::SymbolLookupError::NotFound)?;
886                f.location
887                    .clone()
888                    .ok_or(crate::SymbolLookupError::NoSourceLocation)
889            }
890            crate::Name::Method { class, name }
891            | crate::Name::Property { class, name }
892            | crate::Name::ClassConstant { class, name } => {
893                crate::db::member_location(&db, class, name)
894                    .ok_or(crate::SymbolLookupError::NotFound)
895            }
896            crate::Name::GlobalConstant(_) => Err(crate::SymbolLookupError::NoSourceLocation),
897        }
898    }
899
900    /// Hover information for a symbol: type, docstring, and definition location.
901    ///
902    /// Use [`crate::FileAnalysis::symbol_at`] to find the symbol at a cursor
903    /// position, then build a [`crate::Name`] from its `kind`. This method
904    /// assembles the displayable hover data.
905    ///
906    /// **Side effects:** when `symbol`'s owning class isn't yet loaded, this
907    /// may invoke the configured [`crate::SourceProvider`] to fault in
908    /// dependencies. Use [`Self::hover_cached`] for a pure variant.
909    ///
910    /// Returns `Err(NotFound)` if the symbol doesn't exist. May still return
911    /// `Ok` with `docstring: None` or `definition: None` if those specific
912    /// pieces aren't available.
913    pub fn hover(
914        &self,
915        symbol: &crate::Name,
916    ) -> Result<crate::HoverInfo, crate::SymbolLookupError> {
917        // Trigger lazy loading for class-rooted symbols before snapshotting.
918        // No-op when the class is already known; ensures inherited member
919        // lookups have the chain present.
920        match symbol {
921            crate::Name::Class(fqcn) => {
922                self.load_class(fqcn.as_ref());
923            }
924            crate::Name::Method { class, .. }
925            | crate::Name::Property { class, .. }
926            | crate::Name::ClassConstant { class, .. } => {
927                // Fault in the owning class for navigation if the background
928                // indexer hasn't reached it yet. Its inheritance ancestors
929                // resolve through the (eagerly-built) workspace symbol index.
930                self.load_class(class.as_ref());
931            }
932            _ => {}
933        }
934        self.hover_cached(symbol)
935    }
936
937    /// Pure variant of [`Self::hover`]. Never invokes the
938    /// [`crate::SourceProvider`]; consults only the already-loaded db.
939    pub fn hover_cached(
940        &self,
941        symbol: &crate::Name,
942    ) -> Result<crate::HoverInfo, crate::SymbolLookupError> {
943        use mir_types::{Atomic, Type};
944        let db = self.snapshot_db();
945        match symbol {
946            crate::Name::Function(fqn) => {
947                let here = crate::db::Fqcn::from_str(&db, fqn.as_ref());
948                let f = crate::db::find_function(&db, here)
949                    .ok_or(crate::SymbolLookupError::NotFound)?;
950                let ty = f
951                    .return_type
952                    .as_deref()
953                    .cloned()
954                    .unwrap_or_else(Type::mixed);
955                let docstring = f.docstring.as_ref().map(|s| s.to_string());
956                Ok(crate::HoverInfo {
957                    ty,
958                    docstring,
959                    definition: f.location.clone(),
960                })
961            }
962            crate::Name::Method { class, name } => {
963                let here = crate::db::Fqcn::from_str(&db, class.as_ref());
964                let (_, m) = crate::db::find_method_in_chain(&db, here, name)
965                    .ok_or(crate::SymbolLookupError::NotFound)?;
966                let ty = m
967                    .return_type
968                    .as_deref()
969                    .cloned()
970                    .unwrap_or_else(Type::mixed);
971                let docstring = m.docstring.as_ref().map(|s| s.to_string());
972                Ok(crate::HoverInfo {
973                    ty,
974                    docstring,
975                    definition: m.location.clone(),
976                })
977            }
978            crate::Name::Class(fqcn) => {
979                let here = crate::db::Fqcn::from_str(&db, fqcn.as_ref());
980                let class = crate::db::find_class_like(&db, here)
981                    .ok_or(crate::SymbolLookupError::NotFound)?;
982                let ty = Type::single(Atomic::TNamedObject {
983                    fqcn: mir_types::Name::from(fqcn.as_ref()),
984                    type_params: mir_types::union::empty_type_params(),
985                });
986                Ok(crate::HoverInfo {
987                    ty,
988                    docstring: None,
989                    definition: class.location().cloned(),
990                })
991            }
992            crate::Name::Property { class, name } => {
993                let here = crate::db::Fqcn::from_str(&db, class.as_ref());
994                let (_, p) = crate::db::find_property_in_chain(&db, here, name)
995                    .ok_or(crate::SymbolLookupError::NotFound)?;
996                let ty = p.ty.as_deref().cloned().unwrap_or_else(Type::mixed);
997                Ok(crate::HoverInfo {
998                    ty,
999                    docstring: None,
1000                    definition: p.location.clone(),
1001                })
1002            }
1003            crate::Name::ClassConstant { class, name } => {
1004                let here = crate::db::Fqcn::from_str(&db, class.as_ref());
1005                let (_, c) = crate::db::find_class_constant_in_chain(&db, here, name)
1006                    .ok_or(crate::SymbolLookupError::NotFound)?;
1007                Ok(crate::HoverInfo {
1008                    ty: c.ty.clone(),
1009                    docstring: None,
1010                    definition: c.location.clone(),
1011                })
1012            }
1013            crate::Name::GlobalConstant(fqn) => {
1014                let here = crate::db::Fqcn::from_str(&db, fqn.as_ref());
1015                let ty = crate::db::find_global_constant(&db, here)
1016                    .ok_or(crate::SymbolLookupError::NotFound)?;
1017                Ok(crate::HoverInfo {
1018                    ty: (*ty).clone(),
1019                    docstring: None,
1020                    definition: None,
1021                })
1022            }
1023        }
1024    }
1025
1026    /// Raw reference locations indexed by string symbol key, kept for tests
1027    /// that use the legacy stringly-typed API. Prefer [`Self::references_to`]
1028    /// with a typed [`crate::Name`].
1029    #[doc(hidden)]
1030    pub fn reference_locations(&self, symbol: &str) -> Vec<(Arc<str>, u32, u16, u16)> {
1031        use crate::db::MirDatabase;
1032        let db = self.snapshot_db();
1033        db.reference_locations(symbol)
1034    }
1035
1036    /// Every recorded reference to `symbol` with its source location as a Range.
1037    /// Use [`crate::FileAnalysis::symbol_at`] to find the symbol at a cursor,
1038    /// build a [`crate::Name`] from it, and pass it here.
1039    pub fn references_to(&self, symbol: &crate::Name) -> Vec<(Arc<str>, crate::Range)> {
1040        let db = self.snapshot_db();
1041        let key = symbol.codebase_key();
1042        db.reference_locations(&key)
1043            .into_iter()
1044            .map(|(file, line, col_start, col_end)| {
1045                let range = crate::Range {
1046                    start: crate::Position {
1047                        line,
1048                        column: col_start as u32,
1049                    },
1050                    end: crate::Position {
1051                        line,
1052                        column: col_end as u32,
1053                    },
1054                };
1055                (file, range)
1056            })
1057            .collect()
1058    }
1059
1060    /// Class-level issues (inheritance violations, abstract-method gaps, override
1061    /// incompatibilities) for the given set of files.
1062    ///
1063    /// These checks are cross-file by nature and are not emitted by
1064    /// [`crate::FileAnalyzer::analyze`]. Call this after ingesting or
1065    /// re-analyzing a file and its dependents to get the full diagnostic picture.
1066    ///
1067    /// Circular-inheritance checks always run against the full workspace graph
1068    /// regardless of the `files` filter — a cycle is a workspace-wide problem.
1069    pub fn class_issues(&self, files: &[Arc<str>]) -> Vec<crate::Issue> {
1070        let db = self.snapshot_db();
1071        let file_set: HashSet<Arc<str>> = files.iter().cloned().collect();
1072        let file_data: Vec<(Arc<str>, Arc<str>)> = files
1073            .iter()
1074            .filter_map(|f| Some((f.clone(), self.source_of(f)?)))
1075            .collect();
1076        crate::class::ClassAnalyzer::with_files(&db, file_set, &file_data).analyze_all()
1077    }
1078
1079    /// All declarations defined in `file` as a **hierarchical tree**.
1080    ///
1081    /// Classes/interfaces/traits/enums are returned with their methods,
1082    /// properties, and constants nested in `children`. Top-level functions
1083    /// and constants are returned with empty `children`.
1084    pub fn document_symbols(&self, file: &str) -> Vec<crate::symbol::DocumentSymbol> {
1085        use crate::symbol::{DeclarationKind, DocumentSymbol};
1086
1087        let db = self.snapshot_db();
1088        let Some(sf) = db.lookup_source_file(file) else {
1089            return Vec::new();
1090        };
1091        let defs = crate::db::collect_file_definitions(&db, sf);
1092        let mut out: Vec<DocumentSymbol> = Vec::new();
1093
1094        let class_children =
1095            |methods: &indexmap::IndexMap<Arc<str>, Arc<mir_codebase::storage::MethodDef>>,
1096             props: Option<&indexmap::IndexMap<Arc<str>, mir_codebase::storage::PropertyDef>>,
1097             consts: &indexmap::IndexMap<Arc<str>, mir_codebase::storage::ConstantDef>,
1098             is_enum: bool|
1099             -> Vec<DocumentSymbol> {
1100                let mut out: Vec<DocumentSymbol> = Vec::new();
1101                for (_, m) in methods.iter() {
1102                    out.push(DocumentSymbol {
1103                        name: m.name.clone(),
1104                        kind: DeclarationKind::Method,
1105                        location: m.location.clone(),
1106                        children: Vec::new(),
1107                    });
1108                }
1109                if let Some(props) = props {
1110                    for (_, p) in props.iter() {
1111                        out.push(DocumentSymbol {
1112                            name: p.name.clone(),
1113                            kind: DeclarationKind::Property,
1114                            location: p.location.clone(),
1115                            children: Vec::new(),
1116                        });
1117                    }
1118                }
1119                let const_kind = if is_enum {
1120                    DeclarationKind::EnumCase
1121                } else {
1122                    DeclarationKind::Constant
1123                };
1124                for (_, c) in consts.iter() {
1125                    out.push(DocumentSymbol {
1126                        name: c.name.clone(),
1127                        kind: const_kind,
1128                        location: c.location.clone(),
1129                        children: Vec::new(),
1130                    });
1131                }
1132                out
1133            };
1134
1135        for c in defs.slice.classes.iter() {
1136            out.push(DocumentSymbol {
1137                name: c.fqcn.clone(),
1138                kind: DeclarationKind::Class,
1139                location: c.location.clone(),
1140                children: class_children(
1141                    &c.own_methods,
1142                    Some(&c.own_properties),
1143                    &c.own_constants,
1144                    false,
1145                ),
1146            });
1147        }
1148        for i in defs.slice.interfaces.iter() {
1149            out.push(DocumentSymbol {
1150                name: i.fqcn.clone(),
1151                kind: DeclarationKind::Interface,
1152                location: i.location.clone(),
1153                children: class_children(&i.own_methods, None, &i.own_constants, false),
1154            });
1155        }
1156        for t in defs.slice.traits.iter() {
1157            out.push(DocumentSymbol {
1158                name: t.fqcn.clone(),
1159                kind: DeclarationKind::Trait,
1160                location: t.location.clone(),
1161                children: class_children(
1162                    &t.own_methods,
1163                    Some(&t.own_properties),
1164                    &t.own_constants,
1165                    false,
1166                ),
1167            });
1168        }
1169        for e in defs.slice.enums.iter() {
1170            let mut children = class_children(&e.own_methods, None, &e.own_constants, true);
1171            for (_, case) in e.cases.iter() {
1172                children.push(DocumentSymbol {
1173                    name: case.name.clone(),
1174                    kind: DeclarationKind::EnumCase,
1175                    location: case.location.clone(),
1176                    children: Vec::new(),
1177                });
1178            }
1179            out.push(DocumentSymbol {
1180                name: e.fqcn.clone(),
1181                kind: DeclarationKind::Enum,
1182                location: e.location.clone(),
1183                children,
1184            });
1185        }
1186        for f in defs.slice.functions.iter() {
1187            out.push(DocumentSymbol {
1188                name: f.fqn.clone(),
1189                kind: DeclarationKind::Function,
1190                location: f.location.clone(),
1191                children: Vec::new(),
1192            });
1193        }
1194        for (name, _) in defs.slice.constants.iter() {
1195            out.push(DocumentSymbol {
1196                name: name.clone(),
1197                kind: DeclarationKind::Constant,
1198                location: None,
1199                children: Vec::new(),
1200            });
1201        }
1202        out
1203    }
1204
1205    /// Returns `true` if a function with `fqn` is registered and active in
1206    /// the codebase. Case-insensitive lookup with optional leading backslash.
1207    pub fn contains_function(&self, fqn: &str) -> bool {
1208        let db = self.snapshot_db();
1209        crate::db::function_exists(&db, fqn)
1210    }
1211
1212    /// Returns `true` if a class / interface / trait / enum with `fqcn` is
1213    /// registered and active in the codebase.
1214    pub fn contains_class(&self, fqcn: &str) -> bool {
1215        let db = self.snapshot_db();
1216        crate::db::class_exists(&db, fqcn)
1217    }
1218
1219    /// Returns `true` if `class` has a method named `name` registered. Method
1220    /// names are matched case-insensitively (PHP method dispatch semantics).
1221    pub fn contains_method(&self, class: &str, name: &str) -> bool {
1222        let db = self.snapshot_db();
1223        crate::db::has_method_in_chain(&db, class, name)
1224    }
1225
1226    /// Resolve `fqcn` via the configured [`crate::ClassResolver`] and ingest
1227    /// the mapped file. The session keeps a negative cache so repeated calls
1228    /// for an unresolvable name don't re-hit the resolver; the cache is
1229    /// invalidated on any [`Self::ingest_file`] / [`Self::invalidate_file`].
1230    ///
1231    /// This is the LSP-friendly entry point: the analyzer never touches
1232    /// `vendor/` on its own, but consumers can ask it to resolve individual
1233    /// symbols on demand. Designed to be called when a diagnostic would
1234    /// otherwise report `UndefinedClass`.
1235    ///
1236    /// Returns a [`crate::LoadOutcome`] distinguishing
1237    /// already-loaded / freshly-loaded / not-resolvable. Use
1238    /// [`crate::LoadOutcome::is_loaded`] when only success matters.
1239    pub fn load_class(&self, fqcn: &str) -> crate::LoadOutcome {
1240        if self.contains_class(fqcn) {
1241            return crate::LoadOutcome::AlreadyLoaded;
1242        }
1243        if self.unresolvable_fqcns.read().contains_key(fqcn) {
1244            return crate::LoadOutcome::NotResolvable;
1245        }
1246        if self.try_resolve_and_ingest(fqcn) {
1247            crate::LoadOutcome::Loaded
1248        } else {
1249            // Cache the failure with the resolver-mapped path (if any) so
1250            // future file edits can selectively evict.
1251            let resolved_path: Option<Arc<str>> = self
1252                .resolver
1253                .as_ref()
1254                .and_then(|r| r.resolve(fqcn))
1255                .map(|p| Arc::from(p.to_string_lossy().as_ref()));
1256            let key: Arc<str> = Arc::from(fqcn);
1257            let mut cache = self.unresolvable_fqcns.write();
1258            if cache.len() >= UNRESOLVABLE_CACHE_CAP {
1259                cache.clear();
1260            }
1261            cache.insert(key, resolved_path);
1262            crate::LoadOutcome::NotResolvable
1263        }
1264    }
1265
1266    /// Inner load path: resolver lookup + ingest, no caching. Returns `true`
1267    /// iff `fqcn` ends up registered. Failure buckets are recorded for
1268    /// telemetry.
1269    fn try_resolve_and_ingest(&self, fqcn: &str) -> bool {
1270        use crate::metrics::{record_lazy_load_failure, LazyLoadFailure};
1271        let Some(resolver) = &self.resolver else {
1272            record_lazy_load_failure(LazyLoadFailure::NoResolver, fqcn);
1273            return false;
1274        };
1275        let Some(path) = resolver.resolve(fqcn) else {
1276            record_lazy_load_failure(LazyLoadFailure::ResolverNone, fqcn);
1277            return false;
1278        };
1279        let file: Arc<str> = Arc::from(path.to_string_lossy().as_ref());
1280        // Prefer in-memory text from a prior `set_file_text` /
1281        // `set_workspace_files` call; fall back to disk. This makes the LSP's
1282        // unsaved-edit buffer authoritative over the on-disk content for the
1283        // same path.
1284        let src: Arc<str> = match self.source_of(&file) {
1285            Some(text) => text,
1286            None => match self.source_provider.read(&path.to_string_lossy()) {
1287                Some(text) => text,
1288                None => {
1289                    record_lazy_load_failure(LazyLoadFailure::SourceUnreadable, fqcn);
1290                    return false;
1291                }
1292            },
1293        };
1294        self.ingest_file(file, src);
1295        if self.contains_class(fqcn) {
1296            true
1297        } else {
1298            record_lazy_load_failure(LazyLoadFailure::IngestThenMissing, fqcn);
1299            false
1300        }
1301    }
1302
1303    /// Evict every negative-cache entry whose stored resolver-mapped path
1304    /// equals `file`. FQCNs cached as never-resolvable (path `None`) are left
1305    /// alone — no source-text change can make them resolvable.
1306    fn evict_unresolvable_for_file(&self, file: &str) {
1307        let mut cache = self.unresolvable_fqcns.write();
1308        if cache.is_empty() {
1309            return;
1310        }
1311        cache.retain(|_fqcn, path| path.as_deref() != Some(file));
1312    }
1313
1314    /// Bulk variant of [`Self::evict_unresolvable_for_file`]. One `HashSet`
1315    /// build + one pass over the cache; no resolver calls.
1316    fn evict_unresolvable_for_files(&self, files: &[Arc<str>]) {
1317        let mut cache = self.unresolvable_fqcns.write();
1318        if cache.is_empty() {
1319            return;
1320        }
1321        let registered: HashSet<&str> = files.iter().map(|f| f.as_ref()).collect();
1322        cache.retain(|_fqcn, path| match path {
1323            Some(p) => !registered.contains(p.as_ref()),
1324            None => true,
1325        });
1326    }
1327
1328    /// Retrieve the source text the session has registered for `file`, if
1329    /// any. Returns `None` when the file has never been ingested. Used by
1330    /// the parallel re-analysis path to re-feed dependents to body analysis without
1331    /// the caller having to track sources independently.
1332    pub fn source_of(&self, file: &str) -> Option<Arc<str>> {
1333        let db = self.snapshot_db();
1334        let sf = db.lookup_source_file(file)?;
1335        Some(sf.text(&db))
1336    }
1337
1338    /// Re-analyze every transitive dependent of `file` in parallel.
1339    ///
1340    /// When the user saves a file that other files depend on (e.g. editing
1341    /// a base class, an interface, or a trait), those dependents may have
1342    /// new diagnostics. This method computes them in parallel using rayon
1343    /// and returns the per-file analysis results so the LSP server can
1344    /// publish updated diagnostics in one batch.
1345    ///
1346    /// Source text for dependents is retrieved from the session's salsa
1347    /// inputs (set by previous `ingest_file` calls) — the caller doesn't
1348    /// need to track or re-read files. Files for which the session has no
1349    /// source are silently skipped (returns the analyzable subset).
1350    ///
1351    /// Cross-file inferred return types are resolved on demand via salsa.
1352    pub fn reanalyze_dependents(&self, file: &str) -> Vec<(Arc<str>, crate::FileAnalysis)> {
1353        self.reanalyze_dependents_cancellable(file, &crate::IndexCancel::new())
1354    }
1355
1356    /// Cancellable variant of [`Self::reanalyze_dependents`].
1357    ///
1358    /// The consumer flips `cancel` (typically because a newer edit arrived) to
1359    /// abandon the re-analysis; the flag is checked at each file boundary. Salsa
1360    /// cannot unwind the plain-Rust body-analysis walk mid-flight, so a file
1361    /// already in progress finishes, but no further files are started. Files
1362    /// skipped due to cancellation are simply absent from the returned vec —
1363    /// the consumer should drop a stale flag and start fresh work on each edit.
1364    pub fn reanalyze_dependents_cancellable(
1365        &self,
1366        file: &str,
1367        cancel: &crate::IndexCancel,
1368    ) -> Vec<(Arc<str>, crate::FileAnalysis)> {
1369        use rayon::prelude::*;
1370
1371        if cancel.is_cancelled() {
1372            return Vec::new();
1373        }
1374
1375        // Phase 1: compute dependents outside the analysis loop.
1376        let dependents = self.dependency_graph().transitive_dependents(file);
1377        if dependents.is_empty() {
1378            return Vec::new();
1379        }
1380        let dependents: Vec<Arc<str>> = dependents
1381            .into_iter()
1382            .map(|path| Arc::from(path.as_str()))
1383            .collect();
1384
1385        // Phase 2a: fault in each dependent's direct class references if the
1386        // background indexer hasn't reached them yet (mirrors the FileAnalyzer
1387        // warm-up behavior, avoiding transient false `UndefinedClass` during
1388        // index warm-up).
1389        //
1390        // This runs SERIALLY and *before* the parallel analyze loop below:
1391        // `prepare_ast_for_analysis` resolves and loads classes, and loading
1392        // mutates the shared session salsa storage (`load_class` →
1393        // `ingest_file` sets salsa inputs). Salsa input mutation cancels and
1394        // blocks until every other database handle is released, so it must run
1395        // with NO live snapshot in scope:
1396        //
1397        //  - in parallel (the v0.37.0 regression), sibling rayon workers held
1398        //    live snapshot clones mid-`analyze_file`, so the first warm-up
1399        //    write blocked on them forever — under high dependent fan-out this
1400        //    deadlocked the whole runtime; and
1401        //  - even serially, a snapshot held across the loop (e.g. one taken to
1402        //    parse the dependents) blocks the very first write.
1403        //
1404        // So each iteration takes a *scoped* snapshot to fetch the parsed AST,
1405        // drops it (the `Arc<ParseResult>` is owned), and only then warms up.
1406        for file in &dependents {
1407            if cancel.is_cancelled() {
1408                return Vec::new();
1409            }
1410            let parsed = {
1411                let db = self.snapshot_db();
1412                let Some(sf) = db.lookup_source_file(file.as_ref()) else {
1413                    continue;
1414                };
1415                crate::db::parse_file(&db as &dyn crate::db::MirDatabase, sf).0
1416            };
1417            self.prepare_ast_for_analysis(&parsed.program, file.as_ref());
1418        }
1419
1420        // Phase 2b: drive each dependent through the `analyze_file` tracked
1421        // query in parallel. Salsa's memo validation does the real work
1422        // here: after a body-only edit, a dependent whose tracked inputs are
1423        // structurally unchanged (`FileDefinitions` backdating) returns its
1424        // cached output without re-running body analysis — re-analysis cost
1425        // scales with what actually changed, not with dependent count.
1426        //
1427        // The snapshot is taken AFTER the warm-up above so each worker observes
1428        // the freshly-loaded classes. This loop is read-only on salsa: no
1429        // worker mutates inputs, so the snapshots never contend on a write.
1430        //
1431        // Dependents' `FileAnalysis::symbols` are empty on this path:
1432        // per-expression symbols are intentionally not memoized (a typical
1433        // file resolves thousands; caching them balloons memory), and
1434        // diagnostics consumers don't read them. Hover / go-to-definition
1435        // flows analyze the open file directly via [`crate::FileAnalyzer`].
1436        //
1437        // Each worker short-circuits when cancellation has been requested.
1438        let db_main = self.snapshot_db();
1439        let results: Vec<(Arc<str>, std::sync::Arc<crate::db::AnalyzeOutput>)> = dependents
1440            .into_par_iter()
1441            .map_with(db_main, |db, file| {
1442                if cancel.is_cancelled() {
1443                    return None;
1444                }
1445                let sf = db.lookup_source_file(file.as_ref())?;
1446                let out = crate::db::analyze_file(&*db as &dyn crate::db::MirDatabase, sf);
1447                Some((file, out))
1448            })
1449            .flatten()
1450            .collect();
1451
1452        // Serial commit: each dependent's output is its complete reference
1453        // set, so replace rather than append.
1454        {
1455            let guard = self.db.salsa.read();
1456            for (file, out) in &results {
1457                guard.set_file_reference_locations(file.as_ref(), out.ref_locs.to_vec());
1458            }
1459        }
1460
1461        results
1462            .into_iter()
1463            .map(|(file, out)| {
1464                (
1465                    file,
1466                    crate::FileAnalysis {
1467                        issues: out.issues.to_vec(),
1468                        symbols: Vec::new(),
1469                    },
1470                )
1471            })
1472            .collect()
1473    }
1474
1475    /// FQCNs that `file` imports via `use` statements but that aren't yet
1476    /// loaded in the session.
1477    ///
1478    /// Designed as the input to background prefetching: after the LSP server
1479    /// ingests an open buffer, it can call this and lazy-load the returned
1480    /// FQCNs on a worker thread so the user's first Cmd+Click into vendor
1481    /// code doesn't pay the file-read+parse cost.
1482    ///
1483    /// Returns an empty Vec if the file hasn't been ingested or has no
1484    /// unresolved imports.
1485    pub fn pending_lazy_loads(&self, file: &str) -> Vec<Arc<str>> {
1486        let db = self.snapshot_db();
1487        let imports = db.file_imports(file);
1488        if imports.is_empty() {
1489            return Vec::new();
1490        }
1491        let mut out = Vec::new();
1492        for fqcn in imports.values() {
1493            let here = crate::db::Fqcn::new(&db, *fqcn);
1494            if crate::db::find_class_like(&db, here).is_some() {
1495                continue;
1496            }
1497            if let Some(resolver) = &self.resolver {
1498                if resolver.resolve(fqcn.as_str()).is_some() {
1499                    out.push(Arc::from(fqcn.as_str()));
1500                }
1501            }
1502        }
1503        out
1504    }
1505
1506    /// Convenience: synchronously lazy-load every import of `file` that
1507    /// isn't already in the codebase. Returns the number successfully loaded.
1508    ///
1509    /// For non-blocking prefetch, call this from a worker thread:
1510    ///
1511    /// ```ignore
1512    /// let s = session.clone();  // AnalysisSession is wrapped in Arc by callers
1513    /// std::thread::spawn(move || {
1514    ///     s.prefetch_imports(&file_path);
1515    /// });
1516    /// ```
1517    ///
1518    /// Uses a single shared-visited two-tier BFS across all pending imports
1519    /// (see [`Self::load_classes_transitive_bounded`]) with a shallow depth so
1520    /// member access on imported types type-checks without pulling in the
1521    /// entire vendor tree.
1522    pub fn prefetch_imports(&self, file: &str) -> usize {
1523        let pending = self.pending_lazy_loads(file);
1524        if pending.is_empty() {
1525            return 0;
1526        }
1527        // Fault in each imported FQCN directly (single-file load + tier-merge).
1528        // Inheritance ancestors / signature types resolve through the eagerly
1529        // built workspace symbol index — no transitive walk needed here.
1530        let mut loaded = 0;
1531        for fqcn in &pending {
1532            if self.load_class(fqcn.as_ref()).is_loaded() {
1533                loaded += 1;
1534            }
1535        }
1536        loaded
1537    }
1538
1539    /// All class / interface / trait / enum FQCNs currently known to the
1540    /// session, each paired with the file that defines them when available.
1541    ///
1542    /// Use this to build workspace-wide views (outline, fuzzy search, etc.).
1543    /// Consumers implement their own search/match logic on top — the analyzer
1544    /// only exposes the iterator.
1545    pub fn all_classes(&self) -> Vec<(Arc<str>, Option<mir_types::Location>)> {
1546        let db = self.snapshot_db();
1547        crate::db::workspace_classes(&db)
1548            .iter()
1549            .filter_map(|fqcn| {
1550                let here = crate::db::Fqcn::from_str(&db, fqcn.as_ref());
1551                crate::db::find_class_like(&db, here)
1552                    .map(|class| (fqcn.clone(), class.location().cloned()))
1553            })
1554            .collect()
1555    }
1556
1557    /// All global function FQNs currently known to the session, each paired
1558    /// with their declaration location when available.
1559    pub fn all_functions(&self) -> Vec<(Arc<str>, Option<mir_types::Location>)> {
1560        let db = self.snapshot_db();
1561        crate::db::workspace_functions(&db)
1562            .iter()
1563            .filter_map(|fqn| {
1564                let here = crate::db::Fqcn::from_str(&db, fqn.as_ref());
1565                crate::db::find_function(&db, here).map(|f| (fqn.clone(), f.location.clone()))
1566            })
1567            .collect()
1568    }
1569
1570    /// Compute `file`'s outgoing dependency edges and persist them to the
1571    /// disk cache's reverse-dep graph (if configured). The in-memory graph
1572    /// is no longer maintained imperatively: `dependency_graph()` derives
1573    /// structural edges from the memoized [`crate::db::file_structural_deps`]
1574    /// tracked query, so there is no second copy to drift out of sync.
1575    fn update_reverse_deps_for(&self, file: &str) {
1576        if let Some(cache) = self.cache.as_deref() {
1577            let db = self.snapshot_db();
1578            let targets = file_outgoing_dependencies(&db, file);
1579            cache.update_reverse_deps_for_file(file, &targets);
1580        }
1581    }
1582
1583    /// File dependency graph: which files depend on which other files.
1584    /// Used for incremental invalidation in LSP servers and build systems.
1585    ///
1586    /// File dependency graph: which files depend on which other files.
1587    /// Used for incremental invalidation in LSP servers and build systems.
1588    ///
1589    /// O(edges) — iterates the `file_references` forward index (file → symbol
1590    /// keys it references) which is always current, then resolves each symbol
1591    /// to its defining file via O(1) lookup.  Total cost is O(E) where E is the
1592    /// number of (file, symbol) reference edges, vs. the old O(F × S × R) scan.
1593    pub fn dependency_graph(&self) -> crate::DependencyGraph {
1594        let db = self.snapshot_db();
1595
1596        let all_files: Vec<String> = db
1597            .source_file_paths()
1598            .iter()
1599            .map(|f| f.as_ref().to_string())
1600            .collect();
1601
1602        let mut dependencies: HashMap<String, Vec<String>> = HashMap::default();
1603        let mut dependents: HashMap<String, Vec<String>> = HashMap::default();
1604
1605        for file in &all_files {
1606            // O(degree(file)) — forward index lookup, no full-table scan.
1607            let symbol_keys = db.file_referenced_symbols(file);
1608            let mut file_deps: HashSet<String> = HashSet::default();
1609            for symbol_key in &symbol_keys {
1610                let lookup: &str = match symbol_key.split_once("::") {
1611                    Some((class, _)) => class,
1612                    None => symbol_key.as_ref(),
1613                };
1614                if let Some(def_file) = db.symbol_defining_file(lookup) {
1615                    let def = def_file.as_ref().to_string();
1616                    if &def != file {
1617                        file_deps.insert(def);
1618                    }
1619                }
1620            }
1621            for dep in &file_deps {
1622                dependents
1623                    .entry(dep.clone())
1624                    .or_default()
1625                    .push(file.clone());
1626                dependencies
1627                    .entry(file.clone())
1628                    .or_default()
1629                    .push(dep.clone());
1630            }
1631        }
1632
1633        // Merge structural deps derived from definition collection. The
1634        // forward pass above only captures bare-FQN references recorded
1635        // during body analysis; `file_structural_deps` covers imports, class
1636        // hierarchy (extends/implements/use), and type-hint-only references
1637        // that never appear in file_referenced_symbols. The query is salsa-
1638        // memoized, so the warm rebuild costs one map lookup per file rather
1639        // than a definition walk — and there is no imperatively-maintained
1640        // reverse map to drift out of sync with the definitions.
1641        for file in &all_files {
1642            let Some(sf) = db.lookup_source_file(file) else {
1643                continue;
1644            };
1645            for target in crate::db::file_structural_deps(&db, sf).iter() {
1646                let target = target.as_ref().to_string();
1647                if &target != file {
1648                    dependents
1649                        .entry(target.clone())
1650                        .or_default()
1651                        .push(file.clone());
1652                    dependencies.entry(file.clone()).or_default().push(target);
1653                }
1654            }
1655        }
1656
1657        for deps in dependents.values_mut() {
1658            deps.sort();
1659            deps.dedup();
1660        }
1661        for deps in dependencies.values_mut() {
1662            deps.sort();
1663            deps.dedup();
1664        }
1665
1666        // Augment with stale dependents: files referencing symbols that were
1667        // deleted from their defining file. These edges disappear from the
1668        // symbol_defining_file lookup but the referencing file still needs
1669        // re-analysis to surface the now-broken reference.
1670        {
1671            let stale = self.stale_defined_symbols.read();
1672            if !stale.is_empty() {
1673                for (file, deleted_syms) in stale.iter() {
1674                    for sym in deleted_syms {
1675                        let lookup: &str = match sym.split_once("::") {
1676                            Some((class, _)) => class,
1677                            None => sym.as_ref(),
1678                        };
1679                        for referencing_file in db.symbol_referencers_of(lookup) {
1680                            let ref_file = referencing_file.as_ref().to_string();
1681                            if &ref_file != file {
1682                                dependents
1683                                    .entry(file.clone())
1684                                    .or_default()
1685                                    .push(ref_file.clone());
1686                                dependencies.entry(ref_file).or_default().push(file.clone());
1687                            }
1688                        }
1689                    }
1690                }
1691                // Re-sort and dedup since we may have added entries.
1692                for deps in dependents.values_mut() {
1693                    deps.sort();
1694                    deps.dedup();
1695                }
1696                for deps in dependencies.values_mut() {
1697                    deps.sort();
1698                    deps.dedup();
1699                }
1700            }
1701        }
1702
1703        crate::DependencyGraph {
1704            dependencies,
1705            dependents,
1706        }
1707    }
1708}
1709
1710/// Compute the full set of files `file` depends on: structural edges from
1711/// the memoized [`crate::db::file_structural_deps`] tracked query, plus
1712/// bare-FQN references recorded during body analysis (which live in the
1713/// reference index and are not visible to salsa). Self-edges are excluded.
1714/// Used to persist the disk cache's reverse-dep graph.
1715fn file_outgoing_dependencies(db: &dyn MirDatabase, file: &str) -> HashSet<String> {
1716    let mut targets: HashSet<String> = HashSet::default();
1717
1718    if let Some(sf) = db.lookup_source_file(file) {
1719        for target in crate::db::file_structural_deps(db, sf).iter() {
1720            targets.insert(target.as_ref().to_string());
1721        }
1722    }
1723
1724    // Bare-FQN references recorded during body analysis (new \Foo(),
1725    // \Foo::method(), \foo()) that do not appear in use-import statements.
1726    for symbol_key in db.file_referenced_symbols(file) {
1727        let lookup: &str = match symbol_key.split_once("::") {
1728            Some((class, _)) => class,
1729            None => &symbol_key,
1730        };
1731        if let Some(defining_file) = db.symbol_defining_file(lookup) {
1732            if defining_file.as_ref() != file {
1733                targets.insert(defining_file.as_ref().to_string());
1734            }
1735        }
1736    }
1737
1738    targets
1739}
1740
1741/// AST visitor that collects class FQCN references for PSR-4 preloading.
1742/// Captures identifiers from `new X`, static calls / property / constant
1743/// access, type hints, and `instanceof`. Does *not* normalize via PSR-4 /
1744/// imports — callers run the raw string through `resolve_name`.
1745fn collect_class_refs_from_ast(program: &php_ast::owned::Program) -> Vec<String> {
1746    use php_ast::ast::BinaryOp;
1747    use php_ast::owned::visitor::{
1748        walk_owned_catch_clause, walk_owned_expr, walk_owned_program, walk_owned_type_hint,
1749        OwnedVisitor,
1750    };
1751    use php_ast::owned::{ExprKind, TypeHintKind};
1752    use std::ops::ControlFlow;
1753
1754    fn owned_name_str(name: &php_ast::owned::Name) -> String {
1755        let joined: String = name
1756            .parts
1757            .iter()
1758            .map(|p| p.as_ref())
1759            .collect::<Vec<&str>>()
1760            .join("\\");
1761        if name.kind == php_ast::ast::NameKind::FullyQualified {
1762            format!("\\{joined}")
1763        } else {
1764            joined
1765        }
1766    }
1767
1768    struct V {
1769        names: std::collections::HashSet<String>,
1770    }
1771    impl OwnedVisitor for V {
1772        fn visit_expr(&mut self, expr: &php_ast::owned::Expr) -> ControlFlow<()> {
1773            match &expr.kind {
1774                ExprKind::New(n) => {
1775                    if let ExprKind::Identifier(name) = &n.class.kind {
1776                        self.names.insert(name.as_ref().to_string());
1777                    }
1778                }
1779                ExprKind::StaticMethodCall(c) => {
1780                    if let ExprKind::Identifier(name) = &c.class.kind {
1781                        self.names.insert(name.as_ref().to_string());
1782                    }
1783                }
1784                ExprKind::StaticPropertyAccess(a) => {
1785                    if let ExprKind::Identifier(name) = &a.class.kind {
1786                        self.names.insert(name.as_ref().to_string());
1787                    }
1788                }
1789                ExprKind::ClassConstAccess(a) => {
1790                    if let ExprKind::Identifier(name) = &a.class.kind {
1791                        self.names.insert(name.as_ref().to_string());
1792                    }
1793                }
1794                ExprKind::Binary(b) if b.op == BinaryOp::Instanceof => {
1795                    if let ExprKind::Identifier(name) = &b.right.kind {
1796                        self.names.insert(name.as_ref().to_string());
1797                    }
1798                }
1799                _ => {}
1800            }
1801            walk_owned_expr(self, expr)
1802        }
1803
1804        fn visit_type_hint(&mut self, hint: &php_ast::owned::TypeHint) -> ControlFlow<()> {
1805            if let TypeHintKind::Named(name) = &hint.kind {
1806                let s = owned_name_str(name);
1807                if !s.is_empty() {
1808                    self.names.insert(s);
1809                }
1810            }
1811            walk_owned_type_hint(self, hint)
1812        }
1813
1814        fn visit_catch_clause(&mut self, catch: &php_ast::owned::CatchClause) -> ControlFlow<()> {
1815            for ty in catch.types.iter() {
1816                self.names.insert(owned_name_str(ty));
1817            }
1818            walk_owned_catch_clause(self, catch)
1819        }
1820    }
1821    let mut v = V {
1822        names: std::collections::HashSet::default(),
1823    };
1824    let _ = walk_owned_program(&mut v, program);
1825    v.names.into_iter().collect()
1826}