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 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}