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    /// `references_to_in_files` / `reanalyze_dependents` skip the serial
93    /// parse + AST walk for files already faulted in.
94    prepared_files: PreparedFilesCache,
95    /// Bumped whenever previously loaded declarations may have been removed
96    /// (`invalidate_file`, symbol deletions on `ingest_file`, or a host calling
97    /// [`Self::bump_prepare_generation`]) — a prepared file might then need its
98    /// warm-up re-run to lazy-load a replacement (e.g. a vendor class shadowed
99    /// by a since-deleted project class).
100    prepare_generation: Arc<std::sync::atomic::AtomicU64>,
101    /// file → (source text, analyze output) its reference locations were
102    /// last committed from. The [`crate::db::RefIndex`] posting lists are
103    /// exact for a file while its current input text is pointer-equal to the
104    /// stored text; a text write self-invalidates (the Arc changes), making
105    /// the file "dirty" for [`Self::indexed_references_to`]'s freshness pass.
106    /// The weak output handle lets re-analysis sweeps skip the index rebuild
107    /// when salsa returned the identical memo (no-op sweeps stay pointer
108    /// compares) while still recommitting on cross-file drift, where the
109    /// text is unchanged but the output is a new value. Files absent here
110    /// have never been committed.
111    ref_committed: CommittedRefs,
112    /// file → source text its subtype-index class edges were last committed
113    /// from. Same freshness contract as `ref_committed`, but definitions
114    /// depend only on the file's own text, so a pointer-equal entry is
115    /// always exact (no cross-file drift).
116    defs_committed: CommittedTexts,
117}
118
119/// FQCN → optional resolver-mapped path. See the field doc on
120/// `AnalysisSession::unresolvable_fqcns`.
121type UnresolvableCache = Arc<RwLock<HashMap<Arc<str>, Option<Arc<str>>>>>;
122
123/// Warm-up skip set keyed by file path. See the field doc on
124/// `AnalysisSession::prepared_files`.
125type PreparedFilesCache = Arc<RwLock<HashMap<Arc<str>, (Arc<str>, u64)>>>;
126
127/// file → text a per-file index commit was computed from. See the field docs
128/// on `AnalysisSession::ref_committed` / `defs_committed`.
129type CommittedTexts = Arc<RwLock<HashMap<Arc<str>, Arc<str>>>>;
130
131/// file → (text, weak analyze output) for reference-posting commits. See the
132/// field docs on `AnalysisSession::ref_committed`.
133type CommittedRefs =
134    Arc<RwLock<HashMap<Arc<str>, (Arc<str>, std::sync::Weak<crate::db::AnalyzeOutput>)>>>;
135
136/// Cap on the negative-resolution cache. Sized to accommodate a large
137/// workspace's worth of genuinely-missing references without unbounded
138/// growth. On overflow the cache is cleared; the cost is a few extra
139/// resolver calls until it re-fills.
140const UNRESOLVABLE_CACHE_CAP: usize = 10_000;
141
142impl AnalysisSession {
143    /// Create a session targeting the given PHP language version.
144    pub fn new(php_version: PhpVersion) -> Self {
145        let db = Arc::new(AnalyzerDb::new());
146        db.salsa
147            .write()
148            .set_php_version(Arc::from(php_version.to_string().as_str()));
149        Self {
150            db,
151            cache: None,
152            psr4: None,
153            resolver: None,
154            php_version,
155            user_stub_files: Vec::new(),
156            user_stub_dirs: Vec::new(),
157            stale_defined_symbols: Arc::new(RwLock::new(HashMap::default())),
158            last_ingested_symbols: Arc::new(RwLock::new(HashMap::default())),
159            unresolvable_fqcns: Arc::new(RwLock::new(HashMap::default())),
160            source_provider: Arc::new(crate::FsSourceProvider),
161            pending_eager_function_files: Arc::new(parking_lot::Mutex::new(Some(Vec::new()))),
162            prepared_files: Arc::new(RwLock::new(HashMap::default())),
163            prepare_generation: Arc::new(std::sync::atomic::AtomicU64::new(0)),
164            ref_committed: Arc::new(RwLock::new(HashMap::default())),
165            defs_committed: Arc::new(RwLock::new(HashMap::default())),
166        }
167    }
168
169    /// Times the reference index has been locked on this session's db.
170    pub fn ref_index_lock_count(&self) -> u64 {
171        self.db.salsa.read().ref_index_lock_count()
172    }
173
174    /// Whether `file`'s reference postings were committed from exactly
175    /// `current_text` (pointer identity — a text write self-invalidates).
176    pub(crate) fn is_ref_committed(&self, file: &str, current_text: &Arc<str>) -> bool {
177        self.ref_committed
178            .read()
179            .get(file)
180            .is_some_and(|(t, _)| Arc::ptr_eq(t, current_text))
181    }
182
183    /// Whether `file`'s postings were committed from exactly this
184    /// (text, analyze output) pair — the no-op detector for re-analysis
185    /// sweeps. The weak upgrade guards against ABA on evicted memos.
186    pub(crate) fn ref_commit_is_current(
187        &self,
188        file: &str,
189        current_text: &Arc<str>,
190        out: &Arc<crate::db::AnalyzeOutput>,
191    ) -> bool {
192        self.ref_committed.read().get(file).is_some_and(|(t, w)| {
193            Arc::ptr_eq(t, current_text) && w.upgrade().is_some_and(|prev| Arc::ptr_eq(&prev, out))
194        })
195    }
196
197    pub(crate) fn mark_ref_committed(
198        &self,
199        file: &Arc<str>,
200        text: &Arc<str>,
201        out: Option<&Arc<crate::db::AnalyzeOutput>>,
202    ) {
203        let weak = out.map(Arc::downgrade).unwrap_or_default();
204        self.ref_committed
205            .write()
206            .insert(file.clone(), (text.clone(), weak));
207    }
208
209    pub(crate) fn forget_ref_committed(&self, file: &str) {
210        self.ref_committed.write().remove(file);
211    }
212
213    /// Whether `file`'s subtype-index class edges were committed from exactly
214    /// `current_text`.
215    pub(crate) fn is_defs_committed(&self, file: &str, current_text: &Arc<str>) -> bool {
216        self.defs_committed
217            .read()
218            .get(file)
219            .is_some_and(|t| Arc::ptr_eq(t, current_text))
220    }
221
222    pub(crate) fn mark_defs_committed(&self, file: &Arc<str>, text: &Arc<str>) {
223        self.defs_committed
224            .write()
225            .insert(file.clone(), text.clone());
226    }
227
228    pub(crate) fn forget_defs_committed(&self, file: &str) {
229        self.defs_committed.write().remove(file);
230    }
231
232    /// Every file with a defs commit on record, regardless of staleness.
233    pub(crate) fn defs_committed_keys(&self) -> Vec<Arc<str>> {
234        self.defs_committed.read().keys().cloned().collect()
235    }
236
237    /// Swap in a custom [`crate::SourceProvider`]. LSPs install a VFS-backed
238    /// provider here so the analyzer reads from unsaved editor buffers
239    /// instead of disk.
240    pub fn with_source_provider(mut self, provider: Arc<dyn crate::SourceProvider>) -> Self {
241        self.source_provider = provider;
242        self
243    }
244
245    /// Attach a pre-built [`AnalysisCache`] (the body-analysis issue cache) and
246    /// open a sibling definition [`StubSlice`] cache under the same root, so
247    /// callers using this builder get the same speedup as `with_cache_dir`.
248    ///
249    /// Rebuilds the shared database to attach the definition cache — call
250    /// **before** any file is ingested. A debug assertion catches misuse.
251    ///
252    /// [`StubSlice`]: mir_codebase::definitions::StubSlice
253    pub fn with_cache(mut self, cache: Arc<AnalysisCache>) -> Self {
254        debug_assert_eq!(
255            self.db.source_file_count(),
256            0,
257            "AnalysisSession::with_cache must be called before any file is ingested"
258        );
259        let dir = cache.cache_dir().to_path_buf();
260        self.db = Arc::new(AnalyzerDb::new().with_cache_dir(&dir));
261        self.db
262            .salsa
263            .write()
264            .set_php_version(Arc::from(self.php_version.to_string().as_str()));
265        self.cache = Some(cache);
266        self
267    }
268
269    /// Convenience: open a disk-backed cache at `cache_dir` and attach it.
270    ///
271    /// Attaches both the body-analysis issue cache ([`AnalysisCache`]) and the
272    /// definition [`StubSlice`] cache to the shared database. Builds a fresh
273    /// [`AnalyzerDb`] internally — call **before** any file is ingested. A
274    /// debug assertion catches misuse.
275    ///
276    /// [`StubSlice`]: mir_codebase::definitions::StubSlice
277    pub fn with_cache_dir(mut self, cache_dir: &std::path::Path) -> Self {
278        debug_assert_eq!(
279            self.db.source_file_count(),
280            0,
281            "AnalysisSession::with_cache_dir must be called before any file is ingested"
282        );
283        self.db = Arc::new(AnalyzerDb::new().with_cache_dir(cache_dir));
284        self.db
285            .salsa
286            .write()
287            .set_php_version(Arc::from(self.php_version.to_string().as_str()));
288        // Fold the user-stub fingerprint into the cache epoch. `with_user_stubs`
289        // must run before this for it to be picked up (it does in `build_session`);
290        // sessions without user stubs get 0, which is correct.
291        let user_stub_fp =
292            crate::stubs::user_stub_fingerprint(&self.user_stub_files, &self.user_stub_dirs);
293        self.cache = Some(Arc::new(AnalysisCache::open(
294            cache_dir,
295            self.php_version.cache_byte(),
296            user_stub_fp,
297        )));
298        self
299    }
300
301    /// Attach a Composer autoload map (PSR-4, PSR-0, classmap, files).
302    /// Sets the same map as the active [`crate::ClassResolver`] so
303    /// [`Self::load_class`] works out of the box.
304    pub fn with_psr4(mut self, map: Arc<Psr4Map>) -> Self {
305        let user_resolver: Arc<dyn crate::ClassResolver> = map.clone();
306        // Wrap with stub awareness so `find_class_like` / `resolve_fqcn_to_path`
307        // can map built-in PHP class FQCNs (`ArrayObject`, `Exception`, …)
308        // to their stub virtual paths.
309        let resolver: Arc<dyn crate::ClassResolver> = Arc::new(crate::ChainedClassResolver::new(
310            user_resolver,
311            Arc::new(crate::StubClassResolver),
312        ));
313        self.psr4 = Some(map.clone());
314        self.resolver = Some(resolver.clone());
315        // Mirror into MirDbStorage so salsa-tracked resolver queries
316        // (`db::resolve_fqcn_to_path`) see the same resolver and are
317        // invalidated on swap.
318        self.db.salsa.write().set_resolver(Some(resolver));
319        // Register vendor autoload.files for lazy loading. They define global
320        // functions and constants that the class resolver cannot discover.
321        // `ensure_vendor_eager_functions` will index them on first analysis call.
322        *self.pending_eager_function_files.lock() = Some(map.vendor_eager_files());
323        self
324    }
325
326    /// Attach a generic class resolver for projects that don't use Composer
327    /// (WordPress, Drupal, custom autoloaders, workspace-walk indexes).
328    /// Replaces any previously-set Composer-backed resolver. Automatically
329    /// wrapped with stub awareness so PHP built-ins remain resolvable.
330    pub fn with_class_resolver(mut self, resolver: Arc<dyn crate::ClassResolver>) -> Self {
331        let wrapped: Arc<dyn crate::ClassResolver> = Arc::new(crate::ChainedClassResolver::new(
332            resolver,
333            Arc::new(crate::StubClassResolver),
334        ));
335        self.db.salsa.write().set_resolver(Some(wrapped.clone()));
336        self.resolver = Some(wrapped);
337        self
338    }
339
340    pub fn with_user_stubs(mut self, files: Vec<PathBuf>, dirs: Vec<PathBuf>) -> Self {
341        self.user_stub_files = files;
342        self.user_stub_dirs = dirs;
343        self
344    }
345
346    pub fn php_version(&self) -> PhpVersion {
347        self.php_version
348    }
349
350    pub fn cache(&self) -> Option<&AnalysisCache> {
351        self.cache.as_deref()
352    }
353
354    pub fn psr4(&self) -> Option<&Psr4Map> {
355        self.psr4.as_deref()
356    }
357}
358
359mod incremental;
360mod ingest;
361mod loading;
362mod queries;
363mod stubs;
364
365pub use queries::SubtypeClassSite;
366
367/// Compute the full set of files `file` depends on: structural edges from
368/// the memoized [`crate::db::file_structural_deps`] tracked query, plus
369/// bare-FQN references recorded during body analysis (which live in the
370/// reference index and are not visible to salsa). Self-edges are excluded.
371/// Used to persist the disk cache's reverse-dep graph.
372fn file_outgoing_dependencies(
373    db: &dyn MirDatabase,
374    file: &str,
375    include_body_ref_edges: bool,
376) -> HashSet<String> {
377    let mut targets: HashSet<String> = HashSet::default();
378
379    if let Some(sf) = db.lookup_source_file(file) {
380        for target in crate::db::file_structural_deps(db, sf).iter() {
381            targets.insert(target.as_ref().to_string());
382        }
383    }
384
385    if !include_body_ref_edges {
386        return targets;
387    }
388
389    // Bare-FQN references recorded during body analysis (new \Foo(),
390    // \Foo::method(), \foo()) that do not appear in use-import statements.
391    for symbol_key in db.file_referenced_symbols(file) {
392        let lookup = crate::defining_file_lookup_key(&symbol_key);
393        if let Some(defining_file) = db.symbol_defining_file(lookup) {
394            if defining_file.as_ref() != file {
395                targets.insert(defining_file.as_ref().to_string());
396            }
397        }
398    }
399
400    targets
401}
402
403/// AST visitor that collects class FQCN references for PSR-4 preloading.
404/// Captures identifiers from `new X`, static calls / property / constant
405/// access, type hints, `instanceof`, and `@param`/`@return`/`@var`/`@extends`/
406/// `@implements` docblock annotations. Does *not* normalize via PSR-4 /
407/// imports — callers run the raw string through `resolve_name`.
408fn collect_class_refs_from_ast(program: &php_ast::owned::Program) -> Vec<String> {
409    use php_ast::ast::BinaryOp;
410    use php_ast::owned::visitor::{
411        walk_owned_class_member, walk_owned_expr, walk_owned_program, walk_owned_stmt, OwnedVisitor,
412    };
413    use php_ast::owned::{ClassMemberKind, ExprKind};
414    use std::ops::ControlFlow;
415
416    fn owned_name_str(name: &php_ast::owned::Name) -> String {
417        let joined: String = name
418            .parts
419            .iter()
420            .map(|p| p.as_ref())
421            .collect::<Vec<&str>>()
422            .join("\\");
423        if name.kind == php_ast::ast::NameKind::FullyQualified {
424            format!("\\{joined}")
425        } else {
426            joined
427        }
428    }
429
430    /// Recursively collect all `TNamedObject` FQCNs from a mir type, including
431    /// those nested inside generic type parameters (e.g. `Collection<Item>`).
432    fn collect_from_type(ty: &mir_types::Type, out: &mut std::collections::HashSet<String>) {
433        for atomic in ty.types.iter() {
434            if let mir_types::Atomic::TNamedObject { fqcn, type_params } = atomic {
435                out.insert(fqcn.as_ref().to_string());
436                for tp in type_params.iter() {
437                    collect_from_type(tp, out);
438                }
439            }
440        }
441    }
442
443    /// Parse a docblock and collect class names from `@param`, `@return`,
444    /// `@var`, `@extends`, and `@implements` annotations.
445    fn collect_from_docblock(text: &str, out: &mut std::collections::HashSet<String>) {
446        let parsed = crate::parser::DocblockParser::parse(text);
447        for (_, ty) in &parsed.params {
448            collect_from_type(ty, out);
449        }
450        if let Some(ret) = &parsed.return_type {
451            collect_from_type(ret, out);
452        }
453        if let Some(var) = &parsed.var_type {
454            collect_from_type(var, out);
455        }
456        for ext in &parsed.extends {
457            collect_from_type(ext, out);
458        }
459        for impl_ty in &parsed.implements {
460            collect_from_type(impl_ty, out);
461        }
462    }
463
464    struct V {
465        names: std::collections::HashSet<String>,
466    }
467    impl OwnedVisitor for V {
468        fn visit_stmt(&mut self, stmt: &php_ast::owned::Stmt) -> ControlFlow<()> {
469            if let Some(doc) = stmt.leading_doc_comment() {
470                collect_from_docblock(&doc.text, &mut self.names);
471            }
472            walk_owned_stmt(self, stmt)
473        }
474
475        fn visit_class_member(&mut self, member: &php_ast::owned::ClassMember) -> ControlFlow<()> {
476            match &member.kind {
477                ClassMemberKind::Method(m) => {
478                    if let Some(doc) = &m.doc_comment {
479                        collect_from_docblock(&doc.text, &mut self.names);
480                    }
481                }
482                ClassMemberKind::Property(p) => {
483                    if let Some(doc) = &p.doc_comment {
484                        collect_from_docblock(&doc.text, &mut self.names);
485                    }
486                }
487                _ => {}
488            }
489            walk_owned_class_member(self, member)
490        }
491
492        fn visit_expr(&mut self, expr: &php_ast::owned::Expr) -> ControlFlow<()> {
493            match &expr.kind {
494                ExprKind::New(n) => {
495                    if let ExprKind::Identifier(name) = &n.class.kind {
496                        self.names.insert(name.as_ref().to_string());
497                    }
498                }
499                ExprKind::StaticMethodCall(c) => {
500                    if let ExprKind::Identifier(name) = &c.class.kind {
501                        self.names.insert(name.as_ref().to_string());
502                    }
503                }
504                ExprKind::StaticPropertyAccess(a) => {
505                    if let ExprKind::Identifier(name) = &a.class.kind {
506                        self.names.insert(name.as_ref().to_string());
507                    }
508                }
509                ExprKind::ClassConstAccess(a) => {
510                    if let ExprKind::Identifier(name) = &a.class.kind {
511                        self.names.insert(name.as_ref().to_string());
512                    }
513                }
514                ExprKind::Binary(b) if b.op == BinaryOp::Instanceof => {
515                    if let ExprKind::Identifier(name) = &b.right.kind {
516                        self.names.insert(name.as_ref().to_string());
517                    }
518                }
519                _ => {}
520            }
521            walk_owned_expr(self, expr)
522        }
523
524        // Walker routes every class/type-position Name here: type hints, catch types, extends/implements, trait use, attributes.
525        fn visit_name(&mut self, name: &php_ast::owned::Name) -> ControlFlow<()> {
526            let s = owned_name_str(name);
527            if !s.is_empty() {
528                self.names.insert(s);
529            }
530            ControlFlow::Continue(())
531        }
532    }
533    let mut v = V {
534        names: std::collections::HashSet::default(),
535    };
536    let _ = walk_owned_program(&mut v, program);
537    v.names.into_iter().collect()
538}