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