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