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