diff --git a/composer.json b/composer.json index 45d0b4a..9f94162 100644 --- a/composer.json +++ b/composer.json @@ -27,7 +27,7 @@ "require": { "php": "^8.1", "phpstan/phpdoc-parser": "^2.0", - "nikic/php-parser": "^5.0" + "nikic/php-parser": "^5.3" }, "require-dev": { "laravel/pint": "^1.10", diff --git a/src/Contract/ContractParser.php b/src/Contract/ContractParser.php index 0b96b2d..7aeb20d 100644 --- a/src/Contract/ContractParser.php +++ b/src/Contract/ContractParser.php @@ -619,16 +619,10 @@ private static function parseFunction(\ReflectionFunction $ref): array foreach (DocblockExtractor::getParamTags($phpDocNode) as $paramName => $paramTag) { $type = $paramTag->type; - $isVariadic = $paramTag->isVariadic || ($baseParamVariadic[$paramName] ?? false); - if ( - $isVariadic - && ! ($type instanceof ArrayTypeNode) - && ! ($type instanceof GenericTypeNode && \in_array(strtolower($type->type->name), ['array', 'list', 'iterable', 'traversable', 'non-empty-array', 'non-empty-list'], true)) - ) { - $type = new ArrayTypeNode($type); - } - $pObj = $baseParamObjects[$paramName] ?? null; + + $type = self::wrapVariadicParameterType($type, $paramTag->isVariadic, $baseParamVariadic[$paramName] ?? false, $pObj); + if ( Config::isRespectNativeNullabilityEnabled() && $pObj !== null @@ -796,16 +790,10 @@ private static function parseMethodHierarchyDocs( if ($targetParamName !== null && ! isset($types[$targetParamName])) { $type = $paramTag->type; - $isVariadic = $paramTag->isVariadic || ($baseParamVariadic[$targetParamName] ?? false); - if ( - $isVariadic - && ! ($type instanceof ArrayTypeNode) - && ! ($type instanceof GenericTypeNode && \in_array(strtolower($type->type->name), ['array', 'list', 'iterable', 'traversable', 'non-empty-array', 'non-empty-list'], true)) - ) { - $type = new ArrayTypeNode($type); - } - $pObj = $baseParamObjects[$targetParamName] ?? null; + + $type = self::wrapVariadicParameterType($type, $paramTag->isVariadic, $baseParamVariadic[$targetParamName] ?? false, $pObj); + if ( Config::isRespectNativeNullabilityEnabled() && $pObj !== null @@ -897,10 +885,15 @@ private static function applyConstructorPromotionFallback( $paramName = $p->getName(); if (! isset($types[$paramName]) && $declaringClass->hasProperty($paramName)) { + $propertyRef = $declaringClass->getProperty($paramName); + + // For non-promoted parameters, ensure constructor param and property native types are compatible + if (! self::areConstructorParamAndPropertyCompatible($p, $propertyRef)) { + continue; + } + $className = $declaringClass->getName(); $stubDoc = StubManager::getPropertyDoc($className, $paramName); - - $propertyRef = $declaringClass->getProperty($paramName); $propDoc = $stubDoc ?? $propertyRef->getDocComment(); if ($propDoc !== false && $propDoc !== null) { @@ -926,14 +919,7 @@ private static function applyConstructorPromotionFallback( } } - $isVariadic = $p->isVariadic(); - if ( - $isVariadic - && ! ($resolvedProp instanceof ArrayTypeNode) - && ! ($resolvedProp instanceof GenericTypeNode && \in_array(strtolower($resolvedProp->type->name), ['array', 'list', 'iterable', 'traversable', 'non-empty-array', 'non-empty-list'], true)) - ) { - $resolvedProp = new ArrayTypeNode($resolvedProp); - } + $resolvedProp = self::wrapVariadicParameterType($resolvedProp, false, $p->isVariadic(), $p); if ( Config::isRespectNativeNullabilityEnabled() @@ -954,6 +940,70 @@ private static function applyConstructorPromotionFallback( } } + /** + * Checks whether an un-promoted constructor parameter is compatible with a class property. + */ + private static function areConstructorParamAndPropertyCompatible( + \ReflectionParameter $p, + \ReflectionProperty $propertyRef + ): bool { + if ($p->isPromoted()) { + return true; + } + + if ($p->hasType() && $propertyRef->hasType()) { + $pType = (string) $p->getType(); + $propType = (string) $propertyRef->getType(); + + $normP = strtolower(ltrim($pType, '?')); + $normProp = strtolower(ltrim($propType, '?')); + + if ($normP !== $normProp) { + return false; + } + } + + return true; + } + + /** + * Wraps a variadic parameter type into an ArrayTypeNode, accounting for variadic arrays/iterables. + */ + private static function wrapVariadicParameterType( + TypeNode $type, + bool $tagIsVariadic, + bool $nativeIsVariadic, + ?\ReflectionParameter $reflectionParam = null + ): TypeNode { + if (! $tagIsVariadic && ! $nativeIsVariadic) { + return $type; + } + + $isNativeArrayOrIterable = false; + if ($reflectionParam !== null && $reflectionParam->hasType()) { + $nativeType = $reflectionParam->getType(); + if ($nativeType instanceof \ReflectionNamedType) { + $nativeName = strtolower($nativeType->getName()); + $isNativeArrayOrIterable = \in_array($nativeName, ['array', 'iterable'], true); + } + } + + // If native parameter is array ...$items, each argument is an array, so wrap in ArrayTypeNode + if ($isNativeArrayOrIterable) { + return new ArrayTypeNode($type); + } + + // If DocBlock type is not already an ArrayTypeNode or list/array GenericTypeNode + if ( + ! ($type instanceof ArrayTypeNode) + && ! ($type instanceof GenericTypeNode && \in_array(strtolower($type->type->name), ['array', 'list', 'iterable', 'traversable', 'non-empty-array', 'non-empty-list'], true)) + ) { + return new ArrayTypeNode($type); + } + + return $type; + } + /** * Checks if a scalar refinement type is compatible with a native PHP builtin type. */ diff --git a/src/Internal/Checker/InlineChecker.php b/src/Internal/Checker/InlineChecker.php index 17832e2..d8361ae 100644 --- a/src/Internal/Checker/InlineChecker.php +++ b/src/Internal/Checker/InlineChecker.php @@ -139,6 +139,10 @@ public static function checkVariable(mixed $value, string $typeString, string $v return $value; } + if ($value === [] && $varName !== 'return' && self::isArrayShapeType($typeNode)) { + return $value; + } + $context = ($varName === 'return') ? 'Return value' : "Variable \$$varName"; if ($typeNode instanceof CallableTypeNode || ($typeNode instanceof IdentifierTypeNode && strtolower($typeNode->name) === 'callable')) { @@ -166,6 +170,30 @@ public static function checkVariable(mixed $value, string $typeString, string $v return $value; } + /** + * Checks if a TypeNode is or contains an ArrayShapeNode. + */ + private static function isArrayShapeType(TypeNode $node): bool + { + if ($node instanceof ArrayShapeNode) { + return true; + } + + if ($node instanceof NullableTypeNode) { + return self::isArrayShapeType($node->type); + } + + if ($node instanceof UnionTypeNode) { + foreach ($node->types as $subType) { + if (self::isArrayShapeType($subType)) { + return true; + } + } + } + + return false; + } + /** * Evaluates class property validation dynamically based on configuration. */ diff --git a/src/Internal/Checker/ParamChecker.php b/src/Internal/Checker/ParamChecker.php index 4558843..7f30db1 100644 --- a/src/Internal/Checker/ParamChecker.php +++ b/src/Internal/Checker/ParamChecker.php @@ -102,7 +102,7 @@ public static function checkParams( $allTemplates = [...$classTemplates, ...$methodTemplates]; if (\count($allTemplates) > 0) { - self::preInferGenericArrayTemplates($contract['types'], $vars, $effectiveFunction, $thisObj, $allTemplates); + self::preInferGenericTemplates($contract['types'], $vars, $effectiveFunction, $thisObj, $allTemplates); } $boundTemplates = TemplateManager::getBoundTemplates($effectiveFunction, $thisObj, $allTemplates); @@ -230,56 +230,173 @@ private static function handleMagicCall( } /** - * Pre-infers generic template parameters from array arguments before callback wrapping. - * Only runs if at least one parameter in the signature is a callable that uses generic templates. + * Pre-infers generic template parameters from closure typehints and array arguments. * * @param array $types * @param array $vars * @param array $templates */ - private static function preInferGenericArrayTemplates( + private static function preInferGenericTemplates( array $types, array $vars, string $effectiveFunction, ?object $thisObj, array $templates ): void { - $hasCallableParam = false; + self::inferTemplatesFromClosures($types, $vars, $effectiveFunction, $thisObj, $templates); + self::inferTemplatesFromArrays($types, $vars, $effectiveFunction, $thisObj, $templates); + } + + /** + * Infers generic template parameters from closure parameter typehints. + * + * @param array $types + * @param array $vars + * @param array $templates + */ + private static function inferTemplatesFromClosures( + array $types, + array $vars, + string $effectiveFunction, + ?object $thisObj, + array $templates + ): void { + $callableNodes = self::extractCallableNodes($types); + if (\count($callableNodes) === 0) { + return; + } + + $contract = ContractParser::parse($effectiveFunction); + $classTemplates = $contract['classTemplates'] ?? []; - foreach ($types as $tNode) { + foreach ($callableNodes as $cParamName => $cTypeNode) { + if (! \array_key_exists($cParamName, $vars) || ! ($vars[$cParamName] instanceof \Closure)) { + continue; + } + + try { + $refClosure = new \ReflectionFunction($vars[$cParamName]); + $closureParams = $refClosure->getParameters(); + + foreach ($cTypeNode->parameters as $idx => $pNode) { + if ($pNode->type instanceof IdentifierTypeNode && isset($templates[$pNode->type->name]) && isset($closureParams[$idx])) { + $tName = $pNode->type->name; + $isClassLevel = isset($classTemplates[$tName]); + $targetObj = $isClassLevel ? $thisObj : null; + + $inferredCandidate = self::extractTypeFromClosureParameter($closureParams[$idx]); + if ($inferredCandidate !== null) { + $templateTag = $templates[$tName]; + $satisfiesBound = true; + if ($templateTag->bound !== null) { + $resolvedBound = SpecialTypeResolver::resolve($templateTag->bound, $effectiveFunction, $thisObj); + $satisfiesBound = TemplateManager::checkVariance($inferredCandidate, $resolvedBound, GenericTypeNode::VARIANCE_COVARIANT); + } + + if ($satisfiesBound) { + TemplateManager::bindTemplate($effectiveFunction, $targetObj, $tName, $inferredCandidate); + } + } + } + } + } catch (\Throwable $e) { + // Silently ignore reflection errors + } + } + } + + /** + * @param array $types + * + * @return array + */ + private static function extractCallableNodes(array $types): array + { + $callableNodes = []; + + foreach ($types as $paramName => $tNode) { if ($tNode instanceof CallableTypeNode) { - $hasCallableParam = true; + $callableNodes[$paramName] = $tNode; + } elseif ($tNode instanceof UnionTypeNode) { + foreach ($tNode->types as $subT) { + if ($subT instanceof CallableTypeNode) { + $callableNodes[$paramName] = $subT; - break; + break; + } + } } } - if (! $hasCallableParam) { - return; + return $callableNodes; + } + + /** + * Extracts an inferred TypeNode from a closure parameter, ignoring `mixed`. + */ + private static function extractTypeFromClosureParameter(\ReflectionParameter $closureParam): ?TypeNode + { + if (! $closureParam->hasType()) { + return null; } + $cType = $closureParam->getType(); + if ($cType instanceof \ReflectionNamedType) { + $cTypeName = $cType->getName(); + if ($cTypeName !== 'mixed' && (class_exists($cTypeName) || interface_exists($cTypeName) || SpecialTypeResolver::isBuiltInTypeKeyword($cTypeName))) { + return new IdentifierTypeNode($cTypeName); + } + } elseif ($cType instanceof \ReflectionUnionType) { + $unionSubTypes = []; + foreach ($cType->getTypes() as $subNamedType) { + if ($subNamedType instanceof \ReflectionNamedType) { + $subName = $subNamedType->getName(); + if ($subName !== 'mixed' && (class_exists($subName) || interface_exists($subName) || SpecialTypeResolver::isBuiltInTypeKeyword($subName))) { + $unionSubTypes[] = new IdentifierTypeNode($subName); + } + } + } + if (\count($unionSubTypes) > 0) { + return \count($unionSubTypes) === 1 ? $unionSubTypes[0] : new UnionTypeNode($unionSubTypes); + } + } + + return null; + } + + /** + * Infers generic template parameters from array arguments. + * + * @param array $types + * @param array $vars + * @param array $templates + */ + private static function inferTemplatesFromArrays( + array $types, + array $vars, + string $effectiveFunction, + ?object $thisObj, + array $templates + ): void { foreach ($types as $paramName => $typeNode) { if (! \array_key_exists($paramName, $vars) || ! \is_array($vars[$paramName]) || \count($vars[$paramName]) === 0) { continue; } - $arrVal = $vars[$paramName]; - $sampleKey = array_key_first($arrVal); - $sampleItem = reset($arrVal); - - self::inferFromTypeNode($typeNode, $sampleKey, $sampleItem, $effectiveFunction, $thisObj, $templates); + self::inferArrayTemplatesFromAllElements($typeNode, $vars[$paramName], $effectiveFunction, $thisObj, $templates); } } /** - * Extracts and binds template parameters from GenericTypeNode or ArrayTypeNode. + * Extracts and unifies template parameters across all elements of an array argument. + * Uses Beartype O(1) hybrid random sampling on arrays > 128 items when hybrid mode is active. * + * @param array $arrVal * @param array $templates */ - private static function inferFromTypeNode( + private static function inferArrayTemplatesFromAllElements( TypeNode $typeNode, - mixed $sampleKey, - mixed $sampleItem, + array $arrVal, string $effectiveFunction, ?object $thisObj, array $templates @@ -294,22 +411,92 @@ private static function inferFromTypeNode( return; } + $sampleItems = self::getSampleArraySlice($arrVal); $genericCount = \count($typeNode->genericTypes); + if ($genericCount === 1 && $typeNode->genericTypes[0] instanceof IdentifierTypeNode) { - self::bindTemplateIfUnbound($typeNode->genericTypes[0]->name, $sampleItem, $effectiveFunction, $thisObj, $templates); + self::inferSingleTemplateFromArraySamples($typeNode->genericTypes[0]->name, $sampleItems, $effectiveFunction, $thisObj, $templates); } elseif ($genericCount >= 2) { - if ($typeNode->genericTypes[0] instanceof IdentifierTypeNode) { - self::bindTemplateIfUnbound($typeNode->genericTypes[0]->name, $sampleKey, $effectiveFunction, $thisObj, $templates); + $keyTName = $typeNode->genericTypes[0] instanceof IdentifierTypeNode ? $typeNode->genericTypes[0]->name : null; + $valTName = $typeNode->genericTypes[1] instanceof IdentifierTypeNode ? $typeNode->genericTypes[1]->name : null; + + $inferredKeyType = null; + $inferredValType = null; + + foreach ($sampleItems as $key => $item) { + if ($keyTName !== null) { + $keyType = TemplateManager::inferTypeFromValue($key); + $inferredKeyType = ($inferredKeyType === null) ? $keyType : self::unifyTypes($inferredKeyType, $keyType); + } + if ($valTName !== null) { + $valType = TemplateManager::inferTypeFromValue($item); + $inferredValType = ($inferredValType === null) ? $valType : self::unifyTypes($inferredValType, $valType); + } + } + + if ($keyTName !== null && $inferredKeyType !== null) { + self::bindTemplateIfUnbound($keyTName, $inferredKeyType, $effectiveFunction, $thisObj, $templates); } - if ($typeNode->genericTypes[1] instanceof IdentifierTypeNode) { - self::bindTemplateIfUnbound($typeNode->genericTypes[1]->name, $sampleItem, $effectiveFunction, $thisObj, $templates); + if ($valTName !== null && $inferredValType !== null) { + self::bindTemplateIfUnbound($valTName, $inferredValType, $effectiveFunction, $thisObj, $templates); } } - } elseif ($typeNode instanceof ArrayTypeNode && $typeNode->type instanceof IdentifierTypeNode) { - self::bindTemplateIfUnbound($typeNode->type->name, $sampleItem, $effectiveFunction, $thisObj, $templates); } } + /** + * @param array $sampleItems + * @param array $templates + */ + private static function inferSingleTemplateFromArraySamples( + string $templateName, + array $sampleItems, + string $effectiveFunction, + ?object $thisObj, + array $templates + ): void { + $inferredType = null; + + foreach ($sampleItems as $item) { + $itemType = TemplateManager::inferTypeFromValue($item); + $inferredType = ($inferredType === null) ? $itemType : self::unifyTypes($inferredType, $itemType); + } + + if ($inferredType !== null) { + self::bindTemplateIfUnbound($templateName, $inferredType, $effectiveFunction, $thisObj, $templates); + } + } + + /** + * Extracts items for template inference, using hybrid sampling for arrays > 128 items. + * + * @param array $arrVal + * + * @return array + */ + private static function getSampleArraySlice(array $arrVal): array + { + $count = \count($arrVal); + if ($count <= Config::HYBRID_SAMPLE_THRESHOLD || ! Config::isArrayValidationHybrid()) { + return $arrVal; + } + + $keys = array_keys($arrVal); + $sampleKeys = [$keys[0], $keys[$count - 1]]; + $samplesToTake = min(3, $count - 2); + + for ($i = 0; $i < $samplesToTake; $i++) { + $sampleKeys[] = $keys[mt_rand(1, $count - 2)]; + } + + $samples = []; + foreach ($sampleKeys as $k) { + $samples[$k] = $arrVal[$k]; + } + + return $samples; + } + /** * Binds a template parameter if it is not already bound in the current scope. * @@ -317,7 +504,7 @@ private static function inferFromTypeNode( */ private static function bindTemplateIfUnbound( string $templateName, - mixed $sampleValue, + TypeNode $inferredType, string $effectiveFunction, ?object $thisObj, array $templates @@ -328,7 +515,7 @@ private static function bindTemplateIfUnbound( $targetObj = $isClassLevelTemplate ? $thisObj : null; if (isset($templates[$templateName]) && ! TemplateManager::isBound($effectiveFunction, $targetObj, $templateName)) { - TemplateManager::bindTemplate($effectiveFunction, $targetObj, $templateName, TemplateManager::inferTypeFromValue($sampleValue)); + TemplateManager::bindTemplate($effectiveFunction, $targetObj, $templateName, $inferredType); } } @@ -412,35 +599,13 @@ private static function validateMagicArguments( continue; } - $val = null; - $hasVal = false; + [$val, $hasVal] = self::extractMagicArgumentValue($paramName, $index, $isVariadic, $args, $argValues, $argKeys); + if (! $hasVal) { + continue; + } if ($isVariadic) { - if (\array_key_exists($paramName, $args)) { - $val = [$args[$paramName]]; - $hasVal = true; - } else { - $val = []; - for ($i = $index; $i < \count($argValues); $i++) { - if (\is_int($argKeys[$i])) { - $val[] = $argValues[$i]; - $hasVal = true; - } - } - } $typeNode = new ArrayTypeNode($typeNode); - } else { - if (\array_key_exists($paramName, $args)) { - $val = $args[$paramName]; - $hasVal = true; - } elseif (\array_key_exists($index, $argValues)) { - $val = $argValues[$index]; - $hasVal = true; - } - } - - if (! $hasVal) { - continue; } $err = self::validateSingleParam( @@ -464,6 +629,50 @@ private static function validateMagicArguments( return null; } + /** + * @param array $args + * @param array $argValues + * @param array $argKeys + * + * @return array{0: mixed, 1: bool} + */ + private static function extractMagicArgumentValue( + string $paramName, + int $index, + bool $isVariadic, + array $args, + array $argValues, + array $argKeys + ): array { + if ($isVariadic) { + if (\array_key_exists($paramName, $args)) { + return [[$args[$paramName]], true]; + } + + $val = []; + $hasVal = false; + $count = \count($argValues); + for ($i = $index; $i < $count; $i++) { + if (\is_int($argKeys[$i])) { + $val[] = $argValues[$i]; + $hasVal = true; + } + } + + return [$val, $hasVal]; + } + + if (\array_key_exists($paramName, $args)) { + return [$args[$paramName], true]; + } + + if (\array_key_exists($index, $argValues)) { + return [$argValues[$index], true]; + } + + return [null, false]; + } + /** * @param array $templates */ @@ -497,7 +706,7 @@ private static function resolveClassStringTemplate( $targetObj = $isClassLevelTemplate ? $thisObj : null; if (! TemplateManager::isBound($function, $targetObj, $templateName)) { - if (! \is_string($val) || ! ClassNameValidator::isValid($val) || (! class_exists($val) && ! interface_exists($val) && ! trait_exists($val) && ! enum_exists($val))) { + if (! \is_string($val) || ! ClassNameValidator::isValidClassString($val)) { return ErrorFactory::createError($function . '(): Argument $' . $paramName . ' must be a valid class-string, ' . TypeFormatter::formatGivenValue($val) . ' given'); } @@ -615,15 +824,21 @@ private static function resolveTemplateParam( $isVariadic = $typeNode instanceof ArrayTypeNode; $isNullable = ($typeNode instanceof NullableTypeNode) || ($typeNode instanceof UnionTypeNode && self::typeContainsNull($typeNode)); - if ($isNullable && $val === null) { - return null; - } - $contract = ContractParser::parse($function); $classTemplates = $contract['classTemplates'] ?? []; $isClassLevelTemplate = isset($classTemplates[$templateName]); $targetObj = $isClassLevelTemplate ? $thisObj : null; + $allowsNullInBound = ($templateNode->bound !== null && self::typeContainsNull($templateNode->bound)); + + if ($isNullable && $val === null) { + if ($allowsNullInBound && ! TemplateManager::isBound($function, $targetObj, $templateName)) { + TemplateManager::bindTemplate($function, $targetObj, $templateName, new IdentifierTypeNode('null')); + } + + return null; + } + if (! TemplateManager::isBound($function, $targetObj, $templateName)) { return self::bindInitialTemplate( $val, diff --git a/src/Internal/Checker/ReturnChecker.php b/src/Internal/Checker/ReturnChecker.php index 42636bd..cd96abf 100644 --- a/src/Internal/Checker/ReturnChecker.php +++ b/src/Internal/Checker/ReturnChecker.php @@ -11,6 +11,8 @@ use PHPStan\PhpDocParser\Ast\Type\GenericTypeNode; use PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode; use PHPStan\PhpDocParser\Ast\Type\TypeNode; +use ReflectionClass; +use Traversable; use TypePHP\Contract\ContractParser; use TypePHP\Contract\HierarchyResolver; use TypePHP\Internal\ClassNameValidator; @@ -239,7 +241,7 @@ private static function evaluateReturn( return $err; } - if ($value instanceof \Traversable) { + if ($value instanceof Traversable) { $baseName = ''; if ($resolvedType instanceof IdentifierTypeNode) { $baseName = strtolower(ltrim($resolvedType->name, '\\')); @@ -276,11 +278,11 @@ private static function evaluateReturn( return $err; } - if (\is_callable($value) && $resolvedType instanceof CallableTypeNode) { + if ($resolvedType instanceof CallableTypeNode && CallableWrapper::isCallable($value)) { return CallableWrapper::wrapTypeNode($resolvedType, $value, $function . '(): Return value', $registry); } - if ($value instanceof \Traversable) { + if ($value instanceof Traversable) { $baseName = ''; if ($resolvedType instanceof IdentifierTypeNode) { $baseName = strtolower(ltrim($resolvedType->name, '\\')); @@ -370,7 +372,7 @@ private static function resolveRenamedParamValue(string $function, string $param try { /** @var class-string $className */ - $refClass = new \ReflectionClass($className); + $refClass = new ReflectionClass($className); if (! $refClass->hasMethod($methodName)) { return null; } @@ -420,16 +422,7 @@ private static function resolveTemplateConditional( $subjectTypeNode = $boundTemplates[$subjectTypeNode->name]; } - $subStr = (string) $subjectTypeNode; - $targetStr = (string) $node->targetType; - - $isTargetMatch = ($subStr === $targetStr); - if (! $isTargetMatch) { - $isTargetMatch = ClassNameValidator::isValid($subStr) && ClassNameValidator::isValid($targetStr) && - (class_exists($subStr) || interface_exists($subStr)) && - (class_exists($targetStr) || interface_exists($targetStr)) && - is_a($subStr, $targetStr, true); - } + $isTargetMatch = TemplateManager::checkVariance($subjectTypeNode, $node->targetType, GenericTypeNode::VARIANCE_COVARIANT); if ($node->negated) { $isTargetMatch = ! $isTargetMatch; diff --git a/src/Internal/ClassNameValidator.php b/src/Internal/ClassNameValidator.php index 7723aa1..f2df59a 100644 --- a/src/Internal/ClassNameValidator.php +++ b/src/Internal/ClassNameValidator.php @@ -30,6 +30,26 @@ public static function isValid(mixed $name): bool return false; } - return preg_match('/^[a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff\\\\]*$/', $trimmed) === 1; + return preg_match('/^[a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*(?:\\\\[a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*)*$/', $trimmed) === 1; + } + + /** + * Validates a class-string value: + * - Unqualified names (e.g. 'Hello', 'stdClass') MUST physically exist in runtime. + * - Qualified names (e.g. 'App\Models\User') pass if they exist or match valid qualified class syntax. + */ + public static function isValidClassString(mixed $name): bool + { + if (! \is_string($name) || ! self::isValid($name)) { + return false; + } + + if (class_exists($name) || interface_exists($name) || trait_exists($name) || enum_exists($name)) { + return true; + } + + $trimmed = ltrim($name, '\\'); + + return str_contains($trimmed, '\\'); } } diff --git a/src/Internal/Config.php b/src/Internal/Config.php index 69e5968..9d5bb16 100644 --- a/src/Internal/Config.php +++ b/src/Internal/Config.php @@ -21,6 +21,11 @@ */ final class Config { + /** + * Threshold (number of elements) above which Beartype O(1) hybrid random sampling is applied. + */ + public const HYBRID_SAMPLE_THRESHOLD = 128; + /** * @var array|null */ diff --git a/src/Internal/StreamWrapper.php b/src/Internal/StreamWrapper.php index 5bb7b8d..d994574 100644 --- a/src/Internal/StreamWrapper.php +++ b/src/Internal/StreamWrapper.php @@ -169,6 +169,7 @@ public static function transformSource(string $source, string $filePath = ''): s $printer = new TypePHPPrinter(); $transformed = $printer->printFormatPreserving($newStmts, $oldStmts, $oldTokens); + $transformed = preg_replace('/(?:\/\/(.*?)|#(.*?))(?=[ \t]*\r?\n[ \t]*\/\*__TYPEPHP_INJECTED_START__\*\/)/', '/*$1$2 */', $transformed) ?? $transformed; $transformed = preg_replace('/[ \t]*\r?\n[ \t]*\/\*__TYPEPHP_INJECTED_START__\*\//', ' /*__TYPEPHP_INJECTED_START__*/', $transformed) ?? $transformed; $transformedLineCount = substr_count($transformed, "\n"); diff --git a/src/Internal/TypeFormatter.php b/src/Internal/TypeFormatter.php index 96edd16..4a2b22a 100644 --- a/src/Internal/TypeFormatter.php +++ b/src/Internal/TypeFormatter.php @@ -22,6 +22,10 @@ public static function formatGivenValue(mixed $value): string return "int ($value)"; } + if (\is_float($value)) { + return "float ($value)"; + } + if (\is_string($value)) { if ($value === '') { return "empty string ('')"; diff --git a/src/Internal/TypePHPPrinter.php b/src/Internal/TypePHPPrinter.php index d69dbb0..e2b9341 100644 --- a/src/Internal/TypePHPPrinter.php +++ b/src/Internal/TypePHPPrinter.php @@ -29,6 +29,7 @@ protected function p( $output = parent::p($node, $precedence, $lhsPrecedence, $parentFormatPreserved); if ($node instanceof Node\Stmt && $node->getAttribute('typephp_injected') === true) { + $output = preg_replace('/(?:\/\/(.*?)|#(.*?))(?=\r?\n|$)/', '/*$1$2 */', $output) ?? $output; $output = preg_replace('/\s+/', ' ', trim($output)) ?? $output; return '/*__TYPEPHP_INJECTED_START__*/' . $output . '/*__TYPEPHP_INJECTED_END__*/'; diff --git a/src/Internal/Visitor/FunctionContractInjector.php b/src/Internal/Visitor/FunctionContractInjector.php index 77f9c99..c732621 100644 --- a/src/Internal/Visitor/FunctionContractInjector.php +++ b/src/Internal/Visitor/FunctionContractInjector.php @@ -31,20 +31,19 @@ public static function inject(Node\Stmt\Function_|Node\Stmt\ClassMethod $node): $methodName = $isClassMethod ? strtolower($node->name->toString()) : ''; $isMagicLifecycle = $isClassMethod && \in_array($methodName, ['__construct', '__destruct', '__clone'], true); - $isNativeVoid = $node->returnType instanceof Node\Identifier && strtolower($node->returnType->name) === 'void'; $isNativeNever = $node->returnType instanceof Node\Identifier && strtolower($node->returnType->name) === 'never'; - $hasParam = self::hasParamContracts($docText, $isClassMethod); + $thisArg = self::resolveThisArg($isClassMethod, $node); + $isNativeVoid = $node->returnType instanceof Node\Identifier && strtolower($node->returnType->name) === 'void'; + $needsReturnVars = $isClassMethod || str_contains($docText, ' is ') || (str_contains($docText, '@return') && str_contains($docText, '$')); + + $hasParam = self::hasParamContracts($docText, $isClassMethod) || $needsReturnVars; $hasReturn = ! $isMagicLifecycle && ! $isNativeNever && self::hasReturnContracts($docText, $isClassMethod); if (! $hasParam && ! $hasReturn) { return; } - $thisArg = self::resolveThisArg($isClassMethod, $node); - $isNativeVoid = $node->returnType instanceof Node\Identifier && strtolower($node->returnType->name) === 'void'; - $needsReturnVars = $isClassMethod || str_contains($docText, ' is ') || (str_contains($docText, '@return') && str_contains($docText, '$')); - $injectedStmts = []; if ($hasParam) { $injectedStmts = self::buildParamInjections($node->params, $docText, $thisArg); @@ -199,11 +198,16 @@ private static function buildSetupScopeStmt(array $params, Node\Expr $thisArg): } } + $argsExpr = new Node\Expr\Assign( + new Node\Expr\Variable('__typephpArgs'), + new Node\Expr\Array_($arrayItems) + ); + $checkCall = new Node\Expr\FuncCall( new Node\Name('\TypePHP\Internal\RuntimeTypeChecker::setupScope'), [ new Node\Arg(new Node\Scalar\MagicConst\Method()), - new Node\Arg(new Node\Expr\Array_($arrayItems)), + new Node\Arg($argsExpr), new Node\Arg($thisArg), ] ); @@ -391,7 +395,7 @@ public static function buildTypeErrorThrowStmt(Node\Expr $errorVar): Node\Stmt\E public static function buildReturnCheckCall(Node\Expr $exprToWrap, Node\Expr $thisArg, bool $needsReturnVars = false): Node\Expr\FuncCall { $varsArg = $needsReturnVars - ? new Node\Expr\FuncCall(new Node\Name('get_defined_vars')) + ? new Node\Expr\Variable('__typephpArgs') : new Node\Expr\Array_(); return new Node\Expr\FuncCall( @@ -645,4 +649,4 @@ public function enterNode(Node $n): int|array|null return $newStmts; } -} +} \ No newline at end of file diff --git a/src/Resolver/SpecialTypeResolver.php b/src/Resolver/SpecialTypeResolver.php index 56a1c5a..1026bb2 100644 --- a/src/Resolver/SpecialTypeResolver.php +++ b/src/Resolver/SpecialTypeResolver.php @@ -1065,7 +1065,7 @@ public static function isBuiltInTypeKeyword(string $name): bool * 3. Trait DocBlocks: Scans doc comments preceding T_USE statements inside class declarations. * 4. Top-Level Imports: Parses single, multi, and group use statements outside classes into alias maps. */ - private static function parseFileMetadata(string $fileName, string $source): void + public static function parseFileMetadata(string $fileName, string $source): void { self::$fileNamespaces[$fileName] = ''; self::$fileUseImports[$fileName] = []; diff --git a/src/Resolver/TemplateManager.php b/src/Resolver/TemplateManager.php index b601783..c9254c0 100644 --- a/src/Resolver/TemplateManager.php +++ b/src/Resolver/TemplateManager.php @@ -5,6 +5,7 @@ namespace TypePHP\Resolver; use PHPStan\PhpDocParser\Ast\PhpDoc\TemplateTagValueNode; +use PHPStan\PhpDocParser\Ast\Type\ArrayShapeNode; use PHPStan\PhpDocParser\Ast\Type\ArrayTypeNode; use PHPStan\PhpDocParser\Ast\Type\GenericTypeNode; use PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode; @@ -17,6 +18,7 @@ use PHPStan\PhpDocParser\Parser\TokenIterator; use PHPStan\PhpDocParser\Parser\TypeParser; use PHPStan\PhpDocParser\ParserConfig; +use TypePHP\Contract\ContractParser; use TypePHP\Contract\DocblockExtractor; use TypePHP\Contract\FileFilter; use TypePHP\Contract\HierarchyResolver; @@ -244,6 +246,35 @@ final class TemplateManager ], ]; + /** + * O(1) hashmap for collection and iterable subtype relationships. + * + * @var array> + */ + private const COLLECTION_SUBTYPES = [ + 'array' => [ + 'array' => true, + 'list' => true, + 'non-empty-array' => true, + 'non-empty-list' => true, + ], + 'list' => [ + 'list' => true, + 'non-empty-list' => true, + ], + 'iterable' => [ + 'iterable' => true, + 'array' => true, + 'list' => true, + 'non-empty-array' => true, + 'non-empty-list' => true, + 'traversable' => true, + ], + 'traversable' => [ + 'traversable' => true, + ], + ]; + /** * O(1) lookup set for valid supertypes of integer ranges and numeric literals. * @@ -281,6 +312,38 @@ public static function reset(): void self::$pendingCloneSource = null; } + /** + * Normalizes generic type arguments when a single type argument is supplied + * for a 2-template collection/map whose first template is a key type (TKey of array-key). + * + * @param array $genericTypes + * @param array $templateList + * + * @return array + */ + public static function normalizeGenericArguments(array $genericTypes, array $templateList): array + { + $genericTypes = array_values($genericTypes); + $templateList = array_values($templateList); + + if (\count($genericTypes) === 1 && \count($templateList) === 2) { + $firstTemplate = $templateList[0]; + $firstBound = $firstTemplate->bound !== null ? strtolower((string) $firstTemplate->bound) : ''; + $firstName = strtolower($firstTemplate->name); + + $isKeyTemplate = $firstBound === 'array-key' + || \in_array($firstName, ['tkey', 'key', 'k'], true); + + if ($isKeyTemplate) { + $defaultKeyNode = $firstTemplate->default ?? $firstTemplate->bound ?? new IdentifierTypeNode('array-key'); + + return [$defaultKeyNode, $genericTypes[0]]; + } + } + + return $genericTypes; + } + /** * Copies bound generic template types from a source object to a cloned target object. */ @@ -332,10 +395,6 @@ public static function getBoundTemplates(string $function, ?object $thisObj, arr $bindings = []; if ($thisObj !== null) { - if (self::$pendingCloneSource !== null && ! isset(self::$instanceTemplateBindings[$thisObj])) { - self::copyInstanceBindings(self::$pendingCloneSource, $thisObj); - } - if (self::$instanceTemplateBindings === null || ! isset(self::$instanceTemplateBindings[$thisObj])) { self::resolveInheritedTemplates($thisObj, \get_class($thisObj)); } @@ -348,7 +407,17 @@ public static function getBoundTemplates(string $function, ?object $thisObj, arr if (self::hasCallFrame($function)) { $topFrame = end(self::$callStackBindings[$function]); if ($topFrame !== false) { - $bindings = [...$bindings, ...$topFrame]; + if ($thisObj !== null) { + $contract = ContractParser::parse($function); + $methodTemplates = $contract['templates'] ?? []; + foreach ($topFrame as $tName => $tNode) { + if (isset($methodTemplates[$tName])) { + $bindings[$tName] = $tNode; + } + } + } else { + $bindings = [...$bindings, ...$topFrame]; + } } } @@ -362,10 +431,6 @@ public static function getBoundTemplates(string $function, ?object $thisObj, arr */ public static function getBoundTemplatesForInstance(object $instance): array { - if (self::$pendingCloneSource !== null && ! isset(self::$instanceTemplateBindings[$instance])) { - self::copyInstanceBindings(self::$pendingCloneSource, $instance); - } - if (self::$instanceTemplateBindings === null || ! isset(self::$instanceTemplateBindings[$instance])) { self::resolveInheritedTemplates($instance, \get_class($instance)); } @@ -417,10 +482,6 @@ public static function isBound(string $function, ?object $thisObj, string $templ } if ($thisObj !== null) { - if (self::$pendingCloneSource !== null && ! isset(self::$instanceTemplateBindings[$thisObj])) { - self::copyInstanceBindings(self::$pendingCloneSource, $thisObj); - } - if (self::$instanceTemplateBindings === null || ! isset(self::$instanceTemplateBindings[$thisObj])) { self::resolveInheritedTemplates($thisObj, \get_class($thisObj)); } @@ -444,10 +505,6 @@ public static function getBoundType(string $function, ?object $thisObj, string $ } if ($thisObj !== null) { - if (self::$pendingCloneSource !== null && ! isset(self::$instanceTemplateBindings[$thisObj])) { - self::copyInstanceBindings(self::$pendingCloneSource, $thisObj); - } - if (self::$instanceTemplateBindings === null || ! isset(self::$instanceTemplateBindings[$thisObj])) { self::resolveInheritedTemplates($thisObj, \get_class($thisObj)); } @@ -500,9 +557,14 @@ public static function bindInstanceFromNode(object $instance, GenericTypeNode $t self::$instanceTemplateBindings ??= new WeakMap(); $templateList = array_values($templates); + $normalizedTypeArgs = self::normalizeGenericArguments($typeNode->genericTypes, $templateList); foreach ($templateList as $index => $templateTag) { - $err = self::bindSingleTemplateArgument($instance, $className, $typeNode, $index, $templateTag, $classVariances, $context, $forceBind); + if (! isset($normalizedTypeArgs[$index])) { + continue; + } + + $err = self::bindSingleTemplateArgument($instance, $className, $normalizedTypeArgs[$index], $typeNode->variances[$index] ?? GenericTypeNode::VARIANCE_INVARIANT, $templateTag, $classVariances, $context, $forceBind); if ($err !== null) { return $err; } @@ -573,19 +635,13 @@ private static function collectHierarchyTemplatesAndVariances(\ReflectionClass $ private static function bindSingleTemplateArgument( object $instance, string $className, - GenericTypeNode $typeNode, - int $index, + TypeNode $expectedTypeNode, + string $usageVariance, TemplateTagValueNode $templateTag, array $classVariances, string $context, bool $forceBind ): ?ErrorMessage { - if (! isset($typeNode->genericTypes[$index])) { - return null; - } - - $expectedTypeNode = $typeNode->genericTypes[$index]; - if ($expectedTypeNode instanceof IdentifierTypeNode) { $isBuiltIn = SpecialTypeResolver::isBuiltInTypeKeyword($expectedTypeNode->name); $isRealType = class_exists($expectedTypeNode->name) || interface_exists($expectedTypeNode->name) || enum_exists($expectedTypeNode->name) || trait_exists($expectedTypeNode->name); @@ -609,7 +665,6 @@ private static function bindSingleTemplateArgument( self::$instanceTemplateBindings = new WeakMap(); } - $usageVariance = $typeNode->variances[$index] ?? GenericTypeNode::VARIANCE_INVARIANT; $declaredVariance = $classVariances[$templateTag->name] ?? GenericTypeNode::VARIANCE_INVARIANT; // Return values are naturally in a covariant position under Liskov Substitution Principle @@ -627,20 +682,37 @@ private static function bindSingleTemplateArgument( $valid = self::checkVariance($existingTypeNode, $expectedTypeNode, $variance); if (! $valid) { - if ($existingTypeNode instanceof IdentifierTypeNode && strtolower($existingTypeNode->name) === 'mixed') { - $bindings = self::$instanceTemplateBindings[$instance] ?? []; - $bindings[$templateName] = $expectedTypeNode; - self::$instanceTemplateBindings[$instance] = $bindings; + $isDefaultOrBound = ($existingTypeNode instanceof IdentifierTypeNode) && ( + strtolower($existingTypeNode->name) === 'mixed' + || strtolower($existingTypeNode->name) === 'array-key' + || ($templateTag->bound !== null && (string) $existingTypeNode === (string) $templateTag->bound) + || ($templateTag->default !== null && (string) $existingTypeNode === (string) $templateTag->default) + ); + + if (self::checkVariance($expectedTypeNode, $existingTypeNode, GenericTypeNode::VARIANCE_COVARIANT)) { + if ($isDefaultOrBound || $isReturnContext) { + $bindings = self::$instanceTemplateBindings[$instance] ?? []; + $bindings[$templateName] = $expectedTypeNode; + self::$instanceTemplateBindings[$instance] = $bindings; - return null; + return null; + } } - if ($isReturnContext && self::checkVariance($expectedTypeNode, $existingTypeNode, GenericTypeNode::VARIANCE_COVARIANT)) { - $bindings = self::$instanceTemplateBindings[$instance] ?? []; - $bindings[$templateName] = $expectedTypeNode; - self::$instanceTemplateBindings[$instance] = $bindings; + if ($isReturnContext) { + $isWrapping = ($expectedTypeNode instanceof ArrayTypeNode) + && self::checkVariance($expectedTypeNode->type, $existingTypeNode, GenericTypeNode::VARIANCE_COVARIANT); - return null; + $isUnwrapping = ($existingTypeNode instanceof ArrayTypeNode) + && self::checkVariance($expectedTypeNode, $existingTypeNode->type, GenericTypeNode::VARIANCE_COVARIANT); + + if ($isWrapping || $isUnwrapping) { + $bindings = self::$instanceTemplateBindings[$instance] ?? []; + $bindings[$templateName] = $expectedTypeNode; + self::$instanceTemplateBindings[$instance] = $bindings; + + return null; + } } return ErrorFactory::createError( @@ -785,10 +857,12 @@ private static function collectInheritedGenericTagBindings( $parentPhpDocNode = DocblockExtractor::parseDocString($parentDoc); $parentTemplateNames = array_keys(DocblockExtractor::extractTemplates($parentPhpDocNode)); + $parentTemplateNodes = array_values(DocblockExtractor::extractTemplates($parentPhpDocNode)); + $normalizedGenericTypes = self::normalizeGenericArguments($genericTypeNode->genericTypes, $parentTemplateNodes); foreach ($parentTemplateNames as $idx => $templateName) { - if (isset($genericTypeNode->genericTypes[$idx])) { - $resolved = self::resolveTypeNodeAst($genericTypeNode->genericTypes[$idx], $hierClass); + if (isset($normalizedGenericTypes[$idx])) { + $resolved = self::resolveTypeNodeAst($normalizedGenericTypes[$idx], $hierClass); if ($resolved instanceof IdentifierTypeNode) { $isBuiltIn = SpecialTypeResolver::isBuiltInTypeKeyword($resolved->name); @@ -856,6 +930,22 @@ public static function checkVariance(TypeNode $existing, TypeNode $expected, str return true; } + if ($variance === GenericTypeNode::VARIANCE_COVARIANT && isset(self::COLLECTION_SUBTYPES[$lowerExpected])) { + $allowedSubtypes = self::COLLECTION_SUBTYPES[$lowerExpected]; + + if (isset($allowedSubtypes[$lowerExisting])) { + return true; + } + + if ($existing instanceof ArrayTypeNode || $existing instanceof ArrayShapeNode) { + return $lowerExpected === 'array' || $lowerExpected === 'iterable'; + } + + if ($existing instanceof GenericTypeNode && isset($allowedSubtypes[strtolower($existing->type->name)])) { + return true; + } + } + if ($expected instanceof UnionTypeNode) { return self::checkExpectedUnionVariance($existing, $expected, $variance); } @@ -915,19 +1005,19 @@ private static function isScalarSubtype(string $sub, string $super): bool private static function checkExpectedUnionVariance(TypeNode $existing, UnionTypeNode $expected, string $variance): bool { - if ($variance === GenericTypeNode::VARIANCE_COVARIANT) { + if ($variance === GenericTypeNode::VARIANCE_CONTRAVARIANT) { foreach ($expected->types as $unionVariant) { - if (self::checkVariance($existing, $unionVariant, $variance)) { - return true; + if (! self::checkVariance($existing, $unionVariant, $variance)) { + return false; } } - return false; + return true; } - if ($variance === GenericTypeNode::VARIANCE_CONTRAVARIANT) { - foreach ($expected->types as $unionVariant) { - if (! self::checkVariance($existing, $unionVariant, $variance)) { + if ($existing instanceof UnionTypeNode) { + foreach ($existing->types as $existingVariant) { + if (! self::checkExpectedUnionVariance($existingVariant, $expected, $variance)) { return false; } } @@ -935,6 +1025,12 @@ private static function checkExpectedUnionVariance(TypeNode $existing, UnionType return true; } + foreach ($expected->types as $unionVariant) { + if (self::checkVariance($existing, $unionVariant, GenericTypeNode::VARIANCE_COVARIANT)) { + return true; + } + } + return false; } @@ -965,16 +1061,6 @@ private static function checkExistingUnionVariance(UnionTypeNode $existing, Type private static function checkExpectedIntersectionVariance(TypeNode $existing, IntersectionTypeNode $expected, string $variance): bool { - if ($variance === GenericTypeNode::VARIANCE_COVARIANT) { - foreach ($expected->types as $intersectionMember) { - if (! self::checkVariance($existing, $intersectionMember, $variance)) { - return false; - } - } - - return true; - } - if ($variance === GenericTypeNode::VARIANCE_CONTRAVARIANT) { foreach ($expected->types as $intersectionMember) { if (self::checkVariance($existing, $intersectionMember, $variance)) { @@ -985,21 +1071,17 @@ private static function checkExpectedIntersectionVariance(TypeNode $existing, In return true; } - return false; + foreach ($expected->types as $intersectionMember) { + if (! self::checkVariance($existing, $intersectionMember, GenericTypeNode::VARIANCE_COVARIANT)) { + return false; + } + } + + return true; } private static function checkExistingIntersectionVariance(IntersectionTypeNode $existing, TypeNode $expected, string $variance): bool { - if ($variance === GenericTypeNode::VARIANCE_COVARIANT) { - foreach ($existing->types as $existingMember) { - if (self::checkVariance($existingMember, $expected, $variance)) { - return true; - } - } - - return true; - } - if ($variance === GenericTypeNode::VARIANCE_CONTRAVARIANT) { foreach ($existing->types as $existingMember) { if (! self::checkVariance($existingMember, $expected, $variance)) { @@ -1010,6 +1092,12 @@ private static function checkExistingIntersectionVariance(IntersectionTypeNode $ return true; } + foreach ($existing->types as $existingMember) { + if (self::checkVariance($existingMember, $expected, GenericTypeNode::VARIANCE_COVARIANT)) { + return true; + } + } + return false; } @@ -1135,7 +1223,7 @@ private static function resolveTypeNodeAst(TypeNode $n, \ReflectionClass $ref): } if ($n instanceof GenericTypeNode) { $base = new IdentifierTypeNode(SpecialTypeResolver::resolveFqcn($n->type->name, $ref)); - $generics = array_map(fn ($t) => self::resolveTypeNodeAst($t, $ref), $n->genericTypes); + $generics = array_map(fn($t) => self::resolveTypeNodeAst($t, $ref), $n->genericTypes); return new GenericTypeNode($base, $generics, $n->variances); } @@ -1146,10 +1234,10 @@ private static function resolveTypeNodeAst(TypeNode $n, \ReflectionClass $ref): return new NullableTypeNode(self::resolveTypeNodeAst($n->type, $ref)); } if ($n instanceof UnionTypeNode) { - return new UnionTypeNode(array_map(fn ($t) => self::resolveTypeNodeAst($t, $ref), $n->types)); + return new UnionTypeNode(array_map(fn($t) => self::resolveTypeNodeAst($t, $ref), $n->types)); } if ($n instanceof IntersectionTypeNode) { - return new IntersectionTypeNode(array_map(fn ($t) => self::resolveTypeNodeAst($t, $ref), $n->types)); + return new IntersectionTypeNode(array_map(fn($t) => self::resolveTypeNodeAst($t, $ref), $n->types)); } return $n; diff --git a/src/TypePHP.php b/src/TypePHP.php index 0107094..813038c 100644 --- a/src/TypePHP.php +++ b/src/TypePHP.php @@ -37,7 +37,7 @@ public static function getGenericType(object $instance, ?string $templateName = return reset($types); } - return $types['T'] ?? null; + return $types['T'] ?? $types['TValue'] ?? $types['TElement'] ?? $types['V'] ?? null; } /** @@ -75,7 +75,7 @@ public static function getGenericVariance(object $instance, ?string $templateNam return reset($variances); } - return $variances['T'] ?? 'invariant'; + return $variances['T'] ?? $variances['TValue'] ?? $variances['TElement'] ?? $variances['V'] ?? 'invariant'; } /** diff --git a/src/Validator/ArrayValidator.php b/src/Validator/ArrayValidator.php index 77aeb11..19f98d4 100644 --- a/src/Validator/ArrayValidator.php +++ b/src/Validator/ArrayValidator.php @@ -21,8 +21,6 @@ */ final class ArrayValidator implements TypeValidatorInterface { - private const HYBRID_SAMPLE_THRESHOLD = 64; - public function validate(mixed $value, TypeNode $node, string $context, TypeValidatorRegistry $registry): ?ErrorMessage { if (! \is_array($value) && ! ($value instanceof Traversable)) { @@ -42,7 +40,7 @@ public function validate(mixed $value, TypeNode $node, string $context, TypeVali return null; } - if ($count > self::HYBRID_SAMPLE_THRESHOLD && Config::isArrayValidationHybrid()) { + if ($count > Config::HYBRID_SAMPLE_THRESHOLD && Config::isArrayValidationHybrid()) { return $this->validateArrayHybrid($value, $arrayNode, $context, $registry, $count); } diff --git a/src/Validator/GenericValidator.php b/src/Validator/GenericValidator.php index b333de6..1a123d8 100644 --- a/src/Validator/GenericValidator.php +++ b/src/Validator/GenericValidator.php @@ -26,8 +26,6 @@ */ final class GenericValidator implements TypeValidatorInterface { - private const HYBRID_SAMPLE_THRESHOLD = 64; - /** * @var array */ @@ -98,16 +96,6 @@ private function resolveConstantValue(string $fqcn, string $constName): mixed /** * Validates key-of generic structures with O(1) in-memory caching. - * - * Execution Flow: - * 1. Array Constants: If T is a class constant (e.g., self::DRIVER_MAP), it safely reflects the - * target class to bypass visibility restrictions (private/protected), caches the array in memory, - * and verifies that the provided value exists as a key in that array. - * 2. Enums: If T is an Enum identifier, it extracts and caches the enum case names, then verifies - * that the provided value matches a valid case name. - * 3. Array Shapes: If T is an inline array shape (e.g., array{id: int, name: string}), it verifies - * that the provided value exists as one of the key names in the shape. - * 4. Fallback: Returns null gracefully for unresolvable or unsupported structures. */ private function validateKeyOf(mixed $value, GenericTypeNode $node, string $context): ?ErrorMessage { @@ -174,15 +162,6 @@ private function validateKeyOf(mixed $value, GenericTypeNode $node, string $cont /** * Validates value-of generic structures with O(1) in-memory caching. - * - * Execution Flow: - * 1. Array Constants: If T is a class constant (e.g., self::DRIVER_MAP), it safely reflects the - * target class to bypass visibility restrictions (private/protected), caches the array in memory, - * and verifies that the provided value exists as a value in that array. - * 2. Backed Enums: If T is a Backed Enum identifier, it extracts and caches the enum case backing values, - * then verifies that the provided value matches a valid case value. - * 3. Unit Enums: Pure non-backed UnitEnums have no backing values, so any value-of check fails. - * 4. Fallback: Returns null gracefully for unresolvable or unsupported structures. */ private function validateValueOf(mixed $value, GenericTypeNode $node, string $context): ?ErrorMessage { @@ -354,7 +333,7 @@ private function validateIntRange(mixed $value, GenericTypeNode $node, string $c */ private function validateClassString(mixed $value, GenericTypeNode $node, string $context): ?ErrorMessage { - if (! \is_string($value) || ! ClassNameValidator::isValid($value) || (! class_exists($value) && ! interface_exists($value) && ! trait_exists($value) && ! enum_exists($value))) { + if (! \is_string($value) || ! ClassNameValidator::isValidClassString($value)) { return ErrorFactory::createError($context . ' must be a valid class-string, ' . TypeFormatter::formatGivenValue($value) . ' given'); } @@ -429,7 +408,7 @@ private function validateList(mixed $value, GenericTypeNode $node, string $conte if ($valueTypeNode !== null && $count > 0) { $isComplexObjectGeneric = ($valueTypeNode instanceof GenericTypeNode && ! \in_array(strtolower($valueTypeNode->type->name), ['class-string', 'list', 'array', 'iterable'], strict: true)); - if ($count > self::HYBRID_SAMPLE_THRESHOLD && Config::isArrayValidationHybrid()) { + if ($count > Config::HYBRID_SAMPLE_THRESHOLD && Config::isArrayValidationHybrid()) { $sampleIndices = [0, $count - 1]; $samplesToTake = min(3, $count - 2); for ($i = 0; $i < $samplesToTake; $i++) { @@ -493,7 +472,7 @@ private function validateArray(mixed $value, GenericTypeNode $node, string $cont if ($typesCount === 1) { $valTypeNode = $node->genericTypes[0]; $isComplexObjectGeneric = ($valTypeNode instanceof GenericTypeNode && ! \in_array(strtolower($valTypeNode->type->name), ['class-string', 'list', 'array', 'iterable'], strict: true)); - if ($count > self::HYBRID_SAMPLE_THRESHOLD && Config::isArrayValidationHybrid()) { + if ($count > Config::HYBRID_SAMPLE_THRESHOLD && Config::isArrayValidationHybrid()) { $keys = array_keys($value); $sampleKeys = [$keys[0], $keys[$count - 1]]; $samplesToTake = min(3, $count - 2); @@ -529,7 +508,7 @@ private function validateArray(mixed $value, GenericTypeNode $node, string $cont $valTypeNode = $node->genericTypes[1]; $isComplexObjectGeneric = ($valTypeNode instanceof GenericTypeNode && ! \in_array(strtolower($valTypeNode->type->name), ['class-string', 'list', 'array', 'iterable'], strict: true)); - if ($count > self::HYBRID_SAMPLE_THRESHOLD && Config::isArrayValidationHybrid()) { + if ($count > Config::HYBRID_SAMPLE_THRESHOLD && Config::isArrayValidationHybrid()) { $keys = array_keys($value); $sampleKeys = [$keys[0], $keys[$count - 1]]; $samplesToTake = min(3, $count - 2); diff --git a/src/Validator/IdentifierValidator.php b/src/Validator/IdentifierValidator.php index 6f9ca9a..4a28877 100644 --- a/src/Validator/IdentifierValidator.php +++ b/src/Validator/IdentifierValidator.php @@ -10,6 +10,7 @@ use TypePHP\Internal\ErrorFactory; use TypePHP\Internal\ErrorMessage; use TypePHP\Internal\TypeFormatter; +use TypePHP\Wrapper\CallableWrapper; /** * @internal Class for validating basic scalar identifier types like int, string, bool, array, list, object, callable, resource, null, true, false, mixed, scalar, void. @@ -31,7 +32,7 @@ public function validate(mixed $value, TypeNode $node, string $context, TypeVali 'array' => \is_array($value), 'list' => \is_array($value) && (\count($value) === 0 || array_is_list($value)), 'object', 'self', 'static', 'parent', '$this' => \is_object($value), - 'callable', 'pure-callable' => \is_callable($value), + 'callable', 'pure-callable' => CallableWrapper::isCallable($value), 'iterable' => is_iterable($value), 'resource' => \is_resource($value), 'null' => $value === null, @@ -52,13 +53,11 @@ public function validate(mixed $value, TypeNode $node, string $context, TypeVali 'non-positive-float' => (\is_float($value) || \is_int($value)) && $value <= 0, 'non-negative-float' => (\is_float($value) || \is_int($value)) && $value >= 0, 'non-zero-float' => (\is_float($value) || \is_int($value)) && $value !== 0 && $value !== 0.0, - 'class-string' => \is_string($value) - && ClassNameValidator::isValid($value) - && (class_exists($value) || interface_exists($value) || trait_exists($value) || enum_exists($value)), + 'class-string' => \is_string($value) && ClassNameValidator::isValidClassString($value), 'interface-string' => \is_string($value) && interface_exists($value), 'trait-string' => \is_string($value) && trait_exists($value), 'enum-string' => \is_string($value) && enum_exists($value), - 'callable-string' => \is_string($value) && \is_callable($value), + 'callable-string' => \is_string($value) && CallableWrapper::isCallable($value), 'numeric-string' => \is_string($value) && is_numeric($value), 'non-empty-string' => \is_string($value) && $value !== '', 'lowercase-string' => \is_string($value) && strtolower($value) === $value, diff --git a/src/Wrapper/CallableWrapper.php b/src/Wrapper/CallableWrapper.php index 89da625..0a6f40d 100644 --- a/src/Wrapper/CallableWrapper.php +++ b/src/Wrapper/CallableWrapper.php @@ -4,11 +4,14 @@ namespace TypePHP\Wrapper; +use Closure; use PHPStan\PhpDocParser\Ast\Type\ArrayTypeNode; use PHPStan\PhpDocParser\Ast\Type\CallableTypeNode; use PHPStan\PhpDocParser\Ast\Type\GenericTypeNode; use PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode; use PHPStan\PhpDocParser\Ast\Type\TypeNode; +use ReflectionFunction; +use TypeError; use TypePHP\Contract\ContractParser; use TypePHP\Exception\TypeError as TypePHPTypeError; use TypePHP\Internal\ErrorFactory; @@ -25,6 +28,43 @@ */ final class CallableWrapper { + /** + * Safely checks if a value is callable without triggering PHP 8.2+ deprecation warnings + * on partially supported callables (e.g. 'static::method', ['static', 'method']). + */ + public static function isCallable(mixed $value): bool + { + if ($value instanceof Closure) { + return true; + } + + if (\is_object($value)) { + return method_exists($value, '__invoke'); + } + + if (\is_string($value)) { + if ($value === '' || str_starts_with($value, 'static::') || str_starts_with($value, 'self::') || str_starts_with($value, 'parent::')) { + return false; + } + + return \is_callable($value); + } + + if (\is_array($value)) { + if (! isset($value[0], $value[1]) || \count($value) !== 2) { + return false; + } + + if (\is_string($value[0]) && \in_array(strtolower($value[0]), ['static', 'self', 'parent'], true)) { + return false; + } + + return \is_callable($value); + } + + return false; + } + /** * Resolves callable contract metadata for a function parameter or return value and wraps the callable. */ @@ -49,7 +89,7 @@ public static function wrap(string $function, string $paramName, mixed $callable $prefix = ($paramName === 'return') ? "$function(): Return value" : "$function(): Callback \$$paramName"; - if (\is_callable($callable)) { + if (self::isCallable($callable)) { return self::wrapTypeNode($typeNode, $callable, $prefix, $registry); } @@ -65,7 +105,7 @@ public static function wrap(string $function, string $paramName, mixed $callable if ($innerCallableTypeNode instanceof CallableTypeNode) { $wrappedArray = []; foreach ($callable as $k => $item) { - if (\is_callable($item)) { + if (self::isCallable($item)) { $itemPrefix = $prefix . (\is_int($k) ? "[$k]" : "['$k']"); $wrappedArray[$k] = self::wrapTypeNode($innerCallableTypeNode, $item, $itemPrefix, $registry); } else { @@ -85,28 +125,34 @@ public static function wrap(string $function, string $paramName, mixed $callable */ public static function wrapTypeNode(?TypeNode $typeNode, mixed $callable, string $prefix, TypeValidatorRegistry $registry): mixed { - if (! \is_callable($callable) || ! ($typeNode instanceof CallableTypeNode)) { + if (! ($typeNode instanceof CallableTypeNode) || ! self::isCallable($callable)) { return $callable; } $identifierName = strtolower(ltrim($typeNode->identifier->name, '\\')); self::enforceClosureConstraints($identifierName, $callable, $prefix); + /** @var callable $callable */ return function (...$args) use ($callable, $typeNode, $registry, $prefix) { self::validateCallbackArguments($typeNode, $args, $prefix, $registry); try { $result = $callable(...$args); - } catch (\TypeError $e) { + } catch (TypeError $e) { throw ErrorFactory::prepareException($e); } - $err = $registry->validate($result, $typeNode->returnType, "$prefix return value"); - if ($err !== null) { - throw ErrorFactory::prepareException(new TypePHPTypeError($err->getMessage())); + $isVoidReturn = ($typeNode->returnType instanceof IdentifierTypeNode) + && strtolower($typeNode->returnType->name) === 'void'; + + if (! $isVoidReturn) { + $err = $registry->validate($result, $typeNode->returnType, "$prefix return value"); + if ($err !== null) { + throw ErrorFactory::prepareException(new TypePHPTypeError($err->getMessage())); + } } - if ($typeNode->returnType instanceof CallableTypeNode && \is_callable($result)) { + if ($typeNode->returnType instanceof CallableTypeNode && self::isCallable($result)) { $result = self::wrapTypeNode($typeNode->returnType, $result, "$prefix: Returned callback", $registry); } @@ -119,12 +165,12 @@ public static function wrapTypeNode(?TypeNode $typeNode, mixed $callable, string */ private static function enforceClosureConstraints(string $identifierName, mixed $callable, string $prefix): void { - if (str_contains($identifierName, 'closure') && ! ($callable instanceof \Closure)) { + if (str_contains($identifierName, 'closure') && ! ($callable instanceof Closure)) { throw ErrorFactory::prepareException(new TypePHPTypeError($prefix . ' must be of type Closure, ' . TypeFormatter::formatGivenValue($callable) . ' given')); } - if (str_contains($identifierName, 'static') && $callable instanceof \Closure) { - $refFunc = new \ReflectionFunction($callable); + if (str_contains($identifierName, 'static') && $callable instanceof Closure) { + $refFunc = new ReflectionFunction($callable); if ($refFunc->getClosureThis() !== null) { throw ErrorFactory::prepareException(new TypePHPTypeError($prefix . ' must be a static Closure (not bound to $this)')); } diff --git a/tests/Resolver/SpecialTypeResolverTest.php b/tests/Resolver/SpecialTypeResolverTest.php index a7f6aed..3026af0 100644 --- a/tests/Resolver/SpecialTypeResolverTest.php +++ b/tests/Resolver/SpecialTypeResolverTest.php @@ -295,4 +295,106 @@ expect(SpecialTypeResolver::resolveFqcnForFile('non-empty-string', 'some_file.php'))->toBe('non-empty-string'); }); }); + + describe('parseFileMetadata Tokenizer Edge Cases', function () { + test('extracts single, aliased, multi, and group use imports accurately', function () { + $source = <<<'PHP' + + */ + use LoggerTrait; + + public function process(): void + { + $callback = function () use ($userData) { + // Must not be confused with class use import! + }; + } +} +PHP; + + $virtualFile = 'VirtualCommerceService.php'; + SpecialTypeResolver::parseFileMetadata($virtualFile, $source); + + expect(SpecialTypeResolver::getNamespaceFromFile($virtualFile))->toBe('App\Services\Commerce'); + + $imports = SpecialTypeResolver::getUseImportsFromFile($virtualFile); + + expect($imports)->toHaveKey('User') + ->and($imports['User'])->toBe('App\Models\User') + ; + + expect($imports)->toHaveKey('CustomerOrder') + ->and($imports['CustomerOrder'])->toBe('App\Models\Order') + ; + + expect($imports)->toHaveKey('PaymentInterface') + ->and($imports['PaymentInterface'])->toBe('App\Contracts\PaymentInterface') + ->and($imports)->toHaveKey('Refund') + ->and($imports['Refund'])->toBe('App\Contracts\RefundInterface') + ->and($imports)->toHaveKey('Formatter') + ->and($imports['Formatter'])->toBe('App\Contracts\Utilities\Formatter') + ; + + expect($imports)->toHaveKey('MathHelper') + ->and($imports['MathHelper'])->toBe('App\Helpers\MathHelper') + ->and($imports)->toHaveKey('Str') + ->and($imports['Str'])->toBe('App\Helpers\StringHelper') + ; + + expect($imports)->toHaveKey('compute') + ->and($imports['compute'])->toBe('App\Utils\calculateTotal') + ->and($imports)->toHaveKey('LIMIT') + ->and($imports['LIMIT'])->toBe('App\Config\MAX_ITEMS') + ; + + expect($imports)->not()->toHaveKey('userData') + ->and($imports)->not()->toHaveKey('LoggerTrait') + ; + + $traitDocs = SpecialTypeResolver::getClassTraitUseDocs('App\Services\Commerce\CommerceService'); + expect($traitDocs)->toHaveCount(1) + ->and($traitDocs[0])->toContain('LoggerTrait') + ; + }); + + test('ignores use statements in docblock code examples and comments', function () { + $source = <<<'PHP' +toBeEmpty(); + }); + }); }); diff --git a/tests/RuntimeChecker/ParamCheckerTest.php b/tests/RuntimeChecker/ParamCheckerTest.php index 996e088..c5a8c34 100644 --- a/tests/RuntimeChecker/ParamCheckerTest.php +++ b/tests/RuntimeChecker/ParamCheckerTest.php @@ -171,7 +171,7 @@ $target = ClassStringFactoryContainer::class . '::makeCountable'; $err = ParamChecker::checkParams($target, [ - 'class' => 'NonExistentClass12345', + 'class' => 'Invalid-Class-Name!', ], null, $registry); expect($err)->toBeInstanceOf(ErrorMessage::class) diff --git a/tests/TypeChecking/ArraysAndShapes/ArrayComplexSubtypesTest.php b/tests/TypeChecking/ArraysAndShapes/ArrayComplexSubtypesTest.php new file mode 100644 index 0000000..2e1a211 --- /dev/null +++ b/tests/TypeChecking/ArraysAndShapes/ArrayComplexSubtypesTest.php @@ -0,0 +1,246 @@ + 1]; + public function count(): int { return count($this->data); } + public function offsetExists(mixed $offset): bool { return isset($this->data[$offset]); } + public function offsetGet(mixed $offset): mixed { return $this->data[$offset] ?? null; } + public function offsetSet(mixed $offset, mixed $value): void { $this->data[$offset] = $value; } + public function offsetUnset(mixed $offset): void { unset($this->data[$offset]); } + public function rewind(): void { reset($this->data); } + public function current(): mixed { return current($this->data); } + public function key(): mixed { return key($this->data); } + public function next(): void { next($this->data); } + public function valid(): bool { return key($this->data) !== null; } +} + +class ArrayDoubleInterfaceObject implements Countable, ArrayAccess +{ + private array $data = ['a' => 1]; + public function count(): int { return count($this->data); } + public function offsetExists(mixed $offset): bool { return isset($this->data[$offset]); } + public function offsetGet(mixed $offset): mixed { return $this->data[$offset] ?? null; } + public function offsetSet(mixed $offset, mixed $value): void { $this->data[$offset] = $value; } + public function offsetUnset(mixed $offset): void { unset($this->data[$offset]); } +} + +class ArrayCountableOnly implements Countable +{ + public function count(): int { return 1; } +} + +/** + * @param list $animals + * @return list + */ +function acceptSupersetAnimalList(array $animals): array +{ + return $animals; +} + +/** + * @param list $animals + * @return list + */ +function acceptSubsetAnimalList(array $animals): array +{ + return $animals; +} + +/** + * @param list<'admin'|'editor'|'viewer'|'guest'> $roles + */ +function acceptSupersetRolesList(array $roles): array +{ + return $roles; +} + +/** + * @param list<1|2|3|4|5> $numbers + */ +function acceptSupersetNumbersList(array $numbers): array +{ + return $numbers; +} + +/** + * @param array<'alpha'|'beta'|'gamma', Dog|Cat|ArraySubtypeBird> $map + */ +function acceptSupersetMap(array $map): array +{ + return $map; +} + +/** + * @param list $items + */ +function acceptIntersectionList(array $items): array +{ + return $items; +} + +/** + * @param list<(Countable&ArrayAccess)|(Iterator&Countable)> $items + */ +function acceptDnfList(array $items): array +{ + return $items; +} + +/** + * @param list> $nested + */ +function acceptNestedAnimalList(array $nested): array +{ + return $nested; +} + +describe('Array Complex Subtypes (Unions, Intersections, DNF)', function () { + describe('Union Subset Subtyping in Arrays and Lists', function () { + test('allows array containing subset class union into function expecting superset union (Dog and Cat into Dog|Cat|Bird)', function () { + $subset = [new Dog(), new Cat()]; + + $result = acceptSupersetAnimalList($subset); + expect($result)->toBe($subset); + }); + + test('allows inline @var assignment of subset union into superset union list', function () { + /** @var list $subsetList */ + $subsetList = [new Dog(), new Cat()]; + + /** @var list $supersetList */ + $supersetList = $subsetList; + + expect($supersetList)->toBe($subsetList); + }); + + test('allows string literal subset array into superset string literal list', function () { + $subsetRoles = ['admin', 'editor']; + + expect(acceptSupersetRolesList($subsetRoles))->toBe($subsetRoles); + + /** @var list<'admin'|'editor'|'viewer'|'guest'> $targetList */ + $targetList = $subsetRoles; + expect($targetList)->toBe($subsetRoles); + }); + + test('allows integer literal subset array into superset integer literal list', function () { + $subsetNumbers = [1, 2, 5]; + + expect(acceptSupersetNumbersList($subsetNumbers))->toBe($subsetNumbers); + + /** @var list<1|2|3|4|5> $targetList */ + $targetList = $subsetNumbers; + expect($targetList)->toBe($subsetNumbers); + }); + + test('allows associative array with subset union keys and subset union values', function () { + $subsetMap = [ + 'alpha' => new Dog(), + 'beta' => new Cat(), + ]; + + expect(acceptSupersetMap($subsetMap))->toBe($subsetMap); + }); + + test('strictly rejects array with broader elements passed to function expecting narrower subset union', function () { + $broadArray = [new Dog(), new Cat(), new ArraySubtypeBird()]; + + expect(fn () => acceptSubsetAnimalList($broadArray)) + ->toThrow(TypeError::class); + }); + + test('strictly rejects array containing incompatible element not in union', function () { + $incompatibleArray = [new Dog(), new Car()]; + + expect(fn () => acceptSupersetAnimalList($incompatibleArray)) + ->toThrow(TypeError::class); + }); + }); + + describe('Intersection Subtyping in Arrays and Lists', function () { + test('allows array of triple-interface objects into parameter expecting double-interface intersection list', function () { + $tripleList = [new ArrayTripleInterfaceObject()]; + + expect(acceptIntersectionList($tripleList))->toBe($tripleList); + }); + + test('allows inline @var assignment of triple-interface list into double-interface intersection list', function () { + $tripleList = [new ArrayTripleInterfaceObject()]; + + /** @var list $intersectionList */ + $intersectionList = $tripleList; + + expect($intersectionList)->toBe($tripleList); + }); + + test('strictly rejects array with object missing one required interface of the intersection', function () { + $incompleteList = [new ArrayCountableOnly()]; + + expect(fn () => acceptIntersectionList($incompleteList)) + ->toThrow(TypeError::class); + }); + }); + + describe('Disjunctive Normal Form (DNF) in Arrays and Lists', function () { + test('allows array of triple-interface objects into list expecting DNF ((A&B)|(C&D))', function () { + $tripleList = [new ArrayTripleInterfaceObject()]; + + expect(acceptDnfList($tripleList))->toBe($tripleList); + }); + + test('allows array with heterogeneous items satisfying different branches of DNF', function () { + $mixedDnfList = [ + new ArrayDoubleInterfaceObject(), + new ArrayTripleInterfaceObject(), + ]; + + expect(acceptDnfList($mixedDnfList))->toBe($mixedDnfList); + }); + + test('strictly rejects array containing an object that fails all DNF branches', function () { + $badList = [ + new ArrayDoubleInterfaceObject(), + new ArrayCountableOnly(), + ]; + + expect(fn () => acceptDnfList($badList)) + ->toThrow(TypeError::class); + }); + }); + + describe('Nested Complex Array Subtypes', function () { + test('allows nested list of subset union into nested list expecting superset union', function () { + $nestedSubset = [ + [new Dog()], + [new Cat(), new Dog()], + ]; + + expect(acceptNestedAnimalList($nestedSubset))->toBe($nestedSubset); + }); + + test('strictly rejects nested list when an inner element violates the union', function () { + $nestedBad = [ + [new Dog()], + [new Cat(), new Car()], + ]; + + expect(fn () => acceptNestedAnimalList($nestedBad)) + ->toThrow(TypeError::class); + }); + }); +}); \ No newline at end of file diff --git a/tests/TypeChecking/Boundaries/InlineVariableValidationTest.php b/tests/TypeChecking/Boundaries/InlineVariableValidationTest.php index dba6320..40bcaf3 100644 --- a/tests/TypeChecking/Boundaries/InlineVariableValidationTest.php +++ b/tests/TypeChecking/Boundaries/InlineVariableValidationTest.php @@ -279,4 +279,18 @@ function fetchBroadTuple(int $id, string $name): array ; }); + test('allows initializing empty array for array shape variable before preg_match_all population (Tempest TextBuffer)', function () { + /** @var array{0: list} $matches */ + $matches = []; + preg_match_all('/\X/u', '๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆab', $matches); + + expect($matches[0])->toBe(['๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ', 'a', 'b']); + }); + + test('still rejects non-empty incomplete array shapes on local variable assignment', function () { + expect(function () { + /** @var array{0: list, 1: list} $matches */ + $matches = [0 => ['test']]; + })->toThrow(TypeError::class, "is missing required key '1'"); + }); }); diff --git a/tests/TypeChecking/Boundaries/NormalUnionAndIntersectionSubtypesTest.php b/tests/TypeChecking/Boundaries/NormalUnionAndIntersectionSubtypesTest.php new file mode 100644 index 0000000..f632927 --- /dev/null +++ b/tests/TypeChecking/Boundaries/NormalUnionAndIntersectionSubtypesTest.php @@ -0,0 +1,310 @@ + 'value']; + + public function count(): int { return count($this->data); } + public function offsetExists(mixed $offset): bool { return isset($this->data[$offset]); } + public function offsetGet(mixed $offset): mixed { return $this->data[$offset] ?? null; } + public function offsetSet(mixed $offset, mixed $value): void { $this->data[$offset] = $value; } + public function offsetUnset(mixed $offset): void { unset($this->data[$offset]); } + public function rewind(): void { reset($this->data); } + public function current(): mixed { return current($this->data); } + public function key(): mixed { return key($this->data); } + public function next(): void { next($this->data); } + public function valid(): bool { return key($this->data) !== null; } + public function __toString(): string { return 'quad_object'; } +} + +/** + * Satisfies Branch A: (Countable & ArrayAccess) + */ +class NormalBranchAObject implements Countable, ArrayAccess +{ + private array $data = ['a' => 1]; + + public function count(): int { return count($this->data); } + public function offsetExists(mixed $offset): bool { return isset($this->data[$offset]); } + public function offsetGet(mixed $offset): mixed { return $this->data[$offset] ?? null; } + public function offsetSet(mixed $offset, mixed $value): void { $this->data[$offset] = $value; } + public function offsetUnset(mixed $offset): void { unset($this->data[$offset]); } +} + +/** + * Satisfies Branch B: (Iterator & Stringable) + */ +class NormalBranchBObject implements Iterator, Stringable +{ + private array $data = ['b' => 2]; + + public function rewind(): void { reset($this->data); } + public function current(): mixed { return current($this->data); } + public function key(): mixed { return key($this->data); } + public function next(): void { next($this->data); } + public function valid(): bool { return key($this->data) !== null; } + public function __toString(): string { return 'branch_b_object'; } +} + +/** + * Cross-Over Object: + * Implements Countable (from A) and Stringable (from B), + * but misses ArrayAccess (fails A) and misses Iterator (fails B). + */ +class NormalCrossOverObject implements Countable, Stringable +{ + public function count(): int { return 1; } + public function __toString(): string { return 'crossover_object'; } +} + +class NormalSingleCountable implements Countable +{ + public function count(): int { return 1; } +} + +/** + * Class + Interface Intersection + */ +class NormalSpecialDog extends Dog implements Countable +{ + public function count(): int { return 5; } +} + +/** + * @param Dog|Cat|NormalBird $animal + * @return Dog|Cat|NormalBird + */ +function processNormalUnionAnimal(Animal $animal): Animal +{ + return $animal; +} + +/** + * @param 'admin'|'editor'|'viewer'|'guest' $role + * @return 'admin'|'editor'|'viewer'|'guest' + */ +function processNormalUnionLiteralRole(string $role): string +{ + return $role; +} + +/** + * @param 1|2|3|4|5 $number + * @return 1|2|3|4|5 + */ +function processNormalUnionLiteralNumber(int $number): int +{ + return $number; +} + +/** + * @param positive-int|non-empty-string $idOrCode + * @return positive-int|non-empty-string + */ +function processNormalUnionRefinements(mixed $idOrCode): mixed +{ + return $idOrCode; +} + +/** + * @param Countable&ArrayAccess $collection + * @return Countable&ArrayAccess + */ +function processNormalIntersection(object $collection): object +{ + return $collection; +} + +/** + * @param Dog&Countable $pet + * @return Dog&Countable + */ +function processNormalClassInterfaceIntersection(object $pet): object +{ + return $pet; +} + +/** + * DNF: (Countable & ArrayAccess) | (Iterator & Stringable) + * + * @param (Countable&ArrayAccess)|(Iterator&Stringable) $dnf + * @return (Countable&ArrayAccess)|(Iterator&Stringable) + */ +function processNormalDnf(object $dnf): object +{ + return $dnf; +} + +describe('Normal Non-Generic Union, Intersection, and DNF Subtyping', function () { + describe('Union Parameter and Return Subtyping', function () { + test('accepts single subtype instance into union parameter (Dog into Dog|Cat|Bird)', function () { + $dog = new Dog(); + $cat = new Cat(); + $bird = new NormalBird(); + + expect(processNormalUnionAnimal($dog))->toBe($dog) + ->and(processNormalUnionAnimal($cat))->toBe($cat) + ->and(processNormalUnionAnimal($bird))->toBe($bird) + ; + }); + + test('allows inline @var assignment of narrower union into broader union variable', function () { + /** @var Dog|Cat $narrow */ + $narrow = new Dog(); + + /** @var Dog|Cat|NormalBird $broad */ + $broad = $narrow; + + expect($broad)->toBe($narrow); + }); + + test('accepts string literal member into broader string literal union', function () { + expect(processNormalUnionLiteralRole('admin'))->toBe('admin') + ->and(processNormalUnionLiteralRole('editor'))->toBe('editor') + ->and(processNormalUnionLiteralRole('guest'))->toBe('guest') + ; + + /** @var 'admin'|'editor'|'viewer'|'guest' $role */ + $role = 'viewer'; + expect($role)->toBe('viewer'); + }); + + test('accepts integer literal member into broader integer literal union', function () { + expect(processNormalUnionLiteralNumber(1))->toBe(1) + ->and(processNormalUnionLiteralNumber(5))->toBe(5) + ; + + /** @var 1|2|3|4|5 $num */ + $num = 3; + expect($num)->toBe(3); + }); + + test('accepts refined scalar values matching union of refinements (positive-int | non-empty-string)', function () { + expect(processNormalUnionRefinements(42))->toBe(42) + ->and(processNormalUnionRefinements('order_code_123'))->toBe('order_code_123') + ; + + /** @var positive-int|non-empty-string $val */ + $val = 100; + expect($val)->toBe(100); + + $val = 'SKU-99'; + expect($val)->toBe('SKU-99'); + }); + + test('strictly rejects value not present in union', function () { + expect(fn () => processNormalUnionAnimal(new Car())) + ->toThrow(TypeError::class); + + expect(fn () => processNormalUnionLiteralRole('superadmin')) + ->toThrow(TypeError::class); + + expect(fn () => processNormalUnionLiteralNumber(99)) + ->toThrow(TypeError::class); + + expect(fn () => processNormalUnionRefinements(-50)) + ->toThrow(TypeError::class); + + expect(fn () => processNormalUnionRefinements('')) + ->toThrow(TypeError::class); + }); + }); + + describe('Intersection Subtyping', function () { + test('accepts quadruple-interface object into parameter expecting double-interface intersection', function () { + $quad = new NormalQuadObject(); + + $result = processNormalIntersection($quad); + expect($result)->toBe($quad); + }); + + test('allows inline @var assignment of quadruple-interface object to double-interface intersection', function () { + /** @var Countable&ArrayAccess $intersection */ + $intersection = new NormalQuadObject(); + + expect($intersection)->toBeInstanceOf(NormalQuadObject::class); + }); + + test('accepts class and interface intersection (Dog & Countable)', function () { + $specialDog = new NormalSpecialDog(); + + $result = processNormalClassInterfaceIntersection($specialDog); + expect($result)->toBe($specialDog); + }); + + test('strictly rejects object missing one required interface of the intersection', function () { + expect(fn () => processNormalIntersection(new NormalSingleCountable())) + ->toThrow(TypeError::class); + }); + + test('strictly rejects object failing class or interface part of class-interface intersection', function () { + expect(fn () => processNormalClassInterfaceIntersection(new Dog())) + ->toThrow(TypeError::class); + + expect(fn () => processNormalClassInterfaceIntersection(new NormalSingleCountable())) + ->toThrow(TypeError::class); + }); + }); + + describe('Disjunctive Normal Form (DNF) Subtyping ((A&B) | (C&D))', function () { + test('accepts object satisfying Branch A (Countable & ArrayAccess)', function () { + $branchA = new NormalBranchAObject(); + + expect(processNormalDnf($branchA))->toBe($branchA); + }); + + test('accepts object satisfying Branch B (Iterator & Stringable)', function () { + $branchB = new NormalBranchBObject(); + + expect(processNormalDnf($branchB))->toBe($branchB); + }); + + test('accepts quadruple-interface object satisfying both DNF branches', function () { + $quad = new NormalQuadObject(); + + expect(processNormalDnf($quad))->toBe($quad); + }); + + test('allows inline @var assignment of objects matching either DNF branch', function () { + /** @var (Countable&ArrayAccess)|(Iterator&Stringable) $dnfVar */ + $dnfVar = new NormalBranchAObject(); + expect($dnfVar)->toBeInstanceOf(NormalBranchAObject::class); + + $dnfVar = new NormalBranchBObject(); + expect($dnfVar)->toBeInstanceOf(NormalBranchBObject::class); + }); + + test('strictly rejects cross-over object that mixes interfaces from different branches without satisfying either complete branch', function () { + $crossOver = new NormalCrossOverObject(); + + expect(fn () => processNormalDnf($crossOver)) + ->toThrow(TypeError::class); + }); + + test('strictly rejects object satisfying only a single interface of one branch', function () { + $single = new NormalSingleCountable(); + + expect(fn () => processNormalDnf($single)) + ->toThrow(TypeError::class); + }); + + test('strictly rejects completely unrelated object', function () { + expect(fn () => processNormalDnf(new Car())) + ->toThrow(TypeError::class); + }); + }); +}); \ No newline at end of file diff --git a/tests/TypeChecking/Boundaries/VariadicGenericArrayTest.php b/tests/TypeChecking/Boundaries/VariadicGenericArrayTest.php new file mode 100644 index 0000000..38b39f6 --- /dev/null +++ b/tests/TypeChecking/Boundaries/VariadicGenericArrayTest.php @@ -0,0 +1,53 @@ + $array + * @param array ...$arrays + * + * @return array + */ +function testDiffKeysFixture(array $array, array ...$arrays): array +{ + foreach ($arrays as $arr) { + foreach (array_keys($arr) as $key) { + unset($array[$key]); + } + } + + return $array; +} + +describe('Variadic Parameters with Generic Array Contracts (@param array ...$arrays)', function () { + test('validates variadic array arguments when main array has string keys (Tempest diffKeys pattern)', function () { + $initial = [ + 'first_name' => 'John', + 'last_name' => 'Doe', + 'age' => 42, + ]; + + $diff = testDiffKeysFixture($initial, ['age' => 10]); + + expect($diff)->toBe([ + 'first_name' => 'John', + 'last_name' => 'Doe', + ]); + }); + + test('throws TypeError when a key in a variadic array argument violates template TKey', function () { + $initial = [ + 'first_name' => 'John', + 'last_name' => 'Doe', + ]; + + expect(fn () => testDiffKeysFixture($initial, [123 => 'something'])) + ->toThrow(TypeError::class) + ; + }); +}); diff --git a/tests/TypeChecking/CallablesAndIterators/DeprecatedCallableSyntaxTest.php b/tests/TypeChecking/CallablesAndIterators/DeprecatedCallableSyntaxTest.php new file mode 100644 index 0000000..20bbe91 --- /dev/null +++ b/tests/TypeChecking/CallablesAndIterators/DeprecatedCallableSyntaxTest.php @@ -0,0 +1,56 @@ +getRelativeCallableArray(); + expect($arrayResult)->toBe(['static', 'sampleStaticMethod']); + + $stringResult = $service->getRelativeCallableString(); + expect($stringResult)->toBe('static::sampleStaticMethod'); + } finally { + restore_error_handler(); + } + + expect($warnings)->toBeEmpty(); + }); +}); diff --git a/tests/TypeChecking/CallablesAndIterators/GenericCallablesTest.php b/tests/TypeChecking/CallablesAndIterators/GenericCallablesTest.php index 25667c1..80e3b8f 100644 --- a/tests/TypeChecking/CallablesAndIterators/GenericCallablesTest.php +++ b/tests/TypeChecking/CallablesAndIterators/GenericCallablesTest.php @@ -166,7 +166,7 @@ function testNullableGenericListWithCallback(callable $formatter, ?array $items expect($result)->toBe(['alpha' => 'alpha:10', 'beta' => 'beta:20']); expect(fn () => $service->mapWithKey($combiner, [0 => 10])) - ->toThrow(TypeError::class, '$k') + ->toThrow(TypeError::class, 'must be of type string') ; }); diff --git a/tests/TypeChecking/CallablesAndIterators/TapVoidCallbackTest.php b/tests/TypeChecking/CallablesAndIterators/TapVoidCallbackTest.php new file mode 100644 index 0000000..981409d --- /dev/null +++ b/tests/TypeChecking/CallablesAndIterators/TapVoidCallbackTest.php @@ -0,0 +1,38 @@ + $b->where('name', 'Frieren')); + + expect($result)->toBe($builder); + }); +}); diff --git a/tests/TypeChecking/Conditionals/ListSubtypeOfArrayConditionalTest.php b/tests/TypeChecking/Conditionals/ListSubtypeOfArrayConditionalTest.php new file mode 100644 index 0000000..0070d38 --- /dev/null +++ b/tests/TypeChecking/Conditionals/ListSubtypeOfArrayConditionalTest.php @@ -0,0 +1,32 @@ + + * : array{0: string, 1: int} + * ) + */ +function testListIsArrayConditional(mixed $match, mixed $returnVal): mixed +{ + return $returnVal; +} + +describe('Template Conditional Subtype Evaluation (list is array)', function () { + test('evaluates T is array as true when T is inferred as list from sequential array (Tempest get_match pattern)', function () { + $match = ['a', 'b']; + $result = testListIsArrayConditional($match, ['key1' => 'val1', 'key2' => 'val2']); + + expect($result)->toBe(['key1' => 'val1', 'key2' => 'val2']); + }); +}); \ No newline at end of file diff --git a/tests/TypeChecking/Conditionals/MutatedParamConditionalReturnTest.php b/tests/TypeChecking/Conditionals/MutatedParamConditionalReturnTest.php new file mode 100644 index 0000000..6690540 --- /dev/null +++ b/tests/TypeChecking/Conditionals/MutatedParamConditionalReturnTest.php @@ -0,0 +1,42 @@ + $length + * + * @return ($length is 0 ? '' : non-empty-string) + */ +function testMutatedParameterConditional(int $length): string +{ + if ($length === 0) { + return ''; + } + + $result = ''; + while ($length > 0) { + $result .= 'x'; + --$length; // Mutates parameter down to 0 before return! + } + + return $result; +} + +describe('Parameter Conditional Returns with In-Body Parameter Mutation', function () { + test('evaluates parameter conditional return using initial argument value, not mutated local variable (Tempest secure_string pattern)', function () { + $result = testMutatedParameterConditional(10); + + expect($result)->toBe('xxxxxxxxxx'); + }); + + test('evaluates empty string branch when initial argument is 0', function () { + $result = testMutatedParameterConditional(0); + + expect($result)->toBe(''); + }); +}); \ No newline at end of file diff --git a/tests/TypeChecking/Conditionals/NullableTemplateConditionalReturnTest.php b/tests/TypeChecking/Conditionals/NullableTemplateConditionalReturnTest.php new file mode 100644 index 0000000..a692a9f --- /dev/null +++ b/tests/TypeChecking/Conditionals/NullableTemplateConditionalReturnTest.php @@ -0,0 +1,37 @@ + + * : string + * ) + */ +function testNullableTemplateConditional(?string $match = null, mixed $returnValue = null): mixed +{ + return $returnValue; +} + +describe('Nullable Template Conditional Returns (T is null ? A : B)', function () { + test('binds T to null when parameter defaults to null and evaluates conditional return (Tempest get_match pattern)', function () { + $result = testNullableTemplateConditional(returnValue: [0 => 'first', 1 => 'second']); + + expect($result)->toBe([0 => 'first', 1 => 'second']); + }); + + test('binds T to string when non-null string is passed and evaluates else branch', function () { + $result = testNullableTemplateConditional('id', 'matched_id_string'); + + expect($result)->toBe('matched_id_string'); + }); +}); \ No newline at end of file diff --git a/tests/TypeChecking/Generics/ClassStringTemplateObjectBoundTest.php b/tests/TypeChecking/Generics/ClassStringTemplateObjectBoundTest.php index 66f0c9b..7de66db 100644 --- a/tests/TypeChecking/Generics/ClassStringTemplateObjectBoundTest.php +++ b/tests/TypeChecking/Generics/ClassStringTemplateObjectBoundTest.php @@ -48,8 +48,8 @@ function resolveBoundedClassStringSim(string $class): bool expect(resolveBoundedClassStringSim(DateTime::class))->toBeTrue(); }); - test('rejects non-existent class-string when template T is bounded by object', function () { - expect(fn () => resolveObjectAttributeSim(stdClass::class, 'NonExistentClass12345')) + test('rejects syntactically invalid class-string when template T is bounded by object', function () { + expect(fn () => resolveObjectAttributeSim(stdClass::class, 'Invalid-Class-Name!')) ->toThrow(TypeError::class, 'must be a valid class-string') ; }); diff --git a/tests/TypeChecking/Generics/ClassTemplateInlineVarTest.php b/tests/TypeChecking/Generics/ClassTemplateInlineVarTest.php index 0f16185..04a8744 100644 --- a/tests/TypeChecking/Generics/ClassTemplateInlineVarTest.php +++ b/tests/TypeChecking/Generics/ClassTemplateInlineVarTest.php @@ -2,8 +2,6 @@ declare(strict_types=1); -namespace TypePHP\Tests\TypeChecking\Generics; - use TypePHP\Exception\TypeError; use TypePHP\Internal\Config; use TypePHP\Tests\Fixtures\Collections\ShopwareCollection; diff --git a/tests/TypeChecking/Generics/ClassTemplateIsolationAcrossInstancesTest.php b/tests/TypeChecking/Generics/ClassTemplateIsolationAcrossInstancesTest.php new file mode 100644 index 0000000..415a0e3 --- /dev/null +++ b/tests/TypeChecking/Generics/ClassTemplateIsolationAcrossInstancesTest.php @@ -0,0 +1,73 @@ + + */ + public array $value; + + public function __construct(array $value = []) + { + $this->value = $value; + } + + /** + * @template TMapValue + * + * @param Closure(TValue, TKey): TMapValue $map + * + * @return self + */ + public function map(Closure $map): self + { + $result = []; + foreach ($this->value as $key => $val) { + $result[$key] = $map($val, $key); + } + + return new self($result); + } +} + +describe('Class-Level Template Isolation Across Distinct Instances', function () { + test('does not leak closure parameter template bindings between different collection instances', function () { + $argumentsArray = new FixtureIsolatedArray([ + 'arg1' => new FixtureConsoleArgument('verbose'), + ]); + + $mappedArgs = $argumentsArray->map( + fn (FixtureConsoleArgument $arg, string $key) => $arg->name + ); + expect($mappedArgs->value)->toBe(['arg1' => 'verbose']); + + $versionArray = new FixtureIsolatedArray([ + 'Tempest' => '3.19.0', + 'PHP' => '8.4.0', + ]); + + $mappedVersions = $versionArray->map( + fn (string $version, string $key) => "{$key}: {$version}" + ); + + expect($mappedVersions->value)->toBe([ + 'Tempest' => 'Tempest: 3.19.0', + 'PHP' => 'PHP: 8.4.0', + ]); + }); +}); diff --git a/tests/TypeChecking/Generics/CloneTemplateLeakTest.php b/tests/TypeChecking/Generics/CloneTemplateLeakTest.php new file mode 100644 index 0000000..74db052 --- /dev/null +++ b/tests/TypeChecking/Generics/CloneTemplateLeakTest.php @@ -0,0 +1,75 @@ + + */ + public array $items = []; + + public function __construct(array $items = []) + { + $this->items = $items; + } + + /** + * @template TMapValue + * + * @param Closure(TValue, TKey): TMapValue $map + * + * @return static + */ + public function map(Closure $map): self + { + $res = []; + foreach ($this->items as $k => $v) { + $res[$k] = $map($v, $k); + } + + return new static($res); + } +} + +class FixtureCloneLeakArray +{ + use FixtureCloneLeakTrait; +} + +class FixtureCloneArgument +{ + public function __construct(public string $name = 'test') + { + } +} + +describe('Clone Template Leak Prevention (Tempest tempest about reproduction)', function () { + test('does not leak generic template from a cloned instance to newly instantiated collections', function () { + $argsArray = new FixtureCloneLeakArray([ + 'arg1' => new FixtureCloneArgument('verbose'), + ]); + + $argsArray->map(fn (FixtureCloneArgument $arg) => $arg->name); + + $cloned = clone $argsArray; + + $versionArray = new FixtureCloneLeakArray([ + 'Tempest' => '3.19.0', + 'PHP' => '8.4.0', + ]); + + $result = $versionArray->map(function ($version) { + return (string) $version; + }); + + expect($result->items)->toBe([ + 'Tempest' => '3.19.0', + 'PHP' => '8.4.0', + ]); + }); +}); diff --git a/tests/TypeChecking/Generics/ClosureMixedParamTemplateInferenceTest.php b/tests/TypeChecking/Generics/ClosureMixedParamTemplateInferenceTest.php new file mode 100644 index 0000000..90aa51c --- /dev/null +++ b/tests/TypeChecking/Generics/ClosureMixedParamTemplateInferenceTest.php @@ -0,0 +1,47 @@ + + */ + public array $items; + + public function __construct(array $items = []) + { + $this->items = $items; + } + + /** + * @template TMapValue + * + * @param Closure(TValue, TKey): TMapValue $map + * + * @return self + */ + public function map(Closure $map): self + { + $result = []; + foreach ($this->items as $key => $val) { + $result[$key] = $map($val, $key); + } + + return new self($result); + } +} + +describe('Closure Parameter Type Inference with Bounded Templates', function () { + test('does not overwrite bounded TKey with mixed when closure parameter specifies mixed $key (Tempest map pattern)', function () { + $collection = new BoundedKeyCollectionFixture(['a', 'b']); + + $mapped = $collection->map(fn (string $value, mixed $key) => $value . $key); + + expect($mapped->items)->toBe(['a0', 'b1']); + }); +}); diff --git a/tests/TypeChecking/Generics/ComplexUnionAndIntersectionSubtypesTest.php b/tests/TypeChecking/Generics/ComplexUnionAndIntersectionSubtypesTest.php new file mode 100644 index 0000000..2cac0e5 --- /dev/null +++ b/tests/TypeChecking/Generics/ComplexUnionAndIntersectionSubtypesTest.php @@ -0,0 +1,153 @@ + 1]; + public function count(): int { return count($this->data); } + public function offsetExists(mixed $offset): bool { return isset($this->data[$offset]); } + public function offsetGet(mixed $offset): mixed { return $this->data[$offset] ?? null; } + public function offsetSet(mixed $offset, mixed $value): void { $this->data[$offset] = $value; } + public function offsetUnset(mixed $offset): void { unset($this->data[$offset]); } + public function rewind(): void { reset($this->data); } + public function current(): mixed { return current($this->data); } + public function key(): mixed { return key($this->data); } + public function next(): void { next($this->data); } + public function valid(): bool { return key($this->data) !== null; } +} + +class DoubleInterfaceObject implements Countable, ArrayAccess +{ + private array $data = ['a' => 1]; + public function count(): int { return count($this->data); } + public function offsetExists(mixed $offset): bool { return isset($this->data[$offset]); } + public function offsetGet(mixed $offset): mixed { return $this->data[$offset] ?? null; } + public function offsetSet(mixed $offset, mixed $value): void { $this->data[$offset] = $value; } + public function offsetUnset(mixed $offset): void { unset($this->data[$offset]); } +} + +/** + * @template T + */ +class TypeSetHolder +{ + /** @var array */ + public array $items = []; +} + +describe('Complex Union, Intersection, and DNF Subtyping Guarantees', function () { + describe('Union Subset Assignability', function () { + test('allows assigning subset class union into superset class union (Dog|Cat into Dog|Cat|Bird)', function () { + /** @var TypeSetHolder $broadContainer */ + $broadContainer = new TypeSetHolder(); + + /** @var TypeSetHolder $narrowContainer */ + $narrowContainer = new TypeSetHolder(); + + $broadContainer = $narrowContainer; + expect($broadContainer)->toBe($narrowContainer); + }); + + test('allows assigning subset string literal union into superset string literal union', function () { + /** @var TypeSetHolder<'admin'|'editor'|'viewer'|'guest'> $broadRoles */ + $broadRoles = new TypeSetHolder(); + + /** @var TypeSetHolder<'admin'|'editor'> $narrowRoles */ + $narrowRoles = new TypeSetHolder(); + + $broadRoles = $narrowRoles; + expect($broadRoles)->toBe($narrowRoles); + }); + + test('allows assigning subset integer literal union into superset integer union', function () { + /** @var TypeSetHolder<1|2|3|4|5> $broadNumbers */ + $broadNumbers = new TypeSetHolder(); + + /** @var TypeSetHolder<1|2> $narrowNumbers */ + $narrowNumbers = new TypeSetHolder(); + + $broadNumbers = $narrowNumbers; + expect($broadNumbers)->toBe($narrowNumbers); + }); + + test('strictly rejects assigning broader union into narrower subset union', function () { + /** @var TypeSetHolder $narrowContainer */ + $narrowContainer = new TypeSetHolder(); + + /** @var TypeSetHolder $broadContainer */ + $broadContainer = new TypeSetHolder(); + + // Assigning broader (Dog|Cat|Bird) into narrower (Dog|Cat) must fail! + expect(function () use (&$narrowContainer, $broadContainer) { + $narrowContainer = $broadContainer; + })->toThrow(TypeError::class); + }); + }); + + describe('Intersection Subtyping (More Specific Intersection into Broader Intersection)', function () { + test('allows triple-interface object into variable expecting double-interface intersection', function () { + /** @var Countable&ArrayAccess $expected */ + $expected = new TripleInterfaceObject(); + + expect($expected)->toBeInstanceOf(TripleInterfaceObject::class); + }); + + test('allows generic container of triple-interface objects into container expecting double-interface intersection', function () { + /** @var TypeSetHolder $container */ + $container = new TypeSetHolder(); + + /** @var TypeSetHolder $tripleContainer */ + $tripleContainer = new TypeSetHolder(); + + $container = $tripleContainer; + expect($container)->toBe($tripleContainer); + }); + + test('strictly rejects object missing one required interface of the intersection', function () { + expect(function () { + /** @var Countable&ArrayAccess&Iterator $strictContainer */ + $strictContainer = new DoubleInterfaceObject(); + })->toThrow(TypeError::class); + }); + }); + + describe('Disjunctive Normal Form (DNF) Subtyping ((A&B) | (C&D))', function () { + test('allows triple-interface object into DNF union of intersections', function () { + /** @var (Countable&ArrayAccess)|(Iterator&Countable) $dnfTarget */ + $dnfTarget = new TripleInterfaceObject(); + + expect($dnfTarget)->toBeInstanceOf(TripleInterfaceObject::class); + }); + + test('allows generic container of triple-interface objects into container expecting DNF union', function () { + /** @var TypeSetHolder<(Countable&ArrayAccess)|(Iterator&Countable)> $dnfContainer */ + $dnfContainer = new TypeSetHolder(); + + /** @var TypeSetHolder $tripleContainer */ + $tripleContainer = new TypeSetHolder(); + + $dnfContainer = $tripleContainer; + expect($dnfContainer)->toBe($tripleContainer); + }); + + test('strictly rejects object failing all branches of the DNF union', function () { + expect(function () { + /** @var (Countable&ArrayAccess)|(Iterator&Countable) $dnfTarget */ + $dnfTarget = new Car(); + })->toThrow(TypeError::class); + }); + }); +}); \ No newline at end of file diff --git a/tests/TypeChecking/Generics/ConsoleArgumentLeakTest.php b/tests/TypeChecking/Generics/ConsoleArgumentLeakTest.php new file mode 100644 index 0000000..6d5b881 --- /dev/null +++ b/tests/TypeChecking/Generics/ConsoleArgumentLeakTest.php @@ -0,0 +1,94 @@ + + */ + public array $storage = []; + + public function __construct(mixed $input = []) + { + $this->storage = \is_array($input) ? $input : [$input]; + } + + /** + * @param null|Closure(TValue, TKey): bool $filter + * + * @return static + */ + public function filter(?Closure $filter = null): self + { + $res = []; + foreach ($this->storage as $k => $v) { + if ($filter === null || $filter($v, $k)) { + $res[$k] = $v; + } + } + + return new static($res); + } + + /** + * @template TMapValue + * + * @param Closure(TValue, TKey): TMapValue $map + * + * @return static + */ + public function map(Closure $map): self + { + $res = []; + foreach ($this->storage as $k => $v) { + $res[$k] = $map($v, $k); + } + + return new static($res); + } +} + +class ConsoleTestArray +{ + use ConsoleTestManipulatesArray; +} + +class ConsoleInputArgumentFixture +{ + public function __construct(public ?string $name = null) + { + } +} + +describe('Console Middleware to AboutCommand Template Leak Reproduction', function () { + test('reproduces exact tempest about error when filter with closure is called before map with version string', function () { + $arguments = new ConsoleTestArray([ + new ConsoleInputArgumentFixture('help'), + ]); + + $arguments->filter(fn (ConsoleInputArgumentFixture $arg) => $arg->name !== null); + + $versions = new ConsoleTestArray('3.19.0'); + + $result = $versions + ->filter() + ->map(function (Stringable|string $val) { + return (string) $val; + }) + ; + + expect($result->storage)->toBe(['3.19.0']); + }); +}); diff --git a/tests/TypeChecking/Generics/FluentBuilderReTypeTest.php b/tests/TypeChecking/Generics/FluentBuilderReTypeTest.php new file mode 100644 index 0000000..df5f0c6 --- /dev/null +++ b/tests/TypeChecking/Generics/FluentBuilderReTypeTest.php @@ -0,0 +1,76 @@ + fluent builder + * + * @template ClassType + */ +class FixtureObjectFactory +{ + public bool $isCollection = false; + + /** + * @template T of object + * + * @param class-string $class + * + * @return self + */ + public function forClass(string $class): self + { + return $this; + } + + /** + * Re-types ClassType on $this to ClassType[] + * + * @return self + */ + public function collection(): self + { + $this->isCollection = true; + + return $this; + } + + /** + * @return ClassType + */ + public function from(array $data): mixed + { + return $this->isCollection + ? [new FixtureAuthorModel('Brent'), new FixtureAuthorModel('Roman')] + : new FixtureAuthorModel('Brent'); + } +} + +describe('Fluent Builder Generic Re-Typing (Tempest ObjectFactory pattern)', function () { + test('allows fluent methods returning $this to re-type class template to an array of objects', function () { + $factory = new FixtureObjectFactory(); + + $factory->forClass(FixtureAuthorModel::class); + + $collectionFactory = $factory->collection(); + + expect($collectionFactory)->toBe($factory) + ->and(TypePHP::getGenericType($factory))->toBe(FixtureAuthorModel::class . '[]') + ; + + $result = $factory->from([['name' => 'Brent'], ['name' => 'Roman']]); + + expect($result)->toBeArray() + ->and($result[0])->toBeInstanceOf(FixtureAuthorModel::class) + ; + }); +}); diff --git a/tests/TypeChecking/Generics/GenericUnionMemberAssignabilityTest.php b/tests/TypeChecking/Generics/GenericUnionMemberAssignabilityTest.php new file mode 100644 index 0000000..d11a600 --- /dev/null +++ b/tests/TypeChecking/Generics/GenericUnionMemberAssignabilityTest.php @@ -0,0 +1,65 @@ + + */ + public array $items = []; +} + +class TestQueryBuilderWithUnionProperty +{ + /** + * @var TestGenericCollection + */ + public TestGenericCollection $wheres; + + public function __construct() + { + $this->wheres = new TestGenericCollection(); + } +} + +describe('Generic Container Union Member Assignability (Collection into property Collection)', function () { + test('reproduces tempest CountQueryBuilder error when assigning Collection to property expecting Collection', function () { + $builder = new TestQueryBuilderWithUnionProperty(); + + /** @var TestGenericCollection $singleTypeWheres */ + $singleTypeWheres = new TestGenericCollection(); + + $builder->wheres = $singleTypeWheres; + + expect($builder->wheres)->toBe($singleTypeWheres); + }); + + test('rejects assigning Collection to property expecting Collection', function () { + $builder = new TestQueryBuilderWithUnionProperty(); + + /** @var TestGenericCollection $badWheres */ + $badWheres = new TestGenericCollection(); + + expect(function () use ($builder, $badWheres) { + $builder->wheres = $badWheres; + })->toThrow(TypeError::class); + }); +}); diff --git a/tests/TypeChecking/Generics/GenericUnionSubsetVarianceTest.php b/tests/TypeChecking/Generics/GenericUnionSubsetVarianceTest.php new file mode 100644 index 0000000..465cecf --- /dev/null +++ b/tests/TypeChecking/Generics/GenericUnionSubsetVarianceTest.php @@ -0,0 +1,44 @@ + */ + public array $items = []; +} + +describe('Generic Union Subset Assignability (Collection into Collection)', function () { + test('allows assigning subset union generic collection into broader superset union variable (Tempest WhereGroupBuilder pattern)', function () { + /** @var GenericUnionHolderFixture $container */ + $container = new GenericUnionHolderFixture(); + + /** @var GenericUnionHolderFixture $subsetContainer */ + $subsetContainer = new GenericUnionHolderFixture(); + + // Assigning Collection into variable expecting Collection + $container = $subsetContainer; + + expect($container)->toBe($subsetContainer); + }); + + test('strictly rejects assigning collection with an incompatible type not in the superset union', function () { + /** @var GenericUnionHolderFixture $container */ + $container = new GenericUnionHolderFixture(); + + /** @var GenericUnionHolderFixture $incompatibleContainer */ + $incompatibleContainer = new GenericUnionHolderFixture(); + + expect(function () use (&$container, $incompatibleContainer) { + $container = $incompatibleContainer; + })->toThrow(\TypeError::class); + }); +}); \ No newline at end of file diff --git a/tests/TypeChecking/Generics/HeterogeneousArrayMapTest.php b/tests/TypeChecking/Generics/HeterogeneousArrayMapTest.php new file mode 100644 index 0000000..6962fa9 --- /dev/null +++ b/tests/TypeChecking/Generics/HeterogeneousArrayMapTest.php @@ -0,0 +1,66 @@ + $array + * @param callable(TValue, TKey): Generator $map + * + * @return array + */ +function testMapWithKeys(array $array, callable $map): array +{ + $result = []; + foreach ($array as $key => $value) { + foreach ($map($value, $key) as $k => $v) { + $result[$k] = $v; + } + } + + return $result; +} + +describe('Heterogeneous Generic Array Mapping (Tempest ObjectFactory pattern)', function () { + test('accepts heterogeneous array of different objects passed to map_with_keys', function () { + $objects = [ + new FixtureObjectA('a', 'b'), + new FixtureObjectA('c', 'd'), + new FixtureNestedObjectA(['item1', 'item2']), + ]; + + $result = testMapWithKeys( + $objects, + fn (mixed $item, mixed $key) => yield $key => \get_class($item) + ); + + expect($result)->toHaveCount(3) + ->and($result[0])->toBe(FixtureObjectA::class) + ->and($result[2])->toBe(FixtureNestedObjectA::class) + ; + }); +}); diff --git a/tests/TypeChecking/Generics/HeterogeneousUntypedClosureMapTest.php b/tests/TypeChecking/Generics/HeterogeneousUntypedClosureMapTest.php new file mode 100644 index 0000000..1a76b5a --- /dev/null +++ b/tests/TypeChecking/Generics/HeterogeneousUntypedClosureMapTest.php @@ -0,0 +1,56 @@ + $array + * @param Closure(TValue, TKey): TMapValue $map + * + * @return array + */ +function testUntypedClosureMap(array $array, Closure $map): array +{ + $result = []; + foreach ($array as $key => $value) { + $result[$key] = $map($value, $key); + } + + return $result; +} + +describe('Heterogeneous Array Mapping with Untyped Closures (Tempest CreateTableStatement pattern)', function () { + test('maps array containing different polymorphic statements when closure is untyped', function () { + $statements = [ + new FixturePrimaryKeyStatement('id'), + new FixtureTextStatement('body'), + ]; + + $result = testUntypedClosureMap($statements, fn ($stmt) => $stmt->name); + + expect($result)->toBe(['id', 'body']); + }); +}); diff --git a/tests/TypeChecking/Generics/SingleArgumentMapGenericTest.php b/tests/TypeChecking/Generics/SingleArgumentMapGenericTest.php new file mode 100644 index 0000000..3e142dc --- /dev/null +++ b/tests/TypeChecking/Generics/SingleArgumentMapGenericTest.php @@ -0,0 +1,76 @@ + + * + * @template TKey of array-key = array-key + * @template TValue = mixed + */ +class TestTwoTemplateMap +{ + /** + * @var array + */ + public array $items = []; + + /** + * @param TKey $key + * @param TValue $value + */ + public function put(mixed $key, mixed $value): void + { + $this->items[$key] = $value; + } +} + +class TestWhereStatement +{ +} + +class TestOtherEntity +{ +} + +class TestCountStatement +{ + /** + * @param TestTwoTemplateMap $where + */ + public function __construct( + public TestTwoTemplateMap $where + ) { + } +} + +describe('Single Generic Argument Shorthand on 2-Template Maps (Tempest ImmutableArray pattern)', function () { + test('reproduces tempest CountStatement single generic argument on 2-template map', function () { + $where = new TestTwoTemplateMap(); + + $statement = new TestCountStatement($where); + + expect($statement->where)->toBe($where) + ->and(TypePHP::getGenericType($where, 'TKey'))->toBe('array-key') + ->and(TypePHP::getGenericType($where, 'TValue'))->toBe(TestWhereStatement::class) + ; + }); + + test('enforces inferred TValue contract on single-argument map instance', function () { + $where = new TestTwoTemplateMap(); + new TestCountStatement($where); + + $valid = new TestWhereStatement(); + $where->put('clause_1', $valid); + expect($where->items['clause_1'])->toBe($valid); + + expect(fn () => $where->put('clause_2', new TestOtherEntity())) + ->toThrow(TypeError::class, 'must be of type ' . TestWhereStatement::class) + ; + }); +}); diff --git a/tests/TypeChecking/Generics/TraitTemplateLeakReproductionTest.php b/tests/TypeChecking/Generics/TraitTemplateLeakReproductionTest.php new file mode 100644 index 0000000..0110f69 --- /dev/null +++ b/tests/TypeChecking/Generics/TraitTemplateLeakReproductionTest.php @@ -0,0 +1,82 @@ + + */ + public array $storage = []; + + public function __construct(array $items = []) + { + $this->storage = $items; + } + + /** + * @template TMapValue + * + * @param Closure(TValue, TKey): TMapValue $map + * + * @return static + */ + public function map(Closure $map): self + { + $res = []; + foreach ($this->storage as $k => $v) { + $res[$k] = $map($v, $k); + } + + return new static($res); + } +} + +class FixtureLeakArray +{ + use FixtureLeakTrait; +} + +class FixtureConsoleInputArgument +{ + public function __construct(public string $name = 'test') + { + } +} + +describe('Trait Class-Level Template Leak Across Calls (Tempest tempest about reproduction)', function () { + test('reproduces tempest about failure where map() closure types leak to subsequent calls on different instances', function () { + $argsArray = new FixtureLeakArray([ + 'arg1' => new FixtureConsoleInputArgument('verbose'), + ]); + + $argsArray->map(fn (FixtureConsoleInputArgument $arg, string $key) => $arg->name); + + $versionArray = new FixtureLeakArray([ + 'Tempest' => '3.19.0', + 'PHP' => '8.4.0', + ]); + + $result = $versionArray->map( + function (Stringable|string $version, string $key): string { + return (string) $version; + } + ); + + expect($result->storage)->toBe([ + 'Tempest' => '3.19.0', + 'PHP' => '8.4.0', + ]); + }); +}); diff --git a/tests/TypeChecking/Generics/UnspecializedArrayKeyGenericTest.php b/tests/TypeChecking/Generics/UnspecializedArrayKeyGenericTest.php new file mode 100644 index 0000000..de00a90 --- /dev/null +++ b/tests/TypeChecking/Generics/UnspecializedArrayKeyGenericTest.php @@ -0,0 +1,57 @@ + + * + * @template TKey of array-key = array-key + * @template TValue = mixed + */ +class TestGenericViteArray +{ + /** + * @var array + */ + public array $elements = []; +} + +class TestViteChunk +{ + public function __construct(public string $file = 'app.js') + { + } +} + +class TestViteManifest +{ + /** + * @param TestGenericViteArray $chunks + */ + public function __construct( + public TestGenericViteArray $chunks + ) { + } +} + +describe('Unspecialized array-key Generic Specialization (Tempest Vite Manifest pattern)', function () { + test('specializes unspecialized array-key collection instance to int without throwing invariant mismatch', function () { + $chunksInstance = new TestGenericViteArray(); + + TemplateManager::bindTemplate('none', $chunksInstance, 'TKey', new IdentifierTypeNode('array-key')); + TemplateManager::bindTemplate('none', $chunksInstance, 'TValue', new IdentifierTypeNode(TestViteChunk::class)); + + $manifest = new TestViteManifest($chunksInstance); + + expect($manifest->chunks)->toBe($chunksInstance) + ->and(TypePHP::getGenericType($chunksInstance, 'TKey'))->toBe('int') + ->and(TypePHP::getGenericType($chunksInstance, 'TValue'))->toBe(TestViteChunk::class) + ; + }); +}); diff --git a/tests/TypeChecking/InheritanceAndAttributes/NonPromotedConstructorParameterTest.php b/tests/TypeChecking/InheritanceAndAttributes/NonPromotedConstructorParameterTest.php new file mode 100644 index 0000000..d6c33ab --- /dev/null +++ b/tests/TypeChecking/InheritanceAndAttributes/NonPromotedConstructorParameterTest.php @@ -0,0 +1,71 @@ + * /` + * - Constructor parameter $options is UN-PROMOTED and takes raw iterable inputs (strings, enums, etc.) + * - Internal code transforms raw values into OptionFixture objects + */ +class OptionCollectionFixture +{ + /** + * @var array + */ + private array $options; + + public function __construct(iterable $options) + { + $this->options = []; + foreach ($options as $key => $value) { + $this->options[] = new OptionFixture($key, $value); + } + } + + /** + * @return array + */ + public function getOptions(): array + { + return $this->options; + } +} + +describe('Non-Promoted Constructor Parameters (Tempest OptionCollection Reproduction)', function () { + test('does not copy internal property @var type onto un-promoted constructor parameter with string array', function () { + $collection = new OptionCollectionFixture(['foo', 'bar', 'baz']); + + expect($collection->getOptions())->toHaveCount(3) + ->and($collection->getOptions()[0])->toBeInstanceOf(OptionFixture::class) + ->and($collection->getOptions()[0]->value)->toBe('foo') + ; + }); + + test('does not copy internal property @var type onto un-promoted constructor parameter with enum cases', function () { + $collection = new OptionCollectionFixture(TestOptionEnum::cases()); + + expect($collection->getOptions())->toHaveCount(2) + ->and($collection->getOptions()[0])->toBeInstanceOf(OptionFixture::class) + ->and($collection->getOptions()[0]->value)->toBe(TestOptionEnum::OPT_1) + ; + }); +}); diff --git a/tests/TypeChecking/Scalars/SyntheticClassStringTest.php b/tests/TypeChecking/Scalars/SyntheticClassStringTest.php new file mode 100644 index 0000000..9d647fc --- /dev/null +++ b/tests/TypeChecking/Scalars/SyntheticClassStringTest.php @@ -0,0 +1,78 @@ +getName('App\Models\PersonalAccessToken'); + + expect($result)->toBe('App\Models\PersonalAccessToken'); + }); + + test('accepts synthetic class names on standalone functions with class-string docblock', function () { + expect(testDirectClassStringParam('App\Models\User'))->toBe('App\Models\User'); + expect(testDirectClassStringParam('Vendor\Package\CustomDummyModel'))->toBe('Vendor\Package\CustomDummyModel'); + }); + + test('accepts synthetic class names on functions returning class-string', function () { + expect(testSyntheticClassStringReturn('App\Models\Order'))->toBe('App\Models\Order'); + }); + + test('strictly rejects non-string and syntactically invalid class names', function () { + expect(fn () => testDirectClassStringParam('')) + ->toThrow(TypeError::class, 'must be of type class-string') + ; + + expect(fn () => testDirectClassStringParam('Invalid Class Name With Spaces')) + ->toThrow(TypeError::class, 'must be of type class-string') + ; + + expect(fn () => testDirectClassStringParam('Foo-Bar-Baz')) + ->toThrow(TypeError::class, 'must be of type class-string') + ; + + expect(fn () => testDirectClassStringParam('123InvalidStart')) + ->toThrow(TypeError::class, 'must be of type class-string') + ; + + expect(fn () => testDirectClassStringParam('App\Models\\')) + ->toThrow(TypeError::class, 'must be of type class-string') + ; + }); +}); diff --git a/tests/Unit/LineNumberPreservationTest.php b/tests/Unit/LineNumberPreservationTest.php index 7e497de..428360d 100644 --- a/tests/Unit/LineNumberPreservationTest.php +++ b/tests/Unit/LineNumberPreservationTest.php @@ -133,4 +133,28 @@ public function __construct(public mixed $item) {} expect($transCallLine)->toBe($origCallLine); }); + + test('preserves executable code when single line comments precede injected statements', function () { + $source = <<<'PHP' +toContain('/* Single line trailing comment before return */') + ->and($transformed)->toContain('RuntimeTypeChecker::checkReturn') + ; + }); }); diff --git a/tests/Unit/ValidatorsTest.php b/tests/Unit/ValidatorsTest.php index 88ab787..a94c61b 100644 --- a/tests/Unit/ValidatorsTest.php +++ b/tests/Unit/ValidatorsTest.php @@ -244,7 +244,7 @@ function parseType(string $typeString, Lexer $lexer, TypeParser $typeParser): Ty $classString = parseType('class-string', $this->lexer, $this->typeParser); expect($this->registry->validate(DateTimeInterface::class, $classString, 'arg'))->toBeNull() ->and($this->registry->validate(Dog::class, $classString, 'arg'))->toBeNull() - ->and($this->registry->validate('NonExistentClass123', $classString, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ->and($this->registry->validate('Invalid Class Name', $classString, 'arg'))->toBeInstanceOf(ErrorMessage::class) ; $ifaceString = parseType('interface-string', $this->lexer, $this->typeParser); diff --git a/typephp.php b/typephp.php index 2435018..fe2c398 100644 --- a/typephp.php +++ b/typephp.php @@ -78,7 +78,7 @@ | is caught without exception. | | - 'hybrid' : (Beartype O(1) Mode) Fast boundary + random sampling on - | arrays > 64 items. Ideal for massive production datasets. + | arrays > 128 items. Ideal for massive production datasets. */ 'array_validation' => 'hybrid',