From 76045f563338c977adef41a3136e5adb7bab4c80 Mon Sep 17 00:00:00 2001 From: David Scandurra Date: Thu, 28 Aug 2025 22:38:09 +0200 Subject: [PATCH 1/5] Add unit test for #13048 --- .../Comparison/MatchExpressionRuleTest.php | 7 ++++++ .../Rules/Comparison/data/bug-13048.php | 22 +++++++++++++++++++ 2 files changed, 29 insertions(+) create mode 100644 tests/PHPStan/Rules/Comparison/data/bug-13048.php diff --git a/tests/PHPStan/Rules/Comparison/MatchExpressionRuleTest.php b/tests/PHPStan/Rules/Comparison/MatchExpressionRuleTest.php index 5224ff9d42..87eebc6982 100644 --- a/tests/PHPStan/Rules/Comparison/MatchExpressionRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/MatchExpressionRuleTest.php @@ -435,4 +435,11 @@ public function testPropertyHooks(): void ]); } + + #[RequiresPhp('>= 8.0')] + public function testBug13048(): void + { + $this->analyse([__DIR__ . '/data/bug-13048.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Comparison/data/bug-13048.php b/tests/PHPStan/Rules/Comparison/data/bug-13048.php new file mode 100644 index 0000000000..434a3c1b79 --- /dev/null +++ b/tests/PHPStan/Rules/Comparison/data/bug-13048.php @@ -0,0 +1,22 @@ += 8.0 + +namespace Bug11852; + +enum IndexBy { + case A; + case B; +} + +/** + * @template T of IndexBy|null + * @param T $indexBy + * @return (T is null ? null : string) + */ +function run(?IndexBy $indexBy = null): ?string +{ + return match ($indexBy) { + IndexBy::A => 'by A', + IndexBy::B => 'by B', + null => null, + }; +} From ed9d037860932da94621c5059729514408c54bdb Mon Sep 17 00:00:00 2001 From: David Scandurra Date: Thu, 28 Aug 2025 22:42:10 +0200 Subject: [PATCH 2/5] Add TemplateNullType This fixes issues like #13048. Currently, T of (A|null) subtracted by A is T of mixed --- phpstan-baseline.neon | 12 ++++++ src/Type/Generic/TemplateNullType.php | 37 +++++++++++++++++++ src/Type/Generic/TemplateTypeFactory.php | 5 +++ .../Comparison/MatchExpressionRuleTest.php | 2 +- .../Rules/Comparison/data/bug-13048.php | 5 +-- 5 files changed, 57 insertions(+), 4 deletions(-) create mode 100644 src/Type/Generic/TemplateNullType.php diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index ef49f69ba0..af05e642bf 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1251,6 +1251,12 @@ parameters: count: 2 path: src/Type/Generic/TemplateMixedType.php + - + message: '#^Doing instanceof PHPStan\\Type\\IntersectionType is error\-prone and deprecated\.$#' + identifier: phpstanApi.instanceofType + count: 3 + path: src/Type/Generic/TemplateNullType.php + - message: '#^Doing instanceof PHPStan\\Type\\IntersectionType is error\-prone and deprecated\.$#' identifier: phpstanApi.instanceofType @@ -1335,6 +1341,12 @@ parameters: count: 1 path: src/Type/Generic/TemplateTypeFactory.php + - + message: '#^Doing instanceof PHPStan\\Type\\NullType is error\-prone and deprecated\. Use Type\:\:isNull\(\) instead\.$#' + identifier: phpstanApi.instanceofType + count: 1 + path: src/Type/Generic/TemplateTypeFactory.php + - message: '#^Doing instanceof PHPStan\\Type\\ObjectShapeType is error\-prone and deprecated\. Use Type\:\:isObject\(\) and Type\:\:hasProperty\(\) instead\.$#' identifier: phpstanApi.instanceofType diff --git a/src/Type/Generic/TemplateNullType.php b/src/Type/Generic/TemplateNullType.php new file mode 100644 index 0000000000..7a5b95e598 --- /dev/null +++ b/src/Type/Generic/TemplateNullType.php @@ -0,0 +1,37 @@ + */ + use TemplateTypeTrait; + use UndecidedComparisonCompoundTypeTrait; + + /** + * @param non-empty-string $name + */ + public function __construct( + TemplateTypeScope $scope, + TemplateTypeStrategy $templateTypeStrategy, + TemplateTypeVariance $templateTypeVariance, + string $name, + NullType $bound, + ?Type $default, + ) + { + parent::__construct(); + $this->scope = $scope; + $this->strategy = $templateTypeStrategy; + $this->variance = $templateTypeVariance; + $this->name = $name; + $this->bound = $bound; + $this->default = $default; + } + +} diff --git a/src/Type/Generic/TemplateTypeFactory.php b/src/Type/Generic/TemplateTypeFactory.php index 0471bc249c..fb3b2149bd 100644 --- a/src/Type/Generic/TemplateTypeFactory.php +++ b/src/Type/Generic/TemplateTypeFactory.php @@ -15,6 +15,7 @@ use PHPStan\Type\IterableType; use PHPStan\Type\KeyOfType; use PHPStan\Type\MixedType; +use PHPStan\Type\NullType; use PHPStan\Type\ObjectShapeType; use PHPStan\Type\ObjectType; use PHPStan\Type\ObjectWithoutClassType; @@ -112,6 +113,10 @@ public static function create(TemplateTypeScope $scope, string $name, ?Type $bou return new TemplateIterableType($scope, $strategy, $variance, $name, $bound, $default); } + if ($bound instanceof NullType && ($boundClass === NullType::class || $bound instanceof TemplateType)) { + return new TemplateNullType($scope, $strategy, $variance, $name, $bound, $default); + } + return new TemplateMixedType($scope, $strategy, $variance, $name, new MixedType(true), $default); } diff --git a/tests/PHPStan/Rules/Comparison/MatchExpressionRuleTest.php b/tests/PHPStan/Rules/Comparison/MatchExpressionRuleTest.php index 87eebc6982..600374d42d 100644 --- a/tests/PHPStan/Rules/Comparison/MatchExpressionRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/MatchExpressionRuleTest.php @@ -436,7 +436,7 @@ public function testPropertyHooks(): void } - #[RequiresPhp('>= 8.0')] + #[RequiresPhp('>= 8.1')] public function testBug13048(): void { $this->analyse([__DIR__ . '/data/bug-13048.php'], []); diff --git a/tests/PHPStan/Rules/Comparison/data/bug-13048.php b/tests/PHPStan/Rules/Comparison/data/bug-13048.php index 434a3c1b79..c2b2248123 100644 --- a/tests/PHPStan/Rules/Comparison/data/bug-13048.php +++ b/tests/PHPStan/Rules/Comparison/data/bug-13048.php @@ -1,6 +1,6 @@ -= 8.0 += 8.1 -namespace Bug11852; +namespace Bug13048; enum IndexBy { case A; @@ -10,7 +10,6 @@ enum IndexBy { /** * @template T of IndexBy|null * @param T $indexBy - * @return (T is null ? null : string) */ function run(?IndexBy $indexBy = null): ?string { From 30f8f1cac03110df63309e4458edcaa5a179a3b0 Mon Sep 17 00:00:00 2001 From: David Scandurra Date: Fri, 29 Aug 2025 19:56:11 +0200 Subject: [PATCH 3/5] Add regression bugs --- tests/PHPStan/Analyser/nsrt/bug-12894.php | 46 +++++++++++++++++++++++ tests/PHPStan/Analyser/nsrt/bug-12989.php | 17 +++++++++ 2 files changed, 63 insertions(+) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-12894.php create mode 100644 tests/PHPStan/Analyser/nsrt/bug-12989.php diff --git a/tests/PHPStan/Analyser/nsrt/bug-12894.php b/tests/PHPStan/Analyser/nsrt/bug-12894.php new file mode 100644 index 0000000000..8fd4b27e29 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-12894.php @@ -0,0 +1,46 @@ + + * + * @param D $dependency + * + * @return V + */ + public function resolve(Dependency $dependency): object|null; +} + +/** + * @internal + */ +class Resolver implements DependencyResolver { + public function __construct( + /** + * @var Closure(object|null): void + */ + protected readonly Closure $run, + ) { + // empty + } + + public function resolve(Dependency $dependency): object|null { + $resolved = $dependency(); + $result = is_object($resolved) ? 1 : 2; + ($this->run)($resolved); + return $resolved; + } +} diff --git a/tests/PHPStan/Analyser/nsrt/bug-12989.php b/tests/PHPStan/Analyser/nsrt/bug-12989.php new file mode 100644 index 0000000000..9d033476e8 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-12989.php @@ -0,0 +1,17 @@ + Date: Fri, 29 Aug 2025 20:04:08 +0200 Subject: [PATCH 4/5] Support null templates --- src/Rules/Generics/TemplateTypeCheck.php | 2 ++ tests/PHPStan/Analyser/nsrt/bug-12894.php | 35 +++++++++++-------- tests/PHPStan/Analyser/nsrt/bug-12989.php | 2 +- .../Generics/FunctionTemplateTypeRuleTest.php | 4 --- .../Rules/Generics/data/function-template.php | 2 +- .../PhpDoc/IncompatiblePhpDocTypeRuleTest.php | 16 --------- ...IncompatiblePropertyPhpDocTypeRuleTest.php | 4 --- .../data/generic-callables-incompatible.php | 12 +++---- 8 files changed, 30 insertions(+), 47 deletions(-) diff --git a/src/Rules/Generics/TemplateTypeCheck.php b/src/Rules/Generics/TemplateTypeCheck.php index 3b7e924258..be58cefefe 100644 --- a/src/Rules/Generics/TemplateTypeCheck.php +++ b/src/Rules/Generics/TemplateTypeCheck.php @@ -28,6 +28,7 @@ use PHPStan\Type\IterableType; use PHPStan\Type\KeyOfType; use PHPStan\Type\MixedType; +use PHPStan\Type\NullType; use PHPStan\Type\ObjectShapeType; use PHPStan\Type\ObjectType; use PHPStan\Type\ObjectWithoutClassType; @@ -132,6 +133,7 @@ public function check( && $boundTypeClass !== GenericObjectType::class && $boundTypeClass !== KeyOfType::class && $boundTypeClass !== IterableType::class + && $boundTypeClass !== NullType::class && !$boundType instanceof UnionType && !$boundType instanceof IntersectionType && !$boundType instanceof TemplateType diff --git a/tests/PHPStan/Analyser/nsrt/bug-12894.php b/tests/PHPStan/Analyser/nsrt/bug-12894.php index 8fd4b27e29..6580b47de2 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-12894.php +++ b/tests/PHPStan/Analyser/nsrt/bug-12894.php @@ -1,18 +1,25 @@ -= 8.0 namespace Bug12894; +use Closure; + /** * @template TValue of object|null */ -interface Dependency { +interface Dependency +{ + /** * @return TValue */ public function __invoke(): object|null; + } -interface DependencyResolver { +interface DependencyResolver +{ + /** * @template V of object|null * @template D of Dependency @@ -22,25 +29,23 @@ interface DependencyResolver { * @return V */ public function resolve(Dependency $dependency): object|null; + } -/** - * @internal - */ -class Resolver implements DependencyResolver { - public function __construct( - /** - * @var Closure(object|null): void - */ - protected readonly Closure $run, - ) { - // empty - } +class Resolver implements DependencyResolver +{ + /** + * @var Closure(object|null): void + */ + protected Closure $run; public function resolve(Dependency $dependency): object|null { $resolved = $dependency(); + \PHPStan\Testing\assertType('V of object|null (method Bug12894\DependencyResolver::resolve(), argument)', $resolved); $result = is_object($resolved) ? 1 : 2; + \PHPStan\Testing\assertType('V of object (method Bug12894\DependencyResolver::resolve(), argument)|V of null (method Bug12894\DependencyResolver::resolve(), argument)', $resolved); ($this->run)($resolved); return $resolved; } + } diff --git a/tests/PHPStan/Analyser/nsrt/bug-12989.php b/tests/PHPStan/Analyser/nsrt/bug-12989.php index 9d033476e8..0cbe192304 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-12989.php +++ b/tests/PHPStan/Analyser/nsrt/bug-12989.php @@ -10,8 +10,8 @@ function a(?int $b): ?int { if ($b === null) { + \PHPStan\Testing\assertType('T of null (function Bug12989\a(), argument)', $b); return $b; } return $b; } - diff --git a/tests/PHPStan/Rules/Generics/FunctionTemplateTypeRuleTest.php b/tests/PHPStan/Rules/Generics/FunctionTemplateTypeRuleTest.php index f7a818012a..ef504c54da 100644 --- a/tests/PHPStan/Rules/Generics/FunctionTemplateTypeRuleTest.php +++ b/tests/PHPStan/Rules/Generics/FunctionTemplateTypeRuleTest.php @@ -56,10 +56,6 @@ public function testRule(): void 'PHPDoc tag @template T for function FunctionTemplateType\resourceBound() with bound type resource is not supported.', 50, ], - [ - 'PHPDoc tag @template T for function FunctionTemplateType\nullNotSupported() with bound type null is not supported.', - 68, - ], [ 'Call-site variance of covariant int in generic type FunctionTemplateType\GenericCovariant in PHPDoc tag @template U is redundant, template type T of class FunctionTemplateType\GenericCovariant has the same variance.', 94, diff --git a/tests/PHPStan/Rules/Generics/data/function-template.php b/tests/PHPStan/Rules/Generics/data/function-template.php index 8a1ff456f9..c938c5dff4 100644 --- a/tests/PHPStan/Rules/Generics/data/function-template.php +++ b/tests/PHPStan/Rules/Generics/data/function-template.php @@ -65,7 +65,7 @@ function nakano() } /** @template T of null */ -function nullNotSupported() +function nullSupported() { } diff --git a/tests/PHPStan/Rules/PhpDoc/IncompatiblePhpDocTypeRuleTest.php b/tests/PHPStan/Rules/PhpDoc/IncompatiblePhpDocTypeRuleTest.php index dae341a690..5eae89a24e 100644 --- a/tests/PHPStan/Rules/PhpDoc/IncompatiblePhpDocTypeRuleTest.php +++ b/tests/PHPStan/Rules/PhpDoc/IncompatiblePhpDocTypeRuleTest.php @@ -320,10 +320,6 @@ public function testGenericCallables(): void 'PHPDoc tag @param for parameter $invalidBoundType template T of Closure(T): T has invalid bound type GenericCallablesIncompatible\Invalid.', 25, ], - [ - 'PHPDoc tag @param for parameter $notSupported template T of Closure(T): T with bound type null is not supported.', - 32, - ], [ 'PHPDoc tag @param for parameter $shadows template T of Closure(T): T shadows @template T for function GenericCallablesIncompatible\testShadowFunction.', 40, @@ -360,10 +356,6 @@ public function testGenericCallables(): void 'PHPDoc tag @return template T of Closure(T): T has invalid bound type GenericCallablesIncompatible\Invalid.', 90, ], - [ - 'PHPDoc tag @return template T of Closure(T): T with bound type null is not supported.', - 97, - ], [ 'PHPDoc tag @return template T of Closure(T): T shadows @template T for function GenericCallablesIncompatible\testShadowFunctionReturn.', 105, @@ -380,10 +372,6 @@ public function testGenericCallables(): void 'PHPDoc tag @param for parameter $invalidBoundType template T of Closure(T): T has invalid bound type GenericCallablesIncompatible\Invalid.', 131, ], - [ - 'PHPDoc tag @param for parameter $notSupported template T of Closure(T): T with bound type null is not supported.', - 138, - ], [ 'PHPDoc tag @return template of Closure(stdClass): stdClass cannot have existing class stdClass as its name.', 145, @@ -396,10 +384,6 @@ public function testGenericCallables(): void 'PHPDoc tag @return template T of Closure(T): T has invalid bound type GenericCallablesIncompatible\Invalid.', 159, ], - [ - 'PHPDoc tag @return template T of Closure(T): T with bound type null is not supported.', - 166, - ], [ 'PHPDoc tag @param-out for parameter $existingClass template T of Closure(T): T shadows @template T for function GenericCallablesIncompatible\shadowsParamOut.', 175, diff --git a/tests/PHPStan/Rules/PhpDoc/IncompatiblePropertyPhpDocTypeRuleTest.php b/tests/PHPStan/Rules/PhpDoc/IncompatiblePropertyPhpDocTypeRuleTest.php index c252fa002b..0e6c44f8ff 100644 --- a/tests/PHPStan/Rules/PhpDoc/IncompatiblePropertyPhpDocTypeRuleTest.php +++ b/tests/PHPStan/Rules/PhpDoc/IncompatiblePropertyPhpDocTypeRuleTest.php @@ -186,10 +186,6 @@ public function testGenericCallables(): void 'PHPDoc tag @var template of callable(TypeAlias): TypeAlias cannot have existing type alias TypeAlias as its name.', 26, ], - [ - 'PHPDoc tag @var template TNull of callable(TNull): TNull with bound type null is not supported.', - 31, - ], [ 'PHPDoc tag @var template TInvalid of callable(TInvalid): TInvalid has invalid bound type GenericCallableProperties\Invalid.', 36, diff --git a/tests/PHPStan/Rules/PhpDoc/data/generic-callables-incompatible.php b/tests/PHPStan/Rules/PhpDoc/data/generic-callables-incompatible.php index 238d822894..165296e750 100644 --- a/tests/PHPStan/Rules/PhpDoc/data/generic-callables-incompatible.php +++ b/tests/PHPStan/Rules/PhpDoc/data/generic-callables-incompatible.php @@ -27,9 +27,9 @@ function invalidBoundType(Closure $invalidBoundType): void } /** - * @param Closure(T $val): T $notSupported + * @param Closure(T $val): T $closure */ -function notSupported(Closure $notSupported): void +function testNull(Closure $closure): void { } @@ -94,7 +94,7 @@ function invalidBoundTypeReturn(): Closure /** * @return Closure(T $val): T */ -function notSupportedReturn(): Closure +function nullReturn(): Closure { } @@ -133,9 +133,9 @@ public function invalidBoundType(Closure $invalidBoundType): void } /** - * @param Closure(T $val): T $notSupported + * @param Closure(T $val): T $closure */ - public function notSupported(Closure $notSupported): void + public function nullType(Closure $closure): void { } @@ -163,7 +163,7 @@ public function invalidBoundTypeReturn(): Closure /** * @return Closure(T $val): T */ - public function notSupportedReturn(): Closure + public function nullReturn(): Closure { } } From cf38d4501b3b77d9da36d38c1f2551f00a7487f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Mirtes?= Date: Sat, 30 Aug 2025 09:29:06 +0200 Subject: [PATCH 5/5] Update tests/PHPStan/Analyser/nsrt/bug-12894.php --- tests/PHPStan/Analyser/nsrt/bug-12894.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/PHPStan/Analyser/nsrt/bug-12894.php b/tests/PHPStan/Analyser/nsrt/bug-12894.php index 6580b47de2..67efdf1947 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-12894.php +++ b/tests/PHPStan/Analyser/nsrt/bug-12894.php @@ -1,4 +1,6 @@ -= 8.0 += 8.0 + +declare(strict_types = 1); namespace Bug12894;