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(¤t) {
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) = ¶m.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) = ¶m.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}