mir_analyzer/file_analyzer.rs
1//! Per-file analysis entry point for incremental analysis.
2//!
3//! [`FileAnalyzer`] runs a **single** body-analysis pass against an
4//! [`AnalysisSession`] snapshot. In the eager-static-input model the workspace
5//! symbol index is built up front by the background indexer
6//! ([`AnalysisSession::index_batch`]), so `find_class_like` resolves vendor
7//! classes directly — there is no lazy-load / retry loop. The only on-demand
8//! work is [`AnalysisSession::priority_index_for_ast`], which faults in the
9//! open file's *direct* references if the background walk hasn't reached them
10//! yet, keeping warm-up free of transient false positives.
11//!
12//! For batch multi-file analysis, use [`BatchFileAnalyzer::analyze_batch`]
13//! which parallelizes analysis across multiple pre-parsed files.
14
15use std::sync::Arc;
16
17use mir_issues::Issue;
18use php_ast::owned::Program;
19use php_rs_parser::source_map::SourceMap;
20use rayon::prelude::*;
21
22use crate::body_analysis::BodyAnalyzer;
23use crate::db::MirDatabase;
24use crate::session::AnalysisSession;
25use crate::symbol::ResolvedSymbol;
26
27/// Result of a single-file analysis.
28pub struct FileAnalysis {
29 pub issues: Vec<Issue>,
30 pub symbols: Vec<ResolvedSymbol>,
31}
32
33impl FileAnalysis {
34 /// Return the innermost resolved symbol whose span contains `byte_offset`,
35 /// or `None` if no symbol was recorded at that position.
36 ///
37 /// Entry point for hover / go-to-definition flows: callers map
38 /// (line, column) → byte offset → resolved symbol, then look up the
39 /// symbol's definition via [`crate::AnalysisSession::definition_of`] or
40 /// type info via [`ResolvedSymbol::resolved_type`].
41 pub fn symbol_at(&self, byte_offset: u32) -> Option<&ResolvedSymbol> {
42 // Primary: cursor is on an identifier token.
43 if let Some(sym) = self
44 .symbols
45 .iter()
46 .filter(|s| s.span.start <= byte_offset && byte_offset < s.span.end)
47 .min_by_key(|s| s.span.end - s.span.start)
48 {
49 return Some(sym);
50 }
51
52 // Fallback: cursor is in a call-expression gap (e.g. the whitespace,
53 // argument list, or trailing `->` between two chained method calls).
54 // Match against the full expression span recorded for call-like
55 // symbols and return the innermost (smallest) enclosing call —
56 // mirrors `crate::batch::BatchAnalysis::symbol_at`, which already
57 // does this; this single-file path fell out of sync with it.
58 self.symbols
59 .iter()
60 .filter(|s| {
61 s.expr_span
62 .is_some_and(|es| es.start <= byte_offset && byte_offset < es.end)
63 })
64 .min_by_key(|s| {
65 let es = s.expr_span.unwrap();
66 es.end - es.start
67 })
68 }
69}
70
71/// Per-file body analysis analyzer bound to an [`AnalysisSession`]. Cheap to
72/// construct — typically held transiently per analysis call.
73pub struct FileAnalyzer<'a> {
74 session: &'a AnalysisSession,
75}
76
77impl<'a> FileAnalyzer<'a> {
78 pub fn new(session: &'a AnalysisSession) -> Self {
79 Self { session }
80 }
81
82 /// Run a single body-analysis pass against a frozen db snapshot.
83 ///
84 /// `priority_index_for_ast` runs first to fault in any of this file's
85 /// direct class references not yet reached by the background indexer; then
86 /// one snapshot is analyzed and its reference locations committed. The lock
87 /// is not held during analysis, so concurrent edits and reads proceed.
88 pub fn analyze(
89 &self,
90 file: Arc<str>,
91 source: &str,
92 program: &Program,
93 source_map: &SourceMap,
94 ) -> FileAnalysis {
95 crate::metrics::record_file_analysis();
96
97 // Priority-index the buffer's direct class references so any not yet
98 // reached by the background indexer resolve in this single pass (no
99 // transient false UndefinedClass during warm-up). Once indexing
100 // completes this is a no-op.
101 // Capture (text, generation) BEFORE the warm-up: if a concurrent edit
102 // swaps the input text mid-flight, the stored Arc no longer matches
103 // and the mark is dead on arrival — the safe direction.
104 let prepare_generation = self.session.prepare_generation_snapshot();
105 let ingested_text = {
106 let db = self.session.snapshot_db();
107 db.lookup_source_file(file.as_ref())
108 .map(|sf| sf.text(&db as &dyn crate::db::MirDatabase))
109 };
110 self.session
111 .prepare_ast_for_analysis(program, file.as_ref());
112 // Record the warm-up so later Phase-1 sweeps (references, dependent
113 // re-analysis) skip this file's parse + AST walk while its salsa
114 // input text is unchanged.
115 if let Some(text) = ingested_text.clone() {
116 self.session
117 .mark_prepared_for_analysis(&file, text, prepare_generation);
118 }
119
120 let _scope = crate::metrics::BodyAnalysisScope::new();
121
122 // Generation before the analysis snapshot — after the warm-up, so
123 // its lazy loads don't immediately stale the commit; a file add
124 // racing the analysis still leaves the mark stale, never fresh.
125 let commit_gen = self.session.index_generation();
126 // Single pass against a frozen snapshot. With the eager-static-input
127 // model the workspace index is complete (or priority-indexed for this
128 // file's direct refs), so there are no body-analysis "misses" to fault
129 // in — no retry loop, no whole-file re-analysis.
130 let db = self.session.snapshot_db();
131 let driver = BodyAnalyzer::new(&db, self.session.php_version());
132 let (issues, symbols) = driver.analyze_bodies(program, file.clone(), source, source_map);
133 // Replace (not append): this pass produced the file's complete
134 // reference set, and marking freshness against the pre-analysis text
135 // keeps the mark dead-on-arrival if a concurrent edit swapped the
136 // input mid-flight (Arc identity no longer matches).
137 let resolved = !crate::db::issues_have_unresolved_names(&issues);
138 self.session.commit_file_refs(
139 &file,
140 ingested_text,
141 db.take_pending_ref_locs(),
142 commit_gen,
143 resolved,
144 );
145 FileAnalysis { issues, symbols }
146 }
147}
148
149/// Batch file analyzer for parallel multi-file analysis.
150///
151/// `BatchFileAnalyzer` processes pre-parsed files in parallel using rayon,
152/// making it efficient for analyzing many files at once (e.g., cold-start analysis).
153pub struct BatchFileAnalyzer<'a> {
154 session: &'a AnalysisSession,
155}
156
157/// A pre-parsed file ready for batch analysis.
158pub struct ParsedFile {
159 pub(crate) file: Arc<str>,
160 pub(crate) source: Arc<str>,
161 pub(crate) program: Program,
162 pub(crate) source_map: SourceMap,
163}
164
165impl ParsedFile {
166 /// File path this `ParsedFile` represents.
167 pub fn file(&self) -> &Arc<str> {
168 &self.file
169 }
170
171 /// Source text for this file.
172 pub fn source(&self) -> &Arc<str> {
173 &self.source
174 }
175
176 /// Create a `ParsedFile` from an owned program and source map.
177 pub fn new(file: Arc<str>, source: Arc<str>, program: Program, source_map: SourceMap) -> Self {
178 Self {
179 file,
180 source,
181 program,
182 source_map,
183 }
184 }
185}
186
187impl<'a> BatchFileAnalyzer<'a> {
188 pub fn new(session: &'a AnalysisSession) -> Self {
189 Self { session }
190 }
191
192 /// Analyze multiple pre-parsed files in parallel.
193 ///
194 /// Each rayon worker gets its own cloned database snapshot, so concurrent
195 /// analysis proceeds without lock contention on the session.
196 pub fn analyze_batch(&self, files: Vec<ParsedFile>) -> Vec<(Arc<str>, FileAnalysis)> {
197 // First pass: collect all ASTs and auto-discover stubs.
198 // Also lazy-load vendor autoload.files globals once so they are in the
199 // workspace index before the parallel analysis snapshot is taken.
200 self.session.ensure_vendor_eager_functions();
201 files.iter().for_each(|file| {
202 self.session.ensure_stubs_for_ast(&file.program);
203 });
204
205 // Second pass: analyze files in parallel.
206 // Each rayon worker gets its own database clone (Salsa is Send but !Sync).
207 let db = self.session.snapshot_db();
208 let results: Vec<(Arc<str>, FileAnalysis, Vec<crate::db::RefLoc>)> = files
209 .into_par_iter()
210 .map_with(db, |db, file| {
211 let driver = BodyAnalyzer::new(db as &dyn MirDatabase, self.session.php_version());
212 let (issues, symbols) = driver.analyze_bodies(
213 &file.program,
214 file.file.clone(),
215 &file.source,
216 &file.source_map,
217 );
218 let pending = db.take_pending_ref_locs();
219 let analysis = FileAnalysis { issues, symbols };
220 (file.file, analysis, pending)
221 })
222 .collect();
223 let mut all_ref_locs = Vec::new();
224 let mut out = Vec::with_capacity(results.len());
225 for (file, analysis, ref_locs) in results {
226 all_ref_locs.extend(ref_locs);
227 out.push((file, analysis));
228 }
229 self.session.commit_ref_locs_batch(all_ref_locs);
230 out
231 }
232}