Skip to content

Commit 2a0b125

Browse files
committed
Fix template inference for stub interfaces and generic @var method calls
1 parent 0fa9385 commit 2a0b125

5 files changed

Lines changed: 56 additions & 45 deletions

File tree

docs/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1010
### Fixed
1111

1212
- **Multi-namespace function return type resolution.** In files with multiple `namespace { }` blocks, function return types were resolved against the first namespace instead of the function's own namespace. This caused incorrect type inference for variables assigned from function calls in later namespace blocks.
13+
- **Template inference through stub interfaces.** `@template-implements` on stub-loaded interfaces (e.g. `IteratorAggregate<int, Foo>`) now correctly propagates substituted return types to child methods that omit a return type annotation. Previously these resolved as untyped.
14+
- **Generic method return types from `@var` annotations.** When a variable is annotated with a generic type (e.g. `/** @var Collection<int, User> */ $items`), method calls on that variable now correctly substitute class-level template parameters into the return type. Previously, return types like `TValue` remained unsubstituted.
1315
- **Short class name resolution in type hints.** When resolving an unqualified class name from a property or return type annotation, the resolver now prefers the class in the same namespace as the owning type before falling back to first-match.
1416
- **Foreach narrowing with break in else.** When a foreach body contained an `if` with an `else { break; }` branch, the variable state from the break path was not included in the post-loop type, causing the merged type to be too narrow.
1517
- **Foreach element type from untyped arrays.** Variables bound in a `foreach` over an untyped `array` (e.g. from a function returning bare `array`) now resolve to `mixed` instead of empty, so assignments from the loop variable propagate correctly.

docs/todo/bugs.md

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -18,29 +18,30 @@ to second-guess upstream output.
1818

1919
Constructor generic inference through inherited constructors,
2020
case-insensitive method lookup, function-level `@template`
21-
inference through generic wrapper params, and function name
22-
resolution in multi-namespace files are now fixed.
21+
inference through generic wrapper params, function name
22+
resolution in multi-namespace files, `@extends` with swapped
23+
parameter order, `__get` magic method with `key-of<T>`/`T[K]`,
24+
`@template-implements` return type inheritance from stub
25+
interfaces, and class-level generic substitution in method
26+
call return types via `@var` annotations are now fixed.
2327
Remaining gaps:
2428

25-
- **Multi-namespace file class/function shadowing**: class names
26-
from earlier namespaces leak into later namespaces in single-file
27-
tests, causing wrong resolution. Works correctly in real projects.
29+
- **Array-access assignment overwrites `@var` generic type on
30+
`ArrayAccess` objects**: `$obj[$key] = $val` on an object that
31+
implements `ArrayAccess` causes the forward walker to lose the
32+
`@var` generic annotation on `$obj`. Works correctly when there
33+
is no array-access assignment between the `@var` and the method
34+
call.
2835
- **Method-level `@template` with `key-of<T>` bound and `T[K]` return**:
2936
`key-of<T>`, `value-of<T>`, and `T[K]` now evaluate correctly after
3037
class-level template substitution. However, inferring a method-level
3138
template parameter `K` from a string literal argument (to resolve
3239
`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.
36-
- **`@template-implements` return type inheritance from stub interfaces**:
37-
when a class implements a stub-loaded interface (e.g. `IteratorAggregate`)
38-
with `@template-implements Interface<T>` and overrides a method without
39-
a return type, the interface method's substituted return type is not
40-
propagated. Works correctly for same-file interfaces.
4140

4241
**Tests:** SKIPs in `tests/psalm_assertions/template_class_template_extends.php`
43-
(lines 427, 500, 682, 737-738, 843, 980-981).
42+
(line 500).
43+
44+
4445

4546

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

src/completion/variable/rhs_resolution.rs

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2626,9 +2626,40 @@ fn resolve_rhs_method_call_inner<'b>(
26262626
let is_union = owner_classes.len() > 1;
26272627
let mut union_results: Vec<ResolvedType> = Vec::new();
26282628

2629-
for owner in &owner_classes {
2630-
let template_subs =
2629+
for (idx, owner) in owner_classes.iter().enumerate() {
2630+
// Build class-level template substitutions from the receiver's
2631+
// generic type string (e.g. `Collection<int, User>` maps
2632+
// `TKey => int, TValue => User`). This ensures method return
2633+
// types like `TValue` are concretised when the receiver was
2634+
// annotated with generic arguments via `@var`.
2635+
let class_level_subs: HashMap<String, PhpType> = receiver_resolved
2636+
.get(idx)
2637+
.or_else(|| receiver_resolved.first())
2638+
.and_then(|rt| match &rt.type_string {
2639+
PhpType::Generic(_, args)
2640+
if !args.is_empty()
2641+
&& !owner.template_params.is_empty()
2642+
&& !args.iter().any(|a| a.is_self_like()) =>
2643+
{
2644+
Some(
2645+
owner
2646+
.template_params
2647+
.iter()
2648+
.zip(args.iter())
2649+
.map(|(name, ty)| (name.to_string(), ty.clone()))
2650+
.collect(),
2651+
)
2652+
}
2653+
_ => None,
2654+
})
2655+
.unwrap_or_default();
2656+
2657+
let method_template_subs =
26312658
Backend::build_method_template_subs(owner, &method_name, &arg_refs, &rctx);
2659+
2660+
// Merge class-level and method-level subs. Method-level takes precedence.
2661+
let mut template_subs = class_level_subs;
2662+
template_subs.extend(method_template_subs);
26322663
let mr_ctx = MethodReturnCtx {
26332664
all_classes: ctx.all_classes,
26342665
class_loader: ctx.class_loader,

tests/assert_type_runner.rs

Lines changed: 1 addition & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -24,25 +24,6 @@ use std::path::Path;
2424
use phpantom_lsp::Backend;
2525
use tower_lsp::lsp_types::*;
2626

27-
// ─── Stubs ──────────────────────────────────────────────────────────────────
28-
29-
/// Minimal UnitEnum stub so enum tests can resolve `$name`.
30-
static UNIT_ENUM_STUB: &str = r#"<?php
31-
interface UnitEnum {
32-
public string $name;
33-
public static function cases(): array;
34-
}
35-
"#;
36-
37-
/// Minimal BackedEnum stub so enum tests can resolve `$value`.
38-
static BACKED_ENUM_STUB: &str = r#"<?php
39-
interface BackedEnum extends UnitEnum {
40-
public int|string $value;
41-
public static function from(int|string $value): static;
42-
public static function tryFrom(int|string $value): ?static;
43-
}
44-
"#;
45-
4627
// ─── Assertion extraction ───────────────────────────────────────────────────
4728

4829
/// A single `assertType('expected', expr)` call found in the source.
@@ -580,11 +561,7 @@ fn extract_type_from_hover(hover_text: &str, var_name: &str) -> Option<String> {
580561
// ─── Test runner ────────────────────────────────────────────────────────────
581562

582563
fn create_assert_type_backend() -> Backend {
583-
let mut class_stubs: HashMap<&'static str, &'static str> = HashMap::new();
584-
class_stubs.insert("UnitEnum", UNIT_ENUM_STUB);
585-
class_stubs.insert("BackedEnum", BACKED_ENUM_STUB);
586-
587-
Backend::new_test_with_all_stubs(class_stubs, HashMap::new(), HashMap::new())
564+
Backend::new_test_with_full_stubs()
588565
}
589566

590567
fn run_assert_type(path: &Path, content: String) -> datatest_stable::Result<()> {

tests/psalm_assertions/template_class_template_extends.php

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -424,7 +424,7 @@ public function getIterator() {
424424

425425
$i = (new SomeIterator())->getIterator();
426426

427-
assertType('Traversable<int, Foo>', $i); // SKIP — stub-loaded interface (IteratorAggregate) template resolution not propagating to child method
427+
assertType('Traversable<int, Foo>|array<Foo>', $i);
428428
}
429429

430430
// Test: extendClassThatParameterizesTemplatedParent
@@ -497,7 +497,7 @@ public function __construct(SplObjectStorage $handlers)
497497
/** @psalm-suppress MixedAssignment */
498498
$b = $storage->offsetGet($c);
499499

500-
assertType('mixed', $b); // SKIP — SplObjectStorage::offsetGet return type resolved as ?SpecificEntity instead of mixed
500+
assertType('mixed', $b); // SKIP — array-access assignment ($storage[$c] = ...) overwrites @var generic type on ArrayAccess objects
501501
}
502502

503503
// Test: templateExtendsOnceWithSpecificStaticCall
@@ -679,7 +679,7 @@ public function __construct(array $kv) {
679679
$i = $c->getIterator();
680680

681681
assertType('C<string, int>', $c);
682-
assertType('ArrayIterator<string, int>', $i); // SKIP — multi-namespace: SomeIterator from earlier namespace shadows ArrayIterator resolution
682+
assertType('ArrayIterator<string, int>', $i);
683683
}
684684

685685
// Test: keyOfClassTemplateExtended
@@ -977,8 +977,8 @@ public function __construct(string $key, $value) {
977977
$b = $pair->two;
978978

979979
assertType('StringKeyedPair<int>', $pair);
980-
assertType('int', $a); // SKIP — @extends Pair<TValue, string> swaps params but substitution maps them incorrectly
981-
assertType('string', $b); // SKIP — @extends Pair<TValue, string> swaps params but substitution maps them incorrectly
980+
assertType('int', $a);
981+
assertType('string', $b);
982982
}
983983

984984
// Test: templateExtendsFewerTemplateParameters

0 commit comments

Comments
 (0)