mir_analyzer/session/mod.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 /// Symbols defined by each file as of its last `ingest_file`. The
60 /// authoritative "old" set for the rename/deletion diff, independent of
61 /// whether the salsa `SourceFile` input was already updated to the new text
62 /// by a host driving the db directly (the LSP convergence path). Without
63 /// this, re-deriving "old" symbols from the (possibly pre-updated) input
64 /// would miss deletions and break cross-file dependency invalidation.
65 last_ingested_symbols: Arc<RwLock<HashMap<String, HashSet<Arc<str>>>>>,
66 /// Negative cache: FQCNs that `load_class` already failed on.
67 /// The value is the resolver-mapped path (when known) so eviction on
68 /// `set_file_text` / `ingest_file` is a path equality check rather than
69 /// re-running the resolver per entry. `None` means the resolver itself
70 /// couldn't map the FQCN; those entries survive file edits (no source
71 /// change makes a never-resolvable name resolvable).
72 /// Bounded to `UNRESOLVABLE_CACHE_CAP`; clears on overflow.
73 unresolvable_fqcns: UnresolvableCache,
74 /// Pluggable source-text provider for lazy-load. Defaults to filesystem
75 /// reads ([`crate::FsSourceProvider`]); LSPs swap in a VFS-backed
76 /// implementation so unsaved buffers override on-disk content.
77 source_provider: Arc<dyn crate::SourceProvider>,
78 /// Vendor `autoload.files` entries not yet indexed. `Some(paths)` means
79 /// pending; `None` means the load has already run (idempotent). Populated
80 /// by [`Self::with_psr4`]; drained by [`Self::ensure_vendor_eager_functions`],
81 /// which is called automatically from [`Self::prepare_ast_for_analysis`].
82 ///
83 /// The mutex is held for the full duration of the load so concurrent callers
84 /// block until indexing is complete rather than proceeding with a stale
85 /// workspace snapshot.
86 pub(crate) pending_eager_function_files: Arc<parking_lot::Mutex<Option<Vec<PathBuf>>>>,
87 /// Warm-up skip set: files whose [`Self::prepare_ast_for_analysis`] has
88 /// already run against their current text. Value is `(text, generation)` —
89 /// the entry is live while the file's input text is pointer-equal to `text`
90 /// (a text edit self-invalidates) and `generation` matches
91 /// [`Self::prepare_generation`]. Lets the per-request Phase-1 warm-up in
92 /// `references_to_in_files` / `reanalyze_dependents` skip the serial
93 /// parse + AST walk for files already faulted in.
94 prepared_files: PreparedFilesCache,
95 /// Bumped whenever previously loaded declarations may have been removed
96 /// (`invalidate_file`, symbol deletions on `ingest_file`, or a host calling
97 /// [`Self::bump_prepare_generation`]) — a prepared file might then need its
98 /// warm-up re-run to lazy-load a replacement (e.g. a vendor class shadowed
99 /// by a since-deleted project class).
100 prepare_generation: Arc<std::sync::atomic::AtomicU64>,
101 /// file → [`RefCommit`] its reference locations were last committed
102 /// from. Exact while the text is pointer-equal and the commit either
103 /// fully resolved every name it referenced or was stamped at the current
104 /// [`Self::index_generation`] — a later symbol add elsewhere can resolve
105 /// a reference this file's analysis left unresolved, even though this
106 /// file's own text never changed. Files absent here have never been
107 /// committed.
108 ref_committed: CommittedRefs,
109 /// file → source text its subtype-index class edges were last committed
110 /// from. Same freshness contract as `ref_committed`, but definitions
111 /// depend only on the file's own text, so a pointer-equal entry is
112 /// always exact (no cross-file drift).
113 defs_committed: CommittedTexts,
114}
115
116/// FQCN → optional resolver-mapped path. See the field doc on
117/// `AnalysisSession::unresolvable_fqcns`.
118type UnresolvableCache = Arc<RwLock<HashMap<Arc<str>, Option<Arc<str>>>>>;
119
120/// Warm-up skip set keyed by file path. See the field doc on
121/// `AnalysisSession::prepared_files`.
122type PreparedFilesCache = Arc<RwLock<HashMap<Arc<str>, (Arc<str>, u64)>>>;
123
124/// file → text a per-file index commit was computed from. See the field docs
125/// on `AnalysisSession::ref_committed` / `defs_committed`.
126type CommittedTexts = Arc<RwLock<HashMap<Arc<str>, Arc<str>>>>;
127
128/// A staged [`AnalysisCache`] write for one file's postings, prepared in the
129/// parallel analysis phase and applied during the serial index commit. See
130/// `AnalysisSession::stage_ref_cache_put`.
131pub(crate) struct RefCachePut {
132 content_hash: String,
133 surface_hash: String,
134 ref_locs: Arc<[crate::cache::CachedRefLoc]>,
135}
136
137/// One file's reference-posting commit. See `AnalysisSession::ref_committed`.
138pub(crate) struct RefCommit {
139 /// Source text the postings were computed from (pointer identity; a
140 /// text write self-invalidates).
141 text: Arc<str>,
142 /// Weak handle on the analyze memo — pointer-identical output means
143 /// identical postings, so sweeps can skip the index rewrite. The upgrade
144 /// guards against ABA on evicted memos.
145 out: std::sync::Weak<crate::db::AnalyzeOutput>,
146 /// Workspace generation whose resolution environment the postings
147 /// reflect, captured *before* the analysis snapshot.
148 generation: u64,
149 /// The analysis resolved every workspace-level name it referenced, so no
150 /// later symbol add can change the postings and the commit survives
151 /// generation bumps. FQCN shadowing and unqualified-call fallback
152 /// switches remain the reanalyze_dependents flow's job, as before.
153 resolved: bool,
154 /// Whether this commit came from this session's own `analyze_file` pass
155 /// (`out: Some(..)`) rather than a disk-cache replay (`out: None`, from
156 /// `warm_start_files`). A live analysis's postings are guaranteed
157 /// consistent with a fresh textual scan of the same text — a replayed
158 /// commit's are only as trustworthy as the cache entry, so it always
159 /// re-verifies via full re-analysis instead of the cheaper gate.
160 live_analyzed: bool,
161}
162
163/// file → [`RefCommit`] map shared across session clones.
164type CommittedRefs = Arc<RwLock<HashMap<Arc<str>, RefCommit>>>;
165
166/// Cap on the negative-resolution cache. Sized to accommodate a large
167/// workspace's worth of genuinely-missing references without unbounded
168/// growth. On overflow the cache is cleared; the cost is a few extra
169/// resolver calls until it re-fills.
170const UNRESOLVABLE_CACHE_CAP: usize = 10_000;
171
172impl AnalysisSession {
173 /// Create a session targeting the given PHP language version.
174 pub fn new(php_version: PhpVersion) -> Self {
175 let db = Arc::new(AnalyzerDb::new());
176 db.salsa
177 .write()
178 .set_php_version(Arc::from(php_version.to_string().as_str()));
179 Self {
180 db,
181 cache: None,
182 psr4: None,
183 resolver: None,
184 php_version,
185 user_stub_files: Vec::new(),
186 user_stub_dirs: Vec::new(),
187 stale_defined_symbols: Arc::new(RwLock::new(HashMap::default())),
188 last_ingested_symbols: Arc::new(RwLock::new(HashMap::default())),
189 unresolvable_fqcns: Arc::new(RwLock::new(HashMap::default())),
190 source_provider: Arc::new(crate::FsSourceProvider),
191 pending_eager_function_files: Arc::new(parking_lot::Mutex::new(Some(Vec::new()))),
192 prepared_files: Arc::new(RwLock::new(HashMap::default())),
193 prepare_generation: Arc::new(std::sync::atomic::AtomicU64::new(0)),
194 ref_committed: Arc::new(RwLock::new(HashMap::default())),
195 defs_committed: Arc::new(RwLock::new(HashMap::default())),
196 }
197 }
198
199 /// Times the reference index has been locked on this session's db.
200 pub fn ref_index_lock_count(&self) -> u64 {
201 self.db.salsa.read().ref_index_lock_count()
202 }
203
204 /// Coverage/size counters for the class-mention gate index (host
205 /// metrics and memory-bound checks).
206 pub fn class_mention_stats(&self) -> crate::db::ClassMentionStats {
207 self.db.salsa.read().class_mention_stats()
208 }
209
210 /// Whether `file`'s reference postings are exact for `current_text` at
211 /// `current_gen`: text pointer-equal, and the commit either resolved
212 /// every name (immune to workspace growth) or was stamped at that
213 /// generation — catches a file analyzed before a class it references
214 /// was registered elsewhere, which would otherwise look fresh forever.
215 pub(crate) fn is_ref_committed(
216 &self,
217 file: &str,
218 current_text: &Arc<str>,
219 current_gen: u64,
220 ) -> bool {
221 self.ref_committed.read().get(file).is_some_and(|c| {
222 Arc::ptr_eq(&c.text, current_text) && (c.resolved || c.generation == current_gen)
223 })
224 }
225
226 /// Whether `file` has a *live-analyzed* reference commit recorded against
227 /// exactly `current_text` — i.e. it's only stale by generation (an
228 /// unresolved-name commit racing a workspace-growth bump), not because
229 /// its text changed or because it was seeded by an unverified disk-cache
230 /// replay. Such a commit is still eligible for the mention/needle gate:
231 /// the current text is exactly what this session's own analysis already
232 /// scanned, so a needle miss is just as conclusive as for a
233 /// never-committed file. A replayed commit (`live_analyzed: false`)
234 /// never qualifies here, regardless of text match — its postings are
235 /// only as trustworthy as the cache entry, so it always falls through to
236 /// unconditional re-analysis. Same for a genuinely edited file (text
237 /// differs): the old commit's postings are for different text and must
238 /// be replaced regardless of what the new text mentions.
239 pub(crate) fn ref_commit_stale_by_generation_only(
240 &self,
241 file: &str,
242 current_text: &Arc<str>,
243 ) -> bool {
244 self.ref_committed
245 .read()
246 .get(file)
247 .is_some_and(|c| c.live_analyzed && Arc::ptr_eq(&c.text, current_text))
248 }
249
250 /// Whether `file`'s stored postings came from exactly this
251 /// (text, output) pair — generation aside. Pointer-identical output
252 /// means identical postings (salsa backdates equal results to the same
253 /// Arc), so callers skip the index rewrite and only re-stamp the mark.
254 pub(crate) fn ref_commit_is_current(
255 &self,
256 file: &str,
257 current_text: &Arc<str>,
258 out: &Arc<crate::db::AnalyzeOutput>,
259 ) -> bool {
260 self.ref_committed.read().get(file).is_some_and(|c| {
261 Arc::ptr_eq(&c.text, current_text)
262 && c.out.upgrade().is_some_and(|prev| Arc::ptr_eq(&prev, out))
263 })
264 }
265
266 /// Record a commit computed against the workspace state at `generation`
267 /// — captured by the caller *before* its analysis snapshot, so a file
268 /// add racing the analysis leaves the commit stale (re-verified on the
269 /// next query) rather than wrongly fresh. `resolved` must come from the
270 /// producing analysis' own issue set
271 /// ([`crate::db::issues_have_unresolved_names`]); pass `false` when
272 /// unknown — the gen-guarded safe direction.
273 pub(crate) fn mark_ref_committed(
274 &self,
275 file: &Arc<str>,
276 text: &Arc<str>,
277 out: Option<&Arc<crate::db::AnalyzeOutput>>,
278 generation: u64,
279 resolved: bool,
280 ) {
281 let commit = RefCommit {
282 text: text.clone(),
283 out: out.map(Arc::downgrade).unwrap_or_default(),
284 generation,
285 resolved,
286 live_analyzed: out.is_some(),
287 };
288 self.ref_committed.write().insert(file.clone(), commit);
289 }
290
291 pub(crate) fn forget_ref_committed(&self, file: &str) {
292 self.ref_committed.write().remove(file);
293 }
294
295 /// Stage a disk-cache write for `file`'s postings, computed in the
296 /// parallel analysis phase (needs a live db snapshot for the memoized
297 /// parse). `None` when no cache is attached or the stored entry already
298 /// matches this content — batch-written entries are never clobbered.
299 /// The caller applies the result via [`Self::apply_ref_cache_put`] in
300 /// its serial commit, alongside the in-memory index commit.
301 pub(crate) fn stage_ref_cache_put(
302 &self,
303 db: &dyn crate::db::MirDatabase,
304 sf: crate::db::SourceFile,
305 file: &str,
306 text: &Arc<str>,
307 out: &Arc<crate::db::AnalyzeOutput>,
308 ) -> Option<RefCachePut> {
309 let cache = self.cache.as_deref()?;
310 let content_hash = crate::cache::hash_content(text);
311 if cache.is_valid(file, &content_hash) {
312 return None;
313 }
314 let parsed = crate::db::parse_file(db, sf);
315 let surface_hash = crate::cache::surface_fingerprint(text, &parsed.0.program);
316 let ref_locs: Arc<[crate::cache::CachedRefLoc]> = out
317 .ref_locs
318 .iter()
319 .map(|r| (Arc::clone(&r.symbol_key), r.line, r.col_start, r.col_end))
320 .collect();
321 Some(RefCachePut {
322 content_hash,
323 surface_hash,
324 ref_locs,
325 })
326 }
327
328 pub(crate) fn apply_ref_cache_put(
329 &self,
330 file: &str,
331 out: &Arc<crate::db::AnalyzeOutput>,
332 put: RefCachePut,
333 ) {
334 if let Some(cache) = self.cache.as_deref() {
335 cache.put(
336 file,
337 put.content_hash,
338 put.surface_hash,
339 out.issues.clone(),
340 put.ref_locs,
341 );
342 }
343 }
344
345 /// Persist the attached [`AnalysisCache`] to disk. No-op without an
346 /// attached cache or when nothing changed since the last flush.
347 /// Reference postings committed by session sweeps and on-demand query
348 /// freshness passes reach disk only here — a host should call this after
349 /// its warm sweep completes and on shutdown so the next launch's
350 /// [`Self::warm_start_files`] finds them.
351 pub fn flush_analysis_cache(&self) {
352 if let Some(cache) = &self.cache {
353 cache.flush();
354 }
355 }
356
357 /// Whether the workspace symbol index singleton is populated (seeded by
358 /// [`Self::warm_start_files`] or built by `index_batch`) — symbol lookups
359 /// answer from the O(1) map instead of the tracked O(all-files) walk.
360 pub fn workspace_symbol_index_ready(&self) -> bool {
361 self.db
362 .salsa
363 .read()
364 .workspace_symbol_index_singleton()
365 .is_some()
366 }
367
368 /// Executions of the tracked O(all-files) `workspace_symbol_index` walk
369 /// (diagnostic; a warm-started session should keep this at zero).
370 pub fn workspace_index_walks(&self) -> u64 {
371 self.db.salsa.read().workspace_index_walks()
372 }
373
374 /// Whether `file`'s subtype-index class edges were committed from exactly
375 /// `current_text`.
376 pub(crate) fn is_defs_committed(&self, file: &str, current_text: &Arc<str>) -> bool {
377 self.defs_committed
378 .read()
379 .get(file)
380 .is_some_and(|t| Arc::ptr_eq(t, current_text))
381 }
382
383 pub(crate) fn mark_defs_committed(&self, file: &Arc<str>, text: &Arc<str>) {
384 self.defs_committed
385 .write()
386 .insert(file.clone(), text.clone());
387 }
388
389 pub(crate) fn forget_defs_committed(&self, file: &str) {
390 self.defs_committed.write().remove(file);
391 }
392
393 /// Every file with a defs commit on record, regardless of staleness.
394 pub(crate) fn defs_committed_keys(&self) -> Vec<Arc<str>> {
395 self.defs_committed.read().keys().cloned().collect()
396 }
397
398 /// Every file with a reference commit on record, regardless of
399 /// staleness. Files absent here have no reference postings at all.
400 pub(crate) fn ref_committed_keys(&self) -> Vec<Arc<str>> {
401 self.ref_committed.read().keys().cloned().collect()
402 }
403
404 /// Swap in a custom [`crate::SourceProvider`]. LSPs install a VFS-backed
405 /// provider here so the analyzer reads from unsaved editor buffers
406 /// instead of disk.
407 pub fn with_source_provider(mut self, provider: Arc<dyn crate::SourceProvider>) -> Self {
408 self.source_provider = provider;
409 self
410 }
411
412 /// Attach a pre-built [`AnalysisCache`] (the body-analysis issue cache) and
413 /// open a sibling definition [`StubSlice`] cache under the same root, so
414 /// callers using this builder get the same speedup as `with_cache_dir`.
415 ///
416 /// Rebuilds the shared database to attach the definition cache — call
417 /// **before** any file is ingested. A debug assertion catches misuse.
418 ///
419 /// [`StubSlice`]: mir_codebase::definitions::StubSlice
420 pub fn with_cache(mut self, cache: Arc<AnalysisCache>) -> Self {
421 debug_assert_eq!(
422 self.db.source_file_count(),
423 0,
424 "AnalysisSession::with_cache must be called before any file is ingested"
425 );
426 let dir = cache.cache_dir().to_path_buf();
427 self.db = Arc::new(AnalyzerDb::new().with_cache_dir(&dir));
428 self.db
429 .salsa
430 .write()
431 .set_php_version(Arc::from(self.php_version.to_string().as_str()));
432 self.cache = Some(cache);
433 self
434 }
435
436 /// Convenience: open a disk-backed cache at `cache_dir` and attach it.
437 ///
438 /// Attaches both the body-analysis issue cache ([`AnalysisCache`]) and the
439 /// definition [`StubSlice`] cache to the shared database. Builds a fresh
440 /// [`AnalyzerDb`] internally — call **before** any file is ingested. A
441 /// debug assertion catches misuse.
442 ///
443 /// [`StubSlice`]: mir_codebase::definitions::StubSlice
444 pub fn with_cache_dir(mut self, cache_dir: &std::path::Path) -> Self {
445 debug_assert_eq!(
446 self.db.source_file_count(),
447 0,
448 "AnalysisSession::with_cache_dir must be called before any file is ingested"
449 );
450 self.db = Arc::new(AnalyzerDb::new().with_cache_dir(cache_dir));
451 self.db
452 .salsa
453 .write()
454 .set_php_version(Arc::from(self.php_version.to_string().as_str()));
455 // Fold the user-stub fingerprint into the cache epoch. `with_user_stubs`
456 // must run before this for it to be picked up (it does in `build_session`);
457 // sessions without user stubs get 0, which is correct.
458 let user_stub_fp =
459 crate::stubs::user_stub_fingerprint(&self.user_stub_files, &self.user_stub_dirs);
460 self.cache = Some(Arc::new(AnalysisCache::open(
461 cache_dir,
462 self.php_version.cache_byte(),
463 user_stub_fp,
464 )));
465 self
466 }
467
468 /// Attach a Composer autoload map (PSR-4, PSR-0, classmap, files).
469 /// Sets the same map as the active [`crate::ClassResolver`] so
470 /// [`Self::load_class`] works out of the box.
471 pub fn with_psr4(mut self, map: Arc<Psr4Map>) -> Self {
472 let user_resolver: Arc<dyn crate::ClassResolver> = map.clone();
473 // Wrap with stub awareness so `find_class_like` / `resolve_fqcn_to_path`
474 // can map built-in PHP class FQCNs (`ArrayObject`, `Exception`, …)
475 // to their stub virtual paths.
476 let resolver: Arc<dyn crate::ClassResolver> = Arc::new(crate::ChainedClassResolver::new(
477 user_resolver,
478 Arc::new(crate::StubClassResolver),
479 ));
480 self.psr4 = Some(map.clone());
481 self.resolver = Some(resolver.clone());
482 // Mirror into MirDbStorage so salsa-tracked resolver queries
483 // (`db::resolve_fqcn_to_path`) see the same resolver and are
484 // invalidated on swap.
485 self.db.salsa.write().set_resolver(Some(resolver));
486 // Register vendor autoload.files for lazy loading. They define global
487 // functions and constants that the class resolver cannot discover.
488 // `ensure_vendor_eager_functions` will index them on first analysis call.
489 *self.pending_eager_function_files.lock() = Some(map.vendor_eager_files());
490 self
491 }
492
493 /// Attach a generic class resolver for projects that don't use Composer
494 /// (WordPress, Drupal, custom autoloaders, workspace-walk indexes).
495 /// Replaces any previously-set Composer-backed resolver. Automatically
496 /// wrapped with stub awareness so PHP built-ins remain resolvable.
497 pub fn with_class_resolver(mut self, resolver: Arc<dyn crate::ClassResolver>) -> Self {
498 let wrapped: Arc<dyn crate::ClassResolver> = Arc::new(crate::ChainedClassResolver::new(
499 resolver,
500 Arc::new(crate::StubClassResolver),
501 ));
502 self.db.salsa.write().set_resolver(Some(wrapped.clone()));
503 self.resolver = Some(wrapped);
504 self
505 }
506
507 pub fn with_user_stubs(mut self, files: Vec<PathBuf>, dirs: Vec<PathBuf>) -> Self {
508 self.user_stub_files = files;
509 self.user_stub_dirs = dirs;
510 self
511 }
512
513 pub fn php_version(&self) -> PhpVersion {
514 self.php_version
515 }
516
517 pub fn cache(&self) -> Option<&AnalysisCache> {
518 self.cache.as_deref()
519 }
520
521 pub fn psr4(&self) -> Option<&Psr4Map> {
522 self.psr4.as_deref()
523 }
524}
525
526mod incremental;
527mod ingest;
528mod loading;
529mod queries;
530mod stubs;
531
532pub use queries::SubtypeClassSite;
533
534/// Compute the full set of files `file` depends on: structural edges from
535/// the memoized [`crate::db::file_structural_deps`] tracked query, plus
536/// bare-FQN references recorded during body analysis (which live in the
537/// reference index and are not visible to salsa). Self-edges are excluded.
538/// Used to persist the disk cache's reverse-dep graph.
539fn file_outgoing_dependencies(
540 db: &dyn MirDatabase,
541 file: &str,
542 include_body_ref_edges: bool,
543) -> HashSet<String> {
544 let mut targets: HashSet<String> = HashSet::default();
545
546 if let Some(sf) = db.lookup_source_file(file) {
547 for target in crate::db::file_structural_deps(db, sf).iter() {
548 targets.insert(target.as_ref().to_string());
549 }
550 }
551
552 if !include_body_ref_edges {
553 return targets;
554 }
555
556 // Bare-FQN references recorded during body analysis (new \Foo(),
557 // \Foo::method(), \foo()) that do not appear in use-import statements.
558 for symbol_key in db.file_referenced_symbols(file) {
559 let lookup = crate::defining_file_lookup_key(&symbol_key);
560 if let Some(defining_file) = db.symbol_defining_file(lookup) {
561 if defining_file.as_ref() != file {
562 targets.insert(defining_file.as_ref().to_string());
563 }
564 }
565 }
566
567 targets
568}
569
570/// AST visitor that collects class FQCN references for PSR-4 preloading.
571/// Captures identifiers from `new X`, static calls / property / constant
572/// access, type hints, `instanceof`, and `@param`/`@return`/`@var`/`@extends`/
573/// `@implements` docblock annotations. Does *not* normalize via PSR-4 /
574/// imports — callers run the raw string through `resolve_name`.
575fn collect_class_refs_from_ast(program: &php_ast::owned::Program) -> Vec<String> {
576 use php_ast::ast::BinaryOp;
577 use php_ast::owned::visitor::{
578 walk_owned_class_member, walk_owned_expr, walk_owned_program, walk_owned_stmt, OwnedVisitor,
579 };
580 use php_ast::owned::{ClassMemberKind, ExprKind};
581 use std::ops::ControlFlow;
582
583 fn owned_name_str(name: &php_ast::owned::Name) -> String {
584 let joined: String = name
585 .parts
586 .iter()
587 .map(|p| p.as_ref())
588 .collect::<Vec<&str>>()
589 .join("\\");
590 if name.kind == php_ast::ast::NameKind::FullyQualified {
591 format!("\\{joined}")
592 } else {
593 joined
594 }
595 }
596
597 /// Recursively collect all `TNamedObject` FQCNs from a mir type, including
598 /// those nested inside generic type parameters (e.g. `Collection<Item>`).
599 fn collect_from_type(ty: &mir_types::Type, out: &mut std::collections::HashSet<String>) {
600 for atomic in ty.types.iter() {
601 if let mir_types::Atomic::TNamedObject { fqcn, type_params } = atomic {
602 out.insert(fqcn.as_ref().to_string());
603 for tp in type_params.iter() {
604 collect_from_type(tp, out);
605 }
606 }
607 }
608 }
609
610 /// Parse a docblock and collect class names from `@param`, `@return`,
611 /// `@var`, `@extends`, and `@implements` annotations.
612 fn collect_from_docblock(text: &str, out: &mut std::collections::HashSet<String>) {
613 let parsed = crate::parser::DocblockParser::parse(text);
614 for (_, ty) in &parsed.params {
615 collect_from_type(ty, out);
616 }
617 if let Some(ret) = &parsed.return_type {
618 collect_from_type(ret, out);
619 }
620 if let Some(var) = &parsed.var_type {
621 collect_from_type(var, out);
622 }
623 for ext in &parsed.extends {
624 collect_from_type(ext, out);
625 }
626 for impl_ty in &parsed.implements {
627 collect_from_type(impl_ty, out);
628 }
629 }
630
631 struct V {
632 names: std::collections::HashSet<String>,
633 }
634 impl OwnedVisitor for V {
635 fn visit_stmt(&mut self, stmt: &php_ast::owned::Stmt) -> ControlFlow<()> {
636 if let Some(doc) = stmt.leading_doc_comment() {
637 collect_from_docblock(&doc.text, &mut self.names);
638 }
639 walk_owned_stmt(self, stmt)
640 }
641
642 fn visit_class_member(&mut self, member: &php_ast::owned::ClassMember) -> ControlFlow<()> {
643 match &member.kind {
644 ClassMemberKind::Method(m) => {
645 if let Some(doc) = &m.doc_comment {
646 collect_from_docblock(&doc.text, &mut self.names);
647 }
648 }
649 ClassMemberKind::Property(p) => {
650 if let Some(doc) = &p.doc_comment {
651 collect_from_docblock(&doc.text, &mut self.names);
652 }
653 }
654 _ => {}
655 }
656 walk_owned_class_member(self, member)
657 }
658
659 fn visit_expr(&mut self, expr: &php_ast::owned::Expr) -> ControlFlow<()> {
660 match &expr.kind {
661 ExprKind::New(n) => {
662 if let ExprKind::Identifier(name) = &n.class.kind {
663 self.names.insert(name.as_ref().to_string());
664 }
665 }
666 ExprKind::StaticMethodCall(c) => {
667 if let ExprKind::Identifier(name) = &c.class.kind {
668 self.names.insert(name.as_ref().to_string());
669 }
670 }
671 ExprKind::StaticPropertyAccess(a) => {
672 if let ExprKind::Identifier(name) = &a.class.kind {
673 self.names.insert(name.as_ref().to_string());
674 }
675 }
676 ExprKind::ClassConstAccess(a) => {
677 if let ExprKind::Identifier(name) = &a.class.kind {
678 self.names.insert(name.as_ref().to_string());
679 }
680 }
681 ExprKind::Binary(b) if b.op == BinaryOp::Instanceof => {
682 if let ExprKind::Identifier(name) = &b.right.kind {
683 self.names.insert(name.as_ref().to_string());
684 }
685 }
686 _ => {}
687 }
688 walk_owned_expr(self, expr)
689 }
690
691 // Walker routes every class/type-position Name here: type hints, catch types, extends/implements, trait use, attributes.
692 fn visit_name(&mut self, name: &php_ast::owned::Name) -> ControlFlow<()> {
693 let s = owned_name_str(name);
694 if !s.is_empty() {
695 self.names.insert(s);
696 }
697 ControlFlow::Continue(())
698 }
699 }
700 let mut v = V {
701 names: std::collections::HashSet::default(),
702 };
703 let _ = walk_owned_program(&mut v, program);
704 v.names.into_iter().collect()
705}