Skip to main content

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