diff --git a/src/Contract/ContractParser.php b/src/Contract/ContractParser.php index 66916ef6..534fe03f 100644 --- a/src/Contract/ContractParser.php +++ b/src/Contract/ContractParser.php @@ -69,6 +69,7 @@ public static function reset(): void FileFilter::reset(); TypeValidatorRegistry::reset(); StubManager::reset(); + SpecialTypeResolver::reset(); } /** @@ -811,10 +812,13 @@ private static function applyConstructorPromotionFallback(\ReflectionMethod $ref $propType = new ArrayTypeNode($propType); } $substitutedProp = self::substituteAliases($propType, []); - if ($substitutedProp instanceof IdentifierTypeNode && strtolower($substitutedProp->name) === 'mixed') { + $resolvedProp = SpecialTypeResolver::resolve($substitutedProp, $ref); + + if ($resolvedProp instanceof IdentifierTypeNode && strtolower($resolvedProp->name) === 'mixed') { continue; } - $types[$paramName] = $substitutedProp; + + $types[$paramName] = $resolvedProp; } } } @@ -947,4 +951,4 @@ public static function substituteAliases(TypeNode $node, array $aliases): TypeNo return $node; } -} +} \ No newline at end of file diff --git a/src/Internal/Checker/ParamChecker.php b/src/Internal/Checker/ParamChecker.php index 96b0b3eb..feede515 100644 --- a/src/Internal/Checker/ParamChecker.php +++ b/src/Internal/Checker/ParamChecker.php @@ -26,6 +26,11 @@ */ final class ParamChecker { + /** + * @var array + */ + private static array $effectiveFunctionCache = []; + /** * Resets the effective function cache. Useful for test isolation. */ @@ -34,11 +39,6 @@ public static function reset(): void self::$effectiveFunctionCache = []; } - /** - * @var array - */ - private static array $effectiveFunctionCache = []; - /** * @param array $vars */ @@ -69,6 +69,20 @@ public static function checkParams( $methodTemplates = $contract['templates']; $classTemplates = $contract['classTemplates'] ?? []; $aliases = $contract['aliases']; + $hasGenerics = (\count($methodTemplates) > 0 || \count($classTemplates) > 0); + + if (! $hasGenerics && \count($aliases) === 0) { + foreach ($contract['types'] as $paramName => $typeNode) { + if (\array_key_exists($paramName, $vars)) { + $err = $registry->validate($vars[$paramName], $typeNode, $effectiveFunction . '(): Argument $' . $paramName); + if ($err !== null) { + return $err; + } + } + } + + return null; + } if (\count($methodTemplates) > 0) { TemplateManager::clearCallBindings($effectiveFunction, $methodTemplates); @@ -80,7 +94,9 @@ public static function checkParams( } $allTemplates = [...$classTemplates, ...$methodTemplates]; - self::preInferGenericArrayTemplates($contract['types'], $vars, $effectiveFunction, $thisObj, $allTemplates); + if (\count($allTemplates) > 0) { + self::preInferGenericArrayTemplates($contract['types'], $vars, $effectiveFunction, $thisObj, $allTemplates); + } $boundTemplates = TemplateManager::getBoundTemplates($effectiveFunction, $thisObj, $allTemplates); $declaredTemplates = $allTemplates; diff --git a/src/Internal/Checker/ReturnChecker.php b/src/Internal/Checker/ReturnChecker.php index a0489366..35f50bab 100644 --- a/src/Internal/Checker/ReturnChecker.php +++ b/src/Internal/Checker/ReturnChecker.php @@ -26,6 +26,11 @@ */ final class ReturnChecker { + /** + * @var array + */ + private static array $effectiveFunctionCache = []; + /** * Resets the effective function cache. Useful for test isolation. */ @@ -34,11 +39,6 @@ public static function reset(): void self::$effectiveFunctionCache = []; } - /** - * @var array - */ - private static array $effectiveFunctionCache = []; - /** * @param array $vars */ @@ -222,6 +222,34 @@ private static function evaluateReturn( return $err; } + $hasGenerics = (\count($templates) > 0); + $hasAliases = (\count($aliases) > 0); + $isConditional = ($returnTypeNode instanceof ConditionalTypeForParameterNode || $returnTypeNode instanceof ConditionalTypeNode); + + if (! $hasGenerics && ! $hasAliases && ! $isConditional && ! ($returnTypeNode instanceof CallableTypeNode)) { + $resolvedType = SpecialTypeResolver::resolve($returnTypeNode, $function, $thisObj); + $err = $registry->validate($value, $resolvedType, $function . '(): Return value'); + if ($err !== null) { + return $err; + } + + if ($value instanceof \Traversable) { + $baseName = ''; + if ($resolvedType instanceof IdentifierTypeNode) { + $baseName = strtolower(ltrim($resolvedType->name, '\\')); + } elseif ($resolvedType instanceof GenericTypeNode) { + $baseName = strtolower(ltrim($resolvedType->type->name, '\\')); + } + + $genericIterables = ['iterable', 'traversable', 'iterator', 'generator']; + if (\in_array($baseName, $genericIterables, true)) { + return $wrapIterableCallback($function, 'return', $value); + } + } + + return $value; + } + $resolvedType = SpecialTypeResolver::resolve($returnTypeNode, $function, $thisObj); if ($resolvedType instanceof IdentifierTypeNode && isset($aliases[$resolvedType->name])) { diff --git a/src/Internal/Config.php b/src/Internal/Config.php index 76baf96d..87f2ee8a 100644 --- a/src/Internal/Config.php +++ b/src/Internal/Config.php @@ -11,6 +11,7 @@ use TypePHP\Extension\ExtensionManager; use TypePHP\Internal\Checker\ParamChecker; use TypePHP\Internal\Checker\ReturnChecker; +use TypePHP\Resolver\SpecialTypeResolver; use TypePHP\Resolver\TemplateManager; /** @@ -264,6 +265,7 @@ public static function set(array $config): void PathMatcher::reset(); StreamWrapper::reset(); StubManager::reset(); + SpecialTypeResolver::reset(); } /** @@ -322,6 +324,7 @@ public static function reset(): void PathMatcher::reset(); StreamWrapper::reset(); StubManager::reset(); + SpecialTypeResolver::reset(); } /** diff --git a/src/Resolver/SpecialTypeResolver.php b/src/Resolver/SpecialTypeResolver.php index 092f782c..41dccc7d 100644 --- a/src/Resolver/SpecialTypeResolver.php +++ b/src/Resolver/SpecialTypeResolver.php @@ -110,6 +110,13 @@ final class SpecialTypeResolver 'closed-resource' => true, ]; + /** + * In-memory cache for Reflection instances per context string. + * + * @var array|\ReflectionFunction|\ReflectionMethod> + */ + private static array $reflectionContextCache = []; + /** * In-memory cache of file import maps keyed by filename. * @@ -131,6 +138,17 @@ final class SpecialTypeResolver */ private static array $classTraitUseDocs = []; + /** + * Resets internal reflection and file caches. Useful for test isolation. + */ + public static function reset(): void + { + self::$reflectionContextCache = []; + self::$fileUseImports = []; + self::$fileNamespaces = []; + self::$classTraitUseDocs = []; + } + /** * Validates strict object identity ($value === $thisObj) when the return type node specifies $this. */ @@ -170,7 +188,7 @@ public static function resolve(TypeNode $node, \ReflectionClass|\ReflectionFunct if ($node instanceof GenericTypeNode) { $genericType = self::resolve($node->type, $context, $thisObj); - $innerTypes = array_map(fn ($t) => self::resolve($t, $context, $thisObj), $node->genericTypes); + $innerTypes = array_map(fn($t) => self::resolve($t, $context, $thisObj), $node->genericTypes); return new GenericTypeNode( $genericType instanceof IdentifierTypeNode ? $genericType : $node->type, @@ -224,11 +242,11 @@ public static function resolve(TypeNode $node, \ReflectionClass|\ReflectionFunct } if ($node instanceof UnionTypeNode) { - return new UnionTypeNode(array_map(fn ($t) => self::resolve($t, $context, $thisObj), $node->types)); + return new UnionTypeNode(array_map(fn($t) => self::resolve($t, $context, $thisObj), $node->types)); } if ($node instanceof IntersectionTypeNode) { - return new IntersectionTypeNode(array_map(fn ($t) => self::resolve($t, $context, $thisObj), $node->types)); + return new IntersectionTypeNode(array_map(fn($t) => self::resolve($t, $context, $thisObj), $node->types)); } return $node; @@ -262,7 +280,7 @@ public static function resolveForFile(TypeNode $node, string $file): TypeNode if ($node instanceof GenericTypeNode) { $genericType = self::resolveForFile($node->type, $file); - $innerTypes = array_map(fn ($t) => self::resolveForFile($t, $file), $node->genericTypes); + $innerTypes = array_map(fn($t) => self::resolveForFile($t, $file), $node->genericTypes); return new GenericTypeNode( $genericType instanceof IdentifierTypeNode ? $genericType : $node->type, @@ -316,11 +334,11 @@ public static function resolveForFile(TypeNode $node, string $file): TypeNode } if ($node instanceof UnionTypeNode) { - return new UnionTypeNode(array_map(fn ($t) => self::resolveForFile($t, $file), $node->types)); + return new UnionTypeNode(array_map(fn($t) => self::resolveForFile($t, $file), $node->types)); } if ($node instanceof IntersectionTypeNode) { - return new IntersectionTypeNode(array_map(fn ($t) => self::resolveForFile($t, $file), $node->types)); + return new IntersectionTypeNode(array_map(fn($t) => self::resolveForFile($t, $file), $node->types)); } return clone $node; @@ -334,25 +352,36 @@ public static function resolveForFile(TypeNode $node, string $file): TypeNode private static function getReflectionContext(\ReflectionClass|\ReflectionFunction|\ReflectionMethod|string $context): \ReflectionClass|\ReflectionFunction|\ReflectionMethod { if (\is_string($context)) { + if (isset(self::$reflectionContextCache[$context])) { + return self::$reflectionContextCache[$context]; + } + if (str_contains($context, '::')) { [$className, $methodName] = explode('::', $context, 2); if (class_exists($className) || interface_exists($className) || trait_exists($className) || enum_exists($className)) { /** @var class-string $className */ try { - return new \ReflectionMethod($className, $methodName); + return self::$reflectionContextCache[$context] = new \ReflectionMethod($className, $methodName); } catch (\ReflectionException $e) { - return new \ReflectionClass($className); + return self::$reflectionContextCache[$context] = new \ReflectionClass($className); } } /** @var class-string $fallbackClass */ $fallbackClass = \stdClass::class; - return new \ReflectionClass($fallbackClass); + return self::$reflectionContextCache[$context] = new \ReflectionClass($fallbackClass); } - return new \ReflectionFunction($context); + try { + return self::$reflectionContextCache[$context] = new \ReflectionFunction($context); + } catch (\ReflectionException $e) { + /** @var class-string $fallbackClass */ + $fallbackClass = \stdClass::class; + + return self::$reflectionContextCache[$context] = new \ReflectionClass($fallbackClass); + } } return $context; @@ -690,8 +719,6 @@ private static function resolveCallableForFile(CallableTypeNode $node, string $f return new CallableTypeNode($node->identifier, $resolvedParameters, $resolvedReturnType, $node->templateTypes); } - // --- Shared Utilities --- - private static function extractOffsetKey(TypeNode $offsetType): string|int|null { if ($offsetType instanceof ConstTypeNode) { @@ -1046,6 +1073,7 @@ private static function parseFileMetadata(string $fileName, string $source): voi } } elseif ($stmt instanceof Stmt\Class_ && $stmt->name !== null) { $className = $namespace !== '' ? $namespace . '\\' . $stmt->name->toString() : $stmt->name->toString(); + self::$classTraitUseDocs[$className] = []; foreach ($stmt->stmts as $classStmt) { if ($classStmt instanceof Stmt\TraitUse) { $doc = $classStmt->getDocComment(); diff --git a/src/Validator/IdentifierValidator.php b/src/Validator/IdentifierValidator.php index b05d9cd2..82007049 100644 --- a/src/Validator/IdentifierValidator.php +++ b/src/Validator/IdentifierValidator.php @@ -20,9 +20,9 @@ public function validate(mixed $value, TypeNode $node, string $context, TypeVali { /** @var IdentifierTypeNode $identifierNode */ $identifierNode = $node; - $lower = strtolower($identifierNode->name); + $name = $identifierNode->name; - $ok = match ($lower) { + $ok = match ($name) { 'int', 'integer' => \is_int($value), 'string' => \is_string($value), 'float', 'double' => \is_float($value) || \is_int($value), @@ -75,7 +75,7 @@ public function validate(mixed $value, TypeNode $node, string $context, TypeVali 'open-resource' => \is_resource($value), 'closed-resource' => ! \is_resource($value) && get_debug_type($value) === 'resource (closed)', - default => $this->validateClassOrIgnore($value, $identifierNode->name), + default => $this->validateCaseInsensitiveOrClass($value, $name), }; if (! $ok) { @@ -85,10 +85,31 @@ public function validate(mixed $value, TypeNode $node, string $context, TypeVali return null; } - /** - * Enforces strict object/class checks for valid PHP class identifiers (e.g. User, NonExistentClass), - * but gracefully ignores invalid class syntax (e.g. madeup-type, custom-tag-name). - */ + private function validateCaseInsensitiveOrClass(mixed $value, string $name): bool + { + $lower = strtolower($name); + + return match ($lower) { + 'int', 'integer' => \is_int($value), + 'string' => \is_string($value), + 'float', 'double' => \is_float($value) || \is_int($value), + 'bool', 'boolean' => \is_bool($value), + '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), + 'iterable' => is_iterable($value), + 'resource' => \is_resource($value), + 'null' => $value === null, + 'true' => $value === true, + 'false' => $value === false, + 'mixed' => true, + 'scalar' => \is_scalar($value), + 'void' => $value === null, + default => $this->validateClassOrIgnore($value, $name), + }; + } + private function validateClassOrIgnore(mixed $value, string $name): bool { if (! ClassNameValidator::isValid($name)) {