diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index b3abea2989..81a0abfe88 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -81,7 +81,7 @@ parameters: - message: "#^Doing instanceof PHPStan\\\\Type\\\\Constant\\\\ConstantStringType is error\\-prone and deprecated\\. Use Type\\:\\:getConstantStrings\\(\\) instead\\.$#" - count: 3 + count: 4 path: src/Analyser/TypeSpecifier.php - diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index 057eaf4e3c..fb6658b6a2 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -38,6 +38,7 @@ use PHPStan\Type\ArrayType; use PHPStan\Type\BooleanType; use PHPStan\Type\ConditionalTypeForParameter; +use PHPStan\Type\Constant\ConstantArrayType; use PHPStan\Type\Constant\ConstantArrayTypeBuilder; use PHPStan\Type\Constant\ConstantBooleanType; use PHPStan\Type\Constant\ConstantIntegerType; @@ -665,6 +666,125 @@ public function specifyTypesInCondition( ); } + if ( + $issetExpr instanceof ArrayDimFetch + && $issetExpr->dim !== null + ) { + $type = $scope->getType($issetExpr->var); + if ($type instanceof MixedType) { + return new SpecifiedTypes(); + } + + $dimType = $scope->getType($issetExpr->dim); + if (!($dimType instanceof ConstantIntegerType || $dimType instanceof ConstantStringType)) { + return new SpecifiedTypes(); + } + + $hasOffsetType = $type->hasOffsetValueType($dimType); + if ($hasOffsetType->no()) { + return new SpecifiedTypes(); + } + + $hasOffset = $hasOffsetType->yes(); + $offsetType = $type->getOffsetValueType($dimType); + $isNullable = !$offsetType->isNull()->no(); + + $setOffset = static fn (Type $outerType, Type $dimType, bool $optional): Type => TypeTraverser::map( + $outerType, + static function (Type $type, callable $traverse) use ($dimType, $optional): Type { + if ($type instanceof UnionType || $type instanceof IntersectionType) { + return $traverse($type); + } + + if ($type instanceof ConstantArrayType) { + // unset the offset and set a new value, since we don't want to narrow the existing one + $typeWithoutOffset = $type->unsetOffset($dimType); + if (!$typeWithoutOffset instanceof ConstantArrayType) { + throw new ShouldNotHappenException(); + } + + $builder = ConstantArrayTypeBuilder::createFromConstantArray( + $typeWithoutOffset, + ); + $builder->setOffsetValueType( + $dimType, + new NullType(), + $optional, + ); + return $builder->getArray(); + } + + return $type; + }, + ); + + if ($hasOffset === true) { + if ($isNullable) { + $specifiedType = $this->create( + $issetExpr->var, + $setOffset($type, $dimType, false), + $context->negate(), + true, + $scope, + $rootExpr, + ); + + // keep variable maybe certainty + if ($scope->hasExpressionType($issetExpr->var)->maybe()) { + return $specifiedType->unionWith($this->create( + new IssetExpr($issetExpr->var), + new NullType(), + $context->negate(), + false, + $scope, + $rootExpr, + )); + } + + return $specifiedType; + } + + $typeWithoutOffset = $type->unsetOffset($dimType); + $arraySize = $typeWithoutOffset->getArraySize(); + if ( + !$arraySize instanceof NeverType + && (new ConstantIntegerType(0))->isSuperTypeOf($arraySize)->yes() + ) { + // variable cannot exist + return $this->create( + new IssetExpr($issetExpr->var), + new NullType(), + $context, + false, + $scope, + $rootExpr, + ); + } + + return new SpecifiedTypes(); + } + + if ($isNullable) { + return $this->create( + $issetExpr->var, + $setOffset($type, $dimType, true), + $context->negate(), + false, + $scope, + $rootExpr, + ); + } + + return $this->create( + $issetExpr->var, + $type->unsetOffset($dimType), + $context->negate(), + false, + $scope, + $rootExpr, + ); + } + return new SpecifiedTypes(); } diff --git a/test2.php b/test2.php new file mode 100644 index 0000000000..f05484205f --- /dev/null +++ b/test2.php @@ -0,0 +1,24 @@ +gatherAssertTypes(__DIR__ . '/data/falsy-isset.php'); yield from $this->gatherAssertTypes(__DIR__ . '/data/falsey-coalesce.php'); yield from $this->gatherAssertTypes(__DIR__ . '/data/falsey-ternary-certainty.php'); + yield from $this->gatherAssertTypes(__DIR__ . '/data/bug-9908.php'); yield from $this->gatherAssertTypes(__DIR__ . '/data/bug-7915.php'); yield from $this->gatherAssertTypes(__DIR__ . '/data/bug-9714.php'); yield from $this->gatherAssertTypes(__DIR__ . '/data/bug-9105.php'); diff --git a/tests/PHPStan/Analyser/data/bug-4708.php b/tests/PHPStan/Analyser/data/bug-4708.php index d6bae28afc..ea68902056 100644 --- a/tests/PHPStan/Analyser/data/bug-4708.php +++ b/tests/PHPStan/Analyser/data/bug-4708.php @@ -51,7 +51,7 @@ function GetASCConfig() assertType('array', $result); if (!isset($result['bsw'])) { - assertType('array', $result); + assertType("array", $result); $result['bsw'] = 1; assertType("non-empty-array<1|string>&hasOffsetValue('bsw', 1)", $result); } @@ -66,7 +66,7 @@ function GetASCConfig() if (!isset($result['bew'])) { - assertType("non-empty-array&hasOffsetValue('bsw', int)", $result); + assertType("non-empty-array&hasOffsetValue('bsw', int)", $result); $result['bew'] = 5; assertType("non-empty-array&hasOffsetValue('bew', 5)&hasOffsetValue('bsw', int)", $result); } diff --git a/tests/PHPStan/Analyser/data/bug-9908.php b/tests/PHPStan/Analyser/data/bug-9908.php new file mode 100644 index 0000000000..ae887a342d --- /dev/null +++ b/tests/PHPStan/Analyser/data/bug-9908.php @@ -0,0 +1,27 @@ + 'string']; + } + + assertType("array{}|array{bar: 'string'}", $a); + if (isset($a['bar'])) { + assertType("array{bar: 'string'}", $a); + $a['bar'] = 1; + assertType("array{bar: 1}", $a); + } else { + assertType('array{}', $a); + } + + assertType('array{}|array{bar: 1}', $a); + } +} diff --git a/tests/PHPStan/Analyser/data/falsy-isset.php b/tests/PHPStan/Analyser/data/falsy-isset.php index bce229826a..cc7dd40eba 100644 --- a/tests/PHPStan/Analyser/data/falsy-isset.php +++ b/tests/PHPStan/Analyser/data/falsy-isset.php @@ -6,6 +6,250 @@ use function PHPStan\Testing\assertVariableCertainty; use PHPStan\TrinaryLogic; +class ArrayOffset +{ + public function undefinedVar(): void + { + if (isset($a['bar'])) { + assertType("*ERROR*", $a); + } else { + assertType("*ERROR*", $a); + } + } + + public function definedVar($a): void + { + if (isset($a['bar'])) { + assertType("mixed~null", $a); + } else { + assertType("mixed", $a); + } + } + + /** + * @param array{bar?: null}|array{bar?: 'hello'} $a + */ + public function optionalOffsetNull($a): void + { + if (isset($a['bar'])) { + assertVariableCertainty(TrinaryLogic::createYes(), $a); + assertType("array{bar: 'hello'}", $a); + $a['bar'] = 1; + assertType("array{bar: 1}", $a); + } else { + assertVariableCertainty(TrinaryLogic::createYes(), $a); + assertType('array{bar?: null}', $a); + } + + assertVariableCertainty(TrinaryLogic::createYes(), $a); + assertType("array{bar: 1}|array{bar?: null}", $a); + } + + /** + * @param array{bar?: 'world'}|array{bar?: 'hello'} $a + */ + public function optionalOffsetNonNull($a): void + { + if (isset($a['bar'])) { + assertVariableCertainty(TrinaryLogic::createYes(), $a); + assertType("array{bar: 'hello'}|array{bar: 'world'}", $a); + $a['bar'] = 1; + assertType("array{bar: 1}", $a); + } else { + assertVariableCertainty(TrinaryLogic::createYes(), $a); + assertType('array{}', $a); + } + + assertVariableCertainty(TrinaryLogic::createYes(), $a); + assertType("array{}|array{bar: 1}", $a); + } + + public function maybeCertainNull(): void + { + if (rand() % 2) { + $a = ['bar' => null]; + if (rand() % 3) { + $a = ['bar' => 'hello']; + } + } + assertVariableCertainty(TrinaryLogic::createMaybe(), $a); + + assertType("array{bar: 'hello'}|array{bar: null}", $a); + if (isset($a['bar'])) { + assertVariableCertainty(TrinaryLogic::createYes(), $a); + assertType("array{bar: 'hello'}", $a); + $a['bar'] = 1; + assertType("array{bar: 1}", $a); + } else { + assertVariableCertainty(TrinaryLogic::createMaybe(), $a); + assertType('array{bar: null}', $a); + } + + assertVariableCertainty(TrinaryLogic::createMaybe(), $a); + assertType("array{bar: 1}|array{bar: null}", $a); + } + + public function maybeCertainNonNull(): void + { + if (rand() % 2) { + $a = ['bar' => 'world']; + if (rand() % 3) { + $a = ['bar' => 'hello']; + } + } + assertVariableCertainty(TrinaryLogic::createMaybe(), $a); + + assertType("array{bar: 'hello'}|array{bar: 'world'}", $a); + if (isset($a['bar'])) { + assertVariableCertainty(TrinaryLogic::createYes(), $a); + assertType("array{bar: 'hello'}|array{bar: 'world'}", $a); + $a['bar'] = 1; + assertType("array{bar: 1}", $a); + } else { + assertVariableCertainty(TrinaryLogic::createNo(), $a); + assertType('*ERROR*', $a); + } + + assertVariableCertainty(TrinaryLogic::createMaybe(), $a); + assertType("array{bar: 1}", $a); + } + + public function maybeCertainNonNullMultiOffsetShape(): void + { + if (rand() % 2) { + $a = [ + 'bar' => 'world', + 'foo' => 'hello' + ]; + } + assertVariableCertainty(TrinaryLogic::createMaybe(), $a); + + assertType("array{bar: 'world', foo: 'hello'}", $a); + if (isset($a['bar'])) { + assertVariableCertainty(TrinaryLogic::createYes(), $a); + assertType("array{bar: 'world', foo: 'hello'}", $a); + $a['bar'] = 1; + assertType("array{bar: 1, foo: 'hello'}", $a); + } else { + assertVariableCertainty(TrinaryLogic::createMaybe(), $a); + assertType("array{bar: 'world', foo: 'hello'}", $a); + } + + assertVariableCertainty(TrinaryLogic::createMaybe(), $a); + assertType("array{bar: 1|'world', foo: 'hello'}", $a); + } + + public function yesCertainNull(): void + { + $a = ['bar' => null]; + if (rand() % 2) { + $a = ['bar' => 'hello']; + } + assertVariableCertainty(TrinaryLogic::createYes(), $a); + + assertType("array{bar: 'hello'}|array{bar: null}", $a); + if (isset($a['bar'])) { + assertVariableCertainty(TrinaryLogic::createYes(), $a); + assertType("array{bar: 'hello'}", $a); + $a['bar'] = 1; + assertType("array{bar: 1}", $a); + } else { + assertVariableCertainty(TrinaryLogic::createYes(), $a); + assertType("array{bar: null}", $a); + } + + assertVariableCertainty(TrinaryLogic::createYes(), $a); + assertType("array{bar: 1}|array{bar: null}", $a); + } + + public function yesCertainNonNull(): void + { + $a = ['bar' => 'world']; + if (rand() % 2) { + $a = ['bar' => 'hello']; + } + assertVariableCertainty(TrinaryLogic::createYes(), $a); + + assertType("array{bar: 'hello'}|array{bar: 'world'}", $a); + if (isset($a['bar'])) { + assertVariableCertainty(TrinaryLogic::createYes(), $a); + assertType("array{bar: 'hello'}|array{bar: 'world'}", $a); + $a['bar'] = 1; + assertType("array{bar: 1}", $a); + } else { + assertVariableCertainty(TrinaryLogic::createNo(), $a); + assertType('*ERROR*', $a); + } + + assertVariableCertainty(TrinaryLogic::createYes(), $a); + assertType("array{bar: 1}", $a); + } + + public function nestedFetch(): void + { + $a = ['bar' => null]; + if (rand() % 2) { + $a = ['bar' => ['foo' => 'hello']]; + } + assertVariableCertainty(TrinaryLogic::createYes(), $a); + + assertType("array{bar: array{foo: 'hello'}}|array{bar: null}", $a); + if (isset($a['bar']['foo'])) { + assertVariableCertainty(TrinaryLogic::createYes(), $a); + assertType("array{bar: array{foo: 'hello'}}", $a); + } else { + assertVariableCertainty(TrinaryLogic::createYes(), $a); + assertType("array{bar: null}", $a); + } + + assertVariableCertainty(TrinaryLogic::createYes(), $a); + assertType("array{bar: array{foo: 'hello'}}|array{bar: null}", $a); + } + + public function nestedNullableFetch(?string $nullableString): void + { + $a = ['bar' => null]; + if (rand() % 2) { + $a = ['bar' => ['foo' => $nullableString]]; + } + assertVariableCertainty(TrinaryLogic::createYes(), $a); + + assertType("array{bar: array{foo: string|null}}|array{bar: null}", $a); + if (isset($a['bar']['foo'])) { + assertVariableCertainty(TrinaryLogic::createYes(), $a); + assertType("array{bar: array{foo: string}}", $a); + } else { + assertVariableCertainty(TrinaryLogic::createYes(), $a); + assertType("array{bar: array{foo: null}}|array{bar: null}", $a); + } + + assertVariableCertainty(TrinaryLogic::createYes(), $a); + assertType("array{bar: array{foo: null}}|array{bar: array{foo: string}}|array{bar: null}", $a); + } + + public function nestedOptionalNullableFetch(?string $nullableString): void + { + $a = []; + if (rand() % 2) { + $a = ['bar' => ['foo' => $nullableString]]; + } + assertVariableCertainty(TrinaryLogic::createYes(), $a); + + assertType("array{}|array{bar: array{foo: string|null}}", $a); + if (isset($a['bar']['foo'])) { + assertVariableCertainty(TrinaryLogic::createYes(), $a); + assertType("array{bar: array{foo: string}}", $a); + } else { + assertVariableCertainty(TrinaryLogic::createYes(), $a); + assertType("array{bar: array{foo: null}}", $a); + } + + assertVariableCertainty(TrinaryLogic::createYes(), $a); + assertType("array{bar: array{foo: null}}|array{bar: array{foo: string}}", $a); + } + +} + function doFoo():mixed { return 1; } @@ -72,6 +316,30 @@ function stdclassIsset(?\stdClass $m): void } } +function maybeNonNullableVariable(): void +{ + if (rand(0,1)) { + $a = 'hello'; + } + + if (isset($a)) { + assertType("'hello'", $a); + } else { + assertVariableCertainty(TrinaryLogic::createNo(), $a); + assertType("*ERROR*", $a); + } +} + +function nonNullableVariable(string $a): void +{ + if (isset($a)) { + assertType("string", $a); + } else { + assertVariableCertainty(TrinaryLogic::createNo(), $a); + assertType("*ERROR*", $a); + } +} + function nullableVariable(?string $a): void { if (isset($a)) { @@ -97,3 +365,16 @@ function render(?int $noteListLimit, int $count): void assertType('int', $noteListLimit); } } + +function getParameters(): void +{ + /** @var array{controller: string} */ + $legacyParameters = []; + + assertVariableCertainty(\PHPStan\TrinaryLogic::createYes(), $legacyParameters); + if (isset($legacyParameters['controller'], $legacyParameters['action'])) { + assertType('*NEVER*', $legacyParameters); + } + assertType('array{controller: string}', $legacyParameters); + assertVariableCertainty(\PHPStan\TrinaryLogic::createYes(), $legacyParameters); +} diff --git a/tests/PHPStan/Analyser/data/has-offset-type-bug.php b/tests/PHPStan/Analyser/data/has-offset-type-bug.php index 8b470ba316..c90d43cabe 100644 --- a/tests/PHPStan/Analyser/data/has-offset-type-bug.php +++ b/tests/PHPStan/Analyser/data/has-offset-type-bug.php @@ -65,12 +65,12 @@ public function testIsset($range): void { assertType("array{}|array{min?: bool|float|int|string|null, max?: bool|float|int|string|null}", $range); if (isset($range['min']) || isset($range['max'])) { - assertType("array{min?: bool|float|int|string|null, max?: bool|float|int|string|null}&non-empty-array", $range); + assertType("array{max?: bool|float|int|string|null, min?: bool|float|int|string|null}&non-empty-array", $range); } else { - assertType("array{}|array{min?: bool|float|int|string|null, max?: bool|float|int|string|null}", $range); + assertType("array{}|array{min?: null, max?: null}", $range); } - assertType("array{}|array{min?: bool|float|int|string|null, max?: bool|float|int|string|null}", $range); + assertType("array{}|array{max?: bool|float|int|string|null, min?: bool|float|int|string|null}", $range); } } diff --git a/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php b/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php index e629152005..1da5de2985 100644 --- a/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php +++ b/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php @@ -732,4 +732,19 @@ public function testBug9991(): void ]); } + public function testBug8724(): void + { + $this->analyse([__DIR__ . '/data/bug-8724.php'], []); + } + + public function testBug5128(): void + { + $this->analyse([__DIR__ . '/data/bug-5128.php'], []); + } + + public function testBug5128b(): void + { + $this->analyse([__DIR__ . '/data/bug-5128b.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Arrays/data/bug-5128.php b/tests/PHPStan/Rules/Arrays/data/bug-5128.php new file mode 100644 index 0000000000..48af9efa91 --- /dev/null +++ b/tests/PHPStan/Rules/Arrays/data/bug-5128.php @@ -0,0 +1,14 @@ + + */ + private function convertNumberFilters(array $filter): array + { + if (isset($filter['condition1'], $filter['condition2'])) { + $conditions = [ + $this->convertNumberFilter($filter['condition1']), + $this->convertNumberFilter($filter['condition2']), + ]; + } else { + $conditions = [ + $this->convertNumberFilter($filter), + ]; + } + + return $conditions; + } + + /** + * @param NumberFilter $filter + * @return array + */ + public function convertNumberFilter(array $filter): array + { + return []; + } +} diff --git a/tests/PHPStan/Rules/Arrays/data/bug-8724.php b/tests/PHPStan/Rules/Arrays/data/bug-8724.php new file mode 100644 index 0000000000..d863791534 --- /dev/null +++ b/tests/PHPStan/Rules/Arrays/data/bug-8724.php @@ -0,0 +1,25 @@ +&hasOffsetValue('bsw', int)", $result); + assertType("non-empty-array&hasOffsetValue('bsw', int)", $result); $result['bew'] = 5; assertType("non-empty-array&hasOffsetValue('bew', 5)&hasOffsetValue('bsw', int)", $result); } diff --git a/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php b/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php index 35fbe17d18..899a1f46b1 100644 --- a/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php +++ b/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php @@ -3076,9 +3076,20 @@ public function testTypedClassConstants(): void $this->checkNullables = true; $this->checkUnionTypes = true; $this->checkExplicitMixed = true; + $this->analyse([__DIR__ . '/data/return-type-class-constant.php'], []); } + public function testBug9908b(): void + { + $this->checkThisOnly = false; + $this->checkNullables = true; + $this->checkUnionTypes = true; + $this->checkExplicitMixed = true; + + $this->analyse([__DIR__ . '/data/bug-9908b.php'], []); + } + public function testNamedParametersForMultiVariantFunctions(): void { if (PHP_VERSION_ID < 80000) { diff --git a/tests/PHPStan/Rules/Methods/data/bug-9908b.php b/tests/PHPStan/Rules/Methods/data/bug-9908b.php new file mode 100644 index 0000000000..0fbd23f429 --- /dev/null +++ b/tests/PHPStan/Rules/Methods/data/bug-9908b.php @@ -0,0 +1,28 @@ + 'string']; + } + + if (isset($a['bar'])) { + $a['bar'] = 1; + } + + $this->sayHello($a); + } + + /** + * @param array{bar?: int} $foo + */ + public function sayHello(array $foo): void + { + echo 'Hello' . print_r($foo, true); + } +} diff --git a/tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php b/tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php index cf056f3983..e84fd4fa80 100644 --- a/tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php +++ b/tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php @@ -1008,6 +1008,7 @@ public function testIsStringNarrowsCertainty(): void $this->polluteScopeWithLoopInitialAssignments = true; $this->checkMaybeUndefinedVariables = true; $this->polluteScopeWithAlwaysIterableForeach = true; + $this->analyse([__DIR__ . '/data/isstring-certainty.php'], [ [ 'Variable $a might not be defined.', @@ -1026,7 +1027,18 @@ public function testDiscussion10252(): void $this->polluteScopeWithLoopInitialAssignments = true; $this->checkMaybeUndefinedVariables = true; $this->polluteScopeWithAlwaysIterableForeach = true; + $this->analyse([__DIR__ . '/data/discussion-10252.php'], []); } + public function testBug9426(): void + { + $this->cliArgumentsVariablesRegistered = true; + $this->polluteScopeWithLoopInitialAssignments = true; + $this->checkMaybeUndefinedVariables = true; + $this->polluteScopeWithAlwaysIterableForeach = true; + + $this->analyse([__DIR__ . '/data/bug-9426.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Variables/IssetRuleTest.php b/tests/PHPStan/Rules/Variables/IssetRuleTest.php index 960dad4d31..30ef691f72 100644 --- a/tests/PHPStan/Rules/Variables/IssetRuleTest.php +++ b/tests/PHPStan/Rules/Variables/IssetRuleTest.php @@ -65,7 +65,7 @@ public function testRule(): void 67, ], [ - 'Offset \'dim-null-not-set\' on array{dim: 1, dim-null: 1|null, dim-null-offset: array{a: true|null}, dim-empty: array{}} in isset() does not exist.', + 'Offset \'dim-null-not-set\' on array{dim: 1, dim-null-offset: array{a: true|null}, dim-empty: array{}, dim-null: 1|null} in isset() does not exist.', 73, ], [ @@ -153,7 +153,7 @@ public function testRuleWithoutTreatPhpDocTypesAsCertain(): void 67, ], [ - 'Offset \'dim-null-not-set\' on array{dim: 1, dim-null: 1|null, dim-null-offset: array{a: true|null}, dim-empty: array{}} in isset() does not exist.', + 'Offset \'dim-null-not-set\' on array{dim: 1, dim-null-offset: array{a: true|null}, dim-empty: array{}, dim-null: 1|null} in isset() does not exist.', 73, ], [ diff --git a/tests/PHPStan/Rules/Variables/data/bug-9426.php b/tests/PHPStan/Rules/Variables/data/bug-9426.php new file mode 100644 index 0000000000..5a00ab31dd --- /dev/null +++ b/tests/PHPStan/Rules/Variables/data/bug-9426.php @@ -0,0 +1,20 @@ +