Skip to main content

mir_analyzer/session/
mod.rs

1//! Session-based analysis API for incremental, per-file analysis.
2//!
3//! [`AnalysisSession`] owns the salsa database and per-session caches. It is
4//! the single writer (mutations take `&mut self`); readers 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}