Skip to content
Open
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
11 changes: 11 additions & 0 deletions src/Type/Generic/TemplateTypeTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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(),
Expand Down
95 changes: 95 additions & 0 deletions tests/PHPStan/Analyser/nsrt/template-never-bound.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
<?php declare(strict_types = 1);

namespace TemplateNeverBound;

use function PHPStan\Testing\assertType;

/**
* @template T of int
* @param T $t
* @return T
*/
function nested(int $t): int
{
if ($t > 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<min, -1> (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;
}
13 changes: 13 additions & 0 deletions tests/PHPStan/Rules/Functions/ReturnTypeRuleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
],
]);
}

}
91 changes: 91 additions & 0 deletions tests/PHPStan/Rules/Functions/data/template-never-bound.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
<?php declare(strict_types = 1);

namespace TemplateNeverBoundReturnType;

/**
* @template T of int
* @param T $t
* @return T
*/
function nested(int $t): int
{
if ($t > 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;
}
11 changes: 10 additions & 1 deletion turbo-ext/src/TypeTraits.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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)
{
Expand Down Expand Up @@ -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());
}

Expand Down
Loading