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