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