Skip to content

Commit 10b7ce5

Browse files
committed
Add chain and callable target caches to speed up diagnostics
1 parent 885ba5b commit 10b7ce5

8 files changed

Lines changed: 480 additions & 99 deletions

File tree

docs/todo/bugs.md

Lines changed: 0 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,5 @@
11
# PHPantom — Bug Fixes
22

3-
## B1 — Pathological `unknown_member` performance on large service files
4-
5-
`collect_unknown_member_diagnostics` takes 194+ seconds on
6-
`src/core/Purchase/Services/PurchaseFileService.php`, causing the
7-
analyze command to time out (debug build) or hang indefinitely
8-
(release build). The next-slowest collectors on the same file never
9-
even get to run.
10-
11-
Other files show the same pattern at smaller scale: `unknown_member`
12-
dominates the breakdown on every slow file (often 50–70% of the total
13-
time). The worst offenders are large service/repository classes that
14-
chain many method calls on Eloquent models, payment gateways, and
15-
similar deeply-inherited types.
16-
17-
Likely causes to investigate:
18-
19-
- Repeated full class merges (inheritance + virtual members) for the
20-
same type within a single file — the per-file resolved-class cache
21-
may not be covering all code paths in the unknown-member walker.
22-
- Expensive subject resolution (long `$this->foo()->bar()->baz()`
23-
chains) re-resolved for every member access instead of being
24-
cached across diagnostic collectors that share the same file.
25-
- Virtual member synthesis (Laravel model provider) running
26-
repeatedly for the same model class.
27-
28-
Reproducer: run `phpantom_lsp analyze --project-root <project>`
29-
on any Laravel project with large service classes and observe the
30-
`unknown_member` timing in the slow-file breakdown.
31-
323
## B2 — Variable resolution pipeline produces short names instead of FQN
334

345
The variable resolution pipeline (`resolve_rhs_expression`,
@@ -278,5 +249,3 @@ function test(): void {
278249
$store->products()->filter()->add($product);
279250
}
280251
```
281-
282-

src/analyse.rs

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,10 @@ pub async fn run(options: AnalyseOptions) -> i32 {
267267
let _parse_guard = with_parse_cache(content);
268268
let _cache_guard =
269269
with_active_resolved_class_cache(&backend.resolved_class_cache);
270+
let _chain_guard =
271+
crate::completion::resolver::with_chain_resolution_cache();
272+
let _callable_guard =
273+
crate::completion::call_resolution::with_callable_target_cache();
270274

271275
let mut raw = Vec::new();
272276

@@ -368,16 +372,7 @@ pub async fn run(options: AnalyseOptions) -> i32 {
368372
#[cfg(not(debug_assertions))]
369373
{
370374
backend.collect_fast_diagnostics(uri, content, &mut raw);
371-
backend.collect_unknown_class_diagnostics(uri, content, &mut raw);
372-
backend.collect_unknown_member_diagnostics(uri, content, &mut raw);
373-
backend.collect_unknown_function_diagnostics(uri, content, &mut raw);
374-
backend.collect_argument_count_diagnostics(uri, content, &mut raw);
375-
backend.collect_type_error_diagnostics(uri, content, &mut raw);
376-
backend
377-
.collect_implementation_error_diagnostics(uri, content, &mut raw);
378-
backend.collect_deprecated_diagnostics(uri, content, &mut raw);
379-
backend.collect_undefined_variable_diagnostics(uri, content, &mut raw);
380-
backend.collect_invalid_class_kind_diagnostics(uri, content, &mut raw);
375+
backend.collect_slow_diagnostics(uri, content, &mut raw);
381376
}
382377

383378
let mut filtered: Vec<FileDiagnostic> = raw

src/completion/call_resolution.rs

Lines changed: 153 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,16 @@
1-
/// Call expression and callable target resolution.
1+
//! Call expression and callable target resolution.
2+
//!
3+
//! ## Callable target cache
4+
//!
5+
//! During diagnostic passes, `resolve_instance_method_callable` is
6+
//! called for every call site in the file. Many different chain
7+
//! expressions resolve to the same (class, method) pair — e.g.
8+
//! `$q->where(...)`, `$query->where(...)`, and
9+
//! `Product::query()->where(...)` all end up looking for `where` on
10+
//! `Builder<Product>`. The per-file callable-target cache
11+
//! (`CALLABLE_TARGET_CACHE`) stores `Option<ResolvedCallableTarget>`
12+
//! keyed by `(class_fqn, method_name_lower)` so these redundant
13+
//! resolutions are free after the first hit.
214
///
315
/// This module contains the logic for resolving call expressions (method
416
/// calls, static calls, function calls, constructor calls) to their
@@ -19,6 +31,7 @@
1931
/// - [`Backend::build_method_template_subs`]: builds a template
2032
/// substitution map for method-level `@template` parameters from
2133
/// pre-split call-site argument texts.
34+
use std::cell::RefCell;
2235
use std::collections::HashMap;
2336
use std::sync::Arc;
2437

@@ -99,13 +112,56 @@ pub(super) fn build_var_resolver<'a>(
99112
}
100113
}
101114

115+
// ─── Thread-local callable target cache ─────────────────────────────────────
116+
117+
thread_local! {
118+
/// When `Some`, `resolve_instance_method_callable` caches results
119+
/// by `"FQN::method_lower"`. Activated by
120+
/// [`with_callable_target_cache`], cleared on guard drop.
121+
static CALLABLE_TARGET_CACHE: RefCell<Option<HashMap<String, Option<ResolvedCallableTarget>>>> =
122+
const { RefCell::new(None) };
123+
}
124+
125+
/// RAII guard that clears the callable target cache on drop.
126+
pub(crate) struct CallableTargetCacheGuard {
127+
owns: bool,
128+
}
129+
130+
impl Drop for CallableTargetCacheGuard {
131+
fn drop(&mut self) {
132+
if self.owns {
133+
CALLABLE_TARGET_CACHE.with(|cell| {
134+
*cell.borrow_mut() = None;
135+
});
136+
}
137+
}
138+
}
139+
140+
/// Activate the thread-local callable target cache.
141+
///
142+
/// While the returned guard is alive, `resolve_instance_method_callable`
143+
/// caches callable target resolutions by `"FQN::method_lower"` so
144+
/// that the same method on the same class is resolved at most once per
145+
/// diagnostic pass, regardless of how many different chain expressions
146+
/// lead to it.
147+
pub(crate) fn with_callable_target_cache() -> CallableTargetCacheGuard {
148+
let already_active = CALLABLE_TARGET_CACHE.with(|cell| cell.borrow().is_some());
149+
if already_active {
150+
return CallableTargetCacheGuard { owns: false };
151+
}
152+
CALLABLE_TARGET_CACHE.with(|cell| {
153+
*cell.borrow_mut() = Some(HashMap::new());
154+
});
155+
CallableTargetCacheGuard { owns: true }
156+
}
157+
102158
impl Backend {
103159
/// Resolve an instance method base expression + method name to a
104160
/// [`ResolvedCallableTarget`].
105161
///
106162
/// Resolves `base` to owner classes, merges each via
107-
/// `resolve_class_fully`, and returns the first match for
108-
/// `method_name`.
163+
/// `resolve_class_fully_with_generics`, and returns the first match
164+
/// for `method_name`.
109165
fn resolve_instance_method_callable(
110166
base: &SubjectExpr,
111167
method_name: &str,
@@ -137,34 +193,72 @@ impl Backend {
137193
_ => vec![],
138194
};
139195

196+
// ── Callable target cache check ─────────────────────────
197+
// When args_text is None (argument_count diagnostics),
198+
// the callable target depends only on the resolved class
199+
// and method name, not on the specific chain expression.
200+
// Cache by "FQN::method_lower" so that `$q->where(...)`,
201+
// `$query->where(...)`, and `Product::query()->where(...)`
202+
// all share the result.
203+
//
204+
// When args_text is Some (type_error diagnostics with
205+
// method-level template substitution), the result depends
206+
// on the call-site arguments and cannot be cached this way.
207+
let method_lower = method_name.to_ascii_lowercase();
208+
let generic_arg_strings: Vec<String> =
209+
generic_args.iter().map(|a| a.to_string()).collect();
210+
let callable_cache_key = if args_text.is_none() {
211+
let fqn = owner.fqn();
212+
let key_str = if generic_arg_strings.is_empty() {
213+
format!("{}::{}", fqn, method_lower)
214+
} else {
215+
format!(
216+
"{}<{}>::{}",
217+
fqn,
218+
generic_arg_strings.join(","),
219+
method_lower
220+
)
221+
};
222+
Some(key_str)
223+
} else {
224+
None
225+
};
226+
227+
if let Some(ref key) = callable_cache_key {
228+
let cached = CALLABLE_TARGET_CACHE.with(|cell| {
229+
let borrow = cell.borrow();
230+
borrow.as_ref().and_then(|map| map.get(key).cloned())
231+
});
232+
if let Some(result) = cached {
233+
return result;
234+
}
235+
}
236+
140237
// Always use a fully-resolved class so that inherited
141238
// docblock types (return types, parameter types,
142239
// descriptions) are visible in signature help. The
143240
// candidate from `resolve_target_classes` may not have
144241
// gone through `resolve_class_fully` (e.g. bare `new X`
145242
// instantiation without generics).
146-
let merged = crate::virtual_members::resolve_class_fully_maybe_cached(
243+
//
244+
// Use the fused resolve+substitute helper so that the
245+
// result of `apply_generic_args` is cached under
246+
// `(FQN, generic_args)`. For Eloquent Builder<Model>
247+
// chains where the same generic class appears at dozens
248+
// of call sites, this avoids re-cloning and
249+
// re-substituting hundreds of virtual members each time.
250+
let effective = crate::virtual_members::resolve_class_fully_with_generics(
147251
&owner,
148252
rctx.class_loader,
149253
rctx.resolved_class_cache,
254+
&generic_arg_strings,
255+
&generic_args,
150256
);
151257

152-
// Apply class-level template substitutions when generic
153-
// type arguments are available (e.g. `Collection<User>`
154-
// substitutes `TValue` → `User` in method params).
155-
let effective = if !generic_args.is_empty() && !merged.template_params.is_empty() {
156-
Arc::new(crate::inheritance::apply_generic_args(
157-
&merged,
158-
&generic_args,
159-
))
160-
} else {
161-
Arc::clone(&merged)
162-
};
163-
164258
if let Some(m) = effective
165259
.methods
166260
.iter()
167-
.find(|m| m.name.eq_ignore_ascii_case(method_name))
261+
.find(|m| m.name.eq_ignore_ascii_case(&method_lower))
168262
{
169263
let mut result_method = m.clone();
170264

@@ -186,24 +280,58 @@ impl Backend {
186280
}
187281
}
188282

189-
return Some(ResolvedCallableTarget {
283+
let target = ResolvedCallableTarget {
190284
parameters: result_method.parameters.clone(),
191285
return_type: result_method.return_type.clone(),
192-
});
286+
};
287+
288+
// Store positive result in the callable target cache.
289+
if let Some(ref key) = callable_cache_key {
290+
CALLABLE_TARGET_CACHE.with(|cell| {
291+
let mut borrow = cell.borrow_mut();
292+
if let Some(ref mut map) = *borrow {
293+
map.insert(key.clone(), Some(target.clone()));
294+
}
295+
});
296+
}
297+
298+
return Some(target);
193299
}
194300

195-
// Fall back to the candidate directlyit may contain
196-
// model-specific members (e.g. Eloquent scope methods
197-
// injected onto Builder<Model>) that the FQN-keyed
198-
// cache does not have.
301+
// Fall back to __call / __callStaticthe candidate
302+
// directly may contain model-specific members (e.g.
303+
// Eloquent scope methods injected onto Builder<Model>)
304+
// that the FQN-keyed cache does not have.
199305
if let Some(m) = owner
200306
.methods
201307
.iter()
202308
.find(|m| m.name.eq_ignore_ascii_case(method_name))
203309
{
204-
return Some(ResolvedCallableTarget {
310+
let target = ResolvedCallableTarget {
205311
parameters: m.parameters.clone(),
206312
return_type: m.return_type.clone(),
313+
};
314+
315+
// Store __call fallback in the callable target cache.
316+
if let Some(ref key) = callable_cache_key {
317+
CALLABLE_TARGET_CACHE.with(|cell| {
318+
let mut borrow = cell.borrow_mut();
319+
if let Some(ref mut map) = *borrow {
320+
map.insert(key.clone(), Some(target.clone()));
321+
}
322+
});
323+
}
324+
325+
return Some(target);
326+
}
327+
328+
// Store negative result (method not found) in the cache.
329+
if let Some(ref key) = callable_cache_key {
330+
CALLABLE_TARGET_CACHE.with(|cell| {
331+
let mut borrow = cell.borrow_mut();
332+
if let Some(ref mut map) = *borrow {
333+
map.insert(key.clone(), None);
334+
}
207335
});
208336
}
209337
}
@@ -1688,6 +1816,7 @@ fn resolve_literal_type(text: &str) -> Option<PhpType> {
16881816
None
16891817
}
16901818

1819+
/// Like [`resolve_instance_method_callable`](Self::resolve_instance_method_callable)
16911820
impl Backend {
16921821
/// Extract the first argument from a comma-separated argument text,
16931822
/// respecting nested parentheses, brackets, and braces.

0 commit comments

Comments
 (0)