use std::sync::Arc;
use mir_issues::Issue;
use php_ast::owned::{Program, Stmt, StmtKind};
use php_ast::Span;
use php_rs_parser::source_map::SourceMap;
use rustc_hash::FxHashSet;
use crate::body_analysis::BodyAnalyzer;
use crate::db::MirDatabase;
use crate::session::{AnalysisSession, AnalysisSnapshot};
use crate::symbol::{NavigationFact, ResolvedNavigationFact, ResolvedSymbol};
pub struct FileAnalysis {
pub issues: Vec<Issue>,
pub symbols: Vec<ResolvedSymbol>,
}
impl FileAnalysis {
pub fn symbol_at(&self, byte_offset: u32) -> Option<&ResolvedSymbol> {
symbol_at(&self.symbols, byte_offset)
}
}
fn span_contains(span: Span, byte_offset: u32) -> bool {
span.start <= byte_offset && byte_offset < span.end
}
fn span_len(span: Span) -> u32 {
span.end.saturating_sub(span.start)
}
fn best_at<T>(
items: &[T],
span: impl Fn(&T) -> Span,
expr_span: impl Fn(&T) -> Option<Span>,
byte_offset: u32,
allow_expr_fallback: bool,
) -> Option<&T> {
items
.iter()
.filter(|item| span_contains(span(item), byte_offset))
.min_by_key(|item| span_len(span(item)))
.or_else(|| {
items
.iter()
.filter(|item| span(item).end == byte_offset)
.min_by_key(|item| span_len(span(item)))
})
.or_else(|| {
if !allow_expr_fallback {
return None;
}
items
.iter()
.filter(|item| expr_span(item).is_some_and(|es| span_contains(es, byte_offset)))
.min_by_key(|item| expr_span(item).map(span_len).unwrap_or(u32::MAX))
})
}
fn ends_identifier(source: &str, byte_offset: u32) -> bool {
let is_ident = |b: u8| b.is_ascii_alphanumeric() || b == b'_' || b >= 0x80;
let bytes = source.as_bytes();
let at = byte_offset as usize;
at > 0
&& bytes.get(at - 1).is_some_and(|b| is_ident(*b))
&& !bytes.get(at).is_some_and(|b| is_ident(*b))
}
fn symbol_at(symbols: &[ResolvedSymbol], byte_offset: u32) -> Option<&ResolvedSymbol> {
best_at(symbols, |s| s.span, |s| s.expr_span, byte_offset, true)
}
fn navigation_fact_at(
facts: &[NavigationFact],
byte_offset: u32,
allow_expr_fallback: bool,
) -> Option<&NavigationFact> {
best_at(
facts,
|f| f.span,
|f| f.expr_span,
byte_offset,
allow_expr_fallback,
)
}
fn resolved_navigation_fact_at(
facts: &[ResolvedNavigationFact],
byte_offset: u32,
) -> Option<&ResolvedNavigationFact> {
best_at(facts, |f| f.span, |f| f.expr_span, byte_offset, true)
}
fn for_each_navigation_scope<'a>(stmts: &'a [Stmt], f: &mut impl FnMut(&'a Stmt)) {
for stmt in stmts.iter() {
visit_navigation_scope_stmt(stmt, f);
}
}
fn best_navigation_scope_stmt(program: &Program, byte_offset: u32) -> Option<&Stmt> {
let mut best_stmt: Option<&Stmt> = None;
for_each_navigation_scope(&program.stmts, &mut |stmt| {
if !span_contains(navigation_scope_span(stmt), byte_offset) {
return;
}
if !matches!(
stmt.kind,
StmtKind::Function(_)
| StmtKind::Class(_)
| StmtKind::Enum(_)
| StmtKind::Interface(_)
| StmtKind::Trait(_)
| StmtKind::Use(_)
) {
return;
}
if best_stmt.is_none_or(|best| {
span_len(navigation_scope_span(stmt)) <= span_len(navigation_scope_span(best))
}) {
best_stmt = Some(stmt);
}
});
best_stmt
}
fn navigation_scope_span(stmt: &Stmt) -> Span {
use php_ast::owned::StmtKind;
fn widen_with_comment_and_attrs(
span: Span,
doc_comment: Option<&php_ast::owned::Comment>,
attrs: &[php_ast::owned::Attribute],
) -> Span {
let attr_start = attrs.first().map(|attr| attr.span.start);
let doc_start = doc_comment.map(|comment| comment.span.start);
let start = attr_start
.into_iter()
.chain(doc_start)
.min()
.unwrap_or(span.start);
Span::new(start, span.end)
}
match &stmt.kind {
StmtKind::Function(decl) => {
widen_with_comment_and_attrs(stmt.span, decl.doc_comment.as_ref(), &decl.attributes)
}
StmtKind::Class(decl) => {
widen_with_comment_and_attrs(stmt.span, decl.doc_comment.as_ref(), &decl.attributes)
}
StmtKind::Enum(decl) => {
widen_with_comment_and_attrs(stmt.span, decl.doc_comment.as_ref(), &decl.attributes)
}
StmtKind::Interface(decl) => {
widen_with_comment_and_attrs(stmt.span, decl.doc_comment.as_ref(), &decl.attributes)
}
StmtKind::Trait(decl) => {
widen_with_comment_and_attrs(stmt.span, decl.doc_comment.as_ref(), &decl.attributes)
}
_ => stmt.span,
}
}
fn visit_navigation_scope_stmt<'a>(stmt: &'a Stmt, f: &mut impl FnMut(&'a Stmt)) {
use php_ast::owned::NamespaceBody;
match &stmt.kind {
StmtKind::If(s) => {
visit_navigation_scope_stmt(&s.then_branch, f);
for branch in s.elseif_branches.iter() {
visit_navigation_scope_stmt(&branch.body, f);
}
if let Some(else_branch) = &s.else_branch {
visit_navigation_scope_stmt(else_branch, f);
}
}
StmtKind::While(s) => visit_navigation_scope_stmt(&s.body, f),
StmtKind::For(s) => visit_navigation_scope_stmt(&s.body, f),
StmtKind::Foreach(s) => visit_navigation_scope_stmt(&s.body, f),
StmtKind::DoWhile(s) => visit_navigation_scope_stmt(&s.body, f),
StmtKind::Switch(s) => {
for case in s.body.cases.iter() {
for inner in case.body.iter() {
visit_navigation_scope_stmt(inner, f);
}
}
}
StmtKind::TryCatch(t) => {
for inner in t.body.stmts.iter() {
visit_navigation_scope_stmt(inner, f);
}
for catch in t.catches.iter() {
for inner in catch.body.stmts.iter() {
visit_navigation_scope_stmt(inner, f);
}
}
if let Some(finally) = &t.finally {
for inner in finally.stmts.iter() {
visit_navigation_scope_stmt(inner, f);
}
}
}
StmtKind::Block(b) => {
for inner in b.stmts.iter() {
visit_navigation_scope_stmt(inner, f);
}
}
StmtKind::Declare(d) => {
if let Some(body) = &d.body {
visit_navigation_scope_stmt(body, f);
}
}
StmtKind::Namespace(ns) => {
if let NamespaceBody::Braced(block) = &ns.body {
for inner in block.stmts.iter() {
visit_navigation_scope_stmt(inner, f);
}
}
}
_ => f(stmt),
}
}
#[allow(clippy::too_many_arguments)]
fn resolve_scope_symbols(
db: &dyn MirDatabase,
php_version: crate::PhpVersion,
file: Arc<str>,
source: &str,
program: &Program,
source_map: &SourceMap,
byte_offset: u32,
capture_symbol_types: bool,
codebase_symbols_only: bool,
) -> Vec<ResolvedSymbol> {
let best_stmt = best_navigation_scope_stmt(program, byte_offset);
let mut driver = match best_stmt.map(|stmt| &stmt.kind) {
Some(
StmtKind::Function(_)
| StmtKind::Class(_)
| StmtKind::Enum(_)
| StmtKind::Interface(_)
| StmtKind::Trait(_),
) => BodyAnalyzer::new_inference_only(db, php_version),
_ => BodyAnalyzer::new_inference_only(db, php_version),
};
driver.capture_symbol_types = capture_symbol_types;
driver.codebase_symbols_only = codebase_symbols_only;
driver.record_reference_locations = false;
let mut issues = Vec::new();
let mut symbols = Vec::new();
let guards: FxHashSet<Arc<str>> = FxHashSet::default();
match best_stmt.map(|stmt| &stmt.kind) {
Some(StmtKind::Function(decl)) => {
driver.analyze_fn_decl(
decl,
&file,
source,
source_map,
&mut issues,
Some(&mut symbols),
);
}
Some(StmtKind::Class(decl)) => {
driver.analyze_class_decl(
decl,
&file,
source,
source_map,
&mut issues,
Some(&mut symbols),
&guards,
);
}
Some(StmtKind::Enum(decl)) => {
driver.analyze_enum_decl(
decl,
&file,
source,
source_map,
&mut issues,
Some(&mut symbols),
);
}
Some(StmtKind::Interface(decl)) => {
driver.analyze_interface_decl(
decl,
&file,
source,
source_map,
&mut issues,
&guards,
Some(&mut symbols),
);
}
Some(StmtKind::Trait(decl)) => {
driver.analyze_trait_decl(
decl,
&file,
source,
source_map,
&mut issues,
Some(&mut symbols),
);
}
Some(StmtKind::Use(use_decl)) => {
let mut resolved_navigation_facts = Vec::new();
crate::body_analysis::check_use_decl_casing(
use_decl,
db,
&file,
source,
source_map,
&mut issues,
None,
None,
Some(&mut resolved_navigation_facts),
false,
true,
driver.record_reference_locations,
);
if let Some(fact) = resolved_navigation_fact_at(&resolved_navigation_facts, byte_offset)
{
return vec![fact.clone().into_resolved_symbol(file.clone())];
}
}
_ => {
driver.analyze_global_exec(
program,
&file,
source,
source_map,
&mut issues,
Some(&mut symbols),
);
}
}
symbols
}
pub struct FileAnalyzer<'a> {
session: &'a mut AnalysisSession,
}
impl<'a> FileAnalyzer<'a> {
pub fn new(session: &'a mut AnalysisSession) -> Self {
Self { session }
}
pub fn analyze(
&mut self,
file: Arc<str>,
source: &str,
program: &Program,
source_map: &SourceMap,
) -> FileAnalysis {
self.analyze_with_symbols(file, source, program, source_map, true)
}
pub fn analyze_diagnostics_only(
&mut self,
file: Arc<str>,
source: &str,
program: &Program,
source_map: &SourceMap,
) -> FileAnalysis {
self.analyze_with_symbols(file, source, program, source_map, false)
}
fn analyze_with_symbols(
&mut self,
file: Arc<str>,
source: &str,
program: &Program,
source_map: &SourceMap,
collect_symbols: bool,
) -> FileAnalysis {
self.session.settle_workspace_index();
let prepare_generation = self.session.prepare_generation_snapshot();
let ingested_text = {
let view = self.session.db_view();
let db = view.db();
db.lookup_source_file(file.as_ref())
.map(|sf| sf.text(db as &dyn crate::db::MirDatabase).clone())
};
self.session
.prepare_ast_for_analysis(program, file.as_ref());
if let Some(text) = ingested_text {
self.session
.mark_prepared_for_analysis(&file, text, prepare_generation);
}
self.session.query_snapshot(|snap| {
snap.analyze_with_symbols(file.clone(), source, program, source_map, collect_symbols)
})
}
pub(crate) fn symbol_at(&mut self, file: Arc<str>, byte_offset: u32) -> Option<ResolvedSymbol> {
self.session.prepare_for_query(Some(&file));
self.session
.query_snapshot(|snap| snap.symbol_at(&file, byte_offset))
}
pub(crate) fn name_at(&mut self, file: Arc<str>, byte_offset: u32) -> Option<crate::Name> {
self.session.prepare_for_query(Some(&file));
self.session
.query_snapshot(|snap| snap.name_at(&file, byte_offset))
}
pub(crate) fn outgoing_calls(
&mut self,
file: Arc<str>,
byte_offset: u32,
) -> Vec<(crate::Name, crate::Range)> {
self.session.prepare_for_query(Some(&file));
self.session
.query_snapshot(|snap| snap.outgoing_calls(&file, byte_offset))
}
pub(crate) fn variable_references(
&mut self,
file: Arc<str>,
byte_offset: u32,
) -> Vec<crate::Range> {
self.session.prepare_for_query(Some(&file));
self.session
.query_snapshot(|snap| snap.variable_references(&file, byte_offset))
}
}
impl AnalysisSnapshot {
pub fn analyze(
&self,
file: Arc<str>,
source: &str,
program: &Program,
source_map: &SourceMap,
) -> Result<FileAnalysis, salsa::Cancelled> {
self.analyze_with_symbols(file, source, program, source_map, true)
}
pub fn analyze_diagnostics_only(
&self,
file: Arc<str>,
source: &str,
program: &Program,
source_map: &SourceMap,
) -> Result<FileAnalysis, salsa::Cancelled> {
self.analyze_with_symbols(file, source, program, source_map, false)
}
fn analyze_with_symbols(
&self,
file: Arc<str>,
source: &str,
program: &Program,
source_map: &SourceMap,
collect_symbols: bool,
) -> Result<FileAnalysis, salsa::Cancelled> {
let (analysis, db, text) = salsa::Cancelled::catch(std::panic::AssertUnwindSafe(|| {
crate::metrics::record_file_analysis();
let _scope = crate::metrics::BodyAnalysisScope::new();
let db = self.db().clone();
let text = db
.lookup_source_file(file.as_ref())
.map(|sf| sf.text(&db as &dyn MirDatabase).clone());
let mut driver = BodyAnalyzer::new(&db, self.php_version());
driver.collect_symbols = collect_symbols;
let (issues, symbols) =
driver.analyze_bodies(program, file.clone(), source, source_map);
(FileAnalysis { issues, symbols }, db, text)
}))?;
let resolved = !crate::db::issues_have_unresolved_names(&analysis.issues);
self.index().commit_file_refs(
&db,
&file,
text,
db.take_pending_ref_locs(),
resolved,
self.stamp(),
)?;
Ok(analysis)
}
pub fn symbol_at(
&self,
file: &Arc<str>,
byte_offset: u32,
) -> Result<Option<ResolvedSymbol>, salsa::Cancelled> {
let started = std::time::Instant::now();
let sym =
self.read(|db| resolve_symbol_at(db, self.php_version(), file, byte_offset, true));
crate::metrics::record_symbol_at(started.elapsed().as_micros() as u64);
sym
}
pub fn name_at(
&self,
file: &Arc<str>,
byte_offset: u32,
) -> Result<Option<crate::Name>, salsa::Cancelled> {
let started = std::time::Instant::now();
let name = self.read(|db| resolve_name_at(db, self.php_version(), file, byte_offset));
crate::metrics::record_name_at(started.elapsed().as_micros() as u64);
name
}
pub fn outgoing_calls(
&self,
file: &Arc<str>,
byte_offset: u32,
) -> Result<Vec<(crate::Name, crate::Range)>, salsa::Cancelled> {
self.read(|db| resolve_outgoing_calls(db, self.php_version(), file, byte_offset))
}
pub fn variable_references(
&self,
file: &Arc<str>,
byte_offset: u32,
) -> Result<Vec<crate::Range>, salsa::Cancelled> {
self.read(|db| resolve_variable_references(db, self.php_version(), file, byte_offset))
}
}
struct VariableScope {
span: Span,
kind: VariableScopeKind,
}
enum VariableScopeKind {
Isolated,
Arrow { params: Vec<String> },
Closure {
params: Vec<String>,
captured: Vec<String>,
},
}
fn param_names(params: &[php_ast::owned::Param]) -> Vec<String> {
params
.iter()
.filter_map(|p| p.name.as_deref())
.map(|n| n.trim_start_matches('$').to_string())
.collect()
}
fn collect_variable_scopes(program: &Program) -> Vec<VariableScope> {
use php_ast::owned::visitor::walk_owned_class_member;
use php_ast::owned::visitor::{walk_owned_program, OwnedVisitor};
use php_ast::owned::{ClassMember, ClassMemberKind, Expr, ExprKind};
use std::ops::ControlFlow;
struct Collector(Vec<VariableScope>);
impl OwnedVisitor for Collector {
fn visit_stmt(&mut self, stmt: &Stmt) -> ControlFlow<()> {
if matches!(stmt.kind, StmtKind::Function(_)) {
self.0.push(VariableScope {
span: stmt.span,
kind: VariableScopeKind::Isolated,
});
}
crate::recursion::walk_stmt(self, stmt)
}
fn visit_class_member(&mut self, member: &ClassMember) -> ControlFlow<()> {
if matches!(member.kind, ClassMemberKind::Method(_)) {
self.0.push(VariableScope {
span: member.span,
kind: VariableScopeKind::Isolated,
});
}
walk_owned_class_member(self, member)
}
fn visit_expr(&mut self, expr: &Expr) -> ControlFlow<()> {
match &expr.kind {
ExprKind::Closure(c) => self.0.push(VariableScope {
span: expr.span,
kind: VariableScopeKind::Closure {
params: param_names(&c.params),
captured: c.use_vars.iter().map(|u| u.name.to_string()).collect(),
},
}),
ExprKind::ArrowFunction(a) => self.0.push(VariableScope {
span: expr.span,
kind: VariableScopeKind::Arrow {
params: param_names(&a.params),
},
}),
_ => {}
}
crate::recursion::walk_expr(self, expr)
}
}
let mut collector = Collector(Vec::new());
let _ = walk_owned_program(&mut collector, program);
collector.0
}
fn variable_owner(scopes: &[VariableScope], offset: u32, name: &str) -> Option<Span> {
let mut enclosing: Vec<&VariableScope> = scopes
.iter()
.filter(|s| span_contains(s.span, offset))
.collect();
enclosing.sort_by_key(|s| span_len(s.span));
enclosing
.into_iter()
.find(|s| match &s.kind {
VariableScopeKind::Isolated => true,
VariableScopeKind::Arrow { params } => params.iter().any(|p| p == name),
VariableScopeKind::Closure { params, captured } => {
params.iter().any(|p| p == name) || !captured.iter().any(|c| c == name)
}
})
.map(|s| s.span)
}
fn resolve_variable_references(
db: &dyn MirDatabase,
php_version: crate::PhpVersion,
file: &Arc<str>,
byte_offset: u32,
) -> Vec<crate::Range> {
use crate::symbol::ReferenceKind;
let Some(sf) = db.lookup_source_file(file.as_ref()) else {
return Vec::new();
};
let prepared = crate::db::prepare_analysis_file(db, sf);
let parsed = prepared.parse_result();
let source = prepared.text.as_ref();
let source_map = &parsed.source_map;
let symbols = resolve_scope_symbols(
db,
php_version,
file.clone(),
source,
&parsed.program,
source_map,
byte_offset,
false,
false,
);
let variables: Vec<(&ResolvedSymbol, &str)> = symbols
.iter()
.filter_map(|s| match &s.kind {
ReferenceKind::Variable(name) => Some((s, name.as_ref())),
_ => None,
})
.collect();
let Some(&(_, name)) = best_at(&variables, |v| v.0.span, |_| None, byte_offset, false) else {
return Vec::new();
};
let scopes = collect_variable_scopes(&parsed.program);
let owner = variable_owner(&scopes, byte_offset, name);
let mut spans: Vec<Span> = variables
.iter()
.filter(|(s, n)| *n == name && variable_owner(&scopes, s.span.start, name) == owner)
.map(|(s, _)| s.span)
.collect();
spans.sort_by_key(|s| (s.start, s.end));
spans.dedup_by_key(|s| (s.start, s.end));
spans
.into_iter()
.map(|span| {
let position = |offset: u32| {
let (line, column) =
crate::diagnostics::offset_to_line_col(source, offset, source_map);
crate::Position {
line,
column: u32::from(column),
}
};
crate::Range {
start: position(span.start),
end: position(span.end),
}
})
.collect()
}
fn resolve_name_at(
db: &dyn MirDatabase,
php_version: crate::PhpVersion,
file: &Arc<str>,
byte_offset: u32,
) -> Option<crate::Name> {
let sf = db.lookup_source_file(file.as_ref())?;
if let crate::docblock_nav::DocblockLookup::InDocblock(name) =
crate::docblock_nav::docblock_name_at(db, file, sf.text(db).as_ref(), byte_offset)
{
return name;
}
let prepared = crate::db::prepare_analysis_file(db, sf);
let parsed = prepared.parse_result();
if let Some(name) = resolve_name_at_via_compact_facts(
db,
php_version,
file,
prepared.text.as_ref(),
&parsed.program,
&parsed.source_map,
byte_offset,
) {
crate::metrics::record_name_at_compact_hit();
db.note_work(crate::db::Work::NameAtCompact, 1);
return Some(name);
}
crate::metrics::record_name_at_fallback_walk();
db.note_work(crate::db::Work::NameAtFallback, 1);
let symbols = resolve_scope_symbols(
db,
php_version,
file.clone(),
prepared.text.as_ref(),
&parsed.program,
&parsed.source_map,
byte_offset,
false,
true,
);
let allow_expr_fallback = !ends_identifier(prepared.text.as_ref(), byte_offset);
best_at(
&symbols,
|s| s.span,
|s| s.expr_span,
byte_offset,
allow_expr_fallback,
)
.and_then(ResolvedSymbol::to_symbol)
}
fn enclosing_callable_body(stmt: &Stmt, byte_offset: u32) -> Option<Span> {
use php_ast::owned::EnumMemberKind;
let body_span = |body: &Option<Box<php_ast::owned::Block>>| body.as_ref().map(|b| b.span);
match &stmt.kind {
StmtKind::Function(decl) => Some(decl.body.span),
StmtKind::Class(decl) => class_member_body(&decl.body.members, byte_offset),
StmtKind::Trait(decl) => class_member_body(&decl.body.members, byte_offset),
StmtKind::Enum(decl) => decl
.body
.members
.iter()
.filter(|m| span_contains(m.span, byte_offset))
.find_map(|m| match &m.kind {
EnumMemberKind::Method(method) => body_span(&method.body),
_ => None,
}),
_ => None,
}
}
fn class_member_body(members: &[php_ast::owned::ClassMember], byte_offset: u32) -> Option<Span> {
use php_ast::owned::ClassMemberKind;
members
.iter()
.filter(|m| span_contains(m.span, byte_offset))
.find_map(|m| match &m.kind {
ClassMemberKind::Method(method) => method.body.as_ref().map(|b| b.span),
_ => None,
})
}
fn resolve_outgoing_calls(
db: &dyn MirDatabase,
php_version: crate::PhpVersion,
file: &Arc<str>,
byte_offset: u32,
) -> Vec<(crate::Name, crate::Range)> {
let Some(sf) = db.lookup_source_file(file.as_ref()) else {
return Vec::new();
};
let prepared = crate::db::prepare_analysis_file(db, sf);
let parsed = prepared.parse_result();
let source = prepared.text.as_ref();
let Some(stmt) = best_navigation_scope_stmt(&parsed.program, byte_offset) else {
return Vec::new();
};
let Some(body) = enclosing_callable_body(stmt, byte_offset) else {
return Vec::new();
};
let mut driver = BodyAnalyzer::new_inference_only(db, php_version);
driver.collect_symbols = false;
driver.capture_symbol_types = false;
driver.codebase_symbols_only = true;
driver.record_reference_locations = false;
driver.collect_navigation_facts = true;
let mut issues = Vec::new();
let guards: FxHashSet<Arc<str>> = FxHashSet::default();
let source_map = &parsed.source_map;
match &stmt.kind {
StmtKind::Function(decl) => {
driver.analyze_fn_decl(decl, file, source, source_map, &mut issues, None)
}
StmtKind::Class(decl) => {
driver.analyze_class_decl(decl, file, source, source_map, &mut issues, None, &guards)
}
StmtKind::Trait(decl) => {
driver.analyze_trait_decl(decl, file, source, source_map, &mut issues, None)
}
StmtKind::Enum(decl) => {
driver.analyze_enum_decl(decl, file, source, source_map, &mut issues, None)
}
_ => return Vec::new(),
}
let position = |offset: u32| {
let (line, column) = crate::diagnostics::offset_to_line_col(source, offset, source_map);
crate::Position {
line,
column: u32::from(column),
}
};
let mut facts: Vec<NavigationFact> = driver
.take_navigation_facts()
.into_iter()
.filter(|fact| {
matches!(
fact.name,
crate::Name::Method { .. } | crate::Name::Function(_)
) && fact.span.start >= body.start
&& fact.span.end <= body.end
})
.collect();
facts.sort_by_key(|fact| fact.span.start);
facts
.into_iter()
.map(|fact| {
let range = crate::Range {
start: position(fact.span.start),
end: position(fact.span.end),
};
(resolve_trait_alias(db, fact.name), range)
})
.collect()
}
fn resolve_trait_alias(db: &dyn MirDatabase, name: crate::Name) -> crate::Name {
let crate::Name::Method {
class,
name: method,
} = &name
else {
return name;
};
let here = crate::db::Fqcn::from_str(db, class.as_ref());
let Some(crate::db::ClassLike::Class(cls)) = crate::db::find_class_like(db, here) else {
return name;
};
let Some((trait_name, orig_method, _, _)) = cls.trait_aliases.get(method.as_ref()) else {
return name;
};
let candidates: Vec<Arc<str>> = trait_name
.as_ref()
.map(|t| vec![t.clone()])
.unwrap_or_else(|| cls.traits.clone());
candidates
.into_iter()
.find(|trait_fqcn| {
let trait_ = crate::db::Fqcn::from_str(db, trait_fqcn.as_ref());
crate::db::find_method_in_class(db, trait_, orig_method).is_some()
})
.map(|trait_fqcn| crate::Name::method(trait_fqcn, orig_method))
.unwrap_or(name)
}
fn resolve_symbol_at(
db: &dyn MirDatabase,
php_version: crate::PhpVersion,
file: &Arc<str>,
byte_offset: u32,
capture_symbol_types: bool,
) -> Option<ResolvedSymbol> {
let sf = db.lookup_source_file(file.as_ref())?;
let prepared = crate::db::prepare_analysis_file(db, sf);
let parsed = prepared.parse_result();
if let Some(symbol) = symbol_at_via_compact_facts(
db,
php_version,
file,
prepared.text.as_ref(),
&parsed.program,
&parsed.source_map,
byte_offset,
capture_symbol_types,
) {
crate::metrics::record_symbol_at_compact_hit();
db.note_work(crate::db::Work::SymbolAtCompact, 1);
return Some(symbol);
}
crate::metrics::record_symbol_at_fallback_walk();
db.note_work(crate::db::Work::SymbolAtFallback, 1);
let symbols = resolve_scope_symbols(
db,
php_version,
file.clone(),
prepared.text.as_ref(),
&parsed.program,
&parsed.source_map,
byte_offset,
capture_symbol_types,
false,
);
symbol_at(&symbols, byte_offset).cloned()
}
fn resolve_name_at_via_compact_facts(
db: &dyn MirDatabase,
php_version: crate::PhpVersion,
file: &Arc<str>,
source: &str,
program: &Program,
source_map: &SourceMap,
byte_offset: u32,
) -> Option<crate::Name> {
let best_stmt = best_navigation_scope_stmt(program, byte_offset);
let mut issues = Vec::new();
let guards: FxHashSet<Arc<str>> = FxHashSet::default();
let mut driver = match best_stmt.map(|stmt| &stmt.kind) {
Some(
StmtKind::Function(_)
| StmtKind::Class(_)
| StmtKind::Enum(_)
| StmtKind::Interface(_)
| StmtKind::Trait(_),
) => BodyAnalyzer::new_inference_only(db, php_version),
_ => BodyAnalyzer::new_inference_only(db, php_version),
};
driver.collect_symbols = false;
driver.capture_symbol_types = false;
driver.codebase_symbols_only = true;
driver.record_reference_locations = false;
driver.collect_navigation_facts = true;
match best_stmt.map(|stmt| &stmt.kind) {
Some(StmtKind::Function(decl)) => {
driver.analyze_fn_decl(decl, file, source, source_map, &mut issues, None);
}
Some(StmtKind::Class(decl)) => {
driver.analyze_class_decl(decl, file, source, source_map, &mut issues, None, &guards);
}
Some(StmtKind::Enum(decl)) => {
driver.analyze_enum_decl(decl, file, source, source_map, &mut issues, None);
}
Some(StmtKind::Interface(decl)) => {
driver.analyze_interface_decl(
decl,
file,
source,
source_map,
&mut issues,
&guards,
None,
);
}
Some(StmtKind::Trait(decl)) => {
driver.analyze_trait_decl(decl, file, source, source_map, &mut issues, None);
}
Some(StmtKind::Use(use_decl)) => {
let mut navigation_facts = Vec::new();
crate::body_analysis::check_use_decl_casing(
use_decl,
db,
file,
source,
source_map,
&mut issues,
None,
Some(&mut navigation_facts),
None,
false,
true,
false,
);
return navigation_fact_at(&navigation_facts, byte_offset, true)
.map(|fact| fact.name.clone());
}
_ => {
driver.analyze_global_exec(program, file, source, source_map, &mut issues, None);
}
}
let facts = driver.take_navigation_facts();
let allow_expr_fallback = !ends_identifier(source, byte_offset);
navigation_fact_at(&facts, byte_offset, allow_expr_fallback).map(|fact| fact.name.clone())
}
#[allow(clippy::too_many_arguments)]
fn symbol_at_via_compact_facts(
db: &dyn MirDatabase,
php_version: crate::PhpVersion,
file: &Arc<str>,
source: &str,
program: &Program,
source_map: &SourceMap,
byte_offset: u32,
capture_symbol_types: bool,
) -> Option<ResolvedSymbol> {
let mut issues = Vec::new();
let guards: FxHashSet<Arc<str>> = FxHashSet::default();
let best_stmt = best_navigation_scope_stmt(program, byte_offset);
let mut driver = match best_stmt.map(|stmt| &stmt.kind) {
Some(
StmtKind::Function(_)
| StmtKind::Class(_)
| StmtKind::Enum(_)
| StmtKind::Interface(_)
| StmtKind::Trait(_),
) => BodyAnalyzer::new_inference_only(db, php_version),
_ => BodyAnalyzer::new_inference_only(db, php_version),
};
driver.collect_symbols = false;
driver.capture_symbol_types = capture_symbol_types;
driver.codebase_symbols_only = false;
driver.record_reference_locations = false;
driver.collect_navigation_facts = false;
driver.collect_resolved_navigation_facts = true;
match best_stmt.map(|stmt| &stmt.kind) {
Some(StmtKind::Function(decl)) => {
driver.analyze_fn_decl(decl, file, source, source_map, &mut issues, None);
}
Some(StmtKind::Class(decl)) => {
driver.analyze_class_decl(decl, file, source, source_map, &mut issues, None, &guards);
}
Some(StmtKind::Enum(decl)) => {
driver.analyze_enum_decl(decl, file, source, source_map, &mut issues, None);
}
Some(StmtKind::Interface(decl)) => {
driver.analyze_interface_decl(
decl,
file,
source,
source_map,
&mut issues,
&guards,
None,
);
}
Some(StmtKind::Trait(decl)) => {
driver.analyze_trait_decl(decl, file, source, source_map, &mut issues, None);
}
Some(StmtKind::Use(use_decl)) => {
let mut resolved_navigation_facts = Vec::new();
crate::body_analysis::check_use_decl_casing(
use_decl,
db,
file,
source,
source_map,
&mut issues,
None,
None,
Some(&mut resolved_navigation_facts),
false,
true,
false,
);
if let Some(fact) = resolved_navigation_fact_at(&resolved_navigation_facts, byte_offset)
{
return Some(fact.clone().into_resolved_symbol(file.clone()));
}
}
_ => {
driver.analyze_global_exec(program, file, source, source_map, &mut issues, None);
}
}
let facts = driver.take_resolved_navigation_facts();
resolved_navigation_fact_at(&facts, byte_offset)
.cloned()
.map(|fact| fact.into_resolved_symbol(file.clone()))
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use std::time::{Duration, Instant};
use super::FileAnalyzer;
use crate::db::Work;
use crate::{symbol::ReferenceKind, AnalysisSession, PhpVersion, ResolvedSymbol};
fn session_for_source(path: &str, source: &str) -> (AnalysisSession, Arc<str>) {
let mut session = AnalysisSession::new(PhpVersion::LATEST);
let file: Arc<str> = Arc::from(path);
session.ingest_file(file.clone(), Arc::from(source));
(session, file)
}
fn analyze_diagnostics_only(session: &mut AnalysisSession, file: Arc<str>, source: &str) {
let parsed = php_rs_parser::parse(source);
assert!(
parsed.errors.is_empty(),
"parser errors in test source: {:?}",
parsed.errors
);
let analysis = FileAnalyzer::new(session).analyze_diagnostics_only(
file,
source,
&parsed.program,
&parsed.source_map,
);
assert!(
analysis.symbols.is_empty(),
"diagnostics-only analysis should not retain whole-file symbols"
);
}
fn median(samples: &mut [Duration]) -> Duration {
samples.sort_unstable();
samples[samples.len() / 2]
}
fn metric_section_lines<'a>(dump: &'a str, label: &str) -> Vec<&'a str> {
let Some((_, rest)) = dump.split_once(label) else {
return Vec::new();
};
rest.lines()
.skip(1)
.take_while(|line| line.starts_with(" "))
.collect()
}
fn assert_symbol_at_compact_without_fallback(
session: &mut AnalysisSession,
file: &Arc<str>,
offset: u32,
expected: impl FnOnce(&ResolvedSymbol),
) {
let before = |work| session.work_count(work);
let (compact, fallback, symbols) = (
before(Work::SymbolAtCompact),
before(Work::SymbolAtFallback),
before(Work::SymbolAllocated),
);
let symbol = session
.symbol_at(file.as_ref(), offset)
.expect("symbol_at should find a symbol at the chosen offset");
expected(&symbol);
assert_eq!(session.work_count(Work::SymbolAtCompact) - compact, 1);
assert_eq!(session.work_count(Work::SymbolAtFallback) - fallback, 0);
assert_eq!(
session.work_count(Work::SymbolAllocated) - symbols,
0,
"compact symbol_at should not allocate legacy symbols"
);
}
#[test]
fn name_at_records_compact_path_without_fallback() {
let src = "<?php\nfunction helper(): void {}\nfunction caller(): void { helper(); }\n";
let (mut session, file) = session_for_source("/proj/name_at_metrics.php", src);
analyze_diagnostics_only(&mut session, file.clone(), src);
let offset = src.rfind("helper();").unwrap() as u32;
let name = session
.name_at(file.as_ref(), offset)
.expect("name_at should resolve helper()");
assert_eq!(name, crate::Name::function("helper"));
assert_eq!(session.work_count(Work::NameAtCompact), 1);
assert_eq!(session.work_count(Work::NameAtFallback), 0);
assert_eq!(
session.work_count(Work::SymbolAllocated),
0,
"compact name_at should not allocate legacy symbols"
);
}
#[test]
fn name_at_top_level_exec_uses_compact_path_without_fallback() {
let src = "<?php\nfunction helper(): void {}\nhelper();\n";
let (mut session, file) = session_for_source("/proj/name_at_top_level_metrics.php", src);
analyze_diagnostics_only(&mut session, file.clone(), src);
let offset = src.find("helper();").unwrap() as u32;
let name = session
.name_at(file.as_ref(), offset)
.expect("name_at should resolve top-level helper()");
assert_eq!(name, crate::Name::function("helper"));
assert_eq!(session.work_count(Work::NameAtCompact), 1);
assert_eq!(session.work_count(Work::NameAtFallback), 0);
assert_eq!(
session.work_count(Work::SymbolAllocated),
0,
"compact top-level name_at should not allocate legacy symbols"
);
}
#[test]
fn symbol_at_records_compact_path_without_fallback() {
let src = "<?php\nclass Box { public int $value = 0; }\nfunction read(Box $box): void { $box->value; }\n";
let (mut session, file) = session_for_source("/proj/symbol_at_metrics.php", src);
analyze_diagnostics_only(&mut session, file.clone(), src);
let offset = src.find("$box->value").unwrap() as u32 + "$box".len() as u32;
assert_symbol_at_compact_without_fallback(&mut session, &file, offset, |symbol| {
assert!(matches!(symbol.kind, ReferenceKind::Receiver));
});
}
#[test]
fn symbol_at_top_level_exec_uses_compact_path_without_fallback() {
let src = "<?php\nfunction helper(): void {}\nhelper();\n";
let (mut session, file) = session_for_source("/proj/resolve_top_level_metrics.php", src);
analyze_diagnostics_only(&mut session, file.clone(), src);
let offset = src.find("helper();").unwrap() as u32;
assert_symbol_at_compact_without_fallback(&mut session, &file, offset, |symbol| {
assert!(matches!(
&symbol.kind,
ReferenceKind::FunctionCall(name) if name.as_ref() == "helper"
));
});
}
#[test]
fn symbol_at_method_call_uses_compact_path_without_fallback() {
let src = "<?php\nclass Dep { public function next(): int { return 1; } }\nfunction run(Dep $dep): int { return $dep->next(); }\n";
let (mut session, file) = session_for_source("/proj/resolve_method_metrics.php", src);
analyze_diagnostics_only(&mut session, file.clone(), src);
let offset = src.find("->next()").unwrap() as u32 + 2;
assert_symbol_at_compact_without_fallback(&mut session, &file, offset, |symbol| {
assert!(matches!(
&symbol.kind,
ReferenceKind::MethodCall { class, method }
if class.as_ref() == "Dep" && method.as_ref() == "next"
));
});
}
#[test]
fn symbol_at_type_hint_uses_compact_path_without_fallback() {
let src = "<?php\nclass Dep {}\nfunction run(Dep $dep): Dep { return $dep; }\n";
let (mut session, file) = session_for_source("/proj/resolve_type_hint_metrics.php", src);
analyze_diagnostics_only(&mut session, file.clone(), src);
let offset = src.find("run(Dep").unwrap() as u32 + "run(".len() as u32;
assert_symbol_at_compact_without_fallback(&mut session, &file, offset, |symbol| {
assert!(matches!(
&symbol.kind,
ReferenceKind::ClassReference(name) if name.as_ref() == "Dep"
));
});
}
#[test]
fn symbol_at_use_import_uses_compact_path_without_fallback() {
let mut session = AnalysisSession::new(PhpVersion::LATEST);
let dep_file: Arc<str> = Arc::from("/proj/Dep.php");
let main_file: Arc<str> = Arc::from("/proj/Main.php");
let dep_src = "<?php\nnamespace App;\nclass Dep {}\n";
let main_src = "<?php\nuse App\\Dep;\nfunction run(): Dep { return new Dep(); }\n";
session.ingest_file(dep_file, Arc::from(dep_src));
session.ingest_file(main_file.clone(), Arc::from(main_src));
analyze_diagnostics_only(&mut session, main_file.clone(), main_src);
let offset = main_src.find("App\\Dep").unwrap() as u32 + "App\\".len() as u32;
assert_symbol_at_compact_without_fallback(&mut session, &main_file, offset, |symbol| {
assert!(matches!(
&symbol.kind,
ReferenceKind::UseImport(inner)
if matches!(
inner.as_ref(),
ReferenceKind::ClassReference(name) if name.as_ref() == "App\\Dep"
)
));
});
}
#[test]
fn symbol_at_catch_clause_type_uses_compact_path_without_fallback() {
let src = "<?php\nfinal class MyException extends \\Exception {}\nfunction run(): void {\n try {\n throw new MyException();\n } catch (MyException $e) {\n }\n}\n";
let (mut session, file) = session_for_source("/proj/resolve_catch_metrics.php", src);
analyze_diagnostics_only(&mut session, file.clone(), src);
let offset = src.rfind("catch (MyException").unwrap() as u32 + "catch (".len() as u32;
assert_symbol_at_compact_without_fallback(&mut session, &file, offset, |symbol| {
assert!(matches!(
&symbol.kind,
ReferenceKind::ClassReference(name) if name.as_ref() == "MyException"
));
});
}
#[test]
fn symbol_at_static_property_write_class_uses_compact_path_without_fallback() {
let src = "<?php\nclass Counter {\n public static int $count = 0;\n}\nfunction bump(): void {\n Counter::$count = Counter::$count + 1;\n}\n";
let (mut session, file) = session_for_source("/proj/resolve_static_prop_metrics.php", src);
analyze_diagnostics_only(&mut session, file.clone(), src);
let offset = src.find("Counter::$count =").unwrap() as u32;
assert_symbol_at_compact_without_fallback(&mut session, &file, offset, |symbol| {
assert!(matches!(
&symbol.kind,
ReferenceKind::ClassReference(name) if name.as_ref() == "Counter"
));
});
}
#[test]
fn definition_uses_name_at_compact_path_without_fallback() {
let src = "<?php\nclass Dep {}\nfunction run(Dep $dep): Dep { return $dep; }\n";
let (mut session, file) = session_for_source("/proj/definition_metrics.php", src);
analyze_diagnostics_only(&mut session, file.clone(), src);
let offset = src.find("run(Dep").unwrap() as u32 + "run(".len() as u32;
let name = session
.name_at(file.as_ref(), offset)
.expect("name_at should resolve the Dep type hint");
let loc = session
.definition_of_cached(&name)
.expect("Dep should have a declaration");
assert_eq!(loc.file.as_ref(), file.as_ref());
assert_eq!(session.work_count(Work::NameAtCompact), 1);
assert_eq!(session.work_count(Work::NameAtFallback), 0);
assert_eq!(
session.work_count(Work::SymbolAtCompact) + session.work_count(Work::SymbolAtFallback),
0,
"definition lookup should not rely on symbol_at"
);
}
#[test]
fn references_use_name_at_compact_path_without_fallback() {
let src = "<?php\nfunction helper(): void {}\nfunction caller(): void { helper(); }\n";
let (mut session, file) = session_for_source("/proj/references_metrics.php", src);
analyze_diagnostics_only(&mut session, file.clone(), src);
let offset = src.find("helper();").unwrap() as u32 + 1;
let name = session
.name_at(file.as_ref(), offset)
.expect("name_at should resolve helper()");
let refs = session
.indexed_references_to(
&name,
std::slice::from_ref(&file),
false,
crate::ReferenceIncludes::Plain,
&|| false,
)
.expect("uncancelled references query");
assert!(
refs.iter().any(|(f, _)| f.as_ref() == file.as_ref()),
"references should include the helper() call site; got {refs:?}"
);
assert_eq!(session.work_count(Work::NameAtCompact), 1);
assert_eq!(session.work_count(Work::NameAtFallback), 0);
assert_eq!(
session.work_count(Work::SymbolAtCompact) + session.work_count(Work::SymbolAtFallback),
0,
"references lookup should not rely on symbol_at"
);
}
#[test]
#[ignore]
fn perf_report_post_diagnostics_navigation_micro() {
crate::metrics::test_reset();
let src = "<?php
class Dep {
public function next(int $value): int { return $value + 1; }
}
class Box {
public int $value = 0;
}
function helper(Dep $dep, int $value): int {
return $dep->next($value);
}
function read_value(int $value): int {
return $value;
}
function read_box(Box $box): int {
return $box->value;
}
";
let (mut session, file) = session_for_source("/proj/perf_nav.php", src);
let mut import_session = AnalysisSession::new(PhpVersion::LATEST);
let dep_file: Arc<str> = Arc::from("/proj/import/Dep.php");
let main_file: Arc<str> = Arc::from("/proj/import/Main.php");
let dep_src = "<?php\nnamespace App;\nclass Dep {}\n";
let main_src = "<?php\nuse App\\Dep;\nfunction run(): Dep { return new Dep(); }\n";
import_session.ingest_file(dep_file, Arc::from(dep_src));
import_session.ingest_file(main_file.clone(), Arc::from(main_src));
let parsed = php_rs_parser::parse(src);
assert!(parsed.errors.is_empty(), "fixture must parse cleanly");
let main_parsed = php_rs_parser::parse(main_src);
assert!(
main_parsed.errors.is_empty(),
"import fixture must parse cleanly"
);
let t0 = Instant::now();
let analysis = FileAnalyzer::new(&mut session).analyze_diagnostics_only(
file.clone(),
src,
&parsed.program,
&parsed.source_map,
);
let same_file_diagnostics_time = t0.elapsed();
assert!(analysis.symbols.is_empty());
let t0 = Instant::now();
let main_analysis = FileAnalyzer::new(&mut import_session).analyze_diagnostics_only(
main_file.clone(),
main_src,
&main_parsed.program,
&main_parsed.source_map,
);
let import_diagnostics_time = t0.elapsed();
assert!(main_analysis.symbols.is_empty());
let method_name_offset = src.find("next($value)").unwrap() as u32;
let method_resolve_offset = src.find("->next($value)").unwrap() as u32 + 2;
let variable_resolve_offset =
src.rfind("return $value;").unwrap() as u32 + "return ".len() as u32 + 1;
let type_hint_offset = src.find("helper(Dep").unwrap() as u32 + "helper(".len() as u32;
let receiver_gap_offset = src.find("$box->value").unwrap() as u32 + "$box".len() as u32;
let import_resolve_offset =
main_src.find("App\\Dep").unwrap() as u32 + "App\\".len() as u32;
let definition_offset = src.find("Dep $dep").unwrap() as u32;
let references_offset = method_name_offset;
let t0 = Instant::now();
let first_name = session.name_at(file.as_ref(), method_name_offset);
let first_name_time = t0.elapsed();
assert_eq!(first_name, Some(crate::Name::method("Dep", "next")));
let t0 = Instant::now();
let first_method_resolve = session.symbol_at(file.as_ref(), method_resolve_offset);
let first_method_resolve_time = t0.elapsed();
assert!(matches!(
first_method_resolve.as_ref().map(|s| &s.kind),
Some(ReferenceKind::MethodCall { class, method })
if class.as_ref() == "Dep" && method.as_ref() == "next"
));
let t0 = Instant::now();
let first_variable_resolve = session.symbol_at(file.as_ref(), variable_resolve_offset);
let first_variable_resolve_time = t0.elapsed();
assert!(matches!(
first_variable_resolve.as_ref().map(|s| &s.kind),
Some(ReferenceKind::Variable(name)) if name.as_ref() == "value"
));
let t0 = Instant::now();
let first_type_hint_resolve = session.symbol_at(file.as_ref(), type_hint_offset);
let first_type_hint_resolve_time = t0.elapsed();
assert!(matches!(
first_type_hint_resolve.as_ref().map(|s| &s.kind),
Some(ReferenceKind::ClassReference(name)) if name.as_ref() == "Dep"
));
let t0 = Instant::now();
let first_receiver_gap_resolve = session.symbol_at(file.as_ref(), receiver_gap_offset);
let first_receiver_gap_resolve_time = t0.elapsed();
assert!(matches!(
first_receiver_gap_resolve.as_ref().map(|s| &s.kind),
Some(ReferenceKind::Receiver)
));
let t0 = Instant::now();
let first_import_resolve =
import_session.symbol_at(main_file.as_ref(), import_resolve_offset);
let first_import_resolve_time = t0.elapsed();
assert!(matches!(
first_import_resolve.as_ref().map(|s| &s.kind),
Some(ReferenceKind::UseImport(inner))
if matches!(
inner.as_ref(),
ReferenceKind::ClassReference(name) if name.as_ref() == "App\\Dep"
)
));
let t0 = Instant::now();
let first_definition = session
.name_at(file.as_ref(), definition_offset)
.map(|name| session.definition_of_cached(&name));
let first_definition_time = t0.elapsed();
assert!(
matches!(first_definition, Some(Ok(_))),
"definition lookup should succeed on Dep type hint"
);
let t0 = Instant::now();
let first_references = session
.name_at(file.as_ref(), references_offset)
.and_then(|name| {
session.indexed_references_to(
&name,
std::slice::from_ref(&file),
false,
crate::ReferenceIncludes::Plain,
&|| false,
)
});
let first_references_time = t0.elapsed();
assert!(
first_references
.as_ref()
.is_some_and(|refs| refs.iter().any(|(f, _)| f.as_ref() == file.as_ref())),
"references lookup should succeed on helper-like method call"
);
const ITERS: usize = 100;
let mut name_samples = Vec::with_capacity(ITERS);
let mut method_resolve_samples = Vec::with_capacity(ITERS);
let mut variable_resolve_samples = Vec::with_capacity(ITERS);
let mut type_hint_resolve_samples = Vec::with_capacity(ITERS);
let mut receiver_gap_resolve_samples = Vec::with_capacity(ITERS);
let mut import_resolve_samples = Vec::with_capacity(ITERS);
let mut definition_samples = Vec::with_capacity(ITERS);
let mut references_samples = Vec::with_capacity(ITERS);
for _ in 0..ITERS {
let t0 = Instant::now();
let name = session.name_at(file.as_ref(), method_name_offset);
name_samples.push(t0.elapsed());
assert_eq!(name, Some(crate::Name::method("Dep", "next")));
let t0 = Instant::now();
let resolve = session.symbol_at(file.as_ref(), method_resolve_offset);
method_resolve_samples.push(t0.elapsed());
assert!(matches!(
resolve.as_ref().map(|s| &s.kind),
Some(ReferenceKind::MethodCall { class, method })
if class.as_ref() == "Dep" && method.as_ref() == "next"
));
let t0 = Instant::now();
let resolve = session.symbol_at(file.as_ref(), variable_resolve_offset);
variable_resolve_samples.push(t0.elapsed());
assert!(matches!(
resolve.as_ref().map(|s| &s.kind),
Some(ReferenceKind::Variable(name)) if name.as_ref() == "value"
));
let t0 = Instant::now();
let resolve = session.symbol_at(file.as_ref(), type_hint_offset);
type_hint_resolve_samples.push(t0.elapsed());
assert!(matches!(
resolve.as_ref().map(|s| &s.kind),
Some(ReferenceKind::ClassReference(name)) if name.as_ref() == "Dep"
));
let t0 = Instant::now();
let resolve = session.symbol_at(file.as_ref(), receiver_gap_offset);
receiver_gap_resolve_samples.push(t0.elapsed());
assert!(matches!(
resolve.as_ref().map(|s| &s.kind),
Some(ReferenceKind::Receiver)
));
let t0 = Instant::now();
let resolve = import_session.symbol_at(main_file.as_ref(), import_resolve_offset);
import_resolve_samples.push(t0.elapsed());
assert!(matches!(
resolve.as_ref().map(|s| &s.kind),
Some(ReferenceKind::UseImport(inner))
if matches!(
inner.as_ref(),
ReferenceKind::ClassReference(name) if name.as_ref() == "App\\Dep"
)
));
let t0 = Instant::now();
let definition = session
.name_at(file.as_ref(), definition_offset)
.map(|name| session.definition_of_cached(&name));
definition_samples.push(t0.elapsed());
assert!(matches!(definition, Some(Ok(_))));
let t0 = Instant::now();
let references = session
.name_at(file.as_ref(), references_offset)
.and_then(|name| {
session.indexed_references_to(
&name,
std::slice::from_ref(&file),
false,
crate::ReferenceIncludes::Plain,
&|| false,
)
});
references_samples.push(t0.elapsed());
assert!(references
.as_ref()
.is_some_and(|refs| refs.iter().any(|(f, _)| f.as_ref() == file.as_ref())));
}
let repeat_name_p50 = median(&mut name_samples);
let repeat_method_resolve_p50 = median(&mut method_resolve_samples);
let repeat_variable_resolve_p50 = median(&mut variable_resolve_samples);
let repeat_type_hint_resolve_p50 = median(&mut type_hint_resolve_samples);
let repeat_receiver_gap_resolve_p50 = median(&mut receiver_gap_resolve_samples);
let repeat_import_resolve_p50 = median(&mut import_resolve_samples);
let repeat_definition_p50 = median(&mut definition_samples);
let repeat_references_p50 = median(&mut references_samples);
let dump = crate::metrics::dump().expect("metrics enabled in tests");
let expected_resolve_calls = 5 * (ITERS + 1);
let expected_name_calls = 3 * (ITERS + 1);
assert!(
dump.contains(&format!(
"symbol_at path : compact {expected_resolve_calls} fallback 0"
)),
"expanded probe should stay fully on the compact symbol_at path, got:\n{dump}"
);
assert!(
dump.contains(&format!(
"name_at path : compact {expected_name_calls} fallback 0"
)),
"expanded probe should stay fully on the compact name_at path, got:\n{dump}"
);
println!();
println!("post_diagnostics_navigation_micro");
println!(
" diagnostics_only same-file : {} us",
same_file_diagnostics_time.as_micros()
);
println!(
" diagnostics_only import : {} us",
import_diagnostics_time.as_micros()
);
println!(
" first name_at method : {} us",
first_name_time.as_micros()
);
println!(
" first symbol_at method : {} us",
first_method_resolve_time.as_micros()
);
println!(
" first symbol_at variable : {} us",
first_variable_resolve_time.as_micros()
);
println!(
" first symbol_at type_hint : {} us",
first_type_hint_resolve_time.as_micros()
);
println!(
" first symbol_at receiver : {} us",
first_receiver_gap_resolve_time.as_micros()
);
println!(
" first symbol_at import : {} us",
first_import_resolve_time.as_micros()
);
println!(
" first definition : {} us",
first_definition_time.as_micros()
);
println!(
" first references : {} us",
first_references_time.as_micros()
);
println!(
" repeat name_at method p50 : {} us",
repeat_name_p50.as_micros()
);
println!(
" repeat resolve method p50 : {} us",
repeat_method_resolve_p50.as_micros()
);
println!(
" repeat resolve variable p50: {} us",
repeat_variable_resolve_p50.as_micros()
);
println!(
" repeat resolve hint p50 : {} us",
repeat_type_hint_resolve_p50.as_micros()
);
println!(
" repeat resolve recv p50 : {} us",
repeat_receiver_gap_resolve_p50.as_micros()
);
println!(
" repeat resolve import p50 : {} us",
repeat_import_resolve_p50.as_micros()
);
println!(
" repeat definition p50 : {} us",
repeat_definition_p50.as_micros()
);
println!(
" repeat references p50 : {} us",
repeat_references_p50.as_micros()
);
println!("{dump}");
}
#[test]
#[ignore]
fn perf_report_edit_locality_micro() {
crate::metrics::test_reset();
let mut session = AnalysisSession::new(PhpVersion::LATEST);
let base_file: Arc<str> = Arc::from("/proj/edit/Base.php");
let dep_a_file: Arc<str> = Arc::from("/proj/edit/DepA.php");
let dep_b_file: Arc<str> = Arc::from("/proj/edit/DepB.php");
let leaf_file: Arc<str> = Arc::from("/proj/edit/Leaf.php");
let base_src = "<?php
class Base {
public function id(int $n): int { return $n; }
}
";
let dep_a_src = "<?php
class DepA extends Base {
public function run(int $n): int { return $this->id($n) + 1; }
}
";
let dep_b_src = "<?php
class DepB extends Base {
public function run(int $n): int { return $this->id($n) + 2; }
}
";
let leaf_src = "<?php
function leaf_value(int $n): int { return $n + 1; }
";
session.ingest_file(base_file.clone(), Arc::from(base_src));
session.ingest_file(dep_a_file.clone(), Arc::from(dep_a_src));
session.ingest_file(dep_b_file.clone(), Arc::from(dep_b_src));
session.ingest_file(leaf_file.clone(), Arc::from(leaf_src));
let dep_a_parsed = php_rs_parser::parse(dep_a_src);
let dep_b_parsed = php_rs_parser::parse(dep_b_src);
let leaf_parsed = php_rs_parser::parse(leaf_src);
assert!(dep_a_parsed.errors.is_empty());
assert!(dep_b_parsed.errors.is_empty());
assert!(leaf_parsed.errors.is_empty());
let _ = FileAnalyzer::new(&mut session).analyze_diagnostics_only(
dep_a_file.clone(),
dep_a_src,
&dep_a_parsed.program,
&dep_a_parsed.source_map,
);
let _ = FileAnalyzer::new(&mut session).analyze_diagnostics_only(
dep_b_file.clone(),
dep_b_src,
&dep_b_parsed.program,
&dep_b_parsed.source_map,
);
let _ = FileAnalyzer::new(&mut session).analyze_diagnostics_only(
leaf_file.clone(),
leaf_src,
&leaf_parsed.program,
&leaf_parsed.source_map,
);
const ITERS: usize = 25;
let mut leaf_ingest_samples = Vec::with_capacity(ITERS);
let mut leaf_reanalyze_samples = Vec::with_capacity(ITERS);
let mut base_ingest_samples = Vec::with_capacity(ITERS);
let mut base_reanalyze_samples = Vec::with_capacity(ITERS);
crate::metrics::test_reset();
for i in 0..ITERS {
let leaf_edit = format!(
"<?php\nfunction leaf_value(int $n): int {{ return $n + 1; }}\n// leaf edit {i}\n"
);
let t0 = Instant::now();
session.ingest_file(leaf_file.clone(), Arc::from(leaf_edit.as_str()));
leaf_ingest_samples.push(t0.elapsed());
let t0 = Instant::now();
let leaf_results = session.reanalyze_dependents(leaf_file.as_ref());
leaf_reanalyze_samples.push(t0.elapsed());
assert!(
leaf_results.is_empty(),
"leaf file should have no dependents; got {:?}",
leaf_results
.iter()
.map(|(file, _)| file.as_ref())
.collect::<Vec<_>>()
);
}
let leaf_dump = crate::metrics::dump().expect("metrics enabled in tests");
assert!(
leaf_dump.contains("whole-file walks : 0"),
"leaf edits should not trigger body walks outside the edited file path, got:\n{leaf_dump}"
);
assert!(
leaf_dump.contains("scopes analyzed : 0"),
"leaf edits with no dependents should not trigger per-scope reanalysis, got:\n{leaf_dump}"
);
crate::metrics::test_reset();
for i in 0..ITERS {
let base_edit = format!(
"<?php\nclass Base {{\n public function id(int $n): int {{ return $n; }}\n}}\n// base edit {i}\n"
);
let t0 = Instant::now();
session.ingest_file(base_file.clone(), Arc::from(base_edit.as_str()));
base_ingest_samples.push(t0.elapsed());
let t0 = Instant::now();
let base_results = session.reanalyze_dependents(base_file.as_ref());
base_reanalyze_samples.push(t0.elapsed());
let base_files: Vec<&str> =
base_results.iter().map(|(file, _)| file.as_ref()).collect();
assert!(
base_files.contains(&dep_a_file.as_ref())
&& base_files.contains(&dep_b_file.as_ref()),
"base edit should reanalyze both dependents; got {base_files:?}"
);
}
let leaf_ingest_p50 = median(&mut leaf_ingest_samples);
let leaf_reanalyze_p50 = median(&mut leaf_reanalyze_samples);
let base_ingest_p50 = median(&mut base_ingest_samples);
let base_reanalyze_p50 = median(&mut base_reanalyze_samples);
let base_dump = crate::metrics::dump().expect("metrics enabled in tests");
let body_walks = metric_section_lines(&base_dump, " top body walks/file:");
let scope_walks = metric_section_lines(&base_dump, " top scopes analyzed/file:");
assert!(
base_dump.contains("whole-file walks : 0"),
"base edit loop should avoid whole-file body walks after migrating inferred types to per-scope queries, got:\n{base_dump}"
);
assert!(
body_walks.is_empty(),
"base edit loop should not report any whole-file body-walk metrics anymore, got:\n{base_dump}"
);
assert!(
scope_walks.iter().any(|line| line.contains(base_file.as_ref()))
&& scope_walks.iter().any(|line| line.contains(dep_a_file.as_ref()))
&& scope_walks.iter().any(|line| line.contains(dep_b_file.as_ref())),
"base edit loop should stay entirely on per-scope Salsa queries for the edited file and both dependents, got:\n{base_dump}"
);
println!();
println!("edit_locality_micro");
println!(
" leaf ingest p50 : {} us",
leaf_ingest_p50.as_micros()
);
println!(
" leaf reanalyze p50 : {} us",
leaf_reanalyze_p50.as_micros()
);
println!(
" base ingest p50 : {} us",
base_ingest_p50.as_micros()
);
println!(
" base reanalyze p50 : {} us",
base_reanalyze_p50.as_micros()
);
println!(" leaf metrics:");
println!("{leaf_dump}");
println!(" base metrics:");
println!("{base_dump}");
}
}