diff --git a/src/Type/Generic/TemplateTypeTrait.php b/src/Type/Generic/TemplateTypeTrait.php index d5d15a26abf..c32d463f4e0 100644 --- a/src/Type/Generic/TemplateTypeTrait.php +++ b/src/Type/Generic/TemplateTypeTrait.php @@ -121,6 +121,12 @@ public function isValidVariance(Type $a, Type $b, bool $strict = false): IsSuper public function subtract(Type $typeToRemove): Type { $removedBound = TypeCombinator::remove($this->getBound(), $typeToRemove); + if ($removedBound instanceof NeverType) { + // every value the bound allows is removed, so no T is left; as a bound, + // never would make the factory fall back to a mixed bound + return $removedBound; + } + return TemplateTypeFactory::create( $this->getScope(), $this->getName(), @@ -410,6 +416,11 @@ public function tryRemove(Type $typeToRemove): ?Type return null; } + if ($bound instanceof NeverType) { + // see subtract() + return $bound; + } + return TemplateTypeFactory::create( $this->getScope(), $this->getName(), diff --git a/tests/PHPStan/Analyser/nsrt/template-never-bound.php b/tests/PHPStan/Analyser/nsrt/template-never-bound.php new file mode 100644 index 00000000000..15e8fca7980 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/template-never-bound.php @@ -0,0 +1,95 @@ + 0) { + if ($t >= 0) { + assertType('T of int<1, max> (function TemplateNeverBound\nested(), argument)', $t); + } else { + assertType('*NEVER*', $t); + } + + assertType('T of int<1, max> (function TemplateNeverBound\nested(), argument)', $t); + } + + return $t; +} + +/** + * @template T of int + * @param T $t + * @return T + */ +function chain(int $t): int +{ + if ($t < 0) { + assertType('T of int (function TemplateNeverBound\chain(), argument)', $t); + } elseif ($t >= 0) { + assertType('T of int<0, max> (function TemplateNeverBound\chain(), argument)', $t); + } else { + assertType('*NEVER*', $t); + } + + return $t; +} + +/** + * @template T of int + * @param T $t + * @return T + */ +function afterMerge(int $t): int +{ + if ($t < 0) { + echo 'negative'; + } + + if ($t >= 2 && $t <= 2) { + assertType('T of 2 (function TemplateNeverBound\afterMerge(), argument)', $t); + } + + return $t; +} + +/** + * @template T of bool + * @param T $t + * @return T + */ +function boolean(bool $t): bool +{ + if ($t === true) { + assertType('T of bool (function TemplateNeverBound\boolean(), argument)&true', $t); + } elseif ($t === false) { + assertType('T of false (function TemplateNeverBound\boolean(), argument)&false', $t); + } else { + assertType('*NEVER*', $t); + } + + return $t; +} + +/** + * @template T of string + * @param T $t + * @return T + */ +function str(string $t): string +{ + if (is_string($t)) { + assertType('T of string (function TemplateNeverBound\str(), argument)', $t); + } else { + assertType('*NEVER*', $t); + } + + return $t; +} diff --git a/tests/PHPStan/Rules/Functions/ReturnTypeRuleTest.php b/tests/PHPStan/Rules/Functions/ReturnTypeRuleTest.php index 55f68eca67d..434d8099be0 100644 --- a/tests/PHPStan/Rules/Functions/ReturnTypeRuleTest.php +++ b/tests/PHPStan/Rules/Functions/ReturnTypeRuleTest.php @@ -605,4 +605,17 @@ public function testBug15714(): void ]); } + public function testTemplateNeverBound(): void + { + $this->checkNullables = true; + $this->checkExplicitMixed = true; + $this->analyse([__DIR__ . '/data/template-never-bound.php'], [ + [ + 'Function TemplateNeverBoundReturnType\\stillReported() should return T of int but returns int.', + 86, + 'Type int is not always the same as T. It breaks the contract for some argument types, typically subtypes.', + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Functions/data/template-never-bound.php b/tests/PHPStan/Rules/Functions/data/template-never-bound.php new file mode 100644 index 00000000000..83a8c973d83 --- /dev/null +++ b/tests/PHPStan/Rules/Functions/data/template-never-bound.php @@ -0,0 +1,91 @@ + 0) { + if ($t >= 0) { + echo 'x'; + } else { + echo 'never'; + } + + return $t; + } + + return $t; +} + +/** + * @template T of int + * @param T $t + * @return T + */ +function chain(int $t): int +{ + if ($t < 0) { + echo 'negative'; + } elseif ($t >= 0) { + echo 'non-negative'; + } else { + echo 'never'; + } + + return $t; +} + +/** + * @template T of int + * @param T $t + * @return T + */ +function afterMerge(int $t): int +{ + if ($t < 0) { + echo 'negative'; + } + if ($t >= 2 && $t <= 2) { + } + + return $t; +} + +/** + * @template T of bool + * @param T $t + * @return T + */ +function boolean(bool $t): bool +{ + if ($t === true) { + echo 'true'; + } elseif ($t === false) { + echo 'false'; + } else { + echo 'never'; + } + + return $t; +} + +/** + * @template T of int + * @param T $t + * @return T + */ +function stillReported(int $t, int $other): int +{ + if ($t > 0) { + if ($t >= 0) { + return $other; + } + } + + return $t; +} diff --git a/turbo-ext/src/TypeTraits.cpp b/turbo-ext/src/TypeTraits.cpp index 1388f09f2d5..f77a40c3303 100644 --- a/turbo-ext/src/TypeTraits.cpp +++ b/turbo-ext/src/TypeTraits.cpp @@ -4273,16 +4273,24 @@ class TemplateTypeTrait return pt_type_call(Z_OBJ_P(variance), PT_LC("isvalidvariance"), 4, args); } - /* the rebuild over TypeCombinator::remove($this->getBound(), $typeToRemove) */ + /* the rebuild over TypeCombinator::remove($this->getBound(), $typeToRemove), + * or that never itself when it removes the whole bound */ zv::Val subtract(zval *typeToRemove) const { zv::Val bound = thisGetBound(); if (UNEXPECTED(bound.isUndef())) return zv::Val(); zv::Val removedBound = pt_type_combinator_remove(bound.raw(), typeToRemove); if (UNEXPECTED(removedBound.isUndef())) return zv::Val(); + if (isNever(removedBound.raw())) return removedBound; return recreateKeepingDefault(removedBound.raw()); } + /* $type instanceof NeverType */ + static bool isNever(zval *type) + { + return Z_TYPE_P(type) == IS_OBJECT && instanceof_function(Z_OBJCE_P(type), pt_ce_never_type); + } + /* $bound instanceof SubtractableType; false = pending exception */ [[nodiscard]] static bool isSubtractable(zval *bound, bool &out) { @@ -4798,6 +4806,7 @@ class TemplateTypeTrait zv::Val equal = pt_type_op(Z_OBJ_P(boundOnceMore.raw()), PT_OP_EQUALS, 1, removed.raw()); if (UNEXPECTED(equal.isUndef())) return zv::Val(); if (zend_is_true(equal.raw())) return zv::Val::null(); + if (isNever(removed.raw())) return removed; return recreateKeepingDefault(removed.raw()); }