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