mir_analyzer/session.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. `MirDb::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 Pass 2 entry
11//! point that operates against a session.
12
13use std::collections::HashSet;
14use std::path::PathBuf;
15use std::sync::Arc;
16
17use parking_lot::Mutex;
18
19use crate::cache::AnalysisCache;
20use crate::composer::Psr4Map;
21use crate::db::{MirDatabase, MirDb};
22use crate::php_version::PhpVersion;
23use crate::shared_db::SharedDb;
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`].
31pub struct AnalysisSession {
32 /// Shared database management (salsa, file registry, stub tracking).
33 /// Extracted to allow code sharing with ProjectAnalyzer.
34 shared_db: Arc<SharedDb>,
35 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 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 php_version: PhpVersion,
47 user_stub_files: Vec<PathBuf>,
48 user_stub_dirs: Vec<PathBuf>,
49}
50
51impl AnalysisSession {
52 /// Create a session targeting the given PHP language version.
53 pub fn new(php_version: PhpVersion) -> Self {
54 Self {
55 shared_db: Arc::new(SharedDb::new()),
56 cache: None,
57 psr4: None,
58 resolver: None,
59 php_version,
60 user_stub_files: Vec::new(),
61 user_stub_dirs: Vec::new(),
62 }
63 }
64
65 pub fn with_cache(mut self, cache: Arc<AnalysisCache>) -> Self {
66 self.cache = Some(cache);
67 self
68 }
69
70 /// Convenience: open a disk-backed cache at `cache_dir` and attach it.
71 /// Avoids forcing callers to wrap [`AnalysisCache`] in `Arc` themselves.
72 pub fn with_cache_dir(self, cache_dir: &std::path::Path) -> Self {
73 self.with_cache(Arc::new(AnalysisCache::open(cache_dir)))
74 }
75
76 /// Attach a Composer autoload map (PSR-4, PSR-0, classmap, files).
77 /// Sets the same map as the active [`crate::ClassResolver`] so
78 /// [`Self::lazy_load_class`] works out of the box.
79 pub fn with_psr4(mut self, map: Arc<Psr4Map>) -> Self {
80 let resolver: Arc<dyn crate::ClassResolver> = map.clone();
81 self.psr4 = Some(map);
82 self.resolver = Some(resolver);
83 self
84 }
85
86 /// Attach a generic class resolver for projects that don't use Composer
87 /// (WordPress, Drupal, custom autoloaders, workspace-walk indexes).
88 /// Replaces any previously-set Composer-backed resolver.
89 pub fn with_class_resolver(mut self, resolver: Arc<dyn crate::ClassResolver>) -> Self {
90 self.resolver = Some(resolver);
91 self
92 }
93
94 pub fn with_user_stubs(mut self, files: Vec<PathBuf>, dirs: Vec<PathBuf>) -> Self {
95 self.user_stub_files = files;
96 self.user_stub_dirs = dirs;
97 self
98 }
99
100 pub fn php_version(&self) -> PhpVersion {
101 self.php_version
102 }
103
104 pub fn cache(&self) -> Option<&AnalysisCache> {
105 self.cache.as_deref()
106 }
107
108 pub fn psr4(&self) -> Option<&Psr4Map> {
109 self.psr4.as_deref()
110 }
111
112 /// Load every PHP built-in stub plus any configured user stubs.
113 ///
114 /// **Deprecated**: prefer [`Self::ensure_all_stubs_loaded`] (explicit
115 /// "comprehensive") or [`Self::ensure_essential_stubs_loaded`] (fast
116 /// cold-start with auto-discovery on demand).
117 #[doc(hidden)]
118 pub fn ensure_stubs_loaded(&self) {
119 self.ensure_all_stubs_loaded();
120 }
121
122 /// Load only the curated set of essential stubs (Core, standard, SPL,
123 /// date) plus any configured user stubs. About 25 of 120 stub files;
124 /// covers types and functions used by virtually all PHP code.
125 ///
126 /// Other extension stubs (Reflection, gd, openssl, …) can be brought in
127 /// on demand via [`Self::ensure_stubs_for_symbol`] when user code
128 /// references them. Idempotent — already-loaded stubs are skipped.
129 pub fn ensure_essential_stubs_loaded(&self) {
130 self.shared_db
131 .ingest_stub_paths(crate::stubs::ESSENTIAL_STUB_PATHS, self.php_version);
132 self.ensure_user_stubs_loaded();
133 }
134
135 /// Load every embedded PHP stub plus any configured user stubs.
136 /// Use for batch tools (CLI, full project analysis) where comprehensive
137 /// symbol coverage matters more than cold-start latency.
138 pub fn ensure_all_stubs_loaded(&self) {
139 let paths: Vec<&'static str> = crate::stubs::stub_files().iter().map(|&(p, _)| p).collect();
140 self.shared_db.ingest_stub_paths(&paths, self.php_version);
141 self.ensure_user_stubs_loaded();
142 }
143
144 /// Ensure the embedded stub that defines `name` (a function) is ingested.
145 /// Returns `true` when a matching stub exists (whether or not it was
146 /// already loaded), `false` when `name` isn't a known PHP built-in.
147 ///
148 /// Most callers should use [`Self::ensure_stubs_for_ast`] instead —
149 /// it auto-discovers needed stubs from a parsed file.
150 #[doc(hidden)]
151 pub fn ensure_stub_for_function(&self, name: &str) -> bool {
152 match crate::stubs::stub_path_for_function(name) {
153 Some(path) => {
154 self.shared_db.ingest_stub_paths(&[path], self.php_version);
155 true
156 }
157 None => false,
158 }
159 }
160
161 /// Ensure the embedded stub that defines `fqcn` (a class / interface /
162 /// trait / enum) is ingested. Case-insensitive lookup with optional
163 /// leading backslash.
164 ///
165 /// Most callers should use [`Self::ensure_stubs_for_ast`] instead.
166 #[doc(hidden)]
167 pub fn ensure_stub_for_class(&self, fqcn: &str) -> bool {
168 match crate::stubs::stub_path_for_class(fqcn) {
169 Some(path) => {
170 self.shared_db.ingest_stub_paths(&[path], self.php_version);
171 true
172 }
173 None => false,
174 }
175 }
176
177 /// Ensure the embedded stub that defines `name` (a constant) is ingested.
178 ///
179 /// Most callers should use [`Self::ensure_stubs_for_ast`] instead.
180 #[doc(hidden)]
181 pub fn ensure_stub_for_constant(&self, name: &str) -> bool {
182 match crate::stubs::stub_path_for_constant(name) {
183 Some(path) => {
184 self.shared_db.ingest_stub_paths(&[path], self.php_version);
185 true
186 }
187 None => false,
188 }
189 }
190
191 /// Number of distinct embedded stubs currently ingested into the session.
192 /// Useful for diagnostics and bench reporting.
193 pub fn loaded_stub_count(&self) -> usize {
194 self.shared_db.loaded_stubs.lock().len()
195 }
196
197 /// Auto-discover and ingest the embedded stubs needed to cover every
198 /// built-in PHP function / class / constant referenced by `source`.
199 ///
200 /// Used by [`crate::FileAnalyzer::analyze`] to keep essentials-only mode
201 /// correct without forcing callers to enumerate which stubs they need.
202 /// Idempotent — already-loaded stubs are skipped via [`Self::loaded_stubs`].
203 ///
204 /// The discovery scan is a coarse identifier sweep (see
205 /// [`crate::stubs::collect_referenced_builtin_paths`]) — it may pull in
206 /// a slightly larger set than the file strictly needs, but never misses
207 /// a referenced built-in. Cost is sub-millisecond per file.
208 ///
209 /// Fast path: if every embedded stub is already loaded (e.g. after a
210 /// batch tool called [`Self::ensure_all_stubs_loaded`]), the source scan
211 /// is skipped entirely.
212 pub fn ensure_stubs_for_source(&self, source: &str) {
213 // Cheap check first: skip the scan entirely when we already know we
214 // have everything. Avoids a ~50-500µs source walk on every analyze
215 // call in batch / warm-session scenarios.
216 {
217 let loaded = self.shared_db.loaded_stubs.lock();
218 if loaded.len() >= crate::stubs::stub_files().len() {
219 return;
220 }
221 }
222 let paths = crate::stubs::collect_referenced_builtin_paths(source);
223 if paths.is_empty() {
224 return;
225 }
226 self.shared_db.ingest_stub_paths(&paths, self.php_version);
227 }
228
229 /// Discover and ingest stubs by walking the parsed AST of a PHP file.
230 ///
231 /// Similar to [`Self::ensure_stubs_for_source`], but takes an already-parsed
232 /// AST instead of raw source text. Produces zero false positives since it
233 /// only extracts identifiers from actual AST nodes (not from strings or
234 /// comments). Preferred over `ensure_stubs_for_source` when the AST is
235 /// already available (e.g., in [`crate::FileAnalyzer`]).
236 ///
237 /// Idempotent and skips the scan if all stubs are already loaded.
238 pub fn ensure_stubs_for_ast(&self, program: &php_ast::ast::Program<'_, '_>) {
239 {
240 let loaded = self.shared_db.loaded_stubs.lock();
241 if loaded.len() >= crate::stubs::stub_files().len() {
242 return;
243 }
244 }
245 let paths = crate::stubs::collect_referenced_builtin_paths_from_ast(program);
246 if paths.is_empty() {
247 return;
248 }
249 self.shared_db.ingest_stub_paths(&paths, self.php_version);
250 }
251
252 fn ensure_user_stubs_loaded(&self) {
253 self.shared_db
254 .ingest_user_stubs(&self.user_stub_files, &self.user_stub_dirs);
255 }
256
257 /// Cheap clone of the salsa db for a read-only query. The lock is held
258 /// only for the duration of the clone, so concurrent readers never
259 /// serialize on each other or on writes for longer than the clone itself.
260 ///
261 /// **Internal API — exposes Salsa types.** Subject to change without
262 /// notice. Public consumers should use the typed query methods
263 /// ([`Self::definition_of`], [`Self::hover`], etc.) instead.
264 #[doc(hidden)]
265 pub fn snapshot_db(&self) -> MirDb {
266 self.shared_db.snapshot_db()
267 }
268
269 /// Run a closure with read access to a database snapshot.
270 ///
271 /// **Internal API — exposes Salsa types.** Subject to change without
272 /// notice.
273 #[doc(hidden)]
274 pub fn read<R>(&self, f: impl FnOnce(&dyn MirDatabase) -> R) -> R {
275 let db = self.snapshot_db();
276 f(&db)
277 }
278
279 /// Pass 1 ingestion. Updates the file's source text in the salsa db,
280 /// runs definition collection, and ingests the resulting stub slice.
281 /// Triggers stub loading on first call. Also updates the cache's reverse-
282 /// dependency graph for `file` so cross-file invalidation stays correct
283 /// across incremental edits — without rebuilding the graph from scratch.
284 ///
285 /// If `file` was previously ingested, its old definitions and reference
286 /// locations are removed first so renames / deletions don't leave stale
287 /// state in the codebase. (Without this, long-running sessions would
288 /// accumulate dead reference-location entries indefinitely.)
289 pub fn ingest_file(&self, file: Arc<str>, source: Arc<str>) {
290 self.ensure_stubs_loaded();
291 {
292 let mut guard = self.shared_db.salsa.lock();
293 let (ref mut db, _) = *guard;
294 db.remove_file_definitions(file.as_ref());
295 }
296 let _file_defs = self
297 .shared_db
298 .collect_and_ingest_file(file.clone(), source.as_ref());
299 self.update_reverse_deps_for(&file);
300 }
301
302 /// Drop a file's contribution to the session: codebase definitions,
303 /// reference locations, salsa input handle, cache entry, and outgoing
304 /// reverse-dependency edges. Cache entries of *dependent* files are
305 /// also evicted (cross-file invalidation).
306 ///
307 /// Use this when a file is closed by the consumer, or before a re-ingest
308 /// of substantially changed content. (Plain re-ingest via
309 /// [`Self::ingest_file`] also drops old definitions, but does not
310 /// remove the salsa input handle — call this for full cleanup.)
311 pub fn invalidate_file(&self, file: &str) {
312 {
313 let mut guard = self.shared_db.salsa.lock();
314 let (ref mut db, ref mut files) = *guard;
315 db.remove_file_definitions(file);
316 files.remove(file);
317 }
318 if let Some(cache) = &self.cache {
319 cache.update_reverse_deps_for_file(file, &HashSet::new());
320 cache.evict_with_dependents(&[file.to_string()]);
321 }
322 }
323
324 /// Number of files currently tracked in this session's salsa input set.
325 /// Stable across reads; useful for diagnostics and memory bounds checks.
326 pub fn tracked_file_count(&self) -> usize {
327 let guard = self.shared_db.salsa.lock();
328 guard.1.len()
329 }
330
331 // -----------------------------------------------------------------------
332 // Read-only codebase queries
333 //
334 // All take a brief lock to clone the db, then run the lookup against the
335 // owned snapshot — concurrent edits proceed without blocking.
336 // -----------------------------------------------------------------------
337
338 /// Resolve a top-level symbol (class or function) to its declaration
339 /// location. Powers go-to-definition.
340 ///
341 /// Returns:
342 /// - `Ok(Location)` — symbol found with a source location
343 /// - `Err(NotFound)` — no such symbol in the codebase
344 /// - `Err(NoSourceLocation)` — symbol exists but has no recorded span
345 /// (e.g. some stub-only declarations)
346 pub fn definition_of(
347 &self,
348 symbol: &crate::Symbol,
349 ) -> Result<mir_codebase::storage::Location, crate::SymbolLookupError> {
350 let db = self.snapshot_db();
351 match symbol {
352 crate::Symbol::Class(fqcn) => {
353 let node = db
354 .lookup_class_node(fqcn.as_ref())
355 .filter(|n| n.active(&db))
356 .ok_or(crate::SymbolLookupError::NotFound)?;
357 node.location(&db)
358 .ok_or(crate::SymbolLookupError::NoSourceLocation)
359 }
360 crate::Symbol::Function(fqn) => {
361 let node = db
362 .lookup_function_node(fqn.as_ref())
363 .filter(|n| n.active(&db))
364 .ok_or(crate::SymbolLookupError::NotFound)?;
365 node.location(&db)
366 .ok_or(crate::SymbolLookupError::NoSourceLocation)
367 }
368 crate::Symbol::Method { class, name }
369 | crate::Symbol::Property { class, name }
370 | crate::Symbol::ClassConstant { class, name } => {
371 crate::db::member_location_via_db(&db, class, name)
372 .ok_or(crate::SymbolLookupError::NotFound)
373 }
374 crate::Symbol::GlobalConstant(_) => {
375 // Global constants don't currently store location info
376 Err(crate::SymbolLookupError::NoSourceLocation)
377 }
378 }
379 }
380
381 /// Hover information for a symbol: type, docstring, and definition location.
382 ///
383 /// Use [`crate::FileAnalysis::symbol_at`] to find the symbol at a cursor
384 /// position, then build a [`crate::Symbol`] from its `kind`. This method
385 /// assembles the displayable hover data.
386 ///
387 /// Returns `Err(NotFound)` if the symbol doesn't exist. May still return
388 /// `Ok` with `docstring: None` or `definition: None` if those specific
389 /// pieces aren't available.
390 pub fn hover(
391 &self,
392 symbol: &crate::Symbol,
393 ) -> Result<crate::HoverInfo, crate::SymbolLookupError> {
394 use mir_types::{Atomic, Union};
395 let db = self.snapshot_db();
396 match symbol {
397 crate::Symbol::Function(fqn) => {
398 let node = db
399 .lookup_function_node(fqn.as_ref())
400 .filter(|n| n.active(&db))
401 .ok_or(crate::SymbolLookupError::NotFound)?;
402 let ty = node
403 .return_type(&db)
404 .map(|t| (*t).clone())
405 .unwrap_or_else(Union::mixed);
406 let docstring = node.docstring(&db).map(|s| s.to_string());
407 let definition = node.location(&db);
408 Ok(crate::HoverInfo {
409 ty,
410 docstring,
411 definition,
412 })
413 }
414 crate::Symbol::Method { class, name } => {
415 let node = db
416 .lookup_method_node(class.as_ref(), name.as_ref())
417 .filter(|n| n.active(&db))
418 .ok_or(crate::SymbolLookupError::NotFound)?;
419 let ty = node
420 .return_type(&db)
421 .map(|t| (*t).clone())
422 .unwrap_or_else(Union::mixed);
423 let docstring = node.docstring(&db).map(|s| s.to_string());
424 let definition = node.location(&db);
425 Ok(crate::HoverInfo {
426 ty,
427 docstring,
428 definition,
429 })
430 }
431 crate::Symbol::Class(fqcn) => {
432 let node = db
433 .lookup_class_node(fqcn.as_ref())
434 .filter(|n| n.active(&db))
435 .ok_or(crate::SymbolLookupError::NotFound)?;
436 let ty = Union::single(Atomic::TNamedObject {
437 fqcn: fqcn.clone(),
438 type_params: Vec::new(),
439 });
440 let definition = node.location(&db);
441 Ok(crate::HoverInfo {
442 ty,
443 docstring: None,
444 definition,
445 })
446 }
447 crate::Symbol::Property { class, name } => {
448 let node = db
449 .lookup_property_node(class.as_ref(), name.as_ref())
450 .filter(|n| n.active(&db))
451 .ok_or(crate::SymbolLookupError::NotFound)?;
452 let ty = node.ty(&db).unwrap_or_else(Union::mixed);
453 let definition = node.location(&db);
454 Ok(crate::HoverInfo {
455 ty,
456 docstring: None,
457 definition,
458 })
459 }
460 crate::Symbol::ClassConstant { class, name } => {
461 let node = db
462 .lookup_class_constant_node(class.as_ref(), name.as_ref())
463 .filter(|n| n.active(&db))
464 .ok_or(crate::SymbolLookupError::NotFound)?;
465 let ty = node.ty(&db);
466 let definition = node.location(&db);
467 Ok(crate::HoverInfo {
468 ty,
469 docstring: None,
470 definition,
471 })
472 }
473 crate::Symbol::GlobalConstant(fqn) => {
474 let node = db
475 .lookup_global_constant_node(fqn.as_ref())
476 .filter(|n| n.active(&db))
477 .ok_or(crate::SymbolLookupError::NotFound)?;
478 let ty = node.ty(&db);
479 Ok(crate::HoverInfo {
480 ty,
481 docstring: None,
482 definition: None,
483 })
484 }
485 }
486 }
487
488 /// Every recorded reference to `symbol` with its source location as a Range.
489 /// Use [`crate::FileAnalysis::symbol_at`] to find the symbol at a cursor,
490 /// build a [`crate::Symbol`] from it, and pass it here.
491 pub fn references_to(&self, symbol: &crate::Symbol) -> Vec<(Arc<str>, crate::Range)> {
492 let db = self.snapshot_db();
493 let key = symbol.codebase_key();
494 db.reference_locations(&key)
495 .into_iter()
496 .map(|(file, line, col_start, col_end)| {
497 let range = crate::Range {
498 start: crate::Position {
499 line,
500 column: col_start as u32,
501 },
502 end: crate::Position {
503 line,
504 column: col_end as u32,
505 },
506 };
507 (file, range)
508 })
509 .collect()
510 }
511
512 /// All declarations defined in `file` as a **hierarchical tree**.
513 ///
514 /// Classes/interfaces/traits/enums are returned with their methods,
515 /// properties, and constants nested in `children`. Top-level functions
516 /// and constants are returned with empty `children`.
517 pub fn document_symbols(&self, file: &str) -> Vec<crate::symbol::DocumentSymbol> {
518 use crate::symbol::{DocumentSymbol, DocumentSymbolKind};
519
520 let db = self.snapshot_db();
521 let mut out = Vec::new();
522 for symbol in db.symbols_defined_in_file(file) {
523 // Try class side first — covers Class / Interface / Trait / Enum.
524 if let Some(class_node) = db.lookup_class_node(symbol.as_ref()) {
525 if !class_node.active(&db) {
526 continue;
527 }
528 let (kind, is_enum) = crate::db::class_kind_via_db(&db, symbol.as_ref())
529 .map(|k| {
530 let kind = if k.is_interface {
531 DocumentSymbolKind::Interface
532 } else if k.is_trait {
533 DocumentSymbolKind::Trait
534 } else if k.is_enum {
535 DocumentSymbolKind::Enum
536 } else {
537 DocumentSymbolKind::Class
538 };
539 (kind, k.is_enum)
540 })
541 .unwrap_or((DocumentSymbolKind::Class, false));
542
543 // Build children: methods, properties, and class constants.
544 let mut children: Vec<DocumentSymbol> = Vec::new();
545 for m in db.class_own_methods(symbol.as_ref()) {
546 if !m.active(&db) {
547 continue;
548 }
549 children.push(DocumentSymbol {
550 name: m.name(&db),
551 kind: DocumentSymbolKind::Method,
552 location: m.location(&db),
553 children: Vec::new(),
554 });
555 }
556 for p in db.class_own_properties(symbol.as_ref()) {
557 if !p.active(&db) {
558 continue;
559 }
560 children.push(DocumentSymbol {
561 name: p.name(&db),
562 kind: DocumentSymbolKind::Property,
563 location: p.location(&db),
564 children: Vec::new(),
565 });
566 }
567 for c in db.class_own_constants(symbol.as_ref()) {
568 if !c.active(&db) {
569 continue;
570 }
571 let const_kind = if is_enum {
572 DocumentSymbolKind::EnumCase
573 } else {
574 DocumentSymbolKind::Constant
575 };
576 children.push(DocumentSymbol {
577 name: c.name(&db),
578 kind: const_kind,
579 location: c.location(&db),
580 children: Vec::new(),
581 });
582 }
583
584 out.push(DocumentSymbol {
585 name: symbol.clone(),
586 kind,
587 location: class_node.location(&db),
588 children,
589 });
590 continue;
591 }
592 if let Some(fn_node) = db.lookup_function_node(symbol.as_ref()) {
593 if !fn_node.active(&db) {
594 continue;
595 }
596 out.push(DocumentSymbol {
597 name: symbol.clone(),
598 kind: DocumentSymbolKind::Function,
599 location: fn_node.location(&db),
600 children: Vec::new(),
601 });
602 continue;
603 }
604 // Constants and other top-level declarations: emit with no
605 // location info; consumers can still surface them in an outline.
606 out.push(DocumentSymbol {
607 name: symbol,
608 kind: DocumentSymbolKind::Constant,
609 location: None,
610 children: Vec::new(),
611 });
612 }
613 out
614 }
615
616 /// Returns `true` if a function with `fqn` is registered and active in
617 /// the codebase. Case-insensitive lookup with optional leading backslash.
618 pub fn contains_function(&self, fqn: &str) -> bool {
619 let db = self.snapshot_db();
620 db.lookup_function_node(fqn).is_some_and(|n| n.active(&db))
621 }
622
623 /// Returns `true` if a class / interface / trait / enum with `fqcn` is
624 /// registered and active in the codebase.
625 pub fn contains_class(&self, fqcn: &str) -> bool {
626 let db = self.snapshot_db();
627 db.lookup_class_node(fqcn).is_some_and(|n| n.active(&db))
628 }
629
630 /// Returns `true` if `class` has a method named `name` registered. Method
631 /// names are matched case-insensitively (PHP method dispatch semantics).
632 pub fn contains_method(&self, class: &str, name: &str) -> bool {
633 let db = self.snapshot_db();
634 let name_lower = name.to_ascii_lowercase();
635 db.lookup_method_node(class, &name_lower)
636 .is_some_and(|n| n.active(&db))
637 }
638
639 /// Try to resolve `fqcn` via PSR-4 and ingest the mapped file, returning
640 /// a detailed outcome distinguishing "already there" from "freshly loaded".
641 pub fn lazy_load_class_with_outcome(&self, fqcn: &str) -> crate::LazyLoadOutcome {
642 if self.contains_class(fqcn) {
643 return crate::LazyLoadOutcome::AlreadyLoaded;
644 }
645 if self.lazy_load_class(fqcn) {
646 crate::LazyLoadOutcome::Loaded
647 } else {
648 crate::LazyLoadOutcome::NotResolvable
649 }
650 }
651
652 /// Try to resolve `fqcn` via the configured [`crate::ClassResolver`] and
653 /// ingest the mapped file.
654 ///
655 /// This is the LSP-friendly lazy-load entry point: the analyzer never
656 /// touches `vendor/` on its own, but consumers can ask it to resolve
657 /// individual symbols on demand. Designed to be called when a diagnostic
658 /// would otherwise report `UndefinedClass`.
659 ///
660 /// Returns `true` if either the class is already known or a matching
661 /// file was found and successfully ingested. Returns `false` if:
662 /// - No resolver is configured (neither `with_psr4` nor `with_class_resolver` called),
663 /// - The resolver can't map `fqcn` to a file,
664 /// - The file can't be read, or
665 /// - The file parsed but did not define `fqcn`.
666 pub fn lazy_load_class(&self, fqcn: &str) -> bool {
667 if self.contains_class(fqcn) {
668 return true;
669 }
670 let Some(resolver) = &self.resolver else {
671 return false;
672 };
673 let Some(path) = resolver.resolve(fqcn) else {
674 return false;
675 };
676 let Ok(src) = std::fs::read_to_string(&path) else {
677 return false;
678 };
679 let file: Arc<str> = Arc::from(path.to_string_lossy().as_ref());
680 self.ingest_file(file, Arc::from(src));
681 self.contains_class(fqcn)
682 }
683
684 /// Lazy-load every class transitively reachable from `fqcn` via parent /
685 /// interface / trait edges. Useful when the consumer needs not just the
686 /// requested class but enough of its inheritance chain to type-check
687 /// member access.
688 ///
689 /// Walks at most `max_depth` levels (default in batch analysis is 10).
690 /// Returns the number of classes successfully loaded (not counting
691 /// `fqcn` itself if it was already present).
692 pub fn lazy_load_class_transitive(&self, fqcn: &str, max_depth: usize) -> usize {
693 if self.resolver.is_none() {
694 return 0;
695 }
696 let mut loaded = 0;
697 let mut frontier: Vec<String> = vec![fqcn.to_string()];
698 let mut visited: std::collections::HashSet<String> = std::collections::HashSet::new();
699
700 for _ in 0..max_depth {
701 if frontier.is_empty() {
702 break;
703 }
704 let mut next: Vec<String> = Vec::new();
705 for name in frontier.drain(..) {
706 if !visited.insert(name.clone()) {
707 continue;
708 }
709 let was_present = self.contains_class(&name);
710 let resolved = self.lazy_load_class(&name);
711 if resolved && !was_present {
712 loaded += 1;
713 // Walk the new class's parent / interfaces / traits.
714 let db = self.snapshot_db();
715 if let Some(node) = db.lookup_class_node(&name) {
716 if let Some(parent) = node.parent(&db) {
717 next.push(parent.to_string());
718 }
719 for iface in node.interfaces(&db).iter() {
720 next.push(iface.to_string());
721 }
722 for tr in node.traits(&db).iter() {
723 next.push(tr.to_string());
724 }
725 for ext in node.extends(&db).iter() {
726 next.push(ext.to_string());
727 }
728 }
729 }
730 }
731 frontier = next;
732 }
733 loaded
734 }
735
736 /// Retrieve the source text the session has registered for `file`, if
737 /// any. Returns `None` when the file has never been ingested. Used by
738 /// the parallel re-analysis path to re-feed dependents to Pass 2 without
739 /// the caller having to track sources independently.
740 pub fn source_of(&self, file: &str) -> Option<Arc<str>> {
741 let guard = self.shared_db.salsa.lock();
742 let (ref db, ref files) = *guard;
743 let sf = files.get(file)?;
744 Some(sf.text(db))
745 }
746
747 /// Re-analyze every transitive dependent of `file` in parallel.
748 ///
749 /// When the user saves a file that other files depend on (e.g. editing
750 /// a base class, an interface, or a trait), those dependents may have
751 /// new diagnostics. This method computes them in parallel using rayon
752 /// and returns the per-file analysis results so the LSP server can
753 /// publish updated diagnostics in one batch.
754 ///
755 /// Source text for dependents is retrieved from the session's salsa
756 /// inputs (set by previous `ingest_file` calls) — the caller doesn't
757 /// need to track or re-read files. Files for which the session has no
758 /// source are silently skipped (returns the analyzable subset).
759 ///
760 /// Does not run inference sweeps. For full-fidelity cross-file inferred
761 /// return types, follow up with [`Self::run_inference_sweep`] over the
762 /// affected file set.
763 pub fn analyze_dependents_of(&self, file: &str) -> Vec<(Arc<str>, crate::FileAnalysis)> {
764 use rayon::prelude::*;
765
766 // Phase 1: compute dependents + gather their sources outside the
767 // analysis loop so each worker has everything it needs.
768 let dependents = self.dependency_graph().transitive_dependents(file);
769 if dependents.is_empty() {
770 return Vec::new();
771 }
772 let with_source: Vec<(Arc<str>, Arc<str>)> = dependents
773 .into_iter()
774 .filter_map(|path| {
775 let arc_path: Arc<str> = Arc::from(path.as_str());
776 let src = self.source_of(&path)?;
777 Some((arc_path, src))
778 })
779 .collect();
780 if with_source.is_empty() {
781 return Vec::new();
782 }
783
784 // Phase 2: parallel parse + analyze. Each rayon worker gets its own
785 // database snapshot via FileAnalyzer; writes are isolated to the
786 // session's canonical db (none happen here since we only run Pass 2).
787 with_source
788 .into_par_iter()
789 .map(|(file, source)| {
790 let arena = crate::arena::create_parse_arena(source.len());
791 let parsed = php_rs_parser::parse(&arena, source.as_ref());
792 let analyzer = crate::FileAnalyzer::new(self);
793 let analysis = analyzer.analyze(
794 file.clone(),
795 source.as_ref(),
796 &parsed.program,
797 &parsed.source_map,
798 );
799 (file, analysis)
800 })
801 .collect()
802 }
803
804 /// FQCNs that `file` imports via `use` statements but that aren't yet
805 /// loaded in the session.
806 ///
807 /// Designed as the input to background prefetching: after the LSP server
808 /// ingests an open buffer, it can call this and lazy-load the returned
809 /// FQCNs on a worker thread so the user's first Cmd+Click into vendor
810 /// code doesn't pay the file-read+parse cost.
811 ///
812 /// Returns an empty Vec if the file hasn't been ingested or has no
813 /// unresolved imports.
814 pub fn pending_lazy_loads(&self, file: &str) -> Vec<Arc<str>> {
815 let db = self.snapshot_db();
816 let imports = db.file_imports(file);
817 if imports.is_empty() {
818 return Vec::new();
819 }
820 let mut out = Vec::new();
821 for fqcn in imports.values() {
822 // Cheap check: skip imports already in the codebase.
823 if db.lookup_class_node(fqcn).is_some_and(|n| n.active(&db)) {
824 continue;
825 }
826 // Only worth queueing if the resolver could in principle find it.
827 if let Some(resolver) = &self.resolver {
828 if resolver.resolve(fqcn).is_some() {
829 out.push(Arc::from(fqcn.as_str()));
830 }
831 }
832 }
833 out
834 }
835
836 /// Convenience: synchronously lazy-load every import of `file` that
837 /// isn't already in the codebase. Returns the number successfully loaded.
838 ///
839 /// For non-blocking prefetch, call this from a worker thread:
840 ///
841 /// ```ignore
842 /// let s = session.clone(); // AnalysisSession is wrapped in Arc by callers
843 /// std::thread::spawn(move || {
844 /// s.prefetch_imports(&file_path);
845 /// });
846 /// ```
847 ///
848 /// Internally walks the inheritance chain of each loaded class to a
849 /// shallow depth so member access on imported types type-checks without
850 /// the user paying the cost on their first navigation.
851 pub fn prefetch_imports(&self, file: &str) -> usize {
852 let pending = self.pending_lazy_loads(file);
853 let mut loaded = 0;
854 for fqcn in pending {
855 // Use the transitive walker with a small depth so we pick up
856 // parent classes / interfaces needed for member resolution, but
857 // don't recursively pull in the entire vendor tree.
858 loaded += self.lazy_load_class_transitive(&fqcn, 2);
859 }
860 loaded
861 }
862
863 /// All class / interface / trait / enum FQCNs currently known to the
864 /// session, each paired with the file that defines them when available.
865 ///
866 /// Use this to build workspace-wide views (outline, fuzzy search, etc.).
867 /// Consumers implement their own search/match logic on top — the analyzer
868 /// only exposes the iterator.
869 pub fn all_classes(&self) -> Vec<(Arc<str>, Option<mir_codebase::storage::Location>)> {
870 let db = self.snapshot_db();
871 db.active_class_node_fqcns()
872 .into_iter()
873 .filter_map(|fqcn| {
874 let node = db.lookup_class_node(fqcn.as_ref())?;
875 if !node.active(&db) {
876 return None;
877 }
878 Some((fqcn, node.location(&db)))
879 })
880 .collect()
881 }
882
883 /// All global function FQNs currently known to the session, each paired
884 /// with their declaration location when available.
885 pub fn all_functions(&self) -> Vec<(Arc<str>, Option<mir_codebase::storage::Location>)> {
886 let db = self.snapshot_db();
887 db.active_function_node_fqns()
888 .into_iter()
889 .filter_map(|fqn| {
890 let node = db.lookup_function_node(fqn.as_ref())?;
891 if !node.active(&db) {
892 return None;
893 }
894 Some((fqn, node.location(&db)))
895 })
896 .collect()
897 }
898
899 /// Compute `file`'s outgoing dependency edges and update the cache's
900 /// reverse-dep graph in place. No-op if no cache is configured.
901 fn update_reverse_deps_for(&self, file: &str) {
902 let Some(cache) = self.cache.as_deref() else {
903 return;
904 };
905 let db = self.snapshot_db();
906 let targets = file_outgoing_dependencies(&db, file);
907 cache.update_reverse_deps_for_file(file, &targets);
908 }
909
910 /// Cross-file inference sweep. For each `(file, source)` pair, runs the
911 /// Pass 2 inference-only mode on a cloned db (parallel via rayon), then
912 /// commits the collected inferred return types to the canonical db.
913 ///
914 /// Call this on idle / save / explicit user request, **not** on every
915 /// keystroke — [`crate::FileAnalyzer::analyze`] deliberately skips
916 /// inference sweep on the hot path. Files whose source contains parse
917 /// errors are silently skipped.
918 pub fn run_inference_sweep(&self, files: &[(Arc<str>, Arc<str>)]) {
919 self.ensure_stubs_loaded();
920
921 // The priming db lives only inside `gather_inferred_types`. After it
922 // returns, all rayon-clone references to the salsa storage are dropped
923 // — required so that the subsequent `commit_inferred_return_types`
924 // call (which calls salsa's `cancel_others`) doesn't deadlock waiting
925 // for outstanding db references.
926 let (functions, methods) =
927 gather_inferred_types(self.snapshot_db(), files, self.php_version);
928
929 let mut guard = self.shared_db.salsa.lock();
930 guard.0.commit_inferred_return_types(functions, methods);
931 }
932
933 /// File dependency graph: which files depend on which other files.
934 /// Used for incremental invalidation in LSP servers and build systems.
935 ///
936 /// Dependencies are computed from:
937 /// - Direct imports (use statements)
938 /// - Class inheritance (parent classes, interfaces, traits)
939 pub fn dependency_graph(&self) -> crate::DependencyGraph {
940 let db = self.snapshot_db();
941
942 // Get all files from the session's salsa database
943 let guard = self.shared_db.salsa.lock();
944 let all_files: Vec<String> = guard.1.keys().map(|f| f.as_ref().to_string()).collect();
945 drop(guard);
946
947 // Build forward dependency graph: file → [files it depends on]
948 let mut dependencies: std::collections::HashMap<String, Vec<String>> =
949 std::collections::HashMap::new();
950 for file in &all_files {
951 let deps = file_outgoing_dependencies(&db, file);
952 dependencies.insert(file.clone(), deps.into_iter().collect());
953 }
954
955 // Build reverse dependency graph: file → [files that depend on it]
956 let mut dependents: std::collections::HashMap<String, Vec<String>> =
957 std::collections::HashMap::new();
958 for (file, deps) in &dependencies {
959 for dep in deps {
960 dependents
961 .entry(dep.clone())
962 .or_default()
963 .push(file.clone());
964 }
965 }
966
967 // Sort for determinism
968 for deps in dependents.values_mut() {
969 deps.sort();
970 }
971
972 crate::DependencyGraph {
973 dependencies,
974 dependents,
975 }
976 }
977}
978
979/// Drive Pass 2 inference-only mode in parallel across `files`, accumulating
980/// inferred function and method return types. The `db_priming` MirDb is
981/// consumed (cloned per spawned task and dropped on return), so the caller's
982/// canonical db can subsequently take exclusive access without deadlock.
983///
984/// Crate-internal so [`crate::project::ProjectAnalyzer`] can use the same
985/// deadlock-safe helper for its lazy-load reanalysis sweep.
986#[allow(clippy::type_complexity)]
987pub(crate) fn gather_inferred_types(
988 db_priming: MirDb,
989 files: &[(Arc<str>, Arc<str>)],
990 php_version: PhpVersion,
991) -> (
992 Vec<(Arc<str>, mir_types::Union)>,
993 Vec<(Arc<str>, Arc<str>, mir_types::Union)>,
994) {
995 use crate::pass2::Pass2Driver;
996 use mir_types::Union;
997
998 type Functions = Vec<(Arc<str>, Union)>;
999 type Methods = Vec<(Arc<str>, Arc<str>, Union)>;
1000 let functions: Arc<Mutex<Functions>> = Arc::new(Mutex::new(Vec::new()));
1001 let methods: Arc<Mutex<Methods>> = Arc::new(Mutex::new(Vec::new()));
1002
1003 rayon::in_place_scope(|s| {
1004 for (file, source) in files {
1005 let db = db_priming.clone();
1006 let functions = Arc::clone(&functions);
1007 let methods = Arc::clone(&methods);
1008 let file = file.clone();
1009 let source = source.clone();
1010
1011 s.spawn(move |_| {
1012 let arena = crate::arena::create_parse_arena(source.len());
1013 let parsed = php_rs_parser::parse(&arena, source.as_ref());
1014 if !parsed.errors.is_empty() {
1015 return;
1016 }
1017 let driver = Pass2Driver::new_inference_only(&db as &dyn MirDatabase, php_version);
1018 driver.analyze_bodies(&parsed.program, file, source.as_ref(), &parsed.source_map);
1019 let inferred = driver.take_inferred_types();
1020 {
1021 let mut f = functions.lock();
1022 f.extend(inferred.functions);
1023 }
1024 {
1025 let mut m = methods.lock();
1026 m.extend(inferred.methods);
1027 }
1028 });
1029 }
1030 });
1031
1032 let functions = Arc::try_unwrap(functions)
1033 .map(|m| m.into_inner())
1034 .unwrap_or_else(|arc| arc.lock().clone());
1035 let methods = Arc::try_unwrap(methods)
1036 .map(|m| m.into_inner())
1037 .unwrap_or_else(|arc| arc.lock().clone());
1038
1039 (functions, methods)
1040}
1041
1042/// Compute the set of files `file` depends on: defining files of its imports,
1043/// plus parent / interfaces / traits' defining files for any classes declared
1044/// in `file`. Self-edges are excluded.
1045fn file_outgoing_dependencies(db: &dyn MirDatabase, file: &str) -> HashSet<String> {
1046 let mut targets: HashSet<String> = HashSet::new();
1047
1048 let mut add_target = |symbol: &str| {
1049 if let Some(defining_file) = db.symbol_defining_file(symbol) {
1050 let def = defining_file.as_ref().to_string();
1051 if def != file {
1052 targets.insert(def);
1053 }
1054 }
1055 };
1056
1057 let imports = db.file_imports(file);
1058 for fqcn in imports.values() {
1059 add_target(fqcn);
1060 }
1061
1062 for fqcn in db.symbols_defined_in_file(file) {
1063 let Some(node) = db.lookup_class_node(fqcn.as_ref()) else {
1064 continue;
1065 };
1066 if let Some(parent) = node.parent(db) {
1067 add_target(parent.as_ref());
1068 }
1069 for iface in node.interfaces(db).iter() {
1070 add_target(iface.as_ref());
1071 }
1072 for tr in node.traits(db).iter() {
1073 add_target(tr.as_ref());
1074 }
1075 }
1076
1077 targets
1078}