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