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