From 3b17acc5de4ac507d678f9ca9b2cd15937921ffb Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sun, 30 Jan 2022 21:28:55 +0100 Subject: [PATCH] Fix resolving type of isset(), empty() and null coalesce operator (??) --- src/Analyser/MutatingScope.php | 293 ++++++++-- src/Rules/IssetCheck.php | 1 + .../Analyser/LegacyNodeScopeResolverTest.php | 4 +- .../Analyser/NodeScopeResolverTest.php | 14 + tests/PHPStan/Analyser/data/bug-4592.php | 30 + tests/PHPStan/Analyser/data/bug-4903.php | 27 + .../isset-coalesce-empty-type-post-81.php | 7 + .../data/isset-coalesce-empty-type-pre-81.php | 7 + .../data/isset-coalesce-empty-type-root.php | 19 + .../data/isset-coalesce-empty-type.php | 544 ++++++++++++++++++ ...nexistentOffsetInArrayDimFetchRuleTest.php | 5 + tests/PHPStan/Rules/Arrays/data/bug-3171.php | 17 + .../Variables/DefinedVariableRuleTest.php | 2 +- .../Variables/data/defined-variables.php | 2 +- 14 files changed, 905 insertions(+), 67 deletions(-) create mode 100644 tests/PHPStan/Analyser/data/bug-4592.php create mode 100644 tests/PHPStan/Analyser/data/bug-4903.php create mode 100644 tests/PHPStan/Analyser/data/isset-coalesce-empty-type-post-81.php create mode 100644 tests/PHPStan/Analyser/data/isset-coalesce-empty-type-pre-81.php create mode 100644 tests/PHPStan/Analyser/data/isset-coalesce-empty-type-root.php create mode 100644 tests/PHPStan/Analyser/data/isset-coalesce-empty-type.php create mode 100644 tests/PHPStan/Rules/Arrays/data/bug-3171.php diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 319f2c81b9..4c55a61d09 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -602,51 +602,68 @@ private function resolveType(Expr $node): Type if ( $node instanceof Expr\BinaryOp\Equal || $node instanceof Expr\BinaryOp\NotEqual - || $node instanceof Expr\Empty_ ) { return new BooleanType(); } - if ($node instanceof Expr\Isset_) { - $result = new ConstantBooleanType(true); - foreach ($node->vars as $var) { - if ($var instanceof Expr\ArrayDimFetch && $var->dim !== null) { - $variableType = $this->getType($var->var); - $dimType = $this->getType($var->dim); - $hasOffset = $variableType->hasOffsetValueType($dimType); - $offsetValueType = $variableType->getOffsetValueType($dimType); - $offsetValueIsNotNull = (new NullType())->isSuperTypeOf($offsetValueType)->negate(); - $isset = $hasOffset->and($offsetValueIsNotNull)->toBooleanType(); - if ($isset instanceof ConstantBooleanType) { - if (!$isset->getValue()) { - return $isset; - } + if ($node instanceof Expr\Empty_) { + $result = $this->issetCheck($node->expr, static function (Type $type): ?bool { + $isNull = (new NullType())->isSuperTypeOf($type); + $isFalsey = (new ConstantBooleanType(false))->isSuperTypeOf($type->toBoolean()); + if ($isNull->maybe()) { + return null; + } + if ($isFalsey->maybe()) { + return null; + } - continue; + if ($isNull->yes()) { + if ($isFalsey->yes()) { + return false; + } + if ($isFalsey->no()) { + return true; } - $result = $isset; - continue; + return false; } - if ($var instanceof Expr\Variable && is_string($var->name)) { - $variableType = $this->getType($var); - $isNullSuperType = (new NullType())->isSuperTypeOf($variableType); - $has = $this->hasVariableType($var->name); - if ($has->no() || $isNullSuperType->yes()) { - return new ConstantBooleanType(false); + return !$isFalsey->yes(); + }); + if ($result === null) { + return new BooleanType(); + } + + return new ConstantBooleanType(!$result); + } + + if ($node instanceof Expr\Isset_) { + $issetResult = true; + foreach ($node->vars as $var) { + $result = $this->issetCheck($var, static function (Type $type): ?bool { + $isNull = (new NullType())->isSuperTypeOf($type); + if ($isNull->maybe()) { + return null; } - if ($has->maybe() || !$isNullSuperType->no()) { - $result = new BooleanType(); + return !$isNull->yes(); + }); + if ($result !== null) { + if (!$result) { + return new ConstantBooleanType($result); } + continue; } + $issetResult = $result; + } + + if ($issetResult === null) { return new BooleanType(); } - return $result; + return new ConstantBooleanType($issetResult); } if ($node instanceof Node\Expr\BooleanNot) { @@ -1928,53 +1945,32 @@ private function resolveType(Expr $node): Type } if ($node instanceof Expr\BinaryOp\Coalesce) { - if ($node->left instanceof Expr\ArrayDimFetch && $node->left->dim !== null) { - $dimType = $this->getType($node->left->dim); - $varType = $this->getType($node->left->var); - $hasOffset = $varType->hasOffsetValueType($dimType); - $leftType = $this->getType($node->left); - $rightType = $this->filterByFalseyValue( - new BinaryOp\NotIdentical($node->left, new ConstFetch(new Name('null'))), - )->getType($node->right); - if ($hasOffset->no()) { - return $rightType; - } elseif ($hasOffset->yes()) { - $offsetValueType = $varType->getOffsetValueType($dimType); - if ($offsetValueType->isSuperTypeOf(new NullType())->no()) { - return TypeCombinator::removeNull($leftType); - } - } - - return TypeCombinator::union( - TypeCombinator::removeNull($leftType), - $rightType, - ); - } - $leftType = $this->getType($node->left); $rightType = $this->filterByFalseyValue( new BinaryOp\NotIdentical($node->left, new ConstFetch(new Name('null'))), )->getType($node->right); - if ($leftType instanceof ErrorType || $leftType instanceof NullType) { - return $rightType; - } - if ( - TypeCombinator::containsNull($leftType) - || $node->left instanceof PropertyFetch - || ( - $node->left instanceof Variable - && is_string($node->left->name) - && !$this->hasVariableType($node->left->name)->yes() - ) - ) { + $result = $this->issetCheck($node->left, static function (Type $type): ?bool { + $isNull = (new NullType())->isSuperTypeOf($type); + if ($isNull->maybe()) { + return null; + } + + return !$isNull->yes(); + }); + + if ($result === null) { return TypeCombinator::union( TypeCombinator::removeNull($leftType), $rightType, ); } - return TypeCombinator::removeNull($leftType); + if ($result) { + return TypeCombinator::removeNull($leftType); + } + + return $rightType; } if ($node instanceof ConstFetch) { @@ -2590,6 +2586,177 @@ private function getNullsafeShortCircuitingType(Expr $expr, Type $type): Type return $type; } + /** + * @param callable(Type): ?bool $typeCallback + */ + private function issetCheck(Expr $expr, callable $typeCallback, ?bool $result = null): ?bool + { + // mirrored in PHPStan\Rules\IssetCheck + if ($expr instanceof Node\Expr\Variable && is_string($expr->name)) { + $hasVariable = $this->hasVariableType($expr->name); + if ($hasVariable->maybe()) { + return null; + } + + if ($result === null) { + if ($hasVariable->yes()) { + if ($expr->name === '_SESSION') { + return null; + } + + return $typeCallback($this->getVariableType($expr->name)); + } + + return false; + } + + return $result; + } elseif ($expr instanceof Node\Expr\ArrayDimFetch && $expr->dim !== null) { + $type = $this->treatPhpDocTypesAsCertain + ? $this->getType($expr->var) + : $this->getNativeType($expr->var); + $dimType = $this->treatPhpDocTypesAsCertain + ? $this->getType($expr->dim) + : $this->getNativeType($expr->dim); + $hasOffsetValue = $type->hasOffsetValueType($dimType); + if (!$type->isOffsetAccessible()->yes()) { + return $result ?? $this->issetCheckUndefined($expr->var); + } + + if ($hasOffsetValue->no()) { + if ($result !== null) { + return $result; + } + + return false; + } + + if ($hasOffsetValue->maybe()) { + return null; + } + + // If offset is cannot be null, store this error message and see if one of the earlier offsets is. + // E.g. $array['a']['b']['c'] ?? null; is a valid coalesce if a OR b or C might be null. + if ($hasOffsetValue->yes()) { + if ($result !== null) { + return $result; + } + + $result = $typeCallback($type->getOffsetValueType($dimType)); + + if ($result !== null) { + return $this->issetCheck($expr->var, $typeCallback, $result); + } + } + + // Has offset, it is nullable + return null; + + } elseif ($expr instanceof Node\Expr\PropertyFetch || $expr instanceof Node\Expr\StaticPropertyFetch) { + + $propertyReflection = $this->propertyReflectionFinder->findPropertyReflectionFromNode($expr, $this); + + if ($propertyReflection === null) { + if ($expr instanceof Node\Expr\PropertyFetch) { + return $this->issetCheckUndefined($expr->var); + } + + if ($expr->class instanceof Expr) { + return $this->issetCheckUndefined($expr->class); + } + + return null; + } + + if (!$propertyReflection->isNative()) { + if ($expr instanceof Node\Expr\PropertyFetch) { + return $this->issetCheckUndefined($expr->var); + } + + if ($expr->class instanceof Expr) { + return $this->issetCheckUndefined($expr->class); + } + + return null; + } + + $nativeType = $propertyReflection->getNativeType(); + if (!$nativeType instanceof MixedType) { + if (!$this->isSpecified($expr)) { + if ($expr instanceof Node\Expr\PropertyFetch) { + return $this->issetCheckUndefined($expr->var); + } + + if ($expr->class instanceof Expr) { + return $this->issetCheckUndefined($expr->class); + } + + return null; + } + } + + if ($result !== null) { + return $result; + } + + $result = $typeCallback($propertyReflection->getWritableType()); + if ($result !== null) { + if ($expr instanceof Node\Expr\PropertyFetch) { + return $this->issetCheck($expr->var, $typeCallback, $result); + } + + if ($expr->class instanceof Expr) { + return $this->issetCheck($expr->class, $typeCallback, $result); + } + } + + return $result; + } + + if ($result !== null) { + return $result; + } + + return $typeCallback($this->getType($expr)); + } + + private function issetCheckUndefined(Expr $expr): ?bool + { + if ($expr instanceof Node\Expr\Variable && is_string($expr->name)) { + $hasVariable = $this->hasVariableType($expr->name); + if (!$hasVariable->no()) { + return null; + } + + return false; + } + + if ($expr instanceof Node\Expr\ArrayDimFetch && $expr->dim !== null) { + $type = $this->getType($expr->var); + $dimType = $this->getType($expr->dim); + $hasOffsetValue = $type->hasOffsetValueType($dimType); + if (!$type->isOffsetAccessible()->yes()) { + return $this->issetCheckUndefined($expr->var); + } + + if (!$hasOffsetValue->no()) { + return $this->issetCheckUndefined($expr->var); + } + + return false; + } + + if ($expr instanceof Expr\PropertyFetch) { + return $this->issetCheckUndefined($expr->var); + } + + if ($expr instanceof Expr\StaticPropertyFetch && $expr->class instanceof Expr) { + return $this->issetCheckUndefined($expr->class); + } + + return null; + } + /** * @param ParametersAcceptor[] $variants */ diff --git a/src/Rules/IssetCheck.php b/src/Rules/IssetCheck.php index 13edd168c2..6b5fec3210 100644 --- a/src/Rules/IssetCheck.php +++ b/src/Rules/IssetCheck.php @@ -30,6 +30,7 @@ public function __construct( */ public function check(Expr $expr, Scope $scope, string $operatorDescription, callable $typeMessageCallback, ?RuleError $error = null): ?RuleError { + // mirrored in PHPStan\Analyser\MutatingScope::issetCheck() if ($expr instanceof Node\Expr\Variable && is_string($expr->name)) { $hasVariable = $scope->hasVariableType($expr->name); if ($hasVariable->maybe()) { diff --git a/tests/PHPStan/Analyser/LegacyNodeScopeResolverTest.php b/tests/PHPStan/Analyser/LegacyNodeScopeResolverTest.php index f49e371de0..62f37c3740 100644 --- a/tests/PHPStan/Analyser/LegacyNodeScopeResolverTest.php +++ b/tests/PHPStan/Analyser/LegacyNodeScopeResolverTest.php @@ -2606,11 +2606,11 @@ public function dataBinaryOperations(): array '!isset($foo)', ], [ - 'bool', + 'false', 'empty($foo)', ], [ - 'bool', + 'true', '!empty($foo)', ], [ diff --git a/tests/PHPStan/Analyser/NodeScopeResolverTest.php b/tests/PHPStan/Analyser/NodeScopeResolverTest.php index 58d0bfa0d6..36ac460ea4 100644 --- a/tests/PHPStan/Analyser/NodeScopeResolverTest.php +++ b/tests/PHPStan/Analyser/NodeScopeResolverTest.php @@ -650,7 +650,21 @@ public function dataFileAsserts(): iterable yield from $this->gatherAssertTypes(__DIR__ . '/data/bug-4357.php'); yield from $this->gatherAssertTypes(__DIR__ . '/data/bug-5817.php'); yield from $this->gatherAssertTypes(__DIR__ . '/data/array-column.php'); + + if (PHP_VERSION_ID >= 70400) { + yield from $this->gatherAssertTypes(__DIR__ . '/data/isset-coalesce-empty-type.php'); + yield from $this->gatherAssertTypes(__DIR__ . '/data/isset-coalesce-empty-type-root.php'); + } + + if (PHP_VERSION_ID < 80100) { + yield from $this->gatherAssertTypes(__DIR__ . '/data/isset-coalesce-empty-type-pre-81.php'); + } else { + yield from $this->gatherAssertTypes(__DIR__ . '/data/isset-coalesce-empty-type-post-81.php'); + } + yield from $this->gatherAssertTypes(__DIR__ . '/data/template-null-bound.php'); + yield from $this->gatherAssertTypes(__DIR__ . '/data/bug-4592.php'); + yield from $this->gatherAssertTypes(__DIR__ . '/data/bug-4903.php'); } /** diff --git a/tests/PHPStan/Analyser/data/bug-4592.php b/tests/PHPStan/Analyser/data/bug-4592.php new file mode 100644 index 0000000000..859d237075 --- /dev/null +++ b/tests/PHPStan/Analyser/data/bug-4592.php @@ -0,0 +1,30 @@ + + */ + private array $contacts1 = []; + + /** + * @var array{names: array, emails: array} + */ + private array $contacts2 = ['names' => [], 'emails' => []]; + + public function sayHello1(string $id): void + { + $name = $this->contacts1[$id]['name'] ?? null; + assertType('string|null', $name); + } + + public function sayHello2(string $id): void + { + $name = $this->contacts2['names'][$id] ?? null; + assertType('string|null', $name); + } +} diff --git a/tests/PHPStan/Analyser/data/bug-4903.php b/tests/PHPStan/Analyser/data/bug-4903.php new file mode 100644 index 0000000000..4b6b1fd459 --- /dev/null +++ b/tests/PHPStan/Analyser/data/bug-4903.php @@ -0,0 +1,27 @@ +|false', $ref->name ?? false); +} diff --git a/tests/PHPStan/Analyser/data/isset-coalesce-empty-type-pre-81.php b/tests/PHPStan/Analyser/data/isset-coalesce-empty-type-pre-81.php new file mode 100644 index 0000000000..092bfff69b --- /dev/null +++ b/tests/PHPStan/Analyser/data/isset-coalesce-empty-type-pre-81.php @@ -0,0 +1,7 @@ +name ?? false); +} diff --git a/tests/PHPStan/Analyser/data/isset-coalesce-empty-type-root.php b/tests/PHPStan/Analyser/data/isset-coalesce-empty-type-root.php new file mode 100644 index 0000000000..29671650c0 --- /dev/null +++ b/tests/PHPStan/Analyser/data/isset-coalesce-empty-type-root.php @@ -0,0 +1,19 @@ += 7.4 + +namespace IssetCoalesceEmptyType; + +use function PHPStan\Testing\assertType; + +class FooEmpty +{ + + public function doFoo() + { + $a = []; + if (rand(0, 1)) { + $a[0] = rand(0,1) ? true : false; + $a[1] = false; + } + + $a[2] = rand(0,1) ? true : false; + $a[3] = false; + $a[4] = true; + + assertType('bool', empty($a[0])); + assertType('bool', empty($a[1])); + assertType('true', empty($a['nonexistent'])); + assertType('bool', empty($a[2])); + assertType('true', empty($a[3])); + assertType('false', empty($a[4])); + } + + public function doBar() + { + $a = [ + '', + '0', + 'foo', + rand(0, 1) ? '' : 'foo', + ]; + assertType('true', empty($a[0])); + assertType('true', empty($a[1])); + assertType('false', empty($a[2])); + assertType('bool', empty($a[3])); + } + + public function doBaz() + { + assertType('true', empty($a)); + + $b = 'test'; + assertType('false', empty($b)); + + if (rand(0, 1)) { + $c = 'foo'; + } + + assertType('bool', empty($c)); + + $d = rand(0, 1) ? '' : 'foo'; + assertType('bool', empty($d)); + } + +} + +class FooIsset +{ + /** @var string|null */ + public static $staticStringOrNull = null; + + /** @var string */ + public static $staticString = ''; + + /** @var null */ + public static $staticAlwaysNull; + + /** @var string|null */ + public $stringOrNull = null; + + /** @var string */ + public $string = ''; + + /** @var null */ + public $alwaysNull; + + /** @var FooIsset|null */ + public $FooIssetOrNull; + + /** @var FooIsset */ + public $FooIsset; + + public function thisCoalesce() { + assertType('true', isset($this->string)); + } + + function coalesce() + { + + $scalar = 3; + + assertType('true', isset($scalar)); + + $array = [1, 2, 3]; + + assertType('false', isset($array['string'])); + + $multiDimArray = [[1], [2], [3]]; + + assertType('false', isset($multiDimArray['string'])); + + assertType('false', isset($doesNotExist)); + + if (rand() > 0.5) { + $maybeVariable = 3; + } + + assertType('bool', isset($maybeVariable)); + + $fixedDimArray = [ + 'dim' => 1, + 'dim-null' => rand() > 0.5 ? null : 1, + 'dim-null-offset' => ['a' => rand() > 0.5 ? true : null], + 'dim-empty' => [] + ]; + + // Always set + assertType('true', isset($fixedDimArray['dim'])); + + // Maybe set + assertType('bool', isset($fixedDimArray['dim-null'])); + + // Never set, then unknown + assertType('false', isset($fixedDimArray['dim-null-not-set']['a'])); + + // Always set, then always set + assertType('bool', isset($fixedDimArray['dim-null-offset']['a'])); + + // Always set, then never set + assertType('false', isset($fixedDimArray['dim-empty']['b'])); + + $foo = new FooIsset(); + + assertType('bool', isset($foo->stringOrNull)); + + assertType('true', isset($foo->string)); + + assertType('false', isset($foo->alwaysNull)); + + assertType('true', isset($foo->FooIsset->string)); + + assertType('bool', isset($foo->FooIssetOrNull->string)); + + assertType('bool', isset(FooIsset::$staticStringOrNull)); + + assertType('true', isset(FooIsset::$staticString)); + + assertType('false', isset(FooIsset::$staticAlwaysNull)); + } + + /** + * @param array $array + */ + function coalesceStringOffset(array $array) + { + assertType('bool', isset($array['string'])); + } + + function alwaysNullCoalesce (?string $a): void + { + if (!is_string($a)) { + assertType('false', isset($a)); + } + } + + function fooo(): void { + assertType('true', isset((new FooIsset())->string)); + assertType('bool', isset((new FooIsset())->stringOrNull)); + assertType('false', isset((new FooIsset())->alwaysNull)); + } + + function fooooo(FooIsset $foo): void + { + assertType('false', isset($foo::$staticAlwaysNull)); + assertType('true', isset($foo::$staticString)); + assertType('bool', isset($foo::$staticStringOrNull)); + } +} + +/** + * @property int $integerProperty + * @property FooIsset $foo + */ +class SomeMagicProperties +{ + + function doFoo(SomeMagicProperties $foo, \stdClass $std): void { + assertType('bool', isset($foo->integerProperty)); + + assertType('bool', isset($foo->foo->string)); + + assertType('bool', isset($std->foo)); + } + + function numericStringOffset(string $code): string + { + $array = [1, 2, 3]; + assertType('bool', isset($array[$code])); + + if (isset($array[$code])) { + return (string) $array[$code]; + } + + $mappings = [ + '21021200' => '21028800', + ]; + + assertType('bool', isset($mappings[$code])); + + if (isset($mappings[$code])) { + return (string) $mappings[$code]; + } + + throw new \RuntimeException(); + } + + + /** + * @param array{foo: string} $array + * @param 'bar' $bar + */ + function offsetFromPhpdoc(array $array, string $bar) + { + assertType('true', isset($array['foo'])); + + $array = ['bar' => 1]; + assertType('true', isset($array[$bar])); + } + + +} + +class FooNativeProp +{ + + public int $hasDefaultValue = 0; + + public int $isAssignedBefore; + + public int $canBeUninitialized; + + function doFoo(FooNativeProp $foo): void { + assertType('bool', isset($foo->hasDefaultValue)); + + $foo->isAssignedBefore = 5; + assertType('true', isset($foo->isAssignedBefore)); + + assertType('bool', isset($foo->canBeUninitialized)); + } + +} + +class Bug4290Isset +{ + public function test(): void + { + $array = self::getArray(); + + assertType('bool', isset($array['status'])); + assertType('bool', isset($array['value'])); + + $data = array_filter([ + 'status' => isset($array['status']) ? $array['status'] : null, + 'value' => isset($array['value']) ? $array['value'] : null, + ]); + + if (count($data) === 0) { + return; + } + + assertType('bool', isset($data['status'])); + + isset($data['status']) ? 1 : 0; + } + + /** + * @return string[] + */ + public static function getArray(): array + { + return ['value' => '100']; + } +} + +class Bug4671 +{ + + /** + * @param array $strings + */ + public function doFoo(int $intput, array $strings): void + { + assertType('false', isset($strings[(string) $intput])); + } + +} + +class MoreIsset +{ + + function one() + { + + /** @var string|null $alwaysDefinedNullable */ + $alwaysDefinedNullable = doFoo(); + + assertType('bool', isset($alwaysDefinedNullable)); + + $alwaysDefinedNotNullable = 'string'; + assertType('true', isset($alwaysDefinedNotNullable)); + + if (doFoo()) { + $sometimesDefinedVariable = 1; + } + + assertType('bool', isset( + $sometimesDefinedVariable // fine, this is what's isset() is for + )); + + assertType('false', isset( + $sometimesDefinedVariable, // fine, this is what's isset() is for + $neverDefinedVariable // always false + )); + + assertType('false', isset( + $neverDefinedVariable // always false + )); + + /** @var array|null $anotherAlwaysDefinedNullable */ + $anotherAlwaysDefinedNullable = doFoo(); + + assertType('bool', isset($anotherAlwaysDefinedNullable['test']['test'])); + + /** @var array $anotherAlwaysDefinedNotNullable */ + $anotherAlwaysDefinedNotNullable = doFoo(); + assertType('bool', isset($anotherAlwaysDefinedNotNullable['test']['test'])); + + assertType('false', isset($anotherNeverDefinedVariable['test']['test']->test['test']['test'])); + + assertType('false', isset($yetAnotherNeverDefinedVariable::$test['test'])); + + assertType('bool', isset($_COOKIE['test'])); + + assertType('false', isset($yetYetAnotherNeverDefinedVariableInIsset)); + + if (doFoo()) { + $yetAnotherVariableThatSometimesExists = 1; + } + + assertType('bool', isset($yetAnotherVariableThatSometimesExists)); + + /** @var string|null $nullableVariableUsedInTernary */ + $nullableVariableUsedInTernary = doFoo(); + assertType('bool', isset($nullableVariableUsedInTernary)); + } + + function two() { + $alwaysDefinedNotNullable = 'string'; + if (doFoo()) { + $sometimesDefinedVariable = 1; + } + + assertType('false', isset( + $alwaysDefinedNotNullable, // always true + $sometimesDefinedVariable, // fine, this is what's isset() is for + $neverDefinedVariable // always false + )); + + assertType('true', isset( + $alwaysDefinedNotNullable // always true + )); + + assertType('bool', isset( + $alwaysDefinedNotNullable, // always true + $sometimesDefinedVariable // fine, this is what's isset() is for + )); + } + + function three() { + $null = null; + + assertType('false', isset($null)); + } + + function four() { + assertType('bool', isset($_SESSION)); + assertType('bool', isset($_SESSION['foo'])); + } + +} + +class FooCoalesce +{ + /** @var string|null */ + public static $staticStringOrNull = null; + + /** @var string */ + public static $staticString = ''; + + /** @var null */ + public static $staticAlwaysNull; + + /** @var string|null */ + public $stringOrNull = null; + + /** @var string */ + public $string = ''; + + /** @var null */ + public $alwaysNull; + + /** @var FooCoalesce|null */ + public $fooCoalesceOrNull; + + /** @var FooCoalesce */ + public $fooCoalesce; + + public function thisCoalesce() { + assertType('string', $this->string ?? false); + } + + function coalesce() + { + + $scalar = 3; + + assertType('3', $scalar ?? 4); + + $array = [1, 2, 3]; + + assertType('0', $array['string'] ?? 0); + + $multiDimArray = [[1], [2], [3]]; + + assertType('0', $multiDimArray['string'] ?? 0); + + assertType('0', $doesNotExist ?? 0); + + if (rand() > 0.5) { + $maybeVariable = 3; + } + + assertType('0|3', $maybeVariable ?? 0); + + $fixedDimArray = [ + 'dim' => 1, + 'dim-null' => rand() > 0.5 ? null : 1, + 'dim-null-offset' => ['a' => rand() > 0.5 ? true : null], + 'dim-empty' => [] + ]; + + // Always set + assertType('1', $fixedDimArray['dim'] ?? 0); + + // Maybe set + assertType('0|1', $fixedDimArray['dim-null'] ?? 0); + + // Never set, then unknown + assertType('0', $fixedDimArray['dim-null-not-set']['a'] ?? 0); + + // Always set, then always set + assertType('0|true', $fixedDimArray['dim-null-offset']['a'] ?? 0); + + // Always set, then never set + assertType('0', $fixedDimArray['dim-empty']['b'] ?? 0); + + assertType('int<0, max>', rand() ?? false); + + assertType('0|string', preg_replace('', '', '') ?? 0); + + $foo = new FooCoalesce(); + + assertType('string|false', $foo->stringOrNull ?? false); + + assertType('string', $foo->string ?? false); + + assertType('\'\'', $foo->alwaysNull ?? ''); + + assertType('string', $foo->fooCoalesce->string ?? false); + + assertType('string|false', $foo->fooCoalesceOrNull->string ?? false); + + assertType('string|false', FooCoalesce::$staticStringOrNull ?? false); + + assertType('string', FooCoalesce::$staticString ?? false); + + assertType('false', FooCoalesce::$staticAlwaysNull ?? false); + } + + /** + * @param array $array + */ + function coalesceStringOffset(array $array) + { + assertType('int|false', $array['string'] ?? false); + } + + function alwaysNullCoalesce (?string $a): void + { + if (!is_string($a)) { + assertType('false', $a ?? false); + } + } + + function foo(): void { + assertType('string', (new FooCoalesce())->string ?? false); + assertType('string|false', (new FooCoalesce())->stringOrNull ?? false); + assertType('false', (new FooCoalesce())->alwaysNull ?? false); + + assertType(FooCoalesce::class, (new FooCoalesce()) ?? false); + assertType('\'foo\'', null ?? 'foo'); + } + + function bar(FooCoalesce $foo): void + { + assertType('false', $foo::$staticAlwaysNull ?? false); + assertType('string', $foo::$staticString ?? false); + assertType('string|false', $foo::$staticStringOrNull ?? false); + } + + function lorem(): void { + assertType('\'foo\'', $foo ?? 'foo'); + assertType('\'foo\'', $bar->bar ?? 'foo'); + } + + function ipsum(): void { + $scalar = 3; + assertType('3', $scalar ?? 4); + assertType('0', $doesNotExist ?? 0); + } + + function ipsum2(?string $a): void { + if (!is_string($a)) { + assertType('\'foo\'', $a ?? 'foo'); + } + } + +} diff --git a/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php b/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php index 55afd44852..ccaacd8b11 100644 --- a/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php +++ b/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php @@ -353,4 +353,9 @@ public function testBug4926(): void $this->analyse([__DIR__ . '/data/bug-4926.php'], []); } + public function testBug3171(): void + { + $this->analyse([__DIR__ . '/data/bug-3171.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Arrays/data/bug-3171.php b/tests/PHPStan/Rules/Arrays/data/bug-3171.php new file mode 100644 index 0000000000..96505bbf6c --- /dev/null +++ b/tests/PHPStan/Rules/Arrays/data/bug-3171.php @@ -0,0 +1,17 @@ +