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 let files = self.snapshot_db().source_file_paths();
225 let mut out: Vec<Arc<str>> = self
226 .indexed_subtype_classes(class_fqn, &files, false)
227 .into_iter()
228 .map(|s| s.file)
229 .collect();
230 out.sort();
231 out.dedup();
232 out
233 }
234
235 /// `use`-import occurrences of `symbol` — the import statement's own name
236 /// token (`use Foo\Bar;`, `use function ...;`, `use const ...;`), not a
237 /// usage site. Recorded under a `use:`-prefixed posting distinct from the
238 /// plain `cls:`/`fn:`/`gcnst:` keys [`Self::indexed_references_to`] reads,
239 /// so a symbol rename can also find/update the import line without a
240 /// plain find-references query suddenly including import statements.
241 ///
242 /// Read-only posting-list lookup, filtered to `files` — no freshness pass:
243 /// callers that need guaranteed-fresh results for an uncommitted file
244 /// should analyze it first (e.g. via [`Self::indexed_references_to`] on
245 /// the same file set).
246 pub fn indexed_use_import_locations(
247 &self,
248 symbol: &crate::Name,
249 files: &[Arc<str>],
250 ) -> Vec<(Arc<str>, crate::Range)> {
251 let key = format!("use:{}", symbol.codebase_key());
252 let scope: rustc_hash::FxHashSet<&str> = files.iter().map(|f| f.as_ref()).collect();
253 let guard = self.db.salsa.read();
254 let mut out: Vec<(Arc<str>, crate::Range)> = guard
255 .reference_locations(&key)
256 .into_iter()
257 .filter(|(file, ..)| scope.contains(file.as_ref()))
258 .map(|(file, line, col_start, col_end)| {
259 (file, span_range(line, col_start as u32, col_end as u32))
260 })
261 .collect();
262 out.sort_by(|a, b| {
263 a.0.cmp(&b.0)
264 .then(a.1.start.line.cmp(&b.1.start.line))
265 .then(a.1.start.column.cmp(&b.1.start.column))
266 });
267 out.dedup();
268 out
269 }
270
271 /// Inverted-index find-references: posting-list lookup plus an on-demand
272 /// freshness/completeness pass over `files` (the host's candidate scope
273 /// — passing the whole workspace is fine; see the gate below).
274 ///
275 /// A candidate whose postings were committed from its current input text
276 /// (Arc identity) is answered from the index with no salsa work at all.
277 /// Stale or never-committed candidates are analyzed via the memoized
278 /// `analyze_file` query and committed, so each file pays that cost once
279 /// per text change — after a background warm sweep the steady state is a
280 /// pure lookup, O(results) instead of O(candidates). Never-committed
281 /// candidates are additionally gated on their raw text mentioning the
282 /// symbol's name (whole-identifier, ASCII-case-insensitive), so hosts
283 /// need no text prefilter of their own — and must not use one, since a
284 /// host-side filter cannot know these matching semantics.
285 ///
286 /// Results are filtered to `files` (the host controls scope — e.g.
287 /// workspace files only, excluding stubs/vendor). With
288 /// `include_declaration`, the symbol's declaration name span is appended
289 /// when it lies inside the scope.
290 ///
291 /// `should_cancel` follows [`Self::references_to_in_files_cancellable`]'s
292 /// contract: polled at phase boundaries and between cancellation retries;
293 /// `true` aborts with `None`.
294 pub fn indexed_references_to(
295 &self,
296 symbol: &crate::Name,
297 files: &[Arc<str>],
298 include_declaration: bool,
299 should_cancel: &(dyn Fn() -> bool + Sync),
300 ) -> Option<Vec<(Arc<str>, crate::Range)>> {
301 use std::panic::AssertUnwindSafe;
302
303 use rayon::prelude::*;
304
305 let key = symbol.codebase_key();
306
307 // Freshness pass: candidates whose postings are not exact for their
308 // current text. Files not registered as `SourceFile` inputs are
309 // skipped. Never-committed files — no commit mark, hence no postings
310 // at all (every mark drop accompanies a posting clear) — are further
311 // gated on their text mentioning the symbol's name: such a file can
312 // neither hold stale postings nor produce new ones, so a cold query
313 // on a common name skips the bulk of the workspace instead of
314 // analyzing it. Stale (previously committed) files re-analyze
315 // unconditionally — their existing postings must be replaced. Same
316 // discipline as `commit_defs_for_matching` on the defs index.
317 let needles = reference_gate_needles(symbol);
318 let needle_matcher = IdentifierNeedles::new(&needles);
319 let committed_any: rustc_hash::FxHashSet<Arc<str>> =
320 self.ref_committed_keys().into_iter().collect();
321 let stale: Vec<Arc<str>> = loop {
322 if should_cancel() {
323 return None;
324 }
325 let attempt = salsa::Cancelled::catch(AssertUnwindSafe(|| {
326 let current_gen = self.index_generation();
327 let db_main = self.snapshot_db();
328 files
329 .par_iter()
330 .map_with(db_main, |db, f| {
331 let sf = db.lookup_source_file(f.as_ref())?;
332 let text = sf.text(&*db as &dyn MirDatabase);
333 if self.is_ref_committed(f.as_ref(), text, current_gen) {
334 return None;
335 }
336 if !committed_any.contains(f.as_ref())
337 && !needles.is_empty()
338 && !needle_matcher.matches(text)
339 {
340 return None;
341 }
342 Some(f.clone())
343 })
344 .flatten()
345 .collect::<Vec<_>>()
346 }));
347 match attempt {
348 Ok(v) => break v,
349 Err(_) if should_cancel() => return None,
350 Err(_) => {}
351 }
352 };
353
354 if !stale.is_empty() {
355 // Phase 1 (serial, no live snapshot held): warm up stale
356 // candidates. `prepare_file_for_analysis` mutates salsa inputs
357 // (via `load_class`), so a concurrent writer — the background
358 // warm sweep, or another request — can raise `salsa::Cancelled`
359 // partway through a file. Catch and retry the SAME file here
360 // rather than letting the panic escape: uncaught, it would force
361 // the caller's outer retry loop (`indexed_references`) to
362 // re-enter from scratch, redoing the freshness pass and
363 // re-walking every already-warmed file in `stale` (cheap no-ops
364 // via the `prepared_files` cache, but not free) before it even
365 // gets back to the file that was interrupted. This doesn't
366 // change how many times a write is ultimately attempted (the
367 // outer loop already retries indefinitely on `Cancelled`); it
368 // only narrows what a single cancellation discards from "the
369 // whole query so far" to "the one file that was mid-flight".
370 //
371 // Tried and reverted: running this loop itself in parallel
372 // (rayon, both per-file and whole-batch retry variants). Each
373 // file's warm-up is individually safe under concurrent access
374 // (every shared registry it touches — `prepared_files`,
375 // `unresolvable_fqcns`, `pending_eager_function_files`, the
376 // salsa db via `with_db_mut` — is lock-protected), but under the
377 // `concurrent_reference_cancel` stress test (sustained
378 // multi-thread writers + a background indexer, both hammering
379 // the same db while several readers each run this phase
380 // concurrently) both parallel variants deadlocked: CPU usage
381 // dropped to ~0 while wall time kept climbing, the signature of
382 // several OS threads parked on a lock rather than making
383 // progress — most likely the fixed-size rayon pool getting
384 // saturated with workers blocked on `with_db_mut`'s `RwLock`
385 // write lock (an OS-level block, invisible to rayon's
386 // cooperative scheduler) while the thread that would release it
387 // is itself queued waiting for a free pool worker. Serial
388 // execution never contends for the pool this way, so it stays
389 // the safe choice here even though it forgoes the extra
390 // wall-clock parallelism a large stale set could otherwise use.
391 for path in &stale {
392 loop {
393 if should_cancel() {
394 return None;
395 }
396 match salsa::Cancelled::catch(AssertUnwindSafe(|| {
397 self.prepare_file_for_analysis(path)
398 })) {
399 Ok(()) => break,
400 Err(_) if should_cancel() => return None,
401 Err(_) => {}
402 }
403 }
404 }
405
406 // Phase 2 (parallel, pure) under a cancellation retry loop, then
407 // a serial commit into both inverted indexes.
408 let (commit_gen, analyzed) = loop {
409 if should_cancel() {
410 return None;
411 }
412 // Generation before the snapshot: a file add racing the
413 // analysis leaves these commits stale (self-healing on the
414 // next query), never wrongly fresh.
415 let gen = self.index_generation();
416 let attempt = salsa::Cancelled::catch(AssertUnwindSafe(|| {
417 // Freeze on the pass-scoped snapshot (borrow-only symbol
418 // lookups + pass-shared subtype cache): all lazy-loading
419 // finished in Phase 1, and a concurrent index write
420 // cancels this attempt, so the frozen view is never
421 // stale. Same discipline as the batch body pass.
422 let mut db_main = self.snapshot_db();
423 db_main.freeze_workspace_index();
424 stale
425 .par_iter()
426 .map_with(db_main, |db, path| {
427 let sf = db.lookup_source_file(path.as_ref())?;
428 let text = sf.text(&*db as &dyn MirDatabase).clone();
429 let out = crate::db::analyze_file(&*db as &dyn MirDatabase, sf).clone();
430 let defs =
431 crate::db::collect_file_definitions(&*db as &dyn MirDatabase, sf);
432 let entries = crate::db::subtype_index::entries_from_slice(&defs.slice);
433 // Stage the disk-cache write only when the commit
434 // below will rewrite postings (see the sweep in
435 // `reanalyze_file_set` for the cost rationale).
436 let put = if self.ref_commit_is_current(path.as_ref(), &text, &out) {
437 None
438 } else {
439 self.stage_ref_cache_put(
440 &*db as &dyn MirDatabase,
441 sf,
442 path.as_ref(),
443 &text,
444 &out,
445 )
446 };
447 Some((path.clone(), text, out, entries, put))
448 })
449 .flatten()
450 .collect::<Vec<_>>()
451 }));
452 match attempt {
453 Ok(v) => break (gen, v),
454 Err(_) if should_cancel() => return None,
455 Err(_) => {}
456 }
457 };
458 let mut analyzed = analyzed;
459 let guard = self.db.salsa.read();
460 for (file, text, out, entries, put) in analyzed.iter_mut() {
461 // Pointer-identical memo ⇒ identical postings: skip the
462 // index rewrite and only re-stamp the freshness mark.
463 if !self.ref_commit_is_current(file.as_ref(), text, out) {
464 guard.set_file_reference_locations(file.as_ref(), out.ref_locs.to_vec());
465 }
466 if let Some(put) = put.take() {
467 self.apply_ref_cache_put(file.as_ref(), out, put);
468 }
469 self.mark_ref_committed(
470 file,
471 text,
472 Some(out),
473 commit_gen,
474 !out.has_unresolved_names(),
475 );
476 if !self.is_defs_committed(file.as_ref(), text) {
477 guard.set_file_class_edges(file, entries.clone());
478 self.mark_defs_committed(file, text);
479 }
480 }
481 }
482
483 // Posting lookup, filtered to the candidate scope.
484 //
485 // Member symbols resolve against the queried class plus its hierarchy
486 // (mir records member refs under the *declaring* class, so a query on
487 // an interface method must include implementor keys and vice versa).
488 // Name-only fallback postings — receivers whose type couldn't be
489 // resolved — are consulted only when the typed keys produce nothing,
490 // mirroring the pre-index two-tier behavior: exact results when
491 // resolution succeeds, by-name matches when nothing resolves.
492 // `__construct` stays exact: `new Sub()` invokes `Sub::__construct`
493 // even when only a parent declares one, so hierarchy fan-out would
494 // wrongly return subtype instantiation sites for a parent query.
495 let hierarchy: Vec<String> = match symbol {
496 crate::Name::Method { class, name } => {
497 if name.as_ref() == "__construct" || class.is_empty() {
498 if class.is_empty() {
499 Vec::new()
500 } else {
501 vec![class.trim_start_matches('\\').to_string()]
502 }
503 } else {
504 self.member_hierarchy_classes(class.as_ref())
505 }
506 }
507 crate::Name::Property { class, .. } | crate::Name::ClassConstant { class, .. } => {
508 if class.is_empty() {
509 Vec::new()
510 } else {
511 self.member_hierarchy_classes(class.as_ref())
512 }
513 }
514 _ => Vec::new(),
515 };
516 let primary_keys: Vec<String> = match symbol {
517 crate::Name::Method { name, .. } => hierarchy
518 .iter()
519 .map(|c| format!("meth:{c}::{name}"))
520 .collect(),
521 crate::Name::Property { name, .. } => hierarchy
522 .iter()
523 .map(|c| format!("prop:{c}::{name}"))
524 .collect(),
525 crate::Name::ClassConstant { name, .. } => hierarchy
526 .iter()
527 .map(|c| format!("cnst:{c}::{name}"))
528 .collect(),
529 _ => vec![key.clone()],
530 };
531 let fallback_key: Option<String> = match symbol {
532 crate::Name::Method { name, .. } => Some(format!("methname:{name}")),
533 crate::Name::Property { name, .. } => Some(format!("propname:{name}")),
534 _ => None,
535 };
536 let scope: rustc_hash::FxHashSet<&str> = files.iter().map(|f| f.as_ref()).collect();
537 let read_keys = |keys: &[String]| -> Vec<(Arc<str>, crate::Range)> {
538 let guard = self.db.salsa.read();
539 let mut merged: Vec<(Arc<str>, u32, u16, u16)> = Vec::new();
540 for k in keys {
541 merged.extend(guard.reference_locations(k));
542 }
543 merged
544 .into_iter()
545 .filter(|(file, ..)| scope.contains(file.as_ref()))
546 .map(|(file, line, col_start, col_end)| {
547 (file, span_range(line, col_start as u32, col_end as u32))
548 })
549 .collect()
550 };
551 let mut out = read_keys(&primary_keys);
552 if out.is_empty() {
553 if let Some(fk) = fallback_key {
554 out = read_keys(std::slice::from_ref(&fk));
555 }
556 }
557 out.sort_by(|a, b| {
558 a.0.cmp(&b.0)
559 .then(a.1.start.line.cmp(&b.1.start.line))
560 .then(a.1.start.column.cmp(&b.1.start.column))
561 });
562 out.dedup_by(|a, b| a.0 == b.0 && a.1 == b.1);
563
564 if include_declaration {
565 // Declaration lookup runs salsa queries (and may lazy-load); a
566 // concurrent write cancels it — declarations are then simply
567 // omitted rather than failing the whole request.
568 let decls: Vec<(Arc<str>, crate::Range)> = match symbol {
569 crate::Name::Method { class, .. }
570 | crate::Name::Property { class, .. }
571 | crate::Name::ClassConstant { class, .. } => {
572 if class.is_empty() {
573 // Unknown owner: declarations by name, recorded as
574 // `methdecl:`/`propdecl:`/`cnstdecl:` postings during
575 // class/trait/interface/enum analysis.
576 match symbol {
577 crate::Name::Method { name, .. } => {
578 read_keys(&[format!("methdecl:{name}")])
579 }
580 crate::Name::Property { name, .. } => {
581 read_keys(&[format!("propdecl:{name}")])
582 }
583 crate::Name::ClassConstant { name, .. } => {
584 read_keys(&[format!("cnstdecl:{name}")])
585 }
586 _ => Vec::new(),
587 }
588 } else {
589 salsa::Cancelled::catch(AssertUnwindSafe(|| {
590 self.member_decl_sites(&hierarchy, symbol)
591 }))
592 .unwrap_or_default()
593 }
594 }
595 _ => salsa::Cancelled::catch(AssertUnwindSafe(|| {
596 self.declaration_name_range(symbol).into_iter().collect()
597 }))
598 .unwrap_or_default(),
599 };
600 for (file, range) in decls {
601 if scope.contains(file.as_ref())
602 && !out.iter().any(|(f, r)| *f == file && *r == range)
603 {
604 out.push((file, range));
605 }
606 }
607 }
608 Some(out)
609 }
610
611 /// The queried class plus every class its members' references could be
612 /// keyed under: resolved ancestors (a call on a subtype instance records
613 /// the declaring ancestor) and transitive subtypes including trait users
614 /// (a call on a subtype that overrides records the subtype). Display-form
615 /// FQCNs, deduplicated case-insensitively.
616 fn member_hierarchy_classes(&self, class_fqn: &str) -> Vec<String> {
617 use std::panic::AssertUnwindSafe;
618 let target = class_fqn.trim_start_matches('\\').to_string();
619 let mut out: Vec<String> = vec![target.clone()];
620 let ancestors = salsa::Cancelled::catch(AssertUnwindSafe(|| {
621 let db = self.snapshot_db();
622 let here = crate::db::Fqcn::from_str(&db, &target);
623 crate::db::class_ancestors_by_fqcn(&db, here)
624 .iter()
625 .skip(1)
626 .map(|a| a.trim_start_matches('\\').to_string())
627 .collect::<Vec<_>>()
628 }))
629 .unwrap_or_default();
630 out.extend(ancestors);
631 let subs = {
632 let guard = self.db.salsa.read();
633 guard.subtype_sites_of(&target, true)
634 };
635 out.extend(
636 subs.into_iter()
637 .map(|s| s.fqcn.trim_start_matches('\\').to_string()),
638 );
639 let mut seen: rustc_hash::FxHashSet<String> = rustc_hash::FxHashSet::default();
640 out.retain(|c| seen.insert(c.to_ascii_lowercase()));
641 out
642 }
643
644 /// Own-member declaration sites for `symbol` across `classes`: each class
645 /// that itself declares the member (not inherited) contributes its name
646 /// token. Kind-specific lookups — a class often declares a property and a
647 /// method with the same short name, and `member_location` can't tell them
648 /// apart.
649 fn member_decl_sites(
650 &self,
651 classes: &[String],
652 symbol: &crate::Name,
653 ) -> Vec<(Arc<str>, crate::Range)> {
654 let mut out: Vec<(Arc<str>, crate::Range)> = Vec::new();
655 let db = self.snapshot_db();
656 for class in classes {
657 let here = crate::db::Fqcn::from_str(&db, class);
658 let (loc, needle) = match symbol {
659 crate::Name::Method { name, .. } => {
660 let Some(m) = crate::db::find_method_in_class(&db, here, name) else {
661 continue;
662 };
663 (m.location.clone(), name.to_string())
664 }
665 crate::Name::Property { name, .. } => {
666 let Some(p) = crate::db::find_property_in_class(&db, here, name) else {
667 continue;
668 };
669 (p.location.clone(), name.to_string())
670 }
671 crate::Name::ClassConstant { name, .. } => {
672 let Some(c) = crate::db::find_class_constant_in_class(&db, here, name) else {
673 continue;
674 };
675 (c.location.clone(), name.to_string())
676 }
677 _ => continue,
678 };
679 let Some(loc) = loc else { continue };
680 let range = self.refine_location_to_name(&loc, &needle);
681 out.push((loc.file.clone(), range));
682 }
683 out
684 }
685
686 /// The symbol's declaration site, narrowed from the collector's
687 /// whole-declaration span to the declared name's own token (matching the
688 /// span shape of recorded references).
689 pub fn declaration_name_range(&self, symbol: &crate::Name) -> Option<(Arc<str>, crate::Range)> {
690 if let crate::Name::GlobalConstant(fqn) = symbol {
691 return self.global_constant_decl_range(fqn);
692 }
693 let loc = self.definition_of(symbol).ok()?;
694 let short = match symbol {
695 crate::Name::Class(f) | crate::Name::Function(f) | crate::Name::GlobalConstant(f) => {
696 crate::db::subtype_index::short_name_of(f)
697 }
698 crate::Name::Method { name, .. }
699 | crate::Name::Property { name, .. }
700 | crate::Name::ClassConstant { name, .. } => name.as_ref(),
701 };
702 // Property declarations carry a `$` sigil in source, but reference
703 // ranges cover the bare name; the word-boundary search below lands on
704 // the name right after the sigil.
705 let file = loc.file.clone();
706 let range = self.refine_location_to_name(&loc, short);
707 Some((file, range))
708 }
709
710 /// Narrow a whole-declaration [`mir_types::Location`] to the first
711 /// word-boundary occurrence of `needle` inside its line span. Falls back
712 /// to the location's own coordinates when the text is unavailable or the
713 /// name doesn't appear (e.g. stub-only declarations).
714 fn refine_location_to_name(&self, loc: &mir_types::Location, needle: &str) -> crate::Range {
715 let fallback = span_range(loc.line, loc.col_start as u32, loc.col_end as u32);
716 let text = {
717 let db = self.snapshot_db();
718 db.lookup_source_file(loc.file.as_ref())
719 .map(|sf| sf.text(&db as &dyn MirDatabase).clone())
720 };
721 let Some(text) = text else {
722 return fallback;
723 };
724 let needle_chars = needle.chars().count() as u32;
725 let first_line = loc.line.saturating_sub(1) as usize;
726 // Exact-case first: PHP property/constant names are case-sensitive
727 // and an early case-insensitive hit can land on an unrelated token
728 // (a type hint sharing the name). Case-insensitive second, for
729 // method/class needles that arrive lowercase-normalized.
730 for case_insensitive in [false, true] {
731 for (idx, line_text) in text.lines().enumerate().skip(first_line) {
732 let line_no = idx as u32 + 1;
733 if line_no > loc.line_end {
734 break;
735 }
736 let min_col = if line_no == loc.line {
737 loc.col_start as usize
738 } else {
739 0
740 };
741 if let Some(col) = identifier_char_col(line_text, needle, min_col, case_insensitive)
742 {
743 return span_range(line_no, col, col + needle_chars);
744 }
745 }
746 }
747 fallback
748 }
749
750 /// Transitive subtypes of `class_fqn` (classes/interfaces/enums whose
751 /// resolved ancestor chain reaches it), answered from the maintained
752 /// subtype edge index.
753 ///
754 /// `files` is the host's candidate scope for the on-demand completeness
755 /// pass: per BFS round, not-yet-committed files whose text mentions a
756 /// frontier name get their definitions committed, so results are complete
757 /// even before a background sweep has covered the workspace. Committed
758 /// files answer from the index with no parsing at all.
759 ///
760 /// `include_trait_users` also counts `use Trait;` composition as a
761 /// subtype edge (visibility-scoping semantics); leave it off for
762 /// goto-implementation semantics (extends/implements only).
763 pub fn indexed_subtype_classes(
764 &self,
765 class_fqn: &str,
766 files: &[Arc<str>],
767 include_trait_users: bool,
768 ) -> Vec<SubtypeClassSite> {
769 let mut scanned: rustc_hash::FxHashSet<String> = rustc_hash::FxHashSet::default();
770 let mut pending: Vec<String> = vec![class_fqn.trim_start_matches('\\').to_string()];
771 let mut sites: Vec<crate::db::SubtypeSite> = Vec::new();
772 while !pending.is_empty() {
773 let needles: Vec<String> = pending
774 .drain(..)
775 .filter(|f| scanned.insert(f.clone()))
776 .map(|f| crate::db::subtype_index::short_name_of(&f).to_string())
777 .collect();
778 if !needles.is_empty() {
779 self.commit_defs_for_matching(files, &needles);
780 }
781 sites = {
782 let guard = self.db.salsa.read();
783 guard.subtype_sites_of_lenient(class_fqn, include_trait_users)
784 };
785 pending = sites
786 .iter()
787 .map(|s| s.fqcn.trim_start_matches('\\').to_string())
788 .filter(|f| !scanned.contains(f))
789 .collect();
790 }
791 let mut out: Vec<SubtypeClassSite> = sites
792 .into_iter()
793 .filter_map(|s| {
794 let loc = s.location.as_ref()?;
795 let short = crate::db::subtype_index::short_name_of(&s.fqcn).to_string();
796 let range = self.refine_location_to_name(loc, &short);
797 Some(SubtypeClassSite {
798 fqcn: s.fqcn,
799 kind: s.kind,
800 is_abstract: s.is_abstract,
801 file: s.file,
802 range,
803 })
804 })
805 .collect();
806 // Anonymous classes never reach the definition collector; their
807 // `new class implements X {}` sites are recorded as `impl:` postings
808 // during body analysis (exact FQCN key plus a short-name key for the
809 // same written-form leniency named classes get above).
810 let root_lc = class_fqn.trim_start_matches('\\').to_ascii_lowercase();
811 let short_lc = crate::db::subtype_index::short_name_of(&root_lc).to_string();
812 let scope: rustc_hash::FxHashSet<&str> = files.iter().map(|f| f.as_ref()).collect();
813 let anon: Vec<(Arc<str>, u32, u16, u16)> = {
814 let guard = self.db.salsa.read();
815 let mut v = guard.reference_locations(&format!("impl:{root_lc}"));
816 v.extend(guard.reference_locations(&format!("implshort:{short_lc}")));
817 v.sort();
818 v.dedup();
819 v
820 };
821 for (file, line, cs, ce) in anon {
822 if !scope.contains(file.as_ref()) {
823 continue;
824 }
825 let range = span_range(line, cs as u32, ce as u32);
826 if out.iter().any(|s| s.file == file && s.range == range) {
827 continue;
828 }
829 out.push(SubtypeClassSite {
830 fqcn: Arc::from("class@anonymous"),
831 kind: crate::db::ClassLikeKind::Class,
832 is_abstract: false,
833 file,
834 range,
835 });
836 }
837 out
838 }
839
840 /// Concrete implementations of `class_fqn::method` across its transitive
841 /// subtypes: the same-named non-abstract method available to each subtype
842 /// (its own declaration, or one inherited/composed from a parent, trait,
843 /// or mixin), as `(subtype fqcn, file, name range)`. Subtypes resolving to
844 /// the same declaring location collapse to a single entry.
845 pub fn indexed_method_implementations(
846 &self,
847 class_fqn: &str,
848 method: &str,
849 files: &[Arc<str>],
850 ) -> Vec<(Arc<str>, Arc<str>, crate::Range)> {
851 use std::panic::AssertUnwindSafe;
852 let subs = self.indexed_subtype_classes(class_fqn, files, false);
853 if subs.is_empty() {
854 return Vec::new();
855 }
856 loop {
857 let attempt = salsa::Cancelled::catch(AssertUnwindSafe(|| {
858 let db = self.snapshot_db();
859 let mut out: Vec<(Arc<str>, Arc<str>, crate::Range)> = Vec::new();
860 for sub in &subs {
861 let here = crate::db::Fqcn::from_str(&db, sub.fqcn.as_ref());
862 let Some((_, m)) = crate::db::find_method_in_chain(&db, here, method) else {
863 continue;
864 };
865 if m.is_abstract {
866 continue;
867 }
868 let Some(loc) = m.location.as_ref() else {
869 continue;
870 };
871 let range = self.refine_location_to_name(loc, method);
872 out.push((sub.fqcn.clone(), loc.file.clone(), range));
873 }
874 out
875 }));
876 if let Ok(mut out) = attempt {
877 out.sort_by(|a, b| a.1.cmp(&b.1).then(a.2.start.line.cmp(&b.2.start.line)));
878 out.dedup_by(|a, b| a.1 == b.1 && a.2 == b.2);
879 return out;
880 }
881 }
882 }
883
884 /// Commit definitions (class edges + freshness) for every file in `files`
885 /// that is stale (committed against older text) or that has never been
886 /// committed and mentions one of `shorts` as a whole identifier.
887 fn commit_defs_for_matching(&self, files: &[Arc<str>], shorts: &[String]) {
888 use std::panic::AssertUnwindSafe;
889
890 use rayon::prelude::*;
891
892 let committed_any: rustc_hash::FxHashSet<Arc<str>> = {
893 let guard = self.defs_committed_keys();
894 guard.into_iter().collect()
895 };
896 let needles = IdentifierNeedles::new(shorts);
897 let work = loop {
898 let attempt = salsa::Cancelled::catch(AssertUnwindSafe(|| {
899 let db_main = self.snapshot_db();
900 files
901 .par_iter()
902 .map_with(db_main, |db, path| {
903 let sf = db.lookup_source_file(path.as_ref())?;
904 let text = sf.text(&*db as &dyn MirDatabase).clone();
905 if self.is_defs_committed(path.as_ref(), &text) {
906 return None;
907 }
908 // Never-committed files must mention a frontier name;
909 // stale (previously committed) files recommit
910 // unconditionally — their classes may have re-parented.
911 if !committed_any.contains(path.as_ref()) && !needles.matches(&text) {
912 return None;
913 }
914 let defs =
915 crate::db::collect_file_definitions(&*db as &dyn MirDatabase, sf);
916 let entries = crate::db::subtype_index::entries_from_slice(&defs.slice);
917 Some((path.clone(), text, entries))
918 })
919 .flatten()
920 .collect::<Vec<_>>()
921 }));
922 if let Ok(v) = attempt {
923 break v;
924 }
925 };
926 if work.is_empty() {
927 return;
928 }
929 let guard = self.db.salsa.read();
930 for (file, text, entries) in &work {
931 guard.set_file_class_edges(file, entries.clone());
932 self.mark_defs_committed(file, text);
933 }
934 }
935
936 /// Declaration name span for a global constant. Constant slices carry no
937 /// stored location, so this finds the declaring file via the workspace
938 /// constants index and locates the `const NAME` / `define('NAME'` token
939 /// textually.
940 fn global_constant_decl_range(&self, fqn: &str) -> Option<(Arc<str>, crate::Range)> {
941 use std::panic::AssertUnwindSafe;
942 let short = crate::db::subtype_index::short_name_of(fqn).to_string();
943 salsa::Cancelled::catch(AssertUnwindSafe(|| {
944 let db = self.snapshot_db();
945 let index = crate::db::workspace_index(&db);
946 let loc = index
947 .constants
948 .get(&mir_types::Name::from(fqn.trim_start_matches('\\')))?;
949 let file = loc.file().path(&db).clone();
950 let sf = db.lookup_source_file(file.as_ref())?;
951 let text = sf.text(&db as &dyn MirDatabase);
952 for (idx, line) in text.lines().enumerate() {
953 let trimmed = line.trim_start();
954 let is_decl_line = trimmed.starts_with("const ")
955 || trimmed.contains("define(")
956 || trimmed.contains("define (");
957 if !is_decl_line {
958 continue;
959 }
960 if let Some(col) = identifier_char_col(line, &short, 0, false) {
961 let n = short.chars().count() as u32;
962 return Some((file, span_range(idx as u32 + 1, col, col + n)));
963 }
964 }
965 None
966 }))
967 .ok()
968 .flatten()
969 }
970
971 /// Class-level issues (inheritance violations, abstract-method gaps, override
972 /// incompatibilities) for the given set of files.
973 ///
974 /// These checks are cross-file by nature and are not emitted by
975 /// [`crate::FileAnalyzer::analyze`]. Call this after ingesting or
976 /// re-analyzing a file and its dependents to get the full diagnostic picture.
977 ///
978 /// Circular-inheritance checks always run against the full workspace graph
979 /// regardless of the `files` filter — a cycle is a workspace-wide problem.
980 pub fn class_issues(&self, files: &[Arc<str>]) -> Vec<crate::Issue> {
981 let db = self.snapshot_db();
982 let file_set: HashSet<Arc<str>> = files.iter().cloned().collect();
983 // Read source texts through the snapshot already in hand — calling
984 // `source_of` here would re-enter the session RwLock while this
985 // snapshot is live, and a concurrent salsa write (which blocks new
986 // readers behind the fair write lock while waiting for existing
987 // snapshots to drop) turns that into a deadlock.
988 let file_data: Vec<(Arc<str>, Arc<str>)> = files
989 .iter()
990 .filter_map(|f| {
991 let sf = db.lookup_source_file(f)?;
992 Some((
993 f.clone(),
994 sf.text(&db as &dyn crate::db::MirDatabase).clone(),
995 ))
996 })
997 .collect();
998 crate::class::ClassAnalyzer::with_files(&db, file_set, &file_data).analyze_all()
999 }
1000
1001 /// All declarations defined in `file` as a **hierarchical tree**.
1002 ///
1003 /// Classes/interfaces/traits/enums are returned with their methods,
1004 /// properties, and constants nested in `children`. Top-level functions
1005 /// and constants are returned with empty `children`.
1006 pub fn document_symbols(&self, file: &str) -> Vec<crate::symbol::DocumentSymbol> {
1007 use crate::symbol::{DeclarationKind, DocumentSymbol};
1008
1009 let db = self.snapshot_db();
1010 let Some(sf) = db.lookup_source_file(file) else {
1011 return Vec::new();
1012 };
1013 let defs = crate::db::collect_file_definitions(&db, sf);
1014 let mut out: Vec<DocumentSymbol> = Vec::new();
1015
1016 let class_children = |methods: &mir_codebase::definitions::MemberMap<
1017 Arc<mir_codebase::definitions::MethodDef>,
1018 >,
1019 props: Option<
1020 &mir_codebase::definitions::MemberMap<mir_codebase::definitions::PropertyDef>,
1021 >,
1022 consts: &mir_codebase::definitions::MemberMap<
1023 mir_codebase::definitions::ConstantDef,
1024 >,
1025 is_enum: bool|
1026 -> Vec<DocumentSymbol> {
1027 let mut out: Vec<DocumentSymbol> = Vec::new();
1028 for (_, m) in methods.iter() {
1029 out.push(DocumentSymbol {
1030 name: m.name.clone(),
1031 kind: DeclarationKind::Method,
1032 location: m.location.clone(),
1033 children: Vec::new(),
1034 });
1035 }
1036 if let Some(props) = props {
1037 for (_, p) in props.iter() {
1038 out.push(DocumentSymbol {
1039 name: p.name.clone(),
1040 kind: DeclarationKind::Property,
1041 location: p.location.clone(),
1042 children: Vec::new(),
1043 });
1044 }
1045 }
1046 let const_kind = if is_enum {
1047 DeclarationKind::EnumCase
1048 } else {
1049 DeclarationKind::Constant
1050 };
1051 for (_, c) in consts.iter() {
1052 out.push(DocumentSymbol {
1053 name: c.name.clone(),
1054 kind: const_kind,
1055 location: c.location.clone(),
1056 children: Vec::new(),
1057 });
1058 }
1059 out
1060 };
1061
1062 for c in defs.slice.classes.iter() {
1063 out.push(DocumentSymbol {
1064 name: c.fqcn.clone(),
1065 kind: DeclarationKind::Class,
1066 location: c.location.clone(),
1067 children: class_children(
1068 &c.own_methods,
1069 Some(&c.own_properties),
1070 &c.own_constants,
1071 false,
1072 ),
1073 });
1074 }
1075 for i in defs.slice.interfaces.iter() {
1076 out.push(DocumentSymbol {
1077 name: i.fqcn.clone(),
1078 kind: DeclarationKind::Interface,
1079 location: i.location.clone(),
1080 children: class_children(&i.own_methods, None, &i.own_constants, false),
1081 });
1082 }
1083 for t in defs.slice.traits.iter() {
1084 out.push(DocumentSymbol {
1085 name: t.fqcn.clone(),
1086 kind: DeclarationKind::Trait,
1087 location: t.location.clone(),
1088 children: class_children(
1089 &t.own_methods,
1090 Some(&t.own_properties),
1091 &t.own_constants,
1092 false,
1093 ),
1094 });
1095 }
1096 for e in defs.slice.enums.iter() {
1097 let mut children = class_children(&e.own_methods, None, &e.own_constants, true);
1098 for (_, case) in e.cases.iter() {
1099 children.push(DocumentSymbol {
1100 name: case.name.clone(),
1101 kind: DeclarationKind::EnumCase,
1102 location: case.location.clone(),
1103 children: Vec::new(),
1104 });
1105 }
1106 out.push(DocumentSymbol {
1107 name: e.fqcn.clone(),
1108 kind: DeclarationKind::Enum,
1109 location: e.location.clone(),
1110 children,
1111 });
1112 }
1113 for f in defs.slice.functions.iter() {
1114 out.push(DocumentSymbol {
1115 name: f.fqn.clone(),
1116 kind: DeclarationKind::Function,
1117 location: f.location.clone(),
1118 children: Vec::new(),
1119 });
1120 }
1121 for (name, _) in defs.slice.constants.iter() {
1122 out.push(DocumentSymbol {
1123 name: name.clone(),
1124 kind: DeclarationKind::Constant,
1125 location: None,
1126 children: Vec::new(),
1127 });
1128 }
1129 out
1130 }
1131}
1132
1133/// A transitive subtype hit with its declaration name span, as returned by
1134/// [`AnalysisSession::indexed_subtype_classes`].
1135#[derive(Debug, Clone)]
1136pub struct SubtypeClassSite {
1137 /// Display-form FQCN (no leading `\`).
1138 pub fqcn: Arc<str>,
1139 pub kind: crate::db::ClassLikeKind,
1140 pub is_abstract: bool,
1141 pub file: Arc<str>,
1142 /// The declared name's own token (1-based line, 0-based char columns).
1143 pub range: crate::Range,
1144}
1145
1146/// Build a [`crate::Range`] on one line from mir's native coordinates
1147/// (1-based line, 0-based columns).
1148fn span_range(line: u32, col_start: u32, col_end: u32) -> crate::Range {
1149 crate::Range {
1150 start: crate::Position {
1151 line,
1152 column: col_start,
1153 },
1154 end: crate::Position {
1155 line,
1156 column: col_end,
1157 },
1158 }
1159}
1160
1161/// Char column of the first word-boundary occurrence of `needle` in `line`
1162/// at or after char column `min_col`. Columns are code points, matching the
1163/// collector's `Location` convention.
1164fn identifier_char_col(
1165 line: &str,
1166 needle: &str,
1167 min_col: usize,
1168 case_insensitive: bool,
1169) -> Option<u32> {
1170 if needle.is_empty() {
1171 return None;
1172 }
1173 let is_ident = |c: char| c.is_ascii_alphanumeric() || c == '_';
1174 let chars: Vec<char> = line.chars().collect();
1175 let needle_chars: Vec<char> = needle.chars().collect();
1176 let n = needle_chars.len();
1177 if chars.len() < n {
1178 return None;
1179 }
1180 for start in min_col..=chars.len().saturating_sub(n) {
1181 let matches = chars[start..start + n]
1182 .iter()
1183 .zip(needle_chars.iter())
1184 .all(|(a, b)| {
1185 if case_insensitive {
1186 a.eq_ignore_ascii_case(b)
1187 } else {
1188 a == b
1189 }
1190 });
1191 if !matches {
1192 continue;
1193 }
1194 let before_ok = start == 0 || !is_ident(chars[start - 1]);
1195 let after = start + n;
1196 let after_ok = after >= chars.len() || !is_ident(chars[after]);
1197 if before_ok && after_ok {
1198 return Some(start as u32);
1199 }
1200 }
1201 None
1202}
1203
1204/// Compiled multi-needle form of [`mentions_identifier`]: one SIMD-backed
1205/// pass over the text for the whole needle set instead of one byte scan per
1206/// needle. Identical semantics — whole-identifier, ASCII-case-insensitive.
1207/// Build once per sweep and share across the rayon workers; matters when a
1208/// subtype BFS round carries dozens of frontier names across an
1209/// O(workspace) candidate scan.
1210pub(crate) struct IdentifierNeedles {
1211 /// `None` when the needle set is empty or the automaton failed to build
1212 /// (pattern-set limits — unreachable for identifier words); the fallback
1213 /// then rescans per needle so behavior never changes, only speed.
1214 ac: Option<aho_corasick::AhoCorasick>,
1215 needles: Vec<String>,
1216}
1217
1218impl IdentifierNeedles {
1219 pub(crate) fn new(needles: &[String]) -> Self {
1220 let kept: Vec<String> = needles.iter().filter(|n| !n.is_empty()).cloned().collect();
1221 let ac = if kept.is_empty() {
1222 None
1223 } else {
1224 aho_corasick::AhoCorasick::builder()
1225 .ascii_case_insensitive(true)
1226 .build(&kept)
1227 .ok()
1228 };
1229 Self { ac, needles: kept }
1230 }
1231
1232 /// Whether `hay` mentions any needle as a whole identifier. Overlapping
1233 /// iteration enumerates every occurrence of every needle, so the word-
1234 /// boundary filter sees exactly the candidates the per-needle scans would.
1235 pub(crate) fn matches(&self, hay: &str) -> bool {
1236 let Some(ac) = &self.ac else {
1237 return self.needles.iter().any(|n| mentions_identifier(hay, n));
1238 };
1239 let bytes = hay.as_bytes();
1240 let is_ident = |b: u8| b.is_ascii_alphanumeric() || b == b'_';
1241 ac.find_overlapping_iter(hay).any(|m| {
1242 (m.start() == 0 || !is_ident(bytes[m.start() - 1]))
1243 && (m.end() == bytes.len() || !is_ident(bytes[m.end()]))
1244 })
1245 }
1246}
1247
1248/// Whether `hay` mentions `needle` as a whole identifier (ASCII word
1249/// boundaries; conservative near multibyte text). ASCII-case-insensitive:
1250/// PHP class, function, and method names are case-insensitive, so `new
1251/// COLOR()` must count as mentioning `Color`; for the case-sensitive kinds
1252/// (constants, properties) folding only widens the candidate superset.
1253/// Gates the completeness passes so they never analyze files that cannot
1254/// name the symbol.
1255fn mentions_identifier(hay: &str, needle: &str) -> bool {
1256 let hay = hay.as_bytes();
1257 let needle = needle.as_bytes();
1258 let n = needle.len();
1259 if n == 0 || hay.len() < n {
1260 return false;
1261 }
1262 let is_ident = |b: u8| b.is_ascii_alphanumeric() || b == b'_';
1263 let first = needle[0].to_ascii_lowercase();
1264 for i in 0..=(hay.len() - n) {
1265 if hay[i].to_ascii_lowercase() != first || !hay[i..i + n].eq_ignore_ascii_case(needle) {
1266 continue;
1267 }
1268 if (i == 0 || !is_ident(hay[i - 1])) && (i + n == hay.len() || !is_ident(hay[i + n])) {
1269 return true;
1270 }
1271 }
1272 false
1273}
1274
1275/// Identifier words whose whole-word presence in a file's text is necessary
1276/// for the file to hold any posting [`AnalysisSession::indexed_references_to`]
1277/// can return for `symbol`. Member symbols include the owner class's short
1278/// name alongside the member name: `__construct` postings are recorded at
1279/// `new Cls(` sites, which never spell the member name.
1280fn reference_gate_needles(symbol: &crate::Name) -> Vec<String> {
1281 fn short(fqn: &str) -> &str {
1282 fqn.rsplit('\\').next().unwrap_or(fqn)
1283 }
1284 let mut needles = match symbol {
1285 crate::Name::Class(f) | crate::Name::Function(f) | crate::Name::GlobalConstant(f) => {
1286 vec![short(f).to_string()]
1287 }
1288 // `__construct` is invoked only as `new Cls(...)`, `parent::__construct()`,
1289 // or `self::__construct()`/`static::__construct()` from inside a
1290 // subclass — every real call site textually names the class itself
1291 // (directly, or via the enclosing subclass's own `extends`/`use`),
1292 // never the bare word `__construct`. Gating on the class's short name
1293 // alone is exact (no lost call sites) and, unlike the general member
1294 // case, dropping the method-name needle here doesn't reintroduce a
1295 // false negative. This matters: `__construct` is one of the most
1296 // common tokens in any real codebase, so OR-ing it in as a needle
1297 // admits nearly every file as a "must re-analyze" candidate on a
1298 // cold query, defeating the gate's entire purpose for constructors.
1299 crate::Name::Method { class, name } if name.as_ref() == "__construct" => {
1300 if class.is_empty() {
1301 // No class to scope to (owner unknown) — fall back to gating
1302 // on the bare name, same as the general member case below.
1303 vec![name.to_string()]
1304 } else {
1305 vec![short(class).to_string()]
1306 }
1307 }
1308 crate::Name::Method { class, name }
1309 | crate::Name::Property { class, name }
1310 | crate::Name::ClassConstant { class, name } => {
1311 let mut v = vec![name.to_string()];
1312 if !class.is_empty() {
1313 v.push(short(class).to_string());
1314 }
1315 v
1316 }
1317 };
1318 // An empty needle can never match; dropping it keeps the "empty needle
1319 // set disables the gate" contract at the call site conservative.
1320 needles.retain(|n| !n.is_empty());
1321 needles
1322}
1323
1324#[cfg(test)]
1325mod tests {
1326 use super::*;
1327
1328 #[test]
1329 fn mentions_identifier_is_case_insensitive_and_word_bounded() {
1330 assert!(mentions_identifier("$this->save();", "save"));
1331 assert!(mentions_identifier("new COLOR()", "Color"));
1332 assert!(mentions_identifier("use App\\Color as Paint;", "color"));
1333 assert!(!mentions_identifier("$this->saveAll();", "save"));
1334 assert!(!mentions_identifier("return $unsaved;", "save"));
1335 assert!(!mentions_identifier("no occurrence", "save"));
1336 assert!(!mentions_identifier("anything", ""));
1337 // Multibyte neighbors are conservatively treated as boundaries, and
1338 // substring scans must not split codepoints.
1339 assert!(!mentions_identifier("function xÉclairFoo() {}", "Éclair"));
1340 assert!(mentions_identifier("implements Éclair {}", "Éclair"));
1341 }
1342
1343 #[test]
1344 fn identifier_needles_match_per_needle_scans_exactly() {
1345 let hays = [
1346 "$this->save();",
1347 "new COLOR()",
1348 "use App\\Color as Paint;",
1349 "$this->saveAll();",
1350 "return $unsaved;",
1351 "no occurrence",
1352 "function xÉclairFoo() {}",
1353 "implements Éclair {}",
1354 "save",
1355 "Color save",
1356 "colorsave savecolor",
1357 "",
1358 ];
1359 let needle_sets: [&[&str]; 4] = [
1360 &["save"],
1361 &["Color", "save"],
1362 &["Éclair", "color", "occurrence"],
1363 &[],
1364 ];
1365 for needles in needle_sets {
1366 let owned: Vec<String> = needles.iter().map(|s| s.to_string()).collect();
1367 let compiled = IdentifierNeedles::new(&owned);
1368 for hay in hays {
1369 assert_eq!(
1370 compiled.matches(hay),
1371 owned.iter().any(|n| mentions_identifier(hay, n)),
1372 "needles {owned:?} on {hay:?}"
1373 );
1374 }
1375 }
1376 }
1377
1378 #[test]
1379 fn gate_needles_cover_member_and_owner_class() {
1380 // A regular member (non-constructor) gates on both the member name
1381 // and the owner's short name — a call site may name only one.
1382 let n = reference_gate_needles(&crate::Name::method("App\\Job", "run"));
1383 assert!(n.contains(&"run".to_string()) && n.contains(&"Job".to_string()));
1384 let n = reference_gate_needles(&crate::Name::class("App\\Ui\\Color"));
1385 assert_eq!(n, vec!["Color".to_string()]);
1386 // Unknown-owner member symbols still gate on the member name alone.
1387 let n = reference_gate_needles(&crate::Name::method("", "run"));
1388 assert_eq!(n, vec!["run".to_string()]);
1389 }
1390
1391 #[test]
1392 fn gate_needles_for_constructor_scope_to_owner_class_only() {
1393 // `__construct` is only ever spelled at `new Cls(`/`parent::__construct()`
1394 // sites, which always name the class — the bare method-name needle is
1395 // dropped so a cold constructor query doesn't admit nearly every file
1396 // in the workspace (every class defines *some* `__construct`).
1397 let n = reference_gate_needles(&crate::Name::method("App\\Job", "__construct"));
1398 assert_eq!(n, vec!["Job".to_string()]);
1399 // Unknown owner: nothing to scope to, fall back to the bare name.
1400 let n = reference_gate_needles(&crate::Name::method("", "__construct"));
1401 assert_eq!(n, vec!["__construct".to_string()]);
1402 }
1403}