Skip to main content

mir_analyzer/
file_analyzer.rs

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