From 2265b335826fde1ee210cc348c1ba911798e5f6a Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Mon, 26 Oct 2026 11:23:57 +0100 Subject: [PATCH] Keep accepts() reasons and messages of big types readable on bleeding edge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A return type mismatch in a data provider built by closures (with closureSignaturesFromUsages inferring literal shapes per call) produced an 8 KB message and a 31 KB tip of 68 near-identical lines. On bleeding edge: - Nested array reasons are offset paths (`Offset 'case 0' › 0 › 0 (T): reason`) instead of repeating the whole accepted type at every level; ArrayType::accepts() now names the entry each reason comes from. - Reasons differing only in their offset path are reported once, listing the offsets; at most 5 reasons are shown in a tip. - A sealed shape rejecting an extra key names its value (`extra key 5 => true`). - Unsealed shapes report their extra offsets as offset paths too. - Object shapes report missing properties in one reason. - "is not a list" / "might be empty" do not repeat a long accepted array. - "Sealed array shapes cannot be intersected" describes the shapes without literal values, so union members with the same keys share the reason. - return.type and argument.type describe an accepted type longer than 1000 characters with literal values widened and big shapes generalized. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RFQVeuWzHD5AobZdsRHdT3 --- src/Rules/AcceptedTypeDescriptionHelper.php | 102 ++++++++ src/Rules/FunctionCallParametersCheck.php | 2 +- src/Rules/FunctionReturnTypeCheck.php | 2 +- src/Rules/RuleErrorBuilder.php | 6 + src/Type/AcceptsReasonHelper.php | 229 ++++++++++++++++++ src/Type/ArrayType.php | 8 + src/Type/Constant/ConstantArrayType.php | 103 +++++--- src/Type/IntersectionType.php | 39 +-- src/Type/ObjectShapeType.php | 52 +++- .../CallToFunctionParametersRuleTest.php | 42 +++- .../Rules/Functions/ReturnTypeRuleTest.php | 4 +- .../data/readable-accepts-reasons.php | 30 +++ .../Rules/Methods/CallMethodsRuleTest.php | 19 +- .../Rules/Methods/ReturnTypeRuleTest.php | 16 +- .../nested-array-shape-return-reasons.php | 140 +++++++++++ .../PHPStan/Type/AcceptsReasonHelperTest.php | 86 +++++++ turbo-ext/src/ArrayType.cpp | 9 +- turbo-ext/src/ConstantArrayType.cpp | 160 +++++++++++- turbo-ext/src/ConstantArrayTypeBuilder.cpp | 25 +- turbo-ext/src/IntersectionType.cpp | 57 +++-- turbo-ext/src/ObjectShapeType.cpp | 105 +++++++- turbo-ext/src/TypeTraits.h | 4 + turbo-ext/src/generated/ObjectShapeType.h | 114 +++++---- turbo-ext/src/support.h | 6 + 24 files changed, 1170 insertions(+), 190 deletions(-) create mode 100644 src/Rules/AcceptedTypeDescriptionHelper.php create mode 100644 src/Type/AcceptsReasonHelper.php create mode 100644 tests/PHPStan/Rules/Functions/data/readable-accepts-reasons.php create mode 100644 tests/PHPStan/Rules/Methods/data/nested-array-shape-return-reasons.php create mode 100644 tests/PHPStan/Type/AcceptsReasonHelperTest.php diff --git a/src/Rules/AcceptedTypeDescriptionHelper.php b/src/Rules/AcceptedTypeDescriptionHelper.php new file mode 100644 index 00000000000..8950eff337e --- /dev/null +++ b/src/Rules/AcceptedTypeDescriptionHelper.php @@ -0,0 +1,102 @@ +describe($verbosityLevel); + if (strlen($description) <= self::DESCRIPTION_LENGTH_LIMIT || !BleedingEdgeToggle::isBleedingEdge()) { + return $description; + } + + $generalizedDescription = TypeTraverser::map($acceptedType, static function (Type $type, callable $traverse): Type { + if ($type->isConstantScalarValue()->yes()) { + return $type->generalize(GeneralizePrecision::moreSpecific()); + } + + $type = $traverse($type); + if (!$type->isConstantArray()->yes()) { + return $type; + } + + $constantArrays = $type->getConstantArrays(); + if (count($constantArrays) !== 1 || count($constantArrays[0]->getKeyTypes()) === 0) { + return $type; + } + + if ( + count($constantArrays[0]->getKeyTypes()) <= self::DESCRIBED_ARRAY_SHAPE_KEYS_LIMIT + && !($type->isList()->yes() && self::hasSameValueTypes($constantArrays[0]->getValueTypes())) + ) { + return $type; + } + + $arrayType = new ArrayType($type->getIterableKeyType()->generalize(GeneralizePrecision::moreSpecific()), $type->getIterableValueType()); + if ($type->isList()->yes()) { + $arrayType = TypeCombinator::intersect($arrayType, new AccessoryArrayListType()); + } + if ($type->isIterableAtLeastOnce()->yes()) { + $arrayType = TypeCombinator::intersect($arrayType, new NonEmptyArrayType()); + } + + return $arrayType; + })->describe($verbosityLevel); + + if ( + strlen($generalizedDescription) >= strlen($description) + || $generalizedDescription === $acceptingType->describe($verbosityLevel) + ) { + return $description; + } + + return $generalizedDescription; + } + + /** + * @param array $valueTypes + */ + private static function hasSameValueTypes(array $valueTypes): bool + { + $firstValueType = null; + foreach ($valueTypes as $valueType) { + if ($firstValueType === null) { + $firstValueType = $valueType; + continue; + } + if (!$valueType->equals($firstValueType)) { + return false; + } + } + + return true; + } + +} diff --git a/src/Rules/FunctionCallParametersCheck.php b/src/Rules/FunctionCallParametersCheck.php index aa14e2a9f16..4b7d645d88d 100644 --- a/src/Rules/FunctionCallParametersCheck.php +++ b/src/Rules/FunctionCallParametersCheck.php @@ -407,7 +407,7 @@ public function check( $wrongArgumentTypeMessage, $this->describeParameter($parameter, $argumentName ?? $i + 1), $parameterType->describe($verbosityLevel), - $argumentValueType->describe($verbosityLevel), + AcceptedTypeDescriptionHelper::describe($parameterType, $argumentValueType, $verbosityLevel), )) ->identifier('argument.type') ->line($argumentLine) diff --git a/src/Rules/FunctionReturnTypeCheck.php b/src/Rules/FunctionReturnTypeCheck.php index e205930591a..6e5d370e36a 100644 --- a/src/Rules/FunctionReturnTypeCheck.php +++ b/src/Rules/FunctionReturnTypeCheck.php @@ -98,7 +98,7 @@ public function checkReturnType( RuleErrorBuilder::message(sprintf( $typeMismatchMessage, $returnType->describe($verbosityLevel), - $returnValueType->describe($verbosityLevel), + AcceptedTypeDescriptionHelper::describe($returnType, $returnValueType, $verbosityLevel), )) ->line($returnNode->getStartLine()) ->identifier('return.type') diff --git a/src/Rules/RuleErrorBuilder.php b/src/Rules/RuleErrorBuilder.php index 1e60d7129c1..664d3645ccc 100644 --- a/src/Rules/RuleErrorBuilder.php +++ b/src/Rules/RuleErrorBuilder.php @@ -4,7 +4,9 @@ use PhpParser\Node; use PHPStan\Analyser\Error; +use PHPStan\DependencyInjection\BleedingEdgeToggle; use PHPStan\ShouldNotHappenException; +use PHPStan\Type\AcceptsReasonHelper; use function array_map; use function class_exists; use function count; @@ -244,6 +246,10 @@ public function discoveringSymbolsTip(): self */ public function acceptsReasonsTip(array $reasons): self { + if (BleedingEdgeToggle::isBleedingEdge()) { + $reasons = AcceptsReasonHelper::forTip($reasons); + } + foreach ($reasons as $reason) { $this->addTip($reason); } diff --git a/src/Type/AcceptsReasonHelper.php b/src/Type/AcceptsReasonHelper.php new file mode 100644 index 00000000000..28f5372a0ad --- /dev/null +++ b/src/Type/AcceptsReasonHelper.php @@ -0,0 +1,229 @@ +describe(VerbosityLevel::precise()), + self::OFFSET_PATH_SEPARATOR, + substr($reason, strlen(self::OFFSET_PREFIX)), + ); + } + + if ($acceptedValueType->isConstantArray()->yes()) { + return sprintf( + 'Offset %s (%s): %s', + $offsetType->describe(VerbosityLevel::precise()), + $offsetValueType->describe($verbosity), + $reason, + ); + } + + return sprintf( + 'Offset %s (%s) does not accept type %s: %s', + $offsetType->describe(VerbosityLevel::precise()), + $offsetValueType->describe($verbosity), + $acceptedValueType->describe($verbosity), + $reason, + ); + } + + /** + * The reasons as error tips: grouped by groupReasons(), and only the first + * few of many - the rest are typically the same problem in other members + * of a union. + * + * @param list $reasons + * @return list + */ + public static function forTip(array $reasons): array + { + $reasons = self::groupReasons($reasons); + $count = count($reasons); + if ($count <= self::TIP_REASONS_LIMIT) { + return $reasons; + } + + $reasons = array_slice($reasons, 0, self::TIP_REASONS_LIMIT); + $reasons[] = sprintf('%d more reasons not shown.', $count - self::TIP_REASONS_LIMIT); + + return $reasons; + } + + /** + * Merges reasons that differ only in their offset path into one + * (`Offsets 'a' › 0, 'b' › 0 and 5 more (int): reason`), in the order + * they first appear. + * + * @param list $reasons + * @return list + */ + public static function groupReasons(array $reasons): array + { + /** @var array> $pathsByRest */ + $pathsByRest = []; + + /** @var list $order */ + $order = []; + foreach ($reasons as $reason) { + $split = self::splitOffsetPath($reason); + if ($split === null) { + $order[] = [$reason]; + continue; + } + + [$path, $rest] = $split; + if (array_key_exists($rest, $pathsByRest)) { + $pathsByRest[$rest][] = $path; + continue; + } + + $pathsByRest[$rest] = [$path]; + $order[] = [null, $rest]; + } + + $grouped = []; + foreach ($order as $item) { + if (count($item) === 1) { + $grouped[] = $item[0]; + continue; + } + + $rest = $item[1]; + $paths = $pathsByRest[$rest]; + if (count($paths) === 1) { + $grouped[] = self::OFFSET_PREFIX . $paths[0] . $rest; + continue; + } + + $grouped[] = 'Offsets ' . self::listItems($paths) . $rest; + } + + return $grouped; + } + + /** + * The accepted type as the subject of a reason, or $fallback when its + * description is too long to repeat - the error message describes it already. + */ + public static function describeSubject(Type $type, VerbosityLevel $level, string $fallback): string + { + $description = $type->describe($level); + if (strlen($description) > self::SUBJECT_DESCRIPTION_LENGTH_LIMIT) { + return $fallback; + } + + return $description; + } + + /** + * @param non-empty-list $propertyNames + */ + public static function missingProperties(string $subject, array $propertyNames, bool $certainly): string + { + return sprintf( + '%s %s have %s %s.', + $subject, + $certainly ? 'does not' : 'might not', + count($propertyNames) === 1 ? 'property' : 'properties', + self::listItems(array_map(static fn (string $propertyName): string => '$' . $propertyName, $propertyNames)), + ); + } + + /** + * `a`, `a and b`, `a, b and c`, `a, b, c and 2 more` + * + * @param non-empty-list $items + */ + private static function listItems(array $items): string + { + $count = count($items); + if ($count === 1) { + return $items[0]; + } + + if ($count > self::LISTED_ITEMS_LIMIT) { + return sprintf( + '%s and %d more', + implode(', ', array_slice($items, 0, self::LISTED_ITEMS_LIMIT)), + $count - self::LISTED_ITEMS_LIMIT, + ); + } + + return sprintf('%s and %s', implode(', ', array_slice($items, 0, -1)), $items[$count - 1]); + } + + /** + * @return array{string, string}|null the offset path and the rest of the reason + */ + private static function splitOffsetPath(string $reason): ?array + { + if (!str_starts_with($reason, self::OFFSET_PREFIX)) { + return null; + } + + $position = strlen(self::OFFSET_PREFIX); + while (true) { + if (preg_match(self::OFFSET_PATTERN, $reason, $matches, offset: $position) !== 1) { + return null; + } + $position += strlen($matches[0]); + if (substr($reason, $position, strlen(self::OFFSET_PATH_SEPARATOR)) !== self::OFFSET_PATH_SEPARATOR) { + break; + } + $position += strlen(self::OFFSET_PATH_SEPARATOR); + } + + $rest = substr($reason, $position); + if ($rest === '' || ($rest[0] !== ' ' && $rest[0] !== ':')) { + return null; + } + + return [substr($reason, strlen(self::OFFSET_PREFIX), $position - strlen(self::OFFSET_PREFIX)), $rest]; + } + +} diff --git a/src/Type/ArrayType.php b/src/Type/ArrayType.php index 904f51e6396..125eef06df0 100644 --- a/src/Type/ArrayType.php +++ b/src/Type/ArrayType.php @@ -2,6 +2,7 @@ namespace PHPStan\Type; +use PHPStan\DependencyInjection\BleedingEdgeToggle; use PHPStan\Php\PhpVersion; use PHPStan\PhpDocParser\Ast\Type\GenericTypeNode; use PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode; @@ -131,10 +132,17 @@ public function accepts(Type $type, bool $strictTypes): AcceptsResult $result = AcceptsResult::createYes(); $thisKeyType = $this->keyType; $itemType = $this->getItemType(); + $bleedingEdge = BleedingEdgeToggle::isBleedingEdge(); foreach ($type->getKeyTypes() as $i => $keyType) { $valueType = $type->getValueTypes()[$i]; $acceptsKey = $thisKeyType->accepts($keyType, $strictTypes); $acceptsValue = $itemType->accepts($valueType, $strictTypes); + if ($bleedingEdge && count($acceptsValue->reasons) > 0) { + $verbosity = VerbosityLevel::getRecommendedLevelByType($itemType, $valueType); + $acceptsValue = $acceptsValue->decorateReasons( + static fn (string $reason) => AcceptsReasonHelper::prependOffset($keyType, $itemType, $valueType, $verbosity, $reason), + ); + } $result = $result->and($acceptsKey)->and($acceptsValue); } diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index a198e2161a7..22ad4c5c1bc 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -24,6 +24,7 @@ use PHPStan\ShouldNotHappenException; use PHPStan\TrinaryLogic; use PHPStan\Turbo\ShadowedByTurboExtension; +use PHPStan\Type\AcceptsReasonHelper; use PHPStan\Type\AcceptsResult; use PHPStan\Type\Accessory\AccessoryArrayListType; use PHPStan\Type\Accessory\AccessoryLowercaseStringType; @@ -574,10 +575,18 @@ public function accepts(Type $type, bool $strictTypes): AcceptsResult unset($keys[$keyType->getValue()]); } + $bleedingEdge = BleedingEdgeToggle::isBleedingEdge(); 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 ($bleedingEdge) { + $reason = sprintf( + 'Sealed array shape does not accept array with extra key %s => %s.', + $extraKey->describe(VerbosityLevel::precise()), + $constantArrays[0]->getOffsetValueType($extraKey)->describe(VerbosityLevel::value()), + ); + } else { + $reason = sprintf('Sealed array shape does not accept array with extra key %s.', $extraKey->describe(VerbosityLevel::precise())); + } + $result = $result->and(AcceptsResult::createNo([$reason])); } if (!$constantArrays[0]->isUnsealed()->no()) { @@ -609,6 +618,7 @@ public function accepts(Type $type, bool $strictTypes): AcceptsResult unset($keys[$keyType->getValue()]); } + $bleedingEdge = BleedingEdgeToggle::isBleedingEdge(); foreach ($keys as [$i, $extraKeyType]) { $acceptsKey = $unsealedKeyType->accepts($extraKeyType, $strictTypes)->decorateReasons( static fn (string $reason) => sprintf( @@ -630,24 +640,46 @@ public function accepts(Type $type, bool $strictTypes): AcceptsResult $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.', + $acceptsValue = $unsealedValueType->accepts($extraValueType, $strictTypes); + if ($bleedingEdge) { + // the same offset paths as of the known keys, so they read and group the same way + if (!$acceptsValue->yes() || count($acceptsValue->reasons) > 0) { + $verbosity = VerbosityLevel::getRecommendedLevelByType($unsealedValueType, $extraValueType); + if (count($acceptsValue->reasons) > 0) { + $acceptsValue = $acceptsValue->decorateReasons( + static fn (string $reason) => AcceptsReasonHelper::prependOffset($extraKeyType, $unsealedValueType, $extraValueType, $verbosity, $reason), + ); + } else { + $acceptsValue = new AcceptsResult($acceptsValue->result, [ + sprintf( + 'Offset %s (%s) does not accept type %s.', + $extraKeyType->describe(VerbosityLevel::precise()), + $unsealedValueType->describe($verbosity), + $extraValueType->describe($verbosity), + ), + ]); + } + } + } else { + $acceptsValue = $acceptsValue->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); } @@ -737,15 +769,21 @@ private function checkOurKeys(Type $type, bool $strictTypes): AcceptsResult } $verbosity = VerbosityLevel::getRecommendedLevelByType($valueType, $otherValueType); - $acceptsValue = $acceptsValue->decorateReasons( - static fn (string $reason) => sprintf( - 'Offset %s (%s) does not accept type %s: %s', - $keyType->describe(VerbosityLevel::precise()), - $valueType->describe($verbosity), - $otherValueType->describe($verbosity), - $reason, - ), - ); + if (BleedingEdgeToggle::isBleedingEdge()) { + $acceptsValue = $acceptsValue->decorateReasons( + static fn (string $reason) => AcceptsReasonHelper::prependOffset($keyType, $valueType, $otherValueType, $verbosity, $reason), + ); + } else { + $acceptsValue = $acceptsValue->decorateReasons( + static fn (string $reason) => sprintf( + 'Offset %s (%s) does not accept type %s: %s', + $keyType->describe(VerbosityLevel::precise()), + $valueType->describe($verbosity), + $otherValueType->describe($verbosity), + $reason, + ), + ); + } if (!$acceptsValue->yes() && count($acceptsValue->reasons) === 0 && $type->isConstantArray()->yes()) { $acceptsValue = new AcceptsResult($acceptsValue->result, [ sprintf( @@ -902,10 +940,19 @@ public function isSuperTypeOf(Type $type): IsSuperTypeOfResult */ private function sealedArrayShapesCannotBeIntersectedReason(self $type): string { + $left = $this; + $right = $type; + if (BleedingEdgeToggle::isBleedingEdge()) { + // only the keys matter here; without the literal values the reasons + // of many similar shapes (e.g. members of a union) are the same + $left = $left->generalizeValues(); + $right = $right->generalizeValues(); + } + return sprintf( 'Sealed array shapes %s and %s cannot be intersected. Unseal at least one of them with ... syntax. Learn more: %s', - $this->describe(VerbosityLevel::value()), - $type->describe(VerbosityLevel::value()), + $left->describe(VerbosityLevel::value()), + $right->describe(VerbosityLevel::value()), self::UNSEALED_ARRAY_SHAPES_LINK, ); } diff --git a/src/Type/IntersectionType.php b/src/Type/IntersectionType.php index af13bc1ee3a..52b70e75f6a 100644 --- a/src/Type/IntersectionType.php +++ b/src/Type/IntersectionType.php @@ -2,6 +2,7 @@ namespace PHPStan\Type; +use PHPStan\DependencyInjection\BleedingEdgeToggle; use PHPStan\Internal\CombinationsHelper; use PHPStan\Php\PhpVersion; use PHPStan\PhpDocParser\Ast\Type\ArrayShapeNode; @@ -272,23 +273,29 @@ public function accepts(Type $otherType, bool $strictTypes): AcceptsResult if (!$result->yes()) { $isList = $otherType->isList(); - $reasons = $result->reasons; - $verbosity = VerbosityLevel::getRecommendedLevelByType($this, $otherType); - if ($this->isList()->yes() && !$isList->yes()) { - $reasons[] = sprintf( - '%s %s a list.', - $otherType->describe($verbosity), - $isList->no() ? 'is not' : 'might not be', - ); - } - $isNonEmpty = $otherType->isIterableAtLeastOnce(); - if ($this->isIterableAtLeastOnce()->yes() && !$isNonEmpty->yes()) { - $reasons[] = sprintf( - '%s %s empty.', - $otherType->describe($verbosity), - $isNonEmpty->no() ? 'is' : 'might be', - ); + $reportList = $this->isList()->yes() && !$isList->yes(); + $reportEmpty = $this->isIterableAtLeastOnce()->yes() && !$isNonEmpty->yes(); + $reasons = $result->reasons; + if ($reportList || $reportEmpty) { + $verbosity = VerbosityLevel::getRecommendedLevelByType($this, $otherType); + $otherTypeDescription = BleedingEdgeToggle::isBleedingEdge() && $otherType->isArray()->yes() + ? AcceptsReasonHelper::describeSubject($otherType, $verbosity, 'Array') + : $otherType->describe($verbosity); + if ($reportList) { + $reasons[] = sprintf( + '%s %s a list.', + $otherTypeDescription, + $isList->no() ? 'is not' : 'might not be', + ); + } + if ($reportEmpty) { + $reasons[] = sprintf( + '%s %s empty.', + $otherTypeDescription, + $isNonEmpty->no() ? 'is' : 'might be', + ); + } } if (count($reasons) > 0) { diff --git a/src/Type/ObjectShapeType.php b/src/Type/ObjectShapeType.php index fec23e94c59..f3b38f46b2d 100644 --- a/src/Type/ObjectShapeType.php +++ b/src/Type/ObjectShapeType.php @@ -3,6 +3,7 @@ namespace PHPStan\Type; use PHPStan\Analyser\OutOfClassScope; +use PHPStan\DependencyInjection\BleedingEdgeToggle; use PHPStan\PhpDocParser\Ast\ConstExpr\ConstExprStringNode; use PHPStan\PhpDocParser\Ast\Type\ConstTypeNode; use PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode; @@ -182,11 +183,14 @@ public function accepts(Type $type, bool $strictTypes): AcceptsResult $result = AcceptsResult::createYes(); $scope = new OutOfClassScope(); + $bleedingEdge = BleedingEdgeToggle::isBleedingEdge(); + $missingProperties = []; + $possiblyMissingProperties = []; foreach ($this->properties as $propertyName => $propertyType) { $typeHasProperty = $type->hasInstanceProperty((string) $propertyName); $hasProperty = new AcceptsResult( $typeHasProperty, - $typeHasProperty->yes() ? [] : [ + $typeHasProperty->yes() || $bleedingEdge ? [] : [ sprintf( '%s %s have property $%s.', $type->describe(VerbosityLevel::typeOnly()), @@ -206,11 +210,13 @@ public function accepts(Type $type, bool $strictTypes): AcceptsResult continue; } $result = $result->and($hasProperty); + $missingProperties[] = (string) $propertyName; continue; } if ($hasProperty->maybe()) { if (!in_array($propertyName, $this->optionalProperties, true)) { $result = $result->and($hasProperty); + $possiblyMissingProperties[] = (string) $propertyName; continue; } @@ -270,7 +276,12 @@ public function accepts(Type $type, bool $strictTypes): AcceptsResult $result = $result->and($acceptsValue); } - return $result->and(new AcceptsResult($type->isObject(), [])); + $result = $result->and(new AcceptsResult($type->isObject(), [])); + if ($bleedingEdge) { + $result = $result->and(new AcceptsResult(TrinaryLogic::createYes(), self::missingPropertiesReasons($type, $missingProperties, $possiblyMissingProperties))); + } + + return $result; } public function isSuperTypeOf(Type $type): IsSuperTypeOfResult @@ -297,11 +308,14 @@ public function isSuperTypeOf(Type $type): IsSuperTypeOfResult $result = IsSuperTypeOfResult::createYes(); $scope = new OutOfClassScope(); + $bleedingEdge = BleedingEdgeToggle::isBleedingEdge(); + $missingProperties = []; + $possiblyMissingProperties = []; foreach ($this->properties as $propertyName => $propertyType) { $typeHasProperty = $type->hasInstanceProperty((string) $propertyName); $hasProperty = new IsSuperTypeOfResult( $typeHasProperty, - $typeHasProperty->yes() ? [] : [ + $typeHasProperty->yes() || $bleedingEdge ? [] : [ sprintf( '%s %s have property $%s.', $type->describe(VerbosityLevel::typeOnly()), @@ -315,11 +329,13 @@ public function isSuperTypeOf(Type $type): IsSuperTypeOfResult continue; } $result = $result->and($hasProperty); + $missingProperties[] = (string) $propertyName; continue; } if ($hasProperty->maybe()) { if (!in_array($propertyName, $this->optionalProperties, true)) { $result = $result->and($hasProperty); + $possiblyMissingProperties[] = (string) $propertyName; continue; } @@ -359,7 +375,35 @@ public function isSuperTypeOf(Type $type): IsSuperTypeOfResult $result = $result->and($isSuperType); } - return $result->and(IsSuperTypeOfResult::createFromTrinaryLogic($type->isObject())); + $result = $result->and(IsSuperTypeOfResult::createFromTrinaryLogic($type->isObject())); + if ($bleedingEdge) { + $result = $result->and(new IsSuperTypeOfResult(TrinaryLogic::createYes(), self::missingPropertiesReasons($type, $missingProperties, $possiblyMissingProperties))); + } + + return $result; + } + + /** + * @param list $missingProperties + * @param list $possiblyMissingProperties + * @return list + */ + private static function missingPropertiesReasons(Type $type, array $missingProperties, array $possiblyMissingProperties): array + { + if (count($missingProperties) === 0 && count($possiblyMissingProperties) === 0) { + return []; + } + + $subject = AcceptsReasonHelper::describeSubject($type, VerbosityLevel::typeOnly(), 'Object'); + $reasons = []; + if (count($missingProperties) > 0) { + $reasons[] = AcceptsReasonHelper::missingProperties($subject, $missingProperties, true); + } + if (count($possiblyMissingProperties) > 0) { + $reasons[] = AcceptsReasonHelper::missingProperties($subject, $possiblyMissingProperties, false); + } + + return $reasons; } public function equals(Type $type): bool diff --git a/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php b/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php index 67b5837fe6d..48166e64ac5 100644 --- a/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php +++ b/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php @@ -71,7 +71,7 @@ public function testJointTemplateInferenceErrors(): void [ 'Parameter #1 $promises of function JointInference\\all expects array>, array{JointInference\\Promise<1>, JointInference\\Promise<2>} given.', 13, - 'Template type T on class JointInference\\Promise is not covariant. Learn more: https://phpstan.org/blog/whats-up-with-template-covariant', + "• Offset 0 (JointInference\\Promise<1|2>) does not accept type JointInference\\Promise<1>: Template type T on class JointInference\\Promise is not covariant. Learn more: https://phpstan.org/blog/whats-up-with-template-covariant\n• Offset 1 (JointInference\\Promise<1|2>) does not accept type JointInference\\Promise<2>: Template type T on class JointInference\\Promise is not covariant. Learn more: https://phpstan.org/blog/whats-up-with-template-covariant", ], [ 'Parameter #1 $a of function JointInference\\both expects JointInference\\Box, JointInference\\Box given.', @@ -3145,7 +3145,7 @@ public function testBug11494(): void [ '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: Array does not have offset 'long'.\n• Type #2 from the union: Sealed array shape does not accept array with extra key 'extra'.", + "• Type #1 from the union: Array does not have offset 'long'.\n• Type #2 from the union: Sealed array shape does not accept array with extra key 'extra' => 'other'.", ], ]); } @@ -3326,6 +3326,42 @@ public function testBug11510(): void } #[RequiresPhp('>= 8.1.0')] + public function testReadableAcceptsReasons(): void + { + $this->analyse([__DIR__ . '/data/readable-accepts-reasons.php'], [ + [ + 'Parameter #1 $a of function ReadableAcceptsReasons\\unsealedExtraOffsets expects array{id: int, ...}, array{id: 1, k0: array{x: \'a0\'}, k1: array{x: \'a1\'}, k2: array{x: \'a2\'}, k3: array{x: \'a3\'}, k4: array{x: \'a4\'}, k5: array{x: \'a5\'}} given.', + 22, + 'Offsets \'k0\' › \'x\', \'k1\' › \'x\', \'k2\' › \'x\' and 3 more (int) does not accept type string.', + ], + [ + 'Parameter #1 $o of function ReadableAcceptsReasons\\missingProperties expects object{p0: int, p1: int, p2: int, p3: int, p4: int, p5: int}, object{p0: int, p1: int, p2: int}&stdClass given.', + 23, + 'object{p0: int, p1: int, p2: int} does not have properties $p3, $p4 and $p5.', + ], + [ + 'Parameter #1 $x of function ReadableAcceptsReasons\\nonEmptyList expects non-empty-list, list given.', + 24, + 'Array might be empty.', + ], + [ + 'Parameter #1 $a of function ReadableAcceptsReasons\\listInShape expects array{a: list, b: list}, array{a: array{x: 1}, b: array{1}} given.', + 25, + 'Offset \'a\' (list): array{x: 1} is not a list.', + ], + [ + 'Parameter #1 $it of function ReadableAcceptsReasons\\iterateRows expects Iterator, ArrayIterator<0, array{id: 0, name: \'a0\'}|array{id: 1, name: \'a1\'}|array{id: 2, name: \'a2\'}|array{id: 3, name: \'a3\'}> given.', + 27, + 'Sealed array shapes array{id: int} and array{id: int, name: string} cannot be intersected. Unseal at least one of them with ... syntax. Learn more: https://phpstan.org/blog/phpstan-2-2-unsealed-array-shapes-safer-array-keys', + ], + [ + 'Parameter #1 $it of function ReadableAcceptsReasons\\iterateRows expects Iterator, ArrayIterator<0, array{id: 0, name0: \'a0\'}|array{id: 1, name1: \'a1\'}|array{id: 2, name2: \'a2\'}|array{id: 3, name3: \'a3\'}|array{id: 4, name4: \'a4\'}|array{id: 5, name5: \'a5\'}|array{id: 6, name6: \'a6\'}|array{id: 7, name7: \'a7\'}> given.', + 29, + "• Sealed array shapes array{id: int} and array{id: int, name0: string} cannot be intersected. Unseal at least one of them with ... syntax. Learn more: https://phpstan.org/blog/phpstan-2-2-unsealed-array-shapes-safer-array-keys\n• Sealed array shapes array{id: int} and array{id: int, name1: string} cannot be intersected. Unseal at least one of them with ... syntax. Learn more: https://phpstan.org/blog/phpstan-2-2-unsealed-array-shapes-safer-array-keys\n• Sealed array shapes array{id: int} and array{id: int, name2: string} cannot be intersected. Unseal at least one of them with ... syntax. Learn more: https://phpstan.org/blog/phpstan-2-2-unsealed-array-shapes-safer-array-keys\n• Sealed array shapes array{id: int} and array{id: int, name3: string} cannot be intersected. Unseal at least one of them with ... syntax. Learn more: https://phpstan.org/blog/phpstan-2-2-unsealed-array-shapes-safer-array-keys\n• Sealed array shapes array{id: int} and array{id: int, name4: string} cannot be intersected. Unseal at least one of them with ... syntax. Learn more: https://phpstan.org/blog/phpstan-2-2-unsealed-array-shapes-safer-array-keys\n• 3 more reasons not shown.", + ], + ]); + } + public function testBug15432ImplicitMixed(): void { // the closures are typed from the walked sibling items - never from @@ -3341,7 +3377,7 @@ public function testBug15432(): void [ 'Parameter #1 $callbacks of function Bug15432Rule\\consume expects array<\'closure\'|\'method\', callable(): void>, array{method: Closure(int): void, closure: Closure(): void} given.', 34, - 'Parameter #1 $i of passed callable is required but accepting callable does not have that parameter. It will be called without it.', + 'Offset \'method\' (callable(): void) does not accept type Closure(int): void: Parameter #1 $i of passed callable is required but accepting callable does not have that parameter. It will be called without it.', ], [ 'Parameter #1 $callbacks of function Bug15432Rule\\consumeIntToString expects array<\'closure\'|\'method\', callable(int): string>, array{method: Closure(string): int, closure: Closure(int): decimal-int-string} given.', diff --git a/tests/PHPStan/Rules/Functions/ReturnTypeRuleTest.php b/tests/PHPStan/Rules/Functions/ReturnTypeRuleTest.php index daccbe3882c..a62281c3e08 100644 --- a/tests/PHPStan/Rules/Functions/ReturnTypeRuleTest.php +++ b/tests/PHPStan/Rules/Functions/ReturnTypeRuleTest.php @@ -243,7 +243,7 @@ public function testBug7766(): void [ "Function Bug7766\problem() should return array but returns array{array{id: 1, created: DateTimeImmutable, updated: DateTimeImmutable, valid_from: DateTimeImmutable, valid_till: DateTimeImmutable, string: 'string', other_string: 'string', another_string: 'string', ...}}.", 20, - "Offset 'count' (int<0, max>) does not accept type '4'.", + "Offset 0 › 'count' (int<0, max>) does not accept type '4'.", ], ]); } @@ -458,7 +458,7 @@ public function testBug13565(): void [ '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\'.', + 'Sealed array shape does not accept array with extra key \'email\' => Bug13565\NotAString.', ], ]); } diff --git a/tests/PHPStan/Rules/Functions/data/readable-accepts-reasons.php b/tests/PHPStan/Rules/Functions/data/readable-accepts-reasons.php new file mode 100644 index 00000000000..5167754d858 --- /dev/null +++ b/tests/PHPStan/Rules/Functions/data/readable-accepts-reasons.php @@ -0,0 +1,30 @@ +} $a */ +function unsealedExtraOffsets(array $a): void {} + +/** @param object{p0: int, p1: int, p2: int, p3: int, p4: int, p5: int} $o */ +function missingProperties(object $o): void {} + +/** @param non-empty-list $x */ +function nonEmptyList(array $x): void {} + +/** @param array{a: list, b: list} $a */ +function listInShape(array $a): void {} + +/** @param \Iterator $it */ +function iterateRows(\Iterator $it): void {} + +function doFoo(bool $c, int $i): void +{ + unsealedExtraOffsets(['id' => 1, 'k0' => ['x' => 'a0'], 'k1' => ['x' => 'a1'], 'k2' => ['x' => 'a2'], 'k3' => ['x' => 'a3'], 'k4' => ['x' => 'a4'], 'k5' => ['x' => 'a5']]); + missingProperties((object) ['p0' => 0, 'p1' => 1, 'p2' => 2]); + nonEmptyList($c ? [] : ['v0', 'v1', 'v2', 'v3', 'v4', 'v5', 'v6', 'v7', 'v8', 'v9', 'v10', 'v11', 'v12', 'v13', 'v14', 'v15', 'v16', 'v17', 'v18', 'v19', 'v20', 'v21', 'v22', 'v23', 'v24', 'v25', 'v26', 'v27', 'v28', 'v29', 'v30', 'v31', 'v32', 'v33', 'v34', 'v35', 'v36', 'v37', 'v38', 'v39', 'v40', 'v41', 'v42', 'v43', 'v44', 'v45', 'v46', 'v47', 'v48', 'v49', 'v50', 'v51', 'v52', 'v53', 'v54', 'v55', 'v56', 'v57', 'v58', 'v59', 'v60', 'v61', 'v62', 'v63', 'v64', 'v65', 'v66', 'v67', 'v68', 'v69', 'v70', 'v71', 'v72', 'v73', 'v74', 'v75', 'v76', 'v77', 'v78', 'v79', 'v80', 'v81', 'v82', 'v83', 'v84', 'v85', 'v86', 'v87', 'v88', 'v89', 'v90', 'v91', 'v92', 'v93', 'v94', 'v95', 'v96', 'v97', 'v98', 'v99', 'v100', 'v101', 'v102', 'v103', 'v104', 'v105', 'v106', 'v107', 'v108', 'v109', 'v110', 'v111', 'v112', 'v113', 'v114', 'v115', 'v116', 'v117', 'v118', 'v119', 'v120', 'v121', 'v122', 'v123', 'v124', 'v125', 'v126', 'v127', 'v128', 'v129', 'v130', 'v131', 'v132', 'v133', 'v134', 'v135', 'v136', 'v137', 'v138', 'v139', 'v140', 'v141', 'v142', 'v143', 'v144', 'v145', 'v146', 'v147', 'v148', 'v149']); + listInShape(['a' => ['x' => 1], 'b' => [1]]); + $rows = [['id' => 0, 'name' => 'a0'], ['id' => 1, 'name' => 'a1'], ['id' => 2, 'name' => 'a2'], ['id' => 3, 'name' => 'a3']]; + iterateRows(new \ArrayIterator([$rows[$i]])); + $otherRows = [['id' => 0, 'name0' => 'a0'], ['id' => 1, 'name1' => 'a1'], ['id' => 2, 'name2' => 'a2'], ['id' => 3, 'name3' => 'a3'], ['id' => 4, 'name4' => 'a4'], ['id' => 5, 'name5' => 'a5'], ['id' => 6, 'name6' => 'a6'], ['id' => 7, 'name7' => 'a7']]; + iterateRows(new \ArrayIterator([$otherRows[$i]])); +} diff --git a/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php b/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php index e03a97fc739..bf216057c58 100644 --- a/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php +++ b/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php @@ -571,12 +571,12 @@ public function testCallMethods(): void [ 'Parameter #1 $param of method Test\ConstantArrayAccepts::doBar() expects array{name: string, color?: string}, array{name: string, color: string, year: int} given.', 1614, - "Sealed array shape does not accept array with extra key 'year'.", + "Sealed array shape does not accept array with extra key 'year' => int.", ], [ 'Parameter #1 $params of method Test\ConstantArrayAcceptsOptionalKey::doFoo() expects array{wrapperClass?: class-string}, array{wrapperClass: \'stdClass\', undocumented: 42} given.', 1638, - "Sealed array shape does not accept array with extra key 'undocumented'.", + "Sealed array shape does not accept array with extra key 'undocumented' => 42.", ], [ 'Parameter #1 $test of method Test\NumericStringParam::sayHello() expects numeric-string, 123 given.', @@ -908,12 +908,12 @@ public function testCallMethodsOnThisOnly(): void [ 'Parameter #1 $param of method Test\ConstantArrayAccepts::doBar() expects array{name: string, color?: string}, array{name: string, color: string, year: int} given.', 1614, - "Sealed array shape does not accept array with extra key 'year'.", + "Sealed array shape does not accept array with extra key 'year' => int.", ], [ 'Parameter #1 $params of method Test\ConstantArrayAcceptsOptionalKey::doFoo() expects array{wrapperClass?: class-string}, array{wrapperClass: \'stdClass\', undocumented: 42} given.', 1638, - "Sealed array shape does not accept array with extra key 'undocumented'.", + "Sealed array shape does not accept array with extra key 'undocumented' => 42.", ], [ 'Parameter #1 $test of method Test\NumericStringParam::sayHello() expects numeric-string, 123 given.', @@ -2341,12 +2341,12 @@ public function testBug5258(): void [ 'Parameter #1 $params of method Bug5258\HelloWorld::method2() expects array{other_key: string}, array{some_key: non-falsy-string, other_key: string} given.', 12, - "Sealed array shape does not accept array with extra key 'some_key'.", + "Sealed array shape does not accept array with extra key 'some_key' => non-falsy-string.", ], [ 'Parameter #1 $params of method Bug5258\HelloWorld::method2() expects array{other_key: string}, array{some_key?: string, other_key: non-falsy-string} given.', 14, - "Sealed array shape does not accept array with extra key 'some_key'.", + "Sealed array shape does not accept array with extra key 'some_key' => string.", ], ]); } @@ -3182,12 +3182,12 @@ public function testObjectShapes(): void [ 'Parameter #1 $o of method ObjectShapesAcceptance\Foo::doBar() expects object{foo: int, bar: string}, Exception given.', 14, - PHP_VERSION_ID >= 80200 ? "• Exception does not have property \$foo.\n• Exception does not have property \$bar." : "• Exception might not have property \$foo.\n• Exception might not have property \$bar.", + PHP_VERSION_ID >= 80200 ? 'Exception does not have properties $foo and $bar.' : 'Exception might not have properties $foo and $bar.', ], [ 'Parameter #1 $o of method ObjectShapesAcceptance\Foo::doBar() expects object{foo: int, bar: string}, Exception given.', 15, - "• Exception might not have property \$foo.\n• Exception might not have property \$bar.", + 'Exception might not have properties $foo and $bar.', ], [ 'Parameter #1 $o of method ObjectShapesAcceptance\Foo::doBar() expects object{foo: int, bar: string}, object{foo: string, bar: int} given.', @@ -3211,8 +3211,7 @@ public function testObjectShapes(): void [ 'Parameter #1 $o of method ObjectShapesAcceptance\Foo::doBar() expects object{foo: int, bar: string}, object given.', 55, - '• object might not have property $foo. -• object might not have property $bar.', + 'object might not have properties $foo and $bar.', ], [ 'Parameter #1 $o of method ObjectShapesAcceptance\Foo::doBar() expects object{foo: int, bar: string}, stdClass given.', diff --git a/tests/PHPStan/Rules/Methods/ReturnTypeRuleTest.php b/tests/PHPStan/Rules/Methods/ReturnTypeRuleTest.php index 260fde1475f..cfaeed2cb95 100644 --- a/tests/PHPStan/Rules/Methods/ReturnTypeRuleTest.php +++ b/tests/PHPStan/Rules/Methods/ReturnTypeRuleTest.php @@ -862,9 +862,21 @@ public function testBug8146bErrors(): void $this->checkBenevolentUnionTypes = true; $this->analyse([__DIR__ . '/data/bug-8146b-errors.php'], [ [ - "Method Bug8146bError\LocationFixtures::getData() should return array, coordinates: array{lat: float, lng: float, ...}}>> but returns array{Bács-Kiskun: array{Ágasegyháza: array{constituencies: array{'Bács-Kiskun 4.', true, false, Bug8146bError\X, null}, coordinates: array{lat: 46.8386043, lng: 19.4502899}}, Akasztó: array{constituencies: array{'Bács-Kiskun 3.'}, coordinates: array{lat: 46.6898175, lng: 19.205086}}, Apostag: array{constituencies: array{'Bács-Kiskun 3.'}, coordinates: array{lat: 46.8812652, lng: 18.9648478}}, Bácsalmás: array{constituencies: array{'Bács-Kiskun 5.'}, coordinates: array{lat: 46.1250396, lng: 19.3357509}}, Bácsbokod: array{constituencies: array{'Bács-Kiskun 6.'}, coordinates: array{lat: 46.1234737, lng: 19.155708}}, Bácsborsód: array{constituencies: array{'Bács-Kiskun 6.'}, coordinates: array{lat: 46.0989373, lng: 19.1566725}}, Bácsszentgyörgy: array{constituencies: array{'Bács-Kiskun 6.'}, coordinates: array{lat: 45.9746039, lng: 19.0398066}}, Bácsszőlős: array{constituencies: array{'Bács-Kiskun 5.'}, coordinates: array{lat: 46.1352003, lng: 19.4215997}}, ...}, Baranya: non-empty-array|(literal-string&non-falsy-string), float|(literal-string&non-falsy-string)>>>, Békés: array{Almáskamarás: array{constituencies: array{'Békés 4.'}, coordinates: array{lat: 46.4617785, lng: 21.092448}}, Battonya: array{constituencies: array{'Békés 4.'}, coordinates: array{lat: 46.2902462, lng: 21.0199215}}, Békés: array{constituencies: array{'Békés 2.'}, coordinates: array{lat: 46.6704899, lng: 21.0434996}}, Békéscsaba: array{constituencies: array{'Békés 1.'}, coordinates: array{lat: 46.6735939, lng: 21.0877309}}, Békéssámson: array{constituencies: array{'Békés 4.'}, coordinates: array{lat: 46.4208677, lng: 20.6176498}}, Békésszentandrás: array{constituencies: array{'Békés 2.'}, coordinates: array{lat: 46.8715996, lng: 20.48336}}, Bélmegyer: array{constituencies: array{'Békés 3.'}, coordinates: array{lat: 46.8726019, lng: 21.1832832}}, Biharugra: array{constituencies: array{'Békés 3.'}, coordinates: array{lat: 46.9691009, lng: 21.5987651}}, ...}, Borsod-Abaúj-Zemplén: non-empty-array|(literal-string&non-falsy-string), float|(literal-string&non-falsy-string)>>>, Budapest: array{'Budapest I. ker.': array{constituencies: array{'Budapest 01.'}, coordinates: array{lat: 47.4968219, lng: 19.037458}}, 'Budapest II. ker.': array{constituencies: array{'Budapest 03.', 'Budapest 04.'}, coordinates: array{lat: 47.5393329, lng: 18.986934}}, 'Budapest III. ker.': array{constituencies: array{'Budapest 04.', 'Budapest 10.'}, coordinates: array{lat: 47.5671768, lng: 19.0368517}}, 'Budapest IV. ker.': array{constituencies: array{'Budapest 11.', 'Budapest 12.'}, coordinates: array{lat: 47.5648915, lng: 19.0913149}}, 'Budapest V. ker.': array{constituencies: array{'Budapest 01.'}, coordinates: array{lat: 47.5002319, lng: 19.0520181}}, 'Budapest VI. ker.': array{constituencies: array{'Budapest 05.'}, coordinates: array{lat: 47.509863, lng: 19.0625813}}, 'Budapest VII. ker.': array{constituencies: array{'Budapest 05.'}, coordinates: array{lat: 47.5027289, lng: 19.073376}}, 'Budapest VIII. ker.': array{constituencies: array{'Budapest 01.', 'Budapest 06.'}, coordinates: array{lat: 47.4894184, lng: 19.070668}}, ...}, Csongrád-Csanád: array{Algyő: array{constituencies: array{'Csongrád-Csanád 4.'}, coordinates: array{lat: 46.3329625, lng: 20.207889}}, Ambrózfalva: array{constituencies: array{'Csongrád-Csanád 4.'}, coordinates: array{lat: 46.3501417, lng: 20.7313995}}, Apátfalva: array{constituencies: array{'Csongrád-Csanád 4.'}, coordinates: array{lat: 46.173317, lng: 20.5800472}}, Árpádhalom: array{constituencies: array{'Csongrád-Csanád 3.'}, coordinates: array{lat: 46.6158286, lng: 20.547733}}, Ásotthalom: array{constituencies: array{'Csongrád-Csanád 2.'}, coordinates: array{lat: 46.1995983, lng: 19.7833756}}, Baks: array{constituencies: array{'Csongrád-Csanád 3.'}, coordinates: array{lat: 46.5518708, lng: 20.1064166}}, Balástya: array{constituencies: array{'Csongrád-Csanád 3.'}, coordinates: array{lat: 46.4261828, lng: 20.004933}}, Bordány: array{constituencies: array{'Csongrád-Csanád 2.'}, coordinates: array{lat: 46.3194213, lng: 19.9227063}}, ...}, Fejér: array{Aba: array{constituencies: array{'Fejér 5.'}, coordinates: array{lat: 47.0328193, lng: 18.522359}}, Adony: array{constituencies: array{'Fejér 4.'}, coordinates: array{lat: 47.119831, lng: 18.8612469}}, Alap: array{constituencies: array{'Fejér 5.'}, coordinates: array{lat: 46.8075763, lng: 18.684028}}, Alcsútdoboz: array{constituencies: array{'Fejér 3.'}, coordinates: array{lat: 47.4277067, lng: 18.6030325}}, Alsószentiván: array{constituencies: array{'Fejér 5.'}, coordinates: array{lat: 46.7910573, lng: 18.732161}}, Bakonycsernye: array{constituencies: array{'Fejér 2.'}, coordinates: array{lat: 47.321719, lng: 18.0907379}}, Bakonykúti: array{constituencies: array{'Fejér 2.'}, coordinates: array{lat: 47.2458464, lng: 18.195769}}, Balinka: array{constituencies: array{'Fejér 2.'}, coordinates: array{lat: 47.3135736, lng: 18.1907168}}, ...}, Győr-Moson-Sopron: array{Abda: array{constituencies: array{'Győr-Moson-Sopron 5.'}, coordinates: array{lat: 47.6962149, lng: 17.5445786}}, Acsalag: array{constituencies: array{'Győr-Moson-Sopron 3.'}, coordinates: array{lat: 47.676095, lng: 17.1977771}}, Ágfalva: array{constituencies: array{'Győr-Moson-Sopron 4.'}, coordinates: array{lat: 47.688862, lng: 16.5110233}}, Agyagosszergény: array{constituencies: array{'Győr-Moson-Sopron 3.'}, coordinates: array{lat: 47.608545, lng: 16.9409912}}, Árpás: array{constituencies: array{'Győr-Moson-Sopron 3.'}, coordinates: array{lat: 47.5134127, lng: 17.3931579}}, Ásványráró: array{constituencies: array{'Győr-Moson-Sopron 5.'}, coordinates: array{lat: 47.8287695, lng: 17.499195}}, Babót: array{constituencies: array{'Győr-Moson-Sopron 3.'}, coordinates: array{lat: 47.5752269, lng: 17.0758604}}, Bágyogszovát: array{constituencies: array{'Győr-Moson-Sopron 3.'}, coordinates: array{lat: 47.5866036, lng: 17.3617273}}, ...}, ...}.", + 'Method Bug8146bError\\LocationFixtures::getData() should return array, coordinates: array{lat: float, lng: float, ...}}>> but returns non-empty-array|(literal-string&non-falsy-string), bool|Bug8146bError\\X|float|(literal-string&non-falsy-string)|null>>>>.', 12, - "• Offset 'constituencies' (non-empty-list) does not accept type array{'Bács-Kiskun 4.', true, false, Bug8146bError\X, null}.\n• Sealed array shape can only accept a constant array. Extra keys are not allowed.", + "• Offset 'Bács-Kiskun' › 'Ágasegyháza' › 'constituencies' (non-empty-list) does not accept type array{'Bács-Kiskun 4.', true, false, Bug8146bError\X, null}.\n• Offsets 'Baranya' and 'Borsod-Abaúj-Zemplén' (array, coordinates: array{lat: float, lng: float, ...}}>) does not accept type non-empty-array|(literal-string&non-falsy-string), float|(literal-string&non-falsy-string)>>>: Sealed array shape can only accept a constant array. Extra keys are not allowed.", + ], + ]); + } + + #[RequiresPhp('>= 8.0.0')] + public function testNestedArrayShapeReturnReasons(): void + { + $this->analyse([__DIR__ . '/data/nested-array-shape-return-reasons.php'], [ + [ + 'Method NestedArrayShapeReturnReasons\\CapacityHelperTest::capacitiesData() should return array, bool}>, int, list, bool}> but returns non-empty-array, bool, bool}>, int, list, bool}>.', + 71, + "• Offsets 'case 0' › 0 › 0, 'case 2' › 0 › 1, 'case 3' › 0 › 0 and 4 more (array{int, int, int, list, bool}): Sealed array shape does not accept array with extra key 5 => true.\n• Offsets 'case 1' › 0 › 0, 'case 2' › 0 › 0, 'case 4' › 0 › 1 and 2 more (array{int, int, int, list, bool}): Sealed array shape does not accept array with extra key 5 => false.", ], ]); } diff --git a/tests/PHPStan/Rules/Methods/data/nested-array-shape-return-reasons.php b/tests/PHPStan/Rules/Methods/data/nested-array-shape-return-reasons.php new file mode 100644 index 00000000000..c95b24fee7d --- /dev/null +++ b/tests/PHPStan/Rules/Methods/data/nested-array-shape-return-reasons.php @@ -0,0 +1,140 @@ += 8.0 + +namespace NestedArrayShapeReturnReasons; + +final class ChildData +{ + + public function __construct(private int $age, private ?bool $bedEntitlement) + { + } + +} + +final class CapacityHelperTest +{ + + /** + * @return array, bool}>, int, list, bool}> + */ + public static function capacitiesData(): array + { + $case = static fn ( + array $capacities, + int $adults, + array $children = [], + bool $hasAnimals = false, + ): array => [ + array_values($capacities), + $adults, + array_map( + static fn (array|int $child): ChildData => is_array($child) + ? new ChildData($child[0], $child[1]) + : new ChildData($child, null), + array_values($children), + ), + $hasAnimals, + ]; + + $child = static fn (int $age, ?bool $withBed = null): array => [$age, $withBed]; + + $filteredCapacity = static fn ( + int $maxRooms, + int $totalBeds, + int $minPeople = 1, + array $bedsMaxAges = [], + bool $hasPetsAllowed = false, + ): array => [ + $maxRooms, + $totalBeds, + $minPeople, + array_values($bedsMaxAges), + $hasPetsAllowed, + true, + ]; + + $discardedCapacity = static fn ( + int $maxRooms, + int $totalBeds, + int $minPeople = 1, + array $bedsMaxAges = [], + bool $hasPetsAllowed = false, + ): array => [ + $maxRooms, + $totalBeds, + $minPeople, + array_values($bedsMaxAges), + $hasPetsAllowed, + false, + ]; + + return [ + 'case 0' => $case( + capacities: [ + $filteredCapacity(maxRooms: 1, totalBeds: 2), + ], + adults: 2, + ), + 'case 1' => $case( + capacities: [ + $discardedCapacity(maxRooms: 1, totalBeds: 2), + ], + adults: 3, + ), + 'case 2' => $case( + capacities: [ + $discardedCapacity(maxRooms: 1, totalBeds: 2), + $filteredCapacity(maxRooms: 1, totalBeds: 3), + ], + adults: 3, + ), + 'case 3' => $case( + capacities: [ + $filteredCapacity(maxRooms: 1, totalBeds: 2, bedsMaxAges: [12, 10]), + ], + adults: 1, + children: [$child(11), $child(4, false)], + ), + 'case 4' => $case( + capacities: [ + $filteredCapacity(maxRooms: 2, totalBeds: 4, minPeople: 3, hasPetsAllowed: true), + $discardedCapacity(maxRooms: 1, totalBeds: 4, bedsMaxAges: [16, 10, 5]), + ], + adults: 2, + children: [$child(15), $child(3, true), 8], + hasAnimals: true, + ), + 'case 5' => $case( + capacities: [ + $discardedCapacity(maxRooms: 1, totalBeds: 3, minPeople: 2, bedsMaxAges: [13, 10, 6]), + ], + adults: 4, + children: [$child(2)], + ), + 'case 6' => $case( + capacities: [ + $filteredCapacity(maxRooms: 3, totalBeds: 6, minPeople: 5, bedsMaxAges: [17, 12, 10]), + $filteredCapacity(maxRooms: 2, totalBeds: 5, minPeople: 4, bedsMaxAges: [3, 0]), + ], + adults: 5, + children: [$child(17), $child(12, true), $child(9, false), 1], + ), + 'case 7' => $case( + capacities: [ + $discardedCapacity(maxRooms: 2, totalBeds: 2, minPeople: 7, bedsMaxAges: [8]), + ], + adults: 2, + children: [$child(5), 5, 7], + hasAnimals: true, + ), + 'case 8' => $case( + capacities: [ + $filteredCapacity(maxRooms: 2, totalBeds: 3, minPeople: 8, bedsMaxAges: [11, 11]), + ], + adults: 3, + children: [$child(10, true), $child(13)], + ), + ]; + } + +} diff --git a/tests/PHPStan/Type/AcceptsReasonHelperTest.php b/tests/PHPStan/Type/AcceptsReasonHelperTest.php new file mode 100644 index 00000000000..9f0825640b3 --- /dev/null +++ b/tests/PHPStan/Type/AcceptsReasonHelperTest.php @@ -0,0 +1,86 @@ + [ + ['Type #1 from the union: foo.', 'Array does not have offset \'a\'.'], + ['Type #1 from the union: foo.', 'Array does not have offset \'a\'.'], + ]; + + yield 'single offset reason is unchanged' => [ + ["Offset 'a' › 0 (int): reason."], + ["Offset 'a' › 0 (int): reason."], + ]; + + yield 'two offsets' => [ + ["Offset 'a' › 0 (int): reason.", "Offset 'b' › 1 (int): reason."], + ["Offsets 'a' › 0 and 'b' › 1 (int): reason."], + ]; + + yield 'more offsets than listed' => [ + ['Offset 0 (int): reason.', 'Offset 1 (int): reason.', 'Offset 2 (int): reason.', 'Offset 3 (int): reason.', 'Offset 4 (int): reason.'], + ['Offsets 0, 1, 2 and 2 more (int): reason.'], + ]; + + yield 'groups keep the order of first appearance' => [ + ['Offset 0 (int): a.', 'Unrelated.', 'Offset 1 (int): b.', 'Offset 2 (int): a.'], + ['Offsets 0 and 2 (int): a.', 'Unrelated.', 'Offset 1 (int): b.'], + ]; + + yield 'quoted keys with escapes, separators and parentheses' => [ + ["Offset 'it\\'s › here' › 'x (y)' (int): reason.", 'Offset "a\\nb" (int): reason.', 'Offset -1 (int): reason.'], + ["Offsets 'it\\'s › here' › 'x (y)', \"a\\nb\" and -1 (int): reason."], + ]; + + yield 'leaf form without a nested reason' => [ + ["Offset 'a' (int) does not accept type string.", "Offset 'b' (int) does not accept type string."], + ["Offsets 'a' and 'b' (int) does not accept type string."], + ]; + + yield 'isSuperTypeOf() form' => [ + ["Offset 'a': reason.", "Offset 'b': reason."], + ["Offsets 'a' and 'b': reason."], + ]; + + yield 'not an offset path' => [ + ['Offset foo is weird.', 'Offset foo is weird.'], + ['Offset foo is weird.', 'Offset foo is weird.'], + ]; + } + + /** + * @param list $reasons + * @param list $expected + */ + #[DataProvider('dataGroupReasons')] + public function testGroupReasons(array $reasons, array $expected): void + { + $this->assertSame($expected, AcceptsReasonHelper::groupReasons($reasons)); + } + + public function testForTip(): void + { + $this->assertSame( + ['Offsets 0 and 1 (int): a.', 'b0.', 'b1.', 'b2.', 'b3.', '2 more reasons not shown.'], + AcceptsReasonHelper::forTip(['Offset 0 (int): a.', 'b0.', 'Offset 1 (int): a.', 'b1.', 'b2.', 'b3.', 'b4.', 'b5.']), + ); + $this->assertSame(['a.', 'b.'], AcceptsReasonHelper::forTip(['a.', 'b.'])); + } + + public function testMissingProperties(): void + { + $this->assertSame('Foo does not have property $a.', AcceptsReasonHelper::missingProperties('Foo', ['a'], true)); + $this->assertSame('Foo might not have properties $a and $b.', AcceptsReasonHelper::missingProperties('Foo', ['a', 'b'], false)); + $this->assertSame('Foo does not have properties $a, $b and $c.', AcceptsReasonHelper::missingProperties('Foo', ['a', 'b', 'c'], true)); + $this->assertSame('Foo does not have properties $a, $b, $c and 2 more.', AcceptsReasonHelper::missingProperties('Foo', ['a', 'b', 'c', 'd', 'e'], true)); + } + +} diff --git a/turbo-ext/src/ArrayType.cpp b/turbo-ext/src/ArrayType.cpp index 5cd812cd9a7..2d1c9cf0707 100644 --- a/turbo-ext/src/ArrayType.cpp +++ b/turbo-ext/src/ArrayType.cpp @@ -227,7 +227,8 @@ class ArrayType static zv::Val getConstantArrays() { return zv::Val(zv::Arr::empty()); } /* the CompoundType callback; for a ConstantArrayType the key and value - * types accepted pairwise; for an ArrayType the item and key types + * types accepted pairwise (on bleeding edge the value reasons prefixed + * with their offset); for an ArrayType the item and key types * accepted; no otherwise; UNDEF = pending exception */ zv::Val accepts(zval *type, bool strictTypes) const { @@ -250,6 +251,8 @@ class ArrayType /* $type->getValueTypes()[$i] — the same array on every iteration */ zv::Val valueTypes = pt_type_call_array(Z_OBJ_P(type), PT_LC("getvaluetypes"), 0, NULL); if (UNEXPECTED(valueTypes.isUndef())) return zv::Val(); + bool bleedingEdge; + if (UNEXPECTED(!pt_is_bleeding_edge(bleedingEdge))) return zv::Val(); for (zv::ArrayEntry entry : zv::ArrRef(keyTypes.raw())) { zval *keyType = entry.value().deref().raw(); zv::Ref valueType = zv::ArrRef(valueTypes.raw()).findIndex(entry.indexKey()); @@ -263,6 +266,10 @@ class ArrayType ZVAL_COPY_VALUE(&args[0], valueType.deref().raw()); zv::Val acceptsValue = pt_type_op(Z_OBJ_P(itemType.raw()), PT_OP_ACCEPTS, 2, args); if (UNEXPECTED(acceptsValue.isUndef())) return zv::Val(); + if (bleedingEdge) { + acceptsValue = pt_offset_accepts_reasons_prepend(acceptsValue.raw(), keyType, itemType.raw(), valueType.deref().raw()); + if (UNEXPECTED(acceptsValue.isUndef())) return zv::Val(); + } result = pt_type_result_and(pt_type_result_and(std::move(result), acceptsKey.raw()), acceptsValue.raw()); if (UNEXPECTED(result.isUndef())) return zv::Val(); } diff --git a/turbo-ext/src/ConstantArrayType.cpp b/turbo-ext/src/ConstantArrayType.cpp index 13ef70f9b54..bd05c91f4b8 100644 --- a/turbo-ext/src/ConstantArrayType.cpp +++ b/turbo-ext/src/ConstantArrayType.cpp @@ -1495,10 +1495,23 @@ class ConstantArrayType mapDel(keys.table(), value.raw()); } + bool bleedingEdge; + if (UNEXPECTED(!isBleedingEdge(bleedingEdge))) return zv::Val(); for (zv::ArrayEntry entry : keys.arrRef()) { - zv::Val description = describePrecise(entry.value().deref().raw()); + zval *extraKey = entry.value().deref().raw(); + zv::Val description = describePrecise(extraKey); if (UNEXPECTED(description.isUndef())) return zv::Val(); - result = pt_type_result_and(std::move(result), acceptsNoWithReason(zv::Val::adoptString(zend_strpprintf(0, "Sealed array shape does not accept array with extra key %s.", ZSTR_VAL(zv::Ref(description.raw()).asString())))).raw()); + zv::Val reason; + if (bleedingEdge) { + zv::Val extraValueType = callType(Z_OBJ_P(constantArray), PT_LC("getoffsetvaluetype"), 1, extraKey); + if (UNEXPECTED(extraValueType.isUndef())) return zv::Val(); + zv::Val valueDescription = describeValue(extraValueType.raw()); + if (UNEXPECTED(valueDescription.isUndef())) return zv::Val(); + reason = zv::Val::adoptString(zend_strpprintf(0, "Sealed array shape does not accept array with extra key %s => %s.", ZSTR_VAL(zv::Ref(description.raw()).asString()), ZSTR_VAL(zv::Ref(valueDescription.raw()).asString()))); + } else { + reason = zv::Val::adoptString(zend_strpprintf(0, "Sealed array shape does not accept array with extra key %s.", ZSTR_VAL(zv::Ref(description.raw()).asString()))); + } + result = pt_type_result_and(std::move(result), acceptsNoWithReason(std::move(reason)).raw()); if (UNEXPECTED(result.isUndef())) return zv::Val(); } @@ -1560,6 +1573,8 @@ class ConstantArrayType mapDel(keys.table(), value.raw()); } + bool bleedingEdge; + if (UNEXPECTED(!isBleedingEdge(bleedingEdge))) return zv::Val(); zv::Val otherValueTypes; for (zv::ArrayEntry entry : keys.arrRef()) { zval *pair = entry.value().deref().raw(); @@ -1590,13 +1605,32 @@ class ConstantArrayType ZVAL_COPY_VALUE(&args[0], extraValueType); zv::Val acceptsValue = pt_type_op(Z_OBJ_P(unsealedValueType), PT_OP_ACCEPTS, 2, args); if (UNEXPECTED(acceptsValue.isUndef())) return zv::Val(); - zv::Val captured = tripleOf(unsealedValueType, extraKeyType, extraValueType); - acceptsValue = decorateReasons(acceptsValue.raw(), unsealedValueReasonCallback, captured.raw(), NULL); - if (UNEXPECTED(acceptsValue.isUndef())) return zv::Val(); - if (UNEXPECTED(!isNonYesWithoutReasons(acceptsValue.raw(), reasonless))) return zv::Val(); - if (reasonless) { - acceptsValue = acceptsResultWithReason(acceptsValue.raw(), unsealedValueReason(unsealedValueType, extraKeyType, extraValueType, NULL)); + if (bleedingEdge) { + /* the same offset paths as of the known keys, so they read and group the same way */ + zend_long acceptsValueTrinary = pt_type_result_trinary(acceptsValue.raw()); + if (UNEXPECTED(acceptsValueTrinary < 0)) return zv::Val(); + zv::Val acceptsValueReasons = resultReasons(acceptsValue.raw()); + if (UNEXPECTED(acceptsValueReasons.isUndef())) return zv::Val(); + bool hasReasons = arrayCount(acceptsValueReasons.raw()) > 0; + if (acceptsValueTrinary != PT_TRI_YES || hasReasons) { + zv::Val verbosity = pt_type_verbosity_recommended(unsealedValueType, extraValueType); + if (UNEXPECTED(verbosity.isUndef())) return zv::Val(); + if (hasReasons) { + acceptsValue = prependOffsetToReasons(acceptsValue.raw(), extraKeyType, unsealedValueType, extraValueType, verbosity.raw()); + } else { + acceptsValue = acceptsResultWithReason(acceptsValue.raw(), offsetReason(extraKeyType, unsealedValueType, verbosity.raw(), extraValueType, NULL)); + } + if (UNEXPECTED(acceptsValue.isUndef())) return zv::Val(); + } + } else { + zv::Val captured = tripleOf(unsealedValueType, extraKeyType, extraValueType); + acceptsValue = decorateReasons(acceptsValue.raw(), unsealedValueReasonCallback, captured.raw(), NULL); if (UNEXPECTED(acceptsValue.isUndef())) return zv::Val(); + if (UNEXPECTED(!isNonYesWithoutReasons(acceptsValue.raw(), reasonless))) return zv::Val(); + if (reasonless) { + acceptsValue = acceptsResultWithReason(acceptsValue.raw(), unsealedValueReason(unsealedValueType, extraKeyType, extraValueType, NULL)); + if (UNEXPECTED(acceptsValue.isUndef())) return zv::Val(); + } } result = pt_type_result_and(std::move(result), acceptsValue.raw()); if (UNEXPECTED(result.isUndef())) return zv::Val(); @@ -1736,8 +1770,14 @@ class ConstantArrayType } zv::Val verbosity = pt_type_verbosity_recommended(valueType, otherValueType.raw()); if (UNEXPECTED(verbosity.isUndef())) return zv::Val(); - zv::Val captured = quadOf(keyType, valueType, verbosity.raw(), otherValueType.raw()); - acceptsValue = decorateReasons(acceptsValue.raw(), offsetReasonCallback, captured.raw(), NULL); + bool bleedingEdge; + if (UNEXPECTED(!isBleedingEdge(bleedingEdge))) return zv::Val(); + if (bleedingEdge) { + acceptsValue = prependOffsetToReasons(acceptsValue.raw(), keyType, valueType, otherValueType.raw(), verbosity.raw()); + } else { + zv::Val captured = quadOf(keyType, valueType, verbosity.raw(), otherValueType.raw()); + acceptsValue = decorateReasons(acceptsValue.raw(), offsetReasonCallback, captured.raw(), NULL); + } if (UNEXPECTED(acceptsValue.isUndef())) return zv::Val(); bool reasonless; if (UNEXPECTED(!isNonYesWithoutReasons(acceptsValue.raw(), reasonless))) return zv::Val(); @@ -1989,9 +2029,25 @@ class ConstantArrayType * discarded); UNDEF = pending exception */ zv::Val sealedArrayShapesCannotBeIntersectedReason(zval *type) const { - zv::Val thisDescription = thisDescribeValue(); - if (UNEXPECTED(thisDescription.isUndef())) return zv::Val(); - zv::Val typeDescription = describeValue(type); + bool bleedingEdge; + if (UNEXPECTED(!isBleedingEdge(bleedingEdge))) return zv::Val(); + zv::Val thisDescription; + zv::Val typeDescription; + if (bleedingEdge) { + /* only the keys matter here; without the literal values the + * reasons of many similar shapes are the same */ + zv::Val left = generalizeValues(); + if (UNEXPECTED(left.isUndef())) return zv::Val(); + zv::Val right = ConstantArrayType(Z_OBJ_P(type)).generalizeValues(); + if (UNEXPECTED(right.isUndef())) return zv::Val(); + thisDescription = describeValue(left.raw()); + if (UNEXPECTED(thisDescription.isUndef())) return zv::Val(); + typeDescription = describeValue(right.raw()); + } else { + thisDescription = thisDescribeValue(); + if (UNEXPECTED(thisDescription.isUndef())) return zv::Val(); + typeDescription = describeValue(type); + } if (UNEXPECTED(typeDescription.isUndef())) return zv::Val(); return zv::Val::adoptString(zend_strpprintf(0, "Sealed array shapes %s and %s cannot be intersected. Unseal at least one of them with ... syntax. Learn more: %s", ZSTR_VAL(zv::Ref(thisDescription.raw()).asString()), ZSTR_VAL(zv::Ref(typeDescription.raw()).asString()), PT_CAT_UNSEALED_ARRAY_SHAPES_LINK)); } @@ -5705,6 +5761,36 @@ class ConstantArrayType return arrayShapeNode(true, chosen, NULL, asList); } + /* AcceptsReasonHelper::prependOffset() over every reason of + * $result: a reason already holding an offset path gets $offsetType + * prepended to it, any other one the offset with its expected value + * type (and the accepted one unless it is an array shape); UNDEF = + * pending exception */ + static zv::Val prependOffsetToReasons(zval *result, zval *offsetType, zval *offsetValueType, zval *acceptedValueType, zval *verbosity) + { + zv::Arr captured = zv::Arr::create(4); + captured.push(zv::Ref(offsetType)); + captured.push(zv::Ref(offsetValueType)); + captured.push(zv::Ref(acceptedValueType)); + captured.push(zv::Ref(verbosity)); + zv::Val capturedVal(std::move(captured)); + return decorateReasons(result, prependOffsetReasonCallback, capturedVal.raw(), NULL); + } + + /* ArrayType::accepts()'s decoration of an entry's value result: the + * result itself when it has no reasons, else prependOffsetToReasons() + * at the verbosity recommended for the two value types; UNDEF = + * pending exception */ + static zv::Val prependOffsetToEntryReasons(zval *result, zval *offsetType, zval *offsetValueType, zval *acceptedValueType) + { + zv::Val reasons = resultReasons(result); + if (UNEXPECTED(reasons.isUndef())) return zv::Val(); + if (arrayCount(reasons.raw()) == 0) return zv::Val::copyOf(zv::Ref(result)); + zv::Val verbosity = pt_type_verbosity_recommended(offsetValueType, acceptedValueType); + if (UNEXPECTED(verbosity.isUndef())) return zv::Val(); + return prependOffsetToReasons(result, offsetType, offsetValueType, acceptedValueType, verbosity.raw()); + } + /* Strings::match($value, '~^(?:[\\]?+[a-z_\x80-\xFF][0-9a-z_\x80-\xFF-]*+)++$~si') !== null — * the same preg_match(), called as the internal function it is (the * pcre extension caches the compiled pattern), without the userland @@ -6237,6 +6323,44 @@ class ConstantArrayType result.intoReturnValue(return_value); } + /* AcceptsReasonHelper::prependOffset() */ + static zv::Val prependOffsetReason(zval *offsetType, zval *offsetValueType, zval *acceptedValueType, zval *verbosity, zend_string *reason) + { + static constexpr char offsetPrefix[] = "Offset "; + static constexpr size_t offsetPrefixLength = sizeof(offsetPrefix) - 1; + zv::Val offsetDescription = describePrecise(offsetType); + if (UNEXPECTED(offsetDescription.isUndef())) return zv::Val(); + if (ZSTR_LEN(reason) >= offsetPrefixLength && memcmp(ZSTR_VAL(reason), offsetPrefix, offsetPrefixLength) == 0) { + return zv::Val::adoptString(zend_strpprintf(0, "Offset %s › %s", ZSTR_VAL(zv::Ref(offsetDescription.raw()).asString()), ZSTR_VAL(reason) + offsetPrefixLength)); + } + zv::Val valueDescription = describeOf(offsetValueType, verbosity); + if (UNEXPECTED(valueDescription.isUndef())) return zv::Val(); + zend_long acceptedIsConstantArray = pt_type_op_trinary(Z_OBJ_P(acceptedValueType), PT_OP_IS_CONSTANT_ARRAY, 0, NULL); + if (UNEXPECTED(acceptedIsConstantArray < 0)) return zv::Val(); + if (acceptedIsConstantArray == PT_TRI_YES) { + return zv::Val::adoptString(zend_strpprintf(0, "Offset %s (%s): %s", ZSTR_VAL(zv::Ref(offsetDescription.raw()).asString()), ZSTR_VAL(zv::Ref(valueDescription.raw()).asString()), ZSTR_VAL(reason))); + } + zv::Val acceptedDescription = describeOf(acceptedValueType, verbosity); + if (UNEXPECTED(acceptedDescription.isUndef())) return zv::Val(); + return zv::Val::adoptString(zend_strpprintf(0, "Offset %s (%s) does not accept type %s: %s", ZSTR_VAL(zv::Ref(offsetDescription.raw()).asString()), ZSTR_VAL(zv::Ref(valueDescription.raw()).asString()), ZSTR_VAL(zv::Ref(acceptedDescription.raw()).asString()), ZSTR_VAL(reason))); + } + + /* prependOffsetToReasons()'s decorator: state0 = [offsetType, offsetValueType, acceptedValueType, verbosity] */ + static void prependOffsetReasonCallback(zval *captured, zval *state1, uint32_t argc, zval *argv, zval *return_value) + { + (void) state1; + zend_string *reason = reasonArgument(argc, argv); + if (UNEXPECTED(reason == NULL || Z_TYPE_P(captured) != IS_ARRAY)) return; + zval *offsetType = zend_hash_index_find(Z_ARRVAL_P(captured), 0); + zval *offsetValueType = zend_hash_index_find(Z_ARRVAL_P(captured), 1); + zval *acceptedValueType = zend_hash_index_find(Z_ARRVAL_P(captured), 2); + zval *verbosity = zend_hash_index_find(Z_ARRVAL_P(captured), 3); + ZEND_ASSERT(offsetType != NULL && offsetValueType != NULL && acceptedValueType != NULL && verbosity != NULL); + zv::Val result = prependOffsetReason(offsetType, offsetValueType, acceptedValueType, verbosity, reason); + if (UNEXPECTED(result.isUndef())) return; + result.intoReturnValue(return_value); + } + /* 'Unsealed array key type %s does not accept extra key type %s' */ static zv::Val unsealedKeyReason(zval *unsealedKeyType, zval *extraKeyType, zend_string *reason) { @@ -7112,6 +7236,16 @@ bool pt_constant_array_type_is_valid_identifier(zend_string *value, bool &out) return ConstantArrayType::isValidIdentifier(value, out); } +zv::Val pt_offset_accepts_reasons_prepend(zval *result, zval *offsetType, zval *offsetValueType, zval *acceptedValueType) +{ + return ConstantArrayType::prependOffsetToEntryReasons(result, offsetType, offsetValueType, acceptedValueType); +} + +bool pt_is_bleeding_edge(bool &out) +{ + return phpstanturbo::isBleedingEdge(out); +} + /* {{{ engine ABI glue: parameter parsing + registration */ #define PT_RETURN_BOOL_OR_THROW(expr) \ diff --git a/turbo-ext/src/ConstantArrayTypeBuilder.cpp b/turbo-ext/src/ConstantArrayTypeBuilder.cpp index 0464a8cb7f2..d8eccaf661e 100644 --- a/turbo-ext/src/ConstantArrayTypeBuilder.cpp +++ b/turbo-ext/src/ConstantArrayTypeBuilder.cpp @@ -73,7 +73,7 @@ class ConstantArrayTypeBuilder { zv::Val unsealed = zv::Val::null(); bool bleedingEdge; - if (UNEXPECTED(!isBleedingEdge(bleedingEdge))) return zv::Val(); + if (UNEXPECTED(!pt_is_bleeding_edge(bleedingEdge))) return zv::Val(); if (bleedingEdge) { zval neverRaw; if (UNEXPECTED(!pt_never_type_new(&neverRaw, true))) return zv::Val(); @@ -884,29 +884,6 @@ class ConstantArrayTypeBuilder return part; } - /* BleedingEdgeToggle::isBleedingEdge(): the private static the final - * class returns, read through the engine's static-property path under - * the class's own scope (as ConstantArrayType.cpp reads it); false = - * pending exception */ - static bool isBleedingEdge(bool &out) - { - zend_class_entry *ce = pt_class(PT_CLASS_BLEEDING_EDGE_TOGGLE); - if (UNEXPECTED(ce == NULL)) return false; - auto previousScope = EG(fake_scope); /* const from PHP 8.4 */ - EG(fake_scope) = ce; - zval *value = zend_read_static_property(ce, PT_LC("bleedingEdge"), 1); - EG(fake_scope) = previousScope; - if (EXPECTED(value != NULL && (Z_TYPE_P(value) == IS_TRUE || Z_TYPE_P(value) == IS_FALSE))) { - out = Z_TYPE_P(value) == IS_TRUE; - return true; - } - if (UNEXPECTED(EG(exception))) return false; - zv::Val result = pt_type_call_static(PT_CLASS_BLEEDING_EDGE_TOGGLE, PT_LC("isbleedingedge"), 0, NULL); - if (UNEXPECTED(result.isUndef())) return false; - out = zend_is_true(result.raw()); - return true; - } - /* [$keyType, $valueType] */ static zv::Arr pairOf(zval *keyType, zval *valueType) { diff --git a/turbo-ext/src/IntersectionType.cpp b/turbo-ext/src/IntersectionType.cpp index bdeb4a1755b..960bde1b979 100644 --- a/turbo-ext/src/IntersectionType.cpp +++ b/turbo-ext/src/IntersectionType.cpp @@ -537,39 +537,46 @@ class IntersectionType for (zv::ArrayEntry entry : zv::ArrRef(reasons.raw())) { collected.push(entry.value()); } - zv::Args args{&selfZv, otherType}; - zv::Val verbosity = pt_type_verbosity_recommended(&selfZv, otherType); - if (UNEXPECTED(verbosity.isUndef())) return zv::Val(); - zend_long thisIsList = thisTrinary(PT_LC("islist"), &IntersectionType::isList); - if (UNEXPECTED(thisIsList < 0)) return zv::Val(); - bool added = false; - if (thisIsList == PT_TRI_YES && isList != PT_TRI_YES) { - zv::Val description = describeOf(otherType, verbosity.raw()); - if (UNEXPECTED(description.isUndef())) return zv::Val(); - smart_str reason = {NULL, 0}; - smart_str_append(&reason, zv::Ref(description.raw()).asString()); - smart_str_appendl(&reason, isList == PT_TRI_NO ? " is not a list." : " might not be a list.", isList == PT_TRI_NO ? sizeof(" is not a list.") - 1 : sizeof(" might not be a list.") - 1); - smart_str_0(&reason); - collected.push(zv::Val::adoptString(reason.s)); - added = true; - } - zend_long isNonEmpty = pt_type_op_trinary(Z_OBJ_P(otherType), PT_OP_IS_ITERABLE_AT_LEAST_ONCE, 0, NULL); if (UNEXPECTED(isNonEmpty < 0)) return zv::Val(); + zend_long thisIsList = thisTrinary(PT_LC("islist"), &IntersectionType::isList); + if (UNEXPECTED(thisIsList < 0)) return zv::Val(); zend_long thisNonEmpty = thisTrinary(PT_LC("isiterableatleastonce"), &IntersectionType::isIterableAtLeastOnce); if (UNEXPECTED(thisNonEmpty < 0)) return zv::Val(); - if (thisNonEmpty == PT_TRI_YES && isNonEmpty != PT_TRI_YES) { + bool reportList = thisIsList == PT_TRI_YES && isList != PT_TRI_YES; + bool reportEmpty = thisNonEmpty == PT_TRI_YES && isNonEmpty != PT_TRI_YES; + if (reportList || reportEmpty) { + zv::Val verbosity = pt_type_verbosity_recommended(&selfZv, otherType); + if (UNEXPECTED(verbosity.isUndef())) return zv::Val(); + bool bleedingEdge; + if (UNEXPECTED(!pt_is_bleeding_edge(bleedingEdge))) return zv::Val(); + zend_long otherIsArray = PT_TRI_NO; + if (bleedingEdge) { + otherIsArray = pt_type_op_trinary(Z_OBJ_P(otherType), PT_OP_IS_ARRAY, 0, NULL); + if (UNEXPECTED(otherIsArray < 0)) return zv::Val(); + } zv::Val description = describeOf(otherType, verbosity.raw()); if (UNEXPECTED(description.isUndef())) return zv::Val(); - smart_str reason = {NULL, 0}; - smart_str_append(&reason, zv::Ref(description.raw()).asString()); - smart_str_appendl(&reason, isNonEmpty == PT_TRI_NO ? " is empty." : " might be empty.", isNonEmpty == PT_TRI_NO ? sizeof(" is empty.") - 1 : sizeof(" might be empty.") - 1); - smart_str_0(&reason); - collected.push(zv::Val::adoptString(reason.s)); - added = true; + /* AcceptsReasonHelper::describeSubject($otherType, $verbosity, 'Array') */ + if (otherIsArray == PT_TRI_YES && ZSTR_LEN(zv::Ref(description.raw()).asString()) > PT_ACCEPTS_REASON_SUBJECT_DESCRIPTION_LENGTH_LIMIT) { + description = zv::Val::string(PT_LC("Array")); + } + if (reportList) { + smart_str reason = {NULL, 0}; + smart_str_append(&reason, zv::Ref(description.raw()).asString()); + smart_str_appendl(&reason, isList == PT_TRI_NO ? " is not a list." : " might not be a list.", isList == PT_TRI_NO ? sizeof(" is not a list.") - 1 : sizeof(" might not be a list.") - 1); + smart_str_0(&reason); + collected.push(zv::Val::adoptString(reason.s)); + } + if (reportEmpty) { + smart_str reason = {NULL, 0}; + smart_str_append(&reason, zv::Ref(description.raw()).asString()); + smart_str_appendl(&reason, isNonEmpty == PT_TRI_NO ? " is empty." : " might be empty.", isNonEmpty == PT_TRI_NO ? sizeof(" is empty.") - 1 : sizeof(" might be empty.") - 1); + smart_str_0(&reason); + collected.push(zv::Val::adoptString(reason.s)); + } } - (void) added; if (zend_hash_num_elements(collected.table()) > 0) return withReasons(result.raw(), std::move(collected)); } diff --git a/turbo-ext/src/ObjectShapeType.cpp b/turbo-ext/src/ObjectShapeType.cpp index 56af57bd345..131f251fd8f 100644 --- a/turbo-ext/src/ObjectShapeType.cpp +++ b/turbo-ext/src/ObjectShapeType.cpp @@ -181,6 +181,10 @@ class ObjectShapeType if (UNEXPECTED(result.isUndef())) return zv::Val(); zv::Val scope = pt_type_new(PT_CLASS_OUT_OF_CLASS_SCOPE, 0, NULL); if (UNEXPECTED(scope.isUndef())) return zv::Val(); + bool bleedingEdge; + if (UNEXPECTED(!pt_is_bleeding_edge(bleedingEdge))) return zv::Val(); + zv::Arr missingProperties = zv::Arr::empty(); + zv::Arr possiblyMissingProperties = zv::Arr::empty(); zval *props = properties(); if (UNEXPECTED(props == NULL)) return zv::Val(); for (zv::ArrayEntry entry : zv::ArrRef(props)) { @@ -194,7 +198,7 @@ class ObjectShapeType if (UNEXPECTED(typeHasProperty.isUndef())) return zv::Val(); zend_long typeHasPropertyValue = pt_type_trinary_value(typeHasProperty.raw()); if (UNEXPECTED(typeHasPropertyValue < 0)) return zv::Val(); - zv::Val hasProperty = memberPresenceResult(pt_ce_accepts_result, type, typeHasProperty.raw(), typeHasPropertyValue, &propertyName); + zv::Val hasProperty = memberPresenceResult(pt_ce_accepts_result, type, typeHasProperty.raw(), typeHasPropertyValue, &propertyName, bleedingEdge); if (UNEXPECTED(hasProperty.isUndef())) return zv::Val(); if (typeHasPropertyValue != PT_TRI_YES) { @@ -220,12 +224,14 @@ class ObjectShapeType if (optional) continue; result = resultAnd(std::move(result), hasProperty.raw()); if (UNEXPECTED(result.isUndef())) return zv::Val(); + missingProperties.push(zv::Val::copyOf(zv::Ref(propertyNameString.raw()))); continue; } if (typeHasPropertyValue == PT_TRI_MAYBE) { if (!optional) { result = resultAnd(std::move(result), hasProperty.raw()); if (UNEXPECTED(result.isUndef())) return zv::Val(); + possiblyMissingProperties.push(zv::Val::copyOf(zv::Ref(propertyNameString.raw()))); continue; } hasProperty = pt_type_accepts_result(PT_TRI_YES); @@ -303,7 +309,10 @@ class ObjectShapeType ZVAL_EMPTY_ARRAY(&emptyReasons); if (UNEXPECTED(!pt_accepts_result_create(&objectResult, isObject.raw(), &emptyReasons))) return zv::Val(); zv::Val objectResultVal = zv::Val::adopt(objectResult); - return resultAnd(std::move(result), objectResultVal.raw()); + result = resultAnd(std::move(result), objectResultVal.raw()); + if (UNEXPECTED(result.isUndef())) return zv::Val(); + if (!bleedingEdge) return result; + return withMissingPropertiesReasons(pt_ce_accepts_result, std::move(result), type, missingProperties, possiblyMissingProperties); } /* the CompoundType callback; maybe for object and a universal object @@ -329,6 +338,10 @@ class ObjectShapeType if (UNEXPECTED(result.isUndef())) return zv::Val(); zv::Val scope = pt_type_new(PT_CLASS_OUT_OF_CLASS_SCOPE, 0, NULL); if (UNEXPECTED(scope.isUndef())) return zv::Val(); + bool bleedingEdge; + if (UNEXPECTED(!pt_is_bleeding_edge(bleedingEdge))) return zv::Val(); + zv::Arr missingProperties = zv::Arr::empty(); + zv::Arr possiblyMissingProperties = zv::Arr::empty(); zval *props = properties(); if (UNEXPECTED(props == NULL)) return zv::Val(); for (zv::ArrayEntry entry : zv::ArrRef(props)) { @@ -341,7 +354,7 @@ class ObjectShapeType if (UNEXPECTED(typeHasProperty.isUndef())) return zv::Val(); zend_long typeHasPropertyValue = pt_type_trinary_value(typeHasProperty.raw()); if (UNEXPECTED(typeHasPropertyValue < 0)) return zv::Val(); - zv::Val hasProperty = memberPresenceResult(pt_ce_is_super_type_of_result, type, typeHasProperty.raw(), typeHasPropertyValue, &propertyName); + zv::Val hasProperty = memberPresenceResult(pt_ce_is_super_type_of_result, type, typeHasProperty.raw(), typeHasPropertyValue, &propertyName, bleedingEdge); if (UNEXPECTED(hasProperty.isUndef())) return zv::Val(); bool optional; if (UNEXPECTED(!isOptional(&propertyName, optional))) return zv::Val(); @@ -349,12 +362,14 @@ class ObjectShapeType if (optional) continue; result = resultAnd(std::move(result), hasProperty.raw()); if (UNEXPECTED(result.isUndef())) return zv::Val(); + missingProperties.push(zv::Val::copyOf(zv::Ref(propertyNameString.raw()))); continue; } if (typeHasPropertyValue == PT_TRI_MAYBE) { if (!optional) { result = resultAnd(std::move(result), hasProperty.raw()); if (UNEXPECTED(result.isUndef())) return zv::Val(); + possiblyMissingProperties.push(zv::Val::copyOf(zv::Ref(propertyNameString.raw()))); continue; } hasProperty = pt_type_is_super_type_of_result(PT_TRI_YES); @@ -391,7 +406,83 @@ class ObjectShapeType if (UNEXPECTED(isObject.isUndef())) return zv::Val(); zv::Val objectResult = pt_callable_is_super_type_of_result_of(isObject.raw()); if (UNEXPECTED(objectResult.isUndef())) return zv::Val(); - return resultAnd(std::move(result), objectResult.raw()); + result = resultAnd(std::move(result), objectResult.raw()); + if (UNEXPECTED(result.isUndef())) return zv::Val(); + if (!bleedingEdge) return result; + return withMissingPropertiesReasons(pt_ce_is_super_type_of_result, std::move(result), type, missingProperties, possiblyMissingProperties); + } + + /* $result->and(new (TrinaryLogic::createYes(), + * self::missingPropertiesReasons($type, $missingProperties, $possiblyMissingProperties))); + * UNDEF = pending exception */ + static zv::Val withMissingPropertiesReasons(zend_class_entry *resultCe, zv::Val result, zval *type, zv::Arr &missingProperties, zv::Arr &possiblyMissingProperties) + { + if (missingProperties.arrRef().size() == 0 && possiblyMissingProperties.arrRef().size() == 0) return result; + /* AcceptsReasonHelper::describeSubject($type, VerbosityLevel::typeOnly(), 'Object') */ + zv::Val typeOnly = pt_type_verbosity_level(PT_VERBOSITY_LEVEL_TYPE_ONLY); + if (UNEXPECTED(typeOnly.isUndef())) return zv::Val(); + zv::Val subject = pt_type_op(Z_OBJ_P(type), PT_OP_DESCRIBE, 1, typeOnly.raw()); + if (UNEXPECTED(subject.isUndef())) return zv::Val(); + if (UNEXPECTED(!zv::Ref(subject.raw()).isString())) { + zend_type_error("phpstan_turbo: describe() must return string"); + return zv::Val(); + } + if (ZSTR_LEN(zv::Ref(subject.raw()).asString()) > PT_ACCEPTS_REASON_SUBJECT_DESCRIPTION_LENGTH_LIMIT) { + subject = zv::Val::string(PT_LC("Object")); + } + zv::Arr reasons = zv::Arr::create(2); + if (missingProperties.arrRef().size() > 0) reasons.push(missingPropertiesReason(zv::Ref(subject.raw()).asString(), missingProperties, true)); + if (possiblyMissingProperties.arrRef().size() > 0) reasons.push(missingPropertiesReason(zv::Ref(subject.raw()).asString(), possiblyMissingProperties, false)); + zv::Val reasonsResult; + if (resultCe == pt_ce_accepts_result) { + reasonsResult = acceptsResultCreate(pt_trinary_singleton(PT_TRI_YES), std::move(reasons)); + } else { + zv::Args args{pt_trinary_singleton(PT_TRI_YES), reasons.raw()}; + reasonsResult = pt_type_new_ce(resultCe, 2, args); + } + if (UNEXPECTED(reasonsResult.isUndef())) return zv::Val(); + return resultAnd(std::move(result), reasonsResult.raw()); + } + + /* AcceptsReasonHelper::missingProperties(): 'Foo does not have property $a.', + * 'Foo might not have properties $a, $b, $c and 2 more.' */ + static zv::Val missingPropertiesReason(zend_string *subject, zv::Arr &propertyNames, bool certainly) + { + static constexpr uint32_t listedItemsLimit = 3; /* AcceptsReasonHelper::LISTED_ITEMS_LIMIT */ + uint32_t count = propertyNames.arrRef().size(); + smart_str reason = {NULL, 0}; + smart_str_append(&reason, subject); + if (certainly) { + smart_str_appendl(&reason, PT_LC(" does not have ")); + } else { + smart_str_appendl(&reason, PT_LC(" might not have ")); + } + if (count == 1) { + smart_str_appendl(&reason, PT_LC("property ")); + } else { + smart_str_appendl(&reason, PT_LC("properties ")); + } + uint32_t listed = count > listedItemsLimit ? listedItemsLimit : count; + uint32_t i = 0; + for (zv::ArrayEntry entry : propertyNames.arrRef()) { + if (i == listed) break; + if (i > 0) { + if (count <= listedItemsLimit && i == count - 1) { + smart_str_appendl(&reason, PT_LC(" and ")); + } else { + smart_str_appendl(&reason, PT_LC(", ")); + } + } + smart_str_appendc(&reason, '$'); + smart_str_append(&reason, entry.value().asString()); + i++; + } + if (count > listedItemsLimit) { + smart_str_append_printf(&reason, " and %u more", count - listedItemsLimit); + } + smart_str_appendc(&reason, '.'); + smart_str_0(&reason); + return zv::Val::adoptString(reason.s); } /* another instance (of any subclass) with the same properties (each @@ -942,14 +1033,14 @@ class ObjectShapeType return true; } - /* new ($typeHasProperty, $typeHasProperty->yes() ? [] : + /* new ($typeHasProperty, $typeHasProperty->yes() || $bleedingEdge ? [] : * [sprintf('%s %s have property $%s.', $type->describe(VerbosityLevel::typeOnly()), * $typeHasProperty->no() ? 'does not' : 'might not', $propertyName)]); * UNDEF = pending exception */ - static zv::Val memberPresenceResult(zend_class_entry *resultCe, zval *type, zval *typeHasProperty, zend_long value, zval *propertyName) + static zv::Val memberPresenceResult(zend_class_entry *resultCe, zval *type, zval *typeHasProperty, zend_long value, zval *propertyName, bool bleedingEdge) { zv::Arr reasons; - if (value == PT_TRI_YES) { + if (value == PT_TRI_YES || bleedingEdge) { reasons = zv::Arr::empty(); } else { zv::Val typeOnly = pt_type_verbosity_level(PT_VERBOSITY_LEVEL_TYPE_ONLY); diff --git a/turbo-ext/src/TypeTraits.h b/turbo-ext/src/TypeTraits.h index a33a7efbdd4..29b1d48e123 100644 --- a/turbo-ext/src/TypeTraits.h +++ b/turbo-ext/src/TypeTraits.h @@ -165,6 +165,10 @@ zv::Val pt_type_call_parent(zend_class_entry *scope, zend_object *object, const /* Class::method(...$args) on a class-map class; UNDEF = pending exception */ zv::Val pt_type_call_static(int classIdx, const char *lcname, size_t len, uint32_t argc, zval *argv); +/* ArrayType::accepts()'s bleeding-edge decoration of an entry's value + * result with its offset (AcceptsReasonHelper::prependOffset() over + * every reason, ConstantArrayType.cpp); UNDEF = pending exception */ +zv::Val pt_offset_accepts_reasons_prepend(zval *result, zval *offsetType, zval *offsetValueType, zval *acceptedValueType); /* new Class(...$args) on a class-map class, its constructor called when it * declares one; UNDEF = pending exception */ diff --git a/turbo-ext/src/generated/ObjectShapeType.h b/turbo-ext/src/generated/ObjectShapeType.h index ada5832c858..cfac53a1c48 100644 --- a/turbo-ext/src/generated/ObjectShapeType.h +++ b/turbo-ext/src/generated/ObjectShapeType.h @@ -69,33 +69,36 @@ inline constexpr char strings[] = "accepts\0" /* 627 */ "PHPStan\\Type\\IsSuperTypeOfResult\0" /* 635 */ "isSuperTypeOf\0" /* 668 */ - "equals\0" /* 682 */ - "typeToRemove\0" /* 689 */ - "tryRemove\0" /* 702 */ - "PHPStan\\Type\\ObjectShapeType\0" /* 712 */ - "makePropertyRequired\0" /* 741 */ - "receivedType\0" /* 762 */ - "PHPStan\\Type\\Generic\\TemplateTypeMap\0" /* 775 */ - "inferTemplateTypes\0" /* 812 */ - "positionVariance\0" /* 831 */ - "PHPStan\\Type\\Generic\\TemplateTypeVariance\0" /* 848 */ - "getReferencedTemplateTypes\0" /* 890 */ - "level\0" /* 917 */ - "PHPStan\\Type\\VerbosityLevel\0" /* 923 */ - "describe\0" /* 951 */ - "getEnumCases\0" /* 960 */ - "PHPStan\\Type\\Enum\\EnumCaseObjectType\0" /* 973 */ - "getEnumCaseObject\0" /* 1010 */ - "cb\0" /* 1028 */ - "traverse\0" /* 1031 */ - "right\0" /* 1040 */ - "traverseSimultaneously\0" /* 1046 */ - "exponent\0" /* 1069 */ - "exponentiate\0" /* 1078 */ - "getFiniteTypes\0" /* 1091 */ - "PHPStan\\PhpDocParser\\Ast\\Type\\TypeNode\0" /* 1106 */ - "toPhpDocNode\0" /* 1145 */ - "hasTemplateOrLateResolvableType"; /* 1158 */ + "missingProperties\0" /* 682 */ + "possiblyMissingProperties\0" /* 700 */ + "missingPropertiesReasons\0" /* 726 */ + "equals\0" /* 751 */ + "typeToRemove\0" /* 758 */ + "tryRemove\0" /* 771 */ + "PHPStan\\Type\\ObjectShapeType\0" /* 781 */ + "makePropertyRequired\0" /* 810 */ + "receivedType\0" /* 831 */ + "PHPStan\\Type\\Generic\\TemplateTypeMap\0" /* 844 */ + "inferTemplateTypes\0" /* 881 */ + "positionVariance\0" /* 900 */ + "PHPStan\\Type\\Generic\\TemplateTypeVariance\0" /* 917 */ + "getReferencedTemplateTypes\0" /* 959 */ + "level\0" /* 986 */ + "PHPStan\\Type\\VerbosityLevel\0" /* 992 */ + "describe\0" /* 1020 */ + "getEnumCases\0" /* 1029 */ + "PHPStan\\Type\\Enum\\EnumCaseObjectType\0" /* 1042 */ + "getEnumCaseObject\0" /* 1079 */ + "cb\0" /* 1097 */ + "traverse\0" /* 1100 */ + "right\0" /* 1109 */ + "traverseSimultaneously\0" /* 1115 */ + "exponent\0" /* 1138 */ + "exponentiate\0" /* 1147 */ + "getFiniteTypes\0" /* 1160 */ + "PHPStan\\PhpDocParser\\Ast\\Type\\TypeNode\0" /* 1175 */ + "toPhpDocNode\0" /* 1214 */ + "hasTemplateOrLateResolvableType"; /* 1227 */ inline constexpr reg::PackedArg args[] = { reg::packed(0, MAY_BE_ARRAY), /* __construct $properties */ reg::packed(11, MAY_BE_ARRAY), /* __construct $optionalProperties */ @@ -134,29 +137,33 @@ inline constexpr reg::PackedArg args[] = { reg::packed(42, 0, 600), /* accepts return */ reg::packed(583, 0, 146), /* isSuperTypeOf $type */ reg::packed(42, 0, 635), /* isSuperTypeOf return */ + reg::packed(583, 0, 146), /* missingPropertiesReasons $type */ + reg::packed(682, MAY_BE_ARRAY), /* missingPropertiesReasons $missingProperties */ + reg::packed(700, MAY_BE_ARRAY), /* missingPropertiesReasons $possiblyMissingProperties */ + reg::packed(42, MAY_BE_ARRAY), /* missingPropertiesReasons return */ reg::packed(583, 0, 146), /* equals $type */ reg::packed(42, MAY_BE_BOOL), /* equals return */ - reg::packed(689, 0, 146), /* tryRemove $typeToRemove */ + reg::packed(758, 0, 146), /* tryRemove $typeToRemove */ reg::packed(42, MAY_BE_NULL, 146), /* tryRemove return */ reg::packed(183, MAY_BE_STRING), /* makePropertyRequired $propertyName */ - reg::packed(42, 0, 712), /* makePropertyRequired return */ - reg::packed(762, 0, 146), /* inferTemplateTypes $receivedType */ - reg::packed(42, 0, 775), /* inferTemplateTypes return */ - reg::packed(831, 0, 848), /* getReferencedTemplateTypes $positionVariance */ + reg::packed(42, 0, 781), /* makePropertyRequired return */ + reg::packed(831, 0, 146), /* inferTemplateTypes $receivedType */ + reg::packed(42, 0, 844), /* inferTemplateTypes return */ + reg::packed(900, 0, 917), /* getReferencedTemplateTypes $positionVariance */ reg::packed(42, MAY_BE_ARRAY), /* getReferencedTemplateTypes return */ - reg::packed(917, 0, 923), /* describe $level */ + reg::packed(986, 0, 992), /* describe $level */ reg::packed(42, MAY_BE_STRING), /* describe return */ reg::packed(42, MAY_BE_ARRAY), /* getEnumCases return */ - reg::packed(42, MAY_BE_NULL, 973), /* getEnumCaseObject return */ - reg::packed(1028, MAY_BE_CALLABLE), /* traverse $cb */ + reg::packed(42, MAY_BE_NULL, 1042), /* getEnumCaseObject return */ + reg::packed(1097, MAY_BE_CALLABLE), /* traverse $cb */ reg::packed(42, 0, 146), /* traverse return */ - reg::packed(1040, 0, 146), /* traverseSimultaneously $right */ - reg::packed(1028, MAY_BE_CALLABLE), /* traverseSimultaneously $cb */ + reg::packed(1109, 0, 146), /* traverseSimultaneously $right */ + reg::packed(1097, MAY_BE_CALLABLE), /* traverseSimultaneously $cb */ reg::packed(42, 0, 146), /* traverseSimultaneously return */ - reg::packed(1069, 0, 146), /* exponentiate $exponent */ + reg::packed(1138, 0, 146), /* exponentiate $exponent */ reg::packed(42, 0, 146), /* exponentiate return */ reg::packed(42, MAY_BE_ARRAY), /* getFiniteTypes return */ - reg::packed(42, 0, 1106), /* toPhpDocNode return */ + reg::packed(42, 0, 1175), /* toPhpDocNode return */ reg::packed(42, MAY_BE_BOOL), /* hasTemplateOrLateResolvableType return */ }; using Sig = reg::Sig; @@ -182,20 +189,21 @@ inline constexpr sigtab::Sig getStaticProperty = { { 528 /* getStaticProperty */ inline constexpr sigtab::Sig getUnresolvedStaticPropertyPrototype = { { 546 /* getUnresolvedStaticPropertyPrototype */, 2, 29, 2, 31, ZEND_ACC_PUBLIC } }; inline constexpr sigtab::Sig accepts = { { 627 /* accepts */, 2, 32, 2, 34, ZEND_ACC_PUBLIC } }; inline constexpr sigtab::Sig isSuperTypeOf = { { 668 /* isSuperTypeOf */, 1, 35, 1, 36, ZEND_ACC_PUBLIC } }; -inline constexpr sigtab::Sig equals = { { 682 /* equals */, 1, 37, 1, 38, ZEND_ACC_PUBLIC } }; -inline constexpr sigtab::Sig tryRemove = { { 702 /* tryRemove */, 1, 39, 1, 40, ZEND_ACC_PUBLIC } }; -inline constexpr sigtab::Sig makePropertyRequired = { { 741 /* makePropertyRequired */, 1, 41, 1, 42, ZEND_ACC_PUBLIC } }; -inline constexpr sigtab::Sig inferTemplateTypes = { { 812 /* inferTemplateTypes */, 1, 43, 1, 44, ZEND_ACC_PUBLIC } }; -inline constexpr sigtab::Sig getReferencedTemplateTypes = { { 890 /* getReferencedTemplateTypes */, 1, 45, 1, 46, ZEND_ACC_PUBLIC } }; -inline constexpr sigtab::Sig describe = { { 951 /* describe */, 1, 47, 1, 48, ZEND_ACC_PUBLIC } }; -inline constexpr sigtab::Sig getEnumCases = { { 960 /* getEnumCases */, 0, 49, 0, 49, ZEND_ACC_PUBLIC } }; -inline constexpr sigtab::Sig getEnumCaseObject = { { 1010 /* getEnumCaseObject */, 0, 50, 0, 50, ZEND_ACC_PUBLIC } }; -inline constexpr sigtab::Sig traverse = { { 1031 /* traverse */, 1, 51, 1, 52, ZEND_ACC_PUBLIC } }; -inline constexpr sigtab::Sig traverseSimultaneously = { { 1046 /* traverseSimultaneously */, 2, 53, 2, 55, ZEND_ACC_PUBLIC } }; -inline constexpr sigtab::Sig exponentiate = { { 1078 /* exponentiate */, 1, 56, 1, 57, ZEND_ACC_PUBLIC } }; -inline constexpr sigtab::Sig getFiniteTypes = { { 1091 /* getFiniteTypes */, 0, 58, 0, 58, ZEND_ACC_PUBLIC } }; -inline constexpr sigtab::Sig toPhpDocNode = { { 1145 /* toPhpDocNode */, 0, 59, 0, 59, ZEND_ACC_PUBLIC } }; -inline constexpr sigtab::Sig hasTemplateOrLateResolvableType = { { 1158 /* hasTemplateOrLateResolvableType */, 0, 60, 0, 60, ZEND_ACC_PUBLIC } }; +inline constexpr sigtab::Sig missingPropertiesReasons = { { 726 /* missingPropertiesReasons */, 3, 37, 3, 40, ZEND_ACC_PRIVATE | ZEND_ACC_STATIC } }; +inline constexpr sigtab::Sig equals = { { 751 /* equals */, 1, 41, 1, 42, ZEND_ACC_PUBLIC } }; +inline constexpr sigtab::Sig tryRemove = { { 771 /* tryRemove */, 1, 43, 1, 44, ZEND_ACC_PUBLIC } }; +inline constexpr sigtab::Sig makePropertyRequired = { { 810 /* makePropertyRequired */, 1, 45, 1, 46, ZEND_ACC_PUBLIC } }; +inline constexpr sigtab::Sig inferTemplateTypes = { { 881 /* inferTemplateTypes */, 1, 47, 1, 48, ZEND_ACC_PUBLIC } }; +inline constexpr sigtab::Sig getReferencedTemplateTypes = { { 959 /* getReferencedTemplateTypes */, 1, 49, 1, 50, ZEND_ACC_PUBLIC } }; +inline constexpr sigtab::Sig describe = { { 1020 /* describe */, 1, 51, 1, 52, ZEND_ACC_PUBLIC } }; +inline constexpr sigtab::Sig getEnumCases = { { 1029 /* getEnumCases */, 0, 53, 0, 53, ZEND_ACC_PUBLIC } }; +inline constexpr sigtab::Sig getEnumCaseObject = { { 1079 /* getEnumCaseObject */, 0, 54, 0, 54, ZEND_ACC_PUBLIC } }; +inline constexpr sigtab::Sig traverse = { { 1100 /* traverse */, 1, 55, 1, 56, ZEND_ACC_PUBLIC } }; +inline constexpr sigtab::Sig traverseSimultaneously = { { 1115 /* traverseSimultaneously */, 2, 57, 2, 59, ZEND_ACC_PUBLIC } }; +inline constexpr sigtab::Sig exponentiate = { { 1147 /* exponentiate */, 1, 60, 1, 61, ZEND_ACC_PUBLIC } }; +inline constexpr sigtab::Sig getFiniteTypes = { { 1160 /* getFiniteTypes */, 0, 62, 0, 62, ZEND_ACC_PUBLIC } }; +inline constexpr sigtab::Sig toPhpDocNode = { { 1214 /* toPhpDocNode */, 0, 63, 0, 63, ZEND_ACC_PUBLIC } }; +inline constexpr sigtab::Sig hasTemplateOrLateResolvableType = { { 1227 /* hasTemplateOrLateResolvableType */, 0, 64, 0, 64, ZEND_ACC_PUBLIC } }; } // namespace sig } // namespace ptdecl::ObjectShapeType diff --git a/turbo-ext/src/support.h b/turbo-ext/src/support.h index db10e77bc89..44bebac3f9a 100644 --- a/turbo-ext/src/support.h +++ b/turbo-ext/src/support.h @@ -1078,6 +1078,12 @@ extern zend_class_entry *pt_ce_constant_array_type; [[nodiscard]] bool pt_constant_array_type_new(zval *out, zval *keyTypes, zval *valueTypes, zval *nextAutoIndexes = NULL, zval *optionalKeys = NULL, zval *isList = NULL, zval *unsealed = NULL); /* ConstantArrayType::isValidIdentifier($value); false = pending exception */ [[nodiscard]] bool pt_constant_array_type_is_valid_identifier(zend_string *value, bool &out); +/* BleedingEdgeToggle::isBleedingEdge() (ConstantArrayType.cpp); false = + * pending exception */ +[[nodiscard]] bool pt_is_bleeding_edge(bool &out); +/* AcceptsReasonHelper::SUBJECT_DESCRIPTION_LENGTH_LIMIT: a longer accepted + * type is not repeated as the subject of a reason */ +#define PT_ACCEPTS_REASON_SUBJECT_DESCRIPTION_LENGTH_LIMIT 120 /* merged from the parallel port branch */ /* the compound family (UnionType.cpp, BenevolentUnionType.cpp,