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
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 ;
2235use std:: collections:: HashMap ;
2336use 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+
102158impl 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 directly — it 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 / __callStatic — the 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)
16911820impl Backend {
16921821 /// Extract the first argument from a comma-separated argument text,
16931822 /// respecting nested parentheses, brackets, and braces.
0 commit comments