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