diff --git a/src/PhpDoc/TypeNodeResolver.php b/src/PhpDoc/TypeNodeResolver.php index ac17bb1d1f..cea72fbe16 100644 --- a/src/PhpDoc/TypeNodeResolver.php +++ b/src/PhpDoc/TypeNodeResolver.php @@ -704,24 +704,7 @@ static function (string $variance): TemplateTypeVariance { if (count($genericTypes) === 1) { // array $arrayType = new ArrayType((new BenevolentUnionType([new IntegerType(), new StringType()]))->toArrayKey(), $genericTypes[0]); } elseif (count($genericTypes) === 2) { // array - $originalKey = $genericTypes[0]; - if ($this->reportUnsafeArrayStringKeyCasting === ReportUnsafeArrayStringKeyCastingToggle::PREVENT) { - $originalKey = TypeTraverser::map($originalKey, static function (Type $type, callable $traverse) { - if ($type instanceof UnionType || $type instanceof IntersectionType) { - return $traverse($type); - } - - if ($type instanceof StringType) { - return TypeCombinator::intersect($type, new AccessoryDecimalIntegerStringType(inverse: true)); - } - - return $type; - }); - } - $keyType = TypeCombinator::intersect($originalKey->toArrayKey(), new UnionType([ - new IntegerType(), - new StringType(), - ]))->toArrayKey(); + $keyType = $this->transformUnsafeArrayKey($genericTypes[0]); $finiteTypes = $keyType->getFiniteTypes(); if ( count($finiteTypes) === 1 @@ -1001,6 +984,28 @@ static function (string $variance): TemplateTypeVariance { return new ErrorType(); } + private function transformUnsafeArrayKey(Type $keyType): Type + { + if ($this->reportUnsafeArrayStringKeyCasting === ReportUnsafeArrayStringKeyCastingToggle::PREVENT) { + $keyType = TypeTraverser::map($keyType, static function (Type $type, callable $traverse) { + if ($type instanceof UnionType || $type instanceof IntersectionType) { + return $traverse($type); + } + + if ($type instanceof StringType) { + return TypeCombinator::intersect($type, new AccessoryDecimalIntegerStringType(inverse: true)); + } + + return $type; + }); + } + + return TypeCombinator::intersect($keyType->toArrayKey(), new UnionType([ + new IntegerType(), + new StringType(), + ]))->toArrayKey(); + } + private function resolveCallableTypeNode(CallableTypeNode $typeNode, NameScope $nameScope): Type { $templateTags = []; @@ -1100,13 +1105,48 @@ private function resolveArrayShapeNode(ArrayShapeNode $typeNode, NameScope $name $builder->setOffsetValueType($offsetType, $this->resolve($itemNode->valueType, $nameScope), $itemNode->optional); } + $isList = in_array($typeNode->kind, [ + ArrayShapeNode::KIND_LIST, + ArrayShapeNode::KIND_NON_EMPTY_LIST, + ], true); + + if (!$typeNode->sealed) { + if ($typeNode->unsealedType === null) { + if ($isList) { + $unsealedKeyType = IntegerRangeType::createAllGreaterThanOrEqualTo(0); + } else { + $unsealedKeyType = (new BenevolentUnionType([new IntegerType(), new StringType()]))->toArrayKey(); + } + $builder->makeUnsealed( + $unsealedKeyType, + new MixedType(), + ); + } else { + if ($typeNode->unsealedType->keyType === null) { + if ($isList) { + $unsealedKeyType = IntegerRangeType::createAllGreaterThanOrEqualTo(0); + } else { + $unsealedKeyType = (new BenevolentUnionType([new IntegerType(), new StringType()]))->toArrayKey(); + } + } else { + $unsealedKeyType = $this->transformUnsafeArrayKey($this->resolve($typeNode->unsealedType->keyType, $nameScope)); + } + $unsealedKeyFiniteTypes = $unsealedKeyType->getFiniteTypes(); + $unsealedValueType = $this->resolve($typeNode->unsealedType->valueType, $nameScope); + if (count($unsealedKeyFiniteTypes) > 0) { + foreach ($unsealedKeyFiniteTypes as $unsealedKeyFiniteType) { + $builder->setOffsetValueType($unsealedKeyFiniteType, $unsealedValueType, true); + } + } else { + $builder->makeUnsealed($unsealedKeyType, $unsealedValueType); + } + } + } + $arrayType = $builder->getArray(); $accessories = []; - if (in_array($typeNode->kind, [ - ArrayShapeNode::KIND_LIST, - ArrayShapeNode::KIND_NON_EMPTY_LIST, - ], true)) { + if ($isList) { $accessories[] = new AccessoryArrayListType(); } diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index eb5aec70ca..735d5b3b5f 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -4,11 +4,13 @@ use Nette\Utils\Strings; use PHPStan\Analyser\OutOfClassScope; +use PHPStan\DependencyInjection\BleedingEdgeToggle; use PHPStan\Php\PhpVersion; use PHPStan\PhpDocParser\Ast\ConstExpr\ConstExprIntegerNode; use PHPStan\PhpDocParser\Ast\ConstExpr\ConstExprStringNode; use PHPStan\PhpDocParser\Ast\Type\ArrayShapeItemNode; use PHPStan\PhpDocParser\Ast\Type\ArrayShapeNode; +use PHPStan\PhpDocParser\Ast\Type\ArrayShapeUnsealedTypeNode; use PHPStan\PhpDocParser\Ast\Type\ConstTypeNode; use PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode; use PHPStan\PhpDocParser\Ast\Type\TypeNode; @@ -27,21 +29,27 @@ use PHPStan\Type\Accessory\HasOffsetValueType; use PHPStan\Type\Accessory\NonEmptyArrayType; use PHPStan\Type\ArrayType; +use PHPStan\Type\BenevolentUnionType; use PHPStan\Type\BooleanType; use PHPStan\Type\CompoundType; use PHPStan\Type\ConstantScalarType; use PHPStan\Type\ErrorType; use PHPStan\Type\GeneralizePrecision; +use PHPStan\Type\Generic\TemplateMixedType; +use PHPStan\Type\Generic\TemplateStrictMixedType; use PHPStan\Type\Generic\TemplateType; use PHPStan\Type\Generic\TemplateTypeMap; use PHPStan\Type\Generic\TemplateTypeVariance; use PHPStan\Type\IntegerRangeType; +use PHPStan\Type\IntegerType; use PHPStan\Type\IntersectionType; use PHPStan\Type\IsSuperTypeOfResult; use PHPStan\Type\MixedType; use PHPStan\Type\NeverType; use PHPStan\Type\NullType; use PHPStan\Type\RecursionGuard; +use PHPStan\Type\StrictMixedType; +use PHPStan\Type\StringType; use PHPStan\Type\Traits\ArrayTypeTrait; use PHPStan\Type\Traits\NonObjectTypeTrait; use PHPStan\Type\Traits\UndecidedComparisonTypeTrait; @@ -87,6 +95,9 @@ class ConstantArrayType implements Type private TrinaryLogic $isList; + /** @var array{Type, Type}|null */ + private ?array $unsealed; // phpcs:ignore + /** @var self[]|null */ private ?array $allArrays = null; @@ -103,6 +114,7 @@ class ConstantArrayType implements Type * @param array $valueTypes * @param non-empty-list $nextAutoIndexes * @param int[] $optionalKeys + * @param array{Type, Type}|null $unsealed */ public function __construct( private array $keyTypes, @@ -110,6 +122,7 @@ public function __construct( private array $nextAutoIndexes = [0], private array $optionalKeys = [], ?TrinaryLogic $isList = null, + ?array $unsealed = null, ) { assert(count($keyTypes) === count($valueTypes)); @@ -123,6 +136,44 @@ public function __construct( $isList = TrinaryLogic::createNo(); } $this->isList = $isList; + + if ($unsealed !== null) { + if (in_array($unsealed[0]->describe(VerbosityLevel::value()), ['(int|string)', '(int|non-decimal-int-string)'], true)) { + $unsealed[0] = new MixedType(); + } + if ($unsealed[0] instanceof StrictMixedType && !$unsealed[0] instanceof TemplateStrictMixedType) { + $unsealed[0] = (new UnionType([new StringType(), new IntegerType()]))->toArrayKey(); + } + } else { + $never = new NeverType(true); + $unsealed = [$never, $never]; + } + $this->unsealed = $unsealed; + } + + public function isSealed(): TrinaryLogic + { + return $this->isUnsealed()->negate(); + } + + public function isUnsealed(): TrinaryLogic + { + $unsealed = $this->unsealed; + if ($unsealed === null) { + return TrinaryLogic::createMaybe(); + } + + [$keyType] = $unsealed; + + return TrinaryLogic::createFromBoolean(!$keyType instanceof NeverType || !$keyType->isExplicit()); + } + + /** + * @return array{Type, Type}|null + */ + public function getUnsealedTypes(): ?array + { + return $this->unsealed; } /** @@ -130,16 +181,18 @@ public function __construct( * @param array $valueTypes * @param non-empty-list $nextAutoIndexes * @param int[] $optionalKeys + * @param array{Type, Type}|null $unsealed */ protected function recreate( array $keyTypes, array $valueTypes, - array $nextAutoIndexes = [0], - array $optionalKeys = [], - ?TrinaryLogic $isList = null, + array $nextAutoIndexes, + array $optionalKeys, + ?TrinaryLogic $isList, + ?array $unsealed, ): self { - return new self($keyTypes, $valueTypes, $nextAutoIndexes, $optionalKeys, $isList); + return new self($keyTypes, $valueTypes, $nextAutoIndexes, $optionalKeys, $isList, $unsealed); } public function getConstantArrays(): array @@ -180,6 +233,16 @@ public function getIterableKeyType(): Type $keyType = new UnionType($this->keyTypes); } + if ($this->isUnsealed()->yes() && $this->unsealed !== null) { + $unsealedKeyType = $this->unsealed[0]; + if ($unsealedKeyType instanceof MixedType && !$unsealedKeyType instanceof TemplateMixedType) { + $unsealedKeyType = (new BenevolentUnionType([new IntegerType(), new StringType()]))->toArrayKey(); + } elseif ($unsealedKeyType instanceof StrictMixedType && !$unsealedKeyType instanceof TemplateStrictMixedType) { + $unsealedKeyType = (new BenevolentUnionType([new IntegerType(), new StringType()]))->toArrayKey(); + } + $keyType = TypeCombinator::union($keyType, $unsealedKeyType); + } + return $this->iterableKeyType = $keyType; } @@ -189,7 +252,12 @@ public function getIterableValueType(): Type return $this->iterableValueType; } - return $this->iterableValueType = count($this->valueTypes) > 0 ? TypeCombinator::union(...$this->valueTypes) : new NeverType(true); + $valueType = count($this->valueTypes) > 0 ? TypeCombinator::union(...$this->valueTypes) : new NeverType(true); + if ($this->isUnsealed()->yes() && $this->unsealed !== null) { + $valueType = TypeCombinator::union($valueType, $this->unsealed[1]); + } + + return $this->iterableValueType = $valueType; } public function getKeyType(): Type @@ -330,10 +398,173 @@ public function accepts(Type $type, bool $strictTypes): AcceptsResult return $type->isAcceptedBy($this, $strictTypes); } - if ($type instanceof self && count($this->keyTypes) === 0) { - return AcceptsResult::createFromBoolean(count($type->keyTypes) === 0); + $isUnsealed = $this->isUnsealed(); + if (!$isUnsealed->yes()) { + if ($type instanceof self && count($this->keyTypes) === 0) { + return AcceptsResult::createFromBoolean(count($type->keyTypes) === 0); + } + } + + $result = $this->checkOurKeys($type, $strictTypes)->and(new AcceptsResult($type->isArray(), [])); + if ($this->unsealed === null) { + if ($type->isOversizedArray()->yes()) { + if (!$result->no()) { + return AcceptsResult::createYes(); + } + } + + return $result; + } + + [$unsealedKeyType, $unsealedValueType] = $this->unsealed; + + if ($isUnsealed->no()) { + if (!$type->isConstantArray()->yes()) { + return $result->and(AcceptsResult::createNo([ + 'Sealed array shape can only accept a constant array. Extra keys are not allowed.', + ])); + } + + $constantArrays = $type->getConstantArrays(); + if (count($constantArrays) !== 1) { + throw new ShouldNotHappenException('Type with more than one constant array occurred, should have been eliminated with `instanceof CompoundType` above.'); + } + + $keys = []; + foreach ($constantArrays[0]->getKeyTypes() as $otherKeyType) { + $keys[$otherKeyType->getValue()] = $otherKeyType; + } + + foreach ($this->keyTypes as $keyType) { + unset($keys[$keyType->getValue()]); + } + + foreach ($keys as $extraKey) { + $result = $result->and(AcceptsResult::createNo([ + sprintf('Sealed array shape does not accept array with extra key %s.', $extraKey->describe(VerbosityLevel::precise())), + ])); + } + + if (!$constantArrays[0]->isUnsealed()->no()) { + $result = $result->and(AcceptsResult::createNo([ + 'Sealed array shape does not accept unsealed array shape.', + ])); + } + + return $result; + } + + if (!$type->isConstantArray()->yes()) { + return $result->and($unsealedKeyType->accepts($type->getIterableKeyType(), $strictTypes)) + ->and($unsealedValueType->accepts($type->getIterableValueType(), $strictTypes)); + } + + $constantArrays = $type->getConstantArrays(); + if (count($constantArrays) !== 1) { + throw new ShouldNotHappenException('Type with more than one constant array occurred, should have been eliminated with `instanceof CompoundType` above.'); + } + + $keys = []; + $constantArray = $constantArrays[0]; + foreach ($constantArray->getKeyTypes() as $i => $otherKeyType) { + $keys[$otherKeyType->getValue()] = [$i, $otherKeyType]; + } + + foreach ($this->keyTypes as $keyType) { + unset($keys[$keyType->getValue()]); } + foreach ($keys as [$i, $extraKeyType]) { + $acceptsKey = $unsealedKeyType->accepts($extraKeyType, $strictTypes)->decorateReasons( + static fn (string $reason) => sprintf( + 'Unsealed array key type %s does not accept extra key type %s: %s', + $unsealedKeyType->describe(VerbosityLevel::value()), + $extraKeyType->describe(VerbosityLevel::value()), + $reason, + ), + ); + if (!$acceptsKey->yes() && count($acceptsKey->reasons) === 0) { + $acceptsKey = new AcceptsResult($acceptsKey->result, [ + sprintf( + 'Unsealed array key type %s does not accept extra key type %s.', + $unsealedKeyType->describe(VerbosityLevel::value()), + $extraKeyType->describe(VerbosityLevel::value()), + ), + ]); + } + $result = $result->and($acceptsKey); + + $extraValueType = $constantArray->getValueTypes()[$i]; + $acceptsValue = $unsealedValueType->accepts($extraValueType, $strictTypes)->decorateReasons( + static fn (string $reason) => sprintf( + 'Unsealed array value type %s does not accept extra offset %s with value type %s: %s', + $unsealedValueType->describe(VerbosityLevel::value()), + $extraKeyType->describe(VerbosityLevel::value()), + $extraValueType->describe(VerbosityLevel::value()), + $reason, + ), + ); + if (!$acceptsValue->yes() && count($acceptsValue->reasons) === 0) { + $acceptsValue = new AcceptsResult($acceptsValue->result, [ + sprintf( + 'Unsealed array value type %s does not accept extra offset %s with value type %s.', + $unsealedValueType->describe(VerbosityLevel::value()), + $extraKeyType->describe(VerbosityLevel::value()), + $extraValueType->describe(VerbosityLevel::value()), + ), + ]); + } + $result = $result->and($acceptsValue); + } + + $otherUnsealed = $constantArray->getUnsealedTypes(); + if ($otherUnsealed !== null && !$constantArray->isUnsealed()->no()) { + [$otherUnsealedKeyType, $otherUnsealedValueType] = $otherUnsealed; + + $acceptsUnsealedKey = $unsealedKeyType->accepts($otherUnsealedKeyType, $strictTypes)->decorateReasons( + static fn (string $reason) => sprintf( + 'Unsealed array key type %s does not accept unsealed array key type %s: %s', + $unsealedKeyType->describe(VerbosityLevel::value()), + $otherUnsealedKeyType->describe(VerbosityLevel::value()), + $reason, + ), + ); + if (!$acceptsUnsealedKey->yes() && count($acceptsUnsealedKey->reasons) === 0) { + $acceptsUnsealedKey = new AcceptsResult($acceptsUnsealedKey->result, [ + sprintf( + 'Unsealed array key type %s does not accept unsealed array key type %s.', + $unsealedKeyType->describe(VerbosityLevel::value()), + $otherUnsealedKeyType->describe(VerbosityLevel::value()), + ), + ]); + } + $result = $result->and($acceptsUnsealedKey); + + $acceptsUnsealedValue = $unsealedValueType->accepts($otherUnsealedValueType, $strictTypes)->decorateReasons( + static fn (string $reason) => sprintf( + 'Unsealed array value type %s does not accept unsealed array value type %s: %s', + $unsealedValueType->describe(VerbosityLevel::value()), + $otherUnsealedValueType->describe(VerbosityLevel::value()), + $reason, + ), + ); + if (!$acceptsUnsealedValue->yes() && count($acceptsUnsealedValue->reasons) === 0) { + $acceptsUnsealedValue = new AcceptsResult($acceptsUnsealedValue->result, [ + sprintf( + 'Unsealed array value type %s does not accept unsealed array value type %s.', + $unsealedValueType->describe(VerbosityLevel::value()), + $otherUnsealedValueType->describe(VerbosityLevel::value()), + ), + ]); + } + $result = $result->and($acceptsUnsealedValue); + } + + return $result; + } + + private function checkOurKeys(Type $type, bool $strictTypes): AcceptsResult + { $result = AcceptsResult::createYes(); foreach ($this->keyTypes as $i => $keyType) { $valueType = $this->valueTypes[$i]; @@ -380,13 +611,6 @@ public function accepts(Type $type, bool $strictTypes): AcceptsResult $result = $result->and($acceptsValue); } - $result = $result->and(new AcceptsResult($type->isArray(), [])); - if ($type->isOversizedArray()->yes()) { - if (!$result->no()) { - return AcceptsResult::createYes(); - } - } - return $result; } @@ -723,7 +947,7 @@ public function getOffsetValueType(Type $offsetType): Type $matchingValueTypes[] = $this->valueTypes[$i]; } - if ($all) { + if ($all && !$this->isUnsealed()->yes()) { return $this->getIterableValueType(); } @@ -807,7 +1031,7 @@ public function unsetOffset(Type $offsetType, bool $preserveListCertainty = fals return new NeverType(); } - return $this->recreate($newKeyTypes, $newValueTypes, $this->nextAutoIndexes, $newOptionalKeys, $newIsList); + return $this->recreate($newKeyTypes, $newValueTypes, $this->nextAutoIndexes, $newOptionalKeys, $newIsList, $this->unsealed); } return $this; @@ -854,7 +1078,7 @@ public function unsetOffset(Type $offsetType, bool $preserveListCertainty = fals return new NeverType(); } - return $this->recreate($this->keyTypes, $this->valueTypes, $this->nextAutoIndexes, $optionalKeys, $newIsList); + return $this->recreate($this->keyTypes, $this->valueTypes, $this->nextAutoIndexes, $optionalKeys, $newIsList, $this->unsealed); } $optionalKeys = $this->optionalKeys; @@ -884,7 +1108,7 @@ public function unsetOffset(Type $offsetType, bool $preserveListCertainty = fals return new NeverType(); } - return $this->recreate($this->keyTypes, $this->valueTypes, $this->nextAutoIndexes, $optionalKeys, $newIsList); + return $this->recreate($this->keyTypes, $this->valueTypes, $this->nextAutoIndexes, $optionalKeys, $newIsList, $this->unsealed); } /** @@ -1108,7 +1332,7 @@ public function sliceArray(Type $offsetType, Type $lengthType, TrinaryLogic $pre if ($length === 0 || ($offset < 0 && $length < 0 && $offset - $length >= 0)) { // 0 / 0, 3 / 0 or e.g. -3 / -3 or -3 / -4 and so on never extract anything - return $this->recreate([], []); + return $this->recreate([], [], [0], [], null, [new NeverType(true), new NeverType(true)]); } if ($length < 0) { @@ -1304,11 +1528,16 @@ public function getArraySize(): Type { $optionalKeysCount = count($this->optionalKeys); $totalKeysCount = count($this->getKeyTypes()); - if ($optionalKeysCount === 0) { - return new ConstantIntegerType($totalKeysCount); + if (!$this->isUnsealed()->yes()) { + if ($optionalKeysCount === 0) { + return new ConstantIntegerType($totalKeysCount); + } + $max = $totalKeysCount; + } else { + $max = null; } - return IntegerRangeType::fromInterval($totalKeysCount - $optionalKeysCount, $totalKeysCount); + return IntegerRangeType::fromInterval($totalKeysCount - $optionalKeysCount, $max); } public function getFirstIterableKeyType(): Type @@ -1424,6 +1653,7 @@ private function removeLastElements(int $length): self $nextAutoindexes, array_values($optionalKeys), $this->isList, + $this->unsealed, ); } @@ -1522,7 +1752,7 @@ public function generalizeValues(): self $valueTypes[] = $valueType->generalize(GeneralizePrecision::lessSpecific()); } - return $this->recreate($this->keyTypes, $valueTypes, $this->nextAutoIndexes, $this->optionalKeys, $this->isList); + return $this->recreate($this->keyTypes, $valueTypes, $this->nextAutoIndexes, $this->optionalKeys, $this->isList, $this->unsealed); } private function degradeToGeneralArray(): Type @@ -1570,7 +1800,7 @@ private function getKeysOrValuesArray(array $types): self static fn (int $i): ConstantIntegerType => new ConstantIntegerType($i), array_keys($types), ); - return $this->recreate($keyTypes, $types, $autoIndexes, $this->optionalKeys, TrinaryLogic::createYes()); + return $this->recreate($keyTypes, $types, $autoIndexes, $this->optionalKeys, TrinaryLogic::createYes(), $this->unsealed); // todo unsealed } $keyTypes = []; @@ -1599,7 +1829,7 @@ private function getKeysOrValuesArray(array $types): self $maxIndex++; } - return $this->recreate($keyTypes, $valueTypes, $autoIndexes, $optionalKeys, TrinaryLogic::createYes()); + return $this->recreate($keyTypes, $valueTypes, $autoIndexes, $optionalKeys, TrinaryLogic::createYes(), $this->unsealed); // todo unsealed } public function describe(VerbosityLevel $level): string @@ -1644,6 +1874,23 @@ public function describe(VerbosityLevel $level): string $append = ', ...'; } + if ($this->isUnsealed()->yes() && $this->unsealed !== null) { + if (count($items) > 0) { + $append .= ', '; + } + $append .= '...'; + $keyDescription = $this->unsealed[0]->describe(VerbosityLevel::precise()); + $isMixedKeyType = $this->unsealed[0] instanceof MixedType && $keyDescription === 'mixed' && !$this->unsealed[0]->isExplicitMixed(); + $isMixedItemType = $this->unsealed[1] instanceof MixedType && $this->unsealed[1]->describe(VerbosityLevel::precise()) === 'mixed' && !$this->unsealed[1]->isExplicitMixed(); + if ($isMixedKeyType || ($this->isList()->yes() && $keyDescription === 'int<0, max>')) { + if (!$isMixedItemType) { + $append .= sprintf('<%s>', $this->unsealed[1]->describe($level)); + } + } else { + $append .= sprintf('<%s, %s>', $this->unsealed[0]->describe($level), $this->unsealed[1]->describe($level)); + } + } + return sprintf( '%s{%s%s}', $arrayName, @@ -1760,11 +2007,21 @@ public function traverse(callable $cb): Type $valueTypes[] = $transformedValueType; } + $unsealed = $this->unsealed; + if ($unsealed !== null) { + [$unsealedKeyType, $unsealedValueType] = $unsealed; + $transformedUnsealedValueType = $cb($unsealedValueType); + if ($transformedUnsealedValueType !== $unsealedValueType) { + $stillOriginal = false; + $unsealed = [$unsealedKeyType, $transformedUnsealedValueType]; + } + } + if ($stillOriginal) { return $this; } - return $this->recreate($this->keyTypes, $valueTypes, $this->nextAutoIndexes, $this->optionalKeys, $this->isList); + return $this->recreate($this->keyTypes, $valueTypes, $this->nextAutoIndexes, $this->optionalKeys, $this->isList, $unsealed); } public function traverseSimultaneously(Type $right, callable $cb): Type @@ -1790,7 +2047,7 @@ public function traverseSimultaneously(Type $right, callable $cb): Type return $this; } - return $this->recreate($this->keyTypes, $valueTypes, $this->nextAutoIndexes, $this->optionalKeys, $this->isList); + return $this->recreate($this->keyTypes, $valueTypes, $this->nextAutoIndexes, $this->optionalKeys, $this->isList, $this->unsealed); } public function isKeysSupersetOf(self $otherArray): bool @@ -1847,6 +2104,8 @@ public function isKeysSupersetOf(self $otherArray): bool } } + // todo unsealed + return true; } @@ -1873,7 +2132,7 @@ public function mergeWith(self $otherArray): self $nextAutoIndexes = array_values(array_unique(array_merge($this->nextAutoIndexes, $otherArray->nextAutoIndexes))); sort($nextAutoIndexes); - return $this->recreate($this->keyTypes, $valueTypes, $nextAutoIndexes, $optionalKeys, $this->isList->and($otherArray->isList)); + return $this->recreate($this->keyTypes, $valueTypes, $nextAutoIndexes, $optionalKeys, $this->isList->and($otherArray->isList), $this->unsealed); // todo unsealed } /** @@ -1929,7 +2188,7 @@ public function makeOffsetRequired(Type $offsetType): self } if (count($this->optionalKeys) !== count($optionalKeys)) { - return $this->recreate($this->keyTypes, $this->valueTypes, $this->nextAutoIndexes, array_values($optionalKeys), $this->isList); + return $this->recreate($this->keyTypes, $this->valueTypes, $this->nextAutoIndexes, array_values($optionalKeys), $this->isList, $this->unsealed); } break; @@ -1948,7 +2207,9 @@ public function makeList(): Type return new NeverType(); } - return $this->recreate($this->keyTypes, $this->valueTypes, $this->nextAutoIndexes, $this->optionalKeys, TrinaryLogic::createYes()); + // todo can't be a list if keyTypes are not subsequent integers, or if unsealed type is not int keys + + return $this->recreate($this->keyTypes, $this->valueTypes, $this->nextAutoIndexes, $this->optionalKeys, TrinaryLogic::createYes(), $this->unsealed); } public function toPhpDocNode(): TypeNode @@ -1991,6 +2252,33 @@ public function toPhpDocNode(): TypeNode ); } + if ($this->isUnsealed()->yes() && $this->unsealed !== null) { + $unsealedKeyTypeDescription = $this->unsealed[0]->describe(VerbosityLevel::precise()); + $isMixedUnsealedKeyType = $this->unsealed[0] instanceof MixedType && $unsealedKeyTypeDescription === 'mixed' && !$this->unsealed[0]->isExplicitMixed(); + $isMixedUnsealedItemType = $this->unsealed[1] instanceof MixedType && $this->unsealed[1]->describe(VerbosityLevel::precise()) === 'mixed' && !$this->unsealed[1]->isExplicitMixed(); + if ($isMixedUnsealedKeyType || ($this->isList()->yes() && $unsealedKeyTypeDescription === 'int<0, max>')) { + if ($isMixedUnsealedItemType) { + return ArrayShapeNode::createUnsealed( + $exportValuesOnly ? $values : $items, + null, + $this->shouldBeDescribedAsAList() ? ArrayShapeNode::KIND_LIST : ArrayShapeNode::KIND_ARRAY, + ); + } + + return ArrayShapeNode::createUnsealed( + $exportValuesOnly ? $values : $items, + new ArrayShapeUnsealedTypeNode($this->unsealed[1]->toPhpDocNode(), null), + $this->shouldBeDescribedAsAList() ? ArrayShapeNode::KIND_LIST : ArrayShapeNode::KIND_ARRAY, + ); + } + + return ArrayShapeNode::createUnsealed( + $exportValuesOnly ? $values : $items, + new ArrayShapeUnsealedTypeNode($this->unsealed[1]->toPhpDocNode(), $this->unsealed[0]->toPhpDocNode()), + ArrayShapeNode::KIND_ARRAY, + ); + } + return ArrayShapeNode::createSealed( $exportValuesOnly ? $values : $items, $this->shouldBeDescribedAsAList() ? ArrayShapeNode::KIND_LIST : ArrayShapeNode::KIND_ARRAY, diff --git a/src/Type/Constant/ConstantArrayTypeBuilder.php b/src/Type/Constant/ConstantArrayTypeBuilder.php index aab3b8ad44..f3a915e663 100644 --- a/src/Type/Constant/ConstantArrayTypeBuilder.php +++ b/src/Type/Constant/ConstantArrayTypeBuilder.php @@ -2,6 +2,7 @@ namespace PHPStan\Type\Constant; +use PHPStan\DependencyInjection\BleedingEdgeToggle; use PHPStan\ShouldNotHappenException; use PHPStan\TrinaryLogic; use PHPStan\Type\Accessory\AccessoryArrayListType; @@ -11,6 +12,7 @@ use PHPStan\Type\CallableType; use PHPStan\Type\ClosureType; use PHPStan\Type\IntersectionType; +use PHPStan\Type\NeverType; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; use PHPStan\Type\TypeUtils; @@ -46,6 +48,7 @@ final class ConstantArrayTypeBuilder * @param array $valueTypes * @param non-empty-list $nextAutoIndexes * @param array $optionalKeys + * @param array{Type, Type}|null $unsealed */ private function __construct( private array $keyTypes, @@ -53,13 +56,16 @@ private function __construct( private array $nextAutoIndexes, private array $optionalKeys, private TrinaryLogic $isList, + private ?array $unsealed, ) { } public static function createEmpty(): self { - return new self([], [], [0], [], TrinaryLogic::createYes()); + $never = new NeverType(true); + + return new self([], [], [0], [], TrinaryLogic::createYes(), [$never, $never]); } public static function createFromConstantArray(ConstantArrayType $startArrayType): self @@ -70,6 +76,7 @@ public static function createFromConstantArray(ConstantArrayType $startArrayType $startArrayType->getNextAutoIndexes(), $startArrayType->getOptionalKeys(), $startArrayType->isList(), + $startArrayType->getUnsealedTypes(), ); if (count($startArrayType->getKeyTypes()) > self::ARRAY_COUNT_LIMIT) { @@ -79,6 +86,11 @@ public static function createFromConstantArray(ConstantArrayType $startArrayType return $builder; } + public function makeUnsealed(Type $keyType, Type $valueType): void + { + $this->unsealed = [$keyType, $valueType]; + } + public function setOffsetValueType(?Type $offsetType, Type $valueType, bool $optional = false): void { if ($offsetType !== null) { @@ -367,13 +379,13 @@ public function getArray(): Type { $keyTypesCount = count($this->keyTypes); if ($keyTypesCount === 0) { - return new ConstantArrayType([], []); + return new ConstantArrayType([], [], unsealed: $this->unsealed); } if (!$this->degradeToGeneralArray) { /** @var list $keyTypes */ $keyTypes = $this->keyTypes; - return new ConstantArrayType($keyTypes, $this->valueTypes, $this->nextAutoIndexes, $this->optionalKeys, $this->isList); + return new ConstantArrayType($keyTypes, $this->valueTypes, $this->nextAutoIndexes, $this->optionalKeys, $this->isList, $this->unsealed); } if ($this->degradeClosures === true) { diff --git a/src/Type/Generic/TemplateConstantArrayType.php b/src/Type/Generic/TemplateConstantArrayType.php index 42dba2bb1c..934be1fe77 100644 --- a/src/Type/Generic/TemplateConstantArrayType.php +++ b/src/Type/Generic/TemplateConstantArrayType.php @@ -47,9 +47,10 @@ public function __construct( protected function recreate( array $keyTypes, array $valueTypes, - array $nextAutoIndexes = [0], - array $optionalKeys = [], - ?TrinaryLogic $isList = null, + array $nextAutoIndexes, + array $optionalKeys, + ?TrinaryLogic $isList, + ?array $unsealed, ): ConstantArrayType { return new self( @@ -57,7 +58,7 @@ protected function recreate( $this->strategy, $this->variance, $this->name, - new ConstantArrayType($keyTypes, $valueTypes, $nextAutoIndexes, $optionalKeys, $isList), + new ConstantArrayType($keyTypes, $valueTypes, $nextAutoIndexes, $optionalKeys, $isList, $unsealed), $this->default, ); } diff --git a/tests/PHPStan/Analyser/data/report-unsafe-array-string-key-casting-prevent.php b/tests/PHPStan/Analyser/data/report-unsafe-array-string-key-casting-prevent.php index 163a996bd2..89e1be359b 100644 --- a/tests/PHPStan/Analyser/data/report-unsafe-array-string-key-casting-prevent.php +++ b/tests/PHPStan/Analyser/data/report-unsafe-array-string-key-casting-prevent.php @@ -89,3 +89,41 @@ public function doArrayCreationAndAssign(string $s): void } } + +class Unsealed +{ + + /** + * @param array{a: int, ...} $a + */ + public function doFoo(array $a): void + { + assertType('array{a: int, ...}', $a); + foreach ($a as $k => $v) { + assertType('non-decimal-int-string', $k); + } + } + + /** + * @param array{a: int, ...} $a + */ + public function doBar(array $a): void + { + assertType('array{a: int, ...}', $a); + foreach ($a as $k => $v) { + assertType('(int|non-decimal-int-string)', $k); + } + } + + /** + * @param array{a: int, ...} $a + */ + public function doBaz(array $a): void + { + assertType('array{a: int, ...}', $a); + foreach ($a as $k => $v) { + assertType('int|non-decimal-int-string', $k); + } + } + +} diff --git a/tests/PHPStan/Analyser/nsrt/bug-12355.php b/tests/PHPStan/Analyser/nsrt/bug-12355.php index 4b7ee866cd..ed67cce3e1 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-12355.php +++ b/tests/PHPStan/Analyser/nsrt/bug-12355.php @@ -20,11 +20,11 @@ abstract class Animal * @param AnimalData $arg */ public function __construct(array $arg) { - assertType('ValidType of array{name: string} (class Bug12355\Animal, argument)', $arg['animalSpecificData']); + assertType('ValidType of array{name: string, ...} (class Bug12355\Animal, argument)', $arg['animalSpecificData']); if (isset($arg['habitat'])) { //do things } - assertType('ValidType of array{name: string} (class Bug12355\Animal, argument)', $arg['animalSpecificData']); + assertType('ValidType of array{name: string, ...} (class Bug12355\Animal, argument)', $arg['animalSpecificData']); } } @@ -34,7 +34,7 @@ public function __construct(array $arg) { */ function testMergeWithDifferentObjects(array $arg): void { - assertType('T of array{name: string} (function Bug12355\testMergeWithDifferentObjects(), argument)', $arg['first']); + assertType('T of array{name: string, ...} (function Bug12355\testMergeWithDifferentObjects(), argument)', $arg['first']); // Modifying $arg in one branch causes different ConstantArrayType objects if (isset($arg['flag'])) { @@ -43,6 +43,6 @@ function testMergeWithDifferentObjects(array $arg): void // After scope merge, $arg's value types for 'first' and 'second' go through // ConstantArrayType::mergeWith() which uses new self() — stripping TemplateConstantArrayType - assertType('T of array{name: string} (function Bug12355\testMergeWithDifferentObjects(), argument)', $arg['first']); - assertType('T of array{name: string} (function Bug12355\testMergeWithDifferentObjects(), argument)', $arg['second']); + assertType('T of array{name: string, ...} (function Bug12355\testMergeWithDifferentObjects(), argument)', $arg['first']); + assertType('T of array{name: string, ...} (function Bug12355\testMergeWithDifferentObjects(), argument)', $arg['second']); } diff --git a/tests/PHPStan/Analyser/nsrt/list-shapes.php b/tests/PHPStan/Analyser/nsrt/list-shapes.php index 62313ca8e7..8ea8b4c9ce 100644 --- a/tests/PHPStan/Analyser/nsrt/list-shapes.php +++ b/tests/PHPStan/Analyser/nsrt/list-shapes.php @@ -21,6 +21,6 @@ public function bar($l1, $l2, $l3, $l4, $l5, $l6): void assertType("array{'a'}", $l3); assertType("array{'a', 'b'}", $l4); assertType("array{0: 'a', 1?: 'b'}", $l5); - assertType("array{'a', 'b'}", $l6); + assertType("array{'a', 'b', ...}", $l6); } } diff --git a/tests/PHPStan/Analyser/nsrt/unsealed-array-shapes.php b/tests/PHPStan/Analyser/nsrt/unsealed-array-shapes.php new file mode 100644 index 0000000000..f80da767b6 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/unsealed-array-shapes.php @@ -0,0 +1,85 @@ +} $b + * @param array{a: int, ...} $c + * @param list{int, string, ...} $d + * @param list{int, string, 2?: string, 3?: string, ...} $e + * @param list{int, string, ...} $f + * @param list{int, string, 2?: string, 3?: string, ...} $g + */ + public function doFoo(array $a, array $b, array $c, array $d, array $e, array $f, array $g): void + { + assertType('array{a: int, ...}', $a); + foreach ($a as $k => $v) { + assertType('(int|string)', $k); + assertType('mixed', $v); + } + + assertType('array{a: int, ...}', $b); + foreach ($b as $k => $v) { + assertType('string', $k); + assertType('float|int', $v); + } + assertType('array{a: int, ...}', $c); + foreach ($c as $k => $v) { + assertType('(int|string)', $k); + assertType('float|int', $v); + } + + assertType('array{int, string, ...}', $d); + foreach ($d as $k => $v) { + assertType('int<0, max>', $k); + assertType('float|int|string', $v); + } + + assertType('list{0: int, 1: string, 2?: string, 3?: string, ...}', $e); + foreach ($e as $k => $v) { + assertType('int<0, max>', $k); + assertType('float|int|string', $v); + } + + assertType('array{int, string, ...}', $f); + foreach ($f as $k => $v) { + assertType('int<0, max>', $k); + assertType('mixed', $v); + } + + assertType('list{0: int, 1: string, 2?: string, 3?: string, ...}', $g); + foreach ($e as $k => $v) { + assertType('int<0, max>', $k); + assertType('float|int|string', $v); + } + } + + /** + * @param array{a: int, ...} $a + * @return void + */ + public function wrongKeyButResolvedToIntString(array $a): void + { + assertType('array{a: int, ...}', $a); + } + + /** + * @param array{...} $a + * @param array{a: int, ...<'b'|'c', string>} $b + * @param array{a: int, b: float, ...<'b'|'c', string>} $c + */ + public function edgeCases(array $a, array $b, array $c): void + { + assertType('array{...}', $a); + assertType('array{a: int, b?: string, c?: string}', $b); + assertType('array{a: int, b: float|string, c?: string}', $c); + } + +} diff --git a/tests/PHPStan/Rules/Functions/ReturnTypeRuleTest.php b/tests/PHPStan/Rules/Functions/ReturnTypeRuleTest.php index 8a6c910909..a66846be65 100644 --- a/tests/PHPStan/Rules/Functions/ReturnTypeRuleTest.php +++ b/tests/PHPStan/Rules/Functions/ReturnTypeRuleTest.php @@ -424,4 +424,17 @@ public function testBug12397(): void $this->analyse([__DIR__ . '/data/bug-12397.php'], []); } + public function testBug13565(): void + { + $this->checkNullables = true; + $this->checkExplicitMixed = true; + $this->analyse([__DIR__ . '/data/bug-13565.php'], [ + [ + 'Function Bug13565\x() should return array{name: string} but returns array{name: \'string\', email: Bug13565\NotAString}.', + 11, + 'Sealed array shape does not accept array with extra key \'email\'.', + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Functions/data/bug-13565.php b/tests/PHPStan/Rules/Functions/data/bug-13565.php new file mode 100644 index 0000000000..04270b9997 --- /dev/null +++ b/tests/PHPStan/Rules/Functions/data/bug-13565.php @@ -0,0 +1,19 @@ + 'string', 'email' => new NotAString()]; +} + +/** + * @return array{name: string, email?: string} + */ +function y(): array { return x(); } + +function send_mail(string $val): void { echo "sending mail to $val"; } diff --git a/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php b/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php index 6d6c41af1c..f061882c1e 100644 --- a/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php +++ b/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php @@ -3,6 +3,7 @@ namespace PHPStan\Type\Constant; use Closure; +use PHPStan\DependencyInjection\BleedingEdgeToggle; use PHPStan\Testing\PHPStanTestCase; use PHPStan\TrinaryLogic; use PHPStan\Type\Accessory\HasOffsetType; @@ -13,6 +14,7 @@ use PHPStan\Type\Generic\TemplateTypeFactory; use PHPStan\Type\Generic\TemplateTypeScope; use PHPStan\Type\Generic\TemplateTypeVariance; +use PHPStan\Type\IntegerRangeType; use PHPStan\Type\IntegerType; use PHPStan\Type\IntersectionType; use PHPStan\Type\IterableType; @@ -26,6 +28,7 @@ use PHPStan\Type\UnionType; use PHPStan\Type\VerbosityLevel; use PHPUnit\Framework\Attributes\DataProvider; +use stdClass; use function array_map; use function sprintf; @@ -409,6 +412,9 @@ public static function dataAccepts(): iterable TrinaryLogic::createMaybe(), ]; + $bleedingEdgeBackup = BleedingEdgeToggle::isBleedingEdge(); + BleedingEdgeToggle::setBleedingEdge(false); + yield [ new ConstantArrayType([], []), new ConstantArrayType([], []), @@ -420,6 +426,7 @@ public static function dataAccepts(): iterable new ConstantArrayType([], []), new ConstantArrayType([new ConstantStringType('a')], [new StringType()]), TrinaryLogic::createNo(), + [], ]; // non-empty array (with unknown sealedness) accepts extra keys @@ -433,18 +440,184 @@ public static function dataAccepts(): iterable new IntegerType(), ]), TrinaryLogic::createYes(), + [], + ]; + + BleedingEdgeToggle::setBleedingEdge(true); + + // empty array (sealed) does not accept extra keys + yield [ + new ConstantArrayType([], []), + new ConstantArrayType([new ConstantStringType('a')], [new StringType()]), + TrinaryLogic::createNo(), + [], + ]; + + // non-empty array (sealed) does not accept extra keys + yield [ + new ConstantArrayType([new ConstantStringType('a')], [new StringType()]), + new ConstantArrayType([ + new ConstantStringType('a'), + new ConstantStringType('b'), + ], [ + new StringType(), + new IntegerType(), + ]), + TrinaryLogic::createNo(), + ['Sealed array shape does not accept array with extra key \'b\'.'], + ]; + + // sealed array does not accept general array + yield [ + new ConstantArrayType([new ConstantStringType('a')], [new StringType()]), + new ArrayType(new StringType(), new StringType()), + TrinaryLogic::createNo(), + ['Sealed array shape can only accept a constant array. Extra keys are not allowed.'], + ]; + + // sealed array does not accept unsealed array + yield [ + new ConstantArrayType([new ConstantStringType('a')], [new StringType()]), + new ConstantArrayType([new ConstantStringType('a')], [new StringType()], unsealed: [new StringType(), new ObjectType(stdClass::class)]), + TrinaryLogic::createNo(), + ['Sealed array shape does not accept unsealed array shape.'], + ]; + + // unsealed array accepts compatible general array + yield [ + new ConstantArrayType([new ConstantStringType('a')], [new StringType()], unsealed: [new StringType(), new StringType()]), + new IntersectionType([ + new ArrayType(new StringType(), new StringType()), + new HasOffsetValueType(new ConstantStringType('a'), new StringType()), + ]), + TrinaryLogic::createYes(), + [], + ]; + + // unsealed array does not accept incompatible general array (the error is in the keys already) + yield [ + new ConstantArrayType([new ConstantStringType('a')], [new IntegerType()], unsealed: [new StringType(), new StringType()]), + new IntersectionType([ + new ArrayType(new StringType(), new StringType()), + new HasOffsetValueType(new ConstantStringType('a'), new StringType()), + ]), + TrinaryLogic::createNo(), + [], + ]; + + // unsealed array does not accept incompatible general array (integer vs. string unsealed values) + yield [ + new ConstantArrayType([new ConstantStringType('a')], [new StringType()], unsealed: [new StringType(), new IntegerType()]), + new IntersectionType([ + new ArrayType(new StringType(), new StringType()), + new HasOffsetValueType(new ConstantStringType('a'), new StringType()), + ]), + TrinaryLogic::createNo(), + [], + ]; + + // unsealed array must check extra keys against its own unsealed types + yield [ + new ConstantArrayType([new ConstantStringType('a')], [new StringType()], unsealed: [new StringType(), new StringType()]), + new ConstantArrayType([ + new ConstantStringType('a'), + new ConstantStringType('b'), + ], [ + new StringType(), + new StringType(), + ]), + TrinaryLogic::createYes(), + [], + ]; + + yield [ + new ConstantArrayType([new ConstantStringType('a')], [new StringType()], unsealed: [new IntegerType(), new StringType()]), + new ConstantArrayType([ + new ConstantStringType('a'), + new ConstantIntegerType(10), + ], [ + new StringType(), + new StringType(), + ]), + TrinaryLogic::createYes(), + [], + ]; + + yield [ + new ConstantArrayType([new ConstantStringType('a')], [new StringType()], unsealed: [new IntegerType(), new StringType()]), + new ConstantArrayType([ + new ConstantStringType('a'), + new ConstantStringType('b'), + ], [ + new StringType(), + new StringType(), + ]), + TrinaryLogic::createNo(), + [ + 'Unsealed array key type int does not accept extra key type \'b\'.', + ], + ]; + + yield [ + new ConstantArrayType([new ConstantStringType('a')], [new StringType()], unsealed: [new StringType(), new IntegerType()]), + new ConstantArrayType([ + new ConstantStringType('a'), + new ConstantStringType('b'), + ], [ + new StringType(), + new StringType(), + ]), + TrinaryLogic::createNo(), + [ + 'Unsealed array value type int does not accept extra offset \'b\' with value type string.', + ], + ]; + + // unsealed array must check the other array unsealed types + yield [ + new ConstantArrayType([new ConstantStringType('a')], [new StringType()], unsealed: [new StringType(), new StringType()]), + new ConstantArrayType([new ConstantStringType('a')], [new StringType()], unsealed: [new StringType(), new StringType()]), + TrinaryLogic::createYes(), + [], + ]; + + yield [ + new ConstantArrayType([new ConstantStringType('a')], [new StringType()], unsealed: [new StringType(), new StringType()]), + new ConstantArrayType([new ConstantStringType('a')], [new StringType()], unsealed: [new IntegerType(), new StringType()]), + TrinaryLogic::createNo(), + [ + 'Unsealed array key type string does not accept unsealed array key type int.', + ], + ]; + + yield [ + new ConstantArrayType([new ConstantStringType('a')], [new StringType()], unsealed: [new StringType(), new StringType()]), + new ConstantArrayType([new ConstantStringType('a')], [new StringType()], unsealed: [new StringType(), new IntegerType()]), + TrinaryLogic::createNo(), + [ + 'Unsealed array value type string does not accept unsealed array value type int.', + ], ]; + + BleedingEdgeToggle::setBleedingEdge($bleedingEdgeBackup); } + /** + * @param array|null $reasons + */ #[DataProvider('dataAccepts')] - public function testAccepts(Type $type, Type $otherType, TrinaryLogic $expectedResult): void + public function testAccepts(Type $type, Type $otherType, TrinaryLogic $expectedResult, ?array $reasons = null): void { - $actualResult = $type->accepts($otherType, true)->result; + $actualResult = $type->accepts($otherType, true); + $testDescription = sprintf('%s -> accepts(%s)', $type->describe(VerbosityLevel::precise()), $otherType->describe(VerbosityLevel::precise())); $this->assertSame( $expectedResult->describe(), - $actualResult->describe(), - sprintf('%s -> accepts(%s)', $type->describe(VerbosityLevel::precise()), $otherType->describe(VerbosityLevel::precise())), + $actualResult->result->describe(), + $testDescription, ); + if ($reasons !== null) { + $this->assertSame($reasons, $actualResult->reasons, $testDescription); + } } public static function dataIsSuperTypeOf(): iterable @@ -1116,4 +1289,99 @@ public function testHasOffsetValueType( ); } + public function testSealedness(): void + { + $bleedingEdgeBackup = BleedingEdgeToggle::isBleedingEdge(); + + BleedingEdgeToggle::setBleedingEdge(false); + + try { + $builder = ConstantArrayTypeBuilder::createEmpty(); + $array = $builder->getArray(); + $this->assertInstanceOf(ConstantArrayType::class, $array); + $this->assertSame(TrinaryLogic::createMaybe()->describe(), $array->isSealed()->describe()); + $this->assertSame(TrinaryLogic::createMaybe()->describe(), $array->isUnsealed()->describe()); + + BleedingEdgeToggle::setBleedingEdge(true); + $builder = ConstantArrayTypeBuilder::createEmpty(); + $array = $builder->getArray(); + $this->assertInstanceOf(ConstantArrayType::class, $array); + $this->assertSame(TrinaryLogic::createYes()->describe(), $array->isSealed()->describe()); + $this->assertSame(TrinaryLogic::createNo()->describe(), $array->isUnsealed()->describe()); + + $builder = ConstantArrayTypeBuilder::createEmpty(); + $builder->makeUnsealed(new IntegerType(), new StringType()); + $array = $builder->getArray(); + $this->assertInstanceOf(ConstantArrayType::class, $array); + $this->assertSame(TrinaryLogic::createNo()->describe(), $array->isSealed()->describe()); + $this->assertSame(TrinaryLogic::createYes()->describe(), $array->isUnsealed()->describe()); + } finally { + BleedingEdgeToggle::setBleedingEdge($bleedingEdgeBackup); + } + } + + public static function dataGetArraySize(): iterable + { + $bleedingEdgeBackup = BleedingEdgeToggle::isBleedingEdge(); + + foreach ([false, true] as $bleedingEdge) { + BleedingEdgeToggle::setBleedingEdge($bleedingEdge); + + yield [ + new ConstantArrayType([], []), + new ConstantIntegerType(0), + ]; + + $builder = ConstantArrayTypeBuilder::createEmpty(); + yield [ + $builder->getArray(), + new ConstantIntegerType(0), + ]; + + $builder->makeUnsealed(new IntegerType(), new ObjectType(stdClass::class)); + yield [ + $builder->getArray(), + IntegerRangeType::createAllGreaterThanOrEqualTo(0), + ]; + + $builder->setOffsetValueType(new ConstantIntegerType(0), new ObjectType(stdClass::class)); + yield [ + $builder->getArray(), + IntegerRangeType::createAllGreaterThanOrEqualTo(1), + ]; + + $builder->setOffsetValueType(new ConstantIntegerType(1), new ObjectType(stdClass::class), true); + yield [ + $builder->getArray(), + IntegerRangeType::createAllGreaterThanOrEqualTo(1), + ]; + } + + $builder = ConstantArrayTypeBuilder::createEmpty(); + $builder->makeUnsealed(new IntegerType(), new ObjectType(stdClass::class)); + yield [ + $builder->getArray(), + IntegerRangeType::createAllGreaterThanOrEqualTo(0), + ]; + $builder->setOffsetValueType(new ConstantIntegerType(0), new ObjectType(stdClass::class)); + yield [ + $builder->getArray(), + IntegerRangeType::createAllGreaterThanOrEqualTo(1), + ]; + + $builder->setOffsetValueType(new ConstantIntegerType(1), new ObjectType(stdClass::class), true); + yield [ + $builder->getArray(), + IntegerRangeType::createAllGreaterThanOrEqualTo(1), + ]; + + BleedingEdgeToggle::setBleedingEdge($bleedingEdgeBackup); + } + + #[DataProvider('dataGetArraySize')] + public function testGetArraySize(Type $constantArray, Type $expectedSize): void + { + $this->assertSame($expectedSize->describe(VerbosityLevel::precise()), $constantArray->getArraySize()->describe(VerbosityLevel::precise())); + } + } diff --git a/tests/PHPStan/Type/TypeToPhpDocNodeTest.php b/tests/PHPStan/Type/TypeToPhpDocNodeTest.php index fcc6c6d0cf..41ce52e2e4 100644 --- a/tests/PHPStan/Type/TypeToPhpDocNodeTest.php +++ b/tests/PHPStan/Type/TypeToPhpDocNodeTest.php @@ -562,6 +562,17 @@ public static function dataFromTypeStringToPhpDocNode(): iterable yield ['callable(Foo $foo=, Bar $bar=): Bar']; yield ['Closure(Foo $foo=, Bar $bar=): Bar']; yield ['Closure(Foo $foo=, Bar $bar=): (Closure(Foo): Bar)']; + + yield ['array{a: int}']; + yield ['array{a: int, ...}']; + yield ['array{a: int, ...}']; + yield ['array{a: int, ...}']; + yield ['array{int, int, int, ...}']; + yield ['array{int, int, int, ...}']; + yield ['array{int, int, int, ...}']; + + yield ['list{0?: int, 1?: int, 2?: int, ...}']; + yield ['list{0?: int, 1?: int, 2?: int, ...}']; } #[DataProvider('dataFromTypeStringToPhpDocNode')]