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