1717/// parent chain).
1818/// - [`super::conditional_resolution`]: PHPStan conditional return type
1919/// resolution at call sites.
20+ use std:: collections:: HashMap ;
21+
2022use crate :: Backend ;
2123use crate :: docblock;
2224use crate :: docblock:: types:: {
2325 parse_generic_args, split_intersection_depth0, split_union_depth0, strip_generics,
2426} ;
25- use crate :: inheritance:: apply_generic_args;
27+ use crate :: inheritance:: { apply_generic_args, apply_substitution } ;
2628use crate :: types:: * ;
2729
2830use super :: conditional_resolution:: {
2931 resolve_conditional_with_text_args, resolve_conditional_without_args, split_call_subject,
32+ split_text_args,
3033} ;
3134
3235/// A single bracket segment in a chained array access subject.
@@ -1297,12 +1300,26 @@ impl Backend {
12971300
12981301 let mut results = Vec :: new ( ) ;
12991302 for owner in & lhs_classes {
1303+ // Build template substitution map when the method has
1304+ // method-level @template params and we have arguments.
1305+ let template_subs = if !text_args. is_empty ( ) {
1306+ Self :: build_method_template_subs (
1307+ owner,
1308+ method_name,
1309+ text_args,
1310+ ctx,
1311+ class_loader,
1312+ )
1313+ } else {
1314+ HashMap :: new ( )
1315+ } ;
13001316 results. extend ( Self :: resolve_method_return_types_with_args (
13011317 owner,
13021318 method_name,
13031319 text_args,
13041320 all_classes,
13051321 class_loader,
1322+ & template_subs,
13061323 ) ) ;
13071324 }
13081325 return results;
@@ -1330,12 +1347,24 @@ impl Backend {
13301347 } ;
13311348
13321349 if let Some ( ref owner) = owner_class {
1350+ let template_subs = if !text_args. is_empty ( ) {
1351+ Self :: build_method_template_subs (
1352+ owner,
1353+ method_name,
1354+ text_args,
1355+ ctx,
1356+ class_loader,
1357+ )
1358+ } else {
1359+ HashMap :: new ( )
1360+ } ;
13331361 return Self :: resolve_method_return_types_with_args (
13341362 owner,
13351363 method_name,
13361364 text_args,
13371365 all_classes,
13381366 class_loader,
1367+ & template_subs,
13391368 ) ;
13401369 }
13411370 return vec ! [ ] ;
@@ -1407,41 +1436,24 @@ impl Backend {
14071436 vec ! [ ]
14081437 }
14091438
1410- /// Look up a method's return type in a class (including inherited methods)
1411- /// and resolve all candidate classes.
1412- ///
1413- /// When the return type is a union (e.g. `A|B`), every resolvable part
1414- /// is returned as a separate candidate.
1415- pub ( crate ) fn resolve_method_return_types (
1416- class_info : & ClassInfo ,
1417- method_name : & str ,
1418- all_classes : & [ ClassInfo ] ,
1419- class_loader : & dyn Fn ( & str ) -> Option < ClassInfo > ,
1420- ) -> Vec < ClassInfo > {
1421- Self :: resolve_method_return_types_with_args (
1422- class_info,
1423- method_name,
1424- "" ,
1425- all_classes,
1426- class_loader,
1427- )
1428- }
1429-
14301439 /// Resolve a method call's return type, taking into account PHPStan
1431- /// conditional return types when `text_args` is provided.
1440+ /// conditional return types when `text_args` is provided, and
1441+ /// method-level `@template` substitutions when `template_subs` is
1442+ /// non-empty.
14321443 ///
14331444 /// This is the workhorse behind both `resolve_method_return_types`
14341445 /// (which passes `""`) and the inline call-chain path (which passes
14351446 /// the raw argument text from the source, e.g. `"CurrentCart::class"`).
1436- fn resolve_method_return_types_with_args (
1447+ pub ( super ) fn resolve_method_return_types_with_args (
14371448 class_info : & ClassInfo ,
14381449 method_name : & str ,
14391450 text_args : & str ,
14401451 all_classes : & [ ClassInfo ] ,
14411452 class_loader : & dyn Fn ( & str ) -> Option < ClassInfo > ,
1453+ template_subs : & HashMap < String , String > ,
14421454 ) -> Vec < ClassInfo > {
14431455 // Helper: try to resolve a method's conditional return type, falling
1444- // back to the plain return type.
1456+ // back to template-substituted return type, then plain return type.
14451457 let resolve_method = |method : & MethodInfo | -> Vec < ClassInfo > {
14461458 // Try conditional return type first (PHPStan syntax)
14471459 if let Some ( ref cond) = method. conditional_return {
@@ -1458,6 +1470,28 @@ impl Backend {
14581470 }
14591471 }
14601472 }
1473+
1474+ // Try method-level @template substitution on the return type.
1475+ // This handles the general case where the return type references
1476+ // a template param (e.g. `@return Collection<T>`) and we have
1477+ // resolved bindings from the call-site arguments.
1478+ if !template_subs. is_empty ( )
1479+ && let Some ( ref ret) = method. return_type
1480+ {
1481+ let substituted = apply_substitution ( ret, template_subs) ;
1482+ if substituted != * ret {
1483+ let classes = Self :: type_hint_to_classes (
1484+ & substituted,
1485+ & class_info. name ,
1486+ all_classes,
1487+ class_loader,
1488+ ) ;
1489+ if !classes. is_empty ( ) {
1490+ return classes;
1491+ }
1492+ }
1493+ }
1494+
14611495 // Fall back to plain return type
14621496 if let Some ( ref ret) = method. return_type {
14631497 return Self :: type_hint_to_classes (
@@ -1484,6 +1518,128 @@ impl Backend {
14841518 vec ! [ ]
14851519 }
14861520
1521+ /// Build a template substitution map for a method-level `@template` call.
1522+ ///
1523+ /// Finds the method on the class (or inherited), checks for template
1524+ /// params and bindings, resolves argument types from `text_args` using
1525+ /// the call resolution context, and returns a `HashMap` mapping template
1526+ /// parameter names to their resolved concrete types.
1527+ ///
1528+ /// Returns an empty map if the method has no template params, no
1529+ /// bindings, or if argument types cannot be resolved.
1530+ pub ( super ) fn build_method_template_subs (
1531+ class_info : & ClassInfo ,
1532+ method_name : & str ,
1533+ text_args : & str ,
1534+ ctx : & CallResolutionCtx < ' _ > ,
1535+ class_loader : & dyn Fn ( & str ) -> Option < ClassInfo > ,
1536+ ) -> HashMap < String , String > {
1537+ // Find the method — first on the class directly, then via inheritance.
1538+ let method = class_info
1539+ . methods
1540+ . iter ( )
1541+ . find ( |m| m. name == method_name)
1542+ . cloned ( )
1543+ . or_else ( || {
1544+ let merged = Self :: resolve_class_with_inheritance ( class_info, class_loader) ;
1545+ merged. methods . into_iter ( ) . find ( |m| m. name == method_name)
1546+ } ) ;
1547+
1548+ let method = match method {
1549+ Some ( m) if !m. template_params . is_empty ( ) && !m. template_bindings . is_empty ( ) => m,
1550+ _ => return HashMap :: new ( ) ,
1551+ } ;
1552+
1553+ let args = split_text_args ( text_args) ;
1554+ let mut subs = HashMap :: new ( ) ;
1555+
1556+ for ( tpl_name, param_name) in & method. template_bindings {
1557+ // Find the parameter index for this binding.
1558+ let param_idx = match method. parameters . iter ( ) . position ( |p| p. name == * param_name) {
1559+ Some ( idx) => idx,
1560+ None => continue ,
1561+ } ;
1562+
1563+ // Get the corresponding argument text.
1564+ let arg_text = match args. get ( param_idx) {
1565+ Some ( text) => text. trim ( ) ,
1566+ None => continue ,
1567+ } ;
1568+
1569+ // Try to resolve the argument text to a type name.
1570+ if let Some ( type_name) = Self :: resolve_arg_text_to_type ( arg_text, ctx) {
1571+ subs. insert ( tpl_name. clone ( ) , type_name) ;
1572+ }
1573+ }
1574+
1575+ subs
1576+ }
1577+
1578+ /// Resolve an argument text string to a type name.
1579+ ///
1580+ /// Handles common patterns:
1581+ /// - `ClassName::class` → `ClassName`
1582+ /// - `new ClassName(…)` → `ClassName`
1583+ /// - `$this` / `self` / `static` → current class name
1584+ /// - `$this->prop` → property type
1585+ /// - `$var` → variable type via assignment scanning
1586+ fn resolve_arg_text_to_type ( arg_text : & str , ctx : & CallResolutionCtx < ' _ > ) -> Option < String > {
1587+ let trimmed = arg_text. trim ( ) ;
1588+
1589+ // ClassName::class → ClassName
1590+ if let Some ( name) = trimmed. strip_suffix ( "::class" )
1591+ && !name. is_empty ( )
1592+ && name
1593+ . chars ( )
1594+ . all ( |c| c. is_alphanumeric ( ) || c == '_' || c == '\\' )
1595+ {
1596+ return Some ( name. strip_prefix ( '\\' ) . unwrap_or ( name) . to_string ( ) ) ;
1597+ }
1598+
1599+ // new ClassName(…) → ClassName
1600+ if let Some ( class_name) = Self :: extract_new_expression_class ( trimmed) {
1601+ return Some ( class_name) ;
1602+ }
1603+
1604+ // $this / self / static → current class
1605+ if trimmed == "$this" || trimmed == "self" || trimmed == "static" {
1606+ return ctx. current_class . map ( |c| c. name . clone ( ) ) ;
1607+ }
1608+
1609+ // $this->prop → property type
1610+ if let Some ( prop) = trimmed
1611+ . strip_prefix ( "$this->" )
1612+ . or_else ( || trimmed. strip_prefix ( "$this?->" ) )
1613+ && prop. chars ( ) . all ( |c| c. is_alphanumeric ( ) || c == '_' )
1614+ && let Some ( owner) = ctx. current_class
1615+ {
1616+ let types =
1617+ Self :: resolve_property_types ( prop, owner, ctx. all_classes , ctx. class_loader ) ;
1618+ if let Some ( first) = types. first ( ) {
1619+ return Some ( first. name . clone ( ) ) ;
1620+ }
1621+ }
1622+
1623+ // $var → resolve variable type
1624+ if trimmed. starts_with ( '$' ) {
1625+ let classes = Self :: resolve_target_classes (
1626+ trimmed,
1627+ crate :: types:: AccessKind :: Arrow ,
1628+ ctx. current_class ,
1629+ ctx. all_classes ,
1630+ ctx. content ,
1631+ ctx. cursor_offset ,
1632+ ctx. class_loader ,
1633+ ctx. function_loader ,
1634+ ) ;
1635+ if let Some ( first) = classes. first ( ) {
1636+ return Some ( first. name . clone ( ) ) ;
1637+ }
1638+ }
1639+
1640+ None
1641+ }
1642+
14871643 /// Look up a property's type hint and resolve all candidate classes.
14881644 ///
14891645 /// When the type hint is a union (e.g. `A|B`), every resolvable part
0 commit comments