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