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