Skip to content

Commit 914d8b1

Browse files
committed
Fix interface method return type inheritance and global fallback
1 parent abf0229 commit 914d8b1

5 files changed

Lines changed: 58 additions & 8 deletions

File tree

docs/CHANGELOG.md

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

4848
- **`__get` magic method template resolution.** Property access on a class whose `__get` uses method-level `@template` with `key-of<T>` bounds and `T[K]` return types now infers the concrete type from the property name. For example, `$bag->a` on a `DataBag<array{a: int, b: string}>` resolves to `int`.
4949
- **`(object)` cast type inference.** Casting a scalar to object now resolves to `object{scalar: <type>}` and casting a typed array shape resolves to an object shape with matching properties.
50+
- **Interface method return type inheritance.** When a class implements a same-file interface with `@implements Interface<ConcreteType>` and overrides a method without a return type, the interface method's template-substituted return type is now propagated.
51+
- **Class loader global fallback.** Unqualified class names used in namespaced code without a `use` statement (e.g. `implements IteratorAggregate` in `namespace App`) now fall back to global scope lookup when the namespace-qualified name doesn't exist.
5052
- **Literal `true`/`false` preserved in template inference.** Passing `true` or `false` to a generic constructor now keeps the precise type as the template argument (e.g. `C<false>`) instead of widening to `bool`.
5153
- **`@psalm-method` overrides `@method`.** When a class has both `@method int foo()` and `@psalm-method string foo()`, the vendor-prefixed tag now takes priority instead of using whichever appeared first in the docblock.
5254
- **Multi-namespace class resolution.** In files with multiple `namespace` blocks, short class names now resolve against the correct namespace for the current scope instead of picking the first same-named class in the file.

docs/todo/bugs.md

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,22 +17,30 @@ to second-guess upstream output.
1717
`tests/psalm_assertions/template_class_template_extends.php`.
1818

1919
Constructor generic inference through inherited constructors,
20-
case-insensitive method lookup, and function-level `@template`
21-
inference through generic wrapper params are now fixed.
20+
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.
2223
Remaining gaps:
2324

24-
- **Multi-namespace class shadowing in `IteratorAggregate` resolution**:
25-
When a multi-namespace file defines a class like `SomeIterator` in
26-
an earlier namespace, resolving `getIterator()` return type
27-
`ArrayIterator<K, V>` may pick up the wrong class.
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.
2828
- **Method-level `@template` with `key-of<T>` bound and `T[K]` return**:
2929
`key-of<T>`, `value-of<T>`, and `T[K]` now evaluate correctly after
3030
class-level template substitution. However, inferring a method-level
3131
template parameter `K` from a string literal argument (to resolve
3232
`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.
3341

3442
**Tests:** SKIPs in `tests/psalm_assertions/template_class_template_extends.php`
35-
(lines 427, 500, 682, 980, 981).
43+
(lines 427, 500, 682, 737-738, 843, 980-981).
3644

3745

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

src/inheritance.rs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -552,6 +552,36 @@ pub(crate) fn resolve_class_with_inheritance(
552552
current = ClassRef::Owned(parent);
553553
}
554554

555+
// 3. Enrich methods from implemented interfaces.
556+
// When a class overrides an interface method without a return type,
557+
// propagate the interface method's return type (with template
558+
// substitution from `@implements` generics).
559+
for iface_name in &class.interfaces {
560+
let Some(iface) = class_loader(iface_name) else {
561+
continue;
562+
};
563+
564+
// Build substitution map from @implements/@template-implements generics.
565+
let iface_subs =
566+
build_substitution_map(&ClassRef::Borrowed(class), &iface, &HashMap::new());
567+
568+
for method in &iface.methods {
569+
// Only enrich methods that the class already has (i.e. overrides).
570+
if let Some(existing) = merged
571+
.methods
572+
.make_mut()
573+
.iter_mut()
574+
.find(|m| m.name == method.name)
575+
{
576+
let mut ancestor_method = (**method).clone();
577+
if !iface_subs.is_empty() {
578+
apply_substitution_to_method(&mut ancestor_method, &iface_subs);
579+
}
580+
enrich_method_from_ancestor(Arc::make_mut(existing), &ancestor_method);
581+
}
582+
}
583+
}
584+
555585
// Refine the `value` property on backed enums. The `BackedEnum`
556586
// interface declares `public readonly int|string $value`, but each
557587
// concrete backed enum knows its specific backing type. Replace

src/resolution.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -839,6 +839,16 @@ impl Backend {
839839
if let Some(cls) = self.find_or_load_class(stripped) {
840840
return Some(cls);
841841
}
842+
// When the name is namespace-qualified (e.g. "App\IteratorAggregate")
843+
// and the direct lookup failed, try the short name as a global class.
844+
// This handles the case where resolve_parent_class_names prepended the
845+
// file namespace to an unqualified global class name.
846+
if stripped.contains('\\') {
847+
let short = crate::util::short_name(stripped);
848+
if let Some(cls) = self.find_or_load_class(short) {
849+
return Some(cls);
850+
}
851+
}
842852
self.resolve_class_name(name, classes, use_map, namespace)
843853
}
844854
}

tests/psalm_assertions/template_class_template_extends.php

Lines changed: 1 addition & 1 deletion
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 — getIterator return type not inferred from @template-implements IteratorAggregate
427+
assertType('Traversable<int, Foo>', $i); // SKIP — stub-loaded interface (IteratorAggregate) template resolution not propagating to child method
428428
}
429429

430430
// Test: extendClassThatParameterizesTemplatedParent

0 commit comments

Comments
 (0)