diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1ce977c..b02aa11 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,18 +45,21 @@ jobs: - name: Warm Up TypePHP Cache run: php bin/typephp cache:warm - - name: Run Test Suite with Coverage (Pest) + - name: Run Runtime Type Checking Engine Suite (Pest) + run: ./vendor/bin/pest tests/TypeChecking --compact + + - name: Run Full Test Suite with Coverage (Pest) run: ./vendor/bin/pest --coverage-clover=clover.xml --compact if: matrix.os == 'ubuntu-latest' && matrix.php == '8.4' + - name: Run Full Test Suite (Pest) + run: ./vendor/bin/pest --compact + if: "! (matrix.os == 'ubuntu-latest' && matrix.php == '8.4')" + - name: Upload Coverage to Codecov uses: codecov/codecov-action@v5 if: matrix.os == 'ubuntu-latest' && matrix.php == '8.4' with: token: ${{ secrets.CODECOV_TOKEN }} files: clover.xml - fail_ci_if_error: false - - - name: Run Test Suite (Pest) - run: ./vendor/bin/pest --compact - if: "! (matrix.os == 'ubuntu-latest' && matrix.php == '8.4')" \ No newline at end of file + fail_ci_if_error: false \ No newline at end of file diff --git a/src/Contract/ContractParser.php b/src/Contract/ContractParser.php index 78da573..c4cd6d1 100644 --- a/src/Contract/ContractParser.php +++ b/src/Contract/ContractParser.php @@ -524,7 +524,7 @@ private static function parseFunction(\ReflectionFunction $ref): array $stubDoc = StubManager::getFunctionDoc($funcName); $doc = $stubDoc ?? $ref->getDocComment(); - if ($doc === false || $doc === null) { + if ($doc === false || $doc === null || self::shouldIgnoreDoc($doc)) { return [ 'types' => [], 'templates' => [], @@ -563,12 +563,10 @@ private static function parseFunction(\ReflectionFunction $ref): array } $pObj = $baseParamObjects[$paramName] ?? null; - $isTemplateType = ($type instanceof IdentifierTypeNode && isset($templates[$type->name])); if ( Config::isRespectNativeNullabilityEnabled() && $pObj !== null && self::parameterExplicitlyAllowsNull($pObj) - && ! $isTemplateType && ! self::typeContainsNull($type) ) { $type = new NullableTypeNode($type); @@ -692,7 +690,7 @@ private static function parseMethodHierarchyDocs( } $doc = $stubDoc ?? $hierRef->getDocComment(); - if ($doc === false || $doc === null) { + if ($doc === false || $doc === null || self::shouldIgnoreDoc($doc)) { continue; } @@ -734,12 +732,10 @@ private static function parseMethodHierarchyDocs( } $pObj = $baseParamObjects[$targetParamName] ?? null; - $isTemplateType = ($type instanceof IdentifierTypeNode && isset($templates[$type->name])); if ( Config::isRespectNativeNullabilityEnabled() && $pObj !== null && self::parameterExplicitlyAllowsNull($pObj) - && ! $isTemplateType && ! self::typeContainsNull($type) ) { $type = new NullableTypeNode($type); @@ -857,11 +853,9 @@ private static function applyConstructorPromotionFallback( $resolvedProp = new ArrayTypeNode($resolvedProp); } - $isTemplateType = ($resolvedProp instanceof IdentifierTypeNode && isset($classTemplates[$resolvedProp->name])); if ( Config::isRespectNativeNullabilityEnabled() && self::parameterExplicitlyAllowsNull($p) - && ! $isTemplateType && ! self::typeContainsNull($resolvedProp) ) { $resolvedProp = new NullableTypeNode($resolvedProp); diff --git a/src/Internal/Checker/ParamChecker.php b/src/Internal/Checker/ParamChecker.php index f388713..94ca9ab 100644 --- a/src/Internal/Checker/ParamChecker.php +++ b/src/Internal/Checker/ParamChecker.php @@ -348,8 +348,7 @@ private static function validateSingleParam( $isClassStringT = ($typeNode instanceof GenericTypeNode && self::isClassStringTemplate($typeNode, $templates)); - $isBareTemplate = ($typeNode instanceof IdentifierTypeNode && isset($templates[$typeNode->name])) - || ($typeNode instanceof ArrayTypeNode && $typeNode->type instanceof IdentifierTypeNode && isset($templates[$typeNode->type->name])); + $isBareTemplate = self::getTemplateName($typeNode, $templates) !== null; $shouldSkipTemplateSub = $isBareTemplate || $isClassStringT; @@ -557,10 +556,27 @@ private static function getTemplateName(TypeNode $typeNode, array $templates): ? return $typeNode->name; } + if ($typeNode instanceof NullableTypeNode && $typeNode->type instanceof IdentifierTypeNode && isset($templates[$typeNode->type->name])) { + return $typeNode->type->name; + } + if ($typeNode instanceof ArrayTypeNode && $typeNode->type instanceof IdentifierTypeNode && isset($templates[$typeNode->type->name])) { return $typeNode->type->name; } + if ($typeNode instanceof UnionTypeNode && \count($typeNode->types) === 2) { + $t0 = $typeNode->types[0]; + $t1 = $typeNode->types[1]; + + if ($t0 instanceof IdentifierTypeNode && isset($templates[$t0->name]) && $t1 instanceof IdentifierTypeNode && strtolower($t1->name) === 'null') { + return $t0->name; + } + + if ($t1 instanceof IdentifierTypeNode && isset($templates[$t1->name]) && $t0 instanceof IdentifierTypeNode && strtolower($t0->name) === 'null') { + return $t1->name; + } + } + return null; } @@ -583,6 +599,11 @@ private static function resolveTemplateParam( $templateNode = $templates[$templateName]; $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'] ?? []; @@ -641,4 +662,25 @@ private static function resolveTemplateParam( return null; } + + private static function typeContainsNull(TypeNode $node): bool + { + if ($node instanceof NullableTypeNode) { + return true; + } + + if ($node instanceof IdentifierTypeNode && strtolower($node->name) === 'null') { + return true; + } + + if ($node instanceof UnionTypeNode) { + foreach ($node->types as $t) { + if (self::typeContainsNull($t)) { + return true; + } + } + } + + return false; + } } diff --git a/src/Internal/ContractVisitor.php b/src/Internal/ContractVisitor.php index 75e19df..fc03ad4 100644 --- a/src/Internal/ContractVisitor.php +++ b/src/Internal/ContractVisitor.php @@ -5,7 +5,6 @@ namespace TypePHP\Internal; use PhpParser\Node; -use PhpParser\NodeTraverser; use PhpParser\NodeVisitorAbstract; use TypePHP\Contract\DocblockExtractor; use TypePHP\Internal\Visitor\FunctionContractInjector; @@ -28,9 +27,9 @@ public function __construct() /** * Traverses and transforms AST nodes during entry. * - * @return array|int|null + * @return array|null */ - public function enterNode(Node $node): array|int|null + public function enterNode(Node $node): ?array { if ($node instanceof Node\Stmt\Function_ || $node instanceof Node\Stmt\ClassMethod @@ -49,15 +48,6 @@ public function enterNode(Node $node): array|int|null } if ($node instanceof Node\Stmt\Function_ || $node instanceof Node\Stmt\ClassMethod) { - $doc = $node->getDocComment(); - if ($doc !== null) { - $docText = $doc->getText(); - $shouldRespectIgnore = (bool) (Config::get()['respect_ignore_tags'] ?? true); - if ($shouldRespectIgnore && (str_contains($docText, '@typephp-ignore') || str_contains($docText, '@typephp-disable'))) { - return NodeTraverser::DONT_TRAVERSE_CHILDREN; - } - } - FunctionContractInjector::inject($node); return null; diff --git a/src/Internal/Visitor/FunctionContractInjector.php b/src/Internal/Visitor/FunctionContractInjector.php index 7fb97cc..0ad3cfa 100644 --- a/src/Internal/Visitor/FunctionContractInjector.php +++ b/src/Internal/Visitor/FunctionContractInjector.php @@ -7,7 +7,6 @@ use PhpParser\Node; use PhpParser\NodeTraverser; use PhpParser\NodeVisitorAbstract; -use TypePHP\Internal\Config; /** * @internal Injects parameter checks, return checks, and generator interceptors into functions and methods. @@ -29,10 +28,6 @@ public static function inject(Node\Stmt\Function_|Node\Stmt\ClassMethod $node): $docText = $doc !== null ? $doc->getText() : ''; - if (self::shouldSkipInjection($docText)) { - return; - } - $methodName = $isClassMethod ? strtolower($node->name->toString()) : ''; $isMagicLifecycle = $isClassMethod && \in_array($methodName, ['__construct', '__destruct', '__clone'], true); @@ -123,13 +118,6 @@ private static function hasReturnContracts(string $docText, bool $isClassMethod) return false; } - private static function shouldSkipInjection(string $docText): bool - { - $shouldRespectIgnore = (bool) (Config::get()['respect_ignore_tags'] ?? true); - - return $shouldRespectIgnore && (str_contains($docText, '@typephp-ignore') || str_contains($docText, '@typephp-disable')); - } - private static function resolveThisArg(bool $isClassMethod, Node\Stmt\Function_|Node\Stmt\ClassMethod $node): Node\Expr { if (! $isClassMethod) { diff --git a/src/Resolver/SpecialTypeResolver.php b/src/Resolver/SpecialTypeResolver.php index d553237..4ca9840 100644 --- a/src/Resolver/SpecialTypeResolver.php +++ b/src/Resolver/SpecialTypeResolver.php @@ -374,6 +374,11 @@ private static function getReflectionContext(\ReflectionClass|\ReflectionFunctio return self::$reflectionContextCache[$context] = new \ReflectionClass($fallbackClass); } + if (class_exists($context) || interface_exists($context) || trait_exists($context) || enum_exists($context)) { + /** @var class-string $context */ + return self::$reflectionContextCache[$context] = new \ReflectionClass($context); + } + try { return self::$reflectionContextCache[$context] = new \ReflectionFunction($context); } catch (\ReflectionException $e) { diff --git a/src/Resolver/TemplateManager.php b/src/Resolver/TemplateManager.php index ad9480f..81e44f5 100644 --- a/src/Resolver/TemplateManager.php +++ b/src/Resolver/TemplateManager.php @@ -66,6 +66,209 @@ final class TemplateManager */ public static ?object $pendingCloneSource = null; + /** + * O(1) direct hash-table matrix for scalar subtype relationships. + * + * @var array> + */ + private const SCALAR_SUBTYPES = [ + 'int' => [ + 'int' => true, + 'integer' => true, + 'positive-int' => true, + 'negative-int' => true, + 'non-positive-int' => true, + 'non-negative-int' => true, + 'non-zero-int' => true, + 'unsigned-int' => true, + ], + 'integer' => [ + 'int' => true, + 'integer' => true, + 'positive-int' => true, + 'negative-int' => true, + 'non-positive-int' => true, + 'non-negative-int' => true, + 'non-zero-int' => true, + 'unsigned-int' => true, + ], + 'string' => [ + 'string' => true, + 'non-empty-string' => true, + 'numeric-string' => true, + 'lowercase-string' => true, + 'non-empty-lowercase-string' => true, + 'uppercase-string' => true, + 'non-empty-uppercase-string' => true, + 'class-string' => true, + 'interface-string' => true, + 'trait-string' => true, + 'enum-string' => true, + 'callable-string' => true, + 'literal-string' => true, + 'truthy-string' => true, + 'non-falsy-string' => true, + ], + 'float' => [ + 'float' => true, + 'double' => true, + 'positive-float' => true, + 'negative-float' => true, + 'non-positive-float' => true, + 'non-negative-float' => true, + 'non-zero-float' => true, + ], + 'double' => [ + 'float' => true, + 'double' => true, + 'positive-float' => true, + 'negative-float' => true, + 'non-positive-float' => true, + 'non-negative-float' => true, + 'non-zero-float' => true, + ], + 'bool' => [ + 'bool' => true, + 'boolean' => true, + 'true' => true, + 'false' => true, + ], + 'boolean' => [ + 'bool' => true, + 'boolean' => true, + 'true' => true, + 'false' => true, + ], + 'array-key' => [ + 'array-key' => true, + 'int' => true, + 'integer' => true, + 'positive-int' => true, + 'negative-int' => true, + 'non-positive-int' => true, + 'non-negative-int' => true, + 'non-zero-int' => true, + 'unsigned-int' => true, + 'string' => true, + 'non-empty-string' => true, + 'numeric-string' => true, + 'lowercase-string' => true, + 'non-empty-lowercase-string' => true, + 'uppercase-string' => true, + 'non-empty-uppercase-string' => true, + 'class-string' => true, + 'interface-string' => true, + 'trait-string' => true, + 'enum-string' => true, + 'callable-string' => true, + 'literal-string' => true, + 'truthy-string' => true, + 'non-falsy-string' => true, + ], + 'numeric' => [ + 'numeric' => true, + 'number' => true, + 'int' => true, + 'integer' => true, + 'float' => true, + 'double' => true, + 'positive-int' => true, + 'negative-int' => true, + 'non-positive-int' => true, + 'non-negative-int' => true, + 'non-zero-int' => true, + 'unsigned-int' => true, + 'positive-float' => true, + 'negative-float' => true, + 'non-positive-float' => true, + 'non-negative-float' => true, + 'non-zero-float' => true, + 'numeric-string' => true, + ], + 'number' => [ + 'numeric' => true, + 'number' => true, + 'int' => true, + 'integer' => true, + 'float' => true, + 'double' => true, + 'positive-int' => true, + 'negative-int' => true, + 'non-positive-int' => true, + 'non-negative-int' => true, + 'non-zero-int' => true, + 'unsigned-int' => true, + 'positive-float' => true, + 'negative-float' => true, + 'non-positive-float' => true, + 'non-negative-float' => true, + 'non-zero-float' => true, + 'numeric-string' => true, + ], + 'scalar' => [ + 'scalar' => true, + 'int' => true, + 'integer' => true, + 'string' => true, + 'float' => true, + 'double' => true, + 'bool' => true, + 'boolean' => true, + 'positive-int' => true, + 'negative-int' => true, + 'non-positive-int' => true, + 'non-negative-int' => true, + 'non-zero-int' => true, + 'unsigned-int' => true, + 'positive-float' => true, + 'negative-float' => true, + 'non-positive-float' => true, + 'non-negative-float' => true, + 'non-zero-float' => true, + 'non-empty-string' => true, + 'numeric-string' => true, + 'lowercase-string' => true, + 'non-empty-lowercase-string' => true, + 'uppercase-string' => true, + 'non-empty-uppercase-string' => true, + 'class-string' => true, + 'interface-string' => true, + 'trait-string' => true, + 'enum-string' => true, + 'callable-string' => true, + 'literal-string' => true, + 'truthy-string' => true, + 'non-falsy-string' => true, + 'true' => true, + 'false' => true, + ], + ]; + + /** + * O(1) lookup set for valid supertypes of integer ranges and numeric literals. + * + * @var array + */ + private const INT_SUPERTYPES = [ + 'int' => true, + 'integer' => true, + 'array-key' => true, + 'numeric' => true, + 'number' => true, + 'scalar' => true, + ]; + + /** + * O(1) lookup set for valid supertypes of string literals and class-string. + * + * @var array + */ + private const STRING_SUPERTYPES = [ + 'string' => true, + 'array-key' => true, + 'scalar' => true, + ]; + /** * Resets all static generic template bindings and call stack frames. */ @@ -342,6 +545,15 @@ private static function collectHierarchyTemplatesAndVariances(\ReflectionClass $ foreach ($hierTemplates as $tName => $tagNode) { if (! isset($templates[$tName])) { + if ($tagNode->bound !== null || $tagNode->default !== null) { + $tagNode = new TemplateTagValueNode( + $tagNode->name, + $tagNode->bound !== null ? SpecialTypeResolver::resolve($tagNode->bound, $hierClass) : null, + $tagNode->description, + $tagNode->default !== null ? SpecialTypeResolver::resolve($tagNode->default, $hierClass) : null + ); + } + $templates[$tName] = $tagNode; $classVariances[$tName] = match ($hierVariances[$tName] ?? 'invariant') { 'covariant' => GenericTypeNode::VARIANCE_COVARIANT, @@ -383,6 +595,16 @@ private static function bindSingleTemplateArgument( } } + if ($templateTag->bound !== null) { + $satisfiesBound = self::checkVariance($expectedTypeNode, $templateTag->bound, GenericTypeNode::VARIANCE_COVARIANT); + + if (! $satisfiesBound) { + return ErrorFactory::createError( + ($context !== '' ? $context . ': ' : '') . "Generic type argument {$expectedTypeNode} does not satisfy upper bound {$templateTag->bound} of template {$templateTag->name} in {$className}" + ); + } + } + if (self::$instanceTemplateBindings === null) { self::$instanceTemplateBindings = new WeakMap(); } @@ -607,6 +829,17 @@ public static function checkVariance(TypeNode $existing, TypeNode $expected, str return true; } + $lowerExpected = strtolower($expectedStr); + $lowerExisting = strtolower($existingStr); + + if ($lowerExpected === 'object' && ClassNameValidator::isValid($existingStr) && (class_exists($existingStr) || interface_exists($existingStr))) { + return true; + } + + if (self::isScalarSubtype($lowerExisting, $lowerExpected)) { + return true; + } + if ($expected instanceof UnionTypeNode) { return self::checkExpectedUnionVariance($existing, $expected, $variance); } @@ -644,6 +877,26 @@ public static function checkVariance(TypeNode $existing, TypeNode $expected, str return false; } + /** + * O(1) scalar subtype verification. + */ + private static function isScalarSubtype(string $sub, string $super): bool + { + if (isset(self::SCALAR_SUBTYPES[$super][$sub])) { + return true; + } + + if ((str_starts_with($sub, 'int<') || is_numeric($sub)) && isset(self::INT_SUPERTYPES[$super])) { + return true; + } + + if ((str_starts_with($sub, 'class-string<') || ($sub !== '' && ($sub[0] === "'" || $sub[0] === '"'))) && isset(self::STRING_SUPERTYPES[$super])) { + return true; + } + + return false; + } + private static function checkExpectedUnionVariance(TypeNode $existing, UnionTypeNode $expected, string $variance): bool { if ($variance === GenericTypeNode::VARIANCE_COVARIANT) { diff --git a/tests/TypeChecking/Configuration/RespectNativeNullabilityTemplateTest.php b/tests/TypeChecking/Configuration/RespectNativeNullabilityTemplateTest.php new file mode 100644 index 0000000..4d51612 --- /dev/null +++ b/tests/TypeChecking/Configuration/RespectNativeNullabilityTemplateTest.php @@ -0,0 +1,97 @@ + true]); + + expect(testNativeNullableWithTemplateDocblock(null))->toBeNull(); + + expect(testNativeNullableWithTemplateDocblock(new Dog()))->toBeInstanceOf(Dog::class); + + expect(fn () => testNativeNullableWithTemplateDocblock(new Car())) + ->toThrow(TypeError::class) + ; + }); + + test('accepts null in constructor property promotion for generic class when respect_native_nullability is true', function () { + Config::set(['respect_native_nullability' => true]); + + $container = new BoundedNullabilityContainer(null); + expect($container->pet)->toBeNull(); + + $containerWithDog = new BoundedNullabilityContainer(new Dog()); + expect($containerWithDog->pet)->toBeInstanceOf(Dog::class); + }); + }); + + describe('When respect_native_nullability is false (Strict Pedantic Mode)', function () { + test('rejects null when DocBlock template omitted |null even if native PHP allows null', function () { + Config::set(['respect_native_nullability' => false]); + + expect(fn () => testNativeNullableWithTemplateDocblock(null)) + ->toThrow(TypeError::class, 'must be of type') + ; + + expect(testNativeNullableWithTemplateDocblock(new Dog()))->toBeInstanceOf(Dog::class); + }); + + test('accepts null when DocBlock explicitly wrote T|null even when respect_native_nullability is false', function () { + Config::set(['respect_native_nullability' => false]); + + expect(testExplicitDocblockNullableTemplateParam(null))->toBeNull(); + expect(testExplicitDocblockNullableTemplateParam(new Dog()))->toBeInstanceOf(Dog::class); + }); + }); +}); diff --git a/tests/TypeChecking/Generics/CombinedBoundsAndDefaultsTest.php b/tests/TypeChecking/Generics/CombinedBoundsAndDefaultsTest.php new file mode 100644 index 0000000..be461cf --- /dev/null +++ b/tests/TypeChecking/Generics/CombinedBoundsAndDefaultsTest.php @@ -0,0 +1,364 @@ +occupant = $occupant; + } + + /** + * @param T $occupant + */ + public function set(mixed $occupant): void + { + $this->occupant = $occupant; + } + + /** + * @return T + */ + public function get(): mixed + { + return $this->occupant; + } + + /** + * Method with nullable template return: @return T|null + * + * @return T|null + */ + public function findOccupant(): mixed + { + return $this->occupant; + } + + /** + * Method with strict non-nullable template return: @return T + * + * @return T + */ + public function requireOccupant(): mixed + { + return $this->occupant; + } + + /** + * Method returning invalid object violating @return ?T + * + * @return ?T + */ + public function getInvalidOccupant(): mixed + { + return new Car(); + } +} + +/** + * Fixture: Multi-Template with independent bounds and defaults + * + * @template K of array-key = string + * @template V of object = stdClass + */ +class BoundedDefaultDictionary +{ + /** + * @var array + */ + public array $storage = []; + + /** + * @param K $key + * @param V $val + */ + public function put(mixed $key, mixed $val): void + { + $this->storage[$key] = $val; + } + + /** + * @param K $key + * + * @return V + */ + public function get(mixed $key): mixed + { + return $this->storage[$key] ?? new stdClass(); + } + + /** + * @param K $key + * + * @return V|null + */ + public function find(mixed $key): ?object + { + return $this->storage[$key] ?? null; + } +} + +/** + * Function-level template with bound + default + * + * @template T of Animal = Dog + * + * @param T|null $animal + * @param mixed $fallback + * + * @return T + */ +function rescueOrFallbackAnimal(mixed $animal = null, mixed $fallback = null): mixed +{ + return $animal ?? $fallback ?? new Dog(); +} + +/** + * Function with nullable template return: @return ?T + * + * @template T of Animal = Dog + * + * @param T|null $animal + * @param bool $returnNull + * + * @return ?T + */ +function findAnimalOrNull(mixed $animal = null, bool $returnNull = false): mixed +{ + if ($returnNull) { + return null; + } + + return $animal ?? new Dog(); +} + +/** + * Function with inferred template bounded by range: @template T of int<1, 100> = 50 + * + * @template T of int<1, 100> = 50 + * + * @param T $val + * + * @return T + */ +function resolvePercentageWithBound(mixed $val): mixed +{ + return $val; +} + +/** + * Function where T is unbound and falls back to default 50 + * + * @template T of int<1, 100> = 50 + * + * @param mixed $val + * + * @return T + */ +function resolvePercentageFallback(mixed $val): mixed +{ + return $val; +} + +describe('Combined Template Bounds and Defaults (@template T of Bound = Default)', function () { + + describe('Class-Level Bound + Default (@template T of Animal = Dog)', function () { + test('accepts pre-binding matching the default type (Dog)', function () { + /** @var BoundedDefaultShelter $shelter */ + $shelter = new BoundedDefaultShelter(); + + $shelter->set(new Dog()); + expect($shelter->get())->toBeInstanceOf(Dog::class); + }); + + test('accepts pre-binding satisfying the bound even if different from default (Cat satisfies Animal)', function () { + /** @var BoundedDefaultShelter $shelter */ + $shelter = new BoundedDefaultShelter(); + + $shelter->set(new Cat()); + expect($shelter->get())->toBeInstanceOf(Cat::class); + }); + + test('throws TypeError on assignment when pre-binding violates the bound (Car is not an Animal)', function () { + expect(function () { + /** @var BoundedDefaultShelter $shelter */ + $shelter = new BoundedDefaultShelter(); + })->toThrow(TypeError::class, 'does not satisfy upper bound'); + }); + + test('unbound instance falls back to default Dog on return type contract', function () { + $shelter = new BoundedDefaultShelter(new Dog()); + + expect($shelter->get())->toBeInstanceOf(Dog::class); + }); + + test('infers template parameter from constructor argument and allows Cat on return', function () { + $shelter = new BoundedDefaultShelter(new Cat()); + + expect($shelter->get())->toBeInstanceOf(Cat::class); + }); + }); + + describe('Nullable Template Return Types (@return T|null and @return ?T)', function () { + test('accepts null when method returns null for @return T|null with pre-bound T = Dog', function () { + /** @var BoundedDefaultShelter $shelter */ + $shelter = new BoundedDefaultShelter(null); + + expect($shelter->findOccupant())->toBeNull(); + }); + + test('accepts valid Dog when method returns Dog for @return T|null with pre-bound T = Dog', function () { + /** @var BoundedDefaultShelter $shelter */ + $shelter = new BoundedDefaultShelter(new Dog()); + + expect($shelter->findOccupant())->toBeInstanceOf(Dog::class); + }); + + test('throws TypeError when method with @return ?T returns an object violating bound T = Dog', function () { + /** @var BoundedDefaultShelter $shelter */ + $shelter = new BoundedDefaultShelter(); + + expect(fn () => $shelter->getInvalidOccupant()) + ->toThrow(TypeError::class, 'Return value') + ; + }); + + test('throws TypeError when non-nullable @return T returns null', function () { + /** @var BoundedDefaultShelter $shelter */ + $shelter = new BoundedDefaultShelter(null); + + expect(fn () => $shelter->requireOccupant()) + ->toThrow(TypeError::class, 'none returned') + ; + }); + + test('accepts null on unbound instance with @return T|null', function () { + $shelter = new BoundedDefaultShelter(null); + + expect($shelter->findOccupant())->toBeNull(); + }); + + test('accepts null for dictionary find method with @return V|null', function () { + /** @var BoundedDefaultDictionary $dict */ + $dict = new BoundedDefaultDictionary(); + + expect($dict->find('non_existent_key'))->toBeNull(); + + $dict->put('pet', new Dog()); + expect($dict->find('pet'))->toBeInstanceOf(Dog::class); + }); + }); + + describe('Multi-Template Bounds + Defaults (@template K of array-key = string, @template V of object = stdClass)', function () { + test('accepts pre-binding satisfying both independent bounds', function () { + /** @var BoundedDefaultDictionary $dict */ + $dict = new BoundedDefaultDictionary(); + + $dict->put(10, new Dog()); + expect($dict->storage[10])->toBeInstanceOf(Dog::class); + }); + + test('throws TypeError on assignment when key type violates array-key bound', function () { + expect(function () { + /** @var BoundedDefaultDictionary $dict */ + $dict = new BoundedDefaultDictionary(); + })->toThrow(TypeError::class, 'does not satisfy upper bound'); + }); + + test('throws TypeError on assignment when value type violates object bound', function () { + expect(function () { + /** @var BoundedDefaultDictionary $dict */ + $dict = new BoundedDefaultDictionary(); + })->toThrow(TypeError::class, 'does not satisfy upper bound'); + }); + + test('unbound instance falls back to default string and stdClass', function () { + $dict = new BoundedDefaultDictionary(); + $dict->put('config_key', new stdClass()); + + expect($dict->get('config_key'))->toBeInstanceOf(stdClass::class); + }); + }); + + describe('Function-Level Bound + Default (@template T of Animal = Dog)', function () { + test('uses default Dog when called with no arguments', function () { + $result = rescueOrFallbackAnimal(); + + expect($result)->toBeInstanceOf(Dog::class); + }); + + test('infers template parameter from passed argument and overrides default (Cat overrides Dog)', function () { + $cat = new Cat(); + $result = rescueOrFallbackAnimal($cat); + + expect($result)->toBe($cat); + }); + + test('throws TypeError when passed argument violates the template bound', function () { + expect(fn () => rescueOrFallbackAnimal(new Car())) + ->toThrow(TypeError::class) + ; + }); + + test('throws TypeError when unbound function returns a value violating the default Dog type', function () { + expect(fn () => rescueOrFallbackAnimal(null, new Cat())) + ->toThrow(TypeError::class, 'Return value must be of type TypePHP\Tests\Fixtures\Domain\Dog') + ; + }); + + test('accepts null for standalone function with @return ?T', function () { + expect(findAnimalOrNull(new Dog(), returnNull: true))->toBeNull(); + expect(findAnimalOrNull(new Cat(), returnNull: true))->toBeNull(); + expect(findAnimalOrNull(null, returnNull: true))->toBeNull(); + }); + + test('returns inferred Cat when non-null Cat is passed to function with @return ?T', function () { + $cat = new Cat(); + expect(findAnimalOrNull($cat))->toBe($cat); + }); + }); + + describe('Scalar Bounds + Defaults (@template T of int<1, 100> = 50)', function () { + test('infers template parameter from argument and enforces bound', function () { + expect(resolvePercentageWithBound(50))->toBe(50); + expect(resolvePercentageWithBound(75))->toBe(75); + }); + + test('throws TypeError when argument violates the int-range bound', function () { + expect(fn () => resolvePercentageWithBound(150)) + ->toThrow(TypeError::class, '<= 100') + ; + + expect(fn () => resolvePercentageWithBound(0)) + ->toThrow(TypeError::class, '>= 1') + ; + }); + + test('unbound function falls back to default literal 50', function () { + expect(resolvePercentageFallback(50))->toBe(50); + + expect(fn () => resolvePercentageFallback(75)) + ->toThrow(TypeError::class, 'Return value must be literal 50') + ; + }); + }); +}); diff --git a/tests/TypeChecking/Generics/GenericTemplateBoundsStressTest.php b/tests/TypeChecking/Generics/GenericTemplateBoundsStressTest.php index 449518c..3421431 100644 --- a/tests/TypeChecking/Generics/GenericTemplateBoundsStressTest.php +++ b/tests/TypeChecking/Generics/GenericTemplateBoundsStressTest.php @@ -2,6 +2,10 @@ declare(strict_types=1); +use TypePHP\Tests\Fixtures\Domain\Animal; +use TypePHP\Tests\Fixtures\Domain\Car; +use TypePHP\Tests\Fixtures\Domain\Dog; + /** * @template T of positive-int * @@ -127,6 +131,60 @@ function testInferredOverridesDefault(mixed $input, mixed $valueToReturn): mixed return $valueToReturn; } +/** + * Fixture class with upper bound + * + * @template T of Animal + */ +class BoundedTestShelter +{ + /** + * @param T $item + */ + public function add(mixed $item): void + { + } +} + +/** + * Fixture with scalar upper bound + * + * @template T of positive-int + */ +class BoundedScalarBox +{ + /** + * @param T $val + */ + public function set(mixed $val): void + { + } +} + +describe('Generic Template Upper Bound Pre-binding Enforcement', function () { + test('throws TypeError on assignment when prebinding generic class with argument violating class upper bound', function () { + expect(function () { + /** @var BoundedTestShelter $shelter */ + $shelter = new BoundedTestShelter(); + })->toThrow(TypeError::class, 'does not satisfy upper bound'); + }); + + test('throws TypeError on assignment when prebinding generic class with argument violating scalar upper bound', function () { + expect(function () { + /** @var BoundedScalarBox $box */ + $box = new BoundedScalarBox(); + })->toThrow(TypeError::class, 'does not satisfy upper bound'); + }); + + test('accepts valid prebinding when generic argument satisfies upper bound', function () { + /** @var BoundedTestShelter $shelter */ + $shelter = new BoundedTestShelter(); + + $shelter->add(new Dog()); + expect(true)->toBeTrue(); + }); +}); + describe('Generic Template Bounds Stress Test', function () { test('validates positive-int scalar bound', function () { expect(testPositiveIntBound(42))->toBe(42); diff --git a/tests/Visitor/FunctionContractInjectorTest.php b/tests/Visitor/FunctionContractInjectorTest.php index 77d11c4..6148964 100644 --- a/tests/Visitor/FunctionContractInjectorTest.php +++ b/tests/Visitor/FunctionContractInjectorTest.php @@ -220,10 +220,13 @@ }); describe('Ignore Tag Suppression (@typephp-ignore)', function () { - test('skips injecting checks when method docblock contains @typephp-ignore', function () { + test('injects setupScope hook so @typephp-ignore can be resolved dynamically at runtime by ContractParser', function () { $doc = new Doc("/**\n * @typephp-ignore\n * @param positive-int \$id\n */"); $method = new Node\Stmt\ClassMethod('ignoredMethod', [ + 'params' => [ + new Node\Param(new Node\Expr\Variable('id')), + ], 'stmts' => [], ], [ 'comments' => [$doc], @@ -231,7 +234,10 @@ FunctionContractInjector::inject($method); - expect($method->stmts)->toBeEmpty(); + expect($method->stmts)->not()->toBeEmpty() + ->and($method->stmts[0])->toBeInstanceOf(Node\Stmt\If_::class) + ->and($method->stmts[0]->getAttribute('typephp_injected'))->toBeTrue() + ; }); }); });