diff --git a/src/Parser/RichParser.php b/src/Parser/RichParser.php index 4d3f0bdb4b9..5862e3de682 100644 --- a/src/Parser/RichParser.php +++ b/src/Parser/RichParser.php @@ -361,7 +361,6 @@ private function parseIdentifiers(string $text, int $ignorePos): array throw new IgnoreParseException('Missing identifier', 1); } - /** @phpstan-ignore return.type (return type is correct, not sure why it's being changed from array shape to key-value shape) */ return $identifiers; } diff --git a/src/PhpDoc/TypeNodeResolver.php b/src/PhpDoc/TypeNodeResolver.php index 99e0856fe70..1b841dccec4 100644 --- a/src/PhpDoc/TypeNodeResolver.php +++ b/src/PhpDoc/TypeNodeResolver.php @@ -705,24 +705,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 @@ -1002,6 +985,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 = []; @@ -1101,13 +1106,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 eb5aec70cae..d2d479ca0ab 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; @@ -49,6 +57,7 @@ use PHPStan\Type\TypeCombinator; use PHPStan\Type\UnionType; use PHPStan\Type\VerbosityLevel; +use function array_key_exists; use function array_keys; use function array_map; use function array_merge; @@ -69,6 +78,7 @@ use function sort; use function sprintf; use function str_contains; +use function usort; /** * @api @@ -87,6 +97,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 +116,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,19 +124,69 @@ public function __construct( private array $nextAutoIndexes = [0], private array $optionalKeys = [], ?TrinaryLogic $isList = null, + ?array $unsealed = null, ) { assert(count($keyTypes) === count($valueTypes)); $keyTypesCount = count($this->keyTypes); if ($keyTypesCount === 0) { - $isList = TrinaryLogic::createYes(); + if ($unsealed === null) { + $isList = TrinaryLogic::createYes(); + } else { + [$unsealedKeyType] = $unsealed; + if ($unsealedKeyType instanceof NeverType && $unsealedKeyType->isExplicit()) { + $isList = TrinaryLogic::createYes(); + } elseif ($unsealedKeyType->isInteger()->yes()) { + $isList = TrinaryLogic::createMaybe(); + } else { + $isList = TrinaryLogic::createNo(); + } + } } if ($isList === null) { $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(); + } + } elseif (BleedingEdgeToggle::isBleedingEdge()) { + $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 +194,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 +246,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 +265,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 @@ -324,16 +405,209 @@ public function isOptionalKey(int $i): bool return in_array($i, $this->optionalKeys, true); } + public function sortKeys(): self + { + $indices = array_keys($this->keyTypes); + usort($indices, fn (int $a, int $b): int => $this->keyTypes[$a]->getValue() <=> $this->keyTypes[$b]->getValue()); + + $newKeyTypes = []; + $newValueTypes = []; + $indexMap = []; + foreach ($indices as $newIdx => $oldIdx) { + $newKeyTypes[] = $this->keyTypes[$oldIdx]; + $newValueTypes[] = $this->valueTypes[$oldIdx]; + $indexMap[$oldIdx] = $newIdx; + } + + $newOptionalKeys = []; + foreach ($this->optionalKeys as $oldIdx) { + $newOptionalKeys[] = $indexMap[$oldIdx]; + } + sort($newOptionalKeys); + + return $this->recreate( + $newKeyTypes, + $newValueTypes, + $this->nextAutoIndexes, + $newOptionalKeys, + $this->isList, + $this->unsealed, + ); + } + public function accepts(Type $type, bool $strictTypes): AcceptsResult { if ($type instanceof CompoundType && !$type instanceof IntersectionType) { 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->unsealed; + 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,26 +654,35 @@ 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; } public function isSuperTypeOf(Type $type): IsSuperTypeOfResult { if ($type instanceof self) { + $thisUnsealedness = $this->isUnsealed(); + $typeUnsealedness = $type->isUnsealed(); + $bothDefinite = $this->unsealed !== null && $type->unsealed !== null; + if (count($this->keyTypes) === 0) { - return new IsSuperTypeOfResult($type->isIterableAtLeastOnce()->negate(), []); + if (!$bothDefinite) { + return new IsSuperTypeOfResult($type->isIterableAtLeastOnce()->negate(), []); + } + if ($thisUnsealedness->no()) { + return new IsSuperTypeOfResult($type->isIterableAtLeastOnce()->negate(), []); + } + // $this is unsealed with no known keys — fall through to extras/unsealed-part checks below } $results = []; foreach ($this->keyTypes as $i => $keyType) { $hasOffset = $type->hasOffsetValueType($keyType); + if ($bothDefinite && $hasOffset->no() && $typeUnsealedness->yes()) { + [$typeUnsealedKey] = $type->unsealed; + if (!$typeUnsealedKey->isSuperTypeOf($keyType)->no()) { + $hasOffset = TrinaryLogic::createMaybe(); + } + } if ($hasOffset->no()) { if (!$this->isOptionalKey($i)) { return IsSuperTypeOfResult::createNo(); @@ -411,13 +694,69 @@ public function isSuperTypeOf(Type $type): IsSuperTypeOfResult $results[] = IsSuperTypeOfResult::createMaybe(); } - $isValueSuperType = $this->valueTypes[$i]->isSuperTypeOf($type->getOffsetValueType($keyType)); + $otherValueType = $type->getOffsetValueType($keyType); + if ($otherValueType instanceof ErrorType && $bothDefinite && $typeUnsealedness->yes()) { + [, $typeUnsealedValue] = $type->unsealed; + $otherValueType = $typeUnsealedValue; + } + $isValueSuperType = $this->valueTypes[$i]->isSuperTypeOf($otherValueType); if ($isValueSuperType->no()) { return $isValueSuperType->decorateReasons(static fn (string $reason) => sprintf('Offset %s: %s', $keyType->describe(VerbosityLevel::value()), $reason)); } $results[] = $isValueSuperType; } + if ($bothDefinite) { + $thisKeyValues = []; + foreach ($this->keyTypes as $thisKeyType) { + $thisKeyValues[$thisKeyType->getValue()] = true; + } + + foreach ($type->getKeyTypes() as $i => $typeKey) { + if (array_key_exists($typeKey->getValue(), $thisKeyValues)) { + continue; + } + + if ($thisUnsealedness->no()) { + if (!$type->isOptionalKey($i)) { + return IsSuperTypeOfResult::createNo(); + } + $results[] = IsSuperTypeOfResult::createMaybe(); + continue; + } + + [$thisUnsealedKey, $thisUnsealedValue] = $this->unsealed; + $keyCheck = $thisUnsealedKey->isSuperTypeOf($typeKey); + if ($keyCheck->no()) { + if ($type->isOptionalKey($i)) { + $results[] = IsSuperTypeOfResult::createMaybe(); + continue; + } + return IsSuperTypeOfResult::createNo(); + } + $valueCheck = $thisUnsealedValue->isSuperTypeOf($type->getValueTypes()[$i]); + if ($valueCheck->no()) { + if ($type->isOptionalKey($i)) { + $results[] = IsSuperTypeOfResult::createMaybe(); + continue; + } + return IsSuperTypeOfResult::createNo(); + } + $results[] = $keyCheck->and($valueCheck); + } + + if ($typeUnsealedness->yes()) { + if ($thisUnsealedness->no()) { + $results[] = IsSuperTypeOfResult::createMaybe(); + } else { + [$thisUnsealedKey, $thisUnsealedValue] = $this->unsealed; + [$typeUnsealedKey, $typeUnsealedValue] = $type->unsealed; + $results[] = $thisUnsealedKey->isSuperTypeOf($typeUnsealedKey); + $results[] = $thisUnsealedValue->isSuperTypeOf($typeUnsealedValue); + } + } + } + return IsSuperTypeOfResult::createYes()->and(...$results); } @@ -723,7 +1062,7 @@ public function getOffsetValueType(Type $offsetType): Type $matchingValueTypes[] = $this->valueTypes[$i]; } - if ($all) { + if ($all && !$this->isUnsealed()->yes()) { return $this->getIterableValueType(); } @@ -807,7 +1146,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 +1193,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 +1223,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 +1447,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) { @@ -1289,7 +1628,14 @@ public function isIterableAtLeastOnce(): TrinaryLogic { $keysCount = count($this->keyTypes); if ($keysCount === 0) { - return TrinaryLogic::createNo(); + if ($this->unsealed === null) { + return TrinaryLogic::createNo(); + } + [$unsealedKey] = $this->unsealed; + if ($unsealedKey instanceof NeverType && $unsealedKey->isExplicit()) { + return TrinaryLogic::createNo(); + } + return TrinaryLogic::createMaybe(); } $optionalKeysCount = count($this->optionalKeys); @@ -1304,11 +1650,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 +1775,7 @@ private function removeLastElements(int $length): self $nextAutoindexes, array_values($optionalKeys), $this->isList, + $this->unsealed, ); } @@ -1522,7 +1874,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 +1922,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 +1951,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 +1996,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 +2129,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,10 +2169,89 @@ 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 + { + if ($this->unsealed === null || $otherArray->unsealed === null) { + return $this->legacyIsKeysSupersetOf($otherArray); + } + + [$thisUnsealedKey, $thisUnsealedValue] = $this->unsealed; + [$otherUnsealedKey, $otherUnsealedValue] = $otherArray->unsealed; + $thisHasExtras = !($thisUnsealedKey instanceof NeverType && $thisUnsealedKey->isExplicit()); + $otherHasExtras = !($otherUnsealedKey instanceof NeverType && $otherUnsealedKey->isExplicit()); + + $otherHasRequiredKeys = false; + foreach ($otherArray->keyTypes as $j => $keyType) { + if ($otherArray->isOptionalKey($j)) { + continue; + } + $otherHasRequiredKeys = true; + break; + } + + // Sealed empty $other (no keys, no extras): absorbing it is lossless iff $this + // already accepts []. i.e., all of $this's known keys are optional. Otherwise + // merge would add [] as a new instance. + if (!$otherHasRequiredKeys && !$otherHasExtras && count($otherArray->keyTypes) === 0) { + foreach ($this->keyTypes as $i => $keyType) { + if (!$this->isOptionalKey($i)) { + return false; + } + } + return true; + } + + // With real unsealed extras on both sides that can absorb each other's + // required keys, merging is acceptable regardless of which keys overlap. + if ($thisHasExtras && $otherHasExtras) { + return true; + } + + // Asymmetric extras: one side has real extras that can absorb the other's keys. + if ($thisHasExtras) { + if ($this->legacyIsKeysSupersetOf($otherArray)) { + return true; + } + foreach ($otherArray->keyTypes as $j => $keyType) { + if ($otherArray->isOptionalKey($j)) { + continue; + } + if ($thisUnsealedKey->isSuperTypeOf($keyType)->no()) { + return false; + } + if ($thisUnsealedValue->isSuperTypeOf($otherArray->valueTypes[$j])->no()) { + return false; + } + } + return true; + } + + if ($otherHasExtras) { + if ($this->legacyIsKeysSupersetOf($otherArray)) { + return true; + } + foreach ($this->keyTypes as $i => $keyType) { + if ($this->isOptionalKey($i)) { + continue; + } + if ($otherUnsealedKey->isSuperTypeOf($keyType)->no()) { + return false; + } + if ($otherUnsealedValue->isSuperTypeOf($this->valueTypes[$i])->no()) { + return false; + } + } + return true; + } + + // Both sealed: fall back to the legacy key/value shape check. + return $this->legacyIsKeysSupersetOf($otherArray); + } + + private function legacyIsKeysSupersetOf(self $otherArray): bool { $keyTypesCount = count($this->keyTypes); $otherKeyTypesCount = count($otherArray->keyTypes); @@ -1853,6 +2311,110 @@ public function isKeysSupersetOf(self $otherArray): bool public function mergeWith(self $otherArray): self { // only call this after verifying isKeysSupersetOf, or if losing tagged unions is not an issue + if ($this->unsealed === null || $otherArray->unsealed === null) { + return $this->legacyMergeWith($otherArray); + } + + [$thisUnsealedKey, $thisUnsealedValue] = $this->unsealed; + [$otherUnsealedKey, $otherUnsealedValue] = $otherArray->unsealed; + + $mergedUnsealedKey = TypeCombinator::union($thisUnsealedKey, $otherUnsealedKey); + $mergedUnsealedValue = TypeCombinator::union($thisUnsealedValue, $otherUnsealedValue); + + $absorbIntoExtras = static function (Type $keyType, Type $valueType) use (&$mergedUnsealedKey, &$mergedUnsealedValue): void { + $mergedUnsealedKey = TypeCombinator::union($mergedUnsealedKey, $keyType); + $mergedUnsealedValue = TypeCombinator::union($mergedUnsealedValue, $valueType); + }; + + $canAbsorb = static function (Type $sideUnsealedKey, Type $sideUnsealedValue, Type $keyType, Type $valueType): bool { + if ($sideUnsealedKey instanceof NeverType && $sideUnsealedKey->isExplicit()) { + return false; + } + if ($sideUnsealedKey->isSuperTypeOf($keyType)->no()) { + return false; + } + if ($sideUnsealedValue->isSuperTypeOf($valueType)->no()) { + return false; + } + return true; + }; + + $keyTypes = []; + $valueTypes = []; + $optionalKeys = []; + $nextAutoIndexes = [0]; + + $otherKeyIndexMap = $otherArray->getKeyIndexMap(); + $processed = []; + + foreach ($this->keyTypes as $i => $keyType) { + $keyValue = $keyType->getValue(); + $processed[$keyValue] = true; + $valueType = $this->valueTypes[$i]; + + if (array_key_exists($keyValue, $otherKeyIndexMap)) { + $j = $otherKeyIndexMap[$keyValue]; + $otherValueType = $otherArray->valueTypes[$j]; + $mergedValue = TypeCombinator::union($valueType, $otherValueType); + $optional = $this->isOptionalKey($i) || $otherArray->isOptionalKey($j); + + $keyTypes[] = $keyType; + $valueTypes[] = $mergedValue; + if ($optional) { + $optionalKeys[] = count($keyTypes) - 1; + } + continue; + } + + if ($canAbsorb($otherUnsealedKey, $otherUnsealedValue, $keyType, $valueType)) { + $absorbIntoExtras($keyType, $valueType); + continue; + } + + $keyTypes[] = $keyType; + $valueTypes[] = $valueType; + $optionalKeys[] = count($keyTypes) - 1; + } + + foreach ($otherArray->keyTypes as $j => $keyType) { + $keyValue = $keyType->getValue(); + if (array_key_exists($keyValue, $processed)) { + continue; + } + $valueType = $otherArray->valueTypes[$j]; + + if ($canAbsorb($thisUnsealedKey, $thisUnsealedValue, $keyType, $valueType)) { + $absorbIntoExtras($keyType, $valueType); + continue; + } + + $keyTypes[] = $keyType; + $valueTypes[] = $valueType; + $optionalKeys[] = count($keyTypes) - 1; + } + + $resultUnsealed = [$mergedUnsealedKey, $mergedUnsealedValue]; + + $nextAutoIndexes = array_values(array_unique(array_merge($this->nextAutoIndexes, $otherArray->nextAutoIndexes))); + sort($nextAutoIndexes); + + $optionalKeys = array_values(array_unique($optionalKeys)); + + /** @var list $keyTypes */ + $keyTypes = $keyTypes; + + return $this->recreate( + $keyTypes, + $valueTypes, + $nextAutoIndexes, + $optionalKeys, + $this->isList->and($otherArray->isList), + $resultUnsealed, + ); + } + + private function legacyMergeWith(self $otherArray): self + { $valueTypes = $this->valueTypes; $optionalKeys = $this->optionalKeys; foreach ($this->keyTypes as $i => $keyType) { @@ -1873,7 +2435,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); } /** @@ -1929,7 +2491,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 +2510,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 +2555,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 f4e96ad8894..0da6a01d995 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,19 @@ 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()); + $unsealed = null; + if (BleedingEdgeToggle::isBleedingEdge()) { + $never = new NeverType(true); + $unsealed = [$never, $never]; + } + return new self([], [], [0], [], TrinaryLogic::createYes(), $unsealed); } public static function createFromConstantArray(ConstantArrayType $startArrayType): self @@ -70,6 +79,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 +89,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 +382,20 @@ public function getArray(): Type { $keyTypesCount = count($this->keyTypes); if ($keyTypesCount === 0) { - return new ConstantArrayType([], []); + if ($this->unsealed !== null) { + [$unsealedKey, $unsealedValue] = $this->unsealed; + $isExplicitNever = $unsealedKey instanceof NeverType && $unsealedKey->isExplicit(); + if (!$isExplicitNever) { + return new ArrayType($unsealedKey, $unsealedValue); + } + } + 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 42dba2bb1c1..934be1fe774 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/src/Type/TypeCombinator.php b/src/Type/TypeCombinator.php index 4295899c114..af5b74d9714 100644 --- a/src/Type/TypeCombinator.php +++ b/src/Type/TypeCombinator.php @@ -31,6 +31,7 @@ use function array_filter; use function array_key_exists; use function array_key_first; +use function array_keys; use function array_merge; use function array_slice; use function array_splice; @@ -917,7 +918,7 @@ private static function processArrayTypes(array $arrayTypes): array $filledArrays++; } - if ($generalArrayOccurred || !$isConstantArray) { + if (!$isConstantArray) { foreach ($arrayType->getArrays() as $type) { $keyTypesForGeneralArray[] = $type->getIterableKeyType(); $valueTypesForGeneralArray[] = $type->getItemType(); @@ -1155,7 +1156,14 @@ private static function reduceArrays(array $constantArrays, bool $preserveTagged } if ($emptyArray !== null) { - $newArrays[] = $emptyArray; + if ($preserveTaggedUnions && $emptyArray instanceof ConstantArrayType) { + // Let the empty array participate in merging — the passes below will absorb + // it into any array that already accepts [] (all-optional keys, compatible + // unsealed extras). If no such array exists, it remains as-is in the result. + $arraysToProcess[] = $emptyArray; + } else { + $newArrays[] = $emptyArray; + } } $arraysToProcessPerKey = []; @@ -1240,6 +1248,100 @@ private static function reduceArrays(array $constantArrays, bool $preserveTagged } } + // Second pass: merge pairs that the eligibleCombinations loop above couldn't touch. + // That loop only considers pairs sharing at least one known key, so it never fires + // for e.g. `array{}` ∪ `array{a?: 1}` (disjoint, one empty) or for two + // unsealed-extras arrays with disjoint required keys. Both collapse losslessly if + // one side's extras or optional-key shape can absorb the other side's content. + // + // Performance: two sealed, non-empty, no-extras arrays with disjoint keys cannot + // merge losslessly (legacyIsKeysSupersetOf returns false immediately on the first + // missing key). Skip those pairs via a candidate flag to avoid an O(n²) scan that + // dominated analyse time on files accumulating many sealed ConstantArrayType + // variants (bug-7581 / bug-8146a). A pair is worth checking only if at least one + // side is (a) empty, or (b) has real unsealed extras, or (c) has optional keys — + // the last case covers the narrowing shape used by e.g. array_key_exists checks + // over large optional-key shapes (bug-14032). + $indices = array_keys($arraysToProcess); + $indicesCount = count($indices); + if ($indicesCount > 1) { + $candidateFlags = []; + foreach ($indices as $idx) { + $arr = $arraysToProcess[$idx]; + $unsealed = $arr->getUnsealedTypes(); + if ($unsealed === null) { + $candidateFlags[$idx] = false; + continue; + } + [$unsealedKey] = $unsealed; + $hasRealExtras = !($unsealedKey instanceof NeverType && $unsealedKey->isExplicit()); + if ($hasRealExtras) { + $candidateFlags[$idx] = true; + continue; + } + $keyTypesCount = count($arr->getKeyTypes()); + if ($keyTypesCount === 0) { + $candidateFlags[$idx] = true; + continue; + } + $hasOptional = count($arr->getOptionalKeys()) > 0; + $candidateFlags[$idx] = $hasOptional; + } + + for ($ii = 0; $ii < $indicesCount - 1; $ii++) { + $i = $indices[$ii]; + if (!array_key_exists($i, $arraysToProcess)) { + continue; + } + if ($arraysToProcess[$i]->getUnsealedTypes() === null) { + continue; + } + for ($jj = $ii + 1; $jj < $indicesCount; $jj++) { + $j = $indices[$jj]; + if (!array_key_exists($j, $arraysToProcess)) { + continue; + } + if (!$candidateFlags[$i] && !$candidateFlags[$j]) { + continue; + } + if ($arraysToProcess[$j]->getUnsealedTypes() === null) { + continue; + } + if ($arraysToProcess[$j]->isKeysSupersetOf($arraysToProcess[$i])) { + $arraysToProcess[$j] = $arraysToProcess[$j]->mergeWith($arraysToProcess[$i]); + unset($arraysToProcess[$i]); + continue 2; + } + if (!$arraysToProcess[$i]->isKeysSupersetOf($arraysToProcess[$j])) { + continue; + } + + $arraysToProcess[$i] = $arraysToProcess[$i]->mergeWith($arraysToProcess[$j]); + unset($arraysToProcess[$j]); + } + } + } + + // Final pass: if merging left us with a ConstantArrayType that has no known keys + // but has real unsealed extras, collapse it to a plain ArrayType (mirrors the same + // logic in ConstantArrayTypeBuilder::getArray — but applies to results produced by + // ConstantArrayType::mergeWith, which doesn't go through the builder). + foreach ($arraysToProcess as $idx => $arr) { + if (count($arr->getKeyTypes()) !== 0) { + continue; + } + $unsealed = $arr->getUnsealedTypes(); + if ($unsealed === null) { + continue; + } + [$unsealedKey, $unsealedValue] = $unsealed; + if ($unsealedKey instanceof NeverType && $unsealedKey->isExplicit()) { + continue; + } + $newArrays[] = new ArrayType($unsealedKey, $unsealedValue); + unset($arraysToProcess[$idx]); + } + return array_merge($newArrays, $arraysToProcess); } @@ -1471,6 +1573,7 @@ public static function intersect(Type ...$types): Type && $types[$j] instanceof NonEmptyArrayType && (count($types[$i]->getKeyTypes()) === 1 || $types[$i]->isList()->yes()) && $types[$i]->isOptionalKey(0) + && !$types[$i]->isUnsealed()->yes() ) { $types[$i] = $types[$i]->makeOffsetRequired($types[$i]->getKeyTypes()[0]); array_splice($types, $j--, 1); @@ -1483,6 +1586,7 @@ public static function intersect(Type ...$types): Type && $types[$i] instanceof NonEmptyArrayType && (count($types[$j]->getKeyTypes()) === 1 || $types[$j]->isList()->yes()) && $types[$j]->isOptionalKey(0) + && !$types[$j]->isUnsealed()->yes() ) { $types[$j] = $types[$j]->makeOffsetRequired($types[$j]->getKeyTypes()[0]); array_splice($types, $i--, 1); @@ -1543,42 +1647,46 @@ public static function intersect(Type ...$types): Type continue 2; } - if ($types[$i] instanceof ConstantArrayType && ($types[$j] instanceof ArrayType || $types[$j] instanceof ConstantArrayType)) { - $newArray = ConstantArrayTypeBuilder::createEmpty(); - $valueTypes = $types[$i]->getValueTypes(); - foreach ($types[$i]->getKeyTypes() as $k => $keyType) { - $hasOffset = $types[$j]->hasOffsetValueType($keyType); - if ($hasOffset->no()) { - continue; + $constArrayIsI = $types[$i] instanceof ConstantArrayType && ($types[$j] instanceof ArrayType || $types[$j] instanceof ConstantArrayType); + $constArrayIsJ = $types[$j] instanceof ConstantArrayType && ($types[$i] instanceof ArrayType || $types[$i] instanceof ConstantArrayType); + if ($constArrayIsI || $constArrayIsJ) { + $constArray = $constArrayIsI ? $types[$i] : $types[$j]; + $otherArray = $constArrayIsI ? $types[$j] : $types[$i]; + + if ( + $otherArray instanceof ConstantArrayType + && !$constArray->isUnsealed()->maybe() + && !$otherArray->isUnsealed()->maybe() + ) { + $merged = self::intersectDefiniteConstantArrays($constArray, $otherArray); + if ($merged instanceof NeverType) { + return $merged; + } + $newArrayType = $merged; + } else { + $newArray = ConstantArrayTypeBuilder::createEmpty(); + $valueTypes = $constArray->getValueTypes(); + foreach ($constArray->getKeyTypes() as $k => $keyType) { + $hasOffset = $otherArray->hasOffsetValueType($keyType); + if ($hasOffset->no()) { + continue; + } + $newArray->setOffsetValueType( + self::intersect($keyType, $otherArray->getIterableKeyType()), + self::intersect($valueTypes[$k], $otherArray->getOffsetValueType($keyType)), + $constArray->isOptionalKey($k) && !$hasOffset->yes(), + ); } - $newArray->setOffsetValueType( - self::intersect($keyType, $types[$j]->getIterableKeyType()), - self::intersect($valueTypes[$k], $types[$j]->getOffsetValueType($keyType)), - $types[$i]->isOptionalKey($k) && !$hasOffset->yes(), - ); + $newArrayType = $newArray->getArray(); } - $types[$i] = $newArray->getArray(); - array_splice($types, $j--, 1); - $typesCount--; - continue 2; - } - if ($types[$j] instanceof ConstantArrayType && ($types[$i] instanceof ArrayType || $types[$i] instanceof ConstantArrayType)) { - $newArray = ConstantArrayTypeBuilder::createEmpty(); - $valueTypes = $types[$j]->getValueTypes(); - foreach ($types[$j]->getKeyTypes() as $k => $keyType) { - $hasOffset = $types[$i]->hasOffsetValueType($keyType); - if ($hasOffset->no()) { - continue; - } - $newArray->setOffsetValueType( - self::intersect($keyType, $types[$i]->getIterableKeyType()), - self::intersect($valueTypes[$k], $types[$i]->getOffsetValueType($keyType)), - $types[$j]->isOptionalKey($k) && !$hasOffset->yes(), - ); + if ($constArrayIsI) { + $types[$i] = $newArrayType; + array_splice($types, $j--, 1); + } else { + $types[$j] = $newArrayType; + array_splice($types, $i--, 1); } - $types[$j] = $newArray->getArray(); - array_splice($types, $i--, 1); $typesCount--; continue 2; } @@ -1643,6 +1751,123 @@ public static function intersect(Type ...$types): Type return new IntersectionType($types); } + private static function intersectDefiniteConstantArrays(ConstantArrayType $a, ConstantArrayType $b): Type + { + $aSealed = $a->isUnsealed()->no(); + $bSealed = $b->isUnsealed()->no(); + $bothUnsealed = !$aSealed && !$bSealed && $a->getUnsealedTypes() !== null && $b->getUnsealedTypes() !== null; + + $aKeyByValue = []; + foreach ($a->getKeyTypes() as $k => $keyType) { + $aKeyByValue[$keyType->getValue()] = $k; + } + $bKeyByValue = []; + foreach ($b->getKeyTypes() as $k => $keyType) { + $bKeyByValue[$keyType->getValue()] = $k; + } + + if ($aSealed && $bSealed) { + foreach ($aKeyByValue as $keyValue => $k) { + if (!$a->isOptionalKey($k) && !array_key_exists($keyValue, $bKeyByValue)) { + return new NeverType(); + } + } + foreach ($bKeyByValue as $keyValue => $k) { + if (!$b->isOptionalKey($k) && !array_key_exists($keyValue, $aKeyByValue)) { + return new NeverType(); + } + } + } + + $newArray = ConstantArrayTypeBuilder::createEmpty(); + + if ($bothUnsealed) { + $aUnsealed = $a->getUnsealedTypes(); + $bUnsealed = $b->getUnsealedTypes(); + $unsealedKey = self::intersect($aUnsealed[0], $bUnsealed[0]); + $unsealedValue = self::intersect($aUnsealed[1], $bUnsealed[1]); + if ($unsealedKey instanceof NeverType || $unsealedValue instanceof NeverType) { + return new NeverType(); + } + $newArray->makeUnsealed($unsealedKey, $unsealedValue); + } else { + $never = new NeverType(true); + $newArray->makeUnsealed($never, $never); + } + + $resolveOtherValue = static function (ConstantArrayType $other, Type $keyType): ?Type { + if ($other->hasOffsetValueType($keyType)->yes()) { + return $other->getOffsetValueType($keyType); + } + $otherUnsealed = $other->getUnsealedTypes(); + if ($otherUnsealed === null) { + return null; + } + [$unsealedKey, $unsealedValue] = $otherUnsealed; + if ($unsealedKey instanceof NeverType && $unsealedKey->isExplicit()) { + return null; + } + if ($unsealedKey->isSuperTypeOf($keyType)->no()) { + return null; + } + return $unsealedValue; + }; + + $keysToProcess = []; + foreach ($aKeyByValue as $keyValue => $k) { + $keysToProcess[$keyValue] = [$k, $bKeyByValue[$keyValue] ?? null]; + } + foreach ($bKeyByValue as $keyValue => $k) { + if (array_key_exists($keyValue, $keysToProcess)) { + continue; + } + + $keysToProcess[$keyValue] = [null, $k]; + } + + foreach ($keysToProcess as [$aIdx, $bIdx]) { + if ($aIdx !== null && $bIdx !== null) { + $keyType = $a->getKeyTypes()[$aIdx]; + $value = self::intersect($a->getValueTypes()[$aIdx], $b->getValueTypes()[$bIdx]); + $optional = $a->isOptionalKey($aIdx) && $b->isOptionalKey($bIdx); + } elseif ($aIdx !== null) { + $keyType = $a->getKeyTypes()[$aIdx]; + $aValue = $a->getValueTypes()[$aIdx]; + $bValue = $resolveOtherValue($b, $keyType); + if ($bValue === null) { + if ($a->isOptionalKey($aIdx)) { + continue; + } + return new NeverType(); + } + $value = self::intersect($aValue, $bValue); + $optional = $a->isOptionalKey($aIdx); + } else { + $keyType = $b->getKeyTypes()[$bIdx]; + $bValue = $b->getValueTypes()[$bIdx]; + $aValue = $resolveOtherValue($a, $keyType); + if ($aValue === null) { + if ($b->isOptionalKey($bIdx)) { + continue; + } + return new NeverType(); + } + $value = self::intersect($aValue, $bValue); + $optional = $b->isOptionalKey($bIdx); + } + + if ($value instanceof NeverType) { + if ($optional) { + continue; + } + return new NeverType(); + } + $newArray->setOffsetValueType($keyType, $value, $optional); + } + + return $newArray->getArray(); + } + /** * Merge two IntersectionTypes that have the same structure but differ * in HasOffsetValueType value types (matched by offset key). 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 163a996bd25..89e1be359b1 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/array-append-count.php b/tests/PHPStan/Analyser/nsrt/array-append-count.php new file mode 100644 index 00000000000..d39ed604943 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/array-append-count.php @@ -0,0 +1,32 @@ + 0) { + $types[] = 'x'; + } elseif ($a < 0) { + $types[] = 'y'; + } + if ($b > 0) { + $types[] = 'z'; + } + if ($c === 1) { + $types[] = 'p'; + } elseif ($c === 2) { + $types[] = 'q'; + } + + // $types could have 1 (just 'base'), or 2/3/4 depending on which + // elseif arms fire. count should at least allow 1. + assertType('int<1, 4>', count($types)); + + if (count($types) === 1) { + // reachable: all three ifs miss — $types stays as ['base']. + assertType("array{'base'}", $types); + } +} diff --git a/tests/PHPStan/Analyser/nsrt/bug-12355.php b/tests/PHPStan/Analyser/nsrt/bug-12355.php index 4b7ee866cdc..ed67cce3e12 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/bug-14032.php b/tests/PHPStan/Analyser/nsrt/bug-14032.php new file mode 100644 index 00000000000..ceca2a00494 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-14032.php @@ -0,0 +1,47 @@ + 6]; } - assertType('array{}|array{b?: 6, a?: 5}', $a + $b); + assertType('array{b?: 6, a?: 5}', $a + $b); } } diff --git a/tests/PHPStan/Analyser/nsrt/bug-9985.php b/tests/PHPStan/Analyser/nsrt/bug-9985.php index 09a7ad92eac..9f1e979c014 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-9985.php +++ b/tests/PHPStan/Analyser/nsrt/bug-9985.php @@ -17,7 +17,7 @@ function (): void { $warnings['c'] = true; } - assertType('array{}|array{a?: true, b: true}|array{a?: true, c?: true}', $warnings); + assertType('array{a?: true, b: true}|array{a?: true, c?: true}', $warnings); if (!empty($warnings)) { assertType('array{a?: true, b: true}|non-empty-array{a?: true, c?: true}', $warnings); diff --git a/tests/PHPStan/Analyser/nsrt/conditional-array-key-exists.php b/tests/PHPStan/Analyser/nsrt/conditional-array-key-exists.php new file mode 100644 index 00000000000..0f88f37807d --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/conditional-array-key-exists.php @@ -0,0 +1,25 @@ + $options */ +function apply(array $options): void +{ + $range = []; + if (isset($options['min_range'])) { + $range['min'] = 1; + } + if (isset($options['max_range'])) { + $range['max'] = 2; + } + + // $range can be {}, {min}, {max}, or {min, max} + assertType('array{min?: 1, max?: 2}', $range); + + if (array_key_exists('min', $range) || array_key_exists('max', $range)) { + // reachable: either key could be set. + assertType('non-empty-array{min?: 1, max?: 2}', $range); + } +} diff --git a/tests/PHPStan/Analyser/nsrt/generalize-scope-recursive.php b/tests/PHPStan/Analyser/nsrt/generalize-scope-recursive.php index d4a82c8dcb4..8d13c5526fe 100644 --- a/tests/PHPStan/Analyser/nsrt/generalize-scope-recursive.php +++ b/tests/PHPStan/Analyser/nsrt/generalize-scope-recursive.php @@ -16,7 +16,7 @@ public function doFoo(array $array, array $values) } } - assertType('array{}|array{foo?: array}', $data); + assertType('array{foo?: array}', $data); } /** diff --git a/tests/PHPStan/Analyser/nsrt/has-offset-type-bug.php b/tests/PHPStan/Analyser/nsrt/has-offset-type-bug.php index 09955bde2ea..525fc619c8d 100644 --- a/tests/PHPStan/Analyser/nsrt/has-offset-type-bug.php +++ b/tests/PHPStan/Analyser/nsrt/has-offset-type-bug.php @@ -63,14 +63,14 @@ public function doBar(array $result): void */ public function testIsset($range): void { - assertType("array{}|array{min?: bool|float|int|string|null, max?: bool|float|int|string|null}", $range); + assertType("array{min?: bool|float|int|string|null, max?: bool|float|int|string|null}", $range); if (isset($range['min']) || isset($range['max'])) { assertType("non-empty-array{min?: bool|float|int|string|null, max?: bool|float|int|string|null}", $range); } else { - assertType("array{}|array{min?: bool|float|int|string|null, max?: bool|float|int|string|null}", $range); + assertType("array{min?: bool|float|int|string|null, max?: bool|float|int|string|null}", $range); } - assertType("array{}|array{min?: bool|float|int|string|null, max?: bool|float|int|string|null}", $range); + assertType("array{min?: bool|float|int|string|null, max?: bool|float|int|string|null}", $range); } } diff --git a/tests/PHPStan/Analyser/nsrt/list-count.php b/tests/PHPStan/Analyser/nsrt/list-count.php index 24bfc6fa63f..d9bd37e9b52 100644 --- a/tests/PHPStan/Analyser/nsrt/list-count.php +++ b/tests/PHPStan/Analyser/nsrt/list-count.php @@ -291,7 +291,7 @@ protected function testOptionalKeysInListsOfTaggedUnion($row): void } if (count($row) === 1) { - assertType('array{0: int, 1?: string|null}|array{string}', $row); + assertType('array{int}|array{string}', $row); } else { assertType('array{int, string|null}', $row); } @@ -299,7 +299,7 @@ protected function testOptionalKeysInListsOfTaggedUnion($row): void if (count($row) === 2) { assertType('array{int, string|null}', $row); } else { - assertType('array{0: int, 1?: string|null}|array{string}', $row); + assertType('array{int}|array{string}', $row); } if (count($row) === 3) { @@ -354,7 +354,7 @@ protected function testOptionalKeysInUnionListWithIntRange($row, $listRow, $twoO if (count($row) >= $twoOrThree) { assertType('list{0: int, 1: string|null, 2?: int|null, 3?: float|null}', $row); } else { - assertType('array{string}|list{0: int, 1?: string|null, 2?: int|null, 3?: float|null}', $row); + assertType('array{0: int, 1?: string|null}|array{string}', $row); } if (count($row) >= $tenOrEleven) { @@ -372,7 +372,7 @@ protected function testOptionalKeysInUnionListWithIntRange($row, $listRow, $twoO if (count($row) >= $maxThree) { assertType('array{string}|list{0: int, 1?: string|null, 2?: int|null, 3?: float|null}', $row); } else { - assertType('array{string}|list{0: int, 1?: string|null, 2?: int|null, 3?: float|null}', $row); + assertType('array{0: int, 1?: string|null}|array{string}', $row); } if (count($row) >= $threeOrMoreInRangeLimit) { diff --git a/tests/PHPStan/Analyser/nsrt/list-shapes.php b/tests/PHPStan/Analyser/nsrt/list-shapes.php index 62313ca8e77..8ea8b4c9cea 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/narrow-tagged-union.php b/tests/PHPStan/Analyser/nsrt/narrow-tagged-union.php index 8ecf3438e77..b8bdfe121c8 100644 --- a/tests/PHPStan/Analyser/nsrt/narrow-tagged-union.php +++ b/tests/PHPStan/Analyser/nsrt/narrow-tagged-union.php @@ -101,7 +101,7 @@ public function arrayIntRangeSize(): void } if (count($x) === 1) { - assertType("array{'xy'}|array{0: 'ab', 1?: 'xy'}", $x); + assertType("array{'ab'}|array{'xy'}", $x); } else { assertType("array{}|array{0: 'ab', 1?: 'xy'}", $x); } 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 00000000000..0fa24cd435d --- /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/Comparison/data/bug-7898.php b/tests/PHPStan/Rules/Comparison/data/bug-7898.php index 16e4b813ce4..6fb89d3bd2b 100644 --- a/tests/PHPStan/Rules/Comparison/data/bug-7898.php +++ b/tests/PHPStan/Rules/Comparison/data/bug-7898.php @@ -175,7 +175,7 @@ public function getCountryCode(): string public function getHasDaycationTaxesAndFees(): bool { assertType("array{US: array{bar: array{sales_tax: array{type: 'rate', unit: 'per-room-per-night'}, city_tax: array{type: 'both', unit: 'per-room-per-night'}, resort_fee: array{type: 'both', unit: 'per-room-per-night'}, additional_tax_or_fee: array{type: 'both', unit: 'per-room-per-night'}}, foo: array{tax: array{type: 'rate', unit: 'per-room-per-night'}}}, CA: array{bar: array{goods_and_services_tax: array{type: 'rate', unit: 'per-room-per-night'}, provincial_sales_tax: array{type: 'rate', unit: 'per-room-per-night'}, harmonized_sales_tax: array{type: 'rate', unit: 'per-room-per-night'}, municipal_and_regional_district_tax: array{type: 'rate', unit: 'per-room-per-night'}, additional_tax_or_fee: array{type: 'both', unit: 'per-room-per-night'}}}, SG: array{bar: array{service_charge: array{type: 'rate', unit: 'per-room-per-night'}, tax: array{type: 'rate', unit: 'per-room-per-night'}}}, TH: array{bar: array{service_charge: array{type: 'rate', unit: 'per-room-per-night'}, tax: array{type: 'rate', unit: 'per-room-per-night'}, city_tax: array{type: 'rate', unit: 'per-room-per-night'}}}, AE: array{bar: array{vat: array{type: 'rate', unit: 'per-room-per-night'}, service_charge: array{type: 'rate', unit: 'per-room-per-night'}, municipality_fee: array{type: 'rate', unit: 'per-room-per-night'}, tourism_fee: array{type: 'both', unit: 'per-room-per-night'}, destination_fee: array{type: 'both', unit: 'per-room-per-night'}}}, BH: array{bar: array{vat: array{type: 'rate', unit: 'per-room-per-night'}, service_charge: array{type: 'rate', unit: 'per-room-per-night'}, city_tax: array{type: 'rate', unit: 'per-room-per-night'}}}, HK: array{bar: array{service_charge: array{type: 'rate', unit: 'per-room-per-night'}, tax: array{type: 'rate', unit: 'per-room-per-night'}}}, ES: array{bar: array{city_tax: array{type: 'both', unit: 'per-room-per-night'}}}}", FooEnum::APPLICABLE_TAX_AND_FEES_BY_TYPE); - assertType("array{bar: array{city_tax: array{type: 'both', unit: 'per-room-per-night'}}|array{sales_tax: array{type: 'rate', unit: 'per-room-per-night'}, city_tax: array{type: 'both', unit: 'per-room-per-night'}, resort_fee: array{type: 'both', unit: 'per-room-per-night'}, additional_tax_or_fee: array{type: 'both', unit: 'per-room-per-night'}}, foo?: array{tax: array{type: 'rate', unit: 'per-room-per-night'}}}|array{bar: array{goods_and_services_tax: array{type: 'rate', unit: 'per-room-per-night'}, provincial_sales_tax: array{type: 'rate', unit: 'per-room-per-night'}, harmonized_sales_tax: array{type: 'rate', unit: 'per-room-per-night'}, municipal_and_regional_district_tax: array{type: 'rate', unit: 'per-room-per-night'}, additional_tax_or_fee: array{type: 'both', unit: 'per-room-per-night'}}}|array{bar: array{service_charge: array{type: 'rate', unit: 'per-room-per-night'}, tax: array{type: 'rate', unit: 'per-room-per-night'}, city_tax?: array{type: 'rate', unit: 'per-room-per-night'}}}|array{bar: array{vat: array{type: 'rate', unit: 'per-room-per-night'}, service_charge: array{type: 'rate', unit: 'per-room-per-night'}, city_tax: array{type: 'rate', unit: 'per-room-per-night'}}}|array{bar: array{vat: array{type: 'rate', unit: 'per-room-per-night'}, service_charge: array{type: 'rate', unit: 'per-room-per-night'}, municipality_fee: array{type: 'rate', unit: 'per-room-per-night'}, tourism_fee: array{type: 'both', unit: 'per-room-per-night'}, destination_fee: array{type: 'both', unit: 'per-room-per-night'}}}", FooEnum::APPLICABLE_TAX_AND_FEES_BY_TYPE[$this->getCountryCode()]); + assertType("array{bar: array{city_tax: array{type: 'both', unit: 'per-room-per-night'}}}|array{bar: array{goods_and_services_tax: array{type: 'rate', unit: 'per-room-per-night'}, provincial_sales_tax: array{type: 'rate', unit: 'per-room-per-night'}, harmonized_sales_tax: array{type: 'rate', unit: 'per-room-per-night'}, municipal_and_regional_district_tax: array{type: 'rate', unit: 'per-room-per-night'}, additional_tax_or_fee: array{type: 'both', unit: 'per-room-per-night'}}}|array{bar: array{sales_tax: array{type: 'rate', unit: 'per-room-per-night'}, city_tax: array{type: 'both', unit: 'per-room-per-night'}, resort_fee: array{type: 'both', unit: 'per-room-per-night'}, additional_tax_or_fee: array{type: 'both', unit: 'per-room-per-night'}}, foo: array{tax: array{type: 'rate', unit: 'per-room-per-night'}}}|array{bar: array{service_charge: array{type: 'rate', unit: 'per-room-per-night'}, tax: array{type: 'rate', unit: 'per-room-per-night'}, city_tax?: array{type: 'rate', unit: 'per-room-per-night'}}}|array{bar: array{vat: array{type: 'rate', unit: 'per-room-per-night'}, service_charge: array{type: 'rate', unit: 'per-room-per-night'}, city_tax: array{type: 'rate', unit: 'per-room-per-night'}}}|array{bar: array{vat: array{type: 'rate', unit: 'per-room-per-night'}, service_charge: array{type: 'rate', unit: 'per-room-per-night'}, municipality_fee: array{type: 'rate', unit: 'per-room-per-night'}, tourism_fee: array{type: 'both', unit: 'per-room-per-night'}, destination_fee: array{type: 'both', unit: 'per-room-per-night'}}}", FooEnum::APPLICABLE_TAX_AND_FEES_BY_TYPE[$this->getCountryCode()]); return array_key_exists(FooEnum::FOO_TYPE, FooEnum::APPLICABLE_TAX_AND_FEES_BY_TYPE[$this->getCountryCode()]); } diff --git a/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php b/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php index 7c9eb98ffc6..8756463669b 100644 --- a/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php +++ b/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php @@ -2935,4 +2935,15 @@ public function testBug13643(): void $this->analyse([__DIR__ . '/data/bug-13643.php'], []); } + public function testBug11494(): void + { + $this->analyse([__DIR__ . '/data/bug-11494.php'], [ + [ + 'Parameter #1 $a of function Bug11494\test expects array{long: string, details: string}|array{short: string}, array{short: \'thing\', extra: \'other\'} given.', + 18, + "• Type #1 from the union: Sealed array shape does not accept array with extra key 'extra'.\n• Type #2 from the union: Sealed array shape does not accept array with extra key 'extra'.", + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Functions/ReturnTypeRuleTest.php b/tests/PHPStan/Rules/Functions/ReturnTypeRuleTest.php index 0c23f0e6b0a..b0dffa21adf 100644 --- a/tests/PHPStan/Rules/Functions/ReturnTypeRuleTest.php +++ b/tests/PHPStan/Rules/Functions/ReturnTypeRuleTest.php @@ -431,4 +431,17 @@ public function testBug13000(): void $this->analyse([__DIR__ . '/data/bug-13000.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-11494.php b/tests/PHPStan/Rules/Functions/data/bug-11494.php new file mode 100644 index 00000000000..61f276b3f95 --- /dev/null +++ b/tests/PHPStan/Rules/Functions/data/bug-11494.php @@ -0,0 +1,18 @@ + 'thing', 'extra' => 'other']); 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 00000000000..04270b99975 --- /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/Rules/Methods/ReturnTypeRuleTest.php b/tests/PHPStan/Rules/Methods/ReturnTypeRuleTest.php index efbb967e55f..a0decba4e17 100644 --- a/tests/PHPStan/Rules/Methods/ReturnTypeRuleTest.php +++ b/tests/PHPStan/Rules/Methods/ReturnTypeRuleTest.php @@ -1336,4 +1336,9 @@ public function testBug12653(): void $this->analyse([__DIR__ . '/data/bug-12653.php'], []); } + #[RequiresPhp('>= 8.2.0')] + public function testBug12110(): void + { + $this->analyse([__DIR__ . '/data/bug-12110.php'], []); + } } diff --git a/tests/PHPStan/Rules/Methods/data/bug-12110.php b/tests/PHPStan/Rules/Methods/data/bug-12110.php new file mode 100644 index 00000000000..fbd1e2f8c81 --- /dev/null +++ b/tests/PHPStan/Rules/Methods/data/bug-12110.php @@ -0,0 +1,670 @@ += 8.2 + +namespace Bug12110; + +class OwnerModel {}; +class PermissionsModel {}; + +final readonly class TemplateRepositoryModel implements \JsonSerializable +{ + public function __construct( + /** + * @var null|int + */ + public null|int $id = null, + /** + * @var null|string + */ + public null|string $node_id = null, + /** + * @var null|string + */ + public null|string $name = null, + /** + * @var null|string + */ + public null|string $full_name = null, + /** + * @var null|OwnerModel + */ + public null|OwnerModel $owner = null, + /** + * @var null|bool + */ + public null|bool $private = null, + /** + * @var null|string + */ + public null|string $html_url = null, + /** + * @var null|string + */ + public null|string $description = null, + /** + * @var null|bool + */ + public null|bool $fork = null, + /** + * @var null|string + */ + public null|string $url = null, + /** + * @var null|string + */ + public null|string $archive_url = null, + /** + * @var null|string + */ + public null|string $assignees_url = null, + /** + * @var null|string + */ + public null|string $blobs_url = null, + /** + * @var null|string + */ + public null|string $branches_url = null, + /** + * @var null|string + */ + public null|string $collaborators_url = null, + /** + * @var null|string + */ + public null|string $comments_url = null, + /** + * @var null|string + */ + public null|string $commits_url = null, + /** + * @var null|string + */ + public null|string $compare_url = null, + /** + * @var null|string + */ + public null|string $contents_url = null, + /** + * @var null|string + */ + public null|string $contributors_url = null, + /** + * @var null|string + */ + public null|string $deployments_url = null, + /** + * @var null|string + */ + public null|string $downloads_url = null, + /** + * @var null|string + */ + public null|string $events_url = null, + /** + * @var null|string + */ + public null|string $forks_url = null, + /** + * @var null|string + */ + public null|string $git_commits_url = null, + /** + * @var null|string + */ + public null|string $git_refs_url = null, + /** + * @var null|string + */ + public null|string $git_tags_url = null, + /** + * @var null|string + */ + public null|string $git_url = null, + /** + * @var null|string + */ + public null|string $issue_comment_url = null, + /** + * @var null|string + */ + public null|string $issue_events_url = null, + /** + * @var null|string + */ + public null|string $issues_url = null, + /** + * @var null|string + */ + public null|string $keys_url = null, + /** + * @var null|string + */ + public null|string $labels_url = null, + /** + * @var null|string + */ + public null|string $languages_url = null, + /** + * @var null|string + */ + public null|string $merges_url = null, + /** + * @var null|string + */ + public null|string $milestones_url = null, + /** + * @var null|string + */ + public null|string $notifications_url = null, + /** + * @var null|string + */ + public null|string $pulls_url = null, + /** + * @var null|string + */ + public null|string $releases_url = null, + /** + * @var null|string + */ + public null|string $ssh_url = null, + /** + * @var null|string + */ + public null|string $stargazers_url = null, + /** + * @var null|string + */ + public null|string $statuses_url = null, + /** + * @var null|string + */ + public null|string $subscribers_url = null, + /** + * @var null|string + */ + public null|string $subscription_url = null, + /** + * @var null|string + */ + public null|string $tags_url = null, + /** + * @var null|string + */ + public null|string $teams_url = null, + /** + * @var null|string + */ + public null|string $trees_url = null, + /** + * @var null|string + */ + public null|string $clone_url = null, + /** + * @var null|string + */ + public null|string $mirror_url = null, + /** + * @var null|string + */ + public null|string $hooks_url = null, + /** + * @var null|string + */ + public null|string $svn_url = null, + /** + * @var null|string + */ + public null|string $homepage = null, + /** + * @var null|string + */ + public null|string $language = null, + /** + * @var null|int + */ + public null|int $forks_count = null, + /** + * @var null|int + */ + public null|int $stargazers_count = null, + /** + * @var null|int + */ + public null|int $watchers_count = null, + /** + * @var null|int + */ + public null|int $size = null, + /** + * @var null|string + */ + public null|string $default_branch = null, + /** + * @var null|int + */ + public null|int $open_issues_count = null, + /** + * @var null|bool + */ + public null|bool $is_template = null, + /** + * @var null|list + */ + public null|array $topics = null, + /** + * @var null|bool + */ + public null|bool $has_issues = null, + /** + * @var null|bool + */ + public null|bool $has_projects = null, + /** + * @var null|bool + */ + public null|bool $has_wiki = null, + /** + * @var null|bool + */ + public null|bool $has_pages = null, + /** + * @var null|bool + */ + public null|bool $has_downloads = null, + /** + * @var null|bool + */ + public null|bool $archived = null, + /** + * @var null|bool + */ + public null|bool $disabled = null, + /** + * @var null|string + */ + public null|string $visibility = null, + /** + * @var null|string + */ + public null|string $pushed_at = null, + /** + * @var null|string + */ + public null|string $created_at = null, + /** + * @var null|string + */ + public null|string $updated_at = null, + /** + * @var null|PermissionsModel + */ + public null|PermissionsModel $permissions = null, + /** + * @var null|bool + */ + public null|bool $allow_rebase_merge = null, + /** + * @var null|string + */ + public null|string $template_repository = null, + /** + * @var null|string + */ + public null|string $temp_clone_token = null, + /** + * @var null|bool + */ + public null|bool $allow_squash_merge = null, + /** + * @var null|bool + */ + public null|bool $delete_branch_on_merge = null, + /** + * @var null|bool + */ + public null|bool $allow_merge_commit = null, + /** + * @var null|int + */ + public null|int $subscribers_count = null, + /** + * @var null|int + */ + public null|int $network_count = null, + ) {} + + /** + * @return array{ + * 'id'?: int, + * 'node_id'?: string, + * 'name'?: string, + * 'full_name'?: string, + * 'owner'?: OwnerModel, + * 'private'?: bool, + * 'html_url'?: string, + * 'description'?: string, + * 'fork'?: bool, + * 'url'?: string, + * 'archive_url'?: string, + * 'assignees_url'?: string, + * 'blobs_url'?: string, + * 'branches_url'?: string, + * 'collaborators_url'?: string, + * 'comments_url'?: string, + * 'commits_url'?: string, + * 'compare_url'?: string, + * 'contents_url'?: string, + * 'contributors_url'?: string, + * 'deployments_url'?: string, + * 'downloads_url'?: string, + * 'events_url'?: string, + * 'forks_url'?: string, + * 'git_commits_url'?: string, + * 'git_refs_url'?: string, + * 'git_tags_url'?: string, + * 'git_url'?: string, + * 'issue_comment_url'?: string, + * 'issue_events_url'?: string, + * 'issues_url'?: string, + * 'keys_url'?: string, + * 'labels_url'?: string, + * 'languages_url'?: string, + * 'merges_url'?: string, + * 'milestones_url'?: string, + * 'notifications_url'?: string, + * 'pulls_url'?: string, + * 'releases_url'?: string, + * 'ssh_url'?: string, + * 'stargazers_url'?: string, + * 'statuses_url'?: string, + * 'subscribers_url'?: string, + * 'subscription_url'?: string, + * 'tags_url'?: string, + * 'teams_url'?: string, + * 'trees_url'?: string, + * 'clone_url'?: string, + * 'mirror_url'?: string, + * 'hooks_url'?: string, + * 'svn_url'?: string, + * 'homepage'?: string, + * 'language'?: string, + * 'forks_count'?: int, + * 'stargazers_count'?: int, + * 'watchers_count'?: int, + * 'size'?: int, + * 'default_branch'?: string, + * 'open_issues_count'?: int, + * 'is_template'?: bool, + * 'topics'?: list, + * 'has_issues'?: bool, + * 'has_projects'?: bool, + * 'has_wiki'?: bool, + * 'has_pages'?: bool, + * 'has_downloads'?: bool, + * 'archived'?: bool, + * 'disabled'?: bool, + * 'visibility'?: string, + * 'pushed_at'?: string, + * 'created_at'?: string, + * 'updated_at'?: string, + * 'permissions'?: PermissionsModel, + * 'allow_rebase_merge'?: bool, + * 'template_repository'?: string, + * 'temp_clone_token'?: string, + * 'allow_squash_merge'?: bool, + * 'delete_branch_on_merge'?: bool, + * 'allow_merge_commit'?: bool, + * 'subscribers_count'?: int, + * 'network_count'?: int, + * } + */ + public function jsonSerialize(): array + { + $properties = []; + if ($this->id !== null) { + $properties['id'] = $this->id; + } + if ($this->node_id !== null) { + $properties['node_id'] = $this->node_id; + } + if ($this->name !== null) { + $properties['name'] = $this->name; + } + if ($this->full_name !== null) { + $properties['full_name'] = $this->full_name; + } + if ($this->owner !== null) { + $properties['owner'] = $this->owner; + } + if ($this->private !== null) { + $properties['private'] = $this->private; + } + if ($this->html_url !== null) { + $properties['html_url'] = $this->html_url; + } + if ($this->description !== null) { + $properties['description'] = $this->description; + } + if ($this->fork !== null) { + $properties['fork'] = $this->fork; + } + if ($this->url !== null) { + $properties['url'] = $this->url; + } + if ($this->archive_url !== null) { + $properties['archive_url'] = $this->archive_url; + } + if ($this->assignees_url !== null) { + $properties['assignees_url'] = $this->assignees_url; + } + if ($this->blobs_url !== null) { + $properties['blobs_url'] = $this->blobs_url; + } + if ($this->branches_url !== null) { + $properties['branches_url'] = $this->branches_url; + } + if ($this->collaborators_url !== null) { + $properties['collaborators_url'] = $this->collaborators_url; + } + if ($this->comments_url !== null) { + $properties['comments_url'] = $this->comments_url; + } + if ($this->commits_url !== null) { + $properties['commits_url'] = $this->commits_url; + } + if ($this->compare_url !== null) { + $properties['compare_url'] = $this->compare_url; + } + if ($this->contents_url !== null) { + $properties['contents_url'] = $this->contents_url; + } + if ($this->contributors_url !== null) { + $properties['contributors_url'] = $this->contributors_url; + } + if ($this->deployments_url !== null) { + $properties['deployments_url'] = $this->deployments_url; + } + if ($this->downloads_url !== null) { + $properties['downloads_url'] = $this->downloads_url; + } + if ($this->events_url !== null) { + $properties['events_url'] = $this->events_url; + } + if ($this->forks_url !== null) { + $properties['forks_url'] = $this->forks_url; + } + if ($this->git_commits_url !== null) { + $properties['git_commits_url'] = $this->git_commits_url; + } + if ($this->git_refs_url !== null) { + $properties['git_refs_url'] = $this->git_refs_url; + } + if ($this->git_tags_url !== null) { + $properties['git_tags_url'] = $this->git_tags_url; + } + if ($this->git_url !== null) { + $properties['git_url'] = $this->git_url; + } + if ($this->issue_comment_url !== null) { + $properties['issue_comment_url'] = $this->issue_comment_url; + } + if ($this->issue_events_url !== null) { + $properties['issue_events_url'] = $this->issue_events_url; + } + if ($this->issues_url !== null) { + $properties['issues_url'] = $this->issues_url; + } + if ($this->keys_url !== null) { + $properties['keys_url'] = $this->keys_url; + } + if ($this->labels_url !== null) { + $properties['labels_url'] = $this->labels_url; + } + if ($this->languages_url !== null) { + $properties['languages_url'] = $this->languages_url; + } + if ($this->merges_url !== null) { + $properties['merges_url'] = $this->merges_url; + } + if ($this->milestones_url !== null) { + $properties['milestones_url'] = $this->milestones_url; + } + if ($this->notifications_url !== null) { + $properties['notifications_url'] = $this->notifications_url; + } + if ($this->pulls_url !== null) { + $properties['pulls_url'] = $this->pulls_url; + } + if ($this->releases_url !== null) { + $properties['releases_url'] = $this->releases_url; + } + if ($this->ssh_url !== null) { + $properties['ssh_url'] = $this->ssh_url; + } + if ($this->stargazers_url !== null) { + $properties['stargazers_url'] = $this->stargazers_url; + } + if ($this->statuses_url !== null) { + $properties['statuses_url'] = $this->statuses_url; + } + if ($this->subscribers_url !== null) { + $properties['subscribers_url'] = $this->subscribers_url; + } + if ($this->subscription_url !== null) { + $properties['subscription_url'] = $this->subscription_url; + } + if ($this->tags_url !== null) { + $properties['tags_url'] = $this->tags_url; + } + if ($this->teams_url !== null) { + $properties['teams_url'] = $this->teams_url; + } + if ($this->trees_url !== null) { + $properties['trees_url'] = $this->trees_url; + } + if ($this->clone_url !== null) { + $properties['clone_url'] = $this->clone_url; + } + if ($this->mirror_url !== null) { + $properties['mirror_url'] = $this->mirror_url; + } + if ($this->hooks_url !== null) { + $properties['hooks_url'] = $this->hooks_url; + } + if ($this->svn_url !== null) { + $properties['svn_url'] = $this->svn_url; + } + if ($this->homepage !== null) { + $properties['homepage'] = $this->homepage; + } + if ($this->language !== null) { + $properties['language'] = $this->language; + } + if ($this->forks_count !== null) { + $properties['forks_count'] = $this->forks_count; + } + if ($this->stargazers_count !== null) { + $properties['stargazers_count'] = $this->stargazers_count; + } + if ($this->watchers_count !== null) { + $properties['watchers_count'] = $this->watchers_count; + } + if ($this->size !== null) { + $properties['size'] = $this->size; + } + if ($this->default_branch !== null) { + $properties['default_branch'] = $this->default_branch; + } + if ($this->open_issues_count !== null) { + $properties['open_issues_count'] = $this->open_issues_count; + } + if ($this->is_template !== null) { + $properties['is_template'] = $this->is_template; + } + if ($this->topics !== null) { + $properties['topics'] = $this->topics; + } + if ($this->has_issues !== null) { + $properties['has_issues'] = $this->has_issues; + } + if ($this->has_projects !== null) { + $properties['has_projects'] = $this->has_projects; + } + if ($this->has_wiki !== null) { + $properties['has_wiki'] = $this->has_wiki; + } + if ($this->has_pages !== null) { + $properties['has_pages'] = $this->has_pages; + } + if ($this->has_downloads !== null) { + $properties['has_downloads'] = $this->has_downloads; + } + if ($this->archived !== null) { + $properties['archived'] = $this->archived; + } + if ($this->disabled !== null) { + $properties['disabled'] = $this->disabled; + } + if ($this->visibility !== null) { + $properties['visibility'] = $this->visibility; + } + if ($this->pushed_at !== null) { + $properties['pushed_at'] = $this->pushed_at; + } + if ($this->created_at !== null) { + $properties['created_at'] = $this->created_at; + } + if ($this->updated_at !== null) { + $properties['updated_at'] = $this->updated_at; + } + if ($this->permissions !== null) { + $properties['permissions'] = $this->permissions; + } + if ($this->allow_rebase_merge !== null) { + $properties['allow_rebase_merge'] = $this->allow_rebase_merge; + } + if ($this->template_repository !== null) { + $properties['template_repository'] = $this->template_repository; + } + if ($this->temp_clone_token !== null) { + $properties['temp_clone_token'] = $this->temp_clone_token; + } + if ($this->allow_squash_merge !== null) { + $properties['allow_squash_merge'] = $this->allow_squash_merge; + } + if ($this->delete_branch_on_merge !== null) { + $properties['delete_branch_on_merge'] = $this->delete_branch_on_merge; + } + if ($this->allow_merge_commit !== null) { + $properties['allow_merge_commit'] = $this->allow_merge_commit; + } + if ($this->subscribers_count !== null) { + $properties['subscribers_count'] = $this->subscribers_count; + } + if ($this->network_count !== null) { + $properties['network_count'] = $this->network_count; + } + return $properties; + } +} diff --git a/tests/PHPStan/Type/Constant/ConstantArrayTypeBuilderTest.php b/tests/PHPStan/Type/Constant/ConstantArrayTypeBuilderTest.php index 15f4cd8df13..49a6df66bfa 100644 --- a/tests/PHPStan/Type/Constant/ConstantArrayTypeBuilderTest.php +++ b/tests/PHPStan/Type/Constant/ConstantArrayTypeBuilderTest.php @@ -2,7 +2,9 @@ namespace PHPStan\Type\Constant; +use PHPStan\DependencyInjection\BleedingEdgeToggle; use PHPStan\Testing\PHPStanTestCase; +use PHPStan\Type\ArrayType; use PHPStan\Type\BooleanType; use PHPStan\Type\IntegerType; use PHPStan\Type\NullType; @@ -199,4 +201,45 @@ public function testIsListWithUnion(): void $this->assertFalse($builder->isList()); } + public function testGetArrayEmptyWithUnknownSealednessStaysConstantArrayType(): void + { + $builder = ConstantArrayTypeBuilder::createEmpty(); + $array = $builder->getArray(); + $this->assertInstanceOf(ConstantArrayType::class, $array); + $this->assertSame('array{}', $array->describe(VerbosityLevel::precise())); + } + + public function testGetArraySealedEmptyStaysConstantArrayType(): void + { + $bleedingEdgeBackup = BleedingEdgeToggle::isBleedingEdge(); + BleedingEdgeToggle::setBleedingEdge(true); + try { + $builder = ConstantArrayTypeBuilder::createEmpty(); + $array = $builder->getArray(); + $this->assertInstanceOf(ConstantArrayType::class, $array); + $this->assertSame('array{}', $array->describe(VerbosityLevel::precise())); + } finally { + BleedingEdgeToggle::setBleedingEdge($bleedingEdgeBackup); + } + } + + public function testGetArrayEmptyWithRealUnsealedCollapsesToArrayType(): void + { + $builder = ConstantArrayTypeBuilder::createEmpty(); + $builder->makeUnsealed(new IntegerType(), new StringType()); + $array = $builder->getArray(); + $this->assertInstanceOf(ArrayType::class, $array); + $this->assertSame('array', $array->describe(VerbosityLevel::precise())); + } + + public function testGetArrayWithKnownKeysAndRealUnsealedStaysConstantArrayType(): void + { + $builder = ConstantArrayTypeBuilder::createEmpty(); + $builder->setOffsetValueType(new ConstantStringType('a'), new IntegerType()); + $builder->makeUnsealed(new StringType(), new StringType()); + $array = $builder->getArray(); + $this->assertInstanceOf(ConstantArrayType::class, $array); + $this->assertSame('array{a: int, ...}', $array->describe(VerbosityLevel::precise())); + } + } diff --git a/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php b/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php index 6d6c41af1cc..933d268cc4b 100644 --- a/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php +++ b/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php @@ -3,6 +3,8 @@ namespace PHPStan\Type\Constant; use Closure; +use PHPStan\DependencyInjection\BleedingEdgeToggle; +use PHPStan\PhpDoc\TypeStringResolver; use PHPStan\Testing\PHPStanTestCase; use PHPStan\TrinaryLogic; use PHPStan\Type\Accessory\HasOffsetType; @@ -13,6 +15,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,7 +29,9 @@ use PHPStan\Type\UnionType; use PHPStan\Type\VerbosityLevel; use PHPUnit\Framework\Attributes\DataProvider; +use stdClass; use function array_map; +use function is_string; use function sprintf; class ConstantArrayTypeTest extends PHPStanTestCase @@ -409,6 +414,9 @@ public static function dataAccepts(): iterable TrinaryLogic::createMaybe(), ]; + $bleedingEdgeBackup = BleedingEdgeToggle::isBleedingEdge(); + BleedingEdgeToggle::setBleedingEdge(false); + yield [ new ConstantArrayType([], []), new ConstantArrayType([], []), @@ -420,6 +428,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 +442,186 @@ 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) { + return; + } + + $this->assertSame($reasons, $actualResult->reasons, $testDescription); } public static function dataIsSuperTypeOf(): iterable @@ -736,11 +913,85 @@ public static function dataIsSuperTypeOf(): iterable ]), TrinaryLogic::createYes(), ]; + + // definite sealedness tests (bleeding edge) + + // both sealed, same keys, compatible values + yield ['array{a: int, b: string}', 'array{a: int, b: string}', TrinaryLogic::createYes()]; + + // both sealed, bigger vs smaller (subset) — sealed requires exact keys + yield ['array{a: int, b: string}', 'array{a: int}', TrinaryLogic::createNo()]; + yield ['array{a: int}', 'array{a: int, b: string}', TrinaryLogic::createNo()]; + + // both sealed, narrower value + yield ['array{a: int}', 'array{a: int<0, max>}', TrinaryLogic::createYes()]; + yield ['array{a: int<0, max>}', 'array{a: int}', TrinaryLogic::createMaybe()]; + + // both sealed, optional key in left only + yield ['array{a: int, b?: string}', 'array{a: int}', TrinaryLogic::createYes()]; + yield ['array{a: int, b?: string}', 'array{a: int, b: string}', TrinaryLogic::createYes()]; + + // both unsealed, compatible known keys + compatible unsealed + yield ['array{a: int, ...}', 'array{a: int<0, max>, ...}', TrinaryLogic::createYes()]; + yield ['array{a: int<0, max>, ...}', 'array{a: int, ...}', TrinaryLogic::createMaybe()]; + + // both unsealed, bigger known on right (right's extra fits left's unsealed extras) + yield ['array{a: int, ...}', 'array{a: int, b: string, ...}', TrinaryLogic::createYes()]; + + // both unsealed, right has known key left doesn't require; left's unsealed must cover + yield ['array{a: int, ...}', 'array{a: int, b: int, ...}', TrinaryLogic::createNo()]; + yield ['array{a: int, ...}', 'array{a: int, b: non-empty-string, ...}', TrinaryLogic::createYes()]; + + // both unsealed, narrower unsealed value on right + yield ['array{a: int, ...}', 'array{a: int, ...}', TrinaryLogic::createYes()]; + yield ['array{a: int, ...}', 'array{a: int, ...}', TrinaryLogic::createMaybe()]; + + // both unsealed, narrower unsealed key on right (array-key ⊃ string) + yield ['array{a: int, ...}', 'array{a: int, ...}', TrinaryLogic::createYes()]; + yield ['array{a: int, ...}', 'array{a: int, ...}', TrinaryLogic::createMaybe()]; + + // both unsealed, incompatible unsealed key types + yield ['array{...}', 'array{...}', TrinaryLogic::createNo()]; + + // both unsealed, incompatible unsealed value types + yield ['array{...}', 'array{...}', TrinaryLogic::createNo()]; + + // unsealed vs sealed — sealed's extras must fit unsealed's unsealed + yield ['array{a: int, ...}', 'array{a: int, b: string}', TrinaryLogic::createYes()]; + yield ['array{a: int, ...}', 'array{a: int, b: string}', TrinaryLogic::createNo()]; + + // sealed vs unsealed — unsealed might have extras sealed doesn't allow + yield ['array{a: int}', 'array{a: int, ...}', TrinaryLogic::createMaybe()]; + yield ['array{a: int, b: string}', 'array{a: int<0, max>, ...}', TrinaryLogic::createMaybe()]; + + // sealed vs unsealed where sealed's keys can't be in unsealed's extras + yield ['array{a: int}', 'array{...}', TrinaryLogic::createNo()]; + + // sealed vs unsealed where sealed fits unsealed's extras + yield ['array{a: int}', 'array{...}', TrinaryLogic::createMaybe()]; } + /** + * @param ConstantArrayType|string $type + * @param Type|string $otherType + */ #[DataProvider('dataIsSuperTypeOf')] - public function testIsSuperTypeOf(ConstantArrayType $type, Type $otherType, TrinaryLogic $expectedResult): void + public function testIsSuperTypeOf($type, $otherType, TrinaryLogic $expectedResult): void { + $bleedingEdgeBackup = BleedingEdgeToggle::isBleedingEdge(); + BleedingEdgeToggle::setBleedingEdge(true); + try { + $resolver = self::getContainer()->getByType(TypeStringResolver::class); + if (is_string($type)) { + $type = $resolver->resolve($type, null); + } + if (is_string($otherType)) { + $otherType = $resolver->resolve($otherType, null); + } + } finally { + BleedingEdgeToggle::setBleedingEdge($bleedingEdgeBackup); + } + $actualResult = $type->isSuperTypeOf($otherType); $this->assertSame( $expectedResult->describe(), @@ -1116,4 +1367,100 @@ 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(); + // No known keys + real unsealed extras now collapses to a general ArrayType + // (see ConstantArrayTypeBuilder::getArray). + $this->assertInstanceOf(ArrayType::class, $array); + $this->assertSame('array', $array->describe(VerbosityLevel::precise())); + } 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/TypeCombinatorTest.php b/tests/PHPStan/Type/TypeCombinatorTest.php index 2d1a0c7f48c..741bc032bea 100644 --- a/tests/PHPStan/Type/TypeCombinatorTest.php +++ b/tests/PHPStan/Type/TypeCombinatorTest.php @@ -14,8 +14,10 @@ use InvalidArgumentException; use Iterator; use ObjectShapesAcceptance\ClassWithFooIntProperty; +use PHPStan\DependencyInjection\BleedingEdgeToggle; use PHPStan\Fixture\FinalClass; use PHPStan\Generics\FunctionsAssertType\C; +use PHPStan\PhpDoc\TypeStringResolver; use PHPStan\Reflection\Callables\SimpleImpurePoint; use PHPStan\Testing\PHPStanTestCase; use PHPStan\TrinaryLogic; @@ -64,6 +66,7 @@ use function array_reverse; use function get_class; use function implode; +use function is_string; use function sprintf; use const PHP_VERSION_ID; @@ -731,7 +734,7 @@ public static function dataUnion(): iterable ]), ], UnionType::class, - 'array{foo: DateTimeImmutable, bar: int}|array{foo: null, bar: string}', + 'array{bar: int, foo: DateTimeImmutable}|array{bar: string, foo: null}', ], [ [ @@ -749,7 +752,7 @@ public static function dataUnion(): iterable ]), ], UnionType::class, - 'array{foo: DateTimeImmutable, bar: int}|array{foo: null}', + 'array{bar: int, foo: DateTimeImmutable}|array{foo: null}', ], [ [ @@ -771,7 +774,7 @@ public static function dataUnion(): iterable ]), ], UnionType::class, - 'array{foo: DateTimeImmutable, bar: int}|array{foo: null, bar: string, baz: int}', + 'array{bar: int, foo: DateTimeImmutable}|array{bar: string, baz: int, foo: null}', ], [ [ @@ -2618,7 +2621,7 @@ public static function dataUnion(): iterable new NonAcceptingNeverType(), ], NeverType::class, - 'never', + 'never=explicit', ]; yield [ [ @@ -2892,10 +2895,260 @@ public static function dataUnion(): iterable StringType::class, 'string', ]; + + yield [ + [ + new ConstantArrayType( + [new ConstantIntegerType(0), new ConstantIntegerType(1)], + [new IntegerType(), new UnionType([ + new ConstantStringType('0'), + new ConstantStringType('foo'), + ])], + ), + new ConstantArrayType( + [new ConstantIntegerType(0), new ConstantIntegerType(1)], + [IntegerRangeType::createAllGreaterThanOrEqualTo(0), new IntersectionType([ + new StringType(), + new AccessoryNonFalsyStringType(), + ])], + ), + ], + ConstantArrayType::class, + 'array{int, non-empty-string}', + ]; + + // current behaviour (unknown sealedness) + yield [ + [ + new ConstantArrayType( + [ + new ConstantStringType('a'), + new ConstantStringType('b'), + ], + [ + new IntegerType(), + new StringType(), + ], + ), + new ConstantArrayType( + [ + new ConstantStringType('a'), + ], + [ + new IntegerType(), + ], + ), + ], + ConstantArrayType::class, + 'array{a: int, b?: string}', + ]; + + // new behaviour with definitely sealed arrays + yield [ + [ + 'array{a: int, b: string}', + 'array{a: int}', + ], + ConstantArrayType::class, + 'array{a: int, b?: string}', + ]; + + yield [ + [ + 'array{a: true, b: string}', + 'array{a: false}', + ], + UnionType::class, + 'array{a: false}|array{a: true, b: string}', + ]; + + yield [ + [ + 'array{int, 0|\'foo\'}', + 'array{int<0, max>, non-falsy-string}', + ], + ConstantArrayType::class, + 'array{int, 0|non-falsy-string}', + ]; + + yield [ + [ + 'array{a: int, b: string}', + 'array{a: int<0, max>, ...}', + ], + ConstantArrayType::class, + 'array{a: int, ...}', + ]; + + yield [ + [ + 'array{a: int, b: string}', + 'array{a: int<0, max>, ...}', + ], + ConstantArrayType::class, + 'array{a: int, b?: string, ...}', + ]; + + yield [ + [ + 'array{a: int, b: string, ...}', + 'array{a: int<0, max>, ...}', + ], + ConstantArrayType::class, + 'array{a: int, ...}', + ]; + + yield [ + [ + 'array{a: int, ...}', + 'array{b: string, ...}', + ], + IntersectionType::class, + 'non-empty-array', + ]; + + yield [ + [ + 'array{a: int, ...}', + 'array{b: string, ...}', + ], + IntersectionType::class, + 'non-empty-array{a?: int, b?: string, ...}', + ]; + + yield [ + [ + 'array{a: int, ...}', + 'array{b: string, ...}', + ], + IntersectionType::class, + 'non-empty-array{a?: int, ...}', + ]; + + yield [ + [ + 'array{a: string, ...}', + 'array{b: string, ...}', + ], + IntersectionType::class, + 'non-empty-array', + ]; + + yield [ + [ + 'array{a: int, ...}', + 'array{a: int, ...}', + ], + ConstantArrayType::class, + 'array{a: int, ...}', + ]; + + yield [ + [ + 'array{a: int, ...}', + 'array{a: int, ...}', + ], + ConstantArrayType::class, + 'array{a: int, ...}', + ]; + + yield [ + [ + 'array{...>}', + 'array{...>}', + ], + ArrayType::class, + 'array', + ]; + + yield [ + [ + 'array{...}', + 'array{...}', + ], + ArrayType::class, + 'array', + ]; + + yield [ + [ + 'array{...}', + 'array{...}', + ], + ArrayType::class, + 'array', + ]; + + yield [ + [ + 'array{a: int, ...}', + 'array{...}', + ], + ArrayType::class, + 'array', + ]; + + yield [ + [ + 'array{a: non-empty-string, ...}', + 'array{...}', + ], + ArrayType::class, + 'array', + ]; + + yield [ + [ + 'array{a: int, ...}', + 'array{a: string, ...}', + ], + ConstantArrayType::class, + 'array{a: int|string, ...}', + ]; + + yield [ + [ + 'array{a: int}', + 'array{...}', + ], + ArrayType::class, + 'array<\'a\'|int, int>', + ]; + + yield [ + [ + 'array{a: int}', + 'array{...}', + ], + ArrayType::class, + 'array', + ]; + + // Both unsealed with a shared known key → result preserves the shape as ConstantArrayType + // (only the "empty known keys + real unsealed extras" combination collapses to ArrayType). + yield [ + [ + 'array{a: int, ...}', + 'array{a: int, ...}', + ], + ConstantArrayType::class, + 'array{a: int, ...}', + ]; + + // Sealed empty arrays stay as ConstantArrayType — explicit-Never unsealed + // is NOT "real" extras, so it doesn't trigger the ArrayType collapse. + yield [ + [ + 'array{}', + 'array{}', + ], + ConstantArrayType::class, + 'array{}', + ]; } /** - * @param list $types + * @param list|list $types * @param class-string $expectedTypeClass */ #[DataProvider('dataUnion')] @@ -2905,29 +3158,22 @@ public function testUnion( string $expectedTypeDescription, ): void { - $actualType = TypeCombinator::union(...$types); - $actualTypeDescription = $actualType->describe(VerbosityLevel::precise()); - if ($actualType instanceof MixedType) { - if ($actualType->isExplicitMixed()) { - $actualTypeDescription .= '=explicit'; - } else { - $actualTypeDescription .= '=implicit'; - } - } - if (get_class($actualType) === ObjectType::class) { - $actualClassReflection = $actualType->getClassReflection(); - if ( - $actualClassReflection !== null - && $actualClassReflection->hasFinalByKeywordOverride() - && $actualClassReflection->isFinal() - ) { - $actualTypeDescription .= '=final'; + $bleedingEdgeBackup = BleedingEdgeToggle::isBleedingEdge(); + $typeStringResolver = self::getContainer()->getByType(TypeStringResolver::class); + foreach ($types as $i => $type) { + BleedingEdgeToggle::setBleedingEdge(true); + if (!is_string($type)) { + continue; } + + $types[$i] = $typeStringResolver->resolve($type, null); } + BleedingEdgeToggle::setBleedingEdge($bleedingEdgeBackup); + $actualType = TypeCombinator::union(...$types); $this->assertSame( $expectedTypeDescription, - $actualTypeDescription, + self::describeForIntersectTest($actualType), sprintf('union(%s)', implode(', ', array_map( static fn (Type $type): string => $type->describe(VerbosityLevel::precise()), $types, @@ -2951,7 +3197,7 @@ public function testUnion( } /** - * @param list $types + * @param list|list $types * @param class-string $expectedTypeClass */ #[DataProvider('dataUnion')] @@ -2962,28 +3208,23 @@ public function testUnionInversed( ): void { $types = array_reverse($types); - $actualType = TypeCombinator::union(...$types); - $actualTypeDescription = $actualType->describe(VerbosityLevel::precise()); - if ($actualType instanceof MixedType) { - if ($actualType->isExplicitMixed()) { - $actualTypeDescription .= '=explicit'; - } else { - $actualTypeDescription .= '=implicit'; - } - } - if (get_class($actualType) === ObjectType::class) { - $actualClassReflection = $actualType->getClassReflection(); - if ( - $actualClassReflection !== null - && $actualClassReflection->hasFinalByKeywordOverride() - && $actualClassReflection->isFinal() - ) { - $actualTypeDescription .= '=final'; + $bleedingEdgeBackup = BleedingEdgeToggle::isBleedingEdge(); + $typeStringResolver = self::getContainer()->getByType(TypeStringResolver::class); + foreach ($types as $i => $type) { + BleedingEdgeToggle::setBleedingEdge(true); + if (!is_string($type)) { + continue; } + + $types[$i] = $typeStringResolver->resolve($type, null); } + + BleedingEdgeToggle::setBleedingEdge($bleedingEdgeBackup); + + $actualType = TypeCombinator::union(...$types); $this->assertSame( $expectedTypeDescription, - $actualTypeDescription, + self::describeForIntersectTest($actualType), sprintf('union(%s)', implode(', ', array_map( static fn (Type $type): string => $type->describe(VerbosityLevel::precise()), $types, @@ -5083,10 +5324,213 @@ public static function dataIntersect(): iterable ConstantArrayType::class, 'array{0|1|2|3, stdClass}', ]; + + yield [ + [ + new ConstantArrayType( + [new ConstantIntegerType(0), new ConstantIntegerType(1)], + [new IntegerType(), new UnionType([ + new ConstantStringType('0'), + new ConstantStringType('foo'), + ])], + ), + new ConstantArrayType( + [new ConstantIntegerType(0), new ConstantIntegerType(1)], + [IntegerRangeType::createAllGreaterThanOrEqualTo(0), new IntersectionType([ + new StringType(), + new AccessoryNonFalsyStringType(), + ])], + ), + ], + ConstantArrayType::class, + "array{int<0, max>, 'foo'}", + ]; + + // current flawed behaviour (unknown sealedness) + yield [ + [ + new ConstantArrayType( + [ + new ConstantStringType('a'), + new ConstantStringType('b'), + ], + [ + new IntegerType(), + new StringType(), + ], + ), + new ConstantArrayType( + [ + new ConstantStringType('a'), + ], + [ + new IntegerType(), + ], + ), + ], + ConstantArrayType::class, + 'array{a: int, b: string}', + ]; + + // new behaviour with definitely sealed arrays + yield [ + [ + 'array{a: int, b: string}', + 'array{a: int}', + ], + NeverType::class, + '*NEVER*=implicit', + ]; + + yield [ + [ + 'array{int, 0|\'foo\'}', + 'array{int<0, max>, non-falsy-string}', + ], + ConstantArrayType::class, + "array{int<0, max>, 'foo'}", + ]; + + yield [ + [ + 'array{a: int, b: string}', + 'array{a: int<0, max>, ...}', + ], + ConstantArrayType::class, + 'array{a: int<0, max>, b: string}', + ]; + + yield [ + [ + 'array{a: int, b: string, ...}', + 'array{a: int<0, max>, ...}', + ], + ConstantArrayType::class, + 'array{a: int<0, max>, b: string, ...}', + ]; + + // both unsealed, disjoint known keys, default extras — union of known keys + yield [ + [ + 'array{a: int, ...}', + 'array{b: string, ...}', + ], + ConstantArrayType::class, + 'array{a: int, b: string, ...}', + ]; + + // both unsealed, narrower unsealed value on right + yield [ + [ + 'array{a: int, ...}', + 'array{a: int, ...}', + ], + ConstantArrayType::class, + 'array{a: int, ...}', + ]; + + // both unsealed, narrower unsealed key on right (array-key ∩ string = string) + yield [ + [ + 'array{a: int, ...}', + 'array{a: int, ...}', + ], + ConstantArrayType::class, + 'array{a: int, ...}', + ]; + + // both unsealed, unsealed value types intersect to a narrower common type + yield [ + [ + 'array{...>}', + 'array{...>}', + ], + ArrayType::class, + 'array>', + ]; + + yield [ + [ + 'array{a: int, ...>}', + 'array{a: int, ...>}', + ], + ConstantArrayType::class, + 'array{a: int, ...>}', + ]; + + // both unsealed, unsealed key types incompatible — no valid key overlap + yield [ + [ + 'array{...}', + 'array{...}', + ], + NeverType::class, + '*NEVER*=implicit', + ]; + + // both unsealed, unsealed value types incompatible + yield [ + [ + 'array{...}', + 'array{...}', + ], + NeverType::class, + '*NEVER*=implicit', + ]; + + // both unsealed: one side's known key conflicts with the other side's unsealed value type + yield [ + [ + 'array{a: int, ...}', + 'array{...}', + ], + ConstantArrayType::class, + 'array{a: *NEVER*}', + ]; + + // both unsealed: known key value is compatible with other side's unsealed value + yield [ + [ + 'array{a: non-empty-string, ...}', + 'array{...}', + ], + ConstantArrayType::class, + 'array{a: non-empty-string}', + ]; + + // both unsealed with same known key, value types incompatible at that key + yield [ + [ + 'array{a: int, ...}', + 'array{a: string, ...}', + ], + NeverType::class, + '*NEVER*=implicit', + ]; + + // sealed + unsealed where sealed's known key value doesn't fit unsealed's key type — incompatible + yield [ + [ + 'array{a: int}', + 'array{...}', + ], + NeverType::class, + '*NEVER*=implicit', + ]; + + // sealed + unsealed where sealed is compatible with unsealed's unsealed types + yield [ + [ + 'array{a: int}', + 'array{...}', + ], + ConstantArrayType::class, + 'array{a: int}', + ]; } /** - * @param list $types + * @param list|list $types * @param class-string $expectedTypeClass */ #[DataProvider('dataIntersect')] @@ -5096,40 +5540,33 @@ public function testIntersect( string $expectedTypeDescription, ): void { - $actualType = TypeCombinator::intersect(...$types); - $actualTypeDescription = $actualType->describe(VerbosityLevel::precise()); - if ($actualType instanceof MixedType) { - if ($actualType->isExplicitMixed()) { - $actualTypeDescription .= '=explicit'; - } else { - $actualTypeDescription .= '=implicit'; - } - } - if ($actualType instanceof NeverType) { - if ($actualType->isExplicit()) { - $actualTypeDescription .= '=explicit'; - } else { - $actualTypeDescription .= '=implicit'; + $bleedingEdgeBackup = BleedingEdgeToggle::isBleedingEdge(); + $typeStringResolver = self::getContainer()->getByType(TypeStringResolver::class); + foreach ($types as $i => $type) { + BleedingEdgeToggle::setBleedingEdge(true); + if (!is_string($type)) { + continue; } - } - if (get_class($actualType) === ObjectType::class && $actualType->isEnum()->no()) { - $actualClassReflection = $actualType->getClassReflection(); - if ( - $actualClassReflection !== null - && $actualClassReflection->hasFinalByKeywordOverride() - && $actualClassReflection->isFinal() - ) { - $actualTypeDescription .= '=final'; - } + $types[$i] = $typeStringResolver->resolve($type, null); } - $this->assertSame($expectedTypeDescription, $actualTypeDescription); + BleedingEdgeToggle::setBleedingEdge($bleedingEdgeBackup); + + $actualType = TypeCombinator::intersect(...$types); + $this->assertSame( + $expectedTypeDescription, + self::describeForIntersectTest($actualType), + sprintf('intersect(%s)', implode(', ', array_map( + static fn (Type $type): string => $type->describe(VerbosityLevel::precise()), + $types, + ))), + ); $this->assertInstanceOf($expectedTypeClass, $actualType); } /** - * @param list $types + * @param list|list $types * @param class-string $expectedTypeClass */ #[DataProvider('dataIntersect')] @@ -5139,35 +5576,58 @@ public function testIntersectInversed( string $expectedTypeDescription, ): void { - $actualType = TypeCombinator::intersect(...array_reverse($types)); - $actualTypeDescription = $actualType->describe(VerbosityLevel::precise()); - if ($actualType instanceof MixedType) { - if ($actualType->isExplicitMixed()) { - $actualTypeDescription .= '=explicit'; - } else { - $actualTypeDescription .= '=implicit'; + $bleedingEdgeBackup = BleedingEdgeToggle::isBleedingEdge(); + $typeStringResolver = self::getContainer()->getByType(TypeStringResolver::class); + foreach ($types as $i => $type) { + BleedingEdgeToggle::setBleedingEdge(true); + if (!is_string($type)) { + continue; } + + $types[$i] = $typeStringResolver->resolve($type, null); } - if ($actualType instanceof NeverType) { - if ($actualType->isExplicit()) { - $actualTypeDescription .= '=explicit'; - } else { - $actualTypeDescription .= '=implicit'; + + BleedingEdgeToggle::setBleedingEdge($bleedingEdgeBackup); + + $actualType = TypeCombinator::intersect(...array_reverse($types)); + $this->assertSame( + $expectedTypeDescription, + self::describeForIntersectTest($actualType), + sprintf('union(%s)', implode(', ', array_map( + static fn (Type $type): string => $type->describe(VerbosityLevel::precise()), + $types, + ))), + ); + $this->assertInstanceOf($expectedTypeClass, $actualType); + } + + private static function describeForIntersectTest(Type $type): string + { + $type = TypeTraverser::map($type, static function (Type $type, callable $traverse): Type { + if ($type instanceof ConstantArrayType) { + return $traverse($type->sortKeys()); } - } - if (get_class($actualType) === ObjectType::class && $actualType->isEnum()->no()) { - $actualClassReflection = $actualType->getClassReflection(); + return $traverse($type); + }); + $description = $type->describe(VerbosityLevel::precise()); + if ($type instanceof MixedType) { + $description .= $type->isExplicitMixed() ? '=explicit' : '=implicit'; + } + if ($type instanceof NeverType) { + $description .= $type->isExplicit() ? '=explicit' : '=implicit'; + } + if (get_class($type) === ObjectType::class && $type->isEnum()->no()) { + $classReflection = $type->getClassReflection(); if ( - $actualClassReflection !== null - && $actualClassReflection->hasFinalByKeywordOverride() - && $actualClassReflection->isFinal() + $classReflection !== null + && $classReflection->hasFinalByKeywordOverride() + && $classReflection->isFinal() ) { - $actualTypeDescription .= '=final'; + $description .= '=final'; } } - $this->assertSame($expectedTypeDescription, $actualTypeDescription); - $this->assertInstanceOf($expectedTypeClass, $actualType); + return $description; } public static function dataRemove(): array diff --git a/tests/PHPStan/Type/TypeToPhpDocNodeTest.php b/tests/PHPStan/Type/TypeToPhpDocNodeTest.php index fcc6c6d0cf9..41ce52e2e4d 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')]