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
471impl AnalysisSnapshot {
472    /// Body-analyze `file` and commit its reference locations — the read half
473    /// of [`FileAnalyzer::analyze`]. The owner should have run
474    /// [`AnalysisSession::prepare_for_query`] for `file` (or ingested it via
475    /// [`AnalysisSession::ingest_file_prepared`]): classes load on demand
476    /// regardless, but Composer `autoload.files` functions are indexed only
477    /// by that warm-up. `file` must already be ingested with `source` as its
478    /// text; the warm-up reads its input, so a file never ingested is not
479    /// warmed.
480    pub fn analyze(
481        &self,
482        file: Arc<str>,
483        source: &str,
484        program: &Program,
485        source_map: &SourceMap,
486    ) -> Result<FileAnalysis, salsa::Cancelled> {
487        self.analyze_with_symbols(file, source, program, source_map, true)
488    }
489
490    /// [`Self::analyze`] without the whole-file `ResolvedSymbol` list; see
491    /// [`FileAnalyzer::analyze_diagnostics_only`].
492    pub fn analyze_diagnostics_only(
493        &self,
494        file: Arc<str>,
495        source: &str,
496        program: &Program,
497        source_map: &SourceMap,
498    ) -> Result<FileAnalysis, salsa::Cancelled> {
499        self.analyze_with_symbols(file, source, program, source_map, false)
500    }
501
502    fn analyze_with_symbols(
503        &self,
504        file: Arc<str>,
505        source: &str,
506        program: &Program,
507        source_map: &SourceMap,
508        collect_symbols: bool,
509    ) -> Result<FileAnalysis, salsa::Cancelled> {
510        let (analysis, db, text) = salsa::Cancelled::catch(std::panic::AssertUnwindSafe(|| {
511            crate::metrics::record_file_analysis();
512            let _scope = crate::metrics::BodyAnalysisScope::new();
513            // A pass-local clone: its pending reference locations start empty
514            // even if an earlier query on this snapshot was cancelled midway.
515            let db = self.db().clone();
516            // Marking freshness against the snapshot's text keeps the mark
517            // dead on arrival if the owner has since swapped the input.
518            let text = db
519                .lookup_source_file(file.as_ref())
520                .map(|sf| sf.text(&db as &dyn MirDatabase).clone());
521            let mut driver = BodyAnalyzer::new(&db, self.php_version());
522            driver.collect_symbols = collect_symbols;
523            let (issues, symbols) =
524                driver.analyze_bodies(program, file.clone(), source, source_map);
525            (FileAnalysis { issues, symbols }, db, text)
526        }))?;
527        // Replace (not append): this pass produced the file's complete
528        // reference set.
529        let resolved = !crate::db::issues_have_unresolved_names(&analysis.issues);
530        self.index().commit_file_refs(
531            &db,
532            &file,
533            text,
534            db.take_pending_ref_locs(),
535            resolved,
536            self.stamp(),
537        )?;
538        Ok(analysis)
539    }
540
541    /// The symbol at `byte_offset` in `file`, found by analyzing only the
542    /// containing scope — the read half of [`AnalysisSession::symbol_at`].
543    /// Expects the owner to have prepared `file`
544    /// ([`AnalysisSession::prepare_for_query`]).
545    pub fn symbol_at(
546        &self,
547        file: &Arc<str>,
548        byte_offset: u32,
549    ) -> Result<Option<ResolvedSymbol>, salsa::Cancelled> {
550        let started = std::time::Instant::now();
551        let sym =
552            self.read(|db| resolve_symbol_at(db, self.php_version(), file, byte_offset, true));
553        crate::metrics::record_symbol_at(started.elapsed().as_micros() as u64);
554        sym
555    }
556
557    /// The codebase-level symbol name at `byte_offset` in `file` — the read
558    /// half of [`AnalysisSession::name_at`]. Same preparation contract as
559    /// [`Self::symbol_at`].
560    pub fn name_at(
561        &self,
562        file: &Arc<str>,
563        byte_offset: u32,
564    ) -> Result<Option<crate::Name>, salsa::Cancelled> {
565        let started = std::time::Instant::now();
566        let name = self.read(|db| resolve_name_at(db, self.php_version(), file, byte_offset));
567        crate::metrics::record_name_at(started.elapsed().as_micros() as u64);
568        name
569    }
570}
571
572fn resolve_name_at(
573    db: &dyn MirDatabase,
574    php_version: crate::PhpVersion,
575    file: &Arc<str>,
576    byte_offset: u32,
577) -> Option<crate::Name> {
578    let sf = db.lookup_source_file(file.as_ref())?;
579    let prepared = crate::db::prepare_analysis_file(db, sf);
580    let parsed = prepared.parse_result();
581    if let Some(name) = resolve_name_at_via_compact_facts(
582        db,
583        php_version,
584        file,
585        prepared.text.as_ref(),
586        &parsed.program,
587        &parsed.source_map,
588        byte_offset,
589    ) {
590        crate::metrics::record_name_at_compact_hit();
591        db.note_work(crate::db::Work::NameAtCompact, 1);
592        return Some(name);
593    }
594    crate::metrics::record_name_at_fallback_walk();
595    db.note_work(crate::db::Work::NameAtFallback, 1);
596    let symbols = resolve_scope_symbols(
597        db,
598        php_version,
599        file.clone(),
600        prepared.text.as_ref(),
601        &parsed.program,
602        &parsed.source_map,
603        byte_offset,
604        false,
605        true,
606    );
607    // Codebase-only symbols omit variables, so an enclosing call's span must
608    // not claim a cursor sitting right after one.
609    let allow_expr_fallback = !ends_identifier(prepared.text.as_ref(), byte_offset);
610    best_at(
611        &symbols,
612        |s| s.span,
613        |s| s.expr_span,
614        byte_offset,
615        allow_expr_fallback,
616    )
617    .and_then(ResolvedSymbol::to_symbol)
618}
619
620fn resolve_symbol_at(
621    db: &dyn MirDatabase,
622    php_version: crate::PhpVersion,
623    file: &Arc<str>,
624    byte_offset: u32,
625    capture_symbol_types: bool,
626) -> Option<ResolvedSymbol> {
627    let sf = db.lookup_source_file(file.as_ref())?;
628    let prepared = crate::db::prepare_analysis_file(db, sf);
629    let parsed = prepared.parse_result();
630    if let Some(symbol) = symbol_at_via_compact_facts(
631        db,
632        php_version,
633        file,
634        prepared.text.as_ref(),
635        &parsed.program,
636        &parsed.source_map,
637        byte_offset,
638        capture_symbol_types,
639    ) {
640        crate::metrics::record_symbol_at_compact_hit();
641        db.note_work(crate::db::Work::SymbolAtCompact, 1);
642        return Some(symbol);
643    }
644    crate::metrics::record_symbol_at_fallback_walk();
645    db.note_work(crate::db::Work::SymbolAtFallback, 1);
646    let symbols = resolve_scope_symbols(
647        db,
648        php_version,
649        file.clone(),
650        prepared.text.as_ref(),
651        &parsed.program,
652        &parsed.source_map,
653        byte_offset,
654        capture_symbol_types,
655        false,
656    );
657    symbol_at(&symbols, byte_offset).cloned()
658}
659
660fn resolve_name_at_via_compact_facts(
661    db: &dyn MirDatabase,
662    php_version: crate::PhpVersion,
663    file: &Arc<str>,
664    source: &str,
665    program: &Program,
666    source_map: &SourceMap,
667    byte_offset: u32,
668) -> Option<crate::Name> {
669    let best_stmt = best_navigation_scope_stmt(program, byte_offset);
670    let mut issues = Vec::new();
671    let guards: FxHashSet<Arc<str>> = FxHashSet::default();
672    let mut driver = match best_stmt.map(|stmt| &stmt.kind) {
673        Some(
674            StmtKind::Function(_)
675            | StmtKind::Class(_)
676            | StmtKind::Enum(_)
677            | StmtKind::Interface(_)
678            | StmtKind::Trait(_),
679        ) => BodyAnalyzer::new_inference_only(db, php_version),
680        _ => BodyAnalyzer::new_inference_only(db, php_version),
681    };
682    driver.collect_symbols = false;
683    driver.capture_symbol_types = false;
684    driver.codebase_symbols_only = true;
685    driver.record_reference_locations = false;
686    driver.collect_navigation_facts = true;
687
688    match best_stmt.map(|stmt| &stmt.kind) {
689        Some(StmtKind::Function(decl)) => {
690            driver.analyze_fn_decl(decl, file, source, source_map, &mut issues, None);
691        }
692        Some(StmtKind::Class(decl)) => {
693            driver.analyze_class_decl(decl, file, source, source_map, &mut issues, None, &guards);
694        }
695        Some(StmtKind::Enum(decl)) => {
696            driver.analyze_enum_decl(decl, file, source, source_map, &mut issues, None);
697        }
698        Some(StmtKind::Interface(decl)) => {
699            driver.analyze_interface_decl(
700                decl,
701                file,
702                source,
703                source_map,
704                &mut issues,
705                &guards,
706                None,
707            );
708        }
709        Some(StmtKind::Trait(decl)) => {
710            driver.analyze_trait_decl(decl, file, source, source_map, &mut issues, None);
711        }
712        Some(StmtKind::Use(use_decl)) => {
713            let mut navigation_facts = Vec::new();
714            crate::body_analysis::check_use_decl_casing(
715                use_decl,
716                db,
717                file,
718                source,
719                source_map,
720                &mut issues,
721                None,
722                Some(&mut navigation_facts),
723                None,
724                false,
725                true,
726                false,
727            );
728            return navigation_fact_at(&navigation_facts, byte_offset, true)
729                .map(|fact| fact.name.clone());
730        }
731        _ => {
732            driver.analyze_global_exec(program, file, source, source_map, &mut issues, None);
733        }
734    }
735
736    let facts = driver.take_navigation_facts();
737    // A variable just before the cursor isn't a navigation fact, so the
738    // enclosing call's span must not claim it.
739    let allow_expr_fallback = !ends_identifier(source, byte_offset);
740    navigation_fact_at(&facts, byte_offset, allow_expr_fallback).map(|fact| fact.name.clone())
741}
742
743#[allow(clippy::too_many_arguments)]
744fn symbol_at_via_compact_facts(
745    db: &dyn MirDatabase,
746    php_version: crate::PhpVersion,
747    file: &Arc<str>,
748    source: &str,
749    program: &Program,
750    source_map: &SourceMap,
751    byte_offset: u32,
752    capture_symbol_types: bool,
753) -> Option<ResolvedSymbol> {
754    let mut issues = Vec::new();
755    let guards: FxHashSet<Arc<str>> = FxHashSet::default();
756    let best_stmt = best_navigation_scope_stmt(program, byte_offset);
757    let mut driver = match best_stmt.map(|stmt| &stmt.kind) {
758        Some(
759            StmtKind::Function(_)
760            | StmtKind::Class(_)
761            | StmtKind::Enum(_)
762            | StmtKind::Interface(_)
763            | StmtKind::Trait(_),
764        ) => BodyAnalyzer::new_inference_only(db, php_version),
765        _ => BodyAnalyzer::new_inference_only(db, php_version),
766    };
767    driver.collect_symbols = false;
768    driver.capture_symbol_types = capture_symbol_types;
769    driver.codebase_symbols_only = false;
770    driver.record_reference_locations = false;
771    driver.collect_navigation_facts = false;
772    driver.collect_resolved_navigation_facts = true;
773
774    match best_stmt.map(|stmt| &stmt.kind) {
775        Some(StmtKind::Function(decl)) => {
776            driver.analyze_fn_decl(decl, file, source, source_map, &mut issues, None);
777        }
778        Some(StmtKind::Class(decl)) => {
779            driver.analyze_class_decl(decl, file, source, source_map, &mut issues, None, &guards);
780        }
781        Some(StmtKind::Enum(decl)) => {
782            driver.analyze_enum_decl(decl, file, source, source_map, &mut issues, None);
783        }
784        Some(StmtKind::Interface(decl)) => {
785            driver.analyze_interface_decl(
786                decl,
787                file,
788                source,
789                source_map,
790                &mut issues,
791                &guards,
792                None,
793            );
794        }
795        Some(StmtKind::Trait(decl)) => {
796            driver.analyze_trait_decl(decl, file, source, source_map, &mut issues, None);
797        }
798        Some(StmtKind::Use(use_decl)) => {
799            let mut resolved_navigation_facts = Vec::new();
800            crate::body_analysis::check_use_decl_casing(
801                use_decl,
802                db,
803                file,
804                source,
805                source_map,
806                &mut issues,
807                None,
808                None,
809                Some(&mut resolved_navigation_facts),
810                false,
811                true,
812                false,
813            );
814            if let Some(fact) = resolved_navigation_fact_at(&resolved_navigation_facts, byte_offset)
815            {
816                return Some(fact.clone().into_resolved_symbol(file.clone()));
817            }
818        }
819        _ => {
820            driver.analyze_global_exec(program, file, source, source_map, &mut issues, None);
821        }
822    }
823
824    let facts = driver.take_resolved_navigation_facts();
825    resolved_navigation_fact_at(&facts, byte_offset)
826        .cloned()
827        .map(|fact| fact.into_resolved_symbol(file.clone()))
828}
829
830#[cfg(test)]
831mod tests {
832    use std::sync::Arc;
833    use std::time::{Duration, Instant};
834
835    use super::FileAnalyzer;
836    use crate::db::Work;
837    use crate::{symbol::ReferenceKind, AnalysisSession, PhpVersion, ResolvedSymbol};
838
839    fn session_for_source(path: &str, source: &str) -> (AnalysisSession, Arc<str>) {
840        let mut session = AnalysisSession::new(PhpVersion::LATEST);
841        let file: Arc<str> = Arc::from(path);
842        session.ingest_file(file.clone(), Arc::from(source));
843        (session, file)
844    }
845
846    fn analyze_diagnostics_only(session: &mut AnalysisSession, file: Arc<str>, source: &str) {
847        let parsed = php_rs_parser::parse(source);
848        assert!(
849            parsed.errors.is_empty(),
850            "parser errors in test source: {:?}",
851            parsed.errors
852        );
853
854        let analysis = FileAnalyzer::new(session).analyze_diagnostics_only(
855            file,
856            source,
857            &parsed.program,
858            &parsed.source_map,
859        );
860        assert!(
861            analysis.symbols.is_empty(),
862            "diagnostics-only analysis should not retain whole-file symbols"
863        );
864    }
865
866    fn median(samples: &mut [Duration]) -> Duration {
867        samples.sort_unstable();
868        samples[samples.len() / 2]
869    }
870
871    fn metric_section_lines<'a>(dump: &'a str, label: &str) -> Vec<&'a str> {
872        let Some((_, rest)) = dump.split_once(label) else {
873            return Vec::new();
874        };
875        rest.lines()
876            .skip(1)
877            .take_while(|line| line.starts_with("    "))
878            .collect()
879    }
880
881    fn assert_symbol_at_compact_without_fallback(
882        session: &mut AnalysisSession,
883        file: &Arc<str>,
884        offset: u32,
885        expected: impl FnOnce(&ResolvedSymbol),
886    ) {
887        let before = |work| session.work_count(work);
888        let (compact, fallback, symbols) = (
889            before(Work::SymbolAtCompact),
890            before(Work::SymbolAtFallback),
891            before(Work::SymbolAllocated),
892        );
893        let symbol = session
894            .symbol_at(file.as_ref(), offset)
895            .expect("symbol_at should find a symbol at the chosen offset");
896        expected(&symbol);
897
898        assert_eq!(session.work_count(Work::SymbolAtCompact) - compact, 1);
899        assert_eq!(session.work_count(Work::SymbolAtFallback) - fallback, 0);
900        assert_eq!(
901            session.work_count(Work::SymbolAllocated) - symbols,
902            0,
903            "compact symbol_at should not allocate legacy symbols"
904        );
905    }
906
907    #[test]
908    fn name_at_records_compact_path_without_fallback() {
909        let src = "<?php\nfunction helper(): void {}\nfunction caller(): void { helper(); }\n";
910        let (mut session, file) = session_for_source("/proj/name_at_metrics.php", src);
911        analyze_diagnostics_only(&mut session, file.clone(), src);
912
913        let offset = src.rfind("helper();").unwrap() as u32;
914        let name = session
915            .name_at(file.as_ref(), offset)
916            .expect("name_at should resolve helper()");
917
918        assert_eq!(name, crate::Name::function("helper"));
919
920        assert_eq!(session.work_count(Work::NameAtCompact), 1);
921        assert_eq!(session.work_count(Work::NameAtFallback), 0);
922        assert_eq!(
923            session.work_count(Work::SymbolAllocated),
924            0,
925            "compact name_at should not allocate legacy symbols"
926        );
927    }
928
929    #[test]
930    fn name_at_top_level_exec_uses_compact_path_without_fallback() {
931        let src = "<?php\nfunction helper(): void {}\nhelper();\n";
932        let (mut session, file) = session_for_source("/proj/name_at_top_level_metrics.php", src);
933        analyze_diagnostics_only(&mut session, file.clone(), src);
934
935        let offset = src.find("helper();").unwrap() as u32;
936        let name = session
937            .name_at(file.as_ref(), offset)
938            .expect("name_at should resolve top-level helper()");
939
940        assert_eq!(name, crate::Name::function("helper"));
941
942        assert_eq!(session.work_count(Work::NameAtCompact), 1);
943        assert_eq!(session.work_count(Work::NameAtFallback), 0);
944        assert_eq!(
945            session.work_count(Work::SymbolAllocated),
946            0,
947            "compact top-level name_at should not allocate legacy symbols"
948        );
949    }
950
951    #[test]
952    fn symbol_at_records_compact_path_without_fallback() {
953        let src = "<?php\nclass Box { public int $value = 0; }\nfunction read(Box $box): void { $box->value; }\n";
954        let (mut session, file) = session_for_source("/proj/symbol_at_metrics.php", src);
955        analyze_diagnostics_only(&mut session, file.clone(), src);
956
957        let offset = src.find("$box->value").unwrap() as u32 + "$box".len() as u32;
958        assert_symbol_at_compact_without_fallback(&mut session, &file, offset, |symbol| {
959            assert!(matches!(symbol.kind, ReferenceKind::Receiver));
960        });
961    }
962
963    #[test]
964    fn symbol_at_top_level_exec_uses_compact_path_without_fallback() {
965        let src = "<?php\nfunction helper(): void {}\nhelper();\n";
966        let (mut session, file) = session_for_source("/proj/resolve_top_level_metrics.php", src);
967        analyze_diagnostics_only(&mut session, file.clone(), src);
968
969        let offset = src.find("helper();").unwrap() as u32;
970        assert_symbol_at_compact_without_fallback(&mut session, &file, offset, |symbol| {
971            assert!(matches!(
972                &symbol.kind,
973                ReferenceKind::FunctionCall(name) if name.as_ref() == "helper"
974            ));
975        });
976    }
977
978    #[test]
979    fn symbol_at_method_call_uses_compact_path_without_fallback() {
980        let src = "<?php\nclass Dep { public function next(): int { return 1; } }\nfunction run(Dep $dep): int { return $dep->next(); }\n";
981        let (mut session, file) = session_for_source("/proj/resolve_method_metrics.php", src);
982        analyze_diagnostics_only(&mut session, file.clone(), src);
983
984        let offset = src.find("->next()").unwrap() as u32 + 2;
985        assert_symbol_at_compact_without_fallback(&mut session, &file, offset, |symbol| {
986            assert!(matches!(
987                &symbol.kind,
988                ReferenceKind::MethodCall { class, method }
989                    if class.as_ref() == "Dep" && method.as_ref() == "next"
990            ));
991        });
992    }
993
994    #[test]
995    fn symbol_at_type_hint_uses_compact_path_without_fallback() {
996        let src = "<?php\nclass Dep {}\nfunction run(Dep $dep): Dep { return $dep; }\n";
997        let (mut session, file) = session_for_source("/proj/resolve_type_hint_metrics.php", src);
998        analyze_diagnostics_only(&mut session, file.clone(), src);
999
1000        let offset = src.find("run(Dep").unwrap() as u32 + "run(".len() as u32;
1001        assert_symbol_at_compact_without_fallback(&mut session, &file, offset, |symbol| {
1002            assert!(matches!(
1003                &symbol.kind,
1004                ReferenceKind::ClassReference(name) if name.as_ref() == "Dep"
1005            ));
1006        });
1007    }
1008
1009    #[test]
1010    fn symbol_at_use_import_uses_compact_path_without_fallback() {
1011        let mut session = AnalysisSession::new(PhpVersion::LATEST);
1012        let dep_file: Arc<str> = Arc::from("/proj/Dep.php");
1013        let main_file: Arc<str> = Arc::from("/proj/Main.php");
1014        let dep_src = "<?php\nnamespace App;\nclass Dep {}\n";
1015        let main_src = "<?php\nuse App\\Dep;\nfunction run(): Dep { return new Dep(); }\n";
1016        session.ingest_file(dep_file, Arc::from(dep_src));
1017        session.ingest_file(main_file.clone(), Arc::from(main_src));
1018        analyze_diagnostics_only(&mut session, main_file.clone(), main_src);
1019
1020        let offset = main_src.find("App\\Dep").unwrap() as u32 + "App\\".len() as u32;
1021        assert_symbol_at_compact_without_fallback(&mut session, &main_file, offset, |symbol| {
1022            assert!(matches!(
1023                &symbol.kind,
1024                ReferenceKind::UseImport(inner)
1025                    if matches!(
1026                        inner.as_ref(),
1027                        ReferenceKind::ClassReference(name) if name.as_ref() == "App\\Dep"
1028                    )
1029            ));
1030        });
1031    }
1032
1033    #[test]
1034    fn symbol_at_catch_clause_type_uses_compact_path_without_fallback() {
1035        let src = "<?php\nfinal class MyException extends \\Exception {}\nfunction run(): void {\n    try {\n        throw new MyException();\n    } catch (MyException $e) {\n    }\n}\n";
1036        let (mut session, file) = session_for_source("/proj/resolve_catch_metrics.php", src);
1037        analyze_diagnostics_only(&mut session, file.clone(), src);
1038
1039        let offset = src.rfind("catch (MyException").unwrap() as u32 + "catch (".len() as u32;
1040        assert_symbol_at_compact_without_fallback(&mut session, &file, offset, |symbol| {
1041            assert!(matches!(
1042                &symbol.kind,
1043                ReferenceKind::ClassReference(name) if name.as_ref() == "MyException"
1044            ));
1045        });
1046    }
1047
1048    #[test]
1049    fn symbol_at_static_property_write_class_uses_compact_path_without_fallback() {
1050        let src = "<?php\nclass Counter {\n    public static int $count = 0;\n}\nfunction bump(): void {\n    Counter::$count = Counter::$count + 1;\n}\n";
1051        let (mut session, file) = session_for_source("/proj/resolve_static_prop_metrics.php", src);
1052        analyze_diagnostics_only(&mut session, file.clone(), src);
1053
1054        let offset = src.find("Counter::$count =").unwrap() as u32;
1055        assert_symbol_at_compact_without_fallback(&mut session, &file, offset, |symbol| {
1056            assert!(matches!(
1057                &symbol.kind,
1058                ReferenceKind::ClassReference(name) if name.as_ref() == "Counter"
1059            ));
1060        });
1061    }
1062
1063    #[test]
1064    fn definition_uses_name_at_compact_path_without_fallback() {
1065        let src = "<?php\nclass Dep {}\nfunction run(Dep $dep): Dep { return $dep; }\n";
1066        let (mut session, file) = session_for_source("/proj/definition_metrics.php", src);
1067        analyze_diagnostics_only(&mut session, file.clone(), src);
1068
1069        let offset = src.find("run(Dep").unwrap() as u32 + "run(".len() as u32;
1070        let name = session
1071            .name_at(file.as_ref(), offset)
1072            .expect("name_at should resolve the Dep type hint");
1073        let loc = session
1074            .definition_of_cached(&name)
1075            .expect("Dep should have a declaration");
1076
1077        assert_eq!(loc.file.as_ref(), file.as_ref());
1078
1079        assert_eq!(session.work_count(Work::NameAtCompact), 1);
1080        assert_eq!(session.work_count(Work::NameAtFallback), 0);
1081        assert_eq!(
1082            session.work_count(Work::SymbolAtCompact) + session.work_count(Work::SymbolAtFallback),
1083            0,
1084            "definition lookup should not rely on symbol_at"
1085        );
1086    }
1087
1088    #[test]
1089    fn references_use_name_at_compact_path_without_fallback() {
1090        let src = "<?php\nfunction helper(): void {}\nfunction caller(): void { helper(); }\n";
1091        let (mut session, file) = session_for_source("/proj/references_metrics.php", src);
1092        analyze_diagnostics_only(&mut session, file.clone(), src);
1093
1094        let offset = src.find("helper();").unwrap() as u32 + 1;
1095        let name = session
1096            .name_at(file.as_ref(), offset)
1097            .expect("name_at should resolve helper()");
1098        let refs = session
1099            .indexed_references_to(
1100                &name,
1101                std::slice::from_ref(&file),
1102                false,
1103                crate::ReferenceIncludes::Plain,
1104                &|| false,
1105            )
1106            .expect("uncancelled references query");
1107
1108        assert!(
1109            refs.iter().any(|(f, _)| f.as_ref() == file.as_ref()),
1110            "references should include the helper() call site; got {refs:?}"
1111        );
1112
1113        assert_eq!(session.work_count(Work::NameAtCompact), 1);
1114        assert_eq!(session.work_count(Work::NameAtFallback), 0);
1115        assert_eq!(
1116            session.work_count(Work::SymbolAtCompact) + session.work_count(Work::SymbolAtFallback),
1117            0,
1118            "references lookup should not rely on symbol_at"
1119        );
1120    }
1121
1122    #[test]
1123    #[ignore]
1124    fn perf_report_post_diagnostics_navigation_micro() {
1125        crate::metrics::test_reset();
1126
1127        let src = "<?php
1128class Dep {
1129    public function next(int $value): int { return $value + 1; }
1130}
1131class Box {
1132    public int $value = 0;
1133}
1134function helper(Dep $dep, int $value): int {
1135    return $dep->next($value);
1136}
1137function read_value(int $value): int {
1138    return $value;
1139}
1140function read_box(Box $box): int {
1141    return $box->value;
1142}
1143";
1144        let (mut session, file) = session_for_source("/proj/perf_nav.php", src);
1145        let mut import_session = AnalysisSession::new(PhpVersion::LATEST);
1146        let dep_file: Arc<str> = Arc::from("/proj/import/Dep.php");
1147        let main_file: Arc<str> = Arc::from("/proj/import/Main.php");
1148        let dep_src = "<?php\nnamespace App;\nclass Dep {}\n";
1149        let main_src = "<?php\nuse App\\Dep;\nfunction run(): Dep { return new Dep(); }\n";
1150        import_session.ingest_file(dep_file, Arc::from(dep_src));
1151        import_session.ingest_file(main_file.clone(), Arc::from(main_src));
1152
1153        let parsed = php_rs_parser::parse(src);
1154        assert!(parsed.errors.is_empty(), "fixture must parse cleanly");
1155        let main_parsed = php_rs_parser::parse(main_src);
1156        assert!(
1157            main_parsed.errors.is_empty(),
1158            "import fixture must parse cleanly"
1159        );
1160
1161        let t0 = Instant::now();
1162        let analysis = FileAnalyzer::new(&mut session).analyze_diagnostics_only(
1163            file.clone(),
1164            src,
1165            &parsed.program,
1166            &parsed.source_map,
1167        );
1168        let same_file_diagnostics_time = t0.elapsed();
1169        assert!(analysis.symbols.is_empty());
1170
1171        let t0 = Instant::now();
1172        let main_analysis = FileAnalyzer::new(&mut import_session).analyze_diagnostics_only(
1173            main_file.clone(),
1174            main_src,
1175            &main_parsed.program,
1176            &main_parsed.source_map,
1177        );
1178        let import_diagnostics_time = t0.elapsed();
1179        assert!(main_analysis.symbols.is_empty());
1180
1181        let method_name_offset = src.find("next($value)").unwrap() as u32;
1182        let method_resolve_offset = src.find("->next($value)").unwrap() as u32 + 2;
1183        let variable_resolve_offset =
1184            src.rfind("return $value;").unwrap() as u32 + "return ".len() as u32 + 1;
1185        let type_hint_offset = src.find("helper(Dep").unwrap() as u32 + "helper(".len() as u32;
1186        let receiver_gap_offset = src.find("$box->value").unwrap() as u32 + "$box".len() as u32;
1187        let import_resolve_offset =
1188            main_src.find("App\\Dep").unwrap() as u32 + "App\\".len() as u32;
1189        let definition_offset = src.find("Dep $dep").unwrap() as u32;
1190        let references_offset = method_name_offset;
1191
1192        let t0 = Instant::now();
1193        let first_name = session.name_at(file.as_ref(), method_name_offset);
1194        let first_name_time = t0.elapsed();
1195        assert_eq!(first_name, Some(crate::Name::method("Dep", "next")));
1196
1197        let t0 = Instant::now();
1198        let first_method_resolve = session.symbol_at(file.as_ref(), method_resolve_offset);
1199        let first_method_resolve_time = t0.elapsed();
1200        assert!(matches!(
1201            first_method_resolve.as_ref().map(|s| &s.kind),
1202            Some(ReferenceKind::MethodCall { class, method })
1203                if class.as_ref() == "Dep" && method.as_ref() == "next"
1204        ));
1205
1206        let t0 = Instant::now();
1207        let first_variable_resolve = session.symbol_at(file.as_ref(), variable_resolve_offset);
1208        let first_variable_resolve_time = t0.elapsed();
1209        assert!(matches!(
1210            first_variable_resolve.as_ref().map(|s| &s.kind),
1211            Some(ReferenceKind::Variable(name)) if name.as_ref() == "value"
1212        ));
1213
1214        let t0 = Instant::now();
1215        let first_type_hint_resolve = session.symbol_at(file.as_ref(), type_hint_offset);
1216        let first_type_hint_resolve_time = t0.elapsed();
1217        assert!(matches!(
1218            first_type_hint_resolve.as_ref().map(|s| &s.kind),
1219            Some(ReferenceKind::ClassReference(name)) if name.as_ref() == "Dep"
1220        ));
1221
1222        let t0 = Instant::now();
1223        let first_receiver_gap_resolve = session.symbol_at(file.as_ref(), receiver_gap_offset);
1224        let first_receiver_gap_resolve_time = t0.elapsed();
1225        assert!(matches!(
1226            first_receiver_gap_resolve.as_ref().map(|s| &s.kind),
1227            Some(ReferenceKind::Receiver)
1228        ));
1229
1230        let t0 = Instant::now();
1231        let first_import_resolve =
1232            import_session.symbol_at(main_file.as_ref(), import_resolve_offset);
1233        let first_import_resolve_time = t0.elapsed();
1234        assert!(matches!(
1235            first_import_resolve.as_ref().map(|s| &s.kind),
1236            Some(ReferenceKind::UseImport(inner))
1237                if matches!(
1238                    inner.as_ref(),
1239                    ReferenceKind::ClassReference(name) if name.as_ref() == "App\\Dep"
1240                )
1241        ));
1242
1243        let t0 = Instant::now();
1244        let first_definition = session
1245            .name_at(file.as_ref(), definition_offset)
1246            .map(|name| session.definition_of_cached(&name));
1247        let first_definition_time = t0.elapsed();
1248        assert!(
1249            matches!(first_definition, Some(Ok(_))),
1250            "definition lookup should succeed on Dep type hint"
1251        );
1252
1253        let t0 = Instant::now();
1254        let first_references = session
1255            .name_at(file.as_ref(), references_offset)
1256            .and_then(|name| {
1257                session.indexed_references_to(
1258                    &name,
1259                    std::slice::from_ref(&file),
1260                    false,
1261                    crate::ReferenceIncludes::Plain,
1262                    &|| false,
1263                )
1264            });
1265        let first_references_time = t0.elapsed();
1266        assert!(
1267            first_references
1268                .as_ref()
1269                .is_some_and(|refs| refs.iter().any(|(f, _)| f.as_ref() == file.as_ref())),
1270            "references lookup should succeed on helper-like method call"
1271        );
1272
1273        const ITERS: usize = 100;
1274        let mut name_samples = Vec::with_capacity(ITERS);
1275        let mut method_resolve_samples = Vec::with_capacity(ITERS);
1276        let mut variable_resolve_samples = Vec::with_capacity(ITERS);
1277        let mut type_hint_resolve_samples = Vec::with_capacity(ITERS);
1278        let mut receiver_gap_resolve_samples = Vec::with_capacity(ITERS);
1279        let mut import_resolve_samples = Vec::with_capacity(ITERS);
1280        let mut definition_samples = Vec::with_capacity(ITERS);
1281        let mut references_samples = Vec::with_capacity(ITERS);
1282
1283        for _ in 0..ITERS {
1284            let t0 = Instant::now();
1285            let name = session.name_at(file.as_ref(), method_name_offset);
1286            name_samples.push(t0.elapsed());
1287            assert_eq!(name, Some(crate::Name::method("Dep", "next")));
1288
1289            let t0 = Instant::now();
1290            let resolve = session.symbol_at(file.as_ref(), method_resolve_offset);
1291            method_resolve_samples.push(t0.elapsed());
1292            assert!(matches!(
1293                resolve.as_ref().map(|s| &s.kind),
1294                Some(ReferenceKind::MethodCall { class, method })
1295                    if class.as_ref() == "Dep" && method.as_ref() == "next"
1296            ));
1297
1298            let t0 = Instant::now();
1299            let resolve = session.symbol_at(file.as_ref(), variable_resolve_offset);
1300            variable_resolve_samples.push(t0.elapsed());
1301            assert!(matches!(
1302                resolve.as_ref().map(|s| &s.kind),
1303                Some(ReferenceKind::Variable(name)) if name.as_ref() == "value"
1304            ));
1305
1306            let t0 = Instant::now();
1307            let resolve = session.symbol_at(file.as_ref(), type_hint_offset);
1308            type_hint_resolve_samples.push(t0.elapsed());
1309            assert!(matches!(
1310                resolve.as_ref().map(|s| &s.kind),
1311                Some(ReferenceKind::ClassReference(name)) if name.as_ref() == "Dep"
1312            ));
1313
1314            let t0 = Instant::now();
1315            let resolve = session.symbol_at(file.as_ref(), receiver_gap_offset);
1316            receiver_gap_resolve_samples.push(t0.elapsed());
1317            assert!(matches!(
1318                resolve.as_ref().map(|s| &s.kind),
1319                Some(ReferenceKind::Receiver)
1320            ));
1321
1322            let t0 = Instant::now();
1323            let resolve = import_session.symbol_at(main_file.as_ref(), import_resolve_offset);
1324            import_resolve_samples.push(t0.elapsed());
1325            assert!(matches!(
1326                resolve.as_ref().map(|s| &s.kind),
1327                Some(ReferenceKind::UseImport(inner))
1328                    if matches!(
1329                        inner.as_ref(),
1330                        ReferenceKind::ClassReference(name) if name.as_ref() == "App\\Dep"
1331                    )
1332            ));
1333
1334            let t0 = Instant::now();
1335            let definition = session
1336                .name_at(file.as_ref(), definition_offset)
1337                .map(|name| session.definition_of_cached(&name));
1338            definition_samples.push(t0.elapsed());
1339            assert!(matches!(definition, Some(Ok(_))));
1340
1341            let t0 = Instant::now();
1342            let references = session
1343                .name_at(file.as_ref(), references_offset)
1344                .and_then(|name| {
1345                    session.indexed_references_to(
1346                        &name,
1347                        std::slice::from_ref(&file),
1348                        false,
1349                        crate::ReferenceIncludes::Plain,
1350                        &|| false,
1351                    )
1352                });
1353            references_samples.push(t0.elapsed());
1354            assert!(references
1355                .as_ref()
1356                .is_some_and(|refs| refs.iter().any(|(f, _)| f.as_ref() == file.as_ref())));
1357        }
1358
1359        let repeat_name_p50 = median(&mut name_samples);
1360        let repeat_method_resolve_p50 = median(&mut method_resolve_samples);
1361        let repeat_variable_resolve_p50 = median(&mut variable_resolve_samples);
1362        let repeat_type_hint_resolve_p50 = median(&mut type_hint_resolve_samples);
1363        let repeat_receiver_gap_resolve_p50 = median(&mut receiver_gap_resolve_samples);
1364        let repeat_import_resolve_p50 = median(&mut import_resolve_samples);
1365        let repeat_definition_p50 = median(&mut definition_samples);
1366        let repeat_references_p50 = median(&mut references_samples);
1367        let dump = crate::metrics::dump().expect("metrics enabled in tests");
1368        let expected_resolve_calls = 5 * (ITERS + 1);
1369        let expected_name_calls = 3 * (ITERS + 1);
1370
1371        assert!(
1372            dump.contains(&format!(
1373                "symbol_at path       : compact {expected_resolve_calls}  fallback 0"
1374            )),
1375            "expanded probe should stay fully on the compact symbol_at path, got:\n{dump}"
1376        );
1377        assert!(
1378            dump.contains(&format!(
1379                "name_at path         : compact {expected_name_calls}  fallback 0"
1380            )),
1381            "expanded probe should stay fully on the compact name_at path, got:\n{dump}"
1382        );
1383
1384        println!();
1385        println!("post_diagnostics_navigation_micro");
1386        println!(
1387            "  diagnostics_only same-file : {} us",
1388            same_file_diagnostics_time.as_micros()
1389        );
1390        println!(
1391            "  diagnostics_only import    : {} us",
1392            import_diagnostics_time.as_micros()
1393        );
1394        println!(
1395            "  first name_at method       : {} us",
1396            first_name_time.as_micros()
1397        );
1398        println!(
1399            "  first symbol_at method    : {} us",
1400            first_method_resolve_time.as_micros()
1401        );
1402        println!(
1403            "  first symbol_at variable  : {} us",
1404            first_variable_resolve_time.as_micros()
1405        );
1406        println!(
1407            "  first symbol_at type_hint : {} us",
1408            first_type_hint_resolve_time.as_micros()
1409        );
1410        println!(
1411            "  first symbol_at receiver  : {} us",
1412            first_receiver_gap_resolve_time.as_micros()
1413        );
1414        println!(
1415            "  first symbol_at import    : {} us",
1416            first_import_resolve_time.as_micros()
1417        );
1418        println!(
1419            "  first definition           : {} us",
1420            first_definition_time.as_micros()
1421        );
1422        println!(
1423            "  first references           : {} us",
1424            first_references_time.as_micros()
1425        );
1426        println!(
1427            "  repeat name_at method p50  : {} us",
1428            repeat_name_p50.as_micros()
1429        );
1430        println!(
1431            "  repeat resolve method p50  : {} us",
1432            repeat_method_resolve_p50.as_micros()
1433        );
1434        println!(
1435            "  repeat resolve variable p50: {} us",
1436            repeat_variable_resolve_p50.as_micros()
1437        );
1438        println!(
1439            "  repeat resolve hint p50    : {} us",
1440            repeat_type_hint_resolve_p50.as_micros()
1441        );
1442        println!(
1443            "  repeat resolve recv p50    : {} us",
1444            repeat_receiver_gap_resolve_p50.as_micros()
1445        );
1446        println!(
1447            "  repeat resolve import p50  : {} us",
1448            repeat_import_resolve_p50.as_micros()
1449        );
1450        println!(
1451            "  repeat definition p50      : {} us",
1452            repeat_definition_p50.as_micros()
1453        );
1454        println!(
1455            "  repeat references p50      : {} us",
1456            repeat_references_p50.as_micros()
1457        );
1458        println!("{dump}");
1459    }
1460
1461    #[test]
1462    #[ignore]
1463    fn perf_report_edit_locality_micro() {
1464        crate::metrics::test_reset();
1465
1466        let mut session = AnalysisSession::new(PhpVersion::LATEST);
1467        let base_file: Arc<str> = Arc::from("/proj/edit/Base.php");
1468        let dep_a_file: Arc<str> = Arc::from("/proj/edit/DepA.php");
1469        let dep_b_file: Arc<str> = Arc::from("/proj/edit/DepB.php");
1470        let leaf_file: Arc<str> = Arc::from("/proj/edit/Leaf.php");
1471
1472        let base_src = "<?php
1473class Base {
1474    public function id(int $n): int { return $n; }
1475}
1476";
1477        let dep_a_src = "<?php
1478class DepA extends Base {
1479    public function run(int $n): int { return $this->id($n) + 1; }
1480}
1481";
1482        let dep_b_src = "<?php
1483class DepB extends Base {
1484    public function run(int $n): int { return $this->id($n) + 2; }
1485}
1486";
1487        let leaf_src = "<?php
1488function leaf_value(int $n): int { return $n + 1; }
1489";
1490
1491        session.ingest_file(base_file.clone(), Arc::from(base_src));
1492        session.ingest_file(dep_a_file.clone(), Arc::from(dep_a_src));
1493        session.ingest_file(dep_b_file.clone(), Arc::from(dep_b_src));
1494        session.ingest_file(leaf_file.clone(), Arc::from(leaf_src));
1495
1496        let dep_a_parsed = php_rs_parser::parse(dep_a_src);
1497        let dep_b_parsed = php_rs_parser::parse(dep_b_src);
1498        let leaf_parsed = php_rs_parser::parse(leaf_src);
1499        assert!(dep_a_parsed.errors.is_empty());
1500        assert!(dep_b_parsed.errors.is_empty());
1501        assert!(leaf_parsed.errors.is_empty());
1502
1503        let _ = FileAnalyzer::new(&mut session).analyze_diagnostics_only(
1504            dep_a_file.clone(),
1505            dep_a_src,
1506            &dep_a_parsed.program,
1507            &dep_a_parsed.source_map,
1508        );
1509        let _ = FileAnalyzer::new(&mut session).analyze_diagnostics_only(
1510            dep_b_file.clone(),
1511            dep_b_src,
1512            &dep_b_parsed.program,
1513            &dep_b_parsed.source_map,
1514        );
1515        let _ = FileAnalyzer::new(&mut session).analyze_diagnostics_only(
1516            leaf_file.clone(),
1517            leaf_src,
1518            &leaf_parsed.program,
1519            &leaf_parsed.source_map,
1520        );
1521
1522        const ITERS: usize = 25;
1523        let mut leaf_ingest_samples = Vec::with_capacity(ITERS);
1524        let mut leaf_reanalyze_samples = Vec::with_capacity(ITERS);
1525        let mut base_ingest_samples = Vec::with_capacity(ITERS);
1526        let mut base_reanalyze_samples = Vec::with_capacity(ITERS);
1527
1528        // Ignore the one-time warm-up passes above: the steady-state loops
1529        // below are what we want to compare for edit locality.
1530        crate::metrics::test_reset();
1531
1532        for i in 0..ITERS {
1533            let leaf_edit = format!(
1534                "<?php\nfunction leaf_value(int $n): int {{ return $n + 1; }}\n// leaf edit {i}\n"
1535            );
1536            let t0 = Instant::now();
1537            session.ingest_file(leaf_file.clone(), Arc::from(leaf_edit.as_str()));
1538            leaf_ingest_samples.push(t0.elapsed());
1539
1540            let t0 = Instant::now();
1541            let leaf_results = session.reanalyze_dependents(leaf_file.as_ref());
1542            leaf_reanalyze_samples.push(t0.elapsed());
1543            assert!(
1544                leaf_results.is_empty(),
1545                "leaf file should have no dependents; got {:?}",
1546                leaf_results
1547                    .iter()
1548                    .map(|(file, _)| file.as_ref())
1549                    .collect::<Vec<_>>()
1550            );
1551        }
1552
1553        let leaf_dump = crate::metrics::dump().expect("metrics enabled in tests");
1554        assert!(
1555            leaf_dump.contains("whole-file walks     : 0"),
1556            "leaf edits should not trigger body walks outside the edited file path, got:\n{leaf_dump}"
1557        );
1558        assert!(
1559            leaf_dump.contains("scopes analyzed      : 0"),
1560            "leaf edits with no dependents should not trigger per-scope reanalysis, got:\n{leaf_dump}"
1561        );
1562
1563        crate::metrics::test_reset();
1564
1565        for i in 0..ITERS {
1566            let base_edit = format!(
1567                "<?php\nclass Base {{\n    public function id(int $n): int {{ return $n; }}\n}}\n// base edit {i}\n"
1568            );
1569            let t0 = Instant::now();
1570            session.ingest_file(base_file.clone(), Arc::from(base_edit.as_str()));
1571            base_ingest_samples.push(t0.elapsed());
1572
1573            let t0 = Instant::now();
1574            let base_results = session.reanalyze_dependents(base_file.as_ref());
1575            base_reanalyze_samples.push(t0.elapsed());
1576            let base_files: Vec<&str> =
1577                base_results.iter().map(|(file, _)| file.as_ref()).collect();
1578            assert!(
1579                base_files.contains(&dep_a_file.as_ref())
1580                    && base_files.contains(&dep_b_file.as_ref()),
1581                "base edit should reanalyze both dependents; got {base_files:?}"
1582            );
1583        }
1584
1585        let leaf_ingest_p50 = median(&mut leaf_ingest_samples);
1586        let leaf_reanalyze_p50 = median(&mut leaf_reanalyze_samples);
1587        let base_ingest_p50 = median(&mut base_ingest_samples);
1588        let base_reanalyze_p50 = median(&mut base_reanalyze_samples);
1589        let base_dump = crate::metrics::dump().expect("metrics enabled in tests");
1590        let body_walks = metric_section_lines(&base_dump, "  top body walks/file:");
1591        let scope_walks = metric_section_lines(&base_dump, "  top scopes analyzed/file:");
1592
1593        assert!(
1594            base_dump.contains("whole-file walks     : 0"),
1595            "base edit loop should avoid whole-file body walks after migrating inferred types to per-scope queries, got:\n{base_dump}"
1596        );
1597        assert!(
1598            body_walks.is_empty(),
1599            "base edit loop should not report any whole-file body-walk metrics anymore, got:\n{base_dump}"
1600        );
1601        assert!(
1602            scope_walks.iter().any(|line| line.contains(base_file.as_ref()))
1603                && scope_walks.iter().any(|line| line.contains(dep_a_file.as_ref()))
1604                && scope_walks.iter().any(|line| line.contains(dep_b_file.as_ref())),
1605            "base edit loop should stay entirely on per-scope Salsa queries for the edited file and both dependents, got:\n{base_dump}"
1606        );
1607
1608        println!();
1609        println!("edit_locality_micro");
1610        println!(
1611            "  leaf ingest p50          : {} us",
1612            leaf_ingest_p50.as_micros()
1613        );
1614        println!(
1615            "  leaf reanalyze p50       : {} us",
1616            leaf_reanalyze_p50.as_micros()
1617        );
1618        println!(
1619            "  base ingest p50          : {} us",
1620            base_ingest_p50.as_micros()
1621        );
1622        println!(
1623            "  base reanalyze p50       : {} us",
1624            base_reanalyze_p50.as_micros()
1625        );
1626        println!("  leaf metrics:");
1627        println!("{leaf_dump}");
1628        println!("  base metrics:");
1629        println!("{base_dump}");
1630    }
1631}