diff --git a/src/Internal/Ast/FunctionContractInjector.php b/src/Internal/Ast/FunctionContractInjector.php index 7a6d95d..dff14da 100644 --- a/src/Internal/Ast/FunctionContractInjector.php +++ b/src/Internal/Ast/FunctionContractInjector.php @@ -464,22 +464,33 @@ private static function buildParamWrappers( foreach ($params as $param) { if ($predicate($param, $docText) && $param->var instanceof Node\Expr\Variable && \is_string($param->var->name)) { $paramName = $param->var->name; - $expr = new Node\Stmt\Expression( - new Node\Expr\Assign( - new Node\Expr\Variable($paramName), - new Node\Expr\FuncCall( - new Node\Name\FullyQualified("TypePHP\Internal\RuntimeTypeChecker::{$wrapperMethod}"), - [ - new Node\Arg(new Node\Scalar\MagicConst\Method()), - new Node\Arg(new Node\Scalar\String_($paramName)), - new Node\Arg(new Node\Expr\Variable($paramName)), - new Node\Arg($thisArg), - ] - ) + $assignExpr = new Node\Expr\Assign( + new Node\Expr\Variable($paramName), + new Node\Expr\FuncCall( + new Node\Name\FullyQualified("TypePHP\Internal\RuntimeTypeChecker::{$wrapperMethod}"), + [ + new Node\Arg(new Node\Scalar\MagicConst\Method()), + new Node\Arg(new Node\Scalar\String_($paramName)), + new Node\Arg(new Node\Expr\Variable($paramName)), + new Node\Arg($thisArg), + ] ) ); + + $expr = new Node\Stmt\Expression($assignExpr); $expr->setAttribute('typephp_injected', true); $wrappers[] = $expr; + + if ($param->isPromoted()) { + $propAssign = new Node\Stmt\Expression( + new Node\Expr\Assign( + new Node\Expr\PropertyFetch(new Node\Expr\Variable('this'), $paramName), + new Node\Expr\Variable($paramName) + ) + ); + $propAssign->setAttribute('typephp_injected', true); + $wrappers[] = $propAssign; + } } } diff --git a/src/Internal/Checker/ParamChecker.php b/src/Internal/Checker/ParamChecker.php index de36847..3d57bf6 100644 --- a/src/Internal/Checker/ParamChecker.php +++ b/src/Internal/Checker/ParamChecker.php @@ -165,7 +165,7 @@ public static function checkParams( return self::validateSimpleParams($contract['types'], $vars, $effectiveFunction, $registry); } - self::prepareGenericBindings($effectiveFunction, $methodTemplates, $thisObj, $classTemplates); + self::prepareGenericBindings($effectiveFunction, $methodTemplates, $thisObj, $classTemplates, $thisOrClass); /** @var array $allTemplates */ $allTemplates = [...$classTemplates, ...$methodTemplates]; @@ -175,7 +175,7 @@ public static function checkParams( } $boundTemplates = (\count($allTemplates) > 0) - ? TemplateManager::getBoundTemplates($effectiveFunction, $thisObj, $allTemplates) + ? TemplateManager::getBoundTemplates($effectiveFunction, $thisOrClass, $allTemplates) : []; $declaredTemplates = $allTemplates; @@ -192,7 +192,8 @@ public static function checkParams( $boundTemplates, $declaredTemplates, $registry, - $classTemplates + $classTemplates, + $thisOrClass ); } @@ -240,15 +241,21 @@ private static function prepareGenericBindings( string $effectiveFunction, array $methodTemplates, ?object $thisObj, - array $classTemplates + array $classTemplates, + object|string|null $thisOrClass = null ): void { if (\count($methodTemplates) > 0) { TemplateManager::clearCallBindings($effectiveFunction, $methodTemplates); } - if ($thisObj !== null && \count($classTemplates) > 0 && ! TemplateManager::hasInstanceBindings($thisObj) && str_contains($effectiveFunction, '::')) { + if (\count($classTemplates) > 0 && str_contains($effectiveFunction, '::')) { $declaringClass = explode('::', $effectiveFunction, 2)[0]; - TemplateManager::resolveInheritedTemplates($thisObj, $declaringClass); + if ($thisObj !== null && ! TemplateManager::hasInstanceBindings($thisObj)) { + TemplateManager::resolveInheritedTemplates($thisObj, $declaringClass); + } elseif ($thisObj === null) { + $targetClass = \is_string($thisOrClass) && $thisOrClass !== '' ? $thisOrClass : $declaringClass; + TemplateManager::getClassInheritedBindings($targetClass); + } } } @@ -288,13 +295,10 @@ private static function resolveBaseTypes( * @param array $contractTypes * @param array $baseTypes * @param array $vars - * @param string $effectiveFunction - * @param object|null $thisObj * @param array $allTemplates * @param array $aliases * @param array $boundTemplates * @param array $declaredTemplates - * @param TypeValidatorRegistry $registry * @param array $classTemplates */ private static function validateAllParameters( @@ -308,7 +312,8 @@ private static function validateAllParameters( array $boundTemplates, array $declaredTemplates, TypeValidatorRegistry $registry, - array $classTemplates + array $classTemplates, + object|string|null $thisOrClass = null ): ?ErrorMessage { foreach ($contractTypes as $paramName => $_) { if (! isset($vars[$paramName]) && ! \array_key_exists($paramName, $vars)) { @@ -316,7 +321,7 @@ private static function validateAllParameters( } $currentBoundTemplates = (\count($allTemplates) > 0) - ? TemplateManager::getBoundTemplates($effectiveFunction, $thisObj, $allTemplates) + ? TemplateManager::getBoundTemplates($effectiveFunction, $thisOrClass, $allTemplates) : $boundTemplates; $err = self::validateSingleParam( @@ -331,7 +336,8 @@ private static function validateAllParameters( $declaredTemplates, $registry, $classTemplates, - $vars + $vars, + $thisOrClass ); if ($err !== null) { @@ -440,7 +446,7 @@ private static function handleMagicCall( } /** - * Pre-infers generic template parameters from closure typehints and array arguments. + * Pre-infers generic template parameters from closure typehints, array arguments, and generic object arguments. * * @param array $types * @param array $vars @@ -468,8 +474,9 @@ private static function preInferGenericTemplates( self::inferTemplatesFromClosures($types, $vars, $effectiveFunction, $thisObj, $templates, $classTemplates); } - if (\count($types) > 1) { + if (\count($types) > 0) { self::inferTemplatesFromArrays($types, $vars, $effectiveFunction, $thisObj, $templates); + self::inferTemplatesFromGenericObjects($types, $vars, $effectiveFunction, $thisObj, $templates, $classTemplates); } } @@ -743,6 +750,107 @@ private static function bindTemplateIfUnbound( } } + /** + * Pre-infers generic template parameters from generic object arguments (e.g. PBox or array>). + * + * @param array $types + * @param array $vars + * @param array $templates + * @param array $classTemplates + */ + private static function inferTemplatesFromGenericObjects( + array $types, + array $vars, + string $effectiveFunction, + ?object $thisObj, + array $templates, + array $classTemplates = [] + ): void { + foreach ($types as $paramName => $typeNode) { + if (! isset($vars[$paramName])) { + continue; + } + + $value = $vars[$paramName]; + self::inferGenericObjectNode($typeNode, $value, $effectiveFunction, $thisObj, $templates, $classTemplates); + } + } + + /** + * @param array $templates + * @param array $classTemplates + */ + private static function inferGenericObjectNode( + TypeNode $typeNode, + mixed $value, + string $effectiveFunction, + ?object $thisObj, + array $templates, + array $classTemplates = [] + ): void { + if ($typeNode instanceof NullableTypeNode) { + $typeNode = $typeNode->type; + } + + if ($typeNode instanceof GenericTypeNode && \is_object($value)) { + $baseName = strtolower($typeNode->type->name); + if (\in_array($baseName, ['array', 'list', 'iterable', 'traversable', 'non-empty-array', 'non-empty-list'], true)) { + return; + } + + $boundOnInstance = TemplateManager::getBoundTemplatesForInstance($value); + if ($boundOnInstance === []) { + return; + } + + $instanceBoundTypes = array_values($boundOnInstance); + + foreach ($typeNode->genericTypes as $idx => $gtNode) { + if ($gtNode instanceof IdentifierTypeNode && isset($templates[$gtNode->name])) { + $tName = $gtNode->name; + $isClassLevel = ! TemplateManager::isMethodTemplate($effectiveFunction, $tName) && isset($classTemplates[$tName]); + $targetObj = $isClassLevel ? $thisObj : null; + + if (! TemplateManager::isBound($effectiveFunction, $targetObj, $tName)) { + $inferredCandidate = $instanceBoundTypes[$idx] ?? null; + + 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); + } + } + } + } + } + } elseif (\is_array($value)) { + $innerType = null; + if ($typeNode instanceof ArrayTypeNode) { + $innerType = $typeNode->type; + } elseif ($typeNode instanceof GenericTypeNode) { + $baseName = strtolower($typeNode->type->name); + if (\in_array($baseName, ['array', 'list', 'iterable', 'traversable', 'non-empty-array', 'non-empty-list'], true)) { + $innerType = $typeNode->genericTypes[1] ?? $typeNode->genericTypes[0] ?? null; + } + } + + if ($innerType !== null) { + foreach ($value as $item) { + if (\is_object($item)) { + self::inferGenericObjectNode($innerType, $item, $effectiveFunction, $thisObj, $templates, $classTemplates); + } + } + } + } + } + /** * Unified single-parameter validation pipeline. * @@ -766,7 +874,8 @@ private static function validateSingleParam( array $declaredTemplates, TypeValidatorRegistry $registry, array $classTemplates = [], - array $vars = [] + array $vars = [], + object|string|null $thisOrClass = null ): ?ErrorMessage { if ( $typeNode instanceof ConditionalTypeForParameterNode || @@ -800,7 +909,7 @@ private static function validateSingleParam( } if (self::getTemplateName($typeNode, $templates) !== null) { - return self::resolveTemplateParam($typeNode, $val, $paramName, $effectiveFunction, $thisObj, $templates, $registry, $classTemplates); + return self::resolveTemplateParam($typeNode, $val, $paramName, $effectiveFunction, $thisObj, $templates, $registry, $classTemplates, $thisOrClass); } return $registry->validate($val, $typeNode, $effectiveFunction . '(): Argument $' . $paramName); @@ -1055,7 +1164,8 @@ private static function resolveTemplateParam( ?object $thisObj, array $templates, TypeValidatorRegistry $registry, - array $classTemplates = [] + array $classTemplates = [], + object|string|null $thisOrClass = null ): ?ErrorMessage { $templateName = self::getTemplateName($typeNode, $templates); if ($templateName === null || ! isset($templates[$templateName])) { @@ -1066,24 +1176,24 @@ private static function resolveTemplateParam( $isVariadic = $typeNode instanceof ArrayTypeNode; $isNullable = ($typeNode instanceof NullableTypeNode) || ($typeNode instanceof UnionTypeNode && self::typeContainsNull($typeNode)); $isClassLevelTemplate = ! TemplateManager::isMethodTemplate($function, $templateName) && isset($classTemplates[$templateName]); - $targetObj = $isClassLevelTemplate ? $thisObj : null; + $targetObjOrClass = $isClassLevelTemplate ? ($thisObj ?? $thisOrClass) : 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')); + if ($allowsNullInBound && ! TemplateManager::isBound($function, $targetObjOrClass, $templateName)) { + TemplateManager::bindTemplate($function, $thisObj, $templateName, new IdentifierTypeNode('null')); } return null; } - if (! TemplateManager::isBound($function, $targetObj, $templateName)) { + if (! TemplateManager::isBound($function, $targetObjOrClass, $templateName)) { return self::bindInitialTemplate( $val, $paramName, $function, $thisObj, - $targetObj, + $thisObj, $templateName, $templateNode, $isVariadic, @@ -1097,7 +1207,7 @@ private static function resolveTemplateParam( $paramName, $function, $thisObj, - $targetObj, + $targetObjOrClass, $templateName, $templateNode, $isVariadic, @@ -1154,7 +1264,7 @@ private static function validateBoundTemplate( string $paramName, string $function, ?object $thisObj, - ?object $targetObj, + object|string|null $targetObj, string $templateName, TemplateTagValueNode $templateNode, bool $isVariadic, @@ -1166,9 +1276,11 @@ private static function validateBoundTemplate( return null; } + $targetObjOnly = \is_object($targetObj) ? $targetObj : null; + if ($expectedTypeNode instanceof IdentifierTypeNode && $expectedTypeNode->name === $templateName) { $inferredType = TemplateManager::inferTypeFromValue($val); - TemplateManager::bindTemplate($function, $targetObj, $templateName, $inferredType); + TemplateManager::bindTemplate($function, $targetObjOnly, $templateName, $inferredType); return null; } @@ -1179,7 +1291,7 @@ private static function validateBoundTemplate( $paramName, $function, $thisObj, - $targetObj, + $targetObjOnly, $templateName, $templateNode, $expectedTypeNode, @@ -1199,7 +1311,7 @@ private static function validateBoundTemplate( $isClassLevelTemplate, $function, $thisObj, - $targetObj, + $targetObjOnly, $templateName, $context, $err, diff --git a/src/Internal/Docblock/DocblockParser.php b/src/Internal/Docblock/DocblockParser.php index 198c22b..9cb3be9 100644 --- a/src/Internal/Docblock/DocblockParser.php +++ b/src/Internal/Docblock/DocblockParser.php @@ -820,16 +820,29 @@ public static function parseClassAliases(string $className): array */ private static function parseMethod(\ReflectionMethod $ref): array { + /** @var array $types */ $types = []; + /** @var array $paramOuts */ $paramOuts = []; + /** @var array $methodTemplates */ $methodTemplates = []; + /** @var array $classTemplates */ $classTemplates = []; $returnType = null; $selfOut = null; + /** @var array $aliases */ $aliases = []; - self::parseClassLevelDocs($ref->getDeclaringClass(), $classTemplates, $aliases); - self::parseMethodHierarchyDocs($ref, $types, $methodTemplates, $returnType, $aliases, $paramOuts, $selfOut); + $targetClass = (class_exists($ref->class, false) || class_exists($ref->class) || interface_exists($ref->class) || enum_exists($ref->class) || trait_exists($ref->class)) + ? new \ReflectionClass($ref->class) + : $ref->getDeclaringClass(); + + self::parseClassLevelDocs($targetClass, $classTemplates, $aliases); + if ($targetClass->getName() !== $ref->getDeclaringClass()->getName()) { + self::parseClassLevelDocs($ref->getDeclaringClass(), $classTemplates, $aliases); + } + + self::parseMethodHierarchyDocs($ref, $types, $methodTemplates, $returnType, $aliases, $paramOuts, $selfOut, $classTemplates); if ($ref->getName() === '__construct') { self::applyConstructorPromotionFallback($ref, $types, $classTemplates, $aliases); @@ -1116,13 +1129,25 @@ private static function parseClassLevelDocs(\ReflectionClass $declaringClass, ar $className = $declaringClass->getName(); if (isset(self::$classLevelDocCache[$className])) { $cached = self::$classLevelDocCache[$className]; - $templates = $cached['templates']; - $aliases = $cached['aliases']; + foreach ($cached['templates'] as $name => $tag) { + if (! isset($templates[$name])) { + $templates[$name] = $tag; + } + } + foreach ($cached['aliases'] as $name => $alias) { + if (! isset($aliases[$name])) { + $aliases[$name] = $alias; + } + } return; } $classHierarchy = HierarchyResolver::getClassHierarchy($declaringClass); + /** @var array $localTemplates */ + $localTemplates = []; + /** @var array $localAliases */ + $localAliases = []; foreach ($classHierarchy as $hierClass) { $hierClassName = $hierClass->getName(); @@ -1138,18 +1163,37 @@ private static function parseClassLevelDocs(\ReflectionClass $declaringClass, ar $classPhpDocNode = DocblockExtractor::parseDocString($classDoc); foreach (DocblockExtractor::extractTemplates($classPhpDocNode) as $name => $tag) { - if (! isset($templates[$name])) { - $templates[$name] = $tag; + if (! isset($localTemplates[$name])) { + if ($tag->bound !== null || $tag->default !== null) { + $tag = new TemplateTagValueNode( + $tag->name, + $tag->bound !== null ? SpecialTypeResolver::resolve($tag->bound, $hierClass) : null, + $tag->description, + $tag->default !== null ? SpecialTypeResolver::resolve($tag->default, $hierClass) : null + ); + } + $localTemplates[$name] = $tag; } } - DocblockExtractor::extractAliases($classPhpDocNode, $aliases, $hierClass); + DocblockExtractor::extractAliases($classPhpDocNode, $localAliases, $hierClass); } } self::$classLevelDocCache[$className] = [ - 'templates' => $templates, - 'aliases' => $aliases, + 'templates' => $localTemplates, + 'aliases' => $localAliases, ]; + + foreach ($localTemplates as $name => $tag) { + if (! isset($templates[$name])) { + $templates[$name] = $tag; + } + } + foreach ($localAliases as $name => $alias) { + if (! isset($aliases[$name])) { + $aliases[$name] = $alias; + } + } } /** @@ -1162,6 +1206,7 @@ private static function parseClassLevelDocs(\ReflectionClass $declaringClass, ar * @param array $aliases * @param array $paramOuts * @param TypeNode|null $selfOut + * @param array $classTemplates */ private static function parseMethodHierarchyDocs( \ReflectionMethod $ref, @@ -1170,7 +1215,8 @@ private static function parseMethodHierarchyDocs( ?TypeNode &$returnType, array &$aliases, array &$paramOuts = [], - ?TypeNode &$selfOut = null + ?TypeNode &$selfOut = null, + array &$classTemplates = [] ): void { $hierarchy = HierarchyResolver::getMethodHierarchy($ref); $baseParams = $ref->getParameters(); @@ -1188,8 +1234,11 @@ private static function parseMethodHierarchyDocs( } foreach ($hierarchy as $hierRef) { + $hierDeclaringClass = $hierRef->getDeclaringClass(); + self::parseClassLevelDocs($hierDeclaringClass, $classTemplates, $aliases); + $isOriginal = ($hierRef === $ref); - $declaringClass = $hierRef->getDeclaringClass()->getName(); + $declaringClass = $hierDeclaringClass->getName(); $methodName = $hierRef->getName(); $stubDoc = StubManager::getMethodDoc($declaringClass, $methodName); diff --git a/src/Internal/Generics/TemplateManager.php b/src/Internal/Generics/TemplateManager.php index 4685911..41ba807 100644 --- a/src/Internal/Generics/TemplateManager.php +++ b/src/Internal/Generics/TemplateManager.php @@ -424,6 +424,24 @@ public static function applyPendingInstantiation(object $instance): void } } + /** + * Retrieves inherited class-level template bindings for a class name. + * + * @return array + */ + public static function getClassInheritedBindings(string $className): array + { + if (isset(self::$classInheritedBindingsCache[$className])) { + return self::$classInheritedBindingsCache[$className]; + } + + if (! class_exists($className, false) && ! class_exists($className) && ! interface_exists($className) && ! trait_exists($className)) { + return self::$classInheritedBindingsCache[$className] = []; + } + + return self::$classInheritedBindingsCache[$className] = self::computeClassInheritedBindings($className); + } + /** * 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). @@ -498,15 +516,16 @@ public static function clearCallBindings(string $function, array $templates): vo } /** - * Retrieves currently bound template types for a function call or object instance. + * Retrieves currently bound template types for a function call or object instance / static class. * * @param array $templates * * @return array */ - public static function getBoundTemplates(string $function, ?object $thisObj, array $templates): array + public static function getBoundTemplates(string $function, object|string|null $thisOrClass, array $templates): array { $bindings = []; + $thisObj = \is_object($thisOrClass) ? $thisOrClass : null; if ($thisObj !== null) { self::ensureInstanceInherited($thisObj); @@ -521,6 +540,22 @@ public static function getBoundTemplates(string $function, ?object $thisObj, arr unset($bindings[$methodTName]); } } + } else { + $className = \is_string($thisOrClass) && $thisOrClass !== '' + ? $thisOrClass + : (str_contains($function, '::') ? explode('::', $function, 2)[0] : ''); + + if ($className !== '' && (class_exists($className, false) || class_exists($className) || interface_exists($className) || trait_exists($className))) { + $classBindings = self::getClassInheritedBindings($className); + $bindings = $classBindings; + + $methodTemplates = self::getMethodTemplates($function); + if ($methodTemplates !== []) { + foreach ($methodTemplates as $methodTName => $_) { + unset($bindings[$methodTName]); + } + } + } } $topFrame = self::getTopCallFrame($function); @@ -617,15 +652,17 @@ public static function getMethodTemplates(string $function): array } /** - * Checks if a template name is bound in the current instance or call stack frame. + * Checks if a template name is bound in the current instance, static class, or call stack frame. */ - public static function isBound(string $function, ?object $thisObj, string $templateName): bool + public static function isBound(string $function, object|string|null $thisOrClass, string $templateName): bool { $topFrame = self::getTopCallFrame($function); if ($topFrame !== null && isset($topFrame[$templateName])) { return true; } + $thisObj = \is_object($thisOrClass) ? $thisOrClass : null; + if ($thisObj !== null) { self::ensureInstanceInherited($thisObj); @@ -636,19 +673,35 @@ public static function isBound(string $function, ?object $thisObj, string $templ return isset(self::$instanceTemplateBindings[$thisObj][$templateName]); } + $className = \is_string($thisOrClass) && $thisOrClass !== '' + ? $thisOrClass + : (str_contains($function, '::') ? explode('::', $function, 2)[0] : ''); + + if ($className !== '' && (class_exists($className, false) || class_exists($className) || interface_exists($className) || trait_exists($className))) { + if (self::isMethodTemplate($function, $templateName)) { + return false; + } + + $classBindings = self::getClassInheritedBindings($className); + + return isset($classBindings[$templateName]); + } + return false; } /** - * Retrieves the bound TypeNode for a template name from instance or call stack context. + * Retrieves the bound TypeNode for a template name from instance, static class, or call stack context. */ - public static function getBoundType(string $function, ?object $thisObj, string $templateName): ?TypeNode + public static function getBoundType(string $function, object|string|null $thisOrClass, string $templateName): ?TypeNode { $topFrame = self::getTopCallFrame($function); if ($topFrame !== null && isset($topFrame[$templateName])) { return $topFrame[$templateName]; } + $thisObj = \is_object($thisOrClass) ? $thisOrClass : null; + if ($thisObj !== null) { self::ensureInstanceInherited($thisObj); @@ -659,6 +712,20 @@ public static function getBoundType(string $function, ?object $thisObj, string $ return self::$instanceTemplateBindings[$thisObj][$templateName] ?? null; } + $className = \is_string($thisOrClass) && $thisOrClass !== '' + ? $thisOrClass + : (str_contains($function, '::') ? explode('::', $function, 2)[0] : ''); + + if ($className !== '' && (class_exists($className, false) || class_exists($className) || interface_exists($className) || trait_exists($className))) { + if (self::isMethodTemplate($function, $templateName)) { + return null; + } + + $classBindings = self::getClassInheritedBindings($className); + + return $classBindings[$templateName] ?? null; + } + return null; } diff --git a/tests/Fixtures/Probe/Route.php b/tests/Fixtures/Probe/Route.php new file mode 100644 index 0000000..99d910f --- /dev/null +++ b/tests/Fixtures/Probe/Route.php @@ -0,0 +1,18 @@ + + */ +enum Route: string implements RouteInterface +{ + use HasRoute; + + case A = 'a'; + case B = 'b'; +} diff --git a/tests/Fixtures/Probe/RouteClass.php b/tests/Fixtures/Probe/RouteClass.php new file mode 100644 index 0000000..6dcffd3 --- /dev/null +++ b/tests/Fixtures/Probe/RouteClass.php @@ -0,0 +1,20 @@ + + */ +class RouteClass implements RouteInterface +{ + use HasRoute; + + public static function from(string $name): self + { + return new self(); + } +} diff --git a/tests/Fixtures/Probe/RouteInterface.php b/tests/Fixtures/Probe/RouteInterface.php new file mode 100644 index 0000000..2f1e755 --- /dev/null +++ b/tests/Fixtures/Probe/RouteInterface.php @@ -0,0 +1,16 @@ +factory = $factory; + } +} + +final class PromotedClosureClass +{ + /** + * @param Closure(?PromotedA=): PromotedBase $factory + */ + public function __construct(public Closure $factory) + { + } +} + +final class PromotedCallableClass +{ + /** + * @param callable(?PromotedA=): PromotedBase $formatter + */ + public function __construct(public mixed $formatter) + { + } +} + +describe('Promoted Property Callable Contracts & Consistency', function () { + test('enforces parameter type validation when invoking closure on promoted constructor property', function () { + $closure = static fn (?PromotedA $a = null): PromotedBase => new PromotedA(); + + $promoted = new PromotedClosureClass($closure); + + expect(($promoted->factory)(new PromotedA()))->toBeInstanceOf(PromotedBase::class); + + expect(($promoted->factory)())->toBeInstanceOf(PromotedBase::class); + + expect(fn () => ($promoted->factory)(new PromotedUnrelated())) + ->toThrow(TypeError::class) + ; + }); + + test('enforces return type validation when invoking closure on promoted constructor property', function () { + $badClosure = static fn (?PromotedA $a = null): mixed => new PromotedUnrelated(); + + $promoted = new PromotedClosureClass($badClosure); + + expect(fn () => ($promoted->factory)(new PromotedA())) + ->toThrow(TypeError::class) + ; + }); + + test('behaves consistently between standard parameter assignment and promoted property', function () { + $closure = static fn (?PromotedA $a = null): PromotedBase => new PromotedA(); + + $stored = new StoredClosureClass($closure); + $promoted = new PromotedClosureClass($closure); + + expect(fn () => ($stored->factory)(new PromotedUnrelated())) + ->toThrow(TypeError::class) + ; + + expect(fn () => ($promoted->factory)(new PromotedUnrelated())) + ->toThrow(TypeError::class) + ; + }); + + test('enforces contracts when callable is promoted in constructor (Closure, Array Callable, Invokable)', function () { + $helper = new PromotedHelper(); + + $promotedArrayCallable = new PromotedCallableClass([$helper, 'format']); + + expect(($promotedArrayCallable->formatter)(new PromotedA()))->toBeInstanceOf(PromotedBase::class); + + expect(fn () => ($promotedArrayCallable->formatter)(new PromotedUnrelated())) + ->toThrow(TypeError::class) + ; + + $closure = static fn (?PromotedA $a = null): PromotedBase => new PromotedA(); + $promotedClosure = new PromotedCallableClass($closure); + + expect(($promotedClosure->formatter)(new PromotedA()))->toBeInstanceOf(PromotedBase::class); + + expect(fn () => ($promotedClosure->formatter)(new PromotedUnrelated())) + ->toThrow(TypeError::class) + ; + }); +}); diff --git a/tests/TypeChecking/Generics/MethodTemplateGenericArgumentInferenceTest.php b/tests/TypeChecking/Generics/MethodTemplateGenericArgumentInferenceTest.php new file mode 100644 index 0000000..135998a --- /dev/null +++ b/tests/TypeChecking/Generics/MethodTemplateGenericArgumentInferenceTest.php @@ -0,0 +1,113 @@ + $box + * + * @return T + */ + public function unwrap(MethodTemplateBox $box): MethodTemplateBase + { + return $box->value; + } + + /** + * @template T of MethodTemplateBase + * + * @param array> $items + * + * @return list + */ + public function unwrapAll(array $items): array + { + $result = []; + foreach ($items as $box) { + $result[] = $box->value; + } + + return $result; + } +} + +describe('Method-Level Template Inference from Generic Parameter Objects', function () { + test('infers method template T from generic argument PBox instead of falling back to upper bound PBase', function () { + $consumer = new MethodTemplateConsumer(); + $boxA = new MethodTemplateBox(new MethodTemplateA('Alpha')); + + $result = $consumer->unwrap($boxA); + + expect($result)->toBeInstanceOf(MethodTemplateA::class) + ->and($result->name)->toBe('Alpha') + ; + }); + + test('allows multiple calls to unwrap() with different generic arguments without cross-call template locking', function () { + $consumer = new MethodTemplateConsumer(); + + $boxA = new MethodTemplateBox(new MethodTemplateA('Alpha')); + $resultA = $consumer->unwrap($boxA); + expect($resultA)->toBeInstanceOf(MethodTemplateA::class); + + $boxB = new MethodTemplateBox(new MethodTemplateB('Beta')); + $resultB = $consumer->unwrap($boxB); + expect($resultB)->toBeInstanceOf(MethodTemplateB::class); + }); + + test('infers template T from array of generic objects array>', function () { + $consumer = new MethodTemplateConsumer(); + + $items = [ + 'first' => new MethodTemplateBox(new MethodTemplateA('A1')), + 'second' => new MethodTemplateBox(new MethodTemplateA('A2')), + ]; + + $result = $consumer->unwrapAll($items); + + expect($result)->toHaveCount(2) + ->and($result[0])->toBeInstanceOf(MethodTemplateA::class) + ->and($result[1])->toBeInstanceOf(MethodTemplateA::class) + ; + }); +}); diff --git a/tests/TypeChecking/Generics/StaticMethodSubclassGenericInheritanceTest.php b/tests/TypeChecking/Generics/StaticMethodSubclassGenericInheritanceTest.php new file mode 100644 index 0000000..7a1c3c6 --- /dev/null +++ b/tests/TypeChecking/Generics/StaticMethodSubclassGenericInheritanceTest.php @@ -0,0 +1,144 @@ + $value + * + * @return static + */ + public static function from(string|StaticGenericPBase|StaticGenericIdBase $value): static + { + return new static(); + } + + /** + * @param TEntity $entity + * + * @return static + */ + public static function fromEntity(StaticGenericPBase $entity): static + { + return new static(); + } + + /** + * @return TEntity + */ + public static function produceA(): StaticGenericPBase + { + return new StaticGenericPA(); + } + + /** + * @return TEntity + */ + public static function produceB(): StaticGenericPBase + { + return new StaticGenericPB(); + } + + /** + * @return list + */ + public static function produceList(): array + { + return [new StaticGenericPA()]; + } +} + +/** + * @extends StaticGenericIdBase + */ +final class StaticGenericAId extends StaticGenericIdBase +{ +} + +/** + * @extends StaticGenericIdBase + */ +final class StaticGenericAnyId extends StaticGenericIdBase +{ +} + +describe('Static Methods Resolving Subclass @extends Generic Bindings', function () { + describe('Static Parameter Contracts', function () { + test('resolves TEntity as PA from @extends StaticGenericIdBase on static method calls (Case a)', function () { + $first = StaticGenericAId::from('sample_id'); + expect($first)->toBeInstanceOf(StaticGenericAId::class); + + $second = StaticGenericAId::from($first); + expect($second)->toBeInstanceOf(StaticGenericAId::class); + }); + + test('resolves TEntity as PBase from @extends StaticGenericIdBase and accepts multiple valid subclasses across calls (Case b)', function () { + $first = StaticGenericAnyId::fromEntity(new StaticGenericPA()); + expect($first)->toBeInstanceOf(StaticGenericAnyId::class); + + $second = StaticGenericAnyId::fromEntity(new StaticGenericPB()); + expect($second)->toBeInstanceOf(StaticGenericAnyId::class); + }); + + test('strictly rejects entity not matching the subclass @extends binding', function () { + expect(fn () => StaticGenericAId::fromEntity(new StaticGenericPB())) + ->toThrow(TypeError::class) + ; + }); + }); + + describe('Static Return Type Contracts', function () { + test('validates static method @return TEntity matches subclass @extends binding', function () { + $result = StaticGenericAId::produceA(); + expect($result)->toBeInstanceOf(StaticGenericPA::class); + expect(StaticGenericAnyId::produceA())->toBeInstanceOf(StaticGenericPA::class); + expect(StaticGenericAnyId::produceB())->toBeInstanceOf(StaticGenericPB::class); + }); + + test('throws TypeError when static method @return TEntity violates subclass @extends binding', function () { + expect(fn () => StaticGenericAId::produceB()) + ->toThrow( + TypeError::class, + 'Return value must be of type ' . StaticGenericPA::class . ', ' . StaticGenericPB::class . ' returned' + ) + ; + }); + + test('validates list return types list on static methods', function () { + $list = StaticGenericAId::produceList(); + expect($list)->toHaveCount(1) + ->and($list[0])->toBeInstanceOf(StaticGenericPA::class) + ; + }); + }); +}); diff --git a/tests/TypeChecking/Generics/TraitSuppliedInterfaceMethodNamespaceResolutionTest.php b/tests/TypeChecking/Generics/TraitSuppliedInterfaceMethodNamespaceResolutionTest.php new file mode 100644 index 0000000..896737f --- /dev/null +++ b/tests/TypeChecking/Generics/TraitSuppliedInterfaceMethodNamespaceResolutionTest.php @@ -0,0 +1,29 @@ +toBe(Route::A); + }); + + test('resolves interface template on standard classes fulfilling interface via trait', function () { + $route = RouteClass::fromName('x'); + + expect($route)->toBeInstanceOf(RouteClass::class); + }); + + test('resolves interface template bound when class implements interface via trait without explicit @implements tag', function () { + $route = UnparameterizedRoute::fromName('a'); + + expect($route)->toBe(UnparameterizedRoute::A); + }); +});