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 .iter()
601 .filter_map(|path| {
602 let sf = db_main.lookup_source_file(path.as_ref())?;
603 let text = sf.text(&db_main as &dyn MirDatabase).clone();
604 let out =
605 crate::db::analyze_file(&db_main as &dyn MirDatabase, sf).clone();
606 let defs = crate::db::collect_file_definitions(
607 &db_main as &dyn MirDatabase,
608 sf,
609 );
610 let entries = crate::db::subtype_index::entries_from_slice(&defs.slice);
611 // Stage the disk-cache write only when the commit
612 // below will rewrite postings (see the sweep in
613 // `reanalyze_file_set` for the cost rationale).
614 let put = if self.ref_commit_is_current(path.as_ref(), &text, &out) {
615 None
616 } else {
617 self.stage_ref_cache_put(
618 &db_main as &dyn MirDatabase,
619 sf,
620 path.as_ref(),
621 &text,
622 &out,
623 )
624 };
625 // Mention scan piggybacks on the analysis pass
626 // (pure; committed serially below), skipped when
627 // the file already holds a current scan.
628 let mentions = mention_scanner.as_ref().and_then(|s| {
629 (!db_main.class_mentions_current(path.as_ref(), &text, s.epoch()))
630 .then(|| s.scan(&text))
631 });
632 Some((path.clone(), text, out, entries, put, mentions))
633 })
634 .collect::<Vec<_>>()
635 }));
636 match attempt {
637 Ok(v) => break (gen, v),
638 Err(_) if should_cancel() => return None,
639 Err(_) => {}
640 }
641 };
642 let mut analyzed = analyzed;
643 let guard = self.db.salsa.read();
644 for (file, text, out, entries, put, mentions) in analyzed.iter_mut() {
645 // Pointer-identical memo ⇒ identical postings: skip the
646 // index rewrite and only re-stamp the freshness mark.
647 if !self.ref_commit_is_current(file.as_ref(), text, out) {
648 guard.set_file_reference_locations(file.as_ref(), out.ref_locs.to_vec());
649 }
650 if let (Some(s), Some(m)) = (&mention_scanner, mentions.take()) {
651 guard.set_file_class_mentions(file, text, s.epoch(), m);
652 }
653 if let Some(put) = put.take() {
654 self.apply_ref_cache_put(file.as_ref(), out, put);
655 }
656 self.mark_ref_committed(
657 file,
658 text,
659 Some(out),
660 commit_gen,
661 !out.has_unresolved_names(),
662 );
663 if !self.is_defs_committed(file.as_ref(), text) {
664 guard.set_file_class_edges(file, entries.clone());
665 self.mark_defs_committed(file, text);
666 }
667 }
668 }
669
670 // Posting lookup, filtered to the candidate scope.
671 //
672 // Member symbols resolve against the queried class plus its hierarchy
673 // (mir records member refs under the *declaring* class, so a query on
674 // an interface method must include implementor keys and vice versa).
675 // Name-only fallback postings — receivers whose type couldn't be
676 // resolved — are consulted only when the typed keys produce nothing,
677 // mirroring the pre-index two-tier behavior: exact results when
678 // resolution succeeds, by-name matches when nothing resolves.
679 // `__construct` stays exact: `new Sub()` invokes `Sub::__construct`
680 // even when only a parent declares one, so hierarchy fan-out would
681 // wrongly return subtype instantiation sites for a parent query.
682 let hierarchy: Vec<String> = match symbol {
683 crate::Name::Method { class, name } => {
684 if name.as_ref() == "__construct" || class.is_empty() {
685 if class.is_empty() {
686 Vec::new()
687 } else {
688 vec![class.trim_start_matches('\\').to_string()]
689 }
690 } else {
691 self.member_hierarchy_classes(class.as_ref())
692 }
693 }
694 crate::Name::Property { class, .. } | crate::Name::ClassConstant { class, .. } => {
695 if class.is_empty() {
696 Vec::new()
697 } else {
698 self.member_hierarchy_classes(class.as_ref())
699 }
700 }
701 _ => Vec::new(),
702 };
703 let primary_keys: Vec<String> = match symbol {
704 crate::Name::Method { name, .. } => hierarchy
705 .iter()
706 .map(|c| format!("meth:{c}::{name}"))
707 .collect(),
708 crate::Name::Property { name, .. } => hierarchy
709 .iter()
710 .map(|c| format!("prop:{c}::{name}"))
711 .collect(),
712 crate::Name::ClassConstant { name, .. } => hierarchy
713 .iter()
714 .map(|c| format!("cnst:{c}::{name}"))
715 .collect(),
716 _ => vec![key.clone()],
717 };
718 let fallback_key: Option<String> = match symbol {
719 crate::Name::Method { name, .. } => Some(format!("methname:{name}")),
720 crate::Name::Property { name, .. } => Some(format!("propname:{name}")),
721 _ => None,
722 };
723 let scope: rustc_hash::FxHashSet<&str> = files.iter().map(|f| f.as_ref()).collect();
724 let read_keys = |keys: &[String]| -> Vec<(Arc<str>, crate::Range)> {
725 let guard = self.db.salsa.read();
726 let mut merged: Vec<(Arc<str>, u32, u16, u16)> = Vec::new();
727 for k in keys {
728 merged.extend(guard.reference_locations(k));
729 }
730 merged
731 .into_iter()
732 .filter(|(file, ..)| scope.contains(file.as_ref()))
733 .map(|(file, line, col_start, col_end)| {
734 (file, span_range(line, col_start as u32, col_end as u32))
735 })
736 .collect()
737 };
738 let mut out = read_keys(&primary_keys);
739 if out.is_empty() {
740 if let Some(fk) = fallback_key {
741 out = read_keys(std::slice::from_ref(&fk));
742 }
743 }
744 out.sort_by(|a, b| {
745 a.0.cmp(&b.0)
746 .then(a.1.start.line.cmp(&b.1.start.line))
747 .then(a.1.start.column.cmp(&b.1.start.column))
748 });
749 out.dedup_by(|a, b| a.0 == b.0 && a.1 == b.1);
750
751 if include_declaration {
752 // Declaration lookup runs salsa queries (and may lazy-load); a
753 // concurrent write cancels it — declarations are then simply
754 // omitted rather than failing the whole request.
755 let decls: Vec<(Arc<str>, crate::Range)> = match symbol {
756 crate::Name::Method { class, .. }
757 | crate::Name::Property { class, .. }
758 | crate::Name::ClassConstant { class, .. } => {
759 if class.is_empty() {
760 // Unknown owner: declarations by name, recorded as
761 // `methdecl:`/`propdecl:`/`cnstdecl:` postings during
762 // class/trait/interface/enum analysis.
763 match symbol {
764 crate::Name::Method { name, .. } => {
765 read_keys(&[format!("methdecl:{name}")])
766 }
767 crate::Name::Property { name, .. } => {
768 read_keys(&[format!("propdecl:{name}")])
769 }
770 crate::Name::ClassConstant { name, .. } => {
771 read_keys(&[format!("cnstdecl:{name}")])
772 }
773 _ => Vec::new(),
774 }
775 } else {
776 salsa::Cancelled::catch(AssertUnwindSafe(|| {
777 self.member_decl_sites(&hierarchy, symbol)
778 }))
779 .unwrap_or_default()
780 }
781 }
782 _ => salsa::Cancelled::catch(AssertUnwindSafe(|| {
783 self.declaration_name_range(symbol).into_iter().collect()
784 }))
785 .unwrap_or_default(),
786 };
787 for (file, range) in decls {
788 if scope.contains(file.as_ref())
789 && !out.iter().any(|(f, r)| *f == file && *r == range)
790 {
791 out.push((file, range));
792 }
793 }
794 }
795 Some(out)
796 }
797
798 /// The queried class plus every class its members' references could be
799 /// keyed under: resolved ancestors (a call on a subtype instance records
800 /// the declaring ancestor) and transitive subtypes including trait users
801 /// (a call on a subtype that overrides records the subtype). Display-form
802 /// FQCNs, deduplicated case-insensitively.
803 fn member_hierarchy_classes(&self, class_fqn: &str) -> Vec<String> {
804 use std::panic::AssertUnwindSafe;
805 let target = class_fqn.trim_start_matches('\\').to_string();
806 let mut out: Vec<String> = vec![target.clone()];
807 let ancestors = salsa::Cancelled::catch(AssertUnwindSafe(|| {
808 let db = self.snapshot_db();
809 let here = crate::db::Fqcn::from_str(&db, &target);
810 crate::db::class_ancestors_by_fqcn(&db, here)
811 .iter()
812 .skip(1)
813 .map(|a| a.trim_start_matches('\\').to_string())
814 .collect::<Vec<_>>()
815 }))
816 .unwrap_or_default();
817 out.extend(ancestors);
818 let subs = {
819 let guard = self.db.salsa.read();
820 guard.subtype_sites_of(&target, true)
821 };
822 out.extend(
823 subs.into_iter()
824 .map(|s| s.fqcn.trim_start_matches('\\').to_string()),
825 );
826 let mut seen: rustc_hash::FxHashSet<String> = rustc_hash::FxHashSet::default();
827 out.retain(|c| seen.insert(c.to_ascii_lowercase()));
828 out
829 }
830
831 /// Own-member declaration sites for `symbol` across `classes`: each class
832 /// that itself declares the member (not inherited) contributes its name
833 /// token. Kind-specific lookups — a class often declares a property and a
834 /// method with the same short name, and `member_location` can't tell them
835 /// apart.
836 fn member_decl_sites(
837 &self,
838 classes: &[String],
839 symbol: &crate::Name,
840 ) -> Vec<(Arc<str>, crate::Range)> {
841 let mut out: Vec<(Arc<str>, crate::Range)> = Vec::new();
842 let db = self.snapshot_db();
843 for class in classes {
844 let here = crate::db::Fqcn::from_str(&db, class);
845 let (loc, needle) = match symbol {
846 crate::Name::Method { name, .. } => {
847 let Some(m) = crate::db::find_method_in_class(&db, here, name) else {
848 continue;
849 };
850 (m.location.clone(), name.to_string())
851 }
852 crate::Name::Property { name, .. } => {
853 let Some(p) = crate::db::find_property_in_class(&db, here, name) else {
854 continue;
855 };
856 (p.location.clone(), name.to_string())
857 }
858 crate::Name::ClassConstant { name, .. } => {
859 let Some(c) = crate::db::find_class_constant_in_class(&db, here, name) else {
860 continue;
861 };
862 (c.location.clone(), name.to_string())
863 }
864 _ => continue,
865 };
866 let Some(loc) = loc else { continue };
867 let range = self.refine_location_to_name(&loc, &needle);
868 out.push((loc.file.clone(), range));
869 }
870 out
871 }
872
873 /// The symbol's declaration site, narrowed from the collector's
874 /// whole-declaration span to the declared name's own token (matching the
875 /// span shape of recorded references).
876 pub fn declaration_name_range(&self, symbol: &crate::Name) -> Option<(Arc<str>, crate::Range)> {
877 if let crate::Name::GlobalConstant(fqn) = symbol {
878 return self.global_constant_decl_range(fqn);
879 }
880 let loc = self.definition_of(symbol).ok()?;
881 let short = match symbol {
882 crate::Name::Class(f) | crate::Name::Function(f) | crate::Name::GlobalConstant(f) => {
883 crate::db::subtype_index::short_name_of(f)
884 }
885 crate::Name::Method { name, .. }
886 | crate::Name::Property { name, .. }
887 | crate::Name::ClassConstant { name, .. } => name.as_ref(),
888 };
889 // Property declarations carry a `$` sigil in source, but reference
890 // ranges cover the bare name; the word-boundary search below lands on
891 // the name right after the sigil.
892 let file = loc.file.clone();
893 let range = self.refine_location_to_name(&loc, short);
894 Some((file, range))
895 }
896
897 /// Narrow a whole-declaration [`mir_types::Location`] to the first
898 /// word-boundary occurrence of `needle` inside its line span. Falls back
899 /// to the location's own coordinates when the text is unavailable or the
900 /// name doesn't appear (e.g. stub-only declarations).
901 fn refine_location_to_name(&self, loc: &mir_types::Location, needle: &str) -> crate::Range {
902 let fallback = span_range(loc.line, loc.col_start as u32, loc.col_end as u32);
903 let text = {
904 let db = self.snapshot_db();
905 db.lookup_source_file(loc.file.as_ref())
906 .map(|sf| sf.text(&db as &dyn MirDatabase).clone())
907 };
908 let Some(text) = text else {
909 return fallback;
910 };
911 let needle_chars = needle.chars().count() as u32;
912 let first_line = loc.line.saturating_sub(1) as usize;
913 // Exact-case first: PHP property/constant names are case-sensitive
914 // and an early case-insensitive hit can land on an unrelated token
915 // (a type hint sharing the name). Case-insensitive second, for
916 // method/class needles that arrive lowercase-normalized.
917 for case_insensitive in [false, true] {
918 for (idx, line_text) in text.lines().enumerate().skip(first_line) {
919 let line_no = idx as u32 + 1;
920 if line_no > loc.line_end {
921 break;
922 }
923 let min_col = if line_no == loc.line {
924 loc.col_start as usize
925 } else {
926 0
927 };
928 if let Some(col) = identifier_char_col(line_text, needle, min_col, case_insensitive)
929 {
930 return span_range(line_no, col, col + needle_chars);
931 }
932 }
933 }
934 fallback
935 }
936
937 /// Transitive subtypes of `class_fqn` (classes/interfaces/enums whose
938 /// resolved ancestor chain reaches it), answered from the maintained
939 /// subtype edge index.
940 ///
941 /// `files` is the host's candidate scope for the on-demand completeness
942 /// pass: per BFS round, not-yet-committed files whose text mentions a
943 /// frontier name get their definitions committed, so results are complete
944 /// even before a background sweep has covered the workspace. Committed
945 /// files answer from the index with no parsing at all.
946 ///
947 /// `include_trait_users` also counts `use Trait;` composition as a
948 /// subtype edge (visibility-scoping semantics); leave it off for
949 /// goto-implementation semantics (extends/implements only).
950 ///
951 /// Memoized per `(class_fqn, include_trait_users, files, text_revision)`
952 /// — same shape and rationale as [`Self::indexed_references_to`]'s
953 /// cache: `commit_defs_for_matching`'s freshness pass costs O(candidates)
954 /// on every call regardless of outcome, so a repeat query (e.g. a host
955 /// resolving a protected/static method's reference scope on every
956 /// code-lens refresh) would otherwise re-pay it every time.
957 pub fn indexed_subtype_classes(
958 &self,
959 class_fqn: &str,
960 files: &[Arc<str>],
961 include_trait_users: bool,
962 ) -> Vec<SubtypeClassSite> {
963 let cache_key = SubtypeQueryCacheKey {
964 class_fqn: class_fqn.trim_start_matches('\\').to_ascii_lowercase(),
965 include_trait_users,
966 generation: self.query_cache_generation(),
967 files_hash: hash_files(files),
968 };
969 if let Some(cached) = self.subtype_query_cache.read().get(&cache_key) {
970 self.subtype_query_cache_hits
971 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
972 return (**cached).clone();
973 }
974 let result = self.indexed_subtype_classes_uncached(class_fqn, files, include_trait_users);
975 if self.query_cache_generation() != cache_key.generation {
976 // The generation moved while computing (an edit, or a defs
977 // commit growing the subtype index — possibly this query's own).
978 // The key can never be looked up again, so don't cache it; the
979 // next identical query recomputes once against settled state
980 // and caches then.
981 return result;
982 }
983 let mut cache = self.subtype_query_cache.write();
984 if !cache.advance_to(cache_key.generation, || {
985 self.subtype_query_cache_sites
986 .store(0, std::sync::atomic::Ordering::Relaxed);
987 }) {
988 return result;
989 }
990 let new_len = result.len();
991 let prior = self
992 .subtype_query_cache_sites
993 .fetch_add(new_len, std::sync::atomic::Ordering::Relaxed);
994 if prior + new_len > SUBTYPE_QUERY_CACHE_SITE_CAP {
995 cache.map.clear();
996 self.subtype_query_cache_sites
997 .store(new_len, std::sync::atomic::Ordering::Relaxed);
998 }
999 cache.map.insert(cache_key, Arc::new(result.clone()));
1000 result
1001 }
1002
1003 /// Uncached implementation of [`Self::indexed_subtype_classes`]. Callers
1004 /// should use the memoizing wrapper.
1005 fn indexed_subtype_classes_uncached(
1006 &self,
1007 class_fqn: &str,
1008 files: &[Arc<str>],
1009 include_trait_users: bool,
1010 ) -> Vec<SubtypeClassSite> {
1011 self.settle_workspace_index();
1012 let mut scanned: rustc_hash::FxHashSet<String> = rustc_hash::FxHashSet::default();
1013 let mut pending: Vec<String> = vec![class_fqn.trim_start_matches('\\').to_string()];
1014 let mut sites: Vec<crate::db::SubtypeSite> = Vec::new();
1015 while !pending.is_empty() {
1016 let needles: Vec<String> = pending
1017 .drain(..)
1018 .filter(|f| scanned.insert(f.clone()))
1019 .map(|f| crate::db::subtype_index::short_name_of(&f).to_string())
1020 .collect();
1021 if !needles.is_empty() {
1022 self.commit_defs_for_matching(files, &needles);
1023 }
1024 sites = {
1025 let guard = self.db.salsa.read();
1026 guard.subtype_sites_of_lenient(class_fqn, include_trait_users)
1027 };
1028 pending = sites
1029 .iter()
1030 .map(|s| s.fqcn.trim_start_matches('\\').to_string())
1031 .filter(|f| !scanned.contains(f))
1032 .collect();
1033 }
1034 let mut out: Vec<SubtypeClassSite> = sites
1035 .into_iter()
1036 .filter_map(|s| {
1037 let loc = s.location.as_ref()?;
1038 let short = crate::db::subtype_index::short_name_of(&s.fqcn).to_string();
1039 let range = self.refine_location_to_name(loc, &short);
1040 Some(SubtypeClassSite {
1041 fqcn: s.fqcn,
1042 kind: s.kind,
1043 is_abstract: s.is_abstract,
1044 file: s.file,
1045 range,
1046 })
1047 })
1048 .collect();
1049 // Anonymous classes never reach the definition collector; their
1050 // `new class implements X {}` sites are recorded as `impl:` postings
1051 // during body analysis (exact FQCN key plus a short-name key for the
1052 // same written-form leniency named classes get above).
1053 let root_lc = class_fqn.trim_start_matches('\\').to_ascii_lowercase();
1054 let short_lc = crate::db::subtype_index::short_name_of(&root_lc).to_string();
1055 let scope: rustc_hash::FxHashSet<&str> = files.iter().map(|f| f.as_ref()).collect();
1056 let anon: Vec<(Arc<str>, u32, u16, u16)> = {
1057 let guard = self.db.salsa.read();
1058 let mut v = guard.reference_locations(&format!("impl:{root_lc}"));
1059 v.extend(guard.reference_locations(&format!("implshort:{short_lc}")));
1060 v.sort();
1061 v.dedup();
1062 v
1063 };
1064 for (file, line, cs, ce) in anon {
1065 if !scope.contains(file.as_ref()) {
1066 continue;
1067 }
1068 let range = span_range(line, cs as u32, ce as u32);
1069 if out.iter().any(|s| s.file == file && s.range == range) {
1070 continue;
1071 }
1072 out.push(SubtypeClassSite {
1073 fqcn: Arc::from("class@anonymous"),
1074 kind: crate::db::ClassLikeKind::Class,
1075 is_abstract: false,
1076 file,
1077 range,
1078 });
1079 }
1080 out
1081 }
1082
1083 /// Concrete implementations of `class_fqn::method` across its transitive
1084 /// subtypes: the same-named non-abstract method available to each subtype
1085 /// (its own declaration, or one inherited/composed from a parent, trait,
1086 /// or mixin), as `(subtype fqcn, file, name range)`. Subtypes resolving to
1087 /// the same declaring location collapse to a single entry.
1088 pub fn indexed_method_implementations(
1089 &self,
1090 class_fqn: &str,
1091 method: &str,
1092 files: &[Arc<str>],
1093 ) -> Vec<(Arc<str>, Arc<str>, crate::Range)> {
1094 use std::panic::AssertUnwindSafe;
1095 let subs = self.indexed_subtype_classes(class_fqn, files, false);
1096 if subs.is_empty() {
1097 return Vec::new();
1098 }
1099 loop {
1100 let attempt = salsa::Cancelled::catch(AssertUnwindSafe(|| {
1101 let db = self.snapshot_db();
1102 let mut out: Vec<(Arc<str>, Arc<str>, crate::Range)> = Vec::new();
1103 for sub in &subs {
1104 let here = crate::db::Fqcn::from_str(&db, sub.fqcn.as_ref());
1105 let Some((_, m)) = crate::db::find_method_in_chain(&db, here, method) else {
1106 continue;
1107 };
1108 if m.is_abstract {
1109 continue;
1110 }
1111 let Some(loc) = m.location.as_ref() else {
1112 continue;
1113 };
1114 let range = self.refine_location_to_name(loc, method);
1115 out.push((sub.fqcn.clone(), loc.file.clone(), range));
1116 }
1117 out
1118 }));
1119 if let Ok(mut out) = attempt {
1120 out.sort_by(|a, b| a.1.cmp(&b.1).then(a.2.start.line.cmp(&b.2.start.line)));
1121 out.dedup_by(|a, b| a.1 == b.1 && a.2 == b.2);
1122 return out;
1123 }
1124 }
1125 }
1126
1127 /// Commit definitions (class edges + freshness) for every file in `files`
1128 /// that is stale (committed against older text) or that has never been
1129 /// committed and mentions one of `shorts` as a whole identifier.
1130 ///
1131 /// The textual gate answers from the shared per-file mention cache — the
1132 /// same one `indexed_references_to`'s gate populates — so a file scanned
1133 /// by either consumer answers the other with a set lookup instead of an
1134 /// O(text) rescan per BFS round. A file the cache can't answer for is
1135 /// scanned once against the whole name universe and recorded.
1136 fn commit_defs_for_matching(&self, files: &[Arc<str>], shorts: &[String]) {
1137 use std::panic::AssertUnwindSafe;
1138
1139 use rayon::prelude::*;
1140
1141 let committed_any: rustc_hash::FxHashSet<Arc<str>> = {
1142 let guard = self.defs_committed_keys();
1143 guard.into_iter().collect()
1144 };
1145 // Admit the frontier names before preparing, so every needle gets a
1146 // real query (a declared class's short name is already in the
1147 // universe from indexing — admission then changes nothing).
1148 let (queries, mention_scanner) = {
1149 let guard = self.db.salsa.read();
1150 guard.add_literal_mention_names(shorts.iter().map(|s| s.as_str()));
1151 let queries: Vec<_> = shorts
1152 .iter()
1153 .filter_map(|s| guard.prepare_class_mention_query(s))
1154 .collect();
1155 (queries, guard.class_mention_scanner())
1156 };
1157 // Admission guarantees a query per needle and a non-empty universe;
1158 // anything else is defensive — the gate then admits every candidate
1159 // (recommit rather than skip, the conservative direction).
1160 let use_mentions = queries.len() == shorts.len() && mention_scanner.is_some();
1161 type Work = (Arc<str>, Arc<str>, Vec<crate::db::SubtypeEntry>);
1162 type MentionScanRec = (Arc<str>, Arc<str>, Box<[mir_types::Name]>);
1163 let (work, scanned): (Vec<Work>, Vec<MentionScanRec>) = loop {
1164 let attempt = salsa::Cancelled::catch(AssertUnwindSafe(|| {
1165 let db_main = self.snapshot_db();
1166 files
1167 .par_iter()
1168 .map_with(db_main, |db, path| {
1169 let Some(sf) = db.lookup_source_file(path.as_ref()) else {
1170 return (None, None);
1171 };
1172 let text = sf.text(&*db as &dyn MirDatabase).clone();
1173 if self.is_defs_committed(path.as_ref(), &text) {
1174 return (None, None);
1175 }
1176 // Never-committed files must mention a frontier name;
1177 // stale (previously committed) files recommit
1178 // unconditionally — their classes may have re-parented.
1179 let mut scan_rec: Option<MentionScanRec> = None;
1180 if use_mentions && !committed_any.contains(path.as_ref()) {
1181 let mut answer = Some(false);
1182 for q in &queries {
1183 match db.class_mention_answer(path.as_ref(), q, &text) {
1184 Some(true) => {
1185 answer = Some(true);
1186 break;
1187 }
1188 Some(false) => {}
1189 None => answer = None,
1190 }
1191 }
1192 let hit = match answer {
1193 Some(hit) => hit,
1194 None => {
1195 // Uncoverable entry: one scan answers
1196 // every query and is recorded below.
1197 let scanner = mention_scanner.as_ref().unwrap();
1198 let names = scanner.scan(&text);
1199 let hit = queries
1200 .iter()
1201 .any(|q| names.binary_search(&q.name).is_ok());
1202 scan_rec = Some((path.clone(), text.clone(), names));
1203 hit
1204 }
1205 };
1206 if !hit {
1207 return (None, scan_rec);
1208 }
1209 }
1210 let defs =
1211 crate::db::collect_file_definitions(&*db as &dyn MirDatabase, sf);
1212 let entries = crate::db::subtype_index::entries_from_slice(&defs.slice);
1213 (Some((path.clone(), text, entries)), scan_rec)
1214 })
1215 .collect::<Vec<_>>()
1216 }));
1217 if let Ok(v) = attempt {
1218 let mut work = Vec::new();
1219 let mut scanned = Vec::new();
1220 for (w, rec) in v {
1221 if let Some(w) = w {
1222 work.push(w);
1223 }
1224 if let Some(rec) = rec {
1225 scanned.push(rec);
1226 }
1227 }
1228 break (work, scanned);
1229 }
1230 };
1231 // Record the fallback scans regardless of hit/miss: each is a
1232 // complete, current mention set for its file, so the next round's
1233 // (and the references gate's) checks become set lookups.
1234 if let Some(scanner) = &mention_scanner {
1235 if !scanned.is_empty() {
1236 let guard = self.db.salsa.read();
1237 for (file, text, names) in scanned {
1238 guard.set_file_class_mentions(&file, &text, scanner.epoch(), names);
1239 }
1240 }
1241 }
1242 if work.is_empty() {
1243 return;
1244 }
1245 let guard = self.db.salsa.read();
1246 for (file, text, entries) in &work {
1247 guard.set_file_class_edges(file, entries.clone());
1248 self.mark_defs_committed(file, text);
1249 }
1250 }
1251
1252 /// Declaration name span for a global constant. Constant slices carry no
1253 /// stored location, so this finds the declaring file via the workspace
1254 /// constants index and locates the `const NAME` / `define('NAME'` token
1255 /// textually.
1256 fn global_constant_decl_range(&self, fqn: &str) -> Option<(Arc<str>, crate::Range)> {
1257 use std::panic::AssertUnwindSafe;
1258 let short = crate::db::subtype_index::short_name_of(fqn).to_string();
1259 salsa::Cancelled::catch(AssertUnwindSafe(|| {
1260 let db = self.snapshot_db();
1261 let index = crate::db::workspace_index(&db);
1262 let loc = index
1263 .constants
1264 .get(&mir_types::Name::from(fqn.trim_start_matches('\\')))?;
1265 let file = loc.file().path(&db).clone();
1266 let sf = db.lookup_source_file(file.as_ref())?;
1267 let text = sf.text(&db as &dyn MirDatabase);
1268 for (idx, line) in text.lines().enumerate() {
1269 let trimmed = line.trim_start();
1270 let is_decl_line = trimmed.starts_with("const ")
1271 || trimmed.contains("define(")
1272 || trimmed.contains("define (");
1273 if !is_decl_line {
1274 continue;
1275 }
1276 if let Some(col) = identifier_char_col(line, &short, 0, false) {
1277 let n = short.chars().count() as u32;
1278 return Some((file, span_range(idx as u32 + 1, col, col + n)));
1279 }
1280 }
1281 None
1282 }))
1283 .ok()
1284 .flatten()
1285 }
1286
1287 /// Class-level issues (inheritance violations, abstract-method gaps, override
1288 /// incompatibilities) for the given set of files.
1289 ///
1290 /// These checks are cross-file by nature and are not emitted by
1291 /// [`crate::FileAnalyzer::analyze`]. Call this after ingesting or
1292 /// re-analyzing a file and its dependents to get the full diagnostic picture.
1293 ///
1294 /// Circular-inheritance checks always run against the full workspace graph
1295 /// regardless of the `files` filter — a cycle is a workspace-wide problem.
1296 pub fn class_issues(&self, files: &[Arc<str>]) -> Vec<crate::Issue> {
1297 self.settle_workspace_index();
1298 let db = self.snapshot_db();
1299 let file_set: HashSet<Arc<str>> = files.iter().cloned().collect();
1300 // Read source texts through the snapshot already in hand — calling
1301 // `source_of` here would re-enter the session RwLock while this
1302 // snapshot is live, and a concurrent salsa write (which blocks new
1303 // readers behind the fair write lock while waiting for existing
1304 // snapshots to drop) turns that into a deadlock.
1305 let file_data: Vec<(Arc<str>, Arc<str>)> = files
1306 .iter()
1307 .filter_map(|f| {
1308 let sf = db.lookup_source_file(f)?;
1309 Some((
1310 f.clone(),
1311 sf.text(&db as &dyn crate::db::MirDatabase).clone(),
1312 ))
1313 })
1314 .collect();
1315 crate::class::ClassAnalyzer::with_files(&db, file_set, &file_data).analyze_all()
1316 }
1317
1318 /// Collector-phase issues (e.g. `BackedEnumCaseTypeMismatch`,
1319 /// `InvalidReadonlyPropertyDeclaration`, `InvalidDocblock`, and raw parse
1320 /// errors) for the given files.
1321 ///
1322 /// These are found while building a file's declaration slice
1323 /// ([`crate::db::collect_file_definitions`]), before body analysis or
1324 /// cross-file class checks ever run — neither [`crate::FileAnalyzer::analyze`]
1325 /// nor [`Self::class_issues`] reads them, so a caller merging just those
1326 /// two sources silently drops every collector-time diagnostic. Call this
1327 /// alongside them to get the full picture.
1328 ///
1329 /// A plain snapshot read through [`crate::db::collect_file_definitions`],
1330 /// same as [`Self::document_symbols`] — correct regardless of which path
1331 /// put the file's text into the db (`ingest_file`, `set_file_text`, lazy
1332 /// vendor load) or how many times. The parse-cache fast paths behind that
1333 /// query used to zero out `issues` on any hit, including re-collecting
1334 /// the *same* file after its salsa memo was invalidated; they now
1335 /// preserve (and, for a genuinely different file sharing content,
1336 /// re-point) the originally-computed issues instead.
1337 pub fn collector_issues(&self, files: &[Arc<str>]) -> Vec<crate::Issue> {
1338 let db = self.snapshot_db();
1339 files
1340 .iter()
1341 .filter_map(|f| db.lookup_source_file(f))
1342 .flat_map(|sf| {
1343 crate::db::collect_file_definitions(&db, sf)
1344 .issues
1345 .as_ref()
1346 .clone()
1347 })
1348 .collect()
1349 }
1350
1351 /// All declarations defined in `file` as a **hierarchical tree**.
1352 ///
1353 /// Classes/interfaces/traits/enums are returned with their methods,
1354 /// properties, and constants nested in `children`. Top-level functions
1355 /// and constants are returned with empty `children`.
1356 pub fn document_symbols(&self, file: &str) -> Vec<crate::symbol::DocumentSymbol> {
1357 use crate::symbol::{DeclarationKind, DocumentSymbol};
1358
1359 let db = self.snapshot_db();
1360 let Some(sf) = db.lookup_source_file(file) else {
1361 return Vec::new();
1362 };
1363 let defs = crate::db::collect_file_definitions(&db, sf);
1364 let mut out: Vec<DocumentSymbol> = Vec::new();
1365
1366 let class_children = |methods: &mir_codebase::definitions::MemberMap<
1367 Arc<mir_codebase::definitions::MethodDef>,
1368 >,
1369 props: Option<
1370 &mir_codebase::definitions::MemberMap<mir_codebase::definitions::PropertyDef>,
1371 >,
1372 consts: &mir_codebase::definitions::MemberMap<
1373 mir_codebase::definitions::ConstantDef,
1374 >,
1375 is_enum: bool|
1376 -> Vec<DocumentSymbol> {
1377 let mut out: Vec<DocumentSymbol> = Vec::new();
1378 for (_, m) in methods.iter() {
1379 out.push(DocumentSymbol {
1380 name: m.name.clone(),
1381 kind: DeclarationKind::Method,
1382 location: m.location.clone(),
1383 children: Vec::new(),
1384 });
1385 }
1386 if let Some(props) = props {
1387 for (_, p) in props.iter() {
1388 out.push(DocumentSymbol {
1389 name: p.name.clone(),
1390 kind: DeclarationKind::Property,
1391 location: p.location.clone(),
1392 children: Vec::new(),
1393 });
1394 }
1395 }
1396 let const_kind = if is_enum {
1397 DeclarationKind::EnumCase
1398 } else {
1399 DeclarationKind::Constant
1400 };
1401 for (_, c) in consts.iter() {
1402 out.push(DocumentSymbol {
1403 name: c.name.clone(),
1404 kind: const_kind,
1405 location: c.location.clone(),
1406 children: Vec::new(),
1407 });
1408 }
1409 out
1410 };
1411
1412 for c in defs.slice.classes.iter() {
1413 out.push(DocumentSymbol {
1414 name: c.fqcn.clone(),
1415 kind: DeclarationKind::Class,
1416 location: c.location.clone(),
1417 children: class_children(
1418 &c.own_methods,
1419 Some(&c.own_properties),
1420 &c.own_constants,
1421 false,
1422 ),
1423 });
1424 }
1425 for i in defs.slice.interfaces.iter() {
1426 out.push(DocumentSymbol {
1427 name: i.fqcn.clone(),
1428 kind: DeclarationKind::Interface,
1429 location: i.location.clone(),
1430 children: class_children(&i.own_methods, None, &i.own_constants, false),
1431 });
1432 }
1433 for t in defs.slice.traits.iter() {
1434 out.push(DocumentSymbol {
1435 name: t.fqcn.clone(),
1436 kind: DeclarationKind::Trait,
1437 location: t.location.clone(),
1438 children: class_children(
1439 &t.own_methods,
1440 Some(&t.own_properties),
1441 &t.own_constants,
1442 false,
1443 ),
1444 });
1445 }
1446 for e in defs.slice.enums.iter() {
1447 let mut children = class_children(&e.own_methods, None, &e.own_constants, true);
1448 for (_, case) in e.cases.iter() {
1449 children.push(DocumentSymbol {
1450 name: case.name.clone(),
1451 kind: DeclarationKind::EnumCase,
1452 location: case.location.clone(),
1453 children: Vec::new(),
1454 });
1455 }
1456 out.push(DocumentSymbol {
1457 name: e.fqcn.clone(),
1458 kind: DeclarationKind::Enum,
1459 location: e.location.clone(),
1460 children,
1461 });
1462 }
1463 for f in defs.slice.functions.iter() {
1464 out.push(DocumentSymbol {
1465 name: f.fqn.clone(),
1466 kind: DeclarationKind::Function,
1467 location: f.location.clone(),
1468 children: Vec::new(),
1469 });
1470 }
1471 for (name, _) in defs.slice.constants.iter() {
1472 out.push(DocumentSymbol {
1473 name: name.clone(),
1474 kind: DeclarationKind::Constant,
1475 location: None,
1476 children: Vec::new(),
1477 });
1478 }
1479 out
1480 }
1481
1482 /// Choose the candidate-admission gate for `symbol`.
1483 ///
1484 /// For a method resolving to a *static* declaration (PHP forbids a
1485 /// static/non-static override mismatch, so it is static everywhere
1486 /// reachable in a valid hierarchy), the member name alone is a sound
1487 /// gate — and drops the owner short name the general needle set ORs in,
1488 /// which on common owner names (Color, Asset, ...) admits most of a
1489 /// large workspace. Every posting-producing static reference spells the
1490 /// member token: `Owner::m()`, inherited `Sub::m()`, `self::`/
1491 /// `static::`/`parent::m()`, aliased `Alias::m()`, an *instance*
1492 /// receiver `$obj::m()` (whose file may never name the class at all —
1493 /// this is what makes requiring an owner-group name unsound), and
1494 /// callable strings `'Owner::m'`. Dynamic member names (`Owner::$m()`)
1495 /// produce no posting, so nothing is lost there.
1496 ///
1497 /// For `__construct` with a known owner, the identifier needle is the
1498 /// owner's short name (`new Cls(` sites never spell the member name and
1499 /// the bare word `__construct` would admit every file *declaring* a
1500 /// constructor), complemented by the raw call tokens `->__construct` /
1501 /// `::__construct`: an explicit re-init `$obj->__construct()` is a real
1502 /// recorded reference whose file may never name the class.
1503 ///
1504 /// Everything else uses the general OR needles
1505 /// ([`reference_gate_needles`]).
1506 fn reference_gate(&self, symbol: &crate::Name) -> ReferenceGate {
1507 if let crate::Name::Method { class, name } = symbol {
1508 if name.as_ref() == "__construct" && !class.is_empty() {
1509 return ReferenceGate {
1510 idents: reference_gate_needles(symbol),
1511 raw: vec!["->__construct".to_string(), "::__construct".to_string()],
1512 };
1513 }
1514 if name.as_ref() != "__construct" && !class.is_empty() {
1515 let db = self.snapshot_db();
1516 let here = crate::db::Fqcn::from_str(&db, class.as_ref());
1517 let is_static = crate::db::find_method_in_chain(&db, here, name)
1518 .map(|(_, m)| m.is_static)
1519 .unwrap_or(false);
1520 if is_static {
1521 return ReferenceGate {
1522 idents: vec![name.to_string()],
1523 raw: Vec::new(),
1524 };
1525 }
1526 }
1527 }
1528 ReferenceGate {
1529 idents: reference_gate_needles(symbol),
1530 raw: Vec::new(),
1531 }
1532 }
1533}
1534
1535/// A transitive subtype hit with its declaration name span, as returned by
1536/// [`AnalysisSession::indexed_subtype_classes`].
1537#[derive(Debug, Clone)]
1538pub struct SubtypeClassSite {
1539 /// Display-form FQCN (no leading `\`).
1540 pub fqcn: Arc<str>,
1541 pub kind: crate::db::ClassLikeKind,
1542 pub is_abstract: bool,
1543 pub file: Arc<str>,
1544 /// The declared name's own token (1-based line, 0-based char columns).
1545 pub range: crate::Range,
1546}
1547
1548/// Build a [`crate::Range`] on one line from mir's native coordinates
1549/// (1-based line, 0-based columns).
1550fn span_range(line: u32, col_start: u32, col_end: u32) -> crate::Range {
1551 crate::Range {
1552 start: crate::Position {
1553 line,
1554 column: col_start,
1555 },
1556 end: crate::Position {
1557 line,
1558 column: col_end,
1559 },
1560 }
1561}
1562
1563/// Char column of the first word-boundary occurrence of `needle` in `line`
1564/// at or after char column `min_col`. Columns are code points, matching the
1565/// collector's `Location` convention.
1566fn identifier_char_col(
1567 line: &str,
1568 needle: &str,
1569 min_col: usize,
1570 case_insensitive: bool,
1571) -> Option<u32> {
1572 if needle.is_empty() {
1573 return None;
1574 }
1575 let is_ident = |c: char| c.is_ascii_alphanumeric() || c == '_';
1576 let chars: Vec<char> = line.chars().collect();
1577 let needle_chars: Vec<char> = needle.chars().collect();
1578 let n = needle_chars.len();
1579 if chars.len() < n {
1580 return None;
1581 }
1582 for start in min_col..=chars.len().saturating_sub(n) {
1583 let matches = chars[start..start + n]
1584 .iter()
1585 .zip(needle_chars.iter())
1586 .all(|(a, b)| {
1587 if case_insensitive {
1588 a.eq_ignore_ascii_case(b)
1589 } else {
1590 a == b
1591 }
1592 });
1593 if !matches {
1594 continue;
1595 }
1596 let before_ok = start == 0 || !is_ident(chars[start - 1]);
1597 let after = start + n;
1598 let after_ok = after >= chars.len() || !is_ident(chars[after]);
1599 if before_ok && after_ok {
1600 return Some(start as u32);
1601 }
1602 }
1603 None
1604}
1605
1606/// Whether `hay` mentions `needle` as a whole identifier (ASCII word
1607/// boundaries; conservative near multibyte text). ASCII-case-insensitive:
1608/// PHP class, function, and method names are case-insensitive, so `new
1609/// COLOR()` must count as mentioning `Color`; for the case-sensitive kinds
1610/// (constants, properties) folding only widens the candidate superset.
1611///
1612/// Test-only semantic oracle: the production gates (references freshness,
1613/// subtype-BFS defs commit) answer this predicate through the persistent
1614/// `ClassMentionIndex`; the parity test below pins the scanner to these
1615/// exact boundary and case semantics.
1616#[cfg(test)]
1617fn mentions_identifier(hay: &str, needle: &str) -> bool {
1618 let hay = hay.as_bytes();
1619 let needle = needle.as_bytes();
1620 let n = needle.len();
1621 if n == 0 || hay.len() < n {
1622 return false;
1623 }
1624 let is_ident = |b: u8| b.is_ascii_alphanumeric() || b == b'_';
1625 let first = needle[0].to_ascii_lowercase();
1626 for i in 0..=(hay.len() - n) {
1627 if hay[i].to_ascii_lowercase() != first || !hay[i..i + n].eq_ignore_ascii_case(needle) {
1628 continue;
1629 }
1630 if (i == 0 || !is_ident(hay[i - 1])) && (i + n == hay.len() || !is_ident(hay[i + n])) {
1631 return true;
1632 }
1633 }
1634 false
1635}
1636
1637fn short(fqn: &str) -> &str {
1638 fqn.rsplit('\\').next().unwrap_or(fqn)
1639}
1640
1641/// A candidate-file admission predicate for `indexed_references_to`'s
1642/// freshness pass, chosen by [`AnalysisSession::reference_gate`]. A file is
1643/// admitted when its text mentions any of `idents` as a whole identifier
1644/// (word-bounded, ASCII-case-insensitive) OR contains any of `raw` as a
1645/// plain substring (ASCII-case-insensitive, no word bounds — used for
1646/// call-shaped tokens like `->__construct`). A file matching neither can
1647/// hold no posting for the symbol.
1648struct ReferenceGate {
1649 idents: Vec<String>,
1650 raw: Vec<String>,
1651}
1652
1653/// Identifier words whose whole-word presence in a file's text is necessary
1654/// for the file to hold any posting [`AnalysisSession::indexed_references_to`]
1655/// can return for `symbol`. Member symbols include the owner class's short
1656/// name alongside the member name: `__construct` postings are recorded at
1657/// `new Cls(` sites, which never spell the member name.
1658fn reference_gate_needles(symbol: &crate::Name) -> Vec<String> {
1659 let mut needles = match symbol {
1660 crate::Name::Class(f) | crate::Name::Function(f) | crate::Name::GlobalConstant(f) => {
1661 vec![short(f).to_string()]
1662 }
1663 // `__construct` is invoked only as `new Cls(...)`, `parent::__construct()`,
1664 // or `self::__construct()`/`static::__construct()` from inside a
1665 // subclass — every real call site textually names the class itself
1666 // (directly, or via the enclosing subclass's own `extends`/`use`),
1667 // never the bare word `__construct`. Gating on the class's short name
1668 // alone is exact (no lost call sites) and, unlike the general member
1669 // case, dropping the method-name needle here doesn't reintroduce a
1670 // false negative. This matters: `__construct` is one of the most
1671 // common tokens in any real codebase, so OR-ing it in as a needle
1672 // admits nearly every file as a "must re-analyze" candidate on a
1673 // cold query, defeating the gate's entire purpose for constructors.
1674 crate::Name::Method { class, name } if name.as_ref() == "__construct" => {
1675 if class.is_empty() {
1676 // No class to scope to (owner unknown) — fall back to gating
1677 // on the bare name, same as the general member case below.
1678 vec![name.to_string()]
1679 } else {
1680 vec![short(class).to_string()]
1681 }
1682 }
1683 crate::Name::Method { class, name }
1684 | crate::Name::Property { class, name }
1685 | crate::Name::ClassConstant { class, name } => {
1686 let mut v = vec![name.to_string()];
1687 if !class.is_empty() {
1688 v.push(short(class).to_string());
1689 }
1690 v
1691 }
1692 };
1693 // An empty needle can never match; dropping it keeps the "empty needle
1694 // set disables the gate" contract at the call site conservative.
1695 needles.retain(|n| !n.is_empty());
1696 needles
1697}
1698
1699#[cfg(test)]
1700mod tests {
1701 use super::*;
1702
1703 #[test]
1704 fn mentions_identifier_is_case_insensitive_and_word_bounded() {
1705 assert!(mentions_identifier("$this->save();", "save"));
1706 assert!(mentions_identifier("new COLOR()", "Color"));
1707 assert!(mentions_identifier("use App\\Color as Paint;", "color"));
1708 assert!(!mentions_identifier("$this->saveAll();", "save"));
1709 assert!(!mentions_identifier("return $unsaved;", "save"));
1710 assert!(!mentions_identifier("no occurrence", "save"));
1711 assert!(!mentions_identifier("anything", ""));
1712 // Multibyte neighbors are conservatively treated as boundaries, and
1713 // substring scans must not split codepoints.
1714 assert!(!mentions_identifier("function xÉclairFoo() {}", "Éclair"));
1715 assert!(mentions_identifier("implements Éclair {}", "Éclair"));
1716 }
1717
1718 #[test]
1719 fn mention_scanner_membership_equals_per_needle_scans() {
1720 // The mention index is the sole implementation of the gates' textual
1721 // predicate, so scanner membership must equal the reference
1722 // per-needle predicate for every (hay, needle) pair — same boundary
1723 // and case semantics.
1724 use crate::db::MentionScanner;
1725 use std::sync::Arc;
1726 let universe = ["Color", "save", "ColorPicker", "Éclair", "C1", "_Wrap"];
1727 let names: Vec<(mir_types::Name, bool)> = universe
1728 .iter()
1729 .map(|s| (mir_types::Name::new(s).ascii_lowercase(), false))
1730 .collect();
1731 let scanner = Arc::new(MentionScanner::build(1, names).unwrap());
1732 let hays = [
1733 "$this->save();",
1734 "new COLOR()",
1735 "use App\\Color as Paint;",
1736 "$this->saveAll();",
1737 "return $unsaved;",
1738 "new ColorPicker(); Color::save();",
1739 "function xÉclairFoo() {}",
1740 "implements Éclair {}",
1741 "colorsave savecolor color_save",
1742 "class C1 extends _Wrap {}",
1743 "",
1744 ];
1745 for hay in hays {
1746 let scanned = scanner.scan(hay);
1747 for needle in universe {
1748 let expected = mentions_identifier(hay, needle);
1749 let name = mir_types::Name::new(needle).ascii_lowercase();
1750 assert_eq!(
1751 scanned.binary_search(&name).is_ok(),
1752 expected,
1753 "needle {needle:?} on {hay:?}"
1754 );
1755 }
1756 }
1757 }
1758
1759 #[test]
1760 fn gate_needles_cover_member_and_owner_class() {
1761 // A regular member (non-constructor) gates on both the member name
1762 // and the owner's short name — a call site may name only one.
1763 let n = reference_gate_needles(&crate::Name::method("App\\Job", "run"));
1764 assert!(n.contains(&"run".to_string()) && n.contains(&"Job".to_string()));
1765 let n = reference_gate_needles(&crate::Name::class("App\\Ui\\Color"));
1766 assert_eq!(n, vec!["Color".to_string()]);
1767 // Unknown-owner member symbols still gate on the member name alone.
1768 let n = reference_gate_needles(&crate::Name::method("", "run"));
1769 assert_eq!(n, vec!["run".to_string()]);
1770 }
1771
1772 #[test]
1773 fn gate_needles_for_constructor_scope_to_owner_class_only() {
1774 // `__construct` is only ever spelled at `new Cls(`/`parent::__construct()`
1775 // sites, which always name the class — the bare method-name needle is
1776 // dropped so a cold constructor query doesn't admit nearly every file
1777 // in the workspace (every class defines *some* `__construct`).
1778 let n = reference_gate_needles(&crate::Name::method("App\\Job", "__construct"));
1779 assert_eq!(n, vec!["Job".to_string()]);
1780 // Unknown owner: nothing to scope to, fall back to the bare name.
1781 let n = reference_gate_needles(&crate::Name::method("", "__construct"));
1782 assert_eq!(n, vec!["__construct".to_string()]);
1783 }
1784
1785 fn session_with(files: &[(&str, &str)]) -> crate::AnalysisSession {
1786 let session = crate::AnalysisSession::new(crate::PhpVersion::LATEST);
1787 for (path, text) in files {
1788 session.set_file_text(Arc::from(*path), Arc::from(*text));
1789 }
1790 session
1791 }
1792
1793 #[test]
1794 fn gate_static_method_is_member_name_only() {
1795 // Regardless of subtypes: the member token alone is the sound gate
1796 // (an instance receiver `$obj::m()` never names the owner), and it
1797 // is also the selective part — the owner short name would only
1798 // widen the admitted set.
1799 let session = session_with(&[
1800 (
1801 "owner.php",
1802 "<?php\nclass Owner { public static function m(): void {} }\n",
1803 ),
1804 ("sub.php", "<?php\nclass Sub extends Owner {}\n"),
1805 ]);
1806 let gate = session.reference_gate(&crate::Name::method("Owner", "m"));
1807 assert_eq!(gate.idents, vec!["m".to_string()]);
1808 assert!(gate.raw.is_empty());
1809 }
1810
1811 #[test]
1812 fn gate_instance_method_keeps_general_needles() {
1813 let session = session_with(&[(
1814 "owner.php",
1815 "<?php\nclass Owner { public function m(): void {} }\n",
1816 )]);
1817 let gate = session.reference_gate(&crate::Name::method("Owner", "m"));
1818 assert_eq!(
1819 gate.idents,
1820 reference_gate_needles(&crate::Name::method("Owner", "m"))
1821 );
1822 assert!(gate.raw.is_empty());
1823 }
1824
1825 #[test]
1826 fn gate_constructor_adds_raw_call_tokens() {
1827 // Owner short name for `new Cls(` sites, plus the raw call tokens
1828 // for explicit re-init (`$obj->__construct()`) whose file may never
1829 // name the class. The bare identifier `__construct` must NOT be a
1830 // needle — it would admit every file declaring a constructor.
1831 let session = session_with(&[(
1832 "owner.php",
1833 "<?php\nclass Owner { public function __construct() {} }\n",
1834 )]);
1835 let gate = session.reference_gate(&crate::Name::method("Owner", "__construct"));
1836 assert_eq!(
1837 gate.idents,
1838 reference_gate_needles(&crate::Name::method("Owner", "__construct"))
1839 );
1840 assert_eq!(
1841 gate.raw,
1842 vec!["->__construct".to_string(), "::__construct".to_string()]
1843 );
1844 }
1845
1846 #[test]
1847 fn gate_unresolvable_owner_falls_back_to_general_needles() {
1848 let session = session_with(&[(
1849 "owner.php",
1850 "<?php\nclass Owner { public static function m(): void {} }\n",
1851 )]);
1852 let gate = session.reference_gate(&crate::Name::method("Nonexistent\\Missing", "m"));
1853 assert_eq!(
1854 gate.idents,
1855 reference_gate_needles(&crate::Name::method("Nonexistent\\Missing", "m"))
1856 );
1857 assert!(gate.raw.is_empty());
1858 }
1859}