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