diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index 6cff7ef3af..4b3ca1fcc5 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -47,6 +47,7 @@ use PHPStan\Type\Traits\UndecidedComparisonTypeTrait; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; +use PHPStan\Type\TypeUtils; use PHPStan\Type\UnionType; use PHPStan\Type\VerbosityLevel; use function array_keys; @@ -61,6 +62,7 @@ use function count; use function implode; use function in_array; +use function is_int; use function is_string; use function min; use function pow; @@ -699,6 +701,37 @@ public function getOffsetValueType(Type $offsetType): Type public function setOffsetValueType(?Type $offsetType, Type $valueType, bool $unionValues = true): Type { + if ($offsetType !== null) { + $scalarKeyTypes = $this->resolveFiniteScalarKeyTypes($offsetType); + if ($scalarKeyTypes !== null) { + $hasNewKey = false; + foreach ($scalarKeyTypes as $scalarKeyType) { + $existingKeyFound = false; + foreach ($this->keyTypes as $existingKeyType) { + if ($existingKeyType->getValue() === $scalarKeyType->getValue()) { + $existingKeyFound = true; + break; + } + } + if (!$existingKeyFound) { + $hasNewKey = true; + break; + } + } + + if ($hasNewKey) { + $arrayTypes = []; + foreach ($scalarKeyTypes as $scalarKeyType) { + $builder = ConstantArrayTypeBuilder::createFromConstantArray($this); + $builder->setOffsetValueType($scalarKeyType, $valueType); + $arrayTypes[] = $builder->getArray(); + } + + return TypeCombinator::union(...$arrayTypes); + } + } + } + $builder = ConstantArrayTypeBuilder::createFromConstantArray($this); $builder->setOffsetValueType($offsetType, $valueType); @@ -713,6 +746,63 @@ public function setExistingOffsetValueType(Type $offsetType, Type $valueType): T return $builder->getArray(); } + /** + * @return list|null + */ + private function resolveFiniteScalarKeyTypes(Type $offsetType): ?array + { + $offsetType = $offsetType->toArrayKey(); + + // Handle unions of constant string types (e.g. 'a'|'b') + $constantStrings = $offsetType->getConstantStrings(); + if (count($constantStrings) >= 2 && count($constantStrings) <= self::CHUNK_FINITE_TYPES_LIMIT) { + $result = []; + foreach ($constantStrings as $constantString) { + $scalarValues = $constantString->getConstantScalarValues(); + if (count($scalarValues) !== 1) { + return null; + } + if (is_int($scalarValues[0])) { + $result[] = new ConstantIntegerType($scalarValues[0]); + } elseif (is_string($scalarValues[0])) { + $result[] = new ConstantStringType($scalarValues[0]); + } else { + return null; + } + } + return $result; + } + + // Handle integer range types (e.g. int<1,5>) + $integerRanges = TypeUtils::getIntegerRanges($offsetType); + if (count($integerRanges) > 0) { + $finiteScalarTypes = []; + $seen = []; + foreach ($integerRanges as $integerRange) { + $finiteTypes = $integerRange->getFiniteTypes(); + if ($finiteTypes === []) { + return null; + } + + foreach ($finiteTypes as $finiteType) { + if (isset($seen[$finiteType->getValue()])) { + continue; + } + $seen[$finiteType->getValue()] = true; + $finiteScalarTypes[] = $finiteType; + } + } + + if (count($finiteScalarTypes) < 2 || count($finiteScalarTypes) > self::CHUNK_FINITE_TYPES_LIMIT) { + return null; + } + + return $finiteScalarTypes; + } + + return null; + } + public function unsetOffset(Type $offsetType): Type { $offsetType = $offsetType->toArrayKey(); diff --git a/tests/PHPStan/Analyser/nsrt/bug-13000.php b/tests/PHPStan/Analyser/nsrt/bug-13000.php new file mode 100644 index 0000000000..b0138a3d9b --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-13000.php @@ -0,0 +1,13 @@ + '1', 'b' => '2'] as $key => $val) { + $r[$key] = $val; + } + assertType("array{a?: '1'|'2', b?: '1'|'2'}", $r); +}; diff --git a/tests/PHPStan/Analyser/nsrt/bug-13759.php b/tests/PHPStan/Analyser/nsrt/bug-13759.php new file mode 100644 index 0000000000..07482c181f --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-13759.php @@ -0,0 +1,32 @@ += 8.0 + +namespace Bug13759; + +use function PHPStan\Testing\assertType; + +class Test +{ + public function scenario(): void + { + $ints = []; + foreach (['a', 'b'] as $key) { + $ints[$key] = 1; + } + $ints['c'] = 1; + + assertType("array{a?: 1, b?: 1, c: 1}", $ints); + + foreach (['a'] as $key) { + $ints[$key] = $this->intToSomething($ints[$key]); + } + + assertType("array{a: float|string, b?: 1, c: 1}", $ints); + } + + /** + * @return string|float + */ + protected function intToSomething(int $int): string|float { + return mt_rand(1, 2) ? (string)$int : (float)$int; + } +} diff --git a/tests/PHPStan/Analyser/nsrt/bug-2294.php b/tests/PHPStan/Analyser/nsrt/bug-2294.php new file mode 100644 index 0000000000..a69571d6df --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-2294.php @@ -0,0 +1,15 @@ + null, 'B' => null]; + + $entries2 = []; + foreach($entries as $key => $value) { + $entries2[$key] = ['a' => 1, 'b' => 2]; + } + assertType("array{A?: array{a: 1, b: 2}, B?: array{a: 1, b: 2}}", $entries2); +}; diff --git a/tests/PHPStan/Analyser/nsrt/bug-9907.php b/tests/PHPStan/Analyser/nsrt/bug-9907.php new file mode 100644 index 0000000000..0985776490 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-9907.php @@ -0,0 +1,20 @@ +, bool>', $a); + assertType("array{0: false, 1: false, 2: false, 4: true}|array{false, false, false, true}|array{false, false, true}|array{false, true, false}|array{true, false, false}", $a); } public function doBar6(bool $offset): void diff --git a/tests/PHPStan/Analyser/nsrt/set-constant-union-offset-on-constant-array.php b/tests/PHPStan/Analyser/nsrt/set-constant-union-offset-on-constant-array.php new file mode 100644 index 0000000000..d503ca7b40 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/set-constant-union-offset-on-constant-array.php @@ -0,0 +1,70 @@ + $intRange + */ + public function doBar(array $a, $intRange): void + { + $a[$intRange] = 256; + assertType('array{foo: int, 1: 256}|array{foo: int, 2: 256}|array{foo: int, 3: 256}|array{foo: int, 4: 256}|array{foo: int, 5: 256}', $a); + } + + /** + * @param array{foo: int} $a + * @param int<0, max> $intRange + */ + public function doInfiniteRange(array $a, $intRange): void + { + $a[$intRange] = 256; + assertType('non-empty-array<\'foo\'|int<0, max>, int>', $a); + } + + /** + * @param array{foo: int} $a + * @param int<0, 5>|int<10, 15> $intRange + */ + public function doUnionOfRanges(array $a, $intRange): void + { + $a[$intRange] = 256; + assertType('non-empty-array<\'foo\'|int<0, 5>|int<10, 15>, int>', $a); + } + + /** + * @param array{foo: int} $a + * @param int<0, 3>|int<2, 4> $intRange + */ + public function doOverlappingRanges(array $a, $intRange): void + { + $a[$intRange] = 256; + assertType('array{foo: int, 0: 256}|array{foo: int, 1: 256}|array{foo: int, 2: 256}|array{foo: int, 3: 256}|array{foo: int, 4: 256}', $a); + } + + /** + * @param array{0: 'a', 1: 'b'} $a + * @param int<0,1> $intRange + */ + public function doExistingKeys(array $a, $intRange): void + { + $a[$intRange] = 'c'; + assertType("array{'a'|'c', 'b'|'c'}", $a); + } + +} diff --git a/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php b/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php index 0ab14fd52a..2bb2c5c2e6 100644 --- a/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php +++ b/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php @@ -3853,4 +3853,12 @@ public function testBug13805(): void $this->analyse([__DIR__ . '/data/bug-13805.php'], []); } + public function testBug7978(): void + { + $this->checkThisOnly = false; + $this->checkNullables = true; + $this->checkUnionTypes = true; + $this->analyse([__DIR__ . '/data/bug-7978.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Methods/ReturnTypeRuleTest.php b/tests/PHPStan/Rules/Methods/ReturnTypeRuleTest.php index 84e81f0f33..9b3bb88db0 100644 --- a/tests/PHPStan/Rules/Methods/ReturnTypeRuleTest.php +++ b/tests/PHPStan/Rules/Methods/ReturnTypeRuleTest.php @@ -1310,4 +1310,9 @@ public function testBug9669(): void $this->analyse([__DIR__ . '/data/bug-9669.php'], []); } + public function testBug9907(): void + { + $this->analyse([__DIR__ . '/data/bug-9907.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Methods/data/bug-7978.php b/tests/PHPStan/Rules/Methods/data/bug-7978.php new file mode 100644 index 0000000000..4b3f951835 --- /dev/null +++ b/tests/PHPStan/Rules/Methods/data/bug-7978.php @@ -0,0 +1,29 @@ + ['username', 'password'], + 'headers' => ['app_id', 'app_key'], + ]; + + /** + * @param array $credentials + */ + public function acceptCredentials(array $credentials): void + { + } + + public function doSomething(): void + { + foreach (self::FIELD_SETS as $type => $fields) { + $credentials = []; + foreach ($fields as $field) { + $credentials[$field] = 'fake'; + } + $this->acceptCredentials($credentials); + } + } +} diff --git a/tests/PHPStan/Rules/Methods/data/bug-9907.php b/tests/PHPStan/Rules/Methods/data/bug-9907.php new file mode 100644 index 0000000000..39086dbd70 --- /dev/null +++ b/tests/PHPStan/Rules/Methods/data/bug-9907.php @@ -0,0 +1,34 @@ + + * } + */ + public function diffAddresses(array $address1, array $address2): array + { + $addressDifference = array_diff_assoc($address1, $address2); + $differenceDetails = []; + + foreach ($addressDifference as $name => $differenceValue) { + $differenceDetails[$name] = [ + 'change_to' => $differenceValue, + ]; + } + + if (!empty(count($differenceDetails))) { + $differenceDetails['variation_count'] = count($differenceDetails); + } + + return $differenceDetails; + } +}