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