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