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. It is
4//! the single writer (mutations take `&mut self`); readers on other threads
5//! use [`AnalysisSnapshot`]s from [`AnalysisSession::snapshot`].
6//!
7//! See [`crate::file_analyzer::FileAnalyzer`] for the per-file analysis
8//! entry point that operates against a session.
9
10use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet};
11use std::path::PathBuf;
12use std::sync::Arc;
13
14use crate::analyzer_db::AnalyzerDb;
15use crate::cache::AnalysisCache;
16use crate::composer::Psr4Map;
17use crate::db::{MirDatabase, MirDbStorage, RefLoc};
18use crate::php_version::PhpVersion;
19
20/// Long-lived analysis context. Owns the salsa database and tracks which
21/// stubs have been loaded.
22///
23/// `Send` but not `Sync`: share it across threads through
24/// [`AnalysisSession::snapshot`], never by reference.
25pub struct AnalysisSession {
26    /// Database management (salsa, file registry, stub tracking).
27    pub(crate) db: AnalyzerDb,
28    pub(crate) cache: Option<Arc<AnalysisCache>>,
29    /// PSR-4 / Composer autoload map. Retained alongside `resolver` so the
30    /// `psr4()` accessor can still return a typed `Psr4Map` for callers that
31    /// need Composer-specific data (project_files / vendor_files / etc.).
32    pub(crate) psr4: Option<Arc<Psr4Map>>,
33    /// Generic class resolver used for on-demand lazy loading. When `psr4`
34    /// is set via [`Self::with_psr4`], this is populated with the same map
35    /// re-typed as `dyn ClassResolver`. Consumers can also supply their own
36    /// resolver via [`Self::with_class_resolver`] without going through
37    /// Composer.
38    resolver: Option<Arc<dyn crate::ClassResolver>>,
39    pub(crate) php_version: PhpVersion,
40    memoize_method_call_results: bool,
41    forbidden_functions: Vec<String>,
42    pub(crate) user_stub_files: Vec<PathBuf>,
43    pub(crate) user_stub_dirs: Vec<PathBuf>,
44    /// Tracks symbols that were previously defined in a file but have since
45    /// been removed (deleted or renamed). When `ingest_file` detects that
46    /// a symbol disappears, it records it here so `dependency_graph()` can
47    /// still produce edges to files that reference the now-gone symbol.
48    ///
49    /// Keyed by the file that used to define the symbols. Symbols are removed
50    /// from the set when re-added to the same file on a subsequent ingest.
51    /// The set may contain symbols with no current referencers; those are
52    /// harmless — the `symbol_referencers_of` lookup returns empty.
53    stale_defined_symbols: HashMap<String, HashSet<Arc<str>>>,
54    /// Symbols defined by each file as of its last `ingest_file`. The
55    /// authoritative "old" set for the rename/deletion diff, independent of
56    /// whether the salsa `SourceFile` input was already updated to the new text
57    /// by a host driving the db directly (the LSP convergence path). Without
58    /// this, re-deriving "old" symbols from the (possibly pre-updated) input
59    /// would miss deletions and break cross-file dependency invalidation.
60    last_ingested_symbols: HashMap<String, HashSet<Arc<str>>>,
61    /// Structural outgoing dependency targets by file, as of the last
62    /// ingestion that updated this session's declaration state. Lets
63    /// `ingest_file` tell whether the dependency graph's declaration-shaped
64    /// edges actually changed, so body-only edits need not invalidate the
65    /// cached graph.
66    last_structural_targets: HashMap<String, HashSet<String>>,
67    /// Negative cache: FQCNs that `load_class` already failed on.
68    /// The value is the resolver-mapped path (when known) so eviction on
69    /// `set_file_text` / `ingest_file` is a path equality check rather than
70    /// re-running the resolver per entry. `None` means the resolver itself
71    /// couldn't map the FQCN; those entries survive file edits (no source
72    /// change makes a never-resolvable name resolvable).
73    /// Bounded to `UNRESOLVABLE_CACHE_CAP`; clears on overflow.
74    unresolvable_fqcns: HashMap<Arc<str>, Option<Arc<str>>>,
75    /// Vendor `autoload.files` entries not yet indexed. `Some(paths)` means
76    /// pending; `None` means the load has already run (idempotent). Populated
77    /// by [`Self::with_psr4`]; drained by [`Self::ensure_vendor_eager_functions`],
78    /// which is called automatically from [`Self::prepare_ast_for_analysis`].
79    pub(crate) pending_eager_function_files: Option<Vec<PathBuf>>,
80    /// Warm-up skip set: files whose [`Self::prepare_ast_for_analysis`] has
81    /// already run against their current text. Value is `(text, generation)` —
82    /// the entry is live while the file's input text is pointer-equal to `text`
83    /// (a text edit self-invalidates) and `generation` matches
84    /// [`Self::prepare_generation`]. Lets the per-request Phase-1 warm-up in
85    /// `indexed_references_to` / `reanalyze_dependents` skip the serial
86    /// parse + AST walk for files already faulted in.
87    prepared_files: PreparedFilesCache,
88    /// Parsed suppression directives for unchanged files. Batch analysis
89    /// revisits every analyzed file to emit `UnusedSuppress`, so retaining
90    /// maps across same-session runs avoids rescanning the full workspace.
91    pub(crate) suppression_maps: SuppressionMapCache,
92    /// Bumped whenever previously loaded declarations may have been removed
93    /// (`invalidate_file`, symbol deletions on `ingest_file`, or a host calling
94    /// [`Self::bump_prepare_generation`]) — a prepared file might then need its
95    /// warm-up re-run to lazy-load a replacement (e.g. a vendor class shadowed
96    /// by a since-deleted project class).
97    prepare_generation: u64,
98    /// Index freshness marks and query memos, shared with every
99    /// [`AnalysisSnapshot`].
100    pub(crate) index: Arc<IndexState>,
101}
102
103const _: () = {
104    const fn assert_send<T: Send>() {}
105    assert_send::<AnalysisSession>();
106};
107
108// Fails to compile once `AnalysisSession: Sync`: the second impl then makes
109// `<_>` ambiguous.
110const _: fn() = || {
111    trait AmbiguousIfSync<A> {
112        fn some_item() {}
113    }
114    impl<T: ?Sized> AmbiguousIfSync<()> for T {}
115    struct IsSync;
116    impl<T: ?Sized + Sync> AmbiguousIfSync<IsSync> for T {}
117    let _ = <AnalysisSession as AmbiguousIfSync<_>>::some_item;
118};
119
120/// Which reference postings [`AnalysisSession::indexed_references_to`]
121/// should read back from the maintained reference index.
122#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
123pub enum ReferenceIncludes {
124    /// Plain usage/declaration references only (`cls:` / `fn:` / member
125    /// postings), excluding `use` import items.
126    Plain,
127    /// Only `use` import items (`use Foo\Bar;`, `use function ...;`,
128    /// `use const ...;`), excluding plain usage/declaration references.
129    UseImports,
130    /// Plain references plus `use` import items.
131    PlainAndUseImports,
132}
133
134/// file → `(text, prepare generation)`. See `AnalysisSession::prepared_files`.
135type PreparedFilesCache = HashMap<Arc<str>, (Arc<str>, u64)>;
136
137/// Parsed inline suppressions keyed by file path and source text.
138type SuppressionMapCache = HashMap<Arc<str>, (Arc<str>, Arc<crate::suppression::SuppressionMap>)>;
139
140/// One file's replayable body-analysis output from the previous batch run.
141pub(crate) struct BatchReplayFile {
142    pub(crate) issues: Arc<[mir_issues::Issue]>,
143    pub(crate) ref_locs: Arc<[crate::cache::CachedRefLoc]>,
144    pub(crate) symbols: Arc<[crate::symbol::ResolvedSymbol]>,
145}
146
147/// Fingerprint for a session-local whole-batch replay.
148///
149/// The replay path is intentionally narrow: exact path→content-hash equality,
150/// same PHP version, and same symbol-collection mode. This keeps it suitable
151/// for watch-mode warm repeats without widening it into a general incremental
152/// cache.
153#[derive(PartialEq, Eq)]
154pub(crate) struct BatchReplayKey {
155    php_version: u8,
156    skip_symbols: bool,
157    content_hashes: HashMap<Arc<str>, String>,
158}
159
160pub(crate) struct BatchReplayState {
161    key: BatchReplayKey,
162    pub(crate) files: HashMap<Arc<str>, BatchReplayFile>,
163}
164
165/// Cap on the negative-resolution cache. Sized to accommodate a large
166/// workspace's worth of genuinely-missing references without unbounded
167/// growth. On overflow the cache is cleared; the cost is a few extra
168/// resolver calls until it re-fills.
169const UNRESOLVABLE_CACHE_CAP: usize = 10_000;
170
171/// RAII scope from [`AnalysisSession::defer_revision_bumps`]. Derefs to the
172/// session, so the scope body mutates it through the guard.
173pub(crate) struct DeferredRevisionBumps<'a> {
174    session: &'a mut AnalysisSession,
175}
176
177impl std::ops::Deref for DeferredRevisionBumps<'_> {
178    type Target = AnalysisSession;
179    fn deref(&self) -> &AnalysisSession {
180        self.session
181    }
182}
183
184impl std::ops::DerefMut for DeferredRevisionBumps<'_> {
185    fn deref_mut(&mut self) -> &mut AnalysisSession {
186        self.session
187    }
188}
189
190impl Drop for DeferredRevisionBumps<'_> {
191    fn drop(&mut self) {
192        self.session.db.salsa.resume_revision_bumps();
193    }
194}
195
196impl AnalysisSession {
197    /// Create a session targeting the given PHP language version.
198    pub fn new(php_version: PhpVersion) -> Self {
199        let mut db = AnalyzerDb::new();
200        db.salsa.set_php_version(Arc::from(php_version.to_string()));
201        db.salsa
202            .set_source_provider(Arc::new(crate::FsSourceProvider::new()));
203        Self {
204            db,
205            cache: None,
206            psr4: None,
207            resolver: None,
208            php_version,
209            memoize_method_call_results: false,
210            forbidden_functions: Vec::new(),
211            user_stub_files: Vec::new(),
212            user_stub_dirs: Vec::new(),
213            stale_defined_symbols: HashMap::default(),
214            last_ingested_symbols: HashMap::default(),
215            last_structural_targets: HashMap::default(),
216            unresolvable_fqcns: HashMap::default(),
217            pending_eager_function_files: Some(Vec::new()),
218            prepared_files: HashMap::default(),
219            suppression_maps: HashMap::default(),
220            prepare_generation: 0,
221            index: Arc::default(),
222        }
223    }
224
225    /// A `Send + Clone` read handle on the current revision. See
226    /// [`AnalysisSnapshot`] for the lifetime rules.
227    pub fn snapshot(&self) -> AnalysisSnapshot {
228        AnalysisSnapshot {
229            db: self.db.snapshot_db(),
230            index: Arc::clone(&self.index),
231            cache: self.cache.clone(),
232            php_version: self.php_version,
233            index_generation: self.index_generation(),
234            retire_epoch: self.index.retire_epoch(),
235        }
236    }
237
238    /// A [`Self::snapshot`] scoped to this borrow of the session; see
239    /// [`DbView`].
240    pub(crate) fn db_view(&self) -> DbView<'_> {
241        DbView::new(self, self.snapshot())
242    }
243
244    /// Run a snapshot query on the owner's thread. The owner can't write
245    /// while borrowed, so nothing can cancel the query: a `Cancelled` is
246    /// re-raised, never retried.
247    pub(crate) fn query_snapshot<T>(
248        &self,
249        query: impl FnOnce(&AnalysisSnapshot) -> Result<T, salsa::Cancelled>,
250    ) -> T {
251        match query(&self.db_view()) {
252            Ok(out) => out,
253            Err(cancelled) => snapshot::unwind(cancelled),
254        }
255    }
256
257    /// Hits served from [`Self::indexed_references_to`]'s memoization cache.
258    /// Diagnostic only — lets tests assert a warm repeat skipped the
259    /// freshness scan entirely.
260    pub fn ref_query_cache_hits(&self) -> u64 {
261        self.index.ref_queries.hits()
262    }
263
264    /// Total cached reference-location count across the references memo.
265    /// Diagnostic only — lets tests/hosts assert the cache stays under its
266    /// byte-proportional cap rather than trusting entry count alone.
267    pub fn ref_query_cache_locations(&self) -> usize {
268        self.index.ref_queries.items()
269    }
270
271    /// Hits served from [`Self::indexed_subtype_classes`]'s memoization
272    /// cache. Diagnostic only.
273    pub fn subtype_query_cache_hits(&self) -> u64 {
274        self.index.subtype_queries.hits()
275    }
276
277    /// Total cached site count across the subtype memo. Diagnostic only.
278    pub fn subtype_query_cache_sites(&self) -> usize {
279        self.index.subtype_queries.items()
280    }
281
282    /// Times the reference index has been locked on this session's db.
283    pub fn ref_index_lock_count(&self) -> u64 {
284        self.db.salsa.ref_index_lock_count()
285    }
286
287    pub(crate) fn transient_batch_replay(
288        &self,
289        php_version: u8,
290        skip_symbols: bool,
291        content_hashes: &HashMap<Arc<str>, String>,
292    ) -> Option<Arc<BatchReplayState>> {
293        let state = self.index.transient_batch_replay()?;
294        (state.key.php_version == php_version
295            && state.key.skip_symbols == skip_symbols
296            && state.key.content_hashes == *content_hashes)
297            .then_some(state)
298    }
299
300    pub(crate) fn store_transient_batch_replay(
301        &self,
302        php_version: u8,
303        skip_symbols: bool,
304        content_hashes: &HashMap<Arc<str>, String>,
305        files: HashMap<Arc<str>, BatchReplayFile>,
306    ) {
307        self.index.store_transient_batch_replay(BatchReplayState {
308            key: BatchReplayKey {
309                php_version,
310                skip_symbols,
311                content_hashes: content_hashes.clone(),
312            },
313            files,
314        });
315    }
316
317    /// Defer workspace revision bumps until the returned scope closes; see
318    /// [`MirDbStorage::defer_revision_bumps`].
319    pub(crate) fn defer_revision_bumps(&mut self) -> DeferredRevisionBumps<'_> {
320        self.db.salsa.defer_revision_bumps();
321        DeferredRevisionBumps { session: self }
322    }
323
324    /// Coverage/size counters for the class-mention gate index (host
325    /// metrics and memory-bound checks).
326    pub fn class_mention_stats(&self) -> crate::db::ClassMentionStats {
327        self.db.salsa.class_mention_stats()
328    }
329
330    /// See [`AnalysisSnapshot::files_mentioning_class`].
331    pub fn files_mentioning_class(&self, files: &[Arc<str>], class_name: &str) -> Vec<Arc<str>> {
332        self.files_mentioning_any(files, &[class_name])
333    }
334
335    /// See [`AnalysisSnapshot::files_mentioning_any`].
336    pub fn files_mentioning_any(&self, files: &[Arc<str>], needles: &[&str]) -> Vec<Arc<str>> {
337        self.query_snapshot(|snap| snap.files_mentioning_any(files, needles))
338    }
339
340    /// Persist the attached [`AnalysisCache`] to disk. No-op without an
341    /// attached cache or when nothing changed since the last flush.
342    /// Reference postings committed by session sweeps and on-demand query
343    /// freshness passes reach disk only here — a host should call this after
344    /// its warm sweep completes and on shutdown so the next launch's
345    /// [`Self::warm_start_files`] finds them.
346    pub fn flush_analysis_cache(&self) {
347        if let Some(cache) = &self.cache {
348            cache.flush();
349        }
350    }
351
352    /// Whether the workspace symbol index singleton is populated (seeded by
353    /// [`Self::warm_start_files`] or built by `index_batch`) — symbol lookups
354    /// answer from the O(1) map instead of the tracked O(all-files) walk.
355    pub fn workspace_symbol_index_ready(&self) -> bool {
356        self.db.salsa.workspace_symbol_index_singleton().is_some()
357    }
358
359    /// Executions of the tracked O(all-files) `workspace_symbol_index` walk
360    /// (diagnostic; a warm-started session should keep this at zero).
361    pub fn workspace_index_walks(&self) -> u64 {
362        self.db.salsa.workspace_index_walks()
363    }
364
365    /// Units of `work` this session's analysis has done (diagnostic).
366    pub fn work_count(&self, work: crate::db::Work) -> u64 {
367        self.db.salsa.work_count(work)
368    }
369
370    /// Swap in a custom [`crate::SourceProvider`]. LSPs install a VFS-backed
371    /// provider here so the analyzer reads from unsaved editor buffers
372    /// instead of disk.
373    pub fn with_source_provider(mut self, provider: Arc<dyn crate::SourceProvider>) -> Self {
374        self.db.salsa.set_source_provider(provider);
375        self
376    }
377
378    /// Attach a pre-built [`AnalysisCache`] (the body-analysis issue cache) and
379    /// open a sibling definition [`StubSlice`] cache under the same root, so
380    /// callers using this builder get the same speedup as `with_cache_dir`.
381    ///
382    /// Call **before** any file is ingested. A debug assertion catches misuse.
383    ///
384    /// [`StubSlice`]: mir_codebase::definitions::StubSlice
385    pub fn with_cache(mut self, cache: Arc<AnalysisCache>) -> Self {
386        debug_assert_eq!(
387            self.db.source_file_count(),
388            0,
389            "AnalysisSession::with_cache must be called before any file is ingested"
390        );
391        self.db.attach_cache_dir(cache.cache_dir());
392        self.cache = Some(cache);
393        self
394    }
395
396    /// Convenience: open a disk-backed cache at `cache_dir` and attach it.
397    ///
398    /// Attaches both the body-analysis issue cache ([`AnalysisCache`]) and the
399    /// definition [`StubSlice`] cache to the shared database. Call **before**
400    /// any file is ingested. A debug assertion catches misuse.
401    ///
402    /// [`StubSlice`]: mir_codebase::definitions::StubSlice
403    pub fn with_cache_dir(mut self, cache_dir: &std::path::Path) -> Self {
404        debug_assert_eq!(
405            self.db.source_file_count(),
406            0,
407            "AnalysisSession::with_cache_dir must be called before any file is ingested"
408        );
409        self.db.attach_cache_dir(cache_dir);
410        // Fold the user-stub fingerprint into the cache epoch. `with_user_stubs`
411        // must run before this for it to be picked up (it does in `build_session`);
412        // sessions without user stubs get 0, which is correct.
413        let mut user_stub_fp =
414            crate::stubs::user_stub_fingerprint(&self.user_stub_files, &self.user_stub_dirs);
415        // Cached diagnostics depend on memoization too; salt the epoch so
416        // flipping it discards them. Requires `with_memoize_method_call_results` first.
417        if self.memoize_method_call_results {
418            user_stub_fp ^= 0x6d65_6d6f_697a_6531;
419        }
420        for name in &self.forbidden_functions {
421            user_stub_fp = user_stub_fp.rotate_left(5)
422                ^ name.bytes().fold(0xcbf2_9ce4_8422_2325_u64, |h, b| {
423                    (h ^ u64::from(b)).wrapping_mul(0x100_0000_01b3)
424                });
425        }
426        self.cache = Some(Arc::new(AnalysisCache::open(
427            cache_dir,
428            self.php_version.cache_byte(),
429            user_stub_fp,
430        )));
431        self
432    }
433
434    /// Report calls to these functions as `ForbiddenCode` (Psalm's
435    /// `<forbiddenFunctions>`). Call before [`Self::with_cache_dir`].
436    pub fn with_forbidden_functions(mut self, names: Vec<String>) -> Self {
437        self.db.salsa.set_forbidden_functions(names.clone());
438        self.forbidden_functions = names;
439        self
440    }
441
442    /// Attach a Composer autoload map (PSR-4, PSR-0, classmap, files).
443    /// Sets the same map as the active [`crate::ClassResolver`] so
444    /// [`Self::load_class`] works out of the box.
445    pub fn with_psr4(mut self, map: Arc<Psr4Map>) -> Self {
446        let user_resolver: Arc<dyn crate::ClassResolver> = map.clone();
447        // Wrap with stub awareness so `find_class_like` / `resolve_fqcn_to_path`
448        // can map built-in PHP class FQCNs (`ArrayObject`, `Exception`, …)
449        // to their stub virtual paths.
450        let resolver: Arc<dyn crate::ClassResolver> = Arc::new(crate::ChainedClassResolver::new(
451            user_resolver,
452            Arc::new(crate::StubClassResolver),
453        ));
454        self.psr4 = Some(map.clone());
455        self.resolver = Some(resolver.clone());
456        // Mirror into MirDbStorage so salsa-tracked resolver queries
457        // (`db::resolve_fqcn_to_path`) see the same resolver and are
458        // invalidated on swap.
459        self.db.salsa.set_resolver(Some(resolver));
460        // Register vendor autoload.files for lazy loading. They define global
461        // functions and constants that the class resolver cannot discover.
462        // `ensure_vendor_eager_functions` will index them on first analysis call.
463        self.pending_eager_function_files = Some(map.vendor_eager_files());
464        self
465    }
466
467    /// Attach a generic class resolver for projects that don't use Composer
468    /// (WordPress, Drupal, custom autoloaders, workspace-walk indexes).
469    /// Replaces any previously-set Composer-backed resolver. Automatically
470    /// wrapped with stub awareness so PHP built-ins remain resolvable.
471    pub fn with_class_resolver(mut self, resolver: Arc<dyn crate::ClassResolver>) -> Self {
472        let wrapped: Arc<dyn crate::ClassResolver> = Arc::new(crate::ChainedClassResolver::new(
473            resolver,
474            Arc::new(crate::StubClassResolver),
475        ));
476        self.db.salsa.set_resolver(Some(wrapped.clone()));
477        self.resolver = Some(wrapped);
478        self
479    }
480
481    pub fn with_user_stubs(mut self, files: Vec<PathBuf>, dirs: Vec<PathBuf>) -> Self {
482        self.user_stub_files = files;
483        self.user_stub_dirs = dirs;
484        self
485    }
486
487    /// Assume repeated zero-arg method calls on the same receiver return the
488    /// same value (Psalm's `memoizeMethodCallResults`), so narrowing them
489    /// (`if (!$a->b()) throw; return $a->b();`) holds. Call before
490    /// [`Self::with_cache_dir`].
491    pub fn with_memoize_method_call_results(mut self, enabled: bool) -> Self {
492        self.memoize_method_call_results = enabled;
493        self.db.salsa.set_memoize_method_call_results(enabled);
494        self
495    }
496
497    pub fn php_version(&self) -> PhpVersion {
498        self.php_version
499    }
500
501    pub fn cache(&self) -> Option<&AnalysisCache> {
502        self.cache.as_deref()
503    }
504
505    pub fn psr4(&self) -> Option<&Psr4Map> {
506        self.psr4.as_deref()
507    }
508}
509
510mod incremental;
511mod index_state;
512mod ingest;
513mod loading;
514mod queries;
515mod snapshot;
516mod stubs;
517
518use index_state::IndexState;
519pub use queries::SubtypeClassSite;
520pub use snapshot::AnalysisSnapshot;
521use snapshot::DbView;
522
523/// Compute the full set of files `file` depends on by projecting structural
524/// and body-reference symbols through the workspace symbol index. Self-edges
525/// are excluded. Used to persist the disk cache's reverse-dep graph.
526fn file_outgoing_dependencies(
527    db: &dyn MirDatabase,
528    file: &str,
529    include_body_ref_edges: bool,
530) -> HashSet<String> {
531    let mut targets: HashSet<String> = HashSet::default();
532
533    if let Some(sf) = db.lookup_source_file(file) {
534        for symbol in crate::db::file_structural_symbols(db, sf).iter() {
535            let lookup = crate::defining_file_lookup_key(symbol);
536            if let Some(defining_file) = db.symbol_defining_file(lookup) {
537                if defining_file.as_ref() != file {
538                    targets.insert(defining_file.as_ref().to_string());
539                }
540            }
541        }
542    }
543
544    if !include_body_ref_edges {
545        return targets;
546    }
547
548    // Bare-FQN references recorded during body analysis (new \Foo(),
549    // \Foo::method(), \foo()) that do not appear in use-import statements.
550    for symbol_key in db.file_referenced_symbols(file) {
551        let lookup = crate::defining_file_lookup_key(&symbol_key);
552        if let Some(defining_file) = db.symbol_defining_file(lookup) {
553            if defining_file.as_ref() != file {
554                targets.insert(defining_file.as_ref().to_string());
555            }
556        }
557    }
558
559    targets
560}
561
562/// AST visitor that collects class FQCN references for PSR-4 preloading.
563/// Captures identifiers from `new X`, static calls / property / constant
564/// access, type hints, `instanceof`, and `@param`/`@return`/`@var`/`@extends`/
565/// `@implements` docblock annotations. Does *not* normalize via PSR-4 /
566/// imports — callers run the raw string through `resolve_name`.
567fn collect_class_refs_from_ast(program: &php_ast::owned::Program) -> Vec<String> {
568    use php_ast::ast::BinaryOp;
569    use php_ast::owned::visitor::{walk_owned_class_member, walk_owned_program, OwnedVisitor};
570    use php_ast::owned::{ClassMemberKind, ExprKind};
571    use std::ops::ControlFlow;
572
573    fn owned_name_str(name: &php_ast::owned::Name) -> String {
574        let joined: String = name
575            .parts
576            .iter()
577            .map(|p| p.as_ref())
578            .collect::<Vec<&str>>()
579            .join("\\");
580        if name.kind == php_ast::ast::NameKind::FullyQualified {
581            format!("\\{joined}")
582        } else {
583            joined
584        }
585    }
586
587    /// Recursively collect all `TNamedObject` FQCNs from a mir type, including
588    /// those nested inside generic type parameters (e.g. `Collection<Item>`).
589    fn collect_from_type(ty: &mir_types::Type, out: &mut std::collections::HashSet<String>) {
590        for atomic in ty.types.iter() {
591            if let mir_types::Atomic::TNamedObject { fqcn, type_params } = atomic {
592                out.insert(fqcn.as_ref().to_string());
593                for tp in type_params.iter() {
594                    collect_from_type(tp, out);
595                }
596            }
597        }
598    }
599
600    /// Parse a docblock and collect class names from `@param`, `@return`,
601    /// `@var`, `@extends`, and `@implements` annotations.
602    fn collect_from_docblock(text: &str, out: &mut std::collections::HashSet<String>) {
603        let parsed = crate::parser::DocblockParser::parse(text);
604        for (_, ty) in &parsed.params {
605            collect_from_type(ty, out);
606        }
607        if let Some(ret) = &parsed.return_type {
608            collect_from_type(ret, out);
609        }
610        if let Some(var) = &parsed.var_type {
611            collect_from_type(var, out);
612        }
613        for ext in &parsed.extends {
614            collect_from_type(ext, out);
615        }
616        for impl_ty in &parsed.implements {
617            collect_from_type(impl_ty, out);
618        }
619    }
620
621    struct V {
622        names: std::collections::HashSet<String>,
623    }
624    impl OwnedVisitor for V {
625        fn visit_stmt(&mut self, stmt: &php_ast::owned::Stmt) -> ControlFlow<()> {
626            if let Some(doc) = stmt.leading_doc_comment() {
627                collect_from_docblock(&doc.text, &mut self.names);
628            }
629            crate::recursion::walk_stmt(self, stmt)
630        }
631
632        fn visit_class_member(&mut self, member: &php_ast::owned::ClassMember) -> ControlFlow<()> {
633            match &member.kind {
634                ClassMemberKind::Method(m) => {
635                    if let Some(doc) = &m.doc_comment {
636                        collect_from_docblock(&doc.text, &mut self.names);
637                    }
638                }
639                ClassMemberKind::Property(p) => {
640                    if let Some(doc) = &p.doc_comment {
641                        collect_from_docblock(&doc.text, &mut self.names);
642                    }
643                }
644                _ => {}
645            }
646            walk_owned_class_member(self, member)
647        }
648
649        fn visit_expr(&mut self, expr: &php_ast::owned::Expr) -> ControlFlow<()> {
650            match &expr.kind {
651                ExprKind::New(n) => {
652                    if let ExprKind::Identifier(name) = &n.class.kind {
653                        self.names.insert(name.as_ref().to_string());
654                    }
655                }
656                ExprKind::StaticMethodCall(c) => {
657                    if let ExprKind::Identifier(name) = &c.class.kind {
658                        self.names.insert(name.as_ref().to_string());
659                    }
660                }
661                ExprKind::StaticPropertyAccess(a) => {
662                    if let ExprKind::Identifier(name) = &a.class.kind {
663                        self.names.insert(name.as_ref().to_string());
664                    }
665                }
666                ExprKind::ClassConstAccess(a) => {
667                    if let ExprKind::Identifier(name) = &a.class.kind {
668                        self.names.insert(name.as_ref().to_string());
669                    }
670                }
671                ExprKind::Binary(b) if b.op == BinaryOp::Instanceof => {
672                    if let ExprKind::Identifier(name) = &b.right.kind {
673                        self.names.insert(name.as_ref().to_string());
674                    }
675                }
676                _ => {}
677            }
678            crate::recursion::walk_expr(self, expr)
679        }
680
681        // Walker routes every class/type-position Name here: type hints, catch types, extends/implements, trait use, attributes.
682        fn visit_name(&mut self, name: &php_ast::owned::Name) -> ControlFlow<()> {
683            let s = owned_name_str(name);
684            if !s.is_empty() {
685                self.names.insert(s);
686            }
687            ControlFlow::Continue(())
688        }
689    }
690    let mut v = V {
691        names: std::collections::HashSet::default(),
692    };
693    let _ = walk_owned_program(&mut v, program);
694    v.names.into_iter().collect()
695}