Skip to main content

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}