Skip to content

Commit 58ae8ce

Browse files
committed
Infer array key/value generics in constructors and evaluate
key-of/value-of after substitution
1 parent ce05023 commit 58ae8ce

6 files changed

Lines changed: 466 additions & 17 deletions

File tree

docs/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
5353
- **Type narrowing.** Comprehensive fixes to type narrowing: non-exiting `if` branches no longer leak narrowed types into post-merge scope; `is_float()`, `is_null()`, and other `is_*()` guards correctly narrow multi-member unions; negated `instanceof` on nullable unions preserves `null`; `is_object()` narrows to all class types in a union (not just the first); `instanceof` on `mixed` or `object` narrows to the checked type; `=== null` and `== null` narrow to `null` in the truthy branch; `assert($x instanceof Foo)` followed by any `if` block no longer loses the narrowed type; falsy guard clauses strip both `false` and `null`; template-constrained types are expanded before guard filtering. `is_numeric()`, `is_bool()`, and `is_scalar()` guard clauses now narrow union types correctly. `exit` and `die` are recognized as unconditional exits for guard clause narrowing. Property access expressions (`$a->foo`) are now narrowed through chained if/elseif/else conditions using `is_string()`, `instanceof`, and other type guards. Array shape keys are now narrowed through null-check guard clauses and conditional reassignment (e.g. `$a["test"]` resolves to `int` instead of `?int` after `if ($a["test"] === null) { return; }`). After `instanceof` check plus reassignment inside the if-body, the post-merge type collapses to the common parent class instead of producing a redundant `Child|Parent` union. Property access after OR'd `instanceof` checks (`$a instanceof B || $a instanceof C`) now resolves to the union of all branches' property types. After `while` and `do-while` loops exit, the loop condition's inverse is applied to narrow variable types (e.g. `do { $a = $a->next; } while ($a)` narrows `$a` to `null` after the loop; `while ($a instanceof Foo)` narrows `$a` to the excluded type after exit). Branch merging no longer loses nullable information when two branches produce the same class with different nullability (e.g. merging `A` with `A|null` correctly yields `A|null` instead of silently dropping the null).
5454
- **Mixin resolution.** Static method calls on instances of a class with `@mixin` now resolve through the mixin. `@method` and `@property` tags declared on a mixin class are propagated to the consuming class. `$this` return types on mixin methods resolve to the consumer class. `IteratorIterator` is now patched with `@template` parameters and `@mixin TIterator` (matching PHPStan's stubs).
5555
- **Generics.** Constructor generic inference now works through inherited constructors: child classes without their own constructor infer template parameters from the parent's constructor arguments, with correct remapping through multi-level `@extends` chains (including swapped or renamed template parameters). Function-level `@template` parameters bound to generic wrapper types (e.g. `@param Container<TItem> $c`) are inferred from arguments that extend the wrapper class. Method calls are now case-insensitive, matching PHP semantics (e.g. `$obj->getId()` finds `getID()`). Closure literals passed to `@template` parameters are recognised as `Closure`. Class-level template parameters are preserved through chained method calls. Template parameters fall back to their declared bound (or `mixed` when unbounded) when subclasses omit `@extends` or `@use` annotations. Unbound template parameters at call sites resolve to their declared bounds or `mixed` instead of leaking raw names. Method-level templates resolve correctly through generic wrappers and nested call chains. Return type generic arguments are preserved for template substitution, fixing false "expects TRelatedModel, got Translation" diagnostics. Iterating over a subclass that extends a generic collection with scalar type arguments (e.g. `IntCollection extends Collection<int, int>`) now yields the concrete scalar type instead of the raw template parameter name. Calling a method on a union of generic types (e.g. `$var->get()` where `$var` is `C<A>|C<B>`) now resolves to the union of each branch's return type (`A|B`) instead of only the first branch. Empty array literals passed to generic constructors or functions infer `never` for element type parameters (e.g. `new ArrayCollection([])` resolves to `ArrayCollection<never, never>`).
56+
- **Type narrowing.** Comprehensive fixes to type narrowing: non-exiting `if` branches no longer leak narrowed types into post-merge scope; `is_float()`, `is_null()`, and other `is_*()` guards correctly narrow multi-member unions; negated `instanceof` on nullable unions preserves `null`; `is_object()` narrows to all class types in a union (not just the first); `instanceof` on `mixed` or `object` narrows to the checked type; `=== null` and `== null` narrow to `null` in the truthy branch; `assert($x instanceof Foo)` followed by any `if` block no longer loses the narrowed type; falsy guard clauses strip both `false` and `null`; template-constrained types are expanded before guard filtering. `is_numeric()`, `is_bool()`, and `is_scalar()` guard clauses now narrow union types correctly. `exit` and `die` are recognized as unconditional exits for guard clause narrowing. Property access expressions (`$a->foo`) are now narrowed through chained if/elseif/else conditions using `is_string()`, `instanceof`, and other type guards. Array shape keys are now narrowed through null-check guard clauses and conditional reassignment (e.g. `$a["test"]` resolves to `int` instead of `?int` after `if ($a["test"] === null) { return; }`). After `instanceof` check plus reassignment inside the if-body, the post-merge type collapses to the common parent class instead of producing a redundant `Child|Parent` union.
57+
- **Generics.** Constructor generic inference now works through inherited constructors: child classes without their own constructor infer template parameters from the parent's constructor arguments, with correct remapping through multi-level `@extends` chains (including swapped or renamed template parameters). Function-level `@template` parameters bound to generic wrapper types (e.g. `@param Container<TItem> $c`) are inferred from arguments that extend the wrapper class. Method calls are now case-insensitive, matching PHP semantics (e.g. `$obj->getId()` finds `getID()`). Closure literals passed to `@template` parameters are recognised as `Closure`. Class-level template parameters are preserved through chained method calls. Template parameters fall back to their declared bound (or `mixed` when unbounded) when subclasses omit `@extends` or `@use` annotations. Unbound template parameters at call sites resolve to their declared bounds or `mixed` instead of leaking raw names. Method-level templates resolve correctly through generic wrappers and nested call chains. Return type generic arguments are preserved for template substitution, fixing false "expects TRelatedModel, got Translation" diagnostics. Iterating over a subclass that extends a generic collection with scalar type arguments (e.g. `IntCollection extends Collection<int, int>`) now yields the concrete scalar type instead of the raw template parameter name. Calling a method on a union of generic types (e.g. `$var->get()` where `$var` is `C<A>|C<B>`) now resolves to the union of each branch's return type (`A|B`) instead of only the first branch. Empty array literals passed to generic constructors or functions infer `never` for element type parameters (e.g. `new ArrayCollection([])` resolves to `ArrayCollection<never, never>`). Array literal arguments to `@param array<TKey, TValue>` constructor parameters now infer key and value types separately (e.g. `["a" => new Foo()]` infers `TKey=string, TValue=Foo`). `key-of<T>`, `value-of<T>`, and indexed access types (`T[K]`) now evaluate to concrete types after template substitution (e.g. `value-of<array{name: string, user: User}>` resolves to `string|User`).
5658
- **Vendor functions and constants.** Functions and constants defined in vendor packages are now indexed at startup, eliminating false-positive "Function not found" diagnostics.
5759
- **Use-imported classes no longer shadowed by global-namespace stubs.** The use-map is now checked first for unqualified names, fixing Laravel Facade static method resolution.
5860
- **Same-name class in a different namespace no longer shadows inherited members.** Parent resolution no longer picks up a same-named class from the current namespace instead of the intended global-namespace parent.

docs/todo/bugs.md

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,14 +25,17 @@ Remaining gaps:
2525
function names in namespaced code are not resolved to their FQN,
2626
so function-level template inference fails in single-file tests
2727
with multiple namespaces. Works correctly in real projects.
28-
- **`array<TKey, TValue>` constructor inference**: multi-arg array
29-
generic params in constructors (e.g. `@param array<TKey,TValue> $kv`)
30-
do not infer key/value types separately.
31-
- **`key-of<T>` and indexed access types** (`T[K]`): advanced type
32-
operators not yet supported.
28+
- **Method-level `@template` with `key-of<T>` bound and `T[K]` return**:
29+
`key-of<T>`, `value-of<T>`, and `T[K]` now evaluate correctly after
30+
class-level template substitution. However, inferring a method-level
31+
template parameter `K` from a string literal argument (to resolve
32+
`T[K]` at a specific call site) is not yet supported.
33+
- **`__get` magic method template resolution**: `$foo->a` on a class
34+
using `__get` with `@template K as key-of<TData>` / `@return TData[K]`
35+
does not infer `K` from the property name.
3336

3437
**Tests:** SKIPs in `tests/psalm_assertions/template_class_template_extends.php`
35-
(lines 177, 227, 427, 500, 681-682, 737-738, 843 (namespace)).
38+
(lines 177, 227, 427, 500, 682, 737-738, 843 (namespace)).
3639

3740

3841
## B14. Template/generic resolution in namespace-level and complex scenarios

src/completion/variable/rhs_resolution.rs

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1404,6 +1404,17 @@ fn resolve_generic_wrapper_template(
14041404
rctx: &crate::completion::resolver::ResolutionCtx<'_>,
14051405
ctx: &VarResolutionCtx<'_>,
14061406
) -> Option<PhpType> {
1407+
// ── Built-in array-like types ───────────────────────────────
1408+
// `array`, `list`, `non-empty-array`, `non-empty-list` are not
1409+
// real classes — infer key/value types directly from the array
1410+
// literal argument.
1411+
if matches!(
1412+
wrapper_name,
1413+
"array" | "list" | "non-empty-array" | "non-empty-list"
1414+
) {
1415+
return resolve_array_literal_generic(tpl_position, arg_text, rctx);
1416+
}
1417+
14071418
// Load the wrapper class.
14081419
let wrapper_cls = (ctx.class_loader)(wrapper_name)
14091420
.map(Arc::unwrap_or_clone)
@@ -1439,6 +1450,75 @@ fn resolve_generic_wrapper_template(
14391450
wrapper_subs.get(wrapper_tpl.as_str()).cloned()
14401451
}
14411452

1453+
/// Infer a generic type argument from an array literal.
1454+
///
1455+
/// For `@param array<TKey, TValue> $kv` with argument `["a" => 1]`:
1456+
/// - `tpl_position == 0` → key type (`string`)
1457+
/// - `tpl_position == 1` → value type (`int`)
1458+
///
1459+
/// For single-param wrappers like `list<T>`, position 0 is the element type.
1460+
fn resolve_array_literal_generic(
1461+
tpl_position: usize,
1462+
arg_text: &str,
1463+
rctx: &crate::completion::resolver::ResolutionCtx<'_>,
1464+
) -> Option<PhpType> {
1465+
let trimmed = arg_text.trim();
1466+
1467+
// Must be an array literal.
1468+
let inner = if trimmed.starts_with('[') && trimmed.ends_with(']') {
1469+
trimmed[1..trimmed.len() - 1].trim()
1470+
} else if let Some(s) = trimmed.strip_prefix("array(") {
1471+
s.strip_suffix(')')?.trim()
1472+
} else {
1473+
// Not an array literal — cannot infer.
1474+
return None;
1475+
};
1476+
1477+
if inner.is_empty() {
1478+
return Some(PhpType::never());
1479+
}
1480+
1481+
let elements = crate::completion::conditional_resolution::split_text_args(inner);
1482+
1483+
// Determine whether elements are key=>value pairs.
1484+
// Check the first element for `=>`.
1485+
let first = elements.first()?.trim();
1486+
let has_keys = first.contains("=>");
1487+
1488+
if has_keys {
1489+
// Collect key types (position 0) or value types (position 1)
1490+
// from the first element (sufficient for inference).
1491+
let arrow_pos = first.find("=>")?;
1492+
match tpl_position {
1493+
0 => {
1494+
let key_text = first[..arrow_pos].trim();
1495+
Backend::resolve_arg_text_to_type(key_text, rctx)
1496+
}
1497+
1 => {
1498+
let val_text = first[arrow_pos + 2..].trim();
1499+
Backend::resolve_arg_text_to_type(val_text, rctx)
1500+
}
1501+
_ => None,
1502+
}
1503+
} else {
1504+
// No keys — this is a list-style array.
1505+
// Position 0 in `array<T>` or `list<T>` is the element type.
1506+
// Position 0 in `array<TKey, TValue>` would be `int` (implicit key).
1507+
// Position 1 in `array<TKey, TValue>` is the element type.
1508+
match tpl_position {
1509+
0 => {
1510+
// Implicit integer keys.
1511+
Some(PhpType::Named("int".to_string()))
1512+
}
1513+
1 => {
1514+
// Element type from first element.
1515+
Backend::resolve_arg_text_to_type(first, rctx)
1516+
}
1517+
_ => None,
1518+
}
1519+
}
1520+
}
1521+
14421522
/// Resolve `$arr[0]` / `$arr[$key]` by extracting the generic element
14431523
/// type from the base array's annotation or assignment.
14441524
fn resolve_rhs_array_access<'b>(

0 commit comments

Comments
 (0)