@@ -151,3 +151,219 @@ fn enrich_no_scope_attribute_and_no_convention_skips() {
151151 let result = enrich_builder_type_in_scope ( "Builder" , "active" , false , & model, & model_loader) ;
152152 assert_eq ! ( result, None ) ;
153153}
154+
155+ // ── Variable resolution: static chain assignment ────────────────────
156+
157+ /// `$result = Foo::create()->process(); $result->` should resolve
158+ /// through the static call chain when `resolve_variable_types` is
159+ /// called directly.
160+ #[ test]
161+ fn resolve_var_from_static_method_chain_assignment ( ) {
162+ use crate :: types:: MethodInfo ;
163+
164+ let content = r#"<?php
165+ class Processor {
166+ public function getOutput(): string { return ''; }
167+ }
168+
169+ class Builder {
170+ public function process(): Processor { return new Processor(); }
171+ }
172+
173+ class Factory {
174+ public static function create(): Builder { return new Builder(); }
175+ }
176+
177+ function test() {
178+ $result = Factory::create()->process();
179+ $result->
180+ }
181+ "# ;
182+ // Classes that exist in this file
183+ let processor = {
184+ let mut c = make_class ( "Processor" ) ;
185+ c. methods . push ( MethodInfo {
186+ is_static : false ,
187+ ..MethodInfo :: virtual_method ( "getOutput" , Some ( "string" ) )
188+ } ) ;
189+ c
190+ } ;
191+ let builder = {
192+ let mut c = make_class ( "Builder" ) ;
193+ c. methods . push ( MethodInfo {
194+ is_static : false ,
195+ ..MethodInfo :: virtual_method ( "process" , Some ( "Processor" ) )
196+ } ) ;
197+ c
198+ } ;
199+ let factory = {
200+ let mut c = make_class ( "Factory" ) ;
201+ c. methods . push ( MethodInfo {
202+ is_static : true ,
203+ ..MethodInfo :: virtual_method ( "create" , Some ( "Builder" ) )
204+ } ) ;
205+ c
206+ } ;
207+
208+ let all_classes = vec ! [ processor. clone( ) , builder. clone( ) , factory. clone( ) ] ;
209+ let class_loader = |name : & str | -> Option < ClassInfo > {
210+ match name {
211+ "Processor" => Some ( processor. clone ( ) ) ,
212+ "Builder" => Some ( builder. clone ( ) ) ,
213+ "Factory" => Some ( factory. clone ( ) ) ,
214+ _ => None ,
215+ }
216+ } ;
217+
218+ // cursor_offset: find the position of `$result->` on the last
219+ // meaningful line. We need an offset inside `function test()`.
220+ let cursor_offset = content. find ( "$result->" ) . unwrap ( ) as u32 + 9 ; // after `->`
221+
222+ let results = super :: resolve_variable_types (
223+ "$result" ,
224+ & ClassInfo :: default ( ) ,
225+ & all_classes,
226+ content,
227+ cursor_offset,
228+ & class_loader,
229+ None ,
230+ ) ;
231+
232+ let names: Vec < & str > = results. iter ( ) . map ( |c| c. name . as_str ( ) ) . collect ( ) ;
233+ assert ! (
234+ names. contains( & "Processor" ) ,
235+ "$result should resolve to Processor via Factory::create()->process(), got: {:?}" ,
236+ names
237+ ) ;
238+ }
239+
240+ /// Cross-file scenario: `$user = User::factory()->create(); $user->`
241+ /// where `factory()` comes from a trait with `@return TFactory` and
242+ /// `create()` comes from the Factory base class with `@return TModel`.
243+ ///
244+ /// This mirrors the Laravel `HasFactory` + `Factory` pattern that the
245+ /// integration test `test_factory_variable_assignment_then_create`
246+ /// exercises through the full LSP handler.
247+ #[ test]
248+ fn resolve_var_from_cross_file_factory_chain ( ) {
249+ use crate :: types:: MethodInfo ;
250+
251+ // The PHP source that the variable resolver will parse.
252+ // Classes are NOT defined here — they come from class_loader.
253+ let content = r#"<?php
254+ use App\Models\User;
255+ function test() {
256+ $user = User::factory()->create();
257+ $user->
258+ }
259+ "# ;
260+
261+ // ── Build the class graph ───────────────────────────────────
262+
263+ // HasFactory trait: `public static function factory(): TFactory`
264+ // After trait merging with convention-based subs, User gets
265+ // `factory()` with return type `Database\Factories\UserFactory`.
266+ let has_factory_trait = {
267+ let mut c = make_class ( "HasFactory" ) ;
268+ c. file_namespace = Some ( "Illuminate\\ Database\\ Eloquent\\ Factories" . to_string ( ) ) ;
269+ c. template_params = vec ! [ "TFactory" . to_string( ) ] ;
270+ c. methods . push ( MethodInfo {
271+ is_static : true ,
272+ ..MethodInfo :: virtual_method ( "factory" , Some ( "TFactory" ) )
273+ } ) ;
274+ c
275+ } ;
276+
277+ // Factory base class: `public function create(): TModel`
278+ let factory_base = {
279+ let mut c = make_class ( "Factory" ) ;
280+ c. file_namespace = Some ( "Illuminate\\ Database\\ Eloquent\\ Factories" . to_string ( ) ) ;
281+ c. template_params = vec ! [ "TModel" . to_string( ) ] ;
282+ c. methods
283+ . push ( MethodInfo :: virtual_method ( "create" , Some ( "TModel" ) ) ) ;
284+ c. methods
285+ . push ( MethodInfo :: virtual_method ( "make" , Some ( "TModel" ) ) ) ;
286+ c
287+ } ;
288+
289+ // UserFactory extends Factory — convention says TModel = User.
290+ let user_factory = {
291+ let mut c = make_class ( "UserFactory" ) ;
292+ c. file_namespace = Some ( "Database\\ Factories" . to_string ( ) ) ;
293+ c. parent_class = Some ( "Illuminate\\ Database\\ Eloquent\\ Factories\\ Factory" . to_string ( ) ) ;
294+ // The virtual member provider would synthesize create()/make()
295+ // returning User, but for this unit test we add them directly
296+ // with the substituted return type.
297+ c. methods . push ( MethodInfo :: virtual_method (
298+ "create" ,
299+ Some ( "App\\ Models\\ User" ) ,
300+ ) ) ;
301+ c. methods . push ( MethodInfo :: virtual_method (
302+ "make" ,
303+ Some ( "App\\ Models\\ User" ) ,
304+ ) ) ;
305+ c
306+ } ;
307+
308+ // Model base class
309+ let model_base = make_class ( "Model" ) ;
310+
311+ // User extends Model, uses HasFactory.
312+ // After trait merging, factory() returns UserFactory.
313+ let user = {
314+ let mut c = make_class ( "User" ) ;
315+ c. file_namespace = Some ( "App\\ Models" . to_string ( ) ) ;
316+ c. parent_class = Some ( "Illuminate\\ Database\\ Eloquent\\ Model" . to_string ( ) ) ;
317+ c. used_traits = vec ! [ "Illuminate\\ Database\\ Eloquent\\ Factories\\ HasFactory" . to_string( ) ] ;
318+ // Simulate the result of trait merging with convention-based
319+ // TFactory substitution: factory() returns UserFactory FQN.
320+ c. methods . push ( MethodInfo {
321+ is_static : true ,
322+ ..MethodInfo :: virtual_method ( "factory" , Some ( "Database\\ Factories\\ UserFactory" ) )
323+ } ) ;
324+ c. methods
325+ . push ( MethodInfo :: virtual_method ( "greet" , Some ( "string" ) ) ) ;
326+ c
327+ } ;
328+
329+ let all_classes: Vec < ClassInfo > = vec ! [ ] ;
330+
331+ let user_c = user. clone ( ) ;
332+ let user_factory_c = user_factory. clone ( ) ;
333+ let factory_base_c = factory_base. clone ( ) ;
334+ let model_base_c = model_base. clone ( ) ;
335+ let has_factory_c = has_factory_trait. clone ( ) ;
336+ let class_loader = move |name : & str | -> Option < ClassInfo > {
337+ match name {
338+ "User" | "App\\ Models\\ User" => Some ( user_c. clone ( ) ) ,
339+ "UserFactory" | "Database\\ Factories\\ UserFactory" => Some ( user_factory_c. clone ( ) ) ,
340+ "Factory" | "Illuminate\\ Database\\ Eloquent\\ Factories\\ Factory" => {
341+ Some ( factory_base_c. clone ( ) )
342+ }
343+ "Model" | "Illuminate\\ Database\\ Eloquent\\ Model" => Some ( model_base_c. clone ( ) ) ,
344+ "HasFactory" | "Illuminate\\ Database\\ Eloquent\\ Factories\\ HasFactory" => {
345+ Some ( has_factory_c. clone ( ) )
346+ }
347+ _ => None ,
348+ }
349+ } ;
350+
351+ let cursor_offset = content. find ( "$user->" ) . unwrap ( ) as u32 + 7 ;
352+
353+ let results = super :: resolve_variable_types (
354+ "$user" ,
355+ & ClassInfo :: default ( ) ,
356+ & all_classes,
357+ content,
358+ cursor_offset,
359+ & class_loader,
360+ None ,
361+ ) ;
362+
363+ let names: Vec < & str > = results. iter ( ) . map ( |c| c. name . as_str ( ) ) . collect ( ) ;
364+ assert ! (
365+ names. contains( & "User" ) ,
366+ "$user should resolve to User via User::factory()->create(), got: {:?}" ,
367+ names
368+ ) ;
369+ }
0 commit comments