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