@@ -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