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