diff --git a/.github/ISSUE_TEMPLATE/1-bug-report.yml b/.github/ISSUE_TEMPLATE/1-bug-report.yml index 45e2300..30315f3 100644 --- a/.github/ISSUE_TEMPLATE/1-bug-report.yml +++ b/.github/ISSUE_TEMPLATE/1-bug-report.yml @@ -1,6 +1,6 @@ name: Bug report description: If something isn't working as expected. -title: "" +title: "[Bug]: " labels: ["bug"] body: - type: textarea @@ -18,7 +18,7 @@ body: label: Code snippet that reproduces the problem description: | Please reproduce the issue with the [on-line TypePHP playground](https://typephp-php.github.io/docs/playground), get a unique URL with the Share button and post it here. - placeholder: https://typephp-php.github.io/docs/playground#code= + placeholder: "https://typephp-php.github.io/docs/playground#code=" validations: required: false diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index c450b23..622ae4c 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,8 +1,5 @@ blank_issues_enabled: false contact_links: - - name: Report a security vulnerability - url: https://github.com/typephp-php/typephp/security/policy - about: Please review our security policy for more details. - name: Support question url: https://github.com/typephp-php/typephp/discussions/new?category=q-a about: Please open a new discussion instead. Thank you. \ No newline at end of file diff --git a/src/Internal/Ast/FunctionContractInjector.php b/src/Internal/Ast/FunctionContractInjector.php index 3c029cc..020a344 100644 --- a/src/Internal/Ast/FunctionContractInjector.php +++ b/src/Internal/Ast/FunctionContractInjector.php @@ -276,7 +276,7 @@ private static function resolveThisArg(bool $isClassMethod, Node\Stmt\Function_| private static function isGenerator(Node\Stmt\Function_|Node\Stmt\ClassMethod $node): bool { - $visitor = new class() extends NodeVisitorAbstract { + $visitor = new class () extends NodeVisitorAbstract { public bool $isGen = false; public function enterNode(Node $n): ?int @@ -678,8 +678,10 @@ public static function buildWrappedYieldNode(Node\Expr\Yield_ $n, Node\Expr $thi private static function wrapGeneratorReturns(array $stmts, Node\Expr $thisArg): array { $traverser = new NodeTraverser(); - $traverser->addVisitor(new class($thisArg) extends NodeVisitorAbstract { - public function __construct(private Node\Expr $thisArg) {} + $traverser->addVisitor(new class ($thisArg) extends NodeVisitorAbstract { + public function __construct(private Node\Expr $thisArg) + { + } public function enterNode(Node $n): int|Node|null { @@ -738,7 +740,7 @@ private static function wrapNonGeneratorReturns( bool $hasSelfOut = false ): array { $traverser = new NodeTraverser(); - $traverser->addVisitor(new class($thisArg, $isNativeVoid, $needsReturnVars, $hasReturn, $byRefParams, $hasSelfOut) extends NodeVisitorAbstract { + $traverser->addVisitor(new class ($thisArg, $isNativeVoid, $needsReturnVars, $hasReturn, $byRefParams, $hasSelfOut) extends NodeVisitorAbstract { /** * @param array $byRefParams */ @@ -749,7 +751,8 @@ public function __construct( private bool $hasReturn, private array $byRefParams, private bool $hasSelfOut - ) {} + ) { + } public function enterNode(Node $n): int|array|null { diff --git a/src/Internal/Ast/ScopeManager.php b/src/Internal/Ast/ScopeManager.php index ea3ea2b..df0191e 100644 --- a/src/Internal/Ast/ScopeManager.php +++ b/src/Internal/Ast/ScopeManager.php @@ -5,9 +5,7 @@ namespace TypePHP\Internal\Ast; use PhpParser\Node; -use PHPStan\PhpDocParser\Parser\TokenIterator; use TypePHP\Internal\Docblock\DocblockExtractor; -use TypePHP\Internal\Docblock\DocblockNormalizer; /** * @internal Manages lexical scope stack frames and extracts local @var variable annotations. @@ -15,26 +13,32 @@ final class ScopeManager { /** - * @var array> + * @var list> */ private array $scopeStack = [[]]; /** - * Pushes a new scope frame, inheriting variables from the parent scope. + * Tracks the current scope frame depth. + */ + private int $depth = 0; + + /** + * Pushes a new empty lexical scope frame (O(1)). */ public function pushScope(): void { - $currentScope = end($this->scopeStack); - $this->scopeStack[] = $currentScope !== false ? $currentScope : []; + $this->depth++; + $this->scopeStack[] = []; } /** - * Pops the top scope frame, restoring variables back to the parent scope. + * Pops the top scope frame, restoring the previous lexical scope (O(1)). */ public function popScope(): void { - if (\count($this->scopeStack) > 1) { + if ($this->depth > 0) { array_pop($this->scopeStack); + $this->depth--; } } @@ -44,12 +48,12 @@ public function popScope(): void */ public function extractVarDocblock(string $docText, ?Node\Expr $expr = null): void { - try { - $docText = DocblockNormalizer::normalize($docText); - [$phpDocParser, $lexer] = DocblockExtractor::getParserComponents(); + if (! str_contains($docText, 'var')) { + return; + } - $tokens = new TokenIterator($lexer->tokenize($docText)); - $phpDocNode = $phpDocParser->parse($tokens); + try { + $phpDocNode = DocblockExtractor::parseDocString($docText); $varTags = DocblockExtractor::getVarTags($phpDocNode); foreach ($varTags as $varTag) { @@ -63,8 +67,7 @@ public function extractVarDocblock(string $docText, ?Node\Expr $expr = null): vo } if ($varName !== '') { - $currentScopeIndex = \count($this->scopeStack) - 1; - $this->scopeStack[$currentScopeIndex][$varName] = $typeString; + $this->scopeStack[$this->depth][$varName] = $typeString; } } } catch (\Throwable $e) { @@ -72,9 +75,12 @@ public function extractVarDocblock(string $docText, ?Node\Expr $expr = null): vo } } + /** + * Resolves a variable type by walking upward through the lexical scope chain. + */ public function getVarTypeFromScope(string $varName): ?string { - for ($i = \count($this->scopeStack) - 1; $i >= 0; $i--) { + for ($i = $this->depth; $i >= 0; $i--) { if (isset($this->scopeStack[$i][$varName])) { return $this->scopeStack[$i][$varName]; } diff --git a/src/Internal/Checker/GeneratorChecker.php b/src/Internal/Checker/GeneratorChecker.php index 74ef9fd..2519d77 100644 --- a/src/Internal/Checker/GeneratorChecker.php +++ b/src/Internal/Checker/GeneratorChecker.php @@ -19,6 +19,29 @@ */ final class GeneratorChecker { + /** + * O(1) Fast-path cache for generators determined to have no return contracts. + * + * @var array + */ + private static array $noContractCache = []; + + /** + * Cache for resolved yield & send types of static / non-generic generators: + * [$function] => array{0: ?TypeNode, 1: ?TypeNode, 2: ?TypeNode}. + * + * @var array + */ + private static array $staticYieldTypeCache = []; + + /** + * 2D Cache for resolved yield & send types of generic generators: + * [$function][$templateSignature] => array{0: ?TypeNode, 1: ?TypeNode, 2: ?TypeNode}. + * + * @var array> + */ + private static array $genericYieldTypeCache = []; + /** * Validates a value sent into a generator via $gen->send() against TSend. */ @@ -32,12 +55,12 @@ public static function checkSend( return null; } - $returnTypeNode = self::resolveGeneratorReturnType($function, $thisOrClass); - if (! ($returnTypeNode instanceof GenericTypeNode)) { + $types = self::resolveYieldAndSendTypes($function, $thisOrClass); + if ($types === null) { return $sendValue; } - $sendTypeNode = $returnTypeNode->genericTypes[2] ?? null; + $sendTypeNode = $types[2]; if ($sendTypeNode === null) { return $sendValue; } @@ -48,7 +71,7 @@ public static function checkSend( } /** - * Validates yielded keys and values from a generator function against TKey and TValue. + * Validates yielded keys and values from a generator function against TKey and TValue with zero-allocation caching. */ public static function checkYield( string $function, @@ -57,12 +80,12 @@ public static function checkYield( TypeValidatorRegistry $registry, object|string|null $thisOrClass = null ): mixed { - $returnTypeNode = self::resolveGeneratorReturnType($function, $thisOrClass); - if ($returnTypeNode === null) { + $types = self::resolveYieldAndSendTypes($function, $thisOrClass); + if ($types === null) { return $value; } - [$keyTypeNode, $itemTypeNode] = self::extractYieldTypes($returnTypeNode); + [$keyTypeNode, $itemTypeNode] = $types; if ($key !== null && $keyTypeNode !== null) { $err = $registry->validate($key, $keyTypeNode, "$function(): Return iterator key"); @@ -82,43 +105,91 @@ public static function checkYield( } /** - * Resolves the generator's return contract, applying alias expansion, template substitution, and special types. + * Resolves and caches the generator's yielded key, value, and sent types in memory. + * + * @return array{0: ?TypeNode, 1: ?TypeNode, 2: ?TypeNode}|null */ - private static function resolveGeneratorReturnType(string $function, object|string|null $thisOrClass): ?TypeNode + private static function resolveYieldAndSendTypes(string $function, object|string|null $thisOrClass): ?array { + if (isset(self::$noContractCache[$function])) { + return null; + } + + if (isset(self::$staticYieldTypeCache[$function])) { + return self::$staticYieldTypeCache[$function]; + } + $contract = DocblockParser::parse($function); $returnTypeNode = $contract['return'] ?? null; if ($returnTypeNode === null) { + self::$noContractCache[$function] = true; + return null; } + $allTemplates = [...($contract['classTemplates'] ?? []), ...($contract['templates'] ?? [])]; $aliases = $contract['aliases'] ?? []; + $hasGenerics = \count($allTemplates) > 0; + $hasAliases = \count($aliases) > 0; + $thisObj = \is_object($thisOrClass) ? $thisOrClass : null; + + // Static / non-generic generator: cache permanently for this function + if (! $hasGenerics && ! $hasAliases && $thisObj === null) { + $resolvedNode = SpecialTypeResolver::resolve($returnTypeNode, $function, null); + + return self::$staticYieldTypeCache[$function] = self::extractYieldTypes($resolvedNode); + } + + // Generic generator: check signature-based 2D cache + $boundTemplates = $hasGenerics ? TemplateManager::getBoundTemplates($function, $thisObj, $allTemplates) : []; + + $sig = null; + $boundCount = \count($boundTemplates); + if ($boundCount > 0 && $boundCount <= 2 && ! $hasAliases && $thisObj === null) { + if ($boundCount === 1) { + $first = reset($boundTemplates); + $sig = $first instanceof IdentifierTypeNode ? $first->name : (string) $first; + } else { + $sig = ''; + foreach ($boundTemplates as $v) { + $sig .= ($v instanceof IdentifierTypeNode ? $v->name : (string) $v) . '|'; + } + } + + if (isset(self::$genericYieldTypeCache[$function][$sig])) { + return self::$genericYieldTypeCache[$function][$sig]; + } + } + if ($returnTypeNode instanceof IdentifierTypeNode && isset($aliases[$returnTypeNode->name])) { $returnTypeNode = $aliases[$returnTypeNode->name]; } - $thisObj = \is_object($thisOrClass) ? $thisOrClass : null; - $allTemplates = [...($contract['classTemplates'] ?? []), ...($contract['templates'] ?? [])]; - $boundTemplates = TemplateManager::getBoundTemplates($function, $thisObj, $allTemplates); - if (\count($boundTemplates) > 0 || \count($allTemplates) > 0) { $returnTypeNode = TemplateSubstitutor::substitute($returnTypeNode, $boundTemplates, $allTemplates); $returnTypeNode = SpecialTypeResolver::resolve($returnTypeNode, $function, $thisObj); } - return $returnTypeNode; + $types = self::extractYieldTypes($returnTypeNode); + + if ($sig !== null) { + self::$genericYieldTypeCache[$function][$sig] = $types; + } + + return $types; } /** - * Extracts yielded key and item TypeNodes from a resolved generator/array AST node. + * Extracts yielded key, item, and sent (TSend) TypeNodes from a resolved generator/array AST node. * - * @return array{0: ?TypeNode, 1: ?TypeNode} + * @return array{0: ?TypeNode, 1: ?TypeNode, 2: ?TypeNode} */ private static function extractYieldTypes(TypeNode $returnTypeNode): array { $itemTypeNode = null; $keyTypeNode = null; + $sendTypeNode = null; if ($returnTypeNode instanceof GenericTypeNode) { $typesCount = \count($returnTypeNode->genericTypes); @@ -127,11 +198,12 @@ private static function extractYieldTypes(TypeNode $returnTypeNode): array } elseif ($typesCount >= 2) { $keyTypeNode = $returnTypeNode->genericTypes[0]; $itemTypeNode = $returnTypeNode->genericTypes[1]; + $sendTypeNode = $returnTypeNode->genericTypes[2] ?? null; } } elseif ($returnTypeNode instanceof ArrayTypeNode) { $itemTypeNode = $returnTypeNode->type; } - return [$keyTypeNode, $itemTypeNode]; + return [$keyTypeNode, $itemTypeNode, $sendTypeNode]; } } diff --git a/src/Internal/Checker/InlineChecker.php b/src/Internal/Checker/InlineChecker.php index 9ef77f6..fdbb9cb 100644 --- a/src/Internal/Checker/InlineChecker.php +++ b/src/Internal/Checker/InlineChecker.php @@ -48,16 +48,16 @@ final class InlineChecker private static array $parsedTypeNodeCache = []; /** - * In-memory cache for resolved class contexts with static bounds. + * In-memory 3D cache for resolved class contexts with static bounds: [$className][$methodName][$typeString] => TypeNode. * - * @var array + * @var array>> */ private static array $resolvedClassContextCache = []; /** - * In-memory cache for properties known to have no DocBlock annotations. + * In-memory 2D cache for properties known to have no DocBlock annotations: [$className][$propName] => true. * - * @var array + * @var array> */ public static array $nullPropertyCache = []; @@ -218,7 +218,7 @@ private static function isArrayShapeType(TypeNode $node): bool } /** - * Evaluates class property validation dynamically based on configuration. + * Evaluates class property validation dynamically based on configuration with zero-allocation 2D caching. */ public static function checkProperty(mixed $value, mixed $objectOrClass, string $propName, string $file, TypeValidatorRegistry $registry): mixed { @@ -226,10 +226,9 @@ public static function checkProperty(mixed $value, mixed $objectOrClass, string return $value; } - $className = \is_string($objectOrClass) ? $objectOrClass : \get_class($objectOrClass); - $cacheKey = $className . '::$' . $propName; + $className = \is_string($objectOrClass) ? $objectOrClass : $objectOrClass::class; - if (isset(self::$nullPropertyCache[$cacheKey])) { + if (isset(self::$nullPropertyCache[$className][$propName])) { return $value; } @@ -239,7 +238,7 @@ public static function checkProperty(mixed $value, mixed $objectOrClass, string $typeNode = DocblockParser::parseProperty($className, $propName); if ($typeNode === null) { - self::$nullPropertyCache[$cacheKey] = true; + self::$nullPropertyCache[$className][$propName] = true; return $value; } @@ -324,7 +323,7 @@ private static function resolveFunctionContext(TypeNode $typeNode, string $funct } /** - * Resolves templates, aliases, and class context within class methods. + * Resolves templates, aliases, and class context within class methods using zero-allocation nested caching. */ private static function resolveClassContext( TypeNode $typeNode, @@ -343,11 +342,14 @@ private static function resolveClassContext( $contract = DocblockParser::parse($targetFunc); $hasMethodTemplates = ($contract['templates'] ?? []) !== []; - $cacheKey = null; - if ($thisObj === null && ! $hasMethodTemplates) { - $cacheKey = ((string) $typeNode) . '|' . $className . '|' . ($methodName ?? ''); - if (isset(self::$resolvedClassContextCache[$cacheKey])) { - return self::$resolvedClassContextCache[$cacheKey]; + $methodKey = $methodName ?? ''; + $typeString = null; + $canCache = ($thisObj === null && ! $hasMethodTemplates); + + if ($canCache) { + $typeString = (string) $typeNode; + if (isset(self::$resolvedClassContextCache[$className][$methodKey][$typeString])) { + return self::$resolvedClassContextCache[$className][$methodKey][$typeString]; } } @@ -361,8 +363,8 @@ private static function resolveClassContext( $declaredTemplates = $allTemplates; if ($classAliases === [] && $declaredTemplates === []) { - if ($cacheKey !== null) { - return self::$resolvedClassContextCache[$cacheKey] = $typeNode; + if ($canCache && $typeString !== null) { + return self::$resolvedClassContextCache[$className][$methodKey][$typeString] = $typeNode; } return $typeNode; @@ -379,8 +381,8 @@ private static function resolveClassContext( // Silently continue if reflection fails } - if ($cacheKey !== null) { - return self::$resolvedClassContextCache[$cacheKey] = $typeNode; + if ($canCache && $typeString !== null) { + return self::$resolvedClassContextCache[$className][$methodKey][$typeString] = $typeNode; } return $typeNode; diff --git a/src/Internal/Checker/ParamChecker.php b/src/Internal/Checker/ParamChecker.php index ff548ad..922b55f 100644 --- a/src/Internal/Checker/ParamChecker.php +++ b/src/Internal/Checker/ParamChecker.php @@ -37,7 +37,31 @@ final class ParamChecker { /** - * @var array + * O(1) lookup table for iterable collection container types. + */ + private const ITERABLE_TYPES = [ + 'array' => true, + 'list' => true, + 'iterable' => true, + 'traversable' => true, + ]; + + /** + * O(1) lookup table for all collection and slice container types. + */ + private const COLLECTION_TYPES = [ + 'array' => true, + 'list' => true, + 'iterable' => true, + 'traversable' => true, + 'non-empty-array' => true, + 'non-empty-list' => true, + ]; + + /** + * 2D Cache for effective functions: [$function][$actualClassName] => effectiveFunction. + * + * @var array> */ private static array $effectiveFunctionCache = []; @@ -49,9 +73,9 @@ final class ParamChecker public static array $noParamContractCache = []; /** - * Cache for resolved parameter base types (after alias and special type resolution). + * 2D Cache for resolved parameter base types: [$effectiveFunction][$paramName] => TypeNode. * - * @var array + * @var array> */ private static array $baseTypeCache = []; @@ -262,7 +286,7 @@ private static function prepareGenericBindings( } /** - * Pre‑resolves and caches base types for each parameter. + * Pre‑resolves and caches base types for each parameter using zero-allocation 2D lookup. * * @param array $types * @param array $aliases @@ -277,15 +301,14 @@ private static function resolveBaseTypes( ): array { $baseTypes = []; foreach ($types as $paramName => $typeNode) { - $cacheKey = $effectiveFunction . '|' . $paramName; - if (! isset(self::$baseTypeCache[$cacheKey])) { + if (! isset(self::$baseTypeCache[$effectiveFunction][$paramName])) { if ($typeNode instanceof IdentifierTypeNode && isset($aliases[$typeNode->name])) { $typeNode = $aliases[$typeNode->name]; } $resolved = SpecialTypeResolver::resolve($typeNode, $effectiveFunction, $thisObj); - self::$baseTypeCache[$cacheKey] = $resolved; + self::$baseTypeCache[$effectiveFunction][$paramName] = $resolved; } - $baseTypes[$paramName] = self::$baseTypeCache[$cacheKey]; + $baseTypes[$paramName] = self::$baseTypeCache[$effectiveFunction][$paramName]; } return $baseTypes; @@ -356,7 +379,7 @@ private static function validateAllParameters( } /** - * Resolves the actual runtime class name vs trait name with O(1) memoization. + * Resolves the actual runtime class name vs trait name with O(1) 2D memoization. */ public static function resolveEffectiveFunction(string $function, object|string|null $thisOrClass, ?object $thisObj = null): string { @@ -374,9 +397,8 @@ public static function resolveEffectiveFunction(string $function, object|string| return $function; } - $cacheKey = $function . '|' . $actualClassName; - if (isset(self::$effectiveFunctionCache[$cacheKey])) { - return self::$effectiveFunctionCache[$cacheKey]; + if (isset(self::$effectiveFunctionCache[$function][$actualClassName])) { + return self::$effectiveFunctionCache[$function][$actualClassName]; } [$classOrTrait, $methodName] = explode('::', $function, 2); @@ -404,14 +426,14 @@ public static function resolveEffectiveFunction(string $function, object|string| $frameFunc = $frame['function']; $frameClass = $frame['class'] ?? ''; if (($frameClass === $actualClassName || $frameClass === $classOrTrait) && isset($traitAliases[$frameFunc])) { - return self::$effectiveFunctionCache[$cacheKey] = $actualClassName . '::' . $frameFunc; + return self::$effectiveFunctionCache[$function][$actualClassName] = $actualClassName . '::' . $frameFunc; } } } } } - return self::$effectiveFunctionCache[$cacheKey] = $effectiveFunction; + return self::$effectiveFunctionCache[$function][$actualClassName] = $effectiveFunction; } /** @@ -647,7 +669,7 @@ private static function inferArrayTemplatesFromAllElements( if ($typeNode instanceof GenericTypeNode) { $baseType = strtolower($typeNode->type->name); - if (! \in_array($baseType, ['array', 'list', 'iterable', 'traversable'], true)) { + if (! isset(self::ITERABLE_TYPES[$baseType])) { return; } @@ -801,7 +823,7 @@ private static function inferGenericObjectNode( 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)) { + if (isset(self::COLLECTION_TYPES[$baseName])) { return; } @@ -869,7 +891,7 @@ private static function inferGenericObjectNode( $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)) { + if (isset(self::COLLECTION_TYPES[$baseName])) { $innerType = $typeNode->genericTypes[1] ?? $typeNode->genericTypes[0] ?? null; } } diff --git a/src/Internal/Checker/ReturnChecker.php b/src/Internal/Checker/ReturnChecker.php index 03a1aef..59f7467 100644 --- a/src/Internal/Checker/ReturnChecker.php +++ b/src/Internal/Checker/ReturnChecker.php @@ -27,6 +27,16 @@ */ final class ReturnChecker { + /** + * Fast O(1) lookup set for generic iterable return types. + */ + private const GENERIC_ITERABLES = [ + 'iterable' => true, + 'traversable' => true, + 'iterator' => true, + 'generator' => true, + ]; + /** * O(1) Fast-path cache for methods determined to have no return contracts. * @@ -49,9 +59,9 @@ final class ReturnChecker private static array $resolvedStaticReturnCache = []; /** - * In-memory cache for concrete substituted generic return types. + * In-memory 2D cache for concrete substituted generic return types: [$function][$templateSignature] => TypeNode. * - * @var array + * @var array> */ public static array $substitutedReturnCache = []; @@ -275,8 +285,7 @@ private static function evaluateReturn( $baseName = strtolower(ltrim($resolvedType->type->name, '\\')); } - $genericIterables = ['iterable', 'traversable', 'iterator', 'generator']; - if (\in_array($baseName, $genericIterables, true)) { + if (isset(self::GENERIC_ITERABLES[$baseName])) { return $wrapIterableCallback($function, 'return', $value); } } @@ -286,14 +295,21 @@ private static function evaluateReturn( $boundTemplates = TemplateManager::getBoundTemplates($function, $thisObj, $templates); - $cacheKey = null; - if (\count($boundTemplates) <= 2 && ! $isParamConditional && \count($aliases) === 0 && $thisObj === null) { - $cacheKey = $function; - foreach ($boundTemplates as $k => $v) { - $cacheKey .= '|' . $k . ':' . ($v instanceof IdentifierTypeNode ? $v->name : (string) $v); + $sig = null; + $boundCount = \count($boundTemplates); + if ($boundCount > 0 && $boundCount <= 2 && ! $isParamConditional && \count($aliases) === 0 && $thisObj === null) { + if ($boundCount === 1) { + $first = reset($boundTemplates); + $sig = $first instanceof IdentifierTypeNode ? $first->name : (string) $first; + } else { + $sig = ''; + foreach ($boundTemplates as $v) { + $sig .= ($v instanceof IdentifierTypeNode ? $v->name : (string) $v) . '|'; + } } - if (isset(self::$substitutedReturnCache[$cacheKey])) { - $resolvedType = self::$substitutedReturnCache[$cacheKey]; + + if (isset(self::$substitutedReturnCache[$function][$sig])) { + $resolvedType = self::$substitutedReturnCache[$function][$sig]; } } @@ -317,8 +333,8 @@ private static function evaluateReturn( $resolvedType = ConditionalChecker::resolve($resolvedType, $vars, $boundTemplates, $registry, $function); - if ($cacheKey !== null) { - self::$substitutedReturnCache[$cacheKey] = $resolvedType; + if ($sig !== null) { + self::$substitutedReturnCache[$function][$sig] = $resolvedType; } } @@ -346,8 +362,7 @@ private static function evaluateReturn( $baseName = strtolower(ltrim($resolvedType->type->name, '\\')); } - $genericIterables = ['iterable', 'traversable', 'iterator', 'generator']; - if (\in_array($baseName, $genericIterables, true)) { + if (isset(self::GENERIC_ITERABLES[$baseName])) { return $wrapIterableCallback($function, 'return', $value); } } diff --git a/src/Internal/Docblock/DocblockParser.php b/src/Internal/Docblock/DocblockParser.php index 1cadab0..9c1b9f5 100644 --- a/src/Internal/Docblock/DocblockParser.php +++ b/src/Internal/Docblock/DocblockParser.php @@ -68,16 +68,16 @@ final class DocblockParser private static array $cache = []; /** - * Cache for resolved class property types. + * 2D Cache for resolved class property types: [$className][$propertyName] => ?TypeNode. * - * @var array + * @var array> */ private static array $propertyCache = []; /** - * Cache for resolved magic method contracts. + * 2D Cache for resolved magic method contracts: [$className][$methodName] => ?array{...}. * - * @var array, aliases: array, templates: array}> + * @var array, aliases: array, templates: array}>> */ private static array $magicMethodCache = []; @@ -346,7 +346,7 @@ public static function parse(string $function): array if (str_contains($function, '::')) { [$className, $methodName] = explode('::', $function, 2); - if (class_exists($className) || interface_exists($className) || trait_exists($className) || enum_exists($className)) { + if (class_exists($className, false) || class_exists($className) || interface_exists($className) || trait_exists($className) || enum_exists($className)) { /** @var class-string $className */ $refClass = new \ReflectionClass($className); if ($refClass->hasMethod($methodName)) { @@ -436,17 +436,16 @@ public static function parse(string $function): array } /** - * Parses and resolves the @var or @property docblock for a given class property. + * Parses and resolves the @var or @property docblock for a given class property with zero-allocation 2D caching. */ public static function parseProperty(string $className, string $propertyName): ?TypeNode { - $cacheKey = $className . '::$' . $propertyName; - if (\array_key_exists($cacheKey, self::$propertyCache)) { - return self::$propertyCache[$cacheKey]; + if (isset(self::$propertyCache[$className][$propertyName]) || \array_key_exists($propertyName, self::$propertyCache[$className] ?? [])) { + return self::$propertyCache[$className][$propertyName]; } - if (! class_exists($className) && ! trait_exists($className) && ! interface_exists($className) && ! enum_exists($className)) { - return self::$propertyCache[$cacheKey] = null; + if (! class_exists($className, false) && ! class_exists($className) && ! trait_exists($className, false) && ! trait_exists($className) && ! interface_exists($className, false) && ! interface_exists($className) && ! enum_exists($className, false) && ! enum_exists($className)) { + return self::$propertyCache[$className][$propertyName] = null; } try { @@ -470,7 +469,7 @@ public static function parseProperty(string $className, string $propertyName): ? } if ($doc === false || $declaringClass === null || self::shouldIgnoreDoc($doc)) { - return self::$propertyCache[$cacheKey] = null; + return self::$propertyCache[$className][$propertyName] = null; } if (! $isMagicProperty) { @@ -478,14 +477,14 @@ public static function parseProperty(string $className, string $propertyName): ? $varTags = DocblockExtractor::getVarTags($phpDocNode); if (\count($varTags) === 0) { - return self::$propertyCache[$cacheKey] = null; + return self::$propertyCache[$className][$propertyName] = null; } $typeNode = $varTags[0]->type; } if ($typeNode === null) { - return self::$propertyCache[$cacheKey] = null; + return self::$propertyCache[$className][$propertyName] = null; } $aliases = []; @@ -500,9 +499,9 @@ public static function parseProperty(string $className, string $propertyName): ? $typeNode = self::substituteAliases($typeNode, $aliases); $resolvedNode = SpecialTypeResolver::resolve($typeNode, $declaringClass); - return self::$propertyCache[$cacheKey] = $resolvedNode; + return self::$propertyCache[$className][$propertyName] = $resolvedNode; } catch (\Throwable $e) { - return self::$propertyCache[$cacheKey] = null; + return self::$propertyCache[$className][$propertyName] = null; } } @@ -596,19 +595,18 @@ private static function findMagicPropertyDoc(\ReflectionClass $refClass, string } /** - * Parses and resolves a class-level @method docblock for __call / __callStatic. + * Parses and resolves a class-level @method docblock for __call / __callStatic with zero-allocation 2D caching. * * @return array{return: ?TypeNode, parameters: array, aliases: array, templates: array}|null */ public static function parseMagicMethod(string $className, string $methodName): ?array { - $cacheKey = $className . '::' . $methodName; - if (\array_key_exists($cacheKey, self::$magicMethodCache)) { - return self::$magicMethodCache[$cacheKey]; + if (isset(self::$magicMethodCache[$className][$methodName]) || \array_key_exists($methodName, self::$magicMethodCache[$className] ?? [])) { + return self::$magicMethodCache[$className][$methodName]; } - if (! class_exists($className) && ! trait_exists($className) && ! interface_exists($className) && ! enum_exists($className)) { - return self::$magicMethodCache[$cacheKey] = null; + if (! class_exists($className, false) && ! class_exists($className) && ! trait_exists($className, false) && ! trait_exists($className) && ! interface_exists($className, false) && ! interface_exists($className) && ! enum_exists($className, false) && ! enum_exists($className)) { + return self::$magicMethodCache[$className][$methodName] = null; } try { @@ -617,7 +615,7 @@ public static function parseMagicMethod(string $className, string $methodName): $resolved = self::findMagicMethodDoc($refClass, $methodName); if ($resolved === null) { - return self::$magicMethodCache[$cacheKey] = null; + return self::$magicMethodCache[$className][$methodName] = null; } $doc = $resolved['doc']; @@ -625,7 +623,7 @@ public static function parseMagicMethod(string $className, string $methodName): $methodTag = $resolved['methodTag']; if (self::shouldIgnoreDoc($doc)) { - return self::$magicMethodCache[$cacheKey] = null; + return self::$magicMethodCache[$className][$methodName] = null; } $aliases = []; @@ -643,14 +641,14 @@ public static function parseMagicMethod(string $className, string $methodName): $resolvedParams = self::resolveMagicParameters($methodTag, $declaringClass, $aliases); - return self::$magicMethodCache[$cacheKey] = [ + return self::$magicMethodCache[$className][$methodName] = [ 'return' => $resolvedReturn, 'parameters' => $resolvedParams, 'aliases' => $aliases, 'templates' => $classTemplates, ]; } catch (\Throwable $e) { - return self::$magicMethodCache[$cacheKey] = null; + return self::$magicMethodCache[$className][$methodName] = null; } } @@ -773,7 +771,11 @@ private static function hasSensitiveAttribute(\ReflectionParameter $param): bool */ public static function parseClassAliases(string $className): array { - if (! class_exists($className) && ! interface_exists($className) && ! trait_exists($className) && ! enum_exists($className)) { + if (isset(self::$classLevelDocCache[$className])) { + return self::$classLevelDocCache[$className]['aliases']; + } + + if (! class_exists($className, false) && ! class_exists($className) && ! interface_exists($className, false) && ! interface_exists($className) && ! trait_exists($className, false) && ! trait_exists($className) && ! enum_exists($className, false) && ! enum_exists($className)) { return []; } @@ -1598,6 +1600,10 @@ private static function typeContainsNull(TypeNode $node): bool */ public static function substituteAliases(TypeNode $node, array $aliases): TypeNode { + if ($aliases === []) { + return $node; + } + if ($node instanceof IdentifierTypeNode) { if (isset($aliases[$node->name])) { return self::substituteAliases($aliases[$node->name], $aliases); diff --git a/src/Internal/Generics/TemplateManager.php b/src/Internal/Generics/TemplateManager.php index 1eb2704..7b489c4 100644 --- a/src/Internal/Generics/TemplateManager.php +++ b/src/Internal/Generics/TemplateManager.php @@ -62,12 +62,19 @@ final class TemplateManager private static array $classInheritedBindingsCache = []; /** - * In-memory cache for subclass hierarchy (is_a) lookups. + * In-memory 2D cache for subclass hierarchy (is_a) lookups: [$sub][$super] => bool. * - * @var array + * @var array> */ private static array $subclassCache = []; + /** + * In-memory cache for real type symbol verification (class/interface/trait/enum existence). + * + * @var array + */ + private static array $realTypeSymbolCache = []; + /** * Temporary storage for an original object instance being cloned. */ @@ -356,7 +363,7 @@ final class TemplateManager private static array $methodTemplatesCache = []; /** - * In-memory cache for isMethodTemplate checks per function and template name. + * In-memory cache for isMethodTemplate checks per function and template name: [$function][$templateName] => bool. * * @var array> */ @@ -379,6 +386,7 @@ public static function reset(): void self::$classHierarchyTemplatesCache = []; self::$classInheritedBindingsCache = []; self::$subclassCache = []; + self::$realTypeSymbolCache = []; self::$pendingCloneSource = null; self::$methodTemplatesCache = []; self::$isMethodTemplateCache = []; @@ -1138,7 +1146,7 @@ public static function checkVariance(TypeNode $existing, TypeNode $expected, str $lowerExpected = strtolower($expectedStr); $lowerExisting = strtolower($existingStr); - if ($lowerExpected === 'object' && ClassNameValidator::isValid($existingStr) && (class_exists($existingStr) || interface_exists($existingStr))) { + if ($lowerExpected === 'object' && ClassNameValidator::isValid($existingStr) && self::isRealTypeSymbol($existingStr)) { return true; } @@ -1337,9 +1345,8 @@ private static function checkNestedGenericVariance(GenericTypeNode $existing, Ge private static function isSubclass(string $sub, string $super): bool { - $cacheKey = $sub . '|' . $super; - if (isset(self::$subclassCache[$cacheKey])) { - return self::$subclassCache[$cacheKey]; + if (isset(self::$subclassCache[$sub][$super])) { + return self::$subclassCache[$sub][$super]; } $baseSub = ($pos = strpos($sub, '<')) !== false ? substr($sub, 0, $pos) : $sub; @@ -1359,7 +1366,7 @@ private static function isSubclass(string $sub, string $super): bool $result = is_a($baseSub, $baseSuper, true); } - return self::$subclassCache[$cacheKey] = $result; + return self::$subclassCache[$sub][$super] = $result; } /** @@ -1458,9 +1465,23 @@ private static function ensureInstanceInherited(object $instance): void } } + /** + * Checks if a symbol represents an existing class, interface, enum, or trait with fast non-autoloading cache. + */ private static function isRealTypeSymbol(string $name): bool { - return class_exists($name) || interface_exists($name) || enum_exists($name) || trait_exists($name); + if (isset(self::$realTypeSymbolCache[$name])) { + return self::$realTypeSymbolCache[$name]; + } + + return self::$realTypeSymbolCache[$name] = class_exists($name, false) + || class_exists($name) + || interface_exists($name, false) + || interface_exists($name) + || enum_exists($name, false) + || enum_exists($name) + || trait_exists($name, false) + || trait_exists($name); } /** diff --git a/src/Internal/Resolver/SpecialTypeResolver.php b/src/Internal/Resolver/SpecialTypeResolver.php index 3cc4f71..a2f51d8 100644 --- a/src/Internal/Resolver/SpecialTypeResolver.php +++ b/src/Internal/Resolver/SpecialTypeResolver.php @@ -116,12 +116,26 @@ final class SpecialTypeResolver private static array $reflectionContextCache = []; /** - * In-memory cache for resolved FQCNs per context and type name. + * In-memory 2D cache for resolved FQCNs: [$contextKey][$name] => FQCN. * - * @var array + * @var array> */ private static array $fqcnCache = []; + /** + * In-memory 2D cache for file FQCN resolutions: [$file][$name] => FQCN. + * + * @var array> + */ + private static array $fileFqcnCache = []; + + /** + * In-memory 2D cache for class constant values: [$fqcn][$constName] => mixed. + * + * @var array> + */ + private static array $classConstantCache = []; + /** * In-memory cache of file import maps keyed by filename. * @@ -157,6 +171,8 @@ public static function reset(): void { self::$reflectionContextCache = []; self::$fqcnCache = []; + self::$fileFqcnCache = []; + self::$classConstantCache = []; self::$classTraitUseDocs = []; self::$anonymousTraitUseDocs = []; } @@ -776,24 +792,39 @@ private static function extractItemKey(mixed $keyName): string|int|null return null; } - private static function resolveConstantOffsetValue(string $fqcn, string $constName, string|int $offsetKey): ?TypeNode + /** + * Resolves and caches class constant values in memory. + */ + private static function getClassConstant(string $fqcn, string $constName): mixed { - if ($fqcn !== '' && (class_exists($fqcn) || interface_exists($fqcn) || enum_exists($fqcn) || trait_exists($fqcn))) { - try { - $refClass = new \ReflectionClass($fqcn); - if ($refClass->hasConstant($constName)) { - $constValue = $refClass->getConstant($constName); - if (\is_array($constValue) && \array_key_exists($offsetKey, $constValue)) { - $val = $constValue[$offsetKey]; - if (\is_string($val)) { - return new ConstTypeNode(new ConstExprStringNode($val, ConstExprStringNode::SINGLE_QUOTED)); - } - if (\is_int($val)) { - return new ConstTypeNode(new ConstExprIntegerNode((string) $val)); - } + if (! isset(self::$classConstantCache[$fqcn][$constName]) && ! \array_key_exists($constName, self::$classConstantCache[$fqcn] ?? [])) { + $val = null; + if ($fqcn !== '' && self::symbolExists($fqcn)) { + try { + /** @var class-string $fqcn */ + $refClass = new \ReflectionClass($fqcn); + if ($refClass->hasConstant($constName)) { + $val = $refClass->getConstant($constName); } + } catch (\ReflectionException $e) { } - } catch (\ReflectionException $e) { + } + self::$classConstantCache[$fqcn][$constName] = $val; + } + + return self::$classConstantCache[$fqcn][$constName]; + } + + private static function resolveConstantOffsetValue(string $fqcn, string $constName, string|int $offsetKey): ?TypeNode + { + $constValue = self::getClassConstant($fqcn, $constName); + if (\is_array($constValue) && \array_key_exists($offsetKey, $constValue)) { + $val = $constValue[$offsetKey]; + if (\is_string($val)) { + return new ConstTypeNode(new ConstExprStringNode($val, ConstExprStringNode::SINGLE_QUOTED)); + } + if (\is_int($val)) { + return new ConstTypeNode(new ConstExprIntegerNode((string) $val)); } } @@ -802,20 +833,12 @@ private static function resolveConstantOffsetValue(string $fqcn, string $constNa private static function resolveConstantKeyValue(string $fqcn, string $constName): ConstExprStringNode|ConstExprIntegerNode|null { - if (class_exists($fqcn) || interface_exists($fqcn) || enum_exists($fqcn) || trait_exists($fqcn)) { - try { - $refClass = new \ReflectionClass($fqcn); - if ($refClass->hasConstant($constName)) { - $val = $refClass->getConstant($constName); - if (\is_string($val)) { - return new ConstExprStringNode($val, ConstExprStringNode::SINGLE_QUOTED); - } - if (\is_int($val)) { - return new ConstExprIntegerNode((string) $val); - } - } - } catch (\ReflectionException $e) { - } + $val = self::getClassConstant($fqcn, $constName); + if (\is_string($val)) { + return new ConstExprStringNode($val, ConstExprStringNode::SINGLE_QUOTED); + } + if (\is_int($val)) { + return new ConstExprIntegerNode((string) $val); } return null; @@ -864,6 +887,7 @@ public static function getClassTraitUseDocs(string $className): array } try { + /** @var class-string $className */ $ref = new \ReflectionClass($className); $fileName = $ref->getFileName(); if ($fileName !== false) { @@ -1006,7 +1030,7 @@ public static function getNamespaceFromFile(string $fileName): string /** * Resolves a short class name to its fully qualified class name (FQCN) using Reflection context. - * Memoizes resolved FQCNs in memory to avoid autoloader search storms. + * Memoizes resolved FQCNs in memory via zero-allocation 2D table to avoid autoloader search storms. * * @param \ReflectionClass|\ReflectionFunction|\ReflectionMethod $ref */ @@ -1030,9 +1054,8 @@ public static function resolveFqcn(string $name, \ReflectionClass|\ReflectionFun $ref instanceof \ReflectionFunction => 'F:' . $ref->getName(), }; - $cacheKey = $contextKey . '|' . $name; - if (isset(self::$fqcnCache[$cacheKey])) { - return self::$fqcnCache[$cacheKey]; + if (isset(self::$fqcnCache[$contextKey][$name])) { + return self::$fqcnCache[$contextKey][$name]; } $imports = self::getUseImports($ref); @@ -1049,11 +1072,11 @@ public static function resolveFqcn(string $name, \ReflectionClass|\ReflectionFun $resolved = self::resolveNameFromImportsAndNamespace($name, $imports, $namespace); - return self::$fqcnCache[$cacheKey] = $resolved; + return self::$fqcnCache[$contextKey][$name] = $resolved; } /** - * Resolves a short class name to its FQCN purely based on file context (namespace and use imports). + * Resolves a short class name to its FQCN purely based on file context with 2D caching. */ public static function resolveFqcnForFile(string $name, string $file): string { @@ -1071,10 +1094,14 @@ public static function resolveFqcnForFile(string $name, string $file): string return $name; } + if (isset(self::$fileFqcnCache[$file][$name])) { + return self::$fileFqcnCache[$file][$name]; + } + $imports = self::getUseImportsFromFile($file); $namespace = self::getNamespaceFromFile($file); - return self::resolveNameFromImportsAndNamespace($name, $imports, $namespace); + return self::$fileFqcnCache[$file][$name] = self::resolveNameFromImportsAndNamespace($name, $imports, $namespace); } /** @@ -1091,33 +1118,33 @@ private static function resolveNameFromImportsAndNamespace(string $name, array $ if (! str_contains($name, '\\')) { if ($namespace !== '') { $namespacedClass = $namespace . '\\' . $name; - if (class_exists($namespacedClass) || interface_exists($namespacedClass) || trait_exists($namespacedClass) || enum_exists($namespacedClass)) { + if (self::symbolExists($namespacedClass)) { return $namespacedClass; } } - if (class_exists($name) || interface_exists($name) || trait_exists($name) || enum_exists($name)) { + if (self::symbolExists($name)) { return $name; } return $name; } - if (class_exists($name) || interface_exists($name) || trait_exists($name) || enum_exists($name)) { + if (self::symbolExists($name)) { return $name; } [$firstPart, $subPart] = explode('\\', $name, 2); if (isset($imports[$firstPart])) { $candidate = $imports[$firstPart] . '\\' . $subPart; - if (class_exists($candidate) || interface_exists($candidate) || trait_exists($candidate) || enum_exists($candidate)) { + if (self::symbolExists($candidate)) { return $candidate; } } if ($namespace !== '') { $namespacedClass = $namespace . '\\' . $name; - if (class_exists($namespacedClass) || interface_exists($namespacedClass) || trait_exists($namespacedClass) || enum_exists($namespacedClass)) { + if (self::symbolExists($namespacedClass)) { return $namespacedClass; } } @@ -1125,6 +1152,21 @@ private static function resolveNameFromImportsAndNamespace(string $name, array $ return $name; } + /** + * Fast-checks in-memory symbol tables before invoking autoloader. + */ + private static function symbolExists(string $symbol): bool + { + return class_exists($symbol, false) + || interface_exists($symbol, false) + || enum_exists($symbol, false) + || trait_exists($symbol, false) + || class_exists($symbol) + || interface_exists($symbol) + || enum_exists($symbol) + || trait_exists($symbol); + } + /** * Checks if a type name is a built-in PHP or PHPDoc type keyword. */ @@ -1136,12 +1178,6 @@ public static function isBuiltInTypeKeyword(string $name): bool /** * Fast C-level token scanner using PhpToken (PHP 8.0+) to extract namespace, * use imports, and trait docblocks in microseconds without PhpParser AST overhead. - * - * Execution Flow: - * 1. Namespace: Identifies T_NAMESPACE to track file-level namespace prefix. - * 2. Class Scope: Tracks class, interface, trait, and enum boundaries. - * 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. */ public static function parseFileMetadata(string $fileName, string $source): void { diff --git a/src/Internal/RuntimeTypeChecker.php b/src/Internal/RuntimeTypeChecker.php index 3f1f42b..485c30f 100644 --- a/src/Internal/RuntimeTypeChecker.php +++ b/src/Internal/RuntimeTypeChecker.php @@ -37,7 +37,9 @@ final class RuntimeTypeChecker public static array $hasMethodTemplatesCache = []; /** - * @var array + * 2D Cache for checked static properties: [$className][$propName] => true. + * + * @var array> */ private static array $checkedStaticProperties = []; @@ -83,7 +85,7 @@ public static function withPendingGeneric(string $typeString, \Closure $factory, } /** - * Evaluates inline property validation dynamically based on configuration. + * Evaluates inline property validation dynamically based on configuration with zero-allocation 2D caching. */ public static function checkStaticProperty( mixed $objectOrClass, @@ -97,13 +99,14 @@ public static function checkStaticProperty( } $className = \is_object($objectOrClass) ? $objectOrClass::class : (\is_string($objectOrClass) ? $objectOrClass : ''); - $key = $className . '::$' . $propName; - if (isset(self::$checkedStaticProperties[$key])) { + if ($className !== '' && isset(self::$checkedStaticProperties[$className][$propName])) { return $value; } - self::$checkedStaticProperties[$key] = true; + if ($className !== '') { + self::$checkedStaticProperties[$className][$propName] = true; + } $res = self::checkProperty($value, $objectOrClass, $propName, $file); if ($res instanceof ErrorMessage) { @@ -171,7 +174,7 @@ public static function checkVariable( public static function checkProperty(mixed $value, mixed $objectOrClass, string $propName, string $file): mixed { $className = \is_object($objectOrClass) ? $objectOrClass::class : (\is_string($objectOrClass) ? $objectOrClass : ''); - if ($className !== '' && isset(InlineChecker::$nullPropertyCache[$className . '::$' . $propName])) { + if ($className !== '' && isset(InlineChecker::$nullPropertyCache[$className][$propName])) { return $value; } @@ -313,7 +316,7 @@ public static function checkParamOut(string $function, string $paramName, mixed */ public static function checkSelfOut(string $function, object $thisObj, ?array $vars = []): void { - if (! Config::isEnabled()) { + if (! Config::isEnabled() || ! Config::isSelfOutEnabled()) { return; } diff --git a/src/Internal/Validator/ConstValidator.php b/src/Internal/Validator/ConstValidator.php index cd1f0b1..ffaa756 100644 --- a/src/Internal/Validator/ConstValidator.php +++ b/src/Internal/Validator/ConstValidator.php @@ -13,6 +13,7 @@ use PHPStan\PhpDocParser\Ast\ConstExpr\ConstFetchNode; use PHPStan\PhpDocParser\Ast\Type\ConstTypeNode; use PHPStan\PhpDocParser\Ast\Type\TypeNode; +use ReflectionClass; use TypePHP\Internal\Diagnostic\ErrorFactory; use TypePHP\Internal\Diagnostic\ErrorMessage; use TypePHP\Internal\Diagnostic\TypeFormatter; @@ -23,7 +24,9 @@ final class ConstValidator implements TypeValidatorInterface { /** - * @var array> + * 2D Cache for wildcard constant values: [$className][$pattern] => array{list: list, map: array}. + * + * @var array, map: array}>> */ private static array $wildcardConstantCache = []; @@ -50,9 +53,13 @@ public function validate(mixed $value, TypeNode $node, string $context, TypeVali $pattern = $constExpr->name; if (str_contains($pattern, '*')) { - $allowedValues = self::resolveWildcardConstantValues($className, $pattern); + $allowed = self::resolveWildcardConstantValues($className, $pattern); + + $isValid = (\is_int($value) || \is_string($value)) + ? isset($allowed['map'][$value]) + : \in_array($value, $allowed['list'], strict: true); - if (! \in_array($value, $allowedValues, strict: true)) { + if (! $isValid) { $fqcnPattern = $className !== '' ? "$className::$pattern" : $pattern; return ErrorFactory::createError($context . " must be a valid constant matching $fqcnPattern, " . TypeFormatter::formatGivenValue($value, $isSensitive) . ' given'); @@ -92,28 +99,35 @@ public function validate(mixed $value, TypeNode $node, string $context, TypeVali /** * Resolves and caches all values of class constants matching a wildcard pattern (e.g. PREFIX_*). * - * @return array + * @return array{list: list, map: array} */ private static function resolveWildcardConstantValues(string $className, string $pattern): array { - $cacheKey = $className . '::' . $pattern; - if (isset(self::$wildcardConstantCache[$cacheKey])) { - return self::$wildcardConstantCache[$cacheKey]; + if (isset(self::$wildcardConstantCache[$className][$pattern])) { + return self::$wildcardConstantCache[$className][$pattern]; } - $values = []; + $list = []; + $map = []; - if ($className !== '' && (class_exists($className) || interface_exists($className))) { - $refClass = new \ReflectionClass($className); + if ($className !== '' && (class_exists($className, false) || class_exists($className) || interface_exists($className, false) || interface_exists($className))) { + /** @var class-string $className */ + $refClass = new ReflectionClass($className); $regex = '/^' . str_replace('\*', '.*', preg_quote($pattern, '/')) . '$/i'; foreach ($refClass->getConstants() as $cName => $cValue) { if (preg_match($regex, $cName) === 1) { - $values[] = $cValue; + $list[] = $cValue; + if (\is_int($cValue) || \is_string($cValue)) { + $map[$cValue] = true; + } } } } - return self::$wildcardConstantCache[$cacheKey] = $values; + return self::$wildcardConstantCache[$className][$pattern] = [ + 'list' => $list, + 'map' => $map, + ]; } } diff --git a/src/Internal/Validator/ObjectShapeValidator.php b/src/Internal/Validator/ObjectShapeValidator.php index 06444a0..9e81058 100644 --- a/src/Internal/Validator/ObjectShapeValidator.php +++ b/src/Internal/Validator/ObjectShapeValidator.php @@ -4,8 +4,11 @@ namespace TypePHP\Internal\Validator; +use PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode; use PHPStan\PhpDocParser\Ast\Type\ObjectShapeNode; use PHPStan\PhpDocParser\Ast\Type\TypeNode; +use ReflectionClass; +use ReflectionProperty; use TypePHP\Internal\Diagnostic\ErrorFactory; use TypePHP\Internal\Diagnostic\ErrorMessage; use TypePHP\Internal\Diagnostic\TypeFormatter; @@ -15,8 +18,28 @@ */ final class ObjectShapeValidator implements TypeValidatorInterface { - public function validate(mixed $value, TypeNode $node, string $context, TypeValidatorRegistry $registry, bool $isSensitive = false): ?ErrorMessage - { + /** + * Cache for ReflectionClass instances per class name. + * + * @var array, ReflectionClass> + */ + private static array $refClassCache = []; + + /** + * Cache for ReflectionProperty instances per class name and property name. + * Stored as false if the property is not declared on the class (dynamic or magic). + * + * @var array, array> + */ + private static array $propertyCache = []; + + public function validate( + mixed $value, + TypeNode $node, + string $context, + TypeValidatorRegistry $registry, + bool $isSensitive = false + ): ?ErrorMessage { if (! \is_object($value)) { return ErrorFactory::createError($context . ' must be of type object, ' . TypeFormatter::formatGivenValue($value, $isSensitive) . ' given'); } @@ -26,9 +49,11 @@ public function validate(mixed $value, TypeNode $node, string $context, TypeVali if ($value instanceof \stdClass) { foreach ($shapeNode->items as $item) { - $propName = (string) $item->keyName; + $propName = $item->keyName instanceof IdentifierTypeNode + ? $item->keyName->name + : (string) $item->keyName; - if (! property_exists($value, $propName) && ! isset($value->$propName)) { + if (! isset($value->$propName) && ! property_exists($value, $propName)) { if (! $item->optional) { return ErrorFactory::createError($context . " is missing required property '$propName'"); } @@ -47,22 +72,23 @@ public function validate(mixed $value, TypeNode $node, string $context, TypeVali return null; } - $refObject = new \ReflectionObject($value); + $className = $value::class; foreach ($shapeNode->items as $item) { - $propName = (string) $item->keyName; - - // @phpstan-ignore property.dynamicName - if (! $refObject->hasProperty($propName) && ! isset($value->$propName)) { - if (! $item->optional) { - return ErrorFactory::createError($context . " is missing required property '$propName'"); - } - - continue; + $propName = $item->keyName instanceof IdentifierTypeNode + ? $item->keyName->name + : (string) $item->keyName; + + if (! isset(self::$propertyCache[$className][$propName]) && ! \array_key_exists($propName, self::$propertyCache[$className] ?? [])) { + $refClass = self::$refClassCache[$className] ??= new ReflectionClass($className); + self::$propertyCache[$className][$propName] = $refClass->hasProperty($propName) + ? $refClass->getProperty($propName) + : false; } - if ($refObject->hasProperty($propName)) { - $refProp = $refObject->getProperty($propName); + $refProp = self::$propertyCache[$className][$propName]; + + if ($refProp instanceof ReflectionProperty) { if (! $refProp->isInitialized($value)) { if (! $item->optional) { return ErrorFactory::createError($context . " property '$propName' is uninitialized"); @@ -73,6 +99,15 @@ public function validate(mixed $value, TypeNode $node, string $context, TypeVali $propValue = $refProp->getValue($value); } else { + // @phpstan-ignore property.dynamicName + if (! isset($value->$propName) && ! property_exists($value, $propName)) { + if (! $item->optional) { + return ErrorFactory::createError($context . " is missing required property '$propName'"); + } + + continue; + } + // @phpstan-ignore property.dynamicName $propValue = $value->$propName; }