Skip to main content

mir_analyzer/session/
mod.rs

1//! Session-based analysis API for incremental, per-file analysis.
2//!
3//! [`AnalysisSession`] owns the salsa database and per-session caches for a
4//! long-running analysis context shared across many per-file analyses. Reads
5//! clone the database under a brief lock, then run lock-free; writes hold the
6//! lock briefly to mutate canonical state. `MirDbStorage::clone()` is cheap
7//! (Arc-wrapped registries), so this pattern gives parallel readers without
8//! blocking on concurrent writes for longer than the clone itself.
9//!
10//! See [`crate::file_analyzer::FileAnalyzer`] for the per-file analysis
11//! entry point that operates against a session.
12
13use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet};
14use std::path::PathBuf;
15use std::sync::Arc;
16
17use parking_lot::RwLock;
18
19use crate::analyzer_db::AnalyzerDb;
20use crate::cache::AnalysisCache;
21use crate::composer::Psr4Map;
22use crate::db::{MirDatabase, MirDbStorage, RefLoc};
23use crate::php_version::PhpVersion;
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`].
31#[derive(Clone)]
32pub struct AnalysisSession {
33    /// Shared database management (salsa, file registry, stub tracking).
34    pub(crate) db: Arc<AnalyzerDb>,
35    pub(crate) 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    pub(crate) 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    pub(crate) php_version: PhpVersion,
47    pub(crate) user_stub_files: Vec<PathBuf>,
48    pub(crate) user_stub_dirs: Vec<PathBuf>,
49    /// Tracks symbols that were previously defined in a file but have since
50    /// been removed (deleted or renamed). When `ingest_file` detects that
51    /// a symbol disappears, it records it here so `dependency_graph()` can
52    /// still produce edges to files that reference the now-gone symbol.
53    ///
54    /// Keyed by the file that used to define the symbols. Symbols are removed
55    /// from the set when re-added to the same file on a subsequent ingest.
56    /// The set may contain symbols with no current referencers; those are
57    /// harmless — the `symbol_referencers_of` lookup returns empty.
58    stale_defined_symbols: Arc<RwLock<HashMap<String, HashSet<Arc<str>>>>>,
59    /// Symbols defined by each file as of its last `ingest_file`. The
60    /// authoritative "old" set for the rename/deletion diff, independent of
61    /// whether the salsa `SourceFile` input was already updated to the new text
62    /// by a host driving the db directly (the LSP convergence path). Without
63    /// this, re-deriving "old" symbols from the (possibly pre-updated) input
64    /// would miss deletions and break cross-file dependency invalidation.
65    last_ingested_symbols: Arc<RwLock<HashMap<String, HashSet<Arc<str>>>>>,
66    /// Negative cache: FQCNs that `load_class` already failed on.
67    /// The value is the resolver-mapped path (when known) so eviction on
68    /// `set_file_text` / `ingest_file` is a path equality check rather than
69    /// re-running the resolver per entry. `None` means the resolver itself
70    /// couldn't map the FQCN; those entries survive file edits (no source
71    /// change makes a never-resolvable name resolvable).
72    /// Bounded to `UNRESOLVABLE_CACHE_CAP`; clears on overflow.
73    unresolvable_fqcns: UnresolvableCache,
74    /// Pluggable source-text provider for lazy-load. Defaults to filesystem
75    /// reads ([`crate::FsSourceProvider`]); LSPs swap in a VFS-backed
76    /// implementation so unsaved buffers override on-disk content.
77    source_provider: Arc<dyn crate::SourceProvider>,
78    /// Vendor `autoload.files` entries not yet indexed. `Some(paths)` means
79    /// pending; `None` means the load has already run (idempotent). Populated
80    /// by [`Self::with_psr4`]; drained by [`Self::ensure_vendor_eager_functions`],
81    /// which is called automatically from [`Self::prepare_ast_for_analysis`].
82    ///
83    /// The mutex is held for the full duration of the load so concurrent callers
84    /// block until indexing is complete rather than proceeding with a stale
85    /// workspace snapshot.
86    pub(crate) pending_eager_function_files: Arc<parking_lot::Mutex<Option<Vec<PathBuf>>>>,
87    /// Warm-up skip set: files whose [`Self::prepare_ast_for_analysis`] has
88    /// already run against their current text. Value is `(text, generation)` —
89    /// the entry is live while the file's input text is pointer-equal to `text`
90    /// (a text edit self-invalidates) and `generation` matches
91    /// [`Self::prepare_generation`]. Lets the per-request Phase-1 warm-up in
92    /// `references_to_in_files` / `reanalyze_dependents` skip the serial
93    /// parse + AST walk for files already faulted in.
94    prepared_files: PreparedFilesCache,
95    /// Bumped whenever previously loaded declarations may have been removed
96    /// (`invalidate_file`, symbol deletions on `ingest_file`, or a host calling
97    /// [`Self::bump_prepare_generation`]) — a prepared file might then need its
98    /// warm-up re-run to lazy-load a replacement (e.g. a vendor class shadowed
99    /// by a since-deleted project class).
100    prepare_generation: Arc<std::sync::atomic::AtomicU64>,
101    /// file → [`RefCommit`] its reference locations were last committed
102    /// from. Exact while the text is pointer-equal and the commit either
103    /// fully resolved every name it referenced or was stamped at the current
104    /// [`Self::index_generation`] — a later symbol add elsewhere can resolve
105    /// a reference this file's analysis left unresolved, even though this
106    /// file's own text never changed. Files absent here have never been
107    /// committed.
108    ref_committed: CommittedRefs,
109    /// file → source text its subtype-index class edges were last committed
110    /// from. Same freshness contract as `ref_committed`, but definitions
111    /// depend only on the file's own text, so a pointer-equal entry is
112    /// always exact (no cross-file drift).
113    defs_committed: CommittedTexts,
114}
115
116/// FQCN → optional resolver-mapped path. See the field doc on
117/// `AnalysisSession::unresolvable_fqcns`.
118type UnresolvableCache = Arc<RwLock<HashMap<Arc<str>, Option<Arc<str>>>>>;
119
120/// Warm-up skip set keyed by file path. See the field doc on
121/// `AnalysisSession::prepared_files`.
122type PreparedFilesCache = Arc<RwLock<HashMap<Arc<str>, (Arc<str>, u64)>>>;
123
124/// file → text a per-file index commit was computed from. See the field docs
125/// on `AnalysisSession::ref_committed` / `defs_committed`.
126type CommittedTexts = Arc<RwLock<HashMap<Arc<str>, Arc<str>>>>;
127
128/// A staged [`AnalysisCache`] write for one file's postings, prepared in the
129/// parallel analysis phase and applied during the serial index commit. See
130/// `AnalysisSession::stage_ref_cache_put`.
131pub(crate) struct RefCachePut {
132    content_hash: String,
133    surface_hash: String,
134    ref_locs: Arc<[crate::cache::CachedRefLoc]>,
135}
136
137/// One file's reference-posting commit. See `AnalysisSession::ref_committed`.
138pub(crate) struct RefCommit {
139    /// Source text the postings were computed from (pointer identity; a
140    /// text write self-invalidates).
141    text: Arc<str>,
142    /// Weak handle on the analyze memo — pointer-identical output means
143    /// identical postings, so sweeps can skip the index rewrite. The upgrade
144    /// guards against ABA on evicted memos.
145    out: std::sync::Weak<crate::db::AnalyzeOutput>,
146    /// Workspace generation whose resolution environment the postings
147    /// reflect, captured *before* the analysis snapshot.
148    generation: u64,
149    /// The analysis resolved every workspace-level name it referenced, so no
150    /// later symbol add can change the postings and the commit survives
151    /// generation bumps. FQCN shadowing and unqualified-call fallback
152    /// switches remain the reanalyze_dependents flow's job, as before.
153    resolved: bool,
154}
155
156/// file → [`RefCommit`] map shared across session clones.
157type CommittedRefs = Arc<RwLock<HashMap<Arc<str>, RefCommit>>>;
158
159/// Cap on the negative-resolution cache. Sized to accommodate a large
160/// workspace's worth of genuinely-missing references without unbounded
161/// growth. On overflow the cache is cleared; the cost is a few extra
162/// resolver calls until it re-fills.
163const UNRESOLVABLE_CACHE_CAP: usize = 10_000;
164
165impl AnalysisSession {
166    /// Create a session targeting the given PHP language version.
167    pub fn new(php_version: PhpVersion) -> Self {
168        let db = Arc::new(AnalyzerDb::new());
169        db.salsa
170            .write()
171            .set_php_version(Arc::from(php_version.to_string().as_str()));
172        Self {
173            db,
174            cache: None,
175            psr4: None,
176            resolver: None,
177            php_version,
178            user_stub_files: Vec::new(),
179            user_stub_dirs: Vec::new(),
180            stale_defined_symbols: Arc::new(RwLock::new(HashMap::default())),
181            last_ingested_symbols: Arc::new(RwLock::new(HashMap::default())),
182            unresolvable_fqcns: Arc::new(RwLock::new(HashMap::default())),
183            source_provider: Arc::new(crate::FsSourceProvider),
184            pending_eager_function_files: Arc::new(parking_lot::Mutex::new(Some(Vec::new()))),
185            prepared_files: Arc::new(RwLock::new(HashMap::default())),
186            prepare_generation: Arc::new(std::sync::atomic::AtomicU64::new(0)),
187            ref_committed: Arc::new(RwLock::new(HashMap::default())),
188            defs_committed: Arc::new(RwLock::new(HashMap::default())),
189        }
190    }
191
192    /// Times the reference index has been locked on this session's db.
193    pub fn ref_index_lock_count(&self) -> u64 {
194        self.db.salsa.read().ref_index_lock_count()
195    }
196
197    /// Coverage/size counters for the class-mention gate index (host
198    /// metrics and memory-bound checks).
199    pub fn class_mention_stats(&self) -> crate::db::ClassMentionStats {
200        self.db.salsa.read().class_mention_stats()
201    }
202
203    /// Whether `file`'s reference postings are exact for `current_text` at
204    /// `current_gen`: text pointer-equal, and the commit either resolved
205    /// every name (immune to workspace growth) or was stamped at that
206    /// generation — catches a file analyzed before a class it references
207    /// was registered elsewhere, which would otherwise look fresh forever.
208    pub(crate) fn is_ref_committed(
209        &self,
210        file: &str,
211        current_text: &Arc<str>,
212        current_gen: u64,
213    ) -> bool {
214        self.ref_committed.read().get(file).is_some_and(|c| {
215            Arc::ptr_eq(&c.text, current_text) && (c.resolved || c.generation == current_gen)
216        })
217    }
218
219    /// Whether `file`'s stored postings came from exactly this
220    /// (text, output) pair — generation aside. Pointer-identical output
221    /// means identical postings (salsa backdates equal results to the same
222    /// Arc), so callers skip the index rewrite and only re-stamp the mark.
223    pub(crate) fn ref_commit_is_current(
224        &self,
225        file: &str,
226        current_text: &Arc<str>,
227        out: &Arc<crate::db::AnalyzeOutput>,
228    ) -> bool {
229        self.ref_committed.read().get(file).is_some_and(|c| {
230            Arc::ptr_eq(&c.text, current_text)
231                && c.out.upgrade().is_some_and(|prev| Arc::ptr_eq(&prev, out))
232        })
233    }
234
235    /// Record a commit computed against the workspace state at `generation`
236    /// — captured by the caller *before* its analysis snapshot, so a file
237    /// add racing the analysis leaves the commit stale (re-verified on the
238    /// next query) rather than wrongly fresh. `resolved` must come from the
239    /// producing analysis' own issue set
240    /// ([`crate::db::issues_have_unresolved_names`]); pass `false` when
241    /// unknown — the gen-guarded safe direction.
242    pub(crate) fn mark_ref_committed(
243        &self,
244        file: &Arc<str>,
245        text: &Arc<str>,
246        out: Option<&Arc<crate::db::AnalyzeOutput>>,
247        generation: u64,
248        resolved: bool,
249    ) {
250        let commit = RefCommit {
251            text: text.clone(),
252            out: out.map(Arc::downgrade).unwrap_or_default(),
253            generation,
254            resolved,
255        };
256        self.ref_committed.write().insert(file.clone(), commit);
257    }
258
259    pub(crate) fn forget_ref_committed(&self, file: &str) {
260        self.ref_committed.write().remove(file);
261    }
262
263    /// Stage a disk-cache write for `file`'s postings, computed in the
264    /// parallel analysis phase (needs a live db snapshot for the memoized
265    /// parse). `None` when no cache is attached or the stored entry already
266    /// matches this content — batch-written entries are never clobbered.
267    /// The caller applies the result via [`Self::apply_ref_cache_put`] in
268    /// its serial commit, alongside the in-memory index commit.
269    pub(crate) fn stage_ref_cache_put(
270        &self,
271        db: &dyn crate::db::MirDatabase,
272        sf: crate::db::SourceFile,
273        file: &str,
274        text: &Arc<str>,
275        out: &Arc<crate::db::AnalyzeOutput>,
276    ) -> Option<RefCachePut> {
277        let cache = self.cache.as_deref()?;
278        let content_hash = crate::cache::hash_content(text);
279        if cache.is_valid(file, &content_hash) {
280            return None;
281        }
282        let parsed = crate::db::parse_file(db, sf);
283        let surface_hash = crate::cache::surface_fingerprint(text, &parsed.0.program);
284        let ref_locs: Arc<[crate::cache::CachedRefLoc]> = out
285            .ref_locs
286            .iter()
287            .map(|r| (Arc::clone(&r.symbol_key), r.line, r.col_start, r.col_end))
288            .collect();
289        Some(RefCachePut {
290            content_hash,
291            surface_hash,
292            ref_locs,
293        })
294    }
295
296    pub(crate) fn apply_ref_cache_put(
297        &self,
298        file: &str,
299        out: &Arc<crate::db::AnalyzeOutput>,
300        put: RefCachePut,
301    ) {
302        if let Some(cache) = self.cache.as_deref() {
303            cache.put(
304                file,
305                put.content_hash,
306                put.surface_hash,
307                out.issues.clone(),
308                put.ref_locs,
309            );
310        }
311    }
312
313    /// Persist the attached [`AnalysisCache`] to disk. No-op without an
314    /// attached cache or when nothing changed since the last flush.
315    /// Reference postings committed by session sweeps and on-demand query
316    /// freshness passes reach disk only here — a host should call this after
317    /// its warm sweep completes and on shutdown so the next launch's
318    /// [`Self::warm_start_files`] finds them.
319    pub fn flush_analysis_cache(&self) {
320        if let Some(cache) = &self.cache {
321            cache.flush();
322        }
323    }
324
325    /// Whether the workspace symbol index singleton is populated (seeded by
326    /// [`Self::warm_start_files`] or built by `index_batch`) — symbol lookups
327    /// answer from the O(1) map instead of the tracked O(all-files) walk.
328    pub fn workspace_symbol_index_ready(&self) -> bool {
329        self.db
330            .salsa
331            .read()
332            .workspace_symbol_index_singleton()
333            .is_some()
334    }
335
336    /// Executions of the tracked O(all-files) `workspace_symbol_index` walk
337    /// (diagnostic; a warm-started session should keep this at zero).
338    pub fn workspace_index_walks(&self) -> u64 {
339        self.db.salsa.read().workspace_index_walks()
340    }
341
342    /// Whether `file`'s subtype-index class edges were committed from exactly
343    /// `current_text`.
344    pub(crate) fn is_defs_committed(&self, file: &str, current_text: &Arc<str>) -> bool {
345        self.defs_committed
346            .read()
347            .get(file)
348            .is_some_and(|t| Arc::ptr_eq(t, current_text))
349    }
350
351    pub(crate) fn mark_defs_committed(&self, file: &Arc<str>, text: &Arc<str>) {
352        self.defs_committed
353            .write()
354            .insert(file.clone(), text.clone());
355    }
356
357    pub(crate) fn forget_defs_committed(&self, file: &str) {
358        self.defs_committed.write().remove(file);
359    }
360
361    /// Every file with a defs commit on record, regardless of staleness.
362    pub(crate) fn defs_committed_keys(&self) -> Vec<Arc<str>> {
363        self.defs_committed.read().keys().cloned().collect()
364    }
365
366    /// Every file with a reference commit on record, regardless of
367    /// staleness. Files absent here have no reference postings at all.
368    pub(crate) fn ref_committed_keys(&self) -> Vec<Arc<str>> {
369        self.ref_committed.read().keys().cloned().collect()
370    }
371
372    /// Swap in a custom [`crate::SourceProvider`]. LSPs install a VFS-backed
373    /// provider here so the analyzer reads from unsaved editor buffers
374    /// instead of disk.
375    pub fn with_source_provider(mut self, provider: Arc<dyn crate::SourceProvider>) -> Self {
376        self.source_provider = provider;
377        self
378    }
379
380    /// Attach a pre-built [`AnalysisCache`] (the body-analysis issue cache) and
381    /// open a sibling definition [`StubSlice`] cache under the same root, so
382    /// callers using this builder get the same speedup as `with_cache_dir`.
383    ///
384    /// Rebuilds the shared database to attach the definition cache — call
385    /// **before** any file is ingested. A debug assertion catches misuse.
386    ///
387    /// [`StubSlice`]: mir_codebase::definitions::StubSlice
388    pub fn with_cache(mut self, cache: Arc<AnalysisCache>) -> Self {
389        debug_assert_eq!(
390            self.db.source_file_count(),
391            0,
392            "AnalysisSession::with_cache must be called before any file is ingested"
393        );
394        let dir = cache.cache_dir().to_path_buf();
395        self.db = Arc::new(AnalyzerDb::new().with_cache_dir(&dir));
396        self.db
397            .salsa
398            .write()
399            .set_php_version(Arc::from(self.php_version.to_string().as_str()));
400        self.cache = Some(cache);
401        self
402    }
403
404    /// Convenience: open a disk-backed cache at `cache_dir` and attach it.
405    ///
406    /// Attaches both the body-analysis issue cache ([`AnalysisCache`]) and the
407    /// definition [`StubSlice`] cache to the shared database. Builds a fresh
408    /// [`AnalyzerDb`] internally — call **before** any file is ingested. A
409    /// debug assertion catches misuse.
410    ///
411    /// [`StubSlice`]: mir_codebase::definitions::StubSlice
412    pub fn with_cache_dir(mut self, cache_dir: &std::path::Path) -> Self {
413        debug_assert_eq!(
414            self.db.source_file_count(),
415            0,
416            "AnalysisSession::with_cache_dir must be called before any file is ingested"
417        );
418        self.db = Arc::new(AnalyzerDb::new().with_cache_dir(cache_dir));
419        self.db
420            .salsa
421            .write()
422            .set_php_version(Arc::from(self.php_version.to_string().as_str()));
423        // Fold the user-stub fingerprint into the cache epoch. `with_user_stubs`
424        // must run before this for it to be picked up (it does in `build_session`);
425        // sessions without user stubs get 0, which is correct.
426        let user_stub_fp =
427            crate::stubs::user_stub_fingerprint(&self.user_stub_files, &self.user_stub_dirs);
428        self.cache = Some(Arc::new(AnalysisCache::open(
429            cache_dir,
430            self.php_version.cache_byte(),
431            user_stub_fp,
432        )));
433        self
434    }
435
436    /// Attach a Composer autoload map (PSR-4, PSR-0, classmap, files).
437    /// Sets the same map as the active [`crate::ClassResolver`] so
438    /// [`Self::load_class`] works out of the box.
439    pub fn with_psr4(mut self, map: Arc<Psr4Map>) -> Self {
440        let user_resolver: Arc<dyn crate::ClassResolver> = map.clone();
441        // Wrap with stub awareness so `find_class_like` / `resolve_fqcn_to_path`
442        // can map built-in PHP class FQCNs (`ArrayObject`, `Exception`, …)
443        // to their stub virtual paths.
444        let resolver: Arc<dyn crate::ClassResolver> = Arc::new(crate::ChainedClassResolver::new(
445            user_resolver,
446            Arc::new(crate::StubClassResolver),
447        ));
448        self.psr4 = Some(map.clone());
449        self.resolver = Some(resolver.clone());
450        // Mirror into MirDbStorage so salsa-tracked resolver queries
451        // (`db::resolve_fqcn_to_path`) see the same resolver and are
452        // invalidated on swap.
453        self.db.salsa.write().set_resolver(Some(resolver));
454        // Register vendor autoload.files for lazy loading. They define global
455        // functions and constants that the class resolver cannot discover.
456        // `ensure_vendor_eager_functions` will index them on first analysis call.
457        *self.pending_eager_function_files.lock() = Some(map.vendor_eager_files());
458        self
459    }
460
461    /// Attach a generic class resolver for projects that don't use Composer
462    /// (WordPress, Drupal, custom autoloaders, workspace-walk indexes).
463    /// Replaces any previously-set Composer-backed resolver. Automatically
464    /// wrapped with stub awareness so PHP built-ins remain resolvable.
465    pub fn with_class_resolver(mut self, resolver: Arc<dyn crate::ClassResolver>) -> Self {
466        let wrapped: Arc<dyn crate::ClassResolver> = Arc::new(crate::ChainedClassResolver::new(
467            resolver,
468            Arc::new(crate::StubClassResolver),
469        ));
470        self.db.salsa.write().set_resolver(Some(wrapped.clone()));
471        self.resolver = Some(wrapped);
472        self
473    }
474
475    pub fn with_user_stubs(mut self, files: Vec<PathBuf>, dirs: Vec<PathBuf>) -> Self {
476        self.user_stub_files = files;
477        self.user_stub_dirs = dirs;
478        self
479    }
480
481    pub fn php_version(&self) -> PhpVersion {
482        self.php_version
483    }
484
485    pub fn cache(&self) -> Option<&AnalysisCache> {
486        self.cache.as_deref()
487    }
488
489    pub fn psr4(&self) -> Option<&Psr4Map> {
490        self.psr4.as_deref()
491    }
492}
493
494mod incremental;
495mod ingest;
496mod loading;
497mod queries;
498mod stubs;
499
500pub use queries::SubtypeClassSite;
501
502/// Compute the full set of files `file` depends on: structural edges from
503/// the memoized [`crate::db::file_structural_deps`] tracked query, plus
504/// bare-FQN references recorded during body analysis (which live in the
505/// reference index and are not visible to salsa). Self-edges are excluded.
506/// Used to persist the disk cache's reverse-dep graph.
507fn file_outgoing_dependencies(
508    db: &dyn MirDatabase,
509    file: &str,
510    include_body_ref_edges: bool,
511) -> HashSet<String> {
512    let mut targets: HashSet<String> = HashSet::default();
513
514    if let Some(sf) = db.lookup_source_file(file) {
515        for target in crate::db::file_structural_deps(db, sf).iter() {
516            targets.insert(target.as_ref().to_string());
517        }
518    }
519
520    if !include_body_ref_edges {
521        return targets;
522    }
523
524    // Bare-FQN references recorded during body analysis (new \Foo(),
525    // \Foo::method(), \foo()) that do not appear in use-import statements.
526    for symbol_key in db.file_referenced_symbols(file) {
527        let lookup = crate::defining_file_lookup_key(&symbol_key);
528        if let Some(defining_file) = db.symbol_defining_file(lookup) {
529            if defining_file.as_ref() != file {
530                targets.insert(defining_file.as_ref().to_string());
531            }
532        }
533    }
534
535    targets
536}
537
538/// AST visitor that collects class FQCN references for PSR-4 preloading.
539/// Captures identifiers from `new X`, static calls / property / constant
540/// access, type hints, `instanceof`, and `@param`/`@return`/`@var`/`@extends`/
541/// `@implements` docblock annotations. Does *not* normalize via PSR-4 /
542/// imports — callers run the raw string through `resolve_name`.
543fn collect_class_refs_from_ast(program: &php_ast::owned::Program) -> Vec<String> {
544    use php_ast::ast::BinaryOp;
545    use php_ast::owned::visitor::{
546        walk_owned_class_member, walk_owned_expr, walk_owned_program, walk_owned_stmt, OwnedVisitor,
547    };
548    use php_ast::owned::{ClassMemberKind, ExprKind};
549    use std::ops::ControlFlow;
550
551    fn owned_name_str(name: &php_ast::owned::Name) -> String {
552        let joined: String = name
553            .parts
554            .iter()
555            .map(|p| p.as_ref())
556            .collect::<Vec<&str>>()
557            .join("\\");
558        if name.kind == php_ast::ast::NameKind::FullyQualified {
559            format!("\\{joined}")
560        } else {
561            joined
562        }
563    }
564
565    /// Recursively collect all `TNamedObject` FQCNs from a mir type, including
566    /// those nested inside generic type parameters (e.g. `Collection<Item>`).
567    fn collect_from_type(ty: &mir_types::Type, out: &mut std::collections::HashSet<String>) {
568        for atomic in ty.types.iter() {
569            if let mir_types::Atomic::TNamedObject { fqcn, type_params } = atomic {
570                out.insert(fqcn.as_ref().to_string());
571                for tp in type_params.iter() {
572                    collect_from_type(tp, out);
573                }
574            }
575        }
576    }
577
578    /// Parse a docblock and collect class names from `@param`, `@return`,
579    /// `@var`, `@extends`, and `@implements` annotations.
580    fn collect_from_docblock(text: &str, out: &mut std::collections::HashSet<String>) {
581        let parsed = crate::parser::DocblockParser::parse(text);
582        for (_, ty) in &parsed.params {
583            collect_from_type(ty, out);
584        }
585        if let Some(ret) = &parsed.return_type {
586            collect_from_type(ret, out);
587        }
588        if let Some(var) = &parsed.var_type {
589            collect_from_type(var, out);
590        }
591        for ext in &parsed.extends {
592            collect_from_type(ext, out);
593        }
594        for impl_ty in &parsed.implements {
595            collect_from_type(impl_ty, out);
596        }
597    }
598
599    struct V {
600        names: std::collections::HashSet<String>,
601    }
602    impl OwnedVisitor for V {
603        fn visit_stmt(&mut self, stmt: &php_ast::owned::Stmt) -> ControlFlow<()> {
604            if let Some(doc) = stmt.leading_doc_comment() {
605                collect_from_docblock(&doc.text, &mut self.names);
606            }
607            walk_owned_stmt(self, stmt)
608        }
609
610        fn visit_class_member(&mut self, member: &php_ast::owned::ClassMember) -> ControlFlow<()> {
611            match &member.kind {
612                ClassMemberKind::Method(m) => {
613                    if let Some(doc) = &m.doc_comment {
614                        collect_from_docblock(&doc.text, &mut self.names);
615                    }
616                }
617                ClassMemberKind::Property(p) => {
618                    if let Some(doc) = &p.doc_comment {
619                        collect_from_docblock(&doc.text, &mut self.names);
620                    }
621                }
622                _ => {}
623            }
624            walk_owned_class_member(self, member)
625        }
626
627        fn visit_expr(&mut self, expr: &php_ast::owned::Expr) -> ControlFlow<()> {
628            match &expr.kind {
629                ExprKind::New(n) => {
630                    if let ExprKind::Identifier(name) = &n.class.kind {
631                        self.names.insert(name.as_ref().to_string());
632                    }
633                }
634                ExprKind::StaticMethodCall(c) => {
635                    if let ExprKind::Identifier(name) = &c.class.kind {
636                        self.names.insert(name.as_ref().to_string());
637                    }
638                }
639                ExprKind::StaticPropertyAccess(a) => {
640                    if let ExprKind::Identifier(name) = &a.class.kind {
641                        self.names.insert(name.as_ref().to_string());
642                    }
643                }
644                ExprKind::ClassConstAccess(a) => {
645                    if let ExprKind::Identifier(name) = &a.class.kind {
646                        self.names.insert(name.as_ref().to_string());
647                    }
648                }
649                ExprKind::Binary(b) if b.op == BinaryOp::Instanceof => {
650                    if let ExprKind::Identifier(name) = &b.right.kind {
651                        self.names.insert(name.as_ref().to_string());
652                    }
653                }
654                _ => {}
655            }
656            walk_owned_expr(self, expr)
657        }
658
659        // Walker routes every class/type-position Name here: type hints, catch types, extends/implements, trait use, attributes.
660        fn visit_name(&mut self, name: &php_ast::owned::Name) -> ControlFlow<()> {
661            let s = owned_name_str(name);
662            if !s.is_empty() {
663                self.names.insert(s);
664            }
665            ControlFlow::Continue(())
666        }
667    }
668    let mut v = V {
669        names: std::collections::HashSet::default(),
670    };
671    let _ = walk_owned_program(&mut v, program);
672    v.names.into_iter().collect()
673}