Skip to content

Commit 5a5c740

Browse files
committed
Variable resolution: static chain assignment
1 parent 3c5ee29 commit 5a5c740

4 files changed

Lines changed: 549 additions & 10 deletions

File tree

src/completion/variable/resolution_tests.rs

Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
}

src/completion/variable/rhs_resolution.rs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -789,7 +789,6 @@ fn resolve_rhs_method_call<'b>(
789789
// Variable method name (`$obj->$method()`) — can't resolve statically.
790790
_ => return vec![],
791791
};
792-
793792
// Resolve the object expression to candidate owner classes.
794793
let owner_classes: Vec<ClassInfo> = if let Expression::Variable(Variable::Direct(dv)) =
795794
method_call.object

0 commit comments

Comments
 (0)