mir_analyzer/session/queries.rs
1use super::*;
2
3impl AnalysisSession {
4 /// Resolve a top-level symbol (class or function) to its declaration
5 /// location. Powers go-to-definition.
6 ///
7 /// **Side effects:** if the symbol isn't yet known, this may invoke the
8 /// configured [`crate::SourceProvider`] to fault in additional files and
9 /// mutate the salsa input set. Use [`Self::definition_of_cached`] for a
10 /// pure variant that only consults already-loaded state.
11 ///
12 /// Returns:
13 /// - `Ok(Location)` — symbol found with a source location
14 /// - `Err(NotFound)` — no such symbol in the codebase
15 /// - `Err(NoSourceLocation)` — symbol exists but has no recorded span
16 /// (e.g. some stub-only declarations)
17 pub fn definition_of(
18 &self,
19 symbol: &crate::Name,
20 ) -> Result<mir_types::Location, crate::SymbolLookupError> {
21 // Trigger any necessary lazy-load mutations before snapshotting.
22 match symbol {
23 crate::Name::Class(fqcn) => {
24 let _ = self.load_class(fqcn.as_ref());
25 }
26 crate::Name::Function(fqn) => {
27 let _ = self.load_class(fqn.as_ref());
28 }
29 crate::Name::Method { class, .. }
30 | crate::Name::Property { class, .. }
31 | crate::Name::ClassConstant { class, .. } => {
32 let _ = self.load_class(class.as_ref());
33 }
34 _ => {}
35 }
36 self.definition_of_cached(symbol)
37 }
38
39 /// Pure variant of [`Self::definition_of`]. Never invokes the
40 /// [`crate::SourceProvider`] and never mutates salsa inputs; resolves
41 /// only against state already loaded by `set_file_text` / `ingest_file`.
42 /// Returns `Err(NotFound)` when the symbol isn't in the loaded set, even
43 /// if a resolver could in principle map it.
44 pub fn definition_of_cached(
45 &self,
46 symbol: &crate::Name,
47 ) -> Result<mir_types::Location, crate::SymbolLookupError> {
48 let db = self.snapshot_db();
49 match symbol {
50 crate::Name::Class(fqcn) => {
51 let here = crate::db::Fqcn::from_str(&db, fqcn.as_ref());
52 let class = crate::db::find_class_like(&db, here)
53 .ok_or(crate::SymbolLookupError::NotFound)?;
54 class
55 .location()
56 .cloned()
57 .ok_or(crate::SymbolLookupError::NoSourceLocation)
58 }
59 crate::Name::Function(fqn) => {
60 let here = crate::db::Fqcn::from_str(&db, fqn.as_ref());
61 let f = crate::db::find_function(&db, here)
62 .ok_or(crate::SymbolLookupError::NotFound)?;
63 f.location
64 .clone()
65 .ok_or(crate::SymbolLookupError::NoSourceLocation)
66 }
67 crate::Name::Method { class, name }
68 | crate::Name::Property { class, name }
69 | crate::Name::ClassConstant { class, name } => {
70 crate::db::member_location(&db, class, name)
71 .ok_or(crate::SymbolLookupError::NotFound)
72 }
73 crate::Name::GlobalConstant(_) => Err(crate::SymbolLookupError::NoSourceLocation),
74 }
75 }
76
77 /// Hover information for a symbol: type, docstring, and definition location.
78 ///
79 /// Use [`crate::FileAnalysis::symbol_at`] to find the symbol at a cursor
80 /// position, then build a [`crate::Name`] from its `kind`. This method
81 /// assembles the displayable hover data.
82 ///
83 /// **Side effects:** when `symbol`'s owning class isn't yet loaded, this
84 /// may invoke the configured [`crate::SourceProvider`] to fault in
85 /// dependencies. Use [`Self::hover_cached`] for a pure variant.
86 ///
87 /// Returns `Err(NotFound)` if the symbol doesn't exist. May still return
88 /// `Ok` with `docstring: None` or `definition: None` if those specific
89 /// pieces aren't available.
90 pub fn hover(
91 &self,
92 symbol: &crate::Name,
93 ) -> Result<crate::HoverInfo, crate::SymbolLookupError> {
94 // Trigger lazy loading for class-rooted symbols before snapshotting.
95 // No-op when the class is already known; ensures inherited member
96 // lookups have the chain present.
97 match symbol {
98 crate::Name::Class(fqcn) => {
99 self.load_class(fqcn.as_ref());
100 }
101 crate::Name::Method { class, .. }
102 | crate::Name::Property { class, .. }
103 | crate::Name::ClassConstant { class, .. } => {
104 // Fault in the owning class for navigation if the background
105 // indexer hasn't reached it yet. Its inheritance ancestors
106 // resolve through the (eagerly-built) workspace symbol index.
107 self.load_class(class.as_ref());
108 }
109 _ => {}
110 }
111 self.hover_cached(symbol)
112 }
113
114 /// Pure variant of [`Self::hover`]. Never invokes the
115 /// [`crate::SourceProvider`]; consults only the already-loaded db.
116 pub fn hover_cached(
117 &self,
118 symbol: &crate::Name,
119 ) -> Result<crate::HoverInfo, crate::SymbolLookupError> {
120 use mir_types::{Atomic, Type};
121 let db = self.snapshot_db();
122 match symbol {
123 crate::Name::Function(fqn) => {
124 let here = crate::db::Fqcn::from_str(&db, fqn.as_ref());
125 let f = crate::db::find_function(&db, here)
126 .ok_or(crate::SymbolLookupError::NotFound)?;
127 let ty = f
128 .return_type
129 .as_deref()
130 .cloned()
131 .unwrap_or_else(Type::mixed);
132 let docstring = f.docstring.as_ref().map(|s| s.to_string());
133 Ok(crate::HoverInfo {
134 ty,
135 docstring,
136 definition: f.location.clone(),
137 })
138 }
139 crate::Name::Method { class, name } => {
140 let here = crate::db::Fqcn::from_str(&db, class.as_ref());
141 let (_, m) = crate::db::find_method_in_chain(&db, here, name)
142 .ok_or(crate::SymbolLookupError::NotFound)?;
143 let ty = m
144 .return_type
145 .as_deref()
146 .cloned()
147 .unwrap_or_else(Type::mixed);
148 let docstring = m.docstring.as_ref().map(|s| s.to_string());
149 Ok(crate::HoverInfo {
150 ty,
151 docstring,
152 definition: m.location.clone(),
153 })
154 }
155 crate::Name::Class(fqcn) => {
156 let here = crate::db::Fqcn::from_str(&db, fqcn.as_ref());
157 let class = crate::db::find_class_like(&db, here)
158 .ok_or(crate::SymbolLookupError::NotFound)?;
159 let ty = Type::single(Atomic::TNamedObject {
160 fqcn: mir_types::Name::from(fqcn.as_ref()),
161 type_params: mir_types::union::empty_type_params(),
162 });
163 Ok(crate::HoverInfo {
164 ty,
165 docstring: None,
166 definition: class.location().cloned(),
167 })
168 }
169 crate::Name::Property { class, name } => {
170 let here = crate::db::Fqcn::from_str(&db, class.as_ref());
171 let (_, p) = crate::db::find_property_in_chain(&db, here, name)
172 .ok_or(crate::SymbolLookupError::NotFound)?;
173 let ty = p.ty.as_deref().cloned().unwrap_or_else(Type::mixed);
174 Ok(crate::HoverInfo {
175 ty,
176 docstring: None,
177 definition: p.location.clone(),
178 })
179 }
180 crate::Name::ClassConstant { class, name } => {
181 let here = crate::db::Fqcn::from_str(&db, class.as_ref());
182 let (_, c) = crate::db::find_class_constant_in_chain(&db, here, name)
183 .ok_or(crate::SymbolLookupError::NotFound)?;
184 Ok(crate::HoverInfo {
185 ty: c.ty.clone(),
186 docstring: None,
187 definition: c.location.clone(),
188 })
189 }
190 crate::Name::GlobalConstant(fqn) => {
191 let here = crate::db::Fqcn::from_str(&db, fqn.as_ref());
192 let ty = crate::db::find_global_constant(&db, here)
193 .ok_or(crate::SymbolLookupError::NotFound)?;
194 Ok(crate::HoverInfo {
195 ty: (*ty).clone(),
196 docstring: None,
197 definition: None,
198 })
199 }
200 }
201 }
202
203 /// Raw reference locations indexed by string symbol key, kept for tests
204 /// that use the legacy stringly-typed API. Prefer [`Self::indexed_references_to`]
205 /// with a typed [`crate::Name`].
206 #[doc(hidden)]
207 pub fn reference_locations(&self, symbol: &str) -> Vec<(Arc<str>, u32, u16, u16)> {
208 use crate::db::MirDatabase;
209 let db = self.snapshot_db();
210 db.reference_locations(symbol)
211 }
212
213 /// Files declaring transitive subclasses of `class_fqn`, backed by the
214 /// maintained subtype index (see [`Self::indexed_subtype_classes`]).
215 /// Excludes `class_fqn`'s own declaring file — the caller adds it.
216 ///
217 /// Lets a reference-search caller scope a `protected` member to its class
218 /// hierarchy without reconstructing that hierarchy from declaration text:
219 /// subclasses are matched by resolved FQCN, so `extends \Ns\Base` and
220 /// aliased `use` forms are all found. Read-only from the caller's
221 /// perspective; may trigger an on-demand commit of stale/uncommitted
222 /// candidates' class edges (same self-heal `indexed_subtype_classes` uses).
223 pub fn subtype_files(&self, class_fqn: &str) -> Vec<Arc<str>> {
224 self.settle_workspace_index();
225 let files = self.snapshot_db().source_file_paths();
226 let mut out: Vec<Arc<str>> = self
227 .indexed_subtype_classes(class_fqn, &files, false)
228 .into_iter()
229 .map(|s| s.file)
230 .collect();
231 out.sort();
232 out.dedup();
233 out
234 }
235
236 /// Compatibility wrapper for callers that only want `use` import items.
237 /// Delegates to [`Self::indexed_references_to`] so import references share
238 /// the same freshness/self-heal path, scope filtering, and memoization
239 /// boundary as every other reference query.
240 pub fn indexed_use_import_locations(
241 &self,
242 symbol: &crate::Name,
243 files: &[Arc<str>],
244 ) -> Vec<(Arc<str>, crate::Range)> {
245 self.indexed_references_to(
246 symbol,
247 files,
248 false,
249 crate::ReferenceIncludes::UseImports,
250 &|| false,
251 )
252 .unwrap_or_default()
253 }
254
255 /// Inverted-index find-references: posting-list lookup plus an on-demand
256 /// freshness/completeness pass over `files` (the host's candidate scope
257 /// — passing the whole workspace is fine; see the gate below).
258 ///
259 /// A candidate whose postings were committed from its current input text
260 /// (Arc identity) is answered from the index with no salsa work at all.
261 /// Stale or never-committed candidates are analyzed via the memoized
262 /// `analyze_file` query and committed, so each file pays that cost once
263 /// per text change — after a background warm sweep the steady state is a
264 /// pure lookup, O(results) instead of O(candidates). Never-committed
265 /// candidates are additionally gated on their raw text mentioning the
266 /// symbol's name (whole-identifier, ASCII-case-insensitive), so hosts
267 /// need no text prefilter of their own — and must not use one, since a
268 /// host-side filter cannot know these matching semantics.
269 ///
270 /// Results are filtered to `files` (the host controls scope — e.g.
271 /// workspace files only, excluding stubs/vendor). With
272 /// `include_declaration`, the symbol's declaration name span is appended
273 /// when it lies inside the scope.
274 ///
275 /// Returned ranges use mir's native coordinates: 1-based lines and
276 /// 0-based Unicode code-point columns (UTF-32/LSP `positionEncoding`
277 /// `"utf-32"`), not UTF-8 byte offsets or UTF-16 code units.
278 ///
279 /// `should_cancel` is polled at phase boundaries and between
280 /// cancellation retries; `true` aborts with `None`.
281 ///
282 /// Memoized per `(symbol, files, include_declaration, includes,
283 /// text_revision)` — see [`RefQueryCacheKey`]. The freshness scan below
284 /// still costs O(candidates) even when every candidate is already
285 /// committed (it has to check), so a caller re-running the same query
286 /// against unchanged state (e.g. a host recomputing reference counts for
287 /// a code-lens refresh) would otherwise re-pay that scan on every call;
288 /// this makes the repeat a single hashmap lookup instead.
289 pub fn indexed_references_to(
290 &self,
291 symbol: &crate::Name,
292 files: &[Arc<str>],
293 include_declaration: bool,
294 includes: crate::ReferenceIncludes,
295 should_cancel: &(dyn Fn() -> bool + Sync),
296 ) -> Option<Vec<(Arc<str>, crate::Range)>> {
297 // No `should_cancel()` check here: a cache hit does no analysis
298 // work, so there's nothing to cancel, and calling it would consume
299 // one of the caller's cancellation-probe invocations before the
300 // uncached path's own checks ever run (some callers, e.g.
301 // `session_sweep_persists_postings_for_next_launch`, count these to
302 // prove a warm query needed no re-analysis).
303 let cache_key = RefQueryCacheKey {
304 symbol: symbol.codebase_key(),
305 include_declaration,
306 includes,
307 generation: self.query_cache_generation(),
308 files_hash: hash_files(files),
309 };
310 if let Some(cached) = self.ref_query_cache.read().get(&cache_key) {
311 self.ref_query_cache_hits
312 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
313 return Some((**cached).clone());
314 }
315 let result = self.indexed_references_to_uncached(
316 symbol,
317 files,
318 include_declaration,
319 includes,
320 should_cancel,
321 )?;
322 if self.query_cache_generation() != cache_key.generation {
323 // The generation moved while computing (an edit, or a defs
324 // commit growing the subtype index — possibly this query's own).
325 // The key can never be looked up again, so don't cache it; the
326 // next identical query recomputes once against settled state
327 // and caches then.
328 return Some(result);
329 }
330 let mut cache = self.ref_query_cache.write();
331 if !cache.advance_to(cache_key.generation, || {
332 self.ref_query_cache_locations
333 .store(0, std::sync::atomic::Ordering::Relaxed);
334 }) {
335 return Some(result);
336 }
337 let new_len = result.len();
338 let prior = self
339 .ref_query_cache_locations
340 .fetch_add(new_len, std::sync::atomic::Ordering::Relaxed);
341 if prior + new_len > REF_QUERY_CACHE_LOCATION_CAP {
342 cache.map.clear();
343 self.ref_query_cache_locations
344 .store(new_len, std::sync::atomic::Ordering::Relaxed);
345 }
346 cache.map.insert(cache_key, Arc::new(result.clone()));
347 Some(result)
348 }
349
350 /// Uncached implementation of [`Self::indexed_references_to`]. Callers
351 /// should use the memoizing wrapper; this is split out only so the cache
352 /// check/populate logic doesn't have to interleave with the retry loops
353 /// below.
354 fn indexed_references_to_uncached(
355 &self,
356 symbol: &crate::Name,
357 files: &[Arc<str>],
358 include_declaration: bool,
359 includes: crate::ReferenceIncludes,
360 should_cancel: &(dyn Fn() -> bool + Sync),
361 ) -> Option<Vec<(Arc<str>, crate::Range)>> {
362 self.settle_workspace_index();
363 use std::panic::AssertUnwindSafe;
364
365 use rayon::prelude::*;
366
367 let key = symbol.codebase_key();
368
369 // Freshness pass: candidates whose postings are not exact for their
370 // current text. Files not registered as `SourceFile` inputs are
371 // skipped. Never-committed files — no commit mark, hence no postings
372 // at all (every mark drop accompanies a posting clear) — are further
373 // gated on their text mentioning the symbol's name: such a file can
374 // neither hold stale postings nor produce new ones, so a cold query
375 // on a common name skips the bulk of the workspace instead of
376 // analyzing it. A LIVE-analyzed file stale only by generation
377 // (unresolved-name commit, text unchanged since) gets the same gate:
378 // the current text is exactly what this session's own analysis
379 // already scanned, so a needle miss is just as conclusive as for a
380 // never-committed file. Everything else — a genuinely edited file, or
381 // a commit seeded by an unverified disk-cache replay
382 // (`warm_start_files`, never scanned by this session) — re-analyzes
383 // unconditionally: an edited file's old postings are for different
384 // text, and a replayed commit's postings are only as trustworthy as
385 // the cache entry, so neither can be cleared by a textual gate alone.
386 // Same discipline as `commit_defs_for_matching` on the defs index.
387 //
388 let gate = self.reference_gate(symbol);
389 // The whole gate — identifier needles and raw call tokens alike —
390 // answers from the persistent mention index: every needle is admitted
391 // (a declared class-like short name is already in the universe;
392 // member/function names and the two `__construct` call tokens enter
393 // verbatim), so a recorded mention set answers with lookups and only
394 // a never-scanned or since-edited file pays one scan against the
395 // whole universe, recorded for every later consumer (including the
396 // subtype-BFS gate). A needle new to the universe epoch-invalidates
397 // older recordings for itself only — the first query on it rescans
398 // uncovered files once, the same cost the per-query scan paid every
399 // time before.
400 let has_needles = !gate.idents.is_empty() || !gate.raw.is_empty();
401 let (mention_queries, mention_scanner) = if has_needles {
402 let guard = self.db.salsa.read();
403 guard.add_literal_mention_names(gate.idents.iter().map(|s| s.as_str()));
404 guard.add_raw_mention_needles(gate.raw.iter().map(|s| s.as_str()));
405 let queries: Vec<_> = gate
406 .idents
407 .iter()
408 .chain(gate.raw.iter())
409 .filter_map(|s| guard.prepare_class_mention_query(s))
410 .collect();
411 (queries, guard.class_mention_scanner())
412 } else {
413 (Vec::new(), None)
414 };
415 // Admission guarantees a query per needle and a non-empty universe;
416 // anything else is defensive — the gate then admits every candidate
417 // (analyze rather than skip, the conservative direction).
418 let gate_complete = mention_queries.len() == gate.idents.len() + gate.raw.len()
419 && mention_scanner.is_some();
420 let committed_any: rustc_hash::FxHashSet<Arc<str>> =
421 self.ref_committed_keys().into_iter().collect();
422 type MentionScanRec = (Arc<str>, Arc<str>, Box<[mir_types::Name]>);
423 let (stale, scanned): (Vec<Arc<str>>, Vec<MentionScanRec>) = loop {
424 if should_cancel() {
425 return None;
426 }
427 let attempt = salsa::Cancelled::catch(AssertUnwindSafe(|| {
428 let current_gen = self.index_generation();
429 let db_main = self.snapshot_db();
430 files
431 .par_iter()
432 .map_with(db_main, |db, f| {
433 let Some(sf) = db.lookup_source_file(f.as_ref()) else {
434 return (None, None);
435 };
436 let text = sf.text(&*db as &dyn MirDatabase);
437 if self.is_ref_committed(f.as_ref(), text, current_gen) {
438 return (None, None);
439 }
440 if committed_any.contains(f.as_ref())
441 && !self.ref_commit_stale_by_generation_only(f.as_ref(), text)
442 {
443 return (Some(f.clone()), None);
444 }
445 if has_needles && gate_complete {
446 // Any needle answering `true` admits the file; an
447 // unanswerable one forces the single recorded
448 // scan, which settles every needle at once.
449 let mut answer = Some(false);
450 for q in &mention_queries {
451 match db.class_mention_answer(f.as_ref(), q, text) {
452 Some(true) => {
453 answer = Some(true);
454 break;
455 }
456 Some(false) => {}
457 None => answer = None,
458 }
459 }
460 match answer {
461 Some(true) => {}
462 Some(false) => return (None, None),
463 None => {
464 let scanner = mention_scanner
465 .as_ref()
466 .expect("gate_complete implies a scanner");
467 let names = scanner.scan(text);
468 let hit = mention_queries
469 .iter()
470 .any(|q| names.binary_search(&q.name).is_ok());
471 let rec = (f.clone(), text.clone(), names);
472 return (hit.then(|| f.clone()), Some(rec));
473 }
474 }
475 }
476 (Some(f.clone()), None)
477 })
478 .collect::<Vec<_>>()
479 }));
480 match attempt {
481 Ok(v) => {
482 let mut stale = Vec::new();
483 let mut scanned = Vec::new();
484 for (s, rec) in v {
485 if let Some(s) = s {
486 stale.push(s);
487 }
488 if let Some(rec) = rec {
489 scanned.push(rec);
490 }
491 }
492 break (stale, scanned);
493 }
494 Err(_) if should_cancel() => return None,
495 Err(_) => {}
496 }
497 };
498
499 // Record the fallback scans regardless of how the query proceeds:
500 // each is a complete, current mention set for its file.
501 if let Some(scanner) = &mention_scanner {
502 if !scanned.is_empty() {
503 let guard = self.db.salsa.read();
504 for (file, text, names) in scanned {
505 guard.set_file_class_mentions(&file, &text, scanner.epoch(), names);
506 }
507 }
508 }
509
510 if !stale.is_empty() {
511 // Phase 1 (serial, no live snapshot held): warm up stale
512 // candidates. `prepare_file_for_analysis` mutates salsa inputs
513 // (via `load_class`), so a concurrent writer — the background
514 // warm sweep, or another request — can raise `salsa::Cancelled`
515 // partway through a file. Catch and retry the SAME file here
516 // rather than letting the panic escape: uncaught, it would force
517 // the caller's outer retry loop (`indexed_references`) to
518 // re-enter from scratch, redoing the freshness pass and
519 // re-walking every already-warmed file in `stale` (cheap no-ops
520 // via the `prepared_files` cache, but not free) before it even
521 // gets back to the file that was interrupted. This doesn't
522 // change how many times a write is ultimately attempted (the
523 // outer loop already retries indefinitely on `Cancelled`); it
524 // only narrows what a single cancellation discards from "the
525 // whole query so far" to "the one file that was mid-flight".
526 //
527 // Tried and reverted TWICE: running this loop itself in parallel
528 // (rayon; per-file and whole-batch retry variants, and again as
529 // a `try_for_each` after the deferred-bump scope landed). Each
530 // file's warm-up is individually safe under concurrent access
531 // (every shared registry it touches — `prepared_files`,
532 // `unresolvable_fqcns`, `pending_eager_function_files`, the
533 // salsa db via `with_db_mut` — is lock-protected), but under the
534 // `concurrent_reference_cancel` stress test (sustained
535 // multi-thread writers + a background indexer, both hammering
536 // the same db while several readers each run this phase
537 // concurrently) every parallel variant deadlocks: CPU usage
538 // drops to ~0 while wall time keeps climbing — OS threads parked
539 // on a lock, most likely the fixed-size rayon pool saturated
540 // with workers blocked on `with_db_mut`'s `RwLock` write lock
541 // (an OS-level block, invisible to rayon's cooperative
542 // scheduler) while the thread that would release it is itself
543 // queued waiting for a free pool worker. Coalescing the
544 // per-load revision bumps into one per pass (the deferred scope
545 // below) did NOT fix it — the re-attempt hung the same way
546 // (>590s for a ~5s test), so the cancellation storm was not the
547 // trigger. Serial execution never contends for the pool this
548 // way, so it stays the safe choice here even though it forgoes
549 // the extra wall-clock parallelism a large stale set could
550 // otherwise use.
551 {
552 // One revision bump for the whole warm-up loop instead of one
553 // per lazily-loaded class: each bump is a salsa input write
554 // that cancels every in-flight reader (a concurrent request's
555 // Phase 2 pass restarts per bump). The scope closes before
556 // Phase 2 reads `index_generation`, so commits below are
557 // stamped with the post-load generation as before.
558 let _deferred_bumps = self.defer_revision_bumps();
559 for path in &stale {
560 loop {
561 if should_cancel() {
562 return None;
563 }
564 match salsa::Cancelled::catch(AssertUnwindSafe(|| {
565 self.prepare_file_for_analysis(path)
566 })) {
567 Ok(()) => break,
568 Err(_) if should_cancel() => return None,
569 Err(_) => {}
570 }
571 }
572 }
573 }
574
575 // Phase 2 (parallel, pure) under a cancellation retry loop, then
576 // a serial commit into both inverted indexes.
577 let (commit_gen, analyzed) = loop {
578 if should_cancel() {
579 return None;
580 }
581 // Generation before the snapshot: a file add racing the
582 // analysis leaves these commits stale (self-healing on the
583 // next query), never wrongly fresh.
584 let gen = self.index_generation();
585 let attempt = salsa::Cancelled::catch(AssertUnwindSafe(|| {
586 // Freeze on the pass-scoped snapshot (borrow-only symbol
587 // lookups + pass-shared subtype cache): all lazy-loading
588 // finished in Phase 1, and a concurrent index write
589 // cancels this attempt, so the frozen view is never
590 // stale. Same discipline as the batch body pass.
591 let mut db_main = self.snapshot_db();
592 db_main.freeze_workspace_index();
593 stale
594 .iter()
595 .filter_map(|path| {
596 let sf = db_main.lookup_source_file(path.as_ref())?;
597 let text = sf.text(&db_main as &dyn MirDatabase).clone();
598 let out =
599 crate::db::analyze_file(&db_main as &dyn MirDatabase, sf).clone();
600 let defs = crate::db::collect_file_definitions(
601 &db_main as &dyn MirDatabase,
602 sf,
603 );
604 let entries = crate::db::subtype_index::entries_from_slice(&defs.slice);
605 // Stage the disk-cache write only when the commit
606 // below will rewrite postings (see the sweep in
607 // `reanalyze_file_set` for the cost rationale).
608 let put = if self.ref_commit_is_current(path.as_ref(), &text, &out) {
609 None
610 } else {
611 self.stage_ref_cache_put(
612 &db_main as &dyn MirDatabase,
613 sf,
614 path.as_ref(),
615 &text,
616 &out,
617 )
618 };
619 // Mention scan piggybacks on the analysis pass
620 // (pure; committed serially below), skipped when
621 // the file already holds a current scan.
622 let mentions = mention_scanner.as_ref().and_then(|s| {
623 (!db_main.class_mentions_current(path.as_ref(), &text, s.epoch()))
624 .then(|| s.scan(&text))
625 });
626 Some((path.clone(), text, out, entries, put, mentions))
627 })
628 .collect::<Vec<_>>()
629 }));
630 match attempt {
631 Ok(v) => break (gen, v),
632 Err(_) if should_cancel() => return None,
633 Err(_) => {}
634 }
635 };
636 let mut analyzed = analyzed;
637 let guard = self.db.salsa.read();
638 for (file, text, out, entries, put, mentions) in analyzed.iter_mut() {
639 // Pointer-identical memo ⇒ identical postings: skip the
640 // index rewrite and only re-stamp the freshness mark.
641 if !self.ref_commit_is_current(file.as_ref(), text, out) {
642 guard.set_file_reference_locations(file.as_ref(), out.ref_locs.to_vec());
643 }
644 if let (Some(s), Some(m)) = (&mention_scanner, mentions.take()) {
645 guard.set_file_class_mentions(file, text, s.epoch(), m);
646 }
647 if let Some(put) = put.take() {
648 self.apply_ref_cache_put(file.as_ref(), out, put);
649 }
650 self.mark_ref_committed(
651 file,
652 text,
653 Some(out),
654 commit_gen,
655 !out.has_unresolved_names(),
656 );
657 if !self.is_defs_committed(file.as_ref(), text) {
658 guard.set_file_class_edges(file, entries.clone());
659 self.mark_defs_committed(file, text);
660 }
661 }
662 }
663
664 // Posting lookup, filtered to the candidate scope.
665 //
666 // Member symbols resolve against the queried class plus its hierarchy
667 // (mir records member refs under the *declaring* class, so a query on
668 // an interface method must include implementor keys and vice versa).
669 // Name-only fallback postings — receivers whose type couldn't be
670 // resolved — are consulted only when the typed keys produce nothing,
671 // mirroring the pre-index two-tier behavior: exact results when
672 // resolution succeeds, by-name matches when nothing resolves.
673 // `__construct` stays exact: `new Sub()` invokes `Sub::__construct`
674 // even when only a parent declares one, so hierarchy fan-out would
675 // wrongly return subtype instantiation sites for a parent query.
676 let hierarchy: Vec<String> = match symbol {
677 crate::Name::Method { class, name } => {
678 if name.as_ref() == "__construct" || class.is_empty() {
679 if class.is_empty() {
680 Vec::new()
681 } else {
682 vec![class.trim_start_matches('\\').to_string()]
683 }
684 } else {
685 self.member_hierarchy_classes(class.as_ref())
686 }
687 }
688 crate::Name::Property { class, .. } | crate::Name::ClassConstant { class, .. } => {
689 if class.is_empty() {
690 Vec::new()
691 } else {
692 self.member_hierarchy_classes(class.as_ref())
693 }
694 }
695 _ => Vec::new(),
696 };
697 let scope: rustc_hash::FxHashSet<&str> = files.iter().map(|f| f.as_ref()).collect();
698 let read_symbol_key = |symbol_key: &str| -> Vec<(Arc<str>, crate::Range)> {
699 let guard = self.db.salsa.read();
700 guard
701 .reference_locations(symbol_key)
702 .into_iter()
703 .filter(|(file, ..)| scope.contains(file.as_ref()))
704 .map(|(file, line, col_start, col_end)| {
705 (file, span_range(line, col_start as u32, col_end as u32))
706 })
707 .collect()
708 };
709 let mut scratch_key = String::new();
710 let mut read_composed_key = |prefix: &str, middle: &str, separator: &str, suffix: &str| {
711 scratch_key.clear();
712 scratch_key.reserve(prefix.len() + middle.len() + separator.len() + suffix.len());
713 scratch_key.push_str(prefix);
714 scratch_key.push_str(middle);
715 scratch_key.push_str(separator);
716 scratch_key.push_str(suffix);
717 read_symbol_key(&scratch_key)
718 };
719 let mut out: Vec<(Arc<str>, crate::Range)> = Vec::new();
720 let mut append_plain = || match symbol {
721 crate::Name::Method { name, .. } => {
722 for class in &hierarchy {
723 out.extend(read_composed_key(
724 "meth:",
725 class.as_ref(),
726 "::",
727 name.as_ref(),
728 ));
729 }
730 }
731 crate::Name::Property { name, .. } => {
732 for class in &hierarchy {
733 out.extend(read_composed_key(
734 "prop:",
735 class.as_ref(),
736 "::",
737 name.as_ref(),
738 ));
739 }
740 }
741 crate::Name::ClassConstant { name, .. } => {
742 for class in &hierarchy {
743 out.extend(read_composed_key(
744 "cnst:",
745 class.as_ref(),
746 "::",
747 name.as_ref(),
748 ));
749 }
750 }
751 _ => out.extend(read_symbol_key(key.as_str())),
752 };
753 match includes {
754 crate::ReferenceIncludes::Plain => append_plain(),
755 crate::ReferenceIncludes::UseImports => {
756 out.extend(read_composed_key("use:", key.as_str(), "", ""));
757 }
758 crate::ReferenceIncludes::PlainAndUseImports => {
759 append_plain();
760 out.extend(read_composed_key("use:", key.as_str(), "", ""));
761 }
762 }
763 if matches!(includes, crate::ReferenceIncludes::Plain) && out.is_empty() {
764 match symbol {
765 crate::Name::Method { name, .. } => {
766 out = read_composed_key("methname:", name.as_ref(), "", "");
767 }
768 crate::Name::Property { name, .. } => {
769 out = read_composed_key("propname:", name.as_ref(), "", "");
770 }
771 _ => {}
772 }
773 }
774 out.sort_by(|a, b| {
775 a.0.cmp(&b.0)
776 .then(a.1.start.line.cmp(&b.1.start.line))
777 .then(a.1.start.column.cmp(&b.1.start.column))
778 });
779 out.dedup_by(|a, b| a.0 == b.0 && a.1 == b.1);
780
781 if include_declaration {
782 // Declaration lookup runs salsa queries (and may lazy-load); a
783 // concurrent write cancels it — declarations are then simply
784 // omitted rather than failing the whole request.
785 let decls: Vec<(Arc<str>, crate::Range)> = match symbol {
786 crate::Name::Method { class, .. }
787 | crate::Name::Property { class, .. }
788 | crate::Name::ClassConstant { class, .. } => {
789 if class.is_empty() {
790 // Unknown owner: declarations by name, recorded as
791 // `methdecl:`/`propdecl:`/`cnstdecl:` postings during
792 // class/trait/interface/enum analysis.
793 match symbol {
794 crate::Name::Method { name, .. } => {
795 read_composed_key("methdecl:", name.as_ref(), "", "")
796 }
797 crate::Name::Property { name, .. } => {
798 read_composed_key("propdecl:", name.as_ref(), "", "")
799 }
800 crate::Name::ClassConstant { name, .. } => {
801 read_composed_key("cnstdecl:", name.as_ref(), "", "")
802 }
803 _ => Vec::new(),
804 }
805 } else {
806 salsa::Cancelled::catch(AssertUnwindSafe(|| {
807 self.member_decl_sites(&hierarchy, symbol)
808 }))
809 .unwrap_or_default()
810 }
811 }
812 _ => salsa::Cancelled::catch(AssertUnwindSafe(|| {
813 self.declaration_name_range(symbol).into_iter().collect()
814 }))
815 .unwrap_or_default(),
816 };
817 for (file, range) in decls {
818 if scope.contains(file.as_ref())
819 && !out.iter().any(|(f, r)| *f == file && *r == range)
820 {
821 out.push((file, range));
822 }
823 }
824 }
825 Some(out)
826 }
827
828 /// The queried class plus every class its members' references could be
829 /// keyed under: resolved ancestors (a call on a subtype instance records
830 /// the declaring ancestor) and transitive subtypes including trait users
831 /// (a call on a subtype that overrides records the subtype). Display-form
832 /// FQCNs, deduplicated case-insensitively.
833 fn member_hierarchy_classes(&self, class_fqn: &str) -> Vec<String> {
834 use std::panic::AssertUnwindSafe;
835 let target = class_fqn.trim_start_matches('\\').to_string();
836 let mut out: Vec<String> = vec![target.clone()];
837 let ancestors = salsa::Cancelled::catch(AssertUnwindSafe(|| {
838 let db = self.snapshot_db();
839 let here = crate::db::Fqcn::from_str(&db, &target);
840 crate::db::class_ancestors_by_fqcn(&db, here)
841 .iter()
842 .skip(1)
843 .map(|a| a.trim_start_matches('\\').to_string())
844 .collect::<Vec<_>>()
845 }))
846 .unwrap_or_default();
847 out.extend(ancestors);
848 let subs = {
849 let guard = self.db.salsa.read();
850 guard.subtype_sites_of(&target, true)
851 };
852 out.extend(
853 subs.into_iter()
854 .map(|s| s.fqcn.trim_start_matches('\\').to_string()),
855 );
856 let mut seen: rustc_hash::FxHashSet<String> = rustc_hash::FxHashSet::default();
857 out.retain(|c| seen.insert(c.to_ascii_lowercase()));
858 out
859 }
860
861 /// Own-member declaration sites for `symbol` across `classes`: each class
862 /// that itself declares the member (not inherited) contributes its name
863 /// token. Kind-specific lookups — a class often declares a property and a
864 /// method with the same short name, and `member_location` can't tell them
865 /// apart.
866 fn member_decl_sites(
867 &self,
868 classes: &[String],
869 symbol: &crate::Name,
870 ) -> Vec<(Arc<str>, crate::Range)> {
871 let mut out: Vec<(Arc<str>, crate::Range)> = Vec::new();
872 let db = self.snapshot_db();
873 for class in classes {
874 let here = crate::db::Fqcn::from_str(&db, class);
875 let (loc, needle) = match symbol {
876 crate::Name::Method { name, .. } => {
877 let Some(m) = crate::db::find_method_in_class(&db, here, name) else {
878 continue;
879 };
880 (m.location.clone(), name.to_string())
881 }
882 crate::Name::Property { name, .. } => {
883 let Some(p) = crate::db::find_property_in_class(&db, here, name) else {
884 continue;
885 };
886 (p.location.clone(), name.to_string())
887 }
888 crate::Name::ClassConstant { name, .. } => {
889 let Some(c) = crate::db::find_class_constant_in_class(&db, here, name) else {
890 continue;
891 };
892 (c.location.clone(), name.to_string())
893 }
894 _ => continue,
895 };
896 let Some(loc) = loc else { continue };
897 let range = self.refine_location_to_name(&loc, &needle);
898 out.push((loc.file.clone(), range));
899 }
900 out
901 }
902
903 /// The symbol's declaration site, narrowed from the collector's
904 /// whole-declaration span to the declared name's own token (matching the
905 /// span shape of recorded references).
906 pub fn declaration_name_range(&self, symbol: &crate::Name) -> Option<(Arc<str>, crate::Range)> {
907 if let crate::Name::GlobalConstant(fqn) = symbol {
908 return self.global_constant_decl_range(fqn);
909 }
910 let loc = self.definition_of(symbol).ok()?;
911 let short = match symbol {
912 crate::Name::Class(f) | crate::Name::Function(f) | crate::Name::GlobalConstant(f) => {
913 crate::db::subtype_index::short_name_of(f)
914 }
915 crate::Name::Method { name, .. }
916 | crate::Name::Property { name, .. }
917 | crate::Name::ClassConstant { name, .. } => name.as_ref(),
918 };
919 // Property declarations carry a `$` sigil in source, but reference
920 // ranges cover the bare name; the word-boundary search below lands on
921 // the name right after the sigil.
922 let file = loc.file.clone();
923 let range = self.refine_location_to_name(&loc, short);
924 Some((file, range))
925 }
926
927 /// Narrow a whole-declaration [`mir_types::Location`] to the first
928 /// word-boundary occurrence of `needle` inside its line span. Falls back
929 /// to the location's own coordinates when the text is unavailable or the
930 /// name doesn't appear (e.g. stub-only declarations).
931 fn refine_location_to_name(&self, loc: &mir_types::Location, needle: &str) -> crate::Range {
932 let fallback = span_range(loc.line, loc.col_start as u32, loc.col_end as u32);
933 let text = {
934 let db = self.snapshot_db();
935 db.lookup_source_file(loc.file.as_ref())
936 .map(|sf| sf.text(&db as &dyn MirDatabase).clone())
937 };
938 let Some(text) = text else {
939 return fallback;
940 };
941 let needle_chars = needle.chars().count() as u32;
942 let first_line = loc.line.saturating_sub(1) as usize;
943 // Exact-case first: PHP property/constant names are case-sensitive
944 // and an early case-insensitive hit can land on an unrelated token
945 // (a type hint sharing the name). Case-insensitive second, for
946 // method/class needles that arrive lowercase-normalized.
947 for case_insensitive in [false, true] {
948 for (idx, line_text) in text.lines().enumerate().skip(first_line) {
949 let line_no = idx as u32 + 1;
950 if line_no > loc.line_end {
951 break;
952 }
953 let min_col = if line_no == loc.line {
954 loc.col_start as usize
955 } else {
956 0
957 };
958 if let Some(col) = identifier_char_col(line_text, needle, min_col, case_insensitive)
959 {
960 return span_range(line_no, col, col + needle_chars);
961 }
962 }
963 }
964 fallback
965 }
966
967 /// Transitive subtypes of `class_fqn` (classes/interfaces/enums whose
968 /// resolved ancestor chain reaches it), answered from the maintained
969 /// subtype edge index.
970 ///
971 /// `files` is the host's candidate scope for the on-demand completeness
972 /// pass: per BFS round, not-yet-committed files whose text mentions a
973 /// frontier name get their definitions committed, so results are complete
974 /// even before a background sweep has covered the workspace. Committed
975 /// files answer from the index with no parsing at all.
976 ///
977 /// `include_trait_users` also counts `use Trait;` composition as a
978 /// subtype edge (visibility-scoping semantics); leave it off for
979 /// goto-implementation semantics (extends/implements only).
980 ///
981 /// Memoized per `(class_fqn, include_trait_users, files, text_revision)`
982 /// — same shape and rationale as [`Self::indexed_references_to`]'s
983 /// cache: `commit_defs_for_matching`'s freshness pass costs O(candidates)
984 /// on every call regardless of outcome, so a repeat query (e.g. a host
985 /// resolving a protected/static method's reference scope on every
986 /// code-lens refresh) would otherwise re-pay it every time.
987 pub fn indexed_subtype_classes(
988 &self,
989 class_fqn: &str,
990 files: &[Arc<str>],
991 include_trait_users: bool,
992 ) -> Vec<SubtypeClassSite> {
993 let cache_key = SubtypeQueryCacheKey {
994 class_fqn: class_fqn.trim_start_matches('\\').to_ascii_lowercase(),
995 include_trait_users,
996 generation: self.query_cache_generation(),
997 files_hash: hash_files(files),
998 };
999 if let Some(cached) = self.subtype_query_cache.read().get(&cache_key) {
1000 self.subtype_query_cache_hits
1001 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1002 return (**cached).clone();
1003 }
1004 let result = self.indexed_subtype_classes_uncached(class_fqn, files, include_trait_users);
1005 if self.query_cache_generation() != cache_key.generation {
1006 // The generation moved while computing (an edit, or a defs
1007 // commit growing the subtype index — possibly this query's own).
1008 // The key can never be looked up again, so don't cache it; the
1009 // next identical query recomputes once against settled state
1010 // and caches then.
1011 return result;
1012 }
1013 let mut cache = self.subtype_query_cache.write();
1014 if !cache.advance_to(cache_key.generation, || {
1015 self.subtype_query_cache_sites
1016 .store(0, std::sync::atomic::Ordering::Relaxed);
1017 }) {
1018 return result;
1019 }
1020 let new_len = result.len();
1021 let prior = self
1022 .subtype_query_cache_sites
1023 .fetch_add(new_len, std::sync::atomic::Ordering::Relaxed);
1024 if prior + new_len > SUBTYPE_QUERY_CACHE_SITE_CAP {
1025 cache.map.clear();
1026 self.subtype_query_cache_sites
1027 .store(new_len, std::sync::atomic::Ordering::Relaxed);
1028 }
1029 cache.map.insert(cache_key, Arc::new(result.clone()));
1030 result
1031 }
1032
1033 /// Uncached implementation of [`Self::indexed_subtype_classes`]. Callers
1034 /// should use the memoizing wrapper.
1035 fn indexed_subtype_classes_uncached(
1036 &self,
1037 class_fqn: &str,
1038 files: &[Arc<str>],
1039 include_trait_users: bool,
1040 ) -> Vec<SubtypeClassSite> {
1041 self.settle_workspace_index();
1042 let mut scanned: rustc_hash::FxHashSet<String> = rustc_hash::FxHashSet::default();
1043 let mut pending: Vec<String> = vec![class_fqn.trim_start_matches('\\').to_string()];
1044 let mut sites: Vec<crate::db::SubtypeSite> = Vec::new();
1045 while !pending.is_empty() {
1046 let needles: Vec<String> = pending
1047 .drain(..)
1048 .filter(|f| scanned.insert(f.clone()))
1049 .map(|f| crate::db::subtype_index::short_name_of(&f).to_string())
1050 .collect();
1051 if !needles.is_empty() {
1052 self.commit_defs_for_matching(files, &needles);
1053 }
1054 sites = {
1055 let guard = self.db.salsa.read();
1056 guard.subtype_sites_of_lenient(class_fqn, include_trait_users)
1057 };
1058 pending = sites
1059 .iter()
1060 .map(|s| s.fqcn.trim_start_matches('\\').to_string())
1061 .filter(|f| !scanned.contains(f))
1062 .collect();
1063 }
1064 let mut out: Vec<SubtypeClassSite> = sites
1065 .into_iter()
1066 .filter_map(|s| {
1067 let loc = s.location.as_ref()?;
1068 let short = crate::db::subtype_index::short_name_of(&s.fqcn).to_string();
1069 let range = self.refine_location_to_name(loc, &short);
1070 Some(SubtypeClassSite {
1071 fqcn: s.fqcn,
1072 kind: s.kind,
1073 is_abstract: s.is_abstract,
1074 file: s.file,
1075 range,
1076 })
1077 })
1078 .collect();
1079 // Anonymous classes never reach the definition collector; their
1080 // `new class implements X {}` sites are recorded as `impl:` postings
1081 // during body analysis (exact FQCN key plus a short-name key for the
1082 // same written-form leniency named classes get above).
1083 let root_lc = class_fqn.trim_start_matches('\\').to_ascii_lowercase();
1084 let short_lc = crate::db::subtype_index::short_name_of(&root_lc).to_string();
1085 let scope: rustc_hash::FxHashSet<&str> = files.iter().map(|f| f.as_ref()).collect();
1086 let anon: Vec<(Arc<str>, u32, u16, u16)> = {
1087 let guard = self.db.salsa.read();
1088 let mut key = String::with_capacity("implshort:".len() + root_lc.len());
1089 key.push_str("impl:");
1090 key.push_str(&root_lc);
1091 let mut v = guard.reference_locations(&key);
1092 key.clear();
1093 key.push_str("implshort:");
1094 key.push_str(&short_lc);
1095 v.extend(guard.reference_locations(&key));
1096 v.sort();
1097 v.dedup();
1098 v
1099 };
1100 for (file, line, cs, ce) in anon {
1101 if !scope.contains(file.as_ref()) {
1102 continue;
1103 }
1104 let range = span_range(line, cs as u32, ce as u32);
1105 if out.iter().any(|s| s.file == file && s.range == range) {
1106 continue;
1107 }
1108 out.push(SubtypeClassSite {
1109 fqcn: Arc::from("class@anonymous"),
1110 kind: crate::db::ClassLikeKind::Class,
1111 is_abstract: false,
1112 file,
1113 range,
1114 });
1115 }
1116 out
1117 }
1118
1119 /// Concrete implementations of `class_fqn::method` across its transitive
1120 /// subtypes: the same-named non-abstract method available to each subtype
1121 /// (its own declaration, or one inherited/composed from a parent, trait,
1122 /// or mixin), as `(subtype fqcn, file, name range)`. Subtypes resolving to
1123 /// the same declaring location collapse to a single entry.
1124 pub fn indexed_method_implementations(
1125 &self,
1126 class_fqn: &str,
1127 method: &str,
1128 files: &[Arc<str>],
1129 ) -> Vec<(Arc<str>, Arc<str>, crate::Range)> {
1130 use std::panic::AssertUnwindSafe;
1131 let subs = self.indexed_subtype_classes(class_fqn, files, false);
1132 if subs.is_empty() {
1133 return Vec::new();
1134 }
1135 loop {
1136 let attempt = salsa::Cancelled::catch(AssertUnwindSafe(|| {
1137 let db = self.snapshot_db();
1138 let mut out: Vec<(Arc<str>, Arc<str>, crate::Range)> = Vec::new();
1139 for sub in &subs {
1140 let here = crate::db::Fqcn::from_str(&db, sub.fqcn.as_ref());
1141 let Some((_, m)) = crate::db::find_method_in_chain(&db, here, method) else {
1142 continue;
1143 };
1144 if m.is_abstract {
1145 continue;
1146 }
1147 let Some(loc) = m.location.as_ref() else {
1148 continue;
1149 };
1150 let range = self.refine_location_to_name(loc, method);
1151 out.push((sub.fqcn.clone(), loc.file.clone(), range));
1152 }
1153 out
1154 }));
1155 if let Ok(mut out) = attempt {
1156 out.sort_by(|a, b| a.1.cmp(&b.1).then(a.2.start.line.cmp(&b.2.start.line)));
1157 out.dedup_by(|a, b| a.1 == b.1 && a.2 == b.2);
1158 return out;
1159 }
1160 }
1161 }
1162
1163 /// Commit definitions (class edges + freshness) for every file in `files`
1164 /// that is stale (committed against older text) or that has never been
1165 /// committed and mentions one of `shorts` as a whole identifier.
1166 ///
1167 /// The textual gate answers from the shared per-file mention cache — the
1168 /// same one `indexed_references_to`'s gate populates — so a file scanned
1169 /// by either consumer answers the other with a set lookup instead of an
1170 /// O(text) rescan per BFS round. A file the cache can't answer for is
1171 /// scanned once against the whole name universe and recorded.
1172 fn commit_defs_for_matching(&self, files: &[Arc<str>], shorts: &[String]) {
1173 use std::panic::AssertUnwindSafe;
1174
1175 use rayon::prelude::*;
1176
1177 let committed_any: rustc_hash::FxHashSet<Arc<str>> = {
1178 let guard = self.defs_committed_keys();
1179 guard.into_iter().collect()
1180 };
1181 // Admit the frontier names before preparing, so every needle gets a
1182 // real query (a declared class's short name is already in the
1183 // universe from indexing — admission then changes nothing).
1184 let (queries, mention_scanner) = {
1185 let guard = self.db.salsa.read();
1186 guard.add_literal_mention_names(shorts.iter().map(|s| s.as_str()));
1187 let queries: Vec<_> = shorts
1188 .iter()
1189 .filter_map(|s| guard.prepare_class_mention_query(s))
1190 .collect();
1191 (queries, guard.class_mention_scanner())
1192 };
1193 // Admission guarantees a query per needle and a non-empty universe;
1194 // anything else is defensive — the gate then admits every candidate
1195 // (recommit rather than skip, the conservative direction).
1196 let use_mentions = queries.len() == shorts.len() && mention_scanner.is_some();
1197 type Work = (Arc<str>, Arc<str>, Vec<crate::db::SubtypeEntry>);
1198 type MentionScanRec = (Arc<str>, Arc<str>, Box<[mir_types::Name]>);
1199 let (work, scanned): (Vec<Work>, Vec<MentionScanRec>) = loop {
1200 let attempt = salsa::Cancelled::catch(AssertUnwindSafe(|| {
1201 let db_main = self.snapshot_db();
1202 files
1203 .par_iter()
1204 .map_with(db_main, |db, path| {
1205 let Some(sf) = db.lookup_source_file(path.as_ref()) else {
1206 return (None, None);
1207 };
1208 let text = sf.text(&*db as &dyn MirDatabase).clone();
1209 if self.is_defs_committed(path.as_ref(), &text) {
1210 return (None, None);
1211 }
1212 // Never-committed files must mention a frontier name;
1213 // stale (previously committed) files recommit
1214 // unconditionally — their classes may have re-parented.
1215 let mut scan_rec: Option<MentionScanRec> = None;
1216 if use_mentions && !committed_any.contains(path.as_ref()) {
1217 let mut answer = Some(false);
1218 for q in &queries {
1219 match db.class_mention_answer(path.as_ref(), q, &text) {
1220 Some(true) => {
1221 answer = Some(true);
1222 break;
1223 }
1224 Some(false) => {}
1225 None => answer = None,
1226 }
1227 }
1228 let hit = match answer {
1229 Some(hit) => hit,
1230 None => {
1231 // Uncoverable entry: one scan answers
1232 // every query and is recorded below.
1233 let scanner = mention_scanner.as_ref().unwrap();
1234 let names = scanner.scan(&text);
1235 let hit = queries
1236 .iter()
1237 .any(|q| names.binary_search(&q.name).is_ok());
1238 scan_rec = Some((path.clone(), text.clone(), names));
1239 hit
1240 }
1241 };
1242 if !hit {
1243 return (None, scan_rec);
1244 }
1245 }
1246 let defs =
1247 crate::db::collect_file_definitions(&*db as &dyn MirDatabase, sf);
1248 let entries = crate::db::subtype_index::entries_from_slice(&defs.slice);
1249 (Some((path.clone(), text, entries)), scan_rec)
1250 })
1251 .collect::<Vec<_>>()
1252 }));
1253 if let Ok(v) = attempt {
1254 let mut work = Vec::new();
1255 let mut scanned = Vec::new();
1256 for (w, rec) in v {
1257 if let Some(w) = w {
1258 work.push(w);
1259 }
1260 if let Some(rec) = rec {
1261 scanned.push(rec);
1262 }
1263 }
1264 break (work, scanned);
1265 }
1266 };
1267 // Record the fallback scans regardless of hit/miss: each is a
1268 // complete, current mention set for its file, so the next round's
1269 // (and the references gate's) checks become set lookups.
1270 if let Some(scanner) = &mention_scanner {
1271 if !scanned.is_empty() {
1272 let guard = self.db.salsa.read();
1273 for (file, text, names) in scanned {
1274 guard.set_file_class_mentions(&file, &text, scanner.epoch(), names);
1275 }
1276 }
1277 }
1278 if work.is_empty() {
1279 return;
1280 }
1281 let guard = self.db.salsa.read();
1282 for (file, text, entries) in &work {
1283 guard.set_file_class_edges(file, entries.clone());
1284 self.mark_defs_committed(file, text);
1285 }
1286 }
1287
1288 /// Declaration name span for a global constant. Constant slices carry no
1289 /// stored location, so this finds the declaring file via the workspace
1290 /// constants index and locates the `const NAME` / `define('NAME'` token
1291 /// textually.
1292 fn global_constant_decl_range(&self, fqn: &str) -> Option<(Arc<str>, crate::Range)> {
1293 use std::panic::AssertUnwindSafe;
1294 let short = crate::db::subtype_index::short_name_of(fqn).to_string();
1295 salsa::Cancelled::catch(AssertUnwindSafe(|| {
1296 let db = self.snapshot_db();
1297 let index = crate::db::workspace_index(&db);
1298 let loc = index.constant_loc(mir_types::Name::from(fqn.trim_start_matches('\\')))?;
1299 let file = loc.file().path(&db).clone();
1300 let sf = db.lookup_source_file(file.as_ref())?;
1301 let text = sf.text(&db as &dyn MirDatabase);
1302 for (idx, line) in text.lines().enumerate() {
1303 let trimmed = line.trim_start();
1304 let is_decl_line = trimmed.starts_with("const ")
1305 || trimmed.contains("define(")
1306 || trimmed.contains("define (");
1307 if !is_decl_line {
1308 continue;
1309 }
1310 if let Some(col) = identifier_char_col(line, &short, 0, false) {
1311 let n = short.chars().count() as u32;
1312 return Some((file, span_range(idx as u32 + 1, col, col + n)));
1313 }
1314 }
1315 None
1316 }))
1317 .ok()
1318 .flatten()
1319 }
1320
1321 /// Class-level issues (inheritance violations, abstract-method gaps, override
1322 /// incompatibilities) for the given set of files.
1323 ///
1324 /// These checks are cross-file by nature and are not emitted by
1325 /// [`crate::FileAnalyzer::analyze`]. Call this after ingesting or
1326 /// re-analyzing a file and its dependents to get the full diagnostic picture.
1327 ///
1328 /// Circular-inheritance checks always run against the full workspace graph
1329 /// regardless of the `files` filter — a cycle is a workspace-wide problem.
1330 pub fn class_issues(&self, files: &[Arc<str>]) -> Vec<crate::Issue> {
1331 self.settle_workspace_index();
1332 let db = self.snapshot_db();
1333 let file_set: HashSet<Arc<str>> = files.iter().cloned().collect();
1334 // Read source texts through the snapshot already in hand — calling
1335 // `source_of` here would re-enter the session RwLock while this
1336 // snapshot is live, and a concurrent salsa write (which blocks new
1337 // readers behind the fair write lock while waiting for existing
1338 // snapshots to drop) turns that into a deadlock.
1339 let file_data: Vec<(Arc<str>, Arc<str>)> = files
1340 .iter()
1341 .filter_map(|f| {
1342 let sf = db.lookup_source_file(f)?;
1343 Some((
1344 f.clone(),
1345 sf.text(&db as &dyn crate::db::MirDatabase).clone(),
1346 ))
1347 })
1348 .collect();
1349 crate::class::ClassAnalyzer::with_files(&db, file_set, &file_data).analyze_all()
1350 }
1351
1352 /// Collector-phase issues (e.g. `BackedEnumCaseTypeMismatch`,
1353 /// `InvalidReadonlyPropertyDeclaration`, `InvalidDocblock`, and raw parse
1354 /// errors) for the given files.
1355 ///
1356 /// These are found while building a file's declaration slice
1357 /// ([`crate::db::collect_file_definitions`]), before body analysis or
1358 /// cross-file class checks ever run — neither [`crate::FileAnalyzer::analyze`]
1359 /// nor [`Self::class_issues`] reads them, so a caller merging just those
1360 /// two sources silently drops every collector-time diagnostic. Call this
1361 /// alongside them to get the full picture.
1362 ///
1363 /// A plain snapshot read through [`crate::db::collect_file_definitions`],
1364 /// same as [`Self::document_symbols`] — correct regardless of which path
1365 /// put the file's text into the db (`ingest_file`, `set_file_text`, lazy
1366 /// vendor load) or how many times. The parse-cache fast paths behind that
1367 /// query used to zero out `issues` on any hit, including re-collecting
1368 /// the *same* file after its salsa memo was invalidated; they now
1369 /// preserve (and, for a genuinely different file sharing content,
1370 /// re-point) the originally-computed issues instead.
1371 pub fn collector_issues(&self, files: &[Arc<str>]) -> Vec<crate::Issue> {
1372 let db = self.snapshot_db();
1373 files
1374 .iter()
1375 .filter_map(|f| db.lookup_source_file(f))
1376 .flat_map(|sf| {
1377 crate::db::collect_file_definitions(&db, sf)
1378 .issues
1379 .as_ref()
1380 .clone()
1381 })
1382 .collect()
1383 }
1384
1385 /// All declarations defined in `file` as a **hierarchical tree**.
1386 ///
1387 /// Classes/interfaces/traits/enums are returned with their methods,
1388 /// properties, and constants nested in `children`. Top-level functions
1389 /// and constants are returned with empty `children`.
1390 pub fn document_symbols(&self, file: &str) -> Vec<crate::symbol::DocumentSymbol> {
1391 use crate::symbol::{DeclarationKind, DocumentSymbol};
1392
1393 let db = self.snapshot_db();
1394 let Some(sf) = db.lookup_source_file(file) else {
1395 return Vec::new();
1396 };
1397 let defs = crate::db::collect_file_definitions(&db, sf);
1398 let mut out: Vec<DocumentSymbol> = Vec::new();
1399
1400 let class_children = |methods: &mir_codebase::definitions::MemberMap<
1401 Arc<mir_codebase::definitions::MethodDef>,
1402 >,
1403 props: Option<
1404 &mir_codebase::definitions::MemberMap<mir_codebase::definitions::PropertyDef>,
1405 >,
1406 consts: &mir_codebase::definitions::MemberMap<
1407 mir_codebase::definitions::ConstantDef,
1408 >,
1409 is_enum: bool|
1410 -> Vec<DocumentSymbol> {
1411 let mut out: Vec<DocumentSymbol> = Vec::new();
1412 for (_, m) in methods.iter() {
1413 out.push(DocumentSymbol {
1414 name: m.name.clone(),
1415 kind: DeclarationKind::Method,
1416 location: m.location.clone(),
1417 children: Vec::new(),
1418 });
1419 }
1420 if let Some(props) = props {
1421 for (_, p) in props.iter() {
1422 out.push(DocumentSymbol {
1423 name: p.name.clone(),
1424 kind: DeclarationKind::Property,
1425 location: p.location.clone(),
1426 children: Vec::new(),
1427 });
1428 }
1429 }
1430 let const_kind = if is_enum {
1431 DeclarationKind::EnumCase
1432 } else {
1433 DeclarationKind::Constant
1434 };
1435 for (_, c) in consts.iter() {
1436 out.push(DocumentSymbol {
1437 name: c.name.clone(),
1438 kind: const_kind,
1439 location: c.location.clone(),
1440 children: Vec::new(),
1441 });
1442 }
1443 out
1444 };
1445
1446 for c in defs.slice.classes.iter() {
1447 out.push(DocumentSymbol {
1448 name: c.fqcn.clone(),
1449 kind: DeclarationKind::Class,
1450 location: c.location.clone(),
1451 children: class_children(
1452 &c.own_methods,
1453 Some(&c.own_properties),
1454 &c.own_constants,
1455 false,
1456 ),
1457 });
1458 }
1459 for i in defs.slice.interfaces.iter() {
1460 out.push(DocumentSymbol {
1461 name: i.fqcn.clone(),
1462 kind: DeclarationKind::Interface,
1463 location: i.location.clone(),
1464 children: class_children(&i.own_methods, None, &i.own_constants, false),
1465 });
1466 }
1467 for t in defs.slice.traits.iter() {
1468 out.push(DocumentSymbol {
1469 name: t.fqcn.clone(),
1470 kind: DeclarationKind::Trait,
1471 location: t.location.clone(),
1472 children: class_children(
1473 &t.own_methods,
1474 Some(&t.own_properties),
1475 &t.own_constants,
1476 false,
1477 ),
1478 });
1479 }
1480 for e in defs.slice.enums.iter() {
1481 let mut children = class_children(&e.own_methods, None, &e.own_constants, true);
1482 for (_, case) in e.cases.iter() {
1483 children.push(DocumentSymbol {
1484 name: case.name.clone(),
1485 kind: DeclarationKind::EnumCase,
1486 location: case.location.clone(),
1487 children: Vec::new(),
1488 });
1489 }
1490 out.push(DocumentSymbol {
1491 name: e.fqcn.clone(),
1492 kind: DeclarationKind::Enum,
1493 location: e.location.clone(),
1494 children,
1495 });
1496 }
1497 for f in defs.slice.functions.iter() {
1498 out.push(DocumentSymbol {
1499 name: f.fqn.clone(),
1500 kind: DeclarationKind::Function,
1501 location: f.location.clone(),
1502 children: Vec::new(),
1503 });
1504 }
1505 for (name, _) in defs.slice.constants.iter() {
1506 out.push(DocumentSymbol {
1507 name: name.clone(),
1508 kind: DeclarationKind::Constant,
1509 location: None,
1510 children: Vec::new(),
1511 });
1512 }
1513 out
1514 }
1515
1516 /// Choose the candidate-admission gate for `symbol`.
1517 ///
1518 /// For any known non-constructor/non-`__invoke` method, the member name
1519 /// alone is the sound gate. Every posting-producing reference spells that
1520 /// token: `$obj->m()`, `Owner::m()`, inherited `Sub::m()`,
1521 /// `self::`/`static::`/`parent::m()`, callable strings/arrays, and trait
1522 /// aliases (`orig as alias` records `orig`, alias calls record `alias`
1523 /// plus the origin key). Dropping the owner short name matters on common
1524 /// owner names (`User`, `Model`, `Widget`): otherwise a cold reference
1525 /// query analyzes files that only type-hint the owner and cannot contain a
1526 /// reference to the queried method. Dynamic member names (`$obj->$m()`)
1527 /// produce no posting, so nothing is lost there.
1528 ///
1529 /// For `__construct` with a known owner, the identifier needle is the
1530 /// owner's short name (`new Cls(` sites never spell the member name and
1531 /// the bare word `__construct` would admit every file *declaring* a
1532 /// constructor), complemented by the raw call tokens `->__construct` /
1533 /// `::__construct`: an explicit re-init `$obj->__construct()` is a real
1534 /// recorded reference whose file may never name the class.
1535 ///
1536 /// `__invoke` keeps the general owner/name gate because `$obj()` call sites
1537 /// do not spell `__invoke`.
1538 ///
1539 /// Everything else uses the general OR needles
1540 /// ([`reference_gate_needles`]).
1541 fn reference_gate(&self, symbol: &crate::Name) -> ReferenceGate {
1542 if let crate::Name::Method { class, name } = symbol {
1543 if name.as_ref() == "__construct" && !class.is_empty() {
1544 return ReferenceGate {
1545 idents: reference_gate_needles(symbol),
1546 raw: vec!["->__construct".to_string(), "::__construct".to_string()],
1547 };
1548 }
1549 if name.as_ref() != "__construct" && name.as_ref() != "__invoke" && !class.is_empty() {
1550 let db = self.snapshot_db();
1551 let here = crate::db::Fqcn::from_str(&db, class.as_ref());
1552 let is_static = crate::db::find_method_in_chain(&db, here, name)
1553 .map(|(_, m)| m.is_static)
1554 .unwrap_or(false);
1555 if is_static || crate::db::class_exists(&db, class.as_ref()) {
1556 return ReferenceGate {
1557 idents: vec![name.to_string()],
1558 raw: Vec::new(),
1559 };
1560 }
1561 }
1562 }
1563 ReferenceGate {
1564 idents: reference_gate_needles(symbol),
1565 raw: Vec::new(),
1566 }
1567 }
1568}
1569
1570/// A transitive subtype hit with its declaration name span, as returned by
1571/// [`AnalysisSession::indexed_subtype_classes`].
1572#[derive(Debug, Clone)]
1573pub struct SubtypeClassSite {
1574 /// Display-form FQCN (no leading `\`).
1575 pub fqcn: Arc<str>,
1576 pub kind: crate::db::ClassLikeKind,
1577 pub is_abstract: bool,
1578 pub file: Arc<str>,
1579 /// The declared name's own token (1-based line, 0-based char columns).
1580 pub range: crate::Range,
1581}
1582
1583/// Build a [`crate::Range`] on one line from mir's native coordinates
1584/// (1-based line, 0-based columns).
1585fn span_range(line: u32, col_start: u32, col_end: u32) -> crate::Range {
1586 crate::Range {
1587 start: crate::Position {
1588 line,
1589 column: col_start,
1590 },
1591 end: crate::Position {
1592 line,
1593 column: col_end,
1594 },
1595 }
1596}
1597
1598/// Char column of the first word-boundary occurrence of `needle` in `line`
1599/// at or after char column `min_col`. Columns are code points, matching the
1600/// collector's `Location` convention.
1601fn identifier_char_col(
1602 line: &str,
1603 needle: &str,
1604 min_col: usize,
1605 case_insensitive: bool,
1606) -> Option<u32> {
1607 if needle.is_empty() {
1608 return None;
1609 }
1610 let is_ident = |c: char| c.is_ascii_alphanumeric() || c == '_';
1611 let chars: Vec<char> = line.chars().collect();
1612 let needle_chars: Vec<char> = needle.chars().collect();
1613 let n = needle_chars.len();
1614 if chars.len() < n {
1615 return None;
1616 }
1617 for start in min_col..=chars.len().saturating_sub(n) {
1618 let matches = chars[start..start + n]
1619 .iter()
1620 .zip(needle_chars.iter())
1621 .all(|(a, b)| {
1622 if case_insensitive {
1623 a.eq_ignore_ascii_case(b)
1624 } else {
1625 a == b
1626 }
1627 });
1628 if !matches {
1629 continue;
1630 }
1631 let before_ok = start == 0 || !is_ident(chars[start - 1]);
1632 let after = start + n;
1633 let after_ok = after >= chars.len() || !is_ident(chars[after]);
1634 if before_ok && after_ok {
1635 return Some(start as u32);
1636 }
1637 }
1638 None
1639}
1640
1641/// Whether `hay` mentions `needle` as a whole identifier (ASCII word
1642/// boundaries; conservative near multibyte text). ASCII-case-insensitive:
1643/// PHP class, function, and method names are case-insensitive, so `new
1644/// COLOR()` must count as mentioning `Color`; for the case-sensitive kinds
1645/// (constants, properties) folding only widens the candidate superset.
1646///
1647/// Test-only semantic oracle: the production gates (references freshness,
1648/// subtype-BFS defs commit) answer this predicate through the persistent
1649/// `ClassMentionIndex`; the parity test below pins the scanner to these
1650/// exact boundary and case semantics.
1651#[cfg(test)]
1652fn mentions_identifier(hay: &str, needle: &str) -> bool {
1653 let hay = hay.as_bytes();
1654 let needle = needle.as_bytes();
1655 let n = needle.len();
1656 if n == 0 || hay.len() < n {
1657 return false;
1658 }
1659 let is_ident = |b: u8| b.is_ascii_alphanumeric() || b == b'_';
1660 let first = needle[0].to_ascii_lowercase();
1661 for i in 0..=(hay.len() - n) {
1662 if hay[i].to_ascii_lowercase() != first || !hay[i..i + n].eq_ignore_ascii_case(needle) {
1663 continue;
1664 }
1665 if (i == 0 || !is_ident(hay[i - 1])) && (i + n == hay.len() || !is_ident(hay[i + n])) {
1666 return true;
1667 }
1668 }
1669 false
1670}
1671
1672fn short(fqn: &str) -> &str {
1673 fqn.rsplit('\\').next().unwrap_or(fqn)
1674}
1675
1676/// A candidate-file admission predicate for `indexed_references_to`'s
1677/// freshness pass, chosen by [`AnalysisSession::reference_gate`]. A file is
1678/// admitted when its text mentions any of `idents` as a whole identifier
1679/// (word-bounded, ASCII-case-insensitive) OR contains any of `raw` as a
1680/// plain substring (ASCII-case-insensitive, no word bounds — used for
1681/// call-shaped tokens like `->__construct`). A file matching neither can
1682/// hold no posting for the symbol.
1683struct ReferenceGate {
1684 idents: Vec<String>,
1685 raw: Vec<String>,
1686}
1687
1688/// Identifier words whose whole-word presence in a file's text is necessary
1689/// for the file to hold any posting [`AnalysisSession::indexed_references_to`]
1690/// can return for `symbol`. Member symbols include the owner class's short
1691/// name alongside the member name: `__construct` postings are recorded at
1692/// `new Cls(` sites, which never spell the member name.
1693fn reference_gate_needles(symbol: &crate::Name) -> Vec<String> {
1694 let mut needles = match symbol {
1695 crate::Name::Class(f) | crate::Name::Function(f) | crate::Name::GlobalConstant(f) => {
1696 vec![short(f).to_string()]
1697 }
1698 // `__construct` is invoked only as `new Cls(...)`, `parent::__construct()`,
1699 // or `self::__construct()`/`static::__construct()` from inside a
1700 // subclass — every real call site textually names the class itself
1701 // (directly, or via the enclosing subclass's own `extends`/`use`),
1702 // never the bare word `__construct`. Gating on the class's short name
1703 // alone is exact (no lost call sites) and, unlike the general member
1704 // case, dropping the method-name needle here doesn't reintroduce a
1705 // false negative. This matters: `__construct` is one of the most
1706 // common tokens in any real codebase, so OR-ing it in as a needle
1707 // admits nearly every file as a "must re-analyze" candidate on a
1708 // cold query, defeating the gate's entire purpose for constructors.
1709 crate::Name::Method { class, name } if name.as_ref() == "__construct" => {
1710 if class.is_empty() {
1711 // No class to scope to (owner unknown) — fall back to gating
1712 // on the bare name, same as the general member case below.
1713 vec![name.to_string()]
1714 } else {
1715 vec![short(class).to_string()]
1716 }
1717 }
1718 crate::Name::Method { class, name }
1719 | crate::Name::Property { class, name }
1720 | crate::Name::ClassConstant { class, name } => {
1721 let mut v = vec![name.to_string()];
1722 if !class.is_empty() {
1723 v.push(short(class).to_string());
1724 }
1725 v
1726 }
1727 };
1728 // An empty needle can never match; dropping it keeps the "empty needle
1729 // set disables the gate" contract at the call site conservative.
1730 needles.retain(|n| !n.is_empty());
1731 needles
1732}
1733
1734#[cfg(test)]
1735mod tests {
1736 use super::*;
1737
1738 #[test]
1739 fn mentions_identifier_is_case_insensitive_and_word_bounded() {
1740 assert!(mentions_identifier("$this->save();", "save"));
1741 assert!(mentions_identifier("new COLOR()", "Color"));
1742 assert!(mentions_identifier("use App\\Color as Paint;", "color"));
1743 assert!(!mentions_identifier("$this->saveAll();", "save"));
1744 assert!(!mentions_identifier("return $unsaved;", "save"));
1745 assert!(!mentions_identifier("no occurrence", "save"));
1746 assert!(!mentions_identifier("anything", ""));
1747 // Multibyte neighbors are conservatively treated as boundaries, and
1748 // substring scans must not split codepoints.
1749 assert!(!mentions_identifier("function xÉclairFoo() {}", "Éclair"));
1750 assert!(mentions_identifier("implements Éclair {}", "Éclair"));
1751 }
1752
1753 #[test]
1754 fn mention_scanner_membership_equals_per_needle_scans() {
1755 // The mention index is the sole implementation of the gates' textual
1756 // predicate, so scanner membership must equal the reference
1757 // per-needle predicate for every (hay, needle) pair — same boundary
1758 // and case semantics.
1759 use crate::db::MentionScanner;
1760 use std::sync::Arc;
1761 let universe = ["Color", "save", "ColorPicker", "Éclair", "C1", "_Wrap"];
1762 let names: Vec<(mir_types::Name, bool)> = universe
1763 .iter()
1764 .map(|s| (mir_types::Name::new(s).ascii_lowercase(), false))
1765 .collect();
1766 let scanner = Arc::new(MentionScanner::build(1, names).unwrap());
1767 let hays = [
1768 "$this->save();",
1769 "new COLOR()",
1770 "use App\\Color as Paint;",
1771 "$this->saveAll();",
1772 "return $unsaved;",
1773 "new ColorPicker(); Color::save();",
1774 "function xÉclairFoo() {}",
1775 "implements Éclair {}",
1776 "colorsave savecolor color_save",
1777 "class C1 extends _Wrap {}",
1778 "",
1779 ];
1780 for hay in hays {
1781 let scanned = scanner.scan(hay);
1782 for needle in universe {
1783 let expected = mentions_identifier(hay, needle);
1784 let name = mir_types::Name::new(needle).ascii_lowercase();
1785 assert_eq!(
1786 scanned.binary_search(&name).is_ok(),
1787 expected,
1788 "needle {needle:?} on {hay:?}"
1789 );
1790 }
1791 }
1792 }
1793
1794 #[test]
1795 fn gate_needles_cover_member_and_owner_class() {
1796 // A regular member (non-constructor) gates on both the member name
1797 // and the owner's short name — a call site may name only one.
1798 let n = reference_gate_needles(&crate::Name::method("App\\Job", "run"));
1799 assert!(n.contains(&"run".to_string()) && n.contains(&"Job".to_string()));
1800 let n = reference_gate_needles(&crate::Name::class("App\\Ui\\Color"));
1801 assert_eq!(n, vec!["Color".to_string()]);
1802 // Unknown-owner member symbols still gate on the member name alone.
1803 let n = reference_gate_needles(&crate::Name::method("", "run"));
1804 assert_eq!(n, vec!["run".to_string()]);
1805 }
1806
1807 #[test]
1808 fn gate_needles_for_constructor_scope_to_owner_class_only() {
1809 // `__construct` is only ever spelled at `new Cls(`/`parent::__construct()`
1810 // sites, which always name the class — the bare method-name needle is
1811 // dropped so a cold constructor query doesn't admit nearly every file
1812 // in the workspace (every class defines *some* `__construct`).
1813 let n = reference_gate_needles(&crate::Name::method("App\\Job", "__construct"));
1814 assert_eq!(n, vec!["Job".to_string()]);
1815 // Unknown owner: nothing to scope to, fall back to the bare name.
1816 let n = reference_gate_needles(&crate::Name::method("", "__construct"));
1817 assert_eq!(n, vec!["__construct".to_string()]);
1818 }
1819
1820 fn session_with(files: &[(&str, &str)]) -> crate::AnalysisSession {
1821 let session = crate::AnalysisSession::new(crate::PhpVersion::LATEST);
1822 for (path, text) in files {
1823 session.set_file_text(Arc::from(*path), Arc::from(*text));
1824 }
1825 session
1826 }
1827
1828 #[test]
1829 fn gate_static_method_is_member_name_only() {
1830 // Regardless of subtypes: the member token alone is the sound gate
1831 // (an instance receiver `$obj::m()` never names the owner), and it
1832 // is also the selective part — the owner short name would only
1833 // widen the admitted set.
1834 let session = session_with(&[
1835 (
1836 "owner.php",
1837 "<?php\nclass Owner { public static function m(): void {} }\n",
1838 ),
1839 ("sub.php", "<?php\nclass Sub extends Owner {}\n"),
1840 ]);
1841 let gate = session.reference_gate(&crate::Name::method("Owner", "m"));
1842 assert_eq!(gate.idents, vec!["m".to_string()]);
1843 assert!(gate.raw.is_empty());
1844 }
1845
1846 #[test]
1847 fn gate_instance_method_is_member_name_only() {
1848 let session = session_with(&[(
1849 "owner.php",
1850 "<?php\nclass Owner { public function m(): void {} }\n",
1851 )]);
1852 let gate = session.reference_gate(&crate::Name::method("Owner", "m"));
1853 assert_eq!(gate.idents, vec!["m".to_string()]);
1854 assert!(gate.raw.is_empty());
1855 }
1856
1857 #[test]
1858 fn gate_invoke_keeps_owner_needle() {
1859 let session = session_with(&[(
1860 "owner.php",
1861 "<?php\nclass Owner { public function __invoke(): void {} }\n",
1862 )]);
1863 let gate = session.reference_gate(&crate::Name::method("Owner", "__invoke"));
1864 assert_eq!(
1865 gate.idents,
1866 reference_gate_needles(&crate::Name::method("Owner", "__invoke"))
1867 );
1868 assert!(gate.raw.is_empty());
1869 }
1870
1871 #[test]
1872 fn gate_constructor_adds_raw_call_tokens() {
1873 // Owner short name for `new Cls(` sites, plus the raw call tokens
1874 // for explicit re-init (`$obj->__construct()`) whose file may never
1875 // name the class. The bare identifier `__construct` must NOT be a
1876 // needle — it would admit every file declaring a constructor.
1877 let session = session_with(&[(
1878 "owner.php",
1879 "<?php\nclass Owner { public function __construct() {} }\n",
1880 )]);
1881 let gate = session.reference_gate(&crate::Name::method("Owner", "__construct"));
1882 assert_eq!(
1883 gate.idents,
1884 reference_gate_needles(&crate::Name::method("Owner", "__construct"))
1885 );
1886 assert_eq!(
1887 gate.raw,
1888 vec!["->__construct".to_string(), "::__construct".to_string()]
1889 );
1890 }
1891
1892 #[test]
1893 fn gate_unresolvable_owner_falls_back_to_general_needles() {
1894 let session = session_with(&[(
1895 "owner.php",
1896 "<?php\nclass Owner { public static function m(): void {} }\n",
1897 )]);
1898 let gate = session.reference_gate(&crate::Name::method("Nonexistent\\Missing", "m"));
1899 assert_eq!(
1900 gate.idents,
1901 reference_gate_needles(&crate::Name::method("Nonexistent\\Missing", "m"))
1902 );
1903 assert!(gate.raw.is_empty());
1904 }
1905}