Skip to main content

mir_analyzer/
file_analyzer.rs

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