Skip to content

Commit 35d37f9

Browse files
committed
Chained method calls in variable assignment
1 parent 84d806c commit 35d37f9

3 files changed

Lines changed: 903 additions & 27 deletions

File tree

example.php

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,23 @@
6868
$maybe?->getProfile()?->getDisplayName();
6969

7070

71+
// ── Chained Method Calls in Variable Assignment ─────────────────────────────
72+
// When a variable is assigned from a chained call, the LSP walks the full
73+
// chain to resolve the stored type.
74+
75+
$storedProfile = $user->getProfile();
76+
$storedName = $storedProfile->getUser()->getName(); // $var->method()->method()
77+
78+
$directProfile = $user->getProfile()->getUser(); // chain stored in variable
79+
$directProfile->getEmail(); // resolves to User
80+
81+
$staticBuilt = User::make('test'); // Static::method() in assignment
82+
$staticBuilt->getEmail(); // resolves to User
83+
84+
$fromNew = (new UserProfile($user))->getUser(); // (new Class())->method()
85+
$fromNew->getEmail(); // resolves to User
86+
87+
7188
// ── Return Type Resolution ──────────────────────────────────────────────────
7289

7390
$made = User::make('Charlie'); // static return type

src/completion/resolver.rs

Lines changed: 250 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -406,7 +406,7 @@ impl Backend {
406406
content: &str,
407407
cursor_offset: usize,
408408
current_class: Option<&ClassInfo>,
409-
_all_classes: &[ClassInfo],
409+
all_classes: &[ClassInfo],
410410
class_loader: &dyn Fn(&str) -> Option<ClassInfo>,
411411
) -> Option<String> {
412412
let search_area = content.get(..cursor_offset)?;
@@ -472,9 +472,48 @@ impl Backend {
472472
}
473473

474474
// RHS is a call expression — extract the return type.
475+
//
476+
// Use backward paren scanning (like `split_call_subject`) so that
477+
// chained calls like `$this->getRepo()->findAll()` correctly
478+
// identify `findAll` as the outermost call, not `getRepo`.
475479
if rhs_text.ends_with(')') {
476-
let paren_pos = Self::find_top_level_open_paren(rhs_text)?;
477-
let callee = &rhs_text[..paren_pos];
480+
let (callee, _args_text) = split_call_subject(rhs_text)?;
481+
482+
// ── Chained call: callee contains `->` or `::` beyond a
483+
// single-level access ────────────────────────────────────
484+
// When the callee itself is a chain (e.g.
485+
// `$this->getRepo()->findAll`), delegate to
486+
// `resolve_raw_type_from_call_chain` which walks the full
487+
// chain recursively.
488+
let is_chain = callee.contains("->") && {
489+
if let Some(rest) = callee
490+
.strip_prefix("$this->")
491+
.or_else(|| callee.strip_prefix("$this?->"))
492+
{
493+
rest.contains("->") || rest.contains("::")
494+
} else {
495+
true
496+
}
497+
};
498+
let is_static_chain = !callee.contains("->") && callee.contains("::") && {
499+
let first_dc = callee.find("::").unwrap_or(0);
500+
callee[first_dc + 2..].contains("::") || callee[first_dc + 2..].contains("->")
501+
};
502+
503+
if is_chain || is_static_chain {
504+
return Self::resolve_raw_type_from_call_chain(
505+
callee,
506+
_args_text,
507+
current_class,
508+
all_classes,
509+
class_loader,
510+
);
511+
}
512+
513+
// ── `(new ClassName(…))` or `new ClassName(…)` ──────────
514+
if let Some(class_name) = Self::extract_new_expression_class(rhs_text) {
515+
return Some(class_name);
516+
}
478517

479518
// Method call: `$this->methodName(…)`
480519
if let Some(method_name) = callee.strip_prefix("$this->") {
@@ -527,6 +566,202 @@ impl Backend {
527566
None
528567
}
529568

569+
/// Extract the class name from a `new` expression, handling both
570+
/// parenthesized and bare forms:
571+
///
572+
/// - `(new Builder())` → `Some("Builder")`
573+
/// - `(new Builder)` → `Some("Builder")`
574+
/// - `new Builder()` → `Some("Builder")`
575+
/// - `(new \App\Builder())` → `Some("App\\Builder")`
576+
/// - `$this->foo()` → `None`
577+
fn extract_new_expression_class(s: &str) -> Option<String> {
578+
// Strip balanced outer parentheses.
579+
let inner = if s.starts_with('(') && s.ends_with(')') {
580+
&s[1..s.len() - 1]
581+
} else {
582+
s
583+
};
584+
let rest = inner.trim().strip_prefix("new ")?;
585+
let rest = rest.trim_start();
586+
// The class name runs until `(`, whitespace, or end-of-string.
587+
let end = rest
588+
.find(|c: char| c == '(' || c.is_whitespace())
589+
.unwrap_or(rest.len());
590+
let class_name = rest[..end].trim_start_matches('\\');
591+
if class_name.is_empty()
592+
|| !class_name
593+
.chars()
594+
.all(|c| c.is_alphanumeric() || c == '_' || c == '\\')
595+
{
596+
return None;
597+
}
598+
Some(class_name.to_string())
599+
}
600+
601+
/// Resolve a chained call expression to a raw type string, walking
602+
/// the chain from left to right.
603+
///
604+
/// This is used by `extract_raw_type_from_assignment_text` where we
605+
/// don't have a `function_loader` or full `CallResolutionCtx`, only
606+
/// `class_loader`. Handles:
607+
///
608+
/// - `$this->getRepo()->findAll` + args → return type of `findAll`
609+
/// - `(new Builder())->build` + args → return type of `build`
610+
/// - `Factory::create()->process` + args → return type of `process`
611+
fn resolve_raw_type_from_call_chain(
612+
callee: &str,
613+
_args_text: &str,
614+
current_class: Option<&ClassInfo>,
615+
all_classes: &[ClassInfo],
616+
class_loader: &dyn Fn(&str) -> Option<ClassInfo>,
617+
) -> Option<String> {
618+
// Split at the rightmost `->` to get the final method name and
619+
// the LHS expression that produces the owning object.
620+
let pos = callee.rfind("->")?;
621+
let lhs = &callee[..pos];
622+
let method_name = &callee[pos + 2..];
623+
624+
// Resolve LHS to a class.
625+
let owner = Self::resolve_lhs_to_class(lhs, current_class, all_classes, class_loader)?;
626+
let merged = Self::resolve_class_with_inheritance(&owner, class_loader);
627+
merged
628+
.methods
629+
.iter()
630+
.find(|m| m.name == method_name)
631+
.and_then(|m| m.return_type.clone())
632+
}
633+
634+
/// Resolve a text-based LHS expression (the part before `->method`)
635+
/// to a single `ClassInfo`.
636+
///
637+
/// Handles `$this`, `$this->prop`, `ClassName::method()`,
638+
/// `(new Foo())`, and recursive chains. Used by
639+
/// `resolve_raw_type_from_call_chain` for the text-only path.
640+
fn resolve_lhs_to_class(
641+
lhs: &str,
642+
current_class: Option<&ClassInfo>,
643+
all_classes: &[ClassInfo],
644+
class_loader: &dyn Fn(&str) -> Option<ClassInfo>,
645+
) -> Option<ClassInfo> {
646+
// `$this` / `self` / `static`
647+
if lhs == "$this" || lhs == "self" || lhs == "static" {
648+
return current_class.cloned();
649+
}
650+
651+
// `(new ClassName(...))` or `new ClassName(...)`
652+
if let Some(class_name) = Self::extract_new_expression_class(lhs) {
653+
let lookup = class_name.rsplit('\\').next().unwrap_or(&class_name);
654+
return all_classes
655+
.iter()
656+
.find(|c| c.name == lookup)
657+
.cloned()
658+
.or_else(|| class_loader(&class_name));
659+
}
660+
661+
// LHS ends with `)` — it's a call expression. Recurse.
662+
if lhs.ends_with(')') {
663+
let inner = lhs.strip_suffix(')')?;
664+
// Find matching open paren.
665+
let mut depth = 0u32;
666+
let mut open = None;
667+
for (i, b) in inner.bytes().enumerate().rev() {
668+
match b {
669+
b')' => depth += 1,
670+
b'(' => {
671+
if depth == 0 {
672+
open = Some(i);
673+
break;
674+
}
675+
depth -= 1;
676+
}
677+
_ => {}
678+
}
679+
}
680+
let open = open?;
681+
let inner_callee = &inner[..open];
682+
let inner_args = inner[open + 1..].trim();
683+
684+
// Inner callee may itself be a chain — recurse.
685+
let ret_type = Self::resolve_raw_type_from_call_chain(
686+
inner_callee,
687+
inner_args,
688+
current_class,
689+
all_classes,
690+
class_loader,
691+
)
692+
.or_else(|| {
693+
// Single-level: `$this->method`
694+
if let Some(m) = inner_callee
695+
.strip_prefix("$this->")
696+
.or_else(|| inner_callee.strip_prefix("$this?->"))
697+
{
698+
let owner = current_class?;
699+
let merged = Self::resolve_class_with_inheritance(owner, class_loader);
700+
return merged
701+
.methods
702+
.iter()
703+
.find(|mi| mi.name == m)
704+
.and_then(|mi| mi.return_type.clone());
705+
}
706+
// `ClassName::method`
707+
if let Some((cls_part, m_part)) = inner_callee.rsplit_once("::") {
708+
let resolved = if cls_part == "self" || cls_part == "static" {
709+
current_class.cloned()
710+
} else {
711+
let lookup = cls_part.rsplit('\\').next().unwrap_or(cls_part);
712+
all_classes
713+
.iter()
714+
.find(|c| c.name == lookup)
715+
.cloned()
716+
.or_else(|| class_loader(cls_part))
717+
};
718+
if let Some(cls) = resolved {
719+
let merged = Self::resolve_class_with_inheritance(&cls, class_loader);
720+
return merged
721+
.methods
722+
.iter()
723+
.find(|mi| mi.name == m_part)
724+
.and_then(|mi| mi.return_type.clone());
725+
}
726+
}
727+
None
728+
})?;
729+
730+
// `ret_type` is a type string — resolve it to ClassInfo.
731+
let clean = crate::docblock::types::clean_type(&ret_type);
732+
let lookup = clean.rsplit('\\').next().unwrap_or(&clean);
733+
return all_classes
734+
.iter()
735+
.find(|c| c.name == lookup)
736+
.cloned()
737+
.or_else(|| class_loader(&clean));
738+
}
739+
740+
// `$this->prop` — property access
741+
if let Some(prop) = lhs
742+
.strip_prefix("$this->")
743+
.or_else(|| lhs.strip_prefix("$this?->"))
744+
&& prop.chars().all(|c| c.is_alphanumeric() || c == '_')
745+
{
746+
let owner = current_class?;
747+
let merged = Self::resolve_class_with_inheritance(owner, class_loader);
748+
let type_str = merged
749+
.properties
750+
.iter()
751+
.find(|p| p.name == prop)
752+
.and_then(|p| p.type_hint.clone())?;
753+
let clean = crate::docblock::types::clean_type(&type_str);
754+
let lookup = clean.rsplit('\\').next().unwrap_or(&clean);
755+
return all_classes
756+
.iter()
757+
.find(|c| c.name == lookup)
758+
.cloned()
759+
.or_else(|| class_loader(&clean));
760+
}
761+
762+
None
763+
}
764+
530765
/// Find `;` in `s`, respecting `()`, `[]`, `{}`, and string nesting.
531766
fn find_semicolon_balanced(s: &str) -> Option<usize> {
532767
let mut depth_paren = 0i32;
@@ -572,30 +807,6 @@ impl Backend {
572807

573808
/// Find the position of the first `(` at nesting depth 0.
574809
///
575-
/// Respects `<…>` nesting for generic types but is careful not to
576-
/// treat `>` in `->` (arrow operator) as a closing angle bracket.
577-
fn find_top_level_open_paren(s: &str) -> Option<usize> {
578-
let mut depth_angle = 0i32;
579-
let bytes = s.as_bytes();
580-
let mut i = 0;
581-
while i < bytes.len() {
582-
match bytes[i] {
583-
b'<' => depth_angle += 1,
584-
b'>' if depth_angle > 0 => depth_angle -= 1,
585-
b'-' if i + 1 < bytes.len() && bytes[i + 1] == b'>' => {
586-
// Skip `->` entirely — it's an arrow operator, not
587-
// an angle bracket.
588-
i += 2;
589-
continue;
590-
}
591-
b'(' if depth_angle == 0 => return Some(i),
592-
_ => {}
593-
}
594-
i += 1;
595-
}
596-
None
597-
}
598-
599810
/// Search backward in `content` for a function definition matching
600811
/// `func_name` and extract its `@return` type from the docblock.
601812
fn extract_function_return_from_source(func_name: &str, content: &str) -> Option<String> {
@@ -725,6 +936,18 @@ impl Backend {
725936
let lhs_classes: Vec<ClassInfo> = if lhs == "$this" || lhs == "self" || lhs == "static"
726937
{
727938
current_class.cloned().into_iter().collect()
939+
} else if let Some(class_name) = Self::extract_new_expression_class(lhs) {
940+
// Parenthesized (or bare) `new` expression:
941+
// `(new Builder())`, `(new Builder)`, `new Builder()`
942+
// Resolve the class name to a ClassInfo.
943+
let lookup = class_name.rsplit('\\').next().unwrap_or(&class_name);
944+
all_classes
945+
.iter()
946+
.find(|c| c.name == lookup)
947+
.cloned()
948+
.or_else(|| class_loader(&class_name))
949+
.into_iter()
950+
.collect()
728951
} else if lhs.ends_with(')') {
729952
// LHS is itself a call expression (e.g. `app()` in
730953
// `app()->make(…)`, or `$this->getFactory()` in

0 commit comments

Comments
 (0)