mir_analyzer/session/queries.rs
1use std::sync::Arc;
2
3use super::*;
4
5impl AnalysisSession {
6 /// Open-file diagnostics entrypoint for issue-only consumers.
7 ///
8 /// Updates the session's current text for `file`, reuses the tracked parse
9 /// and definition queries for syntax/collector diagnostics, then runs the
10 /// targeted open-file body-analysis path without retaining whole-file
11 /// `ResolvedSymbol` payloads.
12 pub fn analyze_file_diagnostics(&mut self, file: &str, source: &str) -> crate::FileAnalysis {
13 let file: Arc<str> = Arc::from(file);
14 self.ingest_file(file.clone(), Arc::from(source));
15
16 // Body analysis can load classes, a write that would wait forever on
17 // a db handle still alive here.
18 let (mut issues, parsed) = {
19 let view = self.db_view();
20 let db = view.db();
21 let Some(sf) = db.lookup_source_file(file.as_ref()) else {
22 return crate::FileAnalysis {
23 issues: Vec::new(),
24 symbols: Vec::new(),
25 };
26 };
27 let defs = crate::db::collect_file_definitions(db, sf);
28 let prepared = crate::db::prepare_analysis_file(db, sf);
29 let parsed = (!prepared.has_hard_parse_errors)
30 .then(|| (prepared.text.clone(), prepared.parsed.0.clone()));
31 (Arc::unwrap_or_clone(defs.issues.clone()), parsed)
32 };
33
34 if let Some((text, parsed)) = parsed {
35 let analysis = crate::FileAnalyzer::new(self).analyze_diagnostics_only(
36 file.clone(),
37 text.as_ref(),
38 &parsed.program,
39 &parsed.source_map,
40 );
41 issues.extend(analysis.issues);
42 }
43
44 self.apply_suppressions_and_emit_unused(&mut issues, std::slice::from_ref(&file));
45 crate::FileAnalysis {
46 issues,
47 symbols: Vec::new(),
48 }
49 }
50
51 /// Resolve the codebase-level symbol name at `byte_offset` in `file`.
52 ///
53 /// The identity half of [`Self::symbol_at`], for consumers that only need
54 /// a [`crate::Name`] to feed [`Self::definition_of`] or
55 /// [`Self::indexed_references_to`].
56 pub fn name_at(&mut self, file: &str, byte_offset: u32) -> Option<crate::Name> {
57 crate::FileAnalyzer::new(self).name_at(Arc::from(file), byte_offset)
58 }
59
60 /// Calls inside the function or method whose declaration contains
61 /// `byte_offset` in `file`, as `(callee, call-site name range)` in source
62 /// order. Callees are resolved [`crate::Name`]s — a trait alias reports
63 /// the original method, `parent::`/`self::` the declaring class — so the
64 /// result feeds [`Self::definition_of`] and [`Self::indexed_references_to`]
65 /// directly. Calls in nested closures belong to the enclosing function.
66 /// `new Foo` is a class reference, not reported. Empty when the offset is
67 /// outside a function or method body.
68 pub fn outgoing_calls(
69 &mut self,
70 file: &str,
71 byte_offset: u32,
72 ) -> Vec<(crate::Name, crate::Range)> {
73 crate::FileAnalyzer::new(self).outgoing_calls(Arc::from(file), byte_offset)
74 }
75
76 /// Every occurrence of the local variable or parameter under
77 /// `byte_offset` in `file`, in source order, as name ranges including the
78 /// `$`. An occurrence belongs to the variable when it binds to the same
79 /// declaration: nested functions and closures start a new scope, except
80 /// where a closure captures the variable with `use`; arrow functions share
81 /// the enclosing scope except for their own parameters. Empty when the
82 /// offset is not on a variable. Powers document highlight and local
83 /// rename.
84 pub fn variable_references(&mut self, file: &str, byte_offset: u32) -> Vec<crate::Range> {
85 crate::FileAnalyzer::new(self).variable_references(Arc::from(file), byte_offset)
86 }
87
88 /// Resolve the symbol at `byte_offset` in `file`'s current ingested text.
89 ///
90 /// Answers the same as [`crate::FileAnalysis::symbol_at`] without the
91 /// caller retaining a whole-file symbol list: the containing scope is
92 /// re-analyzed on demand and only its symbol payload is searched.
93 ///
94 /// **Side effects:** may fault in direct dependencies of `file` by running
95 /// the open-file warm-up path (`prepare_file_for_analysis`).
96 pub fn symbol_at(&mut self, file: &str, byte_offset: u32) -> Option<crate::ResolvedSymbol> {
97 crate::FileAnalyzer::new(self).symbol_at(Arc::from(file), byte_offset)
98 }
99
100 /// Resolve a top-level symbol (class or function) to its declaration
101 /// location. Powers go-to-definition.
102 ///
103 /// **Side effects:** ingests the file defining the symbol's class if the
104 /// symbol index lacks it. [`Self::definition_of_cached`] answers the same
105 /// without writing inputs.
106 ///
107 /// Returns:
108 /// - `Ok(Location)` — symbol found with a source location
109 /// - `Err(NotFound)` — no such symbol in the codebase
110 /// - `Err(NoSourceLocation)` — symbol exists but has no recorded span
111 /// (e.g. some stub-only declarations)
112 pub fn definition_of(
113 &mut self,
114 symbol: &crate::Name,
115 ) -> Result<mir_types::Location, crate::SymbolLookupError> {
116 self.load_symbol_owner(symbol);
117 self.definition_of_cached(symbol)
118 }
119
120 /// Ingest the file defining the class (or function) `symbol` is rooted
121 /// in, so the symbol index holds it. No-op when indexed.
122 fn load_symbol_owner(&mut self, symbol: &crate::Name) {
123 match symbol {
124 crate::Name::Class(fqn) | crate::Name::Function(fqn) => {
125 let _ = self.load_class(fqn.as_ref());
126 }
127 crate::Name::Method { class, .. }
128 | crate::Name::Property { class, .. }
129 | crate::Name::ClassConstant { class, .. } => {
130 let _ = self.load_class(class.as_ref());
131 }
132 crate::Name::GlobalConstant(_) => {}
133 }
134 }
135
136 /// Pure variant of [`Self::definition_of`]: never writes salsa inputs.
137 /// Symbols the index lacks load on demand through the resolver and
138 /// [`crate::SourceProvider`] as pure reads.
139 pub fn definition_of_cached(
140 &self,
141 symbol: &crate::Name,
142 ) -> Result<mir_types::Location, crate::SymbolLookupError> {
143 self.query_snapshot(|snap| snap.definition_of_cached(symbol))
144 }
145
146 /// See [`AnalysisSnapshot::implemented_method`].
147 pub fn implemented_method(&self, method: &crate::Name) -> Option<crate::Name> {
148 self.query_snapshot(|snap| snap.implemented_method(method))
149 }
150
151 /// Raw reference locations indexed by string symbol key, kept for tests
152 /// that use the string-keyed API. Prefer [`Self::indexed_references_to`]
153 /// with a typed [`crate::Name`].
154 #[doc(hidden)]
155 pub fn reference_locations(&self, symbol: &str) -> Vec<(Arc<str>, u32, u16, u16)> {
156 use crate::db::MirDatabase;
157 let db = self.snapshot_db();
158 db.reference_locations(symbol)
159 }
160
161 /// Files declaring transitive subclasses of `class_fqn`, backed by the
162 /// maintained subtype index (see [`Self::indexed_subtype_classes`]).
163 /// Excludes `class_fqn`'s own declaring file — the caller adds it.
164 ///
165 /// Lets a reference-search caller scope a `protected` member to its class
166 /// hierarchy without reconstructing that hierarchy from declaration text:
167 /// subclasses are matched by resolved FQCN, so `extends \Ns\Base` and
168 /// aliased `use` forms are all found. Read-only from the caller's
169 /// perspective; may trigger an on-demand commit of stale/uncommitted
170 /// candidates' class edges (same self-heal `indexed_subtype_classes` uses).
171 /// `include_trait_users` also counts classes that `use` the trait.
172 pub fn subtype_files(&mut self, class_fqn: &str, include_trait_users: bool) -> Vec<Arc<str>> {
173 self.prepare_for_query(None);
174 self.query_snapshot(|snap| snap.subtype_files(class_fqn, include_trait_users))
175 }
176
177 /// Compatibility wrapper for callers that only want `use` import items.
178 /// Delegates to [`Self::indexed_references_to`] so import references share
179 /// the same freshness/self-heal path, scope filtering, and memoization
180 /// boundary as every other reference query.
181 pub fn indexed_use_import_locations(
182 &mut self,
183 symbol: &crate::Name,
184 files: &[Arc<str>],
185 ) -> Vec<(Arc<str>, crate::Range)> {
186 self.indexed_references_to(
187 symbol,
188 files,
189 false,
190 crate::ReferenceIncludes::UseImports,
191 &|| false,
192 )
193 .unwrap_or_default()
194 }
195
196 /// See [`AnalysisSnapshot::indexed_references_to`]. Unlike the snapshot
197 /// form, this runs the owner-side warm-up for stale candidates first, so
198 /// every one of them is analyzed with its referenced classes loaded.
199 ///
200 /// `should_cancel` is polled at phase boundaries and between stale
201 /// candidates; `true` aborts with `None`, as does an index settle that
202 /// snapshot readers keep feeding past its round cap.
203 pub fn indexed_references_to(
204 &mut self,
205 symbol: &crate::Name,
206 files: &[Arc<str>],
207 include_declaration: bool,
208 includes: crate::ReferenceIncludes,
209 should_cancel: &(dyn Fn() -> bool + Sync),
210 ) -> Option<Vec<(Arc<str>, crate::Range)>> {
211 // Settling can move the generation, which turns commits with
212 // unresolved names stale, so it runs before both the memo probe and
213 // the stale set.
214 if self.db.salsa.needs_index_settle()
215 && !self.settle_workspace_index_cancellable(should_cancel)
216 {
217 return None;
218 }
219 // No `should_cancel()` check before the memo probe: a hit does no
220 // analysis work, and some callers count probe invocations to prove
221 // a warm query needed no re-analysis.
222 let key = {
223 let snap = self.db_view();
224 let key = snap.reference_query_key(symbol, files, include_declaration, includes);
225 if let Some(hit) = self.index.ref_queries.get(&key) {
226 return Some(hit);
227 }
228 key
229 };
230 if should_cancel() {
231 return None;
232 }
233 let stale = self
234 .db_view()
235 .stale_reference_candidates_unguarded(symbol, files);
236
237 if !stale.is_empty() {
238 if !self.prepare_stale_candidates(&stale, should_cancel) {
239 return None;
240 }
241 self.query_snapshot(|snap| snap.commit_reference_candidates(&stale));
242 }
243
244 if include_declaration {
245 self.load_symbol_owner(symbol);
246 }
247 let snap = self.db_view();
248 let out = snap.read_references(symbol, files, include_declaration, includes);
249 snap.memoize_references(key, &out);
250 Some(out)
251 }
252
253 /// Owner-side prelude for [`AnalysisSnapshot::indexed_references_to`]:
254 /// the warm-up [`Self::indexed_references_to`] runs before reading, so a
255 /// snapshot taken afterwards analyzes every stale candidate with its
256 /// dependencies loaded.
257 ///
258 /// `stale` is [`AnalysisSnapshot::stale_reference_candidates`], computed
259 /// on a snapshot *before* the host takes its lock to call this: that scan
260 /// is O(candidates) and, on a cold workspace, scans file text, so it must
261 /// not run under the lock. Only the stale files are prepared (not every
262 /// candidate), the workspace index is settled when it has pending work,
263 /// and with `include_declaration` the file declaring `symbol` is loaded.
264 /// With nothing stale and nothing pending this is a no-op. A file that
265 /// went stale after `stale` was computed is analyzed without the warm-up,
266 /// as it would be without this call. `false` when `should_cancel` stopped
267 /// it first.
268 pub fn prepare_references_query(
269 &mut self,
270 symbol: &crate::Name,
271 stale: &[Arc<str>],
272 include_declaration: bool,
273 should_cancel: &(dyn Fn() -> bool + Sync),
274 ) -> bool {
275 if self.db.salsa.needs_index_settle()
276 && !self.settle_workspace_index_cancellable(should_cancel)
277 {
278 return false;
279 }
280 if !self.prepare_stale_candidates(stale, should_cancel) {
281 return false;
282 }
283 if include_declaration {
284 self.load_symbol_owner(symbol);
285 }
286 true
287 }
288
289 /// Class-load warm-up for `stale`, with revision bumps coalesced. The
290 /// bump scope closes before the caller's commit view captures its
291 /// generation.
292 fn prepare_stale_candidates(
293 &mut self,
294 stale: &[Arc<str>],
295 should_cancel: &(dyn Fn() -> bool + Sync),
296 ) -> bool {
297 {
298 let mut session = self.defer_revision_bumps();
299 for path in stale {
300 if !session.prepare_file_for_analysis_cancellable(path, should_cancel) {
301 return false;
302 }
303 }
304 }
305 !should_cancel()
306 }
307
308 /// The symbol's declaration site, narrowed from the collector's
309 /// whole-declaration span to the declared name's own token (matching the
310 /// span shape of recorded references).
311 pub fn declaration_name_range(
312 &mut self,
313 symbol: &crate::Name,
314 ) -> Option<(Arc<str>, crate::Range)> {
315 self.load_symbol_owner(symbol);
316 self.db_view().declaration_name_range(symbol)
317 }
318
319 /// See [`AnalysisSnapshot::indexed_subtype_classes`].
320 pub fn indexed_subtype_classes(
321 &mut self,
322 class_fqn: &str,
323 files: &[Arc<str>],
324 include_trait_users: bool,
325 ) -> Vec<SubtypeClassSite> {
326 self.prepare_for_query(None);
327 self.query_snapshot(|snap| {
328 snap.indexed_subtype_classes(class_fqn, files, include_trait_users)
329 })
330 }
331
332 /// See [`AnalysisSnapshot::indexed_direct_subtype_classes`].
333 pub fn indexed_direct_subtype_classes(
334 &mut self,
335 class_fqn: &str,
336 files: &[Arc<str>],
337 include_trait_users: bool,
338 ) -> Vec<SubtypeClassSite> {
339 self.prepare_for_query(None);
340 self.query_snapshot(|snap| {
341 snap.indexed_direct_subtype_classes(class_fqn, files, include_trait_users)
342 })
343 }
344
345 /// See [`AnalysisSnapshot::indexed_method_implementations`].
346 pub fn indexed_method_implementations(
347 &mut self,
348 class_fqn: &str,
349 method: &str,
350 files: &[Arc<str>],
351 ) -> Vec<(Arc<str>, Arc<str>, crate::Range)> {
352 self.prepare_for_query(None);
353 self.query_snapshot(|snap| snap.indexed_method_implementations(class_fqn, method, files))
354 }
355
356 /// See [`AnalysisSnapshot::class_issues`]. Call this after ingesting or
357 /// re-analyzing a file and its dependents to get the full diagnostic
358 /// picture.
359 pub fn class_issues(&mut self, files: &[Arc<str>]) -> Vec<crate::Issue> {
360 self.prepare_for_query(None);
361 self.query_snapshot(|snap| snap.class_issues(files))
362 }
363
364 /// See [`AnalysisSnapshot::collector_issues`]. Correct regardless of
365 /// which path put the file's text into the db (`ingest_file`,
366 /// `set_file_text`, lazy vendor load) or how many times.
367 pub fn collector_issues(&self, files: &[Arc<str>]) -> Vec<crate::Issue> {
368 self.query_snapshot(|snap| snap.collector_issues(files))
369 }
370
371 /// All declarations defined in `file` as a **hierarchical tree**.
372 ///
373 /// Classes/interfaces/traits/enums are returned with their methods,
374 /// properties, and constants nested in `children`. Top-level functions
375 /// and constants are returned with empty `children`.
376 pub fn document_symbols(&self, file: &str) -> Vec<crate::symbol::DocumentSymbol> {
377 use crate::symbol::{DeclarationKind, DocumentSymbol};
378
379 let db = self.snapshot_db();
380 let Some(sf) = db.lookup_source_file(file) else {
381 return Vec::new();
382 };
383 let defs = crate::db::collect_file_definitions(&db, sf);
384 let mut out: Vec<DocumentSymbol> = Vec::new();
385
386 let class_children = |methods: &mir_codebase::definitions::MemberMap<
387 Arc<mir_codebase::definitions::MethodDef>,
388 >,
389 props: Option<
390 &mir_codebase::definitions::MemberMap<mir_codebase::definitions::PropertyDef>,
391 >,
392 consts: &mir_codebase::definitions::MemberMap<
393 mir_codebase::definitions::ConstantDef,
394 >,
395 is_enum: bool|
396 -> Vec<DocumentSymbol> {
397 let mut out: Vec<DocumentSymbol> = Vec::new();
398 for (_, m) in methods.iter() {
399 out.push(DocumentSymbol {
400 name: m.name.clone(),
401 kind: DeclarationKind::Method,
402 location: m.location.clone(),
403 children: Vec::new(),
404 });
405 }
406 if let Some(props) = props {
407 for (_, p) in props.iter() {
408 out.push(DocumentSymbol {
409 name: p.name.clone(),
410 kind: DeclarationKind::Property,
411 location: p.location.clone(),
412 children: Vec::new(),
413 });
414 }
415 }
416 let const_kind = if is_enum {
417 DeclarationKind::EnumCase
418 } else {
419 DeclarationKind::Constant
420 };
421 for (_, c) in consts.iter() {
422 out.push(DocumentSymbol {
423 name: c.name.clone(),
424 kind: const_kind,
425 location: c.location.clone(),
426 children: Vec::new(),
427 });
428 }
429 out
430 };
431
432 for c in defs.slice.classes.iter() {
433 out.push(DocumentSymbol {
434 name: c.fqcn.clone(),
435 kind: DeclarationKind::Class,
436 location: c.location.clone(),
437 children: class_children(
438 &c.own_methods,
439 Some(&c.own_properties),
440 &c.own_constants,
441 false,
442 ),
443 });
444 }
445 for i in defs.slice.interfaces.iter() {
446 out.push(DocumentSymbol {
447 name: i.fqcn.clone(),
448 kind: DeclarationKind::Interface,
449 location: i.location.clone(),
450 children: class_children(&i.own_methods, None, &i.own_constants, false),
451 });
452 }
453 for t in defs.slice.traits.iter() {
454 out.push(DocumentSymbol {
455 name: t.fqcn.clone(),
456 kind: DeclarationKind::Trait,
457 location: t.location.clone(),
458 children: class_children(
459 &t.own_methods,
460 Some(&t.own_properties),
461 &t.own_constants,
462 false,
463 ),
464 });
465 }
466 for e in defs.slice.enums.iter() {
467 let mut children = class_children(&e.own_methods, None, &e.own_constants, true);
468 for (_, case) in e.cases.iter() {
469 children.push(DocumentSymbol {
470 name: case.name.clone(),
471 kind: DeclarationKind::EnumCase,
472 location: case.location.clone(),
473 children: Vec::new(),
474 });
475 }
476 out.push(DocumentSymbol {
477 name: e.fqcn.clone(),
478 kind: DeclarationKind::Enum,
479 location: e.location.clone(),
480 children,
481 });
482 }
483 for f in defs.slice.functions.iter() {
484 out.push(DocumentSymbol {
485 name: f.fqn.clone(),
486 kind: DeclarationKind::Function,
487 location: f.location.clone(),
488 children: Vec::new(),
489 });
490 }
491 for (name, _) in defs.slice.constants.iter() {
492 out.push(DocumentSymbol {
493 name: name.clone(),
494 kind: DeclarationKind::Constant,
495 location: None,
496 children: Vec::new(),
497 });
498 }
499 out
500 }
501}
502
503/// A transitive subtype hit with its declaration name span, as returned by
504/// [`AnalysisSession::indexed_subtype_classes`].
505#[derive(Debug, Clone)]
506pub struct SubtypeClassSite {
507 /// Display-form FQCN (no leading `\`).
508 pub fqcn: Arc<str>,
509 pub kind: crate::db::ClassLikeKind,
510 pub is_abstract: bool,
511 pub file: Arc<str>,
512 /// The declared name's own token (1-based line, 0-based char columns).
513 pub range: crate::Range,
514}
515
516/// Build a [`crate::Range`] on one line from mir's native coordinates
517/// (1-based line, 0-based columns).
518pub(super) fn span_range(line: u32, col_start: u32, col_end: u32) -> crate::Range {
519 crate::Range {
520 start: crate::Position {
521 line,
522 column: col_start,
523 },
524 end: crate::Position {
525 line,
526 column: col_end,
527 },
528 }
529}
530
531/// Char column of the first word-boundary occurrence of `needle` in `line`
532/// at or after char column `min_col`. Columns are code points, matching the
533/// collector's `Location` convention.
534pub(super) fn identifier_char_col(
535 line: &str,
536 needle: &str,
537 min_col: usize,
538 case_insensitive: bool,
539) -> Option<u32> {
540 if needle.is_empty() {
541 return None;
542 }
543 let is_ident = |c: char| c.is_ascii_alphanumeric() || c == '_';
544 let chars: Vec<char> = line.chars().collect();
545 let needle_chars: Vec<char> = needle.chars().collect();
546 let n = needle_chars.len();
547 if chars.len() < n {
548 return None;
549 }
550 for start in min_col..=chars.len().saturating_sub(n) {
551 let matches = chars[start..start + n]
552 .iter()
553 .zip(needle_chars.iter())
554 .all(|(a, b)| {
555 if case_insensitive {
556 a.eq_ignore_ascii_case(b)
557 } else {
558 a == b
559 }
560 });
561 if !matches {
562 continue;
563 }
564 let before_ok = start == 0 || !is_ident(chars[start - 1]);
565 let after = start + n;
566 let after_ok = after >= chars.len() || !is_ident(chars[after]);
567 if before_ok && after_ok {
568 return Some(start as u32);
569 }
570 }
571 None
572}
573
574/// Whether `hay` mentions `needle` as a whole identifier (ASCII word
575/// boundaries; conservative near multibyte text). ASCII-case-insensitive:
576/// PHP class, function, and method names are case-insensitive, so `new
577/// COLOR()` must count as mentioning `Color`; for the case-sensitive kinds
578/// (constants, properties) folding only widens the candidate superset.
579///
580/// Test-only semantic oracle: the production gates (references freshness,
581/// subtype-BFS defs commit) answer this predicate through the persistent
582/// `ClassMentionIndex`; the parity test below pins the scanner to these
583/// exact boundary and case semantics.
584#[cfg(test)]
585fn mentions_identifier(hay: &str, needle: &str) -> bool {
586 let hay = hay.as_bytes();
587 let needle = needle.as_bytes();
588 let n = needle.len();
589 if n == 0 || hay.len() < n {
590 return false;
591 }
592 let is_ident = |b: u8| b.is_ascii_alphanumeric() || b == b'_';
593 let first = needle[0].to_ascii_lowercase();
594 for i in 0..=(hay.len() - n) {
595 if hay[i].to_ascii_lowercase() != first || !hay[i..i + n].eq_ignore_ascii_case(needle) {
596 continue;
597 }
598 if (i == 0 || !is_ident(hay[i - 1])) && (i + n == hay.len() || !is_ident(hay[i + n])) {
599 return true;
600 }
601 }
602 false
603}
604
605fn short(fqn: &str) -> &str {
606 fqn.rsplit('\\').next().unwrap_or(fqn)
607}
608
609/// A candidate-file admission predicate for `indexed_references_to`'s
610/// freshness pass, chosen by [`ReferenceGate::for_symbol`]. A file is
611/// admitted when its text mentions any of `idents` as a whole identifier
612/// (word-bounded, ASCII-case-insensitive) OR contains any of `raw` as a
613/// plain substring (ASCII-case-insensitive, no word bounds — used for
614/// call-shaped tokens like `->__construct`). A file matching neither can
615/// hold no posting for the symbol.
616pub(super) struct ReferenceGate {
617 pub(super) idents: Vec<String>,
618 pub(super) raw: Vec<String>,
619}
620
621impl ReferenceGate {
622 /// Choose the candidate-admission gate for `symbol`.
623 ///
624 /// For any known non-constructor/non-`__invoke` method, the member name
625 /// alone is the sound gate. Every posting-producing reference spells that
626 /// token: `$obj->m()`, `Owner::m()`, inherited `Sub::m()`,
627 /// `self::`/`static::`/`parent::m()`, callable strings/arrays, and trait
628 /// aliases (`orig as alias` records `orig`, alias calls record `alias`
629 /// plus the origin key). Dropping the owner short name matters on common
630 /// owner names (`User`, `Model`, `Widget`): otherwise a cold reference
631 /// query analyzes files that only type-hint the owner and cannot contain a
632 /// reference to the queried method. Dynamic member names (`$obj->$m()`)
633 /// produce no posting, so nothing is lost there.
634 ///
635 /// For `__construct` with a known owner, the identifier needle is the
636 /// owner's short name (`new Cls(` sites never spell the member name and
637 /// the bare word `__construct` would admit every file *declaring* a
638 /// constructor), complemented by the raw call tokens `->__construct` /
639 /// `::__construct`: an explicit re-init `$obj->__construct()` is a real
640 /// recorded reference whose file may never name the class.
641 ///
642 /// `__invoke` keeps the general owner/name gate because `$obj()` call sites
643 /// do not spell `__invoke`.
644 ///
645 /// Everything else uses the general OR needles
646 /// ([`reference_gate_needles`]).
647 pub(super) fn for_symbol(db: &MirDbStorage, symbol: &crate::Name) -> Self {
648 if let crate::Name::Method { class, name } = symbol {
649 if name.as_ref() == "__construct" && !class.is_empty() {
650 return Self {
651 idents: reference_gate_needles(symbol),
652 raw: vec!["->__construct".to_string(), "::__construct".to_string()],
653 };
654 }
655 if name.as_ref() != "__construct" && name.as_ref() != "__invoke" && !class.is_empty() {
656 let here = crate::db::Fqcn::from_str(db, class.as_ref());
657 let is_static = crate::db::find_method_in_chain(db, here, name)
658 .map(|(_, m)| m.is_static)
659 .unwrap_or(false);
660 if is_static || crate::db::class_exists(db, class.as_ref()) {
661 return Self {
662 idents: vec![name.to_string()],
663 raw: Vec::new(),
664 };
665 }
666 }
667 }
668 Self {
669 idents: reference_gate_needles(symbol),
670 raw: Vec::new(),
671 }
672 }
673}
674
675/// Identifier words whose whole-word presence in a file's text is necessary
676/// for the file to hold any posting [`AnalysisSession::indexed_references_to`]
677/// can return for `symbol`. Member symbols include the owner class's short
678/// name alongside the member name: `__construct` postings are recorded at
679/// `new Cls(` sites, which never spell the member name.
680fn reference_gate_needles(symbol: &crate::Name) -> Vec<String> {
681 let mut needles = match symbol {
682 crate::Name::Class(f) | crate::Name::Function(f) | crate::Name::GlobalConstant(f) => {
683 vec![short(f).to_string()]
684 }
685 // `__construct` is invoked only as `new Cls(...)`, `parent::__construct()`,
686 // or `self::__construct()`/`static::__construct()` from inside a
687 // subclass — every real call site textually names the class itself
688 // (directly, or via the enclosing subclass's own `extends`/`use`),
689 // never the bare word `__construct`. Gating on the class's short name
690 // alone is exact (no lost call sites) and, unlike the general member
691 // case, dropping the method-name needle here doesn't reintroduce a
692 // false negative. This matters: `__construct` is one of the most
693 // common tokens in any real codebase, so OR-ing it in as a needle
694 // admits nearly every file as a "must re-analyze" candidate on a
695 // cold query, defeating the gate's entire purpose for constructors.
696 crate::Name::Method { class, name } if name.as_ref() == "__construct" => {
697 if class.is_empty() {
698 // No class to scope to (owner unknown) — fall back to gating
699 // on the bare name, same as the general member case below.
700 vec![name.to_string()]
701 } else {
702 vec![short(class).to_string()]
703 }
704 }
705 crate::Name::Method { class, name }
706 | crate::Name::Property { class, name }
707 | crate::Name::ClassConstant { class, name } => {
708 let mut v = vec![name.to_string()];
709 if !class.is_empty() {
710 v.push(short(class).to_string());
711 }
712 v
713 }
714 };
715 // An empty needle can never match; dropping it keeps the "empty needle
716 // set disables the gate" contract at the call site conservative.
717 needles.retain(|n| !n.is_empty());
718 needles
719}
720
721#[cfg(test)]
722mod tests {
723 use super::*;
724
725 #[test]
726 fn mentions_identifier_is_case_insensitive_and_word_bounded() {
727 assert!(mentions_identifier("$this->save();", "save"));
728 assert!(mentions_identifier("new COLOR()", "Color"));
729 assert!(mentions_identifier("use App\\Color as Paint;", "color"));
730 assert!(!mentions_identifier("$this->saveAll();", "save"));
731 assert!(!mentions_identifier("return $unsaved;", "save"));
732 assert!(!mentions_identifier("no occurrence", "save"));
733 assert!(!mentions_identifier("anything", ""));
734 // Multibyte neighbors are conservatively treated as boundaries, and
735 // substring scans must not split codepoints.
736 assert!(!mentions_identifier("function xÉclairFoo() {}", "Éclair"));
737 assert!(mentions_identifier("implements Éclair {}", "Éclair"));
738 }
739
740 #[test]
741 fn mention_scanner_membership_equals_per_needle_scans() {
742 // The mention index is the sole implementation of the gates' textual
743 // predicate, so scanner membership must equal the reference
744 // per-needle predicate for every (hay, needle) pair — same boundary
745 // and case semantics.
746 use crate::db::MentionScanner;
747 use std::sync::Arc;
748 let universe = ["Color", "save", "ColorPicker", "Éclair", "C1", "_Wrap"];
749 let names: Vec<(mir_types::Name, bool)> = universe
750 .iter()
751 .map(|s| (mir_types::Name::new(s).ascii_lowercase(), false))
752 .collect();
753 let scanner = Arc::new(MentionScanner::build(1, names).unwrap());
754 let hays = [
755 "$this->save();",
756 "new COLOR()",
757 "use App\\Color as Paint;",
758 "$this->saveAll();",
759 "return $unsaved;",
760 "new ColorPicker(); Color::save();",
761 "function xÉclairFoo() {}",
762 "implements Éclair {}",
763 "colorsave savecolor color_save",
764 "class C1 extends _Wrap {}",
765 "",
766 ];
767 for hay in hays {
768 let scanned = scanner.scan(hay);
769 for needle in universe {
770 let expected = mentions_identifier(hay, needle);
771 let name = mir_types::Name::new(needle).ascii_lowercase();
772 assert_eq!(
773 scanned.binary_search(&name).is_ok(),
774 expected,
775 "needle {needle:?} on {hay:?}"
776 );
777 }
778 }
779 }
780
781 #[test]
782 fn gate_needles_cover_member_and_owner_class() {
783 // A regular member (non-constructor) gates on both the member name
784 // and the owner's short name — a call site may name only one.
785 let n = reference_gate_needles(&crate::Name::method("App\\Job", "run"));
786 assert!(n.contains(&"run".to_string()) && n.contains(&"Job".to_string()));
787 let n = reference_gate_needles(&crate::Name::class("App\\Ui\\Color"));
788 assert_eq!(n, vec!["Color".to_string()]);
789 // Unknown-owner member symbols still gate on the member name alone.
790 let n = reference_gate_needles(&crate::Name::method("", "run"));
791 assert_eq!(n, vec!["run".to_string()]);
792 }
793
794 #[test]
795 fn gate_needles_for_constructor_scope_to_owner_class_only() {
796 // `__construct` is only ever spelled at `new Cls(`/`parent::__construct()`
797 // sites, which always name the class — the bare method-name needle is
798 // dropped so a cold constructor query doesn't admit nearly every file
799 // in the workspace (every class defines *some* `__construct`).
800 let n = reference_gate_needles(&crate::Name::method("App\\Job", "__construct"));
801 assert_eq!(n, vec!["Job".to_string()]);
802 // Unknown owner: nothing to scope to, fall back to the bare name.
803 let n = reference_gate_needles(&crate::Name::method("", "__construct"));
804 assert_eq!(n, vec!["__construct".to_string()]);
805 }
806
807 fn session_with(files: &[(&str, &str)]) -> crate::AnalysisSession {
808 let mut session = crate::AnalysisSession::new(crate::PhpVersion::LATEST);
809 for (path, text) in files {
810 session.set_file_text(Arc::from(*path), Arc::from(*text));
811 }
812 session
813 }
814
815 #[test]
816 fn gate_static_method_is_member_name_only() {
817 // Regardless of subtypes: the member token alone is the sound gate
818 // (an instance receiver `$obj::m()` never names the owner), and it
819 // is also the selective part — the owner short name would only
820 // widen the admitted set.
821 let session = session_with(&[
822 (
823 "owner.php",
824 "<?php\nclass Owner { public static function m(): void {} }\n",
825 ),
826 ("sub.php", "<?php\nclass Sub extends Owner {}\n"),
827 ]);
828 let gate = session
829 .snapshot()
830 .reference_gate(&crate::Name::method("Owner", "m"));
831 assert_eq!(gate.idents, vec!["m".to_string()]);
832 assert!(gate.raw.is_empty());
833 }
834
835 #[test]
836 fn gate_instance_method_is_member_name_only() {
837 let session = session_with(&[(
838 "owner.php",
839 "<?php\nclass Owner { public function m(): void {} }\n",
840 )]);
841 let gate = session
842 .snapshot()
843 .reference_gate(&crate::Name::method("Owner", "m"));
844 assert_eq!(gate.idents, vec!["m".to_string()]);
845 assert!(gate.raw.is_empty());
846 }
847
848 #[test]
849 fn gate_invoke_keeps_owner_needle() {
850 let session = session_with(&[(
851 "owner.php",
852 "<?php\nclass Owner { public function __invoke(): void {} }\n",
853 )]);
854 let gate = session
855 .snapshot()
856 .reference_gate(&crate::Name::method("Owner", "__invoke"));
857 assert_eq!(
858 gate.idents,
859 reference_gate_needles(&crate::Name::method("Owner", "__invoke"))
860 );
861 assert!(gate.raw.is_empty());
862 }
863
864 #[test]
865 fn gate_constructor_adds_raw_call_tokens() {
866 // Owner short name for `new Cls(` sites, plus the raw call tokens
867 // for explicit re-init (`$obj->__construct()`) whose file may never
868 // name the class. The bare identifier `__construct` must NOT be a
869 // needle — it would admit every file declaring a constructor.
870 let session = session_with(&[(
871 "owner.php",
872 "<?php\nclass Owner { public function __construct() {} }\n",
873 )]);
874 let gate = session
875 .snapshot()
876 .reference_gate(&crate::Name::method("Owner", "__construct"));
877 assert_eq!(
878 gate.idents,
879 reference_gate_needles(&crate::Name::method("Owner", "__construct"))
880 );
881 assert_eq!(
882 gate.raw,
883 vec!["->__construct".to_string(), "::__construct".to_string()]
884 );
885 }
886
887 #[test]
888 fn gate_unresolvable_owner_falls_back_to_general_needles() {
889 let session = session_with(&[(
890 "owner.php",
891 "<?php\nclass Owner { public static function m(): void {} }\n",
892 )]);
893 let gate = session
894 .snapshot()
895 .reference_gate(&crate::Name::method("Nonexistent\\Missing", "m"));
896 assert_eq!(
897 gate.idents,
898 reference_gate_needles(&crate::Name::method("Nonexistent\\Missing", "m"))
899 );
900 assert!(gate.raw.is_empty());
901 }
902}