Skip to main content

mir_analyzer/
file_analyzer.rs

1//! Per-file analysis entry point for incremental analysis.
2//!
3//! [`FileAnalyzer`] runs a **single** body-analysis pass against an
4//! [`AnalysisSession`] snapshot. Lookups go through the workspace symbol
5//! index the background indexer ([`AnalysisSession::index_batch`]) builds;
6//! classes and built-in stubs it lacks load on demand as pure reads. Before
7//! the pass, [`AnalysisSession::priority_index_for_ast`] indexes the file's
8//! *direct* references the background walk hasn't reached.
9//!
10//! For bulk multi-file work, use the session sweeps
11//! ([`AnalysisSession::reanalyze_files_cancellable`]) — memoized and
12//! cancellable — or the CLI batch pipeline.
13
14use std::sync::Arc;
15
16use mir_issues::Issue;
17use php_ast::owned::{Program, Stmt, StmtKind};
18use php_ast::Span;
19use php_rs_parser::source_map::SourceMap;
20use rustc_hash::FxHashSet;
21
22use crate::body_analysis::BodyAnalyzer;
23use crate::db::MirDatabase;
24use crate::session::{AnalysisSession, AnalysisSnapshot};
25use crate::symbol::{NavigationFact, ResolvedNavigationFact, ResolvedSymbol};
26
27/// Result of a single-file analysis.
28pub struct FileAnalysis {
29    pub issues: Vec<Issue>,
30    pub symbols: Vec<ResolvedSymbol>,
31}
32
33impl FileAnalysis {
34    /// Return the innermost resolved symbol whose span contains `byte_offset`,
35    /// or `None` if no symbol was recorded at that position.
36    ///
37    /// Whole-file counterpart of [`crate::AnalysisSession::symbol_at`]: callers map
38    /// (line, column) → byte offset → resolved symbol, then look up the
39    /// symbol's definition via [`crate::AnalysisSession::definition_of`] or
40    /// type info via [`ResolvedSymbol::resolved_type`].
41    pub fn symbol_at(&self, byte_offset: u32) -> Option<&ResolvedSymbol> {
42        symbol_at(&self.symbols, byte_offset)
43    }
44}
45
46fn span_contains(span: Span, byte_offset: u32) -> bool {
47    span.start <= byte_offset && byte_offset < span.end
48}
49
50fn span_len(span: Span) -> u32 {
51    span.end.saturating_sub(span.start)
52}
53
54/// Innermost item by precedence: one whose own span contains `byte_offset`, then
55/// one whose span ends exactly there (a cursor just past an identifier), then
56/// one whose enclosing expression span contains it (whitespace inside a call).
57fn best_at<T>(
58    items: &[T],
59    span: impl Fn(&T) -> Span,
60    expr_span: impl Fn(&T) -> Option<Span>,
61    byte_offset: u32,
62    allow_expr_fallback: bool,
63) -> Option<&T> {
64    items
65        .iter()
66        .filter(|item| span_contains(span(item), byte_offset))
67        .min_by_key(|item| span_len(span(item)))
68        .or_else(|| {
69            items
70                .iter()
71                .filter(|item| span(item).end == byte_offset)
72                .min_by_key(|item| span_len(span(item)))
73        })
74        .or_else(|| {
75            if !allow_expr_fallback {
76                return None;
77            }
78            items
79                .iter()
80                .filter(|item| expr_span(item).is_some_and(|es| span_contains(es, byte_offset)))
81                .min_by_key(|item| expr_span(item).map(span_len).unwrap_or(u32::MAX))
82        })
83}
84
85/// True when `byte_offset` sits right after the last character of an
86/// identifier or variable name.
87fn ends_identifier(source: &str, byte_offset: u32) -> bool {
88    let is_ident = |b: u8| b.is_ascii_alphanumeric() || b == b'_' || b >= 0x80;
89    let bytes = source.as_bytes();
90    let at = byte_offset as usize;
91    at > 0
92        && bytes.get(at - 1).is_some_and(|b| is_ident(*b))
93        && !bytes.get(at).is_some_and(|b| is_ident(*b))
94}
95
96fn symbol_at(symbols: &[ResolvedSymbol], byte_offset: u32) -> Option<&ResolvedSymbol> {
97    best_at(symbols, |s| s.span, |s| s.expr_span, byte_offset, true)
98}
99
100fn navigation_fact_at(
101    facts: &[NavigationFact],
102    byte_offset: u32,
103    allow_expr_fallback: bool,
104) -> Option<&NavigationFact> {
105    best_at(
106        facts,
107        |f| f.span,
108        |f| f.expr_span,
109        byte_offset,
110        allow_expr_fallback,
111    )
112}
113
114fn resolved_navigation_fact_at(
115    facts: &[ResolvedNavigationFact],
116    byte_offset: u32,
117) -> Option<&ResolvedNavigationFact> {
118    best_at(facts, |f| f.span, |f| f.expr_span, byte_offset, true)
119}
120
121fn for_each_navigation_scope<'a>(stmts: &'a [Stmt], f: &mut impl FnMut(&'a Stmt)) {
122    for stmt in stmts.iter() {
123        visit_navigation_scope_stmt(stmt, f);
124    }
125}
126
127fn best_navigation_scope_stmt(program: &Program, byte_offset: u32) -> Option<&Stmt> {
128    let mut best_stmt: Option<&Stmt> = None;
129    for_each_navigation_scope(&program.stmts, &mut |stmt| {
130        if !span_contains(navigation_scope_span(stmt), byte_offset) {
131            return;
132        }
133        if !matches!(
134            stmt.kind,
135            StmtKind::Function(_)
136                | StmtKind::Class(_)
137                | StmtKind::Enum(_)
138                | StmtKind::Interface(_)
139                | StmtKind::Trait(_)
140                | StmtKind::Use(_)
141        ) {
142            return;
143        }
144        if best_stmt.is_none_or(|best| {
145            span_len(navigation_scope_span(stmt)) <= span_len(navigation_scope_span(best))
146        }) {
147            best_stmt = Some(stmt);
148        }
149    });
150    best_stmt
151}
152
153fn navigation_scope_span(stmt: &Stmt) -> Span {
154    use php_ast::owned::StmtKind;
155
156    fn widen_with_comment_and_attrs(
157        span: Span,
158        doc_comment: Option<&php_ast::owned::Comment>,
159        attrs: &[php_ast::owned::Attribute],
160    ) -> Span {
161        let attr_start = attrs.first().map(|attr| attr.span.start);
162        let doc_start = doc_comment.map(|comment| comment.span.start);
163        let start = attr_start
164            .into_iter()
165            .chain(doc_start)
166            .min()
167            .unwrap_or(span.start);
168        Span::new(start, span.end)
169    }
170
171    match &stmt.kind {
172        StmtKind::Function(decl) => {
173            widen_with_comment_and_attrs(stmt.span, decl.doc_comment.as_ref(), &decl.attributes)
174        }
175        StmtKind::Class(decl) => {
176            widen_with_comment_and_attrs(stmt.span, decl.doc_comment.as_ref(), &decl.attributes)
177        }
178        StmtKind::Enum(decl) => {
179            widen_with_comment_and_attrs(stmt.span, decl.doc_comment.as_ref(), &decl.attributes)
180        }
181        StmtKind::Interface(decl) => {
182            widen_with_comment_and_attrs(stmt.span, decl.doc_comment.as_ref(), &decl.attributes)
183        }
184        StmtKind::Trait(decl) => {
185            widen_with_comment_and_attrs(stmt.span, decl.doc_comment.as_ref(), &decl.attributes)
186        }
187        _ => stmt.span,
188    }
189}
190
191fn visit_navigation_scope_stmt<'a>(stmt: &'a Stmt, f: &mut impl FnMut(&'a Stmt)) {
192    use php_ast::owned::NamespaceBody;
193    match &stmt.kind {
194        StmtKind::If(s) => {
195            visit_navigation_scope_stmt(&s.then_branch, f);
196            for branch in s.elseif_branches.iter() {
197                visit_navigation_scope_stmt(&branch.body, f);
198            }
199            if let Some(else_branch) = &s.else_branch {
200                visit_navigation_scope_stmt(else_branch, f);
201            }
202        }
203        StmtKind::While(s) => visit_navigation_scope_stmt(&s.body, f),
204        StmtKind::For(s) => visit_navigation_scope_stmt(&s.body, f),
205        StmtKind::Foreach(s) => visit_navigation_scope_stmt(&s.body, f),
206        StmtKind::DoWhile(s) => visit_navigation_scope_stmt(&s.body, f),
207        StmtKind::Switch(s) => {
208            for case in s.body.cases.iter() {
209                for inner in case.body.iter() {
210                    visit_navigation_scope_stmt(inner, f);
211                }
212            }
213        }
214        StmtKind::TryCatch(t) => {
215            for inner in t.body.stmts.iter() {
216                visit_navigation_scope_stmt(inner, f);
217            }
218            for catch in t.catches.iter() {
219                for inner in catch.body.stmts.iter() {
220                    visit_navigation_scope_stmt(inner, f);
221                }
222            }
223            if let Some(finally) = &t.finally {
224                for inner in finally.stmts.iter() {
225                    visit_navigation_scope_stmt(inner, f);
226                }
227            }
228        }
229        StmtKind::Block(b) => {
230            for inner in b.stmts.iter() {
231                visit_navigation_scope_stmt(inner, f);
232            }
233        }
234        StmtKind::Declare(d) => {
235            if let Some(body) = &d.body {
236                visit_navigation_scope_stmt(body, f);
237            }
238        }
239        StmtKind::Namespace(ns) => {
240            if let NamespaceBody::Braced(block) = &ns.body {
241                for inner in block.stmts.iter() {
242                    visit_navigation_scope_stmt(inner, f);
243                }
244            }
245        }
246        _ => f(stmt),
247    }
248}
249
250#[allow(clippy::too_many_arguments)]
251fn resolve_scope_symbols(
252    db: &dyn MirDatabase,
253    php_version: crate::PhpVersion,
254    file: Arc<str>,
255    source: &str,
256    program: &Program,
257    source_map: &SourceMap,
258    byte_offset: u32,
259    capture_symbol_types: bool,
260    codebase_symbols_only: bool,
261) -> Vec<ResolvedSymbol> {
262    let best_stmt = best_navigation_scope_stmt(program, byte_offset);
263
264    let mut driver = match best_stmt.map(|stmt| &stmt.kind) {
265        Some(
266            StmtKind::Function(_)
267            | StmtKind::Class(_)
268            | StmtKind::Enum(_)
269            | StmtKind::Interface(_)
270            | StmtKind::Trait(_),
271        ) => BodyAnalyzer::new_inference_only(db, php_version),
272        _ => BodyAnalyzer::new_inference_only(db, php_version),
273    };
274    driver.capture_symbol_types = capture_symbol_types;
275    driver.codebase_symbols_only = codebase_symbols_only;
276    driver.record_reference_locations = false;
277    let mut issues = Vec::new();
278    let mut symbols = Vec::new();
279    let guards: FxHashSet<Arc<str>> = FxHashSet::default();
280
281    match best_stmt.map(|stmt| &stmt.kind) {
282        Some(StmtKind::Function(decl)) => {
283            driver.analyze_fn_decl(
284                decl,
285                &file,
286                source,
287                source_map,
288                &mut issues,
289                Some(&mut symbols),
290            );
291        }
292        Some(StmtKind::Class(decl)) => {
293            driver.analyze_class_decl(
294                decl,
295                &file,
296                source,
297                source_map,
298                &mut issues,
299                Some(&mut symbols),
300                &guards,
301            );
302        }
303        Some(StmtKind::Enum(decl)) => {
304            driver.analyze_enum_decl(
305                decl,
306                &file,
307                source,
308                source_map,
309                &mut issues,
310                Some(&mut symbols),
311            );
312        }
313        Some(StmtKind::Interface(decl)) => {
314            driver.analyze_interface_decl(
315                decl,
316                &file,
317                source,
318                source_map,
319                &mut issues,
320                &guards,
321                Some(&mut symbols),
322            );
323        }
324        Some(StmtKind::Trait(decl)) => {
325            driver.analyze_trait_decl(
326                decl,
327                &file,
328                source,
329                source_map,
330                &mut issues,
331                Some(&mut symbols),
332            );
333        }
334        Some(StmtKind::Use(use_decl)) => {
335            let mut resolved_navigation_facts = Vec::new();
336            crate::body_analysis::check_use_decl_casing(
337                use_decl,
338                db,
339                &file,
340                source,
341                source_map,
342                &mut issues,
343                None,
344                None,
345                Some(&mut resolved_navigation_facts),
346                false,
347                true,
348                driver.record_reference_locations,
349            );
350            if let Some(fact) = resolved_navigation_fact_at(&resolved_navigation_facts, byte_offset)
351            {
352                return vec![fact.clone().into_resolved_symbol(file.clone())];
353            }
354        }
355        _ => {
356            driver.analyze_global_exec(
357                program,
358                &file,
359                source,
360                source_map,
361                &mut issues,
362                Some(&mut symbols),
363            );
364        }
365    }
366
367    symbols
368}
369
370/// Per-file body analysis bound to an [`AnalysisSession`]: runs the
371/// owner-side warm-up, then the read half on a fresh [`AnalysisSnapshot`].
372/// Cheap to construct — typically held transiently per analysis call.
373pub struct FileAnalyzer<'a> {
374    session: &'a mut AnalysisSession,
375}
376
377impl<'a> FileAnalyzer<'a> {
378    pub fn new(session: &'a mut AnalysisSession) -> Self {
379        Self { session }
380    }
381
382    /// Run a single body-analysis pass against a frozen db snapshot.
383    ///
384    /// `priority_index_for_ast` first indexes this file's direct class
385    /// references the background indexer hasn't reached; then one snapshot
386    /// is analyzed and its reference locations committed.
387    pub fn analyze(
388        &mut self,
389        file: Arc<str>,
390        source: &str,
391        program: &Program,
392        source_map: &SourceMap,
393    ) -> FileAnalysis {
394        self.analyze_with_symbols(file, source, program, source_map, true)
395    }
396
397    /// Run the open-file diagnostics path without retaining a whole-file
398    /// `ResolvedSymbol` list.
399    ///
400    /// This is the preferred hot path for editor diagnostics: body analysis
401    /// still walks the entire file and commits references, but per-expression
402    /// symbol payloads are skipped and navigation can use
403    /// [`AnalysisSession::symbol_at`] on demand.
404    pub fn analyze_diagnostics_only(
405        &mut self,
406        file: Arc<str>,
407        source: &str,
408        program: &Program,
409        source_map: &SourceMap,
410    ) -> FileAnalysis {
411        self.analyze_with_symbols(file, source, program, source_map, false)
412    }
413
414    fn analyze_with_symbols(
415        &mut self,
416        file: Arc<str>,
417        source: &str,
418        program: &Program,
419        source_map: &SourceMap,
420        collect_symbols: bool,
421    ) -> FileAnalysis {
422        // Reconcile mirror-only writes with the symbol-index singleton before
423        // resolution runs against it (no-op when nothing is pending).
424        self.session.settle_workspace_index();
425
426        // Capture (text, generation) before the warm-up: if the input text
427        // is swapped meanwhile, the stored Arc mismatches and the mark is
428        // dead on arrival — the safe direction.
429        let prepare_generation = self.session.prepare_generation_snapshot();
430        let ingested_text = {
431            let view = self.session.db_view();
432            let db = view.db();
433            db.lookup_source_file(file.as_ref())
434                .map(|sf| sf.text(db as &dyn crate::db::MirDatabase).clone())
435        };
436        self.session
437            .prepare_ast_for_analysis(program, file.as_ref());
438        // Record the warm-up so later Phase-1 sweeps (references, dependent
439        // re-analysis) skip this file's parse + AST walk while its salsa
440        // input text is unchanged.
441        if let Some(text) = ingested_text {
442            self.session
443                .mark_prepared_for_analysis(&file, text, prepare_generation);
444        }
445
446        self.session.query_snapshot(|snap| {
447            snap.analyze_with_symbols(file.clone(), source, program, source_map, collect_symbols)
448        })
449    }
450
451    /// Resolve the symbol at `byte_offset` in the session's current text for
452    /// `file` by analyzing only the containing scope.
453    ///
454    /// This is the targeted navigation path for open files: it warms direct
455    /// references, snapshots the current ingested text/AST, selects the
456    /// smallest containing file-scope declaration (or `use` item / top-level
457    /// exec region), and runs symbol recording only for that scope.
458    pub(crate) fn symbol_at(&mut self, file: Arc<str>, byte_offset: u32) -> Option<ResolvedSymbol> {
459        self.session.prepare_for_query(Some(&file));
460        self.session
461            .query_snapshot(|snap| snap.symbol_at(&file, byte_offset))
462    }
463
464    pub(crate) fn name_at(&mut self, file: Arc<str>, byte_offset: u32) -> Option<crate::Name> {
465        self.session.prepare_for_query(Some(&file));
466        self.session
467            .query_snapshot(|snap| snap.name_at(&file, byte_offset))
468    }
469
470    pub(crate) fn outgoing_calls(
471        &mut self,
472        file: Arc<str>,
473        byte_offset: u32,
474    ) -> Vec<(crate::Name, crate::Range)> {
475        self.session.prepare_for_query(Some(&file));
476        self.session
477            .query_snapshot(|snap| snap.outgoing_calls(&file, byte_offset))
478    }
479}
480
481impl AnalysisSnapshot {
482    /// Body-analyze `file` and commit its reference locations — the read half
483    /// of [`FileAnalyzer::analyze`]. The owner should have run
484    /// [`AnalysisSession::prepare_for_query`] for `file` (or ingested it via
485    /// [`AnalysisSession::ingest_file_prepared`]): classes load on demand
486    /// regardless, but Composer `autoload.files` functions are indexed only
487    /// by that warm-up. `file` must already be ingested with `source` as its
488    /// text; the warm-up reads its input, so a file never ingested is not
489    /// warmed.
490    pub fn analyze(
491        &self,
492        file: Arc<str>,
493        source: &str,
494        program: &Program,
495        source_map: &SourceMap,
496    ) -> Result<FileAnalysis, salsa::Cancelled> {
497        self.analyze_with_symbols(file, source, program, source_map, true)
498    }
499
500    /// [`Self::analyze`] without the whole-file `ResolvedSymbol` list; see
501    /// [`FileAnalyzer::analyze_diagnostics_only`].
502    pub fn analyze_diagnostics_only(
503        &self,
504        file: Arc<str>,
505        source: &str,
506        program: &Program,
507        source_map: &SourceMap,
508    ) -> Result<FileAnalysis, salsa::Cancelled> {
509        self.analyze_with_symbols(file, source, program, source_map, false)
510    }
511
512    fn analyze_with_symbols(
513        &self,
514        file: Arc<str>,
515        source: &str,
516        program: &Program,
517        source_map: &SourceMap,
518        collect_symbols: bool,
519    ) -> Result<FileAnalysis, salsa::Cancelled> {
520        let (analysis, db, text) = salsa::Cancelled::catch(std::panic::AssertUnwindSafe(|| {
521            crate::metrics::record_file_analysis();
522            let _scope = crate::metrics::BodyAnalysisScope::new();
523            // A pass-local clone: its pending reference locations start empty
524            // even if an earlier query on this snapshot was cancelled midway.
525            let db = self.db().clone();
526            // Marking freshness against the snapshot's text keeps the mark
527            // dead on arrival if the owner has since swapped the input.
528            let text = db
529                .lookup_source_file(file.as_ref())
530                .map(|sf| sf.text(&db as &dyn MirDatabase).clone());
531            let mut driver = BodyAnalyzer::new(&db, self.php_version());
532            driver.collect_symbols = collect_symbols;
533            let (issues, symbols) =
534                driver.analyze_bodies(program, file.clone(), source, source_map);
535            (FileAnalysis { issues, symbols }, db, text)
536        }))?;
537        // Replace (not append): this pass produced the file's complete
538        // reference set.
539        let resolved = !crate::db::issues_have_unresolved_names(&analysis.issues);
540        self.index().commit_file_refs(
541            &db,
542            &file,
543            text,
544            db.take_pending_ref_locs(),
545            resolved,
546            self.stamp(),
547        )?;
548        Ok(analysis)
549    }
550
551    /// The symbol at `byte_offset` in `file`, found by analyzing only the
552    /// containing scope — the read half of [`AnalysisSession::symbol_at`].
553    /// Expects the owner to have prepared `file`
554    /// ([`AnalysisSession::prepare_for_query`]).
555    pub fn symbol_at(
556        &self,
557        file: &Arc<str>,
558        byte_offset: u32,
559    ) -> Result<Option<ResolvedSymbol>, salsa::Cancelled> {
560        let started = std::time::Instant::now();
561        let sym =
562            self.read(|db| resolve_symbol_at(db, self.php_version(), file, byte_offset, true));
563        crate::metrics::record_symbol_at(started.elapsed().as_micros() as u64);
564        sym
565    }
566
567    /// The codebase-level symbol name at `byte_offset` in `file` — the read
568    /// half of [`AnalysisSession::name_at`]. Same preparation contract as
569    /// [`Self::symbol_at`].
570    pub fn name_at(
571        &self,
572        file: &Arc<str>,
573        byte_offset: u32,
574    ) -> Result<Option<crate::Name>, salsa::Cancelled> {
575        let started = std::time::Instant::now();
576        let name = self.read(|db| resolve_name_at(db, self.php_version(), file, byte_offset));
577        crate::metrics::record_name_at(started.elapsed().as_micros() as u64);
578        name
579    }
580
581    /// Calls made inside the function or method whose declaration contains
582    /// `byte_offset` — the read half of [`AnalysisSession::outgoing_calls`].
583    /// Same preparation contract as [`Self::symbol_at`].
584    pub fn outgoing_calls(
585        &self,
586        file: &Arc<str>,
587        byte_offset: u32,
588    ) -> Result<Vec<(crate::Name, crate::Range)>, salsa::Cancelled> {
589        self.read(|db| resolve_outgoing_calls(db, self.php_version(), file, byte_offset))
590    }
591}
592
593fn resolve_name_at(
594    db: &dyn MirDatabase,
595    php_version: crate::PhpVersion,
596    file: &Arc<str>,
597    byte_offset: u32,
598) -> Option<crate::Name> {
599    let sf = db.lookup_source_file(file.as_ref())?;
600    let prepared = crate::db::prepare_analysis_file(db, sf);
601    let parsed = prepared.parse_result();
602    if let Some(name) = resolve_name_at_via_compact_facts(
603        db,
604        php_version,
605        file,
606        prepared.text.as_ref(),
607        &parsed.program,
608        &parsed.source_map,
609        byte_offset,
610    ) {
611        crate::metrics::record_name_at_compact_hit();
612        db.note_work(crate::db::Work::NameAtCompact, 1);
613        return Some(name);
614    }
615    crate::metrics::record_name_at_fallback_walk();
616    db.note_work(crate::db::Work::NameAtFallback, 1);
617    let symbols = resolve_scope_symbols(
618        db,
619        php_version,
620        file.clone(),
621        prepared.text.as_ref(),
622        &parsed.program,
623        &parsed.source_map,
624        byte_offset,
625        false,
626        true,
627    );
628    // Codebase-only symbols omit variables, so an enclosing call's span must
629    // not claim a cursor sitting right after one.
630    let allow_expr_fallback = !ends_identifier(prepared.text.as_ref(), byte_offset);
631    best_at(
632        &symbols,
633        |s| s.span,
634        |s| s.expr_span,
635        byte_offset,
636        allow_expr_fallback,
637    )
638    .and_then(ResolvedSymbol::to_symbol)
639}
640
641/// Body span of the function or method whose declaration contains `byte_offset`.
642fn enclosing_callable_body(stmt: &Stmt, byte_offset: u32) -> Option<Span> {
643    use php_ast::owned::EnumMemberKind;
644
645    let body_span = |body: &Option<Box<php_ast::owned::Block>>| body.as_ref().map(|b| b.span);
646    match &stmt.kind {
647        StmtKind::Function(decl) => Some(decl.body.span),
648        StmtKind::Class(decl) => class_member_body(&decl.body.members, byte_offset),
649        StmtKind::Trait(decl) => class_member_body(&decl.body.members, byte_offset),
650        StmtKind::Enum(decl) => decl
651            .body
652            .members
653            .iter()
654            .filter(|m| span_contains(m.span, byte_offset))
655            .find_map(|m| match &m.kind {
656                EnumMemberKind::Method(method) => body_span(&method.body),
657                _ => None,
658            }),
659        _ => None,
660    }
661}
662
663fn class_member_body(members: &[php_ast::owned::ClassMember], byte_offset: u32) -> Option<Span> {
664    use php_ast::owned::ClassMemberKind;
665
666    members
667        .iter()
668        .filter(|m| span_contains(m.span, byte_offset))
669        .find_map(|m| match &m.kind {
670            ClassMemberKind::Method(method) => method.body.as_ref().map(|b| b.span),
671            _ => None,
672        })
673}
674
675fn resolve_outgoing_calls(
676    db: &dyn MirDatabase,
677    php_version: crate::PhpVersion,
678    file: &Arc<str>,
679    byte_offset: u32,
680) -> Vec<(crate::Name, crate::Range)> {
681    let Some(sf) = db.lookup_source_file(file.as_ref()) else {
682        return Vec::new();
683    };
684    let prepared = crate::db::prepare_analysis_file(db, sf);
685    let parsed = prepared.parse_result();
686    let source = prepared.text.as_ref();
687    let Some(stmt) = best_navigation_scope_stmt(&parsed.program, byte_offset) else {
688        return Vec::new();
689    };
690    // The body excludes the declaration name, which is itself a navigation fact.
691    let Some(body) = enclosing_callable_body(stmt, byte_offset) else {
692        return Vec::new();
693    };
694
695    let mut driver = BodyAnalyzer::new_inference_only(db, php_version);
696    driver.collect_symbols = false;
697    driver.capture_symbol_types = false;
698    driver.codebase_symbols_only = true;
699    driver.record_reference_locations = false;
700    driver.collect_navigation_facts = true;
701    let mut issues = Vec::new();
702    let guards: FxHashSet<Arc<str>> = FxHashSet::default();
703    let source_map = &parsed.source_map;
704    match &stmt.kind {
705        StmtKind::Function(decl) => {
706            driver.analyze_fn_decl(decl, file, source, source_map, &mut issues, None)
707        }
708        StmtKind::Class(decl) => {
709            driver.analyze_class_decl(decl, file, source, source_map, &mut issues, None, &guards)
710        }
711        StmtKind::Trait(decl) => {
712            driver.analyze_trait_decl(decl, file, source, source_map, &mut issues, None)
713        }
714        StmtKind::Enum(decl) => {
715            driver.analyze_enum_decl(decl, file, source, source_map, &mut issues, None)
716        }
717        _ => return Vec::new(),
718    }
719
720    let position = |offset: u32| {
721        let (line, column) = crate::diagnostics::offset_to_line_col(source, offset, source_map);
722        crate::Position {
723            line,
724            column: u32::from(column),
725        }
726    };
727    let mut facts: Vec<NavigationFact> = driver
728        .take_navigation_facts()
729        .into_iter()
730        .filter(|fact| {
731            matches!(
732                fact.name,
733                crate::Name::Method { .. } | crate::Name::Function(_)
734            ) && fact.span.start >= body.start
735                && fact.span.end <= body.end
736        })
737        .collect();
738    facts.sort_by_key(|fact| fact.span.start);
739    facts
740        .into_iter()
741        .map(|fact| {
742            let range = crate::Range {
743                start: position(fact.span.start),
744                end: position(fact.span.end),
745            };
746            (resolve_trait_alias(db, fact.name), range)
747        })
748        .collect()
749}
750
751/// Maps a call to a `use Trait { orig as alias; }` alias onto the trait's
752/// original method; any other name passes through.
753fn resolve_trait_alias(db: &dyn MirDatabase, name: crate::Name) -> crate::Name {
754    let crate::Name::Method {
755        class,
756        name: method,
757    } = &name
758    else {
759        return name;
760    };
761    let here = crate::db::Fqcn::from_str(db, class.as_ref());
762    let Some(crate::db::ClassLike::Class(cls)) = crate::db::find_class_like(db, here) else {
763        return name;
764    };
765    let Some((trait_name, orig_method, _, _)) = cls.trait_aliases.get(method.as_ref()) else {
766        return name;
767    };
768    let candidates: Vec<Arc<str>> = trait_name
769        .as_ref()
770        .map(|t| vec![t.clone()])
771        .unwrap_or_else(|| cls.traits.clone());
772    candidates
773        .into_iter()
774        .find(|trait_fqcn| {
775            let trait_ = crate::db::Fqcn::from_str(db, trait_fqcn.as_ref());
776            crate::db::find_method_in_class(db, trait_, orig_method).is_some()
777        })
778        .map(|trait_fqcn| crate::Name::method(trait_fqcn, orig_method))
779        .unwrap_or(name)
780}
781
782fn resolve_symbol_at(
783    db: &dyn MirDatabase,
784    php_version: crate::PhpVersion,
785    file: &Arc<str>,
786    byte_offset: u32,
787    capture_symbol_types: bool,
788) -> Option<ResolvedSymbol> {
789    let sf = db.lookup_source_file(file.as_ref())?;
790    let prepared = crate::db::prepare_analysis_file(db, sf);
791    let parsed = prepared.parse_result();
792    if let Some(symbol) = symbol_at_via_compact_facts(
793        db,
794        php_version,
795        file,
796        prepared.text.as_ref(),
797        &parsed.program,
798        &parsed.source_map,
799        byte_offset,
800        capture_symbol_types,
801    ) {
802        crate::metrics::record_symbol_at_compact_hit();
803        db.note_work(crate::db::Work::SymbolAtCompact, 1);
804        return Some(symbol);
805    }
806    crate::metrics::record_symbol_at_fallback_walk();
807    db.note_work(crate::db::Work::SymbolAtFallback, 1);
808    let symbols = resolve_scope_symbols(
809        db,
810        php_version,
811        file.clone(),
812        prepared.text.as_ref(),
813        &parsed.program,
814        &parsed.source_map,
815        byte_offset,
816        capture_symbol_types,
817        false,
818    );
819    symbol_at(&symbols, byte_offset).cloned()
820}
821
822fn resolve_name_at_via_compact_facts(
823    db: &dyn MirDatabase,
824    php_version: crate::PhpVersion,
825    file: &Arc<str>,
826    source: &str,
827    program: &Program,
828    source_map: &SourceMap,
829    byte_offset: u32,
830) -> Option<crate::Name> {
831    let best_stmt = best_navigation_scope_stmt(program, byte_offset);
832    let mut issues = Vec::new();
833    let guards: FxHashSet<Arc<str>> = FxHashSet::default();
834    let mut driver = match best_stmt.map(|stmt| &stmt.kind) {
835        Some(
836            StmtKind::Function(_)
837            | StmtKind::Class(_)
838            | StmtKind::Enum(_)
839            | StmtKind::Interface(_)
840            | StmtKind::Trait(_),
841        ) => BodyAnalyzer::new_inference_only(db, php_version),
842        _ => BodyAnalyzer::new_inference_only(db, php_version),
843    };
844    driver.collect_symbols = false;
845    driver.capture_symbol_types = false;
846    driver.codebase_symbols_only = true;
847    driver.record_reference_locations = false;
848    driver.collect_navigation_facts = true;
849
850    match best_stmt.map(|stmt| &stmt.kind) {
851        Some(StmtKind::Function(decl)) => {
852            driver.analyze_fn_decl(decl, file, source, source_map, &mut issues, None);
853        }
854        Some(StmtKind::Class(decl)) => {
855            driver.analyze_class_decl(decl, file, source, source_map, &mut issues, None, &guards);
856        }
857        Some(StmtKind::Enum(decl)) => {
858            driver.analyze_enum_decl(decl, file, source, source_map, &mut issues, None);
859        }
860        Some(StmtKind::Interface(decl)) => {
861            driver.analyze_interface_decl(
862                decl,
863                file,
864                source,
865                source_map,
866                &mut issues,
867                &guards,
868                None,
869            );
870        }
871        Some(StmtKind::Trait(decl)) => {
872            driver.analyze_trait_decl(decl, file, source, source_map, &mut issues, None);
873        }
874        Some(StmtKind::Use(use_decl)) => {
875            let mut navigation_facts = Vec::new();
876            crate::body_analysis::check_use_decl_casing(
877                use_decl,
878                db,
879                file,
880                source,
881                source_map,
882                &mut issues,
883                None,
884                Some(&mut navigation_facts),
885                None,
886                false,
887                true,
888                false,
889            );
890            return navigation_fact_at(&navigation_facts, byte_offset, true)
891                .map(|fact| fact.name.clone());
892        }
893        _ => {
894            driver.analyze_global_exec(program, file, source, source_map, &mut issues, None);
895        }
896    }
897
898    let facts = driver.take_navigation_facts();
899    // A variable just before the cursor isn't a navigation fact, so the
900    // enclosing call's span must not claim it.
901    let allow_expr_fallback = !ends_identifier(source, byte_offset);
902    navigation_fact_at(&facts, byte_offset, allow_expr_fallback).map(|fact| fact.name.clone())
903}
904
905#[allow(clippy::too_many_arguments)]
906fn symbol_at_via_compact_facts(
907    db: &dyn MirDatabase,
908    php_version: crate::PhpVersion,
909    file: &Arc<str>,
910    source: &str,
911    program: &Program,
912    source_map: &SourceMap,
913    byte_offset: u32,
914    capture_symbol_types: bool,
915) -> Option<ResolvedSymbol> {
916    let mut issues = Vec::new();
917    let guards: FxHashSet<Arc<str>> = FxHashSet::default();
918    let best_stmt = best_navigation_scope_stmt(program, byte_offset);
919    let mut driver = match best_stmt.map(|stmt| &stmt.kind) {
920        Some(
921            StmtKind::Function(_)
922            | StmtKind::Class(_)
923            | StmtKind::Enum(_)
924            | StmtKind::Interface(_)
925            | StmtKind::Trait(_),
926        ) => BodyAnalyzer::new_inference_only(db, php_version),
927        _ => BodyAnalyzer::new_inference_only(db, php_version),
928    };
929    driver.collect_symbols = false;
930    driver.capture_symbol_types = capture_symbol_types;
931    driver.codebase_symbols_only = false;
932    driver.record_reference_locations = false;
933    driver.collect_navigation_facts = false;
934    driver.collect_resolved_navigation_facts = true;
935
936    match best_stmt.map(|stmt| &stmt.kind) {
937        Some(StmtKind::Function(decl)) => {
938            driver.analyze_fn_decl(decl, file, source, source_map, &mut issues, None);
939        }
940        Some(StmtKind::Class(decl)) => {
941            driver.analyze_class_decl(decl, file, source, source_map, &mut issues, None, &guards);
942        }
943        Some(StmtKind::Enum(decl)) => {
944            driver.analyze_enum_decl(decl, file, source, source_map, &mut issues, None);
945        }
946        Some(StmtKind::Interface(decl)) => {
947            driver.analyze_interface_decl(
948                decl,
949                file,
950                source,
951                source_map,
952                &mut issues,
953                &guards,
954                None,
955            );
956        }
957        Some(StmtKind::Trait(decl)) => {
958            driver.analyze_trait_decl(decl, file, source, source_map, &mut issues, None);
959        }
960        Some(StmtKind::Use(use_decl)) => {
961            let mut resolved_navigation_facts = Vec::new();
962            crate::body_analysis::check_use_decl_casing(
963                use_decl,
964                db,
965                file,
966                source,
967                source_map,
968                &mut issues,
969                None,
970                None,
971                Some(&mut resolved_navigation_facts),
972                false,
973                true,
974                false,
975            );
976            if let Some(fact) = resolved_navigation_fact_at(&resolved_navigation_facts, byte_offset)
977            {
978                return Some(fact.clone().into_resolved_symbol(file.clone()));
979            }
980        }
981        _ => {
982            driver.analyze_global_exec(program, file, source, source_map, &mut issues, None);
983        }
984    }
985
986    let facts = driver.take_resolved_navigation_facts();
987    resolved_navigation_fact_at(&facts, byte_offset)
988        .cloned()
989        .map(|fact| fact.into_resolved_symbol(file.clone()))
990}
991
992#[cfg(test)]
993mod tests {
994    use std::sync::Arc;
995    use std::time::{Duration, Instant};
996
997    use super::FileAnalyzer;
998    use crate::db::Work;
999    use crate::{symbol::ReferenceKind, AnalysisSession, PhpVersion, ResolvedSymbol};
1000
1001    fn session_for_source(path: &str, source: &str) -> (AnalysisSession, Arc<str>) {
1002        let mut session = AnalysisSession::new(PhpVersion::LATEST);
1003        let file: Arc<str> = Arc::from(path);
1004        session.ingest_file(file.clone(), Arc::from(source));
1005        (session, file)
1006    }
1007
1008    fn analyze_diagnostics_only(session: &mut AnalysisSession, file: Arc<str>, source: &str) {
1009        let parsed = php_rs_parser::parse(source);
1010        assert!(
1011            parsed.errors.is_empty(),
1012            "parser errors in test source: {:?}",
1013            parsed.errors
1014        );
1015
1016        let analysis = FileAnalyzer::new(session).analyze_diagnostics_only(
1017            file,
1018            source,
1019            &parsed.program,
1020            &parsed.source_map,
1021        );
1022        assert!(
1023            analysis.symbols.is_empty(),
1024            "diagnostics-only analysis should not retain whole-file symbols"
1025        );
1026    }
1027
1028    fn median(samples: &mut [Duration]) -> Duration {
1029        samples.sort_unstable();
1030        samples[samples.len() / 2]
1031    }
1032
1033    fn metric_section_lines<'a>(dump: &'a str, label: &str) -> Vec<&'a str> {
1034        let Some((_, rest)) = dump.split_once(label) else {
1035            return Vec::new();
1036        };
1037        rest.lines()
1038            .skip(1)
1039            .take_while(|line| line.starts_with("    "))
1040            .collect()
1041    }
1042
1043    fn assert_symbol_at_compact_without_fallback(
1044        session: &mut AnalysisSession,
1045        file: &Arc<str>,
1046        offset: u32,
1047        expected: impl FnOnce(&ResolvedSymbol),
1048    ) {
1049        let before = |work| session.work_count(work);
1050        let (compact, fallback, symbols) = (
1051            before(Work::SymbolAtCompact),
1052            before(Work::SymbolAtFallback),
1053            before(Work::SymbolAllocated),
1054        );
1055        let symbol = session
1056            .symbol_at(file.as_ref(), offset)
1057            .expect("symbol_at should find a symbol at the chosen offset");
1058        expected(&symbol);
1059
1060        assert_eq!(session.work_count(Work::SymbolAtCompact) - compact, 1);
1061        assert_eq!(session.work_count(Work::SymbolAtFallback) - fallback, 0);
1062        assert_eq!(
1063            session.work_count(Work::SymbolAllocated) - symbols,
1064            0,
1065            "compact symbol_at should not allocate legacy symbols"
1066        );
1067    }
1068
1069    #[test]
1070    fn name_at_records_compact_path_without_fallback() {
1071        let src = "<?php\nfunction helper(): void {}\nfunction caller(): void { helper(); }\n";
1072        let (mut session, file) = session_for_source("/proj/name_at_metrics.php", src);
1073        analyze_diagnostics_only(&mut session, file.clone(), src);
1074
1075        let offset = src.rfind("helper();").unwrap() as u32;
1076        let name = session
1077            .name_at(file.as_ref(), offset)
1078            .expect("name_at should resolve helper()");
1079
1080        assert_eq!(name, crate::Name::function("helper"));
1081
1082        assert_eq!(session.work_count(Work::NameAtCompact), 1);
1083        assert_eq!(session.work_count(Work::NameAtFallback), 0);
1084        assert_eq!(
1085            session.work_count(Work::SymbolAllocated),
1086            0,
1087            "compact name_at should not allocate legacy symbols"
1088        );
1089    }
1090
1091    #[test]
1092    fn name_at_top_level_exec_uses_compact_path_without_fallback() {
1093        let src = "<?php\nfunction helper(): void {}\nhelper();\n";
1094        let (mut session, file) = session_for_source("/proj/name_at_top_level_metrics.php", src);
1095        analyze_diagnostics_only(&mut session, file.clone(), src);
1096
1097        let offset = src.find("helper();").unwrap() as u32;
1098        let name = session
1099            .name_at(file.as_ref(), offset)
1100            .expect("name_at should resolve top-level helper()");
1101
1102        assert_eq!(name, crate::Name::function("helper"));
1103
1104        assert_eq!(session.work_count(Work::NameAtCompact), 1);
1105        assert_eq!(session.work_count(Work::NameAtFallback), 0);
1106        assert_eq!(
1107            session.work_count(Work::SymbolAllocated),
1108            0,
1109            "compact top-level name_at should not allocate legacy symbols"
1110        );
1111    }
1112
1113    #[test]
1114    fn symbol_at_records_compact_path_without_fallback() {
1115        let src = "<?php\nclass Box { public int $value = 0; }\nfunction read(Box $box): void { $box->value; }\n";
1116        let (mut session, file) = session_for_source("/proj/symbol_at_metrics.php", src);
1117        analyze_diagnostics_only(&mut session, file.clone(), src);
1118
1119        let offset = src.find("$box->value").unwrap() as u32 + "$box".len() as u32;
1120        assert_symbol_at_compact_without_fallback(&mut session, &file, offset, |symbol| {
1121            assert!(matches!(symbol.kind, ReferenceKind::Receiver));
1122        });
1123    }
1124
1125    #[test]
1126    fn symbol_at_top_level_exec_uses_compact_path_without_fallback() {
1127        let src = "<?php\nfunction helper(): void {}\nhelper();\n";
1128        let (mut session, file) = session_for_source("/proj/resolve_top_level_metrics.php", src);
1129        analyze_diagnostics_only(&mut session, file.clone(), src);
1130
1131        let offset = src.find("helper();").unwrap() as u32;
1132        assert_symbol_at_compact_without_fallback(&mut session, &file, offset, |symbol| {
1133            assert!(matches!(
1134                &symbol.kind,
1135                ReferenceKind::FunctionCall(name) if name.as_ref() == "helper"
1136            ));
1137        });
1138    }
1139
1140    #[test]
1141    fn symbol_at_method_call_uses_compact_path_without_fallback() {
1142        let src = "<?php\nclass Dep { public function next(): int { return 1; } }\nfunction run(Dep $dep): int { return $dep->next(); }\n";
1143        let (mut session, file) = session_for_source("/proj/resolve_method_metrics.php", src);
1144        analyze_diagnostics_only(&mut session, file.clone(), src);
1145
1146        let offset = src.find("->next()").unwrap() as u32 + 2;
1147        assert_symbol_at_compact_without_fallback(&mut session, &file, offset, |symbol| {
1148            assert!(matches!(
1149                &symbol.kind,
1150                ReferenceKind::MethodCall { class, method }
1151                    if class.as_ref() == "Dep" && method.as_ref() == "next"
1152            ));
1153        });
1154    }
1155
1156    #[test]
1157    fn symbol_at_type_hint_uses_compact_path_without_fallback() {
1158        let src = "<?php\nclass Dep {}\nfunction run(Dep $dep): Dep { return $dep; }\n";
1159        let (mut session, file) = session_for_source("/proj/resolve_type_hint_metrics.php", src);
1160        analyze_diagnostics_only(&mut session, file.clone(), src);
1161
1162        let offset = src.find("run(Dep").unwrap() as u32 + "run(".len() as u32;
1163        assert_symbol_at_compact_without_fallback(&mut session, &file, offset, |symbol| {
1164            assert!(matches!(
1165                &symbol.kind,
1166                ReferenceKind::ClassReference(name) if name.as_ref() == "Dep"
1167            ));
1168        });
1169    }
1170
1171    #[test]
1172    fn symbol_at_use_import_uses_compact_path_without_fallback() {
1173        let mut session = AnalysisSession::new(PhpVersion::LATEST);
1174        let dep_file: Arc<str> = Arc::from("/proj/Dep.php");
1175        let main_file: Arc<str> = Arc::from("/proj/Main.php");
1176        let dep_src = "<?php\nnamespace App;\nclass Dep {}\n";
1177        let main_src = "<?php\nuse App\\Dep;\nfunction run(): Dep { return new Dep(); }\n";
1178        session.ingest_file(dep_file, Arc::from(dep_src));
1179        session.ingest_file(main_file.clone(), Arc::from(main_src));
1180        analyze_diagnostics_only(&mut session, main_file.clone(), main_src);
1181
1182        let offset = main_src.find("App\\Dep").unwrap() as u32 + "App\\".len() as u32;
1183        assert_symbol_at_compact_without_fallback(&mut session, &main_file, offset, |symbol| {
1184            assert!(matches!(
1185                &symbol.kind,
1186                ReferenceKind::UseImport(inner)
1187                    if matches!(
1188                        inner.as_ref(),
1189                        ReferenceKind::ClassReference(name) if name.as_ref() == "App\\Dep"
1190                    )
1191            ));
1192        });
1193    }
1194
1195    #[test]
1196    fn symbol_at_catch_clause_type_uses_compact_path_without_fallback() {
1197        let src = "<?php\nfinal class MyException extends \\Exception {}\nfunction run(): void {\n    try {\n        throw new MyException();\n    } catch (MyException $e) {\n    }\n}\n";
1198        let (mut session, file) = session_for_source("/proj/resolve_catch_metrics.php", src);
1199        analyze_diagnostics_only(&mut session, file.clone(), src);
1200
1201        let offset = src.rfind("catch (MyException").unwrap() as u32 + "catch (".len() as u32;
1202        assert_symbol_at_compact_without_fallback(&mut session, &file, offset, |symbol| {
1203            assert!(matches!(
1204                &symbol.kind,
1205                ReferenceKind::ClassReference(name) if name.as_ref() == "MyException"
1206            ));
1207        });
1208    }
1209
1210    #[test]
1211    fn symbol_at_static_property_write_class_uses_compact_path_without_fallback() {
1212        let src = "<?php\nclass Counter {\n    public static int $count = 0;\n}\nfunction bump(): void {\n    Counter::$count = Counter::$count + 1;\n}\n";
1213        let (mut session, file) = session_for_source("/proj/resolve_static_prop_metrics.php", src);
1214        analyze_diagnostics_only(&mut session, file.clone(), src);
1215
1216        let offset = src.find("Counter::$count =").unwrap() as u32;
1217        assert_symbol_at_compact_without_fallback(&mut session, &file, offset, |symbol| {
1218            assert!(matches!(
1219                &symbol.kind,
1220                ReferenceKind::ClassReference(name) if name.as_ref() == "Counter"
1221            ));
1222        });
1223    }
1224
1225    #[test]
1226    fn definition_uses_name_at_compact_path_without_fallback() {
1227        let src = "<?php\nclass Dep {}\nfunction run(Dep $dep): Dep { return $dep; }\n";
1228        let (mut session, file) = session_for_source("/proj/definition_metrics.php", src);
1229        analyze_diagnostics_only(&mut session, file.clone(), src);
1230
1231        let offset = src.find("run(Dep").unwrap() as u32 + "run(".len() as u32;
1232        let name = session
1233            .name_at(file.as_ref(), offset)
1234            .expect("name_at should resolve the Dep type hint");
1235        let loc = session
1236            .definition_of_cached(&name)
1237            .expect("Dep should have a declaration");
1238
1239        assert_eq!(loc.file.as_ref(), file.as_ref());
1240
1241        assert_eq!(session.work_count(Work::NameAtCompact), 1);
1242        assert_eq!(session.work_count(Work::NameAtFallback), 0);
1243        assert_eq!(
1244            session.work_count(Work::SymbolAtCompact) + session.work_count(Work::SymbolAtFallback),
1245            0,
1246            "definition lookup should not rely on symbol_at"
1247        );
1248    }
1249
1250    #[test]
1251    fn references_use_name_at_compact_path_without_fallback() {
1252        let src = "<?php\nfunction helper(): void {}\nfunction caller(): void { helper(); }\n";
1253        let (mut session, file) = session_for_source("/proj/references_metrics.php", src);
1254        analyze_diagnostics_only(&mut session, file.clone(), src);
1255
1256        let offset = src.find("helper();").unwrap() as u32 + 1;
1257        let name = session
1258            .name_at(file.as_ref(), offset)
1259            .expect("name_at should resolve helper()");
1260        let refs = session
1261            .indexed_references_to(
1262                &name,
1263                std::slice::from_ref(&file),
1264                false,
1265                crate::ReferenceIncludes::Plain,
1266                &|| false,
1267            )
1268            .expect("uncancelled references query");
1269
1270        assert!(
1271            refs.iter().any(|(f, _)| f.as_ref() == file.as_ref()),
1272            "references should include the helper() call site; got {refs:?}"
1273        );
1274
1275        assert_eq!(session.work_count(Work::NameAtCompact), 1);
1276        assert_eq!(session.work_count(Work::NameAtFallback), 0);
1277        assert_eq!(
1278            session.work_count(Work::SymbolAtCompact) + session.work_count(Work::SymbolAtFallback),
1279            0,
1280            "references lookup should not rely on symbol_at"
1281        );
1282    }
1283
1284    #[test]
1285    #[ignore]
1286    fn perf_report_post_diagnostics_navigation_micro() {
1287        crate::metrics::test_reset();
1288
1289        let src = "<?php
1290class Dep {
1291    public function next(int $value): int { return $value + 1; }
1292}
1293class Box {
1294    public int $value = 0;
1295}
1296function helper(Dep $dep, int $value): int {
1297    return $dep->next($value);
1298}
1299function read_value(int $value): int {
1300    return $value;
1301}
1302function read_box(Box $box): int {
1303    return $box->value;
1304}
1305";
1306        let (mut session, file) = session_for_source("/proj/perf_nav.php", src);
1307        let mut import_session = AnalysisSession::new(PhpVersion::LATEST);
1308        let dep_file: Arc<str> = Arc::from("/proj/import/Dep.php");
1309        let main_file: Arc<str> = Arc::from("/proj/import/Main.php");
1310        let dep_src = "<?php\nnamespace App;\nclass Dep {}\n";
1311        let main_src = "<?php\nuse App\\Dep;\nfunction run(): Dep { return new Dep(); }\n";
1312        import_session.ingest_file(dep_file, Arc::from(dep_src));
1313        import_session.ingest_file(main_file.clone(), Arc::from(main_src));
1314
1315        let parsed = php_rs_parser::parse(src);
1316        assert!(parsed.errors.is_empty(), "fixture must parse cleanly");
1317        let main_parsed = php_rs_parser::parse(main_src);
1318        assert!(
1319            main_parsed.errors.is_empty(),
1320            "import fixture must parse cleanly"
1321        );
1322
1323        let t0 = Instant::now();
1324        let analysis = FileAnalyzer::new(&mut session).analyze_diagnostics_only(
1325            file.clone(),
1326            src,
1327            &parsed.program,
1328            &parsed.source_map,
1329        );
1330        let same_file_diagnostics_time = t0.elapsed();
1331        assert!(analysis.symbols.is_empty());
1332
1333        let t0 = Instant::now();
1334        let main_analysis = FileAnalyzer::new(&mut import_session).analyze_diagnostics_only(
1335            main_file.clone(),
1336            main_src,
1337            &main_parsed.program,
1338            &main_parsed.source_map,
1339        );
1340        let import_diagnostics_time = t0.elapsed();
1341        assert!(main_analysis.symbols.is_empty());
1342
1343        let method_name_offset = src.find("next($value)").unwrap() as u32;
1344        let method_resolve_offset = src.find("->next($value)").unwrap() as u32 + 2;
1345        let variable_resolve_offset =
1346            src.rfind("return $value;").unwrap() as u32 + "return ".len() as u32 + 1;
1347        let type_hint_offset = src.find("helper(Dep").unwrap() as u32 + "helper(".len() as u32;
1348        let receiver_gap_offset = src.find("$box->value").unwrap() as u32 + "$box".len() as u32;
1349        let import_resolve_offset =
1350            main_src.find("App\\Dep").unwrap() as u32 + "App\\".len() as u32;
1351        let definition_offset = src.find("Dep $dep").unwrap() as u32;
1352        let references_offset = method_name_offset;
1353
1354        let t0 = Instant::now();
1355        let first_name = session.name_at(file.as_ref(), method_name_offset);
1356        let first_name_time = t0.elapsed();
1357        assert_eq!(first_name, Some(crate::Name::method("Dep", "next")));
1358
1359        let t0 = Instant::now();
1360        let first_method_resolve = session.symbol_at(file.as_ref(), method_resolve_offset);
1361        let first_method_resolve_time = t0.elapsed();
1362        assert!(matches!(
1363            first_method_resolve.as_ref().map(|s| &s.kind),
1364            Some(ReferenceKind::MethodCall { class, method })
1365                if class.as_ref() == "Dep" && method.as_ref() == "next"
1366        ));
1367
1368        let t0 = Instant::now();
1369        let first_variable_resolve = session.symbol_at(file.as_ref(), variable_resolve_offset);
1370        let first_variable_resolve_time = t0.elapsed();
1371        assert!(matches!(
1372            first_variable_resolve.as_ref().map(|s| &s.kind),
1373            Some(ReferenceKind::Variable(name)) if name.as_ref() == "value"
1374        ));
1375
1376        let t0 = Instant::now();
1377        let first_type_hint_resolve = session.symbol_at(file.as_ref(), type_hint_offset);
1378        let first_type_hint_resolve_time = t0.elapsed();
1379        assert!(matches!(
1380            first_type_hint_resolve.as_ref().map(|s| &s.kind),
1381            Some(ReferenceKind::ClassReference(name)) if name.as_ref() == "Dep"
1382        ));
1383
1384        let t0 = Instant::now();
1385        let first_receiver_gap_resolve = session.symbol_at(file.as_ref(), receiver_gap_offset);
1386        let first_receiver_gap_resolve_time = t0.elapsed();
1387        assert!(matches!(
1388            first_receiver_gap_resolve.as_ref().map(|s| &s.kind),
1389            Some(ReferenceKind::Receiver)
1390        ));
1391
1392        let t0 = Instant::now();
1393        let first_import_resolve =
1394            import_session.symbol_at(main_file.as_ref(), import_resolve_offset);
1395        let first_import_resolve_time = t0.elapsed();
1396        assert!(matches!(
1397            first_import_resolve.as_ref().map(|s| &s.kind),
1398            Some(ReferenceKind::UseImport(inner))
1399                if matches!(
1400                    inner.as_ref(),
1401                    ReferenceKind::ClassReference(name) if name.as_ref() == "App\\Dep"
1402                )
1403        ));
1404
1405        let t0 = Instant::now();
1406        let first_definition = session
1407            .name_at(file.as_ref(), definition_offset)
1408            .map(|name| session.definition_of_cached(&name));
1409        let first_definition_time = t0.elapsed();
1410        assert!(
1411            matches!(first_definition, Some(Ok(_))),
1412            "definition lookup should succeed on Dep type hint"
1413        );
1414
1415        let t0 = Instant::now();
1416        let first_references = session
1417            .name_at(file.as_ref(), references_offset)
1418            .and_then(|name| {
1419                session.indexed_references_to(
1420                    &name,
1421                    std::slice::from_ref(&file),
1422                    false,
1423                    crate::ReferenceIncludes::Plain,
1424                    &|| false,
1425                )
1426            });
1427        let first_references_time = t0.elapsed();
1428        assert!(
1429            first_references
1430                .as_ref()
1431                .is_some_and(|refs| refs.iter().any(|(f, _)| f.as_ref() == file.as_ref())),
1432            "references lookup should succeed on helper-like method call"
1433        );
1434
1435        const ITERS: usize = 100;
1436        let mut name_samples = Vec::with_capacity(ITERS);
1437        let mut method_resolve_samples = Vec::with_capacity(ITERS);
1438        let mut variable_resolve_samples = Vec::with_capacity(ITERS);
1439        let mut type_hint_resolve_samples = Vec::with_capacity(ITERS);
1440        let mut receiver_gap_resolve_samples = Vec::with_capacity(ITERS);
1441        let mut import_resolve_samples = Vec::with_capacity(ITERS);
1442        let mut definition_samples = Vec::with_capacity(ITERS);
1443        let mut references_samples = Vec::with_capacity(ITERS);
1444
1445        for _ in 0..ITERS {
1446            let t0 = Instant::now();
1447            let name = session.name_at(file.as_ref(), method_name_offset);
1448            name_samples.push(t0.elapsed());
1449            assert_eq!(name, Some(crate::Name::method("Dep", "next")));
1450
1451            let t0 = Instant::now();
1452            let resolve = session.symbol_at(file.as_ref(), method_resolve_offset);
1453            method_resolve_samples.push(t0.elapsed());
1454            assert!(matches!(
1455                resolve.as_ref().map(|s| &s.kind),
1456                Some(ReferenceKind::MethodCall { class, method })
1457                    if class.as_ref() == "Dep" && method.as_ref() == "next"
1458            ));
1459
1460            let t0 = Instant::now();
1461            let resolve = session.symbol_at(file.as_ref(), variable_resolve_offset);
1462            variable_resolve_samples.push(t0.elapsed());
1463            assert!(matches!(
1464                resolve.as_ref().map(|s| &s.kind),
1465                Some(ReferenceKind::Variable(name)) if name.as_ref() == "value"
1466            ));
1467
1468            let t0 = Instant::now();
1469            let resolve = session.symbol_at(file.as_ref(), type_hint_offset);
1470            type_hint_resolve_samples.push(t0.elapsed());
1471            assert!(matches!(
1472                resolve.as_ref().map(|s| &s.kind),
1473                Some(ReferenceKind::ClassReference(name)) if name.as_ref() == "Dep"
1474            ));
1475
1476            let t0 = Instant::now();
1477            let resolve = session.symbol_at(file.as_ref(), receiver_gap_offset);
1478            receiver_gap_resolve_samples.push(t0.elapsed());
1479            assert!(matches!(
1480                resolve.as_ref().map(|s| &s.kind),
1481                Some(ReferenceKind::Receiver)
1482            ));
1483
1484            let t0 = Instant::now();
1485            let resolve = import_session.symbol_at(main_file.as_ref(), import_resolve_offset);
1486            import_resolve_samples.push(t0.elapsed());
1487            assert!(matches!(
1488                resolve.as_ref().map(|s| &s.kind),
1489                Some(ReferenceKind::UseImport(inner))
1490                    if matches!(
1491                        inner.as_ref(),
1492                        ReferenceKind::ClassReference(name) if name.as_ref() == "App\\Dep"
1493                    )
1494            ));
1495
1496            let t0 = Instant::now();
1497            let definition = session
1498                .name_at(file.as_ref(), definition_offset)
1499                .map(|name| session.definition_of_cached(&name));
1500            definition_samples.push(t0.elapsed());
1501            assert!(matches!(definition, Some(Ok(_))));
1502
1503            let t0 = Instant::now();
1504            let references = session
1505                .name_at(file.as_ref(), references_offset)
1506                .and_then(|name| {
1507                    session.indexed_references_to(
1508                        &name,
1509                        std::slice::from_ref(&file),
1510                        false,
1511                        crate::ReferenceIncludes::Plain,
1512                        &|| false,
1513                    )
1514                });
1515            references_samples.push(t0.elapsed());
1516            assert!(references
1517                .as_ref()
1518                .is_some_and(|refs| refs.iter().any(|(f, _)| f.as_ref() == file.as_ref())));
1519        }
1520
1521        let repeat_name_p50 = median(&mut name_samples);
1522        let repeat_method_resolve_p50 = median(&mut method_resolve_samples);
1523        let repeat_variable_resolve_p50 = median(&mut variable_resolve_samples);
1524        let repeat_type_hint_resolve_p50 = median(&mut type_hint_resolve_samples);
1525        let repeat_receiver_gap_resolve_p50 = median(&mut receiver_gap_resolve_samples);
1526        let repeat_import_resolve_p50 = median(&mut import_resolve_samples);
1527        let repeat_definition_p50 = median(&mut definition_samples);
1528        let repeat_references_p50 = median(&mut references_samples);
1529        let dump = crate::metrics::dump().expect("metrics enabled in tests");
1530        let expected_resolve_calls = 5 * (ITERS + 1);
1531        let expected_name_calls = 3 * (ITERS + 1);
1532
1533        assert!(
1534            dump.contains(&format!(
1535                "symbol_at path       : compact {expected_resolve_calls}  fallback 0"
1536            )),
1537            "expanded probe should stay fully on the compact symbol_at path, got:\n{dump}"
1538        );
1539        assert!(
1540            dump.contains(&format!(
1541                "name_at path         : compact {expected_name_calls}  fallback 0"
1542            )),
1543            "expanded probe should stay fully on the compact name_at path, got:\n{dump}"
1544        );
1545
1546        println!();
1547        println!("post_diagnostics_navigation_micro");
1548        println!(
1549            "  diagnostics_only same-file : {} us",
1550            same_file_diagnostics_time.as_micros()
1551        );
1552        println!(
1553            "  diagnostics_only import    : {} us",
1554            import_diagnostics_time.as_micros()
1555        );
1556        println!(
1557            "  first name_at method       : {} us",
1558            first_name_time.as_micros()
1559        );
1560        println!(
1561            "  first symbol_at method    : {} us",
1562            first_method_resolve_time.as_micros()
1563        );
1564        println!(
1565            "  first symbol_at variable  : {} us",
1566            first_variable_resolve_time.as_micros()
1567        );
1568        println!(
1569            "  first symbol_at type_hint : {} us",
1570            first_type_hint_resolve_time.as_micros()
1571        );
1572        println!(
1573            "  first symbol_at receiver  : {} us",
1574            first_receiver_gap_resolve_time.as_micros()
1575        );
1576        println!(
1577            "  first symbol_at import    : {} us",
1578            first_import_resolve_time.as_micros()
1579        );
1580        println!(
1581            "  first definition           : {} us",
1582            first_definition_time.as_micros()
1583        );
1584        println!(
1585            "  first references           : {} us",
1586            first_references_time.as_micros()
1587        );
1588        println!(
1589            "  repeat name_at method p50  : {} us",
1590            repeat_name_p50.as_micros()
1591        );
1592        println!(
1593            "  repeat resolve method p50  : {} us",
1594            repeat_method_resolve_p50.as_micros()
1595        );
1596        println!(
1597            "  repeat resolve variable p50: {} us",
1598            repeat_variable_resolve_p50.as_micros()
1599        );
1600        println!(
1601            "  repeat resolve hint p50    : {} us",
1602            repeat_type_hint_resolve_p50.as_micros()
1603        );
1604        println!(
1605            "  repeat resolve recv p50    : {} us",
1606            repeat_receiver_gap_resolve_p50.as_micros()
1607        );
1608        println!(
1609            "  repeat resolve import p50  : {} us",
1610            repeat_import_resolve_p50.as_micros()
1611        );
1612        println!(
1613            "  repeat definition p50      : {} us",
1614            repeat_definition_p50.as_micros()
1615        );
1616        println!(
1617            "  repeat references p50      : {} us",
1618            repeat_references_p50.as_micros()
1619        );
1620        println!("{dump}");
1621    }
1622
1623    #[test]
1624    #[ignore]
1625    fn perf_report_edit_locality_micro() {
1626        crate::metrics::test_reset();
1627
1628        let mut session = AnalysisSession::new(PhpVersion::LATEST);
1629        let base_file: Arc<str> = Arc::from("/proj/edit/Base.php");
1630        let dep_a_file: Arc<str> = Arc::from("/proj/edit/DepA.php");
1631        let dep_b_file: Arc<str> = Arc::from("/proj/edit/DepB.php");
1632        let leaf_file: Arc<str> = Arc::from("/proj/edit/Leaf.php");
1633
1634        let base_src = "<?php
1635class Base {
1636    public function id(int $n): int { return $n; }
1637}
1638";
1639        let dep_a_src = "<?php
1640class DepA extends Base {
1641    public function run(int $n): int { return $this->id($n) + 1; }
1642}
1643";
1644        let dep_b_src = "<?php
1645class DepB extends Base {
1646    public function run(int $n): int { return $this->id($n) + 2; }
1647}
1648";
1649        let leaf_src = "<?php
1650function leaf_value(int $n): int { return $n + 1; }
1651";
1652
1653        session.ingest_file(base_file.clone(), Arc::from(base_src));
1654        session.ingest_file(dep_a_file.clone(), Arc::from(dep_a_src));
1655        session.ingest_file(dep_b_file.clone(), Arc::from(dep_b_src));
1656        session.ingest_file(leaf_file.clone(), Arc::from(leaf_src));
1657
1658        let dep_a_parsed = php_rs_parser::parse(dep_a_src);
1659        let dep_b_parsed = php_rs_parser::parse(dep_b_src);
1660        let leaf_parsed = php_rs_parser::parse(leaf_src);
1661        assert!(dep_a_parsed.errors.is_empty());
1662        assert!(dep_b_parsed.errors.is_empty());
1663        assert!(leaf_parsed.errors.is_empty());
1664
1665        let _ = FileAnalyzer::new(&mut session).analyze_diagnostics_only(
1666            dep_a_file.clone(),
1667            dep_a_src,
1668            &dep_a_parsed.program,
1669            &dep_a_parsed.source_map,
1670        );
1671        let _ = FileAnalyzer::new(&mut session).analyze_diagnostics_only(
1672            dep_b_file.clone(),
1673            dep_b_src,
1674            &dep_b_parsed.program,
1675            &dep_b_parsed.source_map,
1676        );
1677        let _ = FileAnalyzer::new(&mut session).analyze_diagnostics_only(
1678            leaf_file.clone(),
1679            leaf_src,
1680            &leaf_parsed.program,
1681            &leaf_parsed.source_map,
1682        );
1683
1684        const ITERS: usize = 25;
1685        let mut leaf_ingest_samples = Vec::with_capacity(ITERS);
1686        let mut leaf_reanalyze_samples = Vec::with_capacity(ITERS);
1687        let mut base_ingest_samples = Vec::with_capacity(ITERS);
1688        let mut base_reanalyze_samples = Vec::with_capacity(ITERS);
1689
1690        // Ignore the one-time warm-up passes above: the steady-state loops
1691        // below are what we want to compare for edit locality.
1692        crate::metrics::test_reset();
1693
1694        for i in 0..ITERS {
1695            let leaf_edit = format!(
1696                "<?php\nfunction leaf_value(int $n): int {{ return $n + 1; }}\n// leaf edit {i}\n"
1697            );
1698            let t0 = Instant::now();
1699            session.ingest_file(leaf_file.clone(), Arc::from(leaf_edit.as_str()));
1700            leaf_ingest_samples.push(t0.elapsed());
1701
1702            let t0 = Instant::now();
1703            let leaf_results = session.reanalyze_dependents(leaf_file.as_ref());
1704            leaf_reanalyze_samples.push(t0.elapsed());
1705            assert!(
1706                leaf_results.is_empty(),
1707                "leaf file should have no dependents; got {:?}",
1708                leaf_results
1709                    .iter()
1710                    .map(|(file, _)| file.as_ref())
1711                    .collect::<Vec<_>>()
1712            );
1713        }
1714
1715        let leaf_dump = crate::metrics::dump().expect("metrics enabled in tests");
1716        assert!(
1717            leaf_dump.contains("whole-file walks     : 0"),
1718            "leaf edits should not trigger body walks outside the edited file path, got:\n{leaf_dump}"
1719        );
1720        assert!(
1721            leaf_dump.contains("scopes analyzed      : 0"),
1722            "leaf edits with no dependents should not trigger per-scope reanalysis, got:\n{leaf_dump}"
1723        );
1724
1725        crate::metrics::test_reset();
1726
1727        for i in 0..ITERS {
1728            let base_edit = format!(
1729                "<?php\nclass Base {{\n    public function id(int $n): int {{ return $n; }}\n}}\n// base edit {i}\n"
1730            );
1731            let t0 = Instant::now();
1732            session.ingest_file(base_file.clone(), Arc::from(base_edit.as_str()));
1733            base_ingest_samples.push(t0.elapsed());
1734
1735            let t0 = Instant::now();
1736            let base_results = session.reanalyze_dependents(base_file.as_ref());
1737            base_reanalyze_samples.push(t0.elapsed());
1738            let base_files: Vec<&str> =
1739                base_results.iter().map(|(file, _)| file.as_ref()).collect();
1740            assert!(
1741                base_files.contains(&dep_a_file.as_ref())
1742                    && base_files.contains(&dep_b_file.as_ref()),
1743                "base edit should reanalyze both dependents; got {base_files:?}"
1744            );
1745        }
1746
1747        let leaf_ingest_p50 = median(&mut leaf_ingest_samples);
1748        let leaf_reanalyze_p50 = median(&mut leaf_reanalyze_samples);
1749        let base_ingest_p50 = median(&mut base_ingest_samples);
1750        let base_reanalyze_p50 = median(&mut base_reanalyze_samples);
1751        let base_dump = crate::metrics::dump().expect("metrics enabled in tests");
1752        let body_walks = metric_section_lines(&base_dump, "  top body walks/file:");
1753        let scope_walks = metric_section_lines(&base_dump, "  top scopes analyzed/file:");
1754
1755        assert!(
1756            base_dump.contains("whole-file walks     : 0"),
1757            "base edit loop should avoid whole-file body walks after migrating inferred types to per-scope queries, got:\n{base_dump}"
1758        );
1759        assert!(
1760            body_walks.is_empty(),
1761            "base edit loop should not report any whole-file body-walk metrics anymore, got:\n{base_dump}"
1762        );
1763        assert!(
1764            scope_walks.iter().any(|line| line.contains(base_file.as_ref()))
1765                && scope_walks.iter().any(|line| line.contains(dep_a_file.as_ref()))
1766                && scope_walks.iter().any(|line| line.contains(dep_b_file.as_ref())),
1767            "base edit loop should stay entirely on per-scope Salsa queries for the edited file and both dependents, got:\n{base_dump}"
1768        );
1769
1770        println!();
1771        println!("edit_locality_micro");
1772        println!(
1773            "  leaf ingest p50          : {} us",
1774            leaf_ingest_p50.as_micros()
1775        );
1776        println!(
1777            "  leaf reanalyze p50       : {} us",
1778            leaf_reanalyze_p50.as_micros()
1779        );
1780        println!(
1781            "  base ingest p50          : {} us",
1782            base_ingest_p50.as_micros()
1783        );
1784        println!(
1785            "  base reanalyze p50       : {} us",
1786            base_reanalyze_p50.as_micros()
1787        );
1788        println!("  leaf metrics:");
1789        println!("{leaf_dump}");
1790        println!("  base metrics:");
1791        println!("{base_dump}");
1792    }
1793}