1use 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
27pub struct FileAnalysis {
29 pub issues: Vec<Issue>,
30 pub symbols: Vec<ResolvedSymbol>,
31}
32
33impl FileAnalysis {
34 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 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
85fn 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
370pub 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 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 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 self.session.settle_workspace_index();
425
426 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 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 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 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 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 let db = self.db().clone();
536 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 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 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 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 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 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
614struct VariableScope {
616 span: Span,
617 kind: VariableScopeKind,
618}
619
620enum VariableScopeKind {
621 Isolated,
623 Arrow { params: Vec<String> },
625 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
692fn 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 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
830fn 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 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
940fn 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 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 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}