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. `MirDb::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 Pass 2 entry
11//! point that operates against a session.
12
13use std::collections::{HashMap, HashSet};
14use std::path::PathBuf;
15use std::sync::Arc;
16
17use parking_lot::RwLock;
18
19use crate::cache::AnalysisCache;
20use crate::composer::Psr4Map;
21use crate::db::{MirDatabase, MirDb, RefLoc};
22use crate::php_version::PhpVersion;
23use crate::shared_db::SharedDb;
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`].
31pub struct AnalysisSession {
32    /// Shared database management (salsa, file registry, stub tracking).
33    /// Extracted to allow code sharing with ProjectAnalyzer.
34    shared_db: Arc<SharedDb>,
35    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    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    php_version: PhpVersion,
47    user_stub_files: Vec<PathBuf>,
48    user_stub_dirs: Vec<PathBuf>,
49    /// In-memory reverse dependency map: target_file → set of files that
50    /// depend on it. Always maintained (not gated on disk cache presence),
51    /// enabling `analyze_dependents_of` and `dependency_graph()` without a
52    /// disk cache. Updated in `ingest_file` and `invalidate_file`.
53    reverse_dep_map: Arc<RwLock<HashMap<String, HashSet<String>>>>,
54    /// Tracks symbols that were previously defined in a file but have since
55    /// been removed (deleted or renamed). When `ingest_file` detects that
56    /// a symbol disappears, it records it here so `dependency_graph()` can
57    /// still produce edges to files that reference the now-gone symbol.
58    ///
59    /// Keyed by the file that used to define the symbols. Symbols are removed
60    /// from the set when re-added to the same file on a subsequent ingest.
61    /// The set may contain symbols with no current referencers; those are
62    /// harmless — the `symbol_referencers_of` lookup returns empty.
63    stale_defined_symbols: Arc<RwLock<HashMap<String, HashSet<Arc<str>>>>>,
64}
65
66impl AnalysisSession {
67    /// Create a session targeting the given PHP language version.
68    pub fn new(php_version: PhpVersion) -> Self {
69        Self {
70            shared_db: Arc::new(SharedDb::new()),
71            cache: None,
72            psr4: None,
73            resolver: None,
74            php_version,
75            user_stub_files: Vec::new(),
76            user_stub_dirs: Vec::new(),
77            reverse_dep_map: Arc::new(RwLock::new(HashMap::new())),
78            stale_defined_symbols: Arc::new(RwLock::new(HashMap::new())),
79        }
80    }
81
82    pub fn with_cache(mut self, cache: Arc<AnalysisCache>) -> Self {
83        self.cache = Some(cache);
84        self
85    }
86
87    /// Convenience: open a disk-backed cache at `cache_dir` and attach it.
88    /// Avoids forcing callers to wrap [`AnalysisCache`] in `Arc` themselves.
89    pub fn with_cache_dir(self, cache_dir: &std::path::Path) -> Self {
90        self.with_cache(Arc::new(AnalysisCache::open(cache_dir)))
91    }
92
93    /// Attach a Composer autoload map (PSR-4, PSR-0, classmap, files).
94    /// Sets the same map as the active [`crate::ClassResolver`] so
95    /// [`Self::lazy_load_class`] works out of the box.
96    pub fn with_psr4(mut self, map: Arc<Psr4Map>) -> Self {
97        let resolver: Arc<dyn crate::ClassResolver> = map.clone();
98        self.psr4 = Some(map);
99        self.resolver = Some(resolver);
100        self
101    }
102
103    /// Attach a generic class resolver for projects that don't use Composer
104    /// (WordPress, Drupal, custom autoloaders, workspace-walk indexes).
105    /// Replaces any previously-set Composer-backed resolver.
106    pub fn with_class_resolver(mut self, resolver: Arc<dyn crate::ClassResolver>) -> Self {
107        self.resolver = Some(resolver);
108        self
109    }
110
111    pub fn with_user_stubs(mut self, files: Vec<PathBuf>, dirs: Vec<PathBuf>) -> Self {
112        self.user_stub_files = files;
113        self.user_stub_dirs = dirs;
114        self
115    }
116
117    pub fn php_version(&self) -> PhpVersion {
118        self.php_version
119    }
120
121    pub fn cache(&self) -> Option<&AnalysisCache> {
122        self.cache.as_deref()
123    }
124
125    pub fn psr4(&self) -> Option<&Psr4Map> {
126        self.psr4.as_deref()
127    }
128
129    /// Load every PHP built-in stub plus any configured user stubs.
130    ///
131    /// **Deprecated**: prefer [`Self::ensure_all_stubs_loaded`] (explicit
132    /// "comprehensive") or [`Self::ensure_essential_stubs_loaded`] (fast
133    /// cold-start with auto-discovery on demand).
134    #[doc(hidden)]
135    pub fn ensure_stubs_loaded(&self) {
136        self.ensure_all_stubs_loaded();
137    }
138
139    /// Load only the curated set of essential stubs (Core, standard, SPL,
140    /// date) plus any configured user stubs. About 25 of 120 stub files;
141    /// covers types and functions used by virtually all PHP code.
142    ///
143    /// Other extension stubs (Reflection, gd, openssl, …) can be brought in
144    /// on demand via [`Self::ensure_stubs_for_symbol`] when user code
145    /// references them. Idempotent — already-loaded stubs are skipped.
146    pub fn ensure_essential_stubs_loaded(&self) {
147        self.shared_db
148            .ingest_stub_paths(crate::stubs::ESSENTIAL_STUB_PATHS, self.php_version);
149        self.ensure_user_stubs_loaded();
150    }
151
152    /// Load every embedded PHP stub plus any configured user stubs.
153    /// Use for batch tools (CLI, full project analysis) where comprehensive
154    /// symbol coverage matters more than cold-start latency.
155    pub fn ensure_all_stubs_loaded(&self) {
156        let paths: Vec<&'static str> = crate::stubs::stub_files().iter().map(|&(p, _)| p).collect();
157        self.shared_db.ingest_stub_paths(&paths, self.php_version);
158        self.ensure_user_stubs_loaded();
159    }
160
161    /// Ensure the embedded stub that defines `name` (a function) is ingested.
162    /// Returns `true` when a matching stub exists (whether or not it was
163    /// already loaded), `false` when `name` isn't a known PHP built-in.
164    ///
165    /// Most callers should use [`Self::ensure_stubs_for_ast`] instead —
166    /// it auto-discovers needed stubs from a parsed file.
167    #[doc(hidden)]
168    pub fn ensure_stub_for_function(&self, name: &str) -> bool {
169        match crate::stubs::stub_path_for_function(name) {
170            Some(path) => {
171                self.shared_db.ingest_stub_paths(&[path], self.php_version);
172                true
173            }
174            None => false,
175        }
176    }
177
178    /// Ensure the embedded stub that defines `fqcn` (a class / interface /
179    /// trait / enum) is ingested. Case-insensitive lookup with optional
180    /// leading backslash.
181    ///
182    /// Most callers should use [`Self::ensure_stubs_for_ast`] instead.
183    #[doc(hidden)]
184    pub fn ensure_stub_for_class(&self, fqcn: &str) -> bool {
185        match crate::stubs::stub_path_for_class(fqcn) {
186            Some(path) => {
187                self.shared_db.ingest_stub_paths(&[path], self.php_version);
188                true
189            }
190            None => false,
191        }
192    }
193
194    /// Ensure the embedded stub that defines `name` (a constant) is ingested.
195    ///
196    /// Most callers should use [`Self::ensure_stubs_for_ast`] instead.
197    #[doc(hidden)]
198    pub fn ensure_stub_for_constant(&self, name: &str) -> bool {
199        match crate::stubs::stub_path_for_constant(name) {
200            Some(path) => {
201                self.shared_db.ingest_stub_paths(&[path], self.php_version);
202                true
203            }
204            None => false,
205        }
206    }
207
208    /// Number of distinct embedded stubs currently ingested into the session.
209    /// Useful for diagnostics and bench reporting.
210    pub fn loaded_stub_count(&self) -> usize {
211        self.shared_db.loaded_stubs.lock().len()
212    }
213
214    /// Auto-discover and ingest the embedded stubs needed to cover every
215    /// built-in PHP function / class / constant referenced by `source`.
216    ///
217    /// Used by [`crate::FileAnalyzer::analyze`] to keep essentials-only mode
218    /// correct without forcing callers to enumerate which stubs they need.
219    /// Idempotent — already-loaded stubs are skipped via [`Self::loaded_stubs`].
220    ///
221    /// The discovery scan is a coarse identifier sweep (see
222    /// [`crate::stubs::collect_referenced_builtin_paths`]) — it may pull in
223    /// a slightly larger set than the file strictly needs, but never misses
224    /// a referenced built-in. Cost is sub-millisecond per file.
225    ///
226    /// Fast path: if every embedded stub is already loaded (e.g. after a
227    /// batch tool called [`Self::ensure_all_stubs_loaded`]), the source scan
228    /// is skipped entirely.
229    pub fn ensure_stubs_for_source(&self, source: &str) {
230        // Cheap check first: skip the scan entirely when we already know we
231        // have everything. Avoids a ~50-500µs source walk on every analyze
232        // call in batch / warm-session scenarios.
233        {
234            let loaded = self.shared_db.loaded_stubs.lock();
235            if loaded.len() >= crate::stubs::stub_files().len() {
236                return;
237            }
238        }
239        let paths = crate::stubs::collect_referenced_builtin_paths(source);
240        if paths.is_empty() {
241            return;
242        }
243        self.shared_db.ingest_stub_paths(&paths, self.php_version);
244    }
245
246    /// Discover and ingest stubs by walking the parsed AST of a PHP file.
247    ///
248    /// Similar to [`Self::ensure_stubs_for_source`], but takes an already-parsed
249    /// AST instead of raw source text. Produces zero false positives since it
250    /// only extracts identifiers from actual AST nodes (not from strings or
251    /// comments). Preferred over `ensure_stubs_for_source` when the AST is
252    /// already available (e.g., in [`crate::FileAnalyzer`]).
253    ///
254    /// Idempotent and skips the scan if all stubs are already loaded.
255    pub fn ensure_stubs_for_ast(&self, program: &php_ast::ast::Program<'_, '_>) {
256        {
257            let loaded = self.shared_db.loaded_stubs.lock();
258            if loaded.len() >= crate::stubs::stub_files().len() {
259                return;
260            }
261        }
262        let paths = crate::stubs::collect_referenced_builtin_paths_from_ast(program);
263        if paths.is_empty() {
264            return;
265        }
266        self.shared_db.ingest_stub_paths(&paths, self.php_version);
267    }
268
269    fn ensure_user_stubs_loaded(&self) {
270        self.shared_db
271            .ingest_user_stubs(&self.user_stub_files, &self.user_stub_dirs);
272    }
273
274    /// Cheap clone of the salsa db for a read-only query. The lock is held
275    /// only for the duration of the clone, so concurrent readers never
276    /// serialize on each other or on writes for longer than the clone itself.
277    ///
278    /// **Internal API — exposes Salsa types.** Subject to change without
279    /// notice. Public consumers should use the typed query methods
280    /// ([`Self::definition_of`], [`Self::hover`], etc.) instead.
281    #[doc(hidden)]
282    pub fn snapshot_db(&self) -> MirDb {
283        self.shared_db.snapshot_db()
284    }
285
286    /// Commit a batch of reference locations from a db snapshot into the
287    /// session's shared maps.  Called by [`crate::FileAnalyzer`] and
288    /// [`crate::BatchFileAnalyzer`] after parallel Pass 2 to flush the pending
289    /// buffers that accumulate in worker db clones.
290    pub(crate) fn commit_ref_locs_batch(&self, locs: Vec<RefLoc>) {
291        if locs.is_empty() {
292            return;
293        }
294        let guard = self.shared_db.salsa.read();
295        guard.commit_reference_locations_batch(locs);
296    }
297
298    /// Run a closure with read access to a database snapshot.
299    ///
300    /// **Internal API — exposes Salsa types.** Subject to change without
301    /// notice.
302    #[doc(hidden)]
303    pub fn read<R>(&self, f: impl FnOnce(&dyn MirDatabase) -> R) -> R {
304        let db = self.snapshot_db();
305        f(&db)
306    }
307
308    /// Pass 1 ingestion. Updates the file's source text in the salsa db,
309    /// runs definition collection, and ingests the resulting stub slice.
310    /// Triggers stub loading on first call. Also updates the cache's reverse-
311    /// dependency graph for `file` so cross-file invalidation stays correct
312    /// across incremental edits — without rebuilding the graph from scratch.
313    ///
314    /// If `file` was previously ingested, its old definitions and reference
315    /// locations are removed first so renames / deletions don't leave stale
316    /// state in the codebase. (Without this, long-running sessions would
317    /// accumulate dead reference-location entries indefinitely.)
318    pub fn ingest_file(&self, file: Arc<str>, source: Arc<str>) {
319        self.ensure_stubs_loaded();
320
321        // Snapshot symbols defined before clearing — O(symbols_in_file) with forward index.
322        let old_symbols: HashSet<Arc<str>> = {
323            let guard = self.shared_db.salsa.read();
324            guard.file_defined_symbols(file.as_ref())
325        };
326
327        {
328            let mut guard = self.shared_db.salsa.write();
329            guard.remove_file_definitions(file.as_ref());
330        }
331        let _file_defs = self
332            .shared_db
333            .collect_and_ingest_file(file.clone(), source.as_ref());
334
335        // Snapshot symbols after ingesting — O(symbols_in_file).
336        let new_symbols: HashSet<Arc<str>> = {
337            let guard = self.shared_db.salsa.read();
338            guard.file_defined_symbols(file.as_ref())
339        };
340
341        // Symbols removed from this file must be tracked so dependency_graph()
342        // can still produce edges to files referencing the now-gone symbols.
343        let deleted: Vec<Arc<str>> = old_symbols.difference(&new_symbols).cloned().collect();
344        let re_added: Vec<Arc<str>> = new_symbols.difference(&old_symbols).cloned().collect();
345        if !deleted.is_empty() || !re_added.is_empty() {
346            let mut stale = self.stale_defined_symbols.write();
347            let entry = stale.entry(file.as_ref().to_string()).or_default();
348            for sym in deleted {
349                entry.insert(sym);
350            }
351            for sym in &re_added {
352                entry.remove(sym);
353            }
354            if entry.is_empty() {
355                stale.remove(file.as_ref());
356            }
357        }
358
359        self.update_reverse_deps_for(&file);
360    }
361
362    /// Drop a file's contribution to the session: codebase definitions,
363    /// reference locations, salsa input handle, cache entry, and outgoing
364    /// reverse-dependency edges. Cache entries of *dependent* files are
365    /// also evicted (cross-file invalidation).
366    ///
367    /// Use this when a file is closed by the consumer, or before a re-ingest
368    /// of substantially changed content. (Plain re-ingest via
369    /// [`Self::ingest_file`] also drops old definitions, but does not
370    /// remove the salsa input handle — call this for full cleanup.)
371    pub fn invalidate_file(&self, file: &str) {
372        {
373            let mut guard = self.shared_db.salsa.write();
374            guard.remove_file_definitions(file);
375            guard.remove_source_file(file);
376        }
377        // Remove this file's outgoing deps from the in-memory reverse dep map.
378        self.update_in_memory_reverse_deps(file, &HashSet::new());
379        // Clear stale symbol tracking for this file — it's fully gone.
380        self.stale_defined_symbols.write().remove(file);
381        if let Some(cache) = &self.cache {
382            cache.update_reverse_deps_for_file(file, &HashSet::new());
383            cache.evict_with_dependents(&[file.to_string()]);
384        }
385    }
386
387    /// Number of files currently tracked in this session's salsa input set.
388    /// Stable across reads; useful for diagnostics and memory bounds checks.
389    pub fn tracked_file_count(&self) -> usize {
390        let guard = self.shared_db.salsa.read();
391        guard.source_file_count()
392    }
393
394    // -----------------------------------------------------------------------
395    // Read-only codebase queries
396    //
397    // All take a brief lock to clone the db, then run the lookup against the
398    // owned snapshot — concurrent edits proceed without blocking.
399    // -----------------------------------------------------------------------
400
401    /// Resolve a top-level symbol (class or function) to its declaration
402    /// location. Powers go-to-definition.
403    ///
404    /// Returns:
405    /// - `Ok(Location)` — symbol found with a source location
406    /// - `Err(NotFound)` — no such symbol in the codebase
407    /// - `Err(NoSourceLocation)` — symbol exists but has no recorded span
408    ///   (e.g. some stub-only declarations)
409    pub fn definition_of(
410        &self,
411        symbol: &crate::Symbol,
412    ) -> Result<mir_codebase::storage::Location, crate::SymbolLookupError> {
413        let db = self.snapshot_db();
414        match symbol {
415            crate::Symbol::Class(fqcn) => {
416                let node = db
417                    .lookup_class_node(fqcn.as_ref())
418                    .filter(|n| n.active(&db))
419                    .ok_or(crate::SymbolLookupError::NotFound)?;
420                node.location(&db)
421                    .ok_or(crate::SymbolLookupError::NoSourceLocation)
422            }
423            crate::Symbol::Function(fqn) => {
424                let node = db
425                    .lookup_function_node(fqn.as_ref())
426                    .filter(|n| n.active(&db))
427                    .ok_or(crate::SymbolLookupError::NotFound)?;
428                node.location(&db)
429                    .ok_or(crate::SymbolLookupError::NoSourceLocation)
430            }
431            crate::Symbol::Method { class, name }
432            | crate::Symbol::Property { class, name }
433            | crate::Symbol::ClassConstant { class, name } => {
434                crate::db::member_location_via_db(&db, class, name)
435                    .ok_or(crate::SymbolLookupError::NotFound)
436            }
437            crate::Symbol::GlobalConstant(_) => {
438                // Global constants don't currently store location info
439                Err(crate::SymbolLookupError::NoSourceLocation)
440            }
441        }
442    }
443
444    /// Hover information for a symbol: type, docstring, and definition location.
445    ///
446    /// Use [`crate::FileAnalysis::symbol_at`] to find the symbol at a cursor
447    /// position, then build a [`crate::Symbol`] from its `kind`. This method
448    /// assembles the displayable hover data.
449    ///
450    /// Returns `Err(NotFound)` if the symbol doesn't exist. May still return
451    /// `Ok` with `docstring: None` or `definition: None` if those specific
452    /// pieces aren't available.
453    pub fn hover(
454        &self,
455        symbol: &crate::Symbol,
456    ) -> Result<crate::HoverInfo, crate::SymbolLookupError> {
457        use mir_types::{Atomic, Union};
458        let db = self.snapshot_db();
459        match symbol {
460            crate::Symbol::Function(fqn) => {
461                let node = db
462                    .lookup_function_node(fqn.as_ref())
463                    .filter(|n| n.active(&db))
464                    .ok_or(crate::SymbolLookupError::NotFound)?;
465                let ty = node
466                    .return_type(&db)
467                    .map(|t| (*t).clone())
468                    .unwrap_or_else(Union::mixed);
469                let docstring = node.docstring(&db).map(|s| s.to_string());
470                let definition = node.location(&db);
471                Ok(crate::HoverInfo {
472                    ty,
473                    docstring,
474                    definition,
475                })
476            }
477            crate::Symbol::Method { class, name } => {
478                let node = db
479                    .lookup_method_node(class.as_ref(), name.as_ref())
480                    .filter(|n| n.active(&db))
481                    .ok_or(crate::SymbolLookupError::NotFound)?;
482                let ty = node
483                    .return_type(&db)
484                    .map(|t| (*t).clone())
485                    .unwrap_or_else(Union::mixed);
486                let docstring = node.docstring(&db).map(|s| s.to_string());
487                let definition = node.location(&db);
488                Ok(crate::HoverInfo {
489                    ty,
490                    docstring,
491                    definition,
492                })
493            }
494            crate::Symbol::Class(fqcn) => {
495                let node = db
496                    .lookup_class_node(fqcn.as_ref())
497                    .filter(|n| n.active(&db))
498                    .ok_or(crate::SymbolLookupError::NotFound)?;
499                let ty = Union::single(Atomic::TNamedObject {
500                    fqcn: fqcn.clone(),
501                    type_params: Vec::new(),
502                });
503                let definition = node.location(&db);
504                Ok(crate::HoverInfo {
505                    ty,
506                    docstring: None,
507                    definition,
508                })
509            }
510            crate::Symbol::Property { class, name } => {
511                let node = db
512                    .lookup_property_node(class.as_ref(), name.as_ref())
513                    .filter(|n| n.active(&db))
514                    .ok_or(crate::SymbolLookupError::NotFound)?;
515                let ty = node.ty(&db).unwrap_or_else(Union::mixed);
516                let definition = node.location(&db);
517                Ok(crate::HoverInfo {
518                    ty,
519                    docstring: None,
520                    definition,
521                })
522            }
523            crate::Symbol::ClassConstant { class, name } => {
524                let node = db
525                    .lookup_class_constant_node(class.as_ref(), name.as_ref())
526                    .filter(|n| n.active(&db))
527                    .ok_or(crate::SymbolLookupError::NotFound)?;
528                let ty = node.ty(&db);
529                let definition = node.location(&db);
530                Ok(crate::HoverInfo {
531                    ty,
532                    docstring: None,
533                    definition,
534                })
535            }
536            crate::Symbol::GlobalConstant(fqn) => {
537                let node = db
538                    .lookup_global_constant_node(fqn.as_ref())
539                    .filter(|n| n.active(&db))
540                    .ok_or(crate::SymbolLookupError::NotFound)?;
541                let ty = node.ty(&db);
542                Ok(crate::HoverInfo {
543                    ty,
544                    docstring: None,
545                    definition: None,
546                })
547            }
548        }
549    }
550
551    /// Every recorded reference to `symbol` with its source location as a Range.
552    /// Use [`crate::FileAnalysis::symbol_at`] to find the symbol at a cursor,
553    /// build a [`crate::Symbol`] from it, and pass it here.
554    pub fn references_to(&self, symbol: &crate::Symbol) -> Vec<(Arc<str>, crate::Range)> {
555        let db = self.snapshot_db();
556        let key = symbol.codebase_key();
557        db.reference_locations(&key)
558            .into_iter()
559            .map(|(file, line, col_start, col_end)| {
560                let range = crate::Range {
561                    start: crate::Position {
562                        line,
563                        column: col_start as u32,
564                    },
565                    end: crate::Position {
566                        line,
567                        column: col_end as u32,
568                    },
569                };
570                (file, range)
571            })
572            .collect()
573    }
574
575    /// Class-level issues (inheritance violations, abstract-method gaps, override
576    /// incompatibilities) for the given set of files.
577    ///
578    /// These checks are cross-file by nature and are not emitted by
579    /// [`crate::FileAnalyzer::analyze`]. Call this after ingesting or
580    /// re-analyzing a file and its dependents to get the full diagnostic picture.
581    ///
582    /// Circular-inheritance checks always run against the full workspace graph
583    /// regardless of the `files` filter — a cycle is a workspace-wide problem.
584    pub fn class_issues_for(&self, files: &[Arc<str>]) -> Vec<crate::Issue> {
585        let db = self.snapshot_db();
586        let file_set: HashSet<Arc<str>> = files.iter().cloned().collect();
587        let file_data: Vec<(Arc<str>, Arc<str>)> = files
588            .iter()
589            .filter_map(|f| Some((f.clone(), self.source_of(f)?)))
590            .collect();
591        crate::class::ClassAnalyzer::with_files(&db, file_set, &file_data).analyze_all()
592    }
593
594    /// All declarations defined in `file` as a **hierarchical tree**.
595    ///
596    /// Classes/interfaces/traits/enums are returned with their methods,
597    /// properties, and constants nested in `children`. Top-level functions
598    /// and constants are returned with empty `children`.
599    pub fn document_symbols(&self, file: &str) -> Vec<crate::symbol::DocumentSymbol> {
600        use crate::symbol::{DocumentSymbol, DocumentSymbolKind};
601
602        let db = self.snapshot_db();
603        let mut out = Vec::new();
604        for symbol in db.symbols_defined_in_file(file) {
605            // Try class side first — covers Class / Interface / Trait / Enum.
606            if let Some(class_node) = db.lookup_class_node(symbol.as_ref()) {
607                if !class_node.active(&db) {
608                    continue;
609                }
610                let (kind, is_enum) = crate::db::class_kind_via_db(&db, symbol.as_ref())
611                    .map(|k| {
612                        let kind = if k.is_interface {
613                            DocumentSymbolKind::Interface
614                        } else if k.is_trait {
615                            DocumentSymbolKind::Trait
616                        } else if k.is_enum {
617                            DocumentSymbolKind::Enum
618                        } else {
619                            DocumentSymbolKind::Class
620                        };
621                        (kind, k.is_enum)
622                    })
623                    .unwrap_or((DocumentSymbolKind::Class, false));
624
625                // Build children: methods, properties, and class constants.
626                let mut children: Vec<DocumentSymbol> = Vec::new();
627                for m in db.class_own_methods(symbol.as_ref()) {
628                    if !m.active(&db) {
629                        continue;
630                    }
631                    children.push(DocumentSymbol {
632                        name: m.name(&db),
633                        kind: DocumentSymbolKind::Method,
634                        location: m.location(&db),
635                        children: Vec::new(),
636                    });
637                }
638                for p in db.class_own_properties(symbol.as_ref()) {
639                    if !p.active(&db) {
640                        continue;
641                    }
642                    children.push(DocumentSymbol {
643                        name: p.name(&db),
644                        kind: DocumentSymbolKind::Property,
645                        location: p.location(&db),
646                        children: Vec::new(),
647                    });
648                }
649                for c in db.class_own_constants(symbol.as_ref()) {
650                    if !c.active(&db) {
651                        continue;
652                    }
653                    let const_kind = if is_enum {
654                        DocumentSymbolKind::EnumCase
655                    } else {
656                        DocumentSymbolKind::Constant
657                    };
658                    children.push(DocumentSymbol {
659                        name: c.name(&db),
660                        kind: const_kind,
661                        location: c.location(&db),
662                        children: Vec::new(),
663                    });
664                }
665
666                out.push(DocumentSymbol {
667                    name: symbol.clone(),
668                    kind,
669                    location: class_node.location(&db),
670                    children,
671                });
672                continue;
673            }
674            if let Some(fn_node) = db.lookup_function_node(symbol.as_ref()) {
675                if !fn_node.active(&db) {
676                    continue;
677                }
678                out.push(DocumentSymbol {
679                    name: symbol.clone(),
680                    kind: DocumentSymbolKind::Function,
681                    location: fn_node.location(&db),
682                    children: Vec::new(),
683                });
684                continue;
685            }
686            // Constants and other top-level declarations: emit with no
687            // location info; consumers can still surface them in an outline.
688            out.push(DocumentSymbol {
689                name: symbol,
690                kind: DocumentSymbolKind::Constant,
691                location: None,
692                children: Vec::new(),
693            });
694        }
695        out
696    }
697
698    /// Returns `true` if a function with `fqn` is registered and active in
699    /// the codebase. Case-insensitive lookup with optional leading backslash.
700    pub fn contains_function(&self, fqn: &str) -> bool {
701        let db = self.snapshot_db();
702        db.lookup_function_node(fqn).is_some_and(|n| n.active(&db))
703    }
704
705    /// Returns `true` if a class / interface / trait / enum with `fqcn` is
706    /// registered and active in the codebase.
707    pub fn contains_class(&self, fqcn: &str) -> bool {
708        let db = self.snapshot_db();
709        db.lookup_class_node(fqcn).is_some_and(|n| n.active(&db))
710    }
711
712    /// Returns `true` if `class` has a method named `name` registered. Method
713    /// names are matched case-insensitively (PHP method dispatch semantics).
714    pub fn contains_method(&self, class: &str, name: &str) -> bool {
715        let db = self.snapshot_db();
716        let name_lower = name.to_ascii_lowercase();
717        db.lookup_method_node(class, &name_lower)
718            .is_some_and(|n| n.active(&db))
719    }
720
721    /// Try to resolve `fqcn` via PSR-4 and ingest the mapped file, returning
722    /// a detailed outcome distinguishing "already there" from "freshly loaded".
723    pub fn lazy_load_class_with_outcome(&self, fqcn: &str) -> crate::LazyLoadOutcome {
724        if self.contains_class(fqcn) {
725            return crate::LazyLoadOutcome::AlreadyLoaded;
726        }
727        if self.lazy_load_class(fqcn) {
728            crate::LazyLoadOutcome::Loaded
729        } else {
730            crate::LazyLoadOutcome::NotResolvable
731        }
732    }
733
734    /// Try to resolve `fqcn` via the configured [`crate::ClassResolver`] and
735    /// ingest the mapped file.
736    ///
737    /// This is the LSP-friendly lazy-load entry point: the analyzer never
738    /// touches `vendor/` on its own, but consumers can ask it to resolve
739    /// individual symbols on demand. Designed to be called when a diagnostic
740    /// would otherwise report `UndefinedClass`.
741    ///
742    /// Returns `true` if either the class is already known or a matching
743    /// file was found and successfully ingested. Returns `false` if:
744    /// - No resolver is configured (neither `with_psr4` nor `with_class_resolver` called),
745    /// - The resolver can't map `fqcn` to a file,
746    /// - The file can't be read, or
747    /// - The file parsed but did not define `fqcn`.
748    pub fn lazy_load_class(&self, fqcn: &str) -> bool {
749        if self.contains_class(fqcn) {
750            return true;
751        }
752        let Some(resolver) = &self.resolver else {
753            return false;
754        };
755        let Some(path) = resolver.resolve(fqcn) else {
756            return false;
757        };
758        let Ok(src) = std::fs::read_to_string(&path) else {
759            return false;
760        };
761        let file: Arc<str> = Arc::from(path.to_string_lossy().as_ref());
762        self.ingest_file(file, Arc::from(src));
763        self.contains_class(fqcn)
764    }
765
766    /// Lazy-load every class transitively reachable from `fqcn` via parent /
767    /// interface / trait edges. Useful when the consumer needs not just the
768    /// requested class but enough of its inheritance chain to type-check
769    /// member access.
770    ///
771    /// Walks at most `max_depth` levels (default in batch analysis is 10).
772    /// Returns the number of classes successfully loaded (not counting
773    /// `fqcn` itself if it was already present).
774    pub fn lazy_load_class_transitive(&self, fqcn: &str, max_depth: usize) -> usize {
775        if self.resolver.is_none() {
776            return 0;
777        }
778        let mut loaded = 0;
779        let mut frontier: Vec<String> = vec![fqcn.to_string()];
780        let mut visited: std::collections::HashSet<String> = std::collections::HashSet::new();
781
782        for _ in 0..max_depth {
783            if frontier.is_empty() {
784                break;
785            }
786            let mut next: Vec<String> = Vec::new();
787            for name in frontier.drain(..) {
788                if !visited.insert(name.clone()) {
789                    continue;
790                }
791                let was_present = self.contains_class(&name);
792                let resolved = self.lazy_load_class(&name);
793                if resolved && !was_present {
794                    loaded += 1;
795                    // Walk the new class's parent / interfaces / traits.
796                    let db = self.snapshot_db();
797                    if let Some(node) = db.lookup_class_node(&name) {
798                        if let Some(parent) = node.parent(&db) {
799                            next.push(parent.to_string());
800                        }
801                        for iface in node.interfaces(&db).iter() {
802                            next.push(iface.to_string());
803                        }
804                        for tr in node.traits(&db).iter() {
805                            next.push(tr.to_string());
806                        }
807                        for ext in node.extends(&db).iter() {
808                            next.push(ext.to_string());
809                        }
810                    }
811                }
812            }
813            frontier = next;
814        }
815        loaded
816    }
817
818    /// Retrieve the source text the session has registered for `file`, if
819    /// any. Returns `None` when the file has never been ingested. Used by
820    /// the parallel re-analysis path to re-feed dependents to Pass 2 without
821    /// the caller having to track sources independently.
822    pub fn source_of(&self, file: &str) -> Option<Arc<str>> {
823        let db = self.snapshot_db();
824        let sf = db.lookup_source_file(file)?;
825        Some(sf.text(&db))
826    }
827
828    /// Re-analyze every transitive dependent of `file` in parallel.
829    ///
830    /// When the user saves a file that other files depend on (e.g. editing
831    /// a base class, an interface, or a trait), those dependents may have
832    /// new diagnostics. This method computes them in parallel using rayon
833    /// and returns the per-file analysis results so the LSP server can
834    /// publish updated diagnostics in one batch.
835    ///
836    /// Source text for dependents is retrieved from the session's salsa
837    /// inputs (set by previous `ingest_file` calls) — the caller doesn't
838    /// need to track or re-read files. Files for which the session has no
839    /// source are silently skipped (returns the analyzable subset).
840    ///
841    /// Does not run inference sweeps. For full-fidelity cross-file inferred
842    /// return types, follow up with [`Self::run_inference_sweep`] over the
843    /// affected file set.
844    pub fn analyze_dependents_of(&self, file: &str) -> Vec<(Arc<str>, crate::FileAnalysis)> {
845        use rayon::prelude::*;
846
847        // Phase 1: compute dependents + gather their sources outside the
848        // analysis loop so each worker has everything it needs.
849        let dependents = self.dependency_graph().transitive_dependents(file);
850        if dependents.is_empty() {
851            return Vec::new();
852        }
853        let with_source: Vec<(Arc<str>, Arc<str>)> = dependents
854            .into_iter()
855            .filter_map(|path| {
856                let arc_path: Arc<str> = Arc::from(path.as_str());
857                let src = self.source_of(&path)?;
858                Some((arc_path, src))
859            })
860            .collect();
861        if with_source.is_empty() {
862            return Vec::new();
863        }
864
865        // Phase 2: parallel parse + analyze. Each rayon worker gets its own
866        // database snapshot via FileAnalyzer; writes are isolated to the
867        // session's canonical db (none happen here since we only run Pass 2).
868        with_source
869            .into_par_iter()
870            .map(|(file, source)| {
871                let arena = crate::arena::create_parse_arena(source.len());
872                let parsed = php_rs_parser::parse(&arena, source.as_ref());
873                let analyzer = crate::FileAnalyzer::new(self);
874                let analysis = analyzer.analyze(
875                    file.clone(),
876                    source.as_ref(),
877                    &parsed.program,
878                    &parsed.source_map,
879                );
880                (file, analysis)
881            })
882            .collect()
883    }
884
885    /// FQCNs that `file` imports via `use` statements but that aren't yet
886    /// loaded in the session.
887    ///
888    /// Designed as the input to background prefetching: after the LSP server
889    /// ingests an open buffer, it can call this and lazy-load the returned
890    /// FQCNs on a worker thread so the user's first Cmd+Click into vendor
891    /// code doesn't pay the file-read+parse cost.
892    ///
893    /// Returns an empty Vec if the file hasn't been ingested or has no
894    /// unresolved imports.
895    pub fn pending_lazy_loads(&self, file: &str) -> Vec<Arc<str>> {
896        let db = self.snapshot_db();
897        let imports = db.file_imports(file);
898        if imports.is_empty() {
899            return Vec::new();
900        }
901        let mut out = Vec::new();
902        for fqcn in imports.values() {
903            // Cheap check: skip imports already in the codebase.
904            if db.lookup_class_node(fqcn).is_some_and(|n| n.active(&db)) {
905                continue;
906            }
907            // Only worth queueing if the resolver could in principle find it.
908            if let Some(resolver) = &self.resolver {
909                if resolver.resolve(fqcn).is_some() {
910                    out.push(Arc::from(fqcn.as_str()));
911                }
912            }
913        }
914        out
915    }
916
917    /// Convenience: synchronously lazy-load every import of `file` that
918    /// isn't already in the codebase. Returns the number successfully loaded.
919    ///
920    /// For non-blocking prefetch, call this from a worker thread:
921    ///
922    /// ```ignore
923    /// let s = session.clone();  // AnalysisSession is wrapped in Arc by callers
924    /// std::thread::spawn(move || {
925    ///     s.prefetch_imports(&file_path);
926    /// });
927    /// ```
928    ///
929    /// Internally walks the inheritance chain of each loaded class to a
930    /// shallow depth so member access on imported types type-checks without
931    /// the user paying the cost on their first navigation.
932    pub fn prefetch_imports(&self, file: &str) -> usize {
933        let pending = self.pending_lazy_loads(file);
934        let mut loaded = 0;
935        for fqcn in pending {
936            // Use the transitive walker with a small depth so we pick up
937            // parent classes / interfaces needed for member resolution, but
938            // don't recursively pull in the entire vendor tree.
939            loaded += self.lazy_load_class_transitive(&fqcn, 2);
940        }
941        loaded
942    }
943
944    /// All class / interface / trait / enum FQCNs currently known to the
945    /// session, each paired with the file that defines them when available.
946    ///
947    /// Use this to build workspace-wide views (outline, fuzzy search, etc.).
948    /// Consumers implement their own search/match logic on top — the analyzer
949    /// only exposes the iterator.
950    pub fn all_classes(&self) -> Vec<(Arc<str>, Option<mir_codebase::storage::Location>)> {
951        let db = self.snapshot_db();
952        db.active_class_node_fqcns()
953            .into_iter()
954            .filter_map(|fqcn| {
955                let node = db.lookup_class_node(fqcn.as_ref())?;
956                if !node.active(&db) {
957                    return None;
958                }
959                Some((fqcn, node.location(&db)))
960            })
961            .collect()
962    }
963
964    /// All global function FQNs currently known to the session, each paired
965    /// with their declaration location when available.
966    pub fn all_functions(&self) -> Vec<(Arc<str>, Option<mir_codebase::storage::Location>)> {
967        let db = self.snapshot_db();
968        db.active_function_node_fqns()
969            .into_iter()
970            .filter_map(|fqn| {
971                let node = db.lookup_function_node(fqn.as_ref())?;
972                if !node.active(&db) {
973                    return None;
974                }
975                Some((fqn, node.location(&db)))
976            })
977            .collect()
978    }
979
980    /// Compute `file`'s outgoing dependency edges and update both the in-memory
981    /// reverse-dep map (always) and the disk cache's reverse-dep graph (if configured).
982    fn update_reverse_deps_for(&self, file: &str) {
983        let db = self.snapshot_db();
984        let targets = file_outgoing_dependencies(&db, file);
985
986        // Always update the in-memory map.
987        self.update_in_memory_reverse_deps(file, &targets);
988
989        // Also persist to disk cache if configured.
990        if let Some(cache) = self.cache.as_deref() {
991            cache.update_reverse_deps_for_file(file, &targets);
992        }
993    }
994
995    /// Update the in-memory reverse dependency map for `file` with `new_targets`.
996    /// Removes `file` from all existing entries, then adds it as a dependent of
997    /// each target in `new_targets` (excluding self-edges).
998    fn update_in_memory_reverse_deps(&self, file: &str, new_targets: &HashSet<String>) {
999        let mut map = self.reverse_dep_map.write();
1000        for dependents in map.values_mut() {
1001            dependents.remove(file);
1002        }
1003        map.retain(|_, dependents| !dependents.is_empty());
1004        for target in new_targets {
1005            if target != file {
1006                map.entry(target.clone())
1007                    .or_default()
1008                    .insert(file.to_string());
1009            }
1010        }
1011    }
1012
1013    /// BFS transitive dependents of `file` using the in-memory reverse dep map.
1014    ///
1015    /// O(D) where D is the number of transitive dependents — faster than
1016    /// [`Self::dependency_graph().transitive_dependents()`] which rebuilds the
1017    /// full graph on every call. Only covers Pass 1 structural dependencies
1018    /// (imports, class hierarchy, type hints); does not include bare FQN body
1019    /// references recorded during Pass 2. For full fidelity, use
1020    /// `dependency_graph().transitive_dependents()` after Pass 2 is complete.
1021    pub fn structural_dependents_of(&self, file: &str) -> Vec<String> {
1022        let map = self.reverse_dep_map.read();
1023        let mut visited: HashSet<String> = HashSet::new();
1024        let mut queue = vec![file.to_string()];
1025        let mut result = Vec::new();
1026        while let Some(current) = queue.pop() {
1027            if !visited.insert(current.clone()) {
1028                continue;
1029            }
1030            if let Some(deps) = map.get(&current) {
1031                for dep in deps {
1032                    if !visited.contains(dep) {
1033                        queue.push(dep.clone());
1034                        result.push(dep.clone());
1035                    }
1036                }
1037            }
1038        }
1039        result
1040    }
1041
1042    /// Cross-file inference sweep. For each `(file, source)` pair, calls the
1043    /// Salsa-tracked `infer_file_return_types` query in parallel, then commits
1044    /// the collected inferred return types to INPUT fields.
1045    ///
1046    /// Files must already be ingested via [`Self::ingest_file`] before calling
1047    /// this method. Subsequent [`FileAnalyzer::analyze`] calls read the committed
1048    /// INPUT fields via O(1) lookups with no lock contention.
1049    pub fn run_inference_sweep(&self, files: &[(Arc<str>, Arc<str>)]) {
1050        use rayon::prelude::*;
1051        let db_priming = self.snapshot_db();
1052        let inferred_results: Vec<crate::db::InferredFileTypes> = files
1053            .par_iter()
1054            .map_with(db_priming, |db, (path, _src)| {
1055                if let Some(sf) = db.lookup_source_file(path) {
1056                    crate::db::infer_file_return_types(db, sf)
1057                } else {
1058                    crate::db::InferredFileTypes::empty()
1059                }
1060            })
1061            .collect();
1062        let mut functions = Vec::new();
1063        let mut methods = Vec::new();
1064        for result in inferred_results {
1065            for (fqn, ty) in result.functions.iter() {
1066                functions.push((fqn.clone(), (**ty).clone()));
1067            }
1068            for ((fqcn, name), ty) in result.methods.iter() {
1069                methods.push((fqcn.clone(), name.clone(), (**ty).clone()));
1070            }
1071        }
1072        let mut guard = self.shared_db.salsa.write();
1073        guard.commit_inferred_return_types(functions, methods);
1074    }
1075
1076    /// File dependency graph: which files depend on which other files.
1077    /// Used for incremental invalidation in LSP servers and build systems.
1078    ///
1079    /// File dependency graph: which files depend on which other files.
1080    /// Used for incremental invalidation in LSP servers and build systems.
1081    ///
1082    /// O(edges) — iterates the `file_references` forward index (file → symbol
1083    /// keys it references) which is always current, then resolves each symbol
1084    /// to its defining file via O(1) lookup.  Total cost is O(E) where E is the
1085    /// number of (file, symbol) reference edges, vs. the old O(F × S × R) scan.
1086    pub fn dependency_graph(&self) -> crate::DependencyGraph {
1087        let db = self.snapshot_db();
1088
1089        let all_files: Vec<String> = db
1090            .source_file_paths()
1091            .iter()
1092            .map(|f| f.as_ref().to_string())
1093            .collect();
1094
1095        let mut dependencies: HashMap<String, Vec<String>> = HashMap::new();
1096        let mut dependents: HashMap<String, Vec<String>> = HashMap::new();
1097
1098        for file in &all_files {
1099            // O(degree(file)) — forward index lookup, no full-table scan.
1100            let symbol_keys = db.file_referenced_symbols(file);
1101            let mut file_deps: HashSet<String> = HashSet::new();
1102            for symbol_key in &symbol_keys {
1103                let lookup: &str = match symbol_key.split_once("::") {
1104                    Some((class, _)) => class,
1105                    None => symbol_key.as_ref(),
1106                };
1107                if let Some(def_file) = db.symbol_defining_file(lookup) {
1108                    let def = def_file.as_ref().to_string();
1109                    if &def != file {
1110                        file_deps.insert(def);
1111                    }
1112                }
1113            }
1114            for dep in &file_deps {
1115                dependents
1116                    .entry(dep.clone())
1117                    .or_default()
1118                    .push(file.clone());
1119                dependencies
1120                    .entry(file.clone())
1121                    .or_default()
1122                    .push(dep.clone());
1123            }
1124        }
1125
1126        // Merge Pass 1 structural deps from the incremental reverse_dep_map.
1127        // dependency_graph() above only captures Pass 2 bare-FQN references;
1128        // the reverse_dep_map covers imports, class hierarchy (extends/implements/use),
1129        // and type-hint-only references that never appear in file_referenced_symbols.
1130        // Together they give a complete picture without requiring Pass 2 on every file.
1131        {
1132            let rev = self.reverse_dep_map.read();
1133            for (target, dep_set) in rev.iter() {
1134                for dep in dep_set {
1135                    if dep != target {
1136                        dependents
1137                            .entry(target.clone())
1138                            .or_default()
1139                            .push(dep.clone());
1140                        dependencies
1141                            .entry(dep.clone())
1142                            .or_default()
1143                            .push(target.clone());
1144                    }
1145                }
1146            }
1147        }
1148
1149        for deps in dependents.values_mut() {
1150            deps.sort();
1151            deps.dedup();
1152        }
1153        for deps in dependencies.values_mut() {
1154            deps.sort();
1155            deps.dedup();
1156        }
1157
1158        // Augment with stale dependents: files referencing symbols that were
1159        // deleted from their defining file. These edges disappear from the
1160        // symbol_defining_file lookup but the referencing file still needs
1161        // re-analysis to surface the now-broken reference.
1162        {
1163            let stale = self.stale_defined_symbols.read();
1164            if !stale.is_empty() {
1165                for (file, deleted_syms) in stale.iter() {
1166                    for sym in deleted_syms {
1167                        let lookup: &str = match sym.split_once("::") {
1168                            Some((class, _)) => class,
1169                            None => sym.as_ref(),
1170                        };
1171                        for referencing_file in db.symbol_referencers_of(lookup) {
1172                            let ref_file = referencing_file.as_ref().to_string();
1173                            if &ref_file != file {
1174                                dependents
1175                                    .entry(file.clone())
1176                                    .or_default()
1177                                    .push(ref_file.clone());
1178                                dependencies.entry(ref_file).or_default().push(file.clone());
1179                            }
1180                        }
1181                    }
1182                }
1183                // Re-sort and dedup since we may have added entries.
1184                for deps in dependents.values_mut() {
1185                    deps.sort();
1186                    deps.dedup();
1187                }
1188                for deps in dependencies.values_mut() {
1189                    deps.sort();
1190                    deps.dedup();
1191                }
1192            }
1193        }
1194
1195        crate::DependencyGraph {
1196            dependencies,
1197            dependents,
1198        }
1199    }
1200}
1201
1202/// Compute the set of files `file` depends on: defining files of its imports,
1203/// plus parent / interfaces / traits' defining files for any classes declared
1204/// in `file`. Self-edges are excluded.
1205fn file_outgoing_dependencies(db: &dyn MirDatabase, file: &str) -> HashSet<String> {
1206    let mut targets: HashSet<String> = HashSet::new();
1207
1208    let mut add_target = |symbol: &str| {
1209        if let Some(defining_file) = db.symbol_defining_file(symbol) {
1210            let def = defining_file.as_ref().to_string();
1211            if def != file {
1212                targets.insert(def);
1213            }
1214        }
1215    };
1216
1217    let extract_named_objects = |union: &mir_types::Union| {
1218        union
1219            .types
1220            .iter()
1221            .filter_map(|atomic| match atomic {
1222                mir_types::atomic::Atomic::TNamedObject { fqcn, .. } => Some(fqcn.clone()),
1223                _ => None,
1224            })
1225            .collect::<Vec<_>>()
1226    };
1227
1228    let imports = db.file_imports(file);
1229    for fqcn in imports.values() {
1230        add_target(fqcn);
1231    }
1232
1233    for fqcn in db.symbols_defined_in_file(file) {
1234        let Some(node) = db.lookup_class_node(fqcn.as_ref()) else {
1235            continue;
1236        };
1237        if let Some(parent) = node.parent(db) {
1238            add_target(parent.as_ref());
1239        }
1240        for iface in node.interfaces(db).iter() {
1241            add_target(iface.as_ref());
1242        }
1243        for tr in node.traits(db).iter() {
1244            add_target(tr.as_ref());
1245        }
1246
1247        // Add types from properties
1248        for prop in db.class_own_properties(fqcn.as_ref()).iter() {
1249            if let Some(ty) = prop.ty(db) {
1250                for named in extract_named_objects(&ty) {
1251                    add_target(named.as_ref());
1252                }
1253            }
1254        }
1255
1256        // Add types from methods
1257        for method in db.class_own_methods(fqcn.as_ref()).iter() {
1258            // Parameter types
1259            for param in method.params(db).iter() {
1260                if let Some(ty) = &param.ty {
1261                    for named in extract_named_objects(ty.as_ref()) {
1262                        add_target(named.as_ref());
1263                    }
1264                }
1265            }
1266            // Return type
1267            if let Some(rt) = method.return_type(db) {
1268                for named in extract_named_objects(rt.as_ref()) {
1269                    add_target(named.as_ref());
1270                }
1271            }
1272        }
1273    }
1274
1275    // Add types from global functions
1276    for fqn in db.active_function_node_fqns() {
1277        let Some(node) = db.lookup_function_node(fqn.as_ref()) else {
1278            continue;
1279        };
1280        if let Some(file_of_fn) = db.symbol_defining_file(fqn.as_ref()) {
1281            if file_of_fn.as_ref() != file {
1282                continue;
1283            }
1284        } else {
1285            continue;
1286        }
1287
1288        // Parameter types
1289        for param in node.params(db).iter() {
1290            if let Some(ty) = &param.ty {
1291                for named in extract_named_objects(ty.as_ref()) {
1292                    add_target(named.as_ref());
1293                }
1294            }
1295        }
1296        // Return type
1297        if let Some(rt) = node.return_type(db) {
1298            for named in extract_named_objects(rt.as_ref()) {
1299                add_target(named.as_ref());
1300            }
1301        }
1302    }
1303
1304    // Also track bare-FQN references recorded during Pass 2 (new \Foo(), \Foo::method(),
1305    // \foo()) that do not appear in use-import statements.
1306    for symbol_key in db.file_referenced_symbols(file) {
1307        let lookup: &str = match symbol_key.split_once("::") {
1308            Some((class, _)) => class,
1309            None => &symbol_key,
1310        };
1311        add_target(lookup);
1312    }
1313
1314    targets
1315}