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 use cheap
5//! [`AnalysisSession::snapshot_db`] clones.
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 parking_lot::RwLock;
15
16use crate::analyzer_db::AnalyzerDb;
17use crate::cache::AnalysisCache;
18use crate::composer::Psr4Map;
19use crate::db::{MirDatabase, MirDbStorage, RefLoc};
20use crate::php_version::PhpVersion;
21
22/// Long-lived analysis context. Owns the salsa database and tracks which
23/// stubs have been loaded.
24pub struct AnalysisSession {
25 /// Database management (salsa, file registry, stub tracking).
26 pub(crate) db: AnalyzerDb,
27 pub(crate) cache: Option<Arc<AnalysisCache>>,
28 /// PSR-4 / Composer autoload map. Retained alongside `resolver` so the
29 /// `psr4()` accessor can still return a typed `Psr4Map` for callers that
30 /// need Composer-specific data (project_files / vendor_files / etc.).
31 pub(crate) psr4: Option<Arc<Psr4Map>>,
32 /// Generic class resolver used for on-demand lazy loading. When `psr4`
33 /// is set via [`Self::with_psr4`], this is populated with the same map
34 /// re-typed as `dyn ClassResolver`. Consumers can also supply their own
35 /// resolver via [`Self::with_class_resolver`] without going through
36 /// Composer.
37 resolver: Option<Arc<dyn crate::ClassResolver>>,
38 pub(crate) php_version: PhpVersion,
39 pub(crate) user_stub_files: Vec<PathBuf>,
40 pub(crate) user_stub_dirs: Vec<PathBuf>,
41 /// Tracks symbols that were previously defined in a file but have since
42 /// been removed (deleted or renamed). When `ingest_file` detects that
43 /// a symbol disappears, it records it here so `dependency_graph()` can
44 /// still produce edges to files that reference the now-gone symbol.
45 ///
46 /// Keyed by the file that used to define the symbols. Symbols are removed
47 /// from the set when re-added to the same file on a subsequent ingest.
48 /// The set may contain symbols with no current referencers; those are
49 /// harmless — the `symbol_referencers_of` lookup returns empty.
50 stale_defined_symbols: Arc<RwLock<HashMap<String, HashSet<Arc<str>>>>>,
51 /// Symbols defined by each file as of its last `ingest_file`. The
52 /// authoritative "old" set for the rename/deletion diff, independent of
53 /// whether the salsa `SourceFile` input was already updated to the new text
54 /// by a host driving the db directly (the LSP convergence path). Without
55 /// this, re-deriving "old" symbols from the (possibly pre-updated) input
56 /// would miss deletions and break cross-file dependency invalidation.
57 last_ingested_symbols: Arc<RwLock<HashMap<String, HashSet<Arc<str>>>>>,
58 /// Structural outgoing dependency targets by file, as of the last
59 /// ingestion that updated this session's declaration state. Lets
60 /// `ingest_file` tell whether the dependency graph's declaration-shaped
61 /// edges actually changed, so body-only edits need not invalidate the
62 /// cached graph.
63 last_structural_targets: Arc<RwLock<HashMap<String, HashSet<String>>>>,
64 /// Negative cache: FQCNs that `load_class` already failed on.
65 /// The value is the resolver-mapped path (when known) so eviction on
66 /// `set_file_text` / `ingest_file` is a path equality check rather than
67 /// re-running the resolver per entry. `None` means the resolver itself
68 /// couldn't map the FQCN; those entries survive file edits (no source
69 /// change makes a never-resolvable name resolvable).
70 /// Bounded to `UNRESOLVABLE_CACHE_CAP`; clears on overflow.
71 unresolvable_fqcns: UnresolvableCache,
72 /// Pluggable source-text provider for lazy-load. Defaults to filesystem
73 /// reads ([`crate::FsSourceProvider`]); LSPs swap in a VFS-backed
74 /// implementation so unsaved buffers override on-disk content.
75 source_provider: Arc<dyn crate::SourceProvider>,
76 /// Vendor `autoload.files` entries not yet indexed. `Some(paths)` means
77 /// pending; `None` means the load has already run (idempotent). Populated
78 /// by [`Self::with_psr4`]; drained by [`Self::ensure_vendor_eager_functions`],
79 /// which is called automatically from [`Self::prepare_ast_for_analysis`].
80 ///
81 /// The mutex is held for the full duration of the load so concurrent callers
82 /// block until indexing is complete rather than proceeding with a stale
83 /// workspace snapshot.
84 pub(crate) pending_eager_function_files: Arc<parking_lot::Mutex<Option<Vec<PathBuf>>>>,
85 /// Warm-up skip set: files whose [`Self::prepare_ast_for_analysis`] has
86 /// already run against their current text. Value is `(text, generation)` —
87 /// the entry is live while the file's input text is pointer-equal to `text`
88 /// (a text edit self-invalidates) and `generation` matches
89 /// [`Self::prepare_generation`]. Lets the per-request Phase-1 warm-up in
90 /// `indexed_references_to` / `reanalyze_dependents` skip the serial
91 /// parse + AST walk for files already faulted in.
92 prepared_files: PreparedFilesCache,
93 /// Parsed suppression directives for unchanged files. Batch analysis
94 /// revisits every analyzed file to emit `UnusedSuppress`, so retaining
95 /// maps across same-session runs avoids rescanning the full workspace.
96 pub(crate) suppression_maps: SuppressionMapCache,
97 /// Bumped whenever previously loaded declarations may have been removed
98 /// (`invalidate_file`, symbol deletions on `ingest_file`, or a host calling
99 /// [`Self::bump_prepare_generation`]) — a prepared file might then need its
100 /// warm-up re-run to lazy-load a replacement (e.g. a vendor class shadowed
101 /// by a since-deleted project class).
102 prepare_generation: Arc<std::sync::atomic::AtomicU64>,
103 /// file → [`RefCommit`] its reference locations were last committed
104 /// from. Exact while the text is pointer-equal and the commit either
105 /// fully resolved every name it referenced or was stamped at the current
106 /// [`Self::index_generation`] — a later symbol add elsewhere can resolve
107 /// a reference this file's analysis left unresolved, even though this
108 /// file's own text never changed. Files absent here have never been
109 /// committed.
110 ref_committed: CommittedRefs,
111 /// file → source text its subtype-index class edges were last committed
112 /// from. Same freshness contract as `ref_committed`, but definitions
113 /// depend only on the file's own text, so a pointer-equal entry is
114 /// always exact (no cross-file drift).
115 defs_committed: CommittedTexts,
116 /// Memoized [`Self::indexed_references_to`] results. See
117 /// [`RefQueryCacheKey`] for the invalidation contract. Without this, a
118 /// repeat query against an unchanged candidate set still re-pays the
119 /// O(candidates) freshness scan on every call — cheap per file, but
120 /// measured at tens of MB / seconds of churn for a widely-referenced
121 /// symbol queried repeatedly (e.g. a host recomputing code-lens
122 /// reference counts on every request). Bounded by total cached
123 /// LOCATIONS (`ref_query_cache_locations`, capped at
124 /// `REF_QUERY_CACHE_LOCATION_CAP`), not entry count — a per-entry cap
125 /// would not bound memory here, since one entry's `Vec` scales with how
126 /// many times its symbol is referenced (a handful for a typical method,
127 /// thousands for a hot one like `Str::class`'s), so a fixed entry count
128 /// gives no fixed byte ceiling. Cleared wholesale on overflow, same
129 /// trade-off as the other simple caches in this module.
130 ref_query_cache: RefQueryCache,
131 /// Sum of `.len()` across every `ref_query_cache` entry, kept in
132 /// lockstep with it. See the field doc above.
133 ref_query_cache_locations: Arc<std::sync::atomic::AtomicUsize>,
134 /// Hits served from `ref_query_cache`. Diagnostic only — lets tests
135 /// assert a warm repeat skipped the freshness scan entirely, the same
136 /// way `ref_index_lock_count` proves a bounded posting lookup.
137 ref_query_cache_hits: Arc<std::sync::atomic::AtomicU64>,
138 /// Memoized [`Self::indexed_subtype_classes`] results. Same rationale
139 /// and invalidation contract as `ref_query_cache`:
140 /// `commit_defs_for_matching`'s freshness pass costs O(candidates) on
141 /// every call regardless of outcome, so a repeat query (e.g. resolving
142 /// a protected/static method's reference scope on every code-lens
143 /// refresh) would otherwise re-pay it every time. Bounded by total
144 /// cached sites (`subtype_query_cache_sites`, capped at
145 /// `SUBTYPE_QUERY_CACHE_SITE_CAP`), not entry count, for the same
146 /// reason `ref_query_cache` isn't entry-count-capped.
147 subtype_query_cache: SubtypeQueryCache,
148 /// Sum of `.len()` across every `subtype_query_cache` entry.
149 subtype_query_cache_sites: Arc<std::sync::atomic::AtomicUsize>,
150 /// Hits served from `subtype_query_cache`. Diagnostic only.
151 subtype_query_cache_hits: Arc<std::sync::atomic::AtomicU64>,
152 /// Session-local memo of the derived dependency graph. Rebuilt lazily on
153 /// demand and invalidated whenever committed references, structural
154 /// edges, source-file membership, or stale-symbol tracking changes.
155 dependency_graph_cache: DependencyGraphCache,
156 /// One-run replay cache for `analyze_paths` when the next batch invocation
157 /// on this session sees the same file set with identical bytes. Any
158 /// mutation outside `analyze_paths` clears it.
159 transient_batch_replay: BatchReplayCache,
160}
161
162/// Which reference postings [`AnalysisSession::indexed_references_to`]
163/// should read back from the maintained reference index.
164#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
165pub enum ReferenceIncludes {
166 /// Plain usage/declaration references only (`cls:` / `fn:` / member
167 /// postings), excluding `use` import items.
168 Plain,
169 /// Only `use` import items (`use Foo\Bar;`, `use function ...;`,
170 /// `use const ...;`), excluding plain usage/declaration references.
171 UseImports,
172 /// Plain references plus `use` import items.
173 PlainAndUseImports,
174}
175
176/// Cache key for [`AnalysisSession::indexed_references_to`]'s memoization.
177///
178/// `generation`'s revision half is salsa's text revision, not
179/// [`AnalysisSession::index_generation`]: a body-only edit never bumps the
180/// workspace generation (by design — see `bump_workspace_revision`'s doc
181/// comment) but can still move, add, or remove a reference location, so
182/// keying on the coarser generation would serve stale locations after such
183/// an edit.
184/// `files_hash` guards against a caller narrowing/widening the candidate
185/// scope between calls for the same symbol at the same revision (e.g. a
186/// different reference-scope plan) — a coincidental hash collision would
187/// only cause a wrong cache HIT, so this hashes full content, not just
188/// length/pointers.
189///
190/// `generation` also carries the subtype-edge epoch (see
191/// [`AnalysisSession::query_cache_generation`]): a member query's hierarchy
192/// fan-out reads the subtype index, which another query's defs commit can
193/// grow without moving the text revision — the epoch keeps such an entry
194/// from being served after its fan-out went stale.
195#[derive(PartialEq, Eq, Hash)]
196struct RefQueryCacheKey {
197 symbol: String,
198 include_declaration: bool,
199 includes: ReferenceIncludes,
200 generation: (salsa::Revision, u64),
201 files_hash: u64,
202}
203
204/// Cap on `ref_query_cache`'s total cached reference-location count (summed
205/// across every entry), not entry count — see the field doc on
206/// `ref_query_cache`. Each location is `(Arc<str>, Range)`, ~24 bytes plus a
207/// refcount bump on an already-allocated path string (no new string data),
208/// so 200k locations is on the order of a few MB — a small, fixed ceiling
209/// regardless of how skewed the query distribution is (many small entries or
210/// a few huge ones for hot symbols cost the same worst case).
211const REF_QUERY_CACHE_LOCATION_CAP: usize = 200_000;
212
213/// Memoized [`AnalysisSession::indexed_references_to`] results. See
214/// [`RefQueryCacheKey`] for the invalidation contract.
215type RefQueryCache =
216 Arc<RwLock<RevisionedMap<RefQueryCacheKey, Arc<Vec<(Arc<str>, crate::Range)>>>>>;
217
218/// A memo map whose keys embed the generation — `(text revision,
219/// subtype-edge epoch)`, see [`AnalysisSession::query_cache_generation`] —
220/// they were computed at. Once the generation moves, old keys can never be
221/// looked up again, so the first insert at a newer generation drops them
222/// wholesale — without this, dead keys (heap `String`s) accumulate until
223/// the value-cap overflow clear.
224struct RevisionedMap<K, V> {
225 generation: Option<(salsa::Revision, u64)>,
226 map: HashMap<K, V>,
227}
228
229impl<K, V> Default for RevisionedMap<K, V> {
230 fn default() -> Self {
231 Self {
232 generation: None,
233 map: HashMap::default(),
234 }
235 }
236}
237
238impl<K: std::hash::Hash + Eq, V> RevisionedMap<K, V> {
239 fn get(&self, key: &K) -> Option<&V> {
240 self.map.get(key)
241 }
242
243 /// Prepare for an insert keyed at `generation`. Rolls the map forward
244 /// (dropping the dead generation) when `generation` is newer than the
245 /// resident one, running `on_clear` so the caller can zero its
246 /// lockstep size counter. Returns `false` when `generation` is older —
247 /// such a key can never hit again, so the caller should skip caching.
248 /// Both components are monotonic, so lexicographic order is sound.
249 fn advance_to(&mut self, generation: (salsa::Revision, u64), on_clear: impl FnOnce()) -> bool {
250 match self.generation {
251 Some(g) if g == generation => true,
252 Some(g) if g > generation => false,
253 _ => {
254 self.map.clear();
255 self.generation = Some(generation);
256 on_clear();
257 true
258 }
259 }
260 }
261}
262
263/// Cache key for [`AnalysisSession::indexed_subtype_classes`]'s memoization.
264/// Same invalidation contract as [`RefQueryCacheKey`]. `class_fqn` is
265/// lowercased and leading-`\`-stripped on entry (PHP class names are
266/// case-insensitive) so two callers spelling the same class differently
267/// still share a cache entry.
268#[derive(PartialEq, Eq, Hash)]
269struct SubtypeQueryCacheKey {
270 class_fqn: String,
271 include_trait_users: bool,
272 generation: (salsa::Revision, u64),
273 files_hash: u64,
274}
275
276/// Cap on `subtype_query_cache`'s total cached site count, not entry count
277/// — same rationale as [`REF_QUERY_CACHE_LOCATION_CAP`]. A `SubtypeClassSite`
278/// is ~80 bytes (two `Arc<str>`, an enum, a bool, a `Range`), so 50k sites
279/// is on the order of a few MB.
280const SUBTYPE_QUERY_CACHE_SITE_CAP: usize = 50_000;
281
282/// Memoized [`AnalysisSession::indexed_subtype_classes`] results. See
283/// [`SubtypeQueryCacheKey`] for the invalidation contract.
284type SubtypeQueryCache =
285 Arc<RwLock<RevisionedMap<SubtypeQueryCacheKey, Arc<Vec<SubtypeClassSite>>>>>;
286
287type DependencyGraphCache = Arc<RwLock<Option<crate::DependencyGraph>>>;
288type BatchReplayCache = Arc<RwLock<Option<Arc<BatchReplayState>>>>;
289
290/// Stable content hash of a candidate-file list, order-sensitive. Callers
291/// that rebuild the list identically each time (the common case — the same
292/// scope-narrowing query re-run against unchanged state) still hash equal;
293/// a genuinely different list (different content OR order) safely misses
294/// rather than risking a wrong hit.
295fn hash_files(files: &[Arc<str>]) -> u64 {
296 use std::hash::{Hash, Hasher};
297 let mut hasher = rustc_hash::FxHasher::default();
298 files.len().hash(&mut hasher);
299 for f in files {
300 f.hash(&mut hasher);
301 }
302 hasher.finish()
303}
304
305/// FQCN → optional resolver-mapped path. See the field doc on
306/// `AnalysisSession::unresolvable_fqcns`.
307type UnresolvableCache = Arc<RwLock<HashMap<Arc<str>, Option<Arc<str>>>>>;
308
309/// Warm-up skip set keyed by file path. See the field doc on
310/// `AnalysisSession::prepared_files`.
311type PreparedFilesCache = Arc<RwLock<HashMap<Arc<str>, (Arc<str>, u64)>>>;
312
313/// Parsed inline suppressions keyed by file path and source text.
314type SuppressionMapCache =
315 Arc<RwLock<HashMap<Arc<str>, (Arc<str>, Arc<crate::suppression::SuppressionMap>)>>>;
316
317/// file → text a per-file index commit was computed from. See the field docs
318/// on `AnalysisSession::ref_committed` / `defs_committed`.
319type CommittedTexts = Arc<RwLock<HashMap<Arc<str>, Arc<str>>>>;
320
321/// A staged [`AnalysisCache`] write for one file's postings, prepared in the
322/// parallel analysis phase and applied during the serial index commit. See
323/// `AnalysisSession::stage_ref_cache_put`.
324pub(crate) struct RefCachePut {
325 content_hash: String,
326 surface_hash: String,
327 ref_locs: Arc<[crate::cache::CachedRefLoc]>,
328}
329
330/// One file's reference-posting commit. See `AnalysisSession::ref_committed`.
331pub(crate) struct RefCommit {
332 /// Source text the postings were computed from (pointer identity; a
333 /// text write self-invalidates).
334 text: Arc<str>,
335 /// Weak handle on the analyze memo — pointer-identical output means
336 /// identical postings, so sweeps can skip the index rewrite. The upgrade
337 /// guards against ABA on evicted memos.
338 out: std::sync::Weak<crate::db::AnalyzeOutput>,
339 /// Workspace generation whose resolution environment the postings
340 /// reflect, captured *before* the analysis snapshot.
341 generation: u64,
342 /// The analysis resolved every workspace-level name it referenced, so no
343 /// later symbol add can change the postings and the commit survives
344 /// generation bumps. FQCN shadowing and unqualified-call fallback
345 /// switches remain the reanalyze_dependents flow's job, as before.
346 resolved: bool,
347 /// Whether this commit came from this session's own `analyze_file` pass
348 /// (`out: Some(..)`) rather than a disk-cache replay (`out: None`, from
349 /// `warm_start_files`). A live analysis's postings are guaranteed
350 /// consistent with a fresh textual scan of the same text — a replayed
351 /// commit's are only as trustworthy as the cache entry, so it always
352 /// re-verifies via full re-analysis instead of the cheaper gate.
353 live_analyzed: bool,
354}
355
356/// file → [`RefCommit`] map shared across session clones.
357type CommittedRefs = Arc<RwLock<HashMap<Arc<str>, RefCommit>>>;
358
359/// One file's replayable body-analysis output from the previous batch run.
360pub(crate) struct BatchReplayFile {
361 pub(crate) issues: Arc<[mir_issues::Issue]>,
362 pub(crate) ref_locs: Arc<[crate::cache::CachedRefLoc]>,
363 pub(crate) symbols: Arc<[crate::symbol::ResolvedSymbol]>,
364}
365
366/// Fingerprint for a session-local whole-batch replay.
367///
368/// The replay path is intentionally narrow: exact path→content-hash equality,
369/// same PHP version, and same symbol-collection mode. This keeps it suitable
370/// for watch-mode warm repeats without widening it into a general incremental
371/// cache.
372#[derive(PartialEq, Eq)]
373pub(crate) struct BatchReplayKey {
374 php_version: u8,
375 skip_symbols: bool,
376 content_hashes: HashMap<Arc<str>, String>,
377}
378
379pub(crate) struct BatchReplayState {
380 key: BatchReplayKey,
381 pub(crate) files: HashMap<Arc<str>, BatchReplayFile>,
382}
383
384/// Cap on the negative-resolution cache. Sized to accommodate a large
385/// workspace's worth of genuinely-missing references without unbounded
386/// growth. On overflow the cache is cleared; the cost is a few extra
387/// resolver calls until it re-fills.
388const UNRESOLVABLE_CACHE_CAP: usize = 10_000;
389
390/// RAII scope from [`AnalysisSession::defer_revision_bumps`]; the last scope
391/// to close performs the one owed `bump_workspace_revision`. Derefs to the
392/// session, so the scope body mutates it through the guard.
393pub(crate) struct DeferredRevisionBumps<'a> {
394 session: &'a mut AnalysisSession,
395 depth: Arc<std::sync::atomic::AtomicUsize>,
396 pending: Arc<std::sync::atomic::AtomicBool>,
397}
398
399impl std::ops::Deref for DeferredRevisionBumps<'_> {
400 type Target = AnalysisSession;
401 fn deref(&self) -> &AnalysisSession {
402 self.session
403 }
404}
405
406impl std::ops::DerefMut for DeferredRevisionBumps<'_> {
407 fn deref_mut(&mut self) -> &mut AnalysisSession {
408 self.session
409 }
410}
411
412impl Drop for DeferredRevisionBumps<'_> {
413 fn drop(&mut self) {
414 use std::sync::atomic::Ordering;
415 if self.depth.fetch_sub(1, Ordering::SeqCst) == 1
416 && self.pending.swap(false, Ordering::SeqCst)
417 {
418 self.session.db.salsa.bump_workspace_revision();
419 }
420 }
421}
422
423impl AnalysisSession {
424 /// Create a session targeting the given PHP language version.
425 pub fn new(php_version: PhpVersion) -> Self {
426 let mut db = AnalyzerDb::new();
427 db.salsa.set_php_version(Arc::from(php_version.to_string()));
428 Self {
429 db,
430 cache: None,
431 psr4: None,
432 resolver: None,
433 php_version,
434 user_stub_files: Vec::new(),
435 user_stub_dirs: Vec::new(),
436 stale_defined_symbols: Arc::new(RwLock::new(HashMap::default())),
437 last_ingested_symbols: Arc::new(RwLock::new(HashMap::default())),
438 last_structural_targets: Arc::new(RwLock::new(HashMap::default())),
439 unresolvable_fqcns: Arc::new(RwLock::new(HashMap::default())),
440 source_provider: Arc::new(crate::FsSourceProvider),
441 pending_eager_function_files: Arc::new(parking_lot::Mutex::new(Some(Vec::new()))),
442 prepared_files: Arc::new(RwLock::new(HashMap::default())),
443 suppression_maps: Arc::new(RwLock::new(HashMap::default())),
444 prepare_generation: Arc::new(std::sync::atomic::AtomicU64::new(0)),
445 ref_committed: Arc::new(RwLock::new(HashMap::default())),
446 defs_committed: Arc::new(RwLock::new(HashMap::default())),
447 ref_query_cache: Arc::new(RwLock::new(RevisionedMap::default())),
448 ref_query_cache_locations: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
449 ref_query_cache_hits: Arc::new(std::sync::atomic::AtomicU64::new(0)),
450 subtype_query_cache: Arc::new(RwLock::new(RevisionedMap::default())),
451 subtype_query_cache_sites: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
452 subtype_query_cache_hits: Arc::new(std::sync::atomic::AtomicU64::new(0)),
453 dependency_graph_cache: Arc::new(RwLock::new(None)),
454 transient_batch_replay: Arc::new(RwLock::new(None)),
455 }
456 }
457
458 /// Hits served from [`Self::indexed_references_to`]'s memoization cache.
459 /// Diagnostic only — see the field doc on `ref_query_cache_hits`.
460 pub fn ref_query_cache_hits(&self) -> u64 {
461 self.ref_query_cache_hits
462 .load(std::sync::atomic::Ordering::Relaxed)
463 }
464
465 /// Total cached reference-location count across `ref_query_cache`.
466 /// Diagnostic only — lets tests/hosts assert the cache stays under its
467 /// byte-proportional cap rather than trusting entry count alone.
468 pub fn ref_query_cache_locations(&self) -> usize {
469 self.ref_query_cache_locations
470 .load(std::sync::atomic::Ordering::Relaxed)
471 }
472
473 /// Hits served from [`Self::indexed_subtype_classes`]'s memoization
474 /// cache. Diagnostic only.
475 pub fn subtype_query_cache_hits(&self) -> u64 {
476 self.subtype_query_cache_hits
477 .load(std::sync::atomic::Ordering::Relaxed)
478 }
479
480 /// Total cached site count across `subtype_query_cache`. Diagnostic
481 /// only — same purpose as `ref_query_cache_locations`.
482 pub fn subtype_query_cache_sites(&self) -> usize {
483 self.subtype_query_cache_sites
484 .load(std::sync::atomic::Ordering::Relaxed)
485 }
486
487 /// Times the reference index has been locked on this session's db.
488 pub fn ref_index_lock_count(&self) -> u64 {
489 self.db.salsa.ref_index_lock_count()
490 }
491
492 pub(crate) fn clear_dependency_graph_cache(&self) {
493 *self.dependency_graph_cache.write() = None;
494 }
495
496 pub(crate) fn clear_transient_batch_replay(&self) {
497 *self.transient_batch_replay.write() = None;
498 }
499
500 pub(crate) fn transient_batch_replay(
501 &self,
502 php_version: u8,
503 skip_symbols: bool,
504 content_hashes: &HashMap<Arc<str>, String>,
505 ) -> Option<Arc<BatchReplayState>> {
506 let replay = self.transient_batch_replay.read();
507 let state = replay.as_ref()?;
508 (state.key.php_version == php_version
509 && state.key.skip_symbols == skip_symbols
510 && state.key.content_hashes == *content_hashes)
511 .then(|| Arc::clone(state))
512 }
513
514 pub(crate) fn store_transient_batch_replay(
515 &self,
516 php_version: u8,
517 skip_symbols: bool,
518 content_hashes: &HashMap<Arc<str>, String>,
519 files: HashMap<Arc<str>, BatchReplayFile>,
520 ) {
521 *self.transient_batch_replay.write() = Some(Arc::new(BatchReplayState {
522 key: BatchReplayKey {
523 php_version,
524 skip_symbols,
525 content_hashes: content_hashes.clone(),
526 },
527 files,
528 }));
529 }
530
531 /// Coalesce `bump_workspace_revision` calls until the returned scope
532 /// closes: each bump cancels in-flight salsa readers, so a pass that
533 /// lazy-loads N classes pays one instead of N. Scopes nest.
534 pub(crate) fn defer_revision_bumps(&mut self) -> DeferredRevisionBumps<'_> {
535 let (depth, pending) = self.db.salsa.revision_bump_deferral_handles();
536 depth.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
537 DeferredRevisionBumps {
538 session: self,
539 depth,
540 pending,
541 }
542 }
543
544 /// Coverage/size counters for the class-mention gate index (host
545 /// metrics and memory-bound checks).
546 pub fn class_mention_stats(&self) -> crate::db::ClassMentionStats {
547 self.db.salsa.class_mention_stats()
548 }
549
550 /// Which of `files` currently mention `class_name` as a whole
551 /// identifier (case-insensitive) — the same persistent, incrementally-
552 /// maintained mechanism [`Self::indexed_references_to`]'s own gate uses
553 /// internally (see `db::class_mention_index`'s doc comment), exposed so
554 /// a host doesn't need to re-implement an equivalent from-scratch text
555 /// scan for its own narrowing (e.g. a reachability pre-filter ahead of
556 /// a references/code-lens query). A file already scanned against a
557 /// universe that included `class_name` answers via an O(log n) lookup
558 /// with no text pass at all; only a never-scanned or since-edited file
559 /// pays a scan, and that scan is recorded for every other name already
560 /// in the universe too, not just this one.
561 ///
562 /// **Scope**: the universe is seeded from *declared class-like names*
563 /// (classes/interfaces/traits/enums) — the same population mir's own
564 /// indexing already does as a side effect — but a `class_name` outside
565 /// that set is not a reason to skip narrowing: it's admitted verbatim
566 /// on the spot (see [`Self::files_mentioning_any`]) and answered by a
567 /// real scan, same as any other needle.
568 pub fn files_mentioning_class(&self, files: &[Arc<str>], class_name: &str) -> Vec<Arc<str>> {
569 self.files_mentioning_any(files, &[class_name])
570 }
571
572 /// Which of `files` currently mention ANY of `needles` as a whole
573 /// identifier (case-insensitive) — the multi-needle form of
574 /// [`Self::files_mentioning_class`], for a caller resolving several
575 /// candidate names at once (e.g. an owner FQN plus its subtype
576 /// closure). Every needle is admitted into the shared universe
577 /// verbatim (no short-name stripping) before querying, so this call's
578 /// answer is never conservative on account of an unknown needle — a
579 /// file only falls back to a raw scan when it was scanned before all
580 /// of `needles` existed in the universe and hasn't been touched since;
581 /// that one scan then answers every needle in the universe at once,
582 /// not just this call's.
583 pub fn files_mentioning_any(&self, files: &[Arc<str>], needles: &[&str]) -> Vec<Arc<str>> {
584 use rayon::prelude::*;
585
586 if needles.is_empty() {
587 return files.to_vec();
588 }
589 let db = self.snapshot_db();
590 db.add_literal_mention_names(needles.iter().copied());
591 // Just admitted every needle above, so `prepare_class_mention_query`
592 // only fails here while the universe itself is somehow still empty
593 // (unreachable: admission just grew it) — kept as a defensive
594 // fallback, not a reachable branch.
595 let queries: Vec<_> = needles
596 .iter()
597 .filter_map(|n| db.prepare_class_mention_query(n))
598 .collect();
599 if queries.is_empty() {
600 return files.to_vec();
601 }
602 let Some(scanner) = db.class_mention_scanner() else {
603 return files.to_vec();
604 };
605
606 files
607 .par_iter()
608 .map_with(db, |db, f| -> Option<Arc<str>> {
609 let sf = db.lookup_source_file(f.as_ref())?;
610 let text = sf.text(&*db as &dyn MirDatabase).clone();
611 for q in &queries {
612 match db.class_mention_answer(f.as_ref(), q, &text) {
613 Some(true) => return Some(f.clone()),
614 Some(false) => continue,
615 None => {
616 let names = scanner.scan(text.as_ref());
617 let hit = queries
618 .iter()
619 .any(|q2| names.binary_search(&q2.name).is_ok());
620 db.set_file_class_mentions(f, &text, scanner.epoch(), names);
621 return hit.then(|| f.clone());
622 }
623 }
624 }
625 None
626 })
627 .flatten()
628 .collect()
629 }
630
631 /// Whether `file`'s reference postings are exact for `current_text` at
632 /// `current_gen`: text pointer-equal, and the commit either resolved
633 /// every name (immune to workspace growth) or was stamped at that
634 /// generation — catches a file analyzed before a class it references
635 /// was registered elsewhere, which would otherwise look fresh forever.
636 pub(crate) fn is_ref_committed(
637 &self,
638 file: &str,
639 current_text: &Arc<str>,
640 current_gen: u64,
641 ) -> bool {
642 self.ref_committed.read().get(file).is_some_and(|c| {
643 Arc::ptr_eq(&c.text, current_text) && (c.resolved || c.generation == current_gen)
644 })
645 }
646
647 /// Whether `file` has a *live-analyzed* reference commit recorded against
648 /// exactly `current_text` — i.e. it's only stale by generation (an
649 /// unresolved-name commit racing a workspace-growth bump), not because
650 /// its text changed or because it was seeded by an unverified disk-cache
651 /// replay. Such a commit is still eligible for the mention/needle gate:
652 /// the current text is exactly what this session's own analysis already
653 /// scanned, so a needle miss is just as conclusive as for a
654 /// never-committed file. A replayed commit (`live_analyzed: false`)
655 /// never qualifies here, regardless of text match — its postings are
656 /// only as trustworthy as the cache entry, so it always falls through to
657 /// unconditional re-analysis. Same for a genuinely edited file (text
658 /// differs): the old commit's postings are for different text and must
659 /// be replaced regardless of what the new text mentions.
660 pub(crate) fn ref_commit_stale_by_generation_only(
661 &self,
662 file: &str,
663 current_text: &Arc<str>,
664 ) -> bool {
665 self.ref_committed
666 .read()
667 .get(file)
668 .is_some_and(|c| c.live_analyzed && Arc::ptr_eq(&c.text, current_text))
669 }
670
671 /// Whether `file`'s stored postings came from exactly this
672 /// (text, output) pair — generation aside. Pointer-identical output
673 /// means identical postings (salsa backdates equal results to the same
674 /// Arc), so callers skip the index rewrite and only re-stamp the mark.
675 pub(crate) fn ref_commit_is_current(
676 &self,
677 file: &str,
678 current_text: &Arc<str>,
679 out: &Arc<crate::db::AnalyzeOutput>,
680 ) -> bool {
681 self.ref_committed.read().get(file).is_some_and(|c| {
682 Arc::ptr_eq(&c.text, current_text)
683 && c.out.upgrade().is_some_and(|prev| Arc::ptr_eq(&prev, out))
684 })
685 }
686
687 /// Record a commit computed against the workspace state at `generation`
688 /// — captured by the caller *before* its analysis snapshot, so a file
689 /// add racing the analysis leaves the commit stale (re-verified on the
690 /// next query) rather than wrongly fresh. `resolved` must come from the
691 /// producing analysis' own issue set
692 /// ([`crate::db::issues_have_unresolved_names`]); pass `false` when
693 /// unknown — the gen-guarded safe direction.
694 pub(crate) fn mark_ref_committed(
695 &self,
696 file: &Arc<str>,
697 text: &Arc<str>,
698 out: Option<&Arc<crate::db::AnalyzeOutput>>,
699 generation: u64,
700 resolved: bool,
701 ) {
702 let commit = RefCommit {
703 text: text.clone(),
704 out: out.map(Arc::downgrade).unwrap_or_default(),
705 generation,
706 resolved,
707 live_analyzed: out.is_some(),
708 };
709 self.ref_committed.write().insert(file.clone(), commit);
710 }
711
712 pub(crate) fn forget_ref_committed(&self, file: &str) {
713 self.ref_committed.write().remove(file);
714 }
715
716 /// Stage a disk-cache write for `file`'s postings, computed in the
717 /// parallel analysis phase (needs a live db snapshot for the memoized
718 /// parse). `None` when no cache is attached or the stored entry already
719 /// matches this content — batch-written entries are never clobbered.
720 /// The caller applies the result via [`Self::apply_ref_cache_put`] in
721 /// its serial commit, alongside the in-memory index commit.
722 pub(crate) fn stage_ref_cache_put(
723 &self,
724 db: &dyn crate::db::MirDatabase,
725 sf: crate::db::SourceFile,
726 file: &str,
727 text: &Arc<str>,
728 out: &Arc<crate::db::AnalyzeOutput>,
729 ) -> Option<RefCachePut> {
730 let cache = self.cache.as_deref()?;
731 let content_hash = crate::cache::hash_content(text);
732 if cache.is_valid(file, &content_hash) {
733 return None;
734 }
735 let parsed = crate::db::parse_file(db, sf);
736 let surface_hash = crate::cache::surface_fingerprint(text, &parsed.0.program);
737 let ref_locs: Arc<[crate::cache::CachedRefLoc]> = out
738 .ref_locs
739 .iter()
740 .map(|r| (Arc::clone(&r.symbol_key), r.line, r.col_start, r.col_end))
741 .collect();
742 Some(RefCachePut {
743 content_hash,
744 surface_hash,
745 ref_locs,
746 })
747 }
748
749 pub(crate) fn apply_ref_cache_put(
750 &self,
751 file: &str,
752 out: &Arc<crate::db::AnalyzeOutput>,
753 put: RefCachePut,
754 ) {
755 if let Some(cache) = self.cache.as_deref() {
756 cache.put(
757 file,
758 put.content_hash,
759 put.surface_hash,
760 out.issues.clone(),
761 put.ref_locs,
762 );
763 }
764 }
765
766 /// Persist the attached [`AnalysisCache`] to disk. No-op without an
767 /// attached cache or when nothing changed since the last flush.
768 /// Reference postings committed by session sweeps and on-demand query
769 /// freshness passes reach disk only here — a host should call this after
770 /// its warm sweep completes and on shutdown so the next launch's
771 /// [`Self::warm_start_files`] finds them.
772 pub fn flush_analysis_cache(&self) {
773 if let Some(cache) = &self.cache {
774 cache.flush();
775 }
776 }
777
778 /// Whether the workspace symbol index singleton is populated (seeded by
779 /// [`Self::warm_start_files`] or built by `index_batch`) — symbol lookups
780 /// answer from the O(1) map instead of the tracked O(all-files) walk.
781 pub fn workspace_symbol_index_ready(&self) -> bool {
782 self.db.salsa.workspace_symbol_index_singleton().is_some()
783 }
784
785 /// Executions of the tracked O(all-files) `workspace_symbol_index` walk
786 /// (diagnostic; a warm-started session should keep this at zero).
787 pub fn workspace_index_walks(&self) -> u64 {
788 self.db.salsa.workspace_index_walks()
789 }
790
791 /// Whether `file`'s subtype-index class edges were committed from exactly
792 /// `current_text`.
793 pub(crate) fn is_defs_committed(&self, file: &str, current_text: &Arc<str>) -> bool {
794 self.defs_committed
795 .read()
796 .get(file)
797 .is_some_and(|t| Arc::ptr_eq(t, current_text))
798 }
799
800 pub(crate) fn mark_defs_committed(&self, file: &Arc<str>, text: &Arc<str>) {
801 self.defs_committed
802 .write()
803 .insert(file.clone(), text.clone());
804 }
805
806 pub(crate) fn forget_defs_committed(&self, file: &str) {
807 self.defs_committed.write().remove(file);
808 }
809
810 /// Every file with a defs commit on record, regardless of staleness.
811 pub(crate) fn defs_committed_keys(&self) -> Vec<Arc<str>> {
812 self.defs_committed.read().keys().cloned().collect()
813 }
814
815 /// Every file with a reference commit on record, regardless of
816 /// staleness. Files absent here have no reference postings at all.
817 pub(crate) fn ref_committed_keys(&self) -> Vec<Arc<str>> {
818 self.ref_committed.read().keys().cloned().collect()
819 }
820
821 /// Swap in a custom [`crate::SourceProvider`]. LSPs install a VFS-backed
822 /// provider here so the analyzer reads from unsaved editor buffers
823 /// instead of disk.
824 pub fn with_source_provider(mut self, provider: Arc<dyn crate::SourceProvider>) -> Self {
825 self.source_provider = provider;
826 self
827 }
828
829 /// Attach a pre-built [`AnalysisCache`] (the body-analysis issue cache) and
830 /// open a sibling definition [`StubSlice`] cache under the same root, so
831 /// callers using this builder get the same speedup as `with_cache_dir`.
832 ///
833 /// Rebuilds the shared database to attach the definition cache — call
834 /// **before** any file is ingested. A debug assertion catches misuse.
835 ///
836 /// [`StubSlice`]: mir_codebase::definitions::StubSlice
837 pub fn with_cache(mut self, cache: Arc<AnalysisCache>) -> Self {
838 debug_assert_eq!(
839 self.db.source_file_count(),
840 0,
841 "AnalysisSession::with_cache must be called before any file is ingested"
842 );
843 let dir = cache.cache_dir().to_path_buf();
844 self.db = AnalyzerDb::new().with_cache_dir(&dir);
845 self.db
846 .salsa
847 .set_php_version(Arc::from(self.php_version.to_string()));
848 self.cache = Some(cache);
849 self
850 }
851
852 /// Convenience: open a disk-backed cache at `cache_dir` and attach it.
853 ///
854 /// Attaches both the body-analysis issue cache ([`AnalysisCache`]) and the
855 /// definition [`StubSlice`] cache to the shared database. Builds a fresh
856 /// [`AnalyzerDb`] internally — call **before** any file is ingested. A
857 /// debug assertion catches misuse.
858 ///
859 /// [`StubSlice`]: mir_codebase::definitions::StubSlice
860 pub fn with_cache_dir(mut self, cache_dir: &std::path::Path) -> Self {
861 debug_assert_eq!(
862 self.db.source_file_count(),
863 0,
864 "AnalysisSession::with_cache_dir must be called before any file is ingested"
865 );
866 self.db = AnalyzerDb::new().with_cache_dir(cache_dir);
867 self.db
868 .salsa
869 .set_php_version(Arc::from(self.php_version.to_string()));
870 // Fold the user-stub fingerprint into the cache epoch. `with_user_stubs`
871 // must run before this for it to be picked up (it does in `build_session`);
872 // sessions without user stubs get 0, which is correct.
873 let user_stub_fp =
874 crate::stubs::user_stub_fingerprint(&self.user_stub_files, &self.user_stub_dirs);
875 self.cache = Some(Arc::new(AnalysisCache::open(
876 cache_dir,
877 self.php_version.cache_byte(),
878 user_stub_fp,
879 )));
880 self
881 }
882
883 /// Attach a Composer autoload map (PSR-4, PSR-0, classmap, files).
884 /// Sets the same map as the active [`crate::ClassResolver`] so
885 /// [`Self::load_class`] works out of the box.
886 pub fn with_psr4(mut self, map: Arc<Psr4Map>) -> Self {
887 let user_resolver: Arc<dyn crate::ClassResolver> = map.clone();
888 // Wrap with stub awareness so `find_class_like` / `resolve_fqcn_to_path`
889 // can map built-in PHP class FQCNs (`ArrayObject`, `Exception`, …)
890 // to their stub virtual paths.
891 let resolver: Arc<dyn crate::ClassResolver> = Arc::new(crate::ChainedClassResolver::new(
892 user_resolver,
893 Arc::new(crate::StubClassResolver),
894 ));
895 self.psr4 = Some(map.clone());
896 self.resolver = Some(resolver.clone());
897 // Mirror into MirDbStorage so salsa-tracked resolver queries
898 // (`db::resolve_fqcn_to_path`) see the same resolver and are
899 // invalidated on swap.
900 self.db.salsa.set_resolver(Some(resolver));
901 // Register vendor autoload.files for lazy loading. They define global
902 // functions and constants that the class resolver cannot discover.
903 // `ensure_vendor_eager_functions` will index them on first analysis call.
904 *self.pending_eager_function_files.lock() = Some(map.vendor_eager_files());
905 self
906 }
907
908 /// Attach a generic class resolver for projects that don't use Composer
909 /// (WordPress, Drupal, custom autoloaders, workspace-walk indexes).
910 /// Replaces any previously-set Composer-backed resolver. Automatically
911 /// wrapped with stub awareness so PHP built-ins remain resolvable.
912 pub fn with_class_resolver(mut self, resolver: Arc<dyn crate::ClassResolver>) -> Self {
913 let wrapped: Arc<dyn crate::ClassResolver> = Arc::new(crate::ChainedClassResolver::new(
914 resolver,
915 Arc::new(crate::StubClassResolver),
916 ));
917 self.db.salsa.set_resolver(Some(wrapped.clone()));
918 self.resolver = Some(wrapped);
919 self
920 }
921
922 pub fn with_user_stubs(mut self, files: Vec<PathBuf>, dirs: Vec<PathBuf>) -> Self {
923 self.user_stub_files = files;
924 self.user_stub_dirs = dirs;
925 self
926 }
927
928 pub fn php_version(&self) -> PhpVersion {
929 self.php_version
930 }
931
932 pub fn cache(&self) -> Option<&AnalysisCache> {
933 self.cache.as_deref()
934 }
935
936 pub fn psr4(&self) -> Option<&Psr4Map> {
937 self.psr4.as_deref()
938 }
939}
940
941mod incremental;
942mod ingest;
943mod loading;
944mod queries;
945mod stubs;
946
947pub use queries::SubtypeClassSite;
948
949/// Compute the full set of files `file` depends on by projecting structural
950/// and body-reference symbols through the workspace symbol index. Self-edges
951/// are excluded. Used to persist the disk cache's reverse-dep graph.
952fn file_outgoing_dependencies(
953 db: &dyn MirDatabase,
954 file: &str,
955 include_body_ref_edges: bool,
956) -> HashSet<String> {
957 let mut targets: HashSet<String> = HashSet::default();
958
959 if let Some(sf) = db.lookup_source_file(file) {
960 for symbol in crate::db::file_structural_symbols(db, sf).iter() {
961 let lookup = crate::defining_file_lookup_key(symbol);
962 if let Some(defining_file) = db.symbol_defining_file(lookup) {
963 if defining_file.as_ref() != file {
964 targets.insert(defining_file.as_ref().to_string());
965 }
966 }
967 }
968 }
969
970 if !include_body_ref_edges {
971 return targets;
972 }
973
974 // Bare-FQN references recorded during body analysis (new \Foo(),
975 // \Foo::method(), \foo()) that do not appear in use-import statements.
976 for symbol_key in db.file_referenced_symbols(file) {
977 let lookup = crate::defining_file_lookup_key(&symbol_key);
978 if let Some(defining_file) = db.symbol_defining_file(lookup) {
979 if defining_file.as_ref() != file {
980 targets.insert(defining_file.as_ref().to_string());
981 }
982 }
983 }
984
985 targets
986}
987
988/// AST visitor that collects class FQCN references for PSR-4 preloading.
989/// Captures identifiers from `new X`, static calls / property / constant
990/// access, type hints, `instanceof`, and `@param`/`@return`/`@var`/`@extends`/
991/// `@implements` docblock annotations. Does *not* normalize via PSR-4 /
992/// imports — callers run the raw string through `resolve_name`.
993fn collect_class_refs_from_ast(program: &php_ast::owned::Program) -> Vec<String> {
994 use php_ast::ast::BinaryOp;
995 use php_ast::owned::visitor::{
996 walk_owned_class_member, walk_owned_expr, walk_owned_program, walk_owned_stmt, OwnedVisitor,
997 };
998 use php_ast::owned::{ClassMemberKind, ExprKind};
999 use std::ops::ControlFlow;
1000
1001 fn owned_name_str(name: &php_ast::owned::Name) -> String {
1002 let joined: String = name
1003 .parts
1004 .iter()
1005 .map(|p| p.as_ref())
1006 .collect::<Vec<&str>>()
1007 .join("\\");
1008 if name.kind == php_ast::ast::NameKind::FullyQualified {
1009 format!("\\{joined}")
1010 } else {
1011 joined
1012 }
1013 }
1014
1015 /// Recursively collect all `TNamedObject` FQCNs from a mir type, including
1016 /// those nested inside generic type parameters (e.g. `Collection<Item>`).
1017 fn collect_from_type(ty: &mir_types::Type, out: &mut std::collections::HashSet<String>) {
1018 for atomic in ty.types.iter() {
1019 if let mir_types::Atomic::TNamedObject { fqcn, type_params } = atomic {
1020 out.insert(fqcn.as_ref().to_string());
1021 for tp in type_params.iter() {
1022 collect_from_type(tp, out);
1023 }
1024 }
1025 }
1026 }
1027
1028 /// Parse a docblock and collect class names from `@param`, `@return`,
1029 /// `@var`, `@extends`, and `@implements` annotations.
1030 fn collect_from_docblock(text: &str, out: &mut std::collections::HashSet<String>) {
1031 let parsed = crate::parser::DocblockParser::parse(text);
1032 for (_, ty) in &parsed.params {
1033 collect_from_type(ty, out);
1034 }
1035 if let Some(ret) = &parsed.return_type {
1036 collect_from_type(ret, out);
1037 }
1038 if let Some(var) = &parsed.var_type {
1039 collect_from_type(var, out);
1040 }
1041 for ext in &parsed.extends {
1042 collect_from_type(ext, out);
1043 }
1044 for impl_ty in &parsed.implements {
1045 collect_from_type(impl_ty, out);
1046 }
1047 }
1048
1049 struct V {
1050 names: std::collections::HashSet<String>,
1051 }
1052 impl OwnedVisitor for V {
1053 fn visit_stmt(&mut self, stmt: &php_ast::owned::Stmt) -> ControlFlow<()> {
1054 if let Some(doc) = stmt.leading_doc_comment() {
1055 collect_from_docblock(&doc.text, &mut self.names);
1056 }
1057 walk_owned_stmt(self, stmt)
1058 }
1059
1060 fn visit_class_member(&mut self, member: &php_ast::owned::ClassMember) -> ControlFlow<()> {
1061 match &member.kind {
1062 ClassMemberKind::Method(m) => {
1063 if let Some(doc) = &m.doc_comment {
1064 collect_from_docblock(&doc.text, &mut self.names);
1065 }
1066 }
1067 ClassMemberKind::Property(p) => {
1068 if let Some(doc) = &p.doc_comment {
1069 collect_from_docblock(&doc.text, &mut self.names);
1070 }
1071 }
1072 _ => {}
1073 }
1074 walk_owned_class_member(self, member)
1075 }
1076
1077 fn visit_expr(&mut self, expr: &php_ast::owned::Expr) -> ControlFlow<()> {
1078 match &expr.kind {
1079 ExprKind::New(n) => {
1080 if let ExprKind::Identifier(name) = &n.class.kind {
1081 self.names.insert(name.as_ref().to_string());
1082 }
1083 }
1084 ExprKind::StaticMethodCall(c) => {
1085 if let ExprKind::Identifier(name) = &c.class.kind {
1086 self.names.insert(name.as_ref().to_string());
1087 }
1088 }
1089 ExprKind::StaticPropertyAccess(a) => {
1090 if let ExprKind::Identifier(name) = &a.class.kind {
1091 self.names.insert(name.as_ref().to_string());
1092 }
1093 }
1094 ExprKind::ClassConstAccess(a) => {
1095 if let ExprKind::Identifier(name) = &a.class.kind {
1096 self.names.insert(name.as_ref().to_string());
1097 }
1098 }
1099 ExprKind::Binary(b) if b.op == BinaryOp::Instanceof => {
1100 if let ExprKind::Identifier(name) = &b.right.kind {
1101 self.names.insert(name.as_ref().to_string());
1102 }
1103 }
1104 _ => {}
1105 }
1106 walk_owned_expr(self, expr)
1107 }
1108
1109 // Walker routes every class/type-position Name here: type hints, catch types, extends/implements, trait use, attributes.
1110 fn visit_name(&mut self, name: &php_ast::owned::Name) -> ControlFlow<()> {
1111 let s = owned_name_str(name);
1112 if !s.is_empty() {
1113 self.names.insert(s);
1114 }
1115 ControlFlow::Continue(())
1116 }
1117 }
1118 let mut v = V {
1119 names: std::collections::HashSet::default(),
1120 };
1121 let _ = walk_owned_program(&mut v, program);
1122 v.names.into_iter().collect()
1123}