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