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 guard.set_file_reference_locations(file.as_ref(), out.ref_locs.to_vec());
839 }
840 if let (Some(s), Some(m)) = (&mention_scanner, mentions.take()) {
841 guard.set_file_class_mentions(file, text, s.epoch(), m);
842 }
843 if let Some(put) = put.take() {
844 self.apply_ref_cache_put(file.as_ref(), out, put);
845 }
846 self.mark_ref_committed(
847 file,
848 text,
849 Some(out),
850 commit_gen,
851 !out.has_unresolved_names(),
852 );
853 if !self.is_defs_committed(file.as_ref(), text) {
854 guard.set_file_class_edges(file, entries.clone());
855 self.mark_defs_committed(file, text);
856 }
857 }
858 }
859
860 // Posting lookup, filtered to the candidate scope.
861 //
862 // Member symbols resolve against the queried class plus its hierarchy
863 // (mir records member refs under the *declaring* class, so a query on
864 // an interface method must include implementor keys and vice versa).
865 // Name-only fallback postings — receivers whose type couldn't be
866 // resolved — are consulted only when the typed keys produce nothing,
867 // mirroring the pre-index two-tier behavior: exact results when
868 // resolution succeeds, by-name matches when nothing resolves.
869 // `__construct` stays exact: `new Sub()` invokes `Sub::__construct`
870 // even when only a parent declares one, so hierarchy fan-out would
871 // wrongly return subtype instantiation sites for a parent query.
872 let hierarchy: Vec<String> = match symbol {
873 crate::Name::Method { class, name } => {
874 if name.as_ref() == "__construct" || class.is_empty() {
875 if class.is_empty() {
876 Vec::new()
877 } else {
878 vec![class.trim_start_matches('\\').to_string()]
879 }
880 } else {
881 self.member_hierarchy_classes(class.as_ref())
882 }
883 }
884 crate::Name::Property { class, .. } | crate::Name::ClassConstant { class, .. } => {
885 if class.is_empty() {
886 Vec::new()
887 } else {
888 self.member_hierarchy_classes(class.as_ref())
889 }
890 }
891 _ => Vec::new(),
892 };
893 let scope: rustc_hash::FxHashSet<&str> = files.iter().map(|f| f.as_ref()).collect();
894 let read_symbol_key = |symbol_key: &str| -> Vec<(Arc<str>, crate::Range)> {
895 let guard = self.db.salsa.read();
896 guard
897 .reference_locations(symbol_key)
898 .into_iter()
899 .filter(|(file, ..)| scope.contains(file.as_ref()))
900 .map(|(file, line, col_start, col_end)| {
901 (file, span_range(line, col_start as u32, col_end as u32))
902 })
903 .collect()
904 };
905 let mut scratch_key = String::new();
906 let mut read_composed_key = |prefix: &str, middle: &str, separator: &str, suffix: &str| {
907 scratch_key.clear();
908 scratch_key.reserve(prefix.len() + middle.len() + separator.len() + suffix.len());
909 scratch_key.push_str(prefix);
910 scratch_key.push_str(middle);
911 scratch_key.push_str(separator);
912 scratch_key.push_str(suffix);
913 read_symbol_key(&scratch_key)
914 };
915 let mut out: Vec<(Arc<str>, crate::Range)> = Vec::new();
916 let mut append_plain = || match symbol {
917 crate::Name::Method { name, .. } => {
918 for class in &hierarchy {
919 out.extend(read_composed_key(
920 "meth:",
921 class.as_ref(),
922 "::",
923 name.as_ref(),
924 ));
925 }
926 }
927 crate::Name::Property { name, .. } => {
928 for class in &hierarchy {
929 out.extend(read_composed_key(
930 "prop:",
931 class.as_ref(),
932 "::",
933 name.as_ref(),
934 ));
935 }
936 }
937 crate::Name::ClassConstant { name, .. } => {
938 for class in &hierarchy {
939 out.extend(read_composed_key(
940 "cnst:",
941 class.as_ref(),
942 "::",
943 name.as_ref(),
944 ));
945 }
946 }
947 _ => out.extend(read_symbol_key(key.as_str())),
948 };
949 match includes {
950 crate::ReferenceIncludes::Plain => append_plain(),
951 crate::ReferenceIncludes::UseImports => {
952 out.extend(read_composed_key("use:", key.as_str(), "", ""));
953 }
954 crate::ReferenceIncludes::PlainAndUseImports => {
955 append_plain();
956 out.extend(read_composed_key("use:", key.as_str(), "", ""));
957 }
958 }
959 if matches!(includes, crate::ReferenceIncludes::Plain) && out.is_empty() {
960 match symbol {
961 crate::Name::Method { name, .. } => {
962 out = read_composed_key("methname:", name.as_ref(), "", "");
963 }
964 crate::Name::Property { name, .. } => {
965 out = read_composed_key("propname:", name.as_ref(), "", "");
966 }
967 _ => {}
968 }
969 }
970 out.sort_by(|a, b| {
971 a.0.cmp(&b.0)
972 .then(a.1.start.line.cmp(&b.1.start.line))
973 .then(a.1.start.column.cmp(&b.1.start.column))
974 });
975 out.dedup_by(|a, b| a.0 == b.0 && a.1 == b.1);
976
977 if include_declaration {
978 // Declaration lookup runs salsa queries (and may lazy-load); a
979 // concurrent write cancels it — declarations are then simply
980 // omitted rather than failing the whole request.
981 let decls: Vec<(Arc<str>, crate::Range)> = match symbol {
982 crate::Name::Method { class, .. }
983 | crate::Name::Property { class, .. }
984 | crate::Name::ClassConstant { class, .. } => {
985 if class.is_empty() {
986 // Unknown owner: declarations by name, recorded as
987 // `methdecl:`/`propdecl:`/`cnstdecl:` postings during
988 // class/trait/interface/enum analysis.
989 match symbol {
990 crate::Name::Method { name, .. } => {
991 read_composed_key("methdecl:", name.as_ref(), "", "")
992 }
993 crate::Name::Property { name, .. } => {
994 read_composed_key("propdecl:", name.as_ref(), "", "")
995 }
996 crate::Name::ClassConstant { name, .. } => {
997 read_composed_key("cnstdecl:", name.as_ref(), "", "")
998 }
999 _ => Vec::new(),
1000 }
1001 } else {
1002 salsa::Cancelled::catch(AssertUnwindSafe(|| {
1003 self.member_decl_sites(&hierarchy, symbol)
1004 }))
1005 .unwrap_or_default()
1006 }
1007 }
1008 _ => salsa::Cancelled::catch(AssertUnwindSafe(|| {
1009 self.declaration_name_range(symbol).into_iter().collect()
1010 }))
1011 .unwrap_or_default(),
1012 };
1013 for (file, range) in decls {
1014 if scope.contains(file.as_ref())
1015 && !out.iter().any(|(f, r)| *f == file && *r == range)
1016 {
1017 out.push((file, range));
1018 }
1019 }
1020 }
1021 Some(out)
1022 }
1023
1024 /// The queried class plus every class its members' references could be
1025 /// keyed under: resolved ancestors (a call on a subtype instance records
1026 /// the declaring ancestor) and transitive subtypes including trait users
1027 /// (a call on a subtype that overrides records the subtype). Display-form
1028 /// FQCNs, deduplicated case-insensitively.
1029 fn member_hierarchy_classes(&self, class_fqn: &str) -> Vec<String> {
1030 use std::panic::AssertUnwindSafe;
1031 let target = class_fqn.trim_start_matches('\\').to_string();
1032 let mut out: Vec<String> = vec![target.clone()];
1033 let ancestors = salsa::Cancelled::catch(AssertUnwindSafe(|| {
1034 let db = self.snapshot_db();
1035 let here = crate::db::Fqcn::from_str(&db, &target);
1036 crate::db::class_ancestors_by_fqcn(&db, here)
1037 .iter()
1038 .skip(1)
1039 .map(|a| a.trim_start_matches('\\').to_string())
1040 .collect::<Vec<_>>()
1041 }))
1042 .unwrap_or_default();
1043 out.extend(ancestors);
1044 let subs = {
1045 let guard = self.db.salsa.read();
1046 guard.subtype_sites_of(&target, true)
1047 };
1048 out.extend(
1049 subs.into_iter()
1050 .map(|s| s.fqcn.trim_start_matches('\\').to_string()),
1051 );
1052 let mut seen: rustc_hash::FxHashSet<String> = rustc_hash::FxHashSet::default();
1053 out.retain(|c| seen.insert(c.to_ascii_lowercase()));
1054 out
1055 }
1056
1057 /// Own-member declaration sites for `symbol` across `classes`: each class
1058 /// that itself declares the member (not inherited) contributes its name
1059 /// token. Kind-specific lookups — a class often declares a property and a
1060 /// method with the same short name, and `member_location` can't tell them
1061 /// apart.
1062 fn member_decl_sites(
1063 &self,
1064 classes: &[String],
1065 symbol: &crate::Name,
1066 ) -> Vec<(Arc<str>, crate::Range)> {
1067 let mut out: Vec<(Arc<str>, crate::Range)> = Vec::new();
1068 let db = self.snapshot_db();
1069 for class in classes {
1070 let here = crate::db::Fqcn::from_str(&db, class);
1071 let (loc, needle) = match symbol {
1072 crate::Name::Method { name, .. } => {
1073 let Some(m) = crate::db::find_method_in_class(&db, here, name) else {
1074 continue;
1075 };
1076 (m.location.clone(), name.to_string())
1077 }
1078 crate::Name::Property { name, .. } => {
1079 let Some(p) = crate::db::find_property_in_class(&db, here, name) else {
1080 continue;
1081 };
1082 (p.location.clone(), name.to_string())
1083 }
1084 crate::Name::ClassConstant { name, .. } => {
1085 let Some(c) = crate::db::find_class_constant_in_class(&db, here, name) else {
1086 continue;
1087 };
1088 (c.location.clone(), name.to_string())
1089 }
1090 _ => continue,
1091 };
1092 let Some(loc) = loc else { continue };
1093 let range = self.refine_location_to_name(&loc, &needle);
1094 out.push((loc.file.clone(), range));
1095 }
1096 out
1097 }
1098
1099 /// The symbol's declaration site, narrowed from the collector's
1100 /// whole-declaration span to the declared name's own token (matching the
1101 /// span shape of recorded references).
1102 pub fn declaration_name_range(&self, symbol: &crate::Name) -> Option<(Arc<str>, crate::Range)> {
1103 if let crate::Name::GlobalConstant(fqn) = symbol {
1104 return self.global_constant_decl_range(fqn);
1105 }
1106 let loc = self.definition_of(symbol).ok()?;
1107 let short = match symbol {
1108 crate::Name::Class(f) | crate::Name::Function(f) | crate::Name::GlobalConstant(f) => {
1109 crate::db::subtype_index::short_name_of(f)
1110 }
1111 crate::Name::Method { name, .. }
1112 | crate::Name::Property { name, .. }
1113 | crate::Name::ClassConstant { name, .. } => name.as_ref(),
1114 };
1115 // Property declarations carry a `$` sigil in source, but reference
1116 // ranges cover the bare name; the word-boundary search below lands on
1117 // the name right after the sigil.
1118 let file = loc.file.clone();
1119 let range = self.refine_location_to_name(&loc, short);
1120 Some((file, range))
1121 }
1122
1123 /// Narrow a whole-declaration [`mir_types::Location`] to the first
1124 /// word-boundary occurrence of `needle` inside its line span. Falls back
1125 /// to the location's own coordinates when the text is unavailable or the
1126 /// name doesn't appear (e.g. stub-only declarations).
1127 fn refine_location_to_name(&self, loc: &mir_types::Location, needle: &str) -> crate::Range {
1128 let fallback = span_range(loc.line, loc.col_start as u32, loc.col_end as u32);
1129 let text = {
1130 let db = self.snapshot_db();
1131 db.lookup_source_file(loc.file.as_ref())
1132 .map(|sf| sf.text(&db as &dyn MirDatabase).clone())
1133 };
1134 let Some(text) = text else {
1135 return fallback;
1136 };
1137 let needle_chars = needle.chars().count() as u32;
1138 let first_line = loc.line.saturating_sub(1) as usize;
1139 // Exact-case first: PHP property/constant names are case-sensitive
1140 // and an early case-insensitive hit can land on an unrelated token
1141 // (a type hint sharing the name). Case-insensitive second, for
1142 // method/class needles that arrive lowercase-normalized.
1143 for case_insensitive in [false, true] {
1144 for (idx, line_text) in text.lines().enumerate().skip(first_line) {
1145 let line_no = idx as u32 + 1;
1146 if line_no > loc.line_end {
1147 break;
1148 }
1149 let min_col = if line_no == loc.line {
1150 loc.col_start as usize
1151 } else {
1152 0
1153 };
1154 if let Some(col) = identifier_char_col(line_text, needle, min_col, case_insensitive)
1155 {
1156 return span_range(line_no, col, col + needle_chars);
1157 }
1158 }
1159 }
1160 fallback
1161 }
1162
1163 /// Transitive subtypes of `class_fqn` (classes/interfaces/enums whose
1164 /// resolved ancestor chain reaches it), answered from the maintained
1165 /// subtype edge index.
1166 ///
1167 /// `files` is the host's candidate scope for the on-demand completeness
1168 /// pass: per BFS round, not-yet-committed files whose text mentions a
1169 /// frontier short name get their definitions committed, so results are
1170 /// complete even before a background sweep has covered the workspace.
1171 /// That short-name gate is only candidate discovery; subtype identity is
1172 /// still resolved from the edge index below using the exact canonical
1173 /// FQCN.
1174 /// Committed files answer from the index with no parsing at all.
1175 ///
1176 /// `include_trait_users` also counts `use Trait;` composition as a
1177 /// subtype edge (visibility-scoping semantics); leave it off for
1178 /// goto-implementation semantics (extends/implements only).
1179 ///
1180 /// Memoized per `(class_fqn, include_trait_users, files, text_revision)`
1181 /// — same shape and rationale as [`Self::indexed_references_to`]'s
1182 /// cache: `commit_defs_for_matching`'s freshness pass costs O(candidates)
1183 /// on every call regardless of outcome, so a repeat query (e.g. a host
1184 /// resolving a protected/static method's reference scope on every
1185 /// code-lens refresh) would otherwise re-pay it every time.
1186 pub fn indexed_subtype_classes(
1187 &self,
1188 class_fqn: &str,
1189 files: &[Arc<str>],
1190 include_trait_users: bool,
1191 ) -> Vec<SubtypeClassSite> {
1192 let cache_key = SubtypeQueryCacheKey {
1193 class_fqn: class_fqn.trim_start_matches('\\').to_ascii_lowercase(),
1194 include_trait_users,
1195 generation: self.query_cache_generation(),
1196 files_hash: hash_files(files),
1197 };
1198 if let Some(cached) = self.subtype_query_cache.read().get(&cache_key) {
1199 self.subtype_query_cache_hits
1200 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1201 return (**cached).clone();
1202 }
1203 let result = self.indexed_subtype_classes_uncached(class_fqn, files, include_trait_users);
1204 if self.query_cache_generation() != cache_key.generation {
1205 // The generation moved while computing (an edit, or a defs
1206 // commit growing the subtype index — possibly this query's own).
1207 // The key can never be looked up again, so don't cache it; the
1208 // next identical query recomputes once against settled state
1209 // and caches then.
1210 return result;
1211 }
1212 let mut cache = self.subtype_query_cache.write();
1213 if !cache.advance_to(cache_key.generation, || {
1214 self.subtype_query_cache_sites
1215 .store(0, std::sync::atomic::Ordering::Relaxed);
1216 }) {
1217 return result;
1218 }
1219 let new_len = result.len();
1220 let prior = self
1221 .subtype_query_cache_sites
1222 .fetch_add(new_len, std::sync::atomic::Ordering::Relaxed);
1223 if prior + new_len > SUBTYPE_QUERY_CACHE_SITE_CAP {
1224 cache.map.clear();
1225 self.subtype_query_cache_sites
1226 .store(new_len, std::sync::atomic::Ordering::Relaxed);
1227 }
1228 cache.map.insert(cache_key, Arc::new(result.clone()));
1229 result
1230 }
1231
1232 /// Uncached implementation of [`Self::indexed_subtype_classes`]. Callers
1233 /// should use the memoizing wrapper.
1234 fn indexed_subtype_classes_uncached(
1235 &self,
1236 class_fqn: &str,
1237 files: &[Arc<str>],
1238 include_trait_users: bool,
1239 ) -> Vec<SubtypeClassSite> {
1240 self.settle_workspace_index();
1241 let mut scanned: rustc_hash::FxHashSet<String> = rustc_hash::FxHashSet::default();
1242 let mut pending: Vec<String> = vec![class_fqn.trim_start_matches('\\').to_string()];
1243 let mut sites: Vec<crate::db::SubtypeSite> = Vec::new();
1244 while !pending.is_empty() {
1245 // Use short names only to discover stale/uncommitted files worth
1246 // collecting. The query result itself comes from the subtype
1247 // edge index (`subtype_sites_of`) below.
1248 let needles: Vec<String> = pending
1249 .drain(..)
1250 .filter(|f| scanned.insert(f.clone()))
1251 .map(|f| crate::db::subtype_index::short_name_of(&f).to_string())
1252 .collect();
1253 if !needles.is_empty() {
1254 self.commit_defs_for_matching(files, &needles);
1255 }
1256 sites = {
1257 let guard = self.db.salsa.read();
1258 guard.subtype_sites_of(class_fqn, include_trait_users)
1259 };
1260 pending = sites
1261 .iter()
1262 .map(|s| s.fqcn.trim_start_matches('\\').to_string())
1263 .filter(|f| !scanned.contains(f))
1264 .collect();
1265 }
1266 let mut out: Vec<SubtypeClassSite> = sites
1267 .into_iter()
1268 .filter_map(|s| {
1269 let loc = s.location.as_ref()?;
1270 let short = crate::db::subtype_index::short_name_of(&s.fqcn).to_string();
1271 let range = self.refine_location_to_name(loc, &short);
1272 Some(SubtypeClassSite {
1273 fqcn: s.fqcn,
1274 kind: s.kind,
1275 is_abstract: s.is_abstract,
1276 file: s.file,
1277 range,
1278 })
1279 })
1280 .collect();
1281 // Anonymous classes never reach the definition collector; their
1282 // `new class implements X {}` sites are recorded under the resolved
1283 // canonical parent FQCN during body analysis.
1284 let root_lc = class_fqn.trim_start_matches('\\').to_ascii_lowercase();
1285 let scope: rustc_hash::FxHashSet<&str> = files.iter().map(|f| f.as_ref()).collect();
1286 let anon: Vec<(Arc<str>, u32, u16, u16)> = {
1287 let guard = self.db.salsa.read();
1288 let mut key = String::with_capacity("impl:".len() + root_lc.len());
1289 key.push_str("impl:");
1290 key.push_str(&root_lc);
1291 guard.reference_locations(&key)
1292 };
1293 for (file, line, cs, ce) in anon {
1294 if !scope.contains(file.as_ref()) {
1295 continue;
1296 }
1297 let range = span_range(line, cs as u32, ce as u32);
1298 if out.iter().any(|s| s.file == file && s.range == range) {
1299 continue;
1300 }
1301 out.push(SubtypeClassSite {
1302 fqcn: Arc::from("class@anonymous"),
1303 kind: crate::db::ClassLikeKind::Class,
1304 is_abstract: false,
1305 file,
1306 range,
1307 });
1308 }
1309 out
1310 }
1311
1312 /// Concrete implementations of `class_fqn::method` across its transitive
1313 /// subtypes: the same-named non-abstract method available to each subtype
1314 /// (its own declaration, or one inherited/composed from a parent, trait,
1315 /// or mixin), as `(subtype fqcn, file, name range)`. Subtypes resolving to
1316 /// the same declaring location collapse to a single entry.
1317 pub fn indexed_method_implementations(
1318 &self,
1319 class_fqn: &str,
1320 method: &str,
1321 files: &[Arc<str>],
1322 ) -> Vec<(Arc<str>, Arc<str>, crate::Range)> {
1323 use std::panic::AssertUnwindSafe;
1324 let subs = self.indexed_subtype_classes(class_fqn, files, false);
1325 if subs.is_empty() {
1326 return Vec::new();
1327 }
1328 loop {
1329 let attempt = salsa::Cancelled::catch(AssertUnwindSafe(|| {
1330 let db = self.snapshot_db();
1331 let mut out: Vec<(Arc<str>, Arc<str>, crate::Range)> = Vec::new();
1332 for sub in &subs {
1333 let here = crate::db::Fqcn::from_str(&db, sub.fqcn.as_ref());
1334 let Some((_, m)) = crate::db::find_method_in_chain(&db, here, method) else {
1335 continue;
1336 };
1337 if m.is_abstract {
1338 continue;
1339 }
1340 let Some(loc) = m.location.as_ref() else {
1341 continue;
1342 };
1343 let range = self.refine_location_to_name(loc, method);
1344 out.push((sub.fqcn.clone(), loc.file.clone(), range));
1345 }
1346 out
1347 }));
1348 if let Ok(mut out) = attempt {
1349 out.sort_by(|a, b| a.1.cmp(&b.1).then(a.2.start.line.cmp(&b.2.start.line)));
1350 out.dedup_by(|a, b| a.1 == b.1 && a.2 == b.2);
1351 return out;
1352 }
1353 }
1354 }
1355
1356 /// Commit definitions (class edges + freshness) for every file in `files`
1357 /// that is stale (committed against older text) or that has never been
1358 /// committed and mentions one of `shorts` as a whole identifier.
1359 ///
1360 /// The textual gate answers from the shared per-file mention cache — the
1361 /// same one `indexed_references_to`'s gate populates — so a file scanned
1362 /// by either consumer answers the other with a set lookup instead of an
1363 /// O(text) rescan per BFS round. A file the cache can't answer for is
1364 /// scanned once against the whole name universe and recorded.
1365 fn commit_defs_for_matching(&self, files: &[Arc<str>], shorts: &[String]) {
1366 use std::panic::AssertUnwindSafe;
1367
1368 use rayon::prelude::*;
1369
1370 let committed_any: rustc_hash::FxHashSet<Arc<str>> = {
1371 let guard = self.defs_committed_keys();
1372 guard.into_iter().collect()
1373 };
1374 // Admit the frontier names before preparing, so every needle gets a
1375 // real query (a declared class's short name is already in the
1376 // universe from indexing — admission then changes nothing).
1377 let (queries, mention_scanner) = {
1378 let guard = self.db.salsa.read();
1379 guard.add_literal_mention_names(shorts.iter().map(|s| s.as_str()));
1380 let queries: Vec<_> = shorts
1381 .iter()
1382 .filter_map(|s| guard.prepare_class_mention_query(s))
1383 .collect();
1384 (queries, guard.class_mention_scanner())
1385 };
1386 // Admission guarantees a query per needle and a non-empty universe;
1387 // anything else is defensive — the gate then admits every candidate
1388 // (recommit rather than skip, the conservative direction).
1389 let use_mentions = queries.len() == shorts.len() && mention_scanner.is_some();
1390 type Work = (Arc<str>, Arc<str>, Vec<crate::db::SubtypeEntry>);
1391 type MentionScanRec = (Arc<str>, Arc<str>, Box<[mir_types::Name]>);
1392 let (work, scanned): (Vec<Work>, Vec<MentionScanRec>) = loop {
1393 let attempt = salsa::Cancelled::catch(AssertUnwindSafe(|| {
1394 let db_main = self.snapshot_db();
1395 files
1396 .par_iter()
1397 .map_with(db_main, |db, path| {
1398 let Some(sf) = db.lookup_source_file(path.as_ref()) else {
1399 return (None, None);
1400 };
1401 let text = sf.text(&*db as &dyn MirDatabase).clone();
1402 if self.is_defs_committed(path.as_ref(), &text) {
1403 return (None, None);
1404 }
1405 // Never-committed files must mention a frontier name;
1406 // stale (previously committed) files recommit
1407 // unconditionally — their classes may have re-parented.
1408 let mut scan_rec: Option<MentionScanRec> = None;
1409 if use_mentions && !committed_any.contains(path.as_ref()) {
1410 let mut answer = Some(false);
1411 for q in &queries {
1412 match db.class_mention_answer(path.as_ref(), q, &text) {
1413 Some(true) => {
1414 answer = Some(true);
1415 break;
1416 }
1417 Some(false) => {}
1418 None => answer = None,
1419 }
1420 }
1421 let hit = match answer {
1422 Some(hit) => hit,
1423 None => {
1424 // Uncoverable entry: one scan answers
1425 // every query and is recorded below.
1426 let scanner = mention_scanner.as_ref().unwrap();
1427 let names = scanner.scan(&text);
1428 let hit = queries
1429 .iter()
1430 .any(|q| names.binary_search(&q.name).is_ok());
1431 scan_rec = Some((path.clone(), text.clone(), names));
1432 hit
1433 }
1434 };
1435 if !hit {
1436 return (None, scan_rec);
1437 }
1438 }
1439 let defs =
1440 crate::db::collect_file_definitions(&*db as &dyn MirDatabase, sf);
1441 let entries = crate::db::subtype_index::entries_from_slice(&defs.slice);
1442 (Some((path.clone(), text, entries)), scan_rec)
1443 })
1444 .collect::<Vec<_>>()
1445 }));
1446 if let Ok(v) = attempt {
1447 let mut work = Vec::new();
1448 let mut scanned = Vec::new();
1449 for (w, rec) in v {
1450 if let Some(w) = w {
1451 work.push(w);
1452 }
1453 if let Some(rec) = rec {
1454 scanned.push(rec);
1455 }
1456 }
1457 break (work, scanned);
1458 }
1459 };
1460 // Record the fallback scans regardless of hit/miss: each is a
1461 // complete, current mention set for its file, so the next round's
1462 // (and the references gate's) checks become set lookups.
1463 if let Some(scanner) = &mention_scanner {
1464 if !scanned.is_empty() {
1465 let guard = self.db.salsa.read();
1466 for (file, text, names) in scanned {
1467 guard.set_file_class_mentions(&file, &text, scanner.epoch(), names);
1468 }
1469 }
1470 }
1471 if work.is_empty() {
1472 return;
1473 }
1474 let guard = self.db.salsa.read();
1475 for (file, text, entries) in &work {
1476 guard.set_file_class_edges(file, entries.clone());
1477 self.mark_defs_committed(file, text);
1478 }
1479 }
1480
1481 /// Declaration name span for a global constant. Constant slices carry no
1482 /// stored location, so this finds the declaring file via the workspace
1483 /// constants index and locates the `const NAME` / `define('NAME'` token
1484 /// textually.
1485 fn global_constant_decl_range(&self, fqn: &str) -> Option<(Arc<str>, crate::Range)> {
1486 use std::panic::AssertUnwindSafe;
1487 let short = crate::db::subtype_index::short_name_of(fqn).to_string();
1488 salsa::Cancelled::catch(AssertUnwindSafe(|| {
1489 let db = self.snapshot_db();
1490 let index = crate::db::workspace_index(&db);
1491 let loc = index.constant_loc(mir_types::Name::from(fqn.trim_start_matches('\\')))?;
1492 let file = loc.file().path(&db).clone();
1493 let sf = db.lookup_source_file(file.as_ref())?;
1494 let text = sf.text(&db as &dyn MirDatabase);
1495 for (idx, line) in text.lines().enumerate() {
1496 let trimmed = line.trim_start();
1497 let is_decl_line = trimmed.starts_with("const ")
1498 || trimmed.contains("define(")
1499 || trimmed.contains("define (");
1500 if !is_decl_line {
1501 continue;
1502 }
1503 if let Some(col) = identifier_char_col(line, &short, 0, false) {
1504 let n = short.chars().count() as u32;
1505 return Some((file, span_range(idx as u32 + 1, col, col + n)));
1506 }
1507 }
1508 None
1509 }))
1510 .ok()
1511 .flatten()
1512 }
1513
1514 /// Class-level issues (inheritance violations, abstract-method gaps, override
1515 /// incompatibilities) for the given set of files.
1516 ///
1517 /// These checks are cross-file by nature and are not emitted by
1518 /// [`crate::FileAnalyzer::analyze`]. Call this after ingesting or
1519 /// re-analyzing a file and its dependents to get the full diagnostic picture.
1520 ///
1521 /// Circular-inheritance checks always run against the full workspace graph
1522 /// regardless of the `files` filter — a cycle is a workspace-wide problem.
1523 pub fn class_issues(&self, files: &[Arc<str>]) -> Vec<crate::Issue> {
1524 self.settle_workspace_index();
1525 let db = self.snapshot_db();
1526 let file_set: HashSet<Arc<str>> = files.iter().cloned().collect();
1527 // Read source texts through the snapshot already in hand — calling
1528 // `source_of` here would re-enter the session RwLock while this
1529 // snapshot is live, and a concurrent salsa write (which blocks new
1530 // readers behind the fair write lock while waiting for existing
1531 // snapshots to drop) turns that into a deadlock.
1532 let file_data: Vec<(Arc<str>, Arc<str>)> = files
1533 .iter()
1534 .filter_map(|f| {
1535 let sf = db.lookup_source_file(f)?;
1536 Some((
1537 f.clone(),
1538 sf.text(&db as &dyn crate::db::MirDatabase).clone(),
1539 ))
1540 })
1541 .collect();
1542 crate::class::ClassAnalyzer::with_files(&db, file_set, &file_data).analyze_all()
1543 }
1544
1545 /// Collector-phase issues (e.g. `BackedEnumCaseTypeMismatch`,
1546 /// `InvalidReadonlyPropertyDeclaration`, `InvalidDocblock`, and raw parse
1547 /// errors) for the given files.
1548 ///
1549 /// These are found while building a file's declaration slice
1550 /// ([`crate::db::collect_file_definitions`]), before body analysis or
1551 /// cross-file class checks ever run — neither [`crate::FileAnalyzer::analyze`]
1552 /// nor [`Self::class_issues`] reads them, so a caller merging just those
1553 /// two sources silently drops every collector-time diagnostic. Call this
1554 /// alongside them to get the full picture.
1555 ///
1556 /// A plain snapshot read through [`crate::db::collect_file_definitions`],
1557 /// same as [`Self::document_symbols`] — correct regardless of which path
1558 /// put the file's text into the db (`ingest_file`, `set_file_text`, lazy
1559 /// vendor load) or how many times. The parse-cache fast paths behind that
1560 /// query used to zero out `issues` on any hit, including re-collecting
1561 /// the *same* file after its salsa memo was invalidated; they now
1562 /// preserve (and, for a genuinely different file sharing content,
1563 /// re-point) the originally-computed issues instead.
1564 pub fn collector_issues(&self, files: &[Arc<str>]) -> Vec<crate::Issue> {
1565 let db = self.snapshot_db();
1566 files
1567 .iter()
1568 .filter_map(|f| db.lookup_source_file(f))
1569 .flat_map(|sf| {
1570 crate::db::collect_file_definitions(&db, sf)
1571 .issues
1572 .as_ref()
1573 .clone()
1574 })
1575 .collect()
1576 }
1577
1578 /// All declarations defined in `file` as a **hierarchical tree**.
1579 ///
1580 /// Classes/interfaces/traits/enums are returned with their methods,
1581 /// properties, and constants nested in `children`. Top-level functions
1582 /// and constants are returned with empty `children`.
1583 pub fn document_symbols(&self, file: &str) -> Vec<crate::symbol::DocumentSymbol> {
1584 use crate::symbol::{DeclarationKind, DocumentSymbol};
1585
1586 let db = self.snapshot_db();
1587 let Some(sf) = db.lookup_source_file(file) else {
1588 return Vec::new();
1589 };
1590 let defs = crate::db::collect_file_definitions(&db, sf);
1591 let mut out: Vec<DocumentSymbol> = Vec::new();
1592
1593 let class_children = |methods: &mir_codebase::definitions::MemberMap<
1594 Arc<mir_codebase::definitions::MethodDef>,
1595 >,
1596 props: Option<
1597 &mir_codebase::definitions::MemberMap<mir_codebase::definitions::PropertyDef>,
1598 >,
1599 consts: &mir_codebase::definitions::MemberMap<
1600 mir_codebase::definitions::ConstantDef,
1601 >,
1602 is_enum: bool|
1603 -> Vec<DocumentSymbol> {
1604 let mut out: Vec<DocumentSymbol> = Vec::new();
1605 for (_, m) in methods.iter() {
1606 out.push(DocumentSymbol {
1607 name: m.name.clone(),
1608 kind: DeclarationKind::Method,
1609 location: m.location.clone(),
1610 children: Vec::new(),
1611 });
1612 }
1613 if let Some(props) = props {
1614 for (_, p) in props.iter() {
1615 out.push(DocumentSymbol {
1616 name: p.name.clone(),
1617 kind: DeclarationKind::Property,
1618 location: p.location.clone(),
1619 children: Vec::new(),
1620 });
1621 }
1622 }
1623 let const_kind = if is_enum {
1624 DeclarationKind::EnumCase
1625 } else {
1626 DeclarationKind::Constant
1627 };
1628 for (_, c) in consts.iter() {
1629 out.push(DocumentSymbol {
1630 name: c.name.clone(),
1631 kind: const_kind,
1632 location: c.location.clone(),
1633 children: Vec::new(),
1634 });
1635 }
1636 out
1637 };
1638
1639 for c in defs.slice.classes.iter() {
1640 out.push(DocumentSymbol {
1641 name: c.fqcn.clone(),
1642 kind: DeclarationKind::Class,
1643 location: c.location.clone(),
1644 children: class_children(
1645 &c.own_methods,
1646 Some(&c.own_properties),
1647 &c.own_constants,
1648 false,
1649 ),
1650 });
1651 }
1652 for i in defs.slice.interfaces.iter() {
1653 out.push(DocumentSymbol {
1654 name: i.fqcn.clone(),
1655 kind: DeclarationKind::Interface,
1656 location: i.location.clone(),
1657 children: class_children(&i.own_methods, None, &i.own_constants, false),
1658 });
1659 }
1660 for t in defs.slice.traits.iter() {
1661 out.push(DocumentSymbol {
1662 name: t.fqcn.clone(),
1663 kind: DeclarationKind::Trait,
1664 location: t.location.clone(),
1665 children: class_children(
1666 &t.own_methods,
1667 Some(&t.own_properties),
1668 &t.own_constants,
1669 false,
1670 ),
1671 });
1672 }
1673 for e in defs.slice.enums.iter() {
1674 let mut children = class_children(&e.own_methods, None, &e.own_constants, true);
1675 for (_, case) in e.cases.iter() {
1676 children.push(DocumentSymbol {
1677 name: case.name.clone(),
1678 kind: DeclarationKind::EnumCase,
1679 location: case.location.clone(),
1680 children: Vec::new(),
1681 });
1682 }
1683 out.push(DocumentSymbol {
1684 name: e.fqcn.clone(),
1685 kind: DeclarationKind::Enum,
1686 location: e.location.clone(),
1687 children,
1688 });
1689 }
1690 for f in defs.slice.functions.iter() {
1691 out.push(DocumentSymbol {
1692 name: f.fqn.clone(),
1693 kind: DeclarationKind::Function,
1694 location: f.location.clone(),
1695 children: Vec::new(),
1696 });
1697 }
1698 for (name, _) in defs.slice.constants.iter() {
1699 out.push(DocumentSymbol {
1700 name: name.clone(),
1701 kind: DeclarationKind::Constant,
1702 location: None,
1703 children: Vec::new(),
1704 });
1705 }
1706 out
1707 }
1708
1709 /// Choose the candidate-admission gate for `symbol`.
1710 ///
1711 /// For any known non-constructor/non-`__invoke` method, the member name
1712 /// alone is the sound gate. Every posting-producing reference spells that
1713 /// token: `$obj->m()`, `Owner::m()`, inherited `Sub::m()`,
1714 /// `self::`/`static::`/`parent::m()`, callable strings/arrays, and trait
1715 /// aliases (`orig as alias` records `orig`, alias calls record `alias`
1716 /// plus the origin key). Dropping the owner short name matters on common
1717 /// owner names (`User`, `Model`, `Widget`): otherwise a cold reference
1718 /// query analyzes files that only type-hint the owner and cannot contain a
1719 /// reference to the queried method. Dynamic member names (`$obj->$m()`)
1720 /// produce no posting, so nothing is lost there.
1721 ///
1722 /// For `__construct` with a known owner, the identifier needle is the
1723 /// owner's short name (`new Cls(` sites never spell the member name and
1724 /// the bare word `__construct` would admit every file *declaring* a
1725 /// constructor), complemented by the raw call tokens `->__construct` /
1726 /// `::__construct`: an explicit re-init `$obj->__construct()` is a real
1727 /// recorded reference whose file may never name the class.
1728 ///
1729 /// `__invoke` keeps the general owner/name gate because `$obj()` call sites
1730 /// do not spell `__invoke`.
1731 ///
1732 /// Everything else uses the general OR needles
1733 /// ([`reference_gate_needles`]).
1734 fn reference_gate(&self, symbol: &crate::Name) -> ReferenceGate {
1735 if let crate::Name::Method { class, name } = symbol {
1736 if name.as_ref() == "__construct" && !class.is_empty() {
1737 return ReferenceGate {
1738 idents: reference_gate_needles(symbol),
1739 raw: vec!["->__construct".to_string(), "::__construct".to_string()],
1740 };
1741 }
1742 if name.as_ref() != "__construct" && name.as_ref() != "__invoke" && !class.is_empty() {
1743 let db = self.snapshot_db();
1744 let here = crate::db::Fqcn::from_str(&db, class.as_ref());
1745 let is_static = crate::db::find_method_in_chain(&db, here, name)
1746 .map(|(_, m)| m.is_static)
1747 .unwrap_or(false);
1748 if is_static || crate::db::class_exists(&db, class.as_ref()) {
1749 return ReferenceGate {
1750 idents: vec![name.to_string()],
1751 raw: Vec::new(),
1752 };
1753 }
1754 }
1755 }
1756 ReferenceGate {
1757 idents: reference_gate_needles(symbol),
1758 raw: Vec::new(),
1759 }
1760 }
1761}
1762
1763/// A transitive subtype hit with its declaration name span, as returned by
1764/// [`AnalysisSession::indexed_subtype_classes`].
1765#[derive(Debug, Clone)]
1766pub struct SubtypeClassSite {
1767 /// Display-form FQCN (no leading `\`).
1768 pub fqcn: Arc<str>,
1769 pub kind: crate::db::ClassLikeKind,
1770 pub is_abstract: bool,
1771 pub file: Arc<str>,
1772 /// The declared name's own token (1-based line, 0-based char columns).
1773 pub range: crate::Range,
1774}
1775
1776/// Build a [`crate::Range`] on one line from mir's native coordinates
1777/// (1-based line, 0-based columns).
1778fn span_range(line: u32, col_start: u32, col_end: u32) -> crate::Range {
1779 crate::Range {
1780 start: crate::Position {
1781 line,
1782 column: col_start,
1783 },
1784 end: crate::Position {
1785 line,
1786 column: col_end,
1787 },
1788 }
1789}
1790
1791/// Char column of the first word-boundary occurrence of `needle` in `line`
1792/// at or after char column `min_col`. Columns are code points, matching the
1793/// collector's `Location` convention.
1794fn identifier_char_col(
1795 line: &str,
1796 needle: &str,
1797 min_col: usize,
1798 case_insensitive: bool,
1799) -> Option<u32> {
1800 if needle.is_empty() {
1801 return None;
1802 }
1803 let is_ident = |c: char| c.is_ascii_alphanumeric() || c == '_';
1804 let chars: Vec<char> = line.chars().collect();
1805 let needle_chars: Vec<char> = needle.chars().collect();
1806 let n = needle_chars.len();
1807 if chars.len() < n {
1808 return None;
1809 }
1810 for start in min_col..=chars.len().saturating_sub(n) {
1811 let matches = chars[start..start + n]
1812 .iter()
1813 .zip(needle_chars.iter())
1814 .all(|(a, b)| {
1815 if case_insensitive {
1816 a.eq_ignore_ascii_case(b)
1817 } else {
1818 a == b
1819 }
1820 });
1821 if !matches {
1822 continue;
1823 }
1824 let before_ok = start == 0 || !is_ident(chars[start - 1]);
1825 let after = start + n;
1826 let after_ok = after >= chars.len() || !is_ident(chars[after]);
1827 if before_ok && after_ok {
1828 return Some(start as u32);
1829 }
1830 }
1831 None
1832}
1833
1834/// Whether `hay` mentions `needle` as a whole identifier (ASCII word
1835/// boundaries; conservative near multibyte text). ASCII-case-insensitive:
1836/// PHP class, function, and method names are case-insensitive, so `new
1837/// COLOR()` must count as mentioning `Color`; for the case-sensitive kinds
1838/// (constants, properties) folding only widens the candidate superset.
1839///
1840/// Test-only semantic oracle: the production gates (references freshness,
1841/// subtype-BFS defs commit) answer this predicate through the persistent
1842/// `ClassMentionIndex`; the parity test below pins the scanner to these
1843/// exact boundary and case semantics.
1844#[cfg(test)]
1845fn mentions_identifier(hay: &str, needle: &str) -> bool {
1846 let hay = hay.as_bytes();
1847 let needle = needle.as_bytes();
1848 let n = needle.len();
1849 if n == 0 || hay.len() < n {
1850 return false;
1851 }
1852 let is_ident = |b: u8| b.is_ascii_alphanumeric() || b == b'_';
1853 let first = needle[0].to_ascii_lowercase();
1854 for i in 0..=(hay.len() - n) {
1855 if hay[i].to_ascii_lowercase() != first || !hay[i..i + n].eq_ignore_ascii_case(needle) {
1856 continue;
1857 }
1858 if (i == 0 || !is_ident(hay[i - 1])) && (i + n == hay.len() || !is_ident(hay[i + n])) {
1859 return true;
1860 }
1861 }
1862 false
1863}
1864
1865fn short(fqn: &str) -> &str {
1866 fqn.rsplit('\\').next().unwrap_or(fqn)
1867}
1868
1869/// A candidate-file admission predicate for `indexed_references_to`'s
1870/// freshness pass, chosen by [`AnalysisSession::reference_gate`]. A file is
1871/// admitted when its text mentions any of `idents` as a whole identifier
1872/// (word-bounded, ASCII-case-insensitive) OR contains any of `raw` as a
1873/// plain substring (ASCII-case-insensitive, no word bounds — used for
1874/// call-shaped tokens like `->__construct`). A file matching neither can
1875/// hold no posting for the symbol.
1876struct ReferenceGate {
1877 idents: Vec<String>,
1878 raw: Vec<String>,
1879}
1880
1881/// Identifier words whose whole-word presence in a file's text is necessary
1882/// for the file to hold any posting [`AnalysisSession::indexed_references_to`]
1883/// can return for `symbol`. Member symbols include the owner class's short
1884/// name alongside the member name: `__construct` postings are recorded at
1885/// `new Cls(` sites, which never spell the member name.
1886fn reference_gate_needles(symbol: &crate::Name) -> Vec<String> {
1887 let mut needles = match symbol {
1888 crate::Name::Class(f) | crate::Name::Function(f) | crate::Name::GlobalConstant(f) => {
1889 vec![short(f).to_string()]
1890 }
1891 // `__construct` is invoked only as `new Cls(...)`, `parent::__construct()`,
1892 // or `self::__construct()`/`static::__construct()` from inside a
1893 // subclass — every real call site textually names the class itself
1894 // (directly, or via the enclosing subclass's own `extends`/`use`),
1895 // never the bare word `__construct`. Gating on the class's short name
1896 // alone is exact (no lost call sites) and, unlike the general member
1897 // case, dropping the method-name needle here doesn't reintroduce a
1898 // false negative. This matters: `__construct` is one of the most
1899 // common tokens in any real codebase, so OR-ing it in as a needle
1900 // admits nearly every file as a "must re-analyze" candidate on a
1901 // cold query, defeating the gate's entire purpose for constructors.
1902 crate::Name::Method { class, name } if name.as_ref() == "__construct" => {
1903 if class.is_empty() {
1904 // No class to scope to (owner unknown) — fall back to gating
1905 // on the bare name, same as the general member case below.
1906 vec![name.to_string()]
1907 } else {
1908 vec![short(class).to_string()]
1909 }
1910 }
1911 crate::Name::Method { class, name }
1912 | crate::Name::Property { class, name }
1913 | crate::Name::ClassConstant { class, name } => {
1914 let mut v = vec![name.to_string()];
1915 if !class.is_empty() {
1916 v.push(short(class).to_string());
1917 }
1918 v
1919 }
1920 };
1921 // An empty needle can never match; dropping it keeps the "empty needle
1922 // set disables the gate" contract at the call site conservative.
1923 needles.retain(|n| !n.is_empty());
1924 needles
1925}
1926
1927#[cfg(test)]
1928mod tests {
1929 use super::*;
1930
1931 #[test]
1932 fn mentions_identifier_is_case_insensitive_and_word_bounded() {
1933 assert!(mentions_identifier("$this->save();", "save"));
1934 assert!(mentions_identifier("new COLOR()", "Color"));
1935 assert!(mentions_identifier("use App\\Color as Paint;", "color"));
1936 assert!(!mentions_identifier("$this->saveAll();", "save"));
1937 assert!(!mentions_identifier("return $unsaved;", "save"));
1938 assert!(!mentions_identifier("no occurrence", "save"));
1939 assert!(!mentions_identifier("anything", ""));
1940 // Multibyte neighbors are conservatively treated as boundaries, and
1941 // substring scans must not split codepoints.
1942 assert!(!mentions_identifier("function xÉclairFoo() {}", "Éclair"));
1943 assert!(mentions_identifier("implements Éclair {}", "Éclair"));
1944 }
1945
1946 #[test]
1947 fn mention_scanner_membership_equals_per_needle_scans() {
1948 // The mention index is the sole implementation of the gates' textual
1949 // predicate, so scanner membership must equal the reference
1950 // per-needle predicate for every (hay, needle) pair — same boundary
1951 // and case semantics.
1952 use crate::db::MentionScanner;
1953 use std::sync::Arc;
1954 let universe = ["Color", "save", "ColorPicker", "Éclair", "C1", "_Wrap"];
1955 let names: Vec<(mir_types::Name, bool)> = universe
1956 .iter()
1957 .map(|s| (mir_types::Name::new(s).ascii_lowercase(), false))
1958 .collect();
1959 let scanner = Arc::new(MentionScanner::build(1, names).unwrap());
1960 let hays = [
1961 "$this->save();",
1962 "new COLOR()",
1963 "use App\\Color as Paint;",
1964 "$this->saveAll();",
1965 "return $unsaved;",
1966 "new ColorPicker(); Color::save();",
1967 "function xÉclairFoo() {}",
1968 "implements Éclair {}",
1969 "colorsave savecolor color_save",
1970 "class C1 extends _Wrap {}",
1971 "",
1972 ];
1973 for hay in hays {
1974 let scanned = scanner.scan(hay);
1975 for needle in universe {
1976 let expected = mentions_identifier(hay, needle);
1977 let name = mir_types::Name::new(needle).ascii_lowercase();
1978 assert_eq!(
1979 scanned.binary_search(&name).is_ok(),
1980 expected,
1981 "needle {needle:?} on {hay:?}"
1982 );
1983 }
1984 }
1985 }
1986
1987 #[test]
1988 fn gate_needles_cover_member_and_owner_class() {
1989 // A regular member (non-constructor) gates on both the member name
1990 // and the owner's short name — a call site may name only one.
1991 let n = reference_gate_needles(&crate::Name::method("App\\Job", "run"));
1992 assert!(n.contains(&"run".to_string()) && n.contains(&"Job".to_string()));
1993 let n = reference_gate_needles(&crate::Name::class("App\\Ui\\Color"));
1994 assert_eq!(n, vec!["Color".to_string()]);
1995 // Unknown-owner member symbols still gate on the member name alone.
1996 let n = reference_gate_needles(&crate::Name::method("", "run"));
1997 assert_eq!(n, vec!["run".to_string()]);
1998 }
1999
2000 #[test]
2001 fn gate_needles_for_constructor_scope_to_owner_class_only() {
2002 // `__construct` is only ever spelled at `new Cls(`/`parent::__construct()`
2003 // sites, which always name the class — the bare method-name needle is
2004 // dropped so a cold constructor query doesn't admit nearly every file
2005 // in the workspace (every class defines *some* `__construct`).
2006 let n = reference_gate_needles(&crate::Name::method("App\\Job", "__construct"));
2007 assert_eq!(n, vec!["Job".to_string()]);
2008 // Unknown owner: nothing to scope to, fall back to the bare name.
2009 let n = reference_gate_needles(&crate::Name::method("", "__construct"));
2010 assert_eq!(n, vec!["__construct".to_string()]);
2011 }
2012
2013 fn session_with(files: &[(&str, &str)]) -> crate::AnalysisSession {
2014 let session = crate::AnalysisSession::new(crate::PhpVersion::LATEST);
2015 for (path, text) in files {
2016 session.set_file_text(Arc::from(*path), Arc::from(*text));
2017 }
2018 session
2019 }
2020
2021 #[test]
2022 fn gate_static_method_is_member_name_only() {
2023 // Regardless of subtypes: the member token alone is the sound gate
2024 // (an instance receiver `$obj::m()` never names the owner), and it
2025 // is also the selective part — the owner short name would only
2026 // widen the admitted set.
2027 let session = session_with(&[
2028 (
2029 "owner.php",
2030 "<?php\nclass Owner { public static function m(): void {} }\n",
2031 ),
2032 ("sub.php", "<?php\nclass Sub extends Owner {}\n"),
2033 ]);
2034 let gate = session.reference_gate(&crate::Name::method("Owner", "m"));
2035 assert_eq!(gate.idents, vec!["m".to_string()]);
2036 assert!(gate.raw.is_empty());
2037 }
2038
2039 #[test]
2040 fn gate_instance_method_is_member_name_only() {
2041 let session = session_with(&[(
2042 "owner.php",
2043 "<?php\nclass Owner { public function m(): void {} }\n",
2044 )]);
2045 let gate = session.reference_gate(&crate::Name::method("Owner", "m"));
2046 assert_eq!(gate.idents, vec!["m".to_string()]);
2047 assert!(gate.raw.is_empty());
2048 }
2049
2050 #[test]
2051 fn gate_invoke_keeps_owner_needle() {
2052 let session = session_with(&[(
2053 "owner.php",
2054 "<?php\nclass Owner { public function __invoke(): void {} }\n",
2055 )]);
2056 let gate = session.reference_gate(&crate::Name::method("Owner", "__invoke"));
2057 assert_eq!(
2058 gate.idents,
2059 reference_gate_needles(&crate::Name::method("Owner", "__invoke"))
2060 );
2061 assert!(gate.raw.is_empty());
2062 }
2063
2064 #[test]
2065 fn gate_constructor_adds_raw_call_tokens() {
2066 // Owner short name for `new Cls(` sites, plus the raw call tokens
2067 // for explicit re-init (`$obj->__construct()`) whose file may never
2068 // name the class. The bare identifier `__construct` must NOT be a
2069 // needle — it would admit every file declaring a constructor.
2070 let session = session_with(&[(
2071 "owner.php",
2072 "<?php\nclass Owner { public function __construct() {} }\n",
2073 )]);
2074 let gate = session.reference_gate(&crate::Name::method("Owner", "__construct"));
2075 assert_eq!(
2076 gate.idents,
2077 reference_gate_needles(&crate::Name::method("Owner", "__construct"))
2078 );
2079 assert_eq!(
2080 gate.raw,
2081 vec!["->__construct".to_string(), "::__construct".to_string()]
2082 );
2083 }
2084
2085 #[test]
2086 fn gate_unresolvable_owner_falls_back_to_general_needles() {
2087 let session = session_with(&[(
2088 "owner.php",
2089 "<?php\nclass Owner { public static function m(): void {} }\n",
2090 )]);
2091 let gate = session.reference_gate(&crate::Name::method("Nonexistent\\Missing", "m"));
2092 assert_eq!(
2093 gate.idents,
2094 reference_gate_needles(&crate::Name::method("Nonexistent\\Missing", "m"))
2095 );
2096 assert!(gate.raw.is_empty());
2097 }
2098}