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
481impl AnalysisSnapshot {
482 pub fn analyze(
491 &self,
492 file: Arc<str>,
493 source: &str,
494 program: &Program,
495 source_map: &SourceMap,
496 ) -> Result<FileAnalysis, salsa::Cancelled> {
497 self.analyze_with_symbols(file, source, program, source_map, true)
498 }
499
500 pub fn analyze_diagnostics_only(
503 &self,
504 file: Arc<str>,
505 source: &str,
506 program: &Program,
507 source_map: &SourceMap,
508 ) -> Result<FileAnalysis, salsa::Cancelled> {
509 self.analyze_with_symbols(file, source, program, source_map, false)
510 }
511
512 fn analyze_with_symbols(
513 &self,
514 file: Arc<str>,
515 source: &str,
516 program: &Program,
517 source_map: &SourceMap,
518 collect_symbols: bool,
519 ) -> Result<FileAnalysis, salsa::Cancelled> {
520 let (analysis, db, text) = salsa::Cancelled::catch(std::panic::AssertUnwindSafe(|| {
521 crate::metrics::record_file_analysis();
522 let _scope = crate::metrics::BodyAnalysisScope::new();
523 let db = self.db().clone();
526 let text = db
529 .lookup_source_file(file.as_ref())
530 .map(|sf| sf.text(&db as &dyn MirDatabase).clone());
531 let mut driver = BodyAnalyzer::new(&db, self.php_version());
532 driver.collect_symbols = collect_symbols;
533 let (issues, symbols) =
534 driver.analyze_bodies(program, file.clone(), source, source_map);
535 (FileAnalysis { issues, symbols }, db, text)
536 }))?;
537 let resolved = !crate::db::issues_have_unresolved_names(&analysis.issues);
540 self.index().commit_file_refs(
541 &db,
542 &file,
543 text,
544 db.take_pending_ref_locs(),
545 resolved,
546 self.stamp(),
547 )?;
548 Ok(analysis)
549 }
550
551 pub fn symbol_at(
556 &self,
557 file: &Arc<str>,
558 byte_offset: u32,
559 ) -> Result<Option<ResolvedSymbol>, salsa::Cancelled> {
560 let started = std::time::Instant::now();
561 let sym =
562 self.read(|db| resolve_symbol_at(db, self.php_version(), file, byte_offset, true));
563 crate::metrics::record_symbol_at(started.elapsed().as_micros() as u64);
564 sym
565 }
566
567 pub fn name_at(
571 &self,
572 file: &Arc<str>,
573 byte_offset: u32,
574 ) -> Result<Option<crate::Name>, salsa::Cancelled> {
575 let started = std::time::Instant::now();
576 let name = self.read(|db| resolve_name_at(db, self.php_version(), file, byte_offset));
577 crate::metrics::record_name_at(started.elapsed().as_micros() as u64);
578 name
579 }
580
581 pub fn outgoing_calls(
585 &self,
586 file: &Arc<str>,
587 byte_offset: u32,
588 ) -> Result<Vec<(crate::Name, crate::Range)>, salsa::Cancelled> {
589 self.read(|db| resolve_outgoing_calls(db, self.php_version(), file, byte_offset))
590 }
591}
592
593fn resolve_name_at(
594 db: &dyn MirDatabase,
595 php_version: crate::PhpVersion,
596 file: &Arc<str>,
597 byte_offset: u32,
598) -> Option<crate::Name> {
599 let sf = db.lookup_source_file(file.as_ref())?;
600 let prepared = crate::db::prepare_analysis_file(db, sf);
601 let parsed = prepared.parse_result();
602 if let Some(name) = resolve_name_at_via_compact_facts(
603 db,
604 php_version,
605 file,
606 prepared.text.as_ref(),
607 &parsed.program,
608 &parsed.source_map,
609 byte_offset,
610 ) {
611 crate::metrics::record_name_at_compact_hit();
612 db.note_work(crate::db::Work::NameAtCompact, 1);
613 return Some(name);
614 }
615 crate::metrics::record_name_at_fallback_walk();
616 db.note_work(crate::db::Work::NameAtFallback, 1);
617 let symbols = resolve_scope_symbols(
618 db,
619 php_version,
620 file.clone(),
621 prepared.text.as_ref(),
622 &parsed.program,
623 &parsed.source_map,
624 byte_offset,
625 false,
626 true,
627 );
628 let allow_expr_fallback = !ends_identifier(prepared.text.as_ref(), byte_offset);
631 best_at(
632 &symbols,
633 |s| s.span,
634 |s| s.expr_span,
635 byte_offset,
636 allow_expr_fallback,
637 )
638 .and_then(ResolvedSymbol::to_symbol)
639}
640
641fn enclosing_callable_body(stmt: &Stmt, byte_offset: u32) -> Option<Span> {
643 use php_ast::owned::EnumMemberKind;
644
645 let body_span = |body: &Option<Box<php_ast::owned::Block>>| body.as_ref().map(|b| b.span);
646 match &stmt.kind {
647 StmtKind::Function(decl) => Some(decl.body.span),
648 StmtKind::Class(decl) => class_member_body(&decl.body.members, byte_offset),
649 StmtKind::Trait(decl) => class_member_body(&decl.body.members, byte_offset),
650 StmtKind::Enum(decl) => decl
651 .body
652 .members
653 .iter()
654 .filter(|m| span_contains(m.span, byte_offset))
655 .find_map(|m| match &m.kind {
656 EnumMemberKind::Method(method) => body_span(&method.body),
657 _ => None,
658 }),
659 _ => None,
660 }
661}
662
663fn class_member_body(members: &[php_ast::owned::ClassMember], byte_offset: u32) -> Option<Span> {
664 use php_ast::owned::ClassMemberKind;
665
666 members
667 .iter()
668 .filter(|m| span_contains(m.span, byte_offset))
669 .find_map(|m| match &m.kind {
670 ClassMemberKind::Method(method) => method.body.as_ref().map(|b| b.span),
671 _ => None,
672 })
673}
674
675fn resolve_outgoing_calls(
676 db: &dyn MirDatabase,
677 php_version: crate::PhpVersion,
678 file: &Arc<str>,
679 byte_offset: u32,
680) -> Vec<(crate::Name, crate::Range)> {
681 let Some(sf) = db.lookup_source_file(file.as_ref()) else {
682 return Vec::new();
683 };
684 let prepared = crate::db::prepare_analysis_file(db, sf);
685 let parsed = prepared.parse_result();
686 let source = prepared.text.as_ref();
687 let Some(stmt) = best_navigation_scope_stmt(&parsed.program, byte_offset) else {
688 return Vec::new();
689 };
690 let Some(body) = enclosing_callable_body(stmt, byte_offset) else {
692 return Vec::new();
693 };
694
695 let mut driver = BodyAnalyzer::new_inference_only(db, php_version);
696 driver.collect_symbols = false;
697 driver.capture_symbol_types = false;
698 driver.codebase_symbols_only = true;
699 driver.record_reference_locations = false;
700 driver.collect_navigation_facts = true;
701 let mut issues = Vec::new();
702 let guards: FxHashSet<Arc<str>> = FxHashSet::default();
703 let source_map = &parsed.source_map;
704 match &stmt.kind {
705 StmtKind::Function(decl) => {
706 driver.analyze_fn_decl(decl, file, source, source_map, &mut issues, None)
707 }
708 StmtKind::Class(decl) => {
709 driver.analyze_class_decl(decl, file, source, source_map, &mut issues, None, &guards)
710 }
711 StmtKind::Trait(decl) => {
712 driver.analyze_trait_decl(decl, file, source, source_map, &mut issues, None)
713 }
714 StmtKind::Enum(decl) => {
715 driver.analyze_enum_decl(decl, file, source, source_map, &mut issues, None)
716 }
717 _ => return Vec::new(),
718 }
719
720 let position = |offset: u32| {
721 let (line, column) = crate::diagnostics::offset_to_line_col(source, offset, source_map);
722 crate::Position {
723 line,
724 column: u32::from(column),
725 }
726 };
727 let mut facts: Vec<NavigationFact> = driver
728 .take_navigation_facts()
729 .into_iter()
730 .filter(|fact| {
731 matches!(
732 fact.name,
733 crate::Name::Method { .. } | crate::Name::Function(_)
734 ) && fact.span.start >= body.start
735 && fact.span.end <= body.end
736 })
737 .collect();
738 facts.sort_by_key(|fact| fact.span.start);
739 facts
740 .into_iter()
741 .map(|fact| {
742 let range = crate::Range {
743 start: position(fact.span.start),
744 end: position(fact.span.end),
745 };
746 (resolve_trait_alias(db, fact.name), range)
747 })
748 .collect()
749}
750
751fn resolve_trait_alias(db: &dyn MirDatabase, name: crate::Name) -> crate::Name {
754 let crate::Name::Method {
755 class,
756 name: method,
757 } = &name
758 else {
759 return name;
760 };
761 let here = crate::db::Fqcn::from_str(db, class.as_ref());
762 let Some(crate::db::ClassLike::Class(cls)) = crate::db::find_class_like(db, here) else {
763 return name;
764 };
765 let Some((trait_name, orig_method, _, _)) = cls.trait_aliases.get(method.as_ref()) else {
766 return name;
767 };
768 let candidates: Vec<Arc<str>> = trait_name
769 .as_ref()
770 .map(|t| vec![t.clone()])
771 .unwrap_or_else(|| cls.traits.clone());
772 candidates
773 .into_iter()
774 .find(|trait_fqcn| {
775 let trait_ = crate::db::Fqcn::from_str(db, trait_fqcn.as_ref());
776 crate::db::find_method_in_class(db, trait_, orig_method).is_some()
777 })
778 .map(|trait_fqcn| crate::Name::method(trait_fqcn, orig_method))
779 .unwrap_or(name)
780}
781
782fn resolve_symbol_at(
783 db: &dyn MirDatabase,
784 php_version: crate::PhpVersion,
785 file: &Arc<str>,
786 byte_offset: u32,
787 capture_symbol_types: bool,
788) -> Option<ResolvedSymbol> {
789 let sf = db.lookup_source_file(file.as_ref())?;
790 let prepared = crate::db::prepare_analysis_file(db, sf);
791 let parsed = prepared.parse_result();
792 if let Some(symbol) = symbol_at_via_compact_facts(
793 db,
794 php_version,
795 file,
796 prepared.text.as_ref(),
797 &parsed.program,
798 &parsed.source_map,
799 byte_offset,
800 capture_symbol_types,
801 ) {
802 crate::metrics::record_symbol_at_compact_hit();
803 db.note_work(crate::db::Work::SymbolAtCompact, 1);
804 return Some(symbol);
805 }
806 crate::metrics::record_symbol_at_fallback_walk();
807 db.note_work(crate::db::Work::SymbolAtFallback, 1);
808 let symbols = resolve_scope_symbols(
809 db,
810 php_version,
811 file.clone(),
812 prepared.text.as_ref(),
813 &parsed.program,
814 &parsed.source_map,
815 byte_offset,
816 capture_symbol_types,
817 false,
818 );
819 symbol_at(&symbols, byte_offset).cloned()
820}
821
822fn resolve_name_at_via_compact_facts(
823 db: &dyn MirDatabase,
824 php_version: crate::PhpVersion,
825 file: &Arc<str>,
826 source: &str,
827 program: &Program,
828 source_map: &SourceMap,
829 byte_offset: u32,
830) -> Option<crate::Name> {
831 let best_stmt = best_navigation_scope_stmt(program, byte_offset);
832 let mut issues = Vec::new();
833 let guards: FxHashSet<Arc<str>> = FxHashSet::default();
834 let mut driver = match best_stmt.map(|stmt| &stmt.kind) {
835 Some(
836 StmtKind::Function(_)
837 | StmtKind::Class(_)
838 | StmtKind::Enum(_)
839 | StmtKind::Interface(_)
840 | StmtKind::Trait(_),
841 ) => BodyAnalyzer::new_inference_only(db, php_version),
842 _ => BodyAnalyzer::new_inference_only(db, php_version),
843 };
844 driver.collect_symbols = false;
845 driver.capture_symbol_types = false;
846 driver.codebase_symbols_only = true;
847 driver.record_reference_locations = false;
848 driver.collect_navigation_facts = true;
849
850 match best_stmt.map(|stmt| &stmt.kind) {
851 Some(StmtKind::Function(decl)) => {
852 driver.analyze_fn_decl(decl, file, source, source_map, &mut issues, None);
853 }
854 Some(StmtKind::Class(decl)) => {
855 driver.analyze_class_decl(decl, file, source, source_map, &mut issues, None, &guards);
856 }
857 Some(StmtKind::Enum(decl)) => {
858 driver.analyze_enum_decl(decl, file, source, source_map, &mut issues, None);
859 }
860 Some(StmtKind::Interface(decl)) => {
861 driver.analyze_interface_decl(
862 decl,
863 file,
864 source,
865 source_map,
866 &mut issues,
867 &guards,
868 None,
869 );
870 }
871 Some(StmtKind::Trait(decl)) => {
872 driver.analyze_trait_decl(decl, file, source, source_map, &mut issues, None);
873 }
874 Some(StmtKind::Use(use_decl)) => {
875 let mut navigation_facts = Vec::new();
876 crate::body_analysis::check_use_decl_casing(
877 use_decl,
878 db,
879 file,
880 source,
881 source_map,
882 &mut issues,
883 None,
884 Some(&mut navigation_facts),
885 None,
886 false,
887 true,
888 false,
889 );
890 return navigation_fact_at(&navigation_facts, byte_offset, true)
891 .map(|fact| fact.name.clone());
892 }
893 _ => {
894 driver.analyze_global_exec(program, file, source, source_map, &mut issues, None);
895 }
896 }
897
898 let facts = driver.take_navigation_facts();
899 let allow_expr_fallback = !ends_identifier(source, byte_offset);
902 navigation_fact_at(&facts, byte_offset, allow_expr_fallback).map(|fact| fact.name.clone())
903}
904
905#[allow(clippy::too_many_arguments)]
906fn symbol_at_via_compact_facts(
907 db: &dyn MirDatabase,
908 php_version: crate::PhpVersion,
909 file: &Arc<str>,
910 source: &str,
911 program: &Program,
912 source_map: &SourceMap,
913 byte_offset: u32,
914 capture_symbol_types: bool,
915) -> Option<ResolvedSymbol> {
916 let mut issues = Vec::new();
917 let guards: FxHashSet<Arc<str>> = FxHashSet::default();
918 let best_stmt = best_navigation_scope_stmt(program, byte_offset);
919 let mut driver = match best_stmt.map(|stmt| &stmt.kind) {
920 Some(
921 StmtKind::Function(_)
922 | StmtKind::Class(_)
923 | StmtKind::Enum(_)
924 | StmtKind::Interface(_)
925 | StmtKind::Trait(_),
926 ) => BodyAnalyzer::new_inference_only(db, php_version),
927 _ => BodyAnalyzer::new_inference_only(db, php_version),
928 };
929 driver.collect_symbols = false;
930 driver.capture_symbol_types = capture_symbol_types;
931 driver.codebase_symbols_only = false;
932 driver.record_reference_locations = false;
933 driver.collect_navigation_facts = false;
934 driver.collect_resolved_navigation_facts = true;
935
936 match best_stmt.map(|stmt| &stmt.kind) {
937 Some(StmtKind::Function(decl)) => {
938 driver.analyze_fn_decl(decl, file, source, source_map, &mut issues, None);
939 }
940 Some(StmtKind::Class(decl)) => {
941 driver.analyze_class_decl(decl, file, source, source_map, &mut issues, None, &guards);
942 }
943 Some(StmtKind::Enum(decl)) => {
944 driver.analyze_enum_decl(decl, file, source, source_map, &mut issues, None);
945 }
946 Some(StmtKind::Interface(decl)) => {
947 driver.analyze_interface_decl(
948 decl,
949 file,
950 source,
951 source_map,
952 &mut issues,
953 &guards,
954 None,
955 );
956 }
957 Some(StmtKind::Trait(decl)) => {
958 driver.analyze_trait_decl(decl, file, source, source_map, &mut issues, None);
959 }
960 Some(StmtKind::Use(use_decl)) => {
961 let mut resolved_navigation_facts = Vec::new();
962 crate::body_analysis::check_use_decl_casing(
963 use_decl,
964 db,
965 file,
966 source,
967 source_map,
968 &mut issues,
969 None,
970 None,
971 Some(&mut resolved_navigation_facts),
972 false,
973 true,
974 false,
975 );
976 if let Some(fact) = resolved_navigation_fact_at(&resolved_navigation_facts, byte_offset)
977 {
978 return Some(fact.clone().into_resolved_symbol(file.clone()));
979 }
980 }
981 _ => {
982 driver.analyze_global_exec(program, file, source, source_map, &mut issues, None);
983 }
984 }
985
986 let facts = driver.take_resolved_navigation_facts();
987 resolved_navigation_fact_at(&facts, byte_offset)
988 .cloned()
989 .map(|fact| fact.into_resolved_symbol(file.clone()))
990}
991
992#[cfg(test)]
993mod tests {
994 use std::sync::Arc;
995 use std::time::{Duration, Instant};
996
997 use super::FileAnalyzer;
998 use crate::db::Work;
999 use crate::{symbol::ReferenceKind, AnalysisSession, PhpVersion, ResolvedSymbol};
1000
1001 fn session_for_source(path: &str, source: &str) -> (AnalysisSession, Arc<str>) {
1002 let mut session = AnalysisSession::new(PhpVersion::LATEST);
1003 let file: Arc<str> = Arc::from(path);
1004 session.ingest_file(file.clone(), Arc::from(source));
1005 (session, file)
1006 }
1007
1008 fn analyze_diagnostics_only(session: &mut AnalysisSession, file: Arc<str>, source: &str) {
1009 let parsed = php_rs_parser::parse(source);
1010 assert!(
1011 parsed.errors.is_empty(),
1012 "parser errors in test source: {:?}",
1013 parsed.errors
1014 );
1015
1016 let analysis = FileAnalyzer::new(session).analyze_diagnostics_only(
1017 file,
1018 source,
1019 &parsed.program,
1020 &parsed.source_map,
1021 );
1022 assert!(
1023 analysis.symbols.is_empty(),
1024 "diagnostics-only analysis should not retain whole-file symbols"
1025 );
1026 }
1027
1028 fn median(samples: &mut [Duration]) -> Duration {
1029 samples.sort_unstable();
1030 samples[samples.len() / 2]
1031 }
1032
1033 fn metric_section_lines<'a>(dump: &'a str, label: &str) -> Vec<&'a str> {
1034 let Some((_, rest)) = dump.split_once(label) else {
1035 return Vec::new();
1036 };
1037 rest.lines()
1038 .skip(1)
1039 .take_while(|line| line.starts_with(" "))
1040 .collect()
1041 }
1042
1043 fn assert_symbol_at_compact_without_fallback(
1044 session: &mut AnalysisSession,
1045 file: &Arc<str>,
1046 offset: u32,
1047 expected: impl FnOnce(&ResolvedSymbol),
1048 ) {
1049 let before = |work| session.work_count(work);
1050 let (compact, fallback, symbols) = (
1051 before(Work::SymbolAtCompact),
1052 before(Work::SymbolAtFallback),
1053 before(Work::SymbolAllocated),
1054 );
1055 let symbol = session
1056 .symbol_at(file.as_ref(), offset)
1057 .expect("symbol_at should find a symbol at the chosen offset");
1058 expected(&symbol);
1059
1060 assert_eq!(session.work_count(Work::SymbolAtCompact) - compact, 1);
1061 assert_eq!(session.work_count(Work::SymbolAtFallback) - fallback, 0);
1062 assert_eq!(
1063 session.work_count(Work::SymbolAllocated) - symbols,
1064 0,
1065 "compact symbol_at should not allocate legacy symbols"
1066 );
1067 }
1068
1069 #[test]
1070 fn name_at_records_compact_path_without_fallback() {
1071 let src = "<?php\nfunction helper(): void {}\nfunction caller(): void { helper(); }\n";
1072 let (mut session, file) = session_for_source("/proj/name_at_metrics.php", src);
1073 analyze_diagnostics_only(&mut session, file.clone(), src);
1074
1075 let offset = src.rfind("helper();").unwrap() as u32;
1076 let name = session
1077 .name_at(file.as_ref(), offset)
1078 .expect("name_at should resolve helper()");
1079
1080 assert_eq!(name, crate::Name::function("helper"));
1081
1082 assert_eq!(session.work_count(Work::NameAtCompact), 1);
1083 assert_eq!(session.work_count(Work::NameAtFallback), 0);
1084 assert_eq!(
1085 session.work_count(Work::SymbolAllocated),
1086 0,
1087 "compact name_at should not allocate legacy symbols"
1088 );
1089 }
1090
1091 #[test]
1092 fn name_at_top_level_exec_uses_compact_path_without_fallback() {
1093 let src = "<?php\nfunction helper(): void {}\nhelper();\n";
1094 let (mut session, file) = session_for_source("/proj/name_at_top_level_metrics.php", src);
1095 analyze_diagnostics_only(&mut session, file.clone(), src);
1096
1097 let offset = src.find("helper();").unwrap() as u32;
1098 let name = session
1099 .name_at(file.as_ref(), offset)
1100 .expect("name_at should resolve top-level helper()");
1101
1102 assert_eq!(name, crate::Name::function("helper"));
1103
1104 assert_eq!(session.work_count(Work::NameAtCompact), 1);
1105 assert_eq!(session.work_count(Work::NameAtFallback), 0);
1106 assert_eq!(
1107 session.work_count(Work::SymbolAllocated),
1108 0,
1109 "compact top-level name_at should not allocate legacy symbols"
1110 );
1111 }
1112
1113 #[test]
1114 fn symbol_at_records_compact_path_without_fallback() {
1115 let src = "<?php\nclass Box { public int $value = 0; }\nfunction read(Box $box): void { $box->value; }\n";
1116 let (mut session, file) = session_for_source("/proj/symbol_at_metrics.php", src);
1117 analyze_diagnostics_only(&mut session, file.clone(), src);
1118
1119 let offset = src.find("$box->value").unwrap() as u32 + "$box".len() as u32;
1120 assert_symbol_at_compact_without_fallback(&mut session, &file, offset, |symbol| {
1121 assert!(matches!(symbol.kind, ReferenceKind::Receiver));
1122 });
1123 }
1124
1125 #[test]
1126 fn symbol_at_top_level_exec_uses_compact_path_without_fallback() {
1127 let src = "<?php\nfunction helper(): void {}\nhelper();\n";
1128 let (mut session, file) = session_for_source("/proj/resolve_top_level_metrics.php", src);
1129 analyze_diagnostics_only(&mut session, file.clone(), src);
1130
1131 let offset = src.find("helper();").unwrap() as u32;
1132 assert_symbol_at_compact_without_fallback(&mut session, &file, offset, |symbol| {
1133 assert!(matches!(
1134 &symbol.kind,
1135 ReferenceKind::FunctionCall(name) if name.as_ref() == "helper"
1136 ));
1137 });
1138 }
1139
1140 #[test]
1141 fn symbol_at_method_call_uses_compact_path_without_fallback() {
1142 let src = "<?php\nclass Dep { public function next(): int { return 1; } }\nfunction run(Dep $dep): int { return $dep->next(); }\n";
1143 let (mut session, file) = session_for_source("/proj/resolve_method_metrics.php", src);
1144 analyze_diagnostics_only(&mut session, file.clone(), src);
1145
1146 let offset = src.find("->next()").unwrap() as u32 + 2;
1147 assert_symbol_at_compact_without_fallback(&mut session, &file, offset, |symbol| {
1148 assert!(matches!(
1149 &symbol.kind,
1150 ReferenceKind::MethodCall { class, method }
1151 if class.as_ref() == "Dep" && method.as_ref() == "next"
1152 ));
1153 });
1154 }
1155
1156 #[test]
1157 fn symbol_at_type_hint_uses_compact_path_without_fallback() {
1158 let src = "<?php\nclass Dep {}\nfunction run(Dep $dep): Dep { return $dep; }\n";
1159 let (mut session, file) = session_for_source("/proj/resolve_type_hint_metrics.php", src);
1160 analyze_diagnostics_only(&mut session, file.clone(), src);
1161
1162 let offset = src.find("run(Dep").unwrap() as u32 + "run(".len() as u32;
1163 assert_symbol_at_compact_without_fallback(&mut session, &file, offset, |symbol| {
1164 assert!(matches!(
1165 &symbol.kind,
1166 ReferenceKind::ClassReference(name) if name.as_ref() == "Dep"
1167 ));
1168 });
1169 }
1170
1171 #[test]
1172 fn symbol_at_use_import_uses_compact_path_without_fallback() {
1173 let mut session = AnalysisSession::new(PhpVersion::LATEST);
1174 let dep_file: Arc<str> = Arc::from("/proj/Dep.php");
1175 let main_file: Arc<str> = Arc::from("/proj/Main.php");
1176 let dep_src = "<?php\nnamespace App;\nclass Dep {}\n";
1177 let main_src = "<?php\nuse App\\Dep;\nfunction run(): Dep { return new Dep(); }\n";
1178 session.ingest_file(dep_file, Arc::from(dep_src));
1179 session.ingest_file(main_file.clone(), Arc::from(main_src));
1180 analyze_diagnostics_only(&mut session, main_file.clone(), main_src);
1181
1182 let offset = main_src.find("App\\Dep").unwrap() as u32 + "App\\".len() as u32;
1183 assert_symbol_at_compact_without_fallback(&mut session, &main_file, offset, |symbol| {
1184 assert!(matches!(
1185 &symbol.kind,
1186 ReferenceKind::UseImport(inner)
1187 if matches!(
1188 inner.as_ref(),
1189 ReferenceKind::ClassReference(name) if name.as_ref() == "App\\Dep"
1190 )
1191 ));
1192 });
1193 }
1194
1195 #[test]
1196 fn symbol_at_catch_clause_type_uses_compact_path_without_fallback() {
1197 let src = "<?php\nfinal class MyException extends \\Exception {}\nfunction run(): void {\n try {\n throw new MyException();\n } catch (MyException $e) {\n }\n}\n";
1198 let (mut session, file) = session_for_source("/proj/resolve_catch_metrics.php", src);
1199 analyze_diagnostics_only(&mut session, file.clone(), src);
1200
1201 let offset = src.rfind("catch (MyException").unwrap() as u32 + "catch (".len() as u32;
1202 assert_symbol_at_compact_without_fallback(&mut session, &file, offset, |symbol| {
1203 assert!(matches!(
1204 &symbol.kind,
1205 ReferenceKind::ClassReference(name) if name.as_ref() == "MyException"
1206 ));
1207 });
1208 }
1209
1210 #[test]
1211 fn symbol_at_static_property_write_class_uses_compact_path_without_fallback() {
1212 let src = "<?php\nclass Counter {\n public static int $count = 0;\n}\nfunction bump(): void {\n Counter::$count = Counter::$count + 1;\n}\n";
1213 let (mut session, file) = session_for_source("/proj/resolve_static_prop_metrics.php", src);
1214 analyze_diagnostics_only(&mut session, file.clone(), src);
1215
1216 let offset = src.find("Counter::$count =").unwrap() as u32;
1217 assert_symbol_at_compact_without_fallback(&mut session, &file, offset, |symbol| {
1218 assert!(matches!(
1219 &symbol.kind,
1220 ReferenceKind::ClassReference(name) if name.as_ref() == "Counter"
1221 ));
1222 });
1223 }
1224
1225 #[test]
1226 fn definition_uses_name_at_compact_path_without_fallback() {
1227 let src = "<?php\nclass Dep {}\nfunction run(Dep $dep): Dep { return $dep; }\n";
1228 let (mut session, file) = session_for_source("/proj/definition_metrics.php", src);
1229 analyze_diagnostics_only(&mut session, file.clone(), src);
1230
1231 let offset = src.find("run(Dep").unwrap() as u32 + "run(".len() as u32;
1232 let name = session
1233 .name_at(file.as_ref(), offset)
1234 .expect("name_at should resolve the Dep type hint");
1235 let loc = session
1236 .definition_of_cached(&name)
1237 .expect("Dep should have a declaration");
1238
1239 assert_eq!(loc.file.as_ref(), file.as_ref());
1240
1241 assert_eq!(session.work_count(Work::NameAtCompact), 1);
1242 assert_eq!(session.work_count(Work::NameAtFallback), 0);
1243 assert_eq!(
1244 session.work_count(Work::SymbolAtCompact) + session.work_count(Work::SymbolAtFallback),
1245 0,
1246 "definition lookup should not rely on symbol_at"
1247 );
1248 }
1249
1250 #[test]
1251 fn references_use_name_at_compact_path_without_fallback() {
1252 let src = "<?php\nfunction helper(): void {}\nfunction caller(): void { helper(); }\n";
1253 let (mut session, file) = session_for_source("/proj/references_metrics.php", src);
1254 analyze_diagnostics_only(&mut session, file.clone(), src);
1255
1256 let offset = src.find("helper();").unwrap() as u32 + 1;
1257 let name = session
1258 .name_at(file.as_ref(), offset)
1259 .expect("name_at should resolve helper()");
1260 let refs = session
1261 .indexed_references_to(
1262 &name,
1263 std::slice::from_ref(&file),
1264 false,
1265 crate::ReferenceIncludes::Plain,
1266 &|| false,
1267 )
1268 .expect("uncancelled references query");
1269
1270 assert!(
1271 refs.iter().any(|(f, _)| f.as_ref() == file.as_ref()),
1272 "references should include the helper() call site; got {refs:?}"
1273 );
1274
1275 assert_eq!(session.work_count(Work::NameAtCompact), 1);
1276 assert_eq!(session.work_count(Work::NameAtFallback), 0);
1277 assert_eq!(
1278 session.work_count(Work::SymbolAtCompact) + session.work_count(Work::SymbolAtFallback),
1279 0,
1280 "references lookup should not rely on symbol_at"
1281 );
1282 }
1283
1284 #[test]
1285 #[ignore]
1286 fn perf_report_post_diagnostics_navigation_micro() {
1287 crate::metrics::test_reset();
1288
1289 let src = "<?php
1290class Dep {
1291 public function next(int $value): int { return $value + 1; }
1292}
1293class Box {
1294 public int $value = 0;
1295}
1296function helper(Dep $dep, int $value): int {
1297 return $dep->next($value);
1298}
1299function read_value(int $value): int {
1300 return $value;
1301}
1302function read_box(Box $box): int {
1303 return $box->value;
1304}
1305";
1306 let (mut session, file) = session_for_source("/proj/perf_nav.php", src);
1307 let mut import_session = AnalysisSession::new(PhpVersion::LATEST);
1308 let dep_file: Arc<str> = Arc::from("/proj/import/Dep.php");
1309 let main_file: Arc<str> = Arc::from("/proj/import/Main.php");
1310 let dep_src = "<?php\nnamespace App;\nclass Dep {}\n";
1311 let main_src = "<?php\nuse App\\Dep;\nfunction run(): Dep { return new Dep(); }\n";
1312 import_session.ingest_file(dep_file, Arc::from(dep_src));
1313 import_session.ingest_file(main_file.clone(), Arc::from(main_src));
1314
1315 let parsed = php_rs_parser::parse(src);
1316 assert!(parsed.errors.is_empty(), "fixture must parse cleanly");
1317 let main_parsed = php_rs_parser::parse(main_src);
1318 assert!(
1319 main_parsed.errors.is_empty(),
1320 "import fixture must parse cleanly"
1321 );
1322
1323 let t0 = Instant::now();
1324 let analysis = FileAnalyzer::new(&mut session).analyze_diagnostics_only(
1325 file.clone(),
1326 src,
1327 &parsed.program,
1328 &parsed.source_map,
1329 );
1330 let same_file_diagnostics_time = t0.elapsed();
1331 assert!(analysis.symbols.is_empty());
1332
1333 let t0 = Instant::now();
1334 let main_analysis = FileAnalyzer::new(&mut import_session).analyze_diagnostics_only(
1335 main_file.clone(),
1336 main_src,
1337 &main_parsed.program,
1338 &main_parsed.source_map,
1339 );
1340 let import_diagnostics_time = t0.elapsed();
1341 assert!(main_analysis.symbols.is_empty());
1342
1343 let method_name_offset = src.find("next($value)").unwrap() as u32;
1344 let method_resolve_offset = src.find("->next($value)").unwrap() as u32 + 2;
1345 let variable_resolve_offset =
1346 src.rfind("return $value;").unwrap() as u32 + "return ".len() as u32 + 1;
1347 let type_hint_offset = src.find("helper(Dep").unwrap() as u32 + "helper(".len() as u32;
1348 let receiver_gap_offset = src.find("$box->value").unwrap() as u32 + "$box".len() as u32;
1349 let import_resolve_offset =
1350 main_src.find("App\\Dep").unwrap() as u32 + "App\\".len() as u32;
1351 let definition_offset = src.find("Dep $dep").unwrap() as u32;
1352 let references_offset = method_name_offset;
1353
1354 let t0 = Instant::now();
1355 let first_name = session.name_at(file.as_ref(), method_name_offset);
1356 let first_name_time = t0.elapsed();
1357 assert_eq!(first_name, Some(crate::Name::method("Dep", "next")));
1358
1359 let t0 = Instant::now();
1360 let first_method_resolve = session.symbol_at(file.as_ref(), method_resolve_offset);
1361 let first_method_resolve_time = t0.elapsed();
1362 assert!(matches!(
1363 first_method_resolve.as_ref().map(|s| &s.kind),
1364 Some(ReferenceKind::MethodCall { class, method })
1365 if class.as_ref() == "Dep" && method.as_ref() == "next"
1366 ));
1367
1368 let t0 = Instant::now();
1369 let first_variable_resolve = session.symbol_at(file.as_ref(), variable_resolve_offset);
1370 let first_variable_resolve_time = t0.elapsed();
1371 assert!(matches!(
1372 first_variable_resolve.as_ref().map(|s| &s.kind),
1373 Some(ReferenceKind::Variable(name)) if name.as_ref() == "value"
1374 ));
1375
1376 let t0 = Instant::now();
1377 let first_type_hint_resolve = session.symbol_at(file.as_ref(), type_hint_offset);
1378 let first_type_hint_resolve_time = t0.elapsed();
1379 assert!(matches!(
1380 first_type_hint_resolve.as_ref().map(|s| &s.kind),
1381 Some(ReferenceKind::ClassReference(name)) if name.as_ref() == "Dep"
1382 ));
1383
1384 let t0 = Instant::now();
1385 let first_receiver_gap_resolve = session.symbol_at(file.as_ref(), receiver_gap_offset);
1386 let first_receiver_gap_resolve_time = t0.elapsed();
1387 assert!(matches!(
1388 first_receiver_gap_resolve.as_ref().map(|s| &s.kind),
1389 Some(ReferenceKind::Receiver)
1390 ));
1391
1392 let t0 = Instant::now();
1393 let first_import_resolve =
1394 import_session.symbol_at(main_file.as_ref(), import_resolve_offset);
1395 let first_import_resolve_time = t0.elapsed();
1396 assert!(matches!(
1397 first_import_resolve.as_ref().map(|s| &s.kind),
1398 Some(ReferenceKind::UseImport(inner))
1399 if matches!(
1400 inner.as_ref(),
1401 ReferenceKind::ClassReference(name) if name.as_ref() == "App\\Dep"
1402 )
1403 ));
1404
1405 let t0 = Instant::now();
1406 let first_definition = session
1407 .name_at(file.as_ref(), definition_offset)
1408 .map(|name| session.definition_of_cached(&name));
1409 let first_definition_time = t0.elapsed();
1410 assert!(
1411 matches!(first_definition, Some(Ok(_))),
1412 "definition lookup should succeed on Dep type hint"
1413 );
1414
1415 let t0 = Instant::now();
1416 let first_references = session
1417 .name_at(file.as_ref(), references_offset)
1418 .and_then(|name| {
1419 session.indexed_references_to(
1420 &name,
1421 std::slice::from_ref(&file),
1422 false,
1423 crate::ReferenceIncludes::Plain,
1424 &|| false,
1425 )
1426 });
1427 let first_references_time = t0.elapsed();
1428 assert!(
1429 first_references
1430 .as_ref()
1431 .is_some_and(|refs| refs.iter().any(|(f, _)| f.as_ref() == file.as_ref())),
1432 "references lookup should succeed on helper-like method call"
1433 );
1434
1435 const ITERS: usize = 100;
1436 let mut name_samples = Vec::with_capacity(ITERS);
1437 let mut method_resolve_samples = Vec::with_capacity(ITERS);
1438 let mut variable_resolve_samples = Vec::with_capacity(ITERS);
1439 let mut type_hint_resolve_samples = Vec::with_capacity(ITERS);
1440 let mut receiver_gap_resolve_samples = Vec::with_capacity(ITERS);
1441 let mut import_resolve_samples = Vec::with_capacity(ITERS);
1442 let mut definition_samples = Vec::with_capacity(ITERS);
1443 let mut references_samples = Vec::with_capacity(ITERS);
1444
1445 for _ in 0..ITERS {
1446 let t0 = Instant::now();
1447 let name = session.name_at(file.as_ref(), method_name_offset);
1448 name_samples.push(t0.elapsed());
1449 assert_eq!(name, Some(crate::Name::method("Dep", "next")));
1450
1451 let t0 = Instant::now();
1452 let resolve = session.symbol_at(file.as_ref(), method_resolve_offset);
1453 method_resolve_samples.push(t0.elapsed());
1454 assert!(matches!(
1455 resolve.as_ref().map(|s| &s.kind),
1456 Some(ReferenceKind::MethodCall { class, method })
1457 if class.as_ref() == "Dep" && method.as_ref() == "next"
1458 ));
1459
1460 let t0 = Instant::now();
1461 let resolve = session.symbol_at(file.as_ref(), variable_resolve_offset);
1462 variable_resolve_samples.push(t0.elapsed());
1463 assert!(matches!(
1464 resolve.as_ref().map(|s| &s.kind),
1465 Some(ReferenceKind::Variable(name)) if name.as_ref() == "value"
1466 ));
1467
1468 let t0 = Instant::now();
1469 let resolve = session.symbol_at(file.as_ref(), type_hint_offset);
1470 type_hint_resolve_samples.push(t0.elapsed());
1471 assert!(matches!(
1472 resolve.as_ref().map(|s| &s.kind),
1473 Some(ReferenceKind::ClassReference(name)) if name.as_ref() == "Dep"
1474 ));
1475
1476 let t0 = Instant::now();
1477 let resolve = session.symbol_at(file.as_ref(), receiver_gap_offset);
1478 receiver_gap_resolve_samples.push(t0.elapsed());
1479 assert!(matches!(
1480 resolve.as_ref().map(|s| &s.kind),
1481 Some(ReferenceKind::Receiver)
1482 ));
1483
1484 let t0 = Instant::now();
1485 let resolve = import_session.symbol_at(main_file.as_ref(), import_resolve_offset);
1486 import_resolve_samples.push(t0.elapsed());
1487 assert!(matches!(
1488 resolve.as_ref().map(|s| &s.kind),
1489 Some(ReferenceKind::UseImport(inner))
1490 if matches!(
1491 inner.as_ref(),
1492 ReferenceKind::ClassReference(name) if name.as_ref() == "App\\Dep"
1493 )
1494 ));
1495
1496 let t0 = Instant::now();
1497 let definition = session
1498 .name_at(file.as_ref(), definition_offset)
1499 .map(|name| session.definition_of_cached(&name));
1500 definition_samples.push(t0.elapsed());
1501 assert!(matches!(definition, Some(Ok(_))));
1502
1503 let t0 = Instant::now();
1504 let references = session
1505 .name_at(file.as_ref(), references_offset)
1506 .and_then(|name| {
1507 session.indexed_references_to(
1508 &name,
1509 std::slice::from_ref(&file),
1510 false,
1511 crate::ReferenceIncludes::Plain,
1512 &|| false,
1513 )
1514 });
1515 references_samples.push(t0.elapsed());
1516 assert!(references
1517 .as_ref()
1518 .is_some_and(|refs| refs.iter().any(|(f, _)| f.as_ref() == file.as_ref())));
1519 }
1520
1521 let repeat_name_p50 = median(&mut name_samples);
1522 let repeat_method_resolve_p50 = median(&mut method_resolve_samples);
1523 let repeat_variable_resolve_p50 = median(&mut variable_resolve_samples);
1524 let repeat_type_hint_resolve_p50 = median(&mut type_hint_resolve_samples);
1525 let repeat_receiver_gap_resolve_p50 = median(&mut receiver_gap_resolve_samples);
1526 let repeat_import_resolve_p50 = median(&mut import_resolve_samples);
1527 let repeat_definition_p50 = median(&mut definition_samples);
1528 let repeat_references_p50 = median(&mut references_samples);
1529 let dump = crate::metrics::dump().expect("metrics enabled in tests");
1530 let expected_resolve_calls = 5 * (ITERS + 1);
1531 let expected_name_calls = 3 * (ITERS + 1);
1532
1533 assert!(
1534 dump.contains(&format!(
1535 "symbol_at path : compact {expected_resolve_calls} fallback 0"
1536 )),
1537 "expanded probe should stay fully on the compact symbol_at path, got:\n{dump}"
1538 );
1539 assert!(
1540 dump.contains(&format!(
1541 "name_at path : compact {expected_name_calls} fallback 0"
1542 )),
1543 "expanded probe should stay fully on the compact name_at path, got:\n{dump}"
1544 );
1545
1546 println!();
1547 println!("post_diagnostics_navigation_micro");
1548 println!(
1549 " diagnostics_only same-file : {} us",
1550 same_file_diagnostics_time.as_micros()
1551 );
1552 println!(
1553 " diagnostics_only import : {} us",
1554 import_diagnostics_time.as_micros()
1555 );
1556 println!(
1557 " first name_at method : {} us",
1558 first_name_time.as_micros()
1559 );
1560 println!(
1561 " first symbol_at method : {} us",
1562 first_method_resolve_time.as_micros()
1563 );
1564 println!(
1565 " first symbol_at variable : {} us",
1566 first_variable_resolve_time.as_micros()
1567 );
1568 println!(
1569 " first symbol_at type_hint : {} us",
1570 first_type_hint_resolve_time.as_micros()
1571 );
1572 println!(
1573 " first symbol_at receiver : {} us",
1574 first_receiver_gap_resolve_time.as_micros()
1575 );
1576 println!(
1577 " first symbol_at import : {} us",
1578 first_import_resolve_time.as_micros()
1579 );
1580 println!(
1581 " first definition : {} us",
1582 first_definition_time.as_micros()
1583 );
1584 println!(
1585 " first references : {} us",
1586 first_references_time.as_micros()
1587 );
1588 println!(
1589 " repeat name_at method p50 : {} us",
1590 repeat_name_p50.as_micros()
1591 );
1592 println!(
1593 " repeat resolve method p50 : {} us",
1594 repeat_method_resolve_p50.as_micros()
1595 );
1596 println!(
1597 " repeat resolve variable p50: {} us",
1598 repeat_variable_resolve_p50.as_micros()
1599 );
1600 println!(
1601 " repeat resolve hint p50 : {} us",
1602 repeat_type_hint_resolve_p50.as_micros()
1603 );
1604 println!(
1605 " repeat resolve recv p50 : {} us",
1606 repeat_receiver_gap_resolve_p50.as_micros()
1607 );
1608 println!(
1609 " repeat resolve import p50 : {} us",
1610 repeat_import_resolve_p50.as_micros()
1611 );
1612 println!(
1613 " repeat definition p50 : {} us",
1614 repeat_definition_p50.as_micros()
1615 );
1616 println!(
1617 " repeat references p50 : {} us",
1618 repeat_references_p50.as_micros()
1619 );
1620 println!("{dump}");
1621 }
1622
1623 #[test]
1624 #[ignore]
1625 fn perf_report_edit_locality_micro() {
1626 crate::metrics::test_reset();
1627
1628 let mut session = AnalysisSession::new(PhpVersion::LATEST);
1629 let base_file: Arc<str> = Arc::from("/proj/edit/Base.php");
1630 let dep_a_file: Arc<str> = Arc::from("/proj/edit/DepA.php");
1631 let dep_b_file: Arc<str> = Arc::from("/proj/edit/DepB.php");
1632 let leaf_file: Arc<str> = Arc::from("/proj/edit/Leaf.php");
1633
1634 let base_src = "<?php
1635class Base {
1636 public function id(int $n): int { return $n; }
1637}
1638";
1639 let dep_a_src = "<?php
1640class DepA extends Base {
1641 public function run(int $n): int { return $this->id($n) + 1; }
1642}
1643";
1644 let dep_b_src = "<?php
1645class DepB extends Base {
1646 public function run(int $n): int { return $this->id($n) + 2; }
1647}
1648";
1649 let leaf_src = "<?php
1650function leaf_value(int $n): int { return $n + 1; }
1651";
1652
1653 session.ingest_file(base_file.clone(), Arc::from(base_src));
1654 session.ingest_file(dep_a_file.clone(), Arc::from(dep_a_src));
1655 session.ingest_file(dep_b_file.clone(), Arc::from(dep_b_src));
1656 session.ingest_file(leaf_file.clone(), Arc::from(leaf_src));
1657
1658 let dep_a_parsed = php_rs_parser::parse(dep_a_src);
1659 let dep_b_parsed = php_rs_parser::parse(dep_b_src);
1660 let leaf_parsed = php_rs_parser::parse(leaf_src);
1661 assert!(dep_a_parsed.errors.is_empty());
1662 assert!(dep_b_parsed.errors.is_empty());
1663 assert!(leaf_parsed.errors.is_empty());
1664
1665 let _ = FileAnalyzer::new(&mut session).analyze_diagnostics_only(
1666 dep_a_file.clone(),
1667 dep_a_src,
1668 &dep_a_parsed.program,
1669 &dep_a_parsed.source_map,
1670 );
1671 let _ = FileAnalyzer::new(&mut session).analyze_diagnostics_only(
1672 dep_b_file.clone(),
1673 dep_b_src,
1674 &dep_b_parsed.program,
1675 &dep_b_parsed.source_map,
1676 );
1677 let _ = FileAnalyzer::new(&mut session).analyze_diagnostics_only(
1678 leaf_file.clone(),
1679 leaf_src,
1680 &leaf_parsed.program,
1681 &leaf_parsed.source_map,
1682 );
1683
1684 const ITERS: usize = 25;
1685 let mut leaf_ingest_samples = Vec::with_capacity(ITERS);
1686 let mut leaf_reanalyze_samples = Vec::with_capacity(ITERS);
1687 let mut base_ingest_samples = Vec::with_capacity(ITERS);
1688 let mut base_reanalyze_samples = Vec::with_capacity(ITERS);
1689
1690 crate::metrics::test_reset();
1693
1694 for i in 0..ITERS {
1695 let leaf_edit = format!(
1696 "<?php\nfunction leaf_value(int $n): int {{ return $n + 1; }}\n// leaf edit {i}\n"
1697 );
1698 let t0 = Instant::now();
1699 session.ingest_file(leaf_file.clone(), Arc::from(leaf_edit.as_str()));
1700 leaf_ingest_samples.push(t0.elapsed());
1701
1702 let t0 = Instant::now();
1703 let leaf_results = session.reanalyze_dependents(leaf_file.as_ref());
1704 leaf_reanalyze_samples.push(t0.elapsed());
1705 assert!(
1706 leaf_results.is_empty(),
1707 "leaf file should have no dependents; got {:?}",
1708 leaf_results
1709 .iter()
1710 .map(|(file, _)| file.as_ref())
1711 .collect::<Vec<_>>()
1712 );
1713 }
1714
1715 let leaf_dump = crate::metrics::dump().expect("metrics enabled in tests");
1716 assert!(
1717 leaf_dump.contains("whole-file walks : 0"),
1718 "leaf edits should not trigger body walks outside the edited file path, got:\n{leaf_dump}"
1719 );
1720 assert!(
1721 leaf_dump.contains("scopes analyzed : 0"),
1722 "leaf edits with no dependents should not trigger per-scope reanalysis, got:\n{leaf_dump}"
1723 );
1724
1725 crate::metrics::test_reset();
1726
1727 for i in 0..ITERS {
1728 let base_edit = format!(
1729 "<?php\nclass Base {{\n public function id(int $n): int {{ return $n; }}\n}}\n// base edit {i}\n"
1730 );
1731 let t0 = Instant::now();
1732 session.ingest_file(base_file.clone(), Arc::from(base_edit.as_str()));
1733 base_ingest_samples.push(t0.elapsed());
1734
1735 let t0 = Instant::now();
1736 let base_results = session.reanalyze_dependents(base_file.as_ref());
1737 base_reanalyze_samples.push(t0.elapsed());
1738 let base_files: Vec<&str> =
1739 base_results.iter().map(|(file, _)| file.as_ref()).collect();
1740 assert!(
1741 base_files.contains(&dep_a_file.as_ref())
1742 && base_files.contains(&dep_b_file.as_ref()),
1743 "base edit should reanalyze both dependents; got {base_files:?}"
1744 );
1745 }
1746
1747 let leaf_ingest_p50 = median(&mut leaf_ingest_samples);
1748 let leaf_reanalyze_p50 = median(&mut leaf_reanalyze_samples);
1749 let base_ingest_p50 = median(&mut base_ingest_samples);
1750 let base_reanalyze_p50 = median(&mut base_reanalyze_samples);
1751 let base_dump = crate::metrics::dump().expect("metrics enabled in tests");
1752 let body_walks = metric_section_lines(&base_dump, " top body walks/file:");
1753 let scope_walks = metric_section_lines(&base_dump, " top scopes analyzed/file:");
1754
1755 assert!(
1756 base_dump.contains("whole-file walks : 0"),
1757 "base edit loop should avoid whole-file body walks after migrating inferred types to per-scope queries, got:\n{base_dump}"
1758 );
1759 assert!(
1760 body_walks.is_empty(),
1761 "base edit loop should not report any whole-file body-walk metrics anymore, got:\n{base_dump}"
1762 );
1763 assert!(
1764 scope_walks.iter().any(|line| line.contains(base_file.as_ref()))
1765 && scope_walks.iter().any(|line| line.contains(dep_a_file.as_ref()))
1766 && scope_walks.iter().any(|line| line.contains(dep_b_file.as_ref())),
1767 "base edit loop should stay entirely on per-scope Salsa queries for the edited file and both dependents, got:\n{base_dump}"
1768 );
1769
1770 println!();
1771 println!("edit_locality_micro");
1772 println!(
1773 " leaf ingest p50 : {} us",
1774 leaf_ingest_p50.as_micros()
1775 );
1776 println!(
1777 " leaf reanalyze p50 : {} us",
1778 leaf_reanalyze_p50.as_micros()
1779 );
1780 println!(
1781 " base ingest p50 : {} us",
1782 base_ingest_p50.as_micros()
1783 );
1784 println!(
1785 " base reanalyze p50 : {} us",
1786 base_reanalyze_p50.as_micros()
1787 );
1788 println!(" leaf metrics:");
1789 println!("{leaf_dump}");
1790 println!(" base metrics:");
1791 println!("{base_dump}");
1792 }
1793}