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