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 /// Whether analysis maintains the legacy imperative reference index
102 /// ([`crate::db::RefIndex`]). On by default. Hosts that read references
103 /// exclusively through the memoized [`Self::references_to_in_files`] path
104 /// opt out via [`Self::without_reference_index`], removing every
105 /// `RefIndex` lock from their edit and read paths.
106 pub(crate) maintain_ref_index: bool,
107}
108
109/// FQCN → optional resolver-mapped path. See the field doc on
110/// `AnalysisSession::unresolvable_fqcns`.
111type UnresolvableCache = Arc<RwLock<HashMap<Arc<str>, Option<Arc<str>>>>>;
112
113/// Warm-up skip set keyed by file path. See the field doc on
114/// `AnalysisSession::prepared_files`.
115type PreparedFilesCache = Arc<RwLock<HashMap<Arc<str>, (Arc<str>, u64)>>>;
116
117/// Cap on the negative-resolution cache. Sized to accommodate a large
118/// workspace's worth of genuinely-missing references without unbounded
119/// growth. On overflow the cache is cleared; the cost is a few extra
120/// resolver calls until it re-fills.
121const UNRESOLVABLE_CACHE_CAP: usize = 10_000;
122
123impl AnalysisSession {
124 /// Create a session targeting the given PHP language version.
125 pub fn new(php_version: PhpVersion) -> Self {
126 let db = Arc::new(AnalyzerDb::new());
127 db.salsa
128 .write()
129 .set_php_version(Arc::from(php_version.to_string().as_str()));
130 Self {
131 db,
132 cache: None,
133 psr4: None,
134 resolver: None,
135 php_version,
136 user_stub_files: Vec::new(),
137 user_stub_dirs: Vec::new(),
138 stale_defined_symbols: Arc::new(RwLock::new(HashMap::default())),
139 last_ingested_symbols: Arc::new(RwLock::new(HashMap::default())),
140 unresolvable_fqcns: Arc::new(RwLock::new(HashMap::default())),
141 source_provider: Arc::new(crate::FsSourceProvider),
142 pending_eager_function_files: Arc::new(parking_lot::Mutex::new(Some(Vec::new()))),
143 prepared_files: Arc::new(RwLock::new(HashMap::default())),
144 prepare_generation: Arc::new(std::sync::atomic::AtomicU64::new(0)),
145 maintain_ref_index: true,
146 }
147 }
148
149 /// Stop maintaining the legacy imperative reference index on the
150 /// incremental (LSP-style) paths: `ingest_file`, `invalidate_file`,
151 /// [`crate::FileAnalyzer`] commits, and the `reanalyze_*` sweeps.
152 ///
153 /// After this, [`Self::references_to`] / [`Self::reference_locations`]
154 /// return empty for files analyzed through those paths and
155 /// [`Self::dependency_graph`] loses body-level bare-FQN edges — callers
156 /// must use the memoized [`Self::references_to_in_files`] /
157 /// [`Self::reanalyze_files_cancellable`] paths instead. In exchange, no
158 /// edit or read ever takes the `RefIndex` lock (assert via
159 /// [`Self::ref_index_lock_count`]) and the index holds no memory.
160 /// The batch entry points (`analyze_paths`) still maintain the index.
161 pub fn without_reference_index(mut self) -> Self {
162 self.maintain_ref_index = false;
163 self
164 }
165
166 /// Times the reference index has been locked on this session's db.
167 pub fn ref_index_lock_count(&self) -> u64 {
168 self.db.salsa.read().ref_index_lock_count()
169 }
170
171 /// Swap in a custom [`crate::SourceProvider`]. LSPs install a VFS-backed
172 /// provider here so the analyzer reads from unsaved editor buffers
173 /// instead of disk.
174 pub fn with_source_provider(mut self, provider: Arc<dyn crate::SourceProvider>) -> Self {
175 self.source_provider = provider;
176 self
177 }
178
179 /// Attach a pre-built [`AnalysisCache`] (the body-analysis issue cache) and
180 /// open a sibling definition [`StubSlice`] cache under the same root, so
181 /// callers using this builder get the same speedup as `with_cache_dir`.
182 ///
183 /// Rebuilds the shared database to attach the definition cache — call
184 /// **before** any file is ingested. A debug assertion catches misuse.
185 ///
186 /// [`StubSlice`]: mir_codebase::storage::StubSlice
187 pub fn with_cache(mut self, cache: Arc<AnalysisCache>) -> Self {
188 debug_assert_eq!(
189 self.db.source_file_count(),
190 0,
191 "AnalysisSession::with_cache must be called before any file is ingested"
192 );
193 let dir = cache.cache_dir().to_path_buf();
194 self.db = Arc::new(AnalyzerDb::new().with_cache_dir(&dir));
195 self.db
196 .salsa
197 .write()
198 .set_php_version(Arc::from(self.php_version.to_string().as_str()));
199 self.cache = Some(cache);
200 self
201 }
202
203 /// Convenience: open a disk-backed cache at `cache_dir` and attach it.
204 ///
205 /// Attaches both the body-analysis issue cache ([`AnalysisCache`]) and the
206 /// definition [`StubSlice`] cache to the shared database. Builds a fresh
207 /// [`AnalyzerDb`] internally — call **before** any file is ingested. A
208 /// debug assertion catches misuse.
209 ///
210 /// [`StubSlice`]: mir_codebase::storage::StubSlice
211 pub fn with_cache_dir(mut self, cache_dir: &std::path::Path) -> Self {
212 debug_assert_eq!(
213 self.db.source_file_count(),
214 0,
215 "AnalysisSession::with_cache_dir must be called before any file is ingested"
216 );
217 self.db = Arc::new(AnalyzerDb::new().with_cache_dir(cache_dir));
218 self.db
219 .salsa
220 .write()
221 .set_php_version(Arc::from(self.php_version.to_string().as_str()));
222 // Fold the user-stub fingerprint into the cache epoch. `with_user_stubs`
223 // must run before this for it to be picked up (it does in `build_session`);
224 // sessions without user stubs get 0, which is correct.
225 let user_stub_fp =
226 crate::stubs::user_stub_fingerprint(&self.user_stub_files, &self.user_stub_dirs);
227 self.cache = Some(Arc::new(AnalysisCache::open(
228 cache_dir,
229 self.php_version.cache_byte(),
230 user_stub_fp,
231 )));
232 self
233 }
234
235 /// Attach a Composer autoload map (PSR-4, PSR-0, classmap, files).
236 /// Sets the same map as the active [`crate::ClassResolver`] so
237 /// [`Self::load_class`] works out of the box.
238 pub fn with_psr4(mut self, map: Arc<Psr4Map>) -> Self {
239 let user_resolver: Arc<dyn crate::ClassResolver> = map.clone();
240 // Wrap with stub awareness so `find_class_like` / `resolve_fqcn_to_path`
241 // can map built-in PHP class FQCNs (`ArrayObject`, `Exception`, …)
242 // to their stub virtual paths.
243 let resolver: Arc<dyn crate::ClassResolver> = Arc::new(crate::ChainedClassResolver::new(
244 user_resolver,
245 Arc::new(crate::StubClassResolver),
246 ));
247 self.psr4 = Some(map.clone());
248 self.resolver = Some(resolver.clone());
249 // Mirror into MirDbStorage so salsa-tracked resolver queries
250 // (`db::resolve_fqcn_to_path`) see the same resolver and are
251 // invalidated on swap.
252 self.db.salsa.write().set_resolver(Some(resolver));
253 // Register vendor autoload.files for lazy loading. They define global
254 // functions and constants that the class resolver cannot discover.
255 // `ensure_vendor_eager_functions` will index them on first analysis call.
256 *self.pending_eager_function_files.lock() = Some(map.vendor_eager_files());
257 self
258 }
259
260 /// Attach a generic class resolver for projects that don't use Composer
261 /// (WordPress, Drupal, custom autoloaders, workspace-walk indexes).
262 /// Replaces any previously-set Composer-backed resolver. Automatically
263 /// wrapped with stub awareness so PHP built-ins remain resolvable.
264 pub fn with_class_resolver(mut self, resolver: Arc<dyn crate::ClassResolver>) -> Self {
265 let wrapped: Arc<dyn crate::ClassResolver> = Arc::new(crate::ChainedClassResolver::new(
266 resolver,
267 Arc::new(crate::StubClassResolver),
268 ));
269 self.db.salsa.write().set_resolver(Some(wrapped.clone()));
270 self.resolver = Some(wrapped);
271 self
272 }
273
274 pub fn with_user_stubs(mut self, files: Vec<PathBuf>, dirs: Vec<PathBuf>) -> Self {
275 self.user_stub_files = files;
276 self.user_stub_dirs = dirs;
277 self
278 }
279
280 pub fn php_version(&self) -> PhpVersion {
281 self.php_version
282 }
283
284 pub fn cache(&self) -> Option<&AnalysisCache> {
285 self.cache.as_deref()
286 }
287
288 pub fn psr4(&self) -> Option<&Psr4Map> {
289 self.psr4.as_deref()
290 }
291}
292
293mod incremental;
294mod ingest;
295mod loading;
296mod queries;
297mod stubs;
298
299/// Compute the full set of files `file` depends on: structural edges from
300/// the memoized [`crate::db::file_structural_deps`] tracked query, plus
301/// bare-FQN references recorded during body analysis (which live in the
302/// reference index and are not visible to salsa). Self-edges are excluded.
303/// Used to persist the disk cache's reverse-dep graph.
304fn file_outgoing_dependencies(
305 db: &dyn MirDatabase,
306 file: &str,
307 include_body_ref_edges: bool,
308) -> HashSet<String> {
309 let mut targets: HashSet<String> = HashSet::default();
310
311 if let Some(sf) = db.lookup_source_file(file) {
312 for target in crate::db::file_structural_deps(db, sf).iter() {
313 targets.insert(target.as_ref().to_string());
314 }
315 }
316
317 if !include_body_ref_edges {
318 return targets;
319 }
320
321 // Bare-FQN references recorded during body analysis (new \Foo(),
322 // \Foo::method(), \foo()) that do not appear in use-import statements.
323 for symbol_key in db.file_referenced_symbols(file) {
324 let lookup: &str = match symbol_key.split_once("::") {
325 Some((class, _)) => class,
326 None => &symbol_key,
327 };
328 if let Some(defining_file) = db.symbol_defining_file(lookup) {
329 if defining_file.as_ref() != file {
330 targets.insert(defining_file.as_ref().to_string());
331 }
332 }
333 }
334
335 targets
336}
337
338/// AST visitor that collects class FQCN references for PSR-4 preloading.
339/// Captures identifiers from `new X`, static calls / property / constant
340/// access, type hints, `instanceof`, and `@param`/`@return`/`@var`/`@extends`/
341/// `@implements` docblock annotations. Does *not* normalize via PSR-4 /
342/// imports — callers run the raw string through `resolve_name`.
343fn collect_class_refs_from_ast(program: &php_ast::owned::Program) -> Vec<String> {
344 use php_ast::ast::BinaryOp;
345 use php_ast::owned::visitor::{
346 walk_owned_class_member, walk_owned_expr, walk_owned_program, walk_owned_stmt, OwnedVisitor,
347 };
348 use php_ast::owned::{ClassMemberKind, ExprKind};
349 use std::ops::ControlFlow;
350
351 fn owned_name_str(name: &php_ast::owned::Name) -> String {
352 let joined: String = name
353 .parts
354 .iter()
355 .map(|p| p.as_ref())
356 .collect::<Vec<&str>>()
357 .join("\\");
358 if name.kind == php_ast::ast::NameKind::FullyQualified {
359 format!("\\{joined}")
360 } else {
361 joined
362 }
363 }
364
365 /// Recursively collect all `TNamedObject` FQCNs from a mir type, including
366 /// those nested inside generic type parameters (e.g. `Collection<Item>`).
367 fn collect_from_type(ty: &mir_types::Type, out: &mut std::collections::HashSet<String>) {
368 for atomic in ty.types.iter() {
369 if let mir_types::Atomic::TNamedObject { fqcn, type_params } = atomic {
370 out.insert(fqcn.as_ref().to_string());
371 for tp in type_params.iter() {
372 collect_from_type(tp, out);
373 }
374 }
375 }
376 }
377
378 /// Parse a docblock and collect class names from `@param`, `@return`,
379 /// `@var`, `@extends`, and `@implements` annotations.
380 fn collect_from_docblock(text: &str, out: &mut std::collections::HashSet<String>) {
381 let parsed = crate::parser::DocblockParser::parse(text);
382 for (_, ty) in &parsed.params {
383 collect_from_type(ty, out);
384 }
385 if let Some(ret) = &parsed.return_type {
386 collect_from_type(ret, out);
387 }
388 if let Some(var) = &parsed.var_type {
389 collect_from_type(var, out);
390 }
391 for ext in &parsed.extends {
392 collect_from_type(ext, out);
393 }
394 for impl_ty in &parsed.implements {
395 collect_from_type(impl_ty, out);
396 }
397 }
398
399 struct V {
400 names: std::collections::HashSet<String>,
401 }
402 impl OwnedVisitor for V {
403 fn visit_stmt(&mut self, stmt: &php_ast::owned::Stmt) -> ControlFlow<()> {
404 if let Some(doc) = stmt.leading_doc_comment() {
405 collect_from_docblock(&doc.text, &mut self.names);
406 }
407 walk_owned_stmt(self, stmt)
408 }
409
410 fn visit_class_member(&mut self, member: &php_ast::owned::ClassMember) -> ControlFlow<()> {
411 match &member.kind {
412 ClassMemberKind::Method(m) => {
413 if let Some(doc) = &m.doc_comment {
414 collect_from_docblock(&doc.text, &mut self.names);
415 }
416 }
417 ClassMemberKind::Property(p) => {
418 if let Some(doc) = &p.doc_comment {
419 collect_from_docblock(&doc.text, &mut self.names);
420 }
421 }
422 _ => {}
423 }
424 walk_owned_class_member(self, member)
425 }
426
427 fn visit_expr(&mut self, expr: &php_ast::owned::Expr) -> ControlFlow<()> {
428 match &expr.kind {
429 ExprKind::New(n) => {
430 if let ExprKind::Identifier(name) = &n.class.kind {
431 self.names.insert(name.as_ref().to_string());
432 }
433 }
434 ExprKind::StaticMethodCall(c) => {
435 if let ExprKind::Identifier(name) = &c.class.kind {
436 self.names.insert(name.as_ref().to_string());
437 }
438 }
439 ExprKind::StaticPropertyAccess(a) => {
440 if let ExprKind::Identifier(name) = &a.class.kind {
441 self.names.insert(name.as_ref().to_string());
442 }
443 }
444 ExprKind::ClassConstAccess(a) => {
445 if let ExprKind::Identifier(name) = &a.class.kind {
446 self.names.insert(name.as_ref().to_string());
447 }
448 }
449 ExprKind::Binary(b) if b.op == BinaryOp::Instanceof => {
450 if let ExprKind::Identifier(name) = &b.right.kind {
451 self.names.insert(name.as_ref().to_string());
452 }
453 }
454 _ => {}
455 }
456 walk_owned_expr(self, expr)
457 }
458
459 // Walker routes every class/type-position Name here: type hints, catch types, extends/implements, trait use, attributes.
460 fn visit_name(&mut self, name: &php_ast::owned::Name) -> ControlFlow<()> {
461 let s = owned_name_str(name);
462 if !s.is_empty() {
463 self.names.insert(s);
464 }
465 ControlFlow::Continue(())
466 }
467 }
468 let mut v = V {
469 names: std::collections::HashSet::default(),
470 };
471 let _ = walk_owned_program(&mut v, program);
472 v.names.into_iter().collect()
473}