Skip to content

Commit daf4c76

Browse files
committed
Method-level @template support
1 parent d55d0b3 commit daf4c76

12 files changed

Lines changed: 1781 additions & 91 deletions

File tree

example.php

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,21 @@
103103
$found = findOrFail(1); // User|AdminUser union
104104
$found->getName(); // available on both types
105105

106+
107+
// ── Method-level @template (General Case) ───────────────────────────────────
108+
// When a method declares @template T and @param T $param, the resolver infers
109+
// T from the actual argument type — not just class-string<T>, but any object.
110+
111+
$mapper = new ObjectMapper();
112+
$mapped = $mapper->wrap($user); // wrap(@param T $item): Collection<T>
113+
$mapped->first(); // → User (T resolved to User)
114+
115+
$identity = $mapper->identity($user); // identity(@param T): T
116+
$identity->getEmail(); // → User methods
117+
118+
$mapper->wrap(new Product())->first()->getPrice(); // new expression arg → Product
119+
120+
106121
function handleIntersection(User&Loggable $entity): void {
107122
$entity->getEmail(); // from User
108123
$entity->log('saved'); // from Loggable
@@ -1161,6 +1176,32 @@ public function demo(): void
11611176
// ┃ SCAFFOLDING — Supporting definitions below this line. ┃
11621177
// ┃ Everything below exists to support the playground above. ┃
11631178
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
1179+
1180+
// ── ObjectMapper (method-level @template demo) ──────────────────────────────
1181+
1182+
class ObjectMapper
1183+
{
1184+
/**
1185+
* @template T
1186+
* @param T $item
1187+
* @return TypedCollection<int, T>
1188+
*/
1189+
public function wrap(object $item): TypedCollection
1190+
{
1191+
/** @var TypedCollection<int, T> */
1192+
return new TypedCollection();
1193+
}
1194+
1195+
/**
1196+
* @template T
1197+
* @param T $item
1198+
* @return T
1199+
*/
1200+
public function identity(mixed $item): mixed
1201+
{
1202+
return $item;
1203+
}
1204+
}
11641205
// ═══════════════════════════════════════════════════════════════════════════
11651206

11661207

src/completion/conditional_resolution.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@ pub(crate) fn resolve_conditional_with_text_args(
121121

122122
/// Split a textual argument list by commas, respecting nested parentheses
123123
/// so that `"foo(a, b), c"` splits into `["foo(a, b)", "c"]`.
124-
fn split_text_args(text: &str) -> Vec<&str> {
124+
pub(crate) fn split_text_args(text: &str) -> Vec<&str> {
125125
let mut result = Vec::new();
126126
let mut depth = 0u32;
127127
let mut start = 0;

src/completion/resolver.rs

Lines changed: 180 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -17,16 +17,19 @@
1717
/// parent chain).
1818
/// - [`super::conditional_resolution`]: PHPStan conditional return type
1919
/// resolution at call sites.
20+
use std::collections::HashMap;
21+
2022
use crate::Backend;
2123
use crate::docblock;
2224
use 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};
2628
use crate::types::*;
2729

2830
use 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

Comments
 (0)