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