1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
use super::*;
impl AnalysisSession {
/// Load every embedded PHP stub plus any configured user stubs.
/// Use for batch tools (CLI, full project analysis) where comprehensive
/// symbol coverage matters more than cold-start latency.
pub fn ensure_all_stubs(&mut self) {
let paths: Vec<&'static str> = crate::stubs::stub_files().iter().map(|&(p, _)| p).collect();
self.db.ingest_stub_paths(&paths);
self.ensure_user_stubs_loaded();
}
/// Ensure the embedded stub that defines `name` (a function) is ingested.
/// Returns `true` when a matching stub exists (whether or not it was
/// already loaded), `false` when `name` isn't a known PHP built-in.
///
/// Most callers should use [`Self::ensure_stubs_for_ast`] instead —
/// it auto-discovers needed stubs from a parsed file.
#[doc(hidden)]
pub fn ensure_stub_for_function(&mut self, name: &str) -> bool {
match crate::stubs::stub_path_for_function(name) {
Some(path) => {
self.db.ingest_stub_paths(&[path]);
true
}
None => false,
}
}
/// Ensure the embedded stub that defines `fqcn` (a class / interface /
/// trait / enum) is ingested. Case-insensitive lookup with optional
/// leading backslash.
///
/// Most callers should use [`Self::ensure_stubs_for_ast`] instead.
#[doc(hidden)]
pub fn ensure_stub_for_class(&mut self, fqcn: &str) -> bool {
match crate::stubs::stub_path_for_class(fqcn) {
Some(path) => {
self.db.ingest_stub_paths(&[path]);
true
}
None => false,
}
}
/// Ensure the embedded stub that defines `name` (a constant) is ingested.
///
/// Most callers should use [`Self::ensure_stubs_for_ast`] instead.
#[doc(hidden)]
pub fn ensure_stub_for_constant(&mut self, name: &str) -> bool {
match crate::stubs::stub_path_for_constant(name) {
Some(path) => {
self.db.ingest_stub_paths(&[path]);
true
}
None => false,
}
}
/// Number of distinct embedded stubs currently ingested into the session.
/// Useful for diagnostics and bench reporting.
pub fn loaded_stub_count(&self) -> usize {
self.db.loaded_stubs.len()
}
/// Auto-discover and ingest the embedded stubs needed to cover every
/// built-in PHP function / class / constant referenced by `source`.
///
/// Used by the open-file analysis paths
/// ([`crate::FileAnalyzer::analyze`] and
/// [`crate::FileAnalyzer::analyze_diagnostics_only`]) to keep
/// essentials-only mode correct without forcing callers to enumerate which
/// stubs they need.
/// Idempotent — already-loaded stubs are skipped.
///
/// The discovery scan is a coarse identifier sweep (see
/// `collect_referenced_builtin_paths`) — it may pull in
/// a slightly larger set than the file strictly needs, but never misses
/// a referenced built-in. Cost is sub-millisecond per file.
///
/// Fast path: if every embedded stub is already loaded (e.g. after a
/// batch tool called [`Self::ensure_all_stubs`]), the source scan
/// is skipped entirely.
pub fn ensure_stubs_for_source(&mut self, source: &str) {
// Cheap check first: skip the scan entirely when we already know we
// have everything. Avoids a ~50-500µs source walk on every analyze
// call in batch / warm-session scenarios.
if self.db.loaded_stubs.len() >= crate::stubs::stub_files().len() {
return;
}
let paths = crate::stubs::collect_referenced_builtin_paths(source);
if paths.is_empty() {
return;
}
self.db.ingest_stub_paths(&paths);
}
/// Discover and ingest stubs by walking the parsed AST of a PHP file.
///
/// Similar to [`Self::ensure_stubs_for_source`], but takes an already-parsed
/// AST instead of raw source text. Produces zero false positives since it
/// only extracts identifiers from actual AST nodes (not from strings or
/// comments). Preferred over `ensure_stubs_for_source` when the AST is
/// already available (e.g., in [`crate::FileAnalyzer`]).
///
/// Idempotent and skips the scan if all stubs are already loaded.
pub fn ensure_stubs_for_ast(&mut self, program: &php_ast::owned::Program) {
if self.db.loaded_stubs.len() >= crate::stubs::stub_files().len() {
return;
}
let paths = crate::stubs::collect_referenced_builtin_paths_from_ast(program);
if paths.is_empty() {
return;
}
self.db.ingest_stub_paths(&paths);
}
/// Returns true if this session has a configured class resolver
/// (typically a PSR-4 / classmap autoloader chained with the stub
/// resolver). Used by `FileAnalyzer` to skip the AST-scan preload
/// when no resolver is wired up.
pub fn has_resolver(&self) -> bool {
self.resolver.is_some()
}
/// Index vendor `autoload.files` entries the first time analysis runs.
///
/// Composer's `autoload.files` lists files that define global functions and
/// constants (e.g. Laravel helpers). These are invisible to the PSR-4 class
/// resolver — there is no function-name → file-path mapping without
/// parsing them first. Rather than per-function lazy resolution, this
/// loads all pending vendor eager files at once on the first
/// [`Self::prepare_ast_for_analysis`] call.
///
/// Subsequent calls see `None` and return immediately (O(1)). Files are
/// read via the session's [`crate::SourceProvider`], so LSP VFS overrides
/// are respected.
pub(crate) fn ensure_vendor_eager_functions(&mut self) {
// Checked before `take()` so a missing provider leaves the queue intact.
let Some(provider) = self.db.salsa.source_provider() else {
return;
};
let files = match self.pending_eager_function_files.take() {
None => return,
Some(f) if f.is_empty() => return,
Some(f) => f,
};
let sources: Vec<(std::sync::Arc<str>, std::sync::Arc<str>)> = files
.iter()
.filter_map(|p| {
let text = provider.read(p.to_string_lossy().as_ref())?;
Some((std::sync::Arc::from(p.to_string_lossy().as_ref()), text))
})
.collect();
if !sources.is_empty() {
let cancel = crate::IndexCancel::new();
// Rayon: when this is the first `index_batch` call it seeds the
// symbol index from every registered file, not just this chunk.
self.index_batch(&sources, crate::IndexParallelism::Rayon, &cancel);
}
}
/// Run all pre-passes (builtin-stub loading, vendor-eager-file loading,
/// and PSR-4 class preloading) before body analysis of a single file.
///
/// Replaces the two separate `ensure_stubs_for_ast` /
/// `preload_psr4_classes_for_ast` calls at every open-file analysis site.
pub fn prepare_ast_for_analysis(&mut self, program: &php_ast::owned::Program, file: &str) {
self.ensure_stubs_for_ast(program);
self.ensure_vendor_eager_functions();
self.priority_index_for_ast(program, file);
}
/// Run `file`'s Phase-1 warm-up — resolve its direct class references and
/// lazy-load any not yet indexed — and record it as prepared against its
/// current text. No-op when the file is unknown or already prepared.
///
/// This is the write-path home of the warm-up: hosts call it (via
/// [`Self::ingest_file_prepared`]) when text lands, so read paths
/// (`indexed_references_to`, `reanalyze_files_cancellable`) find every
/// candidate prepared and stay pure.
pub fn prepare_file_for_analysis(&mut self, path: &std::sync::Arc<str>) {
let _ = self.prepare_file_for_analysis_cancellable(path, &|| false);
}
/// Cancellable variant of [`Self::prepare_file_for_analysis`]; `false`
/// when `should_cancel` stopped it before the warm-up.
pub(crate) fn prepare_file_for_analysis_cancellable(
&mut self,
path: &std::sync::Arc<str>,
should_cancel: &(dyn Fn() -> bool + Sync),
) -> bool {
if should_cancel() {
return false;
}
let generation = self.prepare_generation_snapshot();
let (parsed, text) = {
if should_cancel() {
return false;
}
let view = self.db_view();
let db = view.db();
let Some(sf) = db.lookup_source_file(path.as_ref()) else {
return true;
};
let text = sf.text(db as &dyn crate::db::MirDatabase).clone();
if self.is_prepared_for_analysis(path.as_ref(), &text, generation) {
return true;
}
(
crate::db::parse_file(db as &dyn crate::db::MirDatabase, sf)
.0
.clone(),
text,
)
};
// Nests under pass-level scopes, which coalesce the whole pass.
let mut session = self.defer_revision_bumps();
session.prepare_ast_for_analysis(&parsed.program, path.as_ref());
session.mark_prepared_for_analysis(path, text, generation);
true
}
/// Current warm-up generation; capture before a prepare + mark pair so a
/// [`Self::bump_prepare_generation`] during the warm-up invalidates the
/// mark.
pub(crate) fn prepare_generation_snapshot(&self) -> u64 {
self.prepare_generation
}
/// Whether `file`'s warm-up already ran against `current_text` in
/// `generation`. See the `prepared_files` field docs for the invalidation
/// rules.
pub(crate) fn is_prepared_for_analysis(
&self,
file: &str,
current_text: &std::sync::Arc<str>,
generation: u64,
) -> bool {
self.prepared_files
.get(file)
.is_some_and(|(text, prepared_gen)| {
*prepared_gen == generation && std::sync::Arc::ptr_eq(text, current_text)
})
}
/// Record that `file`'s warm-up ran against `text`. `generation` must be
/// the [`Self::prepare_generation_snapshot`] taken *before* the warm-up —
/// a bump in between leaves the entry stale, which is the safe direction.
pub(crate) fn mark_prepared_for_analysis(
&mut self,
file: &std::sync::Arc<str>,
text: std::sync::Arc<str>,
generation: u64,
) {
self.prepared_files.insert(file.clone(), (text, generation));
}
/// Drop `file`'s warm-up skip entry (its loaded state is being removed).
pub(crate) fn forget_prepared(&mut self, file: &str) {
self.prepared_files.remove(file);
}
/// Invalidate every warm-up skip entry. Call when previously loaded
/// declarations may have been removed outside [`Self::ingest_file`] /
/// [`Self::invalidate_file`] — e.g. a host that writes file text straight
/// into the salsa layer and detects a declaration-level change. A prepared
/// file might then need its warm-up re-run to lazy-load a replacement
/// (a vendor class shadowed by a since-deleted project class).
pub fn bump_prepare_generation(&mut self) {
self.index.clear_transient_batch_replay();
self.prepare_generation += 1;
}
/// Priority-index the classes directly referenced by `file`'s AST.
///
/// Lookups already find unindexed classes on demand; this moves the
/// buffer's *direct* references to the front of the background indexer's
/// ([`Self::index_batch`]) queue so workspace enumeration and reverse
/// lookups see them too. Bounded by one file's direct references: their
/// ancestors and signature types are left to the background walk. Each
/// [`Self::load_class`] is a resolver lookup, a parse (or cache hit) and
/// one index merge; a no-op once indexing has passed the file.
pub fn priority_index_for_ast(&mut self, program: &php_ast::owned::Program, file: &str) {
if self.resolver.is_none() {
return;
}
let refs = collect_class_refs_from_ast(program);
if refs.is_empty() {
return;
}
// Resolve names against the file's namespace/imports up front, then
// drop the view before loading (which mutates inputs).
let resolved: Vec<String> = {
let view = self.db_view();
refs.into_iter()
.map(|raw| crate::db::resolve_name(view.db(), file, &raw))
.collect()
};
for fqcn in resolved {
// load_class is a no-op when the class is already indexed (the
// common case once the background walk has passed this file).
self.load_class(&fqcn);
}
}
fn ensure_user_stubs_loaded(&mut self) {
self.db
.ingest_user_stubs(&self.user_stub_files, &self.user_stub_dirs);
}
}