Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **`@var` annotations no longer leak between functions.** A `/** @var T $x */` annotation in one function used to suppress "undefined variable" warnings for that name everywhere in the file; it is now scoped to the function it appears in.
- **Type resolution through chained and untyped access.** Null-safe call chains such as `$a->b?->c()` resolve through the full receiver. Array access on a value of unknown type resolves to `mixed`, so `$x = $arr['key'] ?? 5` no longer produces spurious type errors. `foreach` element types resolve through interfaces that reach a known iterable several hops away. Nested array-shape narrowing (`$a["x"]["y"]`) no longer targets the wrong key.
- **Generics with fewer arguments than parameters.** `@extends Collection<User>` against `Collection<TKey, TValue>` now binds `User` to the value parameter, so inherited element types resolve correctly.
- **Nullable generic return types resolve through inheritance.** A method whose native return hint is nullable (`object|null`) and whose docblock returns a template (`@return ?T`) now resolves to the bound type, so a repository's `find()` returns `Entity|null` instead of the bare `object|null`. Contributed by @MrSrsen in https://github.com/PHPantom-dev/phpantom_lsp/pull/152.
- **Conditional `is null` return types** resolve consistently regardless of how the call site is parsed, and an explicitly passed `null` now selects the null branch.
- **Go-to-definition, rename, and highlight accuracy.** References in `@see` tags to qualified names like `App\Foo::bar()` now land on the correct location, and renaming a property selects the whole `$name` instead of `$nam`.
- **Renaming variables captured by nested closures and arrow functions.** Renaming or finding references to a variable used inside deeply nested arrow functions (`fn () => fn () => $var`) or closures with `use ($var)` now updates every occurrence, whether the rename is triggered on the declaration or from deep inside the nesting. Contributed by @calebdw in https://github.com/AJenbo/phpantom_lsp/pull/145.
Expand Down
15 changes: 13 additions & 2 deletions src/docblock/tags.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1297,8 +1297,19 @@ pub fn should_override_type_typed(docblock_type: &PhpType, native_type: &PhpType

// Unwrap nullable wrappers for further analysis. `?Foo` → `Foo`,
// `Foo|null` → `Foo`. For non-nullable types, use as-is.
let doc_inner = docblock_type.unwrap_nullable();
let native_inner = native_type.unwrap_nullable();
//
// `non_null_type()` strips nullability from BOTH representations — the
// `?Foo` (`Nullable`) form and the `Foo|null` (`Union` with a `null`
// member) form. Plain `unwrap_nullable()` only handled the former, so a
// nullable-union native such as `object|null` reached the union branch
// below with its `null` member still attached. Since `object` and `null`
// are both "scalar names", that branch then judged the whole type
// unrefinable and discarded a generic docblock return like
// `@psalm-return ?T`, leaving the bare native (`object|null`).
let doc_owned = docblock_type.non_null_type();
let doc_inner = doc_owned.as_ref().unwrap_or(docblock_type);
let native_owned = native_type.non_null_type();
let native_inner = native_owned.as_ref().unwrap_or(native_type);

// If the docblock type is a bare, unparameterised primitive scalar
// (`int`, `string`, `bool`, etc.), there's no value in overriding.
Expand Down
61 changes: 61 additions & 0 deletions tests/phpstan_nsrt/inherited-generic-return-nullable.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
<?php declare(strict_types = 1);

// Regression: a class-level generic return type whose method has a *nullable*
// native return hint (e.g. `object|null`) must still be resolved through the
// `@extends`/`@psalm-return` template binding, not collapse to the native hint.
//
// This is the Doctrine `ServiceEntityRepository<T>::find(): ?T` shape: before
// the fix, `should_override_type_typed` analysed `object|null` with its `null`
// member attached, judged it unrefinable (both `object` and `null` are "scalar
// names"), and discarded the generic docblock return — so `$repo->find()`
// resolved to `object|null` instead of `Entity|null`.

namespace InheritedGenericReturnNullable;

use function PHPStan\Testing\assertType;

class Entity {}

/**
* @template T of object
*/
class EntityRepository
{
/**
* @return object|null
* @psalm-return ?T
*/
public function find(mixed $id): object|null {}
}

/**
* @template T of object
* @template-extends EntityRepository<T>
*/
class ServiceEntityRepository extends EntityRepository {}

/** @extends ServiceEntityRepository<Entity> */
class EntityRepo extends ServiceEntityRepository {}

/** @extends EntityRepository<Entity> */
class DirectRepo extends EntityRepository {}

/** @template V */
class CollectionNonNull
{
/** @return V */
public function get(): object {}
}

/** @extends CollectionNonNull<Entity> */
class EntityCollection extends CollectionNonNull {}

function t(EntityRepo $multi, DirectRepo $single, EntityCollection $coll): void
{
// Two-level @extends (Doctrine's exact shape): native object|null + @psalm-return ?T
assertType('Entity|null', $multi->find(1));
// Single-level @extends: native object|null + @psalm-return ?T
assertType('Entity|null', $single->find(1));
// Control: non-nullable native object + @return V already worked before the fix
assertType('Entity', $coll->get());
}
Loading