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. `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 mir_codebase::{FileId, FileIdMap};
20
21use crate::analyzer_db::AnalyzerDb;
22use crate::cache::AnalysisCache;
23use crate::composer::Psr4Map;
24use crate::db::{MirDatabase, MirDbStorage, RefLoc};
25use crate::php_version::PhpVersion;
26
27/// Long-lived analysis context. Owns the salsa database and tracks which
28/// stubs have been loaded.
29///
30/// Cheap to clone the inner db for parallel reads; writes funnel through
31/// [`Self::ingest_file`], [`Self::invalidate_file`], and the crate-internal
32/// [`Self::with_db_mut`].
33#[derive(Clone)]
34pub struct AnalysisSession {
35 /// Shared database management (salsa, file registry, stub tracking).
36 pub(crate) db: Arc<AnalyzerDb>,
37 pub(crate) cache: Option<Arc<AnalysisCache>>,
38 /// PSR-4 / Composer autoload map. Retained alongside `resolver` so the
39 /// `psr4()` accessor can still return a typed `Psr4Map` for callers that
40 /// need Composer-specific data (project_files / vendor_files / etc.).
41 pub(crate) psr4: Option<Arc<Psr4Map>>,
42 /// Generic class resolver used for on-demand lazy loading. When `psr4`
43 /// is set via [`Self::with_psr4`], this is populated with the same map
44 /// re-typed as `dyn ClassResolver`. Consumers can also supply their own
45 /// resolver via [`Self::with_class_resolver`] without going through
46 /// Composer.
47 resolver: Option<Arc<dyn crate::ClassResolver>>,
48 pub(crate) php_version: PhpVersion,
49 pub(crate) user_stub_files: Vec<PathBuf>,
50 pub(crate) user_stub_dirs: Vec<PathBuf>,
51 /// Path ↔ FileId mapping shared with `reverse_dep_map`.
52 file_id_map: Arc<RwLock<FileIdMap>>,
53 /// In-memory reverse dependency map: target_file → set of files that
54 /// depend on it. Always maintained (not gated on disk cache presence),
55 /// enabling `reanalyze_dependents` and `dependency_graph()` without a
56 /// disk cache. Updated in `ingest_file` and `invalidate_file`.
57 reverse_dep_map: Arc<RwLock<HashMap<FileId, HashSet<FileId>>>>,
58 /// Tracks symbols that were previously defined in a file but have since
59 /// been removed (deleted or renamed). When `ingest_file` detects that
60 /// a symbol disappears, it records it here so `dependency_graph()` can
61 /// still produce edges to files that reference the now-gone symbol.
62 ///
63 /// Keyed by the file that used to define the symbols. Symbols are removed
64 /// from the set when re-added to the same file on a subsequent ingest.
65 /// The set may contain symbols with no current referencers; those are
66 /// harmless — the `symbol_referencers_of` lookup returns empty.
67 stale_defined_symbols: Arc<RwLock<HashMap<String, HashSet<Arc<str>>>>>,
68 /// Negative cache: FQCNs that `load_class` already failed on.
69 /// The value is the resolver-mapped path (when known) so eviction on
70 /// `set_file_text` / `ingest_file` is a path equality check rather than
71 /// re-running the resolver per entry. `None` means the resolver itself
72 /// couldn't map the FQCN; those entries survive file edits (no source
73 /// change makes a never-resolvable name resolvable).
74 /// Bounded to `UNRESOLVABLE_CACHE_CAP`; clears on overflow.
75 unresolvable_fqcns: UnresolvableCache,
76 /// Pluggable source-text provider for lazy-load. Defaults to filesystem
77 /// reads ([`crate::FsSourceProvider`]); LSPs swap in a VFS-backed
78 /// implementation so unsaved buffers override on-disk content.
79 source_provider: Arc<dyn crate::SourceProvider>,
80}
81
82/// FQCN → optional resolver-mapped path. See the field doc on
83/// `AnalysisSession::unresolvable_fqcns`.
84type UnresolvableCache = Arc<RwLock<HashMap<Arc<str>, Option<Arc<str>>>>>;
85
86/// Cap on the negative-resolution cache. Sized to accommodate a large
87/// workspace's worth of genuinely-missing references without unbounded
88/// growth. On overflow the cache is cleared; the cost is a few extra
89/// resolver calls until it re-fills.
90const UNRESOLVABLE_CACHE_CAP: usize = 10_000;
91
92impl AnalysisSession {
93 /// Create a session targeting the given PHP language version.
94 pub fn new(php_version: PhpVersion) -> Self {
95 let db = Arc::new(AnalyzerDb::new());
96 db.salsa
97 .write()
98 .set_php_version(Arc::from(php_version.to_string().as_str()));
99 Self {
100 db,
101 cache: None,
102 psr4: None,
103 resolver: None,
104 php_version,
105 user_stub_files: Vec::new(),
106 user_stub_dirs: Vec::new(),
107 file_id_map: Arc::new(RwLock::new(FileIdMap::new())),
108 reverse_dep_map: Arc::new(RwLock::new(HashMap::default())),
109 stale_defined_symbols: Arc::new(RwLock::new(HashMap::default())),
110 unresolvable_fqcns: Arc::new(RwLock::new(HashMap::default())),
111 source_provider: Arc::new(crate::FsSourceProvider),
112 }
113 }
114
115 /// Swap in a custom [`crate::SourceProvider`]. LSPs install a VFS-backed
116 /// provider here so the analyzer reads from unsaved editor buffers
117 /// instead of disk.
118 pub fn with_source_provider(mut self, provider: Arc<dyn crate::SourceProvider>) -> Self {
119 self.source_provider = provider;
120 self
121 }
122
123 /// Attach a pre-built [`AnalysisCache`] (the body-analysis issue cache) and
124 /// open a sibling definition [`StubSlice`] cache under the same root, so
125 /// callers using this builder get the same speedup as `with_cache_dir`.
126 ///
127 /// Rebuilds the shared database to attach the definition cache — call
128 /// **before** any file is ingested. A debug assertion catches misuse.
129 ///
130 /// [`StubSlice`]: mir_codebase::storage::StubSlice
131 pub fn with_cache(mut self, cache: Arc<AnalysisCache>) -> Self {
132 debug_assert_eq!(
133 self.db.source_file_count(),
134 0,
135 "AnalysisSession::with_cache must be called before any file is ingested"
136 );
137 let dir = cache.cache_dir().to_path_buf();
138 self.db = Arc::new(AnalyzerDb::new().with_cache_dir(&dir));
139 self.db
140 .salsa
141 .write()
142 .set_php_version(Arc::from(self.php_version.to_string().as_str()));
143 self.cache = Some(cache);
144 self
145 }
146
147 /// Convenience: open a disk-backed cache at `cache_dir` and attach it.
148 ///
149 /// Attaches both the body-analysis issue cache ([`AnalysisCache`]) and the
150 /// definition [`StubSlice`] cache to the shared database. Builds a fresh
151 /// [`AnalyzerDb`] internally — call **before** any file is ingested. A
152 /// debug assertion catches misuse.
153 ///
154 /// [`StubSlice`]: mir_codebase::storage::StubSlice
155 pub fn with_cache_dir(mut self, cache_dir: &std::path::Path) -> Self {
156 debug_assert_eq!(
157 self.db.source_file_count(),
158 0,
159 "AnalysisSession::with_cache_dir must be called before any file is ingested"
160 );
161 self.db = Arc::new(AnalyzerDb::new().with_cache_dir(cache_dir));
162 self.db
163 .salsa
164 .write()
165 .set_php_version(Arc::from(self.php_version.to_string().as_str()));
166 self.cache = Some(Arc::new(AnalysisCache::open(cache_dir)));
167 self
168 }
169
170 /// Attach a Composer autoload map (PSR-4, PSR-0, classmap, files).
171 /// Sets the same map as the active [`crate::ClassResolver`] so
172 /// [`Self::load_class`] works out of the box.
173 pub fn with_psr4(mut self, map: Arc<Psr4Map>) -> Self {
174 let user_resolver: Arc<dyn crate::ClassResolver> = map.clone();
175 // Wrap with stub awareness so `find_class_like` / `resolve_fqcn_to_path`
176 // can map built-in PHP class FQCNs (`ArrayObject`, `Exception`, …)
177 // to their stub virtual paths.
178 let resolver: Arc<dyn crate::ClassResolver> = Arc::new(crate::ChainedClassResolver::new(
179 user_resolver,
180 Arc::new(crate::StubClassResolver),
181 ));
182 self.psr4 = Some(map);
183 self.resolver = Some(resolver.clone());
184 // Mirror into MirDbStorage so salsa-tracked resolver queries
185 // (`db::resolve_fqcn_to_path`) see the same resolver and are
186 // invalidated on swap.
187 self.db.salsa.write().set_resolver(Some(resolver));
188 self
189 }
190
191 /// Attach a generic class resolver for projects that don't use Composer
192 /// (WordPress, Drupal, custom autoloaders, workspace-walk indexes).
193 /// Replaces any previously-set Composer-backed resolver. Automatically
194 /// wrapped with stub awareness so PHP built-ins remain resolvable.
195 pub fn with_class_resolver(mut self, resolver: Arc<dyn crate::ClassResolver>) -> Self {
196 let wrapped: Arc<dyn crate::ClassResolver> = Arc::new(crate::ChainedClassResolver::new(
197 resolver,
198 Arc::new(crate::StubClassResolver),
199 ));
200 self.db.salsa.write().set_resolver(Some(wrapped.clone()));
201 self.resolver = Some(wrapped);
202 self
203 }
204
205 pub fn with_user_stubs(mut self, files: Vec<PathBuf>, dirs: Vec<PathBuf>) -> Self {
206 self.user_stub_files = files;
207 self.user_stub_dirs = dirs;
208 self
209 }
210
211 pub fn php_version(&self) -> PhpVersion {
212 self.php_version
213 }
214
215 pub fn cache(&self) -> Option<&AnalysisCache> {
216 self.cache.as_deref()
217 }
218
219 pub fn psr4(&self) -> Option<&Psr4Map> {
220 self.psr4.as_deref()
221 }
222
223 /// Deprecated — stub loading is now fully lazy per-AST.
224 ///
225 /// This is an alias for [`Self::ensure_all_stubs`] kept for API
226 /// compatibility. Internal analysis paths use [`Self::prepare_ast_for_analysis`]
227 /// which loads only the stubs referenced by the file under analysis.
228 #[deprecated(note = "use ensure_all_stubs() or ensure_stubs_for_ast() instead")]
229 pub fn ensure_essential_stubs(&self) {
230 self.ensure_all_stubs();
231 }
232
233 /// Load every embedded PHP stub plus any configured user stubs.
234 /// Use for batch tools (CLI, full project analysis) where comprehensive
235 /// symbol coverage matters more than cold-start latency.
236 pub fn ensure_all_stubs(&self) {
237 let paths: Vec<&'static str> = crate::stubs::stub_files().iter().map(|&(p, _)| p).collect();
238 self.db.ingest_stub_paths(&paths, self.php_version);
239 self.ensure_user_stubs_loaded();
240 }
241
242 /// Ensure the embedded stub that defines `name` (a function) is ingested.
243 /// Returns `true` when a matching stub exists (whether or not it was
244 /// already loaded), `false` when `name` isn't a known PHP built-in.
245 ///
246 /// Most callers should use [`Self::ensure_stubs_for_ast`] instead —
247 /// it auto-discovers needed stubs from a parsed file.
248 #[doc(hidden)]
249 pub fn ensure_stub_for_function(&self, name: &str) -> bool {
250 match crate::stubs::stub_path_for_function(name) {
251 Some(path) => {
252 self.db.ingest_stub_paths(&[path], self.php_version);
253 true
254 }
255 None => false,
256 }
257 }
258
259 /// Ensure the embedded stub that defines `fqcn` (a class / interface /
260 /// trait / enum) is ingested. Case-insensitive lookup with optional
261 /// leading backslash.
262 ///
263 /// Most callers should use [`Self::ensure_stubs_for_ast`] instead.
264 #[doc(hidden)]
265 pub fn ensure_stub_for_class(&self, fqcn: &str) -> bool {
266 match crate::stubs::stub_path_for_class(fqcn) {
267 Some(path) => {
268 self.db.ingest_stub_paths(&[path], self.php_version);
269 true
270 }
271 None => false,
272 }
273 }
274
275 /// Ensure the embedded stub that defines `name` (a constant) is ingested.
276 ///
277 /// Most callers should use [`Self::ensure_stubs_for_ast`] instead.
278 #[doc(hidden)]
279 pub fn ensure_stub_for_constant(&self, name: &str) -> bool {
280 match crate::stubs::stub_path_for_constant(name) {
281 Some(path) => {
282 self.db.ingest_stub_paths(&[path], self.php_version);
283 true
284 }
285 None => false,
286 }
287 }
288
289 /// Number of distinct embedded stubs currently ingested into the session.
290 /// Useful for diagnostics and bench reporting.
291 pub fn loaded_stub_count(&self) -> usize {
292 self.db.loaded_stubs.lock().len()
293 }
294
295 /// Auto-discover and ingest the embedded stubs needed to cover every
296 /// built-in PHP function / class / constant referenced by `source`.
297 ///
298 /// Used by [`crate::FileAnalyzer::analyze`] to keep essentials-only mode
299 /// correct without forcing callers to enumerate which stubs they need.
300 /// Idempotent — already-loaded stubs are skipped via [`Self::loaded_stubs`].
301 ///
302 /// The discovery scan is a coarse identifier sweep (see
303 /// [`crate::stubs::collect_referenced_builtin_paths`]) — it may pull in
304 /// a slightly larger set than the file strictly needs, but never misses
305 /// a referenced built-in. Cost is sub-millisecond per file.
306 ///
307 /// Fast path: if every embedded stub is already loaded (e.g. after a
308 /// batch tool called [`Self::ensure_all_stubs`]), the source scan
309 /// is skipped entirely.
310 pub fn ensure_stubs_for_source(&self, source: &str) {
311 // Cheap check first: skip the scan entirely when we already know we
312 // have everything. Avoids a ~50-500µs source walk on every analyze
313 // call in batch / warm-session scenarios.
314 {
315 let loaded = self.db.loaded_stubs.lock();
316 if loaded.len() >= crate::stubs::stub_files().len() {
317 return;
318 }
319 }
320 let paths = crate::stubs::collect_referenced_builtin_paths(source);
321 if paths.is_empty() {
322 return;
323 }
324 self.db.ingest_stub_paths(&paths, self.php_version);
325 }
326
327 /// Discover and ingest stubs by walking the parsed AST of a PHP file.
328 ///
329 /// Similar to [`Self::ensure_stubs_for_source`], but takes an already-parsed
330 /// AST instead of raw source text. Produces zero false positives since it
331 /// only extracts identifiers from actual AST nodes (not from strings or
332 /// comments). Preferred over `ensure_stubs_for_source` when the AST is
333 /// already available (e.g., in [`crate::FileAnalyzer`]).
334 ///
335 /// Idempotent and skips the scan if all stubs are already loaded.
336 pub fn ensure_stubs_for_ast(&self, program: &php_ast::owned::Program) {
337 {
338 let loaded = self.db.loaded_stubs.lock();
339 if loaded.len() >= crate::stubs::stub_files().len() {
340 return;
341 }
342 }
343 let paths = crate::stubs::collect_referenced_builtin_paths_from_ast(program);
344 if paths.is_empty() {
345 return;
346 }
347 self.db.ingest_stub_paths(&paths, self.php_version);
348 }
349
350 /// Returns true if this session has a configured class resolver
351 /// (typically a PSR-4 / classmap autoloader chained with the stub
352 /// resolver). Used by `FileAnalyzer` to skip the AST-scan preload
353 /// when no resolver is wired up.
354 pub fn has_resolver(&self) -> bool {
355 self.resolver.is_some()
356 }
357
358 /// Run both pre-passes (builtin-stub loading and PSR-4 class preloading)
359 /// in one call. Replaces the two separate `ensure_stubs_for_ast` /
360 /// `preload_psr4_classes_for_ast` calls at every `FileAnalyzer::analyze`
361 /// site.
362 pub fn prepare_ast_for_analysis(&self, program: &php_ast::owned::Program, file: &str) {
363 self.ensure_stubs_for_ast(program);
364 self.priority_index_for_ast(program, file);
365 }
366
367 /// Priority-index the classes directly referenced by `file`'s AST.
368 ///
369 /// In the eager-static-input model the background indexer
370 /// ([`Self::index_batch`]) walks the whole vendor tree, but it may not have
371 /// reached every file the open buffer references yet. To avoid a transient
372 /// false `UndefinedClass` during the warm-up window, this **reorders** that
373 /// static work: it resolves the buffer's *direct* class references and
374 /// loads any not-yet-indexed ones immediately, jumping them to the front of
375 /// the queue.
376 ///
377 /// This is bounded by the number of distinct direct references in **one**
378 /// file — no transitive BFS, no depth/total budget, no pinning. Inheritance
379 /// ancestors and signature types of those classes are picked up by the
380 /// background walk (or, for navigation, by [`Self::hover`] /
381 /// [`Self::definition_of`]). Because `bump_workspace_revision` no longer
382 /// nulls the workspace index singleton, each [`Self::load_class`] here costs
383 /// only a resolver lookup + parse (or cache hit) + one tier-aware merge,
384 /// invalidating just the actively-analyzed file's memo once — not the whole
385 /// cache. Once background indexing completes this is a no-op (every
386 /// reference already resolves).
387 pub fn priority_index_for_ast(&self, program: &php_ast::owned::Program, file: &str) {
388 if self.resolver.is_none() {
389 return;
390 }
391 let refs = collect_class_refs_from_ast(program);
392 if refs.is_empty() {
393 return;
394 }
395 // Resolve names against the file's namespace/imports up front, then
396 // drop the snapshot before loading (which mutates inputs).
397 let resolved: Vec<String> = {
398 let db = self.snapshot_db();
399 refs.into_iter()
400 .map(|raw| crate::db::resolve_name(&db, file, &raw))
401 .collect()
402 };
403 for fqcn in resolved {
404 // load_class is a no-op when the class is already indexed (the
405 // common case once the background walk has passed this file).
406 self.load_class(&fqcn);
407 }
408 }
409
410 fn ensure_user_stubs_loaded(&self) {
411 self.db
412 .ingest_user_stubs(&self.user_stub_files, &self.user_stub_dirs);
413 }
414
415 /// Cheap clone of the salsa db for a read-only query. The lock is held
416 /// only for the duration of the clone, so concurrent readers never
417 /// serialize on each other or on writes for longer than the clone itself.
418 ///
419 /// **Internal API — exposes Salsa types.** Subject to change without
420 /// notice. Public consumers should use the typed query methods
421 /// ([`Self::definition_of`], [`Self::hover`], etc.) instead.
422 #[doc(hidden)]
423 pub fn snapshot_db(&self) -> MirDbStorage {
424 self.db.snapshot_db()
425 }
426
427 /// Commit a batch of reference locations from a db snapshot into the
428 /// session's shared maps. Called by [`crate::FileAnalyzer`] and
429 /// [`crate::BatchFileAnalyzer`] after parallel body analysis to flush the pending
430 /// buffers that accumulate in worker db clones.
431 pub(crate) fn commit_ref_locs_batch(&self, locs: Vec<RefLoc>) {
432 if locs.is_empty() {
433 return;
434 }
435 let guard = self.db.salsa.read();
436 guard.commit_reference_locations_batch(locs);
437 }
438
439 /// Run a closure with read access to a database snapshot.
440 ///
441 /// **Internal API — exposes Salsa types.** Subject to change without
442 /// notice.
443 #[doc(hidden)]
444 pub fn read<R>(&self, f: impl FnOnce(&dyn MirDatabase) -> R) -> R {
445 let db = self.snapshot_db();
446 f(&db)
447 }
448
449 /// definition-collection ingestion. Updates the file's source text in the salsa db,
450 /// runs definition collection, and ingests the resulting stub slice.
451 /// Triggers stub loading on first call. Also updates the cache's reverse-
452 /// dependency graph for `file` so cross-file invalidation stays correct
453 /// across incremental edits — without rebuilding the graph from scratch.
454 ///
455 /// If `file` was previously ingested, its old definitions and reference
456 /// locations are removed first so renames / deletions don't leave stale
457 /// state in the codebase. (Without this, long-running sessions would
458 /// accumulate dead reference-location entries indefinitely.)
459 pub fn ingest_file(&self, file: Arc<str>, source: Arc<str>) {
460 self.ensure_all_stubs();
461
462 // Snapshot symbols defined before clearing — O(symbols_in_file) with forward index.
463 let old_symbols: HashSet<Arc<str>> = {
464 let guard = self.db.salsa.read();
465 guard.file_defined_symbols(file.as_ref())
466 };
467
468 {
469 let mut guard = self.db.salsa.write();
470 guard.remove_file_definitions(file.as_ref());
471 }
472 let _file_defs =
473 self.db
474 .collect_and_ingest_file(file.clone(), source.as_ref(), self.php_version);
475
476 // Snapshot symbols after ingesting — O(symbols_in_file).
477 let new_symbols: HashSet<Arc<str>> = {
478 let guard = self.db.salsa.read();
479 guard.file_defined_symbols(file.as_ref())
480 };
481
482 // Symbols removed from this file must be tracked so dependency_graph()
483 // can still produce edges to files referencing the now-gone symbols.
484 let deleted: Vec<Arc<str>> = old_symbols.difference(&new_symbols).cloned().collect();
485 let re_added: Vec<Arc<str>> = new_symbols.difference(&old_symbols).cloned().collect();
486 if !deleted.is_empty() || !re_added.is_empty() {
487 let mut stale = self.stale_defined_symbols.write();
488 let entry = stale.entry(file.as_ref().to_string()).or_default();
489 for sym in deleted {
490 entry.insert(sym);
491 }
492 for sym in &re_added {
493 entry.remove(sym);
494 }
495 if entry.is_empty() {
496 stale.remove(file.as_ref());
497 }
498 }
499
500 self.update_reverse_deps_for(&file);
501 // Evict cached analysis results for files that depend on this one so
502 // that the next re_analyze_file call re-analyses them rather than
503 // replaying a stale cache entry. Mirrors the eviction in
504 // `re_analyze_file` (batch.rs) but applies to the ingest path used by
505 // LSP servers that edit a single file without re-analysing it.
506 if let Some(cache) = self.cache.as_deref() {
507 cache.evict_with_dependents(&[file.to_string()]);
508 }
509 // Only evict cache entries whose resolver-mapped path equals this
510 // file. FQCNs the resolver can't map (psr4 miss) stay cached — no
511 // ingest could change their fate. Avoids the per-keystroke storm
512 // where wholesale clearing forces every unresolved FQCN to re-hit
513 // the resolver on the next FileAnalyzer iteration.
514 self.evict_unresolvable_for_file(&file);
515
516 // If the workspace symbol index singleton has already been built, keep
517 // it consistent with this edit *incrementally*: subtract the file's old
518 // declarations and add its new ones (tier-aware). Body-only edits are a
519 // no-op inside `update_workspace_index_for_file` (name-only
520 // FileDeclarations equality → no singleton write → the HIGH-durability
521 // dep does not invalidate body-analysis memos). Only the rare ambiguous
522 // case (a removed name still declared by another file, where this file
523 // owned the winning entry) falls back to a full O(N) rebuild.
524 {
525 let mut guard = self.db.salsa.write();
526 if guard.workspace_symbol_index_singleton().is_some() {
527 if let Some(sf) = guard.lookup_source_file(file.as_ref()) {
528 if !guard.update_workspace_index_for_file(sf) {
529 guard.rebuild_workspace_symbol_index();
530 }
531 }
532 }
533 }
534 }
535
536 /// Register `source` as the text of `file` in the salsa input layer **without**
537 /// parsing or running definition collection.
538 ///
539 /// This is the LSP-friendly bulk-population entry point: after a workspace
540 /// scan, callers can feed every discovered file's text to the session
541 /// cheaply (an Arc clone plus a HashMap insert per file). Name resolution
542 /// then happens on demand via [`Self::load_class`], which reads
543 /// the file from disk through the configured [`crate::ClassResolver`] and
544 /// runs definition collection lazily when a class FQCN actually needs to resolve.
545 ///
546 /// Contrast with [`Self::ingest_file`], which eagerly parses, runs definition collection,
547 /// and populates the symbol index. Use `ingest_file` for files the user is
548 /// actively editing (where in-memory text diverges from disk); use
549 /// `set_file_text` for files known only through the workspace scan.
550 ///
551 /// Clears the negative cache: a previously-unresolvable FQCN may now
552 /// resolve if its defining file is among the newly-registered set.
553 pub fn set_file_text(&self, file: Arc<str>, source: Arc<str>) {
554 {
555 let mut guard = self.db.salsa.write();
556 guard.upsert_source_file(file.clone(), source);
557 }
558 self.evict_unresolvable_for_file(&file);
559 }
560
561 /// Bulk-register vendor / library files with HIGH salsa durability.
562 ///
563 /// HIGH-durability files are not expected to change during the session.
564 /// When a LOW-durability project file is edited, salsa can skip O(N)
565 /// dependency verification for every HIGH-durability file, reducing
566 /// `workspace_symbol_index` re-verification cost to O(project files only).
567 ///
568 /// Definition collection runs lazily on first symbol access; no parsing at call time.
569 pub fn set_vendor_files<I>(&self, files: I)
570 where
571 I: IntoIterator<Item = (Arc<str>, Arc<str>)>,
572 {
573 let mut guard = self.db.salsa.write();
574 for (file, source) in files {
575 guard.upsert_source_file_with_durability(file, source, salsa::Durability::HIGH);
576 }
577 }
578
579 /// Build or refresh the `WorkspaceSymbolIndexSingleton` from all currently
580 /// registered files.
581 ///
582 /// After this call, `find_class_like`, `find_function`, and
583 /// `find_global_constant` read `singleton.index(db)` — a single
584 /// `Durability::HIGH` tracked dep — instead of recomputing the full
585 /// O(N_files) dep list via `workspace_symbol_index`. On subsequent
586 /// LOW-durability (project-file) body edits the dep short-circuits in O(1).
587 ///
588 /// Call this once after all vendor + stub + project files have been
589 /// ingested (end of workspace warm-up). Also called automatically by
590 /// [`Self::ingest_file`] when a file's declared names change.
591 pub fn rebuild_workspace_symbol_index(&self) {
592 self.db.salsa.write().rebuild_workspace_symbol_index();
593 }
594
595 /// Bulk variant of [`Self::set_file_text`]. Acquires the salsa write lock
596 /// once for the entire batch instead of once per file.
597 ///
598 /// The intended LSP scan loop is:
599 /// ```text
600 /// let files: Vec<_> = walk_workspace()
601 /// .map(|path| (path, fs::read(&path).unwrap()))
602 /// .collect();
603 /// session.set_workspace_files(files);
604 /// ```
605 /// After this call, every file's source text is known to salsa. No
606 /// parsing has happened yet — Definition collection runs per file on the first
607 /// `load_class` that needs to consult it.
608 pub fn set_workspace_files<I>(&self, files: I)
609 where
610 I: IntoIterator<Item = (Arc<str>, Arc<str>)>,
611 {
612 let registered_paths: Vec<Arc<str>> = {
613 let mut guard = self.db.salsa.write();
614 files
615 .into_iter()
616 .map(|(file, source)| {
617 guard.upsert_source_file(file.clone(), source);
618 file
619 })
620 .collect()
621 };
622 if !registered_paths.is_empty() && self.resolver.is_some() {
623 self.evict_unresolvable_for_files(®istered_paths);
624 }
625 }
626
627 /// The workspace generation epoch — the rust-analyzer-style "are we up to
628 /// date" counter. Bumped whenever a file is added or removed. A consumer
629 /// records this alongside the diagnostics it publishes for a file; when the
630 /// value later advances (background indexing registered more files), those
631 /// files become candidates for re-analysis + re-publish.
632 pub fn index_generation(&self) -> u64 {
633 self.db.salsa.read().workspace_revision_value()
634 }
635
636 /// Index one bounded chunk of `(path, text)` files — the chunked background
637 /// indexing primitive.
638 ///
639 /// For each chunk this: (1) registers the files as `Durability::HIGH` salsa
640 /// inputs in one short write window, (2) parses them to prime the in-process
641 /// and on-disk declaration caches (in parallel when `parallelism ==
642 /// `[`IndexParallelism::Rayon`]; sequentially for wasm / single-thread
643 /// consumers), and (3) merges their declarations into the workspace symbol
644 /// index singleton **incrementally** (no full rebuild) so partially-indexed
645 /// symbols resolve immediately.
646 ///
647 /// The library spawns no thread: the consumer pumps chunks from its own
648 /// driver (LSP worker thread, or one chunk per wasm event-loop tick),
649 /// re-checking higher-priority work between calls. `cancel` is honoured at
650 /// chunk boundaries so an edit can abandon queued indexing cheaply.
651 ///
652 /// **Contract:** index the workspace *incrementally* through this method;
653 /// don't bulk-register the entire file set up front and then index — the
654 /// first call lazily seeds the singleton from the currently-registered set
655 /// (built-in stubs + this chunk), so keeping that initial set small keeps
656 /// the first call cheap. Call [`Self::finalize_index`] once after the last
657 /// chunk to reconcile authoritatively.
658 ///
659 /// **Responsiveness:** parsing / declaration collection happens off the
660 /// salsa write lock (on a snapshot); only the cheap symbol-map merge runs
661 /// under the lock, so the write window per chunk is short and an interactive
662 /// read on another thread blocks at most that long. Note that, per salsa's
663 /// snapshot model, a *cancellable query* in flight on another thread (e.g.
664 /// `hover`, `definition_of`, `FileAnalyzer::analyze`) when this batch takes
665 /// the write lock may unwind with `salsa::Cancelled`; a multi-threaded
666 /// consumer should catch that and retry the request (the rust-analyzer
667 /// pattern). A single-threaded consumer that interleaves requests *between*
668 /// `index_batch` calls never observes cancellation.
669 pub fn index_batch(
670 &self,
671 files: &[(Arc<str>, Arc<str>)],
672 parallelism: crate::IndexParallelism,
673 cancel: &crate::IndexCancel,
674 ) -> crate::IndexBatchOutcome {
675 if files.is_empty() || cancel.is_cancelled() {
676 return crate::IndexBatchOutcome {
677 registered: 0,
678 cancelled: cancel.is_cancelled(),
679 generation: self.index_generation(),
680 };
681 }
682 self.ensure_all_stubs();
683
684 // 1. Register the chunk as HIGH-durability inputs — one short write
685 // window, then release the lock so interactive requests interleave.
686 let sources: Vec<crate::db::SourceFile> = {
687 let mut guard = self.db.salsa.write();
688 files
689 .iter()
690 .map(|(file, source)| {
691 guard.upsert_source_file_with_durability(
692 file.clone(),
693 source.clone(),
694 salsa::Durability::HIGH,
695 )
696 })
697 .collect()
698 };
699 let registered = sources.len();
700
701 if cancel.is_cancelled() {
702 return crate::IndexBatchOutcome {
703 registered,
704 cancelled: true,
705 generation: self.index_generation(),
706 };
707 }
708
709 // Is this the seed chunk (no singleton yet)? If so we must collect decls
710 // for the whole currently-registered set (stubs + this chunk); otherwise
711 // just this chunk.
712 let seed = self
713 .db
714 .salsa
715 .read()
716 .workspace_symbol_index_singleton()
717 .is_none();
718 let snap = self.db.snapshot_db();
719 let to_collect: Vec<crate::db::SourceFile> = if seed {
720 snap.all_source_files()
721 } else {
722 sources.clone()
723 };
724
725 // 2. Collect per-file declarations OFF the write lock (on a snapshot).
726 // This is where parsing happens — crucially NOT while holding the
727 // write lock, so concurrent interactive reads are not blocked for the
728 // parse duration. Also primes the shared parse/disk caches.
729 let collect_one = |db: &crate::db::MirDbStorage, sf: crate::db::SourceFile| {
730 (sf, crate::db::collect_file_declarations(db, sf))
731 };
732 let decls: Vec<(crate::db::SourceFile, crate::db::FileDeclarations)> =
733 if parallelism == crate::IndexParallelism::Rayon {
734 use rayon::prelude::*;
735 to_collect
736 .par_iter()
737 .map_with(snap.clone(), |db, &sf| collect_one(db, sf))
738 .collect()
739 } else {
740 to_collect
741 .iter()
742 .map(|&sf| collect_one(&snap, sf))
743 .collect()
744 };
745 drop(snap);
746
747 if cancel.is_cancelled() {
748 return crate::IndexBatchOutcome {
749 registered,
750 cancelled: true,
751 generation: self.index_generation(),
752 };
753 }
754
755 // 3. Apply to the singleton under a SHORT write window — only cheap map
756 // construction / merge runs here (no parse).
757 {
758 let mut guard = self.db.salsa.write();
759 if guard.workspace_symbol_index_singleton().is_none() {
760 guard.build_workspace_index_from_decls(decls);
761 } else {
762 guard.merge_precomputed_into_workspace_index(&decls);
763 }
764 }
765
766 crate::IndexBatchOutcome {
767 registered,
768 cancelled: cancel.is_cancelled(),
769 generation: self.index_generation(),
770 }
771 }
772
773 /// Authoritative full rebuild of the workspace symbol index. Call once
774 /// after the consumer has pumped every [`Self::index_batch`] chunk (end of
775 /// warm-up) to reconcile the incrementally-merged index against the full
776 /// registered set. Cheap after indexing — every file's declarations are
777 /// already cached.
778 pub fn finalize_index(&self) {
779 self.db.salsa.write().rebuild_workspace_symbol_index();
780 }
781
782 /// Drop a file's contribution to the session: codebase definitions,
783 /// reference locations, salsa input handle, cache entry, and outgoing
784 /// reverse-dependency edges. Cache entries of *dependent* files are
785 /// also evicted (cross-file invalidation).
786 ///
787 /// Use this when a file is closed by the consumer, or before a re-ingest
788 /// of substantially changed content. (Plain re-ingest via
789 /// [`Self::ingest_file`] also drops old definitions, but does not
790 /// remove the salsa input handle — call this for full cleanup.)
791 pub fn invalidate_file(&self, file: &str) {
792 {
793 let mut guard = self.db.salsa.write();
794 guard.remove_file_definitions(file);
795 guard.remove_source_file(file);
796 }
797 // Remove this file's outgoing deps from the in-memory reverse dep map.
798 self.update_in_memory_reverse_deps(file, &HashSet::default());
799 // Clear stale symbol tracking for this file — it's fully gone.
800 self.stale_defined_symbols.write().remove(file);
801 if let Some(cache) = &self.cache {
802 cache.update_reverse_deps_for_file(file, &HashSet::default());
803 cache.evict_with_dependents(&[file.to_string()]);
804 }
805 // The file is gone; cache entries that previously mapped to it stay
806 // unresolvable until the file (or another with matching symbols) is
807 // ingested again. Selective evict mirrors the ingest path.
808 self.evict_unresolvable_for_file(file);
809 // Vendor files are static in the eager-index model — closing a project
810 // buffer never evicts them (no per-file pinning). Memory is bounded by
811 // the LRU on `collect_file_definitions` and the parse cache instead.
812 }
813
814 /// Number of files currently tracked in this session's salsa input set.
815 /// Stable across reads; useful for diagnostics and memory bounds checks.
816 pub fn tracked_file_count(&self) -> usize {
817 let guard = self.db.salsa.read();
818 guard.source_file_count()
819 }
820
821 // -----------------------------------------------------------------------
822 // Read-only codebase queries
823 //
824 // All take a brief lock to clone the db, then run the lookup against the
825 // owned snapshot — concurrent edits proceed without blocking.
826 // -----------------------------------------------------------------------
827
828 /// Resolve a top-level symbol (class or function) to its declaration
829 /// location. Powers go-to-definition.
830 ///
831 /// **Side effects:** if the symbol isn't yet known, this may invoke the
832 /// configured [`crate::SourceProvider`] to fault in additional files and
833 /// mutate the salsa input set. Use [`Self::definition_of_cached`] for a
834 /// pure variant that only consults already-loaded state.
835 ///
836 /// Returns:
837 /// - `Ok(Location)` — symbol found with a source location
838 /// - `Err(NotFound)` — no such symbol in the codebase
839 /// - `Err(NoSourceLocation)` — symbol exists but has no recorded span
840 /// (e.g. some stub-only declarations)
841 pub fn definition_of(
842 &self,
843 symbol: &crate::Name,
844 ) -> Result<mir_types::Location, crate::SymbolLookupError> {
845 // Trigger any necessary lazy-load mutations before snapshotting.
846 match symbol {
847 crate::Name::Class(fqcn) => {
848 let _ = self.load_class(fqcn.as_ref());
849 }
850 crate::Name::Function(fqn) => {
851 let _ = self.load_class(fqn.as_ref());
852 }
853 crate::Name::Method { class, .. }
854 | crate::Name::Property { class, .. }
855 | crate::Name::ClassConstant { class, .. } => {
856 let _ = self.load_class(class.as_ref());
857 }
858 _ => {}
859 }
860 self.definition_of_cached(symbol)
861 }
862
863 /// Pure variant of [`Self::definition_of`]. Never invokes the
864 /// [`crate::SourceProvider`] and never mutates salsa inputs; resolves
865 /// only against state already loaded by `set_file_text` / `ingest_file`.
866 /// Returns `Err(NotFound)` when the symbol isn't in the loaded set, even
867 /// if a resolver could in principle map it.
868 pub fn definition_of_cached(
869 &self,
870 symbol: &crate::Name,
871 ) -> Result<mir_types::Location, crate::SymbolLookupError> {
872 let db = self.snapshot_db();
873 match symbol {
874 crate::Name::Class(fqcn) => {
875 let here = crate::db::Fqcn::from_str(&db, fqcn.as_ref());
876 let class = crate::db::find_class_like(&db, here)
877 .ok_or(crate::SymbolLookupError::NotFound)?;
878 class
879 .location()
880 .cloned()
881 .ok_or(crate::SymbolLookupError::NoSourceLocation)
882 }
883 crate::Name::Function(fqn) => {
884 let here = crate::db::Fqcn::from_str(&db, fqn.as_ref());
885 let f = crate::db::find_function(&db, here)
886 .ok_or(crate::SymbolLookupError::NotFound)?;
887 f.location
888 .clone()
889 .ok_or(crate::SymbolLookupError::NoSourceLocation)
890 }
891 crate::Name::Method { class, name }
892 | crate::Name::Property { class, name }
893 | crate::Name::ClassConstant { class, name } => {
894 crate::db::member_location(&db, class, name)
895 .ok_or(crate::SymbolLookupError::NotFound)
896 }
897 crate::Name::GlobalConstant(_) => Err(crate::SymbolLookupError::NoSourceLocation),
898 }
899 }
900
901 /// Hover information for a symbol: type, docstring, and definition location.
902 ///
903 /// Use [`crate::FileAnalysis::symbol_at`] to find the symbol at a cursor
904 /// position, then build a [`crate::Name`] from its `kind`. This method
905 /// assembles the displayable hover data.
906 ///
907 /// **Side effects:** when `symbol`'s owning class isn't yet loaded, this
908 /// may invoke the configured [`crate::SourceProvider`] to fault in
909 /// dependencies. Use [`Self::hover_cached`] for a pure variant.
910 ///
911 /// Returns `Err(NotFound)` if the symbol doesn't exist. May still return
912 /// `Ok` with `docstring: None` or `definition: None` if those specific
913 /// pieces aren't available.
914 pub fn hover(
915 &self,
916 symbol: &crate::Name,
917 ) -> Result<crate::HoverInfo, crate::SymbolLookupError> {
918 // Trigger lazy loading for class-rooted symbols before snapshotting.
919 // No-op when the class is already known; ensures inherited member
920 // lookups have the chain present.
921 match symbol {
922 crate::Name::Class(fqcn) => {
923 self.load_class(fqcn.as_ref());
924 }
925 crate::Name::Method { class, .. }
926 | crate::Name::Property { class, .. }
927 | crate::Name::ClassConstant { class, .. } => {
928 // Fault in the owning class for navigation if the background
929 // indexer hasn't reached it yet. Its inheritance ancestors
930 // resolve through the (eagerly-built) workspace symbol index.
931 self.load_class(class.as_ref());
932 }
933 _ => {}
934 }
935 self.hover_cached(symbol)
936 }
937
938 /// Pure variant of [`Self::hover`]. Never invokes the
939 /// [`crate::SourceProvider`]; consults only the already-loaded db.
940 pub fn hover_cached(
941 &self,
942 symbol: &crate::Name,
943 ) -> Result<crate::HoverInfo, crate::SymbolLookupError> {
944 use mir_types::{Atomic, Type};
945 let db = self.snapshot_db();
946 match symbol {
947 crate::Name::Function(fqn) => {
948 let here = crate::db::Fqcn::from_str(&db, fqn.as_ref());
949 let f = crate::db::find_function(&db, here)
950 .ok_or(crate::SymbolLookupError::NotFound)?;
951 let ty = f
952 .return_type
953 .as_deref()
954 .cloned()
955 .unwrap_or_else(Type::mixed);
956 let docstring = f.docstring.as_ref().map(|s| s.to_string());
957 Ok(crate::HoverInfo {
958 ty,
959 docstring,
960 definition: f.location.clone(),
961 })
962 }
963 crate::Name::Method { class, name } => {
964 let here = crate::db::Fqcn::from_str(&db, class.as_ref());
965 let (_, m) = crate::db::find_method_in_chain(&db, here, name)
966 .ok_or(crate::SymbolLookupError::NotFound)?;
967 let ty = m
968 .return_type
969 .as_deref()
970 .cloned()
971 .unwrap_or_else(Type::mixed);
972 let docstring = m.docstring.as_ref().map(|s| s.to_string());
973 Ok(crate::HoverInfo {
974 ty,
975 docstring,
976 definition: m.location.clone(),
977 })
978 }
979 crate::Name::Class(fqcn) => {
980 let here = crate::db::Fqcn::from_str(&db, fqcn.as_ref());
981 let class = crate::db::find_class_like(&db, here)
982 .ok_or(crate::SymbolLookupError::NotFound)?;
983 let ty = Type::single(Atomic::TNamedObject {
984 fqcn: mir_types::Name::from(fqcn.as_ref()),
985 type_params: mir_types::union::empty_type_params(),
986 });
987 Ok(crate::HoverInfo {
988 ty,
989 docstring: None,
990 definition: class.location().cloned(),
991 })
992 }
993 crate::Name::Property { class, name } => {
994 let here = crate::db::Fqcn::from_str(&db, class.as_ref());
995 let (_, p) = crate::db::find_property_in_chain(&db, here, name)
996 .ok_or(crate::SymbolLookupError::NotFound)?;
997 let ty = p.ty.as_deref().cloned().unwrap_or_else(Type::mixed);
998 Ok(crate::HoverInfo {
999 ty,
1000 docstring: None,
1001 definition: p.location.clone(),
1002 })
1003 }
1004 crate::Name::ClassConstant { class, name } => {
1005 let here = crate::db::Fqcn::from_str(&db, class.as_ref());
1006 let (_, c) = crate::db::find_class_constant_in_chain(&db, here, name)
1007 .ok_or(crate::SymbolLookupError::NotFound)?;
1008 Ok(crate::HoverInfo {
1009 ty: c.ty.clone(),
1010 docstring: None,
1011 definition: c.location.clone(),
1012 })
1013 }
1014 crate::Name::GlobalConstant(fqn) => {
1015 let here = crate::db::Fqcn::from_str(&db, fqn.as_ref());
1016 let ty = crate::db::find_global_constant(&db, here)
1017 .ok_or(crate::SymbolLookupError::NotFound)?;
1018 Ok(crate::HoverInfo {
1019 ty: (*ty).clone(),
1020 docstring: None,
1021 definition: None,
1022 })
1023 }
1024 }
1025 }
1026
1027 /// Raw reference locations indexed by string symbol key, kept for tests
1028 /// that use the legacy stringly-typed API. Prefer [`Self::references_to`]
1029 /// with a typed [`crate::Name`].
1030 #[doc(hidden)]
1031 pub fn reference_locations(&self, symbol: &str) -> Vec<(Arc<str>, u32, u16, u16)> {
1032 use crate::db::MirDatabase;
1033 let db = self.snapshot_db();
1034 db.reference_locations(symbol)
1035 }
1036
1037 /// Every recorded reference to `symbol` with its source location as a Range.
1038 /// Use [`crate::FileAnalysis::symbol_at`] to find the symbol at a cursor,
1039 /// build a [`crate::Name`] from it, and pass it here.
1040 pub fn references_to(&self, symbol: &crate::Name) -> Vec<(Arc<str>, crate::Range)> {
1041 let db = self.snapshot_db();
1042 let key = symbol.codebase_key();
1043 db.reference_locations(&key)
1044 .into_iter()
1045 .map(|(file, line, col_start, col_end)| {
1046 let range = crate::Range {
1047 start: crate::Position {
1048 line,
1049 column: col_start as u32,
1050 },
1051 end: crate::Position {
1052 line,
1053 column: col_end as u32,
1054 },
1055 };
1056 (file, range)
1057 })
1058 .collect()
1059 }
1060
1061 /// Class-level issues (inheritance violations, abstract-method gaps, override
1062 /// incompatibilities) for the given set of files.
1063 ///
1064 /// These checks are cross-file by nature and are not emitted by
1065 /// [`crate::FileAnalyzer::analyze`]. Call this after ingesting or
1066 /// re-analyzing a file and its dependents to get the full diagnostic picture.
1067 ///
1068 /// Circular-inheritance checks always run against the full workspace graph
1069 /// regardless of the `files` filter — a cycle is a workspace-wide problem.
1070 pub fn class_issues(&self, files: &[Arc<str>]) -> Vec<crate::Issue> {
1071 let db = self.snapshot_db();
1072 let file_set: HashSet<Arc<str>> = files.iter().cloned().collect();
1073 let file_data: Vec<(Arc<str>, Arc<str>)> = files
1074 .iter()
1075 .filter_map(|f| Some((f.clone(), self.source_of(f)?)))
1076 .collect();
1077 crate::class::ClassAnalyzer::with_files(&db, file_set, &file_data).analyze_all()
1078 }
1079
1080 /// All declarations defined in `file` as a **hierarchical tree**.
1081 ///
1082 /// Classes/interfaces/traits/enums are returned with their methods,
1083 /// properties, and constants nested in `children`. Top-level functions
1084 /// and constants are returned with empty `children`.
1085 pub fn document_symbols(&self, file: &str) -> Vec<crate::symbol::DocumentSymbol> {
1086 use crate::symbol::{DeclarationKind, DocumentSymbol};
1087
1088 let db = self.snapshot_db();
1089 let Some(sf) = db.lookup_source_file(file) else {
1090 return Vec::new();
1091 };
1092 let defs = crate::db::collect_file_definitions(&db, sf);
1093 let mut out: Vec<DocumentSymbol> = Vec::new();
1094
1095 let class_children =
1096 |methods: &indexmap::IndexMap<Arc<str>, Arc<mir_codebase::storage::MethodDef>>,
1097 props: Option<&indexmap::IndexMap<Arc<str>, mir_codebase::storage::PropertyDef>>,
1098 consts: &indexmap::IndexMap<Arc<str>, mir_codebase::storage::ConstantDef>,
1099 is_enum: bool|
1100 -> Vec<DocumentSymbol> {
1101 let mut out: Vec<DocumentSymbol> = Vec::new();
1102 for (_, m) in methods.iter() {
1103 out.push(DocumentSymbol {
1104 name: m.name.clone(),
1105 kind: DeclarationKind::Method,
1106 location: m.location.clone(),
1107 children: Vec::new(),
1108 });
1109 }
1110 if let Some(props) = props {
1111 for (_, p) in props.iter() {
1112 out.push(DocumentSymbol {
1113 name: p.name.clone(),
1114 kind: DeclarationKind::Property,
1115 location: p.location.clone(),
1116 children: Vec::new(),
1117 });
1118 }
1119 }
1120 let const_kind = if is_enum {
1121 DeclarationKind::EnumCase
1122 } else {
1123 DeclarationKind::Constant
1124 };
1125 for (_, c) in consts.iter() {
1126 out.push(DocumentSymbol {
1127 name: c.name.clone(),
1128 kind: const_kind,
1129 location: c.location.clone(),
1130 children: Vec::new(),
1131 });
1132 }
1133 out
1134 };
1135
1136 for c in defs.slice.classes.iter() {
1137 out.push(DocumentSymbol {
1138 name: c.fqcn.clone(),
1139 kind: DeclarationKind::Class,
1140 location: c.location.clone(),
1141 children: class_children(
1142 &c.own_methods,
1143 Some(&c.own_properties),
1144 &c.own_constants,
1145 false,
1146 ),
1147 });
1148 }
1149 for i in defs.slice.interfaces.iter() {
1150 out.push(DocumentSymbol {
1151 name: i.fqcn.clone(),
1152 kind: DeclarationKind::Interface,
1153 location: i.location.clone(),
1154 children: class_children(&i.own_methods, None, &i.own_constants, false),
1155 });
1156 }
1157 for t in defs.slice.traits.iter() {
1158 out.push(DocumentSymbol {
1159 name: t.fqcn.clone(),
1160 kind: DeclarationKind::Trait,
1161 location: t.location.clone(),
1162 children: class_children(
1163 &t.own_methods,
1164 Some(&t.own_properties),
1165 &t.own_constants,
1166 false,
1167 ),
1168 });
1169 }
1170 for e in defs.slice.enums.iter() {
1171 let mut children = class_children(&e.own_methods, None, &e.own_constants, true);
1172 for (_, case) in e.cases.iter() {
1173 children.push(DocumentSymbol {
1174 name: case.name.clone(),
1175 kind: DeclarationKind::EnumCase,
1176 location: case.location.clone(),
1177 children: Vec::new(),
1178 });
1179 }
1180 out.push(DocumentSymbol {
1181 name: e.fqcn.clone(),
1182 kind: DeclarationKind::Enum,
1183 location: e.location.clone(),
1184 children,
1185 });
1186 }
1187 for f in defs.slice.functions.iter() {
1188 out.push(DocumentSymbol {
1189 name: f.fqn.clone(),
1190 kind: DeclarationKind::Function,
1191 location: f.location.clone(),
1192 children: Vec::new(),
1193 });
1194 }
1195 for (name, _) in defs.slice.constants.iter() {
1196 out.push(DocumentSymbol {
1197 name: name.clone(),
1198 kind: DeclarationKind::Constant,
1199 location: None,
1200 children: Vec::new(),
1201 });
1202 }
1203 out
1204 }
1205
1206 /// Returns `true` if a function with `fqn` is registered and active in
1207 /// the codebase. Case-insensitive lookup with optional leading backslash.
1208 pub fn contains_function(&self, fqn: &str) -> bool {
1209 let db = self.snapshot_db();
1210 crate::db::function_exists(&db, fqn)
1211 }
1212
1213 /// Returns `true` if a class / interface / trait / enum with `fqcn` is
1214 /// registered and active in the codebase.
1215 pub fn contains_class(&self, fqcn: &str) -> bool {
1216 let db = self.snapshot_db();
1217 crate::db::class_exists(&db, fqcn)
1218 }
1219
1220 /// Returns `true` if `class` has a method named `name` registered. Method
1221 /// names are matched case-insensitively (PHP method dispatch semantics).
1222 pub fn contains_method(&self, class: &str, name: &str) -> bool {
1223 let db = self.snapshot_db();
1224 crate::db::has_method_in_chain(&db, class, name)
1225 }
1226
1227 /// Resolve `fqcn` via the configured [`crate::ClassResolver`] and ingest
1228 /// the mapped file. The session keeps a negative cache so repeated calls
1229 /// for an unresolvable name don't re-hit the resolver; the cache is
1230 /// invalidated on any [`Self::ingest_file`] / [`Self::invalidate_file`].
1231 ///
1232 /// This is the LSP-friendly entry point: the analyzer never touches
1233 /// `vendor/` on its own, but consumers can ask it to resolve individual
1234 /// symbols on demand. Designed to be called when a diagnostic would
1235 /// otherwise report `UndefinedClass`.
1236 ///
1237 /// Returns a [`crate::LoadOutcome`] distinguishing
1238 /// already-loaded / freshly-loaded / not-resolvable. Use
1239 /// [`crate::LoadOutcome::is_loaded`] when only success matters.
1240 pub fn load_class(&self, fqcn: &str) -> crate::LoadOutcome {
1241 if self.contains_class(fqcn) {
1242 return crate::LoadOutcome::AlreadyLoaded;
1243 }
1244 if self.unresolvable_fqcns.read().contains_key(fqcn) {
1245 return crate::LoadOutcome::NotResolvable;
1246 }
1247 if self.try_resolve_and_ingest(fqcn) {
1248 crate::LoadOutcome::Loaded
1249 } else {
1250 // Cache the failure with the resolver-mapped path (if any) so
1251 // future file edits can selectively evict.
1252 let resolved_path: Option<Arc<str>> = self
1253 .resolver
1254 .as_ref()
1255 .and_then(|r| r.resolve(fqcn))
1256 .map(|p| Arc::from(p.to_string_lossy().as_ref()));
1257 let key: Arc<str> = Arc::from(fqcn);
1258 let mut cache = self.unresolvable_fqcns.write();
1259 if cache.len() >= UNRESOLVABLE_CACHE_CAP {
1260 cache.clear();
1261 }
1262 cache.insert(key, resolved_path);
1263 crate::LoadOutcome::NotResolvable
1264 }
1265 }
1266
1267 /// Inner load path: resolver lookup + ingest, no caching. Returns `true`
1268 /// iff `fqcn` ends up registered. Failure buckets are recorded for
1269 /// telemetry.
1270 fn try_resolve_and_ingest(&self, fqcn: &str) -> bool {
1271 use crate::metrics::{record_lazy_load_failure, LazyLoadFailure};
1272 let Some(resolver) = &self.resolver else {
1273 record_lazy_load_failure(LazyLoadFailure::NoResolver, fqcn);
1274 return false;
1275 };
1276 let Some(path) = resolver.resolve(fqcn) else {
1277 record_lazy_load_failure(LazyLoadFailure::ResolverNone, fqcn);
1278 return false;
1279 };
1280 let file: Arc<str> = Arc::from(path.to_string_lossy().as_ref());
1281 // Prefer in-memory text from a prior `set_file_text` /
1282 // `set_workspace_files` call; fall back to disk. This makes the LSP's
1283 // unsaved-edit buffer authoritative over the on-disk content for the
1284 // same path.
1285 let src: Arc<str> = match self.source_of(&file) {
1286 Some(text) => text,
1287 None => match self.source_provider.read(&path.to_string_lossy()) {
1288 Some(text) => text,
1289 None => {
1290 record_lazy_load_failure(LazyLoadFailure::SourceUnreadable, fqcn);
1291 return false;
1292 }
1293 },
1294 };
1295 self.ingest_file(file, src);
1296 if self.contains_class(fqcn) {
1297 true
1298 } else {
1299 record_lazy_load_failure(LazyLoadFailure::IngestThenMissing, fqcn);
1300 false
1301 }
1302 }
1303
1304 /// Evict every negative-cache entry whose stored resolver-mapped path
1305 /// equals `file`. FQCNs cached as never-resolvable (path `None`) are left
1306 /// alone — no source-text change can make them resolvable.
1307 fn evict_unresolvable_for_file(&self, file: &str) {
1308 let mut cache = self.unresolvable_fqcns.write();
1309 if cache.is_empty() {
1310 return;
1311 }
1312 cache.retain(|_fqcn, path| path.as_deref() != Some(file));
1313 }
1314
1315 /// Bulk variant of [`Self::evict_unresolvable_for_file`]. One `HashSet`
1316 /// build + one pass over the cache; no resolver calls.
1317 fn evict_unresolvable_for_files(&self, files: &[Arc<str>]) {
1318 let mut cache = self.unresolvable_fqcns.write();
1319 if cache.is_empty() {
1320 return;
1321 }
1322 let registered: HashSet<&str> = files.iter().map(|f| f.as_ref()).collect();
1323 cache.retain(|_fqcn, path| match path {
1324 Some(p) => !registered.contains(p.as_ref()),
1325 None => true,
1326 });
1327 }
1328
1329 /// Retrieve the source text the session has registered for `file`, if
1330 /// any. Returns `None` when the file has never been ingested. Used by
1331 /// the parallel re-analysis path to re-feed dependents to body analysis without
1332 /// the caller having to track sources independently.
1333 pub fn source_of(&self, file: &str) -> Option<Arc<str>> {
1334 let db = self.snapshot_db();
1335 let sf = db.lookup_source_file(file)?;
1336 Some(sf.text(&db))
1337 }
1338
1339 /// Re-analyze every transitive dependent of `file` in parallel.
1340 ///
1341 /// When the user saves a file that other files depend on (e.g. editing
1342 /// a base class, an interface, or a trait), those dependents may have
1343 /// new diagnostics. This method computes them in parallel using rayon
1344 /// and returns the per-file analysis results so the LSP server can
1345 /// publish updated diagnostics in one batch.
1346 ///
1347 /// Source text for dependents is retrieved from the session's salsa
1348 /// inputs (set by previous `ingest_file` calls) — the caller doesn't
1349 /// need to track or re-read files. Files for which the session has no
1350 /// source are silently skipped (returns the analyzable subset).
1351 ///
1352 /// Cross-file inferred return types are resolved on demand via salsa.
1353 pub fn reanalyze_dependents(&self, file: &str) -> Vec<(Arc<str>, crate::FileAnalysis)> {
1354 self.reanalyze_dependents_cancellable(file, &crate::IndexCancel::new())
1355 }
1356
1357 /// Cancellable variant of [`Self::reanalyze_dependents`].
1358 ///
1359 /// The consumer flips `cancel` (typically because a newer edit arrived) to
1360 /// abandon the re-analysis; the flag is checked at each file boundary. Salsa
1361 /// cannot unwind the plain-Rust body-analysis walk mid-flight, so a file
1362 /// already in progress finishes, but no further files are started. Files
1363 /// skipped due to cancellation are simply absent from the returned vec —
1364 /// the consumer should drop a stale flag and start fresh work on each edit.
1365 pub fn reanalyze_dependents_cancellable(
1366 &self,
1367 file: &str,
1368 cancel: &crate::IndexCancel,
1369 ) -> Vec<(Arc<str>, crate::FileAnalysis)> {
1370 use rayon::prelude::*;
1371
1372 if cancel.is_cancelled() {
1373 return Vec::new();
1374 }
1375
1376 // Phase 1: compute dependents + gather their sources outside the
1377 // analysis loop so each worker has everything it needs.
1378 let dependents = self.dependency_graph().transitive_dependents(file);
1379 if dependents.is_empty() {
1380 return Vec::new();
1381 }
1382 let with_source: Vec<(Arc<str>, Arc<str>)> = dependents
1383 .into_iter()
1384 .filter_map(|path| {
1385 let arc_path: Arc<str> = Arc::from(path.as_str());
1386 let src = self.source_of(&path)?;
1387 Some((arc_path, src))
1388 })
1389 .collect();
1390 if with_source.is_empty() {
1391 return Vec::new();
1392 }
1393
1394 // Phase 2: parallel parse + analyze. Each rayon worker gets its own
1395 // database snapshot via FileAnalyzer; writes are isolated to the
1396 // session's canonical db (none happen here since we only run body analysis).
1397 // Each worker short-circuits when cancellation has been requested.
1398 with_source
1399 .into_par_iter()
1400 .filter_map(|(file, source)| {
1401 if cancel.is_cancelled() {
1402 return None;
1403 }
1404 let parsed = php_rs_parser::parse(source.as_ref());
1405 let analyzer = crate::FileAnalyzer::new(self);
1406 let analysis = analyzer.analyze(
1407 file.clone(),
1408 source.as_ref(),
1409 &parsed.program,
1410 &parsed.source_map,
1411 );
1412 Some((file, analysis))
1413 })
1414 .collect()
1415 }
1416
1417 /// FQCNs that `file` imports via `use` statements but that aren't yet
1418 /// loaded in the session.
1419 ///
1420 /// Designed as the input to background prefetching: after the LSP server
1421 /// ingests an open buffer, it can call this and lazy-load the returned
1422 /// FQCNs on a worker thread so the user's first Cmd+Click into vendor
1423 /// code doesn't pay the file-read+parse cost.
1424 ///
1425 /// Returns an empty Vec if the file hasn't been ingested or has no
1426 /// unresolved imports.
1427 pub fn pending_lazy_loads(&self, file: &str) -> Vec<Arc<str>> {
1428 let db = self.snapshot_db();
1429 let imports = db.file_imports(file);
1430 if imports.is_empty() {
1431 return Vec::new();
1432 }
1433 let mut out = Vec::new();
1434 for fqcn in imports.values() {
1435 let here = crate::db::Fqcn::new(&db, *fqcn);
1436 if crate::db::find_class_like(&db, here).is_some() {
1437 continue;
1438 }
1439 if let Some(resolver) = &self.resolver {
1440 if resolver.resolve(fqcn.as_str()).is_some() {
1441 out.push(Arc::from(fqcn.as_str()));
1442 }
1443 }
1444 }
1445 out
1446 }
1447
1448 /// Convenience: synchronously lazy-load every import of `file` that
1449 /// isn't already in the codebase. Returns the number successfully loaded.
1450 ///
1451 /// For non-blocking prefetch, call this from a worker thread:
1452 ///
1453 /// ```ignore
1454 /// let s = session.clone(); // AnalysisSession is wrapped in Arc by callers
1455 /// std::thread::spawn(move || {
1456 /// s.prefetch_imports(&file_path);
1457 /// });
1458 /// ```
1459 ///
1460 /// Uses a single shared-visited two-tier BFS across all pending imports
1461 /// (see [`Self::load_classes_transitive_bounded`]) with a shallow depth so
1462 /// member access on imported types type-checks without pulling in the
1463 /// entire vendor tree.
1464 pub fn prefetch_imports(&self, file: &str) -> usize {
1465 let pending = self.pending_lazy_loads(file);
1466 if pending.is_empty() {
1467 return 0;
1468 }
1469 // Fault in each imported FQCN directly (single-file load + tier-merge).
1470 // Inheritance ancestors / signature types resolve through the eagerly
1471 // built workspace symbol index — no transitive walk needed here.
1472 let mut loaded = 0;
1473 for fqcn in &pending {
1474 if self.load_class(fqcn.as_ref()).is_loaded() {
1475 loaded += 1;
1476 }
1477 }
1478 loaded
1479 }
1480
1481 /// All class / interface / trait / enum FQCNs currently known to the
1482 /// session, each paired with the file that defines them when available.
1483 ///
1484 /// Use this to build workspace-wide views (outline, fuzzy search, etc.).
1485 /// Consumers implement their own search/match logic on top — the analyzer
1486 /// only exposes the iterator.
1487 pub fn all_classes(&self) -> Vec<(Arc<str>, Option<mir_types::Location>)> {
1488 let db = self.snapshot_db();
1489 crate::db::workspace_classes(&db)
1490 .iter()
1491 .filter_map(|fqcn| {
1492 let here = crate::db::Fqcn::from_str(&db, fqcn.as_ref());
1493 crate::db::find_class_like(&db, here)
1494 .map(|class| (fqcn.clone(), class.location().cloned()))
1495 })
1496 .collect()
1497 }
1498
1499 /// All global function FQNs currently known to the session, each paired
1500 /// with their declaration location when available.
1501 pub fn all_functions(&self) -> Vec<(Arc<str>, Option<mir_types::Location>)> {
1502 let db = self.snapshot_db();
1503 crate::db::workspace_functions(&db)
1504 .iter()
1505 .filter_map(|fqn| {
1506 let here = crate::db::Fqcn::from_str(&db, fqn.as_ref());
1507 crate::db::find_function(&db, here).map(|f| (fqn.clone(), f.location.clone()))
1508 })
1509 .collect()
1510 }
1511
1512 /// Compute `file`'s outgoing dependency edges and update both the in-memory
1513 /// reverse-dep map (always) and the disk cache's reverse-dep graph (if configured).
1514 fn update_reverse_deps_for(&self, file: &str) {
1515 let db = self.snapshot_db();
1516 let targets = file_outgoing_dependencies(&db, file);
1517
1518 // Always update the in-memory map.
1519 self.update_in_memory_reverse_deps(file, &targets);
1520
1521 // Also persist to disk cache if configured.
1522 if let Some(cache) = self.cache.as_deref() {
1523 cache.update_reverse_deps_for_file(file, &targets);
1524 }
1525 }
1526
1527 /// Update the in-memory reverse dependency map for `file` with `new_targets`.
1528 /// Removes `file` from all existing entries, then adds it as a dependent of
1529 /// each target in `new_targets` (excluding self-edges).
1530 fn update_in_memory_reverse_deps(&self, file: &str, new_targets: &HashSet<String>) {
1531 let file_id = self.file_id_map.write().assign_or_get(file);
1532 let target_ids: Vec<FileId> = {
1533 let mut id_map = self.file_id_map.write();
1534 new_targets
1535 .iter()
1536 .map(|t| id_map.assign_or_get(t))
1537 .collect()
1538 };
1539
1540 let mut map = self.reverse_dep_map.write();
1541 for dependents in map.values_mut() {
1542 dependents.remove(&file_id);
1543 }
1544 map.retain(|_, dependents| !dependents.is_empty());
1545 for target_id in target_ids {
1546 if target_id != file_id {
1547 map.entry(target_id).or_default().insert(file_id);
1548 }
1549 }
1550 }
1551
1552 /// BFS transitive dependents of `file` using the in-memory reverse dep map.
1553 ///
1554 /// O(D) where D is the number of transitive dependents — faster than
1555 /// [`Self::dependency_graph().transitive_dependents()`] which rebuilds the
1556 /// full graph on every call. Only covers structural dependencies from definition collection
1557 /// (imports, class hierarchy, type hints); does not include bare FQN body
1558 /// references recorded during body analysis. For full fidelity, use
1559 /// `dependency_graph().transitive_dependents()` after body analysis is complete.
1560 pub fn structural_dependents(&self, file: &str) -> Vec<String> {
1561 let Some(start_id) = self.file_id_map.read().get(file) else {
1562 return Vec::new();
1563 };
1564 let map = self.reverse_dep_map.read();
1565 let mut visited: HashSet<FileId> = HashSet::default();
1566 let mut queue = vec![start_id];
1567 let mut result_ids = Vec::new();
1568 while let Some(current_id) = queue.pop() {
1569 if !visited.insert(current_id) {
1570 continue;
1571 }
1572 if let Some(deps) = map.get(¤t_id) {
1573 for &dep_id in deps {
1574 if !visited.contains(&dep_id) {
1575 queue.push(dep_id);
1576 result_ids.push(dep_id);
1577 }
1578 }
1579 }
1580 }
1581 drop(map);
1582 let id_map = self.file_id_map.read();
1583 result_ids
1584 .iter()
1585 .filter_map(|&id| id_map.path(id))
1586 .map(|s| s.to_string())
1587 .collect()
1588 }
1589
1590 /// File dependency graph: which files depend on which other files.
1591 /// Used for incremental invalidation in LSP servers and build systems.
1592 ///
1593 /// File dependency graph: which files depend on which other files.
1594 /// Used for incremental invalidation in LSP servers and build systems.
1595 ///
1596 /// O(edges) — iterates the `file_references` forward index (file → symbol
1597 /// keys it references) which is always current, then resolves each symbol
1598 /// to its defining file via O(1) lookup. Total cost is O(E) where E is the
1599 /// number of (file, symbol) reference edges, vs. the old O(F × S × R) scan.
1600 pub fn dependency_graph(&self) -> crate::DependencyGraph {
1601 let db = self.snapshot_db();
1602
1603 let all_files: Vec<String> = db
1604 .source_file_paths()
1605 .iter()
1606 .map(|f| f.as_ref().to_string())
1607 .collect();
1608
1609 let mut dependencies: HashMap<String, Vec<String>> = HashMap::default();
1610 let mut dependents: HashMap<String, Vec<String>> = HashMap::default();
1611
1612 for file in &all_files {
1613 // O(degree(file)) — forward index lookup, no full-table scan.
1614 let symbol_keys = db.file_referenced_symbols(file);
1615 let mut file_deps: HashSet<String> = HashSet::default();
1616 for symbol_key in &symbol_keys {
1617 let lookup: &str = match symbol_key.split_once("::") {
1618 Some((class, _)) => class,
1619 None => symbol_key.as_ref(),
1620 };
1621 if let Some(def_file) = db.symbol_defining_file(lookup) {
1622 let def = def_file.as_ref().to_string();
1623 if &def != file {
1624 file_deps.insert(def);
1625 }
1626 }
1627 }
1628 for dep in &file_deps {
1629 dependents
1630 .entry(dep.clone())
1631 .or_default()
1632 .push(file.clone());
1633 dependencies
1634 .entry(file.clone())
1635 .or_default()
1636 .push(dep.clone());
1637 }
1638 }
1639
1640 // Merge structural deps from definition collection from the incremental reverse_dep_map.
1641 // dependency_graph() above only captures bare-FQN references recorded during body analysis;
1642 // the reverse_dep_map covers imports, class hierarchy (extends/implements/use),
1643 // and type-hint-only references that never appear in file_referenced_symbols.
1644 // Together they give a complete picture without requiring body analysis on every file.
1645 {
1646 let id_map = self.file_id_map.read();
1647 let rev = self.reverse_dep_map.read();
1648 for (&target_id, dep_set) in rev.iter() {
1649 let Some(target) = id_map.path(target_id) else {
1650 continue;
1651 };
1652 let target = target.to_string();
1653 for &dep_id in dep_set {
1654 let Some(dep) = id_map.path(dep_id) else {
1655 continue;
1656 };
1657 let dep = dep.to_string();
1658 if dep != target {
1659 dependents
1660 .entry(target.clone())
1661 .or_default()
1662 .push(dep.clone());
1663 dependencies
1664 .entry(dep.clone())
1665 .or_default()
1666 .push(target.clone());
1667 }
1668 }
1669 }
1670 }
1671
1672 for deps in dependents.values_mut() {
1673 deps.sort();
1674 deps.dedup();
1675 }
1676 for deps in dependencies.values_mut() {
1677 deps.sort();
1678 deps.dedup();
1679 }
1680
1681 // Augment with stale dependents: files referencing symbols that were
1682 // deleted from their defining file. These edges disappear from the
1683 // symbol_defining_file lookup but the referencing file still needs
1684 // re-analysis to surface the now-broken reference.
1685 {
1686 let stale = self.stale_defined_symbols.read();
1687 if !stale.is_empty() {
1688 for (file, deleted_syms) in stale.iter() {
1689 for sym in deleted_syms {
1690 let lookup: &str = match sym.split_once("::") {
1691 Some((class, _)) => class,
1692 None => sym.as_ref(),
1693 };
1694 for referencing_file in db.symbol_referencers_of(lookup) {
1695 let ref_file = referencing_file.as_ref().to_string();
1696 if &ref_file != file {
1697 dependents
1698 .entry(file.clone())
1699 .or_default()
1700 .push(ref_file.clone());
1701 dependencies.entry(ref_file).or_default().push(file.clone());
1702 }
1703 }
1704 }
1705 }
1706 // Re-sort and dedup since we may have added entries.
1707 for deps in dependents.values_mut() {
1708 deps.sort();
1709 deps.dedup();
1710 }
1711 for deps in dependencies.values_mut() {
1712 deps.sort();
1713 deps.dedup();
1714 }
1715 }
1716 }
1717
1718 crate::DependencyGraph {
1719 dependencies,
1720 dependents,
1721 }
1722 }
1723}
1724
1725/// Compute the set of files `file` depends on: defining files of its imports,
1726/// plus parent / interfaces / traits' defining files for any classes declared
1727/// in `file`. Self-edges are excluded.
1728fn file_outgoing_dependencies(db: &dyn MirDatabase, file: &str) -> HashSet<String> {
1729 let mut targets: HashSet<String> = HashSet::default();
1730
1731 let mut add_target = |symbol: &str| {
1732 if let Some(defining_file) = db.symbol_defining_file(symbol) {
1733 let def = defining_file.as_ref().to_string();
1734 if def != file {
1735 targets.insert(def);
1736 }
1737 }
1738 };
1739
1740 let extract_named_objects = |union: &mir_types::Type| {
1741 union
1742 .types
1743 .iter()
1744 .filter_map(|atomic| match atomic {
1745 mir_types::atomic::Atomic::TNamedObject { fqcn, .. } => Some(*fqcn),
1746 _ => None,
1747 })
1748 .collect::<Vec<_>>()
1749 };
1750
1751 let imports = db.file_imports(file);
1752 for fqcn in imports.values() {
1753 add_target(fqcn.as_str());
1754 }
1755
1756 // Walk every class/interface/trait/enum/function defined in this file
1757 // via the pull-path slice. Push-path lookup_*_node have been retired.
1758 if let Some(sf) = db.lookup_source_file(file) {
1759 let defs = crate::db::collect_file_definitions(db, sf);
1760 for c in defs.slice.classes.iter() {
1761 if let Some(p) = &c.parent {
1762 add_target(p);
1763 }
1764 for iface in c.interfaces.iter() {
1765 add_target(iface);
1766 }
1767 for tr in c.traits.iter() {
1768 add_target(tr);
1769 }
1770 for prop in c.own_properties.values() {
1771 if let Some(ty) = &prop.ty {
1772 for named in extract_named_objects(ty) {
1773 add_target(named.as_ref());
1774 }
1775 }
1776 }
1777 for method in c.own_methods.values() {
1778 for param in method.params.iter() {
1779 if let Some(ty) = ¶m.ty {
1780 for named in extract_named_objects(ty.as_ref()) {
1781 add_target(named.as_ref());
1782 }
1783 }
1784 }
1785 if let Some(rt) = method.return_type.as_deref() {
1786 for named in extract_named_objects(rt) {
1787 add_target(named.as_ref());
1788 }
1789 }
1790 }
1791 }
1792 for i in defs.slice.interfaces.iter() {
1793 for ext in i.extends.iter() {
1794 add_target(ext);
1795 }
1796 for method in i.own_methods.values() {
1797 for param in method.params.iter() {
1798 if let Some(ty) = ¶m.ty {
1799 for named in extract_named_objects(ty.as_ref()) {
1800 add_target(named.as_ref());
1801 }
1802 }
1803 }
1804 if let Some(rt) = method.return_type.as_deref() {
1805 for named in extract_named_objects(rt) {
1806 add_target(named.as_ref());
1807 }
1808 }
1809 }
1810 }
1811 for t in defs.slice.traits.iter() {
1812 for tr in t.traits.iter() {
1813 add_target(tr);
1814 }
1815 }
1816 for f in defs.slice.functions.iter() {
1817 for param in f.params.iter() {
1818 if let Some(ty) = ¶m.ty {
1819 for named in extract_named_objects(ty.as_ref()) {
1820 add_target(named.as_ref());
1821 }
1822 }
1823 }
1824 if let Some(rt) = f.return_type.as_deref() {
1825 for named in extract_named_objects(rt) {
1826 add_target(named.as_ref());
1827 }
1828 }
1829 }
1830 }
1831
1832 // Also track bare-FQN references recorded during body analysis (new \Foo(), \Foo::method(),
1833 // \foo()) that do not appear in use-import statements.
1834 for symbol_key in db.file_referenced_symbols(file) {
1835 let lookup: &str = match symbol_key.split_once("::") {
1836 Some((class, _)) => class,
1837 None => &symbol_key,
1838 };
1839 add_target(lookup);
1840 }
1841
1842 targets
1843}
1844
1845/// AST visitor that collects class FQCN references for PSR-4 preloading.
1846/// Captures identifiers from `new X`, static calls / property / constant
1847/// access, type hints, and `instanceof`. Does *not* normalize via PSR-4 /
1848/// imports — callers run the raw string through `resolve_name`.
1849fn collect_class_refs_from_ast(program: &php_ast::owned::Program) -> Vec<String> {
1850 use php_ast::ast::BinaryOp;
1851 use php_ast::owned::visitor::{
1852 walk_owned_catch_clause, walk_owned_expr, walk_owned_program, walk_owned_type_hint,
1853 OwnedVisitor,
1854 };
1855 use php_ast::owned::{ExprKind, TypeHintKind};
1856 use std::ops::ControlFlow;
1857
1858 fn owned_name_str(name: &php_ast::owned::Name) -> String {
1859 let joined: String = name
1860 .parts
1861 .iter()
1862 .map(|p| p.as_ref())
1863 .collect::<Vec<&str>>()
1864 .join("\\");
1865 if name.kind == php_ast::ast::NameKind::FullyQualified {
1866 format!("\\{joined}")
1867 } else {
1868 joined
1869 }
1870 }
1871
1872 struct V {
1873 names: std::collections::HashSet<String>,
1874 }
1875 impl OwnedVisitor for V {
1876 fn visit_expr(&mut self, expr: &php_ast::owned::Expr) -> ControlFlow<()> {
1877 match &expr.kind {
1878 ExprKind::New(n) => {
1879 if let ExprKind::Identifier(name) = &n.class.kind {
1880 self.names.insert(name.as_ref().to_string());
1881 }
1882 }
1883 ExprKind::StaticMethodCall(c) => {
1884 if let ExprKind::Identifier(name) = &c.class.kind {
1885 self.names.insert(name.as_ref().to_string());
1886 }
1887 }
1888 ExprKind::StaticPropertyAccess(a) => {
1889 if let ExprKind::Identifier(name) = &a.class.kind {
1890 self.names.insert(name.as_ref().to_string());
1891 }
1892 }
1893 ExprKind::ClassConstAccess(a) => {
1894 if let ExprKind::Identifier(name) = &a.class.kind {
1895 self.names.insert(name.as_ref().to_string());
1896 }
1897 }
1898 ExprKind::Binary(b) if b.op == BinaryOp::Instanceof => {
1899 if let ExprKind::Identifier(name) = &b.right.kind {
1900 self.names.insert(name.as_ref().to_string());
1901 }
1902 }
1903 _ => {}
1904 }
1905 walk_owned_expr(self, expr)
1906 }
1907
1908 fn visit_type_hint(&mut self, hint: &php_ast::owned::TypeHint) -> ControlFlow<()> {
1909 if let TypeHintKind::Named(name) = &hint.kind {
1910 let s = owned_name_str(name);
1911 if !s.is_empty() {
1912 self.names.insert(s);
1913 }
1914 }
1915 walk_owned_type_hint(self, hint)
1916 }
1917
1918 fn visit_catch_clause(&mut self, catch: &php_ast::owned::CatchClause) -> ControlFlow<()> {
1919 for ty in catch.types.iter() {
1920 self.names.insert(owned_name_str(ty));
1921 }
1922 walk_owned_catch_clause(self, catch)
1923 }
1924 }
1925 let mut v = V {
1926 names: std::collections::HashSet::default(),
1927 };
1928 let _ = walk_owned_program(&mut v, program);
1929 v.names.into_iter().collect()
1930}