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